Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions chart/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ Set name of namespace to use for mcp servers
{{- end -}}
{{- end -}}

{{/*
Effective Secret name for the MCP CA certificate bundle.
Returns empty when neither `mcpCACertificate.content` nor `mcpCACertificate.existingSecret` is set.
*/}}
{{- define "obot.mcpCACertificate.secretName" -}}
{{- if .Values.mcpCACertificate.existingSecret -}}
{{- .Values.mcpCACertificate.existingSecret -}}
{{- else if .Values.mcpCACertificate.content -}}
{{ .Release.Name }}-mcp-ca-cert
{{- end -}}
{{- end -}}

{{/*
Generate comma-separated list of MCP image pull secret names
*/}}
Expand Down
23 changes: 21 additions & 2 deletions chart/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ spec:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ include "obot.imageTag" . }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- if or .Values.persistence.enabled .Values.extraVolumeMounts (eq (lower (toString .Values.config.OBOT_SERVER_ENCRYPTION_PROVIDER)) "custom") }}
{{- if or .Values.persistence.enabled .Values.extraVolumeMounts (eq (lower (toString .Values.config.OBOT_SERVER_ENCRYPTION_PROVIDER)) "custom") (include "obot.mcpCACertificate.secretName" .) }}
volumeMounts:
{{- if eq (lower (toString .Values.config.OBOT_SERVER_ENCRYPTION_PROVIDER)) "custom" }}
- name: config
Expand All @@ -93,6 +93,11 @@ spec:
- name: data
mountPath: {{ .Values.persistence.path }}
{{- end }}
{{- if include "obot.mcpCACertificate.secretName" . }}
- name: mcp-ca-bundle
mountPath: /etc/ssl/certs/obot-ca-bundle
readOnly: true
{{- end }}
Comment on lines +96 to +100
{{- if .Values.extraVolumeMounts }}
{{- toYaml .Values.extraVolumeMounts | nindent 12 }}
{{- end }}
Expand Down Expand Up @@ -124,6 +129,15 @@ spec:
valueFrom:
fieldRef:
fieldPath: spec.serviceAccountName
{{- if include "obot.mcpCACertificate.secretName" . }}
{{- $certPath := printf "/etc/ssl/certs/obot-ca-bundle/%s" .Values.mcpCACertificate.secretKey }}
- name: SSL_CERT_FILE
value: {{ $certPath | quote }}
- name: REQUESTS_CA_BUNDLE
value: {{ $certPath | quote }}
- name: NODE_EXTRA_CA_CERTS
value: {{ $certPath | quote }}
{{- end }}
envFrom:
- configMapRef:
name: {{ include "obot.config.configMapName" . }}
Expand All @@ -143,7 +157,7 @@ spec:
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if or .Values.persistence.enabled .Values.extraVolumes (eq (lower (toString .Values.config.OBOT_SERVER_ENCRYPTION_PROVIDER)) "custom") }}
{{- if or .Values.persistence.enabled .Values.extraVolumes (eq (lower (toString .Values.config.OBOT_SERVER_ENCRYPTION_PROVIDER)) "custom") (include "obot.mcpCACertificate.secretName" .) }}
volumes:
{{- if eq (lower (toString .Values.config.OBOT_SERVER_ENCRYPTION_PROVIDER)) "custom" }}
- name: config
Expand All @@ -155,6 +169,11 @@ spec:
persistentVolumeClaim:
claimName: {{ ternary .Values.persistence.existingClaim (print .Release.Name "-pvc") (ne .Values.persistence.existingClaim "") }}
{{- end }}
{{- if include "obot.mcpCACertificate.secretName" . }}
- name: mcp-ca-bundle
secret:
secretName: {{ include "obot.mcpCACertificate.secretName" . }}
{{- end }}
Comment on lines +172 to +176
{{- if .Values.extraVolumes }}
{{- toYaml .Values.extraVolumes | nindent 8 }}
{{- end }}
Expand Down
4 changes: 4 additions & 0 deletions chart/templates/internal-configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ data:
OBOT_SERVER_MCPNAMESPACE: {{ include "obot.config.mcpNamespace" . | quote }}
OBOT_SERVER_SERVICE_NAME: {{ include "obot.fullname" . | quote }}
OBOT_SERVER_SERVICE_NAMESPACE: {{ .Release.Namespace | quote }}
{{- if include "obot.mcpCACertificate.secretName" . }}
OBOT_SERVER_MCPCACERT_SECRET_NAME: {{ include "obot.mcpCACertificate.secretName" . | quote }}
OBOT_SERVER_MCPCACERT_SECRET_KEY: {{ .Values.mcpCACertificate.secretKey | quote }}
{{- end }}
{{- end }}
{{- if .Values.mcpServerDefaults.affinity }}
OBOT_SERVER_MCPK8S_SETTINGS_AFFINITY: {{ .Values.mcpServerDefaults.affinity | toJson | quote }}
Expand Down
29 changes: 29 additions & 0 deletions chart/templates/mcp-ca-certificate.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{{- if .Values.mcpCACertificate.content -}}
{{- $secretName := include "obot.mcpCACertificate.secretName" . -}}
{{- $secretKey := .Values.mcpCACertificate.secretKey -}}
apiVersion: v1
kind: Secret
type: Opaque
metadata:
name: {{ $secretName }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "obot.labels" . | nindent 4 }}
data:
{{ $secretKey }}: {{ .Values.mcpCACertificate.content | b64enc | quote }}
{{- if eq .Values.config.OBOT_SERVER_MCPRUNTIME_BACKEND "kubernetes" }}
{{- if ne (include "obot.config.mcpNamespace" .) .Release.Namespace }}
---
apiVersion: v1
kind: Secret
type: Opaque
metadata:
name: {{ $secretName }}
namespace: {{ include "obot.config.mcpNamespace" . }}
labels:
{{- include "obot.labels" . | nindent 4 }}
data:
{{ $secretKey }}: {{ .Values.mcpCACertificate.content | b64enc | quote }}
{{- end }}
{{- end }}
{{- end }}
13 changes: 13 additions & 0 deletions chart/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ config:
OBOT_SERVER_DISALLOW_LINK_LOCAL_MCP: ""
# config.OBOT_SERVER_MCPRUNTIME_BACKEND -- The runtime backend to use for MCP servers. Can be 'docker' or 'kubernetes'. Defaults to 'docker'. Setting this to 'kubernetes' will also create the necessary service account, role and rolebinding.
OBOT_SERVER_MCPRUNTIME_BACKEND: "kubernetes"
# config.OBOT_SERVER_MCPCACERT_FILE -- Path to a PEM CA certificate file to mount into MCP containers for custom TLS trust. Only applies to the Docker runtime backend. For the Kubernetes backend, use the top-level `mcpCACertificate` section instead.
OBOT_SERVER_MCPCACERT_FILE: ""
# config.OBOT_SERVER_MCPSECRET_BINDING_ALLOWED_LABEL -- Kubernetes Secret label key required for admin UI secret-binding lookup and runtime secret-binding resolution. Empty uses the server default: obot.obot.ai/allow-secret-binding.
OBOT_SERVER_MCPSECRET_BINDING_ALLOWED_LABEL: ""

Expand Down Expand Up @@ -394,6 +396,17 @@ mcpServerDefaults:
# mcpServerDefaults.maxMemoryLimit -- Maximum memory limit allowed for MCP server pods
maxMemoryLimit: ""

# mcpCACertificate -- Mount a custom PEM CA bundle into the Obot pod and every MCP server pod so they trust TLS certificates issued by an internal CA (airgapped / enterprise deployments).
# When set, the bundle is always mounted on the Obot pod. It is additionally mounted on MCP server pods only when config.OBOT_SERVER_MCPRUNTIME_BACKEND is "kubernetes". For the Docker runtime backend, use config.OBOT_SERVER_MCPCACERT_FILE for the MCP containers.
# The bundle replaces the default trust store for Go (SSL_CERT_FILE) and Python (REQUESTS_CA_BUNDLE); if public-internet TLS must also work, concatenate your CA with the system bundle before supplying it.
mcpCACertificate:
# mcpCACertificate.content -- Inline PEM content. When set, the chart creates a Secret in both the Obot release namespace and the MCP namespace.
content: ""
# mcpCACertificate.existingSecret -- Name of a pre-existing Secret. Must exist in BOTH the Obot release namespace (for the Obot pod) and the MCP namespace (for MCP pods). Mutually exclusive with `content`.
existingSecret: ""
# mcpCACertificate.secretKey -- Key within the Secret that holds the PEM bundle. Also used as the filename inside the mounted directory.
secretKey: "ca-bundle.crt"

# nodeSelector -- Configure node selector for pod assignment
nodeSelector: {}

Expand Down
33 changes: 33 additions & 0 deletions pkg/mcp/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type dockerBackend struct {
remoteShimBaseImage string
auditLogsBatchSize int
auditLogsFlushIntervalSeconds int
caCertFile string
authEnabled bool
deploymentCacheMu sync.RWMutex
deploymentCache map[string]*dockerDeploymentCacheEntry
Expand Down Expand Up @@ -93,10 +94,26 @@ func newDockerBackend(ctx context.Context, authEnabled bool, exposedPort int, op
remoteShimBaseImage: opts.MCPRemoteShimBaseImage,
auditLogsBatchSize: opts.MCPAuditLogsPersistBatchSize,
auditLogsFlushIntervalSeconds: opts.MCPAuditLogPersistIntervalSeconds,
caCertFile: opts.MCPCACertFile,
authEnabled: authEnabled,
deploymentCache: map[string]*dockerDeploymentCacheEntry{},
syncedFilesHash: map[string]string{},
}
if d.caCertFile != "" {
fi, err := os.Stat(d.caCertFile)
if err != nil {
return nil, fmt.Errorf("MCP CA cert file %q not accessible: %w", d.caCertFile, err)
}
if !fi.Mode().IsRegular() {
return nil, fmt.Errorf("MCP CA cert file %q is not a regular file", d.caCertFile)
}
// Confirm it's readable now, rather than failing later at container start.
f, err := os.Open(d.caCertFile)
if err != nil {
return nil, fmt.Errorf("MCP CA cert file %q is not readable: %w", d.caCertFile, err)
}
_ = f.Close()
}

if err = d.cleanupDeprecatedContainers(ctx); err != nil {
return nil, fmt.Errorf("failed to cleanup deprecated containers: %w", err)
Expand Down Expand Up @@ -1091,6 +1108,22 @@ func (d *dockerBackend) createAndStartContainer(ctx context.Context, server Serv
return "", 0, fmt.Errorf("unsupported runtime: %s", server.Runtime)
}

// Mount custom CA certificate if configured
if d.caCertFile != "" {
const caCertTarget = "/etc/ssl/certs/obot-ca-bundle.crt"
volumeMounts = append(volumeMounts, mount.Mount{
Type: mount.TypeBind,
Source: d.caCertFile,
Target: caCertTarget,
ReadOnly: true,
})
env = append(env,
"SSL_CERT_FILE="+caCertTarget,
"REQUESTS_CA_BUNDLE="+caCertTarget,
"NODE_EXTRA_CA_CERTS="+caCertTarget,
)
}

// Prepare port binding
containerPortStr := fmt.Sprintf("%d/tcp", containerPort)

Expand Down
56 changes: 56 additions & 0 deletions pkg/mcp/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,20 @@ type kubernetesBackend struct {
imagePullSecrets []string
auditLogsBatchSize int
auditLogsFlushIntervalSeconds int
caCertSecretName string
caCertSecretKey string
authEnabled bool
obotClient kclient.Client
resourceMaximums ResourceMaximums
deploymentCacheMu sync.RWMutex
deploymentCache map[string]*kubernetesDeploymentCacheEntry
}

const (
mcpCACertVolumeName = "mcp-ca-bundle"
mcpCACertMountDir = "/etc/ssl/certs/obot-ca-bundle"
)

type kubernetesDeploymentCacheEntry struct {
hash string
podName string
Expand All @@ -81,6 +88,11 @@ func newKubernetesBackend(authEnabled bool, clientset *kubernetes.Clientset, cli
serviceFQDN = fmt.Sprintf("%s.%s.svc.%s", opts.ServiceName, opts.ServiceNamespace, opts.MCPClusterDomain)
}

caCertSecretKey := opts.MCPCACertSecretKey
if opts.MCPCACertSecretName != "" && caCertSecretKey == "" {
caCertSecretKey = "ca-bundle.crt"
}

return &kubernetesBackend{
clientset: clientset,
client: client,
Expand All @@ -94,6 +106,8 @@ func newKubernetesBackend(authEnabled bool, clientset *kubernetes.Clientset, cli
imagePullSecrets: opts.MCPImagePullSecrets,
auditLogsBatchSize: opts.MCPAuditLogsPersistBatchSize,
auditLogsFlushIntervalSeconds: opts.MCPAuditLogPersistIntervalSeconds,
caCertSecretName: opts.MCPCACertSecretName,
caCertSecretKey: caCertSecretKey,
obotClient: obotClient,
resourceMaximums: resourceMaximums,
deploymentCache: map[string]*kubernetesDeploymentCacheEntry{},
Expand Down Expand Up @@ -922,9 +936,51 @@ func (k *kubernetesBackend) k8sObjects(ctx context.Context, server ServerConfig,
},
})

k.applyCACertVolume(dep)

return objs, nil
}

// applyCACertVolume mounts the configured CA bundle secret on every container in
// the deployment and sets SSL_CERT_FILE / REQUESTS_CA_BUNDLE / NODE_EXTRA_CA_CERTS
// so Go, Python, and Node.js runtimes all trust it. No-op when no secret is configured.
func (k *kubernetesBackend) applyCACertVolume(dep *appsv1.Deployment) {
if k.caCertSecretName == "" {
return
}

certFilePath := mcpCACertMountDir + "/" + k.caCertSecretKey

dep.Spec.Template.Spec.Volumes = append(dep.Spec.Template.Spec.Volumes, corev1.Volume{
Name: mcpCACertVolumeName,
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: k.caCertSecretName,
// Project only the configured key so a misconfigured secretKey
// fails fast at mount time instead of silently pointing
// SSL_CERT_FILE et al. at a non-existent path.
Items: []corev1.KeyToPath{
{Key: k.caCertSecretKey, Path: k.caCertSecretKey},
},
},
},
})

for i := range dep.Spec.Template.Spec.Containers {
c := &dep.Spec.Template.Spec.Containers[i]
c.VolumeMounts = append(c.VolumeMounts, corev1.VolumeMount{
Name: mcpCACertVolumeName,
MountPath: mcpCACertMountDir,
ReadOnly: true,
})
c.Env = append(c.Env,
corev1.EnvVar{Name: "SSL_CERT_FILE", Value: certFilePath},
corev1.EnvVar{Name: "REQUESTS_CA_BUNDLE", Value: certFilePath},
corev1.EnvVar{Name: "NODE_EXTRA_CA_CERTS", Value: certFilePath},
)
}
}

// getNewestPod finds and returns the most recently created pod from the list.
func getNewestPod(pods []corev1.Pod) (*corev1.Pod, error) {
if len(pods) == 0 {
Expand Down
3 changes: 3 additions & 0 deletions pkg/mcp/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ type Options struct {
DisallowLinkLocalMCP bool `usage:"Disallow MCP containers from connecting to link-local addresses" default:"true"`
MCPRuntimeBackend string `usage:"The runtime backend to use for running MCP servers: docker, kubernetes, or k8s. Defaults to docker" default:"docker"`
MCPSecretBindingAllowedLabel string `usage:"Kubernetes Secret label key required for admin UI secret-binding lookup and save-time validation" default:"obot.obot.ai/allow-secret-binding"`
MCPCACertFile string `usage:"Path to a PEM CA certificate file to mount into MCP containers for custom TLS trust (Docker backend only)"`
MCPCACertSecretName string `usage:"Name of a Kubernetes Secret holding a PEM CA bundle to mount into MCP pods for custom TLS trust (Kubernetes backend only)"`
MCPCACertSecretKey string `usage:"Key within MCPCACertSecretName that holds the PEM CA bundle" default:"ca-bundle.crt"`
MCPImagePullSecrets []string `usage:"The name of the image pull secret to use for pulling MCP images"`
SingleUserIdleServerShutdownHours int `usage:"The interval in hours to check for idle MCP servers designated to a single user and shut them down, set to -1 to disable shutdown" default:"24"`
MultiUserIdleServerShutdownHours int `usage:"The interval in hours to check for idle multi-user MCP servers and shut them down, set to -1 to disable" default:"168"`
Expand Down