diff --git a/deploy/cloudformation/README.md b/deploy/cloudformation/README.md index ce6ba16..06a7a63 100644 --- a/deploy/cloudformation/README.md +++ b/deploy/cloudformation/README.md @@ -77,6 +77,41 @@ aws cloudformation create-stack-instances \ - The `CreationPolicy` with `ResourceSignal` ensures the stack only completes when the instance is fully bootstrapped - Requires `CAPABILITY_NAMED_IAM` due to named IAM roles and users +## VPC Block Public Access exclusion + +The stack creates a VPC-wide `allow-bidirectional` VPC Block Public Access exclusion so bootstrap egress and public endpoints keep working when BPA is enabled. This exempts the **entire VPC** for internet ingress and egress, not only the LowKey instance. + +| `ExistingVpcId` | `CreateVpcBpaExclusion` | Behavior | +|---|---|---| +| empty (new VPC) | `true` | Stack-owned exclusion, deleted with the stack | +| empty (new VPC) | `false` | Same as `true` — ignored, because a new VPC always needs one | +| set (reused VPC) | `true` | Created with `DeletionPolicy: Retain` / `UpdateReplacePolicy: Retain`, so it survives stack deletion | +| set (reused VPC) | `false` | Nothing created; the VPC must already have a complete `allow-bidirectional` exclusion | + +Reusing a VPC that already has one **requires** `CreateVpcBpaExclusion=false`, or the stack fails trying to create a duplicate. UserData revalidates the exclusion before running any pack and aborts if it is missing. + +Cleanup, once no deployment needs that VPC exempt: + +```bash +aws ec2 describe-vpc-block-public-access-exclusions \ + --query 'VpcBlockPublicAccessExclusions[].[ExclusionId,ResourceArn,State]' --output text +aws ec2 delete-vpc-block-public-access-exclusion --exclusion-id +``` + +> **Warning** +> A **new-VPC** exclusion is stack-owned and deleted with its stack. If another LowKey deployment was later pointed at that same VPC, deleting the first stack removes the exemption the second one depends on. Recreate an exclusion, or redeploy the remaining stack with `CreateVpcBpaExclusion=true`. + +### Limitation: first 100 exclusions only + +Exclusion discovery is deliberately not paginated. Both the installer and the instance-side check inspect only the **first 100** BPA exclusions in the region (`--max-results 100`). The default quota is well under that, so this is an accepted edge case for now. + +If an account holds more than 100 exclusions and the target VPC's exclusion falls outside that first page: + +- The installer treats it as absent and passes `CreateVpcBpaExclusion=true`, so CloudFormation attempts a duplicate and the stack fails with a create error. +- The instance-side check likewise does not see it and refuses to start pack bootstrap, so the deployment fails closed rather than running without internet access. + +Workaround: none that keeps the deployment working. Setting `CreateVpcBpaExclusion=false` only avoids the duplicate-create failure — the instance-side check reads the same first 100 results, so bootstrap still refuses to start. Deploying into such a region requires bringing the region's exclusion count back under 100, so the target VPC's exclusion appears in the first page of results. + ## Next Steps See [Next Steps After Deployment](../README.md#next-steps-after-deployment) for bootstrap scripts setup. diff --git a/deploy/cloudformation/template.yaml b/deploy/cloudformation/template.yaml index 3179f3a..4081b4a 100644 --- a/deploy/cloudformation/template.yaml +++ b/deploy/cloudformation/template.yaml @@ -1,9 +1,11 @@ AWSTemplateFormatVersion: '2010-09-09' Description: > - OpenClaw Instance - Deploys a fully configured OpenClaw AI assistant on EC2 - within its own VPC. Designed for StackSet deployment across AWS Organization accounts. + LowKey agent host on EC2 - provisions public VPC connectivity and a + bidirectional VPC Block Public Access exclusion for reliable bootstrap and ingress. Metadata: + AWSToolsMetrics: + AWSAgentToolkit: aws-cloudformation@2 AWS::CloudFormation::Interface: ParameterGroups: - Label: @@ -53,6 +55,7 @@ Metadata: - ExistingVpcId - ExistingSubnetId - ExistingSubnetId2 + - CreateVpcBpaExclusion - SSHAllowedCidr - KeyPairName - Label: @@ -368,6 +371,12 @@ Parameters: Description: "Second public subnet ID in a different AZ. Required for KiroCrew pack (ALB needs 2 AZs) when ExistingVpcId is set." AllowedPattern: '^(subnet-[a-z0-9]+)?$' + CreateVpcBpaExclusion: + Type: String + Default: 'true' + AllowedValues: ['true', 'false'] + Description: "Create a VPC-wide allow-bidirectional Block Public Access exclusion. The LowKey wizard sets this to false only when the reused VPC already has an active bidirectional exclusion." + RepoBranch: Type: String Default: 'main' @@ -463,6 +472,11 @@ Conditions: IsApiKey: !Equals [!Ref ModelMode, 'api-key'] IsBedrock: !Equals [!Ref ModelMode, 'bedrock'] CreateNewVpc: !Equals [!Ref ExistingVpcId, ''] + # New VPCs always get a stack-owned exclusion. When LowKey must create one + # for a reused VPC, retain it independently of this individual deployment. + CreateExistingVpcBpaExclusion: !And + - !Not [!Condition CreateNewVpc] + - !Equals [!Ref CreateVpcBpaExclusion, 'true'] IsBuilder: !Equals [!Ref ProfileName, 'builder'] IsNotBuilder: !Not [!Condition IsBuilder] IsAccountAssistant: !Equals [!Ref ProfileName, 'account_assistant'] @@ -506,6 +520,56 @@ Resources: - Key: loki:pack Value: !Ref PackName + # New LowKey VPCs own their exclusion: stack deletion removes both in order. + VpcBpaExclusion: + Type: AWS::EC2::VPCBlockPublicAccessExclusion + Condition: CreateNewVpc + Metadata: + com.aws.cloudformation.Context: + why: Keep LowKey's IGW-backed VPC reachable when regional VPC BPA is enabled. + must: + - VPC-wide allow-bidirectional; bootstrap needs internet egress and public endpoints need ingress. + mutable: review-required + Properties: + InternetGatewayExclusionMode: allow-bidirectional + VpcId: !Ref VPC + Tags: + - Key: Name + Value: !Sub '${EnvironmentName}-vpc-bpa-exclusion' + - Key: loki:managed + Value: 'true' + - Key: loki:watermark + Value: !Ref LokiWatermark + - Key: loki:pack + Value: !Ref PackName + + # An exclusion on a reused VPC is VPC-wide shared infrastructure. Retain it + # when this deployment is deleted or replaced so another LowKey stack using + # the same VPC cannot lose internet connectivity with the owning stack. + ExistingVpcBpaExclusion: + Type: AWS::EC2::VPCBlockPublicAccessExclusion + Condition: CreateExistingVpcBpaExclusion + DeletionPolicy: Retain + UpdateReplacePolicy: Retain + Metadata: + com.aws.cloudformation.Context: + why: Give a reused VPC a BPA exclusion whose lifetime is independent of one LowKey deployment. + must: + - Retain on stack deletion or replacement because other stacks may share this VPC-wide exclusion. + mutable: review-required + Properties: + InternetGatewayExclusionMode: allow-bidirectional + VpcId: !Ref ExistingVpcId + Tags: + - Key: Name + Value: !Sub '${EnvironmentName}-vpc-bpa-exclusion' + - Key: loki:managed + Value: 'true' + - Key: loki:watermark + Value: !Ref LokiWatermark + - Key: loki:pack + Value: !Ref PackName + InternetGateway: Type: AWS::EC2::InternetGateway Condition: CreateNewVpc @@ -850,6 +914,16 @@ Resources: - secretsmanager:GetSecretValue - secretsmanager:DescribeSecret Resource: !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${KirocrewTgBotTokenSecret}*' + # Revalidate the VPC-wide prerequisite immediately before bootstrap. + # Inline attachment avoids an IAM propagation race at instance launch. + - PolicyName: !Sub '${EnvironmentName}-vpc-bpa-read' + PolicyDocument: + Version: '2012-10-17' + Statement: + - Sid: VerifyVpcBpaExclusion + Effect: Allow + Action: ec2:DescribeVpcBlockPublicAccessExclusions + Resource: '*' Tags: - Key: Name Value: !Sub '${EnvironmentName}-role' @@ -1838,6 +1912,22 @@ Resources: # -------------------------------------------------------------------------- Instance: Type: AWS::EC2::Instance + Metadata: + # Wait for whichever exclusion this stack creates before UserData starts. + # A complete exclusion supplied by a reused VPC needs no stack dependency. + VpcBpaExclusionDependency: !If + - CreateNewVpc + - !Ref VpcBpaExclusion + - !If + - CreateExistingVpcBpaExclusion + - !Ref ExistingVpcBpaExclusion + - existing + # On the new-VPC path the public internet route must exist before UserData + # makes its first AWS call; a reused VPC brings its own routing. + PublicRoutingDependency: !If + - CreateNewVpc + - !Sub '${VPCGatewayAttachment}|${PublicRoute}|${PublicSubnetRouteTableAssociation}' + - existing CreationPolicy: ResourceSignal: Timeout: PT30M @@ -1933,6 +2023,46 @@ Resources: aws cloudformation signal-resource --stack-name "${!STACK_NAME}" --logical-resource-id Instance --unique-id "$_INSTANCE_ID" --status FAILURE --region "$REGION" 2>/dev/null || true fi ' ERR + # Fail closed before any pack code starts. This revalidates exclusions + # supplied by reused VPCs and protects direct CloudFormation callers + # that bypass the installer's pre-deployment check. + _IMDS_TOKEN=$(curl -sf -X PUT http://169.254.169.254/latest/api/token \ + -H "X-aws-ec2-metadata-token-ttl-seconds: 60") + _PRIMARY_MAC=$(curl -sf -H "X-aws-ec2-metadata-token: $_IMDS_TOKEN" \ + http://169.254.169.254/latest/meta-data/network/interfaces/macs/ | head -1) + # IMDS directory listings return each entry with a trailing slash; + # leaving it in would build a rejected double-slash vpc-id path. + _PRIMARY_MAC="${!_PRIMARY_MAC%/}" + _TARGET_VPC_ID=$(curl -sf -H "X-aws-ec2-metadata-token: $_IMDS_TOKEN" \ + "http://169.254.169.254/latest/meta-data/network/interfaces/macs/$_PRIMARY_MAC/vpc-id") + if [[ -z "$_TARGET_VPC_ID" ]]; then + echo "FATAL: could not resolve the instance VPC before BPA validation" >&2 + # Fail via a failing command, not 'exit': only that triggers the ERR + # trap above, which publishes SSM status and signals CFN promptly. + false + fi + + _BPA_READY=false + for _attempt in 1 2 3 4 5 6; do + # Known limitation: only the first 100 exclusions in the region are + # inspected; see the BPA section in deploy/cloudformation/README.md. + _BPA_COUNT=$(aws ec2 describe-vpc-block-public-access-exclusions \ + --region "$REGION" --max-results 100 \ + --query "length(VpcBlockPublicAccessExclusions[?ends_with(ResourceArn, ':vpc/$_TARGET_VPC_ID') && InternetGatewayExclusionMode == 'allow-bidirectional' && (State == 'create-complete' || State == 'update-complete')])" \ + --output text 2>/dev/null || echo 0) + if [[ "$_BPA_COUNT" =~ ^[1-9][0-9]*$ ]]; then + _BPA_READY=true + break + fi + echo "VPC BPA exclusion not ready (attempt $_attempt/6); retrying in 10s..." + sleep 10 + done + if [[ "$_BPA_READY" != "true" ]]; then + echo "FATAL: VPC $_TARGET_VPC_ID lacks a complete allow-bidirectional BPA exclusion; refusing to start pack bootstrap" >&2 + # Fail via a failing command, not 'exit', so the ERR trap reports it. + false + fi + # Ensure git is available (not present on all AMIs) command -v git &>/dev/null || dnf install -y git 2>/dev/null || yum install -y git # Clone repo with retry (GitHub blips shouldn't kill bootstrap) diff --git a/deploy/test-templates.sh b/deploy/test-templates.sh index 575ab54..ce34acf 100644 --- a/deploy/test-templates.sh +++ b/deploy/test-templates.sh @@ -45,6 +45,12 @@ check_contains "$CFN_TEMPLATE" "git clone --depth 1" "CFN: UserData uses git clo check_contains "$CFN_TEMPLATE" "deploy/bootstrap.sh" "CFN: UserData calls bootstrap.sh" check_contains "$CFN_TEMPLATE" "--pack" "CFN: UserData passes --pack flag" check_contains "$CFN_TEMPLATE" "Deployed agent pack" "CFN: PackName in Outputs" +check_contains "$CFN_TEMPLATE" "AWS::EC2::VPCBlockPublicAccessExclusion" "CFN: VPC BPA exclusion resource defined" +check_contains "$CFN_TEMPLATE" "InternetGatewayExclusionMode: allow-bidirectional" "CFN: VPC BPA exclusion allows bidirectional internet traffic" +check_contains "$CFN_TEMPLATE" "CreateVpcBpaExclusion" "CFN: BPA exclusion creation parameter defined" +check_contains "$CFN_TEMPLATE" "ExistingVpcBpaExclusion:" "CFN: reused VPC BPA exclusion has independent lifecycle" +check_contains "$CFN_TEMPLATE" "DeletionPolicy: Retain" "CFN: reused VPC BPA exclusion retained on stack deletion" +check_contains "$CFN_TEMPLATE" "UpdateReplacePolicy: Retain" "CFN: reused VPC BPA exclusion retained on replacement" echo "" diff --git a/docs/reference/cloudformation.mdx b/docs/reference/cloudformation.mdx index 1a20093..c0aed20 100644 --- a/docs/reference/cloudformation.mdx +++ b/docs/reference/cloudformation.mdx @@ -39,6 +39,7 @@ You rarely set these by hand — `install.sh` computes them from your pack + pro | `EnableConfigRecorder` | `true` | Enable Config recorder | | `ExistingVpcId` | `vpc-0abc123` | Reuse an existing VPC instead of creating one | | `ExistingSubnetId` | `subnet-0def456` | Public subnet in the existing VPC | +| `CreateVpcBpaExclusion` | `true` | Create the VPC-wide `allow-bidirectional` Block Public Access exclusion. Set to `false` only when reusing a VPC that already has a complete one | | `RepoBranch` | `main` | Git branch of the lowkey repo to clone | | `SSHAllowedCidr` | `127.0.0.1/32` | CIDR allowed to SSH (default disables SSH) | | `KiroFromSecret` | `/lowkey/kiro-api-key` | Secrets Manager id/arn for Kiro API key (kiro-cli only) | @@ -49,6 +50,29 @@ You rarely set these by hand — `install.sh` computes them from your pack + pro `LiteLLMApiKey` and `ProviderApiKey` are `NoEcho: true` — they won't appear in stack history or describe-stacks output, but they **do** pass through UserData (which is Base64-encoded in stack metadata). For long-lived secrets, prefer the `--kiro-from-secret` / Secrets Manager pattern which flows only a reference. +## VPC Block Public Access exclusion + +Every deployment needs its VPC exempt from [VPC Block Public Access](https://docs.aws.amazon.com/vpc/latest/userguide/security-vpc-bpa.html): bootstrap downloads require internet egress, and public endpoints (such as the KiroCrew ALB) require ingress. The stack creates a VPC-wide `allow-bidirectional` exclusion to guarantee this. + + +`allow-bidirectional` exempts the **entire VPC**, allowing internet ingress *and* egress for every resource in it — not just the LowKey instance. Deploy only into a VPC where that is acceptable. + + +| `ExistingVpcId` | `CreateVpcBpaExclusion` | Behavior | +|---|---|---| +| empty (new VPC) | `true` | Creates a stack-owned exclusion, deleted with the stack | +| empty (new VPC) | `false` | Same as `true` — the parameter is ignored, because a new VPC always needs an exclusion | +| set (reused VPC) | `true` | Creates an exclusion with `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain`, so the shared VPC-wide exclusion outlives this stack | +| set (reused VPC) | `false` | Creates nothing; the VPC must already have a complete `allow-bidirectional` exclusion | + +Reusing a VPC that already has a complete exclusion **requires** `CreateVpcBpaExclusion=false` — otherwise CloudFormation attempts a duplicate exclusion and the stack fails. `install.sh` detects this and sets the parameter for you. + +Before cloning the repo or running any pack, UserData verifies its own VPC has a complete `allow-bidirectional` exclusion and aborts if it does not, so a misconfigured direct deployment fails closed instead of bootstrapping without connectivity. + + +**Limitation — first 100 exclusions only.** Discovery is not paginated: the installer and the instance-side check each inspect only the first 100 BPA exclusions in the region. The default quota is far below that, so this is an accepted edge case. In a region holding more, an exclusion outside that first page reads as absent — the stack fails on a duplicate create, and bootstrap refuses to start rather than running unexempted. Setting `CreateVpcBpaExclusion=false` is **not** a workaround: it avoids the duplicate create, but the instance-side check reads the same first 100 results and still aborts. Deploying into such a region requires bringing the region's exclusion count back under 100. + + ## Deploying the template directly ```bash @@ -105,3 +129,10 @@ The stack owns the VPC, EC2, IAM — everything vanishes together. Security serv If you reused an existing VPC via `ExistingVpcId`, the VPC is **not** deleted (you brought it, you keep it). + + +Two BPA exceptions to "everything vanishes together": + +- An exclusion this stack created for a **reused** VPC is `Retain`ed and survives deletion. Remove it with `aws ec2 delete-vpc-block-public-access-exclusion --exclusion-id ` once no deployment needs that VPC exempt. +- An exclusion created for a **new** VPC is deleted with the stack. If another LowKey deployment was later pointed at that same VPC, it loses the exemption — recreate one, or redeploy that stack with `CreateVpcBpaExclusion=true`. + diff --git a/install.sh b/install.sh index 503f014..0842abf 100755 --- a/install.sh +++ b/install.sh @@ -1558,6 +1558,65 @@ _check_codex_model_access() { fi } +# Resolve whether CloudFormation must create the default VPC-wide BPA +# exclusion. New VPCs always need one. For reused VPCs, avoid creating a +# duplicate when an active allow-bidirectional exclusion already covers it. +resolve_vpc_bpa_exclusion() { + CREATE_VPC_BPA_EXCLUSION="true" + VPC_BPA_EXCLUSION_STATUS="will be created" + + [[ -n "${EXISTING_VPC_ID:-}" ]] || return 0 + + local check_region="${DEPLOY_REGION:-$REGION}" + local target_suffix=":vpc/${EXISTING_VPC_ID}" + local exclusions_json + + # Known limitation: only the first 100 exclusions in the region are + # inspected. Accounts holding more are an accepted edge case for now — see + # the BPA section in deploy/cloudformation/README.md. + if ! exclusions_json=$(aws ec2 describe-vpc-block-public-access-exclusions \ + --region "$check_region" --max-results 100 --output json 2>&1) \ + || ! printf '%s' "$exclusions_json" | jq -e . >/dev/null 2>&1; then + fail "Could not inspect VPC BPA exclusions for ${EXISTING_VPC_ID} in ${check_region}. Refusing to risk a duplicate exclusion. AWS said: ${exclusions_json}" + fi + + local exclusion_json + exclusion_json=$(printf '%s' "$exclusions_json" | jq -c --arg suffix "$target_suffix" ' + [.VpcBlockPublicAccessExclusions[]? + | select((.ResourceArn // "") | endswith($suffix))][0] // empty + ') + + [[ -n "$exclusion_json" ]] || return 0 + + local exclusion_state exclusion_mode exclusion_reason + exclusion_state=$(printf '%s' "$exclusion_json" | jq -r '.State // "unknown"') + exclusion_mode=$(printf '%s' "$exclusion_json" | jq -r '.InternetGatewayExclusionMode // "unknown"') + exclusion_reason=$(printf '%s' "$exclusion_json" | jq -r '.Reason // empty') + + case "$exclusion_state" in + create-complete|update-complete) + case "$exclusion_mode" in + allow-bidirectional) + CREATE_VPC_BPA_EXCLUSION="false" + VPC_BPA_EXCLUSION_STATUS="already exists" + ;; + allow-egress) + fail "VPC ${EXISTING_VPC_ID} has an egress-only BPA exclusion. LowKey requires allow-bidirectional so ingress can reach this VPC. Update or remove the existing exclusion, then rerun the wizard." + ;; + *) + fail "VPC ${EXISTING_VPC_ID} has a BPA exclusion with unsupported mode '${exclusion_mode}'. LowKey requires allow-bidirectional." + ;; + esac + ;; + create-in-progress|update-in-progress) + fail "VPC ${EXISTING_VPC_ID} has a BPA exclusion still in state '${exclusion_state}'. Wait for it to complete, then rerun the wizard so no pack starts before the exclusion is active." + ;; + *) + fail "VPC ${EXISTING_VPC_ID} has a BPA exclusion in unusable state '${exclusion_state}'${exclusion_reason:+: ${exclusion_reason}}. Resolve that exclusion, then rerun the wizard." + ;; + esac +} + check_vpc_quota() { local check_region="${DEPLOY_REGION:-$REGION}" echo "" @@ -1617,12 +1676,21 @@ check_vpc_quota() { check_permissions() { echo "" info "Checking permissions..." - if aws iam simulate-principal-policy \ - --policy-source-arn "$CALLER_ARN" \ - --action-names "cloudformation:CreateStack" "iam:CreateRole" "ec2:CreateVpc" \ - --query 'EvaluationResults[?EvalDecision!=`allowed`].EvalActionName' \ - --output text 2>/dev/null | grep -q "."; then - warn "Some permissions may be missing." + local denied_actions + if ! denied_actions=$(aws iam simulate-principal-policy \ + --policy-source-arn "$CALLER_ARN" \ + --action-names "cloudformation:CreateStack" "iam:CreateRole" "ec2:CreateVpc" \ + "ec2:CreateVpcBlockPublicAccessExclusion" "ec2:DescribeVpcBlockPublicAccessExclusions" \ + "ec2:ModifyVpcBlockPublicAccessExclusion" "ec2:DeleteVpcBlockPublicAccessExclusion" \ + --query 'EvaluationResults[?EvalDecision!=`allowed`].EvalActionName' \ + --output text 2>&1); then + warn "Could not verify deployment permissions: ${denied_actions}" + confirm_or_abort "Continue without verified permissions?" + return 0 + fi + + if [[ -n "$denied_actions" ]]; then + warn "Some permissions may be missing: ${denied_actions}" confirm_or_abort "Continue anyway?" else ok "Permissions verified" @@ -2420,7 +2488,7 @@ collect_security_config() { # Parameter source-of-truth: single mapping for CFN Console and CFN CLI # ============================================================================ # ⚠ KEEP THESE TWO ARRAYS IN SYNC — same order, same count -PARAM_CFN_NAMES=(EnvironmentName PackName ProfileName InstanceType DefaultModel ModelMode BedrockRegion LokiWatermark EnableBedrockForm EnableSecurityHub EnableGuardDuty EnableInspector EnableAccessAnalyzer EnableConfigRecorder ExistingVpcId ExistingSubnetId ExistingSubnetId2 RepoBranch KiroFromSecret TelegramBotTokenSecret TelegramUser Primary DailyDriver CodexModel EnableWebUIAuth WebUIAdminEmail EdgeLambdaVersionArn EdgeConfigSecretName EdgeConfigSecretArn SigningKeySecretName SigningKeySecretArn KirocrewTgBotTokenSecret KirocrewTgUserId) +PARAM_CFN_NAMES=(EnvironmentName PackName ProfileName InstanceType DefaultModel ModelMode BedrockRegion LokiWatermark EnableBedrockForm EnableSecurityHub EnableGuardDuty EnableInspector EnableAccessAnalyzer EnableConfigRecorder ExistingVpcId ExistingSubnetId ExistingSubnetId2 CreateVpcBpaExclusion RepoBranch KiroFromSecret TelegramBotTokenSecret TelegramUser Primary DailyDriver CodexModel EnableWebUIAuth WebUIAdminEmail EdgeLambdaVersionArn EdgeConfigSecretName EdgeConfigSecretArn SigningKeySecretName SigningKeySecretArn KirocrewTgBotTokenSecret KirocrewTgUserId) PARAM_VALUES=() # populated by build_deploy_params() # Per-pack default model (passed to CFN DefaultModel / bootstrap.sh --model). @@ -2473,6 +2541,7 @@ build_deploy_params() { "${EXISTING_VPC_ID:-}" "${EXISTING_SUBNET_ID:-}" "${EXISTING_SUBNET_ID2:-}" + "${CREATE_VPC_BPA_EXCLUSION:-true}" "$REPO_BRANCH" "${KIRO_FROM_SECRET:-}" "${TELEGRAM_BOT_TOKEN_SECRET:-}" @@ -2571,6 +2640,9 @@ show_summary() { summary+="Bedrock ${BEDROCK_REGION} (cross-region inference)\n" fi [[ -n "${EXISTING_VPC_ID:-}" ]] && summary+="VPC reuse ${EXISTING_VPC_ID}\n" + local bpa_management_note="" + [[ "${CREATE_VPC_BPA_EXCLUSION:-true}" == "false" ]] && bpa_management_note="; external, not managed by this stack" + summary+="BPA exclusion: ${VPC_BPA_EXCLUSION_STATUS:-will be created} (VPC-wide bidirectional; allows internet ingress to this VPC and internet egress${bpa_management_note})\n" summary+="Security ${security_summary}\n" summary+="Environment ${ENV_NAME}" @@ -2808,6 +2880,8 @@ PACK_NAME="openclaw" # Default pack; overridden by collect_config EXISTING_VPC_ID="" EXISTING_SUBNET_ID="" EXISTING_SUBNET_ID2="" # KiroCrew ALB needs a 2nd AZ subnet on existing-VPC path +CREATE_VPC_BPA_EXCLUSION="true" +VPC_BPA_EXCLUSION_STATUS="will be created" # ============================================================================ # Ensure Lowkey-Session SSM document exists (instance-scoped, not account-wide) @@ -3355,6 +3429,11 @@ run_config_and_review() { check_existing_deployments fi + # Every LowKey VPC is bidirectionally excluded from regional VPC BPA. + # Resolve an existing exclusion before the final review so the summary can + # say whether CloudFormation will create it or it is already present. + resolve_vpc_bpa_exclusion + # VPC quota check (skip if reusing) if [[ -z "${EXISTING_VPC_ID:-}" ]]; then check_vpc_quota diff --git a/tests/test-vpc-bpa.sh b/tests/test-vpc-bpa.sh new file mode 100755 index 0000000..01e27b3 --- /dev/null +++ b/tests/test-vpc-bpa.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# tests/test-vpc-bpa.sh — VPC BPA exclusion detection and template wiring +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +INSTALL_SH="${REPO_ROOT}/install.sh" +TEMPLATE="${REPO_ROOT}/deploy/cloudformation/template.yaml" + +PASS=0 +FAIL=0 +pass() { printf ' ✓ %s\n' "$1"; PASS=$((PASS + 1)); } +fail_test() { printf ' ✗ %s\n' "$1"; FAIL=$((FAIL + 1)); } +assert_eq() { + local description="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + pass "$description" + else + fail_test "$description (expected: $expected, actual: $actual)" + fi +} +assert_contains() { + local description="$1" needle="$2" haystack="$3" + [[ "$haystack" == *"$needle"* ]] && pass "$description" || fail_test "$description (missing: $needle)" +} + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT +cat > "${TMPDIR}/functions.sh" <<'STUBS' +set -euo pipefail +EXISTING_VPC_ID="" +DEPLOY_REGION="us-east-1" +CREATE_VPC_BPA_EXCLUSION="true" +VPC_BPA_EXCLUSION_STATUS="will be created" +fail() { printf '%s\n' "$*" >&2; exit 1; } +STUBS +sed -n '/^resolve_vpc_bpa_exclusion() {/,/^}/p' "$INSTALL_SH" >> "${TMPDIR}/functions.sh" + +printf '── VPC BPA resolution ──\n' + +test_new_vpc_creates_exclusion() { + source "${TMPDIR}/functions.sh" + aws() { fail "AWS must not be called for a new VPC"; } + resolve_vpc_bpa_exclusion + assert_eq "new VPC creates exclusion" "true" "$CREATE_VPC_BPA_EXCLUSION" + assert_eq "new VPC review status" "will be created" "$VPC_BPA_EXCLUSION_STATUS" +} +test_new_vpc_creates_exclusion + +test_existing_bidirectional_exclusion_is_reused() { + source "${TMPDIR}/functions.sh" + EXISTING_VPC_ID="vpc-0123456789abcdef0" + aws() { + cat <<'JSON' +{"VpcBlockPublicAccessExclusions":[{"ExclusionId":"vpcbpa-excl-1","InternetGatewayExclusionMode":"allow-bidirectional","ResourceArn":"arn:aws:ec2:us-east-1:123456789012:vpc/vpc-0123456789abcdef0","State":"create-complete"}]} +JSON + } + resolve_vpc_bpa_exclusion + assert_eq "existing bidirectional exclusion is reused" "false" "$CREATE_VPC_BPA_EXCLUSION" + assert_eq "existing exclusion review status" "already exists" "$VPC_BPA_EXCLUSION_STATUS" +} +test_existing_bidirectional_exclusion_is_reused + +test_other_vpc_exclusion_is_ignored() { + source "${TMPDIR}/functions.sh" + EXISTING_VPC_ID="vpc-0123456789abcdef0" + aws() { + cat <<'JSON' +{"VpcBlockPublicAccessExclusions":[{"ExclusionId":"vpcbpa-excl-other","InternetGatewayExclusionMode":"allow-bidirectional","ResourceArn":"arn:aws:ec2:us-east-1:123456789012:vpc/vpc-fffffffffffffffff","State":"create-complete"}]} +JSON + } + resolve_vpc_bpa_exclusion + assert_eq "other VPC exclusion does not suppress creation" "true" "$CREATE_VPC_BPA_EXCLUSION" + assert_eq "missing target exclusion review status" "will be created" "$VPC_BPA_EXCLUSION_STATUS" +} +test_other_vpc_exclusion_is_ignored + +test_exclusion_beyond_first_page_is_not_inspected() { + source "${TMPDIR}/functions.sh" + EXISTING_VPC_ID="vpc-0123456789abcdef0" + # Documented limitation: only the first 100 exclusions are inspected, so a + # target hidden behind NextToken is treated as absent rather than paginated. + aws() { + while [[ $# -gt 0 ]]; do + # No continuation flag may be used by either caller. + case "$1" in + --next-token|--starting-token) return 252 ;; + esac + shift + done + printf '{"VpcBlockPublicAccessExclusions":[],"NextToken":"page2"}\n' + } + resolve_vpc_bpa_exclusion + assert_eq "single-page lookup does not paginate" "true" "$CREATE_VPC_BPA_EXCLUSION" + assert_eq "single-page lookup review status" "will be created" "$VPC_BPA_EXCLUSION_STATUS" +} +test_exclusion_beyond_first_page_is_not_inspected + +# Malformed API output must fail closed rather than silently creating a duplicate. +if ( + source "${TMPDIR}/functions.sh" + EXISTING_VPC_ID="vpc-0123456789abcdef0" + aws() { printf 'not json\n'; } + resolve_vpc_bpa_exclusion +) >/dev/null 2>&1; then + fail_test "malformed exclusion output stops before duplicate creation" +else + pass "malformed exclusion output stops before duplicate creation" +fi + +assert_reused_exclusion_rejected() { + local state="$1" mode="$2" description="$3" reason="${4:-}" + if ( + source "${TMPDIR}/functions.sh" + EXISTING_VPC_ID="vpc-0123456789abcdef0" + aws() { + printf '{"VpcBlockPublicAccessExclusions":[{"ExclusionId":"vpcbpa-excl-test","InternetGatewayExclusionMode":"%s","ResourceArn":"arn:aws:ec2:us-east-1:123456789012:vpc/vpc-0123456789abcdef0","State":"%s","Reason":"%s"}]}\n' \ + "$mode" "$state" "$reason" + } + resolve_vpc_bpa_exclusion + ) >/dev/null 2>&1; then + fail_test "$description" + else + pass "$description" + fi +} + +assert_reused_exclusion_rejected \ + "create-in-progress" "allow-bidirectional" \ + "in-progress exclusion is rejected so bootstrap cannot start early" +assert_reused_exclusion_rejected \ + "update-in-progress" "allow-bidirectional" \ + "updating exclusion is rejected so bootstrap cannot start early" +assert_reused_exclusion_rejected \ + "create-failed" "allow-bidirectional" \ + "failed exclusion is rejected instead of attempting a duplicate" "service rejected request" +assert_reused_exclusion_rejected \ + "delete-in-progress" "allow-bidirectional" \ + "deleting exclusion is rejected instead of attempting a duplicate" +assert_reused_exclusion_rejected \ + "create-complete" "allow-egress" \ + "egress-only exclusion is rejected because it does not allow ingress" + +if ( + source "${TMPDIR}/functions.sh" + EXISTING_VPC_ID="vpc-0123456789abcdef0" + aws() { return 1; } + resolve_vpc_bpa_exclusion +) >/dev/null 2>&1; then + fail_test "existing VPC API failure stops before duplicate creation" +else + pass "existing VPC API failure stops before duplicate creation" +fi + +printf '\n── Installer and CloudFormation wiring ──\n' +summary_body="$(sed -n '/^show_summary() {/,/^}/p' "$INSTALL_SH")" +run_config_body="$(sed -n '/^run_config_and_review() {/,/^}/p' "$INSTALL_SH")" +permissions_body="$(sed -n '/^check_permissions() {/,/^}/p' "$INSTALL_SH")" +assert_contains "summary shows exact BPA status label" 'BPA exclusion: ${VPC_BPA_EXCLUSION_STATUS:-will be created}' "$summary_body" +assert_contains "summary explains VPC-wide bidirectional scope" 'VPC-wide bidirectional' "$summary_body" +assert_contains "summary explains ingress effect" 'allows internet ingress to this VPC' "$summary_body" +assert_contains "summary explains egress effect" 'and internet egress' "$summary_body" +assert_contains "summary identifies reused exclusion as external" 'external, not managed by this stack' "$summary_body" +assert_contains "config resolves BPA before review" 'resolve_vpc_bpa_exclusion' "$run_config_body" +assert_contains "installer passes BPA creation parameter" 'CreateVpcBpaExclusion' "$(grep '^PARAM_CFN_NAMES=' "$INSTALL_SH")" +assert_contains "permission check includes BPA modification" 'ec2:ModifyVpcBlockPublicAccessExclusion' "$permissions_body" +assert_contains "permission check includes BPA deletion" 'ec2:DeleteVpcBlockPublicAccessExclusion' "$permissions_body" +assert_contains "permission simulation failure is handled separately" 'if ! denied_actions=$(aws iam simulate-principal-policy' "$permissions_body" + +if python3 - "$TEMPLATE" <<'PY' +import sys, yaml +class Loader(yaml.SafeLoader): + pass +Loader.add_multi_constructor( + '!', + lambda loader, tag, node: loader.construct_scalar(node) + if isinstance(node, yaml.ScalarNode) + else loader.construct_sequence(node) + if isinstance(node, yaml.SequenceNode) + else loader.construct_mapping(node), +) +template_text = open(sys.argv[1]).read() +with open(sys.argv[1]) as stream: + doc = yaml.load(stream, Loader=Loader) +assert 'ec2:DescribeVpcBlockPublicAccessExclusions' in template_text +assert '--starting-token' not in template_text +assert '--next-token' not in template_text +assert '--max-results 100' in template_text +assert '${!_PRIMARY_MAC%/}' in template_text +# Failures in the BPA block must reach the ERR trap (which publishes SSM status +# and signals CloudFormation); a bare 'exit' skips it and stalls until timeout. +bpa_block = template_text[ + template_text.index('# Fail closed before any pack code starts'): + template_text.index('# Ensure git is available') +] +assert 'exit 1' not in bpa_block +assert bpa_block.count('false') >= 2 +bpa_check = template_text.index('aws ec2 describe-vpc-block-public-access-exclusions') +git_clone = template_text.index('git clone --depth 1') +pack_bootstrap = template_text.index('bash /tmp/lowkey/deploy/bootstrap.sh') +assert bpa_check < git_clone < pack_bootstrap +assert 'refusing to start pack bootstrap' in template_text +param = doc['Parameters']['CreateVpcBpaExclusion'] +assert param['Default'] == 'true' +assert param['AllowedValues'] == ['true', 'false'] +condition = doc['Conditions']['CreateExistingVpcBpaExclusion'] +assert 'CreateNewVpc' in repr(condition) +assert 'CreateVpcBpaExclusion' in repr(condition) +new_vpc_resource = doc['Resources']['VpcBpaExclusion'] +assert new_vpc_resource['Type'] == 'AWS::EC2::VPCBlockPublicAccessExclusion' +assert new_vpc_resource['Condition'] == 'CreateNewVpc' +assert new_vpc_resource['Properties']['InternetGatewayExclusionMode'] == 'allow-bidirectional' +assert new_vpc_resource['Properties']['VpcId'] == 'VPC' +assert 'DeletionPolicy' not in new_vpc_resource +existing_vpc_resource = doc['Resources']['ExistingVpcBpaExclusion'] +assert existing_vpc_resource['Type'] == 'AWS::EC2::VPCBlockPublicAccessExclusion' +assert existing_vpc_resource['Condition'] == 'CreateExistingVpcBpaExclusion' +assert existing_vpc_resource['Properties']['InternetGatewayExclusionMode'] == 'allow-bidirectional' +assert existing_vpc_resource['Properties']['VpcId'] == 'ExistingVpcId' +assert existing_vpc_resource['DeletionPolicy'] == 'Retain' +assert existing_vpc_resource['UpdateReplacePolicy'] == 'Retain' +instance_dependency = doc['Resources']['Instance']['Metadata']['VpcBpaExclusionDependency'] +assert 'VpcBpaExclusion' in repr(instance_dependency) +assert 'ExistingVpcBpaExclusion' in repr(instance_dependency) +routing_dependency = repr(doc['Resources']['Instance']['Metadata']['PublicRoutingDependency']) +for required in ('VPCGatewayAttachment', 'PublicRoute', 'PublicSubnetRouteTableAssociation'): + assert required in routing_dependency +assert doc['Metadata']['AWSToolsMetrics']['AWSAgentToolkit'] == 'aws-cloudformation@2' +PY +then + pass "template creates a conditional VPC-wide bidirectional exclusion" +else + fail_test "template BPA exclusion wiring is invalid" +fi + +printf '\nPassed: %d Failed: %d\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]]