Files
sxchnge/dev/bootstrap.sh
T

144 lines
7.6 KiB
Bash

initialize() {
attempt=0
until initialized=$(vault sys/init | jq -er '.initialized|tostring'); do
attempt=$((attempt+1)); [ "$attempt" -lt 90 ] || fail 'Vault did not start'; sleep 1
done
if [ "$initialized" = false ]; then
[ ! -e "$STATE/admin/init.json" ] || fail 'Vault data missing but bootstrap material exists; restore matching volume or reset both'
printf '{"secret_shares":1,"secret_threshold":1}' | vault sys/init POST | write "$STATE/admin/init.json"
fi
existing
jq -r '.keys_base64[0]' "$STATE/admin/init.json" | write "$STATE/vault/unseal" 444
attempt=0
until vault sys/seal-status | jq -e '.sealed==false' >/dev/null; do
attempt=$((attempt+1)); [ "$attempt" -lt 60 ] || fail 'Vault did not unseal'; sleep 1
done
}
mounts() {
vault sys/mounts > "$WORK/mounts"
for name in kv database pki; do
if ! jq -e --arg key "$name/" 'has($key)' < "$WORK/mounts" >/dev/null; then
if ! jq -e --arg key "$name/" '.data|has($key)' < "$WORK/mounts" >/dev/null; then
jq -n --arg name "$name" '{type:$name} + (if $name=="kv" then {options:{version:"2"}} else {} end)' | vault "sys/mounts/$name" POST >/dev/null
fi
fi
done
if ! vault sys/auth | jq -e '.data|has("approle/")' >/dev/null; then
printf '{"type":"approle"}' | vault sys/auth/approle POST >/dev/null
fi
}
certificates() {
printf '{"max_lease_ttl":"87600h"}' | vault sys/mounts/pki/tune POST >/dev/null
ca=$(kv tls)
if [ "$ca" = null ]; then
printf '{"common_name":"SXCHNGE local development CA","ttl":"87600h"}' | vault pki/root/generate/internal POST | jq '{ca:.data.certificate}' | put tls
fi
printf '{"allowed_domains":["localhost","notifications"],"allow_bare_domains":true,"allow_ip_sans":true,"max_ttl":"720h"}' | vault pki/roles/local POST >/dev/null
for name in notifications-tls frontend-tls; do
host=notifications; [ "$name" != frontend-tls ] || host=localhost
kv "$name" > "$WORK/cert"
if ! jq -e '.!=null and .expiration>(now+604800)' "$WORK/cert" >/dev/null; then
jq -n --arg host "$host" '{common_name:$host,ip_sans:"127.0.0.1",ttl:"720h"}' | vault pki/issue/local POST | jq '.data' > "$WORK/cert"
put "$name" < "$WORK/cert"
fi
if [ "$name" = frontend-tls ]; then
jq -r '.certificate' "$WORK/cert" | write "$STATE/frontend/server.crt"
jq -r '.private_key' "$WORK/cert" | write "$STATE/frontend/server.key"
fi
done
kv tls | jq -r '.ca' | write "$STATE/ca.pem" 644
}
provision_mongo() {
if [ "$(kv bootstrap/mongo)" = null ]; then
random > "$WORK/password"; random 384 > "$WORK/keyfile"; random > "$WORK/manager"
jq -n --rawfile password "$WORK/password" --rawfile keyfile "$WORK/keyfile" --rawfile manager "$WORK/manager" '{password:($password|rtrimstr("\n")),keyfile:($keyfile|rtrimstr("\n")),manager_password:($manager|rtrimstr("\n"))}' | put bootstrap/mongo
fi
kv bootstrap/mongo > "$WORK/mongo-secret"
jq -r '.password' "$WORK/mongo-secret" | write "$STATE/mongo/password"
jq -r '.keyfile' "$WORK/mongo-secret" | write "$STATE/mongo/keyfile"
compose up -d mongo
admin_auth > "$WORK/admin.js"
attempt=0
while :; do
if { cat "$WORK/admin.js"; printf 'try {rs.status()} catch(e) {if(e.code===94) rs.initiate({_id:"rs0",members:[{_id:0,host:"mongo:27017"}]}); else throw e;} print(db.hello().isWritablePrimary);'; } | mongo > "$WORK/primary" && grep -q true "$WORK/primary"; then break; fi
attempt=$((attempt+1)); [ "$attempt" -lt 90 ] || fail 'Mongo replica set did not become primary'; sleep 2
done
if [ "$(vault database/config/mongo GET yes)" = null ]; then
{ cat "$WORK/admin.js"; jq -r '"if(!admin.getUser(\"vault-manager\")) admin.createUser({user:\"vault-manager\",pwd:"+(.manager_password|tojson)+",roles:[{role:\"userAdminAnyDatabase\",db:\"admin\"}]});"' "$WORK/mongo-secret"; } | mongo >/dev/null || fail 'Mongo manager provisioning failed'
jq '{plugin_name:"mongodb-database-plugin",allowed_roles:["bff","notifications"],connection_url:"mongodb://{{username}}:{{password}}@mongo:27017/admin?replicaSet=rs0",username:"vault-manager",password:.manager_password}' "$WORK/mongo-secret" | vault database/config/mongo POST >/dev/null
printf '{}' | vault database/rotate-root/mongo POST >/dev/null
fi
for role in bff notifications; do
database=sxchnge; [ "$role" != notifications ] || database=sxchnge_notifications
jq -n --arg database "$database" '{db_name:"mongo",creation_statements:({db:"admin",roles:[{role:"readWrite",db:$database}]}|tojson),default_ttl:"1h",max_ttl:"24h"}' | vault "database/roles/$role" POST >/dev/null
done
{ cat "$WORK/admin.js"; cat dev/mongo-client.js; } | mongo >/dev/null || fail 'Browser client provisioning failed'
}
provision_agents() {
for service in bff notifications nats; do
directory="$STATE/agents/$service"
mkdir -p "$directory"; chmod 755 "$directory"
case "$service" in
bff) paths='bff caller tls';;
notifications) paths='caller tls notifications-tls nats email';;
nats) paths=nats;;
esac
: > "$WORK/policy"
for path in $paths; do printf 'path "kv/data/%s" { capabilities = ["read"] }\n' "$path" >> "$WORK/policy"; done
if [ "$service" != nats ]; then printf 'path "database/creds/%s" { capabilities = ["read"] }\n' "$service" >> "$WORK/policy"; fi
jq -n --rawfile policy "$WORK/policy" '{policy:$policy}' | vault "sys/policies/acl/$service" POST >/dev/null
jq -n --arg service "$service" '{token_policies:[$service],token_period:"1h",secret_id_ttl:"0",secret_id_num_uses:0}' | vault "auth/approle/role/$service" POST >/dev/null
vault "auth/approle/role/$service/role-id" | jq -r '.data.role_id' | write "$directory/role-id" 444
if [ ! -f "$directory/secret-id" ]; then
printf '{}' | vault "auth/approle/role/$service/secret-id" POST | jq -r '.data.secret_id' | write "$directory/secret-id" 444
fi
cat "dev/vault/$service.hcl" | write "$directory/config.hcl" 444
done
}
up() {
[ -f "$STATE/images.env" ] || fail 'Run make build before make up'
exec 9> "$STATE/bootstrap.lock"
flock -n 9 || fail 'Another local bootstrap is running'
mkdir -p "$STATE/vault"; chmod 755 "$STATE/vault"
compose up -d vault
# On first bootstrap Compose creates the network after this tool container starts.
if ! docker inspect "$HOSTNAME" --format '{{json .NetworkSettings.Networks}}' | jq -e 'has("sxchnge-local_default")' >/dev/null; then
docker network connect sxchnge-local_default "$HOSTNAME"
fi
initialize
mounts
for path in nats caller bff; do
if [ "$(kv "$path")" = null ]; then
field=token; [ "$path" != bff ] || field=jwt
random | jq -Rs --arg field "$field" '{($field):rtrimstr("\n")}' | put "$path"
fi
done
printf '{"driver":"resend","api_key":"","sender_address":"notifications@localhost.test","sender_name":"SXCHNGE local"}' | ensure email
certificates
provision_mongo
provision_agents
compose up -d --wait --wait-timeout 120 nats
kv email | email_valid || fail 'Vault kv/email needs a Resend api_key and verified sender_address. Run make email, then make up.'
compose up -d --wait --wait-timeout 180
compose exec -T frontend nginx -s reload
echo 'Local stack ready: https://localhost:5126; Vault: http://127.0.0.1:18200'
}
email() {
existing
printf 'Verified Resend sender address: ' >&2
IFS= read -r address
printf 'Resend API key (hidden): ' >&2
tty_state=$(stty -g)
trap 'stty "$tty_state"; cleanup' EXIT
stty -echo
IFS= read -r key
stty "$tty_state"
printf '\n' >&2
[ -n "$key" ] || fail 'A key is required'
case "$address" in *@*) ;; *) fail 'A sender email address is required';; esac
printf '%s' "$key" > "$WORK/email-key"
printf '%s' "$address" > "$WORK/email-address"
jq -n --rawfile key "$WORK/email-key" --rawfile address "$WORK/email-address" '{driver:"resend",api_key:$key,sender_address:$address,sender_name:"SXCHNGE"}' | put email
echo 'Email configuration saved in Vault. Run make up.'
}