dubbo icon indicating copy to clipboard operation
dubbo copied to clipboard

Zero trust security mechanism in mesh

Open namelessssssssssss opened this issue 1 year ago • 2 comments

What is the purpose of the change

This is a draft commit of xDS security support. Mainly including:

  • Role based Authorization framework
  • mTLS support in istio mesh

Proposal (Chinese)

背景

随着微服务架构的广泛应用,服务间的安全通信与访问控制变得至关重要。Dubbo作为业界领先的RPC框架,其安全性同样不容忽视。本提案旨在构建一套全面、细致的Dubbo零信任安全体系,通过mTLS认证和鉴权机制,确保服务间通信的机密性、完整性和可访问性,从而提升整个微服务架构的安全性。

img

目标:为Dubbo提供一套零信任安全实现,包含数据面和可选的控制面。

  • 数据面:使Dubbo框架实现安全相关xDS协议(SDS、LDS、CDS等),接收控制面下发的安全资源并进行解析,通过解析的规则配置安全性能力。
  • 控制面(可选):提供一套Dubbo定制的控制面,使其更贴合Dubbo自身特性。也可直接选择使用现有的控制面作为xDS server(如istio)

实现概览

未命名文件

概述

mesh下的零信任安全体系,关注于两个维度的安全性

  • 验证(Authentication,Authn),关注于保证通信对端服务的信息可信且未被篡改。
  • 鉴权(Authorization,Authz),关注于对可信服务进行权限验证,确认其是否有访问目标资源的权限

在实践中,鉴权和验证通常在L4(传输层)和L7(应用层)进行,代表协议为TCP协议与HTTP协议。

1、传输层下的验证模型

传输层通常通过TLS/SSL加密实现验证。对端通过证书表示自身身份,被调用方通过查找信任存储库、验证信任链来确定证书是否可信。相比常见的单向TLS(一方提供证书,另一方验证证书),mutual(双向)TLS要求通信双方均提供证书并进行验证。

证书由集群内的证书签发服务签发。通常由服务发起一个包含了新生成证书的CSR请求,证书签发服务为证书签名,并确保它可以被信任链中的根证书或某个中间证书验证

信任存储由集群内的证书管理服务提供,可能与证书签发服务相同。服务通过它获取信任存储,通常是根证书和其签发的部分中间证书的集合。

当对端提供证书的签名可以被信任存储中的任一证书的公钥验证,则认为对端证书可信。

1.1 Istio的mTLS验证模型

id-prov

1、istio下服务部署时,其istio agent(和envoy一样在服务pod部署)会首先生成一个证书,并向istiod:15012/IstioCertificateService 发送一条包含证书的CSR请求,为证书签名。

2、此处服务需要通过其pod下的k8s(第一方)或 istio(第三方)ServiceAccount JWT Token 向 istiod 表明身份,以建立最初的信任关系。

3、签名完成后,istio agent 作为SDS服务端,以SDS协议将证书提供给envoy,enovy 再通过该证书向 istiod:15012/AdsService 安全端口建立连接,并进行Xds协议的后续调用。

在该场景下,istio agent永远作为SDS服务端向envoy提供服务,自身则直接与istiod通信,完成证书签名、轮换等工作。

4、在与控制面的Ads服务建立安全连接后,后续服务间通过envoy进行通信时均使用该证书建立mTLS连接。

5、证书的轮换由agent周期性发送CSR请求实现。服务的证书不会在其严格到期时再轮换,而是在其快到期时就尝试签名新证书。IstioCertificateService会将最新的信任链作为CSR请求响应返回,服务则可以使用该信任链更新信任存储。

image

1.2 X.509 SPIFFE Identity

SPIFFE是 Secure Production Identity Framework for Everyone(适用于所有人的安全生产身份框架)的缩写。它以一串spiffe://开头的URL为服务提供身份,通常以X.509证书JWT中附加字段的形式出现。这些spiffe的载体称为SVID

spiffe://<trust-domain>/<namespace>/<service-account>

满足spiffe标准的X.509证书示例 (X.509 SVID):

Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            04:10:df:a2:3b:45:3b:75:ec:fd:fa:41:1b:84:cb:70:d9
    Signature Algorithm: sha256WithRSAEncryption
        Issuer: CN=SPIFFE CA
        Validity
            Not Before: Mar 10 12:00:00 2021 GMT
            Not After : Mar 10 12:00:00 2022 GMT
        Subject: CN=spiffe://example.org/foo/service1/workload1 	<--- SPIFFE URL
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
        X509v3 extensions:
            X509v3 Key Usage: critical
                Digital Signature, Key Encipherment
            X509v3 Extended Key Usage: 
                TLS Web Server Authentication, TLS Web Client Authentication
            X509v3 Subject Alternative Name: 
                URI: spiffe://example.org/foo/service1/workload1	<--- SPIFFE URL
    Signature Algorithm: sha256WithRSAEncryption
         5c:ca:ba:8e:92:...:00:00:00:00:00:00:00:00:00:00:00:00

其中主题拓展字段均包含了一个SPIFFE url,表示该证书来源于信任域为example.org、命名空间为foo ,service1服务的工作负载workload1

Istio签发的证书符合SPIFFE标准。Istio agent在生成证书时,会根据pod的环境变量创建一个SPIFFE URL并将其写入待签名的X.509证书, 并携带k8s SA Token或其它身份证明验证自身身份。

Istiod收到CSR请求时,会通过Token和注册条目校验代理为证书写入的SPIFFE信息是否合规。

通过满足SPIFFE标准的X.509证书,上游服务就可以确定下游服务的具体信息(位于哪个信任域、服务名称及工作负载),这些信息可用于后续的访问权限验证流程。

1.2.1 SPIRE

SPIRE是SPIFFE的运行时环境,它是CNCF提供的一个SPIFFE实现。Istio提供了自己的SPIRE实现(Istio CA/Citadel/Istiod +Istio pilot),并支持和CNCF的实现进行集成。

SPIRE主要由Server代理组成。Server 作为中心化的服务,为客户端(服务实例)提供证书签发、管理服务,而代理则负责生成证书、管理证书并请求Server为证书签名、续签,为Node内工作负载提供有效证书。

![image-20240411120322335](/Users/nameles/Library/Application Support/typora-user-images/image-20240411120322335.png)

1.2.2 注册条目

注册条目描述了哪些工作负载可以和哪些SPIFFE身份关联起来,为工作负载关联身份提供了准入条件。这些条目存储在SPIRE Server上,由SPIRE Server管理。

注册条目由选择器SPIFFE ID联邦关系等字段组成。

  • 选择器用于确定哪些工作负载可以授予对应的SPIFFE ID,如基于UNIX用户ID、UNIX组、K8s服务账户、pod、环境变量、进程属性进行选择
  • SPIFFE ID为已成功注册的ID
  • 联邦关系则指定了哪些其它信任域可接收某个SPIFFE ID,跨集群安全通信依赖该配置

1.3 istio的mTLS配置

istio的mTLS配置由 PeerAuthentication 定义,用于配置是否启用mTLS及对应策略

apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: "example-peer-policy"
  namespace: "foo"
spec:
  selector:
    matchLabels:
      app: reviews
  mtls:
    mode: STRICT

该策略包含三种类型

STRICT:严格模式,经由该服务的流量必须由mTLS加密,否则会被拒绝。这要求对端服务必须支持TLS且可以提供有效证书,一般同为istio mesh下且部署有sidecar的应用。

PERMISSIVE:宽容模式,服务接受通过mTLS或普通TCP连接的流量。此配置适用于集群中同时存在非istio服务的迁移环境。

NONE/不设置:关闭模式,服务不主动配置证书和密钥,也不会主动建立mTLS连接。适用于少数高性能、不能承受TLS带来的额外开销的场景。

mTLS策略有两种解析方式:

  • CDS协议的Endpoint配置中显式获取TLS配置

  • LDS协议隐式获取,通过解析 virtualInbound 监听器链中的协议配置实现:若 TransportProtocol仅包含TLS,则认为使用STRICT模式;若同时包含其它协议,则认为使用PERMISSIVE模式;若不包含TLS协议则认为不启用。

2、应用层下的验证模型

应用层下的验证一般指上游服务对对端服务身份进行验证,确保其包含的身份证明不被伪造、身份信息可信。

身份证明通常由JWT携带,并通过HTTP Header传递,通常包含在 Authorizaiton / Cookie 头部中,但也可以是用户的某个自定义Header、表单中的某个字段,并不强制。

根据服务调用者的不同,其JWT来源也可能不同,主要可分为:网格内服务通信时的服务账户JWT与网格外消费者提供的JWT。不管是哪种来源的JWT,payload及签名都必须包含可被上游服务识别,且符合当前配置鉴权及验证策略的标识。

2.1 网格内服务身份证明

在istio mesh内部,istio默认使用k8s ServiceAccount JWT作为服务的身份证明。

认证服务需要具备根据服务信息的JWT的签发能力,并支持其自动更新。对于默认的K8s SA Token,约定存储于服务pod下的 /var/run/secrets/kubernetes.io/token , 并由k8s负责其自动更新及轮换。相比证书,Token的过期时间更短,通常为几个小时。

2.2 网格外消费者身份证明

非网格或istio体系外的请求者也可以使用其它外部认证服务,如OAuth2、OIDC、或某些云服务提供商的认证服务。Istio也支持与这些服务进行集成,使用其他来源的JWT而非Sa JWT。

2.3 身份证明的验证

istio内,用户通过配置 RequestAuthentication 和 AuthorizationPolicy 来指定服务如何验证对端JWT并进行鉴权。其中,RequestAuthentication更注重于验证JWT的有效性,而AuthorizationPolicy则注重于对有效JWT进行更细粒度的RBAC鉴权

![image-20240411202502036](/Users/nameles/Library/Application Support/typora-user-images/image-20240411202502036.png)

RequestAuthentication示例:

apiVersion: "security.istio.io/v1beta1"
kind: "RequestAuthentication"
metadata:
  name: "jwt-auth"
  namespace: istio-system
spec:
  selector:
    matchLabels:
      app: myapp
  jwtRules:
  - issuer: "https://example.com"
    jwksUri: "https://example.com/.well-known/jwks.json"

JWT的有效性主要由其是否能被JWKS验证决定。

JWKS(JSON Web Key Set)是一组JWK集合,每个JWT包含了用于验证JWT签名的公钥信息。当一个JWT的签名可以被某个JWK的公钥验证时,认为它是可信的。

RequestAuthentication中指定的jwksUri是一个外部的JWKS服务地址。除了配置一个独立的JWKS服务,它也可以配置为静态的JWK串。

Istio 将 RequestAuthentication 中的 JWKS 、issuer等验证属性由LDS协议下发到envoy sidecar。LDS中,它会被转换为 envoy.filters.http.jwt_authn 过滤器配置。http_authn过滤器会在下游请求入站时提取JWT,并按照定义的策略进行验证。

jwt_authn过滤器配置示例:

http_filters:
- name: envoy.filters.http.jwt_authn
  config:
    providers:
      example_provider:
        issuer: "https://example.com"
        audiences:
        - "example_service"
        local_jwks:
          inline_string: "{ \"keys\": [{ \"kty\": \"RSA\", \"kid\": \"abc\", ... }] }"
    rules:
    - match:
        prefix: "/"
      requires:
        provider_name: "example_provider"

这个过滤器使用了静态JWKS。

3、应用层下的鉴权模型

istio下,基于角色的权限控制(RBAC)由AuthorizationPolicy定义。用户通过配置AuthorizationPolicy来为服务进行细粒度的权限控制,包括端口、请求PATH、方法、Header等属性。

![image-20240411204459268](/Users/nameles/Library/Application Support/typora-user-images/image-20240411204459268.png)

AuthorizaitonPolicy示例:

apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: example-policy
  namespace: default
spec:
  selector:
    matchLabels:
      app: my-app
  action: ALLOW
  rules:
  - to:
    - operation:
        paths: ["/private"]
    when:
    - key: request.auth.claims[iss]
      values: ["https://example.com"]
    - key: request.auth.principal
      values: ["[email protected]"]

RBAC过滤器本身不和JWT绑定,它可以仅通过一些通用的连接属性进行权限校验。但对端服务的JWT可以为校验提供更多的元数据,以支持基于更多属性的权限控制。

在Envoy中,jwt_authn过滤器在校验JWT同时也会将各种声明提取到校验上下文(HTTP header等)中,RBAC过滤器后续可以从上下文中获取这些属性用于校验。

以上AuthorizationPolicy转换为rbac_filter的配置:

http_filters:
 - name: envoy.filters.http.rbac
   config:
    rules:
      action: ALLOW
      policies:
        "policy-1":
          permissions:
            - header:
                name: ":path"
                exact_match: "/private"
          principals:
            - authenticated:
                principal_name:
                  exact: "[email protected]"

实现

数据流总览: datastream

传输层验证实现

如前文所述,mesh下传输层安全性主要由mTLS提供,包括

1、证书签名前服务身份证明的获取。身份证明来源多样(k8s SA token、外部OAUTH服务,等),格式统一为 JWT。

2、通过服务运行环境生成证书、使用CSR请求为证书签名并缓存证书

3、从信任存储源拉取信任存储(证书链)

4、LDS 监听Listener配置,按port获取对端安全策略(PERMISSIVE、STRICT、NONE)

5、连接建立时提供证书,并要求对端提供证书,使用信任存储验证

Istio + mTLS保证了:

  • 传输信息可靠性(不被篡改)
  • 双端服务身份可信性(SPIFFE+信任链)

接口及实现:

  • ServiceIdentitySource 服务身份源,提供JWT形式的服务身份证明

    @SPI(value = "default", scope = ExtensionScope.APPLICATION)
    public interface ServiceIdentitySource {
    
        @Adaptive(value = {"serviceIdentity"})
        String getJwt(URL url);
    }
    

    默认实现 KubeServiceJwtIdentitySource 使用本地k8s服务账户Token

  • CertSource 证书源,提供有效的X.509证书并负责维护

    @SPI(scope = ExtensionScope.FRAMEWORK)
    public interface CertSource {
    
        @Adaptive(value = {"mesh", "cert_source"})
        CertPair getCert(URL url);
    }
    
  • TrustSource 信任源,提供信任证书链(信任存储)并负责维护

    @SPI(scope = ExtensionScope.FRAMEWORK)
    public interface TrustSource {
        @Adaptive(value = {"mesh","trust"})
        X509CertChains getTrustCerts(URL url);
    }
    

    CertSource和TrustSource的默认实现 IstioCitadelCertificateSigner,通过本地服务身份生成证书并请求Istio签名,并缓存CSR请求返回的信任链作为信任存储。

  • LdsListener LDS资源监听器

    public interface LdsListener extends XdsResourceListener<Listener> {}
    

    传输层鉴权下 TlsModeListener 负责解析Listener中各入站port的协议配置(是否支持TLS/SSL),将配置写入TlsModeRepo

  • MeshCredentialProvider

    现有CertProvider接口的实现,从CertSourceTrustSource获取证书及信任存储,并通过TlsModeRepo判断对端的安全策略。作为客户端时,其要求服务端提供证书;作为服务端时,其要求客户端提供证书。

应用层验证&鉴权实现

应用层验证、鉴权操作集中在Provider链路:

1、请求入站时从请求中解析出消费者请求凭据,包括所有可用于进行访问控制的字段:

  • 通用连接字段,对端/代理ip:端口、目标ip:端口
  • 应用层协议字段:HTTP method/path/header/form字段
  • 服务身份字段:对端pod id、服务名称、cluster、namespace、ServiceAccount、SPIFFE ID、......
  • 服务身份载体字段(JWT):(iss)签发者、(aud)受众、(iat)签发时间、(exp)过期时间、......
  • Dubbo特有标识字段:服务接口名、目标方法名、版本、......

其中应用层协议字段协议相关,提供 CredentialFactory 从请求中解析协议相关字段

@SPI(scope = ExtensionScope.APPLICATION)
public interface CredentialFactory {

    @Adaptive("protocol")
    RequestCredential getRequestCredential(URL url, Invocation invocation);
}
  • 默认实现TripleMeshRequestCredentialFactory,主要提供基于HTTP的协议字段解析

2、验证、鉴权规则的获取,包括:

  • 凭据验证规则
  • 凭据鉴权规则

若对端是具有SPIFFE身份的服务(TLS中已提供证书),则凭据的验证是可选的,支持不提供JWT/不对JWT的签名进行校验。

首先,根据当前连接的上下文信息(URL、Invocaition、请求凭据)

1、RequestAuthorizer(实现RoleBasedAuthorizer)先获取适用于当前请求的规则源RuleSource

2、使用对应的规则工厂(RuleFactory)生成规则树

3、通过 CredentialFactory 获取请求凭据,创建 AuthorizationRequestContext,并使用规则树评估请求,包括所有AND规则和OR规则。

为对端生成怎样的规则树由RuleFactory判断。生成的规则树可能包含两颗子树:验证树和鉴权树,分别使用请求凭据中的不同字段进行校验;也可能仅包含两颗树中的一个,取决于为对端使用怎样的验证策略。

@Activate
public class RoleBasedAuthorizer implements RequestAuthorizer {

    private final RuleProvider<?> ruleProvider;

    private final CredentialFactory credentialFactory;

    private final RuleFactory ruleFactory;

    private final ErrorTypeAwareLogger logger = LoggerFactory.getErrorTypeAwareLogger(RoleBasedAuthorizer.class);

    /**
     * TODO
     * Cached rules
     * Connection Identity -> Authorization Rules
     */
    private final Map<String, List<RuleRoot>> rules = new ConcurrentHashMap<>();

    public RoleBasedAuthorizer(ApplicationModel applicationModel) {
        this.ruleProvider = applicationModel.getAdaptiveExtension(RuleProvider.class);
        this.credentialFactory = applicationModel.getAdaptiveExtension(CredentialFactory.class);
        this.ruleFactory = applicationModel.getAdaptiveExtension(RuleFactory.class);
    }

    @Override
    @SuppressWarnings({"unchecked", "rawtypes"})
    public void validate(Invocation invocation) throws AuthorizationException {

        List rulesSources = ruleProvider.getSource(invocation.getInvoker().getUrl(), invocation);
        List<RuleRoot> roots = ruleFactory.getRules(invocation.getInvoker().getUrl(),rulesSources);

        List<RuleRoot> andRules = roots.stream()
                .filter(root -> Relation.AND.equals(root.getRelation()))
                .collect(Collectors.toList());
        List<RuleRoot> orRules = roots.stream()
                .filter(root -> Relation.OR.equals(root.getRelation()))
                .collect(Collectors.toList());

        RequestCredential requestCredential =
                credentialFactory.getRequestCredential(invocation.getInvoker().getUrl(), invocation);

        AuthorizationRequestContext context = new AuthorizationRequestContext(invocation, requestCredential);

        boolean andRes = true;
        for (RuleRoot rule : andRules) {
            try {
                if (!rule.evaluate(context) && rule.getAction().boolVal()) {
                    andRes = false;
                    break;
                }
            } catch (Exception e) {
                logger.error(
                        "",
                        "",
                        "",
                        "Request authorization failed, source:" + invocation.getServiceName() + // TODO get source
                                ", target URL:" + invocation.getInvoker().getUrl(),
                        e.getCause());
                if (e instanceof AuthorizationException) {
                    throw (AuthorizationException) e;
                }
                throw new AuthorizationException(e);
            }
        }

        boolean orRes = false;
        for (RuleRoot rule : orRules) {
            try {
                orRes = rule.evaluate(context) && rule.getAction().boolVal();
                if (orRes) {
                    break;
                }
            } catch (Exception e) {
                logger.error(
                        "",
                        "",
                        "",
                        "Request authorization failed, source:" + invocation.getServiceName() + // TODO source
                                ", target URL:" + invocation.getInvoker().getUrl(),
                        e.getCause());
                if (e instanceof AuthorizationException) {
                    throw (AuthorizationException) e;
                }
                throw new AuthorizationException(e);
            }
        }
        if (CollectionUtils.isEmpty(orRules)) {
            orRes = true;
        }
        if (andRes && orRes) {
            return;
        }
        throw new AuthorizationException("Request authorization failed: request credential doesn't meet rules.");
    }
}
/**
 * Provides rules for role-based authorization & authentication
 */
@SPI(value = "default", scope = ExtensionScope.APPLICATION)
public interface RuleProvider<T> {

    @Adaptive(value = {"mesh", "authz_rule"})
    List<T> getSource(URL url, Invocation invocation);
}

@SPI
public interface RuleFactory<T> {

    @Adaptive({"mesh", "authz_rule"})
    List<RuleRoot> getRules(URL url,List<T> ruleSource);
}

@SPI(scope = ExtensionScope.APPLICATION)
public interface CredentialFactory {

    @Adaptive("protocol")
    RequestCredential getRequestCredential(URL url, Invocation invocation);
}

规则树

规则树定义了规则属性之间的关系。如:

  • 同名的多个属性值,匹配其中一个
  • 同级的多个属性,必须匹配其中一个
  • 同级的多个属性,必须匹配其中所有
  • 具有多个属性结点的同级父属性结点,他们的子属性必须全部匹配/匹配其中一个

无论是Isitio的AuthorizationPolicy,又或是envoy的RBAC filter配置,都可以使用规则树表示完整的规则结构。

目前提供两套构建规则树的实现:

1、以KubeApiClient拉取AuthorizationPolicy配置,以Map构建规则树

2、以LDS监听器获取RBAC、authn过滤器配置,以HTTP filter构建规则树

public interface RuleNode {

    /**
     * evaluate if the request can match rules in this node and its children
     */
    boolean evaluate(AuthorizationRequestContext context);

    String getNodeName();

    enum Relation {
        AND,
        OR,
        NOT
    }
}
public class RuleRoot extends CompositeRuleNode {

    /**
     * Relations between rule tree roots.
     * All roots that has Relation=AND will do AND, and all roots has Relation=OR will do OR.
     */
    private Action action;

    public RuleRoot(Relation relation, Action action, String name) {
        super(name, relation);
        this.action = action;
    }

    public RuleRoot(Relation relation, Action action) {
        super("", relation);
        this.action = action;
    }

    public Action getAction() {
        return action;
    }

    @Override
    public boolean evaluate(AuthorizationRequestContext context) {
        boolean result;
        if (relation == Relation.AND) {
            result = children.values().stream()
                    .allMatch(childList -> childList.stream().allMatch(ch -> ch.evaluate(context)));
        } else {
            // Relation == OR
            result = children.values().stream()
                    .anyMatch(childList -> childList.stream().anyMatch(ch -> ch.evaluate(context)));
        }
        return result;
    }

    /**
     * The action of authorization policy
     */
    public enum Action {

        /**
         * The request must map this policy
         */
        ALLOW("ALLOW", true),

        /**
         * The request must not map this policy
         */
        DENY("DENY", false);

        private final String name;

        private boolean boolVal;

        Action(String name, boolean boolValue) {
            this.name = name;
            this.boolVal = boolValue;
        }

        public static Action map(String name) {
            name = name.toUpperCase();
            switch (name) {
                case "ALLOW":
                    return ALLOW;
                case "DENY":
                    return DENY;
                default:
                    return null;
            }
        }

        public boolean boolVal() {
            return boolVal;
        }
    }
}
@SuppressWarnings("unchecked,rawtypes")
public class LeafRuleNode implements RuleNode {

    /**
     * e.g rules.from.source.principles
     */
    private String rulePropName;

    /**
     * patterns that matches required values
     */
    private List<Matcher> matchers;

    public LeafRuleNode(List<? extends Matcher> expectedConditions, String name) {
        this.matchers = (List<Matcher>) expectedConditions;
        this.rulePropName = name;
    }

    public LeafRuleNode(Matcher matcher, String name) {
        this.matchers = Collections.singletonList(matcher);
        this.rulePropName = name;
    }

    @Override
    public boolean evaluate(AuthorizationRequestContext context) {
        // If we have multiple values to validate, then every value must match at list one matcher
        for (Matcher matcher : matchers) {

            Object toValidate = context.getRequestCredential().getRequestProperty(matcher.propType());

            if (!matcher.match(toValidate)) {
                return false;
            }
        }

        return true;
    }

    @Override
    public String getNodeName() {
        return rulePropName;
    }
}

namelessssssssssss avatar Mar 27 '24 07:03 namelessssssssssss

Quality Gate Failed Quality Gate failed

Failed conditions
1 Security Hotspot

See analysis details on SonarCloud

sonarqubecloud[bot] avatar Jul 05 '24 03:07 sonarqubecloud[bot]

Since the target branch is dedicated for xds development, I will merge this pull request now for better cooperation.

chickenlj avatar Jul 05 '24 08:07 chickenlj