From 963c10f798b404808afd14aa90545aef4c20bac6 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Tue, 21 Jul 2026 13:52:33 +0545 Subject: [PATCH 01/12] feat(bastion): add cluster-wide SSH bastion as Terraform --- .../terraform/resources/bastion.tf | 214 ++++++++++++++++++ base-infrastructure/terraform/resources/ip.tf | 14 ++ renovate.json5 | 11 + 3 files changed, 239 insertions(+) create mode 100644 base-infrastructure/terraform/resources/bastion.tf diff --git a/base-infrastructure/terraform/resources/bastion.tf b/base-infrastructure/terraform/resources/bastion.tf new file mode 100644 index 0000000..f2db5ed --- /dev/null +++ b/base-infrastructure/terraform/resources/bastion.tf @@ -0,0 +1,214 @@ +# 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. + +# 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. + +locals { + # renovate: datasource=docker depName=lscr.io/linuxserver/openssh-server versioning=regex:^version-(?\d+)\.(?\d+)_p(?\d+)-r(?\d+)$ + bastion_image = "lscr.io/linuxserver/openssh-server:version-10.3_p1-r0" + + # Idle SSH jump host — kept small, tuned per environment, NOTE: matches the sizing the go-api chart overrides used previously + bastion_resources = var.environment == "staging" ? { + requests = { cpu = "0.2", memory = "0.05Gi" } + limits = { cpu = "1", memory = "0.2Gi" } + } : { + requests = { cpu = "0.1", memory = "0.05Gi" } + limits = { cpu = "1", memory = "0.2Gi" } + } + + # Authorized SSH *public* keys (filename => key) + bastion_keys = { + "zoltan.pub" = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPGAnkQdf5CIpVoqNVJ17AAzUb02gpTltJI5q5SRKxl8 zol@hp" + "daniel.pub" = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDU1XLLPq1J4kFvNyg5eUK8uuW8dtW1f3ALVnYr0nVhldxF0J59XtZbNFBLCVHYZL3NQxYQrucll6LbGaMGKbGsTwtqcxqd2fWlhg7nBnvhOzULYbAru3YfpkgnawGin6Y7qW/MQ3fYmqqm8MB7p5+G4sIL76S2yWbi7lcKWnd87yDTGEEoc8H6i6IwNNVHudvuMA4MzGkSgql7gIC2KuU+s2u9Y6fmE92G39BO454SUgAcCJfhuXukZhU4UN3RVYy+F0MxVeLc0hEJi4sCYcoPKREc0//srNyni7b8G8C+z6t02xrzhWwIORlb8Jr2kmbblp7PFMz4r2qRd8MvXAa5ta6kUvMDg0t52JaDMAGy0IjGZh9PznXbp1LYn7uS5NQh4C/t6Q3TXyJbEiaQaObcmjn6w/DWH6gI7ZRYkPGdlctlNm5MWnhjG9Q/FzRIxvaauSFqgs6bfIUGGaY9i1eNiowVSzDPlP7nH0gJpq+uS5Qdyg69m/XH1DqywPoZY7U= ifrcds\\daniel.tovari@5CG41911RW" + "arun.pub" = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIERqaO+XlqTbvoh88Kuj9c377x77NChWhNP8VpbM1/hf ifrcds\\arun.gandhi@5CG1355NPN" + "thenav56.pub" = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN/f/A3qkaTHSdbKn8Hv75YiJvRMEXvWTDdIiR7tyAjJ navin@nav-machine" + "david.pub" = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC3FzrQdVh5Qwp5Y6KQGcpqHxKErxCW103iEECuutR/jBZe6X0xjD+cW7e+H8SrUsPQwj87fzOsMAc6v6n+3hdYFa6ekgRG/USEIUR5C/GD1Xjva3Xpp45PasBhJEtYt2ON+dlzwvRyOuv2hvqv2WHBO020ewIlVuQ4pU4Qj5ysvwWGj8GAv/jITiVERmjLTStbFwxeIDT3jQEbwnfV1zZZKiGxIecB/y51nk6oIQ00ZGrYEo5ieWsUSVfLHOX0/lZ0mtrdqxDEgMaCbNaUbICAimsJPamNpoirKc7FoKIKKrLQsK8qE1lClWQEecbW+dgSiwxracooKeWhHq+BkKUCNgEL/C0ff2l9e8sJcLmYZUdPtDCdtUDC8BAlELA5HR6tdCTfFcc0nXltclSSODMnZkQohh5/2fixJTwN5p5csEfBLzbdrturKtT/TbYSoaodg4muPqY4YE5jiJfrHVAGS1DVWz/cRcm1vOxT2V4iW2SNvo8fS2PZOpU5furrvbM= ifrcds\\david.muchatiza@5CG41911S1" + "paola.pub" = "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" + "ranjan.pub" = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGJA0ec4Gavc+m1MjEZGoUce51yWouMTRTYJZV3s/jgD rsh@rsh-XPS-15-9510" + } +} + +resource "kubernetes_namespace" "bastion" { + metadata { + name = "bastion" + labels = { + "app.kubernetes.io/managed-by" = "terraform" + environment = var.environment + } + } +} + +# Authorized public keys, one file per key (mounted as PUBLIC_KEY_DIR). +resource "kubernetes_config_map" "bastion_authorized_keys" { + metadata { + name = "ssh-bastion-authorized-keys" + namespace = kubernetes_namespace.bastion.metadata[0].name + } + data = local.bastion_keys +} + +# linuxserver/openssh-server ships with agent + TCP forwarding disabled; this init script flips them on so the host can be used as a jump box. +resource "kubernetes_config_map" "bastion_fix_sshd_config" { + metadata { + name = "ssh-bastion-fix-sshd-config" + namespace = kubernetes_namespace.bastion.metadata[0].name + } + data = { + "fix-sshd-config.sh" = <<-EOT + #!/bin/bash + # set -e + sed -i 's/#AllowAgentForwarding yes/AllowAgentForwarding yes/g' /etc/ssh/sshd_config + sed -i 's/AllowTcpForwarding no/AllowTcpForwarding yes/g' /etc/ssh/sshd_config + EOT + } +} + +resource "kubernetes_stateful_set" "bastion" { + metadata { + name = "ssh-bastion" + namespace = kubernetes_namespace.bastion.metadata[0].name + labels = { + app = "ssh-bastion" + environment = var.environment + } + } + + spec { + replicas = 1 + service_name = "ssh-bastion" + + selector { + match_labels = { + app = "ssh-bastion" + } + } + + template { + metadata { + labels = { + app = "ssh-bastion" + } + } + + spec { + container { + name = "ssh-bastion" + image = local.bastion_image + + port { + container_port = 2222 + } + + resources { + requests = { + cpu = local.bastion_resources.requests.cpu + memory = local.bastion_resources.requests.memory + } + limits = { + cpu = local.bastion_resources.limits.cpu + memory = local.bastion_resources.limits.memory + } + } + + env { + name = "PUID" + value = "1000" + } + env { + name = "PGID" + value = "1000" + } + env { + name = "USER_NAME" + value = "user" + } + env { + name = "PASSWORD_ACCESS" + value = "false" + } + env { + name = "SUDO_ACCESS" + value = "false" + } + env { + name = "PUBLIC_KEY_DIR" + value = "/ssh-public_keys" + } + + volume_mount { + name = "ssh-authorized-keys" + mount_path = "/ssh-public_keys" + read_only = true + } + volume_mount { + name = "config-volume" + mount_path = "/config" + } + volume_mount { + name = "fix-sshd-config" + mount_path = "/custom-cont-init.d/fix-sshd-config.sh" + sub_path = "fix-sshd-config.sh" + } + } + + volume { + name = "ssh-authorized-keys" + config_map { + name = kubernetes_config_map.bastion_authorized_keys.metadata[0].name + } + } + volume { + name = "fix-sshd-config" + config_map { + name = kubernetes_config_map.bastion_fix_sshd_config.metadata[0].name + } + } + } + } + + # Persists the server host keys across pod restarts (avoids host-key-changed warnings for users). + volume_claim_template { + metadata { + name = "config-volume" + } + spec { + access_modes = ["ReadWriteOnce"] + resources { + requests = { + storage = "100Mi" + } + } + } + } + } +} + +resource "kubernetes_service" "bastion" { + metadata { + name = "ssh-bastion" + namespace = kubernetes_namespace.bastion.metadata[0].name + labels = { + app = "ssh-bastion" + environment = var.environment + } + annotations = { + "service.beta.kubernetes.io/azure-load-balancer-resource-group" = data.azurerm_resource_group.ifrcgo.name + } + } + + spec { + type = "LoadBalancer" + # Open to the internet; access is gated by SSH public-key auth only (team members do not have static source IPs, so no loadBalancerSourceRanges). + load_balancer_ip = azurerm_public_ip.bastion.ip_address + + selector = { + app = "ssh-bastion" + } + + port { + port = 2222 + target_port = 2222 + } + } +} diff --git a/base-infrastructure/terraform/resources/ip.tf b/base-infrastructure/terraform/resources/ip.tf index 52d2308..dbc50b4 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}-bastion-PublicIP" + 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..60f86d6 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": [ From c890e049fdaa3e4e6fe6c8c9634c9e12197d57bb Mon Sep 17 00:00:00 2001 From: thenav56 Date: Fri, 24 Jul 2026 11:18:42 +0545 Subject: [PATCH 02/12] fix(bastion): harden sshd via drop-in config and address review findings --- .../terraform/resources/bastion.tf | 48 +++++++++++++------ base-infrastructure/terraform/resources/ip.tf | 2 +- renovate.json5 | 2 +- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/base-infrastructure/terraform/resources/bastion.tf b/base-infrastructure/terraform/resources/bastion.tf index f2db5ed..e773c7f 100644 --- a/base-infrastructure/terraform/resources/bastion.tf +++ b/base-infrastructure/terraform/resources/bastion.tf @@ -9,12 +9,12 @@ locals { bastion_image = "lscr.io/linuxserver/openssh-server:version-10.3_p1-r0" # Idle SSH jump host — kept small, tuned per environment, NOTE: matches the sizing the go-api chart overrides used previously - bastion_resources = var.environment == "staging" ? { - requests = { cpu = "0.2", memory = "0.05Gi" } - limits = { cpu = "1", memory = "0.2Gi" } - } : { - requests = { cpu = "0.1", memory = "0.05Gi" } - limits = { cpu = "1", memory = "0.2Gi" } + bastion_resources = { + requests = { + cpu = var.environment == "staging" ? "0.2" : "0.1" + memory = "0.05Gi" + } + limits = { cpu = "1", memory = "0.2Gi" } } # Authorized SSH *public* keys (filename => key) @@ -48,18 +48,33 @@ resource "kubernetes_config_map" "bastion_authorized_keys" { data = local.bastion_keys } -# linuxserver/openssh-server ships with agent + TCP forwarding disabled; this init script flips them on so the host can be used as a jump box. +# linuxserver/openssh-server ships with TCP forwarding disabled (AllowTcpForwarding no). +# The image's /etc/ssh/sshd_config has `Include /etc/ssh/sshd_config.d/*.conf` near the top, +# above that directive, so a drop-in here wins (sshd uses the first value obtained). +# Agent forwarding is intentionally NOT enabled: this box is used for port-forwarding / +# ProxyJump, which only needs TCP forwarding, and agent forwarding is a security downgrade. resource "kubernetes_config_map" "bastion_fix_sshd_config" { metadata { name = "ssh-bastion-fix-sshd-config" namespace = kubernetes_namespace.bastion.metadata[0].name } data = { - "fix-sshd-config.sh" = <<-EOT - #!/bin/bash - # set -e - sed -i 's/#AllowAgentForwarding yes/AllowAgentForwarding yes/g' /etc/ssh/sshd_config - sed -i 's/AllowTcpForwarding no/AllowTcpForwarding yes/g' /etc/ssh/sshd_config + "100-ifrc-forwarding.conf" = <<-EOT + # Jump host for port-forwarding / ProxyJump. Key-only auth (PasswordAuthentication + # no, GatewayPorts no, X11Forwarding no are already set by the image defaults). + AllowTcpForwarding yes + + # Auth hardening (internet-exposed LoadBalancer) + PermitRootLogin no + KbdInteractiveAuthentication no + MaxAuthTries 3 + LoginGraceTime 30 + AllowUsers user + + # Audit trail (log key fingerprint per login) + reap dead sessions/tunnels + LogLevel VERBOSE + ClientAliveInterval 300 + ClientAliveCountMax 2 EOT } } @@ -147,8 +162,9 @@ resource "kubernetes_stateful_set" "bastion" { } volume_mount { name = "fix-sshd-config" - mount_path = "/custom-cont-init.d/fix-sshd-config.sh" - sub_path = "fix-sshd-config.sh" + mount_path = "/etc/ssh/sshd_config.d/100-ifrc-forwarding.conf" + sub_path = "100-ifrc-forwarding.conf" + read_only = true } } @@ -197,6 +213,10 @@ resource "kubernetes_service" "bastion" { } } + depends_on = [ + azurerm_public_ip.bastion, + ] + spec { type = "LoadBalancer" # Open to the internet; access is gated by SSH public-key auth only (team members do not have static source IPs, so no loadBalancerSourceRanges). diff --git a/base-infrastructure/terraform/resources/ip.tf b/base-infrastructure/terraform/resources/ip.tf index dbc50b4..576027b 100644 --- a/base-infrastructure/terraform/resources/ip.tf +++ b/base-infrastructure/terraform/resources/ip.tf @@ -31,7 +31,7 @@ resource "azurerm_public_ip" "traefik" { # 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}-bastion-PublicIP" + name = "${local.prefix}BastionPublicIP" resource_group_name = data.azurerm_resource_group.ifrcgo.name location = data.azurerm_resource_group.ifrcgo.location allocation_method = "Static" diff --git a/renovate.json5 b/renovate.json5 index 60f86d6..939bcbb 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -22,7 +22,7 @@ customType: "regex", managerFilePatterns: ["*"], matchStrings: [ - '# renovate: datasource=(?\\S+) depName=(?\\S+)(?: versioning=(?\\S+))?\\s*[A-Za-z0-9._-]+\\s*[:=]\\s*"[^":\\s]+:(?[^"\\s]+)"', + '# renovate: datasource=(?\\S+) depName=(?\\S+)(?: versioning=(?\\S+))?\\s*[A-Za-z0-9._-]+\\s*[:=]\\s*"[^"\\s]+:(?[^":\\s]+)"', ], versioningTemplate: "{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}", }, From 661592b84599d74cd3c9d558601c4b0a8ce911fc Mon Sep 17 00:00:00 2001 From: thenav56 Date: Fri, 24 Jul 2026 14:04:19 +0545 Subject: [PATCH 03/12] fix(bastion): make sshd drop-in effective and keys authoritative The linuxserver/openssh-server image runs `sshd -f /config/sshd/sshd_config` and only includes drop-ins from /config/sshd/sshd_config.d/, so the config mounted at /etc/ssh/sshd_config.d/ was silently ignored: AllowTcpForwarding stayed `no` (bastion could not port-forward/ProxyJump) and none of the hardening applied. It also appended keys to a PVC-persisted authorized_keys and never removed them, so key removal did not revoke access. - Mount the sshd drop-in under /config/sshd/sshd_config.d/ (verified via sshd -T) - Serve an authoritative concatenated authorized_keys via AuthorizedKeysFile instead of the append-only PUBLIC_KEY_DIR flow, so removals revoke access - Add checksum annotations so key/config edits roll the pod - Pin storage_class_name=managed-csi (avoid Pending PVC on missing default) - Set externalTrafficPolicy=Local to preserve client IPs for the audit log - Single-source the login user (USER_NAME/AllowUsers) via local.bastion_user --- .../terraform/resources/bastion.tf | 103 ++++++++++++------ 1 file changed, 71 insertions(+), 32 deletions(-) diff --git a/base-infrastructure/terraform/resources/bastion.tf b/base-infrastructure/terraform/resources/bastion.tf index e773c7f..9bddb18 100644 --- a/base-infrastructure/terraform/resources/bastion.tf +++ b/base-infrastructure/terraform/resources/bastion.tf @@ -8,6 +8,10 @@ locals { # renovate: datasource=docker depName=lscr.io/linuxserver/openssh-server versioning=regex:^version-(?\d+)\.(?\d+)_p(?\d+)-r(?\d+)$ bastion_image = "lscr.io/linuxserver/openssh-server:version-10.3_p1-r0" + # Single source of truth for the login user: the image creates this account + # (USER_NAME) and sshd only permits it (AllowUsers). Keep the two in lockstep. + bastion_user = "user" + # Idle SSH jump host — kept small, tuned per environment, NOTE: matches the sizing the go-api chart overrides used previously bastion_resources = { requests = { @@ -17,7 +21,42 @@ locals { limits = { cpu = "1", memory = "0.2Gi" } } - # Authorized SSH *public* keys (filename => key) + # sshd drop-in. NOTE on how this actually takes effect with linuxserver/openssh-server: + # its init script runs `sshd -f /config/sshd/sshd_config`, comments out the stock + # `Include /etc/ssh/sshd_config.d/*.conf`, and re-enables an include pointing at + # /config/sshd/sshd_config.d/ *only if that directory exists*. So this file MUST be + # mounted under /config/sshd/sshd_config.d/ (see volume_mount below) — a drop-in in the + # stock /etc/ssh/sshd_config.d/ is silently ignored. Directives here are obtained before + # the base config's, so first-value-wins settings (AllowTcpForwarding, AuthorizedKeysFile) + # override the image defaults. + # + # Agent forwarding is intentionally NOT enabled: this box is used for port-forwarding / + # ProxyJump, which only needs TCP forwarding, and agent forwarding is a security downgrade. + bastion_sshd_config = <<-EOT + # Jump host for port-forwarding / ProxyJump. Key-only auth (PasswordAuthentication + # no, GatewayPorts no, X11Forwarding no are already set by the image defaults). + AllowTcpForwarding yes + + # Authoritative, declarative authorized_keys (mounted read-only, see below). Using 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 here actually revokes access. + AuthorizedKeysFile /etc/ssh/authorized_keys + + # Auth hardening (internet-exposed LoadBalancer) + PermitRootLogin no + KbdInteractiveAuthentication no + MaxAuthTries 3 + LoginGraceTime 30 + AllowUsers ${local.bastion_user} + + # Audit trail (log key fingerprint per login) + reap dead sessions/tunnels + LogLevel VERBOSE + ClientAliveInterval 300 + ClientAliveCountMax 2 + EOT + + # Authorized SSH *public* keys (filename => key). Concatenated into a single, fully + # declarative authorized_keys file (see bastion_sshd_config / the config map below). bastion_keys = { "zoltan.pub" = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPGAnkQdf5CIpVoqNVJ17AAzUb02gpTltJI5q5SRKxl8 zol@hp" "daniel.pub" = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDU1XLLPq1J4kFvNyg5eUK8uuW8dtW1f3ALVnYr0nVhldxF0J59XtZbNFBLCVHYZL3NQxYQrucll6LbGaMGKbGsTwtqcxqd2fWlhg7nBnvhOzULYbAru3YfpkgnawGin6Y7qW/MQ3fYmqqm8MB7p5+G4sIL76S2yWbi7lcKWnd87yDTGEEoc8H6i6IwNNVHudvuMA4MzGkSgql7gIC2KuU+s2u9Y6fmE92G39BO454SUgAcCJfhuXukZhU4UN3RVYy+F0MxVeLc0hEJi4sCYcoPKREc0//srNyni7b8G8C+z6t02xrzhWwIORlb8Jr2kmbblp7PFMz4r2qRd8MvXAa5ta6kUvMDg0t52JaDMAGy0IjGZh9PznXbp1LYn7uS5NQh4C/t6Q3TXyJbEiaQaObcmjn6w/DWH6gI7ZRYkPGdlctlNm5MWnhjG9Q/FzRIxvaauSFqgs6bfIUGGaY9i1eNiowVSzDPlP7nH0gJpq+uS5Qdyg69m/XH1DqywPoZY7U= ifrcds\\daniel.tovari@5CG41911RW" @@ -39,43 +78,27 @@ resource "kubernetes_namespace" "bastion" { } } -# Authorized public keys, one file per key (mounted as PUBLIC_KEY_DIR). +# Authoritative authorized_keys (all public keys concatenated into one file), mounted at +# the sshd AuthorizedKeysFile path. Declarative: removing a key here revokes access. resource "kubernetes_config_map" "bastion_authorized_keys" { metadata { name = "ssh-bastion-authorized-keys" namespace = kubernetes_namespace.bastion.metadata[0].name } - data = local.bastion_keys + data = { + "authorized_keys" = "${join("\n", values(local.bastion_keys))}\n" + } } -# linuxserver/openssh-server ships with TCP forwarding disabled (AllowTcpForwarding no). -# The image's /etc/ssh/sshd_config has `Include /etc/ssh/sshd_config.d/*.conf` near the top, -# above that directive, so a drop-in here wins (sshd uses the first value obtained). -# Agent forwarding is intentionally NOT enabled: this box is used for port-forwarding / -# ProxyJump, which only needs TCP forwarding, and agent forwarding is a security downgrade. +# sshd drop-in (see local.bastion_sshd_config for the content and the notes on how the +# linuxserver image consumes it). resource "kubernetes_config_map" "bastion_fix_sshd_config" { metadata { name = "ssh-bastion-fix-sshd-config" namespace = kubernetes_namespace.bastion.metadata[0].name } data = { - "100-ifrc-forwarding.conf" = <<-EOT - # Jump host for port-forwarding / ProxyJump. Key-only auth (PasswordAuthentication - # no, GatewayPorts no, X11Forwarding no are already set by the image defaults). - AllowTcpForwarding yes - - # Auth hardening (internet-exposed LoadBalancer) - PermitRootLogin no - KbdInteractiveAuthentication no - MaxAuthTries 3 - LoginGraceTime 30 - AllowUsers user - - # Audit trail (log key fingerprint per login) + reap dead sessions/tunnels - LogLevel VERBOSE - ClientAliveInterval 300 - ClientAliveCountMax 2 - EOT + "100-ifrc-forwarding.conf" = local.bastion_sshd_config } } @@ -104,6 +127,14 @@ resource "kubernetes_stateful_set" "bastion" { labels = { app = "ssh-bastion" } + # Roll the pod when keys or sshd config change (the container ingests both only at + # start; without this a ConfigMap edit applies but the running pod keeps the old + # values, so added keys never work and — combined with the fixes above — nothing + # picks up config changes). + annotations = { + "checksum/authorized-keys" = sha256(jsonencode(local.bastion_keys)) + "checksum/sshd-config" = sha256(local.bastion_sshd_config) + } } spec { @@ -136,7 +167,7 @@ resource "kubernetes_stateful_set" "bastion" { } env { name = "USER_NAME" - value = "user" + value = local.bastion_user } env { name = "PASSWORD_ACCESS" @@ -146,23 +177,24 @@ resource "kubernetes_stateful_set" "bastion" { name = "SUDO_ACCESS" value = "false" } - env { - name = "PUBLIC_KEY_DIR" - value = "/ssh-public_keys" - } + # Authoritative authorized_keys — sshd reads it via AuthorizedKeysFile (see the + # drop-in). root-owned read-only file, which satisfies sshd's ownership checks. volume_mount { name = "ssh-authorized-keys" - mount_path = "/ssh-public_keys" + mount_path = "/etc/ssh/authorized_keys" + sub_path = "authorized_keys" read_only = true } volume_mount { name = "config-volume" mount_path = "/config" } + # Must live under /config/sshd/sshd_config.d/ for the image to include it; the + # stock /etc/ssh/sshd_config.d/ is not read (see local.bastion_sshd_config). volume_mount { name = "fix-sshd-config" - mount_path = "/etc/ssh/sshd_config.d/100-ifrc-forwarding.conf" + mount_path = "/config/sshd/sshd_config.d/100-ifrc-forwarding.conf" sub_path = "100-ifrc-forwarding.conf" read_only = true } @@ -190,6 +222,10 @@ resource "kubernetes_stateful_set" "bastion" { } spec { access_modes = ["ReadWriteOnce"] + # Pin the class instead of relying on a cluster default (an unset/RWX default would + # leave the PVC Pending and the pod never starts). managed-csi is the AKS built-in + # RWO managed-disk class. + storage_class_name = "managed-csi" resources { requests = { storage = "100Mi" @@ -221,6 +257,9 @@ resource "kubernetes_service" "bastion" { type = "LoadBalancer" # Open to the internet; access is gated by SSH public-key auth only (team members do not have static source IPs, so no loadBalancerSourceRanges). load_balancer_ip = azurerm_public_ip.bastion.ip_address + # Preserve the real client source IP (otherwise SNAT'd to a node IP), so the VERBOSE + # sshd audit log records who connected. Matches the traefik service. + external_traffic_policy = "Local" selector = { app = "ssh-bastion" From e6dc16e3f0868ee11de860fcdeab806e00fbd738 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Fri, 24 Jul 2026 14:17:53 +0545 Subject: [PATCH 04/12] feat(bastion): fail closed if sshd config isn't actually applied The image boots sshd even when our drop-in is ignored (wrong mount path, an image change to the include behaviour, etc.), silently serving with insecure defaults. Add exec probes that dump the running config (`sshd -T`) and require every directive we ship to be present: - readinessProbe: pull the pod from the LoadBalancer endpoints on mismatch, so no connections are routed to a misconfigured bastion - startupProbe + livenessProbe: crash-loop the pod instead of serving The expected directive list is derived from the drop-in (lowercased, comment-stripped) so the assertion can't drift from the config. Verified against the image: passes with the correct mount, fails on wrong path / no drop-in / a tampered directive. --- .../terraform/resources/bastion.tf | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/base-infrastructure/terraform/resources/bastion.tf b/base-infrastructure/terraform/resources/bastion.tf index 9bddb18..577f9e9 100644 --- a/base-infrastructure/terraform/resources/bastion.tf +++ b/base-infrastructure/terraform/resources/bastion.tf @@ -68,6 +68,37 @@ locals { } } +locals { + # Fail-closed config assertion. The 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. The probes below dump the *running* sshd config (`sshd -T`) + # and require every directive we shipped to be present. On mismatch the pod is pulled + # from the LoadBalancer (readiness) and restarted / crash-looped (startup + liveness) + # instead of accepting connections with unintended settings. + # + # Derived from the drop-in so the two never drift: normalise to `sshd -T`'s lowercase, + # comment/blank-stripped form. + bastion_expected = join("\n", [ + for l in split("\n", lower(local.bastion_sshd_config)) : + trimspace(l) if trimspace(l) != "" && !startswith(trimspace(l), "#") + ]) + + bastion_config_assert = ["sh", "-c", <<-EOT + set -f + # sshd must be accepting connections ... + nc -z 127.0.0.1 2222 || exit 1 + # ... and its effective config must contain every directive we shipped. + eff=$(sshd.pam -T -f /config/sshd/sshd_config 2>/dev/null | tr 'A-Z' 'a-z') + exp='${local.bastion_expected}' + missing=$(printf '%s\n' "$exp" | while IFS= read -r d; do + [ -n "$d" ] || continue + printf '%s\n' "$eff" | grep -qF "$d" || printf '%s\n' "$d" + done) + [ -z "$missing" ] || { echo "sshd config assertion FAILED (missing directives):" >&2; printf '%s\n' "$missing" >&2; exit 1; } + EOT + ] +} + resource "kubernetes_namespace" "bastion" { metadata { name = "bastion" @@ -157,6 +188,37 @@ resource "kubernetes_stateful_set" "bastion" { } } + # Gate readiness/liveness until sshd is up and the config assertion passes. Given + # a generous budget for first-boot host-key generation; if the config never + # applies the pod never starts (CrashLoopBackOff) rather than serving. + startup_probe { + exec { + command = local.bastion_config_assert + } + period_seconds = 10 + timeout_seconds = 5 + failure_threshold = 30 + } + # Pull the pod out of the Service/LoadBalancer endpoints if the effective config + # ever regresses — no connections are routed to a misconfigured bastion. + readiness_probe { + exec { + command = local.bastion_config_assert + } + period_seconds = 30 + timeout_seconds = 5 + failure_threshold = 2 + } + # Restart (crash-loop) the pod on a runtime config regression. + liveness_probe { + exec { + command = local.bastion_config_assert + } + period_seconds = 60 + timeout_seconds = 5 + failure_threshold = 3 + } + env { name = "PUID" value = "1000" From 66166497697245fd6a547bf284ef8432d5e3d085 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Fri, 24 Jul 2026 14:56:19 +0545 Subject: [PATCH 05/12] fix(bastion): make config-assertion probe work as a non-root user `sshd -T` without `-h` loads host keys from the stock location (root-owned 0600) and exits "no hostkeys available" when the exec probe runs as a non-root user, which made the startup probe report every directive missing and crash-loop a correctly-configured pod. Pass the host keys explicitly (as the image's service run script does) so the config dump succeeds regardless of the probe's user. Verified as both root and uid 1000: passes with the correct mount, fails on wrong path / no drop-in / tampered directive. --- base-infrastructure/terraform/resources/bastion.tf | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/base-infrastructure/terraform/resources/bastion.tf b/base-infrastructure/terraform/resources/bastion.tf index 577f9e9..b242db2 100644 --- a/base-infrastructure/terraform/resources/bastion.tf +++ b/base-infrastructure/terraform/resources/bastion.tf @@ -84,11 +84,16 @@ locals { ]) bastion_config_assert = ["sh", "-c", <<-EOT - set -f # sshd must be accepting connections ... nc -z 127.0.0.1 2222 || exit 1 - # ... and its effective config must contain every directive we shipped. - eff=$(sshd.pam -T -f /config/sshd/sshd_config 2>/dev/null | tr 'A-Z' 'a-z') + # ... and its effective config must contain every directive we shipped. Pass the host + # keys explicitly (as the service does): `sshd -T` otherwise reads them from the stock + # location and exits "no hostkeys available" when the probe runs as a non-root user. + 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') exp='${local.bastion_expected}' missing=$(printf '%s\n' "$exp" | while IFS= read -r d; do [ -n "$d" ] || continue From 38cf881188946cc885891fce5f437ad026e11e94 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Fri, 24 Jul 2026 15:15:32 +0545 Subject: [PATCH 06/12] refactor(bastion): move k8s resources into a local Helm chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bastion had grown into ~280 lines of raw kubernetes_* HCL with an inline sshd config heredoc and an embedded shell assertion script — awkward in HCL and inconsistent with every other cluster component here (traefik, argocd, cert-manager, ...), which are helm_release. Extract it into a local chart and apply it the same way. - base-infrastructure/charts/ssh-bastion: ns (via create_namespace), configmaps, statefulset, service; sshd drop-in and the fail-closed assertion are real files - the assertion now derives expected directives from the mounted drop-in at runtime, so it can't drift from the shipped config - image pin (renovate-annotated) and team public keys live in values.yaml - helm-unittest suite locks in the drop-in mount path, AuthorizedKeysFile, probe wiring, checksum-driven rollout, storage class and service shape - bastion.tf shrinks to the public IP + a helm_release passing environment, cpu request, loadBalancerIP and resource group Behaviour is unchanged; verified via helm lint, helm unittest and terraform validate. --- .../charts/ssh-bastion/.helmignore | 7 + .../charts/ssh-bastion/Chart.yaml | 7 + .../charts/ssh-bastion/files/assert-sshd.sh | 36 ++ .../sshd_config.d/100-ifrc-forwarding.conf | 20 + .../charts/ssh-bastion/templates/_helpers.tpl | 12 + .../templates/configmap-authorized-keys.yaml | 12 + .../templates/configmap-config.yaml | 15 + .../charts/ssh-bastion/templates/service.yaml | 25 ++ .../ssh-bastion/templates/statefulset.yaml | 98 +++++ .../ssh-bastion/tests/bastion_test.yaml | 106 +++++ .../charts/ssh-bastion/values.yaml | 40 ++ .../terraform/resources/bastion.tf | 364 ++---------------- 12 files changed, 411 insertions(+), 331 deletions(-) create mode 100644 base-infrastructure/charts/ssh-bastion/.helmignore create mode 100644 base-infrastructure/charts/ssh-bastion/Chart.yaml create mode 100644 base-infrastructure/charts/ssh-bastion/files/assert-sshd.sh create mode 100644 base-infrastructure/charts/ssh-bastion/files/sshd_config.d/100-ifrc-forwarding.conf create mode 100644 base-infrastructure/charts/ssh-bastion/templates/_helpers.tpl create mode 100644 base-infrastructure/charts/ssh-bastion/templates/configmap-authorized-keys.yaml create mode 100644 base-infrastructure/charts/ssh-bastion/templates/configmap-config.yaml create mode 100644 base-infrastructure/charts/ssh-bastion/templates/service.yaml create mode 100644 base-infrastructure/charts/ssh-bastion/templates/statefulset.yaml create mode 100644 base-infrastructure/charts/ssh-bastion/tests/bastion_test.yaml create mode 100644 base-infrastructure/charts/ssh-bastion/values.yaml 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..7cd9319 --- /dev/null +++ b/base-infrastructure/charts/ssh-bastion/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v2 +name: ssh-bastion +description: Cluster-wide SSH bastion / jump host (port-forwarding + ProxyJump), applied by Terraform. +type: application +version: 0.1.0 +# Informational: tracks the linuxserver/openssh-server image tag (see values.yaml). +appVersion: "10.3_p1-r0" 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..5e98eca --- /dev/null +++ b/base-infrastructure/charts/ssh-bastion/files/sshd_config.d/100-ifrc-forwarding.conf @@ -0,0 +1,20 @@ +# Jump host for port-forwarding / ProxyJump. Key-only auth (PasswordAuthentication +# no, GatewayPorts no, X11Forwarding no are already set by the image defaults). +AllowTcpForwarding yes + +# 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/service.yaml b/base-infrastructure/charts/ssh-bastion/templates/service.yaml new file mode 100644 index 0000000..7ea4393 --- /dev/null +++ b/base-infrastructure/charts/ssh-bastion/templates/service.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "ssh-bastion.name" . }} + labels: + {{- include "ssh-bastion.labels" . | nindent 4 }} + annotations: + {{- with .Values.service.azureResourceGroup }} + service.beta.kubernetes.io/azure-load-balancer-resource-group: {{ . | quote }} + {{- end }} +spec: + type: LoadBalancer + # Open to the internet; access is gated by SSH public-key auth only (team members do not + # have static source IPs, so no loadBalancerSourceRanges). + {{- with .Values.service.loadBalancerIP }} + loadBalancerIP: {{ . | quote }} + {{- end }} + # Preserve the real client source IP (otherwise SNAT'd to a node IP) so the VERBOSE sshd + # audit log records who connected. + externalTrafficPolicy: Local + selector: + app: ssh-bastion + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.port }} 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..a1ae16c --- /dev/null +++ b/base-infrastructure/charts/ssh-bastion/templates/statefulset.yaml @@ -0,0 +1,98 @@ +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: + 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"] + storageClassName: {{ .Values.persistence.storageClass | quote }} + 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..7440d71 --- /dev/null +++ b/base-infrastructure/charts/ssh-bastion/tests/bastion_test.yaml @@ -0,0 +1,106 @@ +suite: ssh-bastion +templates: + - templates/statefulset.yaml + - templates/service.yaml + - templates/configmap-config.yaml + - templates/configmap-authorized-keys.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 + template: templates/statefulset.yaml + set: + persistence.storageClass: managed-csi-premium + asserts: + - equal: + path: spec.volumeClaimTemplates[0].spec.storageClassName + value: managed-csi-premium + + - it: exposes an internet LoadBalancer that preserves the client source IP + template: templates/service.yaml + set: + service.loadBalancerIP: 20.1.2.3 + service.azureResourceGroup: 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: 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..335d84c --- /dev/null +++ b/base-infrastructure/charts/ssh-bastion/values.yaml @@ -0,0 +1,40 @@ +# 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 + +# Free-form environment label (set by Terraform, e.g. "staging" / "production"). +environment: "" + +service: + port: 2222 + # Reserved Azure public IP + the resource group that holds it (set by Terraform). + loadBalancerIP: "" + azureResourceGroup: "" + +persistence: + # AKS built-in RWO managed-disk class. Persists sshd host keys across restarts so users + # don't get host-key-changed warnings. + storageClass: managed-csi + size: 100Mi + +resources: + requests: + cpu: "0.1" + memory: "0.05Gi" + limits: + cpu: "1" + memory: "0.2Gi" + +# Authorized SSH *public* keys, one entry per key. Concatenated into a single, fully +# declarative authorized_keys file — 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" diff --git a/base-infrastructure/terraform/resources/bastion.tf b/base-infrastructure/terraform/resources/bastion.tf index b242db2..b086ac1 100644 --- a/base-infrastructure/terraform/resources/bastion.tf +++ b/base-infrastructure/terraform/resources/bastion.tf @@ -1,340 +1,42 @@ # 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. - -# 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. - -locals { - # renovate: datasource=docker depName=lscr.io/linuxserver/openssh-server versioning=regex:^version-(?\d+)\.(?\d+)_p(?\d+)-r(?\d+)$ - bastion_image = "lscr.io/linuxserver/openssh-server:version-10.3_p1-r0" - - # Single source of truth for the login user: the image creates this account - # (USER_NAME) and sshd only permits it (AllowUsers). Keep the two in lockstep. - bastion_user = "user" - - # Idle SSH jump host — kept small, tuned per environment, NOTE: matches the sizing the go-api chart overrides used previously - bastion_resources = { - requests = { - cpu = var.environment == "staging" ? "0.2" : "0.1" - memory = "0.05Gi" - } - limits = { cpu = "1", memory = "0.2Gi" } - } - - # sshd drop-in. NOTE on how this actually takes effect with linuxserver/openssh-server: - # its init script runs `sshd -f /config/sshd/sshd_config`, comments out the stock - # `Include /etc/ssh/sshd_config.d/*.conf`, and re-enables an include pointing at - # /config/sshd/sshd_config.d/ *only if that directory exists*. So this file MUST be - # mounted under /config/sshd/sshd_config.d/ (see volume_mount below) — a drop-in in the - # stock /etc/ssh/sshd_config.d/ is silently ignored. Directives here are obtained before - # the base config's, so first-value-wins settings (AllowTcpForwarding, AuthorizedKeysFile) - # override the image defaults. - # - # Agent forwarding is intentionally NOT enabled: this box is used for port-forwarding / - # ProxyJump, which only needs TCP forwarding, and agent forwarding is a security downgrade. - bastion_sshd_config = <<-EOT - # Jump host for port-forwarding / ProxyJump. Key-only auth (PasswordAuthentication - # no, GatewayPorts no, X11Forwarding no are already set by the image defaults). - AllowTcpForwarding yes - - # Authoritative, declarative authorized_keys (mounted read-only, see below). Using 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 here actually revokes access. - AuthorizedKeysFile /etc/ssh/authorized_keys - - # Auth hardening (internet-exposed LoadBalancer) - PermitRootLogin no - KbdInteractiveAuthentication no - MaxAuthTries 3 - LoginGraceTime 30 - AllowUsers ${local.bastion_user} - - # Audit trail (log key fingerprint per login) + reap dead sessions/tunnels - LogLevel VERBOSE - ClientAliveInterval 300 - ClientAliveCountMax 2 - EOT - - # Authorized SSH *public* keys (filename => key). Concatenated into a single, fully - # declarative authorized_keys file (see bastion_sshd_config / the config map below). - bastion_keys = { - "zoltan.pub" = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPGAnkQdf5CIpVoqNVJ17AAzUb02gpTltJI5q5SRKxl8 zol@hp" - "daniel.pub" = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDU1XLLPq1J4kFvNyg5eUK8uuW8dtW1f3ALVnYr0nVhldxF0J59XtZbNFBLCVHYZL3NQxYQrucll6LbGaMGKbGsTwtqcxqd2fWlhg7nBnvhOzULYbAru3YfpkgnawGin6Y7qW/MQ3fYmqqm8MB7p5+G4sIL76S2yWbi7lcKWnd87yDTGEEoc8H6i6IwNNVHudvuMA4MzGkSgql7gIC2KuU+s2u9Y6fmE92G39BO454SUgAcCJfhuXukZhU4UN3RVYy+F0MxVeLc0hEJi4sCYcoPKREc0//srNyni7b8G8C+z6t02xrzhWwIORlb8Jr2kmbblp7PFMz4r2qRd8MvXAa5ta6kUvMDg0t52JaDMAGy0IjGZh9PznXbp1LYn7uS5NQh4C/t6Q3TXyJbEiaQaObcmjn6w/DWH6gI7ZRYkPGdlctlNm5MWnhjG9Q/FzRIxvaauSFqgs6bfIUGGaY9i1eNiowVSzDPlP7nH0gJpq+uS5Qdyg69m/XH1DqywPoZY7U= ifrcds\\daniel.tovari@5CG41911RW" - "arun.pub" = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIERqaO+XlqTbvoh88Kuj9c377x77NChWhNP8VpbM1/hf ifrcds\\arun.gandhi@5CG1355NPN" - "thenav56.pub" = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN/f/A3qkaTHSdbKn8Hv75YiJvRMEXvWTDdIiR7tyAjJ navin@nav-machine" - "david.pub" = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC3FzrQdVh5Qwp5Y6KQGcpqHxKErxCW103iEECuutR/jBZe6X0xjD+cW7e+H8SrUsPQwj87fzOsMAc6v6n+3hdYFa6ekgRG/USEIUR5C/GD1Xjva3Xpp45PasBhJEtYt2ON+dlzwvRyOuv2hvqv2WHBO020ewIlVuQ4pU4Qj5ysvwWGj8GAv/jITiVERmjLTStbFwxeIDT3jQEbwnfV1zZZKiGxIecB/y51nk6oIQ00ZGrYEo5ieWsUSVfLHOX0/lZ0mtrdqxDEgMaCbNaUbICAimsJPamNpoirKc7FoKIKKrLQsK8qE1lClWQEecbW+dgSiwxracooKeWhHq+BkKUCNgEL/C0ff2l9e8sJcLmYZUdPtDCdtUDC8BAlELA5HR6tdCTfFcc0nXltclSSODMnZkQohh5/2fixJTwN5p5csEfBLzbdrturKtT/TbYSoaodg4muPqY4YE5jiJfrHVAGS1DVWz/cRcm1vOxT2V4iW2SNvo8fS2PZOpU5furrvbM= ifrcds\\david.muchatiza@5CG41911S1" - "paola.pub" = "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" - "ranjan.pub" = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGJA0ec4Gavc+m1MjEZGoUce51yWouMTRTYJZV3s/jgD rsh@rsh-XPS-15-9510" - } -} - -locals { - # Fail-closed config assertion. The 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. The probes below dump the *running* sshd config (`sshd -T`) - # and require every directive we shipped to be present. On mismatch the pod is pulled - # from the LoadBalancer (readiness) and restarted / crash-looped (startup + liveness) - # instead of accepting connections with unintended settings. - # - # Derived from the drop-in so the two never drift: normalise to `sshd -T`'s lowercase, - # comment/blank-stripped form. - bastion_expected = join("\n", [ - for l in split("\n", lower(local.bastion_sshd_config)) : - trimspace(l) if trimspace(l) != "" && !startswith(trimspace(l), "#") - ]) - - bastion_config_assert = ["sh", "-c", <<-EOT - # sshd must be accepting connections ... - nc -z 127.0.0.1 2222 || exit 1 - # ... and its effective config must contain every directive we shipped. Pass the host - # keys explicitly (as the service does): `sshd -T` otherwise reads them from the stock - # location and exits "no hostkeys available" when the probe runs as a non-root user. - 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') - exp='${local.bastion_expected}' - missing=$(printf '%s\n' "$exp" | while IFS= read -r d; do - [ -n "$d" ] || continue - printf '%s\n' "$eff" | grep -qF "$d" || printf '%s\n' "$d" - done) - [ -z "$missing" ] || { echo "sshd config assertion FAILED (missing directives):" >&2; printf '%s\n' "$missing" >&2; exit 1; } - EOT - ] -} - -resource "kubernetes_namespace" "bastion" { - metadata { - name = "bastion" - labels = { - "app.kubernetes.io/managed-by" = "terraform" - environment = var.environment - } - } -} - -# Authoritative authorized_keys (all public keys concatenated into one file), mounted at -# the sshd AuthorizedKeysFile path. Declarative: removing a key here revokes access. -resource "kubernetes_config_map" "bastion_authorized_keys" { - metadata { - name = "ssh-bastion-authorized-keys" - namespace = kubernetes_namespace.bastion.metadata[0].name - } - data = { - "authorized_keys" = "${join("\n", values(local.bastion_keys))}\n" - } -} - -# sshd drop-in (see local.bastion_sshd_config for the content and the notes on how the -# linuxserver image consumes it). -resource "kubernetes_config_map" "bastion_fix_sshd_config" { - metadata { - name = "ssh-bastion-fix-sshd-config" - namespace = kubernetes_namespace.bastion.metadata[0].name - } - data = { - "100-ifrc-forwarding.conf" = local.bastion_sshd_config - } -} - -resource "kubernetes_stateful_set" "bastion" { - metadata { - name = "ssh-bastion" - namespace = kubernetes_namespace.bastion.metadata[0].name - labels = { - app = "ssh-bastion" - environment = var.environment - } - } - - spec { - replicas = 1 - service_name = "ssh-bastion" - - selector { - match_labels = { - app = "ssh-bastion" - } - } - - template { - metadata { - labels = { - app = "ssh-bastion" - } - # Roll the pod when keys or sshd config change (the container ingests both only at - # start; without this a ConfigMap edit applies but the running pod keeps the old - # values, so added keys never work and — combined with the fixes above — nothing - # picks up config changes). - annotations = { - "checksum/authorized-keys" = sha256(jsonencode(local.bastion_keys)) - "checksum/sshd-config" = sha256(local.bastion_sshd_config) - } - } - - spec { - container { - name = "ssh-bastion" - image = local.bastion_image - - port { - container_port = 2222 - } - - resources { - requests = { - cpu = local.bastion_resources.requests.cpu - memory = local.bastion_resources.requests.memory - } - limits = { - cpu = local.bastion_resources.limits.cpu - memory = local.bastion_resources.limits.memory - } - } - - # Gate readiness/liveness until sshd is up and the config assertion passes. Given - # a generous budget for first-boot host-key generation; if the config never - # applies the pod never starts (CrashLoopBackOff) rather than serving. - startup_probe { - exec { - command = local.bastion_config_assert - } - period_seconds = 10 - timeout_seconds = 5 - failure_threshold = 30 - } - # Pull the pod out of the Service/LoadBalancer endpoints if the effective config - # ever regresses — no connections are routed to a misconfigured bastion. - readiness_probe { - exec { - command = local.bastion_config_assert - } - period_seconds = 30 - timeout_seconds = 5 - failure_threshold = 2 - } - # Restart (crash-loop) the pod on a runtime config regression. - liveness_probe { - exec { - command = local.bastion_config_assert - } - period_seconds = 60 - timeout_seconds = 5 - failure_threshold = 3 - } - - env { - name = "PUID" - value = "1000" - } - env { - name = "PGID" - value = "1000" - } - env { - name = "USER_NAME" - value = local.bastion_user - } - env { - name = "PASSWORD_ACCESS" - value = "false" - } - env { - name = "SUDO_ACCESS" - value = "false" - } - - # Authoritative authorized_keys — sshd reads it via AuthorizedKeysFile (see the - # drop-in). root-owned read-only file, which satisfies sshd's ownership checks. - volume_mount { - name = "ssh-authorized-keys" - mount_path = "/etc/ssh/authorized_keys" - sub_path = "authorized_keys" - read_only = true - } - volume_mount { - name = "config-volume" - mount_path = "/config" - } - # Must live under /config/sshd/sshd_config.d/ for the image to include it; the - # stock /etc/ssh/sshd_config.d/ is not read (see local.bastion_sshd_config). - volume_mount { - name = "fix-sshd-config" - mount_path = "/config/sshd/sshd_config.d/100-ifrc-forwarding.conf" - sub_path = "100-ifrc-forwarding.conf" - read_only = true - } - } - - volume { - name = "ssh-authorized-keys" - config_map { - name = kubernetes_config_map.bastion_authorized_keys.metadata[0].name - } - } - volume { - name = "fix-sshd-config" - config_map { - name = kubernetes_config_map.bastion_fix_sshd_config.metadata[0].name - } - } - } - } - - # Persists the server host keys across pod restarts (avoids host-key-changed warnings for users). - volume_claim_template { - metadata { - name = "config-volume" - } - spec { - access_modes = ["ReadWriteOnce"] - # Pin the class instead of relying on a cluster default (an unset/RWX default would - # leave the PVC Pending and the pod never starts). managed-csi is the AKS built-in - # RWO managed-disk class. - storage_class_name = "managed-csi" - resources { - requests = { - storage = "100Mi" - } - } - } - } - } -} - -resource "kubernetes_service" "bastion" { - metadata { - name = "ssh-bastion" - namespace = kubernetes_namespace.bastion.metadata[0].name - labels = { - app = "ssh-bastion" - environment = var.environment - } - annotations = { - "service.beta.kubernetes.io/azure-load-balancer-resource-group" = data.azurerm_resource_group.ifrcgo.name - } - } +# 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, ] - spec { - type = "LoadBalancer" - # Open to the internet; access is gated by SSH public-key auth only (team members do not have static source IPs, so no loadBalancerSourceRanges). - load_balancer_ip = azurerm_public_ip.bastion.ip_address - # Preserve the real client source IP (otherwise SNAT'd to a node IP), so the VERBOSE - # sshd audit log records who connected. Matches the traefik service. - external_traffic_policy = "Local" - - selector = { - app = "ssh-bastion" + values = [yamlencode({ + environment = var.environment + # Idle jump host — kept small. Staging gets a slightly higher CPU request (matches the + # sizing the go-api chart overrides used previously); everything else comes from the + # chart's values.yaml. + resources = { + requests = { + cpu = var.environment == "staging" ? "0.2" : "0.1" + } } - - port { - port = 2222 - target_port = 2222 + service = { + # Reserved static IP so the bastion endpoint is stable across recreations. + loadBalancerIP = azurerm_public_ip.bastion.ip_address + azureResourceGroup = data.azurerm_resource_group.ifrcgo.name } - } + })] } From c88d769cf33f2500b87cd4f6e03f6be4b94d3138 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Fri, 24 Jul 2026 15:22:02 +0545 Subject: [PATCH 07/12] refactor(bastion): keep keys in Terraform, make the chart cloud-agnostic Per review: the authorized keys stay in resources/bastion.tf (passed to the chart as values), and cluster/cloud-specific settings are injected by Terraform rather than baked into the chart: - chart values default keys: [], storageClass: "" (omitted -> cluster default StorageClass), and a generic service.annotations map (no hardcoded Azure key) - Terraform passes the keys list, storageClass=managed-csi, the reserved loadBalancerIP and the azure-load-balancer-resource-group annotation - tests updated: storageClassName present when set / omitted when unset, and the LB annotation via the generic map Verified: helm lint, helm unittest (9/9), terraform validate. --- .../charts/ssh-bastion/templates/service.yaml | 6 ++-- .../ssh-bastion/templates/statefulset.yaml | 4 ++- .../ssh-bastion/tests/bastion_test.yaml | 11 +++++-- .../charts/ssh-bastion/values.yaml | 26 ++++++++-------- .../terraform/resources/bastion.tf | 30 ++++++++++++++++--- 5 files changed, 53 insertions(+), 24 deletions(-) diff --git a/base-infrastructure/charts/ssh-bastion/templates/service.yaml b/base-infrastructure/charts/ssh-bastion/templates/service.yaml index 7ea4393..835ca5b 100644 --- a/base-infrastructure/charts/ssh-bastion/templates/service.yaml +++ b/base-infrastructure/charts/ssh-bastion/templates/service.yaml @@ -4,10 +4,10 @@ metadata: name: {{ include "ssh-bastion.name" . }} labels: {{- include "ssh-bastion.labels" . | nindent 4 }} + {{- with .Values.service.annotations }} annotations: - {{- with .Values.service.azureResourceGroup }} - service.beta.kubernetes.io/azure-load-balancer-resource-group: {{ . | quote }} - {{- end }} + {{- toYaml . | nindent 4 }} + {{- end }} spec: type: LoadBalancer # Open to the internet; access is gated by SSH public-key auth only (team members do not diff --git a/base-infrastructure/charts/ssh-bastion/templates/statefulset.yaml b/base-infrastructure/charts/ssh-bastion/templates/statefulset.yaml index a1ae16c..9511e80 100644 --- a/base-infrastructure/charts/ssh-bastion/templates/statefulset.yaml +++ b/base-infrastructure/charts/ssh-bastion/templates/statefulset.yaml @@ -92,7 +92,9 @@ spec: name: config-volume spec: accessModes: ["ReadWriteOnce"] - storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- 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 index 7440d71..6b50128 100644 --- a/base-infrastructure/charts/ssh-bastion/tests/bastion_test.yaml +++ b/base-infrastructure/charts/ssh-bastion/tests/bastion_test.yaml @@ -48,7 +48,7 @@ tests: - exists: path: spec.template.metadata.annotations["checksum/config"] - - it: pins the PVC storage class + - it: pins the PVC storage class when provided template: templates/statefulset.yaml set: persistence.storageClass: managed-csi-premium @@ -57,11 +57,18 @@ tests: 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.azureResourceGroup: ifrctgo002rg + service.annotations: + service.beta.kubernetes.io/azure-load-balancer-resource-group: ifrctgo002rg asserts: - equal: path: spec.type diff --git a/base-infrastructure/charts/ssh-bastion/values.yaml b/base-infrastructure/charts/ssh-bastion/values.yaml index 335d84c..56951c9 100644 --- a/base-infrastructure/charts/ssh-bastion/values.yaml +++ b/base-infrastructure/charts/ssh-bastion/values.yaml @@ -10,14 +10,17 @@ environment: "" service: port: 2222 - # Reserved Azure public IP + the resource group that holds it (set by Terraform). + # Reserved cloud LB IP (set per-cluster). Empty = let the cluster assign one. loadBalancerIP: "" - azureResourceGroup: "" + # 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: {} persistence: - # AKS built-in RWO managed-disk class. Persists sshd host keys across restarts so users - # don't get host-key-changed warnings. - storageClass: managed-csi + # 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: @@ -29,12 +32,7 @@ resources: memory: "0.2Gi" # Authorized SSH *public* keys, one entry per key. Concatenated into a single, fully -# declarative authorized_keys file — 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" +# 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 index b086ac1..628ec45 100644 --- a/base-infrastructure/terraform/resources/bastion.tf +++ b/base-infrastructure/terraform/resources/bastion.tf @@ -13,6 +13,20 @@ # NOTE: after editing anything under charts/ssh-bastion, bump the chart `version` in # Chart.yaml so the helm provider detects the change and redeploys. +locals { + # Authorized SSH *public* keys. Concatenated into a single, declarative authorized_keys + # file by the chart — removing an entry here revokes that key's access. + bastion_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", + ] +} + resource "helm_release" "bastion" { name = "ssh-bastion" namespace = "bastion" @@ -23,20 +37,28 @@ resource "helm_release" "bastion" { 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 + keys = local.bastion_keys # Idle jump host — kept small. Staging gets a slightly higher CPU request (matches the - # sizing the go-api chart overrides used previously); everything else comes from the - # chart's values.yaml. + # 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" + } service = { # Reserved static IP so the bastion endpoint is stable across recreations. - loadBalancerIP = azurerm_public_ip.bastion.ip_address - azureResourceGroup = data.azurerm_resource_group.ifrcgo.name + loadBalancerIP = azurerm_public_ip.bastion.ip_address + annotations = { + "service.beta.kubernetes.io/azure-load-balancer-resource-group" = data.azurerm_resource_group.ifrcgo.name + } } })] } From 5158feddb443ddcf97e04aefed47c9cac68729ce Mon Sep 17 00:00:00 2001 From: thenav56 Date: Fri, 24 Jul 2026 15:25:26 +0545 Subject: [PATCH 08/12] refactor(bastion): inline keys in the helm_release values Drop the single-use locals block; the keys list lives directly in the values passed to the chart. --- .../terraform/resources/bastion.tf | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/base-infrastructure/terraform/resources/bastion.tf b/base-infrastructure/terraform/resources/bastion.tf index 628ec45..cd9f830 100644 --- a/base-infrastructure/terraform/resources/bastion.tf +++ b/base-infrastructure/terraform/resources/bastion.tf @@ -13,20 +13,6 @@ # NOTE: after editing anything under charts/ssh-bastion, bump the chart `version` in # Chart.yaml so the helm provider detects the change and redeploys. -locals { - # Authorized SSH *public* keys. Concatenated into a single, declarative authorized_keys - # file by the chart — removing an entry here revokes that key's access. - bastion_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", - ] -} - resource "helm_release" "bastion" { name = "ssh-bastion" namespace = "bastion" @@ -41,7 +27,17 @@ resource "helm_release" "bastion" { # Azure/AKS-specific (storage class, LB annotations, reserved IP) is injected here. values = [yamlencode({ environment = var.environment - keys = local.bastion_keys + # 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 = { From 8eb8ac9bb76b9ad453d8b25c771c6b7749bd6a4f Mon Sep 17 00:00:00 2001 From: thenav56 Date: Fri, 24 Jul 2026 15:35:25 +0545 Subject: [PATCH 09/12] harden(bastion): security-audit fixes + NodePort support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated the chart on a local kind cluster (NodePort, real SSH login and port-forward through the bastion) and ran authenticated + black-box security audits. Black-box surface is solid (key-only, root unreachable, MaxAuthTries 3, no user enumeration, PQ crypto). Applied the safe authenticated-audit fixes: - AllowAgentForwarding no — the comment claimed agent forwarding was intentionally off but it was never actually set; enforce it (also now part of the fail-closed assertion, since that derives from the drop-in) - automountServiceAccountToken: false — the bastion never calls the K8s API, so don't mount a cluster credential on an internet-exposed host - memory requests/limits as integer bytes (50Mi/200Mi); 0.05Gi/0.2Gi rendered as fractional-byte "…m" quantities that Kubernetes warns about - service.type/nodePort are now configurable (default LoadBalancer) so the chart can run on clusters without a cloud LB (kind, bare-metal) Deferred (need per-cluster decisions / image constraints, documented for follow-up): PermitOpen allowlist + egress NetworkPolicy to narrow the pivot surface, restrictive pod securityContext, host-key ownership (linuxserver image chowns /config to the login user), and pruning ECDSA host key / SHA-1 MACs. Verified: helm lint, helm unittest (9/9), terraform validate. --- .../files/sshd_config.d/100-ifrc-forwarding.conf | 5 +++++ .../charts/ssh-bastion/templates/service.yaml | 11 ++++++++--- .../charts/ssh-bastion/templates/statefulset.yaml | 3 +++ base-infrastructure/charts/ssh-bastion/values.yaml | 9 +++++++-- 4 files changed, 23 insertions(+), 5 deletions(-) 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 index 5e98eca..60882bb 100644 --- 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 @@ -2,6 +2,11 @@ # 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 + # 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. diff --git a/base-infrastructure/charts/ssh-bastion/templates/service.yaml b/base-infrastructure/charts/ssh-bastion/templates/service.yaml index 835ca5b..4a627a5 100644 --- a/base-infrastructure/charts/ssh-bastion/templates/service.yaml +++ b/base-infrastructure/charts/ssh-bastion/templates/service.yaml @@ -9,17 +9,22 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} spec: - type: LoadBalancer + 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). - {{- with .Values.service.loadBalancerIP }} - loadBalancerIP: {{ . | quote }} + {{- 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 index 9511e80..9d4236d 100644 --- a/base-infrastructure/charts/ssh-bastion/templates/statefulset.yaml +++ b/base-infrastructure/charts/ssh-bastion/templates/statefulset.yaml @@ -20,6 +20,9 @@ spec: 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 }} diff --git a/base-infrastructure/charts/ssh-bastion/values.yaml b/base-infrastructure/charts/ssh-bastion/values.yaml index 56951c9..725e686 100644 --- a/base-infrastructure/charts/ssh-bastion/values.yaml +++ b/base-infrastructure/charts/ssh-bastion/values.yaml @@ -9,7 +9,10 @@ user: user 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 @@ -26,10 +29,12 @@ persistence: resources: requests: cpu: "0.1" - memory: "0.05Gi" + # Integer byte quantities (0.05Gi / 0.2Gi render as fractional-byte "…m" values that + # Kubernetes warns about). + memory: "50Mi" limits: cpu: "1" - memory: "0.2Gi" + 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. From dde9114071fcbf032b4fa625b15d0533863a70d6 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Fri, 24 Jul 2026 15:59:29 +0545 Subject: [PATCH 10/12] harden(bastion): address HIGH audit findings (forwarding-only + pivot limits) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HIGH #1 (host-key exfil): the image runs sshd as the unprivileged login user, so the host private key is necessarily readable by that account — it can't be hidden from the user the shell runs as. Remove the exfil vector instead: make the bastion forwarding-only (forwardingOnly=true, default). PermitTTY no + ForceCommand /bin/false block interactive shell, command exec, and (ForceCommand also overrides subsystems) SFTP/SCP, while -N/-L/-D/-J/-W keep working. A key holder can forward but cannot read anything on the box. HIGH #2 (broad cluster pivot): - sshd.permitOpen: optional forward-destination allowlist (default unrestricted), sshd-enforced. - networkPolicy (opt-in; enabled in the Terraform values): ingress limited to the SSH port, egress limited to an allowlist (default RFC1918) so the public internet and cloud metadata (169.254.169.254) are denied by construction. Requires an enforcing CNI (azure/calico); harmless no-op otherwise. Also: service.type/nodePort configurable (default LoadBalancer) for non-cloud clusters. Verified on a local kind cluster: pod Ready (fail-closed assertion accepts the new directives), interactive shell + scp + sftp all blocked, host key NOT exfiltrable, port-forward to the kube API still works, PermitOpen allows the listed target and resets a non-listed one, NetworkPolicy renders and is accepted by the API. helm lint, helm unittest (15/15), terraform validate all pass. --- .../sshd_config.d/100-ifrc-forwarding.conf | 13 ++++ .../ssh-bastion/templates/networkpolicy.yaml | 38 +++++++++++ .../ssh-bastion/tests/bastion_test.yaml | 66 +++++++++++++++++++ .../charts/ssh-bastion/values.yaml | 27 ++++++++ .../terraform/resources/bastion.tf | 9 +++ 5 files changed, 153 insertions(+) create mode 100644 base-infrastructure/charts/ssh-bastion/templates/networkpolicy.yaml 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 index 60882bb..479b5a6 100644 --- 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 @@ -6,6 +6,19 @@ AllowTcpForwarding yes # 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) 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/tests/bastion_test.yaml b/base-infrastructure/charts/ssh-bastion/tests/bastion_test.yaml index 6b50128..86def6a 100644 --- a/base-infrastructure/charts/ssh-bastion/tests/bastion_test.yaml +++ b/base-infrastructure/charts/ssh-bastion/tests/bastion_test.yaml @@ -4,6 +4,7 @@ templates: - 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 @@ -98,6 +99,71 @@ tests: 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: diff --git a/base-infrastructure/charts/ssh-bastion/values.yaml b/base-infrastructure/charts/ssh-bastion/values.yaml index 725e686..41975c7 100644 --- a/base-infrastructure/charts/ssh-bastion/values.yaml +++ b/base-infrastructure/charts/ssh-bastion/values.yaml @@ -5,6 +5,19 @@ image: "lscr.io/linuxserver/openssh-server:version-10.3_p1-r0" # (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: "" @@ -19,6 +32,20 @@ service: # (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 diff --git a/base-infrastructure/terraform/resources/bastion.tf b/base-infrastructure/terraform/resources/bastion.tf index cd9f830..935c1dc 100644 --- a/base-infrastructure/terraform/resources/bastion.tf +++ b/base-infrastructure/terraform/resources/bastion.tf @@ -49,6 +49,15 @@ resource "helm_release" "bastion" { # 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 From bb0c3310b733334d5957a79ed64ac422d602cad7 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Fri, 24 Jul 2026 20:23:56 +0545 Subject: [PATCH 11/12] chore(bastion): drop informational appVersion (image tag lives in values.yaml) --- base-infrastructure/charts/ssh-bastion/Chart.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/base-infrastructure/charts/ssh-bastion/Chart.yaml b/base-infrastructure/charts/ssh-bastion/Chart.yaml index 7cd9319..36ce45e 100644 --- a/base-infrastructure/charts/ssh-bastion/Chart.yaml +++ b/base-infrastructure/charts/ssh-bastion/Chart.yaml @@ -3,5 +3,3 @@ name: ssh-bastion description: Cluster-wide SSH bastion / jump host (port-forwarding + ProxyJump), applied by Terraform. type: application version: 0.1.0 -# Informational: tracks the linuxserver/openssh-server image tag (see values.yaml). -appVersion: "10.3_p1-r0" From afb50cc711b5a17902caa36806674a517027aeab Mon Sep 17 00:00:00 2001 From: thenav56 Date: Mon, 27 Jul 2026 11:11:49 +0545 Subject: [PATCH 12/12] fix(pre-commit): ignore charts for check-yaml --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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