构建基于 Vault 动态令牌与 Zustand 状态管理的 Prometheus 前端监控面板


任何暴露在前端的长期凭证都是一个定时炸弹。在构建内部监控面板时,我们遇到了一个典型的安全困境:前端应用需要查询 Prometheus 获取监控指标,而 Prometheus 的 HTTP API 通常通过一个静态的 Bearer Token 进行认证。将这个 Token 硬编码或通过配置下发给前端,无异于将整个监控系统的读取权限拱手相让。一旦 Token 泄露,任何人都可以在其有效期内自由查询我们的所有指标数据,这在生产环境中是不可接受的。

问题的核心在于凭证的生命周期。静态、长期的凭证风险太高。我们需要的是一种动态的、短生命周期的凭证,仅在需要时生成,用完即焚。这自然让我们想到了 HashiCorp Vault。

初步构想是这样的:

  1. 前端应用本身不持有任何 Prometheus 的凭证。
  2. 用户访问监控面板时,前端向一个可信的后端服务发起凭证请求。
  3. 后端服务向 Vault 请求一个专门用于本次会话的、短生命周期的 Prometheus 访问令牌。
  4. Vault 动态生成该令牌,并设置一个很短的 TTL(例如,5分钟)。
  5. 后端将令牌返回给前端。
  6. 前端使用此令牌查询 Prometheus。令牌失效后,前端需要重新执行上述流程。

这个流程在架构上是清晰的,但魔鬼在细节里。前端如何优雅地管理这个动态令牌的生命周期?当用户在一个页面停留超过5分钟,令牌失效,正在进行的数据轮询该如何处理?是粗暴地让所有请求失败并提示用户刷新,还是无缝地在后台完成令牌续期?这正是前端状态管理需要解决的复杂问题。使用 useStateuseEffect 裸写一套这样的逻辑,很快会变得混乱不堪。

因此,我们的技术选型最终确定为:

  • 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 无法访问或后端服务持续失败时,前端应提供更明确的降级体验,例如显示“监控服务暂时不可用”并启动一个带指数退避的重试策略,而不是简单地显示一个错误信息。


  目录