diff --git a/AGENTS.md b/AGENTS.md index 687ac94..fd3976c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,12 @@ generated outputs. Document the generation workflow so a clean checkout can build without committed generated code. +## Local development tooling + +Use Make and shell for local development orchestration. Do not introduce Python +scripts or a Python runtime dependency. Keep bootstrap tools inside Docker so +the host needs only Make, a POSIX shell and Docker with Compose/BuildKit. + ## Container versions Use only the latest stable upstream releases for infrastructure, product base diff --git a/Makefile b/Makefile index 170c1d5..f0d88a2 100644 --- a/Makefile +++ b/Makefile @@ -56,12 +56,13 @@ package-notifications-interface: generate-notifications COMPOSE = docker compose --env-file .local/dev/images.env .PHONY: build up down logs ps test-dev build: - python3 dev/images.py + sh dev/tools.sh build + sh dev/tools.sh images $(COMPOSE) pull vault mongo $(COMPOSE) build --pull up: - python3 dev/local.py up + sh dev/tools.sh up down: $(COMPOSE) down @@ -73,14 +74,21 @@ ps: $(COMPOSE) ps test-dev: - python3 -m unittest discover -s dev -p 'test_*.py' - python3 dev/local.py verify + sh dev/tools.sh test + sh dev/tools.sh verify .PHONY: test-dev-infra test-dev-infra: - python3 -m unittest discover -s dev -p 'test_*.py' - python3 dev/local.py verify-infra + sh dev/tools.sh test + sh dev/tools.sh verify-infra .PHONY: test-dev-rotation test-dev-rotation: - python3 dev/rotation.py + sh dev/tools.sh rotation + +.PHONY: email test-dev-tooling +email: + sh dev/tools.sh email + +test-dev-tooling: + sh dev/tools.sh test diff --git a/README.md b/README.md index 468814f..1468cbc 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,8 @@ Output is `frontend/app/build/web/`. To inspect that build locally, from the repository root: ```sh -python3 -m http.server 5123 --bind 127.0.0.1 --directory frontend/app/build/web +docker run --rm --pull always -p 127.0.0.1:5123:80 \ + -v "$PWD/frontend/app/build/web:/usr/share/nginx/html:ro" nginx:stable-alpine ``` Open [the local app](http://127.0.0.1:5123/). The language selector supports EN/RU diff --git a/dev/agents.py b/dev/agents.py deleted file mode 100644 index 5fecdf7..0000000 --- a/dev/agents.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Build per-service Vault Agent configs (no secret values in configs).""" -import json -from pathlib import Path - -BASE = (Path(__file__).parent / 'vault' / 'agent.hcl').read_text() - - -def kv(path, field): - return '{{ with secret "kv/data/' + path + '" }}{{ .Data.data.' + field + ' }}{{ end }}' - - -def config(service): - env = {} - if service == 'nats': - env['NATS_TOKEN'] = kv('nats', 'token') - else: - env['TLS_CA'] = kv('tls', 'ca') - if service == 'bff': - env['SXCHNGE_JWT_SECRET'] = kv('bff', 'jwt') - env['SXCHNGE_NOTIFICATIONS_TOKEN'] = kv('caller', 'token') - else: - env['SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN'] = kv('caller', 'token') - env['SXCHNGE_NATS_URL'] = ('{{ with secret "kv/data/nats" }}' - 'nats://{{ .Data.data.token }}@nats:4222{{ end }}') - env['TLS_CERT'] = kv('notifications-tls', 'certificate') - env['TLS_KEY'] = kv('notifications-tls', 'private_key') - env['EMAIL_API_KEY'] = kv('email', 'api_key') - # JSON is valid YAML and escapes arbitrary provider configuration. - env['EMAIL_CONFIG'] = ('{{ with secret "kv/data/email" }}' - 'email:\n driver: {{ .Data.data.driver | toJSON }}\n' - ' settings:\n api_key_env: EMAIL_API_KEY\n' - ' sender_address: {{ .Data.data.sender_address | toJSON }}\n' - ' sender_name: {{ .Data.data.sender_name | toJSON }}\n{{ end }}') - result = BASE - for name, template in env.items(): - result += '\nenv_template ' + json.dumps(name) + ' {\n contents = ' + json.dumps(template) + '\n error_on_missing_key = true\n}\n' - command = '/usr/local/bin/start-nats' if service == 'nats' else '/usr/local/bin/start-service' - result += '\nexec {\n command = [' + json.dumps(command) + ']\n restart_on_secret_changes = "always"\n restart_stop_signal = "SIGTERM"\n}\n' - return result diff --git a/dev/bootstrap.py b/dev/bootstrap.py deleted file mode 100644 index a89e719..0000000 --- a/dev/bootstrap.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Idempotent provisioning for the isolated local Compose project.""" -import json -import os -import subprocess -import time - -from agents import config -from vault import STATE, write - -ROOT = STATE.parents[1] -COMPOSE = ['docker', 'compose', '--env-file', str(STATE / 'images.env')] - - -def compose(*args, capture=False, input=None): - result = subprocess.run(COMPOSE + list(args), cwd=ROOT, input=input, text=True, - capture_output=capture) - if result.returncode: - # Bootstrap stdin and child errors may contain credentials. Never echo them. - raise RuntimeError('Compose command failed: ' + ' '.join(args[:3])) - return result.stdout if capture else None - - -def mongo(script, password): - auth = 'const admin = db.getSiblingDB("admin"); admin.auth("local-admin", ' + json.dumps(password) + ');\n' - return compose('exec', '-T', 'mongo', 'mongosh', '--quiet', '--file', '/dev/stdin', capture=True, input=auth + script) - - -def provision_mongo(vault): - secret = vault.ensure('bootstrap/mongo', lambda: {'password': vault.random(), 'keyfile': vault.random(384), 'manager_password': vault.random()}) - write(STATE / 'mongo' / 'password', secret['password']) - write(STATE / 'mongo' / 'keyfile', secret['keyfile']) - compose('up', '-d', 'mongo') - for attempt in range(90): - try: - mongo('try { rs.status(); } catch(e) { if(e.code === 94) rs.initiate({_id:"rs0",members:[{_id:0,host:"mongo:27017"}]}); else throw e; }', secret['password']) - if 'true' in mongo('print(db.hello().isWritablePrimary)', secret['password']): - break - except RuntimeError: - pass - time.sleep(2) - else: - raise RuntimeError('Mongo replica set did not become primary') - if vault.call('database/config/mongo', missing=True) is None: - mongo('if (!admin.getUser("vault-manager")) admin.createUser({user:"vault-manager",pwd:' + json.dumps(secret['manager_password']) + ',roles:[{role:"userAdminAnyDatabase",db:"admin"}]});', secret['password']) - vault.call('database/config/mongo', {'plugin_name': 'mongodb-database-plugin', - 'allowed_roles': ['bff', 'notifications'], - 'connection_url': 'mongodb://{{username}}:{{password}}@mongo:27017/admin?replicaSet=rs0', - 'username': 'vault-manager', 'password': secret['manager_password']}) - vault.call('database/rotate-root/mongo', {}) - for role, database in [('bff', 'sxchnge'), ('notifications', 'sxchnge_notifications')]: - vault.call('database/roles/' + role, {'db_name': 'mongo', - 'creation_statements': json.dumps({'db': 'admin', 'roles': [{'role': 'readWrite', 'db': database}]}), - 'default_ttl': '1h', 'max_ttl': '24h'}) - # Public browser client: an identifier, never a client secret in the web bundle. - mongo('db.getSiblingDB("sxchnge").clients.updateOne({clientId:"sxchnge-local-web"}, {$setOnInsert: {clientName:"Local browser",allowedCIDRs:["0.0.0.0/0","::/0"],allowedScopes:["account"],redirectURIs:["https://localhost:5126"],grantTypes:["password","refresh_token"],tokenEndpointAuthMethod:"none",isRevoked:false}}, {upsert:true});', secret['password']) - - -def provision_agents(vault): - paths = {'bff': ['bff', 'caller', 'tls'], - 'notifications': ['caller', 'tls', 'notifications-tls', 'nats', 'email'], - 'nats': ['nats']} - for service, secrets in paths.items(): - directory = STATE / 'agents' / service - directory.mkdir(parents=True, exist_ok=True, mode=0o755) - os.chmod(directory, 0o755) - policy = ''.join('path "kv/data/' + p + '" { capabilities = ["read"] }\n' for p in secrets) - if service != 'nats': - policy += 'path "database/creds/' + service + '" { capabilities = ["read"] }\n' - vault.call('sys/policies/acl/' + service, {'policy': policy}) - vault.call('auth/approle/role/' + service, {'token_policies': [service], - 'token_period': '1h', 'secret_id_ttl': '0', 'secret_id_num_uses': 0}) - role_id = vault.call('auth/approle/role/' + service + '/role-id')['data']['role_id'] - write(directory / 'role-id', role_id, 0o444) - if not (directory / 'secret-id').exists(): - secret_id = vault.call('auth/approle/role/' + service + '/secret-id', {})['data']['secret_id'] - write(directory / 'secret-id', secret_id, 0o444) - write(directory / 'config.hcl', config(service), 0o444) - - -def up(vault): - if not (STATE / 'images.env').exists(): - raise RuntimeError('Run make build before make up') - (STATE / 'vault').mkdir(parents=True, exist_ok=True, mode=0o755) - compose('up', '-d', 'vault') - vault.initialize() - vault.mounts() - vault.ensure('nats', lambda: {'token': vault.random()}) - vault.ensure('caller', lambda: {'token': vault.random()}) - vault.ensure('bff', lambda: {'jwt': vault.random()}) - vault.ensure('email', lambda: {'driver': 'resend', 'api_key': '', - 'sender_address': 'notifications@localhost.test', 'sender_name': 'SXCHNGE local'}) - vault.certificates() - provision_mongo(vault) - provision_agents(vault) - compose('up', '-d', '--wait', '--wait-timeout', '120', 'nats') - email = vault.kv('email') - key, sender = email.get('api_key'), email.get('sender_address') - if (not isinstance(key, str) or not key.strip() - or not isinstance(sender, str) or not sender.strip() - or sender == 'notifications@localhost.test'): - raise RuntimeError('Vault kv/email needs a Resend api_key and verified sender_address. Run python3 dev/local.py email, then make up.') - compose('up', '-d', '--wait', '--wait-timeout', '180') - compose('exec', '-T', 'frontend', 'nginx', '-s', 'reload', capture=True) - print('Local stack ready: https://localhost:5126; Vault: http://127.0.0.1:18200') - print('Local CA: .local/dev/ca.pem. Email driver: ' + vault.kv('email')['driver']) diff --git a/dev/bootstrap.sh b/dev/bootstrap.sh new file mode 100644 index 0000000..cd6da49 --- /dev/null +++ b/dev/bootstrap.sh @@ -0,0 +1,143 @@ +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.' +} diff --git a/dev/common.sh b/dev/common.sh new file mode 100644 index 0000000..45ef441 --- /dev/null +++ b/dev/common.sh @@ -0,0 +1,60 @@ +# Shared container-side primitives. Secret JSON travels through stdin or private files. +set -eu +set -o pipefail +umask 077 +STATE="$PWD/.local/dev" +TOOL_STATE=$STATE +mkdir -p "$STATE" +WORK=$(mktemp -d) +cleanup() { + rm -rf "$WORK" + chown -R "${LOCAL_UID:-0}:${LOCAL_GID:-0}" "$TOOL_STATE" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +fail() { echo "$*" >&2; exit 1; } +compose() { docker compose --env-file "$STATE/images.env" "$@"; } +write() { + target=$1; mode=${2:-600} + mkdir -p "$(dirname "$target")" + cat > "$target.tmp" + chmod "$mode" "$target.tmp" + mv "$target.tmp" "$target" +} +# Token is never placed in curl's arguments, errors or response logs. +vault() ( + path=$1; method=${2:-GET}; missing=${3:-no} + header=$(mktemp "$WORK/header.XXXXXX") + response=$(mktemp "$WORK/response.XXXXXX") + trap 'rm -f "$header" "$response"' EXIT + printf 'Content-Type: application/json\n' > "$header" + if [ -n "${VAULT_TOKEN:-}" ]; then printf 'X-Vault-Token: %s\n' "$VAULT_TOKEN" >> "$header"; fi + if [ "$method" = GET ]; then + code=$(curl -s --connect-timeout 5 --max-time 30 -H "@$header" -o "$response" -w '%{http_code}' "http://vault:8200/v1/$path") || exit 1 + else + code=$(curl -s --connect-timeout 5 --max-time 30 -H "@$header" -X "$method" --data-binary @- -o "$response" -w '%{http_code}' "http://vault:8200/v1/$path") || exit 1 + fi + case "$code" in + 200|204) if [ -s "$response" ]; then cat "$response"; else printf '{}'; fi;; + 404) if [ "$missing" = yes ]; then printf 'null'; else echo "Vault $path returned HTTP $code" >&2; exit 1; fi;; + *) echo "Vault $path returned HTTP $code" >&2; exit 1;; + esac +) +existing() { + [ -f "$STATE/admin/init.json" ] || fail 'Missing .local/dev/admin/init.json; restore matching bootstrap material' + VAULT_TOKEN=$(jq -er '.root_token' "$STATE/admin/init.json") + export VAULT_TOKEN +} +kv() { vault "kv/data/$1" GET yes | jq 'if . == null then null else .data.data end'; } +put() { jq '{data:.}' | vault "kv/data/$1" POST >/dev/null; } +random() { printf '{"bytes":%s,"format":"hex"}' "${1:-32}" | vault sys/tools/random POST | jq -er '.data.random_bytes'; } +ensure() ( + path=$1 + value=$(kv "$path") + if [ "$value" = null ]; then put "$path"; fi +) +# Never forward mongosh errors: authentication/command errors may include secrets. +mongo() { compose exec -T mongo mongosh --quiet --file /dev/stdin > "$WORK/mongo-result" 2> "$WORK/mongo-error" || return 1; cat "$WORK/mongo-result"; } +admin_auth() { kv bootstrap/mongo | jq -r '"const admin=db.getSiblingDB(\"admin\"); admin.auth(\"local-admin\","+(.password|tojson)+");"'; } +email_valid() { jq -e '(.api_key|type=="string") and (.api_key|test("\\S")) and (.sender_address|type=="string") and (.sender_address|test("\\S")) and .sender_address!="notifications@localhost.test"' >/dev/null; } diff --git a/dev/dispatch.sh b/dev/dispatch.sh new file mode 100644 index 0000000..eba2e24 --- /dev/null +++ b/dev/dispatch.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu +set -o pipefail +. dev/common.sh +case "${1:-}" in + images) . dev/images.sh; images;; + up) . dev/bootstrap.sh; up;; + email) . dev/bootstrap.sh; email;; + verify|verify-infra) . dev/verify.sh; existing; infrastructure; if [ "$1" = verify ]; then product; fi;; + rotation) . dev/verify.sh; . dev/rotation.sh; existing; rotation;; + test) sh dev/test.sh;; + *) fail 'Unknown local tooling command';; +esac diff --git a/dev/images.py b/dev/images.py deleted file mode 100644 index f884728..0000000 --- a/dev/images.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -"""Resolve stable upstream releases on every build; pin their registry digests.""" -import concurrent.futures -import json -import os -from pathlib import Path -import re -import subprocess -import urllib.request - -ROOT = Path(__file__).resolve().parents[1] -STATE = ROOT / '.local' / 'dev' - - -def get(url): - with urllib.request.urlopen(url, timeout=45) as response: - return json.load(response) - - -def image(repo, alias): - url = f'https://hub.docker.com/v2/repositories/{repo}/tags' - selected = get(f'{url}/{alias}') - digest = selected['digest'] - if not re.fullmatch(r'sha256:[a-f0-9]{64}', digest): - raise ValueError('Registry returned an invalid digest') - # Find the exact numeric tag represented by the maintained stable alias. - candidates = get(f'{url}?page_size=100')['results'] - names = [x['name'] for x in candidates if x.get('digest') == digest - and re.fullmatch(r'\d+\.\d+(?:\.\d+)?(?:-alpine)?', x['name'])] - if not names: - raise ValueError(f'No stable version tag for {repo}:{alias}') - tag = max(names, key=lambda x: tuple(map(int, x.split('-')[0].split('.')))) - return f'{repo}:{tag}@{digest}' - - -def flutter(): - commit = get('https://api.github.com/repos/flutter/flutter/commits/stable')['sha'] - tags = subprocess.check_output(['git', 'ls-remote', '--tags', - 'https://github.com/flutter/flutter.git'], text=True) - names = [line.split('refs/tags/')[1] for line in tags.splitlines() - if line.startswith(commit + '\trefs/tags/') - and re.fullmatch(r'\d+\.\d+\.\d+', line.split('refs/tags/')[1])] - if not names: - raise ValueError('Flutter stable does not point at a stable release tag') - return max(names, key=lambda x: tuple(map(int, x.split('.')))), commit - - -def main(): - go = get('https://go.dev/dl/?mode=json')[0] - if not go['stable'] or not re.fullmatch(r'go\d+\.\d+\.\d+', go['version']): - raise ValueError('Go release is not stable') - specs = {'VAULT_IMAGE': ('hashicorp/vault', 'latest'), - 'MONGO_IMAGE': ('library/mongo', 'latest'), - 'NATS_IMAGE': ('library/nats', 'latest'), - 'NGINX_IMAGE': ('library/nginx', 'stable-alpine'), - 'GO_IMAGE': ('library/golang', go['version'][2:] + '-alpine'), - 'UBUNTU_IMAGE': ('library/ubuntu', 'latest')} - with concurrent.futures.ThreadPoolExecutor() as pool: - futures = {key: pool.submit(image, *spec) for key, spec in specs.items()} - refs = {key: future.result() for key, future in futures.items()} - refs['FLUTTER_VERSION'], refs['FLUTTER_COMMIT'] = flutter() - STATE.mkdir(parents=True, exist_ok=True, mode=0o700) - target = STATE / 'images.env' - temporary = target.with_suffix('.tmp') - temporary.write_text(''.join(f'{key}={value}\n' for key, value in refs.items())) - os.replace(temporary, target) - for key, value in refs.items(): - print(f'{key}={value}') - - -if __name__ == '__main__': - main() diff --git a/dev/images.sh b/dev/images.sh new file mode 100644 index 0000000..4238fbb --- /dev/null +++ b/dev/images.sh @@ -0,0 +1,24 @@ +# Resolve maintained stable aliases to exact numeric tags and digests. +get() { curl -fsS --connect-timeout 10 --max-time 60 "$1"; } +image() ( + repo=$1; alias=$2 + digest=$(get "https://hub.docker.com/v2/repositories/$repo/tags/$alias" | jq -er '.digest | select(test("^sha256:[a-f0-9]{64}$"))') || exit 1 + tag=$(get "https://hub.docker.com/v2/repositories/$repo/tags?page_size=100" | jq -er --arg digest "$digest" ' + [.results[] | select(.digest==$digest) | .name | select(test("^[0-9]+\\.[0-9]+(\\.[0-9]+)?(-alpine)?$"))] + | sort_by(split("-")[0] | split(".") | map(tonumber)) | last // error("No stable numeric tag matches digest")') || exit 1 + printf '%s:%s@%s\n' "$repo" "$tag" "$digest" +) +images() ( + go=$(get 'https://go.dev/dl/?mode=json' | jq -er '.[0] | select(.stable) | .version | select(test("^go[0-9]+\\.[0-9]+\\.[0-9]+$"))') || exit 1 + for spec in VAULT_IMAGE:hashicorp/vault:latest MONGO_IMAGE:library/mongo:latest NATS_IMAGE:library/nats:latest NGINX_IMAGE:library/nginx:stable-alpine "GO_IMAGE:library/golang:${go#go}-alpine" UBUNTU_IMAGE:library/ubuntu:latest; do + key=${spec%%:*}; rest=${spec#*:}; repo=${rest%:*}; alias=${rest##*:} + ref=$(image "$repo" "$alias") || exit 1 + printf '%s=%s\n' "$key" "$ref" >> "$WORK/images.env" + done + commit=$(get https://api.github.com/repos/flutter/flutter/commits/stable | jq -er '.sha | select(test("^[a-f0-9]{40}$"))') || exit 1 + git ls-remote --tags https://github.com/flutter/flutter.git > "$WORK/flutter-tags" || exit 1 + tag=$(jq -Rrs --arg commit "$commit" '[split("\n")[] | split("\t") | select(.[0]==$commit) | .[1] | sub("^refs/tags/";"") | select(test("^[0-9]+\\.[0-9]+\\.[0-9]+$"))] | sort_by(split(".")|map(tonumber)) | last // error("Flutter stable has no stable release tag")' "$WORK/flutter-tags") || exit 1 + printf 'FLUTTER_VERSION=%s\nFLUTTER_COMMIT=%s\n' "$tag" "$commit" >> "$WORK/images.env" + cat "$WORK/images.env" | write "$STATE/images.env" + cat "$STATE/images.env" +) diff --git a/dev/images/tools.Dockerfile b/dev/images/tools.Dockerfile new file mode 100644 index 0000000..39e2ea9 --- /dev/null +++ b/dev/images/tools.Dockerfile @@ -0,0 +1,3 @@ +ARG DOCKER_IMAGE +FROM ${DOCKER_IMAGE} +RUN apk add --no-cache curl jq git openssl util-linux shellcheck diff --git a/dev/local.py b/dev/local.py deleted file mode 100644 index 17d3a48..0000000 --- a/dev/local.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python3 -"""Local stack lifecycle. Requires Python 3 and Docker Compose.""" -import sys -from bootstrap import up -from vault import Vault - - -def main(): - vault = Vault() - if len(sys.argv) == 2 and sys.argv[1] == 'up': - import fcntl - from vault import STATE - STATE.mkdir(parents=True, exist_ok=True, mode=0o700) - with (STATE / 'bootstrap.lock').open('w') as lock: - try: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: - raise RuntimeError('Another local bootstrap is running') from None - up(vault) - elif len(sys.argv) == 2 and sys.argv[1] == 'verify': - from verify import verify - vault.existing() - verify(vault) - elif len(sys.argv) == 2 and sys.argv[1] == 'verify-infra': - from verify import infrastructure - vault.existing() - infrastructure(vault) - elif len(sys.argv) == 2 and sys.argv[1] == 'email': - import getpass - vault.existing() - address = input('Verified Resend sender address: ').strip() - key = getpass.getpass('Resend API key (hidden): ').strip() - if not key or '@' not in address: - raise RuntimeError('A key and sender address are required') - vault.put('email', {'driver': 'resend', 'api_key': key, - 'sender_address': address, 'sender_name': 'SXCHNGE'}) - print('Email configuration saved in Vault. Run make up.') - else: - raise RuntimeError('Usage: python3 dev/local.py up|verify|verify-infra|email') - - -if __name__ == '__main__': - try: - main() - except (RuntimeError, OSError) as error: - print('Local development setup failed: ' + str(error), file=sys.stderr) - sys.exit(1) diff --git a/dev/mongo-check.js b/dev/mongo-check.js new file mode 100644 index 0000000..a48b60e --- /dev/null +++ b/dev/mongo-check.js @@ -0,0 +1,32 @@ +// Run using a leased application identity, never the bootstrap administrator. +if (db.hello().setName !== "rs0" || !db.hello().isWritablePrimary) { + throw new Error("replica set is not primary"); +} +const own = db.getSiblingDB(database); +const name = "local_check_" + new ObjectId().toHexString(); +const session = db.getMongo().startSession(); +try { + own.createCollection(name); + const collection = session.getDatabase(database).getCollection(name); + session.startTransaction(); + collection.insertMany([{_id: "first"}, {_id: "second"}]); + session.commitTransaction(); + if (own.getCollection(name).countDocuments({}) !== 2) throw new Error("commit failed"); + session.startTransaction(); + collection.deleteOne({_id: "first"}); + collection.insertOne({_id: "aborted"}); + session.abortTransaction(); + if (own.getCollection(name).countDocuments({}) !== 2 || + !own.getCollection(name).findOne({_id: "first"}) || + own.getCollection(name).findOne({_id: "aborted"})) throw new Error("rollback failed"); + let denied = false; + try { + const result = db.getSiblingDB(forbidden).runCommand({find: name, filter: {}}); + denied = result.code === 13; + } catch (error) { denied = error.code === 13; } + if (!denied) throw new Error("cross-database access allowed"); + print("scope-ok"); +} finally { + session.endSession(); + own.getCollection(name).drop(); +} diff --git a/dev/mongo-client.js b/dev/mongo-client.js new file mode 100644 index 0000000..4e7922a --- /dev/null +++ b/dev/mongo-client.js @@ -0,0 +1,9 @@ +// Public browser client: this identifier is not a client secret. +db.getSiblingDB("sxchnge").clients.updateOne( + {clientId: "sxchnge-local-web"}, + {$setOnInsert: { + clientName: "Local browser", allowedCIDRs: ["0.0.0.0/0", "::/0"], + allowedScopes: ["account"], redirectURIs: ["https://localhost:5126"], + grantTypes: ["password", "refresh_token"], tokenEndpointAuthMethod: "none", isRevoked: false + }}, {upsert: true} +); diff --git a/dev/rotation.py b/dev/rotation.py deleted file mode 100644 index 8a37c46..0000000 --- a/dev/rotation.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Exercise real Vault database rotation under BFF HTTP transaction traffic. - -Run after make build/bootstrap. No Resend credential or live email is needed. -Temporarily shortens the BFF database role TTL, restores it on every exit, and -preserves whether BFF/frontend were running before this check. -""" -import concurrent.futures -import json -import ssl -import threading -import time -import urllib.error -import urllib.request -import uuid - -from bootstrap import compose -from vault import STATE, Vault - - -def verify_rotation(): - vault = Vault() - vault.existing() - role = 'database/roles/bff' - original = vault.call(role)['data'] - running = {json.loads(line)['Service'] for line in compose('ps', '--format', 'json', capture=True).splitlines()} - stop = threading.Event() - successes = [] - failures = [] - lock = threading.Lock() - context = ssl.create_default_context(cafile=str(STATE / 'ca.pem')) - - def requests(): - while not stop.is_set(): - # Exactly 43 characters invokes the activation transaction and token - # lookup, then returns the expected invalid-token response. No user - # or notification is created and the test never sends an email. - token = uuid.uuid4().hex + uuid.uuid4().hex[:11] - request = urllib.request.Request('https://localhost:5126/api/v1/accounts/verification/confirm', - data=json.dumps({'token': token}).encode(), headers={ - 'Content-Type': 'application/json', 'Origin': 'https://localhost:5126', - 'X-Auth-Transport': 'cookie', 'X-CSRF-Protection': '1'}) - try: - with urllib.request.urlopen(request, context=context, timeout=5) as response: - code = response.status - body = response.read() - except urllib.error.HTTPError as error: - code = error.code - body = error.read() - except Exception: - code = 0 - body = b'' - with lock: - (successes if code == 400 and b'Verification is invalid or already used' in body else failures).append(code) - stop.wait(0.03) - - try: - vault.call(role, dict(original, default_ttl='12s', max_ttl='24s')) - compose('up', '-d', '--no-deps', '--force-recreate', '--wait', '--wait-timeout', '90', 'bff', 'frontend') - process = compose('exec', '-T', 'bff', 'pidof', 'service', capture=True).strip() - with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: - workers = [pool.submit(requests) for _ in range(4)] - try: - deadline = time.monotonic() + 40 - while time.monotonic() < deadline: - time.sleep(1) - current = compose('exec', '-T', 'bff', 'pidof', 'service', capture=True).strip() - if current != process: - raise RuntimeError('BFF process restarted during credential rotation') - logs = compose('logs', '--no-color', 'bff', capture=True) - if logs.count('Database pool replaced without restarting service') >= 3: - break - else: - raise RuntimeError('Three database pool rotations were not observed') - finally: - stop.set() - for worker in workers: - worker.result() - if failures or len(successes) < 100: - raise RuntimeError(f'Rotation traffic check: {len(successes)} expected responses, {len(failures)} failures') - print(f'PASS: three real Vault rotations, unchanged BFF PID, {len(successes)} transaction-backed HTTP requests, zero failures') - finally: - stop.set() - vault.call(role, original) - newly_started = [name for name in ['frontend', 'bff'] if name not in running] - if newly_started: - compose('stop', *newly_started) - compose('rm', '-f', *newly_started) - # Existing services stay online; their next refresh picks up normal TTLs. - - -if __name__ == '__main__': - verify_rotation() diff --git a/dev/rotation.sh b/dev/rotation.sh new file mode 100644 index 0000000..9f7ce2f --- /dev/null +++ b/dev/rotation.sh @@ -0,0 +1,60 @@ +# Temporarily deploy BFF/frontend and shorten the role lifetime. Always restore it. +rotation() ( + exec 9> "$STATE/bootstrap.lock" + flock -n 9 || fail 'Another local bootstrap or rotation check is running' + vault database/roles/bff | jq '.data' > "$WORK/original-role" + compose ps --services --status running > "$WORK/running" + workers='' + restore() { + touch "$WORK/stop" + for pid in $workers; do wait "$pid" || true; done + restore_failed=0 + vault database/roles/bff POST < "$WORK/original-role" >/dev/null || { echo 'ERROR: restore BFF role TTL manually' >&2; restore_failed=1; } + for service in frontend bff; do + if ! grep -qx "$service" "$WORK/running"; then compose stop "$service"; compose rm -f "$service"; fi + done + return "$restore_failed" + } + trap restore EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + jq '.+{default_ttl:"12s",max_ttl:"24s"}' "$WORK/original-role" | vault database/roles/bff POST >/dev/null + compose up -d --no-deps --force-recreate --wait --wait-timeout 90 bff frontend + process=$(compose exec -T bff pidof service) + for worker in 1 2 3 4; do + ( + while [ ! -f "$WORK/stop" ]; do + # 43 characters passes syntax validation, executes a Mongo transaction/read, + # and returns invalid-token without creating users or sending email. + token=$(openssl rand -hex 22 | cut -c1-43) + code=$(printf '{"token":"%s"}' "$token" | https -o "$WORK/body-$worker" -w '%{http_code}' \ + -H 'Content-Type: application/json' -H 'Origin: https://localhost:5126' \ + -H 'X-Auth-Transport: cookie' -H 'X-CSRF-Protection: 1' --data-binary @- \ + https://localhost:5126/api/v1/accounts/verification/confirm) || code=0 + if [ "$code" = 400 ] && grep -q 'Verification is invalid or already used' "$WORK/body-$worker"; then + echo ok >> "$WORK/results-$worker" + else echo fail >> "$WORK/results-$worker"; fi + sleep 0.03 + done + ) & + workers="$workers $!" + done + count=0; attempt=0 + while [ "$attempt" -lt 40 ]; do + sleep 1 + [ "$(compose exec -T bff pidof service)" = "$process" ] || fail 'BFF restarted during rotation' + compose logs --no-color bff > "$WORK/logs" + count=$(grep -c 'Database pool replaced without restarting service' "$WORK/logs" || true) + [ "$count" -lt 3 ] || break + attempt=$((attempt+1)) + done + touch "$WORK/stop" + for pid in $workers; do wait "$pid"; done + workers='' + [ "$count" -ge 3 ] || fail 'Three pool rotations were not observed' + cat "$WORK"/results-* > "$WORK/results" + ! grep -q fail "$WORK/results" || fail 'Unexpected HTTP responses during rotation' + count=$(wc -l < "$WORK/results") + [ "$count" -ge 100 ] || fail 'Insufficient rotation traffic' + echo "PASS: three real Vault rotations, unchanged BFF PID, $count transaction-backed HTTP requests, zero failures" +) diff --git a/dev/test.sh b/dev/test.sh new file mode 100644 index 0000000..c0307ed --- /dev/null +++ b/dev/test.sh @@ -0,0 +1,39 @@ +#!/bin/sh +set -eu +set -o pipefail +# Shell syntax and static analysis cover the actual container shell scripts. +shellcheck -s ash -x -S warning dev/dispatch.sh dev/test.sh +shellcheck -x -S warning dev/tools.sh dev/start-*.sh +. dev/common.sh +. dev/images.sh +. dev/bootstrap.sh +# Offline fixtures exercise stable selection, reject prereleases, and preserve pins. +get() { + case "$1" in + */tags/latest) printf '{"digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}';; + *page_size*) cat <<'JSON' +{"results":[{"name":"2.1.0","digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},{"name":"2.2.0-rc1","digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]} +JSON + ;; + *) return 1;; + esac +} +[ "$(image hashicorp/vault latest)" = 'hashicorp/vault:2.1.0@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ] || fail 'Stable release selection failed' +get() { printf '{"digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","results":[{"name":"3.0.0-rc1","digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]}'; } +if image hashicorp/vault latest >/dev/null 2>&1; then fail 'Prerelease accepted'; fi +# Isolate state from the developer stack during failure tests. +STATE="$WORK/state"; mkdir "$STATE"; printf 'previous build' > "$STATE/images.env" +get() { return 1; } +if images >/dev/null 2>&1; then fail 'Offline resolution succeeded'; fi +[ "$(cat "$STATE/images.env")" = 'previous build' ] || fail 'Failed resolution replaced pins' +if printf '{"api_key":"","sender_address":"notifications@localhost.test"}' | email_valid; then fail 'Empty email accepted'; fi +printf '{"api_key":"fixture-only","sender_address":"sender@example.test"}' | email_valid +# Initialized Vault without matching local keys must fail without initializing it again. +vault() { printf '{"initialized":true}'; } +if (initialize) > "$WORK/initialize-error" 2>&1; then fail 'Missing local keys accepted'; fi +grep -q 'Missing .local' "$WORK/initialize-error" +for service in bff notifications; do + if grep -Eq 'database/creds/|SXCHNGE_MONGO_URI' "dev/vault/$service.hcl"; then fail 'Database credentials trigger Agent restart'; fi +done +echo 'PASS: shell analysis, stable selection, failed-resolution preservation, email validation, missing-key protection and Agent contract' +if command -v python >/dev/null 2>&1 || command -v python3 >/dev/null 2>&1; then fail 'Tool image unexpectedly contains Python'; fi diff --git a/dev/test_local.py b/dev/test_local.py deleted file mode 100644 index 3a60194..0000000 --- a/dev/test_local.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Focused regression tests for release selection and bootstrap failure paths.""" -from pathlib import Path -import tempfile -import unittest -from unittest.mock import patch - -import agents -import bootstrap -import images -from vault import Vault - - -class Releases(unittest.TestCase): - def test_selects_exact_stable_digest_not_prerelease(self): - digest = 'sha256:' + 'a' * 64 - versions = [{'name': name, 'digest': digest} for name in ['latest', '2', '2.1', '2.1.0', '2.2.0-rc1']] - with patch.object(images, 'get', side_effect=[{'digest': digest}, {'results': versions}]): - self.assertEqual(images.image('hashicorp/vault', 'latest'), 'hashicorp/vault:2.1.0@' + digest) - - def test_missing_stable_mapping_fails(self): - digest = 'sha256:' + 'a' * 64 - with patch.object(images, 'get', side_effect=[{'digest': digest}, {'results': [{'name': '3.0.0-rc1', 'digest': digest}]}]): - with self.assertRaises(ValueError): - images.image('hashicorp/vault', 'latest') - - def test_upstream_failure_does_not_replace_existing_pins(self): - with tempfile.TemporaryDirectory() as temporary: - state = Path(temporary) - target = state / 'images.env' - target.write_text('previous build') - with patch.object(images, 'STATE', state), patch.object(images, 'get', side_effect=OSError('offline')): - with self.assertRaises(OSError): - images.main() - self.assertEqual(target.read_text(), 'previous build') - - -class AgentContract(unittest.TestCase): - def test_database_lease_does_not_trigger_process_restart(self): - for service in ['bff', 'notifications']: - config = agents.config(service) - self.assertNotIn('database/creds/', config) - self.assertNotIn('SXCHNGE_MONGO_URI', config) - - -class Bootstrap(unittest.TestCase): - def test_missing_resend_credentials_stops_before_product_start(self): - with tempfile.TemporaryDirectory() as temporary: - state = Path(temporary) - (state / 'images.env').touch() - from unittest.mock import Mock - vault = Mock() - vault.kv.return_value = {'driver': 'resend', 'api_key': '', 'sender_address': 'notifications@localhost.test'} - with patch.object(bootstrap, 'STATE', state), patch.object(bootstrap, 'compose') as compose, \ - patch.object(bootstrap, 'provision_mongo'), patch.object(bootstrap, 'provision_agents'): - with self.assertRaisesRegex(RuntimeError, 'Resend api_key'): - bootstrap.up(vault) - self.assertEqual(compose.call_args_list[-1].args[-1], 'nats') - email_seed = next(c for c in vault.ensure.call_args_list if c.args[0] == 'email').args[1]() - self.assertEqual(email_seed['driver'], 'resend') - self.assertEqual(email_seed['api_key'], '') - - def test_initialized_vault_without_local_keys_fails(self): - with tempfile.TemporaryDirectory() as temporary: - with patch('vault.STATE', Path(temporary)), patch.object(Vault, 'call', return_value={'initialized': True}): - with self.assertRaisesRegex(RuntimeError, 'Missing .local'): - Vault().initialize() - - - -if __name__ == '__main__': - unittest.main() diff --git a/dev/tools.sh b/dev/tools.sh new file mode 100644 index 0000000..ff74fc1 --- /dev/null +++ b/dev/tools.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Host entry point: only POSIX shell and Docker are required. +set -eu +cd "$(dirname "$0")/.." +case "${1:-}" in + build) + docker pull docker:cli + ref=$(docker image inspect docker:cli --format '{{index .RepoDigests 0}}') + case "$ref" in docker@sha256:*) ;; *) echo 'Cannot pin stable Docker CLI image' >&2; exit 1;; esac + docker build --pull --build-arg "DOCKER_IMAGE=$ref" -f dev/images/tools.Dockerfile -t sxchnge-local/tools:dev dev/images + exit + ;; +esac +docker image inspect sxchnge-local/tools:dev >/dev/null 2>&1 || { echo 'Run make build first' >&2; exit 1; } +endpoint=${DOCKER_HOST:-$(docker context inspect --format '{{.Endpoints.docker.Host}}')} +case "$endpoint" in unix://*) socket=${endpoint#unix://};; *) echo 'Local tooling requires a local Unix Docker socket' >&2; exit 1;; esac +network_args= +if docker network inspect sxchnge-local_default >/dev/null 2>&1; then network_args='--network sxchnge-local_default'; fi +terminal_args=-i +if [ "${1:-}" = email ]; then terminal_args=-it; fi +# These two argument lists contain only fixed strings controlled above. +# shellcheck disable=SC2086 +exec docker run --rm $terminal_args $network_args -e "LOCAL_UID=$(id -u)" -e "LOCAL_GID=$(id -g)" \ + -v "$socket:/var/run/docker.sock" -v "$PWD:$PWD" -w "$PWD" \ + sxchnge-local/tools:dev sh dev/dispatch.sh "$@" diff --git a/dev/vault.py b/dev/vault.py deleted file mode 100644 index 5dcc0c0..0000000 --- a/dev/vault.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Local Vault bootstrap API; response bodies with secrets never reach logs.""" -import json -import os -from pathlib import Path -import time -import urllib.error -import urllib.request - -STATE = Path(__file__).resolve().parents[1] / '.local' / 'dev' -URL = 'http://127.0.0.1:18200/v1/' - - -def write(path, value, mode=0o600): - path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - tmp = path.with_name(path.name + '.tmp') - fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) - with os.fdopen(fd, 'w') as out: - out.write(value) - os.chmod(tmp, mode) - os.replace(tmp, path) - - -class Vault: - def __init__(self): - self.token = None - - def call(self, path, data=None, method=None, missing=False): - headers = {'Content-Type': 'application/json'} - if self.token: - headers['X-Vault-Token'] = self.token - req = urllib.request.Request(URL + path, headers=headers, - data=json.dumps(data).encode() if data is not None else None, method=method) - try: - with urllib.request.urlopen(req, timeout=30) as response: - body = response.read() - return json.loads(body) if body else {} - except urllib.error.HTTPError as error: - if error.code == 404 and missing: - return None - raise RuntimeError(f'Vault {path} returned HTTP {error.code}') from None - - def initialize(self): - for attempt in range(90): - try: - initialized = self.call('sys/init')['initialized'] - break - except (urllib.error.URLError, ConnectionError): - time.sleep(1) - else: - raise RuntimeError('Vault did not start within 90 seconds') - initfile = STATE / 'admin' / 'init.json' - if not initialized: - if initfile.exists(): - raise RuntimeError('Vault data is missing but bootstrap material exists; restore the matching volume or explicitly reset both') - init = self.call('sys/init', {'secret_shares': 1, 'secret_threshold': 1}) - write(initfile, json.dumps(init)) - if not initfile.exists(): - raise RuntimeError('Missing .local/dev/admin/init.json for initialized Vault; restore the matching bootstrap material') - init = json.loads(initfile.read_text()) - self.token = init['root_token'] - write(STATE / 'vault' / 'unseal', init['keys_base64'][0], 0o444) - for attempt in range(60): - if not self.call('sys/seal-status')['sealed']: - return - time.sleep(1) - raise RuntimeError('Vault did not unseal within 60 seconds') - - def existing(self): - self.token = json.loads((STATE / 'admin' / 'init.json').read_text())['root_token'] - - def kv(self, path): - result = self.call('kv/data/' + path, missing=True) - return result['data']['data'] if result else None - - def put(self, path, data): - self.call('kv/data/' + path, {'data': data}) - - def ensure(self, path, factory): - value = self.kv(path) - if value is None: - value = factory() - self.put(path, value) - return value - - def random(self, length=32): - return self.call('sys/tools/random', {'bytes': length, 'format': 'hex'})['data']['random_bytes'] - - def mounts(self): - mounts = self.call('sys/mounts')['data'] - for name, kind in [('kv', 'kv'), ('database', 'database'), ('pki', 'pki')]: - if name + '/' not in mounts: - settings = {'type': kind} - if kind == 'kv': - settings['options'] = {'version': '2'} - self.call('sys/mounts/' + name, settings) - auth = self.call('sys/auth')['data'] - if 'approle/' not in auth: - self.call('sys/auth/approle', {'type': 'approle'}) - - def certificates(self): - self.call('sys/mounts/pki/tune', {'max_lease_ttl': '87600h'}) - ca = self.kv('tls') - if ca is None: - result = self.call('pki/root/generate/internal', {'common_name': 'SXCHNGE local development CA', 'ttl': '87600h'})['data'] - ca = {'ca': result['certificate']} - self.put('tls', ca) - self.call('pki/roles/local', {'allowed_domains': ['localhost', 'notifications'], - 'allow_bare_domains': True, 'allow_ip_sans': True, 'max_ttl': '720h'}) - for name, host in [('notifications-tls', 'notifications'), ('frontend-tls', 'localhost')]: - cert = self.kv(name) - if cert is None or cert['expiration'] < time.time() + 7 * 86400: - cert = self.call('pki/issue/local', {'common_name': host, 'ip_sans': '127.0.0.1', 'ttl': '720h'})['data'] - self.put(name, cert) - if name == 'frontend-tls': - write(STATE / 'frontend' / 'server.crt', cert['certificate']) - write(STATE / 'frontend' / 'server.key', cert['private_key']) - write(STATE / 'ca.pem', ca['ca'], 0o644) diff --git a/dev/vault/bff.hcl b/dev/vault/bff.hcl new file mode 100644 index 0000000..37e62ef --- /dev/null +++ b/dev/vault/bff.hcl @@ -0,0 +1,35 @@ +vault { address = "http://vault:8200" } +auto_auth { + method "approle" { + config = { + role_id_file_path = "/etc/agent/role-id" + secret_id_file_path = "/etc/agent/secret-id" + remove_secret_id_file_after_reading = false + } + } +} +template_config { + static_secret_render_interval = "10s" + exit_on_retry_failure = true +} + +env_template "TLS_CA" { + contents = "{{ with secret \"kv/data/tls\" }}{{ .Data.data.ca }}{{ end }}" + error_on_missing_key = true +} + +env_template "SXCHNGE_JWT_SECRET" { + contents = "{{ with secret \"kv/data/bff\" }}{{ .Data.data.jwt }}{{ end }}" + error_on_missing_key = true +} + +env_template "SXCHNGE_NOTIFICATIONS_TOKEN" { + contents = "{{ with secret \"kv/data/caller\" }}{{ .Data.data.token }}{{ end }}" + error_on_missing_key = true +} + +exec { + command = ["/usr/local/bin/start-service"] + restart_on_secret_changes = "always" + restart_stop_signal = "SIGTERM" +} diff --git a/dev/vault/agent.hcl b/dev/vault/nats.hcl similarity index 55% rename from dev/vault/agent.hcl rename to dev/vault/nats.hcl index 67c228e..2c2aea3 100644 --- a/dev/vault/agent.hcl +++ b/dev/vault/nats.hcl @@ -12,3 +12,14 @@ template_config { static_secret_render_interval = "10s" exit_on_retry_failure = true } + +env_template "NATS_TOKEN" { + contents = "{{ with secret \"kv/data/nats\" }}{{ .Data.data.token }}{{ end }}" + error_on_missing_key = true +} + +exec { + command = ["/usr/local/bin/start-nats"] + restart_on_secret_changes = "always" + restart_stop_signal = "SIGTERM" +} diff --git a/dev/vault/notifications.hcl b/dev/vault/notifications.hcl new file mode 100644 index 0000000..63c99ee --- /dev/null +++ b/dev/vault/notifications.hcl @@ -0,0 +1,55 @@ +vault { address = "http://vault:8200" } +auto_auth { + method "approle" { + config = { + role_id_file_path = "/etc/agent/role-id" + secret_id_file_path = "/etc/agent/secret-id" + remove_secret_id_file_after_reading = false + } + } +} +template_config { + static_secret_render_interval = "10s" + exit_on_retry_failure = true +} + +env_template "TLS_CA" { + contents = "{{ with secret \"kv/data/tls\" }}{{ .Data.data.ca }}{{ end }}" + error_on_missing_key = true +} + +env_template "SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN" { + contents = "{{ with secret \"kv/data/caller\" }}{{ .Data.data.token }}{{ end }}" + error_on_missing_key = true +} + +env_template "SXCHNGE_NATS_URL" { + contents = "{{ with secret \"kv/data/nats\" }}nats://{{ .Data.data.token }}@nats:4222{{ end }}" + error_on_missing_key = true +} + +env_template "TLS_CERT" { + contents = "{{ with secret \"kv/data/notifications-tls\" }}{{ .Data.data.certificate }}{{ end }}" + error_on_missing_key = true +} + +env_template "TLS_KEY" { + contents = "{{ with secret \"kv/data/notifications-tls\" }}{{ .Data.data.private_key }}{{ end }}" + error_on_missing_key = true +} + +env_template "EMAIL_API_KEY" { + contents = "{{ with secret \"kv/data/email\" }}{{ .Data.data.api_key }}{{ end }}" + error_on_missing_key = true +} + +env_template "EMAIL_CONFIG" { + contents = "{{ with secret \"kv/data/email\" }}email:\n driver: {{ .Data.data.driver | toJSON }}\n settings:\n api_key_env: EMAIL_API_KEY\n sender_address: {{ .Data.data.sender_address | toJSON }}\n sender_name: {{ .Data.data.sender_name | toJSON }}\n{{ end }}" + error_on_missing_key = true +} + +exec { + command = ["/usr/local/bin/start-service"] + restart_on_secret_changes = "always" + restart_stop_signal = "SIGTERM" +} diff --git a/dev/verify.py b/dev/verify.py deleted file mode 100644 index d448797..0000000 --- a/dev/verify.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Integration checks against this project's running stack; never print secrets.""" -import json -import ssl -import urllib.request - -from bootstrap import compose, mongo -from vault import STATE - - -def require(condition, message): - if not condition: - raise RuntimeError(message) - - -def database_access(vault): - for role, database, forbidden in [('bff', 'sxchnge', 'sxchnge_notifications'), - ('notifications', 'sxchnge_notifications', 'sxchnge')]: - lease = vault.call('database/creds/' + role) - credentials = lease['data'] - auth = 'db.getSiblingDB("admin").auth(' + json.dumps(credentials['username']) + ',' + json.dumps(credentials['password']) + ');' - try: - result = compose('exec', '-T', 'mongo', 'mongosh', '--quiet', '--file', '/dev/stdin', capture=True, - input=auth + 'const own=db.getSiblingDB(' + json.dumps(database) + '); own.local_smoke.insertOne({_id:"vault-check"}); own.local_smoke.deleteOne({_id:"vault-check"});\n' - 'let denied=false; try { db.getSiblingDB(' + json.dumps(forbidden) + ').runCommand({find:"local_smoke",filter:{}}); } catch(e) { denied=e.code===13; } if(!denied) throw new Error("cross-database access allowed"); print("scope-ok");') - require('scope-ok' in result, 'Mongo service scope check failed') - renewed = vault.call('sys/leases/renew', {'lease_id': lease['lease_id'], 'increment': '1h'}) - require(renewed['lease_duration'] > 0, 'Mongo lease renewal failed') - finally: - vault.call('sys/leases/revoke', {'lease_id': lease['lease_id']}) - admin = vault.kv('bootstrap/mongo')['password'] - result = mongo('print(admin.getUser(' + json.dumps(credentials['username']) + ')===null)', admin) - require('true' in result, 'Revoked Mongo user still exists') - print('PASS: database-plugin issuance, separate database permissions, lease renewal and revocation') - - -def policies(vault): - for service in ['bff', 'notifications', 'nats']: - root = STATE / 'agents' / service - login = vault.call('auth/approle/login', {'role_id': (root / 'role-id').read_text(), - 'secret_id': (root / 'secret-id').read_text()}) - token = login['auth']['client_token'] - try: - result = vault.call('sys/capabilities', {'token': token, 'paths': ['kv/data/bootstrap/mongo', 'sys/init']})['data'] - require(all(result[path] == ['deny'] for path in ['kv/data/bootstrap/mongo', 'sys/init']), 'Service can access bootstrap credentials') - finally: - vault.call('auth/token/revoke', {'token': token}) - print('PASS: service identities cannot read bootstrap credentials') - - -def infrastructure(vault): - status = compose('ps', '--format', 'json', capture=True) - rows = [json.loads(line) for line in status.splitlines() if line.strip()] - infra = {r['Service']: r for r in rows if r['Service'] in {'vault', 'mongo', 'nats'}} - require(set(infra) == {'vault', 'mongo', 'nats'}, 'Expected Vault, Mongo and NATS') - require(all(r['Health'] == 'healthy' for r in infra.values()), 'Infrastructure is unhealthy') - print('PASS: Vault, Mongo replica set and NATS JetStream are healthy') - policies(vault) - database_access(vault) - - -def verify(vault): - status = compose('ps', '--format', 'json', capture=True) - rows = [json.loads(line) for line in status.splitlines() if line.strip()] - require({r['Service'] for r in rows} == {'vault', 'mongo', 'nats', 'notifications', 'bff', 'frontend'}, 'Expected six running services') - require(all(r['Health'] == 'healthy' for r in rows), 'Some services are unhealthy') - context = ssl.create_default_context(cafile=str(STATE / 'ca.pem')) - for path in ['/', '/api/v1/health']: - with urllib.request.urlopen('https://localhost:5126' + path, context=context, timeout=10) as response: - require(response.status == 200, 'Frontend or BFF health failed') - print('PASS: all six services healthy; frontend and proxied BFF use verified TLS') - infrastructure(vault) - print('Resend credentials are configured; no live email sent by this check') diff --git a/dev/verify.sh b/dev/verify.sh new file mode 100644 index 0000000..a0d636f --- /dev/null +++ b/dev/verify.sh @@ -0,0 +1,50 @@ +policies() ( + root_token=$VAULT_TOKEN + identity='' + trap 'if [ -n "$identity" ]; then printf "{}" | VAULT_TOKEN=$identity vault auth/token/revoke-self POST >/dev/null; fi' EXIT + for service in bff notifications nats; do + directory="$STATE/agents/$service" + login=$(jq -n --rawfile role "$directory/role-id" --rawfile secret "$directory/secret-id" '{role_id:($role|rtrimstr("\n")),secret_id:($secret|rtrimstr("\n"))}' | vault auth/approle/login POST) + identity=$(printf '%s' "$login" | jq -er '.auth.client_token') + printf '%s' "$login" | jq '{token:.auth.client_token,paths:["kv/data/bootstrap/mongo","sys/init"]}' | vault sys/capabilities POST | jq -e '.data | all(.[]; .==["deny"])' >/dev/null || fail 'Service can access bootstrap credentials' + VAULT_TOKEN=$(printf '%s' "$login" | jq -er '.auth.client_token') + printf '{}' | vault auth/token/revoke-self POST >/dev/null + VAULT_TOKEN=$root_token + identity='' + done + echo 'PASS: service identities cannot read bootstrap credentials' +) +database_access() ( + for role in bff notifications; do + database=sxchnge; forbidden=sxchnge_notifications + if [ "$role" = notifications ]; then database=sxchnge_notifications; forbidden=sxchnge; fi + vault "database/creds/$role" > "$WORK/lease" + trap 'jq "{lease_id:.lease_id}" "$WORK/lease" | vault sys/leases/revoke POST >/dev/null' EXIT + { + jq -r '"db.getSiblingDB(\"admin\").auth("+(.data.username|tojson)+","+(.data.password|tojson)+");"' "$WORK/lease" + printf 'const database="%s", forbidden="%s";\n' "$database" "$forbidden" + cat dev/mongo-check.js + } | mongo > "$WORK/check" || fail 'Mongo access/transaction check failed' + grep -q scope-ok "$WORK/check" || fail 'Mongo check incomplete' + jq '{lease_id:.lease_id,increment:"1h"}' "$WORK/lease" | vault sys/leases/renew POST | jq -e '.lease_duration>0' >/dev/null || fail 'Mongo renewal failed' + jq '{lease_id:.lease_id}' "$WORK/lease" | vault sys/leases/revoke POST >/dev/null + trap - EXIT + { admin_auth; jq -r '"print(admin.getUser("+(.data.username|tojson)+")===null);"' "$WORK/lease"; } | mongo > "$WORK/revoked" || fail 'Mongo revocation check failed' + grep -q true "$WORK/revoked" || fail 'Revoked Mongo user still exists' + done + echo 'PASS: Vault database issuance, isolated access, transaction commit/rollback, renewal and revocation' +) +infrastructure() { + compose ps --format json | jq -se '[.[] | select(.Service=="vault" or .Service=="mongo" or .Service=="nats")] | length==3 and all(.[];.Health=="healthy")' >/dev/null || fail 'Infrastructure is not healthy' + echo 'PASS: Vault, Mongo replica set and NATS JetStream are healthy' + policies + database_access +} +# Resolve through Docker DNS while verifying the certificate for localhost. +https() { curl -sS --max-time 5 --cacert "$STATE/ca.pem" --connect-to localhost:5126:frontend:8443 "$@"; } +product() { + compose ps --format json | jq -se 'length==6 and all(.[];.Health=="healthy")' >/dev/null || fail 'Expected six healthy services' + https -f https://localhost:5126/ >/dev/null + https -f https://localhost:5126/api/v1/health >/dev/null + echo 'PASS: six healthy services, frontend and proxied BFF use verified TLS; no email sent' +} diff --git a/docs/implementation.md b/docs/implementation.md index e2deefa..f2e3b6f 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -676,3 +676,32 @@ the supplementary failures are recorded and are not suppressed. The required normal test and lint commands pass. No optional security scan or email delivery was performed. Resend remains intentionally unconfigured; infrastructure stays up and product startup awaits manual `kv/email` provisioning. + +### Make and shell development tooling + +The local development tooling now runs POSIX-style shell scripts in a Docker +image containing Docker CLI, curl, jq, Git, OpenSSL, flock and ShellCheck. All eight +Python scripts were removed. Make remains the entry point; `make email` replaces +the Python credential prompt. Host requirements are Make, shell and local Docker +with Compose/BuildKit. The tool image is built from a freshly pulled stable Docker +CLI digest; application/infrastructure stable resolution and atomic local pins +remain intact. Tooling mounts the local Docker socket and workspace at its host +path; remote Docker engines are unsupported. + +Static Agent HCL sources replace configuration generation and contain only Vault +template expressions. Bootstrap preserves existing Vault state and credentials, +protects its private files, and fails when Resend credentials are absent. The shell +infrastructure check now explicitly validates real Mongo transaction commit and +rollback with each application's Vault-issued identity. The rotation command +retains its unchanged-process and expected-response checks and TTL restoration. + +Validation of the shell replacement passed: `make build`, `make test-dev-tooling`, +`make test-dev-infra`, Compose configuration validation and `git diff --check`. +An isolated `sxchnge-shell-check` project passed first bootstrap, live transaction +and lease checks, and repeated bootstrap; its containers, volumes and temporary +state were removed. `make test-dev-rotation` passed three real Vault replacements +with unchanged BFF PID, 1,048 expected HTTP responses and zero failures; role TTLs +were restored and temporary BFF/frontend containers removed. `make up` exits at +the intentional missing-Resend check, with infrastructure healthy. Backend and +frontend code did not change in this revision; their earlier test/lint results +and supplementary legacy test limitations above still apply. No email was sent. diff --git a/docs/local-development.md b/docs/local-development.md index b4b1937..298b9c2 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -5,9 +5,13 @@ Task [#42](https://git.sendico.io/tech/sxchnge/issues/42), ## Build and bootstrap -Requires Docker with Compose v2 and BuildKit, Python 3.10+ and Git. Docker builds -Go, protobuf bindings and Flutter web; host Go, protoc and Flutter installations -are not required for `make build && make up`. The first Flutter SDK download can +Requires Make, a POSIX shell and a local Docker engine with Compose v2 and +BuildKit. Docker builds Go, protobuf bindings and Flutter web; host Go, protoc +and Flutter installations are not required for `make build && make up`. The +tooling container includes Docker CLI, curl, jq, Git and OpenSSL. No host Python, +jq, curl or Git installation is needed for these Make targets. It mounts +the local Docker socket and workspace to orchestrate Compose; a remote Docker +daemon is not supported. The first Flutter SDK download can take several minutes. Both ARM64 and AMD64 build from the same source definitions. ```sh @@ -52,7 +56,7 @@ containers never mount it. You can also provision the same secret with a hidden terminal prompt, without placing the API key in shell history: ```sh -python3 dev/local.py email +make email make up ``` @@ -169,6 +173,7 @@ make ps make logs make down # preserves named volumes and local bootstrap material make up # idempotent provisioning and readiness checks +make test-dev-tooling # offline shell and failure-path checks make test-dev-infra # works before Resend provisioning make test-dev-rotation # real Vault rotations under BFF HTTP traffic make test-dev # requires all six services running