mirror of
https://github.com/house-of-vanity/k8s-secrets.git
synced 2026-11-08 15:28:50 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61b0ebff0c | ||
|
|
603e263959 |
No files matched your search
Generated
+1
-1
@@ -1860,7 +1860,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "secret-reader"
|
||||
version = "0.2.0"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"askama",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "secret-reader"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
+161
-236
@@ -5,259 +5,184 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Secrets</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;
|
||||
margin: 20px;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.secret {
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
}
|
||||
.secret.expired {
|
||||
border-color: #e74c3c;
|
||||
background: #fdf2f2;
|
||||
}
|
||||
.secret-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.secret-name {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
.source-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.source-badge.k8s {
|
||||
background: #326ce5;
|
||||
color: white;
|
||||
}
|
||||
.source-badge.webhook {
|
||||
background: #6c5ce7;
|
||||
color: white;
|
||||
}
|
||||
.expired-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
.data-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 8px 0;
|
||||
padding: 8px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.data-key {
|
||||
display: inline-block;
|
||||
min-width: 150px;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.data-value-wrapper {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.data-value {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: #fff;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 3px;
|
||||
word-break: break-all;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.copy-btn {
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.copy-btn:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
.copy-btn.copied {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
border-color: #4caf50;
|
||||
}
|
||||
.totp-code {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
font-family: 'Courier New', monospace;
|
||||
letter-spacing: 3px;
|
||||
color: #007bff;
|
||||
padding: 8px 12px;
|
||||
background: #e8f4ff;
|
||||
border: 1px solid #007bff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.totp-timer {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.received-at {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-top: 5px;
|
||||
font-style: italic;
|
||||
}
|
||||
.error {
|
||||
background: #ffdddd;
|
||||
color: #cc0000;
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
:root { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #17233b; background: #f4f6fa; }
|
||||
* { box-sizing: border-box; }
|
||||
body { min-width: 320px; margin: 0; }
|
||||
button { font: inherit; }
|
||||
.page { width: min(100% - 32px, 1160px); margin: 0 auto; padding: 28px 0 48px; }
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
|
||||
h1 { margin: 0; font-size: 24px; font-weight: 720; letter-spacing: -.035em; line-height: 1.2; }
|
||||
.count { flex: none; padding: 6px 10px; border: 1px solid #dce3ed; border-radius: 999px; background: #fff; color: #52617c; font-size: 11px; font-weight: 650; white-space: nowrap; }
|
||||
.secrets-grid { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-start; }
|
||||
.secret-card { flex: 0 1 340px; min-width: min(100%, 290px); overflow: hidden; border: 1px solid #dfe5ef; border-radius: 12px; background: #fff; box-shadow: 0 4px 16px rgba(31, 48, 82, .04); }
|
||||
.secret-card.expired { border-color: #efc8c3; }
|
||||
.card-header { padding: 14px 14px 12px; border-bottom: 1px solid #edf0f5; }
|
||||
.card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
|
||||
.secret-name { min-width: 0; margin: 0; overflow-wrap: anywhere; font-size: 14px; font-weight: 700; letter-spacing: -.01em; line-height: 1.35; }
|
||||
.source-badge, .expired-badge { display: inline-flex; align-items: center; gap: 6px; width: fit-content; border-radius: 999px; font-size: 11px; font-weight: 700; line-height: 1; white-space: nowrap; }
|
||||
.source-badge { flex: none; padding: 5px 7px; }
|
||||
.source-badge.k8s { background: #eaf0ff; color: #315cb3; }
|
||||
.source-badge.webhook { background: #f0ebff; color: #6c47b6; }
|
||||
.expired-badge { margin-top: 8px; padding: 5px 7px; background: #fff0ed; color: #ad453b; }
|
||||
.fields { display: grid; gap: 7px; padding: 10px; }
|
||||
.field { min-width: 0; padding: 9px 10px; border: 1px solid #edf0f5; border-radius: 8px; background: #f8fafd; }
|
||||
.field-key { display: block; margin-bottom: 5px; color: #62718b; font-size: 10px; font-weight: 700; letter-spacing: .025em; overflow-wrap: anywhere; }
|
||||
.field-content { display: flex; align-items: center; gap: 7px; min-width: 0; }
|
||||
.field-value { display: block; flex: 1; min-width: 0; overflow-wrap: anywhere; color: #263751; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; line-height: 1.45; white-space: pre-wrap; }
|
||||
.totp-code { color: #325dc0; font-size: 21px; font-weight: 750; letter-spacing: .06em; line-height: 1.1; white-space: nowrap; }
|
||||
.totp-timer { flex: none; min-width: 24px; color: #6a7890; font-size: 10px; font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.copy-button { display: inline-flex; flex: none; align-items: center; justify-content: center; gap: 5px; min-height: 27px; padding: 0 7px; border: 1px solid #d8e0ec; border-radius: 6px; background: #fff; color: #445674; cursor: pointer; font-size: 10px; font-weight: 700; transition: background .15s, border-color .15s, color .15s; }
|
||||
.copy-button:hover { border-color: #a9bce2; background: #eef3ff; color: #315cb3; }
|
||||
.copy-button:focus-visible { outline: 3px solid #a9bce2; outline-offset: 2px; }
|
||||
.copy-button.copied { border-color: #b9ddcc; background: #eaf7ef; color: #237a4c; }
|
||||
.copy-button.failed { border-color: #efc8c3; background: #fff0ed; color: #ad453b; }
|
||||
.copy-button svg { width: 12px; height: 12px; }
|
||||
.card-footer { display: grid; gap: 3px; padding: 0 14px 12px; color: #73819a; font-size: 10px; line-height: 1.35; }
|
||||
.card-footer strong { color: #596b86; font-weight: 600; }
|
||||
.notice { padding: 18px; border: 1px solid #dfe5ef; border-radius: 12px; background: #fff; color: #60708b; font-size: 13px; }
|
||||
.notice.error { border-color: #efc8c3; background: #fff7f5; color: #a43c34; }
|
||||
@media (max-width: 560px) { .page { width: min(100% - 24px, 1160px); padding-top: 20px; } }
|
||||
@media (max-width: 390px) {
|
||||
.page-header { flex-wrap: wrap; }
|
||||
.field-content { flex-wrap: wrap; }
|
||||
.copy-button { margin-left: auto; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Secrets</h1>
|
||||
|
||||
{% if let Some(err) = error %}
|
||||
<div class="error">
|
||||
Error: {{ err }}
|
||||
</div>
|
||||
{% else %}
|
||||
{% for secret in secrets %}
|
||||
<div class="secret{% if secret.expired %} expired{% endif %}">
|
||||
<div class="secret-header">
|
||||
<div class="secret-name">{{ secret.name }}</div>
|
||||
{% match secret.source %}
|
||||
{% when SecretSource::Kubernetes %}
|
||||
<span class="source-badge k8s">☸ K8s</span>
|
||||
{% when SecretSource::Webhook %}
|
||||
<span class="source-badge webhook">⚡ Webhook</span>
|
||||
{% endmatch %}
|
||||
{% if secret.expired %}
|
||||
{% if let Some(expires) = secret.expires_at %}
|
||||
<span class="expired-badge">⚠ Expired at {{ expires }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% for (key, value) in secret.data %}
|
||||
<div class="data-item">
|
||||
<span class="data-key">{{ key }}:</span>
|
||||
<div class="data-value-wrapper">
|
||||
{% if value.starts_with("otpauth://totp/") %}
|
||||
<span class="totp-code" id="totp-{{ secret.name }}-{{ key }}">------</span>
|
||||
<span class="totp-timer" id="timer-{{ secret.name }}-{{ key }}"></span>
|
||||
<button class="copy-btn" onclick="copyTotp('{{ secret.name }}-{{ key }}')">📋</button>
|
||||
{% else %}
|
||||
<code class="data-value" id="value-{{ secret.name }}-{{ key }}">{{ value }}</code>
|
||||
<button class="copy-btn" onclick="copyValue('{{ secret.name }}-{{ key }}', '{{ value|escape }}')">📋</button>
|
||||
{% endif %}
|
||||
<main class="page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Secrets</h1>
|
||||
</div>
|
||||
<span class="count">{{ secrets.len() }} total</span>
|
||||
</header>
|
||||
|
||||
{% if let Some(err) = error %}
|
||||
<div class="notice error" role="alert">Failed to load secrets: {{ err }}</div>
|
||||
{% else %}
|
||||
{% if secrets.len() == 0 %}
|
||||
<div class="notice">No secrets to display.</div>
|
||||
{% else %}
|
||||
<div class="secrets-grid">
|
||||
{% for secret in secrets %}
|
||||
<article class="secret-card{% if secret.expired %} expired{% endif %}">
|
||||
<header class="card-header">
|
||||
<div class="card-heading">
|
||||
<h2 class="secret-name">{{ secret.name }}</h2>
|
||||
{% match secret.source %}
|
||||
{% when SecretSource::Kubernetes %}<span class="source-badge k8s">☸ Kubernetes</span>
|
||||
{% when SecretSource::Webhook %}<span class="source-badge webhook">↗ Webhook</span>
|
||||
{% endmatch %}
|
||||
</div>
|
||||
{% if secret.expired %}
|
||||
{% if let Some(expires) = secret.expires_at %}<span class="expired-badge">Expired: {{ expires }}</span>{% endif %}
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<div class="fields">
|
||||
{% for (key, value) in secret.data %}
|
||||
<div class="field">
|
||||
<span class="field-key">{{ key }}</span>
|
||||
<div class="field-content">
|
||||
{% if value.starts_with("otpauth://totp/") %}
|
||||
<span class="field-value totp-code" data-totp-url="{{ value }}" aria-label="One-time code">••• •••</span>
|
||||
<span class="totp-timer" aria-label="Seconds remaining"></span>
|
||||
{% else %}
|
||||
<code class="field-value">{{ value }}</code>
|
||||
{% endif %}
|
||||
<button class="copy-button" type="button" aria-label="Copy {{ key }}" title="Copy {{ key }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg>
|
||||
<span>Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if let Some(received) = secret.received_at %}
|
||||
<footer class="card-footer">
|
||||
<span>Received: <strong>{{ received }}</strong></span>
|
||||
{% if let Some(expires) = secret.expires_at %}
|
||||
{% if !secret.expired %}<span>Expires: <strong>{{ expires }}</strong></span>{% endif %}
|
||||
{% endif %}
|
||||
</footer>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if let Some(received) = secret.received_at %}
|
||||
<div class="received-at">Received: {{ received }}{% if let Some(expires) = secret.expires_at %}{% if !secret.expired %} · Expires: {{ expires }}{% endif %}{% endif %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</main>
|
||||
|
||||
<script src="https://unpkg.com/otpauth@9/dist/otpauth.umd.min.js"></script>
|
||||
<script>
|
||||
const otpUrls = [];
|
||||
const totpValues = {};
|
||||
|
||||
{% for secret in secrets %}
|
||||
{% for (key, value) in secret.data %}
|
||||
{% if value.starts_with("otpauth://totp/") %}
|
||||
otpUrls.push({
|
||||
id: 'totp-{{ secret.name }}-{{ key }}',
|
||||
timerId: 'timer-{{ secret.name }}-{{ key }}',
|
||||
url: {{ value|json|safe }},
|
||||
key: '{{ secret.name }}-{{ key }}'
|
||||
});
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
const totpFields = [...document.querySelectorAll('[data-totp-url]')];
|
||||
|
||||
function updateTOTP() {
|
||||
const now = Date.now();
|
||||
const timeRemaining = 30 - (Math.floor(now / 1000) % 30);
|
||||
|
||||
otpUrls.forEach(item => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
for (const codeElement of totpFields) {
|
||||
const timerElement = codeElement.nextElementSibling;
|
||||
try {
|
||||
const totp = OTPAuth.URI.parse(item.url);
|
||||
const token = totp.generate();
|
||||
totpValues[item.key] = token.toString().padStart(6, '0');
|
||||
const formatted = totpValues[item.key].match(/.{1,3}/g).join(' ');
|
||||
document.getElementById(item.id).textContent = formatted;
|
||||
document.getElementById(item.timerId).textContent = timeRemaining + 's';
|
||||
} catch (e) {
|
||||
document.getElementById(item.id).textContent = 'ERROR';
|
||||
console.error('Failed to generate TOTP:', e);
|
||||
const totp = OTPAuth.URI.parse(codeElement.dataset.totpUrl);
|
||||
const code = totp.generate();
|
||||
codeElement.dataset.copyValue = code;
|
||||
codeElement.textContent = code.replace(/(.{3})(?=.)/, '$1 ');
|
||||
timerElement.textContent = (totp.period - (now % totp.period)) + 's';
|
||||
} catch (error) {
|
||||
codeElement.textContent = 'Code error';
|
||||
timerElement.textContent = '';
|
||||
console.error('Failed to generate TOTP:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function copyValue(id, value) {
|
||||
const decodedValue = value.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||
navigator.clipboard.writeText(decodedValue).then(() => {
|
||||
const btn = event.target;
|
||||
btn.classList.add('copied');
|
||||
btn.textContent = '✓';
|
||||
setTimeout(() => {
|
||||
btn.classList.remove('copied');
|
||||
btn.textContent = '📋';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function copyTotp(key) {
|
||||
const code = totpValues[key];
|
||||
if (code) {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
const btn = event.target;
|
||||
btn.classList.add('copied');
|
||||
btn.textContent = '✓';
|
||||
setTimeout(() => {
|
||||
btn.classList.remove('copied');
|
||||
btn.textContent = '📋';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (otpUrls.length > 0) {
|
||||
|
||||
async function writeClipboard(value) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
}
|
||||
const input = document.createElement('textarea');
|
||||
input.value = value;
|
||||
input.style.position = 'fixed';
|
||||
input.style.opacity = '0';
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
try {
|
||||
if (!document.execCommand('copy')) throw new Error('Clipboard unavailable');
|
||||
} finally {
|
||||
input.remove();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', async (event) => {
|
||||
const button = event.target.closest('.copy-button');
|
||||
if (!button) return;
|
||||
const valueElement = button.closest('.field').querySelector('.field-value');
|
||||
const value = valueElement.dataset.totpUrl ? valueElement.dataset.copyValue : valueElement.textContent;
|
||||
if (!value) return;
|
||||
const label = button.querySelector('span');
|
||||
clearTimeout(button.resetTimeout);
|
||||
button.classList.remove('copied', 'failed');
|
||||
try {
|
||||
await writeClipboard(value);
|
||||
button.classList.add('copied');
|
||||
label.textContent = 'Copied';
|
||||
} catch (error) {
|
||||
button.classList.add('failed');
|
||||
label.textContent = 'Error';
|
||||
console.error('Failed to copy value:', error);
|
||||
}
|
||||
button.resetTimeout = setTimeout(() => {
|
||||
button.classList.remove('copied', 'failed');
|
||||
label.textContent = 'Copy';
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
if (totpFields.length > 0) {
|
||||
updateTOTP();
|
||||
setInterval(updateTOTP, 1000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
Reference in new issue
Block a user