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.