From df3986098f56da25a26944c05bf1adb4e541652e Mon Sep 17 00:00:00 2001 From: x032205 Date: Sat, 25 Oct 2025 04:48:29 -0400 Subject: [PATCH] feat: add Terraform EC2 to interactive setup --- .../RelayTab/components/RelayDeployModal.tsx | 8 +- .../RelayTerraformDeploymentMethod.tsx | 520 ++++++++++++++++++ 2 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayTerraformDeploymentMethod.tsx diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeployModal.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeployModal.tsx index 496ecb762..aab599925 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeployModal.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeployModal.tsx @@ -4,6 +4,7 @@ import { Modal, ModalContent } from "@app/components/v2"; import { RelayDeploymentMethodSelect } from "@app/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeploymentMethodSelect"; import { RelayCliDeploymentMethod } from "./RelayCliDeploymentMethod"; +import { RelayTerraformDeploymentMethod } from "./RelayTerraformDeploymentMethod"; type Props = { isOpen: boolean; @@ -11,7 +12,12 @@ type Props = { }; export const RelayDeploymentInfoMap = { - cli: { name: "CLI", image: "SSH.png", component: RelayCliDeploymentMethod } + cli: { name: "CLI", image: "SSH.png", component: RelayCliDeploymentMethod }, + terraform: { + name: "Terraform", + image: "Terraform.png", + component: RelayTerraformDeploymentMethod + } } as const; export type RelayDeploymentMethod = keyof typeof RelayDeploymentInfoMap; diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayTerraformDeploymentMethod.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayTerraformDeploymentMethod.tsx new file mode 100644 index 000000000..6e3cfa9e2 --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayTerraformDeploymentMethod.tsx @@ -0,0 +1,520 @@ +import { useMemo, useState } from "react"; +import { SingleValue } from "react-select"; +import { faCopy, faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + Checkbox, + FilterableSelect, + FormLabel, + IconButton, + Input, + ModalClose, + Tooltip +} from "@app/components/v2"; +import { Tab } from "@headlessui/react"; +import { + OrgPermissionIdentityActions, + OrgPermissionSubjects, + useOrganization, + useOrgPermission +} from "@app/context"; +import { + useAddIdentityTokenAuth, + useCreateTokenIdentityTokenAuth, + useGetIdentityMembershipOrgs, + useGetIdentityTokenAuth +} from "@app/hooks/api"; +import { slugSchema } from "@app/lib/schemas"; + +const baseFormSchema = z.object({ + name: slugSchema({ field: "name" }), + instanceDomain: z.string().url("Must be a valid URL").or(z.literal("")) +}); + +const formSchemaWithIdentity = baseFormSchema.extend({ + identity: z + .object( + { + id: z.string(), + name: z.string() + }, + { required_error: "Identity is required" } + ) + .nullable() + .refine((val) => val !== null, { message: "Identity is required" }) +}); + +const formSchemaWithToken = baseFormSchema.extend({ + identityToken: z.string().min(1, "Token is required") +}); + +const ec2FormSchema = z.object({ + awsRegion: z.string().min(1, "AWS Region is required"), + vpcId: z.string().min(1, "VPC ID is required"), + ami: z.string().min(1, "AMI ID is required"), + subnetId: z.string().min(1, "Subnet ID is required") +}); + +export const RelayTerraformDeploymentMethod = () => { + const { protocol, hostname, port } = window.location; + const portSuffix = port && port !== "80" ? `:${port}` : ""; + const siteURL = `${protocol}//${hostname}${portSuffix}`; + + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + + const [autogenerateToken, setAutogenerateToken] = useState(true); + const [step, setStep] = useState<"form" | "command">("form"); + const [name, setName] = useState(""); + + const [instanceDomain, setInstanceDomain] = useState(siteURL); + const [identity, setIdentity] = useState(null); + const [identityToken, setIdentityToken] = useState(""); + const [formErrors, setFormErrors] = useState([]); + + const [awsRegion, setAwsRegion] = useState("us-east-1"); + const [vpcId, setVpcId] = useState(""); + const [ami, setAmi] = useState("ami-01b2110eef525172b"); + const [subnetId, setSubnetId] = useState(""); + + const errors = useMemo(() => { + const errorMap: Record = {}; + formErrors.forEach((issue) => { + if (issue.path.length > 0) { + errorMap[String(issue.path[0])] = issue.message; + } + }); + return errorMap; + }, [formErrors]); + + const { currentOrg } = useOrganization(); + const organizationId = currentOrg?.id || ""; + + const { permission } = useOrgPermission(); + const canCreateToken = permission.can( + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity + ); + + const { data: identityMembershipOrgsData, isPending: isIdentitiesLoading } = + useGetIdentityMembershipOrgs({ + organizationId, + limit: 20000 + }); + const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships || []; + + const { mutateAsync: createToken, isPending: isCreatingToken } = + useCreateTokenIdentityTokenAuth(); + const { mutateAsync: addIdentityTokenAuth, isPending: isAddingTokenAuth } = + useAddIdentityTokenAuth(); + const { refetch } = useGetIdentityTokenAuth(identity?.id ?? ""); + + const handleGenerateCommand = async () => { + setFormErrors([]); + + if (canCreateToken && autogenerateToken) { + const validation = formSchemaWithIdentity.safeParse({ name, instanceDomain, identity }); + if (!validation.success) { + setFormErrors(validation.error.issues); + return; + } + + if (selectedTabIndex === 0) { + const ec2Validation = ec2FormSchema.safeParse({ awsRegion, vpcId, ami, subnetId }); + if (!ec2Validation.success) { + setFormErrors(ec2Validation.error.issues); + return; + } + } + + const validatedIdentity = validation.data.identity; + + try { + const { data: identityTokenAuth } = await refetch(); + if (!identityTokenAuth) { + await addIdentityTokenAuth({ + identityId: validatedIdentity.id, + organizationId, + accessTokenTTL: 2592000, + accessTokenMaxTTL: 2592000, + accessTokenNumUsesLimit: 0, + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + }); + createNotification({ + text: "Token authentication has been automatically enabled for the selected identity. By default, it is configured to allow all IP addresses with a default token TTL of 30 days. You can manage these settings in Access Control.", + type: "warning" + }); + } + + const token = await createToken({ + identityId: validatedIdentity.id, + name: `relay token for ${name} (autogenerated)` + }); + setIdentityToken(token.accessToken); + createNotification({ + text: "Automatically generated a token for the selected identity.", + type: "info" + }); + setStep("command"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to generate token for the selected identity", + type: "error" + }); + setIdentityToken(""); + } + } else { + const validation = formSchemaWithToken.safeParse({ + name, + instanceDomain, + identityToken + }); + if (!validation.success) { + setFormErrors(validation.error.issues); + return; + } + + if (selectedTabIndex === 0) { + const ec2Validation = ec2FormSchema.safeParse({ awsRegion, vpcId, ami, subnetId }); + if (!ec2Validation.success) { + setFormErrors(ec2Validation.error.issues); + return; + } + } + setStep("command"); + } + }; + + const handleIdentityChange = ( + selectedIdentity: SingleValue<{ + id: string; + name: string; + }> + ) => { + setIdentity(selectedIdentity); + }; + + const terraformCommand = useMemo(() => { + const domain = instanceDomain || "https://app.infisical.com"; + return `terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = "${awsRegion}" +} + +# Security Group for the Infisical Relay instance +resource "aws_security_group" "infisical_relay_sg" { + name = "${name}-relay-sg" + description = "Allows inbound traffic for Infisical Relay and SSH" + vpc_id = "${vpcId}" + + # Inbound: Allows the Infisical platform to securely communicate with the Relay server. + ingress { + from_port = 8443 + to_port = 8443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # Inbound: Allows Infisical Gateway to securely communicate via the Relay. + ingress { + from_port = 2222 + to_port = 2222 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # Inbound: Allows secure shell (SSH) access for administration. + ingress { + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] # Restrict this to your IP in production + } + + # Outbound: Allows the Relay server to make necessary outbound connections to the Infisical platform. + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${name}-relay-sg" + } +} + +# Elastic IP for a static public IP address +resource "aws_eip" "infisical_relay_eip" { + tags = { + Name = "${name}-relay-eip" + } +} + +# EC2 instance to run Infisical Relay +module "infisical_relay_instance" { + source = "terraform-aws-modules/ec2-instance/aws" + version = "~> 5.6" + + name = "${name}-relay-instance" + ami = "${ami}" + instance_type = "t3.micro" + subnet_id = "${subnetId}" + + vpc_security_group_ids = [aws_security_group.infisical_relay_sg.id] + associate_public_ip_address = false # We are using an Elastic IP instead + + user_data = <<-EOT + #!/bin/bash + set -e + # Install Infisical CLI + curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash + apt-get update && apt-get install -y infisical + + # Install the relay as a systemd service. + # This example uses a Machine Identity token for authentication via the INFISICAL_TOKEN environment variable. + # + # Note: For production environments, you might consider fetching the token from AWS Parameter Store or AWS Secrets Manager. + export INFISICAL_TOKEN="${identityToken}" + sudo -E infisical relay systemd install \\ + --name "${name}" \\ + --domain "${domain}" \\ + --host "\${aws_eip.infisical_relay_eip.public_ip}" + + # Start and enable the service to run on boot + sudo systemctl start infisical-relay + sudo systemctl enable infisical-relay + EOT +} + +# Associate the Elastic IP with the EC2 instance +resource "aws_eip_association" "eip_assoc" { + instance_id = module.infisical_relay_instance.id + allocation_id = aws_eip.infisical_relay_eip.id +} +`; + }, [name, instanceDomain, identityToken, awsRegion, vpcId, ami, subnetId]); + + if (step === "command") { + return ( + <> +
+ Terraform Configuration + { + navigator.clipboard.writeText(terraformCommand); + createNotification({ + text: "Terraform configuration copied to clipboard", + type: "info" + }); + }} + className="w-10" + > + + +
+
+
+            {terraformCommand}
+          
+
+
+ + + +
+ + ); + } + + return ( + <> + + setName(e.target.value)} + placeholder="Enter relay name..." + isError={Boolean(errors.name)} + /> + {errors.name &&

{errors.name}

} + + + setInstanceDomain(e.target.value)} + placeholder="https://app.infisical.com" + isError={Boolean(errors.instanceDomain)} + /> + {errors.instanceDomain &&

{errors.instanceDomain}

} + + {canCreateToken && autogenerateToken ? ( + <> + + + handleIdentityChange( + e as SingleValue<{ + id: string; + name: string; + }> + ) + } + isLoading={isIdentitiesLoading} + placeholder="Select identity..." + options={identityMembershipOrgs.map((membership) => membership.identity)} + getOptionValue={(option) => option.id} + getOptionLabel={(option) => option.name} + /> + {errors.identity &&

{errors.identity}

} + + ) : ( + <> + + setIdentityToken(e.target.value)} + placeholder="Enter identity token..." + isError={Boolean(errors.identityToken)} + /> + {errors.identityToken &&

{errors.identityToken}

} + + )} + + {canCreateToken && ( +
+ { + setAutogenerateToken(Boolean(e)); + }} + id="autogenerate-token" + className="mr-2" + > +
+ Automatically enable token auth and generate a token for identity + + Token authentication will be automatically enabled for the selected identity if + it isn't already configured. By default, it will be configured to allow all + IP addresses with a token TTL of 30 days. You can manage these settings in + Access Control. +
+
A token will automatically be generated to be used with the CLI command. + + } + > + +
+
+
+
+ )} + + + + + `-mb-[0.14rem] px-4 py-2 text-sm font-medium whitespace-nowrap outline-hidden disabled:opacity-60 ${ + selected ? "border-b-2 border-mineshaft-300 text-mineshaft-200" : "text-bunker-300" + }` + } + > + EC2 + + + + + + setAwsRegion(e.target.value)} + placeholder="us-east-1" + isError={Boolean(errors.awsRegion)} + /> + {errors.awsRegion &&

{errors.awsRegion}

} + + setVpcId(e.target.value)} + placeholder="vpc-..." + isError={Boolean(errors.vpcId)} + /> + {errors.vpcId &&

{errors.vpcId}

} + + setAmi(e.target.value)} + placeholder="ami-..." + isError={Boolean(errors.ami)} + /> + {errors.ami &&

{errors.ami}

} + + setSubnetId(e.target.value)} + placeholder="subnet-..." + isError={Boolean(errors.subnetId)} + /> + {errors.subnetId &&

{errors.subnetId}

} +
+
+
+ +
+ + + + +
+ + ); +};