任何暴露在前端的长期凭证都是一个定时炸弹。在构建内部监控面板时,我们遇到了一个典型的安全困境:前端应用需要查询 Prometheus 获取监控指标,而 Prometheus 的 HTTP API 通常通过一个静态的 Bearer Token 进行认证。将这个 Token 硬编码或通过配置下发给前端,无异于将整个监控系统的读取权限拱手相让。一旦 Token 泄露,任何人都可以在其有效期内自由查询我们的所有指标数据,这在生产环境中是不可接受的。
问题的核心在于凭证的生命周期。静态、长期的凭证风险太高。我们需要的是一种动态的、短生命周期的凭证,仅在需要时生成,用完即焚。这自然让我们想到了 HashiCorp Vault。
初步构想是这样的:
- 前端应用本身不持有任何 Prometheus 的凭证。
- 用户访问监控面板时,前端向一个可信的后端服务发起凭证请求。
- 后端服务向 Vault 请求一个专门用于本次会话的、短生命周期的 Prometheus 访问令牌。
- Vault 动态生成该令牌,并设置一个很短的 TTL(例如,5分钟)。
- 后端将令牌返回给前端。
- 前端使用此令牌查询 Prometheus。令牌失效后,前端需要重新执行上述流程。
这个流程在架构上是清晰的,但魔鬼在细节里。前端如何优雅地管理这个动态令牌的生命周期?当用户在一个页面停留超过5分钟,令牌失效,正在进行的数据轮询该如何处理?是粗暴地让所有请求失败并提示用户刷新,还是无缝地在后台完成令牌续期?这正是前端状态管理需要解决的复杂问题。使用 useState 和 useEffect 裸写一套这样的逻辑,很快会变得混乱不堪。
因此,我们的技术选型最终确定为:
- HashiCorp Vault: 作为动态秘密的生成引擎。
- Prometheus: 我们的目标数据源。
- 一个轻量级后端 (Node.js/Express): 作为前端与 Vault 之间的可信桥梁。
- Zustand: 在 React 前端中管理动态令牌的生命周期、API 请求状态以及指标数据。选择 Zustand 是因为它足够轻量,没有 Redux 的样板代码,却能提供集中式状态管理的全部能力,非常适合处理这种异步、跨组件的状态。
第一步:配置 Vault 以生成动态凭证
Prometheus 本身没有像数据库那样的用户体系,无法直接与 Vault 的 Database Secrets Engine 集成。在真实项目中,我们可能会编写一个 Vault 插件来与支持动态 API Key 的系统集成。但为了清晰地阐述核心思想,我们可以利用 Vault 的 kv secrets engine V2 来模拟这个过程,并通过一个脚本来生成和管理带有生命周期的“令牌”。在实践中,这个“令牌”可以是任何 Prometheus 网关(如 aProm)能够识别的短期凭证。
首先,我们在 Vault 中设置一个路径来存储我们的“令牌生成脚本”和策略。
# 启用 KV V2 secrets engine
vault secrets enable -path=secret kv-v2
# 写入一个策略,允许后端服务读取动态生成的凭证
vault policy write prometheus-reader - <<EOF
path "secret/data/prometheus/creds/*" {
capabilities = ["read"]
}
EOF
# 假设我们有一个后端服务,它使用 AppRole 进行认证
vault auth enable approle
vault write auth/approle/role/backend-service \
secret_id_ttl=10m \
token_num_uses=10 \
token_ttl=20m \
token_max_ttl=30m \
secret_id_num_uses=40 \
policies="prometheus-reader"
为了模拟动态生成,我们可以创建一个脚本,由 Vault 的周期性任务或外部CI/CD触发,定期生成新的、带时间戳的凭证并写入 Vault。更高级的做法是使用 Vault 的 transit 引擎或自定义插件,但这里我们专注于流程整合。这里我们假设有一个外部机制,它会不断创建类似 secret/prometheus/creds/token-1672531200 这样的密钥,并设置一个5分钟的 TTL。
第二步:构建可信后端服务
这个 Node.js 服务是连接前端和 Vault 的关键。它负责自身的 Vault 认证,并为前端请求动态凭证。
server.js
const express = require('express');
const vault = require('node-vault');
const cors = require('cors');
// --- 配置 ---
const PORT = process.env.PORT || 3001;
const VAULT_ADDR = process.env.VAULT_ADDR || 'http://127.0.0.1:8200';
const APPROLE_ROLE_ID = process.env.APPROLE_ROLE_ID; // 从安全的环境变量中获取
const APPROLE_SECRET_ID = process.env.APPROLE_SECRET_ID; // 从安全的环境变量中获取
if (!APPROLE_ROLE_ID || !APPROLE_SECRET_ID) {
console.error("FATAL: AppRole credentials not configured.");
process.exit(1);
}
const app = express();
app.use(cors({ origin: 'http://localhost:3000' })); // 允许前端访问
// 初始化 Vault 客户端
const vaultClient = vault({
apiVersion: 'v1',
endpoint: VAULT_ADDR,
});
// 使用 AppRole 登录 Vault 并保持 token 自动续期
// 这是一个简化的示例,生产环境应有更健壮的启动和认证逻辑
async function loginVault() {
try {
const result = await vaultClient.approleLogin({
role_id: APPROLE_ROLE_ID,
secret_id: APPROLE_SECRET_ID,
});
vaultClient.token = result.auth.client_token;
console.log("Successfully logged into Vault with AppRole.");
// node-vault 客户端不会自动续期,生产环境需要一个定时器来处理
// 或者在每次请求前检查token是否有效
} catch (err) {
console.error("Failed to login to Vault:", err);
process.exit(1);
}
}
// 模拟动态凭证生成。
// 在真实场景中,这可能是调用Vault的数据库引擎或自定义插件。
// 这里我们简单地从 KV store 读取一个预先生成的、带TTL的密钥。
async function generatePrometheusToken() {
try {
// 在实际应用中,你不会每次都生成一个新密码
// 而是会创建一个具有特定TTL的角色
// 这里我们用KV引擎模拟这个过程
const secretPath = 'secret/data/prometheus/dynamic-token';
const dynamicSecret = {
token: `fake-prom-token-${Date.now()}`,
// TTL is 5 minutes
ttl: 5 * 60,
};
// 写入一个带TTL的密钥
await vaultClient.write(secretPath, { data: dynamicSecret }, { lease: `${dynamicSecret.ttl}s` });
// 读取回来以确认
const { data } = await vaultClient.read(secretPath);
return {
token: data.data.token,
lease_duration: dynamicSecret.ttl,
issue_time: Math.floor(Date.now() / 1000),
};
} catch (error) {
console.error("Error generating dynamic token from Vault:", error);
throw new Error("Could not create dynamic Prometheus token.");
}
}
// --- API 端点 ---
// 这个端点需要被你自己的认证/授权中间件保护
app.get('/api/prometheus-token', async (req, res) => {
// 假设这里已经有用户认证逻辑,比如检查 JWT
console.log("Received request for Prometheus token.");
try {
const credentials = await generatePrometheusToken();
res.json({
accessToken: credentials.token,
expiresIn: credentials.lease_duration, // in seconds
issuedAt: credentials.issue_time, // unix timestamp in seconds
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 启动服务前先登录Vault
loginVault().then(() => {
app.listen(PORT, () => {
console.log(`Backend service listening on port ${PORT}`);
});
});
这个后端服务的职责很单一:验证前端请求的合法性,然后向 Vault 请求一个短期凭证并返回。这里的关键是,前端永远接触不到 Vault,也接触不到 AppRole 的凭证。
第三步:前端状态管理与令牌生命周期控制
现在轮到 Zustand 发挥作用了。我们需要创建一个 Store 来统一管理令牌信息、API 调用状态和从 Prometheus 获取的数据。
src/stores/prometheusStore.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
// --- 类型定义 ---
interface TokenData {
accessToken: string;
expiresAt: number; // unix timestamp in seconds
}
interface PrometheusState {
tokenData: TokenData | null;
metrics: Record<string, any>; // Store metrics data
isLoadingToken: boolean;
isLoadingMetrics: boolean;
error: string | null;
}
interface PrometheusActions {
getToken: (forceRefresh?: boolean) => Promise<string>;
fetchMetrics: (query: string) => Promise<void>;
clearError: () => void;
}
// 令牌即将过期的缓冲时间(秒),提前刷新
const TOKEN_EXPIRATION_BUFFER = 30;
const API_BASE_URL = 'http://localhost:3001';
// 假设 Prometheus 部署在 9090 端口
const PROMETHEUS_URL = 'http://localhost:9090';
export const usePrometheusStore = create<PrometheusState & PrometheusActions>()(
immer((set, get) => ({
tokenData: null,
metrics: {},
isLoadingToken: false,
isLoadingMetrics: false,
error: null,
clearError: () => {
set((state) => {
state.error = null;
});
},
// 核心逻辑:获取或刷新令牌
getToken: async (forceRefresh = false) => {
const { tokenData } = get();
const now = Math.floor(Date.now() / 1000);
// 检查令牌是否存在且未过期(并留有缓冲)
if (tokenData && tokenData.expiresAt > now + TOKEN_EXPIRATION_BUFFER && !forceRefresh) {
return tokenData.accessToken;
}
// 如果令牌不存在、即将过期或强制刷新,则从后端获取新令牌
set((state) => {
state.isLoadingToken = true;
state.error = null; // 清除旧错误
});
try {
const response = await fetch(`${API_BASE_URL}/api/prometheus-token`);
if (!response.ok) {
throw new Error(`Failed to fetch token: ${response.statusText}`);
}
const data = await response.json();
const newExpiresAt = data.issuedAt + data.expiresIn;
const newTokenData = {
accessToken: data.accessToken,
expiresAt: newExpiresAt,
};
set((state) => {
state.tokenData = newTokenData;
state.isLoadingToken = false;
});
return newTokenData.accessToken;
} catch (err: any) {
console.error("getToken error:", err);
set((state) => {
state.error = 'Failed to obtain monitoring credentials.';
state.isLoadingToken = false;
state.tokenData = null; // 获取失败,清空旧令牌
});
// 抛出错误,让调用方知道获取失败
throw err;
}
},
// 获取指标数据
fetchMetrics: async (query: string) => {
set((state) => {
state.isLoadingMetrics = true;
state.error = null;
});
try {
// 在查询数据前,确保我们有一个有效的令牌
const token = await get().getToken();
// 使用获取到的令牌查询 Prometheus
const params = new URLSearchParams({ query });
const response = await fetch(`${PROMETHEUS_URL}/api/v1/query?${params}`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
// 如果是401/403,可能是令牌刚好失效,可以尝试强制刷新一次
if (response.status === 401 || response.status === 403) {
console.warn('Metrics fetch failed with auth error, forcing token refresh...');
const newToken = await get().getToken(true); // 强制刷新
const retryResponse = await fetch(`${PROMETHEUS_URL}/api/v1/query?${params}`, {
headers: {
'Authorization': `Bearer ${newToken}`,
},
});
if (!retryResponse.ok) {
throw new Error(`Failed on retry: ${retryResponse.statusText}`);
}
const data = await retryResponse.json();
set((state) => {
state.metrics = data.data;
state.isLoadingMetrics = false;
});
return;
}
throw new Error(`Prometheus query failed: ${response.statusText}`);
}
const data = await response.json();
set((state) => {
state.metrics = data.data;
state.isLoadingMetrics = false;
});
} catch (err: any) {
console.error("fetchMetrics error:", err);
set((state) => {
state.error = 'Failed to fetch metrics data.';
state.isLoadingMetrics = false;
});
}
},
}))
);
这个 Zustand store 是整个前端方案的核心。getToken 方法封装了所有关于令牌生命周期的判断逻辑,而 fetchMetrics 则专注于业务逻辑——查询数据,它完全不用关心令牌是怎么来的,只需要调用 getToken() 即可。这种职责分离让代码非常清晰。
第四步:在 React 组件中使用 Store
现在,在组件中消费这个 Store 就变得非常简单直观。
src/components/MetricsDashboard.tsx
import React, { useEffect } from 'react';
import { usePrometheusStore } from '../stores/prometheusStore';
const MetricsDashboard: React.FC = () => {
// 从 store 中获取状态和 action
const { metrics, isLoadingMetrics, isLoadingToken, error, fetchMetrics } = usePrometheusStore();
// 在组件加载时触发一次数据获取
useEffect(() => {
// 查询 CPU 使用率
fetchMetrics('rate(process_cpu_seconds_total[1m])');
}, [fetchMetrics]);
// 也可以设置一个定时器来轮询数据
useEffect(() => {
const intervalId = setInterval(() => {
fetchMetrics('rate(process_cpu_seconds_total[1m])');
}, 30000); // 每30秒刷新一次
return () => clearInterval(intervalId);
}, [fetchMetrics]);
if (isLoadingToken) {
return <div>Acquiring secure credentials...</div>;
}
if (error) {
return <div style={{ color: 'red' }}>Error: {error}</div>;
}
return (
<div>
<h1>Prometheus Metrics</h1>
{isLoadingMetrics && !Object.keys(metrics).length ? (
<p>Loading metrics...</p>
) : (
<pre>{JSON.stringify(metrics, null, 2)}</pre>
)}
</div>
);
};
export default MetricsDashboard;
流程可视化
整个请求流程可以用一个序列图来清晰地展示:
sequenceDiagram
participant User
participant ReactApp as React App (Zustand)
participant Backend as Node.js Backend
participant Vault as HashiCorp Vault
participant Prometheus
User->>ReactApp: Loads Dashboard
ReactApp->>ReactApp: useEffect triggers fetchMetrics()
ReactApp->>ReactApp: getToken() is called
Note right of ReactApp: Token is null or expired
ReactApp->>Backend: GET /api/prometheus-token
Backend->>Vault: Request dynamic secret
Vault-->>Backend: Returns short-lived token (5 min TTL)
Backend-->>ReactApp: Responds with token and expiry
ReactApp->>ReactApp: Stores token in Zustand state
ReactApp->>Prometheus: GET /api/v1/query (with Bearer token)
Prometheus-->>ReactApp: Responds with metrics data
ReactApp->>User: Renders metrics
loop 30 seconds polling
ReactApp->>ReactApp: setInterval triggers fetchMetrics()
ReactApp->>ReactApp: getToken() is called
Note right of ReactApp: Token is valid, returns from state
ReactApp->>Prometheus: GET /api/v1/query (with same Bearer token)
Prometheus-->>ReactApp: Responds with fresh metrics data
ReactApp->>User: Updates metrics display
end
Note over ReactApp,Prometheus: After 5 minutes...
ReactApp->>ReactApp: fetchMetrics() -> getToken()
Note right of ReactApp: Token is now expired!
ReactApp->>Backend: GET /api/prometheus-token (requests new one)
Backend->>Vault: Request new dynamic secret
Vault-->>Backend: Returns a new short-lived token
Backend-->>ReactApp: Responds with the new token
ReactApp->>ReactApp: Updates token in Zustand state
ReactApp->>Prometheus: GET /api/v1/query (with NEW Bearer token)
Prometheus-->>ReactApp: Responds with metrics data
ReactApp->>User: Renders metrics seamlessly
这个方案的健壮性体现在它对失败的处理上。例如,在 fetchMetrics 中,如果请求因为401/403失败,我们会主动触发一次强制令牌刷新并重试。这处理了一种边缘情况:在发出请求的瞬间,令牌恰好过期。用户对此过程是无感的。
方案的局限性与未来优化
尽管此架构解决了核心的安全问题,但在生产环境中仍有几个需要考量的点。
首先,后端服务成为了一个关键节点。它的可用性直接影响到整个监控面板。需要对其进行适当的监控和高可用部署。
其次,令牌的刷新逻辑可以更精细。当前是在每次API调用前检查,对于高频轮询的场景,这没有问题。但对于不频繁的交互,可以考虑在令牌过期前通过一个 setTimeout 主动触发刷新,而不是被动等待下一次API调用。
再者,我们用 Vault 的 KV 引擎模拟了动态凭证。一个更彻底的方案是为 Prometheus 或其代理网关编写一个自定义的 Vault Secrets Engine 插件。这将允许 Vault 直接生成和管理 Prometheus 能够理解的、有时限的访问凭证,实现真正的端到端动态密钥管理。
最后,错误处理还可以增强。当 Vault 无法访问或后端服务持续失败时,前端应提供更明确的降级体验,例如显示“监控服务暂时不可用”并启动一个带指数退避的重试策略,而不是简单地显示一个错误信息。