From a8bf794eae6d1dfbe6553dcf6a6a69a8224c1a37 Mon Sep 17 00:00:00 2001 From: Goutam Adwant Date: Sat, 5 Sep 2026 00:36:48 -0700 Subject: [PATCH] Add SSL bundle support to the HC5 transport Apply named Spring Boot SSL bundles to the shared auto-configured HTTP client connection manager while preserving hostname verification and customization precedence. See #974. Signed-off-by: Goutam Adwant --- .../ROOT/pages/spring-cloud-openfeign.adoc | 25 ++ .../HttpClient5FeignConfiguration.java | 29 +- .../support/FeignHttpClientProperties.java | 15 + .../FeignHttpClient5ConfigurationTests.java | 27 ++ .../FeignHttpClient5SslBundleTests.java | 269 ++++++++++++++++++ .../src/test/resources/ssl-bundle-test.p12 | Bin 0 -> 2702 bytes 6 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/FeignHttpClient5SslBundleTests.java create mode 100644 spring-cloud-openfeign-core/src/test/resources/ssl-bundle-test.p12 diff --git a/docs/modules/ROOT/pages/spring-cloud-openfeign.adoc b/docs/modules/ROOT/pages/spring-cloud-openfeign.adoc index 28e323171..12bac4e39 100644 --- a/docs/modules/ROOT/pages/spring-cloud-openfeign.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-openfeign.adoc @@ -177,6 +177,31 @@ Apache HttpClient 5 uses a connection pool to reuse persistent HTTP connections |=== +To apply a Spring Boot SSL bundle to the auto-configured Apache HttpClient 5 connection manager, set `spring.cloud.openfeign.httpclient.hc5.ssl-bundle` to the bundle name: + +[source,yaml] +---- +spring: + ssl: + bundle: + jks: + secure-service: + truststore: + location: classpath:truststore.p12 + password: ${TRUSTSTORE_PASSWORD} + cloud: + openfeign: + httpclient: + hc5: + ssl-bundle: secure-service +---- + +The bundle supplies key and trust material, and any configured protocols and ciphers; hostname verification remains enabled. +It cannot be combined with `spring.cloud.openfeign.httpclient.disable-ssl-validation=true`. +This setting applies to the shared Apache HttpClient 5 connection manager, not to individual Feign clients or other transports. +A custom `CloseableHttpClient` or `HttpClientConnectionManager` bean retains control of its own SSL configuration, and connection manager customizers are applied after the bundle settings. +The bundle is applied when the connection manager is created; automatic reload of SSL bundles is not supported. + If you can not configure Apache HttpClient 5 by using properties, there is an `HttpClient5FeignConfiguration.HttpClientBuilderCustomizer` interface for programmatic configuration. Similarly, to configure the `HttpClientConnectionManager`, you can use `HttpClient5FeignConfiguration.HttpClientConnectionManagerBuilderCustomizer`. Both usages are shown in the example below. TIP: Apache HTTP Components `5.4` have changed defaults in the HttpClient relating to HTTP/1.1 TLS upgrades. Most proxy servers handle upgrades without issue, however, you may encounter issues with Envoy or Istio. If you need to restore previous behaviour, you can use `HttpClient5FeignConfiguration.HttpClientBuilderCustomizer` to do it, as shown in the example below. diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/clientconfig/HttpClient5FeignConfiguration.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/clientconfig/HttpClient5FeignConfiguration.java index 8f056cbf9..a4734f3b7 100644 --- a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/clientconfig/HttpClient5FeignConfiguration.java +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/clientconfig/HttpClient5FeignConfiguration.java @@ -49,10 +49,16 @@ import org.apache.hc.core5.util.Timeout; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.ssl.SslOptions; import org.springframework.cloud.openfeign.support.FeignHttpClientProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; /** * Default configuration for {@link CloseableHttpClient}. @@ -60,6 +66,7 @@ * @author Nguyen Ky Thanh * @author changjin wei(魏昌进) * @author Kwangyong Kim + * @author Goutam Adwant */ @Configuration(proxyBeanMethods = false) @ConditionalOnMissingBean(CloseableHttpClient.class) @@ -69,13 +76,16 @@ public class HttpClient5FeignConfiguration { private CloseableHttpClient httpClient5; + @Autowired + private ObjectProvider sslBundlesProvider; + @Bean @ConditionalOnMissingBean(HttpClientConnectionManager.class) public HttpClientConnectionManager hc5ConnectionManager(FeignHttpClientProperties httpClientProperties, ObjectProvider> customizerProvider) { PoolingHttpClientConnectionManagerBuilder httpClientConnectionManager = PoolingHttpClientConnectionManagerBuilder .create() - .setSSLSocketFactory(httpsSSLConnectionSocketFactory(httpClientProperties.isDisableSslValidation())) + .setSSLSocketFactory(httpsSSLConnectionSocketFactory(httpClientProperties)) .setMaxConnTotal(httpClientProperties.getMaxConnections()) .setMaxConnPerRoute(httpClientProperties.getMaxConnectionsPerRoute()) .setConnPoolPolicy(PoolReusePolicy.valueOf(httpClientProperties.getHc5().getPoolReusePolicy().name())) @@ -122,12 +132,25 @@ public void destroy() { } } - private LayeredConnectionSocketFactory httpsSSLConnectionSocketFactory(boolean isDisableSslValidation) { + private LayeredConnectionSocketFactory httpsSSLConnectionSocketFactory(FeignHttpClientProperties properties) { final SSLConnectionSocketFactoryBuilder sslConnectionSocketFactoryBuilder = SSLConnectionSocketFactoryBuilder .create() .setTlsVersions(TLS.V_1_3, TLS.V_1_2); - if (isDisableSslValidation) { + String bundleName = properties.getHc5().getSslBundle(); + if (StringUtils.hasText(bundleName)) { + Assert.state(!properties.isDisableSslValidation(), + "An SSL bundle cannot be used with spring.cloud.openfeign.httpclient.disable-ssl-validation=true"); + SslBundles sslBundles = sslBundlesProvider.getObject(); + SslBundle sslBundle = sslBundles.getBundle(bundleName); + sslConnectionSocketFactoryBuilder.setSslContext(sslBundle.createSslContext()); + SslOptions options = sslBundle.getOptions(); + if (options.getEnabledProtocols() != null) { + sslConnectionSocketFactoryBuilder.setTlsVersions(options.getEnabledProtocols()); + } + sslConnectionSocketFactoryBuilder.setCiphers(options.getCiphers()); + } + else if (properties.isDisableSslValidation()) { try { final SSLContext sslContext = SSLContext.getInstance("SSL"); sslContext.init(null, new TrustManager[] { new DisabledValidationTrustManager() }, new SecureRandom()); diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/FeignHttpClientProperties.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/FeignHttpClientProperties.java index 049a8cae8..12df4459d 100644 --- a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/FeignHttpClientProperties.java +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/FeignHttpClientProperties.java @@ -28,6 +28,7 @@ * @author Nguyen Ky Thanh * @author Olga Maciaszek-Sharma * @author changjin wei(魏昌进) + * @author Goutam Adwant */ @ConfigurationProperties(prefix = "spring.cloud.openfeign.httpclient") public class FeignHttpClientProperties { @@ -215,6 +216,12 @@ public static class Hc5Properties { */ private PoolConcurrencyPolicy poolConcurrencyPolicy = DEFAULT_POOL_CONCURRENCY_POLICY; + /** + * Name of the SSL bundle to apply to the shared Apache HttpClient 5 connection + * manager. Cannot be combined with disabling SSL validation. + */ + private String sslBundle; + /** * Pool connection re-use policies. */ @@ -240,6 +247,14 @@ public static class Hc5Properties { */ private TimeUnit connectionRequestTimeoutUnit = DEFAULT_CONNECTION_REQUEST_TIMEOUT_UNIT; + public String getSslBundle() { + return sslBundle; + } + + public void setSslBundle(String sslBundle) { + this.sslBundle = sslBundle; + } + public PoolConcurrencyPolicy getPoolConcurrencyPolicy() { return poolConcurrencyPolicy; } diff --git a/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/FeignHttpClient5ConfigurationTests.java b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/FeignHttpClient5ConfigurationTests.java index c0b14282c..e8f65ea5e 100644 --- a/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/FeignHttpClient5ConfigurationTests.java +++ b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/FeignHttpClient5ConfigurationTests.java @@ -29,6 +29,9 @@ import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.boot.WebApplicationType; import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.ssl.SslStoreBundle; import org.springframework.cloud.openfeign.clientconfig.HttpClient5FeignConfiguration.HttpClientBuilderCustomizer; import org.springframework.cloud.openfeign.clientconfig.HttpClient5FeignConfiguration.HttpClientConnectionManagerBuilderCustomizer; import org.springframework.context.ConfigurableApplicationContext; @@ -44,6 +47,7 @@ * @author Nguyen Ky Thanh * @author Olga Maciaszek-Sharma * @author Kwangyong Kim + * @author Goutam Adwant */ class FeignHttpClient5ConfigurationTests { @@ -56,6 +60,17 @@ private static void verifyHc5BeansAvailable(ConfigurableApplicationContext conte assertThat(client).isInstanceOf(ApacheHttp5Client.class); } + @Test + void shouldUseNamedSslBundle() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder().web(WebApplicationType.NONE) + .properties("spring.cloud.openfeign.httpclient.hc5.ssl-bundle=test") + .sources(FeignAutoConfiguration.class, SslConfig.class) + .run()) { + verifyHc5BeansAvailable(context); + verify(context.getBean(SslBundles.class)).getBundle("test"); + } + } + @Test void shouldInstantiateHttpClient5ByDefaultWhenDependenciesPresent() { ConfigurableApplicationContext context = new SpringApplicationBuilder().web(WebApplicationType.NONE) @@ -118,6 +133,18 @@ void shouldInstantiateHttpClientConnectionManager5ByUsingHttpClientConnectionMan } } + @Configuration(proxyBeanMethods = false) + static class SslConfig { + + @Bean + SslBundles sslBundles() { + SslBundles bundles = Mockito.mock(SslBundles.class); + Mockito.when(bundles.getBundle("test")).thenReturn(SslBundle.of(SslStoreBundle.of(null, null, null))); + return bundles; + } + + } + @Configuration static class Config { diff --git a/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/FeignHttpClient5SslBundleTests.java b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/FeignHttpClient5SslBundleTests.java new file mode 100644 index 000000000..7e1bfb6dd --- /dev/null +++ b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/FeignHttpClient5SslBundleTests.java @@ -0,0 +1,269 @@ +/* + * Copyright 2013-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.openfeign; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import java.util.Collections; + +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.SSLParameters; + +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsParameters; +import com.sun.net.httpserver.HttpsServer; +import feign.Client; +import feign.Request; +import feign.Response; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.ssl.NoSuchSslBundleException; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslStoreBundle; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.cloud.openfeign.clientconfig.HttpClient5FeignConfiguration.HttpClientConnectionManagerBuilderCustomizer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +/** + * Tests SSL bundles with the auto-configured Apache HttpClient 5 transport. + * + * @author Goutam Adwant + */ +class FeignHttpClient5SslBundleTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SslAutoConfiguration.class, FeignAutoConfiguration.class)) + .withBean(HttpClientConnectionManagerBuilderCustomizer.class, + () -> builder -> builder.setDnsResolver(new SystemDefaultDnsResolver() { + @Override + public InetAddress[] resolve(String host) throws UnknownHostException { + return new InetAddress[] { InetAddress.getByName("127.0.0.1") }; + } + })) + .withPropertyValues("spring.ssl.bundle.jks.test.truststore.location=classpath:ssl-bundle-test.p12", + "spring.ssl.bundle.jks.test.truststore.password=testpassword"); + + private HttpsServer server; + + @BeforeEach + void startServer() throws Exception { + startServer(false); + } + + private void startServer(boolean mutualTls) throws Exception { + KeyStore store = KeyStore.getInstance("PKCS12"); + try (InputStream input = getClass().getResourceAsStream("/ssl-bundle-test.p12")) { + store.load(input, "testpassword".toCharArray()); + } + server = HttpsServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.setHttpsConfigurator(new HttpsConfigurator( + SslBundle.of(SslStoreBundle.of(store, "testpassword", store)).createSslContext()) { + @Override + public void configure(HttpsParameters parameters) { + SSLParameters sslParameters = getSSLContext().getDefaultSSLParameters(); + sslParameters.setNeedClientAuth(mutualTls); + parameters.setSSLParameters(sslParameters); + } + }); + server.createContext("/", exchange -> { + byte[] body = "ok".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (var output = exchange.getResponseBody()) { + output.write(body); + } + }); + server.start(); + } + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + } + + @Test + void usesNamedBundleForHttpsRequests() { + contextRunner.withPropertyValues("spring.cloud.openfeign.httpclient.hc5.ssl-bundle=test").run(context -> { + try (Response response = request(context.getBean(Client.class), "localhost")) { + assertThat(response.status()).isEqualTo(200); + } + }); + } + + @Test + void doesNotChangeTrustForClientsWithoutBundle() { + contextRunner.withPropertyValues("spring.cloud.openfeign.httpclient.hc5.ssl-bundle=test").run(secured -> { + try (Response response = request(secured.getBean(Client.class), "localhost")) { + assertThat(response.status()).isEqualTo(200); + } + contextRunner.run(defaults -> assertThatThrownBy(() -> request(defaults.getBean(Client.class), "localhost")) + .isInstanceOf(SSLHandshakeException.class)); + }); + } + + @Test + void retainsHostnameVerification() { + contextRunner.withPropertyValues("spring.cloud.openfeign.httpclient.hc5.ssl-bundle=test") + .run(context -> assertThatThrownBy(() -> request(context.getBean(Client.class), "127.0.0.1")) + .isInstanceOf(IOException.class) + .hasMessageContaining("subject alternative")); + } + + @Test + void retainsDisabledValidationWithoutBundle() { + contextRunner.withPropertyValues("spring.cloud.openfeign.httpclient.disable-ssl-validation=true") + .run(context -> { + try (Response response = request(context.getBean(Client.class), "127.0.0.1")) { + assertThat(response.status()).isEqualTo(200); + } + }); + } + + @Test + void appliesConnectionManagerCustomizersAfterBundle() { + server.removeContext("/"); + server.createContext("/", exchange -> { + byte[] body = ((com.sun.net.httpserver.HttpsExchange) exchange).getSSLSession() + .getProtocol() + .getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (var output = exchange.getResponseBody()) { + output.write(body); + } + }); + contextRunner + .withPropertyValues("spring.cloud.openfeign.httpclient.hc5.ssl-bundle=test", + "spring.ssl.bundle.jks.test.options.enabled-protocols=TLSv1.2") + .withBean("sslCustomizer", HttpClientConnectionManagerBuilderCustomizer.class, + () -> builder -> builder.setSSLSocketFactory(SSLConnectionSocketFactoryBuilder.create() + .setSslContext(server.getHttpsConfigurator().getSSLContext()) + .setTlsVersions("TLSv1.3") + .build())) + .run(context -> { + try (Response response = request(context.getBean(Client.class), "localhost")) { + assertThat(new String(response.body().asInputStream().readAllBytes(), StandardCharsets.UTF_8)) + .isEqualTo("TLSv1.3"); + } + }); + } + + @Test + void rejectsUnknownBundle() { + contextRunner.withPropertyValues("spring.cloud.openfeign.httpclient.hc5.ssl-bundle=missing") + .run(context -> assertThat(context).hasFailed() + .getFailure() + .hasRootCauseInstanceOf(NoSuchSslBundleException.class)); + } + + @Test + void rejectsBundleWithDisabledValidation() { + contextRunner + .withPropertyValues("spring.cloud.openfeign.httpclient.hc5.ssl-bundle=test", + "spring.cloud.openfeign.httpclient.disable-ssl-validation=true") + .run(context -> assertThat(context).hasFailed() + .getFailure() + .hasRootCauseInstanceOf(IllegalStateException.class) + .hasStackTraceContaining("An SSL bundle cannot be used")); + } + + @Test + void retainsCustomFeignClient() { + Client custom = mock(Client.class); + contextRunner.withBean(Client.class, () -> custom) + .withPropertyValues("spring.cloud.openfeign.httpclient.hc5.ssl-bundle=test") + .run(context -> assertThat(context.getBean(Client.class)).isSameAs(custom)); + } + + @Test + void retainsCustomHttpClientWithoutResolvingBundle() { + CloseableHttpClient custom = mock(CloseableHttpClient.class); + contextRunner.withBean(CloseableHttpClient.class, () -> custom) + .withPropertyValues("spring.cloud.openfeign.httpclient.hc5.ssl-bundle=missing") + .run(context -> assertThat(context).hasNotFailed().doesNotHaveBean(HttpClientConnectionManager.class)); + } + + @Test + void retainsCustomConnectionManagerWithoutResolvingBundle() { + HttpClientConnectionManager custom = mock(HttpClientConnectionManager.class); + contextRunner.withBean(HttpClientConnectionManager.class, () -> custom) + .withPropertyValues("spring.cloud.openfeign.httpclient.hc5.ssl-bundle=missing") + .run(context -> assertThat(context.getBean(HttpClientConnectionManager.class)).isSameAs(custom)); + } + + @Test + void usesBundleKeyMaterialForMutualTls() throws Exception { + server.stop(0); + startServer(true); + contextRunner + .withPropertyValues("spring.cloud.openfeign.httpclient.hc5.ssl-bundle=test", + "spring.ssl.bundle.jks.test.keystore.location=classpath:ssl-bundle-test.p12", + "spring.ssl.bundle.jks.test.keystore.password=testpassword") + .run(context -> { + try (Response response = request(context.getBean(Client.class), "localhost")) { + assertThat(response.status()).isEqualTo(200); + } + }); + } + + @Test + void usesBundleProtocolsAndCiphers() { + server.removeContext("/"); + server.createContext("/", exchange -> { + var session = ((com.sun.net.httpserver.HttpsExchange) exchange).getSSLSession(); + byte[] body = (session.getProtocol() + ":" + session.getCipherSuite()).getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (var output = exchange.getResponseBody()) { + output.write(body); + } + }); + contextRunner + .withPropertyValues("spring.cloud.openfeign.httpclient.hc5.ssl-bundle=test", + "spring.ssl.bundle.jks.test.options.enabled-protocols=TLSv1.2", + "spring.ssl.bundle.jks.test.options.ciphers=TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256") + .run(context -> { + try (Response response = request(context.getBean(Client.class), "localhost")) { + assertThat(new String(response.body().asInputStream().readAllBytes(), StandardCharsets.UTF_8)) + .isEqualTo("TLSv1.2:TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"); + } + }); + } + + private Response request(Client client, String host) throws IOException { + Request request = Request.create(Request.HttpMethod.GET, + "https://" + host + ":" + server.getAddress().getPort() + "/", Collections.emptyMap(), null, + StandardCharsets.UTF_8, null); + return client.execute(request, new Request.Options()); + } + +} diff --git a/spring-cloud-openfeign-core/src/test/resources/ssl-bundle-test.p12 b/spring-cloud-openfeign-core/src/test/resources/ssl-bundle-test.p12 new file mode 100644 index 0000000000000000000000000000000000000000..dd4c3b85ffe16c5ece797ccabdbb355de9a10977 GIT binary patch literal 2702 zcma);XE+;-7RM73BWRV_BNAJUC|X-vwdyrnYBqupqqXT9s@_<&s`j3xMr}3PqUK9c zl2Bs3h#GAPwQAONpL?Ia_kOzf!+Fj*|8xH5sve zAfOb8m5`L>hiLORMWtS1_N2GI1;T>}=f8fbm9Yic@WPN@qC%rsL<$tG4ZOxMous3sq1~6l)v$}|Hkck|4=2aP z`(nAPi49EC*H7LR9k4)oMjjJ;qSuF69pk|Vc(Z*TLh4%&k918P#)-Vj=i#o{L(d%l zR#5iUU}Xftg@Z7ZD?z+d(-k`1MFfL)w^fy zBDITv7v?vUC%hbbUx|2k{844gd}+(pa*B$N6xZy{52Y4<6Kw$aVg%9R-WoT21pex| zDqGNb^t|t&rY-r6Qkr~hcDVDQC!sC0Oieor4;sod%zFb z%wBi;Kh((*;*(RJc(((Drr8Fd^)YU{^tR=;?We}i(0%NRufTXDfj3vAzF;n^1OnF? zk*0^|pBiulKW7o}xhN)bQQ$O)ZY_;DLwT@Nl^~f0{1hPrAV^mD7H~kNjG?=16WlLF z7{x;JRs`xm-bHOBnrLawh&dDL}EC?{!}j>VCatglSBQ@b?8obz*A-e47sNu6)GM zNv%e!#a6a&e7LvW8Pp|I9@Zf@OL)AsalHYbL`Nqa*Y!Y@LHv4DnDGN_*7#r1; zQkO^fBDhp6R&OsGq4ei&cQ^^#K4ejI-MBeF%!%YoA*lvM$m%-07cab=#pP1!PrdJ1 zv?;<}FZ12=&wvaAe3rMh@z3pNw=rXT8Ab&tHhJjF(X6u7u;a8~`;#>QRID*9cRX67 zQagJoVSQxoPMuH@g_$;A4fi3NFvA9*H<7b-Eb~@`^0+&jnm&8)0J1qmtwdV**4E%Ac~4 z61kF~2VP;rX6@;kS<`aQzDYzhnX0vg^?z-^0TV`C0^C^guRHW~g?NzpxnxWO1e_z*k2JOoe@S z0rZ%De8|t?aO=5kFYbe;ww240rg;|q6xw@^c8=`e8aNhTKihIi;a(r#os&0+{wkj@ zE+6-b87ITv$iO8czxYYzS%~souFOI^SAU}980IsGdXDD}Eouq~24bVJ_V&lTEoj{w zN}5o}LCPk&dAnNpBLCC;wVPWRv{70~!3Z8(C&AQ?mePF1W0?uGX zkOJ|{F839t^q45^S9+QEAw4AvP49oVCRYsWFd>Ce{n<+_ROjC8#X2~JMP z?}0Ykv8!@?t2g%Da;>9od6dB;s@|PFYZQZ)kW3s3M%Ii6)ij?49|!4lg~rzhqATT6 z3u_T59y4N{X9C^W5O6hYtPaJOM8gKsQfFxvEzVG~+i9*XKLCqVS{MA54@SsNQYdN* z?&gV*{&WoYs)pNY(3*YvU_P!%-qu=*Tc#=)5SK%BEESbEzA!-Jjb1 zFzYY=>Y)&a67Rl4zlA?YzPC2U=XUTUM3IoY2!6>Y@8fUnUM4GMY&(>WlS9)9L)Ybv ztKo>~*pP2XqjS)({QA*ofM5Vfh=A7k-2+lwGT*_Skt9-Lncn=hJ?hGoB!}FgsH1*H d76`f<0gu2V!P?m4NUldb0@uNCbp1b(@(%+(=^X$7 literal 0 HcmV?d00001