#!/usr/bin/env bash
# Public first-contact bootstrap for a new grape machine.
#
# This is the one ceremony. A clean machine proves once that it is King Grape,
# and everything else follows from that: an mTLS identity for the services
# behind masterbran.ch, an SSH certificate for the fleet's front door, and
# read access to grape-mods itself. Nothing is pasted in by hand any more --
# the shared read-only deploy key this replaces was one credential for the
# whole fleet, revocable only by rotating it everywhere.
#
# Both keys are generated here and never leave; only public halves go over the
# wire. Authorisation is Authelia's device-code flow (so the second factor
# lives where it already lives) plus the CA passphrase, which nothing checks --
# it decrypts the CA keys on grapelab, so a wrong one simply produces nothing.
#
# This file intentionally contains no secrets. The certificate below is the
# public trust anchor; it is here because this script runs before the repo it
# would otherwise read it from exists, and without it there is nothing to
# verify an issued certificate against. `test/static.sh` holds it to the
# repo's copy so the two cannot drift.
set -Eeuo pipefail

# An alias rather than the real host. A device identity is a read-only account
# on one repository, so a block written against `git.teets.us` itself would
# capture every other use of that server -- on a machine with admin access it
# would break pushes and unrelated clones, and on a machine that already has its
# own block for the host it would silently lose to it and never be used at all.
# `HostName` below points the alias back at the real server.
GIT_HOST_ALIAS="grape-git"
REPO_URL="${GRAPE_MODS_REPO:-ssh://$GIT_HOST_ALIAS/grape-mods.git}"
REPO_DIR="${GRAPE_MODS_DIR:-$HOME/grape-mods}"
SSH_CONFIG="$HOME/.ssh/config"
KNOWN_HOSTS="$HOME/.ssh/known_hosts"
HOST_KEY_LINE='[git.teets.us]:23231 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBJkfsjWEuxRBeAToo+XJSwTuAhvB6519WjCvNX4bI0P'

GRAPE_DIR="$HOME/.config/grape"
PKI_DIR="$GRAPE_DIR/pki"
SSH_DIR="$GRAPE_DIR/ssh"
CRED_DIR="$GRAPE_DIR/credentials"
DEVICE_KEY="$PKI_DIR/device.key"
DEVICE_CERT="$PKI_DIR/device.crt"
CA_CERT="$PKI_DIR/ca.crt"
SSH_KEY="$SSH_DIR/id_ed25519"

# Every door a machine at first contact knocks on -- Authelia and the enrollment
# endpoint both -- carries a publicly trusted certificate, precisely so first
# contact needs no trust that first contact has not yet established. Authelia
# used to be pinned to the Grape Root CA here; it cannot be, because it is also
# a login page a browser has to open, and --cacert replaces the system bundle
# rather than adding to it, so a pin to an anchor it no longer uses fails shut.
AUTHELIA_TLS=()

ISSUER="${GRAPE_AUTHELIA_ISSUER:-https://auth.masterbran.ch}"
CLIENT_ID="grape-enroll"
ENROLL_URL="${GRAPE_ENROLL_URL:-https://enroll.masterbran.ch/enrollment}"
TRUST_URL="$ENROLL_URL/trust"

# EX_CONFIG, matching lib/common.sh's GRAPE_EXIT_SKIP. Spelled out rather than
# sourced because this script is fetched before the repo that defines it.
EXIT_SKIP=78

RUN_INSTALL=1
ENROLL_ONLY=0
# Whether this device will use its identity for SSH. A phone will not, and a
# device that asks for no SSH key must not then demand a certificate over one --
# the cascade is built so a credential it cannot derive costs nothing.
WANT_SSH=1
PACKAGE_PKCS12=0

# Termux has no systemd and no pacman, so nothing is built or converged there.
# It does have OpenSSH and a real home directory, so it gets a full SSH identity
# whose private half is generated on the device and never leaves it -- the
# browser ceremony exists for phones that cannot do that, and this one can. The
# PKCS#12 is still built because Android's credential store is the only way the
# browser gets a client certificate. Detected rather than asked for, so the
# phone runs the same published command as everything else.
if [[ -n "${TERMUX_VERSION:-}" || -d /data/data/com.termux ]]; then
    PACKAGE_PKCS12=1
    ENROLL_ONLY=1
fi

log() {
    printf '[grape-bootstrap] %s\n' "$*"
}

# A terminal left to find URLs on its own scans the rendered screen for text
# shaped like one, so a URL long enough to wrap is two unrelated lines to it and
# the click target stops at the wrap -- which the verification URI, carrying a
# user code, always is. OSC 8 states the target instead of leaving it to be
# guessed. Terminals that do not implement it ignore the sequence and show the
# URL unchanged, so this is safe to emit blind; only a pipe or file, where the
# escapes would be corruption rather than markup, gets the bare string.
#
# Whether that redirection is in force has to be settled out here, once. Every
# caller reaches hyperlink() through a command substitution, and inside one
# stdout is the pipe collecting the output rather than the terminal it is bound
# for -- so a `-t 1` asked within the function is answered about the wrong file
# descriptor and always says "not a terminal".
#
# But `-t 1` alone is too strict: grape-mods runs every module through
# `2>&1 | tee <log>`, so stdout is a pipe even though the stream ends at the
# terminal a human is watching -- and that human is the whole point of the
# link. Being able to open the controlling terminal is the test for that
# human; those tee'd logs already carry the color escapes, so the link markup
# is no new noise there. A cron job or CI runner has no controlling terminal
# and still gets the bare string.
if [[ -t 1 ]] || { : </dev/tty; } 2>/dev/null; then
    HUMAN_TERMINAL=1
else
    HUMAN_TERMINAL=0
fi

hyperlink() {
    if ((HUMAN_TERMINAL)); then
        printf '\e]8;;%s\e\\%s\e]8;;\e\\' "$1" "$1"
    else
        printf '%s' "$1"
    fi
}

die() {
    printf '[grape-bootstrap] ERROR: %s\n' "$*" >&2
    exit 1
}

usage() {
    cat <<USAGE
Usage: bootstrap.sh [options]

Options:
  --enroll-only       Enrol this device and stop; do not clone or install.
  --pkcs12            Emit a PKCS#12 bundle to import, and no SSH identity.
                      Implied on Termux.
  --repo-dir PATH     Clone/update grape-mods at PATH. Default: ~/grape-mods.
  --no-install        Clone/update grape-mods but do not run its bootstrap.
  -h, --help          Show this help.

Environment:
  GRAPE_MODS_REPO         Override repo URL.
  GRAPE_MODS_DIR          Override clone directory.
  GRAPE_ENROLL_URL        Override the enrollment endpoint.
  GRAPE_DEVICE_NAME       Override the device name. Needed where the host name
                          is not one, as on Android.
  GRAPE_NONINTERACTIVE    Refuse to run the ceremony at all.
USAGE
}

while (($#)); do
    case "$1" in
        --enroll-only)
            ENROLL_ONLY=1
            shift
            ;;
        --pkcs12)
            PACKAGE_PKCS12=1
            WANT_SSH=0
            ENROLL_ONLY=1
            shift
            ;;
        --repo-dir)
            [[ $# -ge 2 ]] || die "--repo-dir requires a path"
            REPO_DIR="$2"
            shift 2
            ;;
        --no-install)
            RUN_INSTALL=0
            shift
            ;;
        -h|--help)
            usage
            exit 0
            ;;
        *)
            die "unknown option: $1"
            ;;
    esac
done

have() {
    command -v "$1" >/dev/null 2>&1
}

# The trust anchor, written out before anything needs to verify against it.
write_ca_cert() {
    mkdir -p "$PKI_DIR"
    chmod 700 "$PKI_DIR"
    cat > "$CA_CERT" <<'CACERT'
-----BEGIN CERTIFICATE-----
MIIFITCCAwmgAwIBAgIUHVheK4Fpaa3shWTTneom9c6qjLkwDQYJKoZIhvcNAQEL
BQAwGDEWMBQGA1UEAwwNR3JhcGUgUm9vdCBDQTAeFw0yNjA0MTAwOTUwNTlaFw0z
NjA0MDcwOTUwNTlaMBgxFjAUBgNVBAMMDUdyYXBlIFJvb3QgQ0EwggIiMA0GCSqG
SIb3DQEBAQUAA4ICDwAwggIKAoICAQCsFxbjCaBrw6zKsXyzgE7Gitk/TFjl0xNE
AzWE+DcGt0O1bFbz/tBVrFRk/AbAkt9TNge3hC1yOYUV0df1TxWn7H2ZVTSXAik0
q+IY5AUI2D++ha4E2SCgNCkhh6D3/k5YSB+2+BmGLyl7quznmobSQ2ePKrkfbKFl
v/Qj7whDLyk/vUmLq4COQfHh/sWXaHx+w5PtNLqb93GyOsUdhWOxG45QMUu2vh/T
3sCwl2sDyX5eobNfe2lDEeCVe3FU3CFWQ0TWUQCNub5/pbNixJtOO5KiLwHejoQF
hxdZ9dgQlRYKWOs92hdVJn/tlvLurulqmJqAzix1XXxclrMwYEAz4eUszH28ep+m
tPslZLCIW2T539+vADpNbc3HvGmTrOg7fbym81Te2HQeJeqcMUe1NzWZmmCW4JxC
xxIgf4BCcYNioFNaeZllKBrSLoUB2mClu7IJLhlIFJ80kcNVN3bp7UGpCI/cpL8x
NmK+3kS3QebfyyVuX/dXmwUeYcEzAd02p7D5008Alb1L+kPVt7PLsEPCZdBwa1/v
IBVHL0qHwgSU0YGoTEZP/7pBNafIpfSoqzJvhAo7B2Wq8/ZWoj4slhHRQRwsn8rr
g616DrDa2w2z6UJaztZLOv9vOID59Zo4xkB+7pSQiJB7N2/jzFb2tKUEHvr4fCWF
VRE71EhapwIDAQABo2MwYTAdBgNVHQ4EFgQUDQgg7AEsDG0GU/s8rBHIj/0fB/ow
HwYDVR0jBBgwFoAUDQgg7AEsDG0GU/s8rBHIj/0fB/owDgYDVR0PAQH/BAQDAgEG
MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBAEAVny/HaMk4A/qa
2u9LYbn2Fp8oLW77q+NdaM8ki7qq2AKU4E7bZ7ftuXp4vbs2a7Mi6S2xe30eakP5
azS8OHXKhH2ATmdxwmsXgpia7kTWtbb9k7U6NSqk2NUz2tcpoV8ob+39RxKpI2TX
dm72m5Flp0OgdMaHWFg2fQQiarB9vuM4wMEXjTGk4xAEQXpH7wM7A3uBwp/jglpJ
iaZ6fT66o+BIhF6KJiOyaeVCBU/PIMrfeHeYJW0AfzlhgzDXxa6+uTrai7LhmFGv
CUtWcuyQyKFW68tvUTYj3m8LnYw0rZSWkgqnpR5n1QnnWoxbqeJqH1pVEdA5s65f
PPFbTRLLSAKwwQqBEC4u3pht3CENxSMPCLeFQdbJ+G9KywpJlfoXjvQi7OH7gc3R
JvpUNNEUc8alNb8t5yeOvID33GJlqyKTSNxcljpbDCOpVc/Tu2oDHM/I9KV2EGvI
+X+Y9s27cY+Qasn5sfvYDte4Hlc45w+RY6+h3Gfc1Eo6FD5SriTPPfl+NRI6pr8g
RhU88GfR98GQ6CBn0I23dfviDFW5D7Slawl/hkpT6GABC9XwBUEo7bZxMQ4V1Q8G
ApgaNRebGmH3OI+ntIgHgGzxsIuL1FOdyEvgFZeLFu5Z+vxfJtZhniErXPmkQ38/
IHH4j9FlkcyDdxtFcKS0LntCaUpR
-----END CERTIFICATE-----
CACERT
    chmod 644 "$CA_CERT"
}

install_prereqs() {
    local needed=(git ssh openssl curl jq)
    local missing=()
    local cmd
    for cmd in "${needed[@]}"; do
        have "$cmd" || missing+=("$cmd")
    done

    if ((${#missing[@]} == 0)); then
        return
    fi

    # Before the enroll-only skip below, because `pkg` needs no sudo and asks
    # nothing: the rule that skip enforces is "never stop for a password", not
    # "never install". Termux needs no base-devel -- nothing is built there.
    if have pkg; then
        log "Installing prerequisites: ${missing[*]}"
        pkg install -y openssh openssl-tool curl jq git
        return
    fi

    # Called from an already-converged machine by the 010 module, where an
    # install must never stop to ask for a sudo password. There it is the
    # packaging module's job to have put these on the machine.
    if ((ENROLL_ONLY == 1)); then
        log "SKIP: missing ${missing[*]} — nothing to enrol with"
        exit "$EXIT_SKIP"
    fi

    if have pacman; then
        log "Installing prerequisites: ${missing[*]}"
        sudo pacman -Sy --needed git openssh openssl curl jq sudo base-devel
        return
    fi

    die "missing prerequisites (${missing[*]}) and no known package manager"
}

setup_ssh_dir() {
    mkdir -p "$HOME/.ssh"
    chmod 700 "$HOME/.ssh"
    touch "$KNOWN_HOSTS" "$SSH_CONFIG"
    chmod 600 "$KNOWN_HOSTS" "$SSH_CONFIG"
}

device_name() {
    # Android reports a host name of `localhost`, which is not a device
    # identity, so there the name has to be given rather than discovered.
    local name="${GRAPE_DEVICE_NAME:-${HOSTNAME:-$(uname -n)}}"
    name="${name%%.*}"
    [[ "$name" =~ ^[a-z][a-z0-9-]{1,30}$ ]] ||
        die "this host's short name, '$name', is not a usable device name (lowercase letters, digits and hyphens)"
    printf '%s' "$name"
}

# Validity is checked against the CA rather than mere existence, so an expired
# certificate -- or one signed by a CA since replaced -- re-enrols instead of
# leaving the machine quietly locked out.
already_enrolled() {
    [[ -f "$DEVICE_CERT" && -f "$DEVICE_KEY" ]] || return 1
    # A device that uses SSH is only enrolled if it holds the certificate too,
    # so losing half an identity re-enrols rather than half working.
    ((WANT_SSH == 0)) || [[ -f "$SSH_KEY-cert.pub" ]] || return 1
    openssl verify -CAfile "$CA_CERT" "$DEVICE_CERT" >/dev/null 2>&1
}

enroll() {
    local device_name="$1"

    # The documented way to run this is `curl ... | bash`, which hands the
    # script the pipe as its stdin -- every read below would take EOF from it
    # and the prompts would vanish the instant they appeared. Reopen stdin on
    # the terminal so the passphrase prompt gets the keyboard instead.
    if [[ ! -t 0 ]]; then
        [[ -r /dev/tty ]] || die "no terminal to prompt on — the ceremony needs a human"
        exec < /dev/tty
    fi

    # EXIT rather than RETURN: every failure below leaves through `die`, which
    # exits outright, and a RETURN trap would never fire -- leaving the freshly
    # generated private keys sitting in /tmp.
    # Not `local`: the trap body is evaluated when the trap fires, by which point
    # a function-scoped name is gone and the cleanup dies on it instead of
    # running -- leaving exactly the keys this trap exists to remove.
    scratch="$(mktemp -d)"
    chmod 700 "$scratch"
    trap 'rm -rf "$scratch"' EXIT

    log "generating this device's keys — they will not leave this machine"
    # RSA for mTLS and ED25519 for SSH: different purposes, different
    # algorithms, and neither can be used to stand in for the other.
    openssl req -new -newkey rsa:4096 -nodes \
        -keyout "$scratch/device.key" \
        -out "$scratch/device.csr" \
        -subj "/CN=$device_name" 2>"$scratch/openssl.err" ||
        die "could not generate a device key: $(cat "$scratch/openssl.err")"
    # That subject is ignored by the server, which sets the CN itself from the
    # device name it validated. It is here only because openssl req demands one.

    local ssh_public=""
    if ((WANT_SSH == 1)); then
        ssh-keygen -q -t ed25519 -N '' -C "grape@$device_name" -f "$scratch/id_ed25519" ||
            die "could not generate an SSH key"
        ssh_public="$(cat "$scratch/id_ed25519.pub")"
    fi

    # Endpoints come from discovery rather than being spelled out here, so an
    # Authelia upgrade that moves a path does not silently break enrollment.
    local discovery device_endpoint token_endpoint
    discovery="$(curl -fsS "${AUTHELIA_TLS[@]}" "$ISSUER/.well-known/openid-configuration")" ||
        die "could not reach the identity provider at $ISSUER"
    device_endpoint="$(jq -er '.device_authorization_endpoint' <<<"$discovery")"
    token_endpoint="$(jq -er '.token_endpoint' <<<"$discovery")"

    local authorization
    authorization="$(curl -fsS "${AUTHELIA_TLS[@]}" -X POST "$device_endpoint" \
        -d "client_id=$CLIENT_ID" -d "scope=openid profile groups")" ||
        die "the identity provider refused to start a device authorization"

    local device_code user_code interval expires_in verification_uri
    device_code="$(jq -er '.device_code' <<<"$authorization")"
    user_code="$(jq -er '.user_code' <<<"$authorization")"
    interval="$(jq -er '.interval // 5' <<<"$authorization")"
    expires_in="$(jq -er '.expires_in // 600' <<<"$authorization")"
    verification_uri="$(jq -er '.verification_uri_complete // .verification_uri' <<<"$authorization")"

    log "Open $(hyperlink "$verification_uri")"
    log "and confirm the code: $user_code"

    local deadline access_token="" response status
    deadline=$(( SECONDS + expires_in ))
    while (( SECONDS < deadline )); do
        sleep "$interval"
        # Not -f: the pending and slow_down responses are 400s carrying the
        # very field the loop needs to read.
        response="$(curl -sS "${AUTHELIA_TLS[@]}" -X POST "$token_endpoint" \
            -d "client_id=$CLIENT_ID" \
            -d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
            -d "device_code=$device_code" \
            -w $'\n%{http_code}')"
        status="${response##*$'\n'}"
        response="${response%$'\n'*}"

        # A device flow polls this endpoint by design, so the provider's rate
        # limiter is something to wait out rather than an enrolment that failed.
        # Its body is not the OAuth error shape, so this has to come before the
        # parse below or it lands in the catch-all and kills the ceremony.
        if (( status == 429 )); then
            interval=$(( interval + 5 ))
            continue
        fi

        access_token="$(jq -r '.access_token // empty' <<<"$response")"
        [[ -n "$access_token" ]] && break

        case "$(jq -r '.error // empty' <<<"$response")" in
            authorization_pending) ;;
            slow_down) interval=$(( interval + 5 )) ;;
            access_denied) die "authorization was declined" ;;
            expired_token) die "the code expired before it was approved" ;;
            *) die "device authorization failed: $(jq -r '.error_description // .error // .' <<<"$response")" ;;
        esac
    done
    [[ -n "$access_token" ]] || die "timed out waiting for the code to be approved"

    log "identity confirmed"

    # A mistyped passphrase is a typo, not an attack. The endpoint is already
    # rate limited at the edge, and re-running this script would give unlimited
    # attempts anyway, so ending the ceremony over one buys nothing -- it only
    # throws away a device-code approval the human has already given.
    local attempt ca_passphrase http_status=""
    for attempt in 1 2 3; do
        # Bash's readline turns on bracketed paste for the interactive shell,
        # and `read` in a script does not decode it, so a pasted passphrase
        # arrives wrapped in \e[200~ ... \e[201~ -- wrong in a way a silent
        # prompt cannot show you. Turn the mode off for the prompt, and strip
        # the markers regardless in case the terminal sends them anyway.
        printf '\e[?2004l' >&2
        read -rsp "CA passphrase: " ca_passphrase
        echo
        ca_passphrase="${ca_passphrase//$'\e[200~'/}"
        ca_passphrase="${ca_passphrase//$'\e[201~'/}"
        [[ -n "$ca_passphrase" ]] || die "no passphrase given"

        # jq builds the body so a passphrase containing quotes or backslashes
        # cannot break out of the JSON. It arrives on jq's stdin rather than as
        # --arg because argv is world-readable through /proc; the request file
        # itself is inside a mktemp dir that the trap removes.
        # A device that sends no SSH key gets no SSH credentials: the server
        # records those provisioners as failed and the identity is unaffected.
        jq -n --arg name "$device_name" \
              --arg csr "$(cat "$scratch/device.csr")" \
              --arg sshkey "$ssh_public" \
              --rawfile pass /dev/stdin \
              '{deviceName: $name, csr: $csr, caPassphrase: ($pass | rtrimstr("\n")),
                publicKeys: (if $sshkey == "" then {} else {ssh: $sshkey} end)}' \
              <<<"$ca_passphrase" > "$scratch/request.json"
        ca_passphrase=""

        http_status="$(curl -sS -o "$scratch/response.json" -w '%{http_code}' \
            -X POST "$ENROLL_URL" \
            -H "Authorization: Bearer $access_token" \
            -H "Content-Type: application/json" \
            --data-binary "@$scratch/request.json")" ||
            die "could not reach the enrollment service at $ENROLL_URL"

        if [[ "$http_status" == "201" ]]; then
            break
        fi

        # A rejected passphrase and a rejected token are both 401, so the reason
        # is what separates them. Only the passphrase is worth asking about
        # again: everything else fails identically however often it is retried.
        if [[ "$http_status" != "401" ||
              "$(jq -r '.error // empty' "$scratch/response.json" 2>/dev/null)" != "bad_passphrase" ]]; then
            die "enrollment refused ($http_status): $(jq -r '.message // .error // .' "$scratch/response.json" 2>/dev/null || cat "$scratch/response.json")"
        fi

        log "that passphrase was not accepted (attempt $attempt of 3)"
    done

    [[ "$http_status" == "201" ]] ||
        die "the CA passphrase was not accepted after 3 attempts"

    jq -er '.certificate' "$scratch/response.json" > "$scratch/device.crt"

    # The certificate is only worth keeping if it actually chains to the CA
    # this machine trusts, and the key is only installed alongside a
    # certificate that verified -- so a rejected one leaves no half-enrolled
    # state behind.
    openssl verify -CAfile "$CA_CERT" "$scratch/device.crt" >/dev/null ||
        die "the issued certificate does not verify against the Grape Root CA"

    install -m 600 "$scratch/device.key" "$DEVICE_KEY"
    install -m 644 "$scratch/device.crt" "$DEVICE_CERT"

    if ((WANT_SSH == 1)); then
        mkdir -p "$SSH_DIR"
        chmod 700 "$SSH_DIR"
        install -m 600 "$scratch/id_ed25519" "$SSH_KEY"
        install -m 644 "$scratch/id_ed25519.pub" "$SSH_KEY.pub"
    fi

    # The cascade's material is in this response and nowhere else -- the server
    # does not keep it, so losing it here means re-enrolling. Written by
    # provisioner id rather than by name, so a provisioner added on the server
    # needs no change here.
    mkdir -p "$CRED_DIR"
    chmod 700 "$CRED_DIR"
    local provisioner_id
    while read -r provisioner_id; do
        jq -er --arg id "$provisioner_id" '.credentials[$id]' "$scratch/response.json" \
            > "$scratch/credential.json"
        install -m 600 "$scratch/credential.json" "$CRED_DIR/$provisioner_id.json"
        log "provisioned $provisioner_id"
    done < <(jq -r '.credentials | keys[]' "$scratch/response.json")

    # The SSH certificate is what sshd reads, and it has to sit beside the key
    # under the name ssh(1) looks for rather than in the credentials directory.
    # Demanded only when this device asked for one, so that a device which sent
    # no SSH key is not failed over a credential it never wanted.
    if ((WANT_SSH == 1)); then
        jq -er '.credentials.ssh.material' "$scratch/response.json" \
            > "$scratch/id_ed25519-cert.pub" 2>/dev/null ||
            die "enrollment returned no SSH certificate — this device cannot reach the fleet"
        install -m 644 "$scratch/id_ed25519-cert.pub" "$SSH_KEY-cert.pub"
    fi

    if ((PACKAGE_PKCS12 == 1)); then
        package_pkcs12 "$device_name" "$scratch"
    fi

    # A provisioner that failed is reported rather than fatal: the identity is
    # the master credential and is already installed, so this is something to
    # retry, not a reason to have refused the enrollment.
    local failure
    while read -r failure; do
        log "WARNING: not provisioned: $failure"
    done < <(jq -r '.device.provisioned // [] | .[] | select(.status == "failed")
                    | "\(.provisionerId): \(.detail // "no detail")"' "$scratch/response.json")

    log "enrolled as $device_name, serial $(jq -r '.device.serial' "$scratch/response.json")"
}

# Android will not import a loose key and certificate; its credential store
# takes a PKCS#12 and insists on a password. That password protects the bundle
# between here and the import dialog and is worthless afterwards, so it is
# generated and shown rather than being one more thing to choose and remember.
# The CA passphrase is deliberately not reused: it unlocks the fleet's CAs, and
# nothing that unlocks the fleet belongs in a file on a phone.
package_pkcs12() {
    local device_name="$1" scratch="$2"
    local bundle export_password

    # Somewhere the file manager and the certificate import dialog can both see.
    local out_dir="$HOME"
    [[ -d "$HOME/storage/shared/Download" ]] && out_dir="$HOME/storage/shared/Download"
    bundle="$out_dir/grape-$device_name.p12"

    export_password="$(openssl rand -base64 12)"
    PKCS12_PASSWORD="$export_password" openssl pkcs12 -export \
        -inkey "$scratch/device.key" \
        -in "$scratch/device.crt" \
        -certfile "$CA_CERT" \
        -name "Grape ($device_name)" \
        -passout env:PKCS12_PASSWORD \
        -out "$scratch/bundle.p12" ||
        die "could not package the identity for import"

    install -m 600 "$scratch/bundle.p12" "$bundle"

    log ""
    log "Import this into Android:  $bundle"
    log "Settings > Security > Encryption & credentials > Install a certificate"
    log "  > VPN & app user certificate"
    log ""
    log "  Import password: $export_password"
    log ""
    log "Delete the file once it is imported; the key is inside it."
}

pin_git_host_key() {
    log "Pinning git.teets.us SSH host key"
    ssh-keygen -R '[git.teets.us]:23231' -f "$KNOWN_HOSTS" >/dev/null 2>&1 || true
    printf '%s\n' "$HOST_KEY_LINE" >> "$KNOWN_HOSTS"
    chmod 600 "$KNOWN_HOSTS"
}

# The anchor that lets this device recognise every host the fleet's SSH CA has
# signed, in place of a fingerprint pinned per machine. Fetched over publicly
# trusted TLS rather than from the hosts it vouches for, which would be
# circular: a forged answer would be authoritative for the whole fleet.
pin_host_ca() {
    local anchors
    anchors="$(curl -fsS "$TRUST_URL")" || {
        # The identity is already installed; without the anchor this device
        # only falls back to being asked about fingerprints.
        log "WARNING: could not fetch the host trust anchor from $TRUST_URL"
        return
    }

    local tmp
    tmp="$(mktemp)"
    touch "$KNOWN_HOSTS"
    awk '
        $0 == "# grape-bootstrap ca begin" { skip = 1; next }
        $0 == "# grape-bootstrap ca end" { skip = 0; next }
        skip != 1 { print }
    ' "$KNOWN_HOSTS" > "$tmp"

    {
        printf '# grape-bootstrap ca begin\n'
        jq -er '.anchors[].knownHostsEntry' <<<"$anchors"
        printf '# grape-bootstrap ca end\n'
    } >> "$tmp" || die "the trust endpoint returned no usable anchor"

    install -m 600 "$tmp" "$KNOWN_HOSTS"
    rm -f "$tmp"
    log "Pinned the fleet SSH CA as a host authority"
}

# The account is this device's own, not a shared one, so revoking the device
# is what takes the git access away.
write_ssh_config() {
    local device_name="$1"
    local tmp
    tmp="$(mktemp)"

    awk '
        $0 == "# grape-bootstrap begin" { skip = 1; next }
        $0 == "# grape-bootstrap end" { skip = 0; next }
        skip != 1 { print }
    ' "$SSH_CONFIG" > "$tmp"

    cat >> "$tmp" <<CONFIG
# grape-bootstrap begin
Host $GIT_HOST_ALIAS
    HostName git.teets.us
    Port 23231
    User device-$device_name
    IdentityFile $SSH_KEY
    IdentitiesOnly yes
    StrictHostKeyChecking yes
    UserKnownHostsFile $KNOWN_HOSTS
# grape-bootstrap end
CONFIG

    install -m 600 "$tmp" "$SSH_CONFIG"
    rm -f "$tmp"
}

test_repo_access() {
    log "Testing private grape-mods read access"
    GIT_SSH_COMMAND="ssh -o BatchMode=yes" git ls-remote "$REPO_URL" HEAD >/dev/null || {
        cat >&2 <<ERR
[grape-bootstrap] ERROR: could not read $REPO_URL

This device is enrolled, but the Git server rejected its identity. Do not make
grape-mods public to fix this. Check that enrollment's soft-serve provisioner
succeeded — re-running this script will re-enrol and grant it again.
ERR
        exit 1
    }
}

clone_or_update_repo() {
    if [[ -d "$REPO_DIR/.git" ]]; then
        log "Updating existing grape-mods checkout at $REPO_DIR"
        git -C "$REPO_DIR" remote set-url origin "$REPO_URL"
        git -C "$REPO_DIR" pull --ff-only
    elif [[ -e "$REPO_DIR" ]]; then
        die "$REPO_DIR exists but is not a Git checkout"
    else
        log "Cloning grape-mods into $REPO_DIR"
        git clone "$REPO_URL" "$REPO_DIR"
    fi
}

run_grape_mods() {
    if ((RUN_INSTALL == 0)); then
        log "Skipping grape-mods bootstrap because --no-install was supplied"
        return
    fi

    log "Running grape-mods bootstrap"
    bash "$REPO_DIR/bootstrap.sh"
}

main() {
    local device_name
    install_prereqs
    if ((WANT_SSH == 1)); then
        setup_ssh_dir
    fi
    write_ca_cert
    device_name="$(device_name)"

    if already_enrolled; then
        log "already enrolled as $device_name"
        if ((ENROLL_ONLY == 1)); then
            exit "$EXIT_SKIP"
        fi
    else
        # The ceremony is a human proving they are King Grape. There is nobody
        # in the VM tier to do that, and no test should be able to mint a real
        # fleet identity.
        if [[ -n "${GRAPE_NONINTERACTIVE:-}" ]]; then
            log "SKIP: enrollment needs a human at the keyboard"
            exit "$EXIT_SKIP"
        fi
        enroll "$device_name"
    fi

    # Both describe how to reach the Git server as this device. A device with no
    # SSH identity has no way to be that, and pointing IdentityFile at a key it
    # never generated would only break the ssh it does have.
    if ((WANT_SSH == 1)); then
        pin_git_host_key
        pin_host_ca
        write_ssh_config "$device_name"
    fi

    if ((ENROLL_ONLY == 1)); then
        log "Enrolled; leaving the checkout alone as --enroll-only was supplied"
        return
    fi

    test_repo_access
    clone_or_update_repo
    run_grape_mods
    log "Complete"
}

main "$@"
