feat: Kubernetes native integration — Helm chart + K8s/SPIFFE config

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) <noreply@anthropic.com>
This commit is contained in:
Tyler King 2026-04-05 10:23:09 -04:00
parent 043b9b9bdc
commit 9dc5cb9eee
11 changed files with 368 additions and 0 deletions

12
charts/bascule/Chart.yaml Normal file
View file

@ -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

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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"

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -0,0 +1,8 @@
{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "bascule.serviceAccountName" . }}
labels:
{{- include "bascule.labels" . | nindent 4 }}
{{- end }}

View file

@ -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: {}

View file

@ -40,6 +40,10 @@ pub struct BasculeConfig {
/// Priority: proxy > container > local PTY.
pub container: Option<ContainerConfig>,
/// K8s backend configuration.
/// When running in-cluster, exec into a shell sidecar instead of local PTY.
pub k8s: Option<K8sConfig>,
/// 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<AgentIdConfig>,
/// SPIFFE/SPIRE workload identity authentication.
pub spiffe: Option<SpiffeConfig>,
}
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<String>,
/// SPIRE Workload API socket path.
pub workload_api_socket: Option<String>,
}
/// 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<String>,
/// Pod name (auto-detected from downward API if not set).
pub pod_name: Option<String>,
/// 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 {