Skip to content

Commit

Permalink
Add network diagnostic tool to advanced settings section (#391)
Browse files Browse the repository at this point in the history
Co-authored-by: Nikhil Narayana <[email protected]>
Co-authored-by: Vince Au <[email protected]>
  • Loading branch information
3 people authored Sep 29, 2023
1 parent a22e8af commit 01957f6
Show file tree
Hide file tree
Showing 14 changed files with 907 additions and 8 deletions.
5 changes: 3 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,12 @@ jobs:
- name: Load Windows signing secrets
env:
ES_USERNAME: ${{ secrets.ES_USERNAME }}
if: matrix.os == 'windows-latest' && env.ES_USERNAME != null
FROM_FORK: ${{ github.event_name == 'pull_request' }} # pull request events from our repo are skipped in favor of push events
if: matrix.os == 'windows-latest' && (env.ES_USERNAME != null || github.event_name == 'pull_request')
shell: bash
run: |
msg="$(git log -1 --no-merges --pretty=%B)"
if [[ ! $msg =~ "^release.*" ]];
if [[ "$FROM_FORK" = "true" ]] || [[ ! $msg =~ "^release.*" ]];
then
echo "not a release, skipping code signing"
echo "SKIP_CODE_SIGNING=yes" >> $GITHUB_ENV
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"@testing-library/jest-dom": "^5.16.1",
"@testing-library/react": "^12.1.2",
"@types/compare-func": "^1.3.0",
"@types/default-gateway": "^7.2.0",
"@types/jest": "^27.4.1",
"@types/lodash": "^4.14.178",
"@types/mousetrap": "^1.6.9",
Expand Down Expand Up @@ -155,8 +156,10 @@
"@mui/lab": "^5.0.0-alpha.127",
"@mui/material": "^5.12.1",
"@slippi/slippi-js": "^6.7.0",
"@xmcl/nat-api": "^0.4.1",
"compare-func": "^2.0.0",
"cross-fetch": "^3.1.5",
"default-gateway": "^7.2.2",
"dmg": "^0.1.0",
"electron-debug": "^3.2.0",
"electron-log": "^4.4.6",
Expand All @@ -174,6 +177,7 @@
"moment": "^2.29.4",
"mousetrap": "^1.6.5",
"node-stream-zip": "^1.15.0",
"nodejs-traceroute": "2.0.0",
"notistack": "^3.0.0-alpha.2",
"obs-websocket-js": "^5.0.1",
"observable-fns": "^0.6.1",
Expand All @@ -189,6 +193,7 @@
"react-twitter-embed": "^4.0.4",
"react-virtualized-auto-sizer": "^1.0.3",
"react-window": "^1.8.6",
"stun": "^2.1.0",
"wget-improved": "^3.3.1",
"zustand": "^3.2.0"
}
Expand Down
19 changes: 19 additions & 0 deletions src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,22 @@ export enum IsoValidity {
INVALID = "INVALID",
UNVALIDATED = "UNVALIDATED",
}

export enum NatType {
UNKNOWN = "UNKNOWN",
NORMAL = "NORMAL",
SYMMETRIC = "SYMMETRIC",
FAILED = "FAILED",
}

export enum Presence {
UNKNOWN = "UNKNOWN",
ABSENT = "ABSENT",
PRESENT = "PRESENT",
FAILED = "FAILED",
}

export type PortMapping = {
upnp: Presence;
natpmp: Presence;
};
19 changes: 19 additions & 0 deletions src/declarations.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,22 @@ declare module "dmg" {
export declare function mount(filename: string, callback: (err: unknown, value: string) => void): string;
export declare function unmount(mountPath: string, callback: (err: unknown) => void);
}

declare module "stun" {
export declare type AddressType = {
address: string;
port: number;
family: string;
};
export declare type RequestOptions = {
server: StunServer;
};
export declare class StunResponse {
public getXorAddress(): AddressType;
}
export declare class StunServer {
public close(): void;
}
export declare function createServer(options: { type: string }): StunServer;
export declare function request(url: string, options: RequestOptions): Promise<StunResponse>;
}
5 changes: 5 additions & 0 deletions src/main/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ipc_launcherUpdateDownloadingEvent,
ipc_launcherUpdateFoundEvent,
ipc_launcherUpdateReadyEvent,
ipc_runNetworkDiagnostics,
ipc_showOpenDialog,
} from "./ipc";

Expand Down Expand Up @@ -66,6 +67,10 @@ export default {
const { result } = await ipc_showOpenDialog.renderer!.trigger(options);
return result;
},
async runNetworkDiagnostics() {
const { result } = await ipc_runNetworkDiagnostics.renderer!.trigger({});
return result;
},
onAppUpdateFound(handle: (version: string) => void) {
const { destroy } = ipc_launcherUpdateFoundEvent.renderer!.handle(async ({ version }) => {
handle(version);
Expand Down
8 changes: 7 additions & 1 deletion src/main/ipc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { IsoValidity, NewsItem } from "@common/types";
import type { IsoValidity, NatType, NewsItem, PortMapping, Presence } from "@common/types";
import type { EmptyPayload, SuccessPayload } from "utils/ipc";
import { _, makeEndpoint } from "utils/ipc";

Expand Down Expand Up @@ -34,6 +34,12 @@ export const ipc_showOpenDialog = makeEndpoint.main(

export const ipc_clearTempFolder = makeEndpoint.main("clearTempFolder", <EmptyPayload>_, <SuccessPayload>_);

export const ipc_runNetworkDiagnostics = makeEndpoint.main(
"runNetworkDiagnostics",
<EmptyPayload>_,
<{ address: string; cgnat: Presence; natType: NatType; portMapping: PortMapping }>_,
);

// Events

export const ipc_launcherUpdateFoundEvent = makeEndpoint.renderer("launcherupdate_found", <{ version: string }>_);
Expand Down
109 changes: 109 additions & 0 deletions src/main/networkDiagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { PortMapping } from "@common/types";
import { NatType, Presence } from "@common/types";
import { createPmpClient, createUpnpClient } from "@xmcl/nat-api";
import { gateway4async } from "default-gateway";
import Tracer from "nodejs-traceroute";
import { createServer, request } from "stun";

const STUN_SERVER_URL1 = "stun1.l.google.com:19302";
const STUN_SERVER_URL2 = "stun2.l.google.com:19302";

export async function getNetworkDiagnostics(): Promise<{
address: string;
cgnat: Presence;
natType: NatType;
portMapping: PortMapping;
}> {
let address = "";
let natType = NatType.FAILED;
let portMapping = { upnp: Presence.FAILED, natpmp: Presence.FAILED };
let cgnat = Presence.FAILED;
try {
portMapping = await getPortMappingPresence();
({ address, natType } = await getNatType());
cgnat = await getCgnatPresence(address);
} catch (err) {
// just return what we have
}
return { address, cgnat, natType, portMapping };
}

async function getNatType(): Promise<{ address: string; natType: NatType }> {
const stunServer = createServer({ type: "udp4" });
const stunResponse1 = await request(STUN_SERVER_URL1, { server: stunServer });
const stunResponse2 = await request(STUN_SERVER_URL2, { server: stunServer });
const address1 = stunResponse1.getXorAddress();
const address2 = stunResponse2.getXorAddress();
stunServer.close();
return { address: address1.address, natType: address1.port === address2.port ? NatType.NORMAL : NatType.SYMMETRIC };
}

async function getPortMappingPresence(): Promise<PortMapping> {
let upnpPresence = Presence.UNKNOWN;
const upnpClient = await createUpnpClient();
const upnpPromise = upnpClient
.externalIp()
.then(() => {
upnpPresence = Presence.PRESENT;
})
.catch(() => {
upnpPresence = Presence.ABSENT;
})
.finally(() => {
upnpClient.destroy();
});

let natpmpPresence = Presence.UNKNOWN;
const pmpClient = await createPmpClient((await gateway4async()).gateway);
const pmpPromise = new Promise((resolve, reject) => {
// library does not use a timeout for NAT-PMP, so we do it ourselves.
const timeout = setTimeout(() => {
reject("NAT-PMP timeout");
}, 1800); // same as library UPnP timeout
pmpClient
.externalIp()
.then(resolve)
.catch(reject)
.finally(() => {
clearTimeout(timeout);
});
})
.then(() => {
natpmpPresence = Presence.PRESENT;
})
.catch(() => {
natpmpPresence = Presence.ABSENT;
})
.finally(() => {
pmpClient.close();
});

await Promise.all([upnpPromise, pmpPromise]);
return { upnp: upnpPresence, natpmp: natpmpPresence } as PortMapping;
}

async function getCgnatPresence(address: string): Promise<Presence> {
return new Promise((resolve, reject) => {
let hops = 0;
const tracer = new Tracer();
tracer.on("hop", () => {
hops++;
});
const timeout = setTimeout(() => {
if (hops > 1) {
resolve(Presence.PRESENT);
} else {
reject("CGNAT timeout");
}
}, 9000);
tracer.on("close", (code) => {
clearTimeout(timeout);
if (code === 0 && hops > 0) {
resolve(hops === 1 ? Presence.ABSENT : Presence.PRESENT);
} else {
reject(code);
}
});
tracer.trace(address);
});
}
6 changes: 6 additions & 0 deletions src/main/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ import {
ipc_launcherUpdateDownloadingEvent,
ipc_launcherUpdateFoundEvent,
ipc_launcherUpdateReadyEvent,
ipc_runNetworkDiagnostics,
ipc_showOpenDialog,
} from "./ipc";
import { getNetworkDiagnostics } from "./networkDiagnostics";
import { fetchNewsFeedData } from "./newsFeed";
import { getAssetPath, readLastLines } from "./util";
import { verifyIso } from "./verifyIso";
Expand Down Expand Up @@ -186,4 +188,8 @@ export default function setupMainIpc({ dolphinManager }: { dolphinManager: Dolph
const { canceled, filePaths } = await dialog.showOpenDialog(options);
return { canceled, filePaths };
});

ipc_runNetworkDiagnostics.main!.handle(async () => {
return getNetworkDiagnostics();
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { css } from "@emotion/react";
import styled from "@emotion/styled";
import Button from "@mui/material/Button";
import InputBase from "@mui/material/InputBase";
import Typography from "@mui/material/Typography";
import React from "react";

const buttonStyle = { marginLeft: "8px", width: "96px" };
const hiddenIpAddress = "···.···.···.···";
const inputBaseCss = css`
padding: 4px 8px;
border-radius: 10px;
background-color: rgba(0, 0, 0, 0.4);
font-size: 1em;
margin: 8px 0;
`;

// This is used to correct an observed 1px vertical misalignment
const AlignCenterDiv = styled.div`
display: flex;
align-items: center;
`;

const DialogBody = styled.div`
margin-bottom: 1em;
`;

type CgnatCommandSectionProps = {
address: string;
};
export const CgnatCommandSection = ({ address }: CgnatCommandSectionProps) => {
const [cgnatCommandHidden, setCgnatCommandHidden] = React.useState(true);
const onCgnatCommandShowHide = () => {
setCgnatCommandHidden(!cgnatCommandHidden);
};
const tracerouteCommand = window.electron.common.isWindows ? "tracert" : "traceroute";
const cgnatCommand = `${tracerouteCommand} ${address}`;
const displayedCgnatCommand = `${tracerouteCommand} ${cgnatCommandHidden ? hiddenIpAddress : address}`;
const [cgnatCommandCopied, setCgnatCommandCopied] = React.useState(false);
const onCgnatCommandCopy = React.useCallback(() => {
window.electron.clipboard.writeText(cgnatCommand);
setCgnatCommandCopied(true);
window.setTimeout(() => setCgnatCommandCopied(false), 2000);
}, [cgnatCommand]);

return (
<div>
<Typography variant="subtitle2">Run this command</Typography>
<AlignCenterDiv>
<InputBase css={inputBaseCss} disabled={true} value={displayedCgnatCommand} />
<Button variant="contained" color="secondary" onClick={onCgnatCommandShowHide} style={buttonStyle}>
{cgnatCommandHidden ? "Reveal" : "Hide"}
</Button>
<Button variant="contained" color="secondary" onClick={onCgnatCommandCopy} style={buttonStyle}>
{cgnatCommandCopied ? "Copied!" : "Copy"}
</Button>
</AlignCenterDiv>
<DialogBody>More than one hop to your external IP address indicates CGNAT or Double NAT (or VPN).</DialogBody>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { NatType } from "@common/types";
import { css } from "@emotion/react";
import styled from "@emotion/styled";
import Button from "@mui/material/Button";
import InputBase from "@mui/material/InputBase";
import Typography from "@mui/material/Typography";
import React from "react";

const buttonStyle = { marginLeft: "8px", width: "96px" };
const hiddenIpAddress = "···.···.···.···";
const inputBaseCss = css`
padding: 4px 8px;
border-radius: 10px;
background-color: rgba(0, 0, 0, 0.4);
font-size: 1em;
margin: 8px 0;
`;

const DialogBody = styled.div`
margin-bottom: 1em;
`;

const getIpAddressTitle = (natType: NatType) => {
if (natType === NatType.FAILED) {
return "Failed to determine IP Address";
}
return "External IP Address";
};

type NatTypeSectionProps = {
address: string;
description: string;
natType: NatType;
title: string;
};
export const NatTypeSection = ({ address, description, natType, title }: NatTypeSectionProps) => {
const ipAddressTitle = getIpAddressTitle(natType);
const [ipAddressCopied, setIpAddressCopied] = React.useState(false);
const onIpAddressCopy = React.useCallback(() => {
window.electron.clipboard.writeText(address);
setIpAddressCopied(true);
window.setTimeout(() => setIpAddressCopied(false), 2000);
}, [address]);
const [ipAddressHidden, setIpAddressHidden] = React.useState(true);
const onIpAddressShowHide = () => {
setIpAddressHidden(!ipAddressHidden);
};
return (
<div>
<Typography variant="subtitle2">{ipAddressTitle}</Typography>
{natType !== NatType.FAILED && (
<DialogBody>
<InputBase css={inputBaseCss} disabled={true} value={ipAddressHidden ? hiddenIpAddress : address} />
<Button variant="contained" color="secondary" onClick={onIpAddressShowHide} style={buttonStyle}>
{ipAddressHidden ? "Reveal" : "Hide"}
</Button>
<Button variant="contained" color="secondary" onClick={onIpAddressCopy} style={buttonStyle}>
{ipAddressCopied ? "Copied!" : "Copy"}
</Button>
</DialogBody>
)}
<Typography variant="subtitle2">{title}</Typography>
<DialogBody>{description}</DialogBody>
</div>
);
};
Loading

0 comments on commit 01957f6

Please sign in to comment.