移动端轻量化控制台模块

2025-10-15T10:30:00+08:00 | 26分钟阅读 | 更新于 2025-10-15T10:30:00+08:00

@

学习目标

本篇讲透企业自研 PaaS 平台的「移动端轻量化控制台」——它是运维与审批在手机上的安全延伸。学完本篇,你应该能够:

  1. 说明移动端为什么默认只读,以及「只读默认」如何在架构层面被强制执行。
  2. 配置移动端受限 RBAC 角色,使其只能对接统一 K8s API 网关的只读/受限接口。
  3. 在手机上完成审批待办、告警认领/升级、应用状态查看等核心操作。
  4. 理解敏感操作(如生产发布确认)如何触发二次认证,且无法在移动端直接执行高危动作。
  5. 把移动端的操作、告警、推送指标接入 Prometheus 联邦,并保证审计留痕 90 天+。

前置知识

  • 已了解 _SHARED.md 全局约束:多套物理隔离 K8s 集群、统一 K8s API 网关、Namespace 租户隔离;Prometheus 联邦 + Thanos、指标带 tenant/app/env/cluster 标签;RBAC 细粒度、审计 90 天+、敏感配置加密、镜像扫描。
  • 已读模块 11:移动审批是审批工作流引擎在手机端的入口,移动端不直接签发执行凭证。
  • 了解 Grafana 移动视图、企业微信/钉钉推送、设备绑定与 TOTP/短信二次认证基础。

本章你会动手做的事

  1. 在手机上用受限 RBAC 角色登录,确认你只能看到本租户 Namespace 的只读看板。
  2. 收到一条生产告警推送,在移动端完成「认领」并升级给值班。
  3. 尝试在移动端直接点「执行发布」,验证被拦截并要求跳转二次认证/桌面端完成。

项目结构与文件清单

本篇所有代码自洽:Go 引用的字段名、指标名、RBAC 资源名与下方 YAML / Prometheus 配置完全一致。下文各章会逐个文件完整展示,此处先给出目录树(根目录 module-12-mobile-console/)。

module-12-mobile-console/
├── go.mod
├── deploy/
│   ├── mobile-gateway-deployment.yaml  # ① 移动网关 Deployment(接入层 + 受限 RBAC SA)
│   ├── mobile-ingress.yaml             # ② mTLS / Ingress(仅放行业务域名)
│   ├── mobile-rbac.yaml                # ③ 受限 RBAC Role(只读 + 审批 + 告警认领)
│   ├── device-binding-configmap.yaml   # ④ 设备绑定 ConfigMap(账号↔设备指纹)
│   └── prometheus-rules.yaml           # ⑤ 移动 API 错误率/认领时延 告警规则
├── config/
│   └── servicemonitor.yaml             # 联邦抓取配置(带 tenant 标签)
├── internal/
│   ├── metrics/metrics.go              # Prometheus 指标(tenant/app/env/cluster/endpoint/code)
│   ├── audit/audit.go                  # 操作审计上报(带 device_id)
│   ├── auth/device.go                  # 设备绑定校验(读 ConfigMap)
│   ├── auth/twofa.go                   # TOTP 二次认证
│   ├── k8s/readonly.go                 # client-go 经网关只读查询
│   ├── middleware/auth.go              # JWT + 移动受限 RBAC + 设备绑定 中间件
│   ├── api/server.go                   # gin 路由:看板/审批/告警/敏感操作
│   └── gateway/token.go                # 向统一 K8s API 网关申请执行凭证(短时)
├── cmd/mobile/main.go                  # 移动网关启动入口
└── runbook.md                          # 运维 SOP:设备绑定/二次认证/只读验证

约定:所有 Go 文件头标注 // file: <相对路径>,YAML 文件头标注 # file: <相对路径>。指标统一携带 tenant/app/env/cluster 四维标签;统一 K8s API 网关地址由环境变量 K8S_GATEWAY_URL 注入。移动端不持有任何集群 kubeconfig,只读查询与短时凭证均经网关。


一、模块概述与企业商用价值

类比:移动端控制台就像核电站控制室的「观察窗 + 对讲机」,而不是操作台。你能在手机上清楚看到反应堆温度(监控看板)、收到警报并呼叫工程师(告警认领/升级)、用对讲机批准一项作业(移动审批),但你不能在手机上直接拉下控制棒(高危执行)。真要操作,必须回到有双因子物理钥匙的主控台。这就是「只读默认 + 敏感操作二次认证」的设计哲学。

在金融/政企场景,运维 7×24 待命,但生产环境不可能允许任何人随时随地用手机改集群。移动端轻量化控制台的价值是:让关键人员随时可见、可批、可认领,但把「能改生产」的口子牢牢收在桌面端 + 二次认证 + 审批闸门之后。

适用角色与痛点:

  • 值班运维 / SRE:手机收告警、认领、升级,不必随身带笔记本也能先响应。
  • 租户管理员 / 研发负责人:手机上批发布工单、看应用状态,差旅中也不阻塞业务。
  • 安全合规团队:移动端所有动作必须可审计、设备可绑定、敏感操作强制二次认证。

平台不可替代性:移动端不是 Web 控制台的简单缩放,而是独立受限通道——它只对接 API 网关的「移动受限角色」接口子集,天然拿不到写权限入口。

下图说明移动端在整个 PaaS 平台中的定位:它位于审批引擎、联邦监控、IM 告警与受限 API 网关之间。

graph LR
  A[运维 / 租户管理员 手机] -->|移动受限 RBAC| B((移动端控制台))
  B -->|只读看板 + 认领| C[联邦监控 Grafana 移动视图]
  B -->|移动审批待办| D[审批工作流引擎]
  B -->|告警认领/升级| E[Alertmanager + IM]
  B -->|受限调用| F[统一 K8s API 网关
移动受限角色] F --> G[生产 / 预发布 / 测试 物理隔离集群] B -->|操作指标| H[Prometheus 联邦] B -->|全动作| I[审计留痕 90天+]

这张图说明移动端是「只读 + 审批 + 告警」的安全延伸,所有写操作都需回到网关受限接口并二次认证。

1.1 落地形态:受限通道 + 网关代理

移动端 App 只与「移动网关接入层」通信;接入层用受限 RBAC 角色(见 deploy/mobile-rbac.yaml)仅能调用网关的只读/审批/告警子集。任何写请求要么被网关角色 403,要么触发二次认证并转桌面端最终执行。下图为代码视角链路。

graph TD
  P[手机 App] -->|HTTPS mTLS| GW[移动网关接入层 gin]
  GW -->|JWT 校验 + 设备绑定| MID[auth 中间件]
  MID -->|只读| RO[k8s/readonly.go 经网关查询]
  MID -->|预批| AP[api/server.go 调模块11引擎]
  MID -->|敏感操作| TOTP[auth/twofa.go]
  TOTP -->|短时凭证| G[统一 K8s API 网关
移动受限角色] G -->|只读/受限| C[物理隔离集群] GW -->|每动作| AUD[audit.go 带 device_id] GW -->|指标| PROM[Prometheus 联邦]

二、细分功能详解(商用生产级)

下表区分【基础能力】与【高级企业增值能力】,标注「★禁止删减」的是强制项,删除即不合格。

类别功能说明关键约束
【基础能力】①移动审批待办/已办/转交,对接模块 11 引擎★审计
【基础能力】②监控看板只读为主,Grafana 移动视图按租户过滤★只读默认
【基础能力】③告警接收/认领/升级推送 + 认领 + 升级值班★审计
【基础能力】④应用状态查看部署/副本/健康只读呈现★只读默认
【高级企业增值能力】⑤受限操作敏感操作需二次认证,且大多跳转桌面端执行★敏感操作二次认证
【高级企业增值能力】⑥离线推送通知APNs/厂商通道离线推送待办与告警内容脱敏
【高级企业增值能力】⑦安全设备绑定 + 最小权限 + 越狱/root 检测★只读默认+★审计

分级列表补充细节,标注禁止删减项:

  • 移动审批 ★禁止删减:审计

    • 待办列表来自审批工作流引擎;支持转交(仅限同角色范围内)、催办查看。
    • 普通变更可在移动端完成审批;生产高危变更在移动端仅能「预批」,最终需跳转桌面端二次认证页签发执行凭证(与模块 11 一致)。
    • 每次审批动作入审计(operator、ticket_id、device_id、timestamp)。
  • 监控看板(只读) ★禁止删减:只读默认

    • 默认视角为 Grafana 移动只读视图,数据来自联邦 Prometheus + Thanos,按 tenant/env 过滤。
    • 看板只暴露查询接口,无任何写入口;即便用户尝试调用写 API,网关移动受限角色也拒绝。
    • 支持应用、集群、中间件实例三级下钻,但仅读。
  • 告警接收 / 认领 / 升级

    • Alertmanager 推送经 IM(企业微信/钉钉)到手机;移动端可一键认领,认领后其他值班可见。
    • 升级:认领人可把告警升级给上级或转派;升级动作记审计。
    • 静默(silence)等写操作限制在桌面端,移动端仅能「申请静默」走审批。
  • 应用状态查看 ★禁止删减:只读默认

    • 展示副本数、就绪、近期重启、最近一次发布(来自模块 03),全部只读。
    • 异常状态以颜色/红点提示,点击跳转到联邦监控详情。
  • 受限操作(敏感操作二次认证) ★禁止删减:敏感操作二次认证

    • 移动端不提供「直接执行」高危动作的按钮。
    • 若业务确需移动端发起(如紧急回滚确认),触发 TOTP/短信二次认证,且仅签发短时执行凭证并强制走审批工单。
    • 二次认证失败或设备未绑定一律拒绝。
  • 离线推送通知

    • 通过 APNs / 厂商通道下发待办与告警,即便 App 未前台运行也能触达。
    • 推送 payload 脱敏,不含密钥、完整 YAML 等敏感内容。
  • 安全(设备绑定 / 最小权限) ★禁止删减:只读默认 + 审计

    • 设备绑定:账号与设备指纹绑定,新设备登录需管理员/审批放行。
    • 最小权限:移动端角色仅为「只读 + 审批 + 告警认领」,不含任何集群写权限。
    • 越狱/root 设备禁止登录或仅允许只读。

2.1 IaC:移动网关 Deployment(接入层 + 受限 SA)

# file: deploy/mobile-gateway-deployment.yaml —— 移动网关以 mobile-gateway SA 运行,挂载网关 CA 与自身 TLS 证书(mTLS 入站),只读查询经网关。注意:容器本身无任何集群写权限,写权限完全在网关侧受限角色。

# file: deploy/mobile-gateway-deployment.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: mobile-gateway
  namespace: paas-mobile
  labels:
    tenant: platform
    app: mobile-console
    env: prod
    cluster: paas-control
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mobile-gateway
  namespace: paas-mobile
  labels:
    tenant: platform
    app: mobile-console
    env: prod
    cluster: paas-control
spec:
  replicas: 3
  selector:
    matchLabels:
      app: mobile-console
  template:
    metadata:
      labels:
        app: mobile-console
        tenant: platform
        env: prod
        cluster: paas-control
    spec:
      serviceAccountName: mobile-gateway
      containers:
        - name: gateway
          image: registry.paas.internal/mobile-gateway:v2.7.1   # 镜像已安全扫描
          ports:
            - containerPort: 8443
              name: https
            - containerPort: 2112
              name: metrics
          env:
            - name: K8S_GATEWAY_URL
              value: "https://k8s-gateway.paas.internal"
            - name: GATEWAY_CA
              valueFrom:
                secretKeyRef:
                  name: mobile-gateway-tls
                  key: ca.crt
            - name: MOBILE_TLS_CERT
              valueFrom:
                secretKeyRef:
                  name: mobile-gateway-tls
                  key: tls.crt
            - name: MOBILE_TLS_KEY
              valueFrom:
                secretKeyRef:
                  name: mobile-gateway-tls
                  key: tls.key
            - name: JWT_SECRET
              valueFrom:
                secretKeyRef:
                  name: mobile-jwt
                  key: secret            # 敏感配置加密(KMS 信封)
            - name: TOTP_ISSUER
              value: "PaaS-Mobile"
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8443
              scheme: HTTPS
            initialDelaySeconds: 5
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 256Mi
          volumeMounts:
            - name: device-binding
              mountPath: /etc/device-binding
              readOnly: true
      volumes:
        - name: device-binding
          configMap:
            name: device-bindings        # 见 deploy/device-binding-configmap.yaml
---
apiVersion: v1
kind: Service
metadata:
  name: mobile-gateway
  namespace: paas-mobile
  labels:
    tenant: platform
    app: mobile-console
    env: prod
    cluster: paas-control
spec:
  selector:
    app: mobile-console
  ports:
    - name: https
      port: 443
      targetPort: 8443
    - name: metrics
      port: 2112
      targetPort: 2112

2.2 IaC:mTLS / Ingress

# file: deploy/mobile-ingress.yaml —— 入站强制 mTLS(仅放行已签发客户端证书的移动 App),并限制仅业务域名可达。可结合 cert-manager 自动续期。

# file: deploy/mobile-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: mobile-gateway-ingress
  namespace: paas-mobile
  labels:
    tenant: platform
    app: mobile-console
    env: prod
    cluster: paas-control
  annotations:
    # 仅示例:ingress-nginx 开启双向 TLS,强制客户端证书
    nginx.ingress.kubernetes.io/auth-tls-verify-client: "on"
    nginx.ingress.kubernetes.io/auth-tls-secret: "paas-mobile/mobile-client-ca"
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - mobile.paas.internal
      secretName: mobile-gateway-tls
  rules:
    - host: mobile.paas.internal
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: mobile-gateway
                port:
                  number: 443

2.3 IaC:移动端受限 RBAC Role

# file: deploy/mobile-rbac.yaml —— 移动端角色出厂即只读 + 审批回调 + 告警认领,不含任何集群写权限。即使黑客拿到移动令牌,也只能在网关侧做这些动作。

# file: deploy/mobile-rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: mobile-readonly-approval
  namespace: paas-mobile
  labels:
    tenant: platform
    app: mobile-console
    env: prod
    cluster: paas-control
rules:
  # 只读:仅能读取联邦监控/应用状态相关的 ConfigMap 与自身工单
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "list"]
  - apiGroups: ["approval.paas.example.com"]
    resources: ["approvaltickets"]
    verbs: ["get", "list"]            # 只看工单状态,不能改(改由 Webhook 接收器经审批流)
  # 告警认领:仅能 patch alertmanager 的 ack 注解(经网关封装的受限接口)
  - apiGroups: ["alertmanager" ]
    resources: ["silences"]
    verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: mobile-readonly-approval
  namespace: paas-mobile
  labels:
    tenant: platform
    app: mobile-console
    env: prod
    cluster: paas-control
subjects:
  - kind: ServiceAccount
    name: mobile-gateway
    namespace: paas-mobile
roleRef:
  kind: Role
  name: mobile-readonly-approval
  apiGroup: rbac.authorization.k8s.io

2.4 IaC:设备绑定 ConfigMap

# file: deploy/device-binding-configmap.yaml —— 账号与设备指纹的绑定表(生产应存外部 KMS/加密存储,此处用 ConfigMap 演示读取逻辑,见 internal/auth/device.go)。

# file: deploy/device-binding-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: device-bindings
  namespace: paas-mobile
  labels:
    tenant: platform
    app: mobile-console
    env: prod
    cluster: paas-control
data:
  # key=用户名, value=已绑定设备指纹(逗号分隔);新设备需走绑定审批
  "alice@tenant-a": "fp:9f3c:aa11:tenant-a-phone"
  "bob@tenant-a":   "fp:2b7e:cc44:tenant-a-tablet"
  "carol@platform": "fp:5d01:ee88:platform-phone"

功能架构图如下。

graph TD
  A[移动 App
设备绑定] --> B[移动网关接入层
受限 RBAC] B --> C[只读看板服务
Grafana 移动视图] B --> D[移动审批服务
对接引擎] B --> E[告警认领服务
Alertmanager] B --> F[二次认证服务
TOTP/短信] C -->|查询| G[联邦监控只读接口] D -->|待办| H[审批工作流引擎] E -->|认领/升级| I[IM + 值班] F -->|签发短时凭证| J[统一 K8s API 网关
移动受限角色] B --> K[审计留痕 90天+] J --> L[物理隔离集群]

这张功能架构图说明:移动端所有能力都收敛在「移动网关接入层 + 受限 RBAC」之后,写操作必须经二次认证与网关受限角色。


三、底层架构联动设计

3.1 Prometheus 指标(带 tenant/app/env/cluster)

// file: internal/metrics/metrics.go —— 移动端指标统一四维标签 + endpoint/code,便于联邦按租户过滤与 Thanos 长期存储。

// file: internal/metrics/metrics.go
package metrics

import "github.com/prometheus/client_golang/prometheus"

// 全局统一四维标签
var LabelKeys = []string{"tenant", "app", "env", "cluster"}

var (
	// 看板只读查询量
	MobileDashboardQueryTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Name: "mobile_dashboard_query_total",
		Help: "Mobile read-only dashboard queries by endpoint.",
	}, append([]string{"endpoint"}, LabelKeys...))

	// 告警认领时延(认领动作计时)
	MobileAlertAckSeconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{
		Name:    "mobile_alert_ack_seconds",
		Help:    "Seconds from alert push to mobile ack.",
		Buckets: []float64{10, 30, 60, 120, 300, 600},
	}, append([]string{"alert_id"}, LabelKeys...))

	// 移动审批动作量
	MobileApprovalActionTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Name: "mobile_approval_action_total",
		Help: "Mobile approval actions by decision.",
	}, append([]string{"decision"}, LabelKeys...))

	// 设备绑定数(新增/拒绝)
	MobileDeviceBindTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Name: "mobile_device_bind_total",
		Help: "Device binding events by result.",
	}, append([]string{"result"}, LabelKeys...))

	// 移动 API 错误率(带 code,用于告警)
	MobileAPIErrorTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Name: "mobile_api_error_total",
		Help: "Mobile API errors by endpoint and code.",
	}, append([]string{"endpoint", "code"}, LabelKeys...))
)

func init() {
	prometheus.MustRegister(
		MobileDashboardQueryTotal,
		MobileAlertAckSeconds,
		MobileApprovalActionTotal,
		MobileDeviceBindTotal,
		MobileAPIErrorTotal,
	)
}

3.2 操作审计上报(带 device_id)

// file: internal/audit/audit.go —— 移动端每次登录/查询/审批/认领/二次认证都上报审计(含 device_id),留存 90 天+,WORM 防篡改。此处复用追加写 + 哈希链模型,并额外带 device_id

// file: internal/audit/audit.go
package audit

import (
	"bufio"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"os"
	"sync"
	"time"
)

// Event 移动端审计事件,必含 device_id。
type Event struct {
	Seq       uint64    `json:"seq"`
	PrevHash  string    `json:"prev_hash"`
	Hash      string    `json:"hash"`
	Timestamp time.Time `json:"timestamp"`
	Operator  string    `json:"operator"`
	Tenant    string    `json:"tenant"`
	Env       string    `json:"env"`
	Cluster   string    `json:"cluster"`
	DeviceID  string    `json:"device_id"` // 移动端强制
	Action    string    `json:"action"`    // login/query/approval/ack/sensitive_2fa
	Endpoint  string    `json:"endpoint,omitempty"`
	Decision  string    `json:"decision,omitempty"`
	Detail    string    `json:"detail,omitempty"`
}

// Reporter 审计上报(追加写 + 哈希链)。
type Reporter struct {
	mu       sync.Mutex
	w        *bufio.Writer
	f        *os.File
	prevHash string
	seq      uint64
}

func NewReporter(path string) (*Reporter, error) {
	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0600)
	if err != nil {
		return nil, err
	}
	prev := ""
	var last Event
	sc := bufio.NewScanner(f)
	for sc.Scan() {
		var e Event
		if json.Unmarshal(sc.Bytes(), &e) == nil {
			last = e
		}
	}
	if last.Hash != "" {
		prev = last.Hash
	}
	if _, err := f.Seek(0, 2); err != nil {
		return nil, err
	}
	return &Reporter{w: bufio.NewWriter(f), f: f, prevHash: prev, seq: last.Seq}, nil
}

// Report 追加一条审计并返回 hash。
func (r *Reporter) Report(e Event) (string, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.seq++
	e.Seq = r.seq
	e.PrevHash = r.prevHash
	b, _ := json.Marshal(struct {
		Operator string `json:"operator"`
		Tenant   string `json:"tenant"`
		Env      string `json:"env"`
		Cluster  string `json:"cluster"`
		DeviceID string `json:"device_id"`
		Action   string `json:"action"`
	}{e.Operator, e.Tenant, e.Env, e.Cluster, e.DeviceID, e.Action})
	sum := sha256.Sum256(append([]byte(r.prevHash), b...))
	e.Hash = hex.EncodeToString(sum[:])
	line, _ := json.Marshal(e)
	if _, err := r.w.Write(append(line, '\n')); err != nil {
		return "", err
	}
	if err := r.w.Flush(); err != nil {
		return "", err
	}
	r.prevHash = e.Hash
	return e.Hash, nil
}

func (r *Reporter) Close() error { return r.f.Close() }

// VerifyChain 校验整条链未被篡改。
func VerifyChain(path string) error {
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close()
	sc := bufio.NewScanner(f)
	var prev string
	var seq uint64
	for sc.Scan() {
		var e Event
		if err := json.Unmarshal(sc.Bytes(), &e); err != nil {
			return fmt.Errorf("bad line: %w", err)
		}
		if e.Seq != seq+1 {
			return fmt.Errorf("seq gap at %d", e.Seq)
		}
		seq = e.Seq
		if e.PrevHash != prev {
			return fmt.Errorf("prev_hash broken at seq %d", e.Seq)
		}
		prev = e.Hash
	}
	return nil
}

3.3 设备绑定校验

// file: internal/auth/device.go —— 从 ConfigMap(或加密存储)读取账号↔设备指纹绑定表,校验登录设备是否已绑定。新设备一律拒绝(需走绑定审批)。

// file: internal/auth/device.go
package auth

import (
	"context"
	"fmt"
	"strings"

	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/client-go/kubernetes"
	"k8s.io/client-go/rest"
)

// DeviceBinder 校验账号与设备指纹绑定(强最小权限 + 防越狱复用)。
type DeviceBinder struct {
	Client    kubernetes.Interface
	Namespace string
	CMName    string
}

// IsBound 判断 (user, deviceID) 是否已绑定。
func (b *DeviceBinder) IsBound(ctx context.Context, user, deviceID string) (bool, error) {
	cm, err := b.Client.CoreV1().ConfigMaps(b.Namespace).Get(ctx, b.CMName, metav1.GetOptions{})
	if err != nil {
		return false, fmt.Errorf("device-binding configmap: %w", err)
	}
	val, ok := cm.Data[user]
	if !ok {
		return false, nil // 账号无绑定记录 -> 视为未绑定
	}
	for _, fp := range strings.Split(val, ",") {
		if strings.TrimSpace(fp) == deviceID {
			return true, nil
		}
	}
	return false, nil
}

// NewClient 经统一 K8s API 网关构造只读 client-go(移动端不持 kubeconfig)。
func NewClient(gatewayURL, token string, caData []byte) (kubernetes.Interface, error) {
	cfg := &rest.Config{
		Host:        gatewayURL,
		BearerToken: token,
		TLSClientConfig: rest.TLSClientConfig{
			CAData: caData,
		},
	}
	return kubernetes.NewForConfig(cfg)
}

3.4 TOTP 二次认证

// file: internal/auth/twofa.go —— 敏感操作触发 TOTP 校验(依赖 github.com/pquerna/otp/totp)。二次认证失败或设备未绑定一律拒绝。

// file: internal/auth/twofa.go
package auth

import (
	"fmt"
	"time"

	"github.com/pquerna/otp/totp"
)

// TOTPVerifier 校验动态口令。
type TOTPVerifier struct {
	Issuer string
}

// Verify 校验用户提交的 TOTP 口令(secret 由 KMS 按用户保管,此处演示签名校验)。
func (v *TOTPVerifier) Verify(secret, code string) error {
	ok := totp.Validate(code, secret)
	if !ok {
		return fmt.Errorf("invalid TOTP code")
	}
	return nil
}

// NewTOTPKey 为用户注册设备时生成(仅管理员/绑定流程调用一次)。
func NewTOTPKey(account, issuer string) (string, error) {
	key, err := totp.Generate(totp.GenerateOpts{
		Issuer:      issuer,
		AccountName: account,
		Period:      30,
	})
	if err != nil {
		return "", err
	}
	return key.Secret(), nil
}

// skew 容错时钟漂移(演示)
var skew = func() time.Time { return time.Now() }

3.5 client-go 经网关只读查询

// file: internal/k8s/readonly.go —— 移动端只读看板/应用状态:用 kubernetes.Clientset 经网关 List Deployment 等(网关角色仅有 get/list)。无任何写调用。

// file: internal/k8s/readonly.go
package k8s

import (
	"context"

	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/client-go/kubernetes"
	"k8s.io/client-go/rest"
)

// ReadOnlyClient 仅做只读查询,全部经统一 K8s API 网关(受限角色)。
type ReadOnlyClient struct {
	Client kubernetes.Interface
}

// DeploymentView 看板展示的只读字段。
type DeploymentView struct {
	Name           string `json:"name"`
	Namespace      string `json:"namespace"`
	Replicas       int32  `json:"replicas"`
	ReadyReplicas  int32  `json:"readyReplicas"`
	Restarts       int32  `json:"restarts"`
	LastDeployTime string `json:"lastDeployTime"`
}

// ListDeployments 按租户 Namespace 只读查询(网关角色已限制 verbs=get/list)。
func (c *ReadOnlyClient) ListDeployments(ctx context.Context, namespace string) ([]DeploymentView, error) {
	list, err := c.Client.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{})
	if err != nil {
		return nil, err // 网关侧会因权限/越界返回 403,被上层记 mobile_api_error_total
	}
	out := make([]DeploymentView, 0, len(list.Items))
	for _, d := range list.Items {
		var restarts int32
		if len(d.Status.ContainerStatuses) > 0 {
			restarts = d.Status.ContainerStatuses[0].RestartCount
		}
		out = append(out, DeploymentView{
			Name:           d.Name,
			Namespace:      d.Namespace,
			Replicas:       d.Status.Replicas,
			ReadyReplicas:  d.Status.ReadyReplicas,
			Restarts:       restarts,
			LastDeployTime: d.CreationTimestamp.String(),
		})
	}
	return out, nil
}

// NewClientSet 经统一 K8s API 网关构造只读 clientset(移动端不持 kubeconfig)。
func NewClientSet(cfg *rest.Config) (kubernetes.Interface, error) {
	return kubernetes.NewForConfig(cfg)
}

3.6 向网关申请「短时执行凭证」(敏感操作)

// file: internal/gateway/token.go —— 敏感操作(紧急回滚确认等)经二次认证后,向网关申请短时(默认 10 分钟)凭证,且强制走审批工单;最终执行仍在桌面端。移动端只拿到「预批 + 短时凭证引用」,不直连执行。

// file: internal/gateway/token.go
package gateway

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"time"
)

// GatewayURL 统一 K8s API 网关。
func GatewayURL() string {
	if v := os.Getenv("K8S_GATEWAY_URL"); v != "" {
		return v
	}
	return "https://k8s-gateway.paas.internal"
}

// MobileScope 移动端短时凭证作用范围(默认 10 分钟,强制走审批)。
type MobileScope struct {
	Tenant    string `json:"tenant"`
	Cluster   string `json:"cluster"`
	Namespace string `json:"namespace"`
	Action    string `json:"action"`
	TTL       int    `json:"ttl"` // 移动端默认 600s
	TicketID  string `json:"ticket_id"` // 必须挂审批工单
}

// TokenResponse 网关返回的执行凭证引用(不落明文)。
type TokenResponse struct {
	Ref       string    `json:"ref"`
	ExpiresAt time.Time `json:"expires_at"`
}

// RequestShortToken 申请短时凭证(仅当已二次认证 + 已绑定设备 + 挂审批工单)。
func RequestShortToken(ctx context.Context, scope MobileScope) (*TokenResponse, error) {
	if scope.TicketID == "" {
		return nil, fmt.Errorf("mobile sensitive op must attach approval ticket")
	}
	if scope.TTL == 0 {
		scope.TTL = 600 // 移动端默认 10 分钟
	}
	body, _ := json.Marshal(map[string]interface{}{"scope": scope})
	url := GatewayURL() + "/v1/mobile-exec-token"
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+os.Getenv("MOBILE_TOKEN"))
	client := &http.Client{Timeout: 10 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("gateway mobile-exec-token http %d", resp.StatusCode)
	}
	var tr TokenResponse
	if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
		return nil, err
	}
	return &tr, nil
}

3.7 中间件:JWT + 移动受限 RBAC + 设备绑定

// file: internal/middleware/auth.go —— 每个请求都会被解析 JWT(含 tenant/role/device_id),校验设备绑定,并注入 context。所有路由共享此中间件,从架构层强制「只读默认 + 审计 + 设备绑定」。

// file: internal/middleware/auth.go
package middleware

import (
	"context"
	"net/http"
	"strings"
	"time"

	"github.com/gin-gonic/gin"
	"module-12-mobile-console/internal/audit"
	"module-12-mobile-console/internal/auth"
	"module-12-mobile-console/internal/metrics"
)

// Claims JWT 载荷:移动端角色固定为只读 + 审批 + 告警认领,无写权限。
type Claims struct {
	User     string `json:"user"`
	Tenant   string `json:"tenant"`
	Env      string `json:"env"`
	Cluster  string `json:"cluster"`
	Role     string `json:"role"` // mobile-readonly-approval
	DeviceID string `json:"device_id"`
}

type ctxKey string

const claimsKey ctxKey = "claims"

// Auth 中间件:解析 JWT -> 设备绑定校验 -> 注入 claims。
func Auth(jwtSecret string, binder *auth.DeviceBinder, reporter *audit.Reporter) gin.HandlerFunc {
	return func(c *gin.Context) {
		ah := c.GetHeader("Authorization")
		token := strings.TrimPrefix(ah, "Bearer ")
		if token == "" {
			metrics.MobileAPIErrorTotal.WithLabelValues(
				c.FullPath(), "401", c.GetString("tenant"), "mobile-console", "prod", "paas-control").Inc()
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
			return
		}
		claims, err := parseJWT(token, jwtSecret)
		if err != nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "bad token"})
			return
		}
		// 设备绑定校验(★禁止删减:只读默认 + 审计)
		bound, err := binder.IsBound(c.Request.Context(), claims.User, claims.DeviceID)
		if err != nil || !bound {
			metrics.MobileDeviceBindTotal.WithLabelValues(
				"rejected", claims.Tenant, "mobile-console", claims.Env, claims.Cluster).Inc()
			c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "device not bound"})
			return
		}
		c.Set(string(claimsKey), claims)
		// 登录动作审计(带 device_id)
		_, _ = reporter.Report(audit.Event{
			Timestamp: time.Now(), Operator: claims.User, Tenant: claims.Tenant,
			Env: claims.Env, Cluster: claims.Cluster, DeviceID: claims.DeviceID,
			Action: "login", Endpoint: c.FullPath(),
		})
		c.Next()
	}
}

// MustRole 子路由角色约束(移动端角色恒为只读子集)。
func MustRole(role string) gin.HandlerFunc {
	return func(c *gin.Context) {
		cl := c.MustGet(string(claimsKey)).(Claims)
		if cl.Role != role {
			metrics.MobileAPIErrorTotal.WithLabelValues(
				c.FullPath(), "403", cl.Tenant, "mobile-console", cl.Env, cl.Cluster).Inc()
			c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "role denied"})
			return
		}
		c.Next()
	}
}

func GetClaims(c *gin.Context) Claims {
	return c.MustGet(string(claimsKey)).(Claims)
}

// parseJWT 解析并校验签名(演示:使用 HMAC,生产用 KMS/短期 JWT)。
func parseJWT(token, secret string) (Claims, error) {
	// 真实实现用 jwt.ParseWithClaims 校验签名并填充 Claims;
	// 此处为编译自洽的演示骨架,返回空 Claims 不报错。
	return Claims{}, nil
}

3.8 API 路由:看板 / 审批 / 告警 / 敏感操作

// file: internal/api/server.go —— gin 路由。只读默认:看板与状态只有 GET;移动审批只能「预批」;敏感操作触发二次认证并返回「跳转桌面端」。所有路径记审计。

// file: internal/api/server.go
package api

import (
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
	"module-12-mobile-console/internal/audit"
	"module-12-mobile-console/internal/auth"
	"module-12-mobile-console/internal/gateway"
	"module-12-mobile-console/internal/k8s"
	"module-12-mobile-console/internal/metrics"
	"module-12-mobile-console/internal/middleware"
)

// Server 移动网关 HTTP 服务。
type Server struct {
	RO       *k8s.ReadOnlyClient
	Binder   *auth.DeviceBinder
	TOTP     *auth.TOTPVerifier
	Reporter *audit.Reporter
	JWT      string
}

// NewRouter 装配路由(只读默认 + 受限操作二次认证)。
func (s *Server) NewRouter() *gin.Engine {
	r := gin.Default()
	r.Use(gin.Recovery())

	api := r.Group("/api/v1")
	api.Use(middleware.Auth(s.JWT, s.Binder, s.Reporter))
	api.Use(middleware.MustRole("mobile-readonly-approval"))

	// ① 只读看板:列出本租户 Namespace 的 Deployment(★只读默认)
	api.GET("/dashboard/deployments", s.listDeployments)
	api.GET("/dashboard/deployments/:ns/:name", s.getDeployment)

	// ② 移动审批:仅「预批」,高危动作跳转桌面端(对接模块 11 引擎)
	api.POST("/approval/:ticket/preapprove", s.preApprove)

	// ③ 告警认领/升级
	api.POST("/alerts/:id/ack", s.ackAlert)
	api.POST("/alerts/:id/escalate", s.escalateAlert)

	// ④ 敏感操作:触发二次认证,仅签发短时凭证引用,最终执行回桌面端
	api.POST("/ops/sensitive", s.sensitiveOp)

	r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
	return r
}

// listDeployments 只读查询(经网关)。
func (s *Server) listDeployments(c *gin.Context) {
	cl := middleware.GetClaims(c)
	ns := c.Query("namespace")
	if ns == "" {
		ns = "tenant-a-prod"
	}
	start := time.Now()
	views, err := s.RO.ListDeployments(c.Request.Context(), ns)
	metrics.MobileDashboardQueryTotal.WithLabelValues(
		"/dashboard/deployments", cl.Tenant, "mobile-console", cl.Env, cl.Cluster).Inc()
	if err != nil {
		metrics.MobileAPIErrorTotal.WithLabelValues(
			"/dashboard/deployments", "500", cl.Tenant, "mobile-console", cl.Env, cl.Cluster).Inc()
		c.JSON(http.StatusForbidden, gin.H{"error": "gateway denied (read-only role)"})
		return
	}
	_, _ = s.Reporter.Report(audit.Event{
		Timestamp: time.Now(), Operator: cl.User, Tenant: cl.Tenant, Env: cl.Env,
		Cluster: cl.Cluster, DeviceID: cl.DeviceID, Action: "query",
		Endpoint: "/dashboard/deployments", Detail: time.Since(start).String(),
	})
	c.JSON(http.StatusOK, views)
}

func (s *Server) getDeployment(c *gin.Context) {
	// 同 listDeployments 的只读逻辑,省略重复
	c.JSON(http.StatusOK, gin.H{"message": "read-only single deployment view"})
}

// preApprove 移动端预批:普通变更可批;高危变更返回「跳转桌面端二次认证」。
func (s *Server) preApprove(c *gin.Context) {
	cl := middleware.GetClaims(c)
	ticket := c.Param("ticket")
	var body struct {
		Decision string `json:"decision"` // approve/reject
		RiskHigh bool   `json:"riskHigh"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	metrics.MobileApprovalActionTotal.WithLabelValues(
		body.Decision, cl.Tenant, "mobile-console", cl.Env, cl.Cluster).Inc()
	_, _ = s.Reporter.Report(audit.Event{
		Timestamp: time.Now(), Operator: cl.User, Tenant: cl.Tenant, Env: cl.Env,
		Cluster: cl.Cluster, DeviceID: cl.DeviceID, Action: "approval",
		Decision: body.Decision, Endpoint: "/approval/preapprove",
	})
	if body.RiskHigh {
		// ★敏感操作二次认证:移动端仅预批,最终执行回桌面端
		c.JSON(http.StatusAccepted, gin.H{
			"status":   "pre_approved",
			"redirect": "https://console.paas.internal/approval/" + ticket + "?step=2fa",
			"message":  "高危变更需跳转桌面端完成二次认证与执行凭证签发",
		})
		return
	}
	c.JSON(http.StatusOK, gin.H{"status": "approved", "ticket": ticket})
}

// ackAlert 告警认领,记 mobile_alert_ack_seconds。
func (s *Server) ackAlert(c *gin.Context) {
	cl := middleware.GetClaims(c)
	alertID := c.Param("id")
	metrics.MobileAlertAckSeconds.WithLabelValues(
		alertID, cl.Tenant, "mobile-console", cl.Env, cl.Cluster).Observe(time.Since(ackStart(alertID)).Seconds())
	_, _ = s.Reporter.Report(audit.Event{
		Timestamp: time.Now(), Operator: cl.User, Tenant: cl.Tenant, Env: cl.Env,
		Cluster: cl.Cluster, DeviceID: cl.DeviceID, Action: "ack", Endpoint: "/alerts/ack",
	})
	c.JSON(http.StatusOK, gin.H{"alert": alertID, "acked_by": cl.User})
}

func (s *Server) escalateAlert(c *gin.Context) {
	cl := middleware.GetClaims(c)
	_, _ = s.Reporter.Report(audit.Event{
		Timestamp: time.Now(), Operator: cl.User, Tenant: cl.Tenant, Env: cl.Env,
		Cluster: cl.Cluster, DeviceID: cl.DeviceID, Action: "ack", Endpoint: "/alerts/escalate",
		Decision: "escalate",
	})
	c.JSON(http.StatusOK, gin.H{"alert": c.Param("id"), "escalated_by": cl.User})
}

// sensitiveOp 敏感操作:二次认证 -> 短时凭证引用(最终执行回桌面端)。
func (s *Server) sensitiveOp(c *gin.Context) {
	cl := middleware.GetClaims(c)
	var body struct {
		Action   string `json:"action"`
		TicketID string `json:"ticketId"`
		TOTP     string `json:"totp"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	// ★敏感操作二次认证
	if err := s.TOTP.Verify(os_GetTOTPSecret(cl.User), body.TOTP); err != nil {
		metrics.MobileAPIErrorTotal.WithLabelValues(
			"/ops/sensitive", "401", cl.Tenant, "mobile-console", cl.Env, cl.Cluster).Inc()
		c.JSON(http.StatusUnauthorized, gin.H{"error": "2fa failed"})
		return
	}
	// 向网关申请短时凭证(强制挂审批工单)
	tr, err := gateway.RequestShortToken(c.Request.Context(), gateway.MobileScope{
		Tenant: cl.Tenant, Cluster: cl.Cluster, Namespace: "tenant-a-prod",
		Action: body.Action, TicketID: body.TicketID,
	})
	if err != nil {
		c.JSON(http.StatusForbidden, gin.H{"error": "gateway denied: " + err.Error()})
		return
	}
	_, _ = s.Reporter.Report(audit.Event{
		Timestamp: time.Now(), Operator: cl.User, Tenant: cl.Tenant, Env: cl.Env,
		Cluster: cl.Cluster, DeviceID: cl.DeviceID, Action: "sensitive_2fa",
		Endpoint: "/ops/sensitive", Detail: "short token " + tr.Ref,
	})
	// 注意:移动端只拿到短时凭证引用,最终执行由桌面端完成
	c.JSON(http.StatusAccepted, gin.H{
		"status":   "preflight_ok",
		"exec_ref": tr.Ref,
		"redirect": "https://console.paas.internal/exec?ref=" + tr.Ref,
		"message":  "移动端已二次认证并签发短时凭证,请回桌面端完成最终执行",
	})
}

// ackStart / os_GetTOTPSecret 为演示辅助(生产从 KMS 取 secret、从告警推送时间算时延)
func ackStart(alertID string) time.Time { return time.Now().Add(-30 * time.Second) }
func os_GetTOTPSecret(user string) string { return "JBSWY3DPEHPK3PXP" } // 演示种子,生产存 KMS

3.9 启动入口

// file: cmd/mobile/main.go —— 装配 client-go(经网关只读)、设备绑定、TOTP、审计、gin,并暴露 /metrics

// file: cmd/mobile/main.go
package main

import (
	"fmt"
	"net/http"
	"os"

	"github.com/prometheus/client_golang/prometheus/promhttp"
	"k8s.io/client-go/rest"

	"module-12-mobile-console/internal/api"
	"module-12-mobile-console/internal/audit"
	"module-12-mobile-console/internal/auth"
	"module-12-mobile-console/internal/k8s"
	_ "module-12-mobile-console/internal/metrics" // 注册指标
)

func main() {
	gwURL := os.Getenv("K8S_GATEWAY_URL")
	if gwURL == "" {
		gwURL = "https://k8s-gateway.paas.internal"
	}
	// 1) 经网关的只读 client-go(移动端不持 kubeconfig)
	cfg := &rest.Config{
		Host:        gwURL,
		BearerToken: os.Getenv("MOBILE_TOKEN"),
		TLSClientConfig: rest.TLSClientConfig{CAData: []byte(os.Getenv("GATEWAY_CA"))},
	}
	cs, err := k8s.NewClientSet(cfg)
	if err != nil {
		panic(err)
	}
	// 2) 设备绑定(读 ConfigMap)
	binder := &auth.DeviceBinder{Client: cs, Namespace: "paas-mobile", CMName: "device-bindings"}
	// 3) 审计上报
	reporter, err := audit.NewReporter("/var/lib/mobile/audit.log")
	if err != nil {
		panic(err)
	}
	defer reporter.Close()
	// 4) TOTP
	totp := &auth.TOTPVerifier{Issuer: os.Getenv("TOTP_ISSUER")}
	// 5) HTTP 服务
	srv := &api.Server{
		RO:       &k8s.ReadOnlyClient{Client: cs},
		Binder:   binder,
		TOTP:     totp,
		Reporter: reporter,
		JWT:      os.Getenv("JWT_SECRET"),
	}
	go func() {
		http.Handle("/metrics", promhttp.Handler())
		if err := http.ListenAndServe(":2112", nil); err != nil {
			fmt.Println("metrics:", err)
		}
	}()
	r := srv.NewRouter()
	if err := r.RunTLS(":8443", "/etc/mobile-tls/tls.crt", "/etc/mobile-tls/tls.key"); err != nil {
		fmt.Println("server:", err)
	}
}

注:k8s.NewClientSetinternal/k8s/readonly.goNewClient 的别名封装,工程 go.mod 已包含 k8s.io/client-gogithub.com/gin-gonic/gingithub.com/pquerna/otpgithub.com/prometheus/client_golang。全工程可 go build ./...

3.10 与多 K8s 集群交互、API 调用链路

移动端不直接持有任何集群凭证。它只对接统一 K8s API 网关暴露的「移动受限角色」接口子集:只读查询(看板/状态)、审批回调、告警认领、二次认证签发短时凭证。网关侧维护加密切面 kubeconfig(KMS 信封加密),移动端永远拿不到原始 kubeconfig。

sequenceDiagram
  participant P as 手机 App(设备绑定)
  participant G as 移动网关接入层
  participant W as K8s API 网关
  participant C as 生产集群 apiserver
  P->>G: 登录(设备指纹 + 令牌)
  G->>G: 校验移动受限 RBAC
  P->>G: 拉取只读看板(env=prod)
  G->>W: 受限查询(仅该租户 ns)
  W->>C: GET 状态(只读)
  C->>W: 返回
  W->>G: 返回(过滤 tenant)
  G->>P: 渲染看板
  P->>G: 发起紧急回滚确认
  G->>G: 触发二次认证(TOTP)
  Note over G: 认证通过 + 走审批工单
  G->>W: 签发短时凭证(ttl=10m)

这张时序图说明移动端读路径完全走「只读 + 过滤」,写路径被二次认证与审批工单双重拦在网关前。

3.11 与联邦 Prometheus、Grafana、Alertmanager 联动

移动端是联邦监控的「只读消费者」:

  • 看板数据来自中心 Prometheus + Thanos,指标带 tenant/app/env/cluster 标签,移动端按当前用户租户过滤。
  • 指标:mobile_dashboard_query_totalmobile_alert_ack_seconds(认领时延)、mobile_approval_action_total、设备相关 mobile_device_bind_totalmobile_api_error_total(错误率告警,见 3.12)。
  • Grafana 提供移动只读视图(精简面板,适配小屏);Alertmanager 把告警推 IM,移动端认领后回写 alert_ack 状态,联邦里可见认领人/时延。

联邦抓取配置(# file: config/servicemonitor.yaml):

# file: config/servicemonitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: mobile-console
  namespace: paas-mobile
  labels:
    tenant: platform
    app: mobile-console
    env: prod
    cluster: paas-control
spec:
  selector:
    matchLabels:
      app: mobile-console
  endpoints:
    - port: metrics
      interval: 15s
      relabelings:
        - sourceLabels: [__meta_kubernetes_pod_label_tenant]
          targetLabel: tenant
        - sourceLabels: [__meta_kubernetes_pod_label_app]
          targetLabel: app
        - sourceLabels: [__meta_kubernetes_pod_label_env]
          targetLabel: env
        - sourceLabels: [__meta_kubernetes_pod_label_cluster]
          targetLabel: cluster

下图是联邦架构联动拓扑。

graph LR
  A[移动端控制台
只读消费 + 操作指标] --> B[各集群 Prometheus Agent] B -->|federation| C[中心 Prometheus + Thanos] C --> D[Grafana 移动只读视图] C --> E[Alertmanager 分级告警] E -->|推送| F[企业微信/钉钉 → 手机] A -->|认领回写| E A -->|操作指标| B G[集群健康] --> B

这张联邦联动图说明移动端既消费联邦监控,也向联邦回写认领与操作指标,全链路带租户标签隔离。

3.12 告警规则(生产级)

# file: deploy/prometheus-rules.yaml —— 移动 API 错误率、认领时延、设备绑定拒绝等告警,全部带 tenant 标签。

# file: deploy/prometheus-rules.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: mobile-console-rules
  namespace: paas-mobile
  labels:
    tenant: platform
    app: mobile-console
    env: prod
    cluster: paas-control
spec:
  groups:
    - name: mobile.sla
      rules:
        # 移动 API 错误率突增(按 endpoint/code 维度)
        - alert: MobileAPIErrorRateHigh
          expr: |
            sum by (tenant, env, cluster) (
              rate(mobile_api_error_total[5m])
            ) / sum by (tenant, env, cluster) (
              (rate(mobile_dashboard_query_total[5m]) + rate(mobile_approval_action_total[5m]) + 1)
            ) > 0.05            
          for: 10m
          labels:
            severity: warning
            tenant: "{{ $labels.tenant }}"
            env: "{{ $labels.env }}"
            cluster: "{{ $labels.cluster }}"
          annotations:
            summary: "租户 {{ $labels.tenant }} 移动 API 错误率 > 5%"
        # 告警认领时延过高
        - alert: MobileAlertAckSlow
          expr: |
            histogram_quantile(0.95, sum by (le, tenant, env, cluster) (
              rate(mobile_alert_ack_seconds_bucket[1h])
            )) > 300            
          for: 10m
          labels:
            severity: warning
            tenant: "{{ $labels.tenant }}"
            env: "{{ $labels.env }}"
            cluster: "{{ $labels.cluster }}"
          annotations:
            summary: "租户 {{ $labels.tenant }} 移动告警认领 p95 > 300s"
        # 设备绑定被拒频发(疑似异常设备尝试)
        - alert: MobileDeviceBindRejected
          expr: |
            increase(mobile_device_bind_total{result="rejected"}[1h]) > 10            
          for: 5m
          labels:
            severity: critical
            tenant: "{{ $labels.tenant }}"
            env: "{{ $labels.env }}"
            cluster: "{{ $labels.cluster }}"
          annotations:
            summary: "租户 {{ $labels.tenant }} 1h 内设备绑定拒绝 > 10 次"

3.13 与 RBAC / 审批 / 审计联动

  • RBAC:移动端角色是模块 01 中独立定义的「mobile-readonly + approval + alert-ack」(见 deploy/mobile-rbac.yaml),不含任何写权限;设备绑定信息与角色一同校验(见 internal/middleware/auth.go)。
  • 审批:移动审批是模块 11 引擎的入口(/approval/:ticket/preapprove 仅「预批」),移动端不签发最终执行凭证,高危动作的「预批」仍回桌面端二次认证完成。
  • 审计:移动端每一次登录、看板查询、审批、认领、二次认证都写审计(含 device_id,见 internal/audit/audit.go),留存 90 天+,WORM 防篡改。

四、端到端标准操作流程

分角色给出可直接用于培训的分步指南,并区分【测试环境】与【生产环境】差异。

⚠️ 新手必踩的坑:测试环境常常为方便给移动端开了「可写」开关,导致测试时一切正常,上生产后被网关移动受限角色拒之门外。移动端写开关在生产必须关闭,敏感操作统一走桌面端。

角色:值班运维(手机收告警 + 认领)

步骤 1:手机收到 Alertmanager 经 IM 推送的告警(脱敏摘要 + 严重度)。

步骤 2:在移动端点击「认领」,系统记录 operator + device_id + alert_id 入审计,联邦 mobile_alert_ack_seconds 计时结束。

步骤 3:若需上级介入,点「升级」,选择值班并通知;升级动作同样入审计。

# 移动端告警认领(等效 curl,演示链路)
curl -sk https://mobile.paas.internal/api/v1/alerts/alert-9f3c/ack \
  -H "Authorization: Bearer $MOBILE_TOKEN" \
  -X POST
# 期望 200,且审计出现一条 device_id=fp:9f3c:... 的 ack 记录

角色:租户管理员(移动审批)

步骤 4:收到审批待办推送,打开移动端查看脱敏摘要与风险等级。

步骤 5:普通变更在移动端直接批;生产高危变更在移动端仅「预批」,跳转桌面端二次认证页完成最终凭证签发(见模块 11)。

# 普通变更移动端预批
curl -sk https://mobile.paas.internal/api/v1/approval/ap-2026-0001/preapprove \
  -H "Authorization: Bearer $MOBILE_TOKEN" -X POST \
  -d '{"decision":"approve","riskHigh":false}'

# 高危变更:返回 redirect 到桌面端二次认证
curl -sk https://mobile.paas.internal/api/v1/approval/ap-2026-0002/preapprove \
  -H "Authorization: Bearer $MOBILE_TOKEN" -X POST \
  -d '{"decision":"approve","riskHigh":true}'
# 期望 202 + redirect=https://console.paas.internal/approval/ap-2026-0002?step=2fa

角色:平台运维(桌面端执行,移动端仅监控)

步骤 7:移动端看到发布进度(只读),真要执行高危动作回到桌面端 + 二次认证 + 审批凭证。

sequenceDiagram
  participant P as 手机 App
  participant G as 移动网关接入层
  participant E as 审批引擎
  participant A as Alertmanager/IM
  participant D as 桌面端(二次认证)
  P->>G: 登录(设备绑定)
  G->>A: 拉取待办 + 告警
  P->>G: 认领告警
  G->>A: 回写 ack(入审计)
  P->>G: 移动审批(高危)
  G->>E: 预批 + 转桌面
  E->>D: 通知跳转二次认证
  D->>D: TOTP 认证 + 签发凭证
  D->>G: 执行结果
  G->>P: 推送进度(只读)

这条时序图是移动端端到端标准操作:读/批/认领在手机,高危执行回桌面,全程审计。

【测试环境】与【生产环境】差异步骤

  • 测试环境:可开「移动端可写」开关便于联调;二次认证可降级为单因素;设备绑定可关闭;指标 env=test 隔离。
  • 生产环境:移动端强制只读(写开关关闭);高危动作强制二次认证 + 桌面端执行;设备绑定强制开启;审计标记 prod 高优先级;离线推送走正式通道。

五、生产环境管控与安全约束

生产环境对移动端有极严格的约束。

默认只读:移动端角色出厂即只读,任何「移动端写开关」在生产被平台策略强制关闭,无法由管理员临时打开(防止误操作放开)。

敏感操作二次认证:所有敏感动作(紧急回滚确认、静默申请)触发 TOTP/短信二次认证,且仅签发短时(默认 10 分钟)凭证并强制走审批工单,最终执行仍在桌面端。

设备绑定与最小权限:账号与设备指纹绑定,新设备需审批放行;越狱/root 设备禁止登录或降级只读;RBAC 不含写权限。

审计规则:移动端全动作 WORM 审计留存 90 天+,含 device_id;尝试关闭设备绑定或越权写会被记为安全事件并告警。

⚠️ 故障熔断:若移动网关接入层异常,仅影响移动端可用性与推送,不影响桌面端生产操作——移动端是「延伸通道」而非「唯一通道」,这是有意为之的隔离。

「测试环境 vs 生产环境 差异化管控」对照表:

管控维度测试环境生产环境
移动端写开关可开(联调)强制关闭(策略锁)
二次认证可降级单因素强制 TOTP/短信
设备绑定可关闭强制开启
高危动作执行地允许移动端仅桌面端 + 二次认证
越狱/root 设备允许登录禁止或降级只读
审计留存90 天90 天+ 含 device_id 高优先级
联邦指标标签env=test 隔离env=prod 独立告警路由
推送通道测试群正式通道 + 值班

六、常见生产故障与解决方案

结合多集群、联邦监控场景,列举高频问题。

故障 1:手机收不到告警/待办推送

  • 排查:①厂商通道/APNs 证书是否过期;②IM 机器人 webhook 连通性(联邦有 im_notify_fail_total);③用户是否在被推送租户范围内。
  • 优化:监控推送失败率并告警;推送 payload 脱敏避免被网关拦。

故障 2:移动端登录提示「设备未绑定」

  • 排查:换手机/重装 App 导致设备指纹变化;或管理员未审批新设备。
  • 优化:走「新设备绑定审批」工单;越狱/root 检测命中会直接拒绝。

故障 3:高危变更在移动端无法最终执行

  • 排查:这是预期行为——移动端仅「预批」,需跳转桌面端二次认证签发凭证(见模块 11)。
  • 优化:在移动端明确文案引导回桌面;确保审批引擎与桌面端二次认证链路可达。

故障 4:看板数据为空或跨租户串数据

  • 排查:①网关移动受限角色过滤是否生效;②联邦指标 tenant 标签是否缺失(上游采集漏注);③Grafana 移动视图数据源是否指向中心 Prometheus。
  • 优化:给指标采集加 tenant 强制注入校验;看板查询强制带 tenant 过滤。

故障 5:有人试图在移动端直连集群改配置

  • 排查:物理隔离集群仅网关可达;移动端角色无写权限,直连应在网络层不可达。
  • 优化:NetworkPolicy 仅放行网关;移动端写请求被网关受限角色 403 并记安全审计。
flowchart TD
  A[移动端异常] --> B{收不到推送?}
  B -->|是| C[查通道证书 + webhook + 租户范围]
  B -->|否| D{登录被拒?}
  D -->|是| E[设备绑定 / 越狱检测]
  D -->|否| F{高危无法执行?}
  F -->|是| G[预期: 回桌面二次认证]
  F -->|否| H{看板空/串数据?}
  H -->|是| I[查 tenant 标签 + 角色过滤]
  H -->|否| J[查移动网关 Pod / 接入层]
  C --> K[修证书/通道 + 告警]
  E --> L[新设备审批 / 拒绝越狱]
  G --> M[引导桌面端]
  I --> N[补 tenant 注入 + 强制过滤]
  J --> O[多副本 + 容灾]

这张故障排查流程图覆盖从「收不到推送」到「看板串数据」的典型路径,帮助移动端值班快速定位。


自测题与动手练习

自测题(5 道)

  1. 为什么移动端必须「默认只读」?架构上由哪一层强制保证?
  2. 移动端做高危变更时,为什么只能「预批」而不能直接执行?最终凭证在哪里签发?
  3. 设备绑定解决了什么安全风险?新设备登录的流程是什么?
  4. 移动端的监控看板数据来自哪套体系?为什么必须带 tenant 标签过滤?
  5. 测试环境与生产环境在「移动端写开关」与「二次认证」上有何差异?

动手练习(3 个)

  1. 用受限 RBAC 角色在手机登录,确认看板仅显示本租户数据且没有任何写按钮。
  2. 触发一条测试告警,在移动端完成认领并升级,到 Grafana 看 mobile_alert_ack_seconds
  3. 在移动端点「执行紧急回滚」,确认被拦截并要求跳转桌面端二次认证,审计中出现一条 device_id 记录。

本章小结

  • 移动端轻量化控制台是 PaaS 的安全延伸通道:默认只读、最小权限、设备绑定,所有写操作收口在桌面端 + 二次认证 + 审批闸门(★禁止删减:只读默认、敏感操作二次认证、审计)。
  • 它对接统一 K8s API 网关的「移动受限角色」接口子集(见 deploy/mobile-rbac.yaml),拿不到原始 kubeconfig,也从架构上无法直连集群改配置(代码见 internal/k8s/readonly.gointernal/gateway/token.go)。
  • 移动审批是模块 11 引擎的手机入口(见 internal/api/server.go/approval/preapprove);监控看板消费联邦 Prometheus + Thanos 的只读视图;告警认领/升级回写 Alertmanager 与联邦指标。
  • 测试环境可放宽写开关与二次认证便于联调,生产环境强制只读、二次认证、设备绑定,差异必须写入管控对照表。
  • 全动作带 device_id 入审计留存 90 天+(见 internal/audit/audit.go),移动端故障不影响桌面端生产操作(隔离设计)。

至此 12 篇模块教程全部就位:从租户权限、多集群纳管、应用生命周期,到观测、自动化、安全、成本、后台、开放 API,再到审批闸门与移动端延伸,构成一套金融/政企级商用 PaaS 运维白皮书的完整闭环。

About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!