记一次从外网服务代理到java agent的一次经历

884 阅读2分钟

1.背景

压测环境屏蔽外网,外网相关的服务需要代理到自己的mock服务中。

2.代理服务

隔壁项目组有一个现成的,直接拿过来用了。

配置被代理的地址及目标的地址,需要将被代理的域名通过dns的方式解析到代理服务器。

3.mock服务

用了阿里妈妈开源的RAP2来生成需要mock的数据。RAP2官网

配置界面

4.SSL证书问题

上面相关服务及配置完成后代理的功能就已经完成了,但实际在测试中未成功,问题出在SSL证书上。

4.1发现问题过程

发现相关接口依然报错,但响应时间从原来3s左右(SDK内设置的socket超时时间)变成瞬间返回,查看相关业务日志,

异常被SDK处理,看不出来问题的真正原因,直接用CURL进行验证代理是否生效

到这里已经确定代理确实已经成功,问题出在SSL证书校验这里。

4.2 问题分析

从https验证过程中不难发现问题是出在访问代理服务时返回的证书校验失败。

4.3 初步确定方案

1.服务端入手,代理服务伪造证书,没有相关经验,且我们需要代理的地址较多,排除;

2.客户端入手,客户端不进行证书的校验。

我们目前对外网服务的调用分为两大类:

(1)通过RestTemplate进行调用,我们可以在添加一个配置类,根据环境把IOC容器中的RestTemplate(项目中自定义)进行替换。

@Log4j2
@Component
public class HttpsIgnoreSSLVerifier implements BeanPostProcessor {
    @Value("${info.profile}")
    private String profile;
    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        if (StringUtils.equals(profile, "st") && "restTemplate".equals(beanName)) {
            // 如果遇到需要替换的Bean,我们直接换成自己实现的Bean即可(这里可以把就得removeBeanDefinition,然后注册新的registerBeanDefinition)
            // 这里的myConfig要继承自defaultConfig,否则引用的地方会报错
            log.info("当前环境{},替换restTemplate为不校验ssl证书配置", profile);
            return restTemplateWithoutSslContext();
        }
        return bean;
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        return bean;
    }

    /**
     * 不校验ssl的restTemplate
     *
     * @return
     */
    public RestTemplate restTemplateWithoutSslContext() {
        RequestConfig requestConfig = RequestConfig.custom()
                //服务器返回数据(response)的时间,超过该时间抛出read timeout
                .setSocketTimeout(10000)
                //连接上服务器(握手成功)的时间,超出该时间抛出connect timeout
                .setConnectTimeout(5000)
                //从连接池中获取连接的超时时间,超过该时间未拿到可用连接,会抛出
                // org.apache.http.conn.ConnectionPoolTimeoutException: Timeout waiting for connection from pool
                .setConnectionRequestTimeout(10000)
                .build();
        CloseableHttpClient httpClient = null;
        try {
            httpClient = HttpClients
                    .custom()
                    .setSSLContext(
                            new SSLContextBuilder()
                                    .loadTrustMaterial(null, TrustAllStrategy.INSTANCE)
                                    .build()
                    )
                    .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE)
                    .setDefaultRequestConfig(requestConfig)
                    .build();
        } catch (Exception e) {
            log.error("httpClient create fail", e);
        }
        assert httpClient != null;
        return new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient));
    }
}

(2)SDK调用,对SDK跟踪发现时间调用工具是httpClient,但是我们没办法直接修改这块代码。同时如果修改1中的代码则可以直接去掉。

4.3 如何代理SDK中的httpClient

首先看看

1.动态代理(不适用)

SDK调用httpClient是直接自己去创建httpClient的,我们没办法拿到httpClient对象,不能对其进行修改;

2.静态代理(不适用)

原因同上,且静态代理对原有业务侵入较高;

3.java agent

java agent提供了一种在加载字节码时,对字节码进行修改的方式,基于这点就能满足我们的需求,可以直接修改httpClient的代码让其跳过验证SSL证书环节。修改字节码的技术这里使用了Javassist,api相对比较简单,容易上手。

4.4 如何修改httpClient

这里我们先不研究如何通过java agent去修改,直接下载一份httpClient的源码进行分析。

首先我们先来看一下如何创建一个不校验SSL证书的httpClient.

CloseableHttpClient httpClient = HttpClients
                    .custom()
                    .setSSLContext(
                            new SSLContextBuilder()
                                    .loadTrustMaterial(null, TrustAllStrategy.INSTANCE)
                                    .build()
                    )
                    .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE)
                    .setDefaultRequestConfig(requestConfig)
                    .build();

通过分析上面代码,发现修改配置了信任所有SSL证书的策略和关闭了主机名验证,在对源码跟踪后发现验证的逻辑主要在SSLConnectionSocketFactory中。

1.修改SSLConnectionSocketFactory构造函数使其忽略客户端传入的配置信任所有SSL证书的策略;

2.修改验证主机名的方法为空(此处也可以直接在上面的构造函数中去直接修改hostnameVerifier=NoopHostnameVerifier.INSTANCE来实现)

验证结果

4.5 通过Java agent进行修改

代码

public class HttpClientAgent {

    /**
     * 此方法会在主程序的main方法之前运行
     *
     * @param agentArgs
     * @param inst
     */
    public static void premain(String agentArgs, Instrumentation inst) {
        //这里添加我们自定义的字节码修改对象
        inst.addTransformer(new DefineTransformer(), true);
    }

    /**
     * 自定义的字节码转换器
     */
    static class DefineTransformer implements ClassFileTransformer {

        @Override
        public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
                                ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
            //在类文件加载到内存前被调用,对类字节码进行修改
            if (className.equals("org/apache/http/conn/ssl/SSLConnectionSocketFactory")) {
                System.out.println("SSLConnectionSocketFactory");

                //这里使用了javassist对类文件进行了修改
                ClassPool classPool = ClassPool.getDefault();
                //这里传入当前的类加载器的classPath
                classPool.appendClassPath(new LoaderClassPath(loader));
                try {
                    CtClass clazz = classPool.get("org.apache.http.conn.ssl.SSLConnectionSocketFactory");
                    //修改构造函数
                    CtConstructor constructor = clazz.getConstructor("(Ljavax/net/ssl/SSLSocketFactory;" +
                            "[Ljava/lang/String;[Ljava/lang/String;Ljavax/net/ssl/HostnameVerifier;)V");
                    constructor.setBody("{log = org.apache.commons.logging.LogFactory.getLog(getClass());\n" +
                            "        try {\n" +
                            "            System.out.println(\"忽略了传入的socketfactory\");\n" +
                            "            this.socketfactory =  org.apache.http.ssl.SSLContexts.custom()\n" +
                            "                    .loadTrustMaterial(null,\n" +
                            "          new org.apache.http.conn.ssl.TrustSelfSignedStrategy())\n" +
                            "                    .build().getSocketFactory();\n" +
                            "        } catch (Exception e) {\n" +
                            "           throw new RuntimeException(e);\n" +
                            "        }\n" +
                            "        this.supportedProtocols = supportedProtocols;\n" +
                            "        this.supportedCipherSuites = supportedCipherSuites;\n" +
                            "        this.hostnameVerifier = hostnameVerifier != null ? hostnameVerifier : " +
                            "getDefaultHostnameVerifier();}");
                    System.out.println("SSLConnectionSocketFactory modify constructor");
                    //修改verifyHostname方法
                    CtMethod convertToAbbr = clazz.getDeclaredMethod("verifyHostname");
                    //这里对  verifyHostname 方法进行了改写,在 return之前增加了一个 打印操作
                    String methodBody = "{ System.out.println(\"verifyHostname\");}";
                    convertToAbbr.setBody(methodBody);
                    System.out.println("SSLConnectionSocketFactory modify verifyHostname method");
                    // 返回字节码,并且detachCtClass对象
                    byte[] byteCode = clazz.toBytecode();
                    //detach的意思是将内存中曾经被javassist加载过对象移除,如果下次有需要在内存中找不到会重新走javassist加载
                    clazz.detach();
                    System.out.println("SSLConnectionSocketFactory over");
                    return byteCode;
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
            //其余类不进行修改
            return null;
        }
    }
}

打包验证

打包配置文件MANIFEST.MF

Manifest-Version: 1.0
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Premain-Class: com.peilian.agent.http.client.HttpClientAgent
Class-Path: javassist-3.27.0-GA.jar

本地验证成功

4.7 java agent中遇到的问题

1.重写构造函数后之使有实例变量默认值的实例变量为null

解决方法:在构造函数内再次对log进行赋值

2.打包时需要包含javassist包,并将javassist jar包放在agent.jar的同目录

应该也可以通过将javassist打包在agent.jar中,这里没有继续研究

3.本地验证通过,但是将springboot项目打成jar包后启动报NotFoundException

原因是默认的ClassPool的classpath对应的ClassLoader是Launcher$AppClassLoader,而我们要修改的类是通过子类加载器LaunchedURLClassLoader进行加载的,所以访问不到。

开发阶段可以通过项目的主函数启动Spring boot,通过启动命令我们发现IDE会自动将依赖加入classpath,这样的启动方式和普通Java项目并无二致,所以不会有问题。

参考文章:当javassist遇到springboot

解决方案:将加载该类子类加载ClassLoader加入ClassLoader的classPatch。