From 9dc5cb9eee6a88e81cd1e5aefa57be3b64bb2602516dc8796eed3f6d109982fb Mon Sep 17 00:00:00 2001 From: Tyler King Date: Sun, 5 Apr 2026 10:23:09 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Kubernetes=20native=20integration=20?= =?UTF-8?q?=E2=80=94=20Helm=20chart=20+=20K8s/SPIFFE=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Helm chart (charts/bascule/): Deployment with shell sidecar container (shared jumphost model) Service (LoadBalancer/NodePort/ClusterIP) ConfigMap with auto-generated config.toml RBAC (Role + RoleBinding for pods/exec) NetworkPolicy (restrict shell egress, allow DNS + K8s API) ServiceAccount with create flag Configurable shell image (k8s-ops, net-ops, dev, minimal) Helm lint passes clean K8s backend config (bascule-core): [k8s] section: enabled, namespace, pod_name, shell_container, shell Auto-detection via POD_NAME/POD_NAMESPACE env vars (downward API) Backend priority: K8s > proxy > container > local PTY K8s exec implementation deferred to --features k8s (kube crate) SPIFFE/SPIRE auth config: [auth.spiffe] section: trust_domain, trust_bundle_path, workload_api_socket JWT-SVID token-as-password authentication pattern Implementation deferred to bascule-auth-spiffe crate Zero substrate dependencies. Default build unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) --- charts/bascule/Chart.yaml | 12 +++ charts/bascule/templates/NOTES.txt | 12 +++ charts/bascule/templates/_helpers.tpl | 36 +++++++ charts/bascule/templates/configmap.yaml | 22 ++++ charts/bascule/templates/deployment.yaml | 102 +++++++++++++++++++ charts/bascule/templates/networkpolicy.yaml | 30 ++++++ charts/bascule/templates/rbac.yaml | 25 +++++ charts/bascule/templates/service.yaml | 15 +++ charts/bascule/templates/serviceaccount.yaml | 8 ++ charts/bascule/values.yaml | 65 ++++++++++++ crates/bascule-core/src/config.rs | 41 ++++++++ 11 files changed, 368 insertions(+) create mode 100644 charts/bascule/Chart.yaml create mode 100644 charts/bascule/templates/NOTES.txt create mode 100644 charts/bascule/templates/_helpers.tpl create mode 100644 charts/bascule/templates/configmap.yaml create mode 100644 charts/bascule/templates/deployment.yaml create mode 100644 charts/bascule/templates/networkpolicy.yaml create mode 100644 charts/bascule/templates/rbac.yaml create mode 100644 charts/bascule/templates/service.yaml create mode 100644 charts/bascule/templates/serviceaccount.yaml create mode 100644 charts/bascule/values.yaml diff --git a/charts/bascule/Chart.yaml b/charts/bascule/Chart.yaml new file mode 100644 index 0000000..1e02b90 --- /dev/null +++ b/charts/bascule/Chart.yaml @@ -0,0 +1,12 @@ +apiVersion: v2 +name: bascule +description: Identity-aware SSH proxy for Kubernetes +version: 0.1.0 +appVersion: "0.1.0" +type: application +keywords: + - ssh + - proxy + - identity + - jumphost + - bastion diff --git a/charts/bascule/templates/NOTES.txt b/charts/bascule/templates/NOTES.txt new file mode 100644 index 0000000..a4afbf4 --- /dev/null +++ b/charts/bascule/templates/NOTES.txt @@ -0,0 +1,12 @@ +Bascule SSH proxy deployed! + +Connect: +{{- if eq .Values.service.type "LoadBalancer" }} + ssh -p {{ .Values.service.port }} $(kubectl get svc {{ include "bascule.fullname" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}') +{{- else }} + kubectl port-forward svc/{{ include "bascule.fullname" . }} {{ .Values.service.port }}:{{ .Values.service.port }} + ssh -p {{ .Values.service.port }} localhost +{{- end }} + +Shell: {{ .Values.shell.image.repository }}:{{ .Values.shell.image.tag }} +Auth: {{ .Values.auth.mode }} diff --git a/charts/bascule/templates/_helpers.tpl b/charts/bascule/templates/_helpers.tpl new file mode 100644 index 0000000..eed90d6 --- /dev/null +++ b/charts/bascule/templates/_helpers.tpl @@ -0,0 +1,36 @@ +{{- define "bascule.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "bascule.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{- define "bascule.labels" -}} +helm.sh/chart: {{ include "bascule.name" . }} +{{ include "bascule.selectorLabels" . }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "bascule.selectorLabels" -}} +app.kubernetes.io/name: {{ include "bascule.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "bascule.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "bascule.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/charts/bascule/templates/configmap.yaml b/charts/bascule/templates/configmap.yaml new file mode 100644 index 0000000..ea5c23e --- /dev/null +++ b/charts/bascule/templates/configmap.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "bascule.fullname" . }}-config +data: + config.toml: | + listen_addr = "0.0.0.0:2222" + max_sessions = {{ .Values.maxSessions }} + {{- if .Values.hostKey.persistence }} + host_key_path = "/var/lib/bascule/host_key" + {{- end }} + + [auth] + mode = "{{ .Values.auth.mode }}" + {{- if eq .Values.auth.mode "authorized-keys" }} + authorized_keys_path = "{{ .Values.auth.authorizedKeysPath }}" + {{- end }} + + [k8s] + enabled = true + shell_container = "shell" + shell = "/bin/bash" diff --git a/charts/bascule/templates/deployment.yaml b/charts/bascule/templates/deployment.yaml new file mode 100644 index 0000000..9980817 --- /dev/null +++ b/charts/bascule/templates/deployment.yaml @@ -0,0 +1,102 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "bascule.fullname" . }} + labels: + {{- include "bascule.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "bascule.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "bascule.selectorLabels" . | nindent 8 }} + spec: + serviceAccountName: {{ include "bascule.serviceAccountName" . }} + containers: + - name: bascule + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: ssh + containerPort: 2222 + protocol: TCP + env: + - name: BASCULE_CONFIG + value: /etc/bascule/config.toml + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: BASCULE_SHELL_CONTAINER + value: shell + {{- range .Values.extraEnv }} + - name: {{ .name }} + value: {{ .value | quote }} + {{- end }} + volumeMounts: + - name: config + mountPath: /etc/bascule + readOnly: true + {{- if .Values.hostKey.persistence }} + - name: hostkey + mountPath: /var/lib/bascule + {{- end }} + {{- if .Values.auth.authorizedKeysSecret }} + - name: authorized-keys + mountPath: /etc/bascule/keys + readOnly: true + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + livenessProbe: + tcpSocket: + port: ssh + initialDelaySeconds: 5 + periodSeconds: 30 + readinessProbe: + tcpSocket: + port: ssh + initialDelaySeconds: 3 + periodSeconds: 10 + {{- if .Values.shell.enabled }} + - name: shell + image: "{{ .Values.shell.image.repository }}:{{ .Values.shell.image.tag }}" + imagePullPolicy: {{ .Values.shell.image.pullPolicy }} + command: ["sleep", "infinity"] + resources: + {{- toYaml .Values.shell.resources | nindent 12 }} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + add: ["SETUID", "SETGID"] + {{- end }} + volumes: + - name: config + configMap: + name: {{ include "bascule.fullname" . }}-config + {{- if .Values.hostKey.persistence }} + - name: hostkey + emptyDir: {} + {{- end }} + {{- if .Values.auth.authorizedKeysSecret }} + - name: authorized-keys + secret: + secretName: {{ .Values.auth.authorizedKeysSecret }} + defaultMode: 0600 + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/bascule/templates/networkpolicy.yaml b/charts/bascule/templates/networkpolicy.yaml new file mode 100644 index 0000000..e2f6fc1 --- /dev/null +++ b/charts/bascule/templates/networkpolicy.yaml @@ -0,0 +1,30 @@ +{{- if .Values.networkPolicy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "bascule.fullname" . }} +spec: + podSelector: + matchLabels: + {{- include "bascule.selectorLabels" . | nindent 6 }} + policyTypes: + - Ingress + - Egress + ingress: + - ports: + - port: 2222 + protocol: TCP + egress: + - ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP + {{- if .Values.networkPolicy.allowKubeApi }} + - ports: + - port: 443 + protocol: TCP + - port: 6443 + protocol: TCP + {{- end }} +{{- end }} diff --git a/charts/bascule/templates/rbac.yaml b/charts/bascule/templates/rbac.yaml new file mode 100644 index 0000000..76e08a6 --- /dev/null +++ b/charts/bascule/templates/rbac.yaml @@ -0,0 +1,25 @@ +{{- if .Values.rbac.create }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "bascule.fullname" . }} +rules: + - apiGroups: [""] + resources: ["pods/exec"] + verbs: ["create"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "bascule.fullname" . }} +subjects: + - kind: ServiceAccount + name: {{ include "bascule.serviceAccountName" . }} +roleRef: + kind: Role + name: {{ include "bascule.fullname" . }} + apiGroup: rbac.authorization.k8s.io +{{- end }} diff --git a/charts/bascule/templates/service.yaml b/charts/bascule/templates/service.yaml new file mode 100644 index 0000000..2ac6eeb --- /dev/null +++ b/charts/bascule/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "bascule.fullname" . }} + labels: + {{- include "bascule.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: ssh + protocol: TCP + name: ssh + selector: + {{- include "bascule.selectorLabels" . | nindent 4 }} diff --git a/charts/bascule/templates/serviceaccount.yaml b/charts/bascule/templates/serviceaccount.yaml new file mode 100644 index 0000000..e9cf3fe --- /dev/null +++ b/charts/bascule/templates/serviceaccount.yaml @@ -0,0 +1,8 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "bascule.serviceAccountName" . }} + labels: + {{- include "bascule.labels" . | nindent 4 }} +{{- end }} diff --git a/charts/bascule/values.yaml b/charts/bascule/values.yaml new file mode 100644 index 0000000..bc348c2 --- /dev/null +++ b/charts/bascule/values.yaml @@ -0,0 +1,65 @@ +replicaCount: 1 + +image: + repository: ghcr.io/guildhouse/bascule-server + tag: "latest" + pullPolicy: IfNotPresent + +shell: + enabled: true + image: + repository: ghcr.io/guildhouse/bascule-shell + tag: "k8s-ops" + pullPolicy: IfNotPresent + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: "1" + memory: 512Mi + +service: + type: LoadBalancer + port: 2222 + +auth: + mode: authorized-keys + authorizedKeysPath: /etc/bascule/keys + authorizedKeysSecret: "" + +maxSessions: 100 + +resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi + +hostKey: + persistence: true + size: 1Mi + +serviceAccount: + create: true + name: "" + +rbac: + create: true + +networkPolicy: + enabled: true + allowKubeApi: true + denyAllOtherEgress: true + +podDisruptionBudget: + enabled: false + minAvailable: 1 + +extraEnv: [] + +tolerations: [] +affinity: {} +nodeSelector: {} diff --git a/crates/bascule-core/src/config.rs b/crates/bascule-core/src/config.rs index 18c4a36..cce739a 100644 --- a/crates/bascule-core/src/config.rs +++ b/crates/bascule-core/src/config.rs @@ -40,6 +40,10 @@ pub struct BasculeConfig { /// Priority: proxy > container > local PTY. pub container: Option, + /// K8s backend configuration. + /// When running in-cluster, exec into a shell sidecar instead of local PTY. + pub k8s: Option, + /// Telemetry (OTel tracing). #[serde(default)] pub telemetry: TelemetryConfig, @@ -78,6 +82,9 @@ pub struct AuthConfig { /// AI agent authentication via Microsoft Entra Agent ID. pub agent_id: Option, + + /// SPIFFE/SPIRE workload identity authentication. + pub spiffe: Option, } impl Default for AuthConfig { @@ -86,6 +93,7 @@ impl Default for AuthConfig { mode: default_auth_mode(), authorized_keys_path: None, agent_id: None, + spiffe: None, } } } @@ -114,6 +122,7 @@ impl Default for BasculeConfig { max_sessions: 0, proxy: None, container: None, + k8s: None, telemetry: TelemetryConfig::default(), metrics: MetricsConfig::default(), } @@ -189,6 +198,38 @@ pub struct MountConfig { pub readonly: bool, } +/// SPIFFE/SPIRE authentication configuration. +#[derive(Debug, Deserialize, Clone)] +pub struct SpiffeConfig { + /// SPIFFE trust domain. + pub trust_domain: String, + /// Path to trust bundle PEM file. + pub trust_bundle_path: Option, + /// SPIRE Workload API socket path. + pub workload_api_socket: Option, +} + +/// K8s backend configuration. +#[derive(Debug, Deserialize, Clone)] +pub struct K8sConfig { + /// Enable K8s backend. + #[serde(default)] + pub enabled: bool, + /// Namespace (auto-detected from downward API if not set). + pub namespace: Option, + /// Pod name (auto-detected from downward API if not set). + pub pod_name: Option, + /// Shell container name in the Pod. + #[serde(default = "default_shell_container")] + pub shell_container: String, + /// Shell command inside the container. + #[serde(default = "default_k8s_shell")] + pub shell: String, +} + +fn default_shell_container() -> String { "shell".to_string() } +fn default_k8s_shell() -> String { "/bin/bash".to_string() } + /// Telemetry configuration (OTel + metrics). #[derive(Debug, Deserialize, Clone, Default)] pub struct TelemetryConfig {