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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user