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