Initial release: Headlamp Sealed Secrets plugin v0.1.0

Features:
- Complete SealedSecret CRD integration with Headlamp
- Client-side encryption using controller's public key
- Support for all three scoping modes (strict, namespace-wide, cluster-wide)
- List and detail views for SealedSecrets
- Encryption dialog for creating new SealedSecrets
- Decryption support with RBAC awareness
- Sealing keys management
- Settings page for controller configuration
- Integration with Secret detail view

Technical:
- Full TypeScript with strict mode
- ~1,345 lines of code
- Build size: 339.42 kB (93.21 kB gzipped)
- Compatible with Headlamp v0.13.0+
- Apache 2.0 license

Security:
- All encryption performed client-side
- RSA-OAEP + AES-256-GCM (kubeseal-compatible)
- Auto-hide decrypted values after 30 seconds

Closes: Initial implementation
This commit is contained in:
2026-02-11 20:31:20 -05:00
commit dddbd30677
27 changed files with 21162 additions and 0 deletions
@@ -0,0 +1,148 @@
/**
* Decrypt Dialog
*
* Shows the decrypted value of a secret key by reading the resulting
* Kubernetes Secret (requires RBAC permissions to read secrets)
*/
import { K8s } from '@kinvolk/headlamp-plugin/lib';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
TextField,
Typography,
} from '@mui/material';
import { ContentCopy as CopyIcon, Visibility, VisibilityOff } from '@mui/icons-material';
import { useSnackbar } from 'notistack';
import React from 'react';
import { SealedSecret } from '../lib/SealedSecretCRD';
interface DecryptDialogProps {
sealedSecret: SealedSecret;
secretKey: string;
onClose: () => void;
}
/**
* Decrypt dialog component
*/
export function DecryptDialog({ sealedSecret, secretKey, onClose }: DecryptDialogProps) {
const [secret] = K8s.ResourceClasses.Secret.useGet(
sealedSecret.metadata.name,
sealedSecret.metadata.namespace
);
const [showValue, setShowValue] = React.useState(false);
const [countdown, setCountdown] = React.useState(30);
const { enqueueSnackbar } = useSnackbar();
// Auto-hide after 30 seconds
React.useEffect(() => {
const timer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
onClose();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [onClose]);
const handleCopy = () => {
if (secret && secret.data?.[secretKey]) {
const decoded = atob(secret.data[secretKey]);
navigator.clipboard.writeText(decoded);
enqueueSnackbar('Copied to clipboard', { variant: 'success' });
}
};
// Check if secret exists
if (!secret) {
return (
<Dialog open onClose={onClose}>
<DialogTitle>Secret Not Found</DialogTitle>
<DialogContent>
<Typography>
The Kubernetes Secret for this SealedSecret has not been created yet, or you don't have
permission to read it.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
);
}
// Check if key exists
const encodedValue = secret.data?.[secretKey];
if (!encodedValue) {
return (
<Dialog open onClose={onClose}>
<DialogTitle>Key Not Found</DialogTitle>
<DialogContent>
<Typography>
The key <strong>{secretKey}</strong> was not found in the Secret.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
);
}
const decodedValue = atob(encodedValue);
return (
<Dialog open onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>
Decrypted Value: {secretKey}
<Typography variant="caption" display="block" color="text.secondary">
Auto-closing in {countdown} seconds
</Typography>
</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2 }}>
<TextField
fullWidth
multiline
minRows={3}
maxRows={10}
value={decodedValue}
type={showValue ? 'text' : 'password'}
InputProps={{
readOnly: true,
endAdornment: (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<IconButton onClick={() => setShowValue(!showValue)} size="small">
{showValue ? <VisibilityOff /> : <Visibility />}
</IconButton>
<IconButton onClick={handleCopy} size="small">
<CopyIcon />
</IconButton>
</Box>
),
}}
/>
<Box sx={{ mt: 2, p: 2, bgcolor: 'warning.light', borderRadius: 1 }}>
<Typography variant="body2">
<strong>Security Warning:</strong> This value is sensitive. Ensure no one is looking
over your shoulder.
</Typography>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,248 @@
/**
* Encryption Dialog
*
* Dialog for creating new SealedSecrets by encrypting secret values
* client-side using the controller's public certificate
*/
import { K8s } from '@kinvolk/headlamp-plugin/lib';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
IconButton,
InputLabel,
MenuItem,
Select,
TextField,
Typography,
} from '@mui/material';
import { Add as AddIcon, Delete as DeleteIcon, Visibility, VisibilityOff } from '@mui/icons-material';
import { useSnackbar } from 'notistack';
import React from 'react';
import { encryptKeyValues, parsePublicKeyFromCert } from '../lib/crypto';
import { fetchPublicCertificate, getPluginConfig } from '../lib/controller';
import { SealedSecret } from '../lib/SealedSecretCRD';
import { SecretKeyValue, SealedSecretScope } from '../types';
interface EncryptDialogProps {
open: boolean;
onClose: () => void;
}
/**
* Encrypt dialog component
*/
export function EncryptDialog({ open, onClose }: EncryptDialogProps) {
const [name, setName] = React.useState('');
const [namespace, setNamespace] = React.useState('default');
const [scope, setScope] = React.useState<SealedSecretScope>('strict');
const [keyValues, setKeyValues] = React.useState<(SecretKeyValue & { showValue: boolean })[]>([
{ key: '', value: '', showValue: false },
]);
const [encrypting, setEncrypting] = React.useState(false);
const { enqueueSnackbar } = useSnackbar();
const [namespaces] = K8s.ResourceClasses.Namespace.useList();
const handleAddKeyValue = () => {
setKeyValues([...keyValues, { key: '', value: '', showValue: false }]);
};
const handleRemoveKeyValue = (index: number) => {
setKeyValues(keyValues.filter((_, i) => i !== index));
};
const handleKeyChange = (index: number, key: string) => {
const updated = [...keyValues];
updated[index].key = key;
setKeyValues(updated);
};
const handleValueChange = (index: number, value: string) => {
const updated = [...keyValues];
updated[index].value = value;
setKeyValues(updated);
};
const toggleShowValue = (index: number) => {
const updated = [...keyValues];
updated[index].showValue = !updated[index].showValue;
setKeyValues(updated);
};
const handleCreate = async () => {
// Validate inputs
if (!name) {
enqueueSnackbar('Secret name is required', { variant: 'error' });
return;
}
const validKeyValues = keyValues.filter(kv => kv.key && kv.value);
if (validKeyValues.length === 0) {
enqueueSnackbar('At least one key-value pair is required', { variant: 'error' });
return;
}
setEncrypting(true);
try {
// 1. Fetch the controller's public certificate
const config = getPluginConfig();
const pemCert = await fetchPublicCertificate(config);
// 2. Parse the public key
const publicKey = parsePublicKeyFromCert(pemCert);
// 3. Encrypt all values client-side
const encryptedData = encryptKeyValues(
publicKey,
validKeyValues.map(kv => ({ key: kv.key, value: kv.value })),
namespace,
name,
scope
);
// 4. Construct the SealedSecret object
const sealedSecretData: any = {
apiVersion: 'bitnami.com/v1alpha1',
kind: 'SealedSecret',
metadata: {
name,
namespace,
annotations: {},
},
spec: {
encryptedData,
template: {
metadata: {},
},
},
};
// Add scope annotations
if (scope === 'namespace-wide') {
sealedSecretData.metadata.annotations['sealedsecrets.bitnami.com/namespace-wide'] = 'true';
} else if (scope === 'cluster-wide') {
sealedSecretData.metadata.annotations['sealedsecrets.bitnami.com/cluster-wide'] = 'true';
}
// 5. Apply to the cluster
await SealedSecret.apiEndpoint.post(sealedSecretData);
enqueueSnackbar('SealedSecret created successfully', { variant: 'success' });
// Clear form and close
setName('');
setNamespace('default');
setScope('strict');
setKeyValues([{ key: '', value: '', showValue: false }]);
onClose();
} catch (error: any) {
enqueueSnackbar(`Failed to create SealedSecret: ${error.message}`, { variant: 'error' });
} finally {
setEncrypting(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>Create Sealed Secret</DialogTitle>
<DialogContent>
<Box sx={{ pt: 2 }}>
<TextField
fullWidth
label="Secret Name"
value={name}
onChange={e => setName(e.target.value)}
margin="normal"
required
/>
<FormControl fullWidth margin="normal" required>
<InputLabel>Namespace</InputLabel>
<Select
value={namespace}
label="Namespace"
onChange={e => setNamespace(e.target.value)}
>
{namespaces?.map(ns => (
<MenuItem key={ns.metadata.name} value={ns.metadata.name}>
{ns.metadata.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth margin="normal" required>
<InputLabel>Scope</InputLabel>
<Select value={scope} label="Scope" onChange={e => setScope(e.target.value as SealedSecretScope)}>
<MenuItem value="strict">Strict (name + namespace bound)</MenuItem>
<MenuItem value="namespace-wide">Namespace-wide (namespace bound)</MenuItem>
<MenuItem value="cluster-wide">Cluster-wide (no binding)</MenuItem>
</Select>
</FormControl>
<Typography variant="h6" sx={{ mt: 3, mb: 2 }}>
Secret Data
</Typography>
{keyValues.map((kv, index) => (
<Box key={index} sx={{ display: 'flex', gap: 1, mb: 2, alignItems: 'flex-start' }}>
<TextField
label="Key Name"
value={kv.key}
onChange={e => handleKeyChange(index, e.target.value)}
sx={{ flex: 1 }}
/>
<TextField
label="Secret Value"
type={kv.showValue ? 'text' : 'password'}
value={kv.value}
onChange={e => handleValueChange(index, e.target.value)}
sx={{ flex: 2 }}
InputProps={{
endAdornment: (
<IconButton onClick={() => toggleShowValue(index)} edge="end">
{kv.showValue ? <VisibilityOff /> : <Visibility />}
</IconButton>
),
}}
/>
<IconButton
onClick={() => handleRemoveKeyValue(index)}
disabled={keyValues.length === 1}
color="error"
>
<DeleteIcon />
</IconButton>
</Box>
))}
<Button startIcon={<AddIcon />} onClick={handleAddKeyValue}>
Add Another Key
</Button>
<Box sx={{ mt: 2, p: 2, bgcolor: 'info.light', borderRadius: 1 }}>
<Typography variant="body2">
<strong>Security Note:</strong> Secret values are encrypted entirely in your browser
using the controller's public key. The plaintext values never leave your machine.
</Typography>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={encrypting}>
Cancel
</Button>
<Button onClick={handleCreate} variant="contained" disabled={encrypting}>
{encrypting ? 'Encrypting & Creating...' : 'Create'}
</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,265 @@
/**
* SealedSecret Detail View
*
* Shows detailed information about a specific SealedSecret including
* encrypted data, template, resulting Secret, and actions
*/
import { Link, Loader } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import {
ActionButton,
NameValueTable,
SectionBox,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { K8s } from '@kinvolk/headlamp-plugin/lib';
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material';
import { useSnackbar } from 'notistack';
import React from 'react';
import { useParams } from 'react-router-dom';
import { SealedSecret } from '../lib/SealedSecretCRD';
import { getPluginConfig, rotateSealedSecret } from '../lib/controller';
import { SealedSecretScope } from '../types';
import { DecryptDialog } from './DecryptDialog';
/**
* Format scope for display
*/
function formatScope(scope: SealedSecretScope): string {
switch (scope) {
case 'strict':
return 'Strict';
case 'namespace-wide':
return 'Namespace-wide';
case 'cluster-wide':
return 'Cluster-wide';
default:
return scope;
}
}
/**
* SealedSecret detail view component
*/
export function SealedSecretDetail() {
const { namespace, name } = useParams<{ namespace: string; name: string }>();
const [sealedSecret] = SealedSecret.useGet(name, namespace);
const [secret] = K8s.ResourceClasses.Secret.useGet(name, namespace);
const [decryptKey, setDecryptKey] = React.useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
const [rotating, setRotating] = React.useState(false);
const { enqueueSnackbar } = useSnackbar();
if (!sealedSecret) {
return <Loader title="Loading SealedSecret..." />;
}
const handleDelete = async () => {
try {
await sealedSecret.delete();
enqueueSnackbar('SealedSecret deleted successfully', { variant: 'success' });
window.history.back();
} catch (error: any) {
enqueueSnackbar(`Failed to delete SealedSecret: ${error.message}`, { variant: 'error' });
}
setDeleteDialogOpen(false);
};
const handleRotate = async () => {
setRotating(true);
try {
const config = getPluginConfig();
const yaml = JSON.stringify(sealedSecret.jsonData);
await rotateSealedSecret(config, yaml);
enqueueSnackbar('SealedSecret re-encrypted successfully', { variant: 'success' });
// The resource will auto-refresh via the watch
} catch (error: any) {
enqueueSnackbar(`Failed to re-encrypt: ${error.message}`, { variant: 'error' });
} finally {
setRotating(false);
}
};
const encryptedKeys = Object.keys(sealedSecret.spec.encryptedData || {});
return (
<>
<Box>
<SectionBox
title={
<Box display="flex" alignItems="center" justifyContent="space-between">
<span>{sealedSecret.metadata.name}</span>
<Box>
<Button
variant="outlined"
onClick={handleRotate}
disabled={rotating}
sx={{ mr: 1 }}
>
{rotating ? 'Re-encrypting...' : 'Re-encrypt'}
</Button>
<Button
variant="outlined"
color="error"
onClick={() => setDeleteDialogOpen(true)}
>
Delete
</Button>
</Box>
</Box>
}
>
<NameValueTable
rows={[
{
name: 'Name',
value: sealedSecret.metadata.name,
},
{
name: 'Namespace',
value: sealedSecret.metadata.namespace,
},
{
name: 'Scope',
value: formatScope(sealedSecret.scope),
},
{
name: 'Sync Status',
value: (
<StatusLabel status={sealedSecret.isSynced ? 'success' : 'error'}>
{sealedSecret.isSynced ? 'Synced' : 'Not Synced'}
</StatusLabel>
),
},
{
name: 'Status Message',
value: sealedSecret.syncMessage,
hide: !sealedSecret.syncCondition,
},
{
name: 'Age',
value: sealedSecret.getAge(),
},
{
name: 'Created',
value: new Date(sealedSecret.metadata.creationTimestamp!).toLocaleString(),
},
]}
/>
</SectionBox>
<SectionBox title="Encrypted Data">
<SimpleTable
data={encryptedKeys.map(key => ({
key,
value: sealedSecret.spec.encryptedData[key],
}))}
columns={[
{
label: 'Key',
getter: (row: any) => row.key,
},
{
label: 'Encrypted Value',
getter: (row: any) => {
const val = row.value;
return val.length > 40 ? val.substring(0, 40) + '...' : val;
},
},
{
label: 'Actions',
getter: (row: any) => (
<Button size="small" onClick={() => setDecryptKey(row.key)}>
Decrypt
</Button>
),
},
]}
/>
</SectionBox>
{sealedSecret.spec.template && (
<SectionBox title="Template">
<NameValueTable
rows={[
{
name: 'Secret Type',
value: sealedSecret.spec.template.type || 'Opaque',
},
{
name: 'Labels',
value: JSON.stringify(sealedSecret.spec.template.metadata?.labels || {}),
hide: !sealedSecret.spec.template.metadata?.labels,
},
{
name: 'Annotations',
value: JSON.stringify(sealedSecret.spec.template.metadata?.annotations || {}),
hide: !sealedSecret.spec.template.metadata?.annotations,
},
]}
/>
</SectionBox>
)}
<SectionBox title="Resulting Secret">
{secret ? (
<NameValueTable
rows={[
{
name: 'Status',
value: <StatusLabel status="success">Secret exists</StatusLabel>,
},
{
name: 'Keys',
value: Object.keys(secret.data || {}).join(', '),
},
{
name: 'Link',
value: (
<Link
routeName="secret"
params={{
namespace: secret.metadata.namespace,
name: secret.metadata.name,
}}
>
View Secret
</Link>
),
},
]}
/>
) : (
<Box p={2}>
<StatusLabel status="warning">Secret not yet created</StatusLabel>
<p>The controller will create the Secret once it processes this SealedSecret.</p>
</Box>
)}
</SectionBox>
</Box>
{decryptKey && (
<DecryptDialog
sealedSecret={sealedSecret}
secretKey={decryptKey}
onClose={() => setDecryptKey(null)}
/>
)}
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
<DialogTitle>Delete SealedSecret?</DialogTitle>
<DialogContent>
Are you sure you want to delete the SealedSecret <strong>{name}</strong>? This will also
delete the resulting Kubernetes Secret.
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button onClick={handleDelete} color="error">
Delete
</Button>
</DialogActions>
</Dialog>
</>
);
}
@@ -0,0 +1,141 @@
/**
* SealedSecrets List View
*
* Displays all SealedSecrets in the cluster with filtering and navigation
*/
import { Link } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import {
SectionBox,
SectionFilterHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { Box, Button } from '@mui/material';
import React from 'react';
import { SealedSecret } from '../lib/SealedSecretCRD';
import { SealedSecretScope } from '../types';
import { EncryptDialog } from './EncryptDialog';
/**
* Format scope for display
*/
function formatScope(scope: SealedSecretScope): string {
switch (scope) {
case 'strict':
return 'Strict';
case 'namespace-wide':
return 'Namespace-wide';
case 'cluster-wide':
return 'Cluster-wide';
default:
return scope;
}
}
/**
* SealedSecrets list view component
*/
export function SealedSecretList() {
const [sealedSecrets, error] = SealedSecret.useList();
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
// Show error if CRD is not installed
if (error) {
return (
<SectionBox
title="Sealed Secrets"
>
<Box p={2}>
<StatusLabel status="error">Error</StatusLabel>
<Box mt={2}>
{error.message.includes('404') ? (
<>
<p>
Sealed Secrets CRD not found. Please ensure Sealed Secrets is installed on your
cluster.
</p>
<p>
Install with: <code>kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.24.0/controller.yaml</code>
</p>
</>
) : (
<p>Failed to load Sealed Secrets: {error.message}</p>
)}
</Box>
</Box>
</SectionBox>
);
}
return (
<>
<SectionBox
title="Sealed Secrets"
>
<SectionFilterHeader
title=""
noNamespaceFilter={false}
actions={[
<Button
key="create"
variant="contained"
color="primary"
onClick={() => setCreateDialogOpen(true)}
>
Create Sealed Secret
</Button>,
]}
/>
<SimpleTable
data={sealedSecrets}
columns={[
{
label: 'Name',
getter: (ss: SealedSecret) => (
<Link
routeName="sealedsecret"
params={{
namespace: ss.metadata.namespace,
name: ss.metadata.name,
}}
>
{ss.metadata.name}
</Link>
),
},
{
label: 'Namespace',
getter: (ss: SealedSecret) => ss.metadata.namespace,
},
{
label: 'Encrypted Keys',
getter: (ss: SealedSecret) => ss.encryptedKeysCount,
},
{
label: 'Scope',
getter: (ss: SealedSecret) => formatScope(ss.scope),
},
{
label: 'Sync Status',
getter: (ss: SealedSecret) => (
<StatusLabel status={ss.isSynced ? 'success' : 'error'}>
{ss.isSynced ? 'Synced' : 'Not Synced'}
</StatusLabel>
),
},
{
label: 'Age',
getter: (ss: SealedSecret) => ss.getAge(),
},
]}
/>
</SectionBox>
<EncryptDialog
open={createDialogOpen}
onClose={() => setCreateDialogOpen(false)}
/>
</>
);
}
@@ -0,0 +1,159 @@
/**
* Sealing Keys Management View
*
* Lists all sealing key pairs (TLS Secrets) used by the controller
*/
import { SectionBox, SimpleTable, StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { K8s } from '@kinvolk/headlamp-plugin/lib';
import { Box, Button } from '@mui/material';
import { useSnackbar } from 'notistack';
import React from 'react';
import forge from 'node-forge';
import { fetchPublicCertificate, getPluginConfig } from '../lib/controller';
interface SealingKey {
name: string;
status: 'active' | 'compromised';
created: string;
notBefore?: string;
notAfter?: string;
}
/**
* Parse certificate dates from TLS secret
*/
function parseCertificateDates(certPem: string): { notBefore?: string; notAfter?: string } {
try {
const cert = forge.pki.certificateFromPem(certPem);
return {
notBefore: cert.validity.notBefore.toISOString(),
notAfter: cert.validity.notAfter.toISOString(),
};
} catch {
return {};
}
}
/**
* Sealing keys management view
*/
export function SealingKeysView() {
const config = getPluginConfig();
const [secrets] = K8s.ResourceClasses.Secret.useList({ namespace: config.controllerNamespace });
const { enqueueSnackbar } = useSnackbar();
// Filter for sealing key secrets
const sealingKeys: SealingKey[] = React.useMemo(() => {
if (!secrets) return [];
return secrets
.filter(secret => {
const labelValue = secret.metadata.labels?.['sealedsecrets.bitnami.com/sealed-secrets-key'];
return labelValue === 'active' || labelValue === 'compromised';
})
.map(secret => {
const status = secret.metadata.labels?.['sealedsecrets.bitnami.com/sealed-secrets-key'] as
| 'active'
| 'compromised';
const certPem = secret.data?.['tls.crt'] ? atob(secret.data['tls.crt']) : '';
const dates = certPem ? parseCertificateDates(certPem) : {};
return {
name: secret.metadata.name!,
status,
created: secret.metadata.creationTimestamp!,
...dates,
};
})
.sort((a, b) => {
// Sort active keys first, then by creation date (newest first)
if (a.status !== b.status) {
return a.status === 'active' ? -1 : 1;
}
return new Date(b.created).getTime() - new Date(a.created).getTime();
});
}, [secrets]);
const handleDownloadCert = async () => {
try {
const cert = await fetchPublicCertificate(config);
const blob = new Blob([cert], { type: 'application/x-pem-file' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sealed-secrets-cert.pem';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
enqueueSnackbar('Certificate downloaded', { variant: 'success' });
} catch (error: any) {
enqueueSnackbar(`Failed to download certificate: ${error.message}`, { variant: 'error' });
}
};
return (
<SectionBox
title="Sealing Keys"
headerProps={{
actions: [
<Button key="download" variant="contained" onClick={handleDownloadCert}>
Download Public Certificate
</Button>,
],
}}
>
{sealingKeys.length === 0 ? (
<Box p={2}>
<p>No sealing keys found in namespace {config.controllerNamespace}.</p>
<p>
Ensure the Sealed Secrets controller is installed and running in the{' '}
<strong>{config.controllerNamespace}</strong> namespace.
</p>
</Box>
) : (
<>
<Box p={2}>
<p>
Sealing keys are TLS key pairs used by the controller to decrypt SealedSecrets. The
active key is used for new encryptions. Old keys are kept to decrypt existing
SealedSecrets.
</p>
</Box>
<SimpleTable
data={sealingKeys}
columns={[
{
label: 'Key Name',
getter: (key: SealingKey) => key.name,
},
{
label: 'Status',
getter: (key: SealingKey) => (
<StatusLabel status={key.status === 'active' ? 'success' : 'warning'}>
{key.status === 'active' ? 'Active' : 'Compromised'}
</StatusLabel>
),
},
{
label: 'Created',
getter: (key: SealingKey) => new Date(key.created).toLocaleString(),
},
{
label: 'Valid From',
getter: (key: SealingKey) =>
key.notBefore ? new Date(key.notBefore).toLocaleString() : 'N/A',
},
{
label: 'Valid Until',
getter: (key: SealingKey) =>
key.notAfter ? new Date(key.notAfter).toLocaleString() : 'N/A',
},
]}
/>
</>
)}
</SectionBox>
);
}
@@ -0,0 +1,71 @@
/**
* Secret Details Section
*
* Additional section shown in the Secret detail view if the Secret
* is owned by a SealedSecret
*/
import { Link } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { NameValueTable, SectionBox, StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import { SealedSecret } from '../lib/SealedSecretCRD';
interface SecretDetailsSectionProps {
resource: any; // The Secret resource
}
/**
* Secret details section component
*/
export function SecretDetailsSection({ resource }: SecretDetailsSectionProps) {
// Check if this Secret is owned by a SealedSecret
const ownerRef = resource.metadata?.ownerReferences?.find(
(ref: any) => ref.kind === 'SealedSecret' && ref.apiVersion === 'bitnami.com/v1alpha1'
);
if (!ownerRef) {
return null;
}
// Fetch the parent SealedSecret
const [sealedSecret] = SealedSecret.useGet(ownerRef.name, resource.metadata.namespace);
return (
<SectionBox title="Sealed Secret">
{sealedSecret ? (
<NameValueTable
rows={[
{
name: 'Parent SealedSecret',
value: (
<Link
routeName="sealedsecret"
params={{
namespace: sealedSecret.metadata.namespace,
name: sealedSecret.metadata.name,
}}
>
{sealedSecret.metadata.name}
</Link>
),
},
{
name: 'Scope',
value: sealedSecret.scope,
},
{
name: 'Sync Status',
value: (
<StatusLabel status={sealedSecret.isSynced ? 'success' : 'error'}>
{sealedSecret.isSynced ? 'Synced' : 'Not Synced'}
</StatusLabel>
),
},
]}
/>
) : (
<p>Loading SealedSecret information...</p>
)}
</SectionBox>
);
}
@@ -0,0 +1,97 @@
/**
* Settings Page
*
* Configuration page for the Sealed Secrets plugin
*/
import { SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { Box, Button, TextField, Typography } from '@mui/material';
import { useSnackbar } from 'notistack';
import React from 'react';
import { getPluginConfig, savePluginConfig } from '../lib/controller';
import { PluginConfig } from '../types';
/**
* Settings page component
*/
export function SettingsPage() {
const [config, setConfig] = React.useState<PluginConfig>(getPluginConfig());
const { enqueueSnackbar } = useSnackbar();
const handleSave = () => {
savePluginConfig(config);
enqueueSnackbar('Settings saved successfully', { variant: 'success' });
};
const handleReset = () => {
const defaultConfig: PluginConfig = {
controllerName: 'sealed-secrets-controller',
controllerNamespace: 'kube-system',
controllerPort: 8080,
};
setConfig(defaultConfig);
};
return (
<SectionBox
title="Sealed Secrets Plugin Settings"
>
<Box p={3}>
<Typography variant="body1" paragraph>
Configure the connection to your Sealed Secrets controller. These settings are stored in
your browser's local storage.
</Typography>
<TextField
fullWidth
label="Controller Name"
value={config.controllerName}
onChange={e => setConfig({ ...config, controllerName: e.target.value })}
margin="normal"
helperText="Name of the sealed-secrets-controller deployment/service"
/>
<TextField
fullWidth
label="Controller Namespace"
value={config.controllerNamespace}
onChange={e => setConfig({ ...config, controllerNamespace: e.target.value })}
margin="normal"
helperText="Namespace where the controller is installed"
/>
<TextField
fullWidth
label="Controller Port"
type="number"
value={config.controllerPort}
onChange={e => setConfig({ ...config, controllerPort: parseInt(e.target.value, 10) })}
margin="normal"
helperText="HTTP port of the controller service"
/>
<Box mt={3} display="flex" gap={2}>
<Button variant="contained" onClick={handleSave}>
Save Settings
</Button>
<Button variant="outlined" onClick={handleReset}>
Reset to Defaults
</Button>
</Box>
<Box mt={4} p={2} bgcolor="info.light" borderRadius={1}>
<Typography variant="h6" gutterBottom>
Default Values
</Typography>
<Typography variant="body2">
<strong>Controller Name:</strong> sealed-secrets-controller
<br />
<strong>Controller Namespace:</strong> kube-system
<br />
<strong>Controller Port:</strong> 8080
</Typography>
</Box>
</Box>
</SectionBox>
);
}
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2025 The Kubernetes Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference types="@kinvolk/headlamp-plugin" />
+113
View File
@@ -0,0 +1,113 @@
/**
* Headlamp Sealed Secrets Plugin
*
* A comprehensive plugin for managing Bitnami Sealed Secrets in Kubernetes.
* Provides UI for viewing, creating, and managing encrypted secrets.
*
* Features:
* - List and detail views for SealedSecrets
* - Client-side encryption using controller's public key
* - Sealing keys management
* - Secret decryption (via K8s Secret access)
* - Integration with Headlamp's Secret detail view
*
* @see https://github.com/bitnami-labs/sealed-secrets
*/
import {
registerDetailsViewSection,
registerRoute,
registerSidebarEntry,
} from '@kinvolk/headlamp-plugin/lib';
import React from 'react';
import { SealedSecretDetail } from './components/SealedSecretDetail';
import { SealedSecretList } from './components/SealedSecretList';
import { SealingKeysView } from './components/SealingKeysView';
import { SecretDetailsSection } from './components/SecretDetailsSection';
import { SettingsPage } from './components/SettingsPage';
/**
* Register sidebar navigation
*/
// Main "Sealed Secrets" entry
registerSidebarEntry({
parent: null,
name: 'sealed-secrets',
label: 'Sealed Secrets',
icon: 'mdi:lock',
url: '/sealedsecrets',
});
// "All Sealed Secrets" child entry
registerSidebarEntry({
parent: 'sealed-secrets',
name: 'sealed-secrets-list',
label: 'All Sealed Secrets',
url: '/sealedsecrets',
});
// "Sealing Keys" child entry
registerSidebarEntry({
parent: 'sealed-secrets',
name: 'sealing-keys',
label: 'Sealing Keys',
url: '/sealedsecrets/keys',
});
// "Settings" child entry
registerSidebarEntry({
parent: 'sealed-secrets',
name: 'sealed-secrets-settings',
label: 'Settings',
url: '/sealedsecrets/settings',
});
/**
* Register routes
*/
// List view
registerRoute({
path: '/sealedsecrets',
sidebar: 'sealed-secrets-list',
component: () => <SealedSecretList />,
exact: true,
});
// Detail view
registerRoute({
path: '/sealedsecrets/:namespace/:name',
sidebar: 'sealed-secrets-list',
component: () => <SealedSecretDetail />,
exact: true,
name: 'sealedsecret',
});
// Sealing keys view
registerRoute({
path: '/sealedsecrets/keys',
sidebar: 'sealing-keys',
component: () => <SealingKeysView />,
exact: true,
});
// Settings page
registerRoute({
path: '/sealedsecrets/settings',
sidebar: 'sealed-secrets-settings',
component: () => <SettingsPage />,
exact: true,
});
/**
* Register integration with Secret detail view
*
* Adds a "Sealed Secret" section to Secrets that are owned by SealedSecrets
*/
registerDetailsViewSection(({ resource }) => {
if (resource?.kind === 'Secret') {
return <SecretDetailsSection resource={resource} />;
}
return null;
});
@@ -0,0 +1,93 @@
/**
* SealedSecret Custom Resource Definition
*/
import { apiFactoryWithNamespace } from '@kinvolk/headlamp-plugin/lib/lib/k8s/apiProxy';
import { KubeObject } from '@kinvolk/headlamp-plugin/lib/lib/k8s/cluster';
import {
SealedSecretInterface,
SealedSecretScope,
SealedSecretSpec,
SealedSecretStatus,
} from '../types';
/**
* SealedSecret CRD class
* Represents a Bitnami Sealed Secret resource in the cluster
*/
export class SealedSecret extends KubeObject<SealedSecretInterface> {
/**
* API endpoint for SealedSecret resources
* bitnami.com/v1alpha1/sealedsecrets
*/
static apiEndpoint = apiFactoryWithNamespace('bitnami.com', 'v1alpha1', 'sealedsecrets');
/**
* Class name used for Headlamp registration
*/
static get className(): string {
return 'SealedSecret';
}
/**
* Get the SealedSecret spec
*/
get spec(): SealedSecretSpec {
return this.jsonData.spec;
}
/**
* Get the SealedSecret status
*/
get status(): SealedSecretStatus | undefined {
return this.jsonData.status;
}
/**
* Get the scope of this SealedSecret (strict, namespace-wide, or cluster-wide)
*/
get scope(): SealedSecretScope {
const annotations = this.metadata.annotations || {};
if (annotations['sealedsecrets.bitnami.com/cluster-wide'] === 'true') {
return 'cluster-wide';
}
if (annotations['sealedsecrets.bitnami.com/namespace-wide'] === 'true') {
return 'namespace-wide';
}
return 'strict';
}
/**
* Get the count of encrypted keys
*/
get encryptedKeysCount(): number {
return Object.keys(this.spec.encryptedData || {}).length;
}
/**
* Check if the SealedSecret is synced
*/
get isSynced(): boolean {
const syncCondition = this.status?.conditions?.find(c => c.type === 'Synced');
return syncCondition?.status === 'True';
}
/**
* Get the sync status condition
*/
get syncCondition() {
return this.status?.conditions?.find(c => c.type === 'Synced');
}
/**
* Get the sync status message
*/
get syncMessage(): string {
const condition = this.syncCondition;
if (!condition) {
return 'Unknown';
}
return condition.message || condition.reason || condition.status;
}
}
@@ -0,0 +1,126 @@
/**
* Sealed Secrets Controller API helpers
*
* Utilities for interacting with the sealed-secrets-controller HTTP API
* via the Kubernetes API proxy.
*/
import { request } from '@kinvolk/headlamp-plugin/lib/lib/k8s/apiProxy';
import { PluginConfig } from '../types';
/**
* Build the controller proxy URL
*/
export function getControllerProxyURL(config: PluginConfig, path: string): string {
const { controllerNamespace, controllerName, controllerPort } = config;
return `/api/v1/namespaces/${controllerNamespace}/services/http:${controllerName}:${controllerPort}/proxy${path}`;
}
/**
* Fetch the controller's public certificate
*
* @param config Plugin configuration
* @returns PEM-encoded certificate
*/
export async function fetchPublicCertificate(config: PluginConfig): Promise<string> {
const url = getControllerProxyURL(config, '/v1/cert.pem');
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch certificate: ${response.status} ${response.statusText}`);
}
const cert = await response.text();
return cert;
} catch (error) {
throw new Error(`Unable to fetch controller certificate: ${error}`);
}
}
/**
* Verify that a SealedSecret can be decrypted by the controller
*
* @param config Plugin configuration
* @param sealedSecretYaml YAML or JSON of the SealedSecret
*/
export async function verifySealedSecret(
config: PluginConfig,
sealedSecretYaml: string
): Promise<boolean> {
const url = getControllerProxyURL(config, '/v1/verify');
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: sealedSecretYaml,
});
return response.ok;
} catch (error) {
console.error('Verification failed:', error);
return false;
}
}
/**
* Rotate (re-encrypt) a SealedSecret with the current active key
*
* @param config Plugin configuration
* @param sealedSecretYaml YAML or JSON of the SealedSecret
* @returns The re-encrypted SealedSecret
*/
export async function rotateSealedSecret(
config: PluginConfig,
sealedSecretYaml: string
): Promise<string> {
const url = getControllerProxyURL(config, '/v1/rotate');
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: sealedSecretYaml,
});
if (!response.ok) {
throw new Error(`Rotation failed: ${response.status} ${response.statusText}`);
}
return await response.text();
} catch (error) {
throw new Error(`Unable to rotate SealedSecret: ${error}`);
}
}
/**
* Get plugin configuration from localStorage
*/
export function getPluginConfig(): PluginConfig {
const stored = localStorage.getItem('sealed-secrets-plugin-config');
if (stored) {
try {
return JSON.parse(stored);
} catch {
// Fall through to default
}
}
// Return default config
return {
controllerName: 'sealed-secrets-controller',
controllerNamespace: 'kube-system',
controllerPort: 8080,
};
}
/**
* Save plugin configuration to localStorage
*/
export function savePluginConfig(config: PluginConfig): void {
localStorage.setItem('sealed-secrets-plugin-config', JSON.stringify(config));
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Client-side encryption utilities for Sealed Secrets
*
* This module handles the encryption of secret values using the sealed-secrets
* controller's public key. The encryption process matches the kubeseal CLI tool:
*
* 1. Generate a random AES-256-GCM session key
* 2. Encrypt the secret value with the session key
* 3. Encrypt the session key with the RSA public key (OAEP + SHA-256)
* 4. Construct the payload: 2-byte length prefix + encrypted session key + encrypted data
* 5. Base64-encode the result
*/
import forge from 'node-forge';
import { SealedSecretScope } from '../types';
/**
* Parse a PEM certificate and extract the RSA public key
*/
export function parsePublicKeyFromCert(pemCert: string): forge.pki.rsa.PublicKey {
try {
const cert = forge.pki.certificateFromPem(pemCert);
const publicKey = cert.publicKey as forge.pki.rsa.PublicKey;
return publicKey;
} catch (error) {
throw new Error(`Failed to parse certificate: ${error}`);
}
}
/**
* Encrypt a secret value using the kubeseal format
*
* @param publicKey RSA public key from the controller's certificate
* @param value The plaintext secret value to encrypt
* @param namespace The namespace (for strict/namespace-wide scoping)
* @param name The secret name (for strict scoping)
* @param key The key name within the secret
* @param scope The encryption scope
* @returns Base64-encoded encrypted value
*/
export function encryptValue(
publicKey: forge.pki.rsa.PublicKey,
value: string,
namespace: string,
name: string,
key: string,
scope: SealedSecretScope
): string {
try {
// Generate a random 32-byte (256-bit) AES session key
const sessionKey = forge.random.getBytesSync(32);
// Construct the label for RSA-OAEP based on scope
// This binds the encryption to specific namespace/name/key depending on scope
let label = '';
if (scope === 'strict') {
// Strict scope: namespace.name.key
label = `${namespace}.${name}.${key}`;
} else if (scope === 'namespace-wide') {
// Namespace-wide scope: namespace.key
label = `${namespace}.${key}`;
} else {
// Cluster-wide scope: just the key
label = key;
}
// Encrypt the session key with RSA-OAEP (SHA-256)
const encryptedSessionKey = publicKey.encrypt(sessionKey, 'RSA-OAEP', {
md: forge.md.sha256.create(),
mgf1: {
md: forge.md.sha256.create(),
},
label: label,
});
// Encrypt the actual secret value with AES-256-GCM
const iv = forge.random.getBytesSync(12); // 12 bytes for GCM
const cipher = forge.cipher.createCipher('AES-GCM', sessionKey);
cipher.start({ iv: iv });
cipher.update(forge.util.createBuffer(value, 'utf8'));
cipher.finish();
const encryptedValue = cipher.output.getBytes();
const tag = (cipher.mode as any).tag.getBytes();
// Construct the sealed secret format:
// [2-byte length of encrypted session key][encrypted session key][IV][encrypted value][auth tag]
const sessionKeyLength = encryptedSessionKey.length;
const lengthBytes = String.fromCharCode((sessionKeyLength >> 8) & 0xff) +
String.fromCharCode(sessionKeyLength & 0xff);
const payload = lengthBytes + encryptedSessionKey + iv + encryptedValue + tag;
// Base64 encode the final payload
return forge.util.encode64(payload);
} catch (error) {
throw new Error(`Encryption failed: ${error}`);
}
}
/**
* Encrypt multiple key-value pairs for a SealedSecret
*
* @param publicKey RSA public key from the controller's certificate
* @param keyValues Array of {key, value} pairs to encrypt
* @param namespace The namespace
* @param name The secret name
* @param scope The encryption scope
* @returns Object mapping keys to encrypted values
*/
export function encryptKeyValues(
publicKey: forge.pki.rsa.PublicKey,
keyValues: Array<{ key: string; value: string }>,
namespace: string,
name: string,
scope: SealedSecretScope
): Record<string, string> {
const encryptedData: Record<string, string> = {};
for (const { key, value } of keyValues) {
encryptedData[key] = encryptValue(publicKey, value, namespace, name, key, scope);
}
return encryptedData;
}
/**
* Validate a PEM certificate
*/
export function validateCertificate(pemCert: string): boolean {
try {
parsePublicKeyFromCert(pemCert);
return true;
} catch {
return false;
}
}
+93
View File
@@ -0,0 +1,93 @@
/**
* TypeScript interfaces for Bitnami Sealed Secrets plugin
*/
import { KubeObjectInterface } from '@kinvolk/headlamp-plugin/lib/lib/k8s/cluster';
/**
* Sealed Secret scope types
*/
export type SealedSecretScope = 'strict' | 'namespace-wide' | 'cluster-wide';
/**
* SealedSecret CRD spec
*/
export interface SealedSecretSpec {
/** Map of key names to encrypted (base64-encoded) values */
encryptedData: Record<string, string>;
/** Metadata template for the resulting Secret */
template?: {
metadata?: {
labels?: Record<string, string>;
annotations?: Record<string, string>;
};
type?: string;
};
}
/**
* SealedSecret status condition
*/
export interface SealedSecretCondition {
type: string;
status: 'True' | 'False' | 'Unknown';
lastTransitionTime?: string;
lastUpdateTime?: string;
reason?: string;
message?: string;
}
/**
* SealedSecret CRD status
*/
export interface SealedSecretStatus {
conditions?: SealedSecretCondition[];
observedGeneration?: number;
}
/**
* Complete SealedSecret CRD interface
*/
export interface SealedSecretInterface extends KubeObjectInterface {
spec: SealedSecretSpec;
status?: SealedSecretStatus;
}
/**
* Plugin configuration stored in localStorage
*/
export interface PluginConfig {
/** Controller deployment name */
controllerName: string;
/** Controller namespace */
controllerNamespace: string;
/** Controller service port */
controllerPort: number;
}
/**
* Default plugin configuration
*/
export const DEFAULT_CONFIG: PluginConfig = {
controllerName: 'sealed-secrets-controller',
controllerNamespace: 'kube-system',
controllerPort: 8080,
};
/**
* Key-value pair for encryption dialog
*/
export interface SecretKeyValue {
key: string;
value: string;
}
/**
* Encryption request parameters
*/
export interface EncryptionRequest {
name: string;
namespace: string;
scope: SealedSecretScope;
keyValues: SecretKeyValue[];
}