cat > create_fortiaigate_vpc.py <<'PY' && python3 -c 'from pathlib import Path; import hashlib, sys; p=Path("create_fortiaigate_vpc.py"); raw=p.read_bytes(); expected="0c198934ff8a2e660850ad5131f0aa7a222792aeb333c0310b33b9a450c0dbfd"; hashlib.sha256(raw).hexdigest()==expected or sys.exit("Integrity check failed. Use the complete saved create_fortiaigate_vpc.sh file. No resources were created."); code=compile(raw,str(p),"exec"); sys.argv=[str(p)]; exec(code,{"__name__":"__main__","__file__":str(p)})'
#!/usr/bin/env python3
"""Create the FortiAIGate network and the Ubuntu EC2 lab in us-east-1."""
import ipaddress
import json
import os
import re
import subprocess
import sys
import time
import uuid
from datetime import datetime, timedelta, timezone
from urllib.parse import unquote

REGION = "us-east-1"
PREFIX_LIST_ID = os.environ.get("FORTIAIGATE_PREFIX_LIST_ID", "").strip()
AMI_PARAMETER = "/aws/service/canonical/ubuntu/server/22.04/stable/current/amd64/hvm/ebs-gp2/ami-id"
# Public Canonical publisher ID, retained to verify trusted Ubuntu AMIs.
# This is not a lab user account.
CANONICAL_OWNER = "099720109477"
INSTANCE_TYPE = "t3a.large"
ROLE_SUFFIX = "_FortiAIGate_Docker_SSMRole"
SSM_POLICY = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
# Public edition: supply your existing release bucket and IPv4 prefix list.
# Set the environment variables or answer the prompts; neither is created.
S3_BUCKET = os.environ.get("FORTIAIGATE_S3_BUCKET", "").strip()
S3_PREFIX = "FortiAIGate_8.0.2_CPU/"
os.environ.update(AWS_PAGER="", AWS_CLI_AUTO_PROMPT="off")


class AwsError(RuntimeError):
    def __init__(self, message):
        super().__init__(message)
        match = re.search(r"\(([^()]+)\) when calling", message)
        self.code = match.group(1) if match else ""


def call(service, operation, *args, region=REGION, missing=(), **parameters):
    command = ["aws", service, operation, *args]
    if parameters:
        command += ["--cli-input-json", json.dumps(parameters)]
    command += ["--region", region, "--output", "json", "--no-cli-pager"]
    result = subprocess.run(command, capture_output=True, text=True)
    if result.returncode:
        error = AwsError(result.stderr.strip() or result.stdout.strip())
        if error.code in missing:
            return {}
        raise error
    return json.loads(result.stdout) if result.stdout.strip() else {}


def iam_retry(action):
    """Retry only IAM propagation errors, with a fixed time limit."""
    for attempt in range(36):
        try:
            return action()
        except AwsError as error:
            transient = error.code in ("NoSuchEntity", "InvalidIamInstanceProfile.NotFound")
            transient |= (error.code == "InvalidParameterValue"
                          and "instance profile" in str(error).lower())
            if not transient or attempt == 35:
                raise
            if attempt % 6 == 0:
                print("Waiting for the IAM role/instance profile to propagate...", flush=True)
            time.sleep(5)


def browser_ipv4(caller_arn):
    """CloudShell curl reports its own egress IP; use browser CloudTrail events."""
    now = datetime.now(timezone.utc)
    cutoff = now - timedelta(minutes=30)
    data = call(
        "cloudtrail", "lookup-events", "--max-items", "100",
        region=os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or REGION,
        LookupAttributes=[{"AttributeKey": "EventSource", "AttributeValue": "cloudshell.amazonaws.com"}],
        StartTime=cutoff.isoformat(),
    )
    if data.get("NextToken"):
        return None
    addresses = set()
    for item in data.get("Events", []):
        try:
            event = json.loads(item["CloudTrailEvent"])
            if (event.get("eventSource") != "cloudshell.amazonaws.com"
                    or event.get("userIdentity", {}).get("arn") != caller_arn
                    or not event.get("userAgent", "").startswith("Mozilla/")
                    or event.get("errorCode")):
                continue
            timestamp = datetime.fromisoformat(event["eventTime"].replace("Z", "+00:00"))
            if not cutoff <= timestamp <= now:
                continue
            address = ipaddress.ip_address(event["sourceIPAddress"])
            if address.is_global and not address.is_multicast:
                addresses.add(address)
        except (KeyError, ValueError, TypeError):
            continue
    if len(addresses) == 1:
        address = addresses.pop()
        if address.version == 4:
            return str(address)
    return None


def check_role(role):
    policy = role.get("AssumeRolePolicyDocument", {})
    if isinstance(policy, str):
        policy = json.loads(unquote(policy))
    statements = policy.get("Statement", [])
    if isinstance(statements, dict):
        statements = [statements]

    def values(value):
        return value if isinstance(value, list) else [value]

    allowed = False
    for statement in statements:
        if statement.get("Effect") == "Deny":
            raise RuntimeError("Existing IAM role has a custom Deny trust policy; review it before reuse.")
        principal = statement.get("Principal", {})
        if (statement.get("Effect") == "Allow" and not statement.get("Condition")
                and isinstance(principal, dict)
                and "ec2.amazonaws.com" in values(principal.get("Service"))
                and "sts:AssumeRole" in values(statement.get("Action"))):
            allowed = True
    if not allowed:
        raise RuntimeError("Existing IAM role does not have an unconditional EC2 sts:AssumeRole trust. No trust policy was changed.")


def check_profile(profile, role_name):
    roles = profile.get("Roles", [])
    if roles and (len(roles) != 1 or roles[0]["RoleName"].casefold() != role_name.casefold()):
        raise RuntimeError("Existing instance profile contains a different role. No second role will be added.")


def wait_for_profile(profile_name, role_name):
    for attempt in range(36):
        profile = call("iam", "get-instance-profile", InstanceProfileName=profile_name,
                       missing=("NoSuchEntity",)).get("InstanceProfile", {})
        check_profile(profile, role_name)
        if profile.get("Roles"):
            return profile
        if attempt % 6 == 0:
            print("Waiting for the role to appear in its instance profile...", flush=True)
        time.sleep(5)
    raise RuntimeError("The IAM instance profile did not become ready within 3 minutes.")


def wait_for_ssm(instance_id):
    print("Waiting up to 10 minutes for SSM to report Online...", flush=True)
    for attempt in range(60):
        try:
            information = call("ssm", "describe-instance-information",
                               Filters=[{"Key": "InstanceIds", "Values": [instance_id]}])
        except AwsError as error:
            print(f"Instance created, but SSM readiness could not be checked: {error}", flush=True)
            return False
        if any(i.get("InstanceId") == instance_id and i.get("PingStatus") == "Online"
               for i in information.get("InstanceInformationList", [])):
            print("SSM: Online", flush=True)
            return True
        if attempt % 3 == 0:
            print("  Instance exists; waiting for SSM registration...", flush=True)
        time.sleep(10)
    print("Instance created, but SSM has not reported Online. Check the instance's IAM permissions, agent and outbound connectivity.")
    return False


def main():
    global S3_BUCKET, PREFIX_LIST_ID
    username = input("Enter your name prefix (1-37 characters): ").strip()
    if (not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,36}", username)
            or username.lower().startswith("sg-")):
        raise RuntimeError("Use 1-37 letters, digits, underscores or hyphens; start with a letter or digit and avoid sg-. The IAM role name must fit AWS's 64-character limit.")
    S3_BUCKET = S3_BUCKET or input("Enter the existing S3 release bucket name (no s3://): ").strip()
    PREFIX_LIST_ID = PREFIX_LIST_ID or input("Enter your existing IPv4 managed prefix list ID (pl-...): ").strip()
    if not re.fullmatch(r"pl-(?:[0-9a-f]{8}|[0-9a-f]{17})", PREFIX_LIST_ID):
        raise RuntimeError("FORTIAIGATE_PREFIX_LIST_ID must be an existing IPv4 managed prefix list ID (pl- followed by 8 or 17 lowercase hexadecimal characters).")
    if (not re.fullmatch(r"[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]", S3_BUCKET)
            or ".." in S3_BUCKET or re.fullmatch(r"\d+\.\d+\.\d+\.\d+", S3_BUCKET)):
        raise RuntimeError("FORTIAIGATE_S3_BUCKET must be an existing S3 bucket name: 3-63 lowercase letters, digits, dots or hyphens, without wildcards, a path or an s3:// prefix.")
    identity = call("sts", "get-caller-identity")
    print(f"Account: {identity['Account']}   Region: {REGION}", flush=True)
    print("Looking up your recent browser IPv4 address in CloudTrail...", flush=True)
    try:
        source_ip = browser_ipv4(identity["Arn"])
    except AwsError:
        source_ip = None
    if source_ip:
        print(f"Detected recent browser IPv4: {source_ip}", flush=True)
    else:
        print("Automatic detection unavailable: no unique recent browser IPv4, incomplete history, or CloudTrail lookup denied.")
        source_ip = input("Enter your source public IPv4 address (without /32): ").strip()
    source_cidr = str(ipaddress.IPv4Address(source_ip)) + "/32"

    vpc_name = f"{username}_FortiAIGate_VPC"
    sg_name = f"{username}_FortiAIGate_NSG"
    instance_name = f"{username}_FortiAIGate_Docker"
    role_name = username + ROLE_SUFFIX
    profile_name = role_name
    s3_policy_name = f"{username}-FortiAIGate-S3Read"

    def resource_tags(name, vpc_id=None):
        tags = [{"Key": "Name", "Value": name}, {"Key": "Owner", "Value": username},
                {"Key": "Project", "Value": "FortiAIGate"}]
        if vpc_id:
            tags.append({"Key": "VpcId", "Value": vpc_id})
        return tags

    def tag_spec(resource_type, name, vpc_id=None):
        return [{"ResourceType": resource_type, "Tags": resource_tags(name, vpc_id)}]

    # All preflight checks below are read-only. Do not guess AMIs or replace a role.
    zones = call("ec2", "describe-availability-zones", ZoneNames=["us-east-1a", "us-east-1f"],
                 Filters=[{"Name": "state", "Values": ["available"]}])["AvailabilityZones"]
    if {z["ZoneName"] for z in zones if z["State"] == "available"} != {"us-east-1a", "us-east-1f"}:
        raise RuntimeError("Both us-east-1a and us-east-1f must be available to your account.")
    prefixes = call("ec2", "describe-managed-prefix-lists", PrefixListIds=[PREFIX_LIST_ID])["PrefixLists"]
    if len(prefixes) != 1 or prefixes[0].get("AddressFamily") != "IPv4":
        raise RuntimeError(f"IPv4 prefix list {PREFIX_LIST_ID} must be accessible in {REGION}.")
    print(f"Using prefix list: {PREFIX_LIST_ID} ({prefixes[0]['PrefixListName']})", flush=True)
    offerings = call("ec2", "describe-instance-type-offerings", LocationType="availability-zone",
                     Filters=[{"Name": "instance-type", "Values": [INSTANCE_TYPE]},
                              {"Name": "location", "Values": ["us-east-1a"]}])["InstanceTypeOfferings"]
    if not any(o["InstanceType"] == INSTANCE_TYPE and o["Location"] == "us-east-1a" for o in offerings):
        raise RuntimeError(f"{INSTANCE_TYPE} is not offered in your account's us-east-1a.")
    # Canonical publishes 22.04 as ebs-gp2; RunInstances overrides its root to gp3.
    # https://ubuntu.com/aws/docs/aws-how-to/instances/find-ubuntu-images/
    ami_id = call("ssm", "get-parameter", Name=AMI_PARAMETER)["Parameter"]["Value"]
    images = call("ec2", "describe-images", ImageIds=[ami_id], Owners=[CANONICAL_OWNER])["Images"]
    if len(images) != 1:
        raise RuntimeError("The Ubuntu AMI could not be verified against Canonical's owner account.")
    image = images[0]
    if (image.get("OwnerId") != CANONICAL_OWNER or image.get("Architecture") != "x86_64"
            or image.get("State") != "available" or image.get("RootDeviceType") != "ebs"
            or image.get("VirtualizationType") != "hvm"
            or not image.get("Name", "").startswith("ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-")):
        raise RuntimeError("The AMI does not match Canonical Ubuntu Server 22.04 x86_64 HVM/EBS.")
    root_device = image["RootDeviceName"]
    print(f"Ubuntu AMI: {ami_id} ({image['Name']})", flush=True)
    role = call("iam", "get-role", RoleName=role_name, missing=("NoSuchEntity",)).get("Role")
    profile = call("iam", "get-instance-profile", InstanceProfileName=profile_name,
                   missing=("NoSuchEntity",)).get("InstanceProfile")
    if role:
        check_role(role)
        role_name = role["RoleName"]
    if profile:
        check_profile(profile, role_name)
        profile_name = profile["InstanceProfileName"]

    print(f"\nCreating {vpc_name} and {instance_name}: {INSTANCE_TYPE}, us-east-1a, encrypted 150 GiB gp3 root.", flush=True)
    if not role:
        trust = {"Version": "2012-10-17", "Statement": [{"Effect": "Allow",
                 "Principal": {"Service": "ec2.amazonaws.com"}, "Action": "sts:AssumeRole"}]}
        role = call("iam", "create-role", RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust),
                    Description=f"SSM access for {instance_name}", Tags=resource_tags(role_name))["Role"]
        print(f"Created IAM role: {role['Arn']}", flush=True)
    else:
        print(f"Reusing IAM role: {role['Arn']}", flush=True)
    iam_retry(lambda: call("iam", "attach-role-policy", RoleName=role_name, PolicyArn=SSM_POLICY))
    s3_read_policy = {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Effect": "Allow", "Action": "s3:ListBucket",
                "Resource": f"arn:aws:s3:::{S3_BUCKET}",
                "Condition": {"StringLike": {"s3:prefix": [S3_PREFIX, S3_PREFIX + "*"]}},
            },
            {
                "Effect": "Allow", "Action": "s3:GetObject",
                "Resource": f"arn:aws:s3:::{S3_BUCKET}/{S3_PREFIX}*",
            },
        ],
    }
    iam_retry(lambda: call("iam", "put-role-policy", RoleName=role_name,
                           PolicyName=s3_policy_name, PolicyDocument=json.dumps(s3_read_policy)))
    print(f"S3 inline policy: {s3_policy_name} (list/read s3://{S3_BUCKET}/{S3_PREFIX})", flush=True)
    if not profile:
        profile = call("iam", "create-instance-profile", InstanceProfileName=profile_name,
                       Tags=resource_tags(profile_name))["InstanceProfile"]
        print(f"Created IAM instance profile: {profile['Arn']}", flush=True)
    if not profile.get("Roles"):
        iam_retry(lambda: call("iam", "add-role-to-instance-profile",
                               InstanceProfileName=profile_name, RoleName=role_name))
    profile = wait_for_profile(profile_name, role_name)
    print(f"Instance profile: {profile['Arn']}", flush=True)

    vpc_id = call("ec2", "create-vpc", CidrBlock="10.0.0.0/16",
                  TagSpecifications=tag_spec("vpc", vpc_name))["Vpc"]["VpcId"]
    print(f"VPC: {vpc_id} ({vpc_name})", flush=True)
    call("ec2", "wait", "vpc-available", VpcIds=[vpc_id])
    call("ec2", "modify-vpc-attribute", VpcId=vpc_id, EnableDnsSupport={"Value": True})
    call("ec2", "modify-vpc-attribute", VpcId=vpc_id, EnableDnsHostnames={"Value": True})
    igw_id = call("ec2", "create-internet-gateway",
                  TagSpecifications=tag_spec("internet-gateway", f"{username}_FortiAIGate_IGW", vpc_id))["InternetGateway"]["InternetGatewayId"]
    print(f"Internet gateway: {igw_id}", flush=True)
    call("ec2", "attach-internet-gateway", InternetGatewayId=igw_id, VpcId=vpc_id)
    subnets = []
    for zone, cidr, label in (("us-east-1a", "10.0.1.0/24", "1A"), ("us-east-1f", "10.0.2.0/24", "1F")):
        subnet_id = call("ec2", "create-subnet", VpcId=vpc_id, AvailabilityZone=zone, CidrBlock=cidr,
                         TagSpecifications=tag_spec("subnet", f"{username}_FortiAIGate_Public_{label}", vpc_id))["Subnet"]["SubnetId"]
        subnets.append(subnet_id)
        print(f"Public subnet {label}: {subnet_id} ({cidr})", flush=True)
    call("ec2", "wait", "subnet-available", SubnetIds=subnets)
    route_table_id = call("ec2", "create-route-table", VpcId=vpc_id,
                          TagSpecifications=tag_spec("route-table", f"{username}_FortiAIGate_Public_RT", vpc_id))["RouteTable"]["RouteTableId"]
    print(f"Public route table: {route_table_id}", flush=True)
    call("ec2", "create-route", RouteTableId=route_table_id, DestinationCidrBlock="0.0.0.0/0", GatewayId=igw_id)
    for subnet_id in subnets:
        call("ec2", "modify-subnet-attribute", SubnetId=subnet_id, MapPublicIpOnLaunch={"Value": True})
        call("ec2", "associate-route-table", SubnetId=subnet_id, RouteTableId=route_table_id)

    sg_id = call("ec2", "create-security-group", VpcId=vpc_id, GroupName=sg_name,
                 Description=f"FortiAIGate access for {username}",
                 TagSpecifications=tag_spec("security-group", sg_name, vpc_id))["GroupId"]
    print(f"Instance security group: {sg_id} ({sg_name})", flush=True)
    call("ec2", "authorize-security-group-ingress", GroupId=sg_id, IpPermissions=[
        {"IpProtocol": "tcp", "FromPort": 443, "ToPort": 443,
         "IpRanges": [{"CidrIp": source_cidr, "Description": "HTTPS from user"}]},
        {"IpProtocol": "tcp", "FromPort": 22, "ToPort": 22,
         "IpRanges": [{"CidrIp": source_cidr, "Description": "SSH from user"}]},
        {"IpProtocol": "-1", "PrefixListIds": [{"PrefixListId": PREFIX_LIST_ID, "Description": "RFC1918 all traffic"}]},
    ])
    launch = {
        "ImageId": ami_id, "InstanceType": INSTANCE_TYPE, "MinCount": 1, "MaxCount": 1,
        "ClientToken": str(uuid.uuid4()),
        "Placement": {"AvailabilityZone": "us-east-1a", "Tenancy": "default"},
        "IamInstanceProfile": {"Arn": profile["Arn"]},
        "NetworkInterfaces": [{"DeviceIndex": 0, "SubnetId": subnets[0], "Groups": [sg_id],
                               "AssociatePublicIpAddress": True, "DeleteOnTermination": True}],
        "BlockDeviceMappings": [{"DeviceName": root_device, "Ebs": {
            "VolumeSize": 150, "VolumeType": "gp3", "Iops": 3000, "Throughput": 125,
            "Encrypted": True, "DeleteOnTermination": True}}],
        "CreditSpecification": {"CpuCredits": "standard"}, "Monitoring": {"Enabled": False},
        "TagSpecifications": (tag_spec("instance", instance_name, vpc_id)
                              + tag_spec("volume", instance_name + "_Root", vpc_id)
                              + tag_spec("network-interface", instance_name + "_ENI", vpc_id)),
    }
    # Intentionally omit KeyName and UserData: SSM access, no startup script.
    # Reuse this token for every propagation retry to avoid duplicate launches.
    print(f"Launching {instance_name}...", flush=True)
    instance_id = iam_retry(lambda: call("ec2", "run-instances", **launch))["Instances"][0]["InstanceId"]
    print(f"EC2 instance: {instance_id} ({instance_name})", flush=True)
    print("Waiting for the instance to run and pass EC2 status checks...", flush=True)
    call("ec2", "wait", "instance-running", InstanceIds=[instance_id])
    call("ec2", "wait", "instance-status-ok", InstanceIds=[instance_id])
    instance = call("ec2", "describe-instances", InstanceIds=[instance_id])["Reservations"][0]["Instances"][0]
    print(f"Public IPv4: {instance.get('PublicIpAddress', '(not assigned)')}", flush=True)
    print(f"\nCreated {vpc_name} and {instance_name} in {REGION}.", flush=True)
    print(f"Instance security group: {sg_name} ({sg_id}). HTTPS/SSH source: {source_cidr}; all traffic from prefix list {PREFIX_LIST_ID}.", flush=True)
    ready = wait_for_ssm(instance_id)
    print(f"\nConnect with Session Manager: aws ssm start-session --target {instance_id} --region {REGION}")
    if not ready:
        print("Use the existing instance ID to troubleshoot SSM; rerunning creation would create another VPC and instance.")
        return 2
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except (RuntimeError, OSError, ValueError, KeyError) as error:
        print(f"\nSTOPPED: {error}\nAny resources already created remain; use the IDs printed above to inspect or clean them up.", file=sys.stderr)
        sys.exit(1)
    except (EOFError, KeyboardInterrupt):
        sys.exit("Stopped. Any resources already created remain; keep their IDs from the output above.")
PY
