深入Node鉴权模式:kubelet权限控制的核心机制解析

前言

去年参与了一次K8s安全审计,发现我们集群的一个重大安全隐患:所有kubelet都使用了同一个cluster-admin证书!这意味着任何一个节点被攻破,攻击者都能控制整个集群。

正确的做法应该是:为每个kubelet颁发独立的客户端证书,并启用Node鉴权模式。Node鉴权会限制kubelet只能操作自己节点上的Pod,即使证书泄露,影响范围也仅限于单个节点。

今天我们就深入源码,看看Node鉴权是如何实现的。

为什么需要Node鉴权?

kubelet的权限困境

kubelet是运行在每个节点上的代理,它需要执行很多操作:

  • 读取绑定到本节点的Pod信息
  • 更新Pod状态
  • 读取Secret和ConfigMap(为Pod挂载卷)
  • 创建事件
  • 更新节点状态

如果没有限制,kubelet可以:

  • 读取所有节点的所有Pod
  • 读取整个集群的所有Secret(包括其他namespace的)
  • 删除任意Pod

这显然是不可接受的。

Node鉴权的核心思想

┌─────────────────────────────────────────────────────────────────┐
│                       Node鉴权核心思想                           │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ┌─────────────┐        ┌─────────────┐        ┌─────────────┐  │
│  │   Node A    │───────→│    Pod X    │───────→│   Secret    │  │
│  │  (kubelet)  │        │  (绑定到A)   │        │  (Pod使用)   │  │
│  └─────────────┘        └─────────────┘        └─────────────┘  │
│         │                                                        │
│         │ 只能访问                                                │
│         ▼                                                        │
│  ┌─────────────┐        ┌─────────────┐                         │
│  │   Node B    │──X────→│    Pod Y    │  ← 不能访问其他节点Pod   │
│  │  (kubelet)  │        │  (绑定到B)   │                         │
│  └─────────────┘        └─────────────┘                         │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Node鉴权的基本原则

  1. kubelet只能操作绑定到自己节点上的Pod
  2. kubelet只能读取被Pod引用的Secret/ConfigMap/PVC
  3. kubelet不能读取其他节点的任何资源

Node鉴权的4条规则

Node鉴权器的逻辑非常清晰,遵循4条规则:

// 规则1: 如果不是来自node的请求,不表态(让其他鉴权器处理)
if !isNode {
    return DecisionNoOpinion
}

// 规则2: 如果无法识别node名称,拒绝
if len(nodeName) == 0 {
    return DecisionNoOpinion, "unknown node"
}

// 规则3: 如果是敏感资源(secret/configmap/pv/pvc),需要检查绑定关系
if resource in [secret, configmap, pvc, pv] {
    // - 只允许get操作
    // - 检查资源是否被绑定到该节点的Pod引用
    return checkBinding(nodeName, resource)
}

// 规则4: 其他资源使用静态RBAC规则
return checkStaticRules(attrs)

源码解析:NodeAuthorizer的实现

Authorize方法入口

// plugin/pkg/auth/authorizer/node/node_authorizer.go

func (r *NodeAuthorizer) Authorize(
    ctx context.Context, 
    attrs authorizer.Attributes,
) (authorizer.Decision, string, error) {
    
    // ===== 规则1: 识别请求者是否为kubelet =====
    nodeName, isNode := r.identifier.NodeIdentity(attrs.GetUser())
    if !isNode {
        // 不是kubelet,不表态,让其他鉴权器处理
        return authorizer.DecisionNoOpinion, "", nil
    }
    
    // ===== 规则2: 识别具体的node名称 =====
    if len(nodeName) == 0 {
        klog.V(2).Infof("NODE DENY: unknown node for user %q", attrs.GetUser().GetName())
        return authorizer.DecisionNoOpinion, 
               fmt.Sprintf("unknown node for user %q", attrs.GetUser().GetName()), 
               nil
    }
    
    // ===== 规则3: 敏感资源的特殊处理 =====
    if attrs.IsResourceRequest() {
        requestResource := schema.GroupResource{
            Group: attrs.GetAPIGroup(), 
            Resource: attrs.GetResource(),
        }
        
        switch requestResource {
        case secretResource:
            // Secret只能读,且必须是被Pod引用的
            return r.authorizeReadNamespacedObject(nodeName, secretVertexType, attrs)
            
        case configMapResource:
            // ConfigMap只能读,且必须是被Pod引用的
            return r.authorizeReadNamespacedObject(nodeName, configMapVertexType, attrs)
            
        case pvcResource:
            // PVC可以读,也可以update status(如果启用了ExpandPersistentVolumes)
            if r.features.Enabled(features.ExpandPersistentVolumes) {
                if attrs.GetSubresource() == "status" {
                    return r.authorizeStatusUpdate(nodeName, pvcVertexType, attrs)
                }
            }
            return r.authorizeGet(nodeName, pvcVertexType, attrs)
            
        case pvResource:
            // PV只能读
            return r.authorizeGet(nodeName, pvVertexType, attrs)
            
        case vaResource:
            // VolumeAttachment只能读
            return r.authorizeGet(nodeName, vaVertexType, attrs)
            
        case svcAcctResource:
            // ServiceAccount可以创建token(用于ServiceAccountTokenVolume)
            return r.authorizeCreateToken(nodeName, serviceAccountVertexType, attrs)
            
        case leaseResource:
            // Lease用于节点心跳
            return r.authorizeLease(nodeName, attrs)
            
        case csiNodeResource:
            // CSINode用于CSI驱动
            return r.authorizeCSINode(nodeName, attrs)
        }
    }
    
    // ===== 规则4: 其他资源使用静态RBAC规则 =====
    if rbac.RulesAllow(attrs, r.nodeRules...) {
        return authorizer.DecisionAllow, "", nil
    }
    
    return authorizer.DecisionNoOpinion, "", nil
}

NodeIdentity:如何识别kubelet

Node鉴权器使用NodeIdentifier来识别请求是否来自kubelet,并获取节点名称:

// staging/src/k8s.io/apiserver/pkg/authentication/node/node_identifier.go

type DefaultNodeIdentifier struct{}

func (DefaultNodeIdentifier) NodeIdentity(user user.Info) (string, bool) {
    name := user.GetName()
    
    // kubelet的用户名格式:system:node:<node-name>
    if !strings.HasPrefix(name, "system:node:") {
        return "", false
    }
    
    // 提取node名称
    nodeName := strings.TrimPrefix(name, "system:node:")
    return nodeName, true
}

关键点

  • kubelet必须使用system:node:<node-name>格式的用户名
  • 这是X.509证书的CommonName(CN字段)

证书示例

# 为kubelet创建证书
openssl req -new -key kubelet.key -out kubelet.csr \
  -subj "/CN=system:node:node-1/O=system:nodes"

# CN = system:node:node-1
# O = system:nodes(用户组)

authorizeReadNamespacedObject:敏感资源的鉴权

对于Secret、ConfigMap等敏感资源,Node鉴权器会执行更严格的检查:

func (r *NodeAuthorizer) authorizeReadNamespacedObject(
    nodeName string, 
    startingType vertexType, 
    attrs authorizer.Attributes,
) (authorizer.Decision, string, error) {
    
    // 1. 只允许读操作(get/list/watch)
    switch attrs.GetVerb() {
    case "get", "list", "watch":
        // ok
    default:
        klog.V(2).Infof("NODE DENY: '%s' %#v", nodeName, attrs)
        return authorizer.DecisionNoOpinion, 
               "can only read resources of this type", 
               nil
    }
    
    // 2. 不允许读取子资源
    if len(attrs.GetSubresource()) > 0 {
        klog.V(2).Infof("NODE DENY: '%s' %#v", nodeName, attrs)
        return authorizer.DecisionNoOpinion, 
               "cannot read subresource", 
               nil
    }
    
    // 3. 必须有namespace
    if len(attrs.GetNamespace()) == 0 {
        klog.V(2).Infof("NODE DENY: '%s' %#v", nodeName, attrs)
        return authorizer.DecisionNoOpinion, 
               "can only read namespaced object of this type", 
               nil
    }
    
    // 4. 检查资源是否被该节点的Pod引用
    return r.authorize(nodeName, startingType, attrs)
}

authorize:检查资源绑定关系

这是Node鉴权的核心逻辑——检查资源是否被节点的Pod引用:

func (r *NodeAuthorizer) authorize(
    nodeName string, 
    startingType vertexType, 
    attrs authorizer.Attributes,
) (authorizer.Decision, string, error) {
    
    // 必须有资源名称
    if len(attrs.GetName()) == 0 {
        klog.V(2).Infof("NODE DENY: '%s' %#v", nodeName, attrs)
        return authorizer.DecisionNoOpinion, "No Object name found", nil
    }
    
    // 检查是否存在从Node到该资源的路径
    ok, err := r.hasPathFrom(
        nodeName,           // 起点:节点名称
        startingType,       // 资源类型(secret/configmap等)
        attrs.GetNamespace(), // 资源namespace
        attrs.GetName(),    // 资源名称
    )
    
    if err != nil {
        klog.V(2).InfoS("NODE DENY", "err", err)
        return authorizer.DecisionNoOpinion, 
               fmt.Sprintf("no relationship found between node '%s' and this object", nodeName), 
               nil
    }
    
    if !ok {
        klog.V(2).Infof("NODE DENY: '%s' %#v", nodeName, attrs)
        return authorizer.DecisionNoOpinion, 
               fmt.Sprintf("no relationship found between node '%s' and this object", nodeName), 
               nil
    }
    
    // 找到了路径,允许访问
    return authorizer.DecisionAllow, "", nil
}

Pod-Node关系图:Node鉴权的核心数据结构

为什么需要关系图?

要判断"Secret是否被Node上的Pod引用",最直接的方法是:

  1. 查询该Node上的所有Pod
  2. 检查每个Pod是否引用了该Secret

但这种方法每次请求都要查询etcd,性能很差。

Node鉴权器的解决方案是:维护一个内存中的Pod-Node关系图,实时跟踪Pod调度、Secret引用等关系。

Graph数据结构

// plugin/pkg/auth/authorizer/node/graph.go

type Graph struct {
    lock sync.RWMutex
    
    // 各种类型的顶点
    nodes      map[types.UID]nodeVertex
    pods       map[types.UID]podVertex
    secrets    map[types.UID]secretVertex
    configmaps map[types.UID]configMapVertex
    pvcs       map[types.UID]pvcVertex
    pvs        map[types.UID]pvVertex
    
    // 邻接表,记录边关系
    // 从destination可以到达哪些source
    destEdgeIndex map[vertex][]edge
}

// 顶点类型
type vertex interface {
    ID() types.UID
    Type() vertexType
}

// Pod顶点
type podVertex struct {
    uid           types.UID
    namespace     string
    name          string
    nodeUID       types.UID    // 绑定的Node
    serviceAccount string
    secrets       []types.UID  // 引用的Secret
    configmaps    []types.UID  // 引用的ConfigMap
    pvcs          []types.UID  // 引用的PVC
}

// 边关系
type edge struct {
    from vertex
    to   vertex
}

关系图示例

┌─────────────────────────────────────────────────────────────────┐
│                     Pod-Node关系图                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ┌─────────────────┐                                             │
│  │ Node: node-1    │                                             │
│  │ UID: abc-123    │                                             │
│  └────────┬────────┘                                             │
│           │                                                      │
│           │ 调度到                                                │
│           ▼                                                      │
│  ┌─────────────────┐     引用      ┌─────────────────┐          │
│  │ Pod: web-1      │──────────────→│ Secret: db-pass │          │
│  │ UID: def-456    │               │ UID: ghi-789    │          │
│  │ NodeUID: abc-123│     引用      └─────────────────┘          │
│  └─────────────────┘──────────────→┌─────────────────┐          │
│           │                        │ ConfigMap: cfg  │          │
│           │ 引用                   │ UID: jkl-012    │          │
│           ▼                        └─────────────────┘          │
│  ┌─────────────────┐                                             │
│  │ PVC: data-pvc   │                                             │
│  │ UID: mno-345    │                                             │
│  └─────────────────┘                                             │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

如何查询关系

// hasPathFrom检查是否存在从Node到指定资源的路径
func (r *NodeAuthorizer) hasPathFrom(
    nodeName string, 
    startingType vertexType, 
    namespace, name string,
) (bool, error) {
    
    r.graph.lock.RLock()
    defer r.graph.lock.RUnlock()
    
    // 1. 找到Node的顶点
    nodeVertex, exists := r.graph.getNodeByName(nodeName)
    if !exists {
        return false, fmt.Errorf("node %q not found in graph", nodeName)
    }
    
    // 2. 找到目标资源的顶点
    destVertex, exists := r.graph.getVertex(startingType, namespace, name)
    if !exists {
        return false, fmt.Errorf("destination %s/%s/%s not found in graph", 
            startingType, namespace, name)
    }
    
    // 3. 检查是否存在从Node到destVertex的路径
    // 使用BFS或DFS遍历图
    return r.graph.hasPath(nodeVertex, destVertex), nil
}

关系图的动态更新

关系图通过Informer监听资源变化,实时更新:

// plugin/pkg/auth/authorizer/node/graph_builder.go

func (b *graphBuilder) Run(stopCh <-chan struct{}) {
    // 监听Pod变化
    b.podInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
        AddFunc:    b.addPod,
        UpdateFunc: b.updatePod,
        DeleteFunc: b.deletePod,
    })
    
    // 监听Node变化
    b.nodeInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
        AddFunc:    b.addNode,
        UpdateFunc: b.updateNode,
        DeleteFunc: b.deleteNode,
    })
    
    // 监听Secret变化
    b.secretInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
        AddFunc:    b.addSecret,
        UpdateFunc: b.updateSecret,
        DeleteFunc: b.deleteSecret,
    })
    
    // ... 其他资源
    
    <-stopCh
}

// Pod添加时的处理
func (b *graphBuilder) addPod(obj interface{}) {
    pod := obj.(*v1.Pod)
    b.graph.addPod(pod)
}

func (g *Graph) addPod(pod *v1.Pod) {
    g.lock.Lock()
    defer g.lock.Unlock()
    
    vertex := &podVertex{
        uid:       pod.UID,
        namespace: pod.Namespace,
        name:      pod.Name,
        nodeUID:   pod.Spec.NodeName, // 记录绑定的Node
    }
    
    // 提取引用的Secret
    for _, volume := range pod.Spec.Volumes {
        if volume.Secret != nil {
            vertex.secrets = append(vertex.secrets, volume.Secret.SecretName)
        }
        if volume.ConfigMap != nil {
            vertex.configmaps = append(vertex.configmaps, volume.ConfigMap.Name)
        }
        if volume.PersistentVolumeClaim != nil {
            vertex.pvcs = append(vertex.pvcs, volume.PersistentVolumeClaim.ClaimName)
        }
    }
    
    // 添加到图
    g.pods[pod.UID] = vertex
    
    // 添加边:Node -> Pod
    if len(pod.Spec.NodeName) > 0 {
        nodeUID := g.getNodeUID(pod.Spec.NodeName)
        g.addEdge(nodeUID, pod.UID)
    }
    
    // 添加边:Pod -> Secret
    for _, secretName := range vertex.secrets {
        secretUID := g.getSecretUID(pod.Namespace, secretName)
        g.addEdge(pod.UID, secretUID)
    }
    
    // ... 其他边
}

静态RBAC规则

对于非敏感资源,Node鉴权器使用预定义的静态RBAC规则:

// plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go

func NodeRules() []rbacv1.PolicyRule {
    return []rbacv1.PolicyRule{
        // 认证相关:可以创建tokenreviews和subjectaccessreviews
        rbacv1helpers.NewRule("create").Groups(authenticationGroup).Resources("tokenreviews").RuleOrDie(),
        rbacv1helpers.NewRule("create").Groups(authorizationGroup).Resources("subjectaccessreviews", "localsubjectaccessreviews").RuleOrDie(),
        
        // 服务发现:可以读取Service
        rbacv1helpers.NewRule("get", "list", "watch").Groups(legacyGroup).Resources("services").RuleOrDie(),
        
        // 节点管理:可以操作Node资源(结合NodeRestriction准入控制限制只能操作自己)
        rbacv1helpers.NewRule("create", "get", "list", "watch").Groups(legacyGroup).Resources("nodes").RuleOrDie(),
        rbacv1helpers.NewRule("update", "patch").Groups(legacyGroup).Resources("nodes/status").RuleOrDie(),
        
        // 事件:可以创建事件
        rbacv1helpers.NewRule("create", "update", "patch").Groups(legacyGroup).Resources("events").RuleOrDie(),
        
        // Pod管理:可以读取、创建、删除Pod
        // NodeRestriction准入控制限制只能操作绑定到自己的Pod
        rbacv1helpers.NewRule("get", "list", "watch").Groups(legacyGroup).Resources("pods").RuleOrDie(),
        rbacv1helpers.NewRule("create", "delete").Groups(legacyGroup).Resources("pods").RuleOrDie(),
        rbacv1helpers.NewRule("update", "patch").Groups(legacyGroup).Resources("pods/status").RuleOrDie(),
        rbacv1helpers.NewRule("create").Groups(legacyGroup).Resources("pods/eviction").RuleOrDie(),
        
        // 证书轮转:可以操作CertificateSigningRequest
        rbacv1helpers.NewRule("create", "get", "list", "watch").Groups(certificatesGroup).Resources("certificatesigningrequests").RuleOrDie(),
        
        // 租约:用于节点心跳
        rbacv1helpers.NewRule("get", "create", "update", "patch", "delete").Groups("coordination.k8s.io").Resources("leases").RuleOrDie(),
        
        // CSI相关
        rbacv1helpers.NewRule("get").Groups(storageGroup).Resources("volumeattachments").RuleOrDie(),
        rbacv1helpers.NewRule("get", "watch", "list").Groups("storage.k8s.io").Resources("csidrivers").RuleOrDie(),
        rbacv1helpers.NewRule("get", "create", "update", "patch", "delete").Groups("storage.k8s.io").Resources("csinodes").RuleOrDie(),
        
        // RuntimeClass
        rbacv1helpers.NewRule("get", "list", "watch").Groups("node.k8s.io").Resources("runtimeclasses").RuleOrDie(),
    }
}

Node鉴权 + NodeRestriction准入控制

Node鉴权器通常与NodeRestriction准入控制插件配合使用,实现双重保护:

层级组件作用
鉴权层Node Authorizer限制kubelet能访问哪些资源
准入层NodeRestriction限制kubelet能修改哪些字段

NodeRestriction的作用

// 限制kubelet只能修改自己的Node资源
// 不允许:
// - 修改Node的labels(除了kubernetes.io前缀的)
// - 修改Node的annotations
// - 删除Node

// 限制kubelet只能操作绑定到自己的Pod
// 不允许:
// - 创建不绑定到自己节点的Pod
// - 修改Pod的spec(只能修改status)

启用方式

kube-apiserver \
  --authorization-mode=Node,RBAC \
  --enable-admission-plugins=NodeRestriction

配置Node鉴权

1. 启用Node鉴权模式

kube-apiserver \
  --authorization-mode=Node,RBAC  # Node必须在RBAC之前

注意:Node模式必须在RBAC之前,因为Node模式对kubelet有特殊的处理逻辑。

2. 为kubelet颁发证书

# 创建证书签名请求
openssl req -new -newkey rsa:2048 -nodes \
  -keyout kubelet.key \
  -out kubelet.csr \
  -subj "/CN=system:node:node-1/O=system:nodes"

# 使用CA签名
cat > kubelet-csr.json <<EOF
{
  "CN": "system:node:node-1",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "O": "system:nodes"
    }
  ]
}
EOF

cfssl gencert \
  -ca=ca.pem \
  -ca-key=ca-key.pem \
  -config=ca-config.json \
  -profile=kubernetes \
  kubelet-csr.json | cfssljson -bare kubelet

3. kubelet配置

# /var/lib/kubelet/config.yaml
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
authentication:
  x509:
    clientCAFile: "/etc/kubernetes/pki/ca.crt"
  webhook:
    enabled: true
    cacheTTL: 2m0s
authorization:
  mode: Webhook
  webhook:
    cacheAuthorizedTTL: 5m0s
    cacheUnauthorizedTTL: 30s
tlsCertFile: "/etc/kubernetes/pki/kubelet.crt"
tlsPrivateKeyFile: "/etc/kubernetes/pki/kubelet.key"

踩坑实录:Node鉴权常见问题

坑1:kubelet无法读取Secret

现象:kubelet报错cannot get secret,Pod无法启动

排查

# 检查kubelet证书CN
openssl x509 -in /etc/kubernetes/pki/kubelet.crt -noout -subject
# Subject: CN = system:node:node-1, O = system:nodes

# 检查Pod是否调度到该节点
kubectl get pod <pod-name> -o wide
# NODE列应该是 node-1

# 检查Secret是否被Pod引用
kubectl get pod <pod-name> -o yaml | grep -A5 volumes

常见原因

  • kubelet证书CN不正确(必须是system:node:<node-name>
  • Pod没有调度到该节点
  • Secret没有被Pod引用(直接volume或env引用)

坑2:Node鉴权顺序错误

现象:启用了Node鉴权,但kubelet仍有全局权限

根因:鉴权模式顺序不对

# 错误:RBAC在Node之前,RBAC可能给kubelet授权
--authorization-mode=RBAC,Node

# 正确:Node在RBAC之前,Node模式先处理kubelet请求
--authorization-mode=Node,RBAC

坑3:kubelet无法更新节点状态

现象:节点显示NotReady,但kubelet正常运行

排查

# 查看kubelet日志
journalctl -u kubelet -f

# 检查Node对象
kubectl get node <node-name> -o yaml

可能原因

  • NodeRestriction准入控制限制了节点更新
  • kubelet证书过期
  • 缺少nodes/status的update权限

坑4:镜像拉取失败

现象:Pod卡在ImagePullBackOff,但Secret存在

排查

# 检查Secret是否被ServiceAccount引用
kubectl get serviceaccount default -o yaml

# 检查Pod是否使用了正确的ServiceAccount
kubectl get pod <pod-name> -o yaml | grep serviceAccount

# 检查imagePullSecrets
kubectl get pod <pod-name> -o yaml | grep -A3 imagePullSecrets

Node鉴权限制:kubelet只能读取被Pod直接引用的Secret,不能读取通过ServiceAccount间接引用的Secret。

安全最佳实践

1. 每个节点独立的证书

# 不要这样做:所有节点使用同一个证书
# CN=system:node (没有指定具体节点名)

# 应该这样做:每个节点有自己的证书
# CN=system:node:node-1
# CN=system:node:node-2
# CN=system:node:node-3

2. 定期轮换kubelet证书

# 使用CertificateSigningRequest自动轮换
# kubelet启动时会自动创建CSR
kubectl get csr
kubectl certificate approve <csr-name>

3. 启用NodeRestriction准入控制

kube-apiserver \
  --enable-admission-plugins=NodeRestriction

4. 监控Node鉴权拒绝

# 查看apiserver日志中的NODE DENY
kubectl logs -n kube-system kube-apiserver-<pod-name> | grep "NODE DENY"

# 设置告警规则
- alert: KubeletAuthorizationDeny
  expr: increase(apiserver_authorization_decisions_denied{authorizer="Node"}[5m]) > 0

总结

通过今天的分析,我们深入理解了Node鉴权机制:

  1. 核心思想:限制kubelet只能操作自己节点上的资源
  2. 4条规则:识别node→识别node名→检查敏感资源绑定关系→静态规则
  3. Pod-Node关系图:内存中的图结构,实时跟踪资源关系
  4. 静态RBAC规则:非敏感资源使用预定义规则
  5. 与NodeRestriction配合:实现鉴权+准入的双重保护

Node鉴权是保护K8s集群安全的重要屏障,正确使用它可以有效防止节点被攻破后的横向移动。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

加倍巴巴

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值