#!/usr/bin/env bash
# ══════════════════════════════════════════════════════════════════════════════
#  ChainOS Edge on AWS Nitro Enclaves — guided installer
#
#    curl -fsSLO https://docs.chainos.cloud/install/chainos-edge-nitro.sh
#    bash chainos-edge-nitro.sh               # install (asks for everything)
#    bash chainos-edge-nitro.sh status        # where it is and how to reach it
#    bash chainos-edge-nitro.sh upgrade       # move to another release
#    bash chainos-edge-nitro.sh uninstall     # remove it (keys and envelopes kept)
#
#  What it builds, in your AWS account:
#
#    - an enclave-enabled EC2 instance (Auto Scaling group of one) in an ECS cluster;
#    - the Edge enclave image (EIF), built on that instance from the released
#      chainos-edge image you choose, and cached in S3 so a replacement instance
#      runs the identical image;
#    - a recovery phrase GENERATED INSIDE THE INSTANCE AND NEVER SHOWN TO ANYONE,
#      sealed twice: once for the enclave (KMS releases it only to an enclave whose
#      measurement matches), and once under a separate recovery key that only the
#      principal you name can open — the only backup of a phrase nobody saw;
#    - the ECS service running the Edge, and optionally an internal load balancer
#      giving it a stable private endpoint.
#
#  Needs: bash, curl. Installs AWS CLI v2 into ~/.local if it is missing.
#  Re-running `install` with the same deployment name resumes where it stopped.
#
#  Every value can also be given as an environment variable (CHAINOS_NAME,
#  CHAINOS_VERSION, CHAINOS_API_KEY, …, listed in `--help`), and --yes accepts
#  every default, for running it from automation.
# ══════════════════════════════════════════════════════════════════════════════
set -euo pipefail

INSTALLER_VERSION=1
# Without a registry host: Docker records Docker Hub images by their short name, and
# ECS finds the cached image only under the name it was pulled as.
IMAGE_REPO="${CHAINOS_EDGE_IMAGE:-ziklag/chainos-edge}"
SELF="chainos-edge-nitro.sh"
DEFAULT_CLOUD_URL="https://api.chainos.cloud"
CHAINS="BTC ETH BSC POLYGON AVAX BASE TRON SOL XRP XLM"
EDGE_PORT=8787
export AWS_PAGER=""

# ── output ───────────────────────────────────────────────────────────────────

if [ -t 1 ]; then
  B=$'\033[1m'; D=$'\033[2m'; R=$'\033[31m'; G=$'\033[32m'; Y=$'\033[33m'; C=$'\033[36m'; N=$'\033[0m'
else
  B=""; D=""; R=""; G=""; Y=""; C=""; N=""
fi
say()  { printf '%s\n' "$*"; }
step() { printf '\n%s▸ %s%s\n' "$B$C" "$*" "$N"; }
ok()   { printf '  %s✓%s %s\n' "$G" "$N" "$*"; }
warn() { printf '  %s!%s %s\n' "$Y" "$N" "$*" >&2; }
die()  { printf '\n%s✗ %s%s\n' "$R$B" "$*" "$N" >&2; exit 1; }
hint() { printf '    %s%s%s\n' "$D" "$*" "$N"; }

# Prompts read from the terminal, so `curl … | bash` works too.
TTY=/dev/tty
YES=0
have_tty() { [ -r "$TTY" ] && { : < "$TTY"; } 2>/dev/null; }
to_tty() { if have_tty; then printf "$@" > "$TTY"; fi; }

# ask VAR "question" "default" — CHAINOS_<VAR> in the environment wins.
# Locals are __-prefixed: printf -v assigns in the caller's scope, and a caller
# asking for a variable that shares a name with one of these would get nothing.
ask() {
  local __var="$1" __q="$2" __def="${3:-}" __env="CHAINOS_$1" __ans=""
  if [ -n "${!__env:-}" ]; then
    printf -v "$__var" '%s' "${!__env}"
    printf '  %s %s%s%s\n' "$__q" "$D" "${!__env}" "$N"
    return
  fi
  if [ "$YES" = 1 ] && [ -n "$__def" ]; then
    printf -v "$__var" '%s' "$__def"
    printf '  %s %s%s%s\n' "$__q" "$D" "$__def" "$N"
    return
  fi
  have_tty || die "no terminal to ask '$__q'; set $__env"
  if [ -n "$__def" ]; then
    printf '  %s %s[%s]%s ' "$__q" "$D" "$__def" "$N" > "$TTY"
  else
    printf '  %s ' "$__q" > "$TTY"
  fi
  IFS= read -r __ans < "$TTY" || true
  printf -v "$__var" '%s' "${__ans:-$__def}"
}

ask_secret() {
  local __var="$1" __q="$2" __env="CHAINOS_$1" __ans=""
  if [ -n "${!__env:-}" ]; then printf -v "$__var" '%s' "${!__env}"; return; fi
  have_tty || die "no terminal to ask '$__q'; set $__env"
  printf '  %s ' "$__q" > "$TTY"
  IFS= read -rs __ans < "$TTY" || true
  printf '\n' > "$TTY"
  printf -v "$__var" '%s' "$__ans"
}

confirm() {
  local question="$1" default="${2:-y}" answer=""
  [ "$YES" = 1 ] && return 0
  have_tty || die "no terminal to confirm '$question'; pass --yes"
  if [ "$default" = y ]; then printf '  %s [Y/n] ' "$question" > "$TTY"
  else printf '  %s [y/N] ' "$question" > "$TTY"; fi
  IFS= read -r answer < "$TTY" || true
  answer="${answer:-$default}"
  case "$answer" in [Yy]*) return 0 ;; *) return 1 ;; esac
}

# choose VAR "question" default-index line… — prints a numbered menu.
choose() {
  local __cvar="$1" __cq="$2" __cdef="$3"; shift 3
  local __i=1 __line __pick
  for __line in "$@"; do printf '    %s%2d)%s %s\n' "$B" "$__i" "$N" "$__line"; __i=$((__i + 1)); done
  while :; do
    ask __pick "$__cq" "$__cdef"
    if [[ "$__pick" =~ ^[0-9]+$ ]] && [ "$__pick" -ge 1 ] && [ "$__pick" -le $# ]; then
      printf -v "$__cvar" '%s' "$__pick"; return
    fi
    warn "pick a number from 1 to $#"
    YES=0
  done
}

# ── AWS CLI ──────────────────────────────────────────────────────────────────

ensure_aws_cli() {
  step "AWS CLI"
  export PATH="$HOME/.local/bin:$PATH"
  if command -v aws >/dev/null 2>&1 && aws --version 2>&1 | grep -q '^aws-cli/2'; then
    ok "$(aws --version 2>&1 | cut -d' ' -f1)"
    return
  fi
  if command -v aws >/dev/null 2>&1; then
    warn "found $(aws --version 2>&1 | cut -d' ' -f1); this installer needs AWS CLI v2"
  else
    say "  The AWS CLI is not installed."
  fi
  confirm "Install AWS CLI v2 into $HOME/.local (no sudo needed)?" y ||
    die "install AWS CLI v2 (https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) and re-run"
  local tmp; tmp="$(mktemp -d)"
  case "$(uname -s)" in
    Darwin)
      curl -fsSL https://awscli.amazonaws.com/AWSCLIV2.pkg -o "$tmp/AWSCLIV2.pkg"
      mkdir -p "$HOME/.local/bin"
      cat > "$tmp/choices.xml" <<XML
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><array><dict>
  <key>choiceAttribute</key><string>customLocation</string>
  <key>attributeSetting</key><string>$HOME/.local</string>
  <key>choiceIdentifier</key><string>default</string>
</dict></array></plist>
XML
      installer -pkg "$tmp/AWSCLIV2.pkg" -target CurrentUserHomeDirectory \
        -applyChoiceChangesXML "$tmp/choices.xml" >/dev/null
      ln -sf "$HOME/.local/aws-cli/aws" "$HOME/.local/bin/aws"
      ln -sf "$HOME/.local/aws-cli/aws_completer" "$HOME/.local/bin/aws_completer"
      ;;
    Linux)
      local arch; arch="$(uname -m)"
      case "$arch" in x86_64|aarch64) ;; arm64) arch=aarch64 ;; *) die "no AWS CLI build for $arch" ;; esac
      curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-$arch.zip" -o "$tmp/awscli.zip"
      if command -v unzip >/dev/null 2>&1; then unzip -q "$tmp/awscli.zip" -d "$tmp"
      elif command -v python3 >/dev/null 2>&1; then python3 -m zipfile -e "$tmp/awscli.zip" "$tmp"
        chmod -R u+x "$tmp/aws"
      else die "need unzip or python3 to unpack the AWS CLI"; fi
      "$tmp/aws/install" -i "$HOME/.local/aws-cli" -b "$HOME/.local/bin" --update >/dev/null
      ;;
    *) die "unsupported OS $(uname -s); install AWS CLI v2 yourself and re-run" ;;
  esac
  command -v aws >/dev/null 2>&1 || die "the AWS CLI did not install"
  ok "installed $(aws --version 2>&1 | cut -d' ' -f1) — add $HOME/.local/bin to your PATH to keep it"
}

# ── credentials and region ───────────────────────────────────────────────────

ACCOUNT=""; CALLER_ARN=""; PRINCIPAL_ARN=""

choose_credentials() {
  step "AWS credentials"
  if [ -n "${AWS_ACCESS_KEY_ID:-}" ] || [ -n "${AWS_PROFILE:-}" ] && [ -z "${CHAINOS_PROFILE:-}" ]; then
    if identity_ok; then
      say "  Using the credentials in this environment: $B$CALLER_ARN$N"
      confirm "Install into account $ACCOUNT with these?" y && return
      unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_PROFILE
    fi
  fi
  local profiles=() line
  while IFS= read -r line; do [ -n "$line" ] && profiles+=("$line"); done \
    < <(aws configure list-profiles 2>/dev/null || true)
  # Credentials that already work with no profile named: an instance role, AWS
  # CloudShell, a default profile, an SSO session. Offered first when present.
  local default_arn="" offset=0
  if [ -z "${CHAINOS_PROFILE:-}" ] || [ "${CHAINOS_PROFILE:-}" = default ]; then
    identity_ok && default_arn="$CALLER_ARN" && offset=1
  fi
  local options=() p
  [ -n "$default_arn" ] && options+=("the default credentials: $default_arn")
  for p in ${profiles[@]+"${profiles[@]}"}; do options+=("profile: $p"); done
  options+=("set up a new profile with an access key" "set up a new profile with AWS IAM Identity Center (SSO)")
  local pick
  if [ "${CHAINOS_PROFILE:-}" = default ]; then
    [ -n "$default_arn" ] || die "CHAINOS_PROFILE=default, and no default credentials work here"
  elif [ -n "${CHAINOS_PROFILE:-}" ]; then
    export AWS_PROFILE="$CHAINOS_PROFILE"
  else
    say "  Which credentials should create the resources?"
    choose pick "Choice:" 1 "${options[@]}"
    local n=${#profiles[@]}
    if [ "$offset" = 1 ] && [ "$pick" = 1 ]; then
      :
    elif pick=$((pick - offset)) && [ "$pick" -le "$n" ]; then
      export AWS_PROFILE="${profiles[$((pick - 1))]}"
    else
      local name; ask name "Name for the new profile:" "chainos-installer"
      if [ "$pick" -eq $((n + 1)) ]; then
        hint "Use an IAM user or role with administrator access for the install; it can be removed after."
        aws configure --profile "$name" < "$TTY"
      else
        aws configure sso --profile "$name" < "$TTY"
      fi
      export AWS_PROFILE="$name"
    fi
  fi
  if ! identity_ok; then
    if aws configure get sso_start_url >/dev/null 2>&1 || aws configure get sso_session >/dev/null 2>&1; then
      say "  Signing in to AWS IAM Identity Center…"
      aws sso login < "$TTY" || true
    fi
    identity_ok || die "these credentials do not work: $(aws sts get-caller-identity 2>&1 | tail -1)"
  fi
  ok "account $ACCOUNT as $CALLER_ARN"
}

identity_ok() {
  local out
  out="$(aws sts get-caller-identity --query '[Account,Arn]' --output text 2>/dev/null)" || return 1
  ACCOUNT="${out%%	*}"; CALLER_ARN="${out#*	}"
  case "$CALLER_ARN" in
    arn:aws*:sts::*:assumed-role/*)
      local role="${CALLER_ARN#*:assumed-role/}"; role="${role%%/*}"
      PRINCIPAL_ARN="$(aws iam get-role --role-name "$role" --query Role.Arn --output text 2>/dev/null ||
        echo "arn:aws:iam::$ACCOUNT:role/$role")" ;;
    arn:aws*:iam::*:user/*) PRINCIPAL_ARN="$CALLER_ARN" ;;
    *:root) PRINCIPAL_ARN="" ;;
    *) PRINCIPAL_ARN="" ;;
  esac
  return 0
}

choose_region() {
  local default="${AWS_REGION:-$(aws configure get region 2>/dev/null || true)}"
  ask REGION "AWS region:" "${default:-eu-west-1}"
  [[ "$REGION" =~ ^[a-z]{2}(-gov)?-[a-z]+-[0-9]$ ]] || die "'$REGION' is not a region name"
  export AWS_REGION="$REGION" AWS_DEFAULT_REGION="$REGION"
  aws ec2 describe-availability-zones --query 'AvailabilityZones[0].ZoneName' --output text >/dev/null 2>&1 ||
    die "cannot use $REGION with these credentials (is the region enabled for the account?)"
}

# ── names: everything derives from the deployment name ───────────────────────

set_names() {
  BUCKET="$NAME-$ACCOUNT-$REGION"
  ROLE_INSTANCE="$NAME-instance"; ROLE_TASK="$NAME-task"; ROLE_EXEC="$NAME-exec"
  PROFILE_INSTANCE="$NAME-instance"
  ALIAS_SEAL="alias/$NAME-seal"; ALIAS_RECOVERY="alias/$NAME-recovery"
  PARAM_API_KEY="/$NAME/api-key"
  LOG_GROUP="/$NAME"
  CLUSTER="$NAME"; SERVICE="edge"; FAMILY="$NAME"
  SG_NAME="$NAME-edge"; LT_NAME="$NAME"; ASG_NAME="$NAME"
  NLB_NAME="$NAME"; TG_NAME="$NAME-api"
}

# ── the privilege check ──────────────────────────────────────────────────────

check_privileges() {
  step "Checking your permissions"
  if [ -z "$PRINCIPAL_ARN" ]; then
    warn "you are signed in as the account root user. It works, but use an administrator role instead."
    return
  fi
  local actions="kms:CreateKey kms:PutKeyPolicy kms:CreateAlias kms:TagResource kms:DescribeKey
    iam:CreateRole iam:GetRole iam:PutRolePolicy iam:AttachRolePolicy iam:CreateInstanceProfile
    iam:AddRoleToInstanceProfile iam:PassRole iam:CreateServiceLinkedRole
    ec2:DescribeVpcs ec2:DescribeSubnets ec2:DescribeRouteTables ec2:CreateSecurityGroup
    ec2:AuthorizeSecurityGroupIngress ec2:CreateLaunchTemplate ec2:CreateLaunchTemplateVersion
    ec2:RunInstances ec2:CreateTags ec2:DescribeInstanceTypes
    autoscaling:CreateAutoScalingGroup autoscaling:UpdateAutoScalingGroup
    ecs:CreateCluster ecs:RegisterTaskDefinition ecs:CreateService ecs:UpdateService ecs:DescribeServices
    s3:CreateBucket s3:PutBucketPolicy s3:PutBucketVersioning s3:PutEncryptionConfiguration
    s3:PutBucketPublicAccessBlock s3:PutObject s3:GetObject s3:ListBucket
    ssm:PutParameter ssm:GetParameter logs:CreateLogGroup logs:PutRetentionPolicy logs:FilterLogEvents"
  if [ "${WANT_NLB:-y}" = y ]; then
    actions="$actions elasticloadbalancing:CreateLoadBalancer elasticloadbalancing:CreateTargetGroup
      elasticloadbalancing:CreateListener autoscaling:AttachLoadBalancerTargetGroups"
  fi
  local denied
  # shellcheck disable=SC2086
  if ! denied="$(aws iam simulate-principal-policy --policy-source-arn "$PRINCIPAL_ARN" \
      --action-names $actions \
      --context-entries "ContextKeyName=aws:RequestedRegion,ContextKeyValues=$REGION,ContextKeyType=string" \
      --query 'EvaluationResults[?EvalDecision!=`allowed`].EvalActionName' --output text 2>&1)"; then
    warn "could not check permissions ($(printf '%s' "$denied" | tail -1 | cut -c1-160))."
    hint "That needs iam:SimulatePrincipalPolicy. The install will stop at the first missing permission instead."
    confirm "Continue anyway?" y || exit 1
    return
  fi
  if [ -z "$denied" ] || [ "$denied" = None ]; then
    ok "$PRINCIPAL_ARN has every permission the install uses"
    return
  fi
  warn "these permissions are missing for $PRINCIPAL_ARN:"
  local a; for a in $denied; do hint "$a"; done
  hint "Service control policies and permission boundaries can also deny; the simulation sees boundaries only."
  hint "An administrator role (AdministratorAccess) covers all of them."
  confirm "Continue anyway? It will fail at the first one it needs." n || exit 1
}

# ── release ──────────────────────────────────────────────────────────────────

choose_version() {
  local latest=""
  local first="${IMAGE_REPO%%/*}"
  if [ "$first" = docker.io ] || { [ "${first#*.}" = "$first" ] && [ "${first#*:}" = "$first" ]; }; then
    local repo="${IMAGE_REPO#docker.io/}"
    latest="$(curl -fsSL "https://hub.docker.com/v2/repositories/$repo/tags?page_size=50&ordering=last_updated" 2>/dev/null |
      grep -o '"name": *"[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/' || true)"
  fi
  ask VERSION "chainos-edge release to install:" "${latest:-}"
  [[ "$VERSION" =~ ^[0-9A-Za-z][0-9A-Za-z._-]*$ ]] || die "'$VERSION' is not a release tag"
  if [ "$VERSION" = latest ]; then die "pin a release number, not 'latest': the enclave measurement is per release"; fi
}

# ── networking ───────────────────────────────────────────────────────────────

choose_network() {
  step "Network"
  local vpcs=() rows line
  rows="$(aws ec2 describe-vpcs --query 'Vpcs[].[VpcId,CidrBlock,IsDefault,Tags[?Key==`Name`]|[0].Value]' --output text)"
  [ -n "$rows" ] || die "no VPC in $REGION; create one first"
  local labels=() default=1 i=1
  while IFS=$'\t' read -r id cidr isdefault tagname; do
    vpcs+=("$id|$cidr")
    labels+=("$id  $cidr  ${tagname/None/}$( [ "$isdefault" = True ] && printf ' (default)')")
    [ "$isdefault" = True ] && default=$i
    i=$((i + 1))
  done <<< "$rows"
  local pick
  if [ -n "${CHAINOS_VPC:-}" ]; then
    VPC="$CHAINOS_VPC"; VPC_CIDR="$(aws ec2 describe-vpcs --vpc-ids "$VPC" --query 'Vpcs[0].CidrBlock' --output text)"
  else
    say "  VPC for the Edge:"
    choose pick "VPC:" "$default" "${labels[@]}"
    VPC="${vpcs[$((pick - 1))]%%|*}"; VPC_CIDR="${vpcs[$((pick - 1))]#*|}"
  fi

  ask INSTANCE_TYPE "Instance type (enclave-capable, 4+ vCPUs):" "m5.xlarge"
  local support
  support="$(aws ec2 describe-instance-types --instance-types "$INSTANCE_TYPE" \
    --query 'InstanceTypes[0].[NitroEnclavesSupport,VCpuInfo.DefaultVCpus]' --output text 2>/dev/null || true)"
  case "$support" in
    supported*) [ "${support##*	}" -ge 4 ] || die "$INSTANCE_TYPE has ${support##*	} vCPUs; the enclave takes 2 and the host needs 2" ;;
    *) die "$INSTANCE_TYPE does not support Nitro Enclaves in $REGION (try m5.xlarge, m6i.xlarge, c6i.xlarge)" ;;
  esac
  local zones
  zones=" $(aws ec2 describe-instance-type-offerings --location-type availability-zone \
    --filters "Name=instance-type,Values=$INSTANCE_TYPE" --query 'InstanceTypeOfferings[].Location' --output text | tr '\t' ' ') "

  rows="$(aws ec2 describe-subnets --filters "Name=vpc-id,Values=$VPC" \
    --query 'Subnets[].[SubnetId,AvailabilityZone,CidrBlock,Tags[?Key==`Name`]|[0].Value]' --output text)"
  local subnets=() slabels=()
  while IFS=$'\t' read -r id az cidr tagname; do
    [ -n "$id" ] || continue
    case "$zones" in *" $az "*) ;; *) continue ;; esac
    local egress; egress="$(subnet_egress "$id")"
    subnets+=("$id|$cidr|$egress")
    slabels+=("$id  $az  $cidr  ${tagname/None/}  — $egress")
  done <<< "$rows"
  [ ${#subnets[@]} -gt 0 ] || die "no subnet in $VPC is in a zone that offers $INSTANCE_TYPE"

  local picks
  if [ -n "${CHAINOS_SUBNETS:-}" ]; then
    picks=""
    local s j; for s in ${CHAINOS_SUBNETS//,/ }; do
      for j in "${!subnets[@]}"; do [ "${subnets[$j]%%|*}" = "$s" ] && picks="$picks $((j + 1))"; done
    done
  else
    say "  Subnets (the instance is placed in one; give two or more for a replacement to use another zone):"
    i=1; for line in "${slabels[@]}"; do printf '    %s%2d)%s %s\n' "$B" "$i" "$N" "$line"; i=$((i + 1)); done
    local first=1 p
    for p in "${!subnets[@]}"; do case "${subnets[$p]##*|}" in private*|public*) first=$((p + 1)); break ;; esac; done
    ask picks "Subnet numbers, comma-separated:" "$first"
    picks="${picks//,/ }"
  fi
  SUBNETS=""; SUBNET_CIDRS=""; EGRESS=""
  local k
  for k in $picks; do
    [[ "$k" =~ ^[0-9]+$ ]] && [ "$k" -ge 1 ] && [ "$k" -le ${#subnets[@]} ] || die "no subnet number $k"
    local entry="${subnets[$((k - 1))]}" kind
    kind="${entry##*|}"
    SUBNETS="${SUBNETS:+$SUBNETS,}${entry%%|*}"
    entry="${entry#*|}"; SUBNET_CIDRS="$SUBNET_CIDRS ${entry%%|*}"
    if [ -n "$EGRESS" ] && [ "${EGRESS%% *}" != "${kind%% *}" ]; then
      die "pick subnets of one kind: all public or all private"
    fi
    EGRESS="$kind"
  done
  [ -n "$SUBNETS" ] || die "no subnet chosen"
  case "$EGRESS" in
    public*) PUBLIC_IP=true
      hint "Public subnet: the instance gets a public IP for outbound traffic only. Nothing inbound is opened to the internet." ;;
    private*) PUBLIC_IP=false ;;
    *) PUBLIC_IP=false
      warn "these subnets have no route to the internet. The Edge needs outbound HTTPS to Docker Hub,"
      hint "S3, KMS, CloudWatch Logs, ECS and ChainOS Cloud. Without a NAT gateway or proxy it will not start."
      confirm "Continue anyway?" n || exit 1 ;;
  esac
  ok "VPC $VPC, subnets $SUBNETS"

  say ""
  say "  The Edge's local API (port $EDGE_PORT) is what your application calls. It is never opened to the internet."
  ask ALLOWED_CIDR "Network allowed to reach it (CIDR):" "$VPC_CIDR"
  [[ "$ALLOWED_CIDR" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]] || die "'$ALLOWED_CIDR' is not an IPv4 CIDR"
  [ "$ALLOWED_CIDR" != "0.0.0.0/0" ] || die "refusing 0.0.0.0/0: the local API must stay private"
  ask WANT_NLB_ANSWER "Create an internal load balancer for a stable private DNS name (~USD 18/month)? [y/n]" "y"
  case "$WANT_NLB_ANSWER" in [Nn]*) WANT_NLB=n ;; *) WANT_NLB=y ;; esac
}

# public (internet gateway), private (NAT/TGW/other default route) or isolated.
subnet_egress() {
  local rt
  rt="$(aws ec2 describe-route-tables --filters "Name=association.subnet-id,Values=$1" \
    --query 'RouteTables[0].Routes[?DestinationCidrBlock==`0.0.0.0/0`]|[0].[GatewayId,NatGatewayId,TransitGatewayId,NetworkInterfaceId]' --output text 2>/dev/null)"
  if [ -z "$rt" ] || [ "$rt" = None ]; then
    rt="$(aws ec2 describe-route-tables --filters "Name=vpc-id,Values=$VPC" "Name=association.main,Values=true" \
      --query 'RouteTables[0].Routes[?DestinationCidrBlock==`0.0.0.0/0`]|[0].[GatewayId,NatGatewayId,TransitGatewayId,NetworkInterfaceId]' --output text 2>/dev/null)"
  fi
  case "$rt" in
    igw-*) echo "public (internet gateway)" ;;
    *nat-*|*tgw-*|*eni-*) echo "private (NAT)" ;;
    *) echo "isolated (no internet route)" ;;
  esac
}

# ── the Edge's own settings ──────────────────────────────────────────────────

# ── an existing sealed seed, or a new one ───────────────────────────────────
#
# A seed sealed by an earlier install is a wallet with addresses and possibly
# funds. Generating a new one beside it by accident would be the costliest
# mistake this script can make, so it looks first, lists what it finds, and
# makes "new" an explicit choice.

# envelope_field FILE KEY — one top-level string field of a sealed envelope.
envelope_field() { sed -n "s/^ *\"$2\": *\"\([^\"]*\)\".*/\1/p" "$1" | head -1; }

SEED_NAMES=(); SEED_LINES=()
discover_seeds() {
  local rows alias key name tag meta state created deletion bucket where identity environment
  rows="$(aws kms list-aliases --query 'Aliases[?ends_with(AliasName, `-seal`)].[AliasName,TargetKeyId]' \
    --output text 2>/dev/null || true)"
  while IFS=$'\t' read -r alias key; do
    [ -n "$alias" ] && [ -n "$key" ] && [ "$key" != None ] || continue
    name="${alias#alias/}"; name="${name%-seal}"
    [[ "$name" =~ ^[a-z][a-z0-9-]{2,23}$ ]] || continue
    # Only keys this installer made: tagged with their deployment, and with
    # the recovery key beside them.
    tag="$(aws kms list-resource-tags --key-id "$key" \
      --query 'Tags[?TagKey==`chainos:deployment`].TagValue | [0]' --output text 2>/dev/null || true)"
    [ "$tag" = "$name" ] || continue
    aws kms describe-key --key-id "alias/$name-recovery" >/dev/null 2>&1 || continue
    meta="$(aws kms describe-key --key-id "$key" \
      --query 'KeyMetadata.[KeyState,CreationDate,DeletionDate]' --output text 2>/dev/null || true)"
    IFS=$'\t' read -r state created deletion <<< "$meta"
    bucket="$name-$ACCOUNT-$REGION"
    identity="?"; environment="?"; where="envelopes not in AWS — you will need the saved copies"
    if aws s3 cp "s3://$bucket/sealed/primary.json" "$SCRATCH/found-$name.json" --only-show-errors 2>/dev/null; then
      identity="$(envelope_field "$SCRATCH/found-$name.json" identityFingerprint)"
      environment="$(sed -n 's/^ *"environment": *"\([a-z]*\)".*/\1/p' "$SCRATCH/found-$name.json" | head -1)"
      where="envelopes in s3://$bucket"
    fi
    case "$state" in
      Enabled) state="enabled" ;;
      PendingDeletion) state="${R}pending deletion on ${deletion%%T*}${N}" ;;
      *) state="${Y}$(printf '%s' "$state" | tr '[:upper:]' '[:lower:]')${N}" ;;
    esac
    SEED_NAMES+=("$name")
    SEED_LINES+=("$B$name$N  key ${key:0:8}…  $state  $environment  identity ${identity:0:8}…  created ${created%%T*}
          $D$where$N")
  done <<< "$rows"
}

SEED_MODE=new
choose_seed() {
  step "Recovery phrase"
  say "  Looking for seeds sealed by earlier installs in ${ACCOUNT} / ${REGION}…"
  discover_seeds
  local pick n=${#SEED_NAMES[@]}
  if [ -n "${CHAINOS_SEED:-}" ]; then
    if [ "$CHAINOS_SEED" = new ]; then pick=$((n + 1))
    else
      pick=""; local i
      for i in "${!SEED_NAMES[@]}"; do [ "${SEED_NAMES[$i]}" = "$CHAINOS_SEED" ] && pick=$((i + 1)); done
      [ -n "$pick" ] || die "CHAINOS_SEED=$CHAINOS_SEED: no sealed seed of that name in $REGION"
    fi
  elif [ "$n" -eq 0 ]; then
    ok "none found: a new seed will be generated"
    pick=1
  else
    say "  Found $n. Using one keeps its wallet — the same addresses and funds:"
    # Reusing is the default: generating is the choice that cannot be undone
    # by choosing again.
    choose pick "Use which?" 1 "${SEED_LINES[@]}" \
      "Generate a new seed — a new wallet with new addresses, beside the ones above"
  fi
  if [ "$pick" -le "$n" ]; then
    SEED_MODE=existing
    NAME="${SEED_NAMES[$((pick - 1))]}"
    set_names
    ok "using the seed of deployment $NAME"
    adopt_existing_seed
  else
    SEED_MODE=new
    ask NAME "Name for the new deployment (used for every resource):" \
      "$( [ "$n" -eq 0 ] && echo chainos-edge || echo "chainos-edge-$((n + 1))")"
    [[ "$NAME" =~ ^[a-z][a-z0-9-]{2,23}$ ]] || die "the name must be 3-24 characters: lower-case letters, digits, hyphens"
    set_names
    local existing; existing="$(key_arn "$ALIAS_SEAL")"
    if [ -n "$existing" ] && [ "$existing" != None ]; then
      die "$ALIAS_SEAL already exists: deployment $NAME already has a seed. Choose it from the list, or pick another name"
    fi
  fi
}

# adopt_existing_seed checks the chosen seed can be used, and gathers what the
# install needs from it. Nothing is changed until the summary is confirmed.
EXISTING_STATE=""; LOCAL_PRIMARY=""; LOCAL_RECOVERY=""; EXISTING_ENV=""; EXISTING_IDENTITY=""
adopt_existing_seed() {
  local seal recovery
  seal="$(key_arn "$ALIAS_SEAL")"; recovery="$(key_arn "$ALIAS_RECOVERY")"
  EXISTING_STATE="$(aws kms describe-key --key-id "$seal" --query KeyMetadata.KeyState --output text)"
  local recovery_state; recovery_state="$(aws kms describe-key --key-id "$recovery" --query KeyMetadata.KeyState --output text)"
  if [ "$EXISTING_STATE" = PendingDeletion ] || [ "$recovery_state" = PendingDeletion ]; then
    warn "this seed's keys are scheduled for deletion. Using it cancels the deletion and re-enables them."
    confirm "Cancel the deletion and use this seed?" y || exit 1
    EXISTING_STATE=PendingDeletion
  fi
  local envelope="$SCRATCH/existing-primary.json"
  if s3_exists sealed/primary.json; then
    aws s3 cp "s3://$BUCKET/sealed/primary.json" "$envelope" --only-show-errors
    s3_exists sealed/recovery.json || warn "s3://$BUCKET has no recovery envelope. Keep your saved copy safe."
  else
    say "  The envelopes for $NAME are not in AWS any more (its bucket was removed)."
    say "  The installer saved copies when $NAME was installed, in ./$NAME-install/."
    ask LOCAL_PRIMARY "Path to primary.sealed.json:" "./$NAME-install/primary.sealed.json"
    ask LOCAL_RECOVERY "Path to recovery.sealed.json:" "./$NAME-install/recovery.sealed.json"
    [ -r "$LOCAL_PRIMARY" ] || die "cannot read $LOCAL_PRIMARY"
    [ -r "$LOCAL_RECOVERY" ] || die "cannot read $LOCAL_RECOVERY"
    [ "$(envelope_field "$LOCAL_RECOVERY" keyRef)" = "$recovery" ] ||
      die "$LOCAL_RECOVERY was not sealed under $ALIAS_RECOVERY"
    cp "$LOCAL_PRIMARY" "$envelope"
  fi
  [ "$(envelope_field "$envelope" provider)" = nitro ] || die "that envelope is not a nitro envelope"
  [ "$(envelope_field "$envelope" keyRef)" = "$seal" ] || die "that envelope was not sealed under $ALIAS_SEAL"
  EXISTING_IDENTITY="$(envelope_field "$envelope" identityFingerprint)"
  EXISTING_ENV="$(sed -n 's/^ *"environment": *"\([a-z]*\)".*/\1/p' "$envelope" | head -1)"
  ok "identity $EXISTING_IDENTITY, sealed for $EXISTING_ENV"
  # Earlier answers, so the questions below default to them.
  local cfg; cfg="$(s3_text install/config.env 2>/dev/null || true)"
  [ -z "$cfg" ] || PREVIOUS_RECOVERY="$(printf '%s\n' "$cfg" | sed -n 's/^RECOVERY_PRINCIPAL=//p')"
  if [ -z "${PREVIOUS_RECOVERY:-}" ]; then
    PREVIOUS_RECOVERY="$(aws kms get-key-policy --key-id "$recovery" --policy-name default --query Policy \
      --output text 2>/dev/null | tr -d ' \n' |
      sed -n 's/.*"Sid":"BreakGlassRecoveryOnly"[^}]*"AWS":"\([^"]*\)".*/\1/p' || true)"
  fi
  PCRS="$(printf '%s\n' "$cfg" | sed -n 's/^PCRS=//p')"
}

# restore_existing_seed makes the chosen seed usable again: keys enabled, and
# the envelopes back in the bucket if they had to come from saved copies.
restore_existing_seed() {
  [ "$SEED_MODE" = existing ] || return 0
  local alias key
  for alias in "$ALIAS_SEAL" "$ALIAS_RECOVERY"; do
    # CancelKeyDeletion and EnableKey take a key ID or ARN, never an alias.
    key="$(key_arn "$alias")"
    case "$(aws kms describe-key --key-id "$key" --query KeyMetadata.KeyState --output text)" in
      PendingDeletion) aws kms cancel-key-deletion --key-id "$key" >/dev/null
        aws kms enable-key --key-id "$key"; ok "$alias: deletion cancelled, enabled" ;;
      Disabled) aws kms enable-key --key-id "$key"; ok "$alias: enabled" ;;
    esac
  done
  if [ -n "$LOCAL_PRIMARY" ]; then
    s3_exists sealed/recovery.json ||
      aws s3 cp "$LOCAL_RECOVERY" "s3://$BUCKET/sealed/recovery.json" --only-show-errors
    s3_exists sealed/primary.json ||
      aws s3 cp "$LOCAL_PRIMARY" "s3://$BUCKET/sealed/primary.json" --only-show-errors
    ok "envelopes restored to s3://$BUCKET/sealed/ from your saved copies"
  fi
}

choose_edge_settings() {
  step "The Edge"
  choose_version

  say ""
  say "  The Edge's API key, from the ChainOS portal: Settings → API keys (flk_live_… or flk_test_…)."
  while :; do
    ask_secret API_KEY "API key (input hidden):"
    case "$API_KEY" in
      flk_live_*) ENVIRONMENT=live; break ;;
      flk_test_*) ENVIRONMENT=sandbox; break ;;
      *) warn "that is not a flk_live_ or flk_test_ key"; unset CHAINOS_API_KEY ;;
    esac
  done
  ok "$ENVIRONMENT environment"
  if [ "$SEED_MODE" = existing ] && [ "$EXISTING_ENV" != "$ENVIRONMENT" ]; then
    die "the seed of $NAME was sealed for $EXISTING_ENV, and this API key is for $ENVIRONMENT. Use a matching key"
  fi
  ask CLOUD_URL "ChainOS Cloud URL:" "$DEFAULT_CLOUD_URL"
  [[ "$API_KEY" =~ ^flk_(live|test)_[A-Za-z0-9_-]+$ ]] || die "the API key has unexpected characters"
  [[ "$CLOUD_URL" =~ ^https?://[A-Za-z0-9.:/_-]+$ ]] || die "'$CLOUD_URL' is not a URL"
  LABEL=primary
}

choose_policy() {
  if [ "$SEED_MODE" = existing ]; then
    step "Autonomous-spend policy"
    ok "sealed with the existing seed; it is kept as it is (the Edge logs it at start)"
    hint "To change it, reseal from the recovery copy — see the guided installer page in the docs."
    POLICY_JSON="(sealed with the existing seed)"
    return
  fi
  step "Autonomous-spend policy (sealed with the seed, enforced inside the enclave)"
  say "  Withdrawals, sweeps and consolidations are signed on request and need no policy here."
  say "  These caps cover what the Edge signs ${B}on its own${N}: gas top-ups of deposit addresses and"
  say "  payment forwards. They are sealed with the seed and can only be changed by resealing."
  if [ -n "${CHAINOS_POLICY_FILE:-}" ]; then
    POLICY_JSON="$(cat "$CHAINOS_POLICY_FILE")"
    ok "policy from $CHAINOS_POLICY_FILE"
    return
  fi
  local caps window allowlist forwarders
  hint "Format: CHAIN=amount in the chain's smallest unit, comma-separated — e.g. ETH=50000000000000000,TRON=20000000"
  hint "Chains: $CHAINS. Empty: no autonomous spending."
  ask caps "Spend caps:" "none"
  [ "$caps" = none ] && caps=""
  local caps_json="" pair chain amount
  for pair in ${caps//,/ }; do
    chain="$(printf '%s' "${pair%%=*}" | tr '[:lower:]' '[:upper:]')"; amount="${pair#*=}"
    case " $CHAINS " in *" $chain "*) ;; *) die "unknown chain '$chain'" ;; esac
    [[ "$amount" =~ ^[0-9]+$ ]] || die "the cap for $chain must be a whole number of the smallest unit"
    caps_json="${caps_json:+$caps_json, }\"$chain\": \"$amount\""
  done
  window=24
  [ -n "$caps_json" ] && ask window "Spend window in hours:" "24"
  [[ "$window" =~ ^[0-9]+$ ]] && [ "$window" -ge 1 ] || die "the window must be a whole number of hours"
  hint "Sweep treasury allowlist: external addresses scheduled sweeps may send to (empty: none)."
  ask allowlist "Treasury addresses, comma-separated:" "none"
  [ "$allowlist" = none ] && allowlist=""
  local list_json="" a
  for a in ${allowlist//,/ }; do
    [[ "$a" =~ ^[A-Za-z0-9:._-]{20,128}$ ]] || die "'$a' does not look like an address"
    list_json="${list_json:+$list_json, }\"$a\""
  done
  forwarders="${CHAINOS_FORWARDERS:-}"
  [[ "$forwarders" =~ ^[A-Za-z0-9=,\;x]*$ ]] || die "CHAINOS_FORWARDERS has unexpected characters"
  POLICY_JSON="{
  \"spendCaps\": {${caps_json}},
  \"spendWindowSeconds\": $((window * 3600)),
  \"treasuryAllowlist\": [${list_json}],
  \"forwarderContracts\": \"${forwarders}\"
}"
}

choose_recovery() {
  step "Recovery"
  say "  The recovery phrase is generated on the instance and never shown to anyone. A second copy"
  say "  is sealed under a separate KMS key: it is the ${B}only backup${N}. If the sealing key, the"
  say "  enclave image or the instance is ever lost, the recovery copy restores the wallet."
  say "  Name the IAM role or user allowed to open it — a break-glass role, ideally used by nobody day to day."
  if [ "$SEED_MODE" = existing ]; then
    say "  For the existing seed, this is who may open its recovery copy from now on."
  fi
  ask RECOVERY_PRINCIPAL "Recovery principal ARN:" "${PREVIOUS_RECOVERY:-${PRINCIPAL_ARN:-}}"
  [[ "$RECOVERY_PRINCIPAL" =~ ^arn:aws[a-z-]*:iam::[0-9]{12}:(role|user)/[A-Za-z0-9+=,.@/_-]+$ ]] ||
    die "'$RECOVERY_PRINCIPAL' is not an IAM role or user ARN"
  if [ "$RECOVERY_PRINCIPAL" = "$PRINCIPAL_ARN" ]; then
    hint "That is you. Fine to start with; move it to a break-glass role later by editing the key policy of $ALIAS_RECOVERY."
  fi
}

# ── small AWS helpers ────────────────────────────────────────────────────────


retry() { # retry N cmd… — IAM is eventually consistent; KMS and ECS see new roles late
  local n="$1" i=1; shift
  until "$@" 2>"$SCRATCH/retry.err"; do
    [ "$i" -ge "$n" ] && { cat "$SCRATCH/retry.err" >&2; return 1; }
    sleep 5; i=$((i + 1))
  done
}

# A wait loop that only sees empty answers cannot tell "not yet" from "your
# credentials expired" — checked every lap, so it says which.
signed_in() { aws sts get-caller-identity >/dev/null 2>&1; }

key_arn() { aws kms describe-key --key-id "$1" --query KeyMetadata.Arn --output text 2>/dev/null || true; }
s3_exists() { aws s3api head-object --bucket "$BUCKET" --key "$1" >/dev/null 2>&1; }
s3_text() { aws s3 cp "s3://$BUCKET/$1" - 2>/dev/null; }
put_text() { printf '%s\n' "$2" | aws s3 cp - "s3://$BUCKET/$1" --only-show-errors; }

# ── key policies ─────────────────────────────────────────────────────────────
#
# Neither key lets IAM policies grant Decrypt: the account-root statement carries
# administration only. So nobody can be given the seed through an IAM policy —
# only through these statements, and editing them is a CloudTrail event.

admin_statement() {
  cat <<JSON
    {
      "Sid": "AccountAdministrationNoUse",
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::$ACCOUNT:root" },
      "Action": ["kms:Describe*", "kms:Get*", "kms:List*", "kms:PutKeyPolicy", "kms:CreateAlias",
                 "kms:UpdateAlias", "kms:DeleteAlias", "kms:UpdateKeyDescription", "kms:EnableKey",
                 "kms:DisableKey", "kms:TagResource", "kms:UntagResource", "kms:ScheduleKeyDeletion",
                 "kms:CancelKeyDeletion"],
      "Resource": "*"
    }
JSON
}

install_seal_statement() {
  cat <<JSON
    ,{
      "Sid": "InstallerInstanceSealsOnce",
      "Effect": "Allow",
      "Principal": { "AWS": "$INSTANCE_ROLE_ARN" },
      "Action": "kms:GenerateDataKey",
      "Resource": "*",
      "Condition": { "StringEquals": { "kms:EncryptionContext:purpose": "chainos-edge-mnemonic" } }
    }
JSON
}

seal_key_policy() { # $1 = install|final, $2 = JSON list of PCR0 values
  printf '{\n  "Version": "2012-10-17",\n  "Statement": [\n'
  admin_statement
  [ "$1" = install ] && install_seal_statement
  # The recovery principal may make new envelopes (a policy change is a reseal
  # from the recovery copy). GenerateDataKey opens nothing that already exists.
  cat <<JSON
    ,{
      "Sid": "RecoveryPrincipalMayReseal",
      "Effect": "Allow",
      "Principal": { "AWS": "$RECOVERY_PRINCIPAL" },
      "Action": "kms:GenerateDataKey",
      "Resource": "*",
      "Condition": { "StringEquals": { "kms:EncryptionContext:purpose": "chainos-edge-mnemonic" } }
    }
JSON
  if [ -n "${2:-}" ]; then
    cat <<JSON
    ,{
      "Sid": "EdgeUnsealAttestedOnly",
      "Effect": "Allow",
      "Principal": { "AWS": "$TASK_ROLE_ARN" },
      "Action": "kms:Decrypt",
      "Resource": "*",
      "Condition": {
        "StringEqualsIgnoreCase": { "kms:RecipientAttestation:ImageSha384": $2 },
        "StringEquals": {
          "kms:EncryptionContext:purpose": "chainos-edge-mnemonic",
          "kms:EncryptionContext:environment": "$ENVIRONMENT"
        }
      }
    }
JSON
  fi
  cat <<JSON
    ,{
      "Sid": "DenyDecryptWithoutAttestation",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "kms:Decrypt",
      "Resource": "*",
      "Condition": { "Null": { "kms:RecipientAttestation:ImageSha384": "true" } }
    }
  ]
}
JSON
}

recovery_key_policy() { # $1 = install|final
  printf '{\n  "Version": "2012-10-17",\n  "Statement": [\n'
  admin_statement
  [ "$1" = install ] && install_seal_statement
  cat <<JSON
    ,{
      "Sid": "BreakGlassRecoveryOnly",
      "Effect": "Allow",
      "Principal": { "AWS": "$RECOVERY_PRINCIPAL" },
      "Action": "kms:Decrypt",
      "Resource": "*",
      "Condition": { "StringEquals": { "kms:EncryptionContext:purpose": "chainos-edge-mnemonic" } }
    }
  ]
}
JSON
}

# ── the instance side ────────────────────────────────────────────────────────
#
# reconcile.sh runs on the instance as root: at first boot, then every minute.
# It makes the instance match install/desired-version in S3 — pulls the release,
# builds or fetches the EIF, builds the launcher image, and on the very first run
# generates and seals the phrase. Upgrades are a new desired-version.

write_reconcile() {
  cat > "$SCRATCH/reconcile.sh" <<'RECONCILE'
#!/bin/bash
# chainos-edge-nitro — instance reconciler. Fetched from the deployment's bucket on
# every run; the installers upload it. Source: chainos-edge/deploy/nitro/installer/.
set -euo pipefail
. /etc/chainos/install.env
export AWS_REGION="$REGION" AWS_DEFAULT_REGION="$REGION"
exec 9>/run/chainos-reconcile.lock
flock -n 9 || exit 0
TOKEN="$(curl -fsS -X PUT http://169.254.169.254/latest/api/token -H 'X-aws-ec2-metadata-token-ttl-seconds: 60')"
IID="$(curl -fsS -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/instance-id)"
aws s3 cp "s3://$BUCKET/install/config.env" /etc/chainos/config.env --only-show-errors
. /etc/chainos/config.env
VERSION="$(aws s3 cp "s3://$BUCKET/install/desired-version" - | tr -d '[:space:]')"
[[ "$VERSION" =~ ^[0-9A-Za-z][0-9A-Za-z._-]*$ ]] || { echo "bad desired-version" >&2; exit 1; }
REL="/opt/chainos/releases/$VERSION"

# The envelope follows the bucket. A reseal — a new spend policy, say —
# replaces sealed/primary.json, and the Edge's next start must use it. Swapped
# with a rename, never removed, so an Edge starting mid-swap reads one or the
# other. The first copy is made below, with the release.
S=/opt/chainos/sealed
if [ -f "$S/primary.json" ] &&
   aws s3 cp "s3://$BUCKET/sealed/primary.json" "$S/.primary.next" --only-show-errors 2>/dev/null; then
  if cmp -s "$S/.primary.next" "$S/primary.json"; then
    rm -f "$S/.primary.next"
  else
    chmod 0444 "$S/.primary.next" && mv -f "$S/.primary.next" "$S/primary.json"
    echo "envelope updated from s3://$BUCKET/sealed/primary.json; it takes effect at the Edge's next start"
  fi
fi

[ -f "$REL/ready" ] && exit 0

fail() {
  trap - ERR
  echo "reconcile $VERSION failed: $*" >&2
  { echo "$*"; [ -f "$REL/step.log" ] && tail -40 "$REL/step.log"; } |
    aws s3 cp - "s3://$BUCKET/install/failed/$VERSION/$IID.txt" --only-show-errors || true
  exit 1
}
trap 'fail "line $LINENO"' ERR
put_new() { # put_new local key — never overwrites (S3 conditional write, or a check where unsupported)
  if aws s3api put-object help 2>/dev/null | grep -q -- '--if-none-match'; then
    aws s3api put-object --bucket "$BUCKET" --key "$2" --body "$1" --if-none-match '*' >/dev/null
  else
    ! aws s3api head-object --bucket "$BUCKET" --key "$2" >/dev/null 2>&1 &&
      aws s3api put-object --bucket "$BUCKET" --key "$2" --body "$1" >/dev/null
  fi
}
exists() { aws s3api head-object --bucket "$BUCKET" --key "$1" >/dev/null 2>&1; }

mkdir -p "$REL"
: > "$REL/step.log"
systemctl start docker
case "$IMAGE" in
  *.dkr.ecr.*.amazonaws.com/*) # a mirror in your own ECR: the instance role may pull from it
    host="${IMAGE%%/*}"
    aws ecr get-login-password --region "$(echo "$host" | cut -d. -f4)" |
      docker login --username AWS --password-stdin "$host" >>"$REL/step.log" 2>&1 ;;
esac
docker pull -q "$IMAGE:$VERSION" >>"$REL/step.log" 2>&1 || fail "cannot pull $IMAGE:$VERSION"
help="$(docker run --rm "$IMAGE:$VERSION" seal-mnemonic --help 2>&1 || true)"
case "$help" in *--generate*) ;; *)
  fail "chainos-edge $VERSION is older than this installer (no seal-mnemonic --generate); choose a newer release" ;;
esac
cid="$(docker create "$IMAGE:$VERSION")"
docker cp "$cid:/chainos-edge" "$REL/chainos-edge"; docker rm "$cid" >/dev/null

# The EIF. Built once per release and cached, so every instance runs the same
# measured image — a replacement instance with a newer nitro-cli would otherwise
# build a different PCR0, and KMS would refuse it.
if exists "eif/$VERSION/measurements.json"; then
  aws s3 cp "s3://$BUCKET/eif/$VERSION/chainos-edge.eif" "$REL/chainos-edge.eif" --only-show-errors
  aws s3 cp "s3://$BUCKET/eif/$VERSION/measurements.json" "$REL/measurements.json" --only-show-errors
else
  # The enclave image is imported from a tarball rather than built from a
  # Dockerfile. A Dockerfile build stamps directories and layers with the build
  # time, so one release built twice measures differently; a sorted tarball with
  # fixed times and owners measures the same wherever and whenever it is built.
  # And its content is exactly the tarball: nothing cached from an earlier
  # release can end up inside.
  R="$REL/enclave-root"; mkdir -p "$R/etc/ssl/certs"
  cp "$REL/chainos-edge" "$R/chainos-edge"
  aws s3 cp "s3://$BUCKET/install/enclave-ca.crt" "$R/etc/ssl/certs/ca-certificates.crt" --only-show-errors
  chmod 0755 "$R" "$R/etc" "$R/etc/ssl" "$R/etc/ssl/certs" "$R/chainos-edge"
  chmod 0644 "$R/etc/ssl/certs/ca-certificates.crt"
  find "$R" -exec touch -h -d '2000-01-01T00:00:00Z' {} +
  tar --sort=name --mtime='2000-01-01 00:00:00Z' --owner=0 --group=0 --numeric-owner \
    -C "$R" -cf "$REL/enclave-root.tar" .
  docker import --change 'ENV EDGE_NITRO_PORT=5005' --change 'ENTRYPOINT ["/chainos-edge", "enclave-signer"]' \
    "$REL/enclave-root.tar" "chainos-edge-enclave:$VERSION" >>"$REL/step.log" 2>&1 || fail "enclave image import"
  echo "enclave image $(docker image inspect --format '{{.Id}}' "chainos-edge-enclave:$VERSION") from binary $(sha256sum "$R/chainos-edge" | cut -d' ' -f1)" >> "$REL/step.log"
  # nitro-cli needs somewhere to write its intermediate artifacts when not run
  # from a login shell, and refuses (E51) without it.
  export NITRO_CLI_ARTIFACTS="$REL/artifacts" HOME="$REL/home"; mkdir -p "$NITRO_CLI_ARTIFACTS" "$HOME"
  nitro-cli build-enclave --docker-uri "chainos-edge-enclave:$VERSION" \
    --output-file "$REL/chainos-edge.eif" > "$REL/build.json" 2>>"$REL/step.log" || fail "nitro-cli build-enclave"
  python3 - "$REL/build.json" "$REL/measurements.json" <<'PY'
import json, sys
text = open(sys.argv[1]).read()
measurements = json.loads(text[text.index("{"):text.rindex("}") + 1])["Measurements"]
json.dump(measurements, open(sys.argv[2], "w"), indent=2)
PY
  if put_new "$REL/chainos-edge.eif" "eif/$VERSION/chainos-edge.eif"; then
    put_new "$REL/measurements.json" "eif/$VERSION/measurements.json"
  else
    # Another instance won the race: use its image, not ours.
    aws s3 cp "s3://$BUCKET/eif/$VERSION/chainos-edge.eif" "$REL/chainos-edge.eif" --only-show-errors
    aws s3 cp "s3://$BUCKET/eif/$VERSION/measurements.json" "$REL/measurements.json" --only-show-errors
  fi
fi

# The launcher: nitro-cli and the socket bridge. Built locally; ECS uses it
# from the cache (ECS_IMAGE_PULL_BEHAVIOR=prefer-cached).
L="$REL/launcher"; mkdir -p "$L"
cp "$REL/chainos-edge" "$L/chainos-edge"
aws s3 cp "s3://$BUCKET/install/launcher.sh" "$L/launcher.sh" --only-show-errors
cat > "$L/Dockerfile" <<'DOCKERFILE'
FROM amazonlinux:2023
RUN dnf install -y -q aws-nitro-enclaves-cli && dnf clean all \
    && mkdir -p /var/log/nitro_enclaves /run/nitro_enclaves /opt/chainos
COPY chainos-edge /usr/local/bin/chainos-edge
COPY launcher.sh /usr/local/bin/chainos-enclave-launcher
RUN chmod 0555 /usr/local/bin/chainos-edge /usr/local/bin/chainos-enclave-launcher
CMD ["/usr/local/bin/chainos-enclave-launcher"]
DOCKERFILE
docker build -q -t "chainos-edge-launcher:$VERSION" "$L" >>"$REL/step.log" 2>&1 || fail "launcher image build"

# The phrase. Generated here once, in the seal-mnemonic process's locked memory,
# sealed twice and never printed or written in plaintext. Every later run, and
# every replacement instance, downloads the envelope instead.
mkdir -p /opt/chainos/sealed
if exists sealed/primary.json; then
  aws s3 cp "s3://$BUCKET/sealed/primary.json" /opt/chainos/sealed/primary.json --only-show-errors
else
  exists sealed/recovery.json && fail "sealed/recovery.json exists without sealed/primary.json: an earlier \
seal was interrupted. Inspect s3://$BUCKET/sealed/ before retrying, and never delete a recovery envelope \
that may protect funds"
  W="$(mktemp -d /opt/chainos/seal.XXXXXX)"
  aws s3 cp "s3://$BUCKET/install/policy.json" "$W/policy.json" --only-show-errors
  chown 65534:65534 "$W"; chmod 0700 "$W"; chmod 0444 "$W/policy.json"
  if ! docker run --rm --network host -e AWS_REGION="$REGION" -v "$W:/w" "$IMAGE:$VERSION" \
      seal-mnemonic --generate --provider=nitro --key="$SEAL_KEY" --environment="$ENVIRONMENT" \
      --label="$LABEL" --policy=/w/policy.json --out=/w/primary.json \
      --recovery-key="$RECOVERY_KEY" --recovery-out=/w/recovery.json --summary=/w/summary.json \
      </dev/null >>"$REL/step.log" 2>&1; then
    fail "seal-mnemonic failed"
  fi
  # The recovery copy first: a primary without its backup must never exist.
  put_new "$W/recovery.json" sealed/recovery.json || fail "could not store the recovery envelope"
  put_new "$W/summary.json" sealed/summary.json || fail "could not store the seal summary"
  put_new "$W/primary.json" sealed/primary.json || fail "could not store the envelope"
  cp "$W/primary.json" /opt/chainos/sealed/primary.json
  rm -rf "$W"
fi
chmod 0444 /opt/chainos/sealed/primary.json
mkdir -p /var/lib/chainos-data && chown 65534:65534 /var/lib/chainos-data && chmod 0700 /var/lib/chainos-data

# Provenance, for anyone checking what this instance runs against the release.
{
  echo "instance $IID"
  echo "release $IMAGE:$VERSION $(docker image inspect --format '{{.Id}}' "$IMAGE:$VERSION")"
  echo "binary $(sha256sum "$REL/chainos-edge" | cut -d' ' -f1)"
  grep '^enclave image' "$REL/step.log" || true
  echo "eif $(sha256sum "$REL/chainos-edge.eif" | cut -d' ' -f1)"
  grep -o '"PCR0": *"[0-9a-f]*"' "$REL/measurements.json"
} > "$REL/provenance.txt"
touch "$REL/ready"
aws s3 cp "$REL/provenance.txt" "s3://$BUCKET/install/ready/$VERSION/$IID" --only-show-errors
echo "reconciled to $VERSION"
RECONCILE
}

write_launcher() {
  cat > "$SCRATCH/launcher.sh" <<'LAUNCHER'
#!/bin/sh
# Starts the Edge enclave and keeps it running for the life of the task. The only
# privileged piece: nitro-cli needs /dev/nitro_enclaves. The Edge container beside
# it is unprivileged and reaches the enclave through the socket bridged here.
set -eu
EIF="${ENCLAVE_EIF:-/opt/chainos/release/chainos-edge.eif}"
CID="${EDGE_NITRO_CID:-16}"
nitro-cli terminate-enclave --all >/dev/null 2>&1 || true
nitro-cli run-enclave --eif-path "$EIF" --cpu-count "${ENCLAVE_CPUS:-2}" \
  --memory "${ENCLAVE_MEMORY_MIB:-1024}" --enclave-cid "$CID"
if [ -n "${EDGE_NITRO_SOCKET:-}" ]; then
  /usr/local/bin/chainos-edge enclave-bridge &
fi
stop() { nitro-cli terminate-enclave --all; exit 0; }
trap stop TERM INT
while nitro-cli describe-enclaves | grep -q '"State": "RUNNING"'; do
  sleep 5 &
  wait $!
done
echo "the enclave stopped" >&2
exit 1
LAUNCHER
}

write_user_data() {
  { printf '#!/bin/bash\n'
    printf "NAME='%s'\nBUCKET='%s'\nREGION='%s'\nCLUSTER='%s'\n" "$NAME" "$BUCKET" "$REGION" "$CLUSTER"
    cat <<'USERDATA'
# chainos-edge-nitro — instance bootstrap (EC2 user data).
#
# Both installers (chainos-edge-nitro.sh, chainos-edge-nitro.ps1) send this file
# with four assignments prepended: NAME, BUCKET, REGION and CLUSTER. Everything
# else the instance needs comes from the deployment's bucket.
set -euo pipefail
: "${NAME:?}" "${BUCKET:?}" "${REGION:?}" "${CLUSTER:?}"
exec >>/var/log/chainos-install.log 2>&1
mkdir -p /etc/chainos
printf 'BUCKET=%s\nREGION=%s\nNAME=%s\n' "$BUCKET" "$REGION" "$NAME" > /etc/chainos/install.env
echo "ECS_CLUSTER=$CLUSTER" >> /etc/ecs/ecs.config
echo "ECS_IMAGE_PULL_BEHAVIOR=prefer-cached" >> /etc/ecs/ecs.config
dnf install -y -q aws-nitro-enclaves-cli aws-nitro-enclaves-cli-devel python3 awscli-2
sed -i 's/^memory_mib:.*/memory_mib: 1536/; s/^cpu_count:.*/cpu_count: 2/' /etc/nitro_enclaves/allocator.yaml
systemctl enable --now nitro-enclaves-allocator.service
# The enclave's only route out: KMS in this region, over one vsock port. TLS runs
# inside the enclave, so the host relays ciphertext.
cat > /etc/nitro_enclaves/vsock-proxy-kms.yaml <<YAML
allowlist:
- {address: kms.$REGION.amazonaws.com, port: 443}
YAML
cat > /etc/systemd/system/chainos-kms-proxy.service <<UNIT
[Unit]
Description=vsock-proxy to KMS for the ChainOS Edge enclave
After=network-online.target
[Service]
ExecStart=/usr/bin/vsock-proxy 8000 kms.$REGION.amazonaws.com 443 --config /etc/nitro_enclaves/vsock-proxy-kms.yaml
Restart=always
[Install]
WantedBy=multi-user.target
UNIT
aws s3 cp "s3://$BUCKET/install/reconcile.sh" /usr/local/sbin/chainos-reconcile --region "$REGION" --only-show-errors
chmod 0700 /usr/local/sbin/chainos-reconcile
cat > /etc/systemd/system/chainos-reconcile.service <<UNIT
[Unit]
Description=Bring the ChainOS Edge release on this instance to the desired version
After=docker.service network-online.target
[Service]
Type=oneshot
# Fetched each run, so a fix or an upgrade of the installer reaches this instance.
ExecStartPre=/usr/bin/aws s3 cp s3://$BUCKET/install/reconcile.sh /usr/local/sbin/chainos-reconcile --region $REGION --only-show-errors
ExecStartPre=/usr/bin/chmod 0700 /usr/local/sbin/chainos-reconcile
ExecStart=/usr/local/sbin/chainos-reconcile
UNIT
cat > /etc/systemd/system/chainos-reconcile.timer <<UNIT
[Unit]
Description=Reconcile the ChainOS Edge release every minute
[Timer]
OnBootSec=3min
OnUnitActiveSec=1min
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now chainos-kms-proxy.service
/usr/local/sbin/chainos-reconcile || true
systemctl enable --now chainos-reconcile.timer
USERDATA
  } > "$SCRATCH/user-data.sh"
}

# ── the task definition ──────────────────────────────────────────────────────

task_definition() { # $1 = version
  cat <<JSON
{
  "family": "$FAMILY",
  "networkMode": "bridge",
  "requiresCompatibilities": ["EC2"],
  "taskRoleArn": "$TASK_ROLE_ARN",
  "executionRoleArn": "$EXEC_ROLE_ARN",
  "tags": [{"key": "chainos:deployment", "value": "$NAME"}],
  "volumes": [
    {"name": "enclave-socket", "host": {}},
    {"name": "release", "host": {"sourcePath": "/opt/chainos/releases/$1"}},
    {"name": "sealed", "host": {"sourcePath": "/opt/chainos/sealed"}},
    {"name": "data", "host": {"sourcePath": "/var/lib/chainos-data"}}
  ],
  "containerDefinitions": [
    {
      "name": "launcher",
      "image": "chainos-edge-launcher:$1",
      "privileged": true,
      "essential": true,
      "memory": 256,
      "mountPoints": [
        {"sourceVolume": "enclave-socket", "containerPath": "/run/chainos"},
        {"sourceVolume": "release", "containerPath": "/opt/chainos/release", "readOnly": true}
      ],
      "environment": [
        {"name": "EDGE_NITRO_SOCKET", "value": "/run/chainos/enclave.sock"},
        {"name": "ENCLAVE_EIF", "value": "/opt/chainos/release/chainos-edge.eif"},
        {"name": "ENCLAVE_MEMORY_MIB", "value": "1024"},
        {"name": "ENCLAVE_CPUS", "value": "2"}
      ],
      "logConfiguration": {"logDriver": "awslogs", "options": {
        "awslogs-group": "$LOG_GROUP", "awslogs-region": "$REGION", "awslogs-stream-prefix": "launcher"}}
    },
    {
      "name": "edge",
      "image": "$IMAGE_REPO:$1",
      "user": "65534",
      "essential": true,
      "memory": 512,
      "readonlyRootFilesystem": true,
      "stopTimeout": 60,
      "portMappings": [{"containerPort": $EDGE_PORT, "hostPort": $EDGE_PORT, "protocol": "tcp"}],
      "mountPoints": [
        {"sourceVolume": "enclave-socket", "containerPath": "/run/chainos"},
        {"sourceVolume": "sealed", "containerPath": "/etc/chainos/sealed", "readOnly": true},
        {"sourceVolume": "data", "containerPath": "/var/lib/chainos"}
      ],
      "dependsOn": [{"containerName": "launcher", "condition": "START"}],
      "environment": [
        {"name": "EDGE_KEYSTORE", "value": "nitro"},
        {"name": "PASS_PHRASE_SEALED_FILE", "value": "/etc/chainos/sealed/primary.json"},
        {"name": "EDGE_AWSKMS_KEY_ID", "value": "$SEAL_KEY_ARN"},
        {"name": "AWS_REGION", "value": "$REGION"},
        {"name": "EDGE_NITRO_SOCKET", "value": "/run/chainos/enclave.sock"},
        {"name": "EDGE_DATA_DIR", "value": "/var/lib/chainos"},
        {"name": "CHAINOS_CLOUD_URL", "value": "$CLOUD_URL"},
        {"name": "EDGE_POD_HINT", "value": "$NAME"}
      ],
      "secrets": [{"name": "API_KEY", "valueFrom": "arn:aws:ssm:$REGION:$ACCOUNT:parameter$PARAM_API_KEY"}],
      "logConfiguration": {"logDriver": "awslogs", "options": {
        "awslogs-group": "$LOG_GROUP", "awslogs-region": "$REGION", "awslogs-stream-prefix": "edge"}}
    }
  ]
}
JSON
}

# ── install ──────────────────────────────────────────────────────────────────

create_role() { # name trust-service managed-policy…
  local name="$1" service="$2"; shift 2
  if ! aws iam get-role --role-name "$name" >/dev/null 2>&1; then
    aws iam create-role --role-name "$name" --tags "Key=chainos:deployment,Value=$NAME" --assume-role-policy-document \
      "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"$service\"},\"Action\":\"sts:AssumeRole\"}]}" >/dev/null
  fi
  local p; for p in "$@"; do aws iam attach-role-policy --role-name "$name" --policy-arn "$p"; done
  aws iam get-role --role-name "$name" --query Role.Arn --output text
}

instance_s3_policy() { # $1 = install|final
  local puts="\"arn:aws:s3:::$BUCKET/eif/*\", \"arn:aws:s3:::$BUCKET/install/ready/*\", \"arn:aws:s3:::$BUCKET/install/failed/*\""
  [ "$1" = install ] && puts="$puts, \"arn:aws:s3:::$BUCKET/sealed/*\""
  cat <<JSON
{"Version": "2012-10-17", "Statement": [
  {"Effect": "Allow", "Action": ["s3:GetObject"], "Resource": "arn:aws:s3:::$BUCKET/*"},
  {"Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": "arn:aws:s3:::$BUCKET"},
  {"Effect": "Allow", "Action": ["s3:PutObject"], "Resource": [$puts]}
]}
JSON
}

load_existing() { # fills ARNs for an existing deployment
  INSTANCE_ROLE_ARN="$(aws iam get-role --role-name "$ROLE_INSTANCE" --query Role.Arn --output text 2>/dev/null || true)"
  TASK_ROLE_ARN="$(aws iam get-role --role-name "$ROLE_TASK" --query Role.Arn --output text 2>/dev/null || true)"
  EXEC_ROLE_ARN="$(aws iam get-role --role-name "$ROLE_EXEC" --query Role.Arn --output text 2>/dev/null || true)"
  SEAL_KEY_ARN="$(key_arn "$ALIAS_SEAL")"; RECOVERY_KEY_ARN="$(key_arn "$ALIAS_RECOVERY")"
}

read_config() { # the deployment's settings, from its bucket
  local cfg; cfg="$(s3_text install/config.env)" || die "no deployment '$NAME' in $REGION (s3://$BUCKET/install/config.env)"
  local line key value
  while IFS= read -r line; do
    key="${line%%=*}"; value="${line#*=}"
    case "$key" in IMAGE|SEAL_KEY|RECOVERY_KEY|ENVIRONMENT|LABEL|RECOVERY_PRINCIPAL|CLOUD_URL|WANT_NLB|PCRS)
      printf -v "$key" '%s' "$value" ;; esac
  done <<< "$cfg"
  IMAGE_REPO="$IMAGE"
}

write_config() {
  put_text install/config.env "IMAGE=$IMAGE_REPO
SEAL_KEY=$SEAL_KEY_ARN
RECOVERY_KEY=$RECOVERY_KEY_ARN
ENVIRONMENT=$ENVIRONMENT
LABEL=$LABEL
RECOVERY_PRINCIPAL=$RECOVERY_PRINCIPAL
CLOUD_URL=$CLOUD_URL
WANT_NLB=$WANT_NLB
PCRS=${PCRS:-}
INSTALLER_VERSION=$INSTALLER_VERSION"
}

wait_ready() { # $1 = version; prints PCR0
  local version="$1" waited=0 iid started
  started="$(date -u +%Y-%m-%dT%H:%M:%S)"
  # A failure from an earlier attempt is history; the instance retries every minute.
  aws s3 rm "s3://$BUCKET/install/failed/$version/" --recursive --only-show-errors 2>/dev/null || true
  say "  The instance is building the enclave image for $version (first boot: about 6–10 minutes)."
  while :; do
    signed_in || die "your AWS session expired while waiting. Sign in again (aws sso login, or aws login) and re-run the same command: it resumes"
    if [ -n "$(aws s3 ls "s3://$BUCKET/install/ready/$version/" 2>/dev/null)" ] &&
       s3_exists "eif/$version/measurements.json"; then
      break
    fi
    # Only failures reported since this wait began: an instance being replaced
    # can still report one from before.
    local failed; failed="$(aws s3api list-objects-v2 --bucket "$BUCKET" --prefix "install/failed/$version/" \
      --query "Contents[?LastModified>='$started'].Key | [-1]" --output text 2>/dev/null || true)"
    if [ -n "$failed" ] && [ "$failed" != None ]; then
      say ""; s3_text "$failed" >&2 || true
      die "the instance could not prepare $version (details above; full log in /var/log/chainos-install.log on the instance)"
    fi
    iid="$(aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names "$ASG_NAME" \
      --query 'AutoScalingGroups[0].Instances[0].[InstanceId,LifecycleState]' --output text 2>/dev/null || true)"
    to_tty '\r  %s… %dm%02ds  instance: %s   ' "$D" $((waited / 60)) $((waited % 60)) "${iid:-pending}$N"
    [ "$waited" -gt 1800 ] && die "nothing after 30 minutes; check the instance's /var/log/chainos-install.log"
    sleep 15; waited=$((waited + 15))
  done
  to_tty '\n'
}

pcr0_of() { s3_text "eif/$1/measurements.json" | sed -n 's/.*"PCR0": *"\([0-9a-fA-F]*\)".*/\1/p' | head -1; }

wait_edge() { # waits for the Edge to report the unsealed seed
  local waited=0 since; since=$(( ($(date +%s) - 10) * 1000 ))
  say "  Waiting for the Edge to unseal inside the enclave…"
  while :; do
    signed_in || die "your AWS session expired while waiting. Sign in again (aws sso login, or aws login) and re-run the same command: it resumes"
    local hit
    hit="$(aws logs filter-log-events --log-group-name "$LOG_GROUP" --log-stream-name-prefix edge \
      --start-time "$since" --filter-pattern '?"seed unsealed inside the enclave" ?"edge failed to start"' \
      --query 'events[-1].message' --output text 2>/dev/null || true)"
    case "$hit" in
      *"seed unsealed inside the enclave"*)
        local running; running="$(printf '%s' "$hit" | grep -o 'pcr0[^0-9a-fA-F]*[0-9a-fA-F]\{96\}' | grep -o '[0-9a-fA-F]\{96\}' || true)"
        ok "unsealed inside the enclave${running:+ (running PCR0 $running)}"
        return 0 ;;
      *"edge failed to start"*) say "$hit" >&2; die "the Edge did not start (above). Logs: CloudWatch $LOG_GROUP" ;;
    esac
    [ "$waited" -gt 900 ] && die "the Edge has not reported after 15 minutes; see CloudWatch $LOG_GROUP"
    sleep 10; waited=$((waited + 10))
  done
}

do_install() {
  say "${B}ChainOS Edge — AWS Nitro Enclaves installer${N} ${D}v$INSTALLER_VERSION${N}"
  SCRATCH="$(mktemp -d)"; trap 'rm -rf "$SCRATCH"' EXIT
  ensure_aws_cli
  choose_credentials
  choose_region
  choose_seed
  choose_edge_settings
  choose_network
  check_privileges
  choose_policy
  choose_recovery

  step "Summary"
  cat <<SUMMARY
  account / region   $ACCOUNT / $REGION
  deployment         $NAME
  release            $IMAGE_REPO:$VERSION
  environment        $ENVIRONMENT   → $CLOUD_URL
  instance           $INSTANCE_TYPE in $SUBNETS
  local API          port $EDGE_PORT from $ALLOWED_CIDR$( [ "$WANT_NLB" = y ] && printf ', internal load balancer')
  seed               $( [ "$SEED_MODE" = existing ] && printf 'existing — identity %s' "$EXISTING_IDENTITY" || printf 'new — generated on the instance, never shown')
  recovery opens to  $RECOVERY_PRINCIPAL
  policy             $(printf '%s' "$POLICY_JSON" | tr -d '\n' | tr -s ' ')
  cost (approx.)     USD 140–160/month for $INSTANCE_TYPE$( [ "$WANT_NLB" = y ] && printf ' + 18 load balancer') + 2 KMS keys
SUMMARY
  confirm "Create it?" y || exit 0

  PCRS="$(s3_text install/config.env 2>/dev/null | sed -n 's/^PCRS=//p' || true)"

  step "Storage"
  if ! aws s3api head-bucket --bucket "$BUCKET" >/dev/null 2>&1; then
    if [ "$REGION" = us-east-1 ]; then aws s3api create-bucket --bucket "$BUCKET" >/dev/null
    else aws s3api create-bucket --bucket "$BUCKET" --create-bucket-configuration "LocationConstraint=$REGION" >/dev/null; fi
  fi
  aws s3api put-public-access-block --bucket "$BUCKET" --public-access-block-configuration \
    BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
  aws s3api put-bucket-versioning --bucket "$BUCKET" --versioning-configuration Status=Enabled
  aws s3api put-bucket-encryption --bucket "$BUCKET" --server-side-encryption-configuration \
    '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
  aws s3api put-bucket-tagging --bucket "$BUCKET" --tagging "TagSet=[{Key=chainos:deployment,Value=$NAME}]"
  # The envelopes can be read, not deleted: a deleted recovery envelope can be the
  # last copy of a wallet. Uninstall removes this statement only on request.
  aws s3api put-bucket-policy --bucket "$BUCKET" --policy "{\"Version\":\"2012-10-17\",\"Statement\":[
    {\"Sid\":\"TLSOnly\",\"Effect\":\"Deny\",\"Principal\":\"*\",\"Action\":\"s3:*\",\"Resource\":[\"arn:aws:s3:::$BUCKET\",\"arn:aws:s3:::$BUCKET/*\"],\"Condition\":{\"Bool\":{\"aws:SecureTransport\":\"false\"}}},
    {\"Sid\":\"EnvelopesAreNeverDeleted\",\"Effect\":\"Deny\",\"Principal\":\"*\",\"Action\":[\"s3:DeleteObject\",\"s3:DeleteObjectVersion\"],\"Resource\":\"arn:aws:s3:::$BUCKET/sealed/*\"}]}"
  ok "s3://$BUCKET (private, versioned, envelopes undeletable)"
  aws logs create-log-group --log-group-name "$LOG_GROUP" --tags "chainos:deployment=$NAME" 2>/dev/null || true
  aws logs put-retention-policy --log-group-name "$LOG_GROUP" --retention-in-days 90
  ok "logs in CloudWatch $LOG_GROUP"
  restore_existing_seed

  step "Identities"
  INSTANCE_ROLE_ARN="$(create_role "$ROLE_INSTANCE" ec2.amazonaws.com \
    arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role)"
  aws iam put-role-policy --role-name "$ROLE_INSTANCE" --policy-name chainos-bucket \
    --policy-document "$(instance_s3_policy install)"
  if ! aws iam get-instance-profile --instance-profile-name "$PROFILE_INSTANCE" >/dev/null 2>&1; then
    aws iam create-instance-profile --instance-profile-name "$PROFILE_INSTANCE" >/dev/null
    aws iam add-role-to-instance-profile --instance-profile-name "$PROFILE_INSTANCE" --role-name "$ROLE_INSTANCE"
  fi
  TASK_ROLE_ARN="$(create_role "$ROLE_TASK" ecs-tasks.amazonaws.com)"
  EXEC_ROLE_ARN="$(create_role "$ROLE_EXEC" ecs-tasks.amazonaws.com \
    arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy)"
  aws iam put-role-policy --role-name "$ROLE_EXEC" --policy-name chainos-api-key --policy-document \
    "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"ssm:GetParameters\",\"Resource\":\"arn:aws:ssm:$REGION:$ACCOUNT:parameter$PARAM_API_KEY\"}]}"
  ok "roles $ROLE_INSTANCE, $ROLE_TASK (no permissions of its own), $ROLE_EXEC"
  # From a file, not --value: an argument is visible to every process listing on this machine.
  ( umask 077; printf '{"Name": "%s", "Type": "SecureString", "Overwrite": true, "Value": "%s",
    "Description": "ChainOS Edge API key for %s"}' "$PARAM_API_KEY" "$API_KEY" "$NAME" > "$SCRATCH/param.json" )
  aws ssm put-parameter --cli-input-json "file://$SCRATCH/param.json" >/dev/null
  rm -f "$SCRATCH/param.json"
  ok "API key in SSM $PARAM_API_KEY (SecureString)"

  step "Keys"
  sleep 8 # new roles take a moment to be usable as KMS principals
  SEAL_KEY_ARN="$(key_arn "$ALIAS_SEAL")"
  if [ -z "$SEAL_KEY_ARN" ] || [ "$SEAL_KEY_ARN" = None ]; then
    seal_key_policy install "" > "$SCRATCH/seal-policy.json"
    SEAL_KEY_ARN="$(retry 12 aws kms create-key --description "ChainOS Edge $NAME: seals the phrase for the enclave" \
      --policy "file://$SCRATCH/seal-policy.json" --tags TagKey=chainos:deployment,TagValue="$NAME" \
      --query KeyMetadata.Arn --output text)"
    aws kms create-alias --alias-name "$ALIAS_SEAL" --target-key-id "$SEAL_KEY_ARN"
  else
    seal_key_policy install "${PCRS:-}" > "$SCRATCH/seal-policy.json"
    retry 12 aws kms put-key-policy --key-id "$SEAL_KEY_ARN" --policy-name default --policy "file://$SCRATCH/seal-policy.json"
  fi
  ok "$ALIAS_SEAL — Decrypt only by an attested enclave (pinned below, once its image exists)"
  RECOVERY_KEY_ARN="$(key_arn "$ALIAS_RECOVERY")"
  recovery_key_policy install > "$SCRATCH/recovery-policy.json"
  if [ -z "$RECOVERY_KEY_ARN" ] || [ "$RECOVERY_KEY_ARN" = None ]; then
    RECOVERY_KEY_ARN="$(retry 12 aws kms create-key --description "ChainOS Edge $NAME: break-glass recovery copy of the phrase" \
      --policy "file://$SCRATCH/recovery-policy.json" --tags TagKey=chainos:deployment,TagValue="$NAME" \
      --query KeyMetadata.Arn --output text)"
    aws kms create-alias --alias-name "$ALIAS_RECOVERY" --target-key-id "$RECOVERY_KEY_ARN"
  else
    retry 12 aws kms put-key-policy --key-id "$RECOVERY_KEY_ARN" --policy-name default --policy "file://$SCRATCH/recovery-policy.json"
  fi
  ok "$ALIAS_RECOVERY — Decrypt only by $RECOVERY_PRINCIPAL"

  step "Instance"
  write_config
  write_reconcile; write_launcher; write_user_data
  aws s3 cp "$SCRATCH/reconcile.sh" "s3://$BUCKET/install/reconcile.sh" --only-show-errors
  aws s3 cp "$SCRATCH/launcher.sh" "s3://$BUCKET/install/launcher.sh" --only-show-errors
  printf '%s\n' "$ENCLAVE_CA" | aws s3 cp - "s3://$BUCKET/install/enclave-ca.crt" --only-show-errors
  printf '%s\n' "$POLICY_JSON" > "$SCRATCH/policy.json"
  s3_exists sealed/primary.json || aws s3 cp "$SCRATCH/policy.json" "s3://$BUCKET/install/policy.json" --only-show-errors
  put_text install/desired-version "$VERSION"

  local sg
  sg="$(aws ec2 describe-security-groups --filters "Name=vpc-id,Values=$VPC" "Name=group-name,Values=$SG_NAME" \
    --query 'SecurityGroups[0].GroupId' --output text)"
  if [ "$sg" = None ] || [ -z "$sg" ]; then
    sg="$(aws ec2 create-security-group --group-name "$SG_NAME" --vpc-id "$VPC" \
      --description "ChainOS Edge $NAME: local API from $ALLOWED_CIDR only" \
      --tag-specifications "ResourceType=security-group,Tags=[{Key=chainos:deployment,Value=$NAME},{Key=Name,Value=$SG_NAME}]" \
      --query GroupId --output text)"
  fi
  local cidr
  for cidr in "$ALLOWED_CIDR" $( [ "$WANT_NLB" = y ] && printf '%s' "$SUBNET_CIDRS"); do
    aws ec2 authorize-security-group-ingress --group-id "$sg" --ip-permissions \
      "IpProtocol=tcp,FromPort=$EDGE_PORT,ToPort=$EDGE_PORT,IpRanges=[{CidrIp=$cidr,Description=ChainOS Edge local API}]" \
      >/dev/null 2>&1 || true
  done
  ok "security group $sg: port $EDGE_PORT from $ALLOWED_CIDR; nothing else inbound"

  aws ecs create-cluster --cluster-name "$CLUSTER" --tags "key=chainos:deployment,value=$NAME" >/dev/null
  local ami
  ami="$(aws ssm get-parameter --name /aws/service/ecs/optimized-ami/amazon-linux-2023/recommended/image_id \
    --query Parameter.Value --output text)"
  cat > "$SCRATCH/lt.json" <<JSON
{
  "ImageId": "$ami",
  "InstanceType": "$INSTANCE_TYPE",
  "IamInstanceProfile": {"Name": "$PROFILE_INSTANCE"},
  "EnclaveOptions": {"Enabled": true},
  "MetadataOptions": {"HttpTokens": "required", "HttpPutResponseHopLimit": 1, "HttpEndpoint": "enabled"},
  "BlockDeviceMappings": [{"DeviceName": "/dev/xvda", "Ebs": {"VolumeSize": 40, "VolumeType": "gp3", "Encrypted": true, "DeleteOnTermination": true}}],
  "NetworkInterfaces": [{"DeviceIndex": 0, "AssociatePublicIpAddress": $PUBLIC_IP, "Groups": ["$sg"], "DeleteOnTermination": true}],
  "UserData": "$(base64 < "$SCRATCH/user-data.sh" | tr -d '\n')",
  "TagSpecifications": [
    {"ResourceType": "instance", "Tags": [{"Key": "Name", "Value": "$NAME"}, {"Key": "chainos:deployment", "Value": "$NAME"}]},
    {"ResourceType": "volume", "Tags": [{"Key": "chainos:deployment", "Value": "$NAME"}]}
  ]
}
JSON
  if aws ec2 describe-launch-templates --launch-template-names "$LT_NAME" >/dev/null 2>&1; then
    aws ec2 create-launch-template-version --launch-template-name "$LT_NAME" \
      --launch-template-data "file://$SCRATCH/lt.json" >/dev/null
  else
    retry 12 aws ec2 create-launch-template --launch-template-name "$LT_NAME" \
      --launch-template-data "file://$SCRATCH/lt.json" \
      --tag-specifications "ResourceType=launch-template,Tags=[{Key=chainos:deployment,Value=$NAME}]" >/dev/null
  fi
  if [ "$(aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names "$ASG_NAME" \
      --query 'length(AutoScalingGroups)' --output text)" = 0 ]; then
    retry 12 aws autoscaling create-auto-scaling-group --auto-scaling-group-name "$ASG_NAME" \
      --launch-template "LaunchTemplateName=$LT_NAME,Version=\$Latest" \
      --min-size 1 --max-size 1 --desired-capacity 1 --vpc-zone-identifier "$SUBNETS" \
      --tags "Key=chainos:deployment,Value=$NAME,PropagateAtLaunch=true"
  fi
  ok "Auto Scaling group $ASG_NAME ($INSTANCE_TYPE, enclaves enabled, IMDSv2, encrypted disk)"

  if [ "$WANT_NLB" = y ]; then
    local tg lb
    tg="$(aws elbv2 create-target-group --name "$TG_NAME" --protocol TCP --port "$EDGE_PORT" --vpc-id "$VPC" \
      --target-type instance --health-check-protocol HTTP --health-check-path /v1/edge/health \
      --tags "Key=chainos:deployment,Value=$NAME" --query 'TargetGroups[0].TargetGroupArn' --output text)"
    lb="$(aws elbv2 create-load-balancer --name "$NLB_NAME" --type network --scheme internal \
      --subnets ${SUBNETS//,/ } --tags "Key=chainos:deployment,Value=$NAME" \
      --query 'LoadBalancers[0].LoadBalancerArn' --output text)"
    aws elbv2 create-listener --load-balancer-arn "$lb" --protocol TCP --port "$EDGE_PORT" \
      --default-actions "Type=forward,TargetGroupArn=$tg" >/dev/null 2>&1 || true
    aws autoscaling attach-load-balancer-target-groups --auto-scaling-group-name "$ASG_NAME" \
      --target-group-arns "$tg"
    ok "internal load balancer $NLB_NAME → port $EDGE_PORT"
  fi

  step "Building the enclave$( [ "$SEED_MODE" = new ] && printf ' and sealing the phrase') (on the instance)"
  wait_ready "$VERSION"
  local pcr0; pcr0="$(pcr0_of "$VERSION")"
  [[ "$pcr0" =~ ^[0-9a-fA-F]{96}$ ]] || die "the enclave measurement is unreadable: '$pcr0'"
  [ "$pcr0" != "$(printf '0%.0s' $(seq 1 96))" ] || die "refusing an all-zero PCR0 (a debug-mode measurement)"
  ok "enclave image built: PCR0 $pcr0"
  aws s3 cp "s3://$BUCKET/sealed/primary.json" "$SCRATCH/primary.json" --only-show-errors
  IDENTITY="$(envelope_field "$SCRATCH/primary.json" identityFingerprint)"
  if [ "$SEED_MODE" = existing ]; then ok "existing seed in place — identity $IDENTITY"
  else ok "phrase generated and sealed — identity $IDENTITY"; fi

  step "Pinning the key to this enclave"
  PCRS="[\"$pcr0\"]"
  seal_key_policy final "$PCRS" > "$SCRATCH/seal-policy.json"
  retry 6 aws kms put-key-policy --key-id "$SEAL_KEY_ARN" --policy-name default --policy "file://$SCRATCH/seal-policy.json"
  recovery_key_policy final > "$SCRATCH/recovery-policy.json"
  retry 6 aws kms put-key-policy --key-id "$RECOVERY_KEY_ARN" --policy-name default --policy "file://$SCRATCH/recovery-policy.json"
  aws iam put-role-policy --role-name "$ROLE_INSTANCE" --policy-name chainos-bucket \
    --policy-document "$(instance_s3_policy final)"
  write_config
  ok "$ALIAS_SEAL releases the seed only to PCR0 $pcr0, for the task role, in $ENVIRONMENT"
  ok "the instance can no longer seal or replace envelopes"

  step "Starting the Edge"
  task_definition "$VERSION" > "$SCRATCH/task.json"
  local td; td="$(retry 6 aws ecs register-task-definition --cli-input-json "file://$SCRATCH/task.json" \
    --query taskDefinition.taskDefinitionArn --output text)"
  if [ "$(aws ecs describe-services --cluster "$CLUSTER" --services "$SERVICE" \
      --query 'services[?status==`ACTIVE`]|length(@)' --output text)" = 1 ]; then
    aws ecs update-service --cluster "$CLUSTER" --service "$SERVICE" --task-definition "$td" >/dev/null
  else
    aws ecs create-service --cluster "$CLUSTER" --service-name "$SERVICE" --task-definition "$td" \
      --desired-count 1 --launch-type EC2 \
      --deployment-configuration "minimumHealthyPercent=0,maximumPercent=100" \
      --tags "key=chainos:deployment,value=$NAME" >/dev/null
  fi
  wait_edge

  save_local_copies
  print_access
}

save_local_copies() {
  local dir="./$NAME-install"
  mkdir -p "$dir"
  aws s3 cp "s3://$BUCKET/sealed/primary.json" "$dir/primary.sealed.json" --only-show-errors || true
  aws s3 cp "s3://$BUCKET/sealed/recovery.json" "$dir/recovery.sealed.json" --only-show-errors || true
  aws s3 cp "s3://$BUCKET/sealed/summary.json" "$dir/seal-summary.json" --only-show-errors || true
  chmod 0400 "$dir"/*.json 2>/dev/null || true
  ok "copies of both envelopes (ciphertext) and the seal summary in $dir/"
  hint "Keep recovery.sealed.json somewhere outside this AWS account as well. It is useless without"
  hint "$ALIAS_RECOVERY, and the key is useless without it."
}

# ── reaching it ──────────────────────────────────────────────────────────────

endpoint() {
  if [ "${WANT_NLB:-n}" = y ]; then
    local dns; dns="$(aws elbv2 describe-load-balancers --names "$NLB_NAME" \
      --query 'LoadBalancers[0].DNSName' --output text 2>/dev/null || true)"
    [ -n "$dns" ] && [ "$dns" != None ] && { echo "http://$dns:$EDGE_PORT"; return; }
  fi
  local iid ip
  iid="$(aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names "$ASG_NAME" \
    --query 'AutoScalingGroups[0].Instances[0].InstanceId' --output text 2>/dev/null || true)"
  ip="$(aws ec2 describe-instances --instance-ids "$iid" \
    --query 'Reservations[0].Instances[0].PrivateIpAddress' --output text 2>/dev/null || true)"
  echo "http://${ip:-<instance private IP>}:$EDGE_PORT"
}

print_access() {
  local url; url="$(endpoint)"
  local key_prefix=zkl_test_; [ "$ENVIRONMENT" = live ] && key_prefix=zkl_live_
  if [ -z "${IDENTITY:-}" ]; then
    aws s3 cp "s3://$BUCKET/sealed/primary.json" "$SCRATCH/primary.json" --only-show-errors 2>/dev/null || true
    IDENTITY="$(envelope_field "$SCRATCH/primary.json" identityFingerprint 2>/dev/null || true)"
  fi
  step "Done — your Edge is running"
  cat <<ACCESS

  ${B}Edge endpoint${N}       $B$G$url$N
                      private: reachable from $( [ -n "${ALLOWED_CIDR:-}" ] && printf '%s' "$ALLOWED_CIDR" || printf 'the allowed network') only, plain HTTP inside your VPC
  ${B}Edge identity${N}       $IDENTITY
  ${B}Status page${N}         $url/   (open it from inside the VPC, e.g. over your VPN or a bastion)

  ${B}Check it${N}
    curl -s $url/v1/edge/health

  ${B}Non-custodial signing — what your application does${N}
    1. In the ChainOS portal, open Settings → Edge readiness and confirm the identity above.
    2. Mint a local key: Settings → Local API Keys. It starts with $key_prefix.
    3. Point your application at the Edge instead of ChainOS Cloud:
         base URL   $url/v1
         header     X-API-Key: ${key_prefix}…
       Every /v1 call goes through the Edge. Calls that need a signature — withdrawals
       (POST /v1/transactions), sweeps, consolidations — are signed inside the enclave on
       the way and broadcast; the key never leaves it, and Cloud never sees it.
    4. Try it:
         curl -s -H "X-API-Key: \$CHAINOS_EDGE_KEY" $url/v1/edge/status

  ${B}What protects the seed${N}
    - The phrase was sealed on the instance when it was generated; nobody has seen it.
    - $ALIAS_SEAL releases it only to the enclave measured above. Root on the instance,
      the task's own role, and AWS administrators get nothing usable.
    - The recovery copy opens only for $RECOVERY_PRINCIPAL. Alert on any
      kms:Decrypt with $ALIAS_RECOVERY in CloudTrail — it should never happen.

  ${B}Later${N}
    bash $SELF status      endpoint, instance, recent logs
    bash $SELF upgrade     move to another release (re-pins the key)
    bash $SELF uninstall   remove it; keys and envelopes are kept unless you say otherwise
    bash $SELF reveal      write the recovery phrase on paper (as the recovery principal)

ACCESS
}

# ── status, upgrade, uninstall ───────────────────────────────────────────────

existing_deployment() {
  say "${B}ChainOS Edge — AWS Nitro Enclaves${N}"
  ensure_aws_cli
  choose_credentials
  choose_region
  ask NAME "Deployment name:" "chainos-edge"
  set_names
  SCRATCH="$(mktemp -d)"; trap 'rm -rf "$SCRATCH"' EXIT
  read_config
  load_existing
  ALLOWED_CIDR=""
}

do_status() {
  existing_deployment
  step "Status of $NAME"
  local version; version="$(s3_text install/desired-version | tr -d '[:space:]')"
  say "  release      $IMAGE_REPO:$version"
  say "  PCR0 pinned  $PCRS"
  aws ecs describe-services --cluster "$CLUSTER" --services "$SERVICE" \
    --query 'services[0].[status,runningCount,desiredCount,taskDefinition]' --output text 2>/dev/null |
    awk '{printf "  service      %s, %s of %s running (%s)\n", $1, $2, $3, $4}'
  aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names "$ASG_NAME" \
    --query 'AutoScalingGroups[0].Instances[].[InstanceId,LifecycleState,HealthStatus,AvailabilityZone]' --output text |
    awk '{printf "  instance     %s %s %s %s\n", $1, $2, $3, $4}'
  say "  endpoint     $(endpoint)"
  step "Recent Edge log"
  aws logs filter-log-events --log-group-name "$LOG_GROUP" --log-stream-name-prefix edge \
    --start-time $(( ($(date +%s) - 3600) * 1000 )) --query 'events[-15:].message' --output text 2>/dev/null |
    tr '\t' '\n' | cut -c1-220 | sed 's/^/  /'
}

do_upgrade() {
  existing_deployment
  local current; current="$(s3_text install/desired-version | tr -d '[:space:]')"
  say "  Installed: $IMAGE_REPO:$current"
  choose_version
  [ "$VERSION" != "$current" ] || die "$current is already installed"
  confirm "Upgrade $NAME from $current to $VERSION? The Edge restarts once (under a minute)." y || exit 0

  step "Preparing $VERSION on the instance"
  # This installer's own instance logic goes up first, so an upgrade of the
  # script reaches the instance together with the release.
  write_reconcile; write_launcher
  aws s3 cp "$SCRATCH/reconcile.sh" "s3://$BUCKET/install/reconcile.sh" --only-show-errors
  aws s3 cp "$SCRATCH/launcher.sh" "s3://$BUCKET/install/launcher.sh" --only-show-errors
  put_text install/desired-version "$VERSION"
  wait_ready "$VERSION"
  local pcr0; pcr0="$(pcr0_of "$VERSION")"
  [[ "$pcr0" =~ ^[0-9a-fA-F]{96}$ ]] || die "the enclave measurement is unreadable"
  ok "PCR0 $pcr0"

  step "Allowing the new measurement"
  # The new PCR0 and the one in use, so a rollback needs no policy change.
  local old; old="$(printf '%s' "$PCRS" | grep -o '[0-9a-fA-F]\{96\}' | head -1)"
  # Every release embeds its own version, so its enclave measures differently.
  # The same PCR0 means the instance built the old binary into the new image.
  [ "$pcr0" != "$old" ] || die "$VERSION built the same enclave measurement as $current, so the new \
release is not in it. See install/ready/$VERSION/ in s3://$BUCKET for what the instance built."
  PCRS="[\"$pcr0\"]"
  if [ -n "$old" ] && [ "$old" != "$pcr0" ]; then PCRS="[\"$pcr0\", \"$old\"]"; fi
  seal_key_policy final "$PCRS" > "$SCRATCH/seal-policy.json"
  aws kms put-key-policy --key-id "$SEAL_KEY_ARN" --policy-name default --policy "file://$SCRATCH/seal-policy.json"
  write_config
  ok "$ALIAS_SEAL now releases to $PCRS"

  step "Rolling the Edge"
  task_definition "$VERSION" > "$SCRATCH/task.json"
  local td; td="$(aws ecs register-task-definition --cli-input-json "file://$SCRATCH/task.json" \
    --query taskDefinition.taskDefinitionArn --output text)"
  aws ecs update-service --cluster "$CLUSTER" --service "$SERVICE" --task-definition "$td" >/dev/null
  wait_edge
  print_access
}

do_uninstall() {
  existing_deployment
  step "Uninstall $NAME"
  say "  This stops the Edge and removes the instance, service, cluster, load balancer, roles,"
  say "  security group, logs and the API key parameter."
  say "  ${B}It keeps${N} both KMS keys and the bucket holding the envelopes, unless you choose below."
  local typed; ask typed "Type the deployment name to confirm:" ""
  [ "$typed" = "$NAME" ] || die "not confirmed"

  aws ecs update-service --cluster "$CLUSTER" --service "$SERVICE" --desired-count 0 >/dev/null 2>&1 || true
  aws ecs delete-service --cluster "$CLUSTER" --service "$SERVICE" --force >/dev/null 2>&1 || true
  ok "service removed (the Edge drained its outbox on shutdown)"
  if [ "${WANT_NLB:-n}" = y ]; then
    local lb tg
    lb="$(aws elbv2 describe-load-balancers --names "$NLB_NAME" --query 'LoadBalancers[0].LoadBalancerArn' --output text 2>/dev/null || true)"
    tg="$(aws elbv2 describe-target-groups --names "$TG_NAME" --query 'TargetGroups[0].TargetGroupArn' --output text 2>/dev/null || true)"
    [ -n "$lb" ] && [ "$lb" != None ] && aws elbv2 delete-load-balancer --load-balancer-arn "$lb"
    if [ -n "$tg" ] && [ "$tg" != None ]; then
      aws autoscaling detach-load-balancer-target-groups --auto-scaling-group-name "$ASG_NAME" --target-group-arns "$tg" 2>/dev/null || true
      sleep 10; aws elbv2 delete-target-group --target-group-arn "$tg" 2>/dev/null || true
    fi
    ok "load balancer removed"
  fi
  aws autoscaling delete-auto-scaling-group --auto-scaling-group-name "$ASG_NAME" --force-delete 2>/dev/null || true
  say "  Waiting for the instance to terminate…"
  while [ "$(aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names "$ASG_NAME" \
      --query 'length(AutoScalingGroups)' --output text)" != 0 ]; do sleep 10; done
  aws ec2 delete-launch-template --launch-template-name "$LT_NAME" >/dev/null 2>&1 || true
  ok "instance and launch template removed"
  local arn
  for arn in $(aws ecs list-task-definitions --family-prefix "$FAMILY" --query 'taskDefinitionArns[]' --output text); do
    aws ecs deregister-task-definition --task-definition "$arn" >/dev/null
  done
  aws ecs delete-cluster --cluster "$CLUSTER" >/dev/null 2>&1 || true
  local sg
  sg="$(aws ec2 describe-security-groups --filters "Name=group-name,Values=$SG_NAME" --query 'SecurityGroups[0].GroupId' --output text)"
  [ -n "$sg" ] && [ "$sg" != None ] && retry 12 aws ec2 delete-security-group --group-id "$sg" >/dev/null
  aws logs delete-log-group --log-group-name "$LOG_GROUP" 2>/dev/null || true
  aws ssm delete-parameter --name "$PARAM_API_KEY" 2>/dev/null || true
  aws iam remove-role-from-instance-profile --instance-profile-name "$PROFILE_INSTANCE" --role-name "$ROLE_INSTANCE" 2>/dev/null || true
  aws iam delete-instance-profile --instance-profile-name "$PROFILE_INSTANCE" 2>/dev/null || true
  local role p
  for role in "$ROLE_INSTANCE" "$ROLE_TASK" "$ROLE_EXEC"; do
    for p in $(aws iam list-attached-role-policies --role-name "$role" --query 'AttachedPolicies[].PolicyArn' --output text 2>/dev/null); do
      aws iam detach-role-policy --role-name "$role" --policy-arn "$p"
    done
    for p in $(aws iam list-role-policies --role-name "$role" --query 'PolicyNames[]' --output text 2>/dev/null); do
      aws iam delete-role-policy --role-name "$role" --policy-name "$p"
    done
    aws iam delete-role --role-name "$role" 2>/dev/null || true
  done
  ok "cluster, task definitions, security group, logs, API key and roles removed"

  step "Keys and envelopes"
  say "  ${R}${B}Deleting the keys destroys the only copies of a phrase nobody has seen.${N} Any funds still on"
  say "  this wallet's addresses become unrecoverable. Keep them unless the wallet is empty and retired."
  local answer
  ask answer "Schedule BOTH KMS keys for deletion (30-day waiting period)? Type DELETE KEYS, or press Enter to keep:" "keep"
  if [ "$answer" = "DELETE KEYS" ]; then
    aws kms schedule-key-deletion --key-id "$SEAL_KEY_ARN" --pending-window-in-days 30 >/dev/null
    aws kms schedule-key-deletion --key-id "$RECOVERY_KEY_ARN" --pending-window-in-days 30 >/dev/null
    warn "both keys are pending deletion for 30 days; cancel with: aws kms cancel-key-deletion --key-id <arn>"
    ok "kept s3://$BUCKET with the envelopes (its policy forbids deleting them)"
  else
    ok "kept $ALIAS_SEAL and $ALIAS_RECOVERY (about USD 2/month)"
    ok "kept s3://$BUCKET with the envelopes (its policy forbids deleting them)"
    hint "Re-running install with the name $NAME reuses both and starts the same wallet again."
  fi
}

# ── reveal: a paper backup of a phrase nobody has seen ──────────────────────
#
# Keystore hardening §11 D5. The phrase was generated on the instance and never
# shown; its backup is the recovery envelope. This shows it once, on this
# terminal, so it can be written on paper — for an offline backup, or to take
# the wallet elsewhere. It runs the release's own reveal-mnemonic in Docker on
# this machine, as the recovery principal: nothing about it happens on the
# instance, and the words go to this screen only.
do_reveal() {
  existing_deployment
  step "Paper backup of the recovery phrase of $NAME"
  say "  This shows the 24 words of the wallet's recovery phrase, once, on this screen, so you can"
  say "  write them on paper. ${B}Anyone who sees them controls the wallet.${N} Be alone, with no screen"
  say "  sharing or recording, and have paper ready."
  command -v docker >/dev/null 2>&1 ||
    die "this needs Docker on this computer (Docker Desktop on macOS). Install it and re-run"
  docker info >/dev/null 2>&1 || die "Docker is installed but not running. Start it and re-run"
  local version image
  version="$(s3_text install/desired-version | tr -d '[:space:]')"
  image="$IMAGE_REPO:$version"
  case "$IMAGE_REPO" in
    *.dkr.ecr.*.amazonaws.com/*)
      aws ecr get-login-password | docker login --username AWS --password-stdin "${IMAGE_REPO%%/*}" >/dev/null ;;
  esac
  say "  Fetching ${image}…"
  # The release image is linux/amd64 only; on Apple silicon Docker runs it emulated.
  docker pull -q --platform linux/amd64 "$image" >/dev/null || die "cannot pull $image"
  # Captured, not piped into grep: --help exits 2, which pipefail would turn into
  # "this release cannot reveal".
  local help; help="$(docker run --rm --platform linux/amd64 "$image" reveal-mnemonic --help 2>&1 || true)"
  case "$help" in *"reveal-mnemonic --from-envelope"*) ;;
    *) die "release $version cannot reveal a phrase; upgrade $NAME to a newer release first" ;;
  esac
  if [ "$PRINCIPAL_ARN" != "$RECOVERY_PRINCIPAL" ]; then
    warn "only $RECOVERY_PRINCIPAL can open the recovery copy, and you are signed in as"
    hint "${PRINCIPAL_ARN:-$CALLER_ARN}. Sign in as that principal, or KMS will refuse."
  fi
  confirm "Continue to the paper backup?" n || exit 0
  aws s3 cp "s3://$BUCKET/sealed/recovery.json" "$SCRATCH/recovery.json" --only-show-errors ||
    die "s3://$BUCKET/sealed/recovery.json is missing; use your saved recovery.sealed.json with chainos-edge reveal-mnemonic"
  # Docker cannot use a profile or an SSO cache from this machine: short-lived
  # credentials go in by file, readable by nobody else, removed straight after.
  ( umask 077; aws configure export-credentials --format env-no-export > "$SCRATCH/credentials.env" ) ||
    die "could not export your AWS credentials for Docker"
  local code=0
  docker run --rm -it --platform linux/amd64 --user "$(id -u):$(id -g)" --env-file "$SCRATCH/credentials.env" -e AWS_REGION="$REGION" \
    -v "$SCRATCH:/w:ro" "$image" reveal-mnemonic --from-envelope=/w/recovery.json < "$TTY" > "$TTY" || code=$?
  rm -f "$SCRATCH/credentials.env"
  [ "$code" -eq 0 ] || die "no backup was made"
  hint "CloudTrail records this as a kms:Decrypt with $ALIAS_RECOVERY, by ${PRINCIPAL_ARN:-$CALLER_ARN}."
}

usage() {
  cat <<USAGE
usage: bash $SELF [install|status|upgrade|uninstall|reveal] [--yes]

  reveal   show the recovery phrase once, on this screen, to write it on paper

Environment variables answer the questions instead of a prompt:
  CHAINOS_PROFILE (a profile, or default)  CHAINOS_REGION  CHAINOS_SEED (a deployment name, or new)
  CHAINOS_NAME  CHAINOS_VERSION  CHAINOS_API_KEY
  CHAINOS_CLOUD_URL  CHAINOS_VPC  CHAINOS_SUBNETS (comma-separated IDs)  CHAINOS_INSTANCE_TYPE
  CHAINOS_ALLOWED_CIDR  CHAINOS_WANT_NLB_ANSWER (y/n)  CHAINOS_POLICY_FILE (JSON)
  CHAINOS_FORWARDERS  CHAINOS_RECOVERY_PRINCIPAL  CHAINOS_EDGE_IMAGE (default $IMAGE_REPO)
USAGE
}

# The roots the enclave trusts for its TLS session to KMS: Amazon's only. Pinned
# here, byte for byte the repository's chainos-edge/deploy/nitro/ca-certificates.crt.
# (read -d '', not $(cat <<…): bash 3.2, macOS's, misparses quotes inside a
# heredoc in a command substitution.)
ENCLAVE_CA=""
IFS= read -r -d '' ENCLAVE_CA <<'CA' || true
# The only roots the enclave trusts: Amazon's, which sign the KMS endpoints.
# Taken from Amazon Linux 2023's ca-bundle. Pinned so PCR0 does not move with
# a distribution's bundle, and so a relay cannot present any other CA.
# Amazon Root CA 1
-----BEGIN CERTIFICATE-----
MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsF
ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6
b24gUm9vdCBDQSAxMB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTEL
MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv
b3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ4gHHKeNXj
ca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM
9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qw
IFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6
VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L
93FcXmn/6pUCyziKrlA4b9v7LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQm
jgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC
AYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUA
A4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDI
U5PMCCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUs
N+gDS63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vv
o/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU
5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeXeGADbkpy
rqXRfboQnoZsG4q5WTP468SQvvG5
-----END CERTIFICATE-----
# Amazon Root CA 2
-----BEGIN CERTIFICATE-----
MIIFQTCCAymgAwIBAgITBmyf0pY1hp8KD+WGePhbJruKNzANBgkqhkiG9w0BAQwF
ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6
b24gUm9vdCBDQSAyMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTEL
MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv
b3QgQ0EgMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK2Wny2cSkxK
gXlRmeyKy2tgURO8TW0G/LAIjd0ZEGrHJgw12MBvIITplLGbhQPDW9tK6Mj4kHbZ
W0/jTOgGNk3Mmqw9DJArktQGGWCsN0R5hYGCrVo34A3MnaZMUnbqQ523BNFQ9lXg
1dKmSYXpN+nKfq5clU1Imj+uIFptiJXZNLhSGkOQsL9sBbm2eLfq0OQ6PBJTYv9K
8nu+NQWpEjTj82R0Yiw9AElaKP4yRLuH3WUnAnE72kr3H9rN9yFVkE8P7K6C4Z9r
2UXTu/Bfh+08LDmG2j/e7HJV63mjrdvdfLC6HM783k81ds8P+HgfajZRRidhW+me
z/CiVX18JYpvL7TFz4QuK/0NURBs+18bvBt+xa47mAExkv8LV/SasrlX6avvDXbR
8O70zoan4G7ptGmh32n2M8ZpLpcTnqWHsFcQgTfJU7O7f/aS0ZzQGPSSbtqDT6Zj
mUyl+17vIWR6IF9sZIUVyzfpYgwLKhbcAS4y2j5L9Z469hdAlO+ekQiG+r5jqFoz
7Mt0Q5X5bGlSNscpb/xVA1wf+5+9R+vnSUeVC06JIglJ4PVhHvG/LopyboBZ/1c6
+XUyo05f7O0oYtlNc/LMgRdg7c3r3NunysV+Ar3yVAhU/bQtCSwXVEqY0VThUWcI
0u1ufm8/0i2BWSlmy5A5lREedCf+3euvAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB
Af8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSwDPBMMPQFWAJI/TPlUq9LhONm
UjANBgkqhkiG9w0BAQwFAAOCAgEAqqiAjw54o+Ci1M3m9Zh6O+oAA7CXDpO8Wqj2
LIxyh6mx/H9z/WNxeKWHWc8w4Q0QshNabYL1auaAn6AFC2jkR2vHat+2/XcycuUY
+gn0oJMsXdKMdYV2ZZAMA3m3MSNjrXiDCYZohMr/+c8mmpJ5581LxedhpxfL86kS
k5Nrp+gvU5LEYFiwzAJRGFuFjWJZY7attN6a+yb3ACfAXVU3dJnJUH/jWS5E4ywl
7uxMMne0nxrpS10gxdr9HIcWxkPo1LsmmkVwXqkLN1PiRnsn/eBG8om3zEK2yygm
btmlyTrIQRNg91CMFa6ybRoVGld45pIq2WWQgj9sAq+uEjonljYE1x2igGOpm/Hl
urR8FLBOybEfdF849lHqm/osohHUqS0nGkWxr7JOcQ3AWEbWaQbLU8uz/mtBzUF+
fUwPfHJ5elnNXkoOrJupmHN5fLT0zLm4BwyydFy4x2+IoZCn9Kr5v2c69BoVYh63
n749sSmvZ6ES8lgQGVMDMBu4Gon2nL2XA46jCfMdiyHxtN/kHNGfZQIG6lzWE7OE
76KlXIx3KadowGuuQNKotOrN8I1LOJwZmhsoVLiJkO/KdYE+HvJkJMcYr07/R54H
9jVlpNMKVv/1F2Rs76giJUmTtt8AF9pYfl3uxRuw0dFfIRDH+fO6AgonB8Xx1sfT
4PsJYGw=
-----END CERTIFICATE-----
# Amazon Root CA 3
-----BEGIN CERTIFICATE-----
MIIBtjCCAVugAwIBAgITBmyf1XSXNmY/Owua2eiedgPySjAKBggqhkjOPQQDAjA5
MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24g
Um9vdCBDQSAzMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkG
A1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3Qg
Q0EgMzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCmXp8ZBf8ANm+gBG1bG8lKl
ui2yEujSLtf6ycXYqm0fc4E7O5hrOXwzpcVOho6AF2hiRVd9RFgdszflZwjrZt6j
QjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSr
ttvXBp43rDCGB5Fwx5zEGbF4wDAKBggqhkjOPQQDAgNJADBGAiEA4IWSoxe3jfkr
BqWTrBqYaGFy+uGh0PsceGCmQ5nFuMQCIQCcAu/xlJyzlvnrxir4tiz+OpAUFteM
YyRIHN8wfdVoOw==
-----END CERTIFICATE-----
# Amazon Root CA 4
-----BEGIN CERTIFICATE-----
MIIB8jCCAXigAwIBAgITBmyf18G7EEwpQ+Vxe3ssyBrBDjAKBggqhkjOPQQDAzA5
MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24g
Um9vdCBDQSA0MB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkG
A1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3Qg
Q0EgNDB2MBAGByqGSM49AgEGBSuBBAAiA2IABNKrijdPo1MN/sGKe0uoe0ZLY7Bi
9i0b2whxIdIA6GO9mif78DluXeo9pcmBqqNbIJhFXRbb/egQbeOc4OO9X4Ri83Bk
M6DLJC9wuoihKqB1+IGuYgbEgds5bimwHvouXKNCMEAwDwYDVR0TAQH/BAUwAwEB
/zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFNPsxzplbszh2naaVvuc84ZtV+WB
MAoGCCqGSM49BAMDA2gAMGUCMDqLIfG9fhGt0O9Yli/W651+kI0rz2ZVwyzjKKlw
CkcO8DdZEv8tmZQoTipPNU0zWgIxAOp1AE47xDqUEpHJWEadIRNyp4iciuRMStuW
1KyLa2tJElMzrdfkviT8tQp21KW8EA==
-----END CERTIFICATE-----
# Starfield Services Root Certificate Authority - G2
-----BEGIN CERTIFICATE-----
MIID7zCCAtegAwIBAgIBADANBgkqhkiG9w0BAQsFADCBmDELMAkGA1UEBhMCVVMx
EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoT
HFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xOzA5BgNVBAMTMlN0YXJmaWVs
ZCBTZXJ2aWNlcyBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5
MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgZgxCzAJBgNVBAYTAlVTMRAwDgYD
VQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFy
ZmllbGQgVGVjaG5vbG9naWVzLCBJbmMuMTswOQYDVQQDEzJTdGFyZmllbGQgU2Vy
dmljZXMgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZI
hvcNAQEBBQADggEPADCCAQoCggEBANUMOsQq+U7i9b4Zl1+OiFOxHz/Lz58gE20p
OsgPfTz3a3Y4Y9k2YKibXlwAgLIvWX/2h/klQ4bnaRtSmpDhcePYLQ1Ob/bISdm2
8xpWriu2dBTrz/sm4xq6HZYuajtYlIlHVv8loJNwU4PahHQUw2eeBGg6345AWh1K
Ts9DkTvnVtYAcMtS7nt9rjrnvDH5RfbCYM8TWQIrgMw0R9+53pBlbQLPLJGmpufe
hRhJfGZOozptqbXuNC66DQO4M99H67FrjSXZm86B0UVGMpZwh94CDklDhbZsc7tk
6mFBrMnUVN+HL8cisibMn1lUaJ/8viovxFUcdUBgF4UCVTmLfwUCAwEAAaNCMEAw
DwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFJxfAN+q
AdcwKziIorhtSpzyEZGDMA0GCSqGSIb3DQEBCwUAA4IBAQBLNqaEd2ndOxmfZyMI
bw5hyf2E3F/YNoHN2BtBLZ9g3ccaaNnRbobhiCPPE95Dz+I0swSdHynVv/heyNXB
ve6SbzJ08pGCL72CQnqtKrcgfU28elUSwhXqvfdqlS5sdJ/PHLTyxQGjhdByPq1z
qwubdQxtRbeOlKyWN7Wg0I8VRw7j6IPdj/3vQQF3zCepYoUz8jcI73HPdwbeyBkd
iEDPfUYd/x7H4c7/I9vG+o1VTqkC50cRRj70/b17KSa7qWFiNyi2LSr2EIZkyXCn
0q23KXB56jzaYyWf/Wi3MOxw+3WKt21gZ7IeyLnp2KhvAotnDU0mV3HaIPzBSlCN
sSi6
-----END CERTIFICATE-----
CA

main() {
  local command=install arg
  for arg in "$@"; do
    case "$arg" in
      install|status|upgrade|uninstall|reveal) command="$arg" ;;
      --yes|-y) YES=1 ;;
      -h|--help|help) usage; exit 0 ;;
      *) usage >&2; exit 2 ;;
    esac
  done
  case "$command" in
    install) do_install ;;
    status) do_status ;;
    upgrade) do_upgrade ;;
    uninstall) do_uninstall ;;
    reveal) do_reveal ;;
  esac
}

main "$@"
