Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Tailscale] Add netcheck command #15634

Merged
merged 3 commits into from
Jan 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions extensions/tailscale/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Tailscale Changelog

## [Add new features] - 2025-01-02

- Add `netcheck` command

## [Improvement] - 2024-11-08

- Provide UI indicator, and HUD message on `connect (tailscale up)` and `disconnect (tailscale down)` commands
Expand Down
10 changes: 9 additions & 1 deletion extensions/tailscale/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"itsmingjie",
"erics118",
"brandenw",
"j3lte"
"j3lte",
"itsmatteomanf"
],
"categories": [
"Developer Tools",
Expand Down Expand Up @@ -90,6 +91,13 @@
"subtitle": "Tailscale",
"description": "Tailscale down",
"mode": "no-view"
},
{
"name": "netcheck",
"title": "Netcheck",
"subtitle": "Tailscale",
"description": "Tailscale netcheck",
"mode": "view"
}
],
"preferences": [
Expand Down
151 changes: 151 additions & 0 deletions extensions/tailscale/src/netcheck.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { List, Icon, ActionPanel, Action, Color } from "@raycast/api";
import { useEffect, useState } from "react";
import { getErrorDetails, ErrorDetails, getNetcheck, getDerpMap, NetcheckResponse, Derp } from "./shared";

const getDerps = (netcheck: NetcheckResponse, setPreferredDERP: (derp: Derp) => void) => {
// the netcheck json response contains a map of region IDs to latencies, so we need to map them to the actual region data
// the pretty printed command does that behind the scenes
const _derpMap = getDerpMap();
return Object.keys(netcheck.RegionLatency)
.map((key) => {
const _key = parseInt(key);
const derp = {
id: key,
code: _derpMap[_key].RegionCode,
name: _derpMap[_key].RegionName,
latency: netcheck.RegionLatency[key] ? (netcheck.RegionLatency[key] / 1000000).toFixed(1) : undefined,
latencies: {
v4: netcheck.RegionV4Latency[key] ? (netcheck.RegionV4Latency[key] / 1000000).toFixed(1) : undefined,
v6: netcheck.RegionV6Latency[key] ? (netcheck.RegionV6Latency[key] / 1000000).toFixed(1) : undefined,
},
nodes: _derpMap[_key].Nodes,
};
netcheck.PreferredDERP === _key && setPreferredDERP(derp);
return derp;
})
.sort((a, b) => {
if (a.latency && b.latency) return parseFloat(a.latency) - parseFloat(b.latency);
if (a.latency) return 1;
if (b.latency) return -1;
return 0;
});
};

export default function MyDeviceList() {
const [netcheck, setNetcheck] = useState<NetcheckResponse>();
const [portMappings, setPortMappings] = useState<string[]>([]);
const [derps, setDerps] = useState<Derp[]>([]);
const [preferredDERP, setPreferredDERP] = useState<Derp>();
const [error, setError] = useState<ErrorDetails>();

useEffect(() => {
async function fetch() {
try {
const _netcheck = getNetcheck();
setDerps(getDerps(_netcheck, setPreferredDERP));
setNetcheck(_netcheck);
setPortMappings(
["UPnP", "PMP", "PCP"].reduce((acc, mapping) => {
_netcheck[mapping] && acc.push(mapping);
return acc;
}, [] as string[]),
);
} catch (error) {
setError(getErrorDetails(error, "Couldn’t load netcheck."));
}
}
fetch();
}, []);

return (
<List isLoading={!netcheck && !portMappings && !error}>
{error ? (
<List.EmptyView icon={Icon.Warning} title={error.title} description={error.description} />
) : (
<>
<List.Item title="UDP" subtitle={String(netcheck?.UDP)} />
<List.Item
title="IPv4"
subtitle={`${netcheck?.IPv4 ? `yes, ${netcheck?.GlobalV4}` : "no"}`}
actions={
<ActionPanel>
{netcheck?.IPv4 && <Action.CopyToClipboard title="Copy IPv4" content={netcheck?.GlobalV4} />}
{netcheck?.IPv4 && <Action.Paste title="Paste IPv4" content={netcheck?.GlobalV4} />}
</ActionPanel>
}
/>
<List.Item
title="IPv6"
subtitle={`${netcheck?.IPv6 ? `yes, ${netcheck?.GlobalV6}` : "no"}`}
actions={
<ActionPanel>
{netcheck?.IPv6 && <Action.CopyToClipboard title="Copy IPv6" content={netcheck?.GlobalV6} />}
{netcheck?.IPv6 && <Action.Paste title="Paste IPv6" content={netcheck?.GlobalV6} />}
</ActionPanel>
}
/>
<List.Item title="Mapping Varies by Destination IP" subtitle={String(netcheck?.MappingVariesByDestIP)} />
<List.Item title="Port Mapping" subtitle={portMappings.join(", ")} />
<List.Item title="Nearest DERP" subtitle={preferredDERP?.name} />
<List.Section title="DERP Regions">
{derps.map((derp) => (
<List.Item
title={derp.code}
subtitle={`${derp.name} · ${derp.nodes?.length} node${derp.nodes?.length === 1 ? "" : "s"}`}
accessories={[
...(derp.id === String(netcheck?.PreferredDERP)
? [{ icon: { source: Icon.Star, tintColor: Color.Yellow } }]
: []),
{
text: `${derp.latency} ms`,
},
]}
keywords={[derp.id, derp.name]}
key={derp.id}
actions={
<ActionPanel>
<Action.Push title="Open Details" target={derpDetails(derp)} />
<Action.CopyToClipboard title="Copy Name" content={derp.name} />
<Action.CopyToClipboard title="Copy Code" content={derp.code} />
{derp.latency && <Action.CopyToClipboard title="Copy Latency" content={derp.latency} />}
</ActionPanel>
}
/>
))}
</List.Section>
</>
)}
</List>
);
}

export function derpDetails(derp: Derp) {
return (
<List navigationTitle={`${derp.code} (${derp.name})`}>
<List.Item title="Code" subtitle={derp.code} />
<List.Item title="Name" subtitle={derp.name} />
<List.Item title="ID" subtitle={derp.id} />
<List.Section title="Latencies">
{derp.latencies.v4 && <List.Item title="IPv4" subtitle={`${derp.latencies.v4} ms`} />}
{derp.latencies.v6 && <List.Item title="IPv6" subtitle={`${derp.latencies.v6} ms`} />}
</List.Section>
<List.Section title="Servers">
{derp.nodes?.map((node) => (
<List.Item
title={node.Name}
subtitle={node.HostName}
keywords={[node.HostName, node.IPv4, node.IPv6]}
key={node.Name}
actions={
<ActionPanel>
<Action.CopyToClipboard title="Copy Hostname" content={node.HostName} />
{node.IPv4 && <Action.CopyToClipboard title="Copy IPv4" content={node.IPv4} />}
{node.IPv6 && <Action.CopyToClipboard title="Copy IPv6" content={node.IPv6} />}
</ActionPanel>
}
/>
))}
</List.Section>
</List>
);
}
63 changes: 63 additions & 0 deletions extensions/tailscale/src/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,56 @@ export type StatusResponse = {
>;
};

/**
* NetcheckResponse are the fields returned by `tailscale netcheck --format json`.
* These are mentioned to not be stable and may change in the future. Doubtful, but possible.
*/
export type NetcheckResponse = {
UDP: boolean;
IPv4: boolean;
GlobalV4: string;
IPv6: boolean;
GlobalV6: string;
MappingVariesByDestIP: boolean;
UPnP: boolean;
PMP: boolean;
PCP: boolean;
PreferredDERP: number;
RegionLatency: Record<string, number>;
RegionV4Latency: Record<string, number>;
RegionV6Latency: Record<string, number>;
};

export type DerpRegion = {
RegionId: number;
RegionCode: string;
RegionName: string;
Latitude: number;
Longitude: number;
Nodes: DerpNode[];
};

type DerpNode = {
Name: string;
RegionID: number;
HostName: string;
IPv4: string;
IPv6: string;
CanPort80: boolean;
};

export type Derp = {
id: string;
code: string;
name: string;
latency: string | undefined;
latencies: {
v4: string | undefined;
v6: string | undefined;
};
nodes: DerpNode[];
};

export function getStatus(peers = true) {
const resp = tailscale(`status --json --peers=${peers}`);
const data = JSON.parse(resp) as StatusResponse;
Expand All @@ -69,6 +119,19 @@ export function getStatus(peers = true) {
return data;
}

export function getNetcheck() {
const resp = tailscale("netcheck --format json");
return JSON.parse(resp);
}

/**
* This funtion relies on a debug command, so it may not be stable on the returned value.
*/
export function getDerpMap() {
const resp = tailscale("debug netmap");
return JSON.parse(resp).DERPMap.Regions as DerpRegion[];
}

export function getDevices(status: StatusResponse) {
const devices: Device[] = [];
const self = status.Self;
Expand Down