mirror of
https://github.com/house-of-vanity/k8s-secrets.git
synced 2026-02-04 01:37:57 +00:00
first commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
2395
Cargo.lock
generated
Normal file
2395
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
Cargo.toml
Normal file
17
Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "secret-reader"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.7"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
kube = { version = "0.95", features = ["runtime", "derive"] }
|
||||
k8s-openapi = { version = "0.23", features = ["latest"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
askama = { version = "0.12", features = ["serde-json"] }
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
anyhow = "1.0"
|
||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
FROM rust:1.83-bookworm as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
RUN mkdir src && echo "fn main() {}" > src/main.rs
|
||||
RUN cargo build --release
|
||||
RUN rm -rf src
|
||||
|
||||
COPY . .
|
||||
RUN touch src/main.rs
|
||||
RUN cargo build --release
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/target/release/secret-reader /usr/local/bin/secret-reader
|
||||
COPY --from=builder /app/templates /templates
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
USER 1000
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/secret-reader"]
|
||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Secret Reader
|
||||
|
||||
Kubernetes secret viewer with TOTP support.
|
||||
|
||||
## Features
|
||||
|
||||
- View Kubernetes secrets in web UI
|
||||
- Auto-generate TOTP codes from otpauth:// URLs
|
||||
- Copy values with one click
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
kubectl apply -f service-account.yaml
|
||||
kubectl apply -f rbac.yaml
|
||||
kubectl apply -f deployment.yaml
|
||||
kubectl apply -f service.yaml
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `deployment.yaml` to specify which secrets to display:
|
||||
|
||||
```yaml
|
||||
args:
|
||||
- "--secrets"
|
||||
- "secret1,secret2"
|
||||
```
|
||||
|
||||
## Access
|
||||
|
||||
```bash
|
||||
kubectl port-forward service/secret-reader 8080:80
|
||||
```
|
||||
|
||||
Open http://localhost:8080
|
||||
59
deployment.yaml
Normal file
59
deployment.yaml
Normal file
@@ -0,0 +1,59 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: secret-reader
|
||||
labels:
|
||||
app: secret-reader
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: secret-reader
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: secret-reader
|
||||
spec:
|
||||
serviceAccountName: secret-reader
|
||||
containers:
|
||||
- name: secret-reader
|
||||
image: ultradesu/k8s-secrets:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- "--secrets"
|
||||
- "openai-creds"
|
||||
- "--port"
|
||||
- "3000"
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
name: http
|
||||
env:
|
||||
- name: RUST_LOG
|
||||
value: "info"
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
44
external-secret.yaml
Normal file
44
external-secret.yaml
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
apiVersion: external-secrets.io/v1beta1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: openai-creds
|
||||
spec:
|
||||
target:
|
||||
name: openai-creds
|
||||
deletionPolicy: Delete
|
||||
template:
|
||||
type: Opaque
|
||||
data:
|
||||
USER: |-
|
||||
{{ .user }}
|
||||
PASS: |-
|
||||
{{ .pass }}
|
||||
TOTP: |-
|
||||
{{ .totp }}
|
||||
data:
|
||||
- secretKey: user
|
||||
sourceRef:
|
||||
storeRef:
|
||||
name: vaultwarden-login
|
||||
kind: ClusterSecretStore
|
||||
remoteRef:
|
||||
key: a485f323-fd47-40ee-a5cf-40891b1f963c
|
||||
property: login.username
|
||||
- secretKey: pass
|
||||
sourceRef:
|
||||
storeRef:
|
||||
name: vaultwarden-login
|
||||
kind: ClusterSecretStore
|
||||
remoteRef:
|
||||
key: a485f323-fd47-40ee-a5cf-40891b1f963c
|
||||
property: login.password
|
||||
- secretKey: totp
|
||||
sourceRef:
|
||||
storeRef:
|
||||
name: vaultwarden-login
|
||||
kind: ClusterSecretStore
|
||||
remoteRef:
|
||||
key: a485f323-fd47-40ee-a5cf-40891b1f963c
|
||||
property: login.totp
|
||||
|
||||
20
rbac.yaml
Normal file
20
rbac.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: secret-reader
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["get", "list"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: secret-reader
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: secret-reader
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: secret-reader
|
||||
6
service-account.yaml
Normal file
6
service-account.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: secret-reader
|
||||
labels:
|
||||
app: secret-reader
|
||||
15
service.yaml
Normal file
15
service.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: secret-reader
|
||||
labels:
|
||||
app: secret-reader
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: secret-reader
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 3000
|
||||
protocol: TCP
|
||||
name: http
|
||||
163
src/main.rs
Normal file
163
src/main.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
use anyhow::Result;
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use clap::Parser;
|
||||
use k8s_openapi::api::core::v1::Secret;
|
||||
use kube::{Api, Client};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info};
|
||||
use tracing_subscriber;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct Args {
|
||||
#[arg(short, long, default_value = "3000")]
|
||||
port: u16,
|
||||
|
||||
#[arg(short, long, value_delimiter = ',', help = "Secret names to display (comma-separated)")]
|
||||
secrets: Vec<String>,
|
||||
|
||||
#[arg(short, long, default_value = "default")]
|
||||
namespace: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
client: Client,
|
||||
secret_names: Vec<String>,
|
||||
namespace: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SecretData {
|
||||
name: String,
|
||||
data: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "index.html")]
|
||||
struct IndexTemplate {
|
||||
secrets: Vec<SecretData>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
async fn read_secrets(state: &AppState) -> Result<Vec<SecretData>> {
|
||||
let secrets_api: Api<Secret> = Api::namespaced(state.client.clone(), &state.namespace);
|
||||
let mut result = Vec::new();
|
||||
|
||||
for secret_name in &state.secret_names {
|
||||
match secrets_api.get(secret_name).await {
|
||||
Ok(secret) => {
|
||||
let mut data_pairs = Vec::new();
|
||||
|
||||
if let Some(data) = secret.data {
|
||||
for (key, value) in data {
|
||||
let decoded = String::from_utf8_lossy(&value.0).to_string();
|
||||
data_pairs.push((key, decoded));
|
||||
}
|
||||
} else if let Some(string_data) = secret.string_data {
|
||||
for (key, value) in string_data {
|
||||
data_pairs.push((key, value));
|
||||
}
|
||||
}
|
||||
|
||||
data_pairs.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
result.push(SecretData {
|
||||
name: secret_name.clone(),
|
||||
data: data_pairs,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to read secret {}: {}", secret_name, e);
|
||||
result.push(SecretData {
|
||||
name: secret_name.clone(),
|
||||
data: vec![("error".to_string(), format!("Failed to read: {}", e))],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn index_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
info!("Handling request, fetching secrets: {:?}", state.secret_names);
|
||||
|
||||
match read_secrets(&state).await {
|
||||
Ok(secrets) => {
|
||||
let template = IndexTemplate {
|
||||
secrets,
|
||||
error: None,
|
||||
};
|
||||
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
error!("Template render error: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Template render error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to read secrets: {}", e);
|
||||
let template = IndexTemplate {
|
||||
secrets: vec![],
|
||||
error: Some(format!("Failed to read secrets: {}", e)),
|
||||
};
|
||||
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Failed to read secrets").into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn health_handler() -> impl IntoResponse {
|
||||
"OK"
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
if args.secrets.is_empty() {
|
||||
error!("No secret names provided. Use --secrets flag with comma-separated secret names");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
info!("Starting secret-reader service");
|
||||
info!("Configured to read secrets: {:?}", args.secrets);
|
||||
info!("Namespace: {}", args.namespace);
|
||||
|
||||
let client = Client::try_default().await?;
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
client,
|
||||
secret_names: args.secrets,
|
||||
namespace: args.namespace,
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(index_handler))
|
||||
.route("/health", get(health_handler))
|
||||
.with_state(state);
|
||||
|
||||
let addr = format!("0.0.0.0:{}", args.port);
|
||||
info!("Server listening on {}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
203
templates/index.html
Normal file
203
templates/index.html
Normal file
@@ -0,0 +1,203 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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;
|
||||
}
|
||||
.secret-name {
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.error {
|
||||
background: #ffdddd;
|
||||
color: #cc0000;
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</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">
|
||||
<div class="secret-name">{{ secret.name }}</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 %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<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 %}
|
||||
|
||||
function updateTOTP() {
|
||||
const now = Date.now();
|
||||
const timeRemaining = 30 - (Math.floor(now / 1000) % 30);
|
||||
|
||||
otpUrls.forEach(item => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
updateTOTP();
|
||||
setInterval(updateTOTP, 1000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user