diff --git a/.env.example b/.env.example index 594a3045..0d05e034 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,11 @@ PUBLIC_URL=http://localhost:9000 # it to "dev" on a local machine you trust, since that disables all three. PUBLIC_BUILD_ENV=production +# Origin checking is OFF unless this is explicitly "false" — an inversion of the usual rule, and one +# that is only defensible when the tailnet is the perimeter. On a machine with no tailnet, set it to +# false. Written explicitly rather than left to the default so the choice is visible. +ALLOW_ANY_ORIGIN=true + DATA_PATH=/path/to/data OFFICER_ITEMS_DIR=/path/to/officer-items HOME_DIR=/home/user @@ -31,9 +36,17 @@ BROWSER_RELAY_PORT=18792 # daemon URL and its API key live encrypted in `service_connections`; the sidecar injects the key as # X-API-Key on every forwarded request. -# Vaultwarden (officer-vault). VAULT_STORE_KEY encrypts stored secrets at rest — any strong secret -# of 16+ chars works, and CHANGING IT MAKES EXISTING STORED SECRETS UNREADABLE. +# Vaultwarden (officer-vault). VAULTWARDEN_URL=http://127.0.0.1:8222 + +# VAULT_STORE_KEY is NOT Vaultwarden's, despite the name and where it sits — it is the platform's +# at-rest key, and it encrypts every secret column in Postgres: Headscale admin API keys, app-store +# service credentials, Jellyfin tokens, wallet node credentials, and the wallet seed envelope on top +# of the owner passphrase that seals it. Any strong secret of 16+ chars works. +# +# CHANGING IT MAKES ALL OF THAT UNREADABLE AT ONCE, and for the seed that is unrecoverable: the +# passphrase opens the inner envelope and this is the outer one. See docs/secret-store.md, which is +# the design for moving this key out of here and making rotation a supported operation. VAULT_STORE_KEY="" # Anthropic proxy (officer-anthropic-proxy). Defaults to 5051; it holds the API credential, which diff --git a/scripts/setup/officer-setup.sh b/scripts/setup/officer-setup.sh index c394398f..2e81d2da 100755 --- a/scripts/setup/officer-setup.sh +++ b/scripts/setup/officer-setup.sh @@ -46,6 +46,8 @@ source "$SCRIPT_DIR/officer-setup/lib/repo.sh" source "$SCRIPT_DIR/officer-setup/lib/layout.sh" # shellcheck source=officer-setup/lib/postgres.sh source "$SCRIPT_DIR/officer-setup/lib/postgres.sh" +# shellcheck source=officer-setup/lib/env.sh +source "$SCRIPT_DIR/officer-setup/lib/env.sh" trap 'echo ""; echo -e "${RED}╔══════════════════════════════════════════════════╗${NC}"; echo -e "${RED}║ OFFICER SETUP FAILED${NC}"; echo -e "${RED}║ Step: ${CURRENT_STEP:-unknown}${NC}"; echo -e "${RED}║ Line: $LINENO${NC}"; echo -e "${RED}║ Command: $BASH_COMMAND${NC}"; echo -e "${RED}╚══════════════════════════════════════════════════╝${NC}"' ERR @@ -447,10 +449,111 @@ if ! skip; then step_ok fi +# ============================================================================= +# 6. Environment +# ============================================================================= + +step "Environment" +if ! skip; then + echo "" + info "Environment — $(env_file)" + + # Read back before anything is asked. The two secrets below are kept, never + # reminted, and everything else becomes the default for its question. + ENV_JWT_SECRET="$(env_get JWT_SECRET)" + ENV_VAULT_STORE_KEY="$(env_get VAULT_STORE_KEY)" + ENV_PORT="$(env_get PORT)" + ENV_PUBLIC_URL="$(env_get PUBLIC_URL)" + ENV_MAIL_TRANSPORT="$(env_get MAIL_TRANSPORT)" + ENV_DISCORD_WEBHOOK="$(env_get DISCORD_BUG_REPORT_WEBHOOK)" + ENV_BROWSER_RELAY_PORT="$(env_get BROWSER_RELAY_PORT)" + + if env_exists; then + echo " exists — its values are the defaults below, and the two secrets are kept" + else + echo " does not exist yet" + fi + + # ── the secrets ── + if [[ -n "$ENV_JWT_SECRET" ]]; then + echo " JWT_SECRET: kept (regenerating it logs everybody out)" + else + ENV_JWT_SECRET="$(generate_secret)" + echo " JWT_SECRET: generated" + fi + + if [[ -n "$ENV_VAULT_STORE_KEY" ]]; then + echo " VAULT_STORE_KEY: kept" + else + ENV_VAULT_STORE_KEY="$(generate_secret)" + echo " VAULT_STORE_KEY: generated" + echo "" + warn "back up VAULT_STORE_KEY somewhere safe, now." + echo " It encrypts every upstream credential the platform stores, and the" + echo " wallet's seed on top of your passphrase. Lose it and those are gone" + echo " — the passphrase does not help, because it opens the inner envelope" + echo " and this is the outer one." + fi + + # ── what is asked ── + echo "" + ask_required ENV_PORT "Port Officer listens on" "${ENV_PORT:-9000}" + ENV_BROWSER_RELAY_PORT="${ENV_BROWSER_RELAY_PORT:-18792}" + + echo "" + echo " PUBLIC_URL is where Officer is reached from a browser. Allowed" + echo " origins are derived from it, and passkeys are bound to its host —" + echo " so it has to be the address you actually use, not localhost, unless" + echo " localhost is genuinely it." + ask_required ENV_PUBLIC_URL "Public URL" "${ENV_PUBLIC_URL:-http://localhost:${ENV_PORT}}" + + # ── origin checking, decided by the machine rather than by a default ── + # + # ALLOW_ANY_ORIGIN defaults to ON inside the platform, which CLAUDE.md says is + # only defensible because the tailnet is the perimeter. So the value is written + # explicitly here, from whether this machine actually has one. + if tailnet_present; then + ENV_ALLOW_ANY_ORIGIN="true" + ORIGIN_WHY="tailscale0 is up, so the tailnet is the perimeter" + else + ENV_ALLOW_ANY_ORIGIN="false" + ORIGIN_WHY="no tailnet on this machine, so origin checking is left ON" + fi + echo "" + echo " ALLOW_ANY_ORIGIN=${ENV_ALLOW_ANY_ORIGIN} — ${ORIGIN_WHY}" + + echo "" + ENV_MAIL_TRANSPORT="${ENV_MAIL_TRANSPORT:-}" + read -rp " Mail transport, blank for none [${ENV_MAIL_TRANSPORT}]: " REPLY_MAIL || true + ENV_MAIL_TRANSPORT="${REPLY_MAIL:-$ENV_MAIL_TRANSPORT}" + + echo "" + echo " to write:" + echo " PORT=${ENV_PORT} BROWSER_RELAY_PORT=${ENV_BROWSER_RELAY_PORT}" + echo " PUBLIC_URL=${ENV_PUBLIC_URL}" + echo " ALLOW_ANY_ORIGIN=${ENV_ALLOW_ANY_ORIGIN}" + echo " DATA_PATH=${OFFICER_ROOT}/data" + echo " OFFICER_ITEMS_DIR=${OFFICER_ROOT}/capabilities" + echo " HOME_DIR=${USER_HOME}" + echo " POSTGRES_URL=${POSTGRES_URL%%:*}://…" + echo " JWT_SECRET, VAULT_STORE_KEY — not shown" + echo "" + + if confirm "Write it?"; then + write_env + ok "written, 0600, owned by ${USERNAME}" + [[ -f "$(env_file).before-officer-setup" ]] && echo " previous kept as $(env_file).before-officer-setup" + SUMMARY+=("Environment: $(env_file)") + else + warn "skipped by request" + SUMMARY+=("Environment: SKIPPED by request") + fi + step_ok +fi + # ============================================================================= # NOT BUILT YET # ============================================================================= -# 5 Environment .env # 6 Schema db:push # 7 Build gen:index # 8 Services pm2 startOrRestart · save · startup diff --git a/scripts/setup/officer-setup/lib/env.sh b/scripts/setup/officer-setup/lib/env.sh new file mode 100644 index 00000000..acbd05b0 --- /dev/null +++ b/scripts/setup/officer-setup/lib/env.sh @@ -0,0 +1,123 @@ +#!/bin/bash +# ============================================================================= +# officer-setup — the environment file +# ============================================================================= +# +# Definitions only. +# +# ── Two secrets that must never be regenerated ── +# +# JWT_SECRET signs every session token. Minting a new one logs everybody out of +# every device, silently — the symptom is people being signed out for no stated +# reason. The original regenerated it on every run that answered "yes" to +# regenerating .env. +# +# VAULT_STORE_KEY is worse, and the original never wrote it at all — so a +# scripted install had no key and the vault and wallet refused to store anything. +# It encrypts every upstream credential the platform holds (see docs/secret-store.md +# for the full list) and, on top of the owner passphrase, the BIP39 seed envelope. +# Changing it makes all of them unreadable, and for the seed that is unrecoverable: +# the passphrase opens the inner envelope, and the outer one is gone. Unless the +# mnemonic was written down offline, so are the coins. +# +# Both are read back from an existing .env and kept. Both are slated to move into +# the secret store — docs/secret-store.md — which is what makes changing them an +# operation rather than data loss. +# +# ── Derived, not asked ── +# +# DATA_PATH and OFFICER_ITEMS_DIR come from $OFFICER_ROOT. They were two separate +# questions in the original, which had to agree with each other and with where the +# app store looks. + +[[ -n "${OFFICER_SETUP_ENV_LOADED:-}" ]] && return 0 +OFFICER_SETUP_ENV_LOADED=1 + +env_file() { echo "$(platform_dir)/.env"; } + +env_exists() { [[ -f "$(env_file)" ]]; } + +# One value out of an existing .env, without sourcing it — the file holds +# secrets and arbitrary shell would run as root. +env_get() { + [[ -r "$(env_file)" ]] || return 0 + awk -F= -v k="$1" ' + $1 == k { + v = substr($0, index($0, "=") + 1) + gsub(/^"|"$/, "", v) + print v + exit + }' "$(env_file)" +} + +# Long enough to be worth having, and stripped of characters that would need +# quoting in a file everything reads with a naive parser. +generate_secret() { openssl rand -base64 48 | tr -d '/+=\n' | head -c 48; } + +# Origin checking is OFF unless this is explicitly false — CLAUDE.md is explicit +# that the inversion is deliberate and is only defensible because the tailnet is +# the perimeter. With no tailnet there is no perimeter, so the default stops +# being defensible and the value has to be written the other way. +tailnet_present() { ip link show tailscale0 &>/dev/null; } + +write_env() { + local dest + dest="$(env_file)" + + [[ -f "$dest" ]] && cp -a "$dest" "${dest}.before-officer-setup" + + # Restrictive from the moment it exists rather than chmod'd afterwards, so the + # secrets are never briefly world-readable. Restored straight after: umask is + # not scoped to a function, and leaving it at 077 would quietly make every file + # a later section creates owner-only. + local prior_umask + prior_umask="$(umask)" + umask 077 + cat >"$dest" <