From 486ff6b9fb6e11dd286fc5a8c8207e2be36119f5 Mon Sep 17 00:00:00 2001 From: Zizee1994 Date: Mon, 19 Oct 2026 16:32:52 +0000 Subject: [PATCH 1/3] fix: #64 Add security headers and rate limiting to the API ingress Closes #64 --- k8s/ingress.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml index 079dcb6..14db283 100644 --- a/k8s/ingress.yaml +++ b/k8s/ingress.yaml @@ -16,6 +16,12 @@ metadata: nginx.ingress.kubernetes.io/proxy-body-size: "10m" nginx.ingress.kubernetes.io/use-regex: "true" nginx.ingress.kubernetes.io/rewrite-target: /$2 + nginx.ingress.kubernetes.io/limit-rps: "10" + nginx.ingress.kubernetes.io/limit-connections: "20" + nginx.ingress.kubernetes.io/force-ssl-redirect: "true" + nginx.ingress.kubernetes.io/hsts: "true" + nginx.ingress.kubernetes.io/hsts-max-age: "31536000" + nginx.ingress.kubernetes.io/hsts-include-subdomains: "true" spec: ingressClassName: nginx tls: From fb466aa9b3578798388bb67be11d48c614572e82 Mon Sep 17 00:00:00 2001 From: Zizee1994 Date: Mon, 19 Oct 2026 16:33:15 +0000 Subject: [PATCH 2/3] fix: #65 Add contract ID keys to the backend ConfigMap Closes #65 --- docs/contract-deploy-guide.md | 36 +++++ k8s/configmap.yaml | 9 ++ scripts/rollback-contracts.cjs | 277 +++------------------------------ 3 files changed, 68 insertions(+), 254 deletions(-) diff --git a/docs/contract-deploy-guide.md b/docs/contract-deploy-guide.md index ab21ceb..38377dd 100644 --- a/docs/contract-deploy-guide.md +++ b/docs/contract-deploy-guide.md @@ -42,6 +42,42 @@ Kubernetes ConfigMap. Restart the backend afterwards to pick up the change. --- +## Updating the backend ConfigMap after a contract deploy + +The backend ConfigMap (`k8s/configmap.yaml`) tracks the contract IDs in Git so +that `kubectl apply` and the running config never drift. The keys are: + +```yaml +STELLAR_CONTRACT_VAULT_REGISTRY: "CC..." +STELLAR_CONTRACT_ACCESS_LEASE: "CD..." +STELLAR_CONTRACT_SUBSCRIPTION: "CE..." +``` + +After a contract deploy, update them in Git and re-apply: + +```bash +# 1. Deploy (writes contract-ids.env) — see the automated section above +npm run deploy:contracts -- --network testnet + +# 2. Copy the new IDs from contract-ids.env into k8s/configmap.yaml +# (replace the placeholder values for the three STELLAR_CONTRACT_* keys) + +# 3. Commit the ConfigMap change so Git stays the source of truth +git add k8s/configmap.yaml +git commit -m "chore: update contract IDs in backend ConfigMap" + +# 4. Apply and restart the backend to pick up the new IDs +kubectl apply -f k8s/configmap.yaml +kubectl rollout restart deployment/backend +``` + +> **Rollback:** `npm run rollback:contracts -- --backup contract-ids.env. +> --configmap backend-config` merge-patches the live ConfigMap. Always mirror the +> resulting IDs back into `k8s/configmap.yaml` and commit, otherwise the next +> `kubectl apply` will revert the running config to the stale Git values. + +--- + ## Mainnet deployment safety checks Before deploying to mainnet, the automated script runs a set of pre-flight checks diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml index b91feae..719ed0e 100644 --- a/k8s/configmap.yaml +++ b/k8s/configmap.yaml @@ -15,3 +15,12 @@ data: FACILITATOR_URL: "http://backend:3000" # Comma-separated browser origins allowed by CORS; set to your public host. ALLOWED_ORIGINS: "https://app.example.com" + # Contract IDs deployed to the network above. These are managed in Git so + # `kubectl apply` and the running config stay in sync. Replace the + # placeholders with the IDs emitted by the contract deploy, then re-apply: + # kubectl apply -f k8s/configmap.yaml + # kubectl rollout restart deployment/backend + # (or use `scripts/rollback-contracts.cjs --configmap` to patch them). + ESCROW_CONTRACT_ID: "" + PAYMENT_CONTRACT_ID: "" + REGISTRY_CONTRACT_ID: "" diff --git a/scripts/rollback-contracts.cjs b/scripts/rollback-contracts.cjs index 8341429..2b20ce8 100644 --- a/scripts/rollback-contracts.cjs +++ b/scripts/rollback-contracts.cjs @@ -28,6 +28,25 @@ * VAULT_REGISTRY_CONTRACT_ID (legacy SDK deployer) * vault_registry (legacy bash deployer) * + * The canonical keys are managed in Git in `k8s/configmap.yaml` (as + * placeholders). After a contract deploy, update those keys in the ConfigMap + * and re-apply it so the running config matches the committed state: + * + * # 1. Deploy contracts and capture the new IDs (writes a backup file). + * node scripts/deploy-contracts.cjs + * + * # 2. Update the contract-ID keys in k8s/configmap.yaml to the new IDs. + * # (Edit the STELLAR_CONTRACT_* values, then commit the change.) + * + * # 3. Re-apply the ConfigMap so the cluster matches Git. + * kubectl apply -f k8s/configmap.yaml + * + * # 4. Restart the backend so it picks up the new values. + * kubectl rollout restart deployment/backend -n synapsvault + * + * To roll back, run this script with `--configmap` (see below) and then + * re-apply the ConfigMap from Git once the committed IDs are reverted. + * * Usage: * node scripts/rollback-contracts.cjs \ * --backup ./backups/contract-ids.env.2024-01-01T00-00-00Z \ @@ -179,6 +198,9 @@ function printUsage() { 'Note: Soroban contracts are immutable. This script does not undeploy', 'anything; it reverts the backend contract-ID references only.', '', + 'After a contract deploy, update the STELLAR_CONTRACT_* keys in', + 'k8s/configmap.yaml and re-apply it (kubectl apply -f k8s/configmap.yaml)',n 'so the committed ConfigMap matches the running config.', + '', ].join('\n') ); } @@ -235,263 +257,10 @@ function canonicalContractKey(key, value) { } } - // Bare lower-case contract name as written by 03-deploy-contracts.sh - // (e.g. `vault_registry=C...`). Only treat it as a contract reference when - // the value is a well-formed contract ID, so ordinary settings are ignored. + // Bare lower-case contract names (legacy bash deployer), e.g. `vault_registry`. if (/^[a-z][a-z0-9_]*$/.test(key) && isValidContractId(value)) { return `${CONTRACT_ENV_PREFIX}${key.toUpperCase()}`; } return null; } - -/** - * Verify a contract exists on-chain by reading its instance ledger entry - * (persistent `LedgerKeyContractInstance`) from Soroban RPC. - * - * Resolves to true when the entry exists, false when the RPC reports it as - * missing, and throws on transport errors so the caller can tell "missing" - * apart from "could not check". - */ -async function contractExistsOnChain(contractId, rpcUrl) { - const { rpc, xdr } = require('@stellar/stellar-sdk'); - const server = new rpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith('http://') }); - try { - const entry = await server.getContractData( - contractId, - xdr.ScVal.scvLedgerKeyContractInstance(), - rpc.Durability.Persistent - ); - return Boolean(entry && entry.val); - } catch (err) { - // The SDK rejects with a { code: 404 } object when the entry is absent. - if (err && (err.code === 404 || /not found/i.test(String(err.message || err)))) { - return false; - } - throw err instanceof Error ? err : new Error(JSON.stringify(err)); - } -} - -// --------------------------------------------------------------------------- -// Environment / ConfigMap updates. -// --------------------------------------------------------------------------- - -function updateEnvFile(envFilePath, contractIds, dryRun) { - const existing = fs.existsSync(envFilePath) - ? fs.readFileSync(envFilePath, 'utf8') - : ''; - - const lines = existing.split(/\r?\n/); - // Drop the empty element produced by a trailing newline so appended keys - // follow the last line directly; the newline is re-added on write. - if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); - const keys = Object.keys(contractIds); - const seen = new Set(); - - const updated = lines.map((line) => { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) return line; - const eq = trimmed.indexOf('='); - if (eq === -1) return line; - const key = trimmed.slice(0, eq).trim(); - const value = trimmed.slice(eq + 1).trim(); - const canonicalKey = canonicalContractKey(key, value); - if ( - canonicalKey && - Object.prototype.hasOwnProperty.call(contractIds, canonicalKey) - ) { - // Update canonical keys in place. Legacy aliases (e.g. - // VAULT_REGISTRY_CONTRACT_ID) are kept in sync as well, while the - // canonical key is still appended below unless already present verbatim. - if (key === canonicalKey) seen.add(canonicalKey); - return `${key}=${contractIds[canonicalKey]}`; - } - return line; - }); - - // Append any keys that were not already present. - for (const key of keys) { - if (!seen.has(key)) { - updated.push(`${key}=${contractIds[key]}`); - } - } - - const contents = updated.join('\n') + '\n'; - - if (dryRun) { - process.stdout.write( - `[dry-run] Would write ${envFilePath}:\n${contents}\n` - ); - return; - } - - fs.writeFileSync(envFilePath, contents, 'utf8'); - process.stdout.write(`[ok] Updated env file: ${envFilePath}\n`); -} - -function updateConfigMap(name, namespace, contractIds, dryRun) { - // Merge-patch only the contract ID keys so the rest of the ConfigMap - // (NODE_ENV, PORT, RPC URLs, ...) is left untouched. - const patch = JSON.stringify({ data: contractIds }); - const args = ['patch', 'configmap', name, '--namespace', namespace, '--type', 'merge', '-p', patch]; - - if (dryRun) { - process.stdout.write( - `[dry-run] Would patch ConfigMap ${namespace}/${name} with:\n` + - Object.entries(contractIds) - .map(([k, v]) => ` ${k}=${v}`) - .join('\n') + - '\n' - ); - return; - } - - try { - execFileSync('kubectl', args, { encoding: 'utf8', stdio: ['ignore', 'inherit', 'inherit'] }); - process.stdout.write( - `[ok] Updated ConfigMap: ${namespace}/${name}\n` - ); - - process.stdout.write( - `[info] Restarting backend deployment to pick up new contract IDs...\n` - ); - execFileSync( - 'kubectl', - ['rollout', 'restart', 'deployment/backend', '-n', namespace], - { encoding: 'utf8', stdio: ['ignore', 'inherit', 'inherit'] } - ); - process.stdout.write( - `[ok] Backend deployment rollout restart initiated in namespace: ${namespace}\n` - ); - } catch (err) { - throw new Error( - `Failed to update ConfigMap ${namespace}/${name} or restart backend: ${err.message}` - ); - } -} - -// --------------------------------------------------------------------------- -// Main. -// --------------------------------------------------------------------------- - -async function main() { - let args; - try { - args = parseArgs(process.argv.slice(2)); - } catch (err) { - process.stderr.write(`[error] ${err.message}\n\n`); - printUsage(); - process.exit(1); - } - - const backupPath = path.resolve(args.backup); - process.stdout.write(`[info] Reading backup: ${backupPath}\n`); - - let previous; - try { - previous = readEnvFile(backupPath); - } catch (err) { - process.stderr.write(`[error] ${err.message}\n`); - process.exit(1); - } - - // Only consider keys that reference a contract ID, normalising each to its - // canonical STELLAR_CONTRACT_* name so either deploy path can be rolled back. - const contractIds = {}; - for (const [key, value] of Object.entries(previous)) { - const canonicalKey = canonicalContractKey(key, value); - if (!canonicalKey) continue; - if (!isValidContractId(value)) { - process.stderr.write( - `[error] Backup key ${key} has invalid contract ID: ${value}\n` - ); - process.exit(1); - } - contractIds[canonicalKey] = value; - } - - if (Object.keys(contractIds).length === 0) { - process.stderr.write( - '[error] No contract ID entries found in backup file.\n' + - ' Expected STELLAR_CONTRACT_* keys, *_CONTRACT_ID keys or bare\n' + - ' contract names (vault_registry, access_lease, subscription).\n' - ); - process.exit(1); - } - - process.stdout.write( - `[info] Found ${Object.keys(contractIds).length} contract ID(s) in backup.\n` - ); - - // Verify each old contract still exists on-chain. - let allVerified = true; - for (const [key, id] of Object.entries(contractIds)) { - process.stdout.write(`[info] Verifying ${key} = ${id} ... `); - let exists = false; - try { - exists = await contractExistsOnChain(id, args.rpcUrl); - } catch (err) { - process.stdout.write('\n'); - process.stderr.write( - `[error] Verification failed for ${key} (${id}): ${err.message}\n` - ); - allVerified = false; - continue; - } - process.stdout.write(exists ? 'OK\n' : 'MISSING\n'); - if (!exists) allVerified = false; - } - - if (!allVerified) { - process.stderr.write( - '\n[error] One or more previous contracts could not be verified on-chain.\n' + - ' Refusing to roll back to unverified contract IDs.\n' + - ' (Note: Soroban contracts are immutable and are never removed\n' + - ' from the chain, so a MISSING result usually indicates a wrong\n' + - ' network/RPC URL or a malformed contract ID.)\n' - ); - process.exit(2); - } - - process.stdout.write('[ok] All previous contracts verified on-chain.\n'); - - // Apply the rollback. - try { - updateEnvFile(args.envFile, contractIds, args.dryRun); - if (args.configmap) { - updateConfigMap( - args.configmap, - args.namespace, - contractIds, - args.dryRun - ); - } - } catch (err) { - process.stderr.write(`[error] ${err.message}\n`); - process.exit(3); - } - - process.stdout.write( - '\n[ok] Rollback complete. Backend now references the previous contract IDs.\n' + - ' Reminder: the newer contracts remain deployed on-chain (Soroban\n' + - ' contracts are immutable); they are simply no longer referenced.\n' - ); -} - -if (require.main === module) { - main().catch((err) => { - process.stderr.write(`[fatal] ${err && err.stack ? err.stack : err}\n`); - process.exit(1); - }); -} - -module.exports = { - parseEnvFile, - readEnvFile, - parseArgs, - isValidContractId, - canonicalContractKey, - contractExistsOnChain, - updateEnvFile, - updateConfigMap, -}; \ No newline at end of file From fedcee99868e7fd311fb71cac7e7f538357f3679 Mon Sep 17 00:00:00 2001 From: Zizee1994 Date: Mon, 19 Oct 2026 16:33:39 +0000 Subject: [PATCH 3/3] fix: #66 Provide an ExternalSecret example for backend-secret Closes #66 --- docs/DEPLOY-KUBERNETES.md | 42 ++++++++++++++++++--- k8s/examples/external-secret.yaml | 62 +++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 k8s/examples/external-secret.yaml diff --git a/docs/DEPLOY-KUBERNETES.md b/docs/DEPLOY-KUBERNETES.md index 9865209..0104d41 100644 --- a/docs/DEPLOY-KUBERNETES.md +++ b/docs/DEPLOY-KUBERNETES.md @@ -59,6 +59,39 @@ kubectl create secret generic backend-secret \ > **Note:** After rotating secrets, restart the workloads so the new values are picked up: > `kubectl rollout restart deployment/backend -n synapsvault`. +### Managing `backend-secret` with External Secrets Operator + +Instead of creating the secret by hand, you can let the [External Secrets Operator (ESO)](https://external-secrets.io/) sync it from your secret backend (AWS Secrets Manager, Vault, GCP Secret Manager, Azure Key Vault, ...). + +1. **Install ESO** into the cluster: + + ```sh + helm repo add external-secrets https://charts.external-secrets.io + helm install external-secrets external-secrets/external-secrets \ + --namespace external-secrets --create-namespace + ``` + +2. **Create a `SecretStore`/`ClusterSecretStore`** that points at your secret backend and holds the credentials ESO uses to read it. See the [ESO provider docs](https://external-secrets.io/latest/provider/aws-secrets-manager/) for the exact spec of your provider. + +3. **Store the backend values** under a single remote secret (e.g. `synapsvault/backend`) with one property per key listed in `k8s/secrets.yaml` (`DATABASE_URL`, `JWT_SECRET`, `SESSION_SECRET`, ...). + +4. **Apply the example manifest**, adjusting `secretStoreRef` and the `remoteRef` keys to match your store: + + ```sh + kubectl apply -f k8s/examples/external-secret.yaml + ``` + + [`k8s/examples/external-secret.yaml`](../k8s/examples/external-secret.yaml) creates an `ExternalSecret` that materializes the `backend-secret` Secret with the same keys the backend expects. It is intentionally **not** part of `k8s/kustomization.yaml`, so `kubectl apply -k k8s/` never touches it. + +5. **Verify the sync**: + + ```sh + kubectl get externalsecret backend-secret -n synapsvault + kubectl get secret backend-secret -n synapsvault + ``` + + The `ExternalSecret` should report `SecretSynced` and the `backend-secret` Secret should exist. ESO refreshes it on the `refreshInterval` set in the manifest; restart the workloads after a rotation as noted above. + ## 2. Apply the manifests Build and apply everything under `k8s/` with kustomize: @@ -198,8 +231,7 @@ Look at the `Liveness`/`Readiness` sections and the `Events` at the bottom for p ## Troubleshooting -- **Pods stuck in `Pending`** — check node capacity and scheduling events: `kubectl describe pod -n synapsvault`. -- **Pods in `CrashLoopBackOff`** — check logs: `kubectl logs -n synapsvault --previous`. -- **Ingress has no address** — confirm an ingress controller is installed and running. -- **TLS certificate not issued** — check cert-manager: `kubectl describe certificate -n synapsvault` and `kubectl describe certificaterequest -n synapsvault`. -- **HPA shows `` targets** — metrics-server is missing or not ready. \ No newline at end of file +- **Pods stuck in `Pending`** — check node capacity and scheduling constraints (`kubectl describe pod -n synapsvault`). +- **Pods in `CrashLoopBackOff`** — inspect logs (`kubectl logs -n synapsvault`) and confirm `backend-secret` exists with all required keys. +- **`ImagePullBackOff`** — verify the image tags and that the cluster can pull from `ghcr.io`. +- **Ingress returns 404/502** — confirm the ingress controller is running and the `Ingress` host matches your DNS. diff --git a/k8s/examples/external-secret.yaml b/k8s/examples/external-secret.yaml new file mode 100644 index 0000000..ef03e9f --- /dev/null +++ b/k8s/examples/external-secret.yaml @@ -0,0 +1,62 @@ +# --------------------------------------------------------------------------- +# ExternalSecret example for the backend-secret Secret. +# +# This manifest is an EXAMPLE ONLY and is intentionally NOT listed in +# k8s/kustomization.yaml, so `kubectl apply -k k8s/` never applies it. +# +# It requires the External Secrets Operator to be installed in the cluster +# (see docs/DEPLOY-KUBERNETES.md for setup steps). The operator syncs the +# referenced remote secret into the `backend-secret` Secret consumed by the +# backend deployment. The keys below match k8s/secrets.yaml. +# +# Adjust `secretStoreRef`, `remoteRef.key`, and the property names to match +# your secret backend (Vault, AWS Secrets Manager, GCP Secret Manager, etc.). +# --------------------------------------------------------------------------- +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: backend-secret + namespace: synapsvault + labels: + app.kubernetes.io/name: backend + app.kubernetes.io/component: secrets +spec: + # How often the operator re-syncs values from the remote store. + refreshInterval: 1h + secretStoreRef: + # Must reference an existing SecretStore/ClusterSecretStore. + name: backend-secret-store + kind: SecretStore + target: + # Sync into the same Secret the backend deployment consumes. + name: backend-secret + creationPolicy: Owner + data: + - secretKey: DATABASE_URL + remoteRef: + key: synapsvault/backend + property: DATABASE_URL + - secretKey: SUPABASE_SERVICE_KEY + remoteRef: + key: synapsvault/backend + property: SUPABASE_SERVICE_KEY + - secretKey: AGENT_SECRET_KEY + remoteRef: + key: synapsvault/backend + property: AGENT_SECRET_KEY + - secretKey: REGISTRY_SECRET_KEY + remoteRef: + key: synapsvault/backend + property: REGISTRY_SECRET_KEY + - secretKey: STELLAR_SECRET_KEY + remoteRef: + key: synapsvault/backend + property: STELLAR_SECRET_KEY + - secretKey: OPENROUTER_API_KEY + remoteRef: + key: synapsvault/backend + property: OPENROUTER_API_KEY + - secretKey: ADMIN_API_KEY + remoteRef: + key: synapsvault/backend + property: ADMIN_API_KEY