diff --git a/G-Earth/pom.xml b/G-Earth/pom.xml index 13dc8cf..fb07db2 100644 --- a/G-Earth/pom.xml +++ b/G-Earth/pom.xml @@ -12,6 +12,7 @@ 1.8 9.4.53.v20231009 1.3.12 + 1.78.1 @@ -331,19 +332,19 @@ org.bouncycastle bcprov-jdk18on - 1.78.1 + ${bouncycastle.version} org.bouncycastle bcpkix-jdk18on - 1.78.1 + ${bouncycastle.version} org.bouncycastle bctls-jdk18on - 1.78.1 + ${bouncycastle.version} G-Earth diff --git a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/NitroProxyProvider.java b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/NitroProxyProvider.java index 90ea41a..7f7a047 100644 --- a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/NitroProxyProvider.java +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/NitroProxyProvider.java @@ -121,7 +121,7 @@ public class NitroProxyProvider implements ProxyProvider, NitroHttpProxyServerCa } @Override - public String replaceWebsocketServer(String configUrl, String websocketUrl) { + public String replaceWebsocketServer(String websocketUrl) { originalWebsocketUrl = websocketUrl; return String.format("wss://127.0.0.1:%d", websocketPort); diff --git a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroCertificateFactory.java b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroCertificateFactory.java index c18604c..3411af7 100644 --- a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroCertificateFactory.java +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroCertificateFactory.java @@ -2,19 +2,35 @@ package gearth.protocol.connection.proxy.nitro.http; import com.github.monkeywie.proxyee.crt.CertUtil; import com.github.monkeywie.proxyee.server.HttpProxyCACertFactory; +import com.github.monkeywie.proxyee.server.HttpProxyServerConfig; +import io.netty.buffer.ByteBufAllocator; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.net.ssl.SSLEngine; import java.io.File; +import java.math.BigInteger; import java.nio.file.Files; import java.nio.file.Paths; import java.security.KeyPair; import java.security.PrivateKey; +import java.security.PublicKey; import java.security.cert.X509Certificate; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Date; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; public class NitroCertificateFactory implements HttpProxyCACertFactory { @@ -25,6 +41,7 @@ public class NitroCertificateFactory implements HttpProxyCACertFactory { private X509Certificate caCert; private PrivateKey caKey; + private HttpProxyServerConfig config; public NitroCertificateFactory() { this.caCertFile = new File(String.format("./%s.crt", NitroAuthority.CERT_ALIAS)); @@ -107,8 +124,57 @@ public class NitroCertificateFactory implements HttpProxyCACertFactory { return this.caKey; } - public SSLEngine websocketSslEngine(String host) { - throw new UnsupportedOperationException("Not implemented"); + public void setServerConfig(HttpProxyServerConfig config) { + this.config = config; } + public SSLEngine websocketSslEngine(String commonName) { + if (this.config == null) { + throw new IllegalStateException("Server config not set"); + } + + try { + final X509Certificate cert = generateServerCert(commonName, + new GeneralName(GeneralName.dNSName, "localhost"), + new GeneralName(GeneralName.iPAddress, "127.0.0.1")); + + final SslContext ctx = SslContextBuilder.forServer(this.config.getServerPriKey(), cert).build(); + + return ctx.newEngine(ByteBufAllocator.DEFAULT); + } catch (Exception e) { + log.error("Failed to create SSLEngine", e); + return null; + } + } + + private X509Certificate generateServerCert(String commonName, GeneralName... san) throws Exception { + final String issuer = this.config.getIssuer(); + final PrivateKey caPriKey = this.config.getCaPriKey(); + final Date caNotBefore = this.config.getCaNotBefore(); + final Date caNotAfter = this.config.getCaNotAfter(); + final PublicKey serverPubKey = this.config.getServerPubKey(); + + // Replace "CN" in cert authority + final String subject = Stream.of(issuer.split(", ")).map(item -> { + String[] arr = item.split("="); + if ("CN".equals(arr[0])) { + return "CN=" + commonName; + } else { + return item; + } + }).collect(Collectors.joining(", ")); + + final JcaX509v3CertificateBuilder jv3Builder = new JcaX509v3CertificateBuilder(new X500Name(issuer), + BigInteger.valueOf(System.currentTimeMillis() + (long) (Math.random() * 10000) + 1000), + caNotBefore, + caNotAfter, + new X500Name(subject), + serverPubKey); + + jv3Builder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(san)); + + final ContentSigner signer = new JcaContentSignerBuilder("SHA256WithRSAEncryption").build(caPriKey); + + return new JcaX509CertificateConverter().getCertificate(jv3Builder.build(signer)); + } } diff --git a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroHttpProxy.java b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroHttpProxy.java index 7afc683..443d7a6 100644 --- a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroHttpProxy.java +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroHttpProxy.java @@ -133,6 +133,7 @@ public class NitroHttpProxy { proxyServer.startAsync(NitroConstants.HTTP_PORT); // Hack to swap the SSL context. + // Need to set this after proxyServer is started because starting it will override the configured SSL context. try { Security.addProvider(new BouncyCastleProvider()); @@ -150,6 +151,9 @@ public class NitroHttpProxy { return false; } + // Add config to factory so websocket server can use it as well. + this.certificateFactory.setServerConfig(config); + if (!registerProxy()) { proxyServer.close(); diff --git a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroHttpProxyIntercept.java b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroHttpProxyIntercept.java index 81273e3..dca6cdf 100644 --- a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroHttpProxyIntercept.java +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroHttpProxyIntercept.java @@ -2,19 +2,59 @@ package gearth.protocol.connection.proxy.nitro.http; import com.github.monkeywie.proxyee.intercept.HttpProxyInterceptInitializer; import com.github.monkeywie.proxyee.intercept.HttpProxyInterceptPipeline; +import com.github.monkeywie.proxyee.intercept.common.FullRequestIntercept; import com.github.monkeywie.proxyee.intercept.common.FullResponseIntercept; -import io.netty.handler.codec.http.FullHttpResponse; -import io.netty.handler.codec.http.HttpRequest; -import io.netty.handler.codec.http.HttpResponse; +import com.github.monkeywie.proxyee.util.ByteUtil; +import io.netty.buffer.ByteBuf; +import io.netty.handler.codec.http.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + public class NitroHttpProxyIntercept extends HttpProxyInterceptInitializer { private static final Logger log = LoggerFactory.getLogger(NitroHttpProxyIntercept.class); - public NitroHttpProxyIntercept(NitroHttpProxyServerCallback serverCallback) { + private static final String NitroConfigSearch = "socket.url"; + private static final String NitroClientSearch = "configurationUrls:"; + private static final Pattern NitroConfigPattern = Pattern.compile("[\"']socket\\.url[\"']:(\\s+)?[\"'](wss?:.*?)[\"']", Pattern.MULTILINE); + // https://developers.cloudflare.com/fundamentals/get-started/reference/cloudflare-cookies/ + private static final HashSet CloudflareCookies = new HashSet<>(Arrays.asList( + "__cflb", + "__cf_bm", + "__cfseq", + "cf_ob_info", + "cf_use_ob", + "__cfwaitingroom", + "__cfruid", + "_cfuvid", + "cf_clearance", + "cf_chl_rc_i", + "cf_chl_rc_ni", + "cf_chl_rc_m" + )); + + private static final String HeaderAcceptEncoding = "Accept-Encoding"; + private static final String HeaderAge = "Age"; + private static final String HeaderCacheControl = "Cache-Control"; + private static final String HeaderContentSecurityPolicy = "Content-Security-Policy"; + private static final String HeaderETag = "ETag"; + private static final String HeaderIfNoneMatch = "If-None-Match"; + private static final String HeaderIfModifiedSince = "If-Modified-Since"; + private static final String HeaderLastModified = "Last-Modified"; + + private final NitroHttpProxyServerCallback callback; + + public NitroHttpProxyIntercept(NitroHttpProxyServerCallback callback) { + this.callback = callback; } @Override @@ -22,14 +62,144 @@ public class NitroHttpProxyIntercept extends HttpProxyInterceptInitializer { pipeline.addLast(new FullResponseIntercept() { @Override public boolean match(HttpRequest httpRequest, HttpResponse httpResponse, HttpProxyInterceptPipeline httpProxyInterceptPipeline) { - log.debug("Intercepting response for {}", httpRequest.uri()); - return false; + log.debug("Intercepting response for {} {}", httpRequest.headers().get(HttpHeaderNames.HOST), httpRequest.uri()); + return true; } @Override public void handleResponse(HttpRequest httpRequest, FullHttpResponse httpResponse, HttpProxyInterceptPipeline pipeline) { - super.handleResponse(httpRequest, httpResponse, pipeline); + // Strip cache headers. + stripCacheHeaders(httpResponse.headers()); + + // Check for response body. + final ByteBuf content = httpResponse.content(); + + if (content == null || content.readableBytes() == 0) { + return; + } + + // Find nitro configuration. + if (ByteUtil.findText(content, NitroConfigSearch) != -1) { + final String responseBody = responseRead(httpResponse); + final Matcher matcher = NitroConfigPattern.matcher(responseBody); + + // Replace websocket with proxy. + if (matcher.find()) { + final String originalWebsocket = matcher.group(2).replace("\\/", "/"); + final String replacementWebsocket = callback.replaceWebsocketServer(originalWebsocket); + + if (replacementWebsocket != null) { + final String updatedBody = responseBody.replace(matcher.group(2), replacementWebsocket); + + responseWrite(httpResponse, updatedBody); + } + } + + // Retrieve cookies for request to the origin. + final String requestCookies = parseCookies(httpRequest); + + if (requestCookies != null && !requestCookies.isEmpty()) { + callback.setOriginCookies(requestCookies); + } + } + + // Strip CSP headers + if (ByteUtil.findText(content, NitroClientSearch) != -1) { + stripContentSecurityPolicy(httpResponse); + } + } + }); + + pipeline.addLast(new FullRequestIntercept() { + @Override + public boolean match(HttpRequest httpRequest, HttpProxyInterceptPipeline pipeline) { + log.debug("Intercepting request for {} {}", httpRequest.headers().get(HttpHeaderNames.HOST), httpRequest.uri()); + return true; + } + + @Override + public void handleRequest(FullHttpRequest httpRequest, HttpProxyInterceptPipeline pipeline) { + // Disable caching. + stripCacheHeaders(httpRequest.headers()); } }); } + + /** + * Check if cookies from the request need to be recorded for the websocket connection to the origin server. + */ + private static String parseCookies(final HttpRequest request) { + final List result = new ArrayList<>(); + final List cookieHeaders = request.headers().getAll("Cookie"); + + for (final String cookieHeader : cookieHeaders) { + final String[] cookies = cookieHeader.split(";"); + + for (final String cookie : cookies) { + final String[] parts = cookie.trim().split("="); + + if (CloudflareCookies.contains(parts[0])) { + result.add(cookie.trim()); + } + } + } + + if (result.isEmpty()) { + return null; + } + + return String.join("; ", result); + } + + /** + * Modify Content-Security-Policy header, which could prevent Nitro from connecting with G-Earth. + */ + private static void stripContentSecurityPolicy(FullHttpResponse response) { + final HttpHeaders headers = response.headers(); + + if (!headers.contains(HeaderContentSecurityPolicy)){ + return; + } + + String csp = headers.get(HeaderContentSecurityPolicy); + + if (csp.contains("connect-src")) { + csp = csp.replace("connect-src", "connect-src *"); + } else if (csp.contains("default-src")) { + csp = csp.replace("default-src", "default-src *"); + } + + headers.set(HeaderContentSecurityPolicy, csp); + } + + /** + * Strip cache headers from the response. + */ + private static void stripCacheHeaders(HttpHeaders headers) { + headers.remove(HeaderAcceptEncoding); + headers.remove(HeaderAge); + headers.remove(HeaderCacheControl); + headers.remove(HeaderETag); + headers.remove(HeaderIfNoneMatch); + headers.remove(HeaderIfModifiedSince); + headers.remove(HeaderLastModified); + } + + private static String responseRead(FullHttpResponse response) { + final ByteBuf contentBuf = response.content(); + return contentBuf.toString(StandardCharsets.UTF_8); + } + + private static void responseWrite(FullHttpResponse response, String content) { + final byte[] body = content.getBytes(StandardCharsets.UTF_8); + + // Update content. + response.content().clear().writeBytes(body); + + // Update content-length. + HttpUtil.setContentLength(response, body.length); + + // Ensure modified response is not cached. + stripCacheHeaders(response.headers()); + } } diff --git a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroHttpProxyServerCallback.java b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroHttpProxyServerCallback.java index c90b5dc..ed2f7a0 100644 --- a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroHttpProxyServerCallback.java +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroHttpProxyServerCallback.java @@ -5,11 +5,10 @@ public interface NitroHttpProxyServerCallback { /** * Specify a replacement for the given websocket url. * - * @param configUrl The url at which the websocket url was found. * @param websocketUrl The hotel websocket url. * @return Return null to not replace anything, otherwise specify an alternative websocket url. */ - String replaceWebsocketServer(String configUrl, String websocketUrl); + String replaceWebsocketServer(String websocketUrl); /** * Sets the parsed cookies for the origin WebSocket connection.