diff --git a/package.json b/package.json index a85fd235..2e60495b 100644 --- a/package.json +++ b/package.json @@ -311,6 +311,7 @@ "date-fns": "^3.6.0", "eventsource": "^3.0.6", "find-process": "https://github.com/coder/find-process#fix/sequoia-compat", + "ip-range-check": "^0.2.0", "jsonc-parser": "^3.3.1", "memfs": "^4.9.3", "node-forge": "^1.3.1", diff --git a/src/api.ts b/src/api.ts index fdb83b81..0c78d7cd 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,7 +1,7 @@ -import { AxiosInstance } from "axios" +import { AxiosInstance, isAxiosError } from "axios" import { spawn } from "child_process" import { Api } from "coder/site/src/api/api" -import { ProvisionerJobLog, Workspace } from "coder/site/src/api/typesGenerated" +import { ProvisionerJobLog, SSHConfigResponse, Workspace } from "coder/site/src/api/typesGenerated" import { FetchLikeInit } from "eventsource" import fs from "fs/promises" import { ProxyAgent } from "proxy-agent" @@ -280,3 +280,30 @@ export async function waitForBuild( writeEmitter.fire(`Workspace is now ${updatedWorkspace.latest_build.status}\r\n`) return updatedWorkspace } + +export async function fetchSSHConfig(restClient: Api): Promise { + try { + const sshConfig = await restClient.getDeploymentSSHConfig() + return { + hostname_prefix: sshConfig.hostname_prefix, + hostname_suffix: sshConfig.hostname_suffix ?? "coder", + ssh_config_options: sshConfig.ssh_config_options, + } + } catch (error) { + if (!isAxiosError(error)) { + throw error + } + switch (error.response?.status) { + case 404: { + // Very old deployment that doesn't support SSH config + return { + hostname_prefix: "coder", + hostname_suffix: "coder", + ssh_config_options: {}, + } + } + default: + throw error + } + } +} diff --git a/src/commands.ts b/src/commands.ts index d24df729..f9019856 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,12 +1,13 @@ +import { isAxiosError } from "axios" import { Api } from "coder/site/src/api/api" import { getErrorMessage } from "coder/site/src/api/errors" import { User, Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated" import * as vscode from "vscode" -import { makeCoderSdk, needToken } from "./api" +import { fetchSSHConfig, makeCoderSdk, needToken } from "./api" import { extractAgents } from "./api-helper" import { CertificateError } from "./error" import { Storage } from "./storage" -import { toRemoteAuthority, toSafeHost } from "./util" +import { maybeCoderConnectAddr, toRemoteAuthority, toSafeHost } from "./util" import { OpenableTreeItem } from "./workspacesProvider" export class Commands { @@ -392,7 +393,7 @@ export class Commands { if (!baseUrl) { throw new Error("You are not logged in") } - await openWorkspace( + await this.openWorkspace( baseUrl, treeItem.workspaceOwner, treeItem.workspaceName, @@ -491,12 +492,12 @@ export class Commands { } else { workspaceOwner = args[0] as string workspaceName = args[1] as string - // workspaceAgent is reserved for args[2], but multiple agents aren't supported yet. + workspaceAgent = args[2] as string | undefined folderPath = args[3] as string | undefined openRecent = args[4] as boolean | undefined } - await openWorkspace(baseUrl, workspaceOwner, workspaceName, workspaceAgent, folderPath, openRecent) + await this.openWorkspace(baseUrl, workspaceOwner, workspaceName, workspaceAgent, folderPath, openRecent) } /** @@ -512,11 +513,18 @@ export class Commands { const workspaceOwner = args[0] as string const workspaceName = args[1] as string - const workspaceAgent = undefined // args[2] is reserved, but we do not support multiple agents yet. + const workspaceAgent = args[2] as string | undefined const devContainerName = args[3] as string const devContainerFolder = args[4] as string - await openDevContainer(baseUrl, workspaceOwner, workspaceName, workspaceAgent, devContainerName, devContainerFolder) + await this.openDevContainerInner( + baseUrl, + workspaceOwner, + workspaceName, + workspaceAgent, + devContainerName, + devContainerFolder, + ) } /** @@ -540,107 +548,206 @@ export class Commands { await this.workspaceRestClient.updateWorkspaceVersion(this.workspace) } } -} -/** - * Given a workspace, build the host name, find a directory to open, and pass - * both to the Remote SSH plugin in the form of a remote authority URI. - */ -async function openWorkspace( - baseUrl: string, - workspaceOwner: string, - workspaceName: string, - workspaceAgent: string | undefined, - folderPath: string | undefined, - openRecent: boolean | undefined, -) { - // A workspace can have multiple agents, but that's handled - // when opening a workspace unless explicitly specified. - const remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent) - - let newWindow = true - // Open in the existing window if no workspaces are open. - if (!vscode.workspace.workspaceFolders?.length) { - newWindow = false - } + /** + * Given a workspace, build the host name, find a directory to open, and pass + * both to the Remote SSH plugin in the form of a remote authority URI. + */ + private async openWorkspace( + baseUrl: string, + workspaceOwner: string, + workspaceName: string, + workspaceAgent: string | undefined, + folderPath: string | undefined, + openRecent: boolean | undefined, + ) { + let remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent) + + // We can't connect using Coder Connect straightaway if `workspaceAgent` + // is undefined. This happens when: + // 1. The workspace is stopped + // 2. A `vscode://coder.coder-remote` URI does not include the agent as a + // query parameter. + // + // For 1. `Remote.setup` will migrate us to Coder Connect once the workspace is built. + // For 2. `Remote.setup` will call `maybeAskAgent` and then migrate us to Coder Connect + if (workspaceAgent) { + let sshConfig + try { + // Fetch (or get defaults) for the SSH config. + sshConfig = await fetchSSHConfig(this.restClient) + } catch (error) { + return this.handleInitialRequestError(error, workspaceName, baseUrl, async () => { + await this.openWorkspace(baseUrl, workspaceOwner, workspaceName, workspaceAgent, folderPath, openRecent) + }) + } - // If a folder isn't specified or we have been asked to open the most recent, - // we can try to open a recently opened folder/workspace. - if (!folderPath || openRecent) { - const output: { - workspaces: { folderUri: vscode.Uri; remoteAuthority: string }[] - } = await vscode.commands.executeCommand("_workbench.getRecentlyOpened") - const opened = output.workspaces.filter( - // Remove recents that do not belong to this connection. The remote - // authority maps to a workspace or workspace/agent combination (using the - // SSH host name). This means, at the moment, you can have a different - // set of recents for a workspace versus workspace/agent combination, even - // if that agent is the default for the workspace. - (opened) => opened.folderUri?.authority === remoteAuthority, - ) + const coderConnectAddr = await maybeCoderConnectAddr( + workspaceAgent, + workspaceName, + workspaceOwner, + sshConfig.hostname_suffix, + ) + if (coderConnectAddr) { + remoteAuthority = `ssh-remote+${coderConnectAddr}` + } + } - // openRecent will always use the most recent. Otherwise, if there are - // multiple we ask the user which to use. - if (opened.length === 1 || (opened.length > 1 && openRecent)) { - folderPath = opened[0].folderUri.path - } else if (opened.length > 1) { - const items = opened.map((f) => f.folderUri.path) - folderPath = await vscode.window.showQuickPick(items, { - title: "Select a recently opened folder", - }) - if (!folderPath) { - // User aborted. - return + let newWindow = true + // Open in the existing window if no workspaces are open. + if (!vscode.workspace.workspaceFolders?.length) { + newWindow = false + } + + // If a folder isn't specified or we have been asked to open the most recent, + // we can try to open a recently opened folder/workspace. + if (!folderPath || openRecent) { + const output: { + workspaces: { folderUri: vscode.Uri; remoteAuthority: string }[] + } = await vscode.commands.executeCommand("_workbench.getRecentlyOpened") + const opened = output.workspaces.filter( + // Remove recents that do not belong to this connection. The remote + // authority maps to a workspace or workspace/agent combination (using the + // SSH host name). This means, at the moment, you can have a different + // set of recents for a workspace versus workspace/agent combination, even + // if that agent is the default for the workspace. + (opened) => opened.folderUri?.authority === remoteAuthority, + ) + + // openRecent will always use the most recent. Otherwise, if there are + // multiple we ask the user which to use. + if (opened.length === 1 || (opened.length > 1 && openRecent)) { + folderPath = opened[0].folderUri.path + } else if (opened.length > 1) { + const items = opened.map((f) => f.folderUri.path) + folderPath = await vscode.window.showQuickPick(items, { + title: "Select a recently opened folder", + }) + if (!folderPath) { + // User aborted. + return + } } } + + if (folderPath) { + await vscode.commands.executeCommand( + "vscode.openFolder", + vscode.Uri.from({ + scheme: "vscode-remote", + authority: remoteAuthority, + path: folderPath, + }), + // Open this in a new window! + newWindow, + ) + return + } + + // This opens the workspace without an active folder opened. + await vscode.commands.executeCommand("vscode.newWindow", { + remoteAuthority: remoteAuthority, + reuseWindow: !newWindow, + }) } - if (folderPath) { + private async openDevContainerInner( + baseUrl: string, + workspaceOwner: string, + workspaceName: string, + workspaceAgent: string | undefined, + devContainerName: string, + devContainerFolder: string, + ) { + let remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent) + + if (workspaceAgent) { + let sshConfig + try { + // Fetch (or get defaults) for the SSH config. + sshConfig = await fetchSSHConfig(this.restClient) + } catch (error) { + return this.handleInitialRequestError(error, workspaceName, baseUrl, async () => { + await this.openDevContainerInner( + baseUrl, + workspaceOwner, + workspaceName, + workspaceAgent, + devContainerName, + devContainerFolder, + ) + }) + } + + const coderConnectAddr = await maybeCoderConnectAddr( + workspaceAgent, + workspaceName, + workspaceOwner, + sshConfig.hostname_suffix, + ) + if (coderConnectAddr) { + remoteAuthority = `ssh-remote+${coderConnectAddr}` + } + } + + const devContainer = Buffer.from(JSON.stringify({ containerName: devContainerName }), "utf-8").toString("hex") + const devContainerAuthority = `attached-container+${devContainer}@${remoteAuthority}` + + let newWindow = true + if (!vscode.workspace.workspaceFolders?.length) { + newWindow = false + } + await vscode.commands.executeCommand( "vscode.openFolder", vscode.Uri.from({ scheme: "vscode-remote", - authority: remoteAuthority, - path: folderPath, + authority: devContainerAuthority, + path: devContainerFolder, }), - // Open this in a new window! newWindow, ) - return } - // This opens the workspace without an active folder opened. - await vscode.commands.executeCommand("vscode.newWindow", { - remoteAuthority: remoteAuthority, - reuseWindow: !newWindow, - }) -} - -async function openDevContainer( - baseUrl: string, - workspaceOwner: string, - workspaceName: string, - workspaceAgent: string | undefined, - devContainerName: string, - devContainerFolder: string, -) { - const remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent) - - const devContainer = Buffer.from(JSON.stringify({ containerName: devContainerName }), "utf-8").toString("hex") - const devContainerAuthority = `attached-container+${devContainer}@${remoteAuthority}` - - let newWindow = true - if (!vscode.workspace.workspaceFolders?.length) { - newWindow = false + private async handleInitialRequestError( + error: unknown, + workspaceName: string, + baseUrl: string, + retryCallback: () => Promise, + ) { + if (!isAxiosError(error)) { + throw error + } + switch (error.response?.status) { + case 401: { + const result = await this.vscodeProposed.window.showInformationMessage( + "Your session expired...", + { + useCustom: true, + modal: true, + detail: `You must log in to access ${workspaceName}.`, + }, + "Log In", + ) + if (!result) { + // User declined to log in. + return + } + // Log in then try again + await vscode.commands.executeCommand("coder.login", baseUrl, undefined, undefined) + await retryCallback() + return + } + default: { + const message = getErrorMessage(error, "no response from the server") + this.storage.writeToCoderOutputChannel(`Failed to open workspace: ${message}`) + this.vscodeProposed.window.showErrorMessage("Failed to open workspace", { + detail: message, + modal: true, + useCustom: true, + }) + return + } + } } - - await vscode.commands.executeCommand( - "vscode.openFolder", - vscode.Uri.from({ - scheme: "vscode-remote", - authority: devContainerAuthority, - path: devContainerFolder, - }), - newWindow, - ) } diff --git a/src/remote.ts b/src/remote.ts index 5b8a9694..5ef942a9 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -1,6 +1,6 @@ import { isAxiosError } from "axios" import { Api } from "coder/site/src/api/api" -import { Workspace } from "coder/site/src/api/typesGenerated" +import { SSHConfigResponse, Workspace } from "coder/site/src/api/typesGenerated" import find from "find-process" import * as fs from "fs/promises" import * as jsonc from "jsonc-parser" @@ -9,7 +9,14 @@ import * as path from "path" import prettyBytes from "pretty-bytes" import * as semver from "semver" import * as vscode from "vscode" -import { createHttpAgent, makeCoderSdk, needToken, startWorkspaceIfStoppedOrFailed, waitForBuild } from "./api" +import { + createHttpAgent, + fetchSSHConfig, + makeCoderSdk, + needToken, + startWorkspaceIfStoppedOrFailed, + waitForBuild, +} from "./api" import { extractAgents } from "./api-helper" import * as cli from "./cliManager" import { Commands } from "./commands" @@ -19,7 +26,7 @@ import { Inbox } from "./inbox" import { SSHConfig, SSHValues, mergeSSHConfigValues } from "./sshConfig" import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport" import { Storage } from "./storage" -import { AuthorityPrefix, expandPath, parseRemoteAuthority } from "./util" +import { AuthorityPrefix, expandPath, maybeCoderConnectAddr, parseRemoteAuthority } from "./util" import { WorkspaceMonitor } from "./workspaceMonitor" export interface RemoteDetails extends vscode.Disposable { @@ -469,9 +476,19 @@ export class Remote { // // If we didn't write to the SSH config file, connecting would fail with // "Host not found". + let sshConfigResponse: SSHConfigResponse try { this.storage.writeToCoderOutputChannel("Updating SSH config...") - await this.updateSSHConfig(workspaceRestClient, parts.label, parts.host, binaryPath, logDir, featureSet) + sshConfigResponse = await fetchSSHConfig(workspaceRestClient) + await this.updateSSHConfig( + workspaceRestClient, + parts.label, + parts.host, + binaryPath, + logDir, + featureSet, + sshConfigResponse, + ) } catch (error) { this.storage.writeToCoderOutputChannel(`Failed to configure SSH: ${error}`) throw error @@ -503,6 +520,42 @@ export class Remote { this.storage.writeToCoderOutputChannel("Remote setup complete") + // If Coder Connect is available for this workspace, switch to that + const coderConnectAddr = await maybeCoderConnectAddr( + agent.name, + parts.workspace, + parts.username, + sshConfigResponse.hostname_suffix, + ) + if (coderConnectAddr) { + // Find the path of the current workspace, which will have the same authority + const folderPath = this.vscodeProposed.workspace.workspaceFolders?.find( + (folder) => folder.uri.authority === remoteAuthority, + )?.uri.path + let newRemoteAuthority = `ssh-remote+${coderConnectAddr}` + if (parts.containerNameHex) { + newRemoteAuthority = `attached-container+${parts.containerNameHex}@${newRemoteAuthority}` + } + + if (folderPath) { + await vscode.commands.executeCommand( + "vscode.openFolder", + vscode.Uri.from({ + scheme: "vscode-remote", + authority: newRemoteAuthority, + path: folderPath, + }), + //`ForceNewWindow` + false, + ) + } else { + await vscode.commands.executeCommand("vscode.newWindow", { + remoteAuthority: newRemoteAuthority, + reuseWindow: true, + }) + } + } + // Returning the URL and token allows the plugin to authenticate its own // client, for example to display the list of workspaces belonging to this // deployment in the sidebar. We use our own client in here for reasons @@ -550,30 +603,8 @@ export class Remote { binaryPath: string, logDir: string, featureSet: FeatureSet, + sshConfigResponse: SSHConfigResponse, ) { - let deploymentSSHConfig = {} - try { - const deploymentConfig = await restClient.getDeploymentSSHConfig() - deploymentSSHConfig = deploymentConfig.ssh_config_options - } catch (error) { - if (!isAxiosError(error)) { - throw error - } - switch (error.response?.status) { - case 404: { - // Deployment does not support overriding ssh config yet. Likely an - // older version, just use the default. - break - } - case 401: { - await this.vscodeProposed.window.showErrorMessage("Your session expired...") - throw error - } - default: - throw error - } - } - // deploymentConfig is now set from the remote coderd deployment. // Now override with the user's config. const userConfigSSH = vscode.workspace.getConfiguration("coder").get("sshConfig") || [] @@ -596,7 +627,7 @@ export class Remote { }, {} as Record, ) - const sshConfigOverrides = mergeSSHConfigValues(deploymentSSHConfig, userConfig) + const sshConfigOverrides = mergeSSHConfigValues(sshConfigResponse.ssh_config_options, userConfig) let sshConfigFile = vscode.workspace.getConfiguration().get("remote.SSH.configFile") if (!sshConfigFile) { diff --git a/src/util.test.ts b/src/util.test.ts index 4fffcc75..b3583da1 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -9,6 +9,7 @@ it("ignore unrelated authorities", async () => { "vscode://ssh-remote+coder-vscode-test--foo--bar", "vscode://ssh-remote+coder-vscode-foo--bar", "vscode://ssh-remote+coder--foo--bar", + "vscode://attached-container+namehash@ssh-remote+dev.foo.admin.coder", ] for (const test of tests) { expect(parseRemoteAuthority(test)).toBe(null) @@ -29,6 +30,7 @@ it("should error on invalid authorities", async () => { it("should parse authority", async () => { expect(parseRemoteAuthority("vscode://ssh-remote+coder-vscode--foo--bar")).toStrictEqual({ + containerNameHex: undefined, agent: "", host: "coder-vscode--foo--bar", label: "", @@ -36,6 +38,7 @@ it("should parse authority", async () => { workspace: "bar", }) expect(parseRemoteAuthority("vscode://ssh-remote+coder-vscode--foo--bar--baz")).toStrictEqual({ + containerNameHex: undefined, agent: "baz", host: "coder-vscode--foo--bar--baz", label: "", @@ -43,6 +46,7 @@ it("should parse authority", async () => { workspace: "bar", }) expect(parseRemoteAuthority("vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar")).toStrictEqual({ + containerNameHex: undefined, agent: "", host: "coder-vscode.dev.coder.com--foo--bar", label: "dev.coder.com", @@ -50,6 +54,7 @@ it("should parse authority", async () => { workspace: "bar", }) expect(parseRemoteAuthority("vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar--baz")).toStrictEqual({ + containerNameHex: undefined, agent: "baz", host: "coder-vscode.dev.coder.com--foo--bar--baz", label: "dev.coder.com", @@ -57,6 +62,17 @@ it("should parse authority", async () => { workspace: "bar", }) expect(parseRemoteAuthority("vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar.baz")).toStrictEqual({ + containerNameHex: undefined, + agent: "baz", + host: "coder-vscode.dev.coder.com--foo--bar.baz", + label: "dev.coder.com", + username: "foo", + workspace: "bar", + }) + expect( + parseRemoteAuthority("vscode://attached-container+namehash@ssh-remote+coder-vscode.dev.coder.com--foo--bar.baz"), + ).toStrictEqual({ + containerNameHex: "namehash", agent: "baz", host: "coder-vscode.dev.coder.com--foo--bar.baz", label: "dev.coder.com", diff --git a/src/util.ts b/src/util.ts index 8253f152..c42dcd72 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,7 +1,11 @@ +import { lookup } from "dns" +import ipRangeCheck from "ip-range-check" import * as os from "os" import url from "url" +import { promisify } from "util" export interface AuthorityParts { + containerNameHex: string | undefined agent: string | undefined host: string label: string @@ -21,14 +25,20 @@ export const AuthorityPrefix = "coder-vscode" * Throw an error if the host is invalid. */ export function parseRemoteAuthority(authority: string): AuthorityParts | null { - // The authority looks like: vscode://ssh-remote+ - const authorityParts = authority.split("+") + // The Dev Container authority looks like: vscode://attached-container+containerNameHex@ssh-remote+ + // The SSH authority looks like: vscode://ssh-remote+ + const authorityURI = authority.startsWith("vscode://") ? authority : `vscode://${authority}` + const authorityParts = new URL(authorityURI) + const containerParts = authorityParts.username.split("+") + const containerNameHex = containerParts[1] + const sshAuthority = authorityParts.host + const sshAuthorityParts = sshAuthority.split("+") // We create SSH host names in a format matching: // coder-vscode(--|.)--(--|.) // The agent can be omitted; the user will be prompted for it instead. // Anything else is unrelated to Coder and can be ignored. - const parts = authorityParts[1].split("--") + const parts = sshAuthorityParts[1].split("--") if (parts.length <= 1 || (parts[0] !== AuthorityPrefix && !parts[0].startsWith(`${AuthorityPrefix}.`))) { return null } @@ -53,14 +63,32 @@ export function parseRemoteAuthority(authority: string): AuthorityParts | null { } return { + containerNameHex: containerNameHex, agent: agent, - host: authorityParts[1], + host: sshAuthorityParts[1], label: parts[0].replace(/^coder-vscode\.?/, ""), username: parts[1], workspace: workspace, } } +export async function maybeCoderConnectAddr( + agent: string, + workspace: string, + owner: string, + hostnameSuffix: string, +): Promise { + const coderConnectHostname = `${agent}.${workspace}.${owner}.${hostnameSuffix}` + try { + const res = await promisify(lookup)(coderConnectHostname) + // Captive DNS portals may return an unrelated address, so we check it's + // within the Coder Service Prefix. + return res.family === 6 && ipRangeCheck(res.address, "fd60:627a:a42b::/48") ? coderConnectHostname : undefined + } catch { + return undefined + } +} + export function toRemoteAuthority( baseUrl: string, workspaceOwner: string, diff --git a/src/workspacesProvider.ts b/src/workspacesProvider.ts index 0709487e..12718546 100644 --- a/src/workspacesProvider.ts +++ b/src/workspacesProvider.ts @@ -353,7 +353,7 @@ export class WorkspaceTreeItem extends OpenableTreeItem { showOwner ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.Expanded, workspace.owner_name, workspace.name, - undefined, + agents[0]?.name, agents[0]?.expanded_directory, agents.length > 1 ? "coderWorkspaceMultipleAgents" : "coderWorkspaceSingleAgent", ) diff --git a/yarn.lock b/yarn.lock index efc2df73..72bc49da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1749,7 +1749,7 @@ co@3.1.0: "coder@https://github.com/coder/coder#main": version "0.0.0" - resolved "https://github.com/coder/coder#3ac844ad3d341d2910542b83d4f33df7bd0be85e" + resolved "https://github.com/coder/coder#f8971bb3cc01d81b3085b2b3c9253d8d340d125c" collapse-white-space@^1.0.2: version "1.0.6" @@ -3441,6 +3441,18 @@ ip-address@^9.0.5: jsbn "1.1.0" sprintf-js "^1.1.3" +ip-range-check@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/ip-range-check/-/ip-range-check-0.2.0.tgz#e67f126c8fb36c8f11d4c07d7924b7e364365157" + integrity sha512-oaM3l/3gHbLlt/tCWLvt0mj1qUaI+STuRFnUvARGCujK9vvU61+2JsDpmkMzR4VsJhuFXWWgeKKVnwwoFfzCqw== + dependencies: + ipaddr.js "^1.0.1" + +ipaddr.js@^1.0.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + irregular-plurals@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/irregular-plurals/-/irregular-plurals-2.0.0.tgz#39d40f05b00f656d0b7fa471230dd3b714af2872"