diff --git a/deploy/openbao-autonomy/admin-bootstrap-openbao-autonomy-v1-v3.self-test.json b/deploy/openbao-autonomy/admin-bootstrap-openbao-autonomy-v1-v3.self-test.json new file mode 100644 index 0000000..040e6db --- /dev/null +++ b/deploy/openbao-autonomy/admin-bootstrap-openbao-autonomy-v1-v3.self-test.json @@ -0,0 +1 @@ +{"checks": {"72h_acceptance_gate": true, "approle_implemented": true, "audit_implemented": true, "backup_implemented": true, "branch_protection_gate_not_bypassable": true, "charter_store_sign_verify": true, "continuous_optimization_implemented": true, "declarative_self_initialization": true, "embedded_shell_syntax": true, "exact_checksum_duplicate_rejected": true, "exact_checksum_good_fixture": true, "exact_checksum_missing_rejected": true, "fault_injection_implemented": true, "gitea_1_22_6_rejected": true, "gitea_1_23_0_accepted": true, "gitea_tag_api_version_gate": true, "isolated_restore_implemented": true, "kv_v2_implemented": true, "machine_credential_persisted": true, "no_git_force_push": true, "no_git_tag_mutation": true, "openbao_ready_gate_not_bypassable": true, "outer_bash_syntax": true, "policy_implemented": true, "ready_not_predeclared": true, "root_token_not_persisted": true, "self_contained_no_sibling_preflight": true, "self_init_secret_removal_implemented": true, "tag_protection_gate_implemented": true, "transit_charter_key_implemented": true, "transit_runtime_key_implemented": true}, "passed": 31, "runtime_claims": false, "schema": "AIOS_OPENBAO_BOOTSTRAP_STATIC_SELF_TEST_V3", "scope": "static_and_fixture_only", "script_sha256": "4bece5edc8be5d2024b5b81ce569549625c2bf318fdaed1066773b379b76cc66", "total": 31} diff --git a/deploy/openbao-autonomy/admin-bootstrap-openbao-autonomy-v1-v3.sh b/deploy/openbao-autonomy/admin-bootstrap-openbao-autonomy-v1-v3.sh new file mode 100755 index 0000000..9881732 --- /dev/null +++ b/deploy/openbao-autonomy/admin-bootstrap-openbao-autonomy-v1-v3.sh @@ -0,0 +1,3766 @@ +#!/usr/bin/env bash +# AIOS OpenBao Autonomy Runtime V1 +# One-time, non-interactive, fail-closed bootstrap. +# +# This script is designed to be executed once by an administrator: +# sudo bash admin-bootstrap-openbao-autonomy-v1.sh +# +# It does not declare OPENBAO_AUTONOMY_BOOTSTRAP_READY. It installs and starts +# the autonomous acceptance workflow; READY may only be written by that +# workflow after the configured unattended acceptance window succeeds. +# +# Official references: +# https://openbao.org/docs/install/ +# https://openbao.org/docs/configuration/seal/static/ +# https://openbao.org/docs/configuration/self-init/ +# https://openbao.org/docs/auth/approle/ +# https://openbao.org/docs/secrets/kv/kv-v2/ +# https://openbao.org/docs/secrets/transit/ +# https://docs.gitea.com/development/api-usage +# https://docs.gitea.com/administration/command-line +# Gitea 1.23.0 release: tag-protection REST API was introduced there. + +set -Eeuo pipefail +shopt -s inherit_errexit nullglob +umask 077 +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +export PATH +export LC_ALL=C + +readonly SCRIPT_VERSION="3.2.0" +readonly SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")" +readonly SCRIPT_SHA256="$(sha256sum "$SCRIPT_PATH" | awk '{print $1}')" +readonly OPENBAO_PRIMARY_GPG_FPR="66D15FDD87287219C8E15478D200CD702853E6D0" +readonly OPENBAO_GPG_KEY_URL="https://openbao.org/assets/openbao-gpg-pub-20240618.asc" +readonly OPENBAO_RELEASE_API="https://api.github.com/repos/openbao/openbao/releases/latest" +readonly GITEA_MIN_TAG_PROTECTION_API_VERSION="1.23.0" + +readonly ROOT="${AIOS_AUTONOMY_ROOT:-/var/lib/aios-autonomy}" +readonly BAO_ROOT="${AIOS_OPENBAO_ROOT:-/var/lib/aios-openbao}" +readonly ETC_ROOT="${AIOS_ETC_ROOT:-/etc/aios}" +readonly BAO_ETC="${AIOS_OPENBAO_ETC:-$ETC_ROOT/openbao}" +readonly CREDENTIAL_ROOT="${AIOS_CREDENTIAL_ROOT:-$ETC_ROOT/credentials}" +readonly RELEASE_LOCK="${AIOS_OPENBAO_RELEASE_LOCK:-$BAO_ETC/release-lock.json}" +readonly EVIDENCE="${AIOS_BOOTSTRAP_EVIDENCE:-$ROOT/bootstrap/bootstrap-evidence.json}" +readonly STATUS_FILE="${AIOS_AUTONOMY_STATUS:-$ROOT/state/AUTONOMY_STATUS.json}" +readonly GITEA_TOKEN_FILE="${AIOS_GITEA_TOKEN_FILE:-$CREDENTIAL_ROOT/gitea-machine.token}" +readonly PACKAGE_DIR="${AIOS_PACKAGE_DIR:-$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)}" + +readonly GITEA_BIN="${AIOS_GITEA_BIN:-/data/aios-gitops/runtime/bin/gitea}" +readonly GITEA_URL="${AIOS_GITEA_URL:-http://127.0.0.1:3000}" +readonly GITEA_OWNER="${AIOS_GITEA_OWNER:-aios}" +readonly GITEA_REPO="${AIOS_GITEA_REPO:-aios}" +readonly GITEA_MACHINE_USER="${AIOS_GITEA_MACHINE_USER:-aios-autonomy-agent}" +readonly GITEA_MACHINE_EMAIL="${AIOS_GITEA_MACHINE_EMAIL:-aios-autonomy-agent@localhost}" +readonly GITEA_GIT_URL="${AIOS_GITEA_GIT_URL:-${GITEA_URL%/}/${GITEA_OWNER}/${GITEA_REPO}.git}" +readonly GITEA_CONFIG="${AIOS_GITEA_CONFIG:-}" +readonly GITEA_WORK_PATH="${AIOS_GITEA_WORK_PATH:-}" +readonly GITEA_RUN_USER="${AIOS_GITEA_RUN_USER:-}" + +readonly BAO_SERVICE="${AIOS_OPENBAO_SERVICE:-aios-openbao.service}" +readonly CONTROLLER_SERVICE="${AIOS_AUTONOMY_CONTROLLER_SERVICE:-aios-autonomy-controller.service}" +readonly BAO_HEALTH_URL="${AIOS_OPENBAO_HEALTH_URL:-https://127.0.0.1:8200/v1/sys/health}" +readonly BAO_READY_TIMEOUT="${AIOS_OPENBAO_READY_TIMEOUT:-300}" +# These are invariant acceptance gates. Environment variables with the old +# bypass names are rejected by validate_configuration(). +readonly REQUIRE_BRANCH_PROTECTION="1" +readonly REQUIRE_OPENBAO_READY="1" +readonly ALLOW_FILE_SEAL_KEY="${AIOS_ALLOW_FILE_SEAL_KEY:-1}" + +readonly SEAL_DIR="$BAO_ROOT/seal" +readonly SEAL_KEY_FILE="$SEAL_DIR/current.key" +readonly SEAL_KEY_ID_FILE="$SEAL_DIR/current.key.id" +readonly TLS_DIR="$BAO_ETC/tls" +readonly TLS_KEY="$TLS_DIR/server.key" +readonly TLS_CERT="$TLS_DIR/server.crt" +readonly TLS_CA="$TLS_DIR/ca.crt" +readonly TLS_CA_KEY="$TLS_DIR/ca.key" +readonly SEAL_CRED_FILE="$BAO_ETC/seal-key.cred" +readonly OPENBAO_CONFIG="$BAO_ETC/openbao.hcl" +readonly OPENBAO_INIT_MARKER="$ROOT/bootstrap/openbao-self-init-complete.json" +readonly MANAGED_MARKER="$ROOT/bootstrap/managed-by-openbao-autonomy-v3.json" +readonly APPROLE_ROLE_ID_FILE="$CREDENTIAL_ROOT/openbao-approle.role-id" +readonly APPROLE_SECRET_ID_FILE="$CREDENTIAL_ROOT/openbao-approle.secret-id" +readonly APPROLE_SECRET_ID_CRED="$BAO_ETC/openbao-approle-secret-id.cred" +readonly CHARTER_DIR="$BAO_ETC/charter" +readonly CHARTER_FILE="$CHARTER_DIR/aios-autonomy-charter-v1.json" +readonly CHARTER_SIGNATURE_FILE="$ROOT/state/charter-signature.json" +readonly AUDIT_DIR="/var/log/aios-openbao" +readonly AUDIT_LOG="$AUDIT_DIR/audit.jsonl" +readonly BACKUP_STATUS="$ROOT/state/backup-status.json" +readonly RESTORE_STATUS="$ROOT/state/restore-test-evidence.json" +readonly FAULT_STATUS="$ROOT/state/fault-injection-results.json" +readonly ACCEPTANCE_STATUS="$ROOT/state/unattended-72h-acceptance.json" +readonly OPTIMIZATION_LEDGER="$ROOT/optimization/optimization-ledger.jsonl" +readonly CONTROLLER_INTERVAL_FILE="$ROOT/state/controller-interval-seconds" +readonly ACCEPTANCE_START_FILE="$ROOT/state/unattended-acceptance-started-at" +readonly ACCEPTANCE_SCRIPT_FINGERPRINT="$ROOT/state/unattended-acceptance-script.sha256" +readonly LIBEXEC_DIR="/usr/local/libexec" +readonly CLIENT_LIB="$LIBEXEC_DIR/aios-openbao-client-lib" +readonly CONTROLLER_BIN="$LIBEXEC_DIR/aios-autonomy-controller" +readonly BACKUP_BIN="$LIBEXEC_DIR/aios-openbao-backup" +readonly RESTORE_TEST_BIN="$LIBEXEC_DIR/aios-openbao-restore-test" +readonly FAULT_TEST_BIN="$LIBEXEC_DIR/aios-openbao-fault-injection" +readonly OPTIMIZE_BIN="$LIBEXEC_DIR/aios-autonomy-optimize" +readonly ACCEPTANCE_BIN="$LIBEXEC_DIR/aios-autonomy-acceptance" +readonly RELEASES_DIR="/usr/local/lib/aios-openbao/versions" +readonly CURRENT_LINK="/usr/local/lib/aios-openbao/current" +readonly BAO_LINK="/usr/local/bin/bao" + +PHASE="preflight" +FAILURE_REASON="" +TEMP_DIR="" +TEMP_ADMIN_USER="" +TEMP_ADMIN_TOKEN="" +TEMP_ADMIN_TOKEN_REVOKED=false +TEMP_ADMIN_REMOVED=false +HOST_MUTATED=false +GITEA_MUTATED=false +PRODUCTION_SERVICE_STARTED=false +PRODUCTION_TRAFFIC_CHANGED=false +RELEASE_SHA256_VERIFIED=false +RELEASE_GPG_VERIFIED=false +RELEASE_SIGSTORE_VERIFIED=false +RELEASE_BINARY_HELP_VERIFIED=false +RELEASE_RUNTIME_READY_VERIFIED=false +SEAL_STORAGE="file" +SEAL_RUNTIME_PATH="$SEAL_KEY_FILE" +PENDING_LEGACY_SEAL_KEY_REMOVAL=false +SEAL_KEY_LENGTH_VERIFIED=false +SEAL_RUNTIME_READ_VERIFIED=false +SEAL_REBOOT_READ_VERIFIED=false +MACHINE_IDENTITY_CREATED=false +MACHINE_CREDENTIAL_PERSISTED=false +MACHINE_AUTHENTICATED=false +MACHINE_WRITE_PERMISSION_VERIFIED=false +MACHINE_NON_MAIN_PUSH_VERIFIED=false +DEFAULT_BRANCH_PROTECTION_VERIFIED=false +TAG_PROTECTION_VERIFIED=false +MACHINE_CREDENTIAL_STORAGE="file" +SERVICES_ENABLED=false +SERVICES_STARTED=false +SERVICES_HEALTHY=false +ACCEPTANCE_STARTED=false +OPENBAO_SELF_INIT_CONFIGURED=false +OPENBAO_INITIALIZED_VERIFIED=false +OPENBAO_UNSEALED_VERIFIED=false +OPENBAO_AUDIT_VERIFIED=false +OPENBAO_TRANSIT_VERIFIED=false +OPENBAO_KV_VERIFIED=false +OPENBAO_APPROLE_VERIFIED=false +OPENBAO_POLICY_VERIFIED=false +OPENBAO_CHARTER_STORED=false +OPENBAO_CHARTER_SIGNED=false +OPENBAO_CHARTER_SIGNATURE_VERIFIED=false +OPENBAO_INIT_SECRET_REMOVED=false +APPROLE_SECRET_STORAGE="file" +APPROLE_SECRET_RUNTIME_PATH="$APPROLE_SECRET_ID_FILE" +APPROLE_BACKUP_RUNTIME_PATH="$APPROLE_SECRET_ID_FILE" +APPROLE_FAULT_RUNTIME_PATH="$APPROLE_SECRET_ID_FILE" +APPROLE_RESTORE_RUNTIME_PATH="$APPROLE_SECRET_ID_FILE" +CLEANUP_RUNNING=false +FINAL_RC=1 +SELECTED_VERSION="" +SELECTED_ASSET="" +SELECTED_SOURCE="" +SELECTED_SHA256="" +SELECTED_SIGNING_FPR="" +DEFAULT_BRANCH="" +GITEA_VERSION="" +PREVIOUS_CURRENT_TARGET="" +BAO_WAS_ACTIVE=false +CONTROLLER_WAS_ACTIVE=false + +log() { + printf '%s %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*" >&2 +} + +die() { + FAILURE_REASON="$1" + FINAL_RC="${2:-1}" + log "FATAL phase=$PHASE reason=$FAILURE_REASON" + exit "$FINAL_RC" +} + +need() { + command -v "$1" >/dev/null 2>&1 || die "required command unavailable: $1" 69 +} + +is_true() { + case "${1,,}" in + 1|true|yes|on) return 0 ;; + *) return 1 ;; + esac +} + +validate_configuration() { + [[ "$GITEA_OWNER" =~ ^[A-Za-z0-9._-]+$ ]] || die "unsafe Gitea owner" + [[ "$GITEA_REPO" =~ ^[A-Za-z0-9._-]+$ ]] || die "unsafe Gitea repository name" + [[ "$GITEA_MACHINE_USER" =~ ^[A-Za-z0-9._-]+$ ]] || die "unsafe Gitea machine username" + [[ "$BAO_SERVICE" =~ ^[A-Za-z0-9_.@-]+\.service$ ]] || die "unsafe OpenBao service name" + [[ "$CONTROLLER_SERVICE" =~ ^[A-Za-z0-9_.@-]+\.service$ ]] || die "unsafe autonomy controller service name" + [[ "$BAO_SERVICE" == "aios-openbao.service" ]] || + die "AIOS_OPENBAO_SERVICE override is unsupported by the self-contained unit graph" + [[ "$CONTROLLER_SERVICE" == "aios-autonomy-controller.service" ]] || + die "AIOS_AUTONOMY_CONTROLLER_SERVICE override is unsupported by the self-contained unit graph" + [[ "$GITEA_URL" =~ ^https?://[^[:space:]]+$ ]] || die "invalid Gitea API URL" + [[ "$GITEA_GIT_URL" =~ ^https?://[^[:space:]]+\.git$ ]] || die "invalid Gitea Git URL" + [[ ! "$GITEA_URL" =~ ://[^/]*@ ]] || die "Gitea API URL must not embed credentials" + [[ ! "$GITEA_GIT_URL" =~ ://[^/]*@ ]] || die "Gitea Git URL must not embed credentials" + if [[ "$GITEA_URL" == http://* && ! "$GITEA_URL" =~ ^http://(127\.0\.0\.1|localhost|\[::1\])(:[0-9]+)?(/|$) ]]; then + die "plaintext Gitea API transport is allowed only on loopback" + fi + if [[ "$GITEA_GIT_URL" == http://* && ! "$GITEA_GIT_URL" =~ ^http://(127\.0\.0\.1|localhost|\[::1\])(:[0-9]+)?/ ]]; then + die "plaintext Git transport is allowed only on loopback" + fi + [[ "$BAO_READY_TIMEOUT" =~ ^[0-9]+$ && "$BAO_READY_TIMEOUT" -ge 60 ]] || + die "AIOS_OPENBAO_READY_TIMEOUT must be an integer of at least 60 seconds" + + # Historical escape hatches are intentionally rejected. These are tier-one + # acceptance gates and cannot be lowered by an environment variable. + if [[ -n "${AIOS_REQUIRE_OPENBAO_READY+x}" ]] && ! is_true "${AIOS_REQUIRE_OPENBAO_READY}"; then + die "AIOS_REQUIRE_OPENBAO_READY cannot disable the OpenBao readiness gate" + fi + if [[ -n "${AIOS_REQUIRE_BRANCH_PROTECTION+x}" ]] && ! is_true "${AIOS_REQUIRE_BRANCH_PROTECTION}"; then + die "AIOS_REQUIRE_BRANCH_PROTECTION cannot disable branch protection" + fi + + local path + for path in \ + "$ROOT" "$BAO_ROOT" "$ETC_ROOT" "$BAO_ETC" "$CREDENTIAL_ROOT" \ + "$RELEASE_LOCK" "$EVIDENCE" "$STATUS_FILE" "$GITEA_TOKEN_FILE" \ + "$APPROLE_ROLE_ID_FILE" "$APPROLE_SECRET_ID_FILE" "$APPROLE_SECRET_ID_CRED" \ + "$CHARTER_FILE" "$AUDIT_DIR" "$AUDIT_LOG" "$BACKUP_STATUS" \ + "$RESTORE_STATUS" "$FAULT_STATUS" "$ACCEPTANCE_STATUS" \ + "$OPTIMIZATION_LEDGER" "$ACCEPTANCE_SCRIPT_FINGERPRINT" "$PACKAGE_DIR"; do + [[ "$path" == /* && "$path" != *$'\n'* ]] || die "unsafe non-absolute path: $path" + done +} + +random_secret() { + python3 - <<'PY' +import secrets +print(secrets.token_urlsafe(48)) +PY +} + +atomic_json_write() { + local target="$1" + local mode="${2:-0600}" + local owner="${3:-root}" + local group="${4:-root}" + local temp + temp="$(mktemp "${target}.tmp.XXXXXX")" + cat >"$temp" + python3 -m json.tool "$temp" >/dev/null + chown "$owner:$group" "$temp" + chmod "$mode" "$temp" + mv -f "$temp" "$target" +} + +write_status() { + local state="$1" + local reason="$2" + local evidence_group="root" + getent group aios-autonomy >/dev/null 2>&1 && evidence_group="aios-autonomy" + install -d -m 0750 -o root -g "$evidence_group" "$ROOT/state" + python3 - "$state" "$reason" "$SELECTED_VERSION" <<'PY' | atomic_json_write "$STATUS_FILE" 0640 root "$evidence_group" +import json, sys, time +state, reason, version = sys.argv[1:] +print(json.dumps({ + "schema": "AIOS_AUTONOMY_STATUS_V1", + "state": state, + "reason": reason, + "openbao_version": version or None, + "last_health_check": None, + "last_successful_backup": None, + "last_restore_test": None, + "last_update_check": None, + "last_optimization": None, + "active_git_branch": None, + "production_change_in_progress": False, + "human_action_required": False, + "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), +}, sort_keys=True)) +PY +} + +write_evidence() { + local rc="$1" + local result="FAILED" + [[ "$rc" -eq 0 ]] && result="PARTIALLY_COMPLETED" + + local evidence_group="root" + getent group aios-autonomy >/dev/null 2>&1 && evidence_group="aios-autonomy" + install -d -m 0750 -o root -g "$evidence_group" "$ROOT/bootstrap" + env \ + AIOS_E_RESULT="$result" \ + AIOS_E_RC="$rc" \ + AIOS_E_PHASE="$PHASE" \ + AIOS_E_FAILURE="$FAILURE_REASON" \ + AIOS_E_SCRIPT_VERSION="$SCRIPT_VERSION" \ + AIOS_E_SCRIPT_SHA256="$SCRIPT_SHA256" \ + AIOS_E_VERSION="$SELECTED_VERSION" \ + AIOS_E_ASSET="$SELECTED_ASSET" \ + AIOS_E_SOURCE="$SELECTED_SOURCE" \ + AIOS_E_SHA="$SELECTED_SHA256" \ + AIOS_E_SIGNING_FPR="$SELECTED_SIGNING_FPR" \ + AIOS_E_SHA_OK="$RELEASE_SHA256_VERIFIED" \ + AIOS_E_GPG_OK="$RELEASE_GPG_VERIFIED" \ + AIOS_E_SIGSTORE_OK="$RELEASE_SIGSTORE_VERIFIED" \ + AIOS_E_BINARY_OK="$RELEASE_BINARY_HELP_VERIFIED" \ + AIOS_E_RUNTIME_OK="$RELEASE_RUNTIME_READY_VERIFIED" \ + AIOS_E_SEAL_STORAGE="$SEAL_STORAGE" \ + AIOS_E_SEAL_LEN="$SEAL_KEY_LENGTH_VERIFIED" \ + AIOS_E_SEAL_READ="$SEAL_RUNTIME_READ_VERIFIED" \ + AIOS_E_SEAL_REBOOT="$SEAL_REBOOT_READ_VERIFIED" \ + AIOS_E_OPENBAO_SELF_INIT_CONFIGURED="$OPENBAO_SELF_INIT_CONFIGURED" \ + AIOS_E_OPENBAO_INITIALIZED="$OPENBAO_INITIALIZED_VERIFIED" \ + AIOS_E_OPENBAO_UNSEALED="$OPENBAO_UNSEALED_VERIFIED" \ + AIOS_E_OPENBAO_AUDIT="$OPENBAO_AUDIT_VERIFIED" \ + AIOS_E_OPENBAO_TRANSIT="$OPENBAO_TRANSIT_VERIFIED" \ + AIOS_E_OPENBAO_KV="$OPENBAO_KV_VERIFIED" \ + AIOS_E_OPENBAO_APPROLE="$OPENBAO_APPROLE_VERIFIED" \ + AIOS_E_OPENBAO_POLICY="$OPENBAO_POLICY_VERIFIED" \ + AIOS_E_OPENBAO_CHARTER_STORED="$OPENBAO_CHARTER_STORED" \ + AIOS_E_OPENBAO_CHARTER_SIGNED="$OPENBAO_CHARTER_SIGNED" \ + AIOS_E_OPENBAO_CHARTER_VERIFIED="$OPENBAO_CHARTER_SIGNATURE_VERIFIED" \ + AIOS_E_OPENBAO_INIT_SECRET_REMOVED="$OPENBAO_INIT_SECRET_REMOVED" \ + AIOS_E_GITEA_VERSION="$GITEA_VERSION" \ + AIOS_E_MACHINE_CREATED="$MACHINE_IDENTITY_CREATED" \ + AIOS_E_MACHINE_CRED="$MACHINE_CREDENTIAL_PERSISTED" \ + AIOS_E_MACHINE_AUTH="$MACHINE_AUTHENTICATED" \ + AIOS_E_MACHINE_WRITE="$MACHINE_WRITE_PERMISSION_VERIFIED" \ + AIOS_E_MACHINE_PUSH="$MACHINE_NON_MAIN_PUSH_VERIFIED" \ + AIOS_E_BRANCH_PROTECTED="$DEFAULT_BRANCH_PROTECTION_VERIFIED" \ + AIOS_E_TAG_PROTECTED="$TAG_PROTECTION_VERIFIED" \ + AIOS_E_MACHINE_CRED_STORAGE="$MACHINE_CREDENTIAL_STORAGE" \ + AIOS_E_TEMP_TOKEN_REVOKED="$TEMP_ADMIN_TOKEN_REVOKED" \ + AIOS_E_TEMP_ADMIN_REMOVED="$TEMP_ADMIN_REMOVED" \ + AIOS_E_SERVICES_ENABLED="$SERVICES_ENABLED" \ + AIOS_E_SERVICES_STARTED="$SERVICES_STARTED" \ + AIOS_E_SERVICES_HEALTHY="$SERVICES_HEALTHY" \ + AIOS_E_ACCEPTANCE_STARTED="$ACCEPTANCE_STARTED" \ + AIOS_E_HOST_MUTATED="$HOST_MUTATED" \ + AIOS_E_GITEA_MUTATED="$GITEA_MUTATED" \ + AIOS_E_SERVICE_STARTED="$PRODUCTION_SERVICE_STARTED" \ + AIOS_E_TRAFFIC_CHANGED="$PRODUCTION_TRAFFIC_CHANGED" \ + python3 - <<'PY' | atomic_json_write "$EVIDENCE" 0640 root "$evidence_group" +import json, os, time +b = lambda name: os.environ.get(name, "false").lower() == "true" +i = lambda name: int(os.environ.get(name, "1")) +out = { + "schema": "AIOS_OPENBAO_BOOTSTRAP_EVIDENCE_V2", + "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "execution_status": os.environ["AIOS_E_RESULT"], + "exit_code": i("AIOS_E_RC"), + "phase": os.environ.get("AIOS_E_PHASE"), + "failure_reason": os.environ.get("AIOS_E_FAILURE") or None, + "script_version": os.environ["AIOS_E_SCRIPT_VERSION"], + "script_sha256": os.environ["AIOS_E_SCRIPT_SHA256"], + "target_state": { + "name": "OPENBAO_AUTONOMY_BOOTSTRAP_READY", + "value": False, + "reason": "72-hour unattended acceptance has not completed", + }, + "release": { + "version": os.environ.get("AIOS_E_VERSION") or None, + "asset": os.environ.get("AIOS_E_ASSET") or None, + "source": os.environ.get("AIOS_E_SOURCE") or None, + "sha256": os.environ.get("AIOS_E_SHA") or None, + "signing_fingerprint": os.environ.get("AIOS_E_SIGNING_FPR") or None, + "sha256_verified": b("AIOS_E_SHA_OK"), + "gpg_verified": b("AIOS_E_GPG_OK"), + "sigstore_verified": b("AIOS_E_SIGSTORE_OK"), + "binary_help_verified": b("AIOS_E_BINARY_OK"), + "runtime_ready_verified": b("AIOS_E_RUNTIME_OK"), + }, + "seal": { + "storage": os.environ.get("AIOS_E_SEAL_STORAGE", "unknown"), + "key_length_verified": b("AIOS_E_SEAL_LEN"), + "runtime_read_verified": b("AIOS_E_SEAL_READ"), + "reboot_read_verified": b("AIOS_E_SEAL_REBOOT"), + }, + "openbao": { + "self_init_configured": b("AIOS_E_OPENBAO_SELF_INIT_CONFIGURED"), + "initialized_verified": b("AIOS_E_OPENBAO_INITIALIZED"), + "unsealed_verified": b("AIOS_E_OPENBAO_UNSEALED"), + "audit_verified": b("AIOS_E_OPENBAO_AUDIT"), + "transit_verified": b("AIOS_E_OPENBAO_TRANSIT"), + "kv_v2_verified": b("AIOS_E_OPENBAO_KV"), + "approle_verified": b("AIOS_E_OPENBAO_APPROLE"), + "policy_verified": b("AIOS_E_OPENBAO_POLICY"), + "charter_stored": b("AIOS_E_OPENBAO_CHARTER_STORED"), + "charter_signed": b("AIOS_E_OPENBAO_CHARTER_SIGNED"), + "charter_signature_verified": b("AIOS_E_OPENBAO_CHARTER_VERIFIED"), + "self_init_secret_removed": b("AIOS_E_OPENBAO_INIT_SECRET_REMOVED"), + }, + "gitea": { + "version": os.environ.get("AIOS_E_GITEA_VERSION") or None, + "minimum_tag_protection_api_version": "1.23.0", + "machine_identity_created": b("AIOS_E_MACHINE_CREATED"), + "machine_credential_persisted": b("AIOS_E_MACHINE_CRED"), + "machine_authenticated": b("AIOS_E_MACHINE_AUTH"), + "write_permission_verified": b("AIOS_E_MACHINE_WRITE"), + "non_main_push_verified": b("AIOS_E_MACHINE_PUSH"), + "default_branch_protection_verified": b("AIOS_E_BRANCH_PROTECTED"), + "tag_protection_verified": b("AIOS_E_TAG_PROTECTED"), + "machine_credential_storage": os.environ.get("AIOS_E_MACHINE_CRED_STORAGE", "unknown"), + "temporary_token_revoked": b("AIOS_E_TEMP_TOKEN_REVOKED"), + "temporary_admin_removed": b("AIOS_E_TEMP_ADMIN_REMOVED"), + }, + "services": { + "enabled": b("AIOS_E_SERVICES_ENABLED"), + "started": b("AIOS_E_SERVICES_STARTED"), + "healthy": b("AIOS_E_SERVICES_HEALTHY"), + "unattended_acceptance_started": b("AIOS_E_ACCEPTANCE_STARTED"), + }, + "mutations": { + "host_mutated": b("AIOS_E_HOST_MUTATED"), + "gitea_mutated": b("AIOS_E_GITEA_MUTATED"), + "production_service_started": b("AIOS_E_SERVICE_STARTED"), + "production_traffic_changed": b("AIOS_E_TRAFFIC_CHANGED"), + }, + "governance": { + "force_push_used": False, + "tag_rewritten": False, + "direct_main_push": False, + "secrets_committed": False, + "owner_approval_respected": True, + }, +} +print(json.dumps(out, sort_keys=True)) +PY +} + +gitea_global_args=() + +configure_gitea_cli() { + [[ -x "$GITEA_BIN" ]] || die "local Gitea CLI unavailable: $GITEA_BIN" + gitea_global_args=() + [[ -n "$GITEA_CONFIG" ]] && gitea_global_args+=(--config "$GITEA_CONFIG") + [[ -n "$GITEA_WORK_PATH" ]] && gitea_global_args+=(--work-path "$GITEA_WORK_PATH") +} + +gitea_cli() { + if [[ -n "$GITEA_RUN_USER" && "$GITEA_RUN_USER" != "root" ]]; then + runuser -u "$GITEA_RUN_USER" -- "$GITEA_BIN" "${gitea_global_args[@]}" "$@" + else + "$GITEA_BIN" "${gitea_global_args[@]}" "$@" + fi +} + +gitea_user_exists() { + local username="$1" + local out="$TEMP_DIR/gitea-users.txt" + if gitea_cli admin user list --help 2>&1 | grep -q -- '--csv'; then + gitea_cli admin user list --csv >"$out" + python3 - "$out" "$username" <<'PY' +import csv, sys +path, wanted = sys.argv[1:] +with open(path, newline="") as f: + rows = csv.reader(f) + for row in rows: + if wanted in row: + raise SystemExit(0) +raise SystemExit(1) +PY + else + gitea_cli admin user list >"$out" + awk -v wanted="$username" ' + { + for (i=1; i<=NF; i++) if ($i==wanted) found=1 + } + END { exit(found ? 0 : 1) } + ' "$out" + fi +} + +curl_config_token() { + local file="$1" + local token_file="$2" + local token + token="$(cat "$token_file")" + [[ -n "$token" ]] || die "empty token file: $token_file" + [[ "$token" =~ ^[A-Za-z0-9._~-]+$ ]] || die "token contains unexpected characters" + { + printf '%s\n' "silent" "show-error" "location" + printf 'header = "Authorization: token %s"\n' "$token" + printf '%s\n' 'header = "Content-Type: application/json"' + } >"$file" + chmod 0600 "$file" + unset token +} + +api_with_config() { + local config="$1" + local method="$2" + local path="$3" + local output="$4" + local data_file="${5:-}" + local args=(--config "$config" --fail --request "$method" --output "$output") + [[ -n "$data_file" ]] && args+=(--data-binary "@$data_file") + curl "${args[@]}" "${GITEA_URL%/}/api/v1$path" +} + +fetch_paginated_json_array() { + local config="$1" + local base_path="$2" + local output="$3" + local page_dir + page_dir="$(mktemp -d "$TEMP_DIR/gitea-pages.XXXXXX")" + printf '%s\n' '[]' >"$output" + + local page=1 + local completed=false + while ((page <= 100)); do + local page_file="$page_dir/page-$page.json" + local separator="?" + [[ "$base_path" == *\?* ]] && separator="&" + api_with_config "$config" GET \ + "${base_path}${separator}limit=50&page=$page" \ + "$page_file" + + local count + count="$( + python3 - "$page_file" <<'PY' +import json, sys +items = json.load(open(sys.argv[1])) +if not isinstance(items, list): + raise SystemExit("paginated endpoint did not return an array") +print(len(items)) +PY + )" + + local merged="$page_dir/merged-$page.json" + python3 - "$output" "$page_file" "$merged" <<'PY' +import json, sys +left = json.load(open(sys.argv[1])) +right = json.load(open(sys.argv[2])) +if not isinstance(left, list) or not isinstance(right, list): + raise SystemExit("invalid pagination input") +json.dump(left + right, open(sys.argv[3], "w"), separators=(",", ":")) +PY + mv -f "$merged" "$output" + + if [[ "$count" == "0" ]]; then + completed=true + break + fi + page=$((page + 1)) + done + + [[ "$completed" == true ]] || + die "Gitea pagination exceeded 100 pages; refusing incomplete authorization proof" + rm -rf "$page_dir" +} + +cleanup_gitea_credentials() { + set +e + + if [[ -n "$TEMP_ADMIN_USER" && "$TEMP_ADMIN_REMOVED" != true ]]; then + if gitea_user_exists "$TEMP_ADMIN_USER"; then + gitea_cli admin user delete --username "$TEMP_ADMIN_USER" >/dev/null 2>&1 + fi + if ! gitea_user_exists "$TEMP_ADMIN_USER"; then + TEMP_ADMIN_REMOVED=true + fi + fi + + if [[ -n "$TEMP_ADMIN_TOKEN" && "$TEMP_ADMIN_REMOVED" == true && "$TEMP_ADMIN_TOKEN_REVOKED" != true ]]; then + local token_file="$TEMP_DIR/temp-admin-revocation-check.token" + local cfg="$TEMP_DIR/temp-admin-revocation-check.curl" + local out="$TEMP_DIR/temp-admin-revocation-check.json" + local http_code + printf '%s' "$TEMP_ADMIN_TOKEN" >"$token_file" + chmod 0600 "$token_file" + curl_config_token "$cfg" "$token_file" + http_code="$( + curl --config "$cfg" --request GET --output "$out" --write-out '%{http_code}' \ + "${GITEA_URL%/}/api/v1/user" 2>/dev/null + )" + if [[ "$http_code" == "401" || "$http_code" == "403" ]]; then + TEMP_ADMIN_TOKEN_REVOKED=true + fi + fi + + if [[ "$TEMP_ADMIN_TOKEN_REVOKED" == true ]]; then + TEMP_ADMIN_TOKEN="" + fi + + set -e +} + +rollback_binary_link() { + set +e + if [[ -n "$PREVIOUS_CURRENT_TARGET" && -e "$PREVIOUS_CURRENT_TARGET" ]]; then + ln -sfn "$PREVIOUS_CURRENT_TARGET" "$CURRENT_LINK" + ln -sfn "$CURRENT_LINK/bao" "$BAO_LINK" + fi + set -e +} + +cleanup() { + local rc=$? + trap - EXIT INT TERM + CLEANUP_RUNNING=true + + cleanup_gitea_credentials + + if [[ "$rc" -ne 0 ]]; then + set +e + if [[ "$PRODUCTION_SERVICE_STARTED" == true && "$SERVICES_HEALTHY" != true ]]; then + systemctl stop "$CONTROLLER_SERVICE" "$BAO_SERVICE" >/dev/null 2>&1 || true + fi + rollback_binary_link + if [[ "$BAO_WAS_ACTIVE" == true ]]; then + systemctl start "$BAO_SERVICE" >/dev/null 2>&1 || true + fi + if [[ "$CONTROLLER_WAS_ACTIVE" == true ]]; then + systemctl start "$CONTROLLER_SERVICE" >/dev/null 2>&1 || true + fi + set -e + fi + + if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then + find "$TEMP_DIR" -type f -exec shred -u {} + 2>/dev/null || true + rm -rf -- "$TEMP_DIR" + fi + + if [[ "$rc" -eq 0 ]]; then + FINAL_RC=0 + FAILURE_REASON="" + else + FINAL_RC="$rc" + [[ -n "$FAILURE_REASON" ]] || FAILURE_REASON="unexpected failure in phase $PHASE" + fi + + write_evidence "$FINAL_RC" || true + + if [[ "$FINAL_RC" -eq 0 ]]; then + write_status "VERIFYING" "BOOTSTRAP_STARTED_AUTONOMOUS_ACCEPTANCE" || true + log "BOOTSTRAP_STARTED_AUTONOMOUS_ACCEPTANCE" + else + write_status "BLOCKED" "$FAILURE_REASON" || true + log "BLOCKED: $FAILURE_REASON" + fi + + exit "$FINAL_RC" +} + +on_err() { + local rc=$? + local line="$1" + FAILURE_REASON="${FAILURE_REASON:-unexpected command failure at line $line}" + return "$rc" +} + +trap 'on_err "$LINENO"' ERR +trap cleanup EXIT +trap 'FAILURE_REASON="interrupted"; exit 130' INT TERM + +preflight() { + PHASE="preflight" + [[ "${EUID:-$(id -u)}" -eq 0 ]] || die "root is required" 77 + validate_configuration + + for cmd in awk base64 cat chmod chown cp curl date find getent git gpg grep groupadd head hostname install ln mktemp openssl python3 readlink runuser sed sha256sum shred sort stat systemctl tar timeout tr useradd wc; do + need "$cmd" + done + + [[ -d /run/systemd/system ]] || die "systemd is not PID 1 on the target host" + systemctl show-environment >/dev/null 2>&1 || die "systemd service manager unavailable" + + systemctl is-active --quiet "$BAO_SERVICE" && BAO_WAS_ACTIVE=true || true + systemctl is-active --quiet "$CONTROLLER_SERVICE" && CONTROLLER_WAS_ACTIVE=true || true + if [[ "$BAO_WAS_ACTIVE" == true || "$CONTROLLER_WAS_ACTIVE" == true ]]; then + [[ -s "$MANAGED_MARKER" ]] || + die "target service is active but is not marked as managed by this bootstrap" + fi + + TEMP_DIR="$(mktemp -d "${XDG_RUNTIME_DIR:-/run}/aios-openbao-bootstrap.XXXXXX")" + chmod 0700 "$TEMP_DIR" + + configure_gitea_cli + gitea_cli --version >"$TEMP_DIR/gitea-version.txt" + GITEA_VERSION="$( + python3 - "$TEMP_DIR/gitea-version.txt" <<'PY' +import re +import sys +text = open(sys.argv[1], encoding="utf-8", errors="replace").read() +match = re.search(r"\bGitea version\s+v?([0-9]+\.[0-9]+\.[0-9]+)", text, re.I) +if not match: + raise SystemExit("unable to parse Gitea semantic version") +print(match.group(1)) +PY + )" + + python3 - "$GITEA_VERSION" "$GITEA_MIN_TAG_PROTECTION_API_VERSION" <<'PY' || +import sys + +def version_tuple(value): + parts = value.split(".") + if len(parts) != 3 or any(not p.isdigit() for p in parts): + raise SystemExit(f"invalid semantic version: {value}") + return tuple(int(p) for p in parts) + +actual = version_tuple(sys.argv[1]) +minimum = version_tuple(sys.argv[2]) +raise SystemExit(0 if actual >= minimum else 1) +PY + die "GITEA_TAG_PROTECTION_API_UNAVAILABLE: Gitea $GITEA_VERSION is below required $GITEA_MIN_TAG_PROTECTION_API_VERSION; no safe native REST path exists to deny machine tag writes" + + # The compatibility gate above intentionally runs before persistent AIOS + # bootstrap directories, identities, credentials, repository permissions, or + # services are changed. + install -d -m 0750 -o root -g root "$ROOT" "$ROOT/bootstrap" + + gitea_cli admin user generate-access-token --help >"$TEMP_DIR/gitea-token-help.txt" 2>&1 + grep -q -- '--scopes' "$TEMP_DIR/gitea-token-help.txt" || die "Gitea CLI lacks scoped access-token support" + grep -q -- '--raw' "$TEMP_DIR/gitea-token-help.txt" || die "Gitea CLI lacks raw access-token output" + gitea_cli admin user create --help >"$TEMP_DIR/gitea-user-create-help.txt" 2>&1 + grep -q -- '--random-password' "$TEMP_DIR/gitea-user-create-help.txt" || die "Gitea CLI lacks random-password user creation" + +} + +install_identities_and_directories() { + PHASE="identities-and-directories" + + getent group aios-openbao >/dev/null || groupadd --system aios-openbao + getent passwd aios-openbao >/dev/null || useradd --system --gid aios-openbao --home "$BAO_ROOT" --shell /usr/sbin/nologin aios-openbao + + getent group aios-autonomy >/dev/null || groupadd --system aios-autonomy + getent passwd aios-autonomy >/dev/null || useradd --system --gid aios-autonomy --home "$ROOT" --shell /usr/sbin/nologin aios-autonomy + + install -d -m 0750 -o root -g aios-autonomy "$ROOT" "$ROOT/bootstrap" + install -d -m 0770 -o aios-autonomy -g aios-autonomy "$ROOT/state" "$ROOT/log" "$ROOT/optimization" + install -d -m 0770 -o root -g aios-openbao "$ROOT/backups" "$ROOT/restore-tests" + install -d -m 0750 -o aios-openbao -g aios-openbao "$BAO_ROOT" "$BAO_ROOT/raft" + install -d -m 0750 -o root -g aios-openbao "$SEAL_DIR" + install -d -m 0750 -o root -g aios-openbao "$BAO_ETC" "$TLS_DIR" "$CHARTER_DIR" + install -d -m 0750 -o root -g aios-autonomy "$CREDENTIAL_ROOT" + install -d -m 0750 -o aios-openbao -g aios-openbao "$AUDIT_DIR" + install -d -m 0755 -o root -g root "$RELEASES_DIR" "$(dirname "$CURRENT_LINK")" "$(dirname "$BAO_LINK")" "$LIBEXEC_DIR" + + touch "$AUDIT_LOG" + chown aios-openbao:aios-openbao "$AUDIT_LOG" + chmod 0600 "$AUDIT_LOG" + + HOST_MUTATED=true + write_status "BOOTSTRAPPING" "identities and directories installed" +} + +capture_environment() { + PHASE="environment-capture" + local environment="$ROOT/bootstrap/environment.json" + env \ + AIOS_ENV_GITEA_BIN="$GITEA_BIN" \ + AIOS_ENV_GITEA_URL="$GITEA_URL" \ + python3 - <<'PY' | atomic_json_write "$environment" 0640 root aios-autonomy +import json, os, platform, shutil, subprocess, time +def first(command): + try: + return subprocess.check_output(command, text=True, stderr=subprocess.DEVNULL).splitlines()[0] + except Exception: + return None +out = { + "schema": "AIOS_OPENBAO_ENVIRONMENT_V2", + "detected_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "system": platform.platform(), + "architecture": platform.machine(), + "systemd": first(["systemctl", "--version"]), + "gitea": first([os.environ["AIOS_ENV_GITEA_BIN"], "--version"]), + "gitea_url": os.environ["AIOS_ENV_GITEA_URL"], + "tpm2_tools": bool(shutil.which("tpm2_getcap")), + "systemd_creds": bool(shutil.which("systemd-creds")), + "cosign": bool(shutil.which("cosign")), + "selinux": first(["getenforce"]) if shutil.which("getenforce") else None, +} +print(json.dumps(out, sort_keys=True)) +PY +} + +select_release() { + PHASE="release-selection" + + local release_json="$TEMP_DIR/release.json" + local lock_mode=false + + if [[ -s "$RELEASE_LOCK" ]]; then + python3 -m json.tool "$RELEASE_LOCK" >/dev/null || die "existing release lock is invalid JSON" + read -r SELECTED_VERSION SELECTED_ASSET SELECTED_SOURCE < <( + python3 - "$RELEASE_LOCK" <<'PY' +import json, sys +x = json.load(open(sys.argv[1])) +for key in ("version", "artifact", "source"): + if not isinstance(x.get(key), str) or not x[key]: + raise SystemExit("invalid release lock") +print(x["version"], x["artifact"], x["source"]) +PY + ) + lock_mode=true + log "reusing locked OpenBao release version=$SELECTED_VERSION asset=$SELECTED_ASSET" + else + curl --fail --silent --show-error --location \ + --retry 4 --retry-delay 2 --retry-connrefused --connect-timeout 15 --max-time 120 \ + "$OPENBAO_RELEASE_API" -o "$release_json" + + read -r SELECTED_VERSION SELECTED_ASSET SELECTED_SOURCE < <( + python3 - "$release_json" <<'PY' +import json, platform, re, sys +r = json.load(open(sys.argv[1])) +tag = str(r.get("tag_name", "")) +if r.get("draft") or r.get("prerelease") or not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", tag): + raise SystemExit("latest release is not a stable semantic version") +version = tag[1:] +arch = {"x86_64":"amd64", "amd64":"amd64", "aarch64":"arm64", "arm64":"arm64"}.get(platform.machine().lower()) +if not arch: + raise SystemExit("unsupported architecture") +assets = {a["name"]: a["browser_download_url"] for a in r.get("assets", [])} +candidates = [ + f"openbao_{version}_linux_{arch}.tar.gz", + f"bao_{version}_linux_{arch}.tar.gz", + f"bao_{version}_Linux_{arch}.tar.gz", +] +matches = [name for name in candidates if name in assets] +if len(matches) != 1: + raise SystemExit("expected exactly one compatible Linux archive") +name = matches[0] +print(version, name, assets[name]) +PY + ) || die "unable to select one official stable OpenBao release asset" + fi + + [[ "$SELECTED_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "invalid selected release version" + python3 - "$SELECTED_VERSION" <<'PY' +import sys +parts=tuple(int(x) for x in sys.argv[1].split(".")) +raise SystemExit(0 if parts >= (2, 6, 0) else "OpenBao 2.6.0 or newer is required for declarative self-initialization") +PY + [[ "$SELECTED_ASSET" != */* && "$SELECTED_ASSET" =~ \.tar\.gz$ ]] || die "unsafe selected asset name" + [[ "$SELECTED_SOURCE" == https://github.com/openbao/openbao/releases/download/* ]] || die "release source is not an official OpenBao GitHub release URL" + + local release_base="${SELECTED_SOURCE%/*}" + printf '%s\n' "$lock_mode" >"$TEMP_DIR/release-lock-mode" + printf '%s\n' "$release_base/checksums.txt" >"$TEMP_DIR/checksums-url" + printf '%s\n' "$release_base/checksums.txt.gpgsig" >"$TEMP_DIR/gpgsig-url" + printf '%s\n' "$release_base/checksums.txt.sigstore.json" >"$TEMP_DIR/sigstore-url" +} + +download_and_verify_release() { + PHASE="release-verification" + + local archive="$TEMP_DIR/$SELECTED_ASSET" + local checksums="$TEMP_DIR/checksums.txt" + local gpgsig="$TEMP_DIR/checksums.txt.gpgsig" + local sigstore="$TEMP_DIR/checksums.txt.sigstore.json" + local gpg_key="$TEMP_DIR/openbao-signing-key.asc" + local gnupg="$TEMP_DIR/gnupg" + local checksum_line="$TEMP_DIR/selected.checksum" + + curl --fail --silent --show-error --location --retry 4 --retry-delay 2 --retry-connrefused --connect-timeout 15 --max-time 600 "$SELECTED_SOURCE" -o "$archive" + curl --fail --silent --show-error --location --retry 4 --retry-delay 2 --retry-connrefused --connect-timeout 15 --max-time 120 "$(cat "$TEMP_DIR/checksums-url")" -o "$checksums" + curl --fail --silent --show-error --location --retry 4 --retry-delay 2 --retry-connrefused --connect-timeout 15 --max-time 120 "$(cat "$TEMP_DIR/gpgsig-url")" -o "$gpgsig" + curl --fail --silent --show-error --location --retry 4 --retry-delay 2 --retry-connrefused --connect-timeout 15 --max-time 120 "$OPENBAO_GPG_KEY_URL" -o "$gpg_key" + + python3 - "$checksums" "$SELECTED_ASSET" >"$checksum_line" <<'PY' +import re, sys +path, asset = sys.argv[1:] +if "/" in asset or "\\" in asset: + raise SystemExit("unsafe asset") +pattern = re.compile(r"^([0-9a-fA-F]{64}) ([ *])(.+)$") +matches = [] +for raw in open(path, encoding="utf-8"): + line = raw.rstrip("\n") + m = pattern.match(line) + if m and m.group(3) == asset: + matches.append((m.group(1).lower(), asset)) +if len(matches) != 1: + raise SystemExit("checksum entry count must be exactly one") +print(f"{matches[0][0]} {asset}") +PY + + SELECTED_SHA256="$(awk '{print $1}' "$checksum_line")" + ( + cd "$TEMP_DIR" + sha256sum --check "$(basename "$checksum_line")" + ) | grep -Fx "$SELECTED_ASSET: OK" >/dev/null || die "OpenBao archive SHA-256 verification failed" + RELEASE_SHA256_VERIFIED=true + + install -d -m 0700 "$gnupg" + gpg --homedir "$gnupg" --batch --quiet --import "$gpg_key" + + local imported_primary + imported_primary="$( + gpg --homedir "$gnupg" --batch --with-colons --fingerprint | + awk -F: '$1=="pub"{want=1;next} want && $1=="fpr"{print $10; exit}' + )" + [[ "$imported_primary" == "$OPENBAO_PRIMARY_GPG_FPR" ]] || die "OpenBao signing primary-key fingerprint mismatch" + + local status="$TEMP_DIR/gpg-status.txt" + gpg --homedir "$gnupg" --batch --status-fd 1 --verify "$gpgsig" "$checksums" >"$status" 2>"$TEMP_DIR/gpg-stderr.txt" || + die "OpenBao checksum GPG signature invalid" + + SELECTED_SIGNING_FPR="$( + awk '$2=="VALIDSIG"{print $NF; exit}' "$status" + )" + [[ "$SELECTED_SIGNING_FPR" == "$OPENBAO_PRIMARY_GPG_FPR" ]] || + die "valid signature is not rooted in the approved OpenBao primary key" + RELEASE_GPG_VERIFIED=true + + if command -v cosign >/dev/null 2>&1; then + if curl --fail --silent --show-error --location --retry 2 --connect-timeout 15 --max-time 120 "$(cat "$TEMP_DIR/sigstore-url")" -o "$sigstore"; then + cosign verify-blob \ + --bundle "$sigstore" \ + --certificate-oidc-issuer='https://token.actions.githubusercontent.com' \ + --certificate-identity-regexp='https://github.com/openbao/openbao/.github/workflows/release.yml@refs/heads/(main|release/)' \ + "$checksums" >/dev/null + RELEASE_SIGSTORE_VERIFIED=true + else + log "sigstore bundle unavailable; mandatory GPG verification already succeeded" + fi + fi + + tar -tzf "$archive" >"$TEMP_DIR/archive-list.txt" + python3 - "$TEMP_DIR/archive-list.txt" <<'PY' +import sys +items = [x.strip() for x in open(sys.argv[1]) if x.strip()] +bad = [x for x in items if x.startswith("/") or ".." in x.split("/")] +if bad: + raise SystemExit("unsafe archive paths") +if not any(x == "bao" or x.endswith("/bao") for x in items): + raise SystemExit("bao binary missing") +PY + + local extract="$TEMP_DIR/extract" + install -d -m 0700 "$extract" + tar -xzf "$archive" -C "$extract" + local bao_src + bao_src="$(find "$extract" -type f -name bao -perm -0100 -print -quit)" + [[ -n "$bao_src" ]] || die "extracted OpenBao binary missing" + + "$bao_src" -h >/dev/null + RELEASE_BINARY_HELP_VERIFIED=true + printf '%s\n' "$bao_src" >"$TEMP_DIR/bao-source" +} + +install_release() { + PHASE="release-installation" + + local version_dir="$RELEASES_DIR/$SELECTED_VERSION" + local bao_src + bao_src="$(cat "$TEMP_DIR/bao-source")" + + install -d -m 0755 -o root -g root "$version_dir" + if [[ -f "$version_dir/bao" ]]; then + local existing_sha new_sha + existing_sha="$(sha256sum "$version_dir/bao" | awk '{print $1}')" + new_sha="$(sha256sum "$bao_src" | awk '{print $1}')" + [[ "$existing_sha" == "$new_sha" ]] || die "installed version directory contains a different binary" + else + install -m 0755 -o root -g root "$bao_src" "$version_dir/bao" + fi + + if [[ -L "$CURRENT_LINK" ]]; then + PREVIOUS_CURRENT_TARGET="$(readlink -f "$CURRENT_LINK" || true)" + fi + + ln -sfn "$version_dir" "$CURRENT_LINK.new" + mv -Tf "$CURRENT_LINK.new" "$CURRENT_LINK" + ln -sfn "$CURRENT_LINK/bao" "$BAO_LINK.new" + mv -Tf "$BAO_LINK.new" "$BAO_LINK" + + "$BAO_LINK" -h >/dev/null + HOST_MUTATED=true + + if [[ ! -s "$RELEASE_LOCK" ]]; then + env \ + AIOS_LOCK_VERSION="$SELECTED_VERSION" \ + AIOS_LOCK_ASSET="$SELECTED_ASSET" \ + AIOS_LOCK_SOURCE="$SELECTED_SOURCE" \ + AIOS_LOCK_SHA="$SELECTED_SHA256" \ + AIOS_LOCK_FPR="$SELECTED_SIGNING_FPR" \ + AIOS_LOCK_GPG="$RELEASE_GPG_VERIFIED" \ + AIOS_LOCK_SIGSTORE="$RELEASE_SIGSTORE_VERIFIED" \ + python3 - <<'PY' | atomic_json_write "$RELEASE_LOCK" 0640 root aios-openbao +import json, os, platform, time +print(json.dumps({ + "schema": "AIOS_OPENBAO_RELEASE_LOCK_V2", + "product": "openbao", + "version": os.environ["AIOS_LOCK_VERSION"], + "platform": "linux_" + {"x86_64":"amd64","amd64":"amd64","aarch64":"arm64","arm64":"arm64"}[platform.machine().lower()], + "artifact": os.environ["AIOS_LOCK_ASSET"], + "source": os.environ["AIOS_LOCK_SOURCE"], + "sha256": os.environ["AIOS_LOCK_SHA"], + "gpg_verified": os.environ["AIOS_LOCK_GPG"] == "true", + "sigstore_verified": os.environ["AIOS_LOCK_SIGSTORE"] == "true", + "signing_primary_fingerprint": os.environ["AIOS_LOCK_FPR"], + "selected_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "binary_help_verified": True, + "runtime_ready_verified": False, +}, sort_keys=True)) +PY + else + local locked_sha + locked_sha="$(python3 - "$RELEASE_LOCK" <<'PY' +import json, sys +print(json.load(open(sys.argv[1])).get("sha256", "")) +PY +)" + [[ "$locked_sha" == "$SELECTED_SHA256" ]] || die "downloaded release does not match existing release lock" + fi +} + + +mark_release_runtime_ready() { + local temp + temp="$(mktemp "${RELEASE_LOCK}.ready.XXXXXX")" + python3 - "$RELEASE_LOCK" "$temp" <<'PY' +import json, sys, time +src, out = sys.argv[1:] +x = json.load(open(src)) +x["runtime_ready_verified"] = True +x["runtime_ready_verified_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) +json.dump(x, open(out, "w"), sort_keys=True) +PY + python3 -m json.tool "$temp" >/dev/null + chown root:aios-openbao "$temp" + chmod 0640 "$temp" + mv -f "$temp" "$RELEASE_LOCK" +} + +provision_static_seal() { + PHASE="static-seal" + + local key_material="$TEMP_DIR/seal-key.plain" + local use_systemd_credential=false + + if command -v systemd-creds >/dev/null 2>&1 && + systemd-creds --help 2>&1 | grep -q -- '--with-key='; then + use_systemd_credential=true + fi + + if [[ "$use_systemd_credential" == true ]]; then + SEAL_STORAGE="systemd-credential" + SEAL_RUNTIME_PATH="/run/credentials/${BAO_SERVICE}/seal-key" + + if [[ -s "$SEAL_CRED_FILE" ]]; then + systemd-creds decrypt "$SEAL_CRED_FILE" "$key_material" + else + if [[ -s "$SEAL_KEY_FILE" ]]; then + cp --preserve=mode,ownership "$SEAL_KEY_FILE" "$key_material" + PENDING_LEGACY_SEAL_KEY_REMOVAL=true + else + openssl rand -out "$key_material" 32 + fi + + [[ "$(wc -c <"$key_material")" -eq 32 ]] || die "static seal key must be exactly 32 bytes" + systemd-creds setup >/dev/null + systemd-creds encrypt \ + --name=seal-key \ + --with-key=auto \ + "$key_material" "$SEAL_CRED_FILE.tmp" + chown root:root "$SEAL_CRED_FILE.tmp" + chmod 0400 "$SEAL_CRED_FILE.tmp" + mv -f "$SEAL_CRED_FILE.tmp" "$SEAL_CRED_FILE" + HOST_MUTATED=true + fi + + [[ -f "$SEAL_CRED_FILE" && ! -L "$SEAL_CRED_FILE" ]] || + die "encrypted static seal credential must be a regular non-symlink file" + [[ "$(stat -c '%U:%G:%a' "$SEAL_CRED_FILE")" == "root:root:400" ]] || + die "encrypted static seal credential ownership or mode is invalid" + [[ "$(wc -c <"$key_material")" -eq 32 ]] || die "decrypted static seal key must be exactly 32 bytes" + SEAL_KEY_LENGTH_VERIFIED=true + else + is_true "$ALLOW_FILE_SEAL_KEY" || + die "systemd encrypted credentials unavailable and file seal-key fallback is disabled" + + SEAL_STORAGE="file" + SEAL_RUNTIME_PATH="$SEAL_KEY_FILE" + + if [[ ! -e "$SEAL_KEY_FILE" ]]; then + openssl rand -out "$SEAL_KEY_FILE.tmp" 32 + chown root:aios-openbao "$SEAL_KEY_FILE.tmp" + chmod 0440 "$SEAL_KEY_FILE.tmp" + mv -f "$SEAL_KEY_FILE.tmp" "$SEAL_KEY_FILE" + HOST_MUTATED=true + fi + + [[ -f "$SEAL_KEY_FILE" && ! -L "$SEAL_KEY_FILE" ]] || + die "static seal key must be a regular non-symlink file" + [[ "$(stat -c '%U:%G:%a' "$SEAL_KEY_FILE")" == "root:aios-openbao:440" ]] || + die "static seal key ownership or mode is invalid" + [[ "$(wc -c <"$SEAL_KEY_FILE")" -eq 32 ]] || die "static seal key must be exactly 32 bytes" + SEAL_KEY_LENGTH_VERIFIED=true + + runuser -u aios-openbao -- test -r "$SEAL_KEY_FILE" || + die "aios-openbao cannot read the static seal key" + runuser -u aios-openbao -- python3 - "$SEAL_KEY_FILE" <<'PY' +import sys +data = open(sys.argv[1], "rb").read() +raise SystemExit(0 if len(data) == 32 else 1) +PY + SEAL_RUNTIME_READ_VERIFIED=true + + if getent passwd nobody >/dev/null; then + if runuser -u nobody -- test -r "$SEAL_KEY_FILE" 2>/dev/null; then + die "unauthorized user nobody can read the static seal key" + fi + fi + fi + + local key_id_source expected_key_id + if [[ -s "$key_material" ]]; then + key_id_source="$key_material" + else + key_id_source="$SEAL_KEY_FILE" + fi + expected_key_id="sha256-$(sha256sum "$key_id_source" | awk '{print substr($1,1,32)}')" + + if [[ ! -s "$SEAL_KEY_ID_FILE" ]]; then + printf '%s\n' "$expected_key_id" >"$SEAL_KEY_ID_FILE.tmp" + chown root:aios-openbao "$SEAL_KEY_ID_FILE.tmp" + chmod 0440 "$SEAL_KEY_ID_FILE.tmp" + mv -f "$SEAL_KEY_ID_FILE.tmp" "$SEAL_KEY_ID_FILE" + fi + + [[ "$(stat -c '%U:%G:%a' "$SEAL_KEY_ID_FILE")" == "root:aios-openbao:440" ]] || + die "static seal key-id ownership or mode is invalid" + [[ "$(tr -d '\r\n' <"$SEAL_KEY_ID_FILE")" == "$expected_key_id" ]] || + die "static seal key-id does not match the current key material" + + if [[ -s "$key_material" ]]; then + shred -u "$key_material" 2>/dev/null || rm -f "$key_material" + fi +} + +provision_tls() { + PHASE="tls" + + local hostname_fqdn + hostname_fqdn="$(hostname -f 2>/dev/null || hostname)" + local regenerate_ca=false + local regenerate_server=false + + if [[ ! -s "$TLS_CA_KEY" || ! -s "$TLS_CA" ]]; then + regenerate_ca=true + else + openssl x509 -checkend 2592000 -noout -in "$TLS_CA" >/dev/null 2>&1 || regenerate_ca=true + local ca_cert_pub ca_key_pub + ca_cert_pub="$(openssl x509 -in "$TLS_CA" -pubkey -noout | openssl pkey -pubin -outform DER 2>/dev/null | sha256sum | awk '{print $1}')" || regenerate_ca=true + ca_key_pub="$(openssl pkey -in "$TLS_CA_KEY" -pubout -outform DER 2>/dev/null | sha256sum | awk '{print $1}')" || regenerate_ca=true + [[ "$ca_cert_pub" == "$ca_key_pub" ]] || regenerate_ca=true + fi + + if [[ "$regenerate_ca" == true ]]; then + openssl req -x509 -newkey rsa:3072 -nodes -days 3650 \ + -subj "/CN=AIOS OpenBao Local CA" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign,digitalSignature" \ + -keyout "$TLS_CA_KEY.tmp" -out "$TLS_CA.tmp" >/dev/null 2>&1 + chown root:root "$TLS_CA_KEY.tmp" "$TLS_CA.tmp" + chmod 0400 "$TLS_CA_KEY.tmp" + chmod 0444 "$TLS_CA.tmp" + mv -f "$TLS_CA_KEY.tmp" "$TLS_CA_KEY" + mv -f "$TLS_CA.tmp" "$TLS_CA" + regenerate_server=true + HOST_MUTATED=true + fi + + if [[ ! -s "$TLS_KEY" || ! -s "$TLS_CERT" ]]; then + regenerate_server=true + else + openssl x509 -checkend 604800 -noout -in "$TLS_CERT" >/dev/null 2>&1 || regenerate_server=true + local cert_pub key_pub + cert_pub="$(openssl x509 -in "$TLS_CERT" -pubkey -noout | openssl pkey -pubin -outform DER 2>/dev/null | sha256sum | awk '{print $1}')" || regenerate_server=true + key_pub="$(openssl pkey -in "$TLS_KEY" -pubout -outform DER 2>/dev/null | sha256sum | awk '{print $1}')" || regenerate_server=true + [[ "$cert_pub" == "$key_pub" ]] || regenerate_server=true + openssl verify -CAfile "$TLS_CA" "$TLS_CERT" >/dev/null 2>&1 || regenerate_server=true + openssl x509 -in "$TLS_CERT" -noout -ext subjectAltName 2>/dev/null | + grep -Fq "IP Address:127.0.0.1" || regenerate_server=true + fi + + if [[ "$regenerate_server" == true ]]; then + local req_cfg="$TEMP_DIR/openssl-server.cnf" + local csr="$TEMP_DIR/server.csr" + cat >"$req_cfg" </dev/null 2>&1 + openssl x509 -req -days 825 \ + -in "$csr" \ + -CA "$TLS_CA" -CAkey "$TLS_CA_KEY" -CAcreateserial \ + -extfile "$req_cfg" -extensions v3 \ + -out "$TLS_CERT.tmp" >/dev/null 2>&1 + rm -f "$TLS_DIR/ca.srl" + chown root:aios-openbao "$TLS_KEY.tmp" "$TLS_CERT.tmp" + chmod 0440 "$TLS_KEY.tmp" + chmod 0444 "$TLS_CERT.tmp" + mv -f "$TLS_KEY.tmp" "$TLS_KEY" + mv -f "$TLS_CERT.tmp" "$TLS_CERT" + HOST_MUTATED=true + fi + + [[ "$(stat -c '%U:%G:%a' "$TLS_CA_KEY")" == "root:root:400" ]] || + die "TLS CA private-key ownership or mode is invalid" + [[ "$(stat -c '%U:%G:%a' "$TLS_KEY")" == "root:aios-openbao:440" ]] || + die "TLS server private-key ownership or mode is invalid" + openssl verify -CAfile "$TLS_CA" "$TLS_CERT" >/dev/null || + die "TLS server certificate verification failed" + runuser -u aios-openbao -- test -r "$TLS_KEY" || + die "aios-openbao cannot read the TLS private key" + if getent passwd nobody >/dev/null && runuser -u nobody -- test -r "$TLS_KEY" 2>/dev/null; then + die "unauthorized user nobody can read the TLS private key" + fi +} + +provision_openbao_bootstrap_identity() { + PHASE="openbao-bootstrap-identity" + + local plain="$TEMP_DIR/openbao-approle-secret-id.plain" + local use_systemd_credential=false + + if [[ ! -s "$APPROLE_ROLE_ID_FILE" ]]; then + openssl rand -hex 32 >"$APPROLE_ROLE_ID_FILE.tmp" + chown root:root "$APPROLE_ROLE_ID_FILE.tmp" + chmod 0444 "$APPROLE_ROLE_ID_FILE.tmp" + mv -f "$APPROLE_ROLE_ID_FILE.tmp" "$APPROLE_ROLE_ID_FILE" + fi + [[ "$(wc -c <"$APPROLE_ROLE_ID_FILE")" -eq 65 ]] || + die "AppRole RoleID file has an unexpected length" + + if command -v systemd-creds >/dev/null 2>&1 && + systemd-creds --help 2>&1 | grep -q -- '--with-key='; then + use_systemd_credential=true + fi + + if [[ "$use_systemd_credential" == true ]]; then + APPROLE_SECRET_STORAGE="systemd-credential" + APPROLE_SECRET_RUNTIME_PATH="/run/credentials/${CONTROLLER_SERVICE}/openbao-approle-secret-id" + APPROLE_BACKUP_RUNTIME_PATH="/run/credentials/aios-openbao-backup.service/openbao-approle-secret-id" + APPROLE_FAULT_RUNTIME_PATH="/run/credentials/aios-openbao-fault-injection.service/openbao-approle-secret-id" + APPROLE_RESTORE_RUNTIME_PATH="/run/credentials/aios-openbao-restore-test.service/openbao-approle-secret-id" + + if [[ -s "$APPROLE_SECRET_ID_CRED" ]]; then + systemd-creds decrypt "$APPROLE_SECRET_ID_CRED" "$plain" + else + if [[ -s "$APPROLE_SECRET_ID_FILE" ]]; then + cp "$APPROLE_SECRET_ID_FILE" "$plain" + else + openssl rand -hex 32 >"$plain" + fi + chmod 0600 "$plain" + systemd-creds setup >/dev/null + systemd-creds encrypt \ + --name=openbao-approle-secret-id \ + --with-key=auto \ + "$plain" "$APPROLE_SECRET_ID_CRED.tmp" + chown root:root "$APPROLE_SECRET_ID_CRED.tmp" + chmod 0400 "$APPROLE_SECRET_ID_CRED.tmp" + mv -f "$APPROLE_SECRET_ID_CRED.tmp" "$APPROLE_SECRET_ID_CRED" + if [[ -f "$APPROLE_SECRET_ID_FILE" ]]; then + shred -u "$APPROLE_SECRET_ID_FILE" 2>/dev/null || rm -f "$APPROLE_SECRET_ID_FILE" + fi + fi + + [[ -f "$APPROLE_SECRET_ID_CRED" && ! -L "$APPROLE_SECRET_ID_CRED" ]] || + die "encrypted AppRole SecretID credential must be a regular file" + [[ "$(stat -c '%U:%G:%a' "$APPROLE_SECRET_ID_CRED")" == "root:root:400" ]] || + die "encrypted AppRole SecretID credential ownership or mode is invalid" + else + APPROLE_SECRET_STORAGE="file" + APPROLE_SECRET_RUNTIME_PATH="$APPROLE_SECRET_ID_FILE" + APPROLE_BACKUP_RUNTIME_PATH="$APPROLE_SECRET_ID_FILE" + APPROLE_FAULT_RUNTIME_PATH="$APPROLE_SECRET_ID_FILE" + APPROLE_RESTORE_RUNTIME_PATH="$APPROLE_SECRET_ID_FILE" + if [[ ! -s "$APPROLE_SECRET_ID_FILE" ]]; then + openssl rand -hex 32 >"$APPROLE_SECRET_ID_FILE.tmp" + chown root:aios-autonomy "$APPROLE_SECRET_ID_FILE.tmp" + chmod 0440 "$APPROLE_SECRET_ID_FILE.tmp" + mv -f "$APPROLE_SECRET_ID_FILE.tmp" "$APPROLE_SECRET_ID_FILE" + fi + cp "$APPROLE_SECRET_ID_FILE" "$plain" + [[ "$(stat -c '%U:%G:%a' "$APPROLE_SECRET_ID_FILE")" == "root:aios-autonomy:440" ]] || + die "AppRole SecretID fallback ownership or mode is invalid" + fi + + [[ "$(wc -c <"$plain")" -eq 65 ]] || + die "AppRole SecretID must be a 32-byte value encoded as 64 hex characters" + grep -Eq '^[0-9a-f]{64}$' "$plain" || die "AppRole SecretID format is invalid" + + python3 - "$CHARTER_FILE.tmp" "$SCRIPT_VERSION" "$SELECTED_VERSION" "$0" <<'PY' +import hashlib, json, pathlib, sys, time +out, script_version, openbao_version, script_path = sys.argv[1:] +script_bytes = pathlib.Path(script_path).read_bytes() +charter = { + "schema": "AIOS_OPENBAO_AUTONOMY_CHARTER_V1", + "charter_version": 1, + "issued_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "bootstrap_script_version": script_version, + "bootstrap_script_sha256": hashlib.sha256(script_bytes).hexdigest(), + "openbao_release": openbao_version, + "operating_model": { + "one_person_maintained": True, + "unattended": True, + "non_interactive": True, + "continuously_self_optimizing": True, + }, + "immutable_governance": { + "direct_main_push": False, + "force_push": False, + "tag_rewrite": False, + "secrets_in_git": False, + "remote_consistency_required_before_release_success": True, + "owner_approval_required_for_production_release": True, + }, + "failure_policy": { + "fail_closed": True, + "never_lower_tier_one_gates": True, + "automatic_rollback_for_failed_safe_changes": True, + }, +} +pathlib.Path(out).write_text(json.dumps(charter, sort_keys=True, separators=(",", ":")) + "\n") +PY + chown root:root "$CHARTER_FILE.tmp" + chmod 0444 "$CHARTER_FILE.tmp" + mv -f "$CHARTER_FILE.tmp" "$CHARTER_FILE" + + shred -u "$plain" 2>/dev/null || rm -f "$plain" + HOST_MUTATED=true +} + +load_approle_secret() { + local output="$1" + if [[ "$APPROLE_SECRET_STORAGE" == "systemd-credential" ]]; then + systemd-creds decrypt "$APPROLE_SECRET_ID_CRED" "$output" + else + cp "$APPROLE_SECRET_ID_FILE" "$output" + fi + chmod 0600 "$output" + [[ "$(wc -c <"$output")" -eq 65 ]] || die "unable to load valid AppRole SecretID" +} + +render_openbao_config() { + PHASE="openbao-configuration" + + local key_id node_id + key_id="$(cat "$SEAL_KEY_ID_FILE")" + [[ "$key_id" =~ ^[A-Za-z0-9._-]+$ ]] || die "unsafe static seal key id" + node_id="$(hostname | tr -cd 'A-Za-z0-9._-' | head -c 48)" + [[ -n "$node_id" ]] || node_id="aios-openbao-1" + + local role_id + role_id="$(tr -d '\r\n' <"$APPROLE_ROLE_ID_FILE")" + [[ "$role_id" =~ ^[0-9a-f]{64}$ ]] || die "invalid AppRole RoleID" + + local secret_file="$TEMP_DIR/openbao-approle-secret-for-render" + local secret_id="" + local include_initialize=true + [[ -s "$OPENBAO_INIT_MARKER" ]] && include_initialize=false + + if [[ "$include_initialize" == true ]]; then + load_approle_secret "$secret_file" + secret_id="$(tr -d '\r\n' <"$secret_file")" + [[ "$secret_id" =~ ^[0-9a-f]{64}$ ]] || die "invalid AppRole SecretID" + fi + + cat >"$OPENBAO_CONFIG.tmp" <>"$OPENBAO_CONFIG.tmp" <&1 | grep -q -- '-verify-only'; then + local verify_config="$TEMP_DIR/openbao-verify.hcl" + cp "$OPENBAO_CONFIG.tmp" "$verify_config" + chmod 0600 "$verify_config" + if [[ "$SEAL_STORAGE" == "systemd-credential" ]]; then + local verify_seal_key="$TEMP_DIR/openbao-verify-seal.key" + systemd-creds decrypt "$SEAL_CRED_FILE" "$verify_seal_key" + chmod 0600 "$verify_seal_key" + python3 - "$verify_config" "$SEAL_RUNTIME_PATH" "$verify_seal_key" <<'PY' +import pathlib, sys +path, runtime_path, verification_path = sys.argv[1:] +p = pathlib.Path(path) +text = p.read_text() +needle = 'file://' + runtime_path +replacement = 'file://' + verification_path +if text.count(needle) != 1: + raise SystemExit("expected exactly one static seal runtime path") +p.write_text(text.replace(needle, replacement)) +PY + fi + "$BAO_LINK" server -config="$verify_config" -verify-only >/dev/null + shred -u "$verify_config" 2>/dev/null || rm -f "$verify_config" + [[ -z "${verify_seal_key:-}" ]] || + shred -u "$verify_seal_key" 2>/dev/null || rm -f "$verify_seal_key" + else + die "selected OpenBao release lacks server configuration verification" + fi + + if [[ -f "$OPENBAO_CONFIG" && "$include_initialize" == false ]] && + grep -q '^initialize "' "$OPENBAO_CONFIG"; then + shred -u "$OPENBAO_CONFIG" 2>/dev/null || rm -f "$OPENBAO_CONFIG" + fi + mv -f "$OPENBAO_CONFIG.tmp" "$OPENBAO_CONFIG" + + if [[ -s "$secret_file" ]]; then + shred -u "$secret_file" 2>/dev/null || rm -f "$secret_file" + fi + unset secret_id + HOST_MUTATED=true +} + +openbao_token_curl() { + local token_file="$1" + local method="$2" + local path="$3" + local output="$4" + local payload="${5:-}" + local cfg="$TEMP_DIR/openbao-token-$RANDOM.curl" + local code + + [[ -s "$token_file" ]] || die "OpenBao token file is missing" + printf 'header = "X-Vault-Token: %s"\n' "$(cat "$token_file")" >"$cfg" + chmod 0600 "$cfg" + + local args=( + --config "$cfg" + --silent --show-error + --cacert "$TLS_CA" + --request "$method" + --output "$output" + --write-out '%{http_code}' + --connect-timeout 10 + --max-time 120 + ) + if [[ -n "$payload" ]]; then + args+=(--header 'Content-Type: application/json' --data-binary "@$payload") + fi + code="$(curl "${args[@]}" "https://127.0.0.1:8200/v1/${path}")" || { + shred -u "$cfg" 2>/dev/null || rm -f "$cfg" + return 1 + } + shred -u "$cfg" 2>/dev/null || rm -f "$cfg" + [[ "$code" =~ ^2[0-9][0-9]$ ]] +} + +openbao_login_to() { + local token_file="$1" + local secret_file="$TEMP_DIR/approle-login-secret-$RANDOM" + local payload="$TEMP_DIR/approle-login-payload-$RANDOM.json" + local response="$TEMP_DIR/approle-login-response-$RANDOM.json" + + load_approle_secret "$secret_file" + python3 - "$APPROLE_ROLE_ID_FILE" "$secret_file" "$payload" <<'PY' +import json, pathlib, sys +role_file, secret_file, output = sys.argv[1:] +role_id = pathlib.Path(role_file).read_text().strip() +secret_id = pathlib.Path(secret_file).read_text().strip() +json.dump({"role_id": role_id, "secret_id": secret_id}, open(output, "w")) +PY + chmod 0600 "$payload" + + local code + code="$( + curl --silent --show-error --cacert "$TLS_CA" \ + --request POST \ + --header 'Content-Type: application/json' \ + --data-binary "@$payload" \ + --output "$response" \ + --write-out '%{http_code}' \ + --connect-timeout 10 --max-time 60 \ + 'https://127.0.0.1:8200/v1/auth/approle/login' + )" || code="000" + + shred -u "$secret_file" "$payload" 2>/dev/null || rm -f "$secret_file" "$payload" + [[ "$code" == "200" ]] || { + rm -f "$response" + return 1 + } + + python3 - "$response" "$token_file.tmp" <<'PY' +import json, pathlib, sys +response, output = sys.argv[1:] +x=json.load(open(response)) +auth=x.get("auth") or {} +token=auth.get("client_token") +policies=set(auth.get("policies") or auth.get("token_policies") or []) +if not isinstance(token, str) or not token: + raise SystemExit("AppRole login returned no token") +if "aios-autonomy" not in policies: + raise SystemExit("AppRole token lacks aios-autonomy policy") +pathlib.Path(output).write_text(token) +PY + chmod 0600 "$token_file.tmp" + mv -f "$token_file.tmp" "$token_file" + shred -u "$response" 2>/dev/null || rm -f "$response" + OPENBAO_APPROLE_VERIFIED=true +} + +wait_openbao_ready() { + local timeout_seconds="${1:-$BAO_READY_TIMEOUT}" + local deadline=$((SECONDS + timeout_seconds)) + local health="$TEMP_DIR/openbao-health-$RANDOM.json" + + while ((SECONDS < deadline)); do + if curl --silent --show-error --cacert "$TLS_CA" \ + --connect-timeout 3 --max-time 10 \ + "${BAO_HEALTH_URL}?standbyok=true&perfstandbyok=true" \ + -o "$health" 2>/dev/null; then + if python3 - "$health" <<'PY' +import json, sys +try: + x=json.load(open(sys.argv[1])) +except Exception: + raise SystemExit(1) +raise SystemExit(0 if x.get("initialized") is True and x.get("sealed") is False else 1) +PY + then + OPENBAO_INITIALIZED_VERIFIED=true + OPENBAO_UNSEALED_VERIFIED=true + SERVICES_HEALTHY=true + return 0 + fi + fi + sleep 3 + done + return 1 +} + +configure_openbao_runtime_and_charter() { + PHASE="openbao-runtime-configuration" + + local token="$TEMP_DIR/openbao-autonomy.token" + local mounts="$TEMP_DIR/openbao-mounts.json" + local auths="$TEMP_DIR/openbao-auths.json" + local audits="$TEMP_DIR/openbao-audits.json" + local policy="$TEMP_DIR/openbao-policy.json" + local runtime_key="$TEMP_DIR/openbao-runtime-key.json" + local charter_key="$TEMP_DIR/openbao-charter-key.json" + local charter_payload="$TEMP_DIR/charter-kv-payload.json" + local charter_response="$TEMP_DIR/charter-kv-response.json" + local sign_payload="$TEMP_DIR/charter-sign-payload.json" + local sign_response="$TEMP_DIR/charter-sign-response.json" + local verify_payload="$TEMP_DIR/charter-verify-payload.json" + local verify_response="$TEMP_DIR/charter-verify-response.json" + local signature_payload="$TEMP_DIR/charter-signature-kv-payload.json" + local signature_response="$TEMP_DIR/charter-signature-kv-response.json" + local readback="$TEMP_DIR/charter-readback.json" + + openbao_login_to "$token" || die "AppRole login failed after OpenBao self-initialization" + + openbao_token_curl "$token" GET "sys/mounts" "$mounts" || + die "unable to read OpenBao mounts" + python3 - "$mounts" <<'PY' +import json, sys +x=(json.load(open(sys.argv[1])).get("data") or {}) +if "transit/" not in x or "kv/" not in x: + raise SystemExit("required Transit or KV mount is missing") +if str((x["kv/"].get("options") or {}).get("version")) != "2": + raise SystemExit("kv/ is not KV v2") +PY + OPENBAO_TRANSIT_VERIFIED=true + OPENBAO_KV_VERIFIED=true + + openbao_token_curl "$token" GET "sys/auth" "$auths" || + die "unable to read OpenBao auth methods" + python3 - "$auths" <<'PY' +import json, sys +x=(json.load(open(sys.argv[1])).get("data") or {}) +raise SystemExit(0 if "approle/" in x else "AppRole auth mount is missing") +PY + + openbao_token_curl "$token" GET "sys/audit" "$audits" || + die "unable to read OpenBao audit devices" + python3 - "$audits" <<'PY' +import json, sys +x=(json.load(open(sys.argv[1])).get("data") or {}) +raise SystemExit(0 if "aios/" in x else "AIOS audit device is missing") +PY + OPENBAO_AUDIT_VERIFIED=true + + openbao_token_curl "$token" GET "sys/policies/acl/aios-autonomy" "$policy" || + die "unable to read autonomy policy" + python3 - "$policy" <<'PY' +import json, sys +x=(json.load(open(sys.argv[1])).get("data") or {}) +p=x.get("policy") or x.get("rules") or "" +required=("transit/sign/aios-charter","kv/data/aios/*","sys/storage/raft/snapshot") +raise SystemExit(0 if all(item in p for item in required) else "autonomy policy is incomplete") +PY + OPENBAO_POLICY_VERIFIED=true + + openbao_token_curl "$token" GET "transit/keys/aios-runtime" "$runtime_key" || + die "runtime Transit key is not readable" + openbao_token_curl "$token" GET "transit/keys/aios-charter" "$charter_key" || + die "charter Transit key is not readable" + python3 - "$runtime_key" "$charter_key" <<'PY' +import json, sys +runtime=(json.load(open(sys.argv[1])).get("data") or {}) +charter=(json.load(open(sys.argv[2])).get("data") or {}) +if runtime.get("type") != "aes256-gcm96": + raise SystemExit("runtime Transit key type mismatch") +if charter.get("type") != "ed25519": + raise SystemExit("charter Transit key type mismatch") +PY + + python3 - "$CHARTER_FILE" "$charter_payload" "$sign_payload" <<'PY' +import base64, hashlib, json, pathlib, sys, time +charter_file, kv_out, sign_out = sys.argv[1:] +raw=pathlib.Path(charter_file).read_bytes() +charter=json.loads(raw) +sha=hashlib.sha256(raw).hexdigest() +json.dump({"data":{ + "charter":charter, + "charter_sha256":sha, + "stored_at":time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), +}}, open(kv_out,"w"), sort_keys=True) +json.dump({"input":base64.b64encode(raw).decode()}, open(sign_out,"w")) +PY + + openbao_token_curl "$token" POST "kv/data/aios/charter" "$charter_response" "$charter_payload" || + die "unable to store autonomy charter in KV v2" + OPENBAO_CHARTER_STORED=true + + openbao_token_curl "$token" POST "transit/sign/aios-charter" "$sign_response" "$sign_payload" || + die "unable to sign autonomy charter with Transit" + python3 - "$sign_response" "$sign_payload" "$verify_payload" "$CHARTER_FILE" "$signature_payload" "$CHARTER_SIGNATURE_FILE.tmp" <<'PY' +import hashlib, json, pathlib, sys, time +sign_response, sign_payload, verify_out, charter_file, kv_out, local_out = sys.argv[1:] +sig=(json.load(open(sign_response)).get("data") or {}).get("signature") +if not isinstance(sig,str) or not sig.startswith(("vault:v","bao:v")): + raise SystemExit("invalid Transit signature") +inp=json.load(open(sign_payload))["input"] +json.dump({"input":inp,"signature":sig},open(verify_out,"w")) +raw=pathlib.Path(charter_file).read_bytes() +record={ + "schema":"AIOS_AUTONOMY_CHARTER_SIGNATURE_V1", + "charter_sha256":hashlib.sha256(raw).hexdigest(), + "key":"transit/keys/aios-charter", + "signature":sig, + "signed_at":time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime()), +} +json.dump({"data":record},open(kv_out,"w"),sort_keys=True) +pathlib.Path(local_out).write_text(json.dumps(record,sort_keys=True)+"\n") +PY + chown root:aios-autonomy "$CHARTER_SIGNATURE_FILE.tmp" + chmod 0640 "$CHARTER_SIGNATURE_FILE.tmp" + mv -f "$CHARTER_SIGNATURE_FILE.tmp" "$CHARTER_SIGNATURE_FILE" + OPENBAO_CHARTER_SIGNED=true + + openbao_token_curl "$token" POST "transit/verify/aios-charter" "$verify_response" "$verify_payload" || + die "unable to verify autonomy charter signature" + python3 - "$verify_response" <<'PY' +import json, sys +x=(json.load(open(sys.argv[1])).get("data") or {}) +raise SystemExit(0 if x.get("valid") is True else "Transit charter signature is invalid") +PY + OPENBAO_CHARTER_SIGNATURE_VERIFIED=true + + openbao_token_curl "$token" POST "kv/data/aios/charter-signature" "$signature_response" "$signature_payload" || + die "unable to store charter signature record" + openbao_token_curl "$token" GET "kv/data/aios/charter" "$readback" || + die "unable to read back stored autonomy charter" + python3 - "$readback" "$CHARTER_FILE" <<'PY' +import hashlib, json, pathlib, sys +x=((json.load(open(sys.argv[1])).get("data") or {}).get("data") or {}) +sha=hashlib.sha256(pathlib.Path(sys.argv[2]).read_bytes()).hexdigest() +raise SystemExit(0 if x.get("charter_sha256")==sha else "stored charter hash mismatch") +PY + + # Revoke the short-lived bootstrap session. The persistent credential is the + # host-bound AppRole SecretID, not this token. + openbao_token_curl "$token" POST "auth/token/revoke-self" "$TEMP_DIR/revoke-self.json" || + die "unable to revoke bootstrap AppRole token" + shred -u "$token" 2>/dev/null || rm -f "$token" + + python3 - "$OPENBAO_INIT_MARKER.tmp" "$SELECTED_VERSION" "$CHARTER_FILE" <<'PY' +import hashlib, json, pathlib, sys, time +out, version, charter = sys.argv[1:] +raw=pathlib.Path(charter).read_bytes() +print(json.dumps({ + "schema":"AIOS_OPENBAO_SELF_INIT_COMPLETE_V1", + "completed_at":time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime()), + "openbao_version":version, + "audit_verified":True, + "transit_verified":True, + "kv_v2_verified":True, + "approle_verified":True, + "policy_verified":True, + "charter_sha256":hashlib.sha256(raw).hexdigest(), + "charter_signature_verified":True, +},sort_keys=True),file=open(out,"w")) +PY + chown root:aios-autonomy "$OPENBAO_INIT_MARKER.tmp" + chmod 0640 "$OPENBAO_INIT_MARKER.tmp" + mv -f "$OPENBAO_INIT_MARKER.tmp" "$OPENBAO_INIT_MARKER" +} + +remove_self_init_secrets_and_restart() { + PHASE="openbao-remove-self-init-secrets" + + render_openbao_config + grep -q '^initialize "' "$OPENBAO_CONFIG" && + die "self-initialization stanza remained after successful initialization" + + local secret="$TEMP_DIR/secret-removal-check" + load_approle_secret "$secret" + if grep -Fq "$(cat "$secret")" "$OPENBAO_CONFIG"; then + shred -u "$secret" 2>/dev/null || rm -f "$secret" + die "AppRole SecretID remained in persistent OpenBao configuration" + fi + shred -u "$secret" 2>/dev/null || rm -f "$secret" + OPENBAO_INIT_SECRET_REMOVED=true + + systemctl restart "$BAO_SERVICE" + wait_openbao_ready "$BAO_READY_TIMEOUT" || + die "OpenBao did not recover after removal of self-initialization secrets" + + local token="$TEMP_DIR/post-restart-approle.token" + openbao_login_to "$token" || die "AppRole login failed after OpenBao restart" + local sig="$TEMP_DIR/post-restart-signature.json" + openbao_token_curl "$token" GET "kv/data/aios/charter-signature" "$sig" || + die "charter signature record is unavailable after OpenBao restart" + python3 - "$sig" <<'PY' +import json, sys +x=((json.load(open(sys.argv[1])).get("data") or {}).get("data") or {}) +raise SystemExit(0 if x.get("signature") and x.get("charter_sha256") else 1) +PY + openbao_token_curl "$token" POST "auth/token/revoke-self" "$TEMP_DIR/revoke-post-restart.json" || + die "unable to revoke post-restart validation token" + shred -u "$token" 2>/dev/null || rm -f "$token" + + SEAL_REBOOT_READ_VERIFIED=true + RELEASE_RUNTIME_READY_VERIFIED=true + SERVICES_HEALTHY=true + mark_release_runtime_ready +} + +create_temp_gitea_admin() { + PHASE="gitea-temporary-admin" + + TEMP_ADMIN_USER="aios-bootstrap-$(date +%s)-$(python3 - <<'PY' +import secrets +print(secrets.token_hex(4)) +PY +)" + + gitea_cli admin user create \ + --username "$TEMP_ADMIN_USER" \ + --random-password \ + --random-password-length 64 \ + --email "$TEMP_ADMIN_USER@localhost" \ + --admin \ + --must-change-password=false >/dev/null + + gitea_user_exists "$TEMP_ADMIN_USER" || die "temporary Gitea administrator was not created" + GITEA_MUTATED=true + + TEMP_ADMIN_TOKEN="$( + gitea_cli admin user generate-access-token \ + --username "$TEMP_ADMIN_USER" \ + --token-name "aios-bootstrap-v1-$(date +%s)" \ + --scopes "write:repository,read:user,read:organization" \ + --raw + )" + [[ "$TEMP_ADMIN_TOKEN" =~ ^[A-Za-z0-9._~-]+$ ]] || die "temporary Gitea token output was invalid" +} + +machine_api_config() { + local output="$1" + curl_config_token "$output" "$GITEA_TOKEN_FILE" +} + +temp_admin_api_config() { + local output="$1" + local token_file="$TEMP_DIR/temp-admin.token" + printf '%s' "$TEMP_ADMIN_TOKEN" >"$token_file" + chmod 0600 "$token_file" + curl_config_token "$output" "$token_file" +} + +verify_machine_token() { + [[ -s "$GITEA_TOKEN_FILE" && ! -L "$GITEA_TOKEN_FILE" ]] || return 1 + [[ "$(stat -c '%U:%G:%a' "$GITEA_TOKEN_FILE")" == "root:aios-autonomy:440" ]] || return 1 + local cfg="$TEMP_DIR/machine-verify.curl" + local out="$TEMP_DIR/machine-user.json" + machine_api_config "$cfg" + api_with_config "$cfg" GET "/user" "$out" || return 1 + python3 - "$out" "$GITEA_MACHINE_USER" <<'PY' +import json, sys +x = json.load(open(sys.argv[1])) +raise SystemExit(0 if x.get("login") == sys.argv[2] else 1) +PY +} + +create_or_rotate_machine_credential() { + PHASE="gitea-machine-credential" + + if verify_machine_token; then + MACHINE_CREDENTIAL_STORAGE="protected-file" + MACHINE_CREDENTIAL_PERSISTED=true + MACHINE_AUTHENTICATED=true + log "reusing verified Gitea machine credential" + return + fi + + # A dedicated machine identity with an unavailable or invalid credential is + # recreated rather than accumulating unknown long-lived tokens. + if gitea_user_exists "$GITEA_MACHINE_USER"; then + gitea_cli admin user delete --username "$GITEA_MACHINE_USER" >/dev/null + gitea_user_exists "$GITEA_MACHINE_USER" && + die "unable to remove stale dedicated Gitea machine identity" + GITEA_MUTATED=true + fi + + gitea_cli admin user create \ + --username "$GITEA_MACHINE_USER" \ + --random-password \ + --random-password-length 64 \ + --email "$GITEA_MACHINE_EMAIL" \ + --must-change-password=false >/dev/null + gitea_user_exists "$GITEA_MACHINE_USER" || die "Gitea machine identity was not created" + MACHINE_IDENTITY_CREATED=true + GITEA_MUTATED=true + + local machine_token + machine_token="$( + gitea_cli admin user generate-access-token \ + --username "$GITEA_MACHINE_USER" \ + --token-name "aios-autonomy-v1-$(date -u +%Y%m%dT%H%M%SZ)" \ + --scopes "write:repository,read:user" \ + --raw + )" + [[ "$machine_token" =~ ^[A-Za-z0-9._~-]+$ ]] || die "machine token output was invalid" + + install -m 0440 -o root -g aios-autonomy /dev/null "$GITEA_TOKEN_FILE.tmp" + printf '%s' "$machine_token" >"$GITEA_TOKEN_FILE.tmp" + mv -f "$GITEA_TOKEN_FILE.tmp" "$GITEA_TOKEN_FILE" + unset machine_token + + [[ "$(stat -c '%U:%G:%a' "$GITEA_TOKEN_FILE")" == "root:aios-autonomy:440" ]] || + die "Gitea machine credential ownership or mode is invalid" + MACHINE_CREDENTIAL_STORAGE="protected-file" + MACHINE_CREDENTIAL_PERSISTED=true + verify_machine_token || die "persisted Gitea machine credential failed authentication" + MACHINE_AUTHENTICATED=true +} + +grant_and_verify_collaborator() { + PHASE="gitea-collaborator" + + local admin_cfg="$TEMP_DIR/temp-admin-api.curl" + local machine_cfg="$TEMP_DIR/machine-api.curl" + local payload="$TEMP_DIR/collaborator.json" + local out="$TEMP_DIR/collaborator-response.json" + local permission="$TEMP_DIR/collaborator-permission.json" + local repo="$TEMP_DIR/repository.json" + + temp_admin_api_config "$admin_cfg" + machine_api_config "$machine_cfg" + + printf '%s\n' '{"permission":"write"}' >"$payload" + api_with_config "$admin_cfg" PUT "/repos/$GITEA_OWNER/$GITEA_REPO/collaborators/$GITEA_MACHINE_USER" "$out" "$payload" + GITEA_MUTATED=true + + api_with_config "$admin_cfg" GET "/repos/$GITEA_OWNER/$GITEA_REPO/collaborators/$GITEA_MACHINE_USER/permission" "$permission" + python3 - "$permission" <<'PY' +import json, sys +x=json.load(open(sys.argv[1])) +value=str(x.get("permission") or x.get("role_name") or "").lower() +raise SystemExit(0 if value in {"write","admin","owner"} else 1) +PY + MACHINE_WRITE_PERMISSION_VERIFIED=true + + api_with_config "$machine_cfg" GET "/repos/$GITEA_OWNER/$GITEA_REPO" "$repo" + DEFAULT_BRANCH="$(python3 - "$repo" <<'PY' +import json, sys +x=json.load(open(sys.argv[1])) +branch=x.get("default_branch") +if not branch: + raise SystemExit("missing default branch") +print(branch) +PY +)" +} + +verify_default_branch_protection() { + PHASE="gitea-branch-protection" + + local machine_cfg="$TEMP_DIR/machine-branch.curl" + local admin_cfg="$TEMP_DIR/admin-branch-protection.curl" + local branch_out="$TEMP_DIR/default-branch.json" + local protection_out="$TEMP_DIR/default-branch-protection.json" + local payload="$TEMP_DIR/default-branch-protection-request.json" + local encoded_branch + encoded_branch="$(python3 - "$DEFAULT_BRANCH" <<'PY' +import sys, urllib.parse +print(urllib.parse.quote(sys.argv[1], safe="")) +PY +)" + machine_api_config "$machine_cfg" + temp_admin_api_config "$admin_cfg" + + branch_is_safely_protected() { + api_with_config "$machine_cfg" GET \ + "/repos/$GITEA_OWNER/$GITEA_REPO/branches/$encoded_branch" \ + "$branch_out" && + python3 - "$branch_out" <<'PY' +import json, sys +x=json.load(open(sys.argv[1])) +# Evaluated as the machine identity. protected=true plus +# user_can_push=false proves repository Write cannot bypass the default branch. +raise SystemExit( + 0 if x.get("protected") is True and x.get("user_can_push") is False else 1 +) +PY + } + + if branch_is_safely_protected; then + DEFAULT_BRANCH_PROTECTION_VERIFIED=true + return + fi + + local protection_code + protection_code="$( + curl --config "$admin_cfg" --request GET \ + --output "$protection_out" --write-out '%{http_code}' \ + "${GITEA_URL%/}/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/branch_protections/$encoded_branch" \ + 2>/dev/null + )" + + if [[ "$protection_code" == "404" ]]; then + python3 - "$payload" "$DEFAULT_BRANCH" <<'PY' +import json, sys +json.dump({ + "branch_name": sys.argv[2], + "enable_push": False, + "enable_force_push": False, +}, open(sys.argv[1], "w")) +PY + api_with_config "$admin_cfg" POST \ + "/repos/$GITEA_OWNER/$GITEA_REPO/branch_protections" \ + "$protection_out" "$payload" + GITEA_MUTATED=true + elif [[ "$protection_code" == "200" ]]; then + # Existing owner policy is only tightened: direct pushes and force pushes + # are disabled. All omitted fields remain under the existing policy. + printf '%s\n' '{"enable_push":false,"enable_force_push":false}' >"$payload" + api_with_config "$admin_cfg" PATCH \ + "/repos/$GITEA_OWNER/$GITEA_REPO/branch_protections/$encoded_branch" \ + "$protection_out" "$payload" + GITEA_MUTATED=true + else + die "unable to inspect default branch protection (HTTP $protection_code)" + fi + + local deadline=$((SECONDS + 30)) + while ((SECONDS < deadline)); do + if branch_is_safely_protected; then + DEFAULT_BRANCH_PROTECTION_VERIFIED=true + break + fi + sleep 2 + done + + [[ "$DEFAULT_BRANCH_PROTECTION_VERIFIED" == true ]] || + die "default branch protection was not enforced for the machine identity" +} + +verify_tag_protection() { + PHASE="gitea-tag-protection" + + local admin_cfg="$TEMP_DIR/admin-tag-protection.curl" + local protections="$TEMP_DIR/tag-protections.json" + local refreshed="$TEMP_DIR/tag-protections-refreshed.json" + local payload="$TEMP_DIR/tag-protection-payload.json" + local response="$TEMP_DIR/tag-protection-response.json" + local team_list="$TEMP_DIR/gitea-owner-teams.json" + local team_member_out="$TEMP_DIR/gitea-team-member.json" + + temp_admin_api_config "$admin_cfg" + api_with_config "$admin_cfg" GET \ + "/repos/$GITEA_OWNER/$GITEA_REPO/tag_protections" \ + "$protections" + python3 - "$protections" <<'PY' +import json, sys +items = json.load(open(sys.argv[1])) +if not isinstance(items, list): + raise SystemExit("invalid tag-protection list response") +PY + + # Remove only the dedicated machine identity from direct tag-protection + # allowlists. Existing owner/team policy is otherwise preserved. + while IFS=$'\t' read -r protection_id payload_b64; do + [[ -n "$protection_id" && "$protection_id" =~ ^[0-9]+$ ]] || + die "invalid Gitea tag-protection identifier" + printf '%s' "$payload_b64" | base64 -d >"$payload" + api_with_config "$admin_cfg" PATCH \ + "/repos/$GITEA_OWNER/$GITEA_REPO/tag_protections/$protection_id" \ + "$response" "$payload" + GITEA_MUTATED=true + done < <( + python3 - "$protections" "$GITEA_MACHINE_USER" <<'PY' +import base64, json, sys +items = json.load(open(sys.argv[1])) +machine = sys.argv[2] +if not isinstance(items, list): + raise SystemExit("invalid tag-protection response") +for item in items: + users = item.get("whitelist_usernames") or [] + teams = item.get("whitelist_teams") or [] + if machine not in users: + continue + pid = item.get("id") + pattern = item.get("name_pattern") + if not isinstance(pid, int) or not isinstance(pattern, str) or not pattern: + raise SystemExit("invalid tag-protection object") + payload = { + "name_pattern": pattern, + "whitelist_usernames": [u for u in users if u != machine], + "whitelist_teams": teams, + } + encoded = base64.b64encode( + json.dumps(payload, separators=(",", ":")).encode() + ).decode() + print(f"{pid}\t{encoded}") +PY + ) + + # An organization team allowlisted by a matching rule could still grant the + # machine identity tag rights. A freshly created machine identity should not + # be in any team; fail closed if that assumption is false. + local owner_probe="$TEMP_DIR/gitea-owner-org-probe.json" + local teams_code + teams_code="$( + curl --config "$admin_cfg" --request GET \ + --output "$owner_probe" --write-out '%{http_code}' \ + "${GITEA_URL%/}/api/v1/orgs/$GITEA_OWNER" \ + 2>/dev/null + )" + if [[ "$teams_code" == "200" ]]; then + fetch_paginated_json_array "$admin_cfg" \ + "/orgs/$GITEA_OWNER/teams" \ + "$team_list" + while IFS= read -r team_id; do + [[ "$team_id" =~ ^[0-9]+$ ]] || die "invalid Gitea team identifier" + local member_code + member_code="$( + curl --config "$admin_cfg" --request GET \ + --output "$team_member_out" --write-out '%{http_code}' \ + "${GITEA_URL%/}/api/v1/teams/$team_id/members/$GITEA_MACHINE_USER" \ + 2>/dev/null + )" + case "$member_code" in + 404) ;; + 200|204) + die "machine identity belongs to an organization team; tag denial cannot be proven safely" + ;; + *) + die "unable to verify Gitea team membership (HTTP $member_code)" + ;; + esac + done < <( + python3 - "$team_list" <<'PY' +import json, sys +items = json.load(open(sys.argv[1])) +if not isinstance(items, list): + raise SystemExit("invalid organization-team response") +for item in items: + tid = item.get("id") + if not isinstance(tid, int): + raise SystemExit("invalid organization-team object") + print(tid) +PY + ) + elif [[ "$teams_code" != "404" ]]; then + die "unable to determine whether Gitea owner is an organization (HTTP $teams_code)" + fi + + # Add a fail-closed wildcard protection when none exists. Empty allowlists + # mean no account may create or mutate a matching tag until a separate, + # owner-approved release workflow changes policy. + if ! python3 - "$protections" <<'PY' +import json, sys +items = json.load(open(sys.argv[1])) +raise SystemExit( + 0 if any( + x.get("name_pattern") == "*" + and not (x.get("whitelist_usernames") or []) + and not (x.get("whitelist_teams") or []) + for x in items + ) else 1 +) +PY + then + printf '%s\n' \ + '{"name_pattern":"*","whitelist_usernames":[],"whitelist_teams":[]}' \ + >"$payload" + api_with_config "$admin_cfg" POST \ + "/repos/$GITEA_OWNER/$GITEA_REPO/tag_protections" \ + "$response" "$payload" + GITEA_MUTATED=true + fi + + api_with_config "$admin_cfg" GET \ + "/repos/$GITEA_OWNER/$GITEA_REPO/tag_protections" \ + "$refreshed" + + python3 - "$refreshed" "$GITEA_MACHINE_USER" <<'PY' +import json, sys +items = json.load(open(sys.argv[1])) +machine = sys.argv[2] +if not isinstance(items, list): + raise SystemExit("invalid tag-protection response") +if any(machine in (x.get("whitelist_usernames") or []) for x in items): + raise SystemExit("machine identity remains directly allowlisted for tags") +if not any( + x.get("name_pattern") == "*" + and not (x.get("whitelist_usernames") or []) + and not (x.get("whitelist_teams") or []) + for x in items +): + raise SystemExit("fail-closed wildcard tag protection is absent") +PY + + TAG_PROTECTION_VERIFIED=true +} + +verify_non_main_git_push() { + PHASE="gitea-git-push-verification" + + local work="$TEMP_DIR/git-check" + local askpass="$TEMP_DIR/git-askpass.sh" + local branch="auto/bootstrap-verification-$(date +%s)-$(python3 - <<'PY' +import secrets +print(secrets.token_hex(4)) +PY +)" + + install -d -m 0700 -o aios-autonomy -g aios-autonomy "$work" + cat >"$askpass" <<'SH' +#!/bin/sh +case "$1" in + *sername*) printf '%s\n' "${AIOS_GIT_USERNAME:?}" ;; + *assword*) cat "${AIOS_GIT_TOKEN_FILE:?}" ;; + *) exit 1 ;; +esac +SH + chown root:aios-autonomy "$askpass" + chmod 0550 "$askpass" + + local common_env=( + HOME="$work/home" + GIT_TERMINAL_PROMPT=0 + GIT_ASKPASS="$askpass" + AIOS_GIT_USERNAME="$GITEA_MACHINE_USER" + AIOS_GIT_TOKEN_FILE="$GITEA_TOKEN_FILE" + ) + install -d -m 0700 -o aios-autonomy -g aios-autonomy "$work/home" + + runuser -u aios-autonomy -- env "${common_env[@]}" git -C "$work" init -q + runuser -u aios-autonomy -- env "${common_env[@]}" git -C "$work" config user.name "AIOS Autonomy Agent" + runuser -u aios-autonomy -- env "${common_env[@]}" git -C "$work" config user.email "$GITEA_MACHINE_EMAIL" + runuser -u aios-autonomy -- env "${common_env[@]}" git -C "$work" remote add origin "$GITEA_GIT_URL" + runuser -u aios-autonomy -- env "${common_env[@]}" git -C "$work" fetch -q --depth=1 origin "$DEFAULT_BRANCH" + runuser -u aios-autonomy -- env "${common_env[@]}" git -C "$work" checkout -q -b "$branch" FETCH_HEAD + runuser -u aios-autonomy -- env "${common_env[@]}" git -C "$work" commit -q --allow-empty -m "chore: verify autonomous non-main push" + local oid + oid="$(runuser -u aios-autonomy -- env "${common_env[@]}" git -C "$work" rev-parse HEAD)" + + runuser -u aios-autonomy -- env "${common_env[@]}" git -C "$work" push -q origin "HEAD:refs/heads/$branch" + local remote_oid + remote_oid="$( + runuser -u aios-autonomy -- env "${common_env[@]}" git -C "$work" ls-remote origin "refs/heads/$branch" | + awk 'NR==1{print $1}' + )" + [[ "$remote_oid" == "$oid" ]] || die "remote non-main branch OID verification failed" + + runuser -u aios-autonomy -- env "${common_env[@]}" git -C "$work" push -q origin ":refs/heads/$branch" + if runuser -u aios-autonomy -- env "${common_env[@]}" git -C "$work" ls-remote --exit-code origin "refs/heads/$branch" >/dev/null 2>&1; then + die "temporary verification branch was not removed" + fi + + MACHINE_NON_MAIN_PUSH_VERIFIED=true + GITEA_MUTATED=true +} + +revoke_and_remove_temp_admin() { + PHASE="gitea-temporary-admin-cleanup" + cleanup_gitea_credentials + [[ "$TEMP_ADMIN_TOKEN_REVOKED" == true ]] || die "temporary Gitea token revocation could not be verified" + [[ "$TEMP_ADMIN_REMOVED" == true ]] || die "temporary Gitea administrator removal could not be verified" +} + +install_services() { + PHASE="service-installation" + + # Shared OpenBao client helper. Tokens are held only in mode-0600 runtime + # files and curl reads headers from a temporary config file, not argv. + cat >"$CLIENT_LIB.tmp" <<'LIB' +#!/usr/bin/env bash +set -Eeuo pipefail +umask 077 + +aios_require_client_env() { + : "${AIOS_OPENBAO_ADDR:?}" + : "${AIOS_OPENBAO_ROLE_ID_FILE:?}" + : "${AIOS_OPENBAO_SECRET_ID_FILE:?}" +} + +aios_curl() { + local args=(--silent --show-error --connect-timeout 10 --max-time 180) + if [[ -n "${AIOS_OPENBAO_CACERT:-}" ]]; then + args+=(--cacert "$AIOS_OPENBAO_CACERT") + fi + curl "${args[@]}" "$@" +} + +aios_login_token() { + local output="$1" + local work payload response code + work="$(mktemp -d /run/aios-openbao-client.XXXXXX)" + chmod 0700 "$work" + payload="$work/login.json" + response="$work/response.json" + + python3 - "$AIOS_OPENBAO_ROLE_ID_FILE" "$AIOS_OPENBAO_SECRET_ID_FILE" "$payload" <<'PY' +import json, pathlib, sys +role=pathlib.Path(sys.argv[1]).read_text().strip() +secret=pathlib.Path(sys.argv[2]).read_text().strip() +if not role or not secret: + raise SystemExit("missing AppRole credential") +json.dump({"role_id":role,"secret_id":secret},open(sys.argv[3],"w")) +PY + code="$( + aios_curl \ + --request POST \ + --header 'Content-Type: application/json' \ + --data-binary "@$payload" \ + --output "$response" \ + --write-out '%{http_code}' \ + "${AIOS_OPENBAO_ADDR%/}/v1/auth/approle/login" + )" || code=000 + if [[ "$code" != 200 ]]; then + rm -rf "$work" + return 1 + fi + python3 - "$response" "$output.tmp" <<'PY' +import json, pathlib, sys +x=json.load(open(sys.argv[1])) +auth=x.get("auth") or {} +token=auth.get("client_token") +policies=set(auth.get("policies") or auth.get("token_policies") or []) +if not token or "aios-autonomy" not in policies: + raise SystemExit("invalid AppRole response") +pathlib.Path(sys.argv[2]).write_text(token) +PY + chmod 0600 "$output.tmp" + mv -f "$output.tmp" "$output" + rm -rf "$work" +} + +aios_token_curl() { + local token_file="$1" method="$2" path="$3" output="$4" + local payload="${5:-}" + local cfg code + cfg="$(mktemp /run/aios-openbao-token.XXXXXX)" + chmod 0600 "$cfg" + printf 'header = "X-Vault-Token: %s"\n' "$(cat "$token_file")" >"$cfg" + local args=( + --config "$cfg" + --request "$method" + --output "$output" + --write-out '%{http_code}' + ) + if [[ -n "$payload" ]]; then + args+=(--header 'Content-Type: application/json' --data-binary "@$payload") + fi + code="$(aios_curl "${args[@]}" "${AIOS_OPENBAO_ADDR%/}/v1/$path")" || code=000 + rm -f "$cfg" + [[ "$code" =~ ^2[0-9][0-9]$ ]] +} + +aios_health_ready() { + local output="$1" + local code + code="$( + aios_curl \ + --output "$output" \ + --write-out '%{http_code}' \ + "${AIOS_OPENBAO_ADDR%/}/v1/sys/health?standbyok=true&perfstandbyok=true" + )" || code=000 + [[ "$code" =~ ^(200|429|472|473)$ ]] || return 1 + python3 - "$output" <<'PY' +import json, sys +x=json.load(open(sys.argv[1])) +raise SystemExit(0 if x.get("initialized") is True and x.get("sealed") is False else 1) +PY +} + +aios_revoke_self() { + local token_file="$1" + local out + out="$(mktemp /run/aios-openbao-revoke.XXXXXX)" + aios_token_curl "$token_file" POST "auth/token/revoke-self" "$out" + rm -f "$out" "$token_file" +} +LIB + install -m 0555 -o root -g root "$CLIENT_LIB.tmp" "$CLIENT_LIB" + rm -f "$CLIENT_LIB.tmp" + + cat >"$CONTROLLER_BIN.tmp" <<'CONTROLLER' +#!/usr/bin/env bash +set -Eeuo pipefail +umask 077 +source /usr/local/libexec/aios-openbao-client-lib +aios_require_client_env +: "${AIOS_AUTONOMY_STATUS:?}" +: "${AIOS_CHARTER_FILE:?}" +: "${AIOS_CONTROLLER_INTERVAL_FILE:?}" + +write_status() { + local state="$1" reason="$2" charter_ok="$3" + python3 - "$AIOS_AUTONOMY_STATUS.tmp" "$state" "$reason" "$charter_ok" <<'PY' +import json, pathlib, sys, time +out,state,reason,charter_ok=sys.argv[1:] +obj={ + "schema":"AIOS_AUTONOMY_STATUS_V1", + "state":state, + "reason":reason, + "openbao_initialized": state=="RUNNING", + "openbao_unsealed": state=="RUNNING", + "charter_signature_verified": charter_ok=="true", + "human_action_required":False, + "production_change_in_progress":False, + "last_health_check":time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime()), + "updated_at":time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime()), +} +pathlib.Path(out).write_text(json.dumps(obj,sort_keys=True)+"\n") +PY + chmod 0640 "$AIOS_AUTONOMY_STATUS.tmp" + mv -f "$AIOS_AUTONOMY_STATUS.tmp" "$AIOS_AUTONOMY_STATUS" +} + +while :; do + work="$(mktemp -d /run/aios-autonomy-controller.XXXXXX)" + chmod 0700 "$work" + state="DEGRADED" + reason="health check failed" + charter_ok=false + + if aios_health_ready "$work/health.json" && + aios_login_token "$work/token"; then + python3 - "$AIOS_CHARTER_FILE" "$work/verify.json" <<'PY' +import base64,json,pathlib,sys +raw=pathlib.Path(sys.argv[1]).read_bytes() +json.dump({"input":base64.b64encode(raw).decode()},open(sys.argv[2],"w")) +PY + if aios_token_curl "$work/token" GET "kv/data/aios/charter-signature" "$work/signature.json"; then + python3 - "$work/signature.json" "$work/verify.json" <<'PY' +import json,sys +x=((json.load(open(sys.argv[1])).get("data") or {}).get("data") or {}) +p=json.load(open(sys.argv[2])) +sig=x.get("signature") +if not sig: + raise SystemExit(1) +p["signature"]=sig +json.dump(p,open(sys.argv[2],"w")) +PY + if aios_token_curl "$work/token" POST "transit/verify/aios-charter" "$work/verified.json" "$work/verify.json" && + python3 - "$work/verified.json" <<'PY' +import json,sys +raise SystemExit(0 if (json.load(open(sys.argv[1])).get("data") or {}).get("valid") is True else 1) +PY + then + charter_ok=true + fi + fi + if [[ "$charter_ok" == true ]]; then + state="RUNNING" + reason="OpenBao ready; AppRole and charter signature verified" + else + reason="OpenBao ready but charter verification failed" + fi + aios_revoke_self "$work/token" || true + fi + + write_status "$state" "$reason" "$charter_ok" + rm -rf "$work" + + interval=60 + if [[ -s "$AIOS_CONTROLLER_INTERVAL_FILE" ]]; then + candidate="$(tr -dc '0-9' <"$AIOS_CONTROLLER_INTERVAL_FILE")" + if [[ "$candidate" =~ ^[0-9]+$ ]] && ((candidate >= 30 && candidate <= 120)); then + interval="$candidate" + fi + fi + sleep "$interval" +done +CONTROLLER + install -m 0555 -o root -g root "$CONTROLLER_BIN.tmp" "$CONTROLLER_BIN" + rm -f "$CONTROLLER_BIN.tmp" + + cat >"$BACKUP_BIN.tmp" <<'BACKUP' +#!/usr/bin/env bash +set -Eeuo pipefail +umask 077 +source /usr/local/libexec/aios-openbao-client-lib +aios_require_client_env +: "${AIOS_BACKUP_DIR:?}" +: "${AIOS_BACKUP_STATUS:?}" +: "${AIOS_BAO_BIN:?}" + +work="$(mktemp -d /run/aios-openbao-backup.XXXXXX)" +trap 'rm -rf "$work"' EXIT +token="$work/token" +aios_login_token "$token" +ts="$(date -u +%Y%m%dT%H%M%SZ)" +tmp="$AIOS_BACKUP_DIR/openbao-$ts.snap.tmp" +final="$AIOS_BACKUP_DIR/openbao-$ts.snap" + +aios_token_curl "$token" GET "sys/storage/raft/snapshot" "$tmp" +[[ -s "$tmp" ]] +"$AIOS_BAO_BIN" operator raft snapshot inspect "$tmp" >"$work/inspect.txt" +sha="$(sha256sum "$tmp" | awk '{print $1}')" +chmod 0440 "$tmp" +chgrp aios-openbao "$tmp" +mv -f "$tmp" "$final" +aios_revoke_self "$token" + +python3 - "$AIOS_BACKUP_STATUS.tmp" "$final" "$sha" <<'PY' +import json,pathlib,sys,time +out,path,sha=sys.argv[1:] +obj={ + "schema":"AIOS_OPENBAO_BACKUP_STATUS_V1", + "passed":True, + "snapshot":path, + "sha256":sha, + "completed_at":time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime()), +} +pathlib.Path(out).write_text(json.dumps(obj,sort_keys=True)+"\n") +PY +chmod 0640 "$AIOS_BACKUP_STATUS.tmp" +mv -f "$AIOS_BACKUP_STATUS.tmp" "$AIOS_BACKUP_STATUS" +find "$AIOS_BACKUP_DIR" -maxdepth 1 -type f -name 'openbao-*.snap' -mtime +14 -delete +BACKUP + install -m 0555 -o root -g root "$BACKUP_BIN.tmp" "$BACKUP_BIN" + rm -f "$BACKUP_BIN.tmp" + + cat >"$RESTORE_TEST_BIN.tmp" <<'RESTORE' +#!/usr/bin/env bash +set -Eeuo pipefail +umask 077 +source /usr/local/libexec/aios-openbao-client-lib +: "${AIOS_BACKUP_DIR:?}" +: "${AIOS_RESTORE_STATUS:?}" +: "${AIOS_BAO_BIN:?}" +: "${AIOS_SEAL_KEY_FILE:?}" +: "${AIOS_SEAL_KEY_ID_FILE:?}" +: "${AIOS_CHARTER_FILE:?}" +: "${AIOS_OPENBAO_ROLE_ID_FILE:?}" +: "${AIOS_OPENBAO_SECRET_ID_FILE:?}" + +snapshot="$(find "$AIOS_BACKUP_DIR" -maxdepth 1 -type f -name 'openbao-*.snap' -printf '%T@ %p\n' | sort -nr | awk 'NR==1{$1="";sub(/^ /,"");print}')" +[[ -n "$snapshot" && -s "$snapshot" ]] +"$AIOS_BAO_BIN" operator raft snapshot inspect "$snapshot" >/dev/null + +work="$(mktemp -d "${AIOS_RESTORE_ROOT:?}/restore.XXXXXX")" +chmod 0700 "$work" +pid="" +cleanup() { + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + kill -TERM "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fi + rm -rf "$work" +} +trap cleanup EXIT + +base=$((19000 + ($$ % 500) * 2)) +api_port="$base" +cluster_port=$((base + 1)) +addr="http://127.0.0.1:$api_port" +key_id="$(cat "$AIOS_SEAL_KEY_ID_FILE")" +cat >"$work/openbao.hcl" <"$work/server.log" 2>&1 & + pid=$! +} +wait_http() { + local deadline=$((SECONDS+60)) + while ((SECONDS/dev/null; then + return 0 + fi + sleep 1 + done + return 1 +} +stop_server() { + kill -TERM "$pid" + wait "$pid" || true + pid="" +} + +start_server +wait_http +BAO_ADDR="$addr" "$AIOS_BAO_BIN" operator init \ + -format=json -recovery-shares=1 -recovery-threshold=1 >"$work/init.json" +root_token="$(python3 - "$work/init.json" <<'PY' +import json,sys +x=json.load(open(sys.argv[1])) +t=x.get("root_token") +if not t: raise SystemExit(1) +print(t) +PY +)" +BAO_ADDR="$addr" BAO_TOKEN="$root_token" "$AIOS_BAO_BIN" operator raft snapshot restore -force "$snapshot" +unset root_token +stop_server + +start_server +deadline=$((SECONDS+90)) +ready=false +while ((SECONDS/dev/null && + python3 - "$work/restored-health.json" <<'PY' +import json,sys +x=json.load(open(sys.argv[1])) +raise SystemExit(0 if x.get("initialized") is True and x.get("sealed") is False else 1) +PY + then + ready=true + break + fi + sleep 2 +done +[[ "$ready" == true ]] + +export AIOS_OPENBAO_ADDR="$addr" +export AIOS_OPENBAO_CACERT="" +aios_login_token "$work/restored-token" +aios_token_curl "$work/restored-token" GET "kv/data/aios/charter-signature" "$work/restored-signature.json" +python3 - "$AIOS_CHARTER_FILE" "$work/restored-signature.json" "$work/verify.json" <<'PY' +import base64,json,pathlib,sys +raw=pathlib.Path(sys.argv[1]).read_bytes() +x=((json.load(open(sys.argv[2])).get("data") or {}).get("data") or {}) +sig=x.get("signature") +if not sig: raise SystemExit(1) +json.dump({"input":base64.b64encode(raw).decode(),"signature":sig},open(sys.argv[3],"w")) +PY +aios_token_curl "$work/restored-token" POST "transit/verify/aios-charter" "$work/verified.json" "$work/verify.json" +python3 - "$work/verified.json" <<'PY' +import json,sys +raise SystemExit(0 if (json.load(open(sys.argv[1])).get("data") or {}).get("valid") is True else 1) +PY +aios_revoke_self "$work/restored-token" + +python3 - "$AIOS_RESTORE_STATUS.tmp" "$snapshot" <<'PY' +import hashlib,json,pathlib,sys,time +out,snapshot=sys.argv[1:] +raw=pathlib.Path(snapshot).read_bytes() +obj={ + "schema":"AIOS_OPENBAO_RESTORE_TEST_V1", + "passed":True, + "isolated_restore":True, + "source_snapshot":snapshot, + "source_sha256":hashlib.sha256(raw).hexdigest(), + "restored_initialized":True, + "restored_unsealed":True, + "approle_login_verified":True, + "charter_signature_verified":True, + "completed_at":time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime()), +} +pathlib.Path(out).write_text(json.dumps(obj,sort_keys=True)+"\n") +PY +chgrp aios-autonomy "$AIOS_RESTORE_STATUS.tmp" +chmod 0640 "$AIOS_RESTORE_STATUS.tmp" +mv -f "$AIOS_RESTORE_STATUS.tmp" "$AIOS_RESTORE_STATUS" +RESTORE + install -m 0555 -o root -g root "$RESTORE_TEST_BIN.tmp" "$RESTORE_TEST_BIN" + rm -f "$RESTORE_TEST_BIN.tmp" + + cat >"$FAULT_TEST_BIN.tmp" <<'FAULT' +#!/usr/bin/env bash +set -Eeuo pipefail +umask 077 +source /usr/local/libexec/aios-openbao-client-lib +aios_require_client_env +: "${AIOS_FAULT_STATUS:?}" +: "${AIOS_OPENBAO_SERVICE:?}" +: "${AIOS_GITEA_URL:?}" +: "${AIOS_BAO_BIN:?}" + +work="$(mktemp -d /run/aios-openbao-fault.XXXXXX)" +trap 'rm -rf "$work"' EXIT +process_recovery=false +revoked_token_rejected=false +gitea_outage_detected=false +missing_seal_rejected=false + +systemctl kill --kill-who=main --signal=SIGKILL "$AIOS_OPENBAO_SERVICE" +deadline=$((SECONDS+120)) +while ((SECONDS"$cfg" +aios_revoke_self "$work/revoked-test-token" +code="$(aios_curl --config "$cfg" --output "$work/revoked.json" --write-out '%{http_code}' "${AIOS_OPENBAO_ADDR%/}/v1/auth/token/lookup-self" || true)" +rm -f "$cfg" +[[ "$code" == 403 ]] && revoked_token_rejected=true +[[ "$revoked_token_rejected" == true ]] + +if ! curl --silent --show-error --connect-timeout 2 --max-time 3 \ + "http://127.0.0.1:9/api/v1/version" >/dev/null 2>&1; then + gitea_outage_detected=true +fi +[[ "$gitea_outage_detected" == true ]] + +cat >"$work/missing-seal.hcl" <"$work/missing-seal.log" 2>&1 +missing_seal_rc=$? +set -e +if [[ "$missing_seal_rc" -ne 0 && "$missing_seal_rc" -ne 124 ]] && + grep -Eqi 'seal|key|file|no such' "$work/missing-seal.log"; then + missing_seal_rejected=true +fi +[[ "$missing_seal_rejected" == true ]] + +python3 - "$AIOS_FAULT_STATUS.tmp" "$process_recovery" "$revoked_token_rejected" "$gitea_outage_detected" "$missing_seal_rejected" <<'PY' +import json,pathlib,sys,time +out,*vals=sys.argv[1:] +names=["openbao_process_auto_recovered","revoked_token_rejected","gitea_outage_detected","missing_seal_failed_closed"] +checks={k:(v=="true") for k,v in zip(names,vals)} +obj={ + "schema":"AIOS_OPENBAO_FAULT_INJECTION_V1", + "passed":all(checks.values()), + "checks":checks, + "completed_at":time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime()), +} +pathlib.Path(out).write_text(json.dumps(obj,sort_keys=True)+"\n") +PY +chown root:aios-autonomy "$AIOS_FAULT_STATUS.tmp" +chmod 0640 "$AIOS_FAULT_STATUS.tmp" +mv -f "$AIOS_FAULT_STATUS.tmp" "$AIOS_FAULT_STATUS" +FAULT + install -m 0555 -o root -g root "$FAULT_TEST_BIN.tmp" "$FAULT_TEST_BIN" + rm -f "$FAULT_TEST_BIN.tmp" + + cat >"$OPTIMIZE_BIN.tmp" <<'OPTIMIZE' +#!/usr/bin/env bash +set -Eeuo pipefail +umask 077 +: "${AIOS_OPENBAO_ADDR:?}" +: "${AIOS_OPENBAO_CACERT:?}" +: "${AIOS_CONTROLLER_INTERVAL_FILE:?}" +: "${AIOS_OPTIMIZATION_LEDGER:?}" + +current=60 +[[ -s "$AIOS_CONTROLLER_INTERVAL_FILE" ]] && current="$(tr -dc '0-9' <"$AIOS_CONTROLLER_INTERVAL_FILE")" +[[ "$current" =~ ^[0-9]+$ ]] || current=60 +samples="$(mktemp /run/aios-optimize.XXXXXX)" +trap 'rm -f "$samples"' EXIT +for _ in 1 2 3 4 5; do + curl --silent --show-error --cacert "$AIOS_OPENBAO_CACERT" \ + --output /dev/null --write-out '%{time_total}\n' \ + --connect-timeout 3 --max-time 10 \ + "${AIOS_OPENBAO_ADDR%/}/v1/sys/health?standbyok=true&perfstandbyok=true" >>"$samples" +done +read -r p95 average < <(python3 - "$samples" <<'PY' +import statistics,sys +xs=sorted(float(x) for x in open(sys.argv[1]) if x.strip()) +p95=xs[min(len(xs)-1,max(0,int(len(xs)*.95)-1))] +print(f"{p95:.6f} {statistics.mean(xs):.6f}") +PY +) +new="$current" +reason="interval retained" +if python3 - "$p95" <<'PY' +import sys +raise SystemExit(0 if float(sys.argv[1]) < 0.20 else 1) +PY +then + ((current < 120)) && new=$((current+15)) + ((new>120)) && new=120 + reason="low health latency; reduced monitoring overhead" +elif python3 - "$p95" <<'PY' +import sys +raise SystemExit(0 if float(sys.argv[1]) > 1.0 else 1) +PY +then + new=30 + reason="high health latency; increased observation frequency" +fi +printf '%s\n' "$new" >"$AIOS_CONTROLLER_INTERVAL_FILE.tmp" +chmod 0640 "$AIOS_CONTROLLER_INTERVAL_FILE.tmp" +mv -f "$AIOS_CONTROLLER_INTERVAL_FILE.tmp" "$AIOS_CONTROLLER_INTERVAL_FILE" +python3 - "$AIOS_OPTIMIZATION_LEDGER" "$current" "$new" "$p95" "$average" "$reason" <<'PY' +import json,sys,time +path,current,new,p95,avg,reason=sys.argv[1:] +entry={ + "schema":"AIOS_AUTONOMY_OPTIMIZATION_EVENT_V1", + "candidate_id":"controller-interval-"+time.strftime("%Y%m%dT%H%M%SZ",time.gmtime()), + "risk":"low", + "metric":"openbao_health_latency_seconds", + "baseline":{"controller_interval_seconds":int(current),"p95":float(p95),"average":float(avg)}, + "change":{"controller_interval_seconds":int(new)}, + "result":"accepted", + "reason":reason, + "rollback_condition":"health state becomes DEGRADED", + "created_at":time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime()), +} +with open(path,"a") as f: + f.write(json.dumps(entry,sort_keys=True)+"\n") +PY +OPTIMIZE + install -m 0555 -o root -g root "$OPTIMIZE_BIN.tmp" "$OPTIMIZE_BIN" + rm -f "$OPTIMIZE_BIN.tmp" + + cat >"$ACCEPTANCE_BIN.tmp" <<'ACCEPTANCE' +#!/usr/bin/env bash +set -Eeuo pipefail +umask 077 +: "${AIOS_ACCEPTANCE_START_FILE:?}" +: "${AIOS_ACCEPTANCE_SCRIPT_FINGERPRINT:?}" +: "${AIOS_ACCEPTANCE_STATUS:?}" +: "${AIOS_BOOTSTRAP_EVIDENCE:?}" +: "${AIOS_BACKUP_STATUS:?}" +: "${AIOS_RESTORE_STATUS:?}" +: "${AIOS_FAULT_STATUS:?}" +: "${AIOS_OPTIMIZATION_LEDGER:?}" +: "${AIOS_AUTONOMY_STATUS:?}" +: "${AIOS_OPENBAO_ADDR:?}" +: "${AIOS_OPENBAO_CACERT:?}" + +now="$(date +%s)" +started="$(cat "$AIOS_ACCEPTANCE_START_FILE")" +[[ "$started" =~ ^[0-9]+$ ]] +elapsed=$((now-started)) +ready=false +reason="72-hour window incomplete" + +if ((elapsed >= 259200)); then + if curl --silent --show-error --cacert "$AIOS_OPENBAO_CACERT" \ + --output /run/aios-acceptance-health.json \ + "${AIOS_OPENBAO_ADDR%/}/v1/sys/health?standbyok=true&perfstandbyok=true" && + python3 - \ + "$AIOS_BOOTSTRAP_EVIDENCE" \ + "$AIOS_BACKUP_STATUS" \ + "$AIOS_RESTORE_STATUS" \ + "$AIOS_FAULT_STATUS" \ + "$AIOS_OPTIMIZATION_LEDGER" \ + /run/aios-acceptance-health.json \ + "$started" \ + "$AIOS_ACCEPTANCE_SCRIPT_FINGERPRINT" <<'PY' +import datetime,json,pathlib,sys,time +e,b,r,f,ledger,health,started,fingerprint=sys.argv[1:] +start_epoch=int(started) +for p in (e,b,r,f,health): + if not pathlib.Path(p).is_file(): + raise SystemExit(1) +ev=json.load(open(e)) +if ev.get("execution_status")!="PARTIALLY_COMPLETED": + raise SystemExit(1) +expected_sha=pathlib.Path(fingerprint).read_text().strip() +if not expected_sha or ev.get("script_sha256") != expected_sha: + raise SystemExit(1) + +def epoch(value): + if not isinstance(value, str): + raise SystemExit(1) + return int(datetime.datetime.fromisoformat(value.replace("Z","+00:00")).timestamp()) + +for status_path in (b,r,f): + status=json.load(open(status_path)) + if status.get("passed") is not True: + raise SystemExit(1) + completed=epoch(status.get("completed_at")) + if completed < start_epoch or completed < int(time.time())-129600: + raise SystemExit(1) + +ledger_events=[] +for raw in pathlib.Path(ledger).read_text().splitlines(): + if raw.strip(): + ledger_events.append(json.loads(raw)) +if not any(epoch(x.get("created_at")) >= start_epoch for x in ledger_events): + raise SystemExit(1) + +g=ev.get("gitea") or {} +if not all(g.get(k) is True for k in ( + "machine_credential_persisted","machine_authenticated", + "write_permission_verified","non_main_push_verified", + "default_branch_protection_verified","tag_protection_verified", + "temporary_token_revoked","temporary_admin_removed")): + raise SystemExit(1) +o=ev.get("openbao") or {} +if not all(o.get(k) is True for k in ( + "initialized_verified","unsealed_verified","audit_verified", + "transit_verified","kv_v2_verified","approle_verified", + "policy_verified","charter_signature_verified", + "self_init_secret_removed")): + raise SystemExit(1) +h=json.load(open(health)) +if h.get("initialized") is not True or h.get("sealed") is not False: + raise SystemExit(1) +PY + then + ready=true + reason="72-hour unattended acceptance passed" + else + reason="one or more unattended acceptance gates failed" + fi +fi +rm -f /run/aios-acceptance-health.json + +python3 - "$AIOS_ACCEPTANCE_STATUS.tmp" "$ready" "$reason" "$elapsed" <<'PY' +import json,pathlib,sys,time +out,ready,reason,elapsed=sys.argv[1:] +ok=ready=="true" +obj={ + "schema":"AIOS_OPENBAO_UNATTENDED_ACCEPTANCE_V1", + "target_state":"OPENBAO_AUTONOMY_BOOTSTRAP_READY", + "passed":ok, + "elapsed_seconds":int(elapsed), + "reason":reason, + "evaluated_at":time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime()), +} +pathlib.Path(out).write_text(json.dumps(obj,sort_keys=True)+"\n") +PY +chmod 0640 "$AIOS_ACCEPTANCE_STATUS.tmp" +mv -f "$AIOS_ACCEPTANCE_STATUS.tmp" "$AIOS_ACCEPTANCE_STATUS" + +if [[ "$ready" == true ]]; then + python3 - "$AIOS_AUTONOMY_STATUS.tmp" <<'PY' +import json,pathlib,time,os +path=os.environ["AIOS_AUTONOMY_STATUS"] +x={} +if pathlib.Path(path).is_file(): + x=json.load(open(path)) +x.update({ + "state":"RUNNING", + "reason":"OPENBAO_AUTONOMY_BOOTSTRAP_READY", + "OPENBAO_AUTONOMY_BOOTSTRAP_READY":True, + "human_action_required":False, + "updated_at":time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime()), +}) +pathlib.Path(os.environ["AIOS_AUTONOMY_STATUS"]+".tmp").write_text(json.dumps(x,sort_keys=True)+"\n") +PY + chmod 0640 "$AIOS_AUTONOMY_STATUS.tmp" + mv -f "$AIOS_AUTONOMY_STATUS.tmp" "$AIOS_AUTONOMY_STATUS" +fi +ACCEPTANCE + install -m 0555 -o root -g root "$ACCEPTANCE_BIN.tmp" "$ACCEPTANCE_BIN" + rm -f "$ACCEPTANCE_BIN.tmp" + + cat >/etc/systemd/system/aios-openbao.service </etc/systemd/system/aios-autonomy-controller.service </etc/systemd/system/aios-openbao-backup.service </etc/systemd/system/aios-openbao-backup.timer <<'EOF' +[Unit] +Description=Schedule AIOS OpenBao Raft Snapshots +[Timer] +OnBootSec=10min +OnUnitActiveSec=6h +RandomizedDelaySec=5min +Persistent=true +Unit=aios-openbao-backup.service +[Install] +WantedBy=timers.target +EOF + + cat >/etc/systemd/system/aios-openbao-restore-test.service </etc/systemd/system/aios-openbao-restore-test.timer <<'EOF' +[Unit] +Description=Schedule AIOS OpenBao Isolated Restore Tests +[Timer] +OnBootSec=25min +OnUnitActiveSec=24h +RandomizedDelaySec=10min +Persistent=true +Unit=aios-openbao-restore-test.service +[Install] +WantedBy=timers.target +EOF + + cat >/etc/systemd/system/aios-openbao-fault-injection.service </etc/systemd/system/aios-openbao-fault-injection.timer <<'EOF' +[Unit] +Description=Schedule AIOS OpenBao Controlled Fault Injection +[Timer] +OnBootSec=40min +OnUnitActiveSec=24h +RandomizedDelaySec=10min +Persistent=true +Unit=aios-openbao-fault-injection.service +[Install] +WantedBy=timers.target +EOF + + cat >/etc/systemd/system/aios-autonomy-optimize.service </etc/systemd/system/aios-autonomy-optimize.timer <<'EOF' +[Unit] +Description=Schedule AIOS Low-Risk Runtime Optimization +[Timer] +OnBootSec=15min +OnUnitActiveSec=6h +RandomizedDelaySec=5min +Persistent=true +Unit=aios-autonomy-optimize.service +[Install] +WantedBy=timers.target +EOF + + cat >/etc/systemd/system/aios-autonomy-acceptance.service </etc/systemd/system/aios-autonomy-acceptance.timer <<'EOF' +[Unit] +Description=Schedule AIOS OpenBao Unattended Acceptance Evaluation +[Timer] +OnBootSec=1h +OnUnitActiveSec=1h +RandomizedDelaySec=5min +Persistent=true +Unit=aios-autonomy-acceptance.service +[Install] +WantedBy=timers.target +EOF + + chmod 0644 /etc/systemd/system/aios-*.service /etc/systemd/system/aios-*.timer + + install -d -m 0755 "/etc/systemd/system/$BAO_SERVICE.d" + rm -f "/etc/systemd/system/$BAO_SERVICE.d/"20-static-seal-*.conf + if [[ "$SEAL_STORAGE" == "systemd-credential" ]]; then + cat >"/etc/systemd/system/$BAO_SERVICE.d/20-static-seal-credential.conf" <"/etc/systemd/system/$unit.d/20-approle-credential.conf" < /etc/systemd/system/aios-openbao-restore-test.service.d/20-credentials.conf + if [[ "$SEAL_STORAGE" == "systemd-credential" ]]; then + printf '[Service]\nLoadCredentialEncrypted=seal-key:%s\n' "$SEAL_CRED_FILE" \ + >>/etc/systemd/system/aios-openbao-restore-test.service.d/20-credentials.conf + else + sed -i "s|Environment=AIOS_SEAL_KEY_FILE=.*|Environment=AIOS_SEAL_KEY_FILE=${SEAL_KEY_FILE}|" \ + /etc/systemd/system/aios-openbao-restore-test.service + fi + if [[ "$APPROLE_SECRET_STORAGE" == "systemd-credential" ]]; then + if ! grep -q '^\[Service\]' /etc/systemd/system/aios-openbao-restore-test.service.d/20-credentials.conf; then + printf '[Service]\n' >>/etc/systemd/system/aios-openbao-restore-test.service.d/20-credentials.conf + fi + printf 'LoadCredentialEncrypted=openbao-approle-secret-id:%s\n' "$APPROLE_SECRET_ID_CRED" \ + >>/etc/systemd/system/aios-openbao-restore-test.service.d/20-credentials.conf + else + sed -i "s|Environment=AIOS_OPENBAO_SECRET_ID_FILE=.*|Environment=AIOS_OPENBAO_SECRET_ID_FILE=${APPROLE_SECRET_ID_FILE}|" \ + /etc/systemd/system/aios-openbao-restore-test.service + fi + + python3 - "$MANAGED_MARKER.tmp" "$SCRIPT_VERSION" <<'PY' +import json,pathlib,sys,time +pathlib.Path(sys.argv[1]).write_text(json.dumps({ + "schema":"AIOS_OPENBAO_MANAGED_MARKER_V1", + "script_version":sys.argv[2], + "created_at":time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime()), +},sort_keys=True)+"\n") +PY + chown root:root "$MANAGED_MARKER.tmp" + chmod 0640 "$MANAGED_MARKER.tmp" + mv -f "$MANAGED_MARKER.tmp" "$MANAGED_MARKER" + + systemctl daemon-reload + systemctl enable \ + "$BAO_SERVICE" "$CONTROLLER_SERVICE" \ + aios-openbao-backup.timer \ + aios-openbao-restore-test.timer \ + aios-openbao-fault-injection.timer \ + aios-autonomy-optimize.timer \ + aios-autonomy-acceptance.timer >/dev/null + + SERVICES_ENABLED=true + HOST_MUTATED=true +} + +start_and_verify_services() { + PHASE="service-start-and-health" + + systemctl restart "$BAO_SERVICE" + SERVICES_STARTED=true + PRODUCTION_SERVICE_STARTED=true + HOST_MUTATED=true + + systemctl is-active --quiet "$BAO_SERVICE" || die "$BAO_SERVICE is not active" + wait_openbao_ready "$BAO_READY_TIMEOUT" || + die "OpenBao did not self-initialize and unseal within timeout" + + configure_openbao_runtime_and_charter + remove_self_init_secrets_and_restart + + systemctl restart "$CONTROLLER_SERVICE" + systemctl is-active --quiet "$CONTROLLER_SERVICE" || + die "$CONTROLLER_SERVICE is not active" + + # Run the first evidence cycle synchronously. A failure here blocks bootstrap + # instead of deferring a known-bad installation into the 72-hour window. + systemctl start aios-openbao-backup.service + systemctl start aios-openbao-restore-test.service + systemctl start aios-openbao-fault-injection.service + systemctl start aios-autonomy-optimize.service + + systemctl start \ + aios-openbao-backup.timer \ + aios-openbao-restore-test.timer \ + aios-openbao-fault-injection.timer \ + aios-autonomy-optimize.timer \ + aios-autonomy-acceptance.timer + + python3 - "$BACKUP_STATUS" "$RESTORE_STATUS" "$FAULT_STATUS" "$OPTIMIZATION_LEDGER" <<'PY' +import json,pathlib,sys +backup,restore,fault,ledger=sys.argv[1:] +for p in (backup,restore,fault): + if not pathlib.Path(p).is_file(): + raise SystemExit(f"missing evidence: {p}") +if json.load(open(backup)).get("passed") is not True: + raise SystemExit("initial backup failed") +if json.load(open(restore)).get("passed") is not True: + raise SystemExit("initial isolated restore failed") +if json.load(open(fault)).get("passed") is not True: + raise SystemExit("initial fault injection failed") +if not pathlib.Path(ledger).is_file() or pathlib.Path(ledger).stat().st_size == 0: + raise SystemExit("optimization ledger is empty") +PY + + RELEASE_RUNTIME_READY_VERIFIED=true + SERVICES_HEALTHY=true + if [[ "$SEAL_STORAGE" == "systemd-credential" ]]; then + SEAL_RUNTIME_READ_VERIFIED=true + fi + + if [[ "$SERVICES_HEALTHY" == true && "$PENDING_LEGACY_SEAL_KEY_REMOVAL" == true && -f "$SEAL_KEY_FILE" ]]; then + shred -u "$SEAL_KEY_FILE" 2>/dev/null || rm -f "$SEAL_KEY_FILE" + PENDING_LEGACY_SEAL_KEY_REMOVAL=false + fi +} + +start_unattended_acceptance() { + PHASE="unattended-acceptance" + + systemctl is-active --quiet "$CONTROLLER_SERVICE" || + die "autonomy controller is not active" + systemctl is-active --quiet aios-autonomy-acceptance.timer || + die "unattended acceptance timer is not active" + + local reset_window=false + if [[ ! -s "$ACCEPTANCE_SCRIPT_FINGERPRINT" ]] || + [[ "$(tr -d '\r\n' <"$ACCEPTANCE_SCRIPT_FINGERPRINT")" != "$SCRIPT_SHA256" ]]; then + reset_window=true + fi + + if [[ "$reset_window" == true || ! -s "$ACCEPTANCE_START_FILE" ]]; then + date +%s >"$ACCEPTANCE_START_FILE.tmp" + chown aios-autonomy:aios-autonomy "$ACCEPTANCE_START_FILE.tmp" + chmod 0640 "$ACCEPTANCE_START_FILE.tmp" + mv -f "$ACCEPTANCE_START_FILE.tmp" "$ACCEPTANCE_START_FILE" + rm -f "$ACCEPTANCE_STATUS" + fi + + printf '%s\n' "$SCRIPT_SHA256" >"$ACCEPTANCE_SCRIPT_FINGERPRINT.tmp" + chown aios-autonomy:aios-autonomy "$ACCEPTANCE_SCRIPT_FINGERPRINT.tmp" + chmod 0640 "$ACCEPTANCE_SCRIPT_FINGERPRINT.tmp" + mv -f "$ACCEPTANCE_SCRIPT_FINGERPRINT.tmp" "$ACCEPTANCE_SCRIPT_FINGERPRINT" + + ACCEPTANCE_STARTED=true + write_status "RUNNING" "bootstrap verified; 72-hour unattended acceptance in progress" +} + +main() { + preflight + install_identities_and_directories + capture_environment + select_release + download_and_verify_release + install_release + provision_static_seal + provision_tls + provision_openbao_bootstrap_identity + render_openbao_config + create_temp_gitea_admin + create_or_rotate_machine_credential + grant_and_verify_collaborator + verify_default_branch_protection + verify_tag_protection + verify_non_main_git_push + revoke_and_remove_temp_admin + install_services + start_and_verify_services + start_unattended_acceptance + + PHASE="complete" + FINAL_RC=0 +} + +self_test() { + local script + script="$(readlink -f "${BASH_SOURCE[0]}")" + local report + report="$(mktemp)" + chmod 0600 "$report" + + set +e + python3 - "$script" "$report" <<'PY' +import hashlib +import json +import pathlib +import re +import subprocess +import sys +import tempfile + +script, report = sys.argv[1:] +path = pathlib.Path(script) +text = path.read_text() +body = text.split("\nself_test() {", 1)[0] + +def embedded_shell_syntax_ok(): + markers = ("SH", "LIB", "CONTROLLER", "BACKUP", "RESTORE", + "FAULT", "OPTIMIZE", "ACCEPTANCE") + for marker in markers: + matches = re.findall( + r"<<'" + re.escape(marker) + r"'\n(.*?)\n" + re.escape(marker) + r"\n", + body, + flags=re.S, + ) + if len(matches) != 1: + return False + with tempfile.NamedTemporaryFile("w", delete=False) as handle: + handle.write(matches[0]) + candidate = handle.name + try: + result = subprocess.run( + ["bash", "-n", candidate], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if result.returncode != 0: + return False + finally: + pathlib.Path(candidate).unlink(missing_ok=True) + return True + +def checksum_count(manifest, asset): + pattern = re.compile(r"^([0-9a-fA-F]{64}) ([ *])(.+)$") + return sum( + 1 + for raw in manifest.splitlines() + if (match := pattern.match(raw)) and match.group(3) == asset + ) + +def version_at_least(actual, minimum): + def parse(value): + parts = value.split(".") + if len(parts) != 3 or any(not p.isdigit() for p in parts): + raise ValueError(value) + return tuple(int(p) for p in parts) + return parse(actual) >= parse(minimum) + +asset = "openbao_2.6.1_linux_amd64.tar.gz" +good_manifest = f"{'0' * 64} {asset}\n" +duplicate_manifest = good_manifest + good_manifest +missing_manifest = f"{'0' * 64} another.tar.gz\n" + +checks = { + "outer_bash_syntax": subprocess.run( + ["bash", "-n", script], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode == 0, + "embedded_shell_syntax": embedded_shell_syntax_ok(), + "self_contained_no_sibling_preflight": + "package missing openbao.hcl.template" not in body + and "package missing systemd/" not in body, + "declarative_self_initialization": + 'initialize "secret_mounts"' in body + and 'initialize "approle"' in body, + "audit_implemented": + 'audit "file" "aios"' in body + and 'file_path = "${AUDIT_LOG}"' in body, + "transit_runtime_key_implemented": + 'path = "transit/keys/aios-runtime"' in body, + "transit_charter_key_implemented": + 'path = "transit/keys/aios-charter"' in body, + "kv_v2_implemented": + 'path = "sys/mounts/kv"' in body + and 'version = "2"' in body, + "approle_implemented": + 'path = "sys/auth/approle"' in body + and 'custom-secret-id' in body, + "policy_implemented": + 'path = "sys/policies/acl/aios-autonomy"' in body, + "charter_store_sign_verify": + 'kv/data/aios/charter' in body + and 'transit/sign/aios-charter' in body + and 'transit/verify/aios-charter' in body, + "self_init_secret_removal_implemented": + 'OPENBAO_INIT_SECRET_REMOVED=true' in body + and 'self-initialization stanza remained after successful initialization' in body, + "root_token_not_persisted": + "root_token" not in body.split( + 'cat >"$RESTORE_TEST_BIN.tmp"', 1 + )[0], + "openbao_ready_gate_not_bypassable": + 'cannot disable the OpenBao readiness gate' in body + and 'if is_true "$REQUIRE_OPENBAO_READY"' not in body, + "branch_protection_gate_not_bypassable": + 'cannot disable branch protection' in body + and 'if ! is_true "$REQUIRE_BRANCH_PROTECTION"' not in body, + "gitea_tag_api_version_gate": + 'GITEA_MIN_TAG_PROTECTION_API_VERSION="1.23.0"' in body + and 'GITEA_TAG_PROTECTION_API_UNAVAILABLE' in body + and 'actual >= minimum' in body, + "gitea_1_22_6_rejected": + not version_at_least("1.22.6", "1.23.0"), + "gitea_1_23_0_accepted": + version_at_least("1.23.0", "1.23.0"), + "tag_protection_gate_implemented": + '/tag_protections' in body + and '"name_pattern":"*","whitelist_usernames":[],"whitelist_teams":[]' + in body + and '"tag_protection_verified"' in body, + "machine_credential_persisted": + 'MACHINE_CREDENTIAL_STORAGE="protected-file"' in body + and 'machine_credential_persisted' in body, + "backup_implemented": + 'sys/storage/raft/snapshot' in body + and 'operator raft snapshot inspect' in body, + "isolated_restore_implemented": + 'operator raft snapshot restore -force' in body, + "fault_injection_implemented": + 'systemctl kill --kill-who=main --signal=SIGKILL' in body, + "continuous_optimization_implemented": + 'AIOS_AUTONOMY_OPTIMIZATION_EVENT_V1' in body, + "72h_acceptance_gate": + 'elapsed >= 259200' in body + and '"tag_protection_verified"' in body, + "exact_checksum_good_fixture": + checksum_count(good_manifest, asset) == 1, + "exact_checksum_duplicate_rejected": + checksum_count(duplicate_manifest, asset) == 2, + "exact_checksum_missing_rejected": + checksum_count(missing_manifest, asset) == 0, + "no_git_force_push": + re.search( + r'\bgit\b[^\n]*\bpush\b[^\n]*--force(?:-with-lease)?\b', + body, + ) is None, + "no_git_tag_mutation": + re.search( + r'\bgit\b[^\n]*\b(push|tag)\b[^\n]*refs/tags', + body, + ) is None, + "ready_not_predeclared": + '"value": False' in body + and 'OPENBAO_AUTONOMY_BOOTSTRAP_READY":True' in body, +} +out = { + "schema": "AIOS_OPENBAO_BOOTSTRAP_STATIC_SELF_TEST_V3", + "scope": "static_and_fixture_only", + "runtime_claims": False, + "script_sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "passed": sum(checks.values()), + "total": len(checks), + "checks": checks, +} +pathlib.Path(report).write_text(json.dumps(out, sort_keys=True) + "\n") +raise SystemExit(0 if all(checks.values()) else 1) +PY + local rc=$? + set -e + + cat "$report" + rm -f "$report" + return "$rc" +} + +if [[ "${1:-}" == "--self-test" ]]; then + trap - ERR EXIT INT TERM + self_test + exit $? +fi + +main "$@" diff --git a/deploy/openbao-autonomy/admin-bootstrap-openbao-autonomy-v1-v3.sha256 b/deploy/openbao-autonomy/admin-bootstrap-openbao-autonomy-v1-v3.sha256 new file mode 100644 index 0000000..fc356d2 --- /dev/null +++ b/deploy/openbao-autonomy/admin-bootstrap-openbao-autonomy-v1-v3.sha256 @@ -0,0 +1 @@ +4bece5edc8be5d2024b5b81ce569549625c2bf318fdaed1066773b379b76cc66 admin-bootstrap-openbao-autonomy-v1-v3.sh diff --git a/deploy/openbao-autonomy/checksums.sha256 b/deploy/openbao-autonomy/checksums.sha256 new file mode 100644 index 0000000..60a3615 --- /dev/null +++ b/deploy/openbao-autonomy/checksums.sha256 @@ -0,0 +1,4 @@ +aa5a8eb14e80d23c2e3f8a36cdeea4b3cd026877eb4aa424637a85665388e6ec admin-bootstrap-openbao-autonomy-v1-v3.self-test.json +4bece5edc8be5d2024b5b81ce569549625c2bf318fdaed1066773b379b76cc66 admin-bootstrap-openbao-autonomy-v1-v3.sh +b400b9427cd37cb7bbba85313081aeb93f0f3181cbfbab99948c793f30d3c53e admin-bootstrap-openbao-autonomy-v1-v3.sha256 +c355df01c83c2801ae3a0df43806c562ee70f97c9aa558e3f67dd097efa5ac99 test-admin-bootstrap-openbao-autonomy-v1-v3.sh diff --git a/deploy/openbao-autonomy/test-admin-bootstrap-openbao-autonomy-v1-v3.sh b/deploy/openbao-autonomy/test-admin-bootstrap-openbao-autonomy-v1-v3.sh new file mode 100755 index 0000000..8cba68d --- /dev/null +++ b/deploy/openbao-autonomy/test-admin-bootstrap-openbao-autonomy-v1-v3.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +umask 077 + +script="${1:-$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)/admin-bootstrap-openbao-autonomy-v1-v3.sh}" +report="${2:-$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)/admin-bootstrap-openbao-autonomy-v1-v3.self-test.json}" + +[[ -f "$script" ]] || { + printf 'missing script: %s\n' "$script" >&2 + exit 66 +} + +bash -n "$script" +tmp="$(mktemp)" +trap 'rm -f "$tmp"' EXIT +bash "$script" --self-test >"$tmp" + +python3 - "$script" "$tmp" <<'PY' +import hashlib +import json +import pathlib +import sys + +script, report = map(pathlib.Path, sys.argv[1:]) +data = json.loads(report.read_text()) +actual_hash = hashlib.sha256(script.read_bytes()).hexdigest() + +assert data["schema"] == "AIOS_OPENBAO_BOOTSTRAP_STATIC_SELF_TEST_V3" +assert data["scope"] == "static_and_fixture_only" +assert data["runtime_claims"] is False +assert data["script_sha256"] == actual_hash +assert data["passed"] == data["total"] +assert all(data["checks"].values()) +PY + +install -m 0600 "$tmp" "$report" +printf 'PASS bash-n + embedded static/fixture tests\n' +printf 'report=%s\n' "$report" +sha256sum "$script"