first commit

This commit is contained in:
Ultradesu
2025-09-03 16:32:44 +03:00
commit fb06123e50
12 changed files with 2986 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

2395
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: secret-reader
labels:
app: secret-reader

15
service.yaml Normal file
View 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
View 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
View 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(/&quot;/g, '"').replace(/&#x27;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/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>