diff --git a/.gitignore b/.gitignore index 902afbc..beed7d5 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ Extensions/BlockReplacePackets/.classpath *.settings/org.eclipse.jdt.apt.core.prefs *.settings/org.eclipse.jdt.core.prefs *.settings/org.eclipse.m2e.core.prefs + +# Certificates +*.p12 +*.pem \ No newline at end of file diff --git a/G-Earth/pom.xml b/G-Earth/pom.xml index 09b1b80..9164616 100644 --- a/G-Earth/pom.xml +++ b/G-Earth/pom.xml @@ -224,7 +224,11 @@ bytes 1.5.0 - + + com.github.ganskef + littleproxy-mitm + 1.1.0 + diff --git a/G-Earth/src/main/java/gearth/misc/RuntimeUtil.java b/G-Earth/src/main/java/gearth/misc/RuntimeUtil.java new file mode 100644 index 0000000..0e17736 --- /dev/null +++ b/G-Earth/src/main/java/gearth/misc/RuntimeUtil.java @@ -0,0 +1,31 @@ +package gearth.misc; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; + +public final class RuntimeUtil { + + public static String getCommandOutput(String[] command) throws IOException { + try { + final Runtime rt = Runtime.getRuntime(); + final Process proc = rt.exec(command); + + final BufferedReader stdInput = new BufferedReader(new InputStreamReader(proc.getInputStream())); + final StringBuilder result = new StringBuilder(); + + String line; + + while ((line = stdInput.readLine()) != null) { + result.append(line); + result.append("\n"); + } + + return result.toString(); + } catch (IOException e) { + e.printStackTrace(); + throw e; + } + } + +} diff --git a/G-Earth/src/main/java/gearth/protocol/HConnection.java b/G-Earth/src/main/java/gearth/protocol/HConnection.java index db1c083..70bd0c4 100644 --- a/G-Earth/src/main/java/gearth/protocol/HConnection.java +++ b/G-Earth/src/main/java/gearth/protocol/HConnection.java @@ -1,6 +1,7 @@ package gearth.protocol; import gearth.misc.listenerpattern.Observable; +import gearth.protocol.connection.proxy.nitro.NitroProxyProvider; import gearth.services.packet_info.PacketInfoManager; import gearth.protocol.connection.HClient; import gearth.protocol.connection.HProxy; @@ -68,6 +69,12 @@ public class HConnection { startMITM(); } + public void startNitro() { + HConnection selff = this; + proxyProvider = new NitroProxyProvider(proxy -> selff.proxy = proxy, selff::setState, this); + startMITM(); + } + private void startMITM() { try { if (proxyProvider != null) { diff --git a/G-Earth/src/main/java/gearth/protocol/connection/HClient.java b/G-Earth/src/main/java/gearth/protocol/connection/HClient.java index 54653c3..027fafd 100644 --- a/G-Earth/src/main/java/gearth/protocol/connection/HClient.java +++ b/G-Earth/src/main/java/gearth/protocol/connection/HClient.java @@ -2,5 +2,6 @@ package gearth.protocol.connection; public enum HClient { UNITY, - FLASH + FLASH, + NITRO } diff --git a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/NitroConstants.java b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/NitroConstants.java new file mode 100644 index 0000000..2d111f3 --- /dev/null +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/NitroConstants.java @@ -0,0 +1,13 @@ +package gearth.protocol.connection.proxy.nitro; + +public final class NitroConstants { + + public static final int HTTP_PORT = 9090; + public static final int HTTP_BUFFER_SIZE = 1024 * 1024 * 10; + + public static final int WEBSOCKET_PORT = 2096; + public static final int WEBSOCKET_BUFFER_SIZE = 1024 * 1024 * 10; + public static final String WEBSOCKET_REVISION = "PRODUCTION-201611291003-338511768"; + public static final String WEBSOCKET_CLIENT_IDENTIFIER = "HTML5"; + +} 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 new file mode 100644 index 0000000..3cb47a1 --- /dev/null +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/NitroProxyProvider.java @@ -0,0 +1,93 @@ +package gearth.protocol.connection.proxy.nitro; + +import gearth.protocol.HConnection; +import gearth.protocol.StateChangeListener; +import gearth.protocol.connection.HProxySetter; +import gearth.protocol.connection.HState; +import gearth.protocol.connection.HStateSetter; +import gearth.protocol.connection.proxy.ProxyProvider; +import gearth.protocol.connection.proxy.nitro.http.NitroHttpProxy; +import gearth.protocol.connection.proxy.nitro.http.NitroHttpProxyServerCallback; +import gearth.protocol.connection.proxy.nitro.websocket.NitroWebsocketProxy; + +import java.io.IOException; + +public class NitroProxyProvider implements ProxyProvider, NitroHttpProxyServerCallback, StateChangeListener { + + private final HProxySetter proxySetter; + private final HStateSetter stateSetter; + private final HConnection connection; + private final NitroHttpProxy nitroHttpProxy; + private final NitroWebsocketProxy nitroWebsocketProxy; + + private String originalWebsocketUrl; + + public NitroProxyProvider(HProxySetter proxySetter, HStateSetter stateSetter, HConnection connection) { + this.proxySetter = proxySetter; + this.stateSetter = stateSetter; + this.connection = connection; + this.nitroHttpProxy = new NitroHttpProxy(this); + this.nitroWebsocketProxy = new NitroWebsocketProxy(proxySetter, stateSetter, connection, this); + } + + public String getOriginalWebsocketUrl() { + return originalWebsocketUrl; + } + + @Override + public void start() throws IOException { + connection.getStateObservable().addListener(this); + + if (!nitroHttpProxy.start()) { + System.out.println("Failed to start nitro proxy"); + abort(); + return; + } + + if (!nitroWebsocketProxy.start()) { + System.out.println("Failed to start nitro websocket proxy"); + abort(); + return; + } + + stateSetter.setState(HState.WAITING_FOR_CLIENT); + } + + @Override + public void abort() { + stateSetter.setState(HState.ABORTING); + + new Thread(() -> { + try { + nitroHttpProxy.stop(); + } catch (Exception e) { + e.printStackTrace(); + } + + try { + nitroWebsocketProxy.stop(); + } catch (Exception e) { + e.printStackTrace(); + } + + stateSetter.setState(HState.NOT_CONNECTED); + + connection.getStateObservable().removeListener(this); + }).start(); + } + + @Override + public String replaceWebsocketServer(String configUrl, String websocketUrl) { + originalWebsocketUrl = websocketUrl; + return String.format("ws://127.0.0.1:%d", NitroConstants.WEBSOCKET_PORT); + } + + @Override + public void stateChanged(HState oldState, HState newState) { + if (oldState == HState.WAITING_FOR_CLIENT && newState == HState.CONNECTED) { + // Unregister but do not stop http proxy. + // We are not stopping the http proxy because some requests might still require it to be running. + nitroHttpProxy.pause(); + } + } +} diff --git a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroAuthority.java b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroAuthority.java new file mode 100644 index 0000000..fd5bd4e --- /dev/null +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroAuthority.java @@ -0,0 +1,24 @@ +package gearth.protocol.connection.proxy.nitro.http; + +import org.littleshoot.proxy.mitm.Authority; + +import java.io.File; + +public class NitroAuthority extends Authority { + + private static final String CERT_ALIAS = "gearth-nitro"; + private static final String CERT_ORGANIZATION = "G-Earth Nitro"; + private static final String CERT_DESCRIPTION = "G-Earth nitro support"; + + public NitroAuthority() { + super(new File("."), + CERT_ALIAS, + "verysecure".toCharArray(), + CERT_DESCRIPTION, + CERT_ORGANIZATION, + "Certificate Authority", + CERT_ORGANIZATION, + CERT_DESCRIPTION); + } + +} 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 new file mode 100644 index 0000000..231abb7 --- /dev/null +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroHttpProxy.java @@ -0,0 +1,153 @@ +package gearth.protocol.connection.proxy.nitro.http; + +import gearth.misc.ConfirmationDialog; +import gearth.protocol.connection.proxy.nitro.NitroConstants; +import gearth.protocol.connection.proxy.nitro.os.NitroOsFunctions; +import gearth.protocol.connection.proxy.nitro.os.NitroOsFunctionsFactory; +import javafx.application.Platform; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; +import org.littleshoot.proxy.HttpProxyServer; +import org.littleshoot.proxy.impl.DefaultHttpProxyServer; +import org.littleshoot.proxy.mitm.Authority; +import org.littleshoot.proxy.mitm.CertificateSniffingMitmManager; +import org.littleshoot.proxy.mitm.RootCertificateException; + +import java.io.File; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; + +public class NitroHttpProxy { + + private static final String ADMIN_WARNING_KEY = "admin_warning_dialog"; + private static final AtomicBoolean SHUTDOWN_HOOK = new AtomicBoolean(); + + private final Authority authority; + private final NitroOsFunctions osFunctions; + private final NitroHttpProxyServerCallback serverCallback; + + private HttpProxyServer proxyServer = null; + + public NitroHttpProxy(NitroHttpProxyServerCallback serverCallback) { + this.serverCallback = serverCallback; + this.authority = new NitroAuthority(); + this.osFunctions = NitroOsFunctionsFactory.create(); + } + + private boolean initializeCertificate() { + final File certificate = this.authority.aliasFile(".pem"); + + // All good if certificate is already trusted. + if (this.osFunctions.isRootCertificateTrusted(certificate)) { + return true; + } + + // Let the user know about admin permissions. + final Semaphore waitForDialog = new Semaphore(0); + final AtomicBoolean shouldInstall = new AtomicBoolean(); + + Platform.runLater(() -> { + Alert alert = ConfirmationDialog.createAlertWithOptOut(Alert.AlertType.WARNING, ADMIN_WARNING_KEY, + "Root certificate installation", null, + "G-Earth detected that you do not have the root certificate authority installed. " + + "This is required for Nitro to work, do you want to continue? " + + "G-Earth will ask you for Administrator permission if you do so.", "Remember my choice", + ButtonType.YES, ButtonType.NO + ); + + shouldInstall.set(!(alert.showAndWait().filter(t -> t == ButtonType.YES).isPresent())); + waitForDialog.release(); + }); + + // Wait for dialog choice. + try { + waitForDialog.acquire(); + } catch (InterruptedException e) { + e.printStackTrace(); + return false; + } + + // User opted out. + if (!shouldInstall.get()) { + return false; + } + + return this.osFunctions.installRootCertificate(this.authority.aliasFile(".pem")); + } + + /** + * Register HTTP(s) proxy on the system. + */ + private boolean registerProxy() { + return this.osFunctions.registerSystemProxy("127.0.0.1", NitroConstants.HTTP_PORT); + } + + /** + * Unregister HTTP(s) proxy from system. + */ + private boolean unregisterProxy() { + return this.osFunctions.unregisterSystemProxy(); + } + + public boolean start() { + setupShutdownHook(); + + try { + proxyServer = DefaultHttpProxyServer.bootstrap() + .withPort(NitroConstants.HTTP_PORT) + .withManInTheMiddle(new CertificateSniffingMitmManager(authority)) + .withFiltersSource(new NitroHttpProxyFilterSource(serverCallback)) + .start(); + + if (!initializeCertificate()) { + proxyServer.stop(); + + System.out.println("Failed to initialize certificate"); + return false; + } + + if (!registerProxy()) { + proxyServer.stop(); + + System.out.println("Failed to register certificate"); + return false; + } + + return true; + } catch (RootCertificateException e) { + e.printStackTrace(); + return false; + } + } + + public void pause() { + if (!unregisterProxy()) { + System.out.println("Failed to unregister system proxy, please check manually"); + } + } + + public void stop() { + pause(); + + if (proxyServer == null) { + return; + } + + proxyServer.stop(); + proxyServer = null; + } + + /** + * Ensure the system proxy is removed when G-Earth exits. + * Otherwise, users might complain that their browsers / discord stop working when closing G-Earth incorrectly. + */ + private static void setupShutdownHook() { + if (SHUTDOWN_HOOK.get()) { + return; + } + + if (SHUTDOWN_HOOK.compareAndSet(false, true)) { + Runtime.getRuntime().addShutdownHook(new Thread(() -> NitroOsFunctionsFactory.create().unregisterSystemProxy())); + } + } +} diff --git a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroHttpProxyFilter.java b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroHttpProxyFilter.java new file mode 100644 index 0000000..ea0cddc --- /dev/null +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroHttpProxyFilter.java @@ -0,0 +1,152 @@ +package gearth.protocol.connection.proxy.nitro.http; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.http.*; +import io.netty.util.CharsetUtil; +import org.littleshoot.proxy.HttpFiltersAdapter; + +import java.nio.charset.StandardCharsets; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class NitroHttpProxyFilter extends HttpFiltersAdapter { + + private static final String NitroConfigSearch = "\"socket.url\""; + private static final String NitroClientSearch = "configurationUrls:"; + private static final Pattern NitroConfigPattern = Pattern.compile("\"socket\\.url\":.?\"(wss?://.*?)\"", Pattern.MULTILINE); + + 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; + private final String url; + + public NitroHttpProxyFilter(HttpRequest originalRequest, ChannelHandlerContext ctx, NitroHttpProxyServerCallback callback, String url) { + super(originalRequest, ctx); + this.callback = callback; + this.url = url; + } + + @Override + public HttpResponse clientToProxyRequest(HttpObject httpObject) { + if (httpObject instanceof HttpRequest) { + HttpRequest request = (HttpRequest) httpObject; + HttpHeaders headers = request.headers(); + + // Only support gzip or deflate. + // The LittleProxy library does not support brotli. + if (headers.contains(HeaderAcceptEncoding)) { + String encoding = headers.get(HeaderAcceptEncoding); + + if (encoding.contains("br")) { + if (encoding.contains("gzip") && encoding.contains("deflate")) { + headers.set(HeaderAcceptEncoding, "gzip, deflate"); + } else if (encoding.contains("gzip")) { + headers.set(HeaderAcceptEncoding, "gzip, deflate"); + } else { + headers.remove(HeaderAcceptEncoding); + } + } + } + + // Disable caching. + stripCacheHeaders(headers); + } + + return super.clientToProxyRequest(httpObject); + } + + @Override + public HttpObject serverToProxyResponse(HttpObject httpObject) { + if (httpObject instanceof FullHttpResponse) { + final FullHttpResponse response = (FullHttpResponse) httpObject; + + // Find nitro configuration file. + boolean responseModified = false; + String responseBody = responseRead(response); + + if (responseBody.contains(NitroConfigSearch)) { + final Matcher matcher = NitroConfigPattern.matcher(responseBody); + + if (matcher.find()) { + final String originalWebsocket = matcher.group(1); + final String replacementWebsocket = callback.replaceWebsocketServer(url, originalWebsocket); + + if (replacementWebsocket != null) { + responseBody = responseBody.replace(originalWebsocket, replacementWebsocket); + responseModified = true; + } + } + } + + // Apply changes. + if (responseModified) { + responseWrite(response, responseBody); + } + + // CSP. + if (responseBody.contains(NitroClientSearch)) { + stripContentSecurityPolicy(response); + } + } + + return httpObject; + } + + /** + * Modify Content-Security-Policy header, which could prevent Nitro from connecting with G-Earth. + */ + private 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); + } + + private static String responseRead(FullHttpResponse response) { + final ByteBuf contentBuf = response.content(); + return contentBuf.toString(CharsetUtil.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. + HttpHeaders.setContentLength(response, body.length); + + // Ensure modified response is not cached. + stripCacheHeaders(response.headers()); + } + + 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); + } + +} diff --git a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroHttpProxyFilterSource.java b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroHttpProxyFilterSource.java new file mode 100644 index 0000000..c736458 --- /dev/null +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroHttpProxyFilterSource.java @@ -0,0 +1,47 @@ +package gearth.protocol.connection.proxy.nitro.http; + +import gearth.protocol.connection.proxy.nitro.NitroConstants; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.util.AttributeKey; +import org.littleshoot.proxy.HttpFilters; +import org.littleshoot.proxy.HttpFiltersAdapter; +import org.littleshoot.proxy.HttpFiltersSourceAdapter; + +public class NitroHttpProxyFilterSource extends HttpFiltersSourceAdapter { + + private static final AttributeKey CONNECTED_URL = AttributeKey.valueOf("connected_url"); + + private final NitroHttpProxyServerCallback callback; + + public NitroHttpProxyFilterSource(NitroHttpProxyServerCallback callback) { + this.callback = callback; + } + + @Override + public HttpFilters filterRequest(HttpRequest originalRequest, ChannelHandlerContext ctx) { + // https://github.com/ganskef/LittleProxy-mitm#resolving-uri-in-case-of-https + String uri = originalRequest.getUri(); + if (originalRequest.getMethod() == HttpMethod.CONNECT) { + if (ctx != null) { + String prefix = "https://" + uri.replaceFirst(":443$", ""); + ctx.channel().attr(CONNECTED_URL).set(prefix); + } + return new HttpFiltersAdapter(originalRequest, ctx); + } + + String connectedUrl = ctx.channel().attr(CONNECTED_URL).get(); + if (connectedUrl == null) { + return new NitroHttpProxyFilter(originalRequest, ctx, callback, uri); + } + + return new NitroHttpProxyFilter(originalRequest, ctx, callback, connectedUrl + uri); + } + + @Override + public int getMaximumResponseBufferSizeInBytes() { + // Increasing this causes LittleProxy to output "FullHttpResponse" objects. + return NitroConstants.HTTP_BUFFER_SIZE; + } +} 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 new file mode 100644 index 0000000..3f04f11 --- /dev/null +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/http/NitroHttpProxyServerCallback.java @@ -0,0 +1,14 @@ +package gearth.protocol.connection.proxy.nitro.http; + +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); + +} diff --git a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/os/NitroOsFunctions.java b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/os/NitroOsFunctions.java new file mode 100644 index 0000000..0bfaf29 --- /dev/null +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/os/NitroOsFunctions.java @@ -0,0 +1,15 @@ +package gearth.protocol.connection.proxy.nitro.os; + +import java.io.File; + +public interface NitroOsFunctions { + + boolean isRootCertificateTrusted(File certificate); + + boolean installRootCertificate(File certificate); + + boolean registerSystemProxy(String host, int port); + + boolean unregisterSystemProxy(); + +} diff --git a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/os/NitroOsFunctionsFactory.java b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/os/NitroOsFunctionsFactory.java new file mode 100644 index 0000000..18bbf7c --- /dev/null +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/os/NitroOsFunctionsFactory.java @@ -0,0 +1,21 @@ +package gearth.protocol.connection.proxy.nitro.os; + +import gearth.misc.OSValidator; +import gearth.protocol.connection.proxy.nitro.os.windows.NitroWindows; +import org.apache.commons.lang3.NotImplementedException; + +public final class NitroOsFunctionsFactory { + + public static NitroOsFunctions create() { + if (OSValidator.isWindows()) { + return new NitroWindows(); + } + + if (OSValidator.isUnix()) { + throw new NotImplementedException("unix nitro is not implemented yet"); + } + + throw new NotImplementedException("macOS nitro is not implemented yet"); + } + +} diff --git a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/os/windows/NitroWindows.java b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/os/windows/NitroWindows.java new file mode 100644 index 0000000..049a6be --- /dev/null +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/os/windows/NitroWindows.java @@ -0,0 +1,89 @@ +package gearth.protocol.connection.proxy.nitro.os.windows; + +import com.sun.jna.platform.win32.Kernel32; +import com.sun.jna.platform.win32.WinBase; +import com.sun.jna.platform.win32.WinDef; +import com.sun.jna.ptr.IntByReference; +import gearth.misc.RuntimeUtil; +import gearth.protocol.connection.proxy.nitro.os.NitroOsFunctions; + +import java.io.File; +import java.io.IOException; + +public class NitroWindows implements NitroOsFunctions { + + /** + * Semicolon separated hosts to ignore for proxying. + */ + private static final String PROXY_IGNORE = "discord.com;github.com;"; + + /** + * Checks if the certificate is trusted by the local machine. + * @param certificate Absolute path to the certificate. + * @return true if trusted + */ + @Override + public boolean isRootCertificateTrusted(File certificate) { + try { + final String output = RuntimeUtil.getCommandOutput(new String[] {"cmd", "/c", " certutil.exe -f -verify " + certificate.getAbsolutePath()}); + + return !output.contains("CERT_TRUST_IS_UNTRUSTED_ROOT") && + output.contains("dwInfoStatus=10c dwErrorStatus=0"); + } catch (IOException e) { + e.printStackTrace(); + } + + return false; + } + + @Override + public boolean installRootCertificate(File certificate) { + final String certificatePath = certificate.getAbsolutePath(); + + // Prompt UAC elevation. + WinDef.HINSTANCE result = NitroWindowsShell32.INSTANCE.ShellExecuteA(null, "runas", "cmd.exe", "/S /C \"certutil -addstore root " + certificatePath + "\"", null, 1); + + // Wait for exit. + Kernel32.INSTANCE.WaitForSingleObject(result, WinBase.INFINITE); + + // Exit code for certutil. + final IntByReference statusRef = new IntByReference(-1); + Kernel32.INSTANCE.GetExitCodeProcess(result, statusRef); + + // Check if process exited without errors + if (statusRef.getValue() != -1) { + System.out.printf("Certutil command exited with exit code %s%n", statusRef.getValue()); + return false; + } + + return true; + } + + @Override + public boolean registerSystemProxy(String host, int port) { + try { + final String proxy = String.format("%s:%d", host, port); + Runtime.getRuntime().exec("reg add \"HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings\" /v ProxyServer /t REG_SZ /d \"" + proxy + "\" /f"); + Runtime.getRuntime().exec("reg add \"HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings\" /v ProxyOverride /t REG_SZ /d \"" + PROXY_IGNORE + "\" /f"); + Runtime.getRuntime().exec("reg add \"HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings\" /v ProxyEnable /t REG_DWORD /d 1 /f"); + return true; + } catch (Exception e) { + e.printStackTrace(); + } + + return false; + } + + @Override + public boolean unregisterSystemProxy() { + try { + Runtime.getRuntime().exec("reg add \"HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings\" /v ProxyEnable /t REG_DWORD /d 0 /f"); + return true; + } catch (Exception e) { + e.printStackTrace(); + } + + return false; + } + +} diff --git a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/os/windows/NitroWindowsShell32.java b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/os/windows/NitroWindowsShell32.java new file mode 100644 index 0000000..69087aa --- /dev/null +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/os/windows/NitroWindowsShell32.java @@ -0,0 +1,17 @@ +package gearth.protocol.connection.proxy.nitro.os.windows; + +import com.sun.jna.Native; +import com.sun.jna.platform.win32.ShellAPI; +import com.sun.jna.platform.win32.WinDef; +import com.sun.jna.win32.StdCallLibrary; + +public interface NitroWindowsShell32 extends ShellAPI, StdCallLibrary { + NitroWindowsShell32 INSTANCE = Native.loadLibrary("shell32", NitroWindowsShell32.class); + + WinDef.HINSTANCE ShellExecuteA(WinDef.HWND hwnd, + String lpOperation, + String lpFile, + String lpParameters, + String lpDirectory, + int nShowCmd); +} diff --git a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/websocket/NitroPacketHandler.java b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/websocket/NitroPacketHandler.java new file mode 100644 index 0000000..d6ed58a --- /dev/null +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/websocket/NitroPacketHandler.java @@ -0,0 +1,59 @@ +package gearth.protocol.connection.proxy.nitro.websocket; + +import gearth.protocol.HMessage; +import gearth.protocol.HPacket; +import gearth.protocol.packethandler.PacketHandler; +import gearth.services.extension_handler.ExtensionHandler; +import gearth.services.extension_handler.OnHMessageHandled; + +import javax.websocket.Session; +import java.io.IOException; +import java.nio.ByteBuffer; + +public class NitroPacketHandler extends PacketHandler { + + private final HMessage.Direction direction; + private final NitroSession session; + + protected NitroPacketHandler(HMessage.Direction direction, NitroSession session, ExtensionHandler extensionHandler, Object[] trafficObservables) { + super(extensionHandler, trafficObservables); + this.direction = direction; + this.session = session; + } + + @Override + public boolean sendToStream(byte[] buffer) { + final Session localSession = session.getSession(); + + if (localSession == null) { + return false; + } + + // Required to prevent garbage buffer within the UI logger. + if (direction == HMessage.Direction.TOSERVER) { + buffer = buffer.clone(); + } + + localSession.getAsyncRemote().sendBinary(ByteBuffer.wrap(buffer)); + return true; + } + + @Override + public void act(byte[] buffer) throws IOException { + HMessage hMessage = new HMessage(new HPacket(buffer), direction, currentIndex); + + OnHMessageHandled afterExtensionIntercept = hMessage1 -> { + notifyListeners(2, hMessage1); + + if (!hMessage1.isBlocked()) { + sendToStream(hMessage1.getPacket().toBytes()); + } + }; + + notifyListeners(0, hMessage); + notifyListeners(1, hMessage); + extensionHandler.handle(hMessage, afterExtensionIntercept); + + currentIndex++; + } +} diff --git a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/websocket/NitroSession.java b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/websocket/NitroSession.java new file mode 100644 index 0000000..f85854e --- /dev/null +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/websocket/NitroSession.java @@ -0,0 +1,9 @@ +package gearth.protocol.connection.proxy.nitro.websocket; + +import javax.websocket.Session; + +public interface NitroSession { + + Session getSession(); + +} diff --git a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/websocket/NitroWebsocketClient.java b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/websocket/NitroWebsocketClient.java new file mode 100644 index 0000000..aabb946 --- /dev/null +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/websocket/NitroWebsocketClient.java @@ -0,0 +1,118 @@ +package gearth.protocol.connection.proxy.nitro.websocket; + +import gearth.protocol.HConnection; +import gearth.protocol.HMessage; +import gearth.protocol.connection.*; +import gearth.protocol.connection.proxy.nitro.NitroConstants; +import gearth.protocol.connection.proxy.nitro.NitroProxyProvider; + +import javax.websocket.*; +import javax.websocket.server.ServerEndpoint; +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; + +@ServerEndpoint(value = "/") +public class NitroWebsocketClient implements NitroSession { + + private final HProxySetter proxySetter; + private final HStateSetter stateSetter; + private final HConnection connection; + private final NitroProxyProvider proxyProvider; + private final NitroWebsocketServer server; + private final NitroPacketHandler packetHandler; + private final AtomicBoolean shutdownLock; + + private Session activeSession = null; + + public NitroWebsocketClient(HProxySetter proxySetter, HStateSetter stateSetter, HConnection connection, NitroProxyProvider proxyProvider) { + this.proxySetter = proxySetter; + this.stateSetter = stateSetter; + this.connection = connection; + this.proxyProvider = proxyProvider; + this.server = new NitroWebsocketServer(connection, this); + this.packetHandler = new NitroPacketHandler(HMessage.Direction.TOSERVER, server, connection.getExtensionHandler(), connection.getTrafficObservables()); + this.shutdownLock = new AtomicBoolean(); + } + + @OnOpen + public void onOpen(Session session) throws IOException { + activeSession = session; + activeSession.setMaxBinaryMessageBufferSize(NitroConstants.WEBSOCKET_BUFFER_SIZE); + + server.connect(proxyProvider.getOriginalWebsocketUrl()); + + final HProxy proxy = new HProxy(HClient.NITRO, "", "", -1, -1, ""); + + proxy.verifyProxy( + this.server.getPacketHandler(), + this.packetHandler, + NitroConstants.WEBSOCKET_REVISION, + NitroConstants.WEBSOCKET_CLIENT_IDENTIFIER + ); + + proxySetter.setProxy(proxy); + stateSetter.setState(HState.CONNECTED); + } + + @OnMessage + public void onMessage(byte[] b, Session session) throws IOException { + packetHandler.act(b); + } + + @OnClose + public void onClose(Session session) throws IOException { + activeSession = null; + shutdownProxy(); + } + + @OnError + public void onError(Session session, Throwable throwable) { + throwable.printStackTrace(); + + // Shutdown. + shutdownProxy(); + } + + @Override + public Session getSession() { + return activeSession; + } + + /** + * Shutdown and clean up the client connection. + */ + private void shutdown() { + if (activeSession == null) { + return; + } + + try { + activeSession.close(); + } catch (IOException e) { + e.printStackTrace(); + } finally { + activeSession = null; + } + } + + /** + * Shutdown all connections and reset program state. + */ + public void shutdownProxy() { + if (shutdownLock.get()) { + return; + } + + if (shutdownLock.compareAndSet(false, true)) { + // Close client connection. + shutdown(); + + // Close server connection. + server.shutdown(); + + // Reset program state. + proxySetter.setProxy(null); + stateSetter.setState(HState.NOT_CONNECTED); + } + } +} diff --git a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/websocket/NitroWebsocketProxy.java b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/websocket/NitroWebsocketProxy.java new file mode 100644 index 0000000..11122d1 --- /dev/null +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/websocket/NitroWebsocketProxy.java @@ -0,0 +1,66 @@ +package gearth.protocol.connection.proxy.nitro.websocket; + +import gearth.protocol.HConnection; +import gearth.protocol.connection.HProxySetter; +import gearth.protocol.connection.HStateSetter; +import gearth.protocol.connection.proxy.nitro.NitroConstants; +import gearth.protocol.connection.proxy.nitro.NitroProxyProvider; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.handler.HandlerList; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.websocket.jsr356.server.deploy.WebSocketServerContainerInitializer; + +import javax.websocket.server.ServerContainer; +import javax.websocket.server.ServerEndpointConfig; + +public class NitroWebsocketProxy { + + private final HProxySetter proxySetter; + private final HStateSetter stateSetter; + private final HConnection connection; + private final NitroProxyProvider proxyProvider; + + private final Server server; + + public NitroWebsocketProxy(HProxySetter proxySetter, HStateSetter stateSetter, HConnection connection, NitroProxyProvider proxyProvider) { + this.proxySetter = proxySetter; + this.stateSetter = stateSetter; + this.connection = connection; + this.proxyProvider = proxyProvider; + this.server = new Server(NitroConstants.WEBSOCKET_PORT); + } + + public boolean start() { + try { + final ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS); + context.setContextPath("/"); + + final HandlerList handlers = new HandlerList(); + handlers.setHandlers(new Handler[] { context }); + + final ServerContainer wscontainer = WebSocketServerContainerInitializer.configureContext(context); + wscontainer.addEndpoint(ServerEndpointConfig.Builder + .create(NitroWebsocketClient.class, "/") + .configurator(new NitroWebsocketServerConfigurator(proxySetter, stateSetter, connection, proxyProvider)) + .build()); + + server.setHandler(handlers); + server.start(); + + return true; + } catch (Exception e) { + e.printStackTrace(); + } + + return false; + } + + public void stop() { + try { + server.stop(); + } catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/websocket/NitroWebsocketServer.java b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/websocket/NitroWebsocketServer.java new file mode 100644 index 0000000..04b238c --- /dev/null +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/websocket/NitroWebsocketServer.java @@ -0,0 +1,82 @@ +package gearth.protocol.connection.proxy.nitro.websocket; + +import gearth.protocol.HConnection; +import gearth.protocol.HMessage; +import gearth.protocol.connection.proxy.nitro.NitroConstants; +import gearth.protocol.packethandler.PacketHandler; + +import javax.websocket.*; +import java.io.IOException; +import java.net.URI; + +@ClientEndpoint +public class NitroWebsocketServer implements NitroSession { + + private final PacketHandler packetHandler; + private final NitroWebsocketClient client; + private Session activeSession = null; + + public NitroWebsocketServer(HConnection connection, NitroWebsocketClient client) { + this.client = client; + this.packetHandler = new NitroPacketHandler(HMessage.Direction.TOCLIENT, client, connection.getExtensionHandler(), connection.getTrafficObservables()); + } + + public void connect(String websocketUrl) throws IOException { + try { + ContainerProvider.getWebSocketContainer().connectToServer(this, URI.create(websocketUrl)); + } catch (DeploymentException e) { + throw new IOException("Failed to deploy websocket client", e); + } + } + + @OnOpen + public void onOpen(Session Session) { + this.activeSession = Session; + this.activeSession.setMaxBinaryMessageBufferSize(NitroConstants.WEBSOCKET_BUFFER_SIZE); + } + + @OnMessage + public void onMessage(byte[] b, Session session) throws IOException { + packetHandler.act(b); + } + + @OnClose + public void onClose(Session userSession, CloseReason reason) { + // Hotel closed connection. + client.shutdownProxy(); + } + + @OnError + public void onError(Session session, Throwable throwable) { + throwable.printStackTrace(); + + // Shutdown. + client.shutdownProxy(); + } + + @Override + public Session getSession() { + return activeSession; + } + + public PacketHandler getPacketHandler() { + return packetHandler; + } + + /** + * Shutdown and clean up the server connection. + */ + public void shutdown() { + if (activeSession == null) { + return; + } + + try { + activeSession.close(); + } catch (IOException e) { + e.printStackTrace(); + } finally { + activeSession = null; + } + } +} diff --git a/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/websocket/NitroWebsocketServerConfigurator.java b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/websocket/NitroWebsocketServerConfigurator.java new file mode 100644 index 0000000..8155f27 --- /dev/null +++ b/G-Earth/src/main/java/gearth/protocol/connection/proxy/nitro/websocket/NitroWebsocketServerConfigurator.java @@ -0,0 +1,28 @@ +package gearth.protocol.connection.proxy.nitro.websocket; + +import gearth.protocol.HConnection; +import gearth.protocol.connection.HProxySetter; +import gearth.protocol.connection.HStateSetter; +import gearth.protocol.connection.proxy.nitro.NitroProxyProvider; + +import javax.websocket.server.ServerEndpointConfig; + +public class NitroWebsocketServerConfigurator extends ServerEndpointConfig.Configurator { + + private final HProxySetter proxySetter; + private final HStateSetter stateSetter; + private final HConnection connection; + private final NitroProxyProvider proxyProvider; + + public NitroWebsocketServerConfigurator(HProxySetter proxySetter, HStateSetter stateSetter, HConnection connection, NitroProxyProvider proxyProvider) { + this.proxySetter = proxySetter; + this.stateSetter = stateSetter; + this.connection = connection; + this.proxyProvider = proxyProvider; + } + + @Override + public T getEndpointInstance(Class endpointClass) { + return (T) new NitroWebsocketClient(proxySetter, stateSetter, connection, proxyProvider); + } +} diff --git a/G-Earth/src/main/java/gearth/services/packet_info/PacketInfoManager.java b/G-Earth/src/main/java/gearth/services/packet_info/PacketInfoManager.java index 27904b4..af1fda8 100644 --- a/G-Earth/src/main/java/gearth/services/packet_info/PacketInfoManager.java +++ b/G-Earth/src/main/java/gearth/services/packet_info/PacketInfoManager.java @@ -114,8 +114,7 @@ public class PacketInfoManager { if (clientType == HClient.UNITY) { result.addAll(new GEarthUnityPacketInfoProvider(hotelversion).provide()); - } - else if (clientType == HClient.FLASH) { + } else if (clientType == HClient.FLASH || clientType == HClient.NITRO) { try { List providers = new ArrayList<>(); providers.add(new HarblePacketInfoProvider(hotelversion)); diff --git a/G-Earth/src/main/java/gearth/ui/connection/ConnectionController.java b/G-Earth/src/main/java/gearth/ui/connection/ConnectionController.java index 287d70a..b908666 100644 --- a/G-Earth/src/main/java/gearth/ui/connection/ConnectionController.java +++ b/G-Earth/src/main/java/gearth/ui/connection/ConnectionController.java @@ -2,6 +2,7 @@ package gearth.ui.connection; import gearth.Main; import gearth.misc.Cacher; +import gearth.protocol.connection.HClient; import gearth.protocol.connection.HState; import gearth.protocol.connection.proxy.ProxyProviderFactory; import gearth.services.Constants; @@ -38,10 +39,11 @@ public class ConnectionController extends SubForm { private volatile int fullyInitialized = 0; - public static final String USE_UNITY_CLIENT_CACHE_KEY = "use_unity"; + public static final String CLIENT_CACHE_KEY = "last_client_mode"; public ToggleGroup tgl_clientMode; public RadioButton rd_unity; public RadioButton rd_flash; + public RadioButton rd_nitro; public GridPane grd_clientSelection; private volatile int initcount = 0; @@ -54,9 +56,18 @@ public class ConnectionController extends SubForm { Constants.UNITY_PACKETS = rd_unity.isSelected(); }); - if (Cacher.getCacheContents().has(USE_UNITY_CLIENT_CACHE_KEY)) { - rd_unity.setSelected(Cacher.getCacheContents().getBoolean(USE_UNITY_CLIENT_CACHE_KEY)); - rd_flash.setSelected(!Cacher.getCacheContents().getBoolean(USE_UNITY_CLIENT_CACHE_KEY)); + if (Cacher.getCacheContents().has(CLIENT_CACHE_KEY)) { + switch (Cacher.getCacheContents().getEnum(HClient.class, CLIENT_CACHE_KEY)) { + case FLASH: + rd_flash.setSelected(true); + break; + case UNITY: + rd_unity.setSelected(true); + break; + case NITRO: + rd_nitro.setSelected(true); + break; + } } @@ -235,6 +246,10 @@ public class ConnectionController extends SubForm { Platform.runLater(() -> rd_unity.setSelected(true)); getHConnection().startUnity(); } + else if (connectMode.equals("nitro")) { + Platform.runLater(() -> rd_nitro.setSelected(true)); + getHConnection().startNitro(); + } Platform.runLater(this::updateInputUI); } } @@ -244,16 +259,16 @@ public class ConnectionController extends SubForm { btnConnect.setDisable(true); new Thread(() -> { - if (useFlash()) { + if (isClientMode(HClient.FLASH)) { if (cbx_autodetect.isSelected()) { getHConnection().start(); - } - else { + } else { getHConnection().start(inpHost.getEditor().getText(), Integer.parseInt(inpPort.getEditor().getText())); } - } - else { + } else if (isClientMode(HClient.UNITY)) { getHConnection().startUnity(); + } else if (isClientMode(HClient.NITRO)) { + getHConnection().startNitro(); } @@ -269,7 +284,13 @@ public class ConnectionController extends SubForm { @Override protected void onExit() { - Cacher.put(USE_UNITY_CLIENT_CACHE_KEY, rd_unity.isSelected()); + if (rd_flash.isSelected()) { + Cacher.put(CLIENT_CACHE_KEY, HClient.FLASH); + } else if (rd_unity.isSelected()) { + Cacher.put(CLIENT_CACHE_KEY, HClient.UNITY); + } else if (rd_nitro.isSelected()) { + Cacher.put(CLIENT_CACHE_KEY, HClient.NITRO); + } getHConnection().abort(); } @@ -280,4 +301,17 @@ public class ConnectionController extends SubForm { private boolean useFlash() { return rd_flash.isSelected(); } + + private boolean isClientMode(HClient client) { + switch (client) { + case FLASH: + return rd_flash.isSelected(); + case UNITY: + return rd_unity.isSelected(); + case NITRO: + return rd_nitro.isSelected(); + } + + return false; + } } diff --git a/G-Earth/src/main/resources/gearth/ui/connection/Connection.fxml b/G-Earth/src/main/resources/gearth/ui/connection/Connection.fxml index 96b87e5..16982b0 100644 --- a/G-Earth/src/main/resources/gearth/ui/connection/Connection.fxml +++ b/G-Earth/src/main/resources/gearth/ui/connection/Connection.fxml @@ -5,7 +5,7 @@ - + @@ -132,9 +132,9 @@ - - - + + + @@ -156,7 +156,13 @@ - + + + + + + +