#!/usr/bin/env bash # # credentials.sh — Shared credential loader for Mosaic tool suites # # Usage: source ~/.config/mosaic/tools/_lib/credentials.sh # load_credentials # # credentials.json is the single source of truth. # For Woodpecker, credentials are also synced to ~/.woodpecker/.env. # # Supported services: # portainer, coolify, authentik, glpi, github, # gitea-mosaicstack, gitea-usc, woodpecker, cloudflare, # turbo-cache, openbrain # # After loading, service-specific env vars are exported. # Run `load_credentials --help` for details. # # Resolution order (first match wins): # 1. $MOSAIC_CREDENTIALS_FILE (explicit override — never second-guessed) # 2. $HOME/.config/mosaic/credentials.json # 3. /etc/mosaic/credentials.json (host-level fallback) # The /etc fallback exists for HOME-redirected profile environments, where # $HOME points at a per-profile directory that has no credentials file. # Operators symlink /etc/mosaic/credentials.json to the host's canonical # file once, instead of exporting MOSAIC_CREDENTIALS_FILE per invocation. # # Gitea has one additional identity-aware path. When the resolved git identity # names a fleet seat, gitea-mosaicstack and gitea-usc obtain the token through # tools/git/git-credential-mosaic rather than reading a slot directly. That # production entrypoint enforces the clean-environment and process-ancestry # fence before it reads a seat slot. A seat-slot miss is terminal: this loader # never substitutes the service-store token for it. URLs remain provider # configuration and continue to come from this loader's service store. if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then for _cand in "$HOME/.config/mosaic/credentials.json" "/etc/mosaic/credentials.json"; do if [[ -f "$_cand" ]]; then MOSAIC_CREDENTIALS_FILE="$_cand"; break; fi done : "${MOSAIC_CREDENTIALS_FILE:=$HOME/.config/mosaic/credentials.json}" fi export MOSAIC_CREDENTIALS_FILE _mosaic_require_jq() { if ! command -v jq &>/dev/null; then echo "Error: jq is required but not installed" >&2 return 1 fi } _mosaic_read_cred() { local jq_path="$1" if [[ ! -f "$MOSAIC_CREDENTIALS_FILE" ]]; then echo "Error: Credentials file not found: $MOSAIC_CREDENTIALS_FILE" >&2 return 1 fi jq -r "$jq_path // empty" "$MOSAIC_CREDENTIALS_FILE" } # Decide curl TLS flag for a target URL: validate public hosts (MITM matters on # WAN); allow self-signed only for private-network IP literals (trusted LAN) or an # explicit $MOSAIC_INSECURE_TLS opt-in. Echoes "-k" or "" (empty). _mosaic_tls_opt() { local url="$1" host [[ -n "${MOSAIC_INSECURE_TLS:-}" ]] && { echo "-k"; return; } host=$(printf '%s' "$url" | sed -E 's#^[a-zA-Z]+://([^/:]+).*#\1#') if [[ "$host" =~ ^(10\.|127\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.) ]]; then echo "-k"; return fi echo "" } # Sync Woodpecker credentials to ~/.woodpecker/.env # Only writes when values differ to avoid unnecessary disk writes. _mosaic_sync_woodpecker_env() { local instance="$1" url="$2" token="$3" local env_file="$HOME/.woodpecker/${instance}.env" [[ -d "$HOME/.woodpecker" ]] || return 0 local expected expected=$(printf '# %s Woodpecker CI\nexport WOODPECKER_SERVER="%s"\nexport WOODPECKER_TOKEN="%s"\n' \ "$instance" "$url" "$token") if [[ -f "$env_file" ]]; then local current_url current_token current_url=$(grep -oP '(?<=WOODPECKER_SERVER=").*(?=")' "$env_file" 2>/dev/null || true) current_token=$(grep -oP '(?<=WOODPECKER_TOKEN=").*(?=")' "$env_file" 2>/dev/null || true) [[ "$current_url" == "$url" && "$current_token" == "$token" ]] && return 0 fi printf '%s\n' "$expected" > "$env_file" } # Load legacy flat Woodpecker credentials (.woodpecker.url / .woodpecker.token). # Some environments export WOODPECKER_INSTANCE=mosaic, but the current # credentials.json may still use the legacy flat schema. Treat "mosaic" as the # default flat instance when a nested .woodpecker.mosaic object is absent. _mosaic_load_woodpecker_legacy() { export WOODPECKER_URL="$(_mosaic_read_cred '.woodpecker.url')" export WOODPECKER_TOKEN="$(_mosaic_read_cred '.woodpecker.token')" export WOODPECKER_INSTANCE="${WOODPECKER_INSTANCE:-mosaic}" WOODPECKER_URL="${WOODPECKER_URL%/}" [[ -n "$WOODPECKER_URL" ]] || { echo "Error: woodpecker.url not found" >&2; return 1; } [[ -n "$WOODPECKER_TOKEN" ]] || { echo "Error: woodpecker.token not found" >&2; return 1; } _mosaic_sync_woodpecker_env "$WOODPECKER_INSTANCE" "$WOODPECKER_URL" "$WOODPECKER_TOKEN" } _mosaic_resolve_git_identity() { local ident="${MOSAIC_GIT_IDENTITY:-}" if [[ -z "$ident" ]]; then ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)" fi printf '%s' "$ident" } _mosaic_git_identity_is_seat() { local ident="$1" brain_home [[ -n "$ident" ]] || return 1 brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}" [[ -d "$brain_home/fleet/agents/$ident" ]] } _mosaic_gitea_seat_token_from_helper() { # Use the production wrapper, not its Bash implementation. The wrapper # removes BASH_ENV/function injection before the implementation evaluates # MOSAIC_AGENT_NAME ancestry, so a loader consumer cannot bypass that fence. local host="$1" ident="$2" script_dir helper response key value local username="" password="" username_seen=0 password_seen=0 script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" helper="$script_dir/../git/git-credential-mosaic" if [[ ! -x "$helper" ]]; then echo "Error: Gitea seat credential helper is unavailable: $helper" >&2 return 1 fi if ! response="$(printf 'protocol=https\nhost=%s\n\n' "$host" | "$helper" get)"; then return 1 fi while IFS='=' read -r key value; do [[ -n "$key" ]] || continue case "$key" in username) if (( username_seen )); then echo 'Error: Gitea seat credential helper returned duplicate username fields' >&2 return 1 fi username="$value" username_seen=1 ;; password) if (( password_seen )); then echo 'Error: Gitea seat credential helper returned duplicate password fields' >&2 return 1 fi password="$value" password_seen=1 ;; *) echo 'Error: Gitea seat credential helper returned an invalid protocol field' >&2 return 1 ;; esac done <<< "$response" if [[ "$username_seen" -ne 1 || "$password_seen" -ne 1 || "$username" != "$ident" || -z "$password" ]]; then echo "Error: Gitea seat credential helper did not return a valid credential for '$ident'" >&2 return 1 fi printf '%s' "$password" } _mosaic_gitea_token() { # $1 is the Gitea host and $2 is the legacy service-store jq path. # Seats are delegated to the fenced helper; all other identities retain the # existing service-store behavior. A failed seat delegation returns nonzero # to the caller and deliberately cannot fall through to _mosaic_read_cred. local host="$1" service_jq_path="$2" ident ident="$(_mosaic_resolve_git_identity)" if _mosaic_git_identity_is_seat "$ident"; then _mosaic_gitea_seat_token_from_helper "$host" "$ident" return fi _mosaic_read_cred "$service_jq_path" } load_credentials() { local service="$1" if [[ -z "$service" || "$service" == "--help" ]]; then cat <<'EOF' Usage: load_credentials Services and exported variables: portainer → PORTAINER_URL, PORTAINER_API_KEY coolify → COOLIFY_URL, COOLIFY_TOKEN authentik → AUTHENTIK_URL, AUTHENTIK_TOKEN, AUTHENTIK_TEST_USER, AUTHENTIK_TEST_PASSWORD (uses default instance) authentik- → AUTHENTIK_URL, AUTHENTIK_TOKEN, AUTHENTIK_TEST_USER, AUTHENTIK_TEST_PASSWORD (specific instance, e.g. authentik-usc) glpi → GLPI_URL, GLPI_APP_TOKEN, GLPI_USER_TOKEN github → GITHUB_TOKEN gitea-mosaicstack → GITEA_URL, GITEA_TOKEN gitea-usc → GITEA_URL, GITEA_TOKEN woodpecker → WOODPECKER_URL, WOODPECKER_TOKEN (uses default instance) woodpecker- → WOODPECKER_URL, WOODPECKER_TOKEN (specific instance, e.g. woodpecker-usc) cloudflare → CLOUDFLARE_API_TOKEN (uses default instance) cloudflare- → CLOUDFLARE_API_TOKEN (specific instance, e.g. cloudflare-personal) turbo-cache → TURBO_API, TURBO_TOKEN, TURBO_TEAM openbrain → OPENBRAIN_URL, OPENBRAIN_TOKEN EOF return 0 fi _mosaic_require_jq || return 1 case "$service" in portainer) export PORTAINER_URL="${PORTAINER_URL:-$(_mosaic_read_cred '.portainer.url')}" export PORTAINER_API_KEY="${PORTAINER_API_KEY:-$(_mosaic_read_cred '.portainer.api_key')}" PORTAINER_URL="${PORTAINER_URL%/}" [[ -n "$PORTAINER_URL" ]] || { echo "Error: portainer.url not found" >&2; return 1; } [[ -n "$PORTAINER_API_KEY" ]] || { echo "Error: portainer.api_key not found" >&2; return 1; } ;; coolify) export COOLIFY_URL="${COOLIFY_URL:-$(_mosaic_read_cred '.coolify.url')}" export COOLIFY_TOKEN="${COOLIFY_TOKEN:-$(_mosaic_read_cred '.coolify.app_token')}" COOLIFY_URL="${COOLIFY_URL%/}" [[ -n "$COOLIFY_URL" ]] || { echo "Error: coolify.url not found" >&2; return 1; } [[ -n "$COOLIFY_TOKEN" ]] || { echo "Error: coolify.app_token not found" >&2; return 1; } ;; authentik-*) local ak_instance="${service#authentik-}" export AUTHENTIK_URL="$(_mosaic_read_cred ".authentik.${ak_instance}.url")" export AUTHENTIK_TOKEN="$(_mosaic_read_cred ".authentik.${ak_instance}.token")" export AUTHENTIK_TEST_USER="$(_mosaic_read_cred ".authentik.${ak_instance}.test_user.username")" export AUTHENTIK_TEST_PASSWORD="$(_mosaic_read_cred ".authentik.${ak_instance}.test_user.password")" export AUTHENTIK_INSTANCE="$ak_instance" AUTHENTIK_URL="${AUTHENTIK_URL%/}" [[ -n "$AUTHENTIK_URL" ]] || { echo "Error: authentik.${ak_instance}.url not found" >&2; return 1; } ;; authentik) local ak_default ak_default="${AUTHENTIK_INSTANCE:-$(_mosaic_read_cred '.authentik.default')}" if [[ -z "$ak_default" ]]; then # Fallback: try legacy flat structure (.authentik.url) local legacy_url legacy_url="$(_mosaic_read_cred '.authentik.url')" if [[ -n "$legacy_url" ]]; then export AUTHENTIK_URL="${AUTHENTIK_URL:-$legacy_url}" export AUTHENTIK_TOKEN="${AUTHENTIK_TOKEN:-$(_mosaic_read_cred '.authentik.token')}" export AUTHENTIK_TEST_USER="${AUTHENTIK_TEST_USER:-$(_mosaic_read_cred '.authentik.test_user.username')}" export AUTHENTIK_TEST_PASSWORD="${AUTHENTIK_TEST_PASSWORD:-$(_mosaic_read_cred '.authentik.test_user.password')}" AUTHENTIK_URL="${AUTHENTIK_URL%/}" [[ -n "$AUTHENTIK_URL" ]] || { echo "Error: authentik.url not found" >&2; return 1; } else echo "Error: authentik.default not set and no AUTHENTIK_INSTANCE env var" >&2 echo "Available instances: $(jq -r '.authentik | keys | join(", ")' "$MOSAIC_CREDENTIALS_FILE" 2>/dev/null)" >&2 return 1 fi else load_credentials "authentik-${ak_default}" fi ;; glpi) export GLPI_URL="${GLPI_URL:-$(_mosaic_read_cred '.glpi.url')}" export GLPI_APP_TOKEN="${GLPI_APP_TOKEN:-$(_mosaic_read_cred '.glpi.app_token')}" export GLPI_USER_TOKEN="${GLPI_USER_TOKEN:-$(_mosaic_read_cred '.glpi.user_token')}" GLPI_URL="${GLPI_URL%/}" [[ -n "$GLPI_URL" ]] || { echo "Error: glpi.url not found" >&2; return 1; } ;; github) export GITHUB_TOKEN="${GITHUB_TOKEN:-$(_mosaic_read_cred '.github.token')}" [[ -n "$GITHUB_TOKEN" ]] || { echo "Error: github.token not found" >&2; return 1; } ;; gitea-mosaicstack) export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.mosaicstack.url')}" GITEA_URL="${GITEA_URL%/}" [[ -n "$GITEA_URL" ]] || { echo "Error: gitea.mosaicstack.url not found" >&2; return 1; } # An explicit caller value retains the loader's established precedence. # Otherwise, a known seat delegates to the ancestry-fenced helper and a # non-seat identity uses the established service-store lookup. if [[ -z "${GITEA_TOKEN:-}" ]]; then local _gitea_token _gitea_token="$(_mosaic_gitea_token 'git.mosaicstack.dev' '.gitea.mosaicstack.token')" || return 1 GITEA_TOKEN="$_gitea_token" fi # Preserve the loader's contract even when the caller supplied an # unexported shell variable before invoking load_credentials. export GITEA_TOKEN [[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.mosaicstack.token not found" >&2; return 1; } ;; gitea-usc) export GITEA_URL="${GITEA_URL:-$(_mosaic_read_cred '.gitea.usc.url')}" GITEA_URL="${GITEA_URL%/}" [[ -n "$GITEA_URL" ]] || { echo "Error: gitea.usc.url not found" >&2; return 1; } if [[ -z "${GITEA_TOKEN:-}" ]]; then local _gitea_token _gitea_token="$(_mosaic_gitea_token 'git.uscllc.com' '.gitea.usc.token')" || return 1 GITEA_TOKEN="$_gitea_token" fi export GITEA_TOKEN [[ -n "$GITEA_TOKEN" ]] || { echo "Error: gitea.usc.token not found" >&2; return 1; } ;; woodpecker-*) local wp_instance="${service#woodpecker-}" # credentials.json is authoritative — always read from it, ignore env. # Backward compatibility: the default Mosaic Woodpecker instance may be # stored in the legacy flat schema (.woodpecker.url/.token) instead of # .woodpecker.mosaic.url/.token. if [[ "$wp_instance" == "mosaic" ]] && [[ -z "$(_mosaic_read_cred '.woodpecker.mosaic.url')" ]] && [[ -n "$(_mosaic_read_cred '.woodpecker.url')" ]]; then WOODPECKER_INSTANCE="mosaic" _mosaic_load_woodpecker_legacy return $? fi export WOODPECKER_URL="$(_mosaic_read_cred ".woodpecker.${wp_instance}.url")" export WOODPECKER_TOKEN="$(_mosaic_read_cred ".woodpecker.${wp_instance}.token")" export WOODPECKER_INSTANCE="$wp_instance" WOODPECKER_URL="${WOODPECKER_URL%/}" [[ -n "$WOODPECKER_URL" ]] || { echo "Error: woodpecker.${wp_instance}.url not found" >&2; return 1; } [[ -n "$WOODPECKER_TOKEN" ]] || { echo "Error: woodpecker.${wp_instance}.token not found" >&2; return 1; } # Sync to ~/.woodpecker/.env so the wp CLI wrapper stays current _mosaic_sync_woodpecker_env "$wp_instance" "$WOODPECKER_URL" "$WOODPECKER_TOKEN" ;; woodpecker) # Resolve default instance, then load it. If WOODPECKER_INSTANCE is set to # "mosaic" by a shell/profile but credentials.json still uses the legacy # flat .woodpecker.url/.token schema, load the flat credentials instead of # failing with "woodpecker.mosaic.url not found". local wp_default wp_default="${WOODPECKER_INSTANCE:-$(_mosaic_read_cred '.woodpecker.default')}" if [[ -z "$wp_default" ]]; then # Fallback: try legacy flat structure (.woodpecker.url / .woodpecker.token) local legacy_url legacy_url="$(_mosaic_read_cred '.woodpecker.url')" if [[ -n "$legacy_url" ]]; then _mosaic_load_woodpecker_legacy else echo "Error: woodpecker.default not set and no WOODPECKER_INSTANCE env var" >&2 echo "Available instances: $(jq -r '.woodpecker | keys | join(", ")' "$MOSAIC_CREDENTIALS_FILE" 2>/dev/null)" >&2 return 1 fi else if [[ "$wp_default" == "mosaic" ]] && [[ -z "$(_mosaic_read_cred '.woodpecker.mosaic.url')" ]] && [[ -n "$(_mosaic_read_cred '.woodpecker.url')" ]]; then WOODPECKER_INSTANCE="mosaic" _mosaic_load_woodpecker_legacy else load_credentials "woodpecker-${wp_default}" fi fi ;; cloudflare-*) local cf_instance="${service#cloudflare-}" export CLOUDFLARE_API_TOKEN="${CLOUDFLARE_API_TOKEN:-$(_mosaic_read_cred ".cloudflare.${cf_instance}.api_token")}" export CLOUDFLARE_INSTANCE="$cf_instance" [[ -n "$CLOUDFLARE_API_TOKEN" ]] || { echo "Error: cloudflare.${cf_instance}.api_token not found" >&2; return 1; } ;; cloudflare) # Resolve default instance, then load it local cf_default cf_default="${CLOUDFLARE_INSTANCE:-$(_mosaic_read_cred '.cloudflare.default')}" if [[ -z "$cf_default" ]]; then echo "Error: cloudflare.default not set and no CLOUDFLARE_INSTANCE env var" >&2 return 1 fi load_credentials "cloudflare-${cf_default}" ;; turbo-cache) export TURBO_API="${TURBO_API:-$(_mosaic_read_cred '.turbo_cache.api_url')}" export TURBO_TOKEN="${TURBO_TOKEN:-$(_mosaic_read_cred '.turbo_cache.token')}" export TURBO_TEAM="${TURBO_TEAM:-$(_mosaic_read_cred '.turbo_cache.team')}" [[ -n "$TURBO_API" ]] || { echo "Error: turbo_cache.api_url not found" >&2; return 1; } [[ -n "$TURBO_TOKEN" ]] || { echo "Error: turbo_cache.token not found" >&2; return 1; } [[ -n "$TURBO_TEAM" ]] || { echo "Error: turbo_cache.team not found" >&2; return 1; } ;; openbrain) export OPENBRAIN_URL="${OPENBRAIN_URL:-$(_mosaic_read_cred '.openbrain.url')}" export OPENBRAIN_TOKEN="${OPENBRAIN_TOKEN:-$(_mosaic_read_cred '.openbrain.api_key')}" OPENBRAIN_URL="${OPENBRAIN_URL%/}" [[ -n "$OPENBRAIN_URL" ]] || { echo "Error: openbrain.url not found" >&2; return 1; } [[ -n "$OPENBRAIN_TOKEN" ]] || { echo "Error: openbrain.api_key not found" >&2; return 1; } ;; *) echo "Error: Unknown service '$service'" >&2 echo "Supported: portainer, coolify, authentik[-], glpi, github, gitea-mosaicstack, gitea-usc, woodpecker[-], cloudflare[-], turbo-cache, openbrain" >&2 return 1 ;; esac } # Common HTTP helper — makes a curl request and separates body from status code # Usage: mosaic_http GET "/api/v1/endpoint" "Authorization: Bearer $TOKEN" [base_url] # Returns: body on stdout, sets MOSAIC_HTTP_CODE mosaic_http() { local method="$1" local endpoint="$2" local auth_header="$3" local base_url="${4:-}" local response local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}") response=$(curl -sS $_tls -w "\n%{http_code}" -X "$method" \ -H "$auth_header" \ -H "Content-Type: application/json" \ "${base_url}${endpoint}") MOSAIC_HTTP_CODE=$(echo "$response" | tail -n1) echo "$response" | sed '$d' } # POST variant with body # Usage: mosaic_http_post "/api/v1/endpoint" "Authorization: Bearer $TOKEN" '{"key":"val"}' [base_url] mosaic_http_post() { local endpoint="$1" local auth_header="$2" local data="$3" local base_url="${4:-}" local response local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}") response=$(curl -sS $_tls -w "\n%{http_code}" -X POST \ -H "$auth_header" \ -H "Content-Type: application/json" \ -d "$data" \ "${base_url}${endpoint}") MOSAIC_HTTP_CODE=$(echo "$response" | tail -n1) echo "$response" | sed '$d' } # PATCH variant with body mosaic_http_patch() { local endpoint="$1" local auth_header="$2" local data="$3" local base_url="${4:-}" local response local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}") response=$(curl -sS $_tls -w "\n%{http_code}" -X PATCH \ -H "$auth_header" \ -H "Content-Type: application/json" \ -d "$data" \ "${base_url}${endpoint}") MOSAIC_HTTP_CODE=$(echo "$response" | tail -n1) echo "$response" | sed '$d' }