diff --git a/.meteorignore b/.meteorignore index c013f6f..eeec0b9 100644 --- a/.meteorignore +++ b/.meteorignore @@ -3,3 +3,5 @@ cucumber output.json # REST smoke-test harness runs standalone (Node), never as Meteor server code smoke +# Ops/deploy scripts (p.ej. scripts/*.mongo.js usan el global `db` de mongosh, no de Meteor) +scripts diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 0000000..c19ae46 --- /dev/null +++ b/docker-compose.staging.yml @@ -0,0 +1,163 @@ +# Stack de STAGING de "Todos contra el fuego" para testfuegos.comunes.org. +# Derivado de docker-compose.yml (validación local). Diferencias clave: +# - web sirve en https://testfuegos.comunes.org (tras el proxy assange), puerto host 8004 +# - Mongo 7 AISLADO, sembrado con un volcado real de `fuegos` y contactos neutralizados +# (NUNCA apunta al rsmain de producción) +# - notificaciones en MODE=shadow / MATCHER_MODE=shadow: calcula y loguea, NO envía +# - mailhog captura TODO el correo (nada sale a un SMTP real) +# - node-red solo conversacional; si se prueba el bot, con un BOT DE PRUEBAS, nunca los reales +# +# Despliegue real: vía el role ansible roles/tcef_staging/ en el repo de Comunes +# (~/proyectos/sync/comunes/shared-l2/ansible/). Este compose es la fuente que ese role monta. +# Secretos: ./secrets/*.staging.* (gitignored). Uso: RUNBOOK.md. + +name: tcef-staging + +x-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + +services: + mongo: + image: mongo:7 + container_name: tcef-staging-mongo + command: ["--replSet", "rs0", "--bind_ip_all"] + volumes: + - mongo-data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + restart: unless-stopped + logging: *default-logging + + # One-shot: inicia el replica set de un nodo y marca las migraciones a v18 + # (los datos reales llegan pre-migrados). Idéntico al compose de validación. + mongo-init: + image: mongo:7 + depends_on: + mongo: + condition: service_healthy + restart: "no" + entrypoint: + - bash + - -ec + - | + mongosh --host mongo --quiet --eval 'try { rs.status().ok } catch (e) { rs.initiate({_id: "rs0", members: [{_id: 0, host: "mongo:27017"}]}) }' + for i in $$(seq 1 30); do + state=$$(mongosh --host mongo --quiet --eval 'try { rs.isMaster().ismaster } catch (e) { false }') + [ "$$state" = "true" ] && break + sleep 1 + done + mongosh --host mongo --quiet fuegos --eval 'db.migrations.replaceOne({_id: "control"}, {_id: "control", version: 18, locked: false}, {upsert: true})' + echo "mongo-init: replica set + migrations OK" + logging: *default-logging + + web: + build: + context: . + dockerfile: Dockerfile + image: tcef-web:meteor3 + container_name: tcef-staging-web + depends_on: + mongo-init: + condition: service_completed_successfully + environment: + PORT: "3000" + ROOT_URL: https://testfuegos.comunes.org + MONGO_URL: mongodb://mongo:27017/fuegos?replicaSet=rs0 + METEOR_SETTINGS_FILE: /run/secrets/meteor-settings.json + volumes: + - ./secrets/meteor-settings.staging.json:/run/secrets/meteor-settings.json:ro + # Solo el proxy (assange) llega a este puerto; se publica en la red interna del host. + ports: + - "8004:3000" + restart: unless-stopped + logging: *default-logging + + redis: + image: redis:7-alpine + container_name: tcef-staging-redis + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 6 + restart: unless-stopped + logging: *default-logging + + notifications: + build: + context: ../tcef-notifications + image: tcef-notifications:local + container_name: tcef-staging-notifications + depends_on: + mongo-init: + condition: service_completed_successfully + redis: + condition: service_healthy + # secrets/notifications.staging.env → MODE=shadow, MATCHER_MODE=shadow, sin FCM, + # MAIL_URL=smtp://mailhog:1025. No envía nada a usuarios reales. + env_file: + - ./secrets/notifications.staging.env + restart: unless-stopped + logging: *default-logging + + # Buzón SMTP de captura: TODO el correo (verificación/reset/notifs) aterriza aquí, nunca fuera. + # UI web en 127.0.0.1:8025 (acceder por túnel SSH). SMTP interno en mailhog:1025. + mailhog: + image: mailhog/mailhog:latest + container_name: tcef-staging-mailhog + environment: + MH_STORAGE: memory + ports: + - "127.0.0.1:8025:8025" + restart: unless-stopped + logging: *default-logging + + node-red: + image: nodered/node-red:4.0 + container_name: tcef-staging-node-red + volumes: + - nodered-data:/data + ports: + - "127.0.0.1:1880:1880" + healthcheck: + test: ["CMD", "wget", "-qO", "/dev/null", "http://127.0.0.1:1880/"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + logging: *default-logging + + # Importador NASA FIRMS → escribe activefires en ESTE mongo (no en prod), alimentando el + # matcher en shadow. No arranca con `up` (profile "importer"): lo dispara un systemd timer + # del host cada 15 min con `docker compose run --rm importer`. + # NOTA: requiere crear su Dockerfile en fires-csv-mongo-import/ (fase-3 §2). Hasta entonces, + # este servicio queda declarado pero su build context debe completarse. + importer: + profiles: ["importer"] + build: + context: ../todos-contra-el-fuego/fires-csv-mongo-import + image: tcef-nasa-importer:local + container_name: tcef-staging-importer + depends_on: + mongo-init: + condition: service_completed_successfully + environment: + MONGO_URL: mongodb://mongo:27017/fuegos?replicaSet=rs0 + restart: "no" + logging: *default-logging + +volumes: + mongo-data: + redis-data: + nodered-data: diff --git a/scripts/neutralize-contacts.mongo.js b/scripts/neutralize-contacts.mongo.js new file mode 100644 index 0000000..9732ed1 --- /dev/null +++ b/scripts/neutralize-contacts.mongo.js @@ -0,0 +1,95 @@ +// Neutraliza los datos de contacto en un volcado REAL de `fuegos` restaurado en el +// Mongo 7 de STAGING, para que ninguna notificación pueda alcanzar a un usuario real. +// IDEMPOTENTE: se puede ejecutar varias veces sin efectos acumulativos indeseados. +// +// Uso (desde el host de staging, contra el contenedor del compose): +// docker exec -i tcef-staging-mongo mongosh --quiet fuegos < scripts/neutralize-contacts.mongo.js +// +// SEGURIDAD: ejecutar SOLO contra el Mongo aislado de staging. Aborta si detecta que la +// conexión no es la esperada (heurística: nombre de host contiene 'shiva'/'simone'/'rbg'/'rosaparks'). + +(function guardAgainstProd() { + try { + const host = db.getMongo().host || ''; + const prod = /shiva|simone|rbg|rosaparks|rsmain/i; + if (prod.test(host)) { + throw new Error('ABORTADO: la conexión (' + host + ') parece de PRODUCCIÓN. Este script solo debe correr contra el Mongo de staging.'); + } + } catch (e) { + if (/ABORTADO/.test(e.message)) { print(e.message); quit(1); } + } +})(); + +const dbx = db.getSiblingDB('fuegos'); + +// 1) users.emails[].address -> "<_id>@invalid.test" (único por usuario) + verified:false +const rEmails = dbx.users.updateMany( + { emails: { $type: 'array', $ne: [] } }, + [{ + $set: { + emails: { + $map: { + input: '$emails', + as: 'e', + in: { + address: { $concat: [{ $toString: '$_id' }, '@invalid.test'] }, + verified: false + } + } + } + } + }] +); + +// 2) users: purgar tokens/handles de notificación (FCM y Telegram) +const rUserTokens = dbx.users.updateMany( + {}, { $unset: { + fireBaseToken: '', + telegramChatId: '', + telegramUsername: '', + telegramFirstName: '', + telegramLanguageCode: '' + }} +); + +// 3) users.services.google.email -> invalidar (evita PII en OAuth); conservar el resto de services +const rGoogle = dbx.users.updateMany( + { 'services.google.email': { $exists: true } }, + [{ $set: { 'services.google.email': { $concat: [{ $toString: '$_id' }, '@invalid.test'] } } }] +); + +// 4) subscriptions: quitar el destino de Telegram (chatId) de las subs de ese tipo +const rSubs = dbx.subscriptions.updateMany( + { chatId: { $exists: true } }, { $unset: { chatId: '' } } +); + +// 5) Cola de Mail-Time: el volcado de prod trae correos ya encolados con el +// DESTINATARIO real "horneado" (p.ej. de 2020). Neutralizar users.emails NO los +// toca; la web (isMailServer) los drenaría. Se vacían las colas de la cola/jobs. +// (En staging MAIL_URL apunta a Mailhog, así que aunque quedaran, se capturarían; +// esto es defensa en profundidad para no arrastrar direcciones reales.) +const rMailQueue = dbx.getCollection('__mailTimeQueue__').deleteMany({}); +const rMailJobs = dbx.getCollection('__JobTasks__mailTimeQueue').deleteMany({}); + +printjson({ + usersEmailsNeutralizados: rEmails.modifiedCount, + usersTokensPurgados: rUserTokens.modifiedCount, + googleEmailsInvalidados: rGoogle.modifiedCount, + subsChatIdEliminados: rSubs.modifiedCount, + colaCorreoBorrada: rMailQueue.deletedCount, + colaJobsBorrada: rMailJobs.deletedCount +}); + +// Verificación rápida (debe dar 0 en todas). OJO: filtrar por $exists ANTES del +// $not/regex — si no, los usuarios SIN email cuentan como "no invalidados" (falso positivo). +const conToken = dbx.users.countDocuments({ fireBaseToken: { $exists: true } }); +const emailMalo = dbx.users.countDocuments({ 'emails.address': { $exists: true, $not: /@invalid\.test$/ } }); +const googleMalo = dbx.users.countDocuments({ 'services.google.email': { $exists: true, $not: /@invalid\.test$/ } }); +const subsConChat = dbx.subscriptions.countDocuments({ chatId: { $exists: true } }); +const colaCorreo = dbx.getCollection('__mailTimeQueue__').countDocuments({}); +printjson({ verificacion: { usersConFireBaseToken: conToken, usersConEmailRealVisible: emailMalo, usersConGoogleEmailReal: googleMalo, subsConChatId: subsConChat, correosEnCola: colaCorreo } }); +if (conToken === 0 && emailMalo === 0 && googleMalo === 0 && subsConChat === 0 && colaCorreo === 0) { + print('OK: contactos neutralizados. Ninguna notificación puede alcanzar a un usuario real.'); +} else { + print('AVISO: quedan campos sin neutralizar — revisar antes de exponer el staging.'); +} diff --git a/secrets/meteor-settings.staging.json.example b/secrets/meteor-settings.staging.json.example new file mode 100644 index 0000000..f0a1a77 --- /dev/null +++ b/secrets/meteor-settings.staging.json.example @@ -0,0 +1,46 @@ +{ + "// AVISO": "Plantilla de STAGING (testfuegos.comunes.org). Copia a meteor-settings.staging.json y rellena los valores reales desde tcef-private-config/local/todos-contra-el-fuego-web/settings-staging.json. El fichero real está gitignorado.", + "PrerenderIO": { + "serviceUrl": "http://groucho.comunes.org:3000/render", + "token": "PRERENDER_TOKEN" + }, + "gmaps": { + "key": "GMAPS_BROWSER_KEY", + "serverKey": "GMAPS_SERVER_KEY" + }, + "piwik": { + "url": "https://piwik.comunes.org/piwik.php", + "site_id": 7 + }, + "sentryPrivateDSN": "", + "public": { + "sentryPublicDSN": "" + }, + "private": { + "testEmail": "vjrj@ourproject.org", + "// mail": "STAGING: correo capturado por Mailhog del compose. testMailer=false para no emitir 4 correos de autoprueba al arrancar; debugMailer=true para loguear la cola.", + "testMailer": false, + "debugMailer": true, + "MAIL_URL": "smtp://mailhog:1025", + "isMailServer": true, + "fireIconUrl": "https://testfuegos.comunes.org/n-fire-marker.png", + "OAuth": { + "google": { + "clientId": "GOOGLE_OAUTH_CLIENT_ID", + "secret": "GOOGLE_OAUTH_SECRET", + "loginStyle": "popup" + } + }, + "// fcm": "STAGING: sin fcmApiToken. El push lo gestiona tcef-notifications (en shadow, sin service account).", + "proxies_count": 1, + "ironPassword": "IRON_PASSWORD", + "internalApiToken": "INTERNAL_API_TOKEN", + "cron": { + "debug": false + }, + "twitter": { + "es": { "enabled": false }, + "en": { "enabled": false } + } + } +} diff --git a/secrets/notifications.staging.env.example b/secrets/notifications.staging.env.example new file mode 100644 index 0000000..19c0fe8 --- /dev/null +++ b/secrets/notifications.staging.env.example @@ -0,0 +1,40 @@ +# tcef-notifications en STAGING (testfuegos.comunes.org). Copia a notifications.staging.env. +# Referencia completa: ../../tcef-notifications/.env.example +# +# CERO ENVÍOS A USUARIOS REALES: +# MODE=shadow → calcula decisiones y las loguea, NO envía (ver src/mode.ts decideSend) +# MATCHER_MODE=shadow→ el matcher computa candidatos a notificar, NO inserta/envía +# sin FCM_SERVICE_ACCOUNT → no se construye el cliente FCM (imposible enviar push) +# MAIL_URL=smtp://mailhog:1025 → cualquier correo se captura en Mailhog, nunca sale +# telegram FUERA de OWNED_CHANNELS → los bots reales ES/EN ni se tocan + +MODE=shadow +KILL_SWITCH=0 +OWNED_CHANNELS=fcm,email +MATCHER_MODE=shadow +LOG_LEVEL=debug + +# Infra del compose de staging (nombres de servicio de docker-compose.staging.yml) +MONGO_URL=mongodb://mongo:27017/fuegos?replicaSet=rs0 +MONGO_DB=fuegos +REDIS_HOST=redis +REDIS_PORT=6379 + +POLL_INTERVAL_MS=10000 +POLL_BATCH_SIZE=500 + +# Captura de correo (no un SMTP real). En shadow no se envía, pero se deja apuntando a Mailhog +# como red de seguridad si alguien sube el MODE por error. +MAIL_URL=smtp://mailhog:1025 +MAIL_FROM=Todos contra el Fuego (STAGING) + +# NO configurar en staging (dejar comentado): sin service account no hay push posible. +# FCM_SERVICE_ACCOUNT=/run/secrets/firebase-service-account.json +# FCM_PROJECT_ID=angular-cosmos-108908 + +# OBLIGATORIO si MATCHER_MODE != off: el matcher lo exige para sellar notification.sealed +# (createMatchContext falla al arrancar sin él, aun en shadow). = ironPassword de los settings. +IRON_PASSWORD=IRON_PASSWORD_DE_LOS_SETTINGS + +ROOT_URL=https://testfuegos.comunes.org/ +SERVICE_NAME=tcef-notifications-staging