diff --git a/src/Explorer/Tabs/CloudShellTab/Utils/CloudShellIPUtils.ts b/src/Explorer/Tabs/CloudShellTab/Utils/CloudShellIPUtils.ts new file mode 100644 index 000000000..9f611ec8c --- /dev/null +++ b/src/Explorer/Tabs/CloudShellTab/Utils/CloudShellIPUtils.ts @@ -0,0 +1,71 @@ +import { userContext } from "../../../../UserContext"; + +export const CLOUDSHELL_IP_RECOMMENDATIONS = { + centralindia: [ + { startIP: "4.247.135.109", endIP: "4.247.135.109" }, + { startIP: "74.225.207.63", endIP: "74.225.207.63" }, + ], + southeastasia: [{ startIP: "4.194.5.74", endIP: "4.194.213.10" }], + centraluseuap: [ + { startIP: "52.158.186.182", endIP: "52.158.186.182" }, + { startIP: "172.215.26.246", endIP: "172.215.26.246" }, + { startIP: "134.138.154.177", endIP: "134.138.154.177" }, + { startIP: "134.138.129.52", endIP: "134.138.129.52" }, + { startIP: "172.215.31.177", endIP: "172.215.31.177" }, + ], + eastus2euap: [ + { startIP: "135.18.43.51", endIP: "135.18.43.51" }, + { startIP: "20.252.175.33", endIP: "20.252.175.33" }, + { startIP: "40.89.88.111", endIP: "40.89.88.111" }, + { startIP: "135.18.17.187", endIP: "135.18.17.187" }, + { startIP: "135.18.67.251", endIP: "135.18.67.251" }, + ], + eastus: [ + { startIP: "40.71.199.151", endIP: "40.71.199.151" }, + { startIP: "20.42.18.188", endIP: "20.42.18.188" }, + { startIP: "52.190.17.9", endIP: "52.190.17.9" }, + { startIP: "20.120.96.152", endIP: "20.120.96.152" }, + ], + northeurope: [ + { startIP: "74.234.65.146", endIP: "74.234.65.146" }, + { startIP: "52.169.70.113", endIP: "52.169.70.113" }, + ], + southcentralus: [ + { startIP: "4.151.247.81", endIP: "4.151.247.81" }, + { startIP: "20.225.211.35", endIP: "20.225.211.35" }, + { startIP: "4.151.48.133", endIP: "4.151.48.133" }, + { startIP: "4.151.247.225", endIP: "4.151.247.225" }, + ], + westeurope: [ + { startIP: "52.166.126.216", endIP: "52.166.126.216" }, + { startIP: "108.142.162.20", endIP: "108.142.162.20" }, + { startIP: "52.178.13.125", endIP: "52.178.13.125" }, + { startIP: "172.201.33.160", endIP: "172.201.33.160" }, + ], + westus: [ + { startIP: "20.245.161.131", endIP: "20.245.161.131" }, + { startIP: "57.154.182.51", endIP: "57.154.182.51" }, + { startIP: "40.118.133.244", endIP: "40.118.133.244" }, + { startIP: "20.253.192.12", endIP: "20.253.192.12" }, + { startIP: "20.43.245.209", endIP: "20.43.245.209" }, + { startIP: "20.66.22.66", endIP: "20.66.22.66" }, + ], +} as const; + +export interface CloudShellIPRange { + startIP: string; + endIP: string; +} + +export function getCloudShellIPsForRegion(region: string): readonly CloudShellIPRange[] { + const normalizedRegion = region.toLowerCase(); + return CLOUDSHELL_IP_RECOMMENDATIONS[normalizedRegion as keyof typeof CLOUDSHELL_IP_RECOMMENDATIONS] || []; +} + +export function getClusterRegion(): string { + const location = userContext?.databaseAccount?.location; + if (location) { + return location.toLowerCase(); + } + return ""; +} diff --git a/src/Explorer/Tabs/Shared/CloudShellIPChecker.ts b/src/Explorer/Tabs/Shared/CloudShellIPChecker.ts new file mode 100644 index 000000000..62ec89a0c --- /dev/null +++ b/src/Explorer/Tabs/Shared/CloudShellIPChecker.ts @@ -0,0 +1,78 @@ +import { configContext } from "ConfigContext"; +import * as DataModels from "Contracts/DataModels"; +import { userContext } from "UserContext"; +import { armRequest } from "Utils/arm/request"; +import { + CloudShellIPRange, + getCloudShellIPsForRegion, + getClusterRegion, +} from "../CloudShellTab/Utils/CloudShellIPUtils"; +import { getNormalizedRegion } from "../CloudShellTab/Utils/RegionUtils"; + +// Constants +const DEFAULT_CLOUDSHELL_REGION = "westus"; + +/** + * Check if user has added all CloudShell IPs for their normalized region + * @param apiVersion - The API version to use for the ARM request + * @returns Promise - true if all CloudShell IPs are configured (don't show screenshot), false if missing (show screenshot) + */ +export async function checkCloudShellIPsConfigured(apiVersion: string): Promise { + const clusterRegion = getClusterRegion(); + + if (!clusterRegion) { + return false; + } + + const normalizedRegion = getNormalizedRegion(clusterRegion, DEFAULT_CLOUDSHELL_REGION); + const cloudShellIPs = getCloudShellIPsForRegion(normalizedRegion); + + if (cloudShellIPs.length === 0) { + return false; + } + + const firewallRulesUri = `${userContext.databaseAccount.id}/firewallRules`; + const response: any = await armRequest({ + host: configContext.ARM_ENDPOINT, + path: firewallRulesUri, + method: "GET", + apiVersion: apiVersion, + }); + + const firewallRules: DataModels.FirewallRule[] = response?.data?.value || response?.value || []; + + const missingIPs: Array<{ startIP: string; endIP: string; reason?: string }> = []; + const foundIPs: Array<{ startIP: string; endIP: string; ruleName?: string }> = []; + + for (const cloudShellIP of cloudShellIPs) { + const matchingRule = firewallRules.find((rule) => { + const startMatch = rule.properties.startIpAddress === cloudShellIP.startIP; + const endMatch = rule.properties.endIpAddress === cloudShellIP.endIP; + return startMatch && endMatch; + }); + + if (matchingRule) { + foundIPs.push({ ...cloudShellIP, ruleName: matchingRule.name }); + } else { + missingIPs.push({ ...cloudShellIP, reason: "No exact IP match in firewall rules" }); + } + } + + const allConfigured = missingIPs.length === 0; + return allConfigured; +} + +/** + * Get the normalized region and its CloudShell IPs for display in the guide + * @returns Object with region and IPs for the guide + */ +export function getCloudShellGuideInfo(): { region: string; cloudShellIPs: readonly CloudShellIPRange[] } { + const clusterRegion = getClusterRegion(); + const normalizedRegion = getNormalizedRegion(clusterRegion || "", DEFAULT_CLOUDSHELL_REGION); + const cloudShellIPs = getCloudShellIPsForRegion(normalizedRegion); + + return { + region: normalizedRegion, + cloudShellIPs: cloudShellIPs, + }; +} diff --git a/src/Explorer/Tabs/ShellAdapters/BaseTerminalComponentAdapter.tsx b/src/Explorer/Tabs/ShellAdapters/BaseTerminalComponentAdapter.tsx index 1664326d9..ec3eadb0c 100644 --- a/src/Explorer/Tabs/ShellAdapters/BaseTerminalComponentAdapter.tsx +++ b/src/Explorer/Tabs/ShellAdapters/BaseTerminalComponentAdapter.tsx @@ -22,10 +22,22 @@ export abstract class BaseTerminalComponentAdapter implements ReactAdapter { protected getUsername: () => string, protected isAllPublicIPAddressesEnabled: ko.Observable, protected kind: ViewModels.TerminalKind, - ) {} + protected isCloudShellIPsConfigured?: ko.Observable, + ) { } public renderComponent(): JSX.Element { - if (!this.isAllPublicIPAddressesEnabled()) { + const publicIPEnabled = this.isAllPublicIPAddressesEnabled(); + const cloudShellConfigured = this.isCloudShellIPsConfigured ? this.isCloudShellIPsConfigured() : true; + let shouldShowScreenshot: boolean; + + if (this.isCloudShellIPsConfigured) { + shouldShowScreenshot = !cloudShellConfigured; + + } else { + shouldShowScreenshot = !publicIPEnabled; + } + + if (shouldShowScreenshot) { return ( ; + private isCloudShellIPsConfigured: ko.Observable; constructor(options: TerminalTabOptions) { super(options); this.container = options.container; this.isAllPublicIPAddressesEnabled = ko.observable(true); + this.isCloudShellIPsConfigured = ko.observable(true); // Start optimistic, will be updated const commonArgs: [ () => DataModels.DatabaseAccount, @@ -36,18 +39,33 @@ export default class TerminalTab extends TabsBase { ko.Observable, ViewModels.TerminalKind, ] = [ - () => userContext?.databaseAccount, - () => this.tabId, - () => this.getUsername(), - this.isAllPublicIPAddressesEnabled, - options.kind, - ]; + () => userContext?.databaseAccount, + () => this.tabId, + () => this.getUsername(), + this.isAllPublicIPAddressesEnabled, + options.kind, + ]; if (userContext.features.enableCloudShell) { - this.notebookTerminalComponentAdapter = new CloudShellTerminalComponentAdapter(...commonArgs); + this.notebookTerminalComponentAdapter = new CloudShellTerminalComponentAdapter( + () => userContext?.databaseAccount, + () => this.tabId, + () => this.getUsername(), + this.isAllPublicIPAddressesEnabled, + options.kind, + this.isCloudShellIPsConfigured, + ); this.notebookTerminalComponentAdapter.parameters = ko.computed(() => { - return this.isTemplateReady() && this.isAllPublicIPAddressesEnabled(); + const cloudShellConfigured = this.isCloudShellIPsConfigured(); + return this.isTemplateReady() && cloudShellConfigured; + }); + + checkCloudShellIPsConfigured("2023-03-01-preview").then(result => { + this.isCloudShellIPsConfigured(result); + }).catch(error => { + console.error(`CloudShell IP Check failed for ${ViewModels.TerminalKind[options.kind]} terminal:`, error); + this.isCloudShellIPsConfigured(false); }); } else { this.notebookTerminalComponentAdapter = new NotebookTerminalComponentAdapter( @@ -65,22 +83,25 @@ export default class TerminalTab extends TabsBase { }); } - if (options.kind === ViewModels.TerminalKind.Postgres) { - checkFirewallRules( - "2022-11-08", - (rule) => rule.properties.startIpAddress === "0.0.0.0" && rule.properties.endIpAddress === "255.255.255.255", - this.isAllPublicIPAddressesEnabled, - ); - } + // Only run legacy firewall checks for NON-CloudShell terminals cloudShell terminals use the CloudShell IP checker instead + if (!userContext.features.enableCloudShell) { + if (options.kind === ViewModels.TerminalKind.Postgres) { + checkFirewallRules( + "2022-11-08", + (rule) => rule.properties.startIpAddress === "0.0.0.0" && rule.properties.endIpAddress === "255.255.255.255", + this.isAllPublicIPAddressesEnabled, + ); + } - if (options.kind === ViewModels.TerminalKind.VCoreMongo) { - checkFirewallRules( - "2023-03-01-preview", - (rule) => - rule.name.startsWith("AllowAllAzureServicesAndResourcesWithinAzureIps") || - (rule.properties.startIpAddress === "0.0.0.0" && rule.properties.endIpAddress === "255.255.255.255"), - this.isAllPublicIPAddressesEnabled, - ); + if (options.kind === ViewModels.TerminalKind.VCoreMongo) { + checkFirewallRules( + "2023-03-01-preview", + (rule) => + rule.name.startsWith("AllowAllAzureServicesAndResourcesWithinAzureIps") || + (rule.properties.startIpAddress === "0.0.0.0" && rule.properties.endIpAddress === "255.255.255.255"), + this.isAllPublicIPAddressesEnabled, + ); + } } }