diff --git a/.env.example b/.env.example index dfba1e4..2bfe0e8 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,9 @@ TAG=latest # ---- Domínios (MUDAM na máquina nova) ---- WEB_DOMAIN=rastro.SEU-NOVO-DOMINIO.app.br ADMIN_DOMAIN=admin.rastro.SEU-NOVO-DOMINIO.app.br +# Rastro Campo (PWA volante, celular). Atribua este domínio ao serviço `campo` +# (porta 39030) no Coolify. HTTPS obrigatório — a câmera exige. +CAMPO_DOMAIN=campo.rastro.SEU-NOVO-DOMINIO.app.br # ---- Banco ---- POSTGRES_USER=rastro @@ -91,3 +94,16 @@ TERADATA_PASSWORD= # "1:". PERDER A CHAVE = PERDER AS PROVAS: guarde cópia fora do servidor. EVIDENCE_KEYS= + +# ---- Rastro Campo: registro de passagem (evento do fisco 610500) ---- +# Assinado com o certificado A1 acima (DFE_CERT_PFX_BASE64). Nasce DESLIGADO e em +# HOMOLOGAÇÃO. Hoje o Ambiente Nacional devolve cStat 576: o CNPJ do certificado +# (78.393.592/0001-46) ainda não está habilitado como autor do 610500. Depois da +# habilitação: validar 135/136 em homologação, então SEFAZ_EVENTO_TP_AMB=1 e ligar. +CAMPO_PASSAGEM_ENABLED=false +SEFAZ_EVENTO_TP_AMB=2 +# 91 = Ambiente Nacional (único que recebe o 610500; a SEFAZ-PR responde 491). +SEFAZ_EVENTO_CORGAO=91 +# CNPJ da SEFAZ autora; vazio = o do certificado. +SEFAZ_EVENTO_CNPJ= +SEFAZ_EVENTO_ENDPOINTS= diff --git a/README.md b/README.md index f0f492a..7a36da1 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ Não é uma imagem única. São **2 imagens custom** + **5 de infra públicas**: | Imagem | Origem | Serviços | |---|---|---| -| `rastro-node` | build local → **Gitea** | backend, web, admin, ingestor, processor, dfe-worker, migrate | +| `rastro-node` | build local → **Gitea** | backend, web, admin, campo, ingestor, processor, dfe-worker, migrate | | `rastro-analytics` | build local → **Gitea** | analytics (Python) | +| `rastro-alpr` | build local → **Gitea** | alpr (OCR de placas do Rastro Campo) | | `timescale/timescaledb-ha:pg16` | Docker Hub | db | | `rabbitmq:3-management` | Docker Hub | rabbitmq | | `minio/minio:latest` | Docker Hub | minio | @@ -81,6 +82,7 @@ aparecem. (`/api` e `/health` no MESMO domínio). Configure no Coolify: - `web` → `https://${WEB_DOMAIN}` (porta 39000) - `admin`→ `https://${ADMIN_DOMAIN}` (porta 39020) + - `campo`→ `https://${CAMPO_DOMAIN}` (porta 39030) — o /api desse domínio já vai ao backend pela label do Traefik - `backend` → responder `${WEB_DOMAIN}/api`, `${WEB_DOMAIN}/health`, `${ADMIN_DOMAIN}/api`, `${ADMIN_DOMAIN}/health` (porta 39010) — roteamento por PATH. Se o roteamento por path do Coolify complicar, a alternativa é dar ao backend um domínio diff --git a/docker-compose.yaml b/docker-compose.yaml index 2750b9d..6ef9107 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -75,18 +75,21 @@ services: - '--api-timeout=60s' osrm: # Rotas pelas vias (grafo do Brasil, MLD, v5.26.0, --mmap: ~70-700 MB de RAM). - # O grafo (15 GB) JÁ ESTÁ no volume osrm_data (baixado em 2026-09-17, marcado .pronto). - # Só se o volume for perdido: adicione `environment: { OSRM_DATA_URL: '' }` - # aqui, com ~25 GB livres, e o serviço baixa de novo (retoma e confere sha256). + # O grafo (15 GB) vive no volume osrm_data e é baixado UMA vez (marca .pronto). + # Máquina nova (ou volume perdido) = defina OSRM_DATA_URL nas variáveis do Coolify, + # com ~25 GB livres: o serviço baixa de lá, retoma queda e confere sha256. Sem a + # variável e sem .pronto o container para e diz isso no log — de propósito. image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-osrm:v1' restart: unless-stopped + environment: + OSRM_DATA_URL: '${OSRM_DATA_URL:-}' volumes: - 'osrm_data:/data' expose: - '5000' ingestor: # DRENAGEM do ONE (passagens + fotos). Valores de cadência da produção antiga. - image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.2' + image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.3' restart: unless-stopped # Só ORDEM de partida, sem `condition: service_healthy`: a espera de verdade é # dentro do container (`wait-for-db schema rabbitmq`). Com a condição aqui, o @@ -125,7 +128,7 @@ services: MINIO_BUCKET: rastro-photos command: 'sh -c "wait-for-db schema rabbitmq && pnpm --filter @rastro/ingestor start"' migrate: - image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.2' + image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.3' restart: 'no' depends_on: - db @@ -138,7 +141,7 @@ services: DB_CONNECTION_LIMIT: '5' command: 'sh -c "wait-for-db && pnpm --filter @rastro/db migrate && pnpm --filter @rastro/db postgis && pnpm --filter @rastro/db timescale && pnpm --filter @rastro/db intel && pnpm --filter @rastro/db analyze && pnpm --filter @rastro/db restore-users"' processor: - image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.2' + image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.3' restart: unless-stopped depends_on: - db @@ -165,7 +168,7 @@ services: command: 'sh -c "wait-for-db schema rabbitmq && pnpm --filter @rastro/processor start"' dfe-worker: # Cadeia MDF-e → CT-e → NF-e (SEFAZ; Teradata para transporte além de D+1) + rota OSRM. - image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.2' + image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.3' restart: unless-stopped depends_on: - db @@ -223,7 +226,7 @@ services: TERADATA_USER: '${TERADATA_USER}' TERADATA_PASSWORD: '${TERADATA_PASSWORD}' backend: - image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.2' + image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.3' restart: unless-stopped depends_on: - db @@ -237,7 +240,7 @@ services: BACKEND_PORT: '39010' JWT_SECRET: '${JWT_SECRET}' SESSION_COOKIE_NAME: rastro_session - CORS_ORIGINS: 'https://${WEB_DOMAIN},https://${ADMIN_DOMAIN}' + CORS_ORIGINS: 'https://${WEB_DOMAIN},https://${ADMIN_DOMAIN},https://${CAMPO_DOMAIN}' RATE_LIMIT_MAX: '1000' GOTENBERG_URL: 'http://gotenberg:3000' MINIO_ENDPOINT: minio @@ -258,18 +261,41 @@ services: DFE_CERT_PFX_BASE64: '${DFE_CERT_PFX_BASE64}' DFE_CERT_PASSPHRASE: '${DFE_CERT_PASSPHRASE}' DFE_TLS_REJECT_UNAUTHORIZED: 'false' + # Rastro Campo (PWA volante). O registro de passagem (610500) nasce DESLIGADO e + # em HOMOLOGAÇÃO (tpAmb 2): ligar CAMPO_PASSAGEM_ENABLED só depois de ver cStat + # 135/136 em homologação; produção = SEFAZ_EVENTO_TP_AMB=1. + CAMPO_PUBLIC_URL: 'https://${CAMPO_DOMAIN}' + ALPR_URL: 'http://alpr:8000' + CAMPO_PASSAGEM_ENABLED: '${CAMPO_PASSAGEM_ENABLED:-false}' + SEFAZ_EVENTO_TP_AMB: '${SEFAZ_EVENTO_TP_AMB:-2}' + SEFAZ_EVENTO_CORGAO: '${SEFAZ_EVENTO_CORGAO:-91}' + SEFAZ_EVENTO_CORGAO_AUTOR: '41' + SEFAZ_EVENTO_CNPJ: '${SEFAZ_EVENTO_CNPJ:-}' + SEFAZ_EVENTO_ENDPOINTS: '${SEFAZ_EVENTO_ENDPOINTS:-}' + TD_DOC_CACHE_HOURS_CAMPO: '1' command: 'sh -c "wait-for-db schema && pnpm --filter @rastro/backend start"' expose: - '39010' labels: - traefik.enable=true - - 'traefik.http.routers.rastroapi.rule=(Host(`rastrobr.externo.utic.app.br`) || Host(`admin.rastrobr.externo.utic.app.br`)) && PathPrefix(`/api`)' + # Hosts vêm de WEB_DOMAIN/ADMIN_DOMAIN (mesmas variáveis do PREVIEW_ALLOWED_HOSTS e + # do CORS): domínio hardcoded aqui deixou o /api fora do ar na troca de endereço — + # o Traefik mandava /api para o `web`, que respondia 403 do vite preview. + - 'traefik.http.routers.rastroapi.rule=(Host(`${WEB_DOMAIN}`) || Host(`${ADMIN_DOMAIN}`)) && PathPrefix(`/api`)' - traefik.http.routers.rastroapi.entryPoints=https - traefik.http.routers.rastroapi.tls=true - traefik.http.routers.rastroapi.priority=1000 + - traefik.http.routers.rastroapi.service=rastroapi - traefik.http.services.rastroapi.loadbalancer.server.port=39010 + # /api no domínio do Rastro Campo: roteador SEPARADO — se CAMPO_DOMAIN estiver + # vazio, só ele fica inválido; o /api do web/admin continua de pé. + - 'traefik.http.routers.rastroapicampo.rule=Host(`${CAMPO_DOMAIN}`) && PathPrefix(`/api`)' + - traefik.http.routers.rastroapicampo.entryPoints=https + - traefik.http.routers.rastroapicampo.tls=true + - traefik.http.routers.rastroapicampo.priority=1000 + - traefik.http.routers.rastroapicampo.service=rastroapi web: - image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.2' + image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.3' restart: unless-stopped environment: DATABASE_URL: 'postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public' @@ -282,7 +308,7 @@ services: expose: - '39000' admin: - image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.2' + image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.3' restart: unless-stopped environment: DATABASE_URL: 'postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public' @@ -294,6 +320,23 @@ services: command: 'sh -c "pnpm --filter admin preview --host 0.0.0.0 --port 39020"' expose: - '39020' + # Rastro Campo — PWA da fiscalização volante. No Coolify, atribua o domínio + # (CAMPO_DOMAIN) a este serviço, porta 39030. HTTPS obrigatório (câmera). + campo: + image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.3' + restart: unless-stopped + environment: + DATABASE_URL: 'postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public' + PREVIEW_ALLOWED_HOSTS: '${CAMPO_DOMAIN}' + command: 'sh -c "pnpm --filter campo preview --host 0.0.0.0 --port 39030"' + expose: + - '39030' + # OCR de placas do Rastro Campo (CPU, modelos embutidos na imagem). Só rede interna. + alpr: + image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-alpr:v1' + restart: unless-stopped + expose: + - '8000' volumes: osrm_data: null dbdata2: null diff --git a/docker-compose.yml b/docker-compose.yml index 2750b9d..6ef9107 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,18 +75,21 @@ services: - '--api-timeout=60s' osrm: # Rotas pelas vias (grafo do Brasil, MLD, v5.26.0, --mmap: ~70-700 MB de RAM). - # O grafo (15 GB) JÁ ESTÁ no volume osrm_data (baixado em 2026-09-17, marcado .pronto). - # Só se o volume for perdido: adicione `environment: { OSRM_DATA_URL: '' }` - # aqui, com ~25 GB livres, e o serviço baixa de novo (retoma e confere sha256). + # O grafo (15 GB) vive no volume osrm_data e é baixado UMA vez (marca .pronto). + # Máquina nova (ou volume perdido) = defina OSRM_DATA_URL nas variáveis do Coolify, + # com ~25 GB livres: o serviço baixa de lá, retoma queda e confere sha256. Sem a + # variável e sem .pronto o container para e diz isso no log — de propósito. image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-osrm:v1' restart: unless-stopped + environment: + OSRM_DATA_URL: '${OSRM_DATA_URL:-}' volumes: - 'osrm_data:/data' expose: - '5000' ingestor: # DRENAGEM do ONE (passagens + fotos). Valores de cadência da produção antiga. - image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.2' + image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.3' restart: unless-stopped # Só ORDEM de partida, sem `condition: service_healthy`: a espera de verdade é # dentro do container (`wait-for-db schema rabbitmq`). Com a condição aqui, o @@ -125,7 +128,7 @@ services: MINIO_BUCKET: rastro-photos command: 'sh -c "wait-for-db schema rabbitmq && pnpm --filter @rastro/ingestor start"' migrate: - image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.2' + image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.3' restart: 'no' depends_on: - db @@ -138,7 +141,7 @@ services: DB_CONNECTION_LIMIT: '5' command: 'sh -c "wait-for-db && pnpm --filter @rastro/db migrate && pnpm --filter @rastro/db postgis && pnpm --filter @rastro/db timescale && pnpm --filter @rastro/db intel && pnpm --filter @rastro/db analyze && pnpm --filter @rastro/db restore-users"' processor: - image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.2' + image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.3' restart: unless-stopped depends_on: - db @@ -165,7 +168,7 @@ services: command: 'sh -c "wait-for-db schema rabbitmq && pnpm --filter @rastro/processor start"' dfe-worker: # Cadeia MDF-e → CT-e → NF-e (SEFAZ; Teradata para transporte além de D+1) + rota OSRM. - image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.2' + image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.3' restart: unless-stopped depends_on: - db @@ -223,7 +226,7 @@ services: TERADATA_USER: '${TERADATA_USER}' TERADATA_PASSWORD: '${TERADATA_PASSWORD}' backend: - image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.2' + image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.3' restart: unless-stopped depends_on: - db @@ -237,7 +240,7 @@ services: BACKEND_PORT: '39010' JWT_SECRET: '${JWT_SECRET}' SESSION_COOKIE_NAME: rastro_session - CORS_ORIGINS: 'https://${WEB_DOMAIN},https://${ADMIN_DOMAIN}' + CORS_ORIGINS: 'https://${WEB_DOMAIN},https://${ADMIN_DOMAIN},https://${CAMPO_DOMAIN}' RATE_LIMIT_MAX: '1000' GOTENBERG_URL: 'http://gotenberg:3000' MINIO_ENDPOINT: minio @@ -258,18 +261,41 @@ services: DFE_CERT_PFX_BASE64: '${DFE_CERT_PFX_BASE64}' DFE_CERT_PASSPHRASE: '${DFE_CERT_PASSPHRASE}' DFE_TLS_REJECT_UNAUTHORIZED: 'false' + # Rastro Campo (PWA volante). O registro de passagem (610500) nasce DESLIGADO e + # em HOMOLOGAÇÃO (tpAmb 2): ligar CAMPO_PASSAGEM_ENABLED só depois de ver cStat + # 135/136 em homologação; produção = SEFAZ_EVENTO_TP_AMB=1. + CAMPO_PUBLIC_URL: 'https://${CAMPO_DOMAIN}' + ALPR_URL: 'http://alpr:8000' + CAMPO_PASSAGEM_ENABLED: '${CAMPO_PASSAGEM_ENABLED:-false}' + SEFAZ_EVENTO_TP_AMB: '${SEFAZ_EVENTO_TP_AMB:-2}' + SEFAZ_EVENTO_CORGAO: '${SEFAZ_EVENTO_CORGAO:-91}' + SEFAZ_EVENTO_CORGAO_AUTOR: '41' + SEFAZ_EVENTO_CNPJ: '${SEFAZ_EVENTO_CNPJ:-}' + SEFAZ_EVENTO_ENDPOINTS: '${SEFAZ_EVENTO_ENDPOINTS:-}' + TD_DOC_CACHE_HOURS_CAMPO: '1' command: 'sh -c "wait-for-db schema && pnpm --filter @rastro/backend start"' expose: - '39010' labels: - traefik.enable=true - - 'traefik.http.routers.rastroapi.rule=(Host(`rastrobr.externo.utic.app.br`) || Host(`admin.rastrobr.externo.utic.app.br`)) && PathPrefix(`/api`)' + # Hosts vêm de WEB_DOMAIN/ADMIN_DOMAIN (mesmas variáveis do PREVIEW_ALLOWED_HOSTS e + # do CORS): domínio hardcoded aqui deixou o /api fora do ar na troca de endereço — + # o Traefik mandava /api para o `web`, que respondia 403 do vite preview. + - 'traefik.http.routers.rastroapi.rule=(Host(`${WEB_DOMAIN}`) || Host(`${ADMIN_DOMAIN}`)) && PathPrefix(`/api`)' - traefik.http.routers.rastroapi.entryPoints=https - traefik.http.routers.rastroapi.tls=true - traefik.http.routers.rastroapi.priority=1000 + - traefik.http.routers.rastroapi.service=rastroapi - traefik.http.services.rastroapi.loadbalancer.server.port=39010 + # /api no domínio do Rastro Campo: roteador SEPARADO — se CAMPO_DOMAIN estiver + # vazio, só ele fica inválido; o /api do web/admin continua de pé. + - 'traefik.http.routers.rastroapicampo.rule=Host(`${CAMPO_DOMAIN}`) && PathPrefix(`/api`)' + - traefik.http.routers.rastroapicampo.entryPoints=https + - traefik.http.routers.rastroapicampo.tls=true + - traefik.http.routers.rastroapicampo.priority=1000 + - traefik.http.routers.rastroapicampo.service=rastroapi web: - image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.2' + image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.3' restart: unless-stopped environment: DATABASE_URL: 'postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public' @@ -282,7 +308,7 @@ services: expose: - '39000' admin: - image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.2' + image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.3' restart: unless-stopped environment: DATABASE_URL: 'postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public' @@ -294,6 +320,23 @@ services: command: 'sh -c "pnpm --filter admin preview --host 0.0.0.0 --port 39020"' expose: - '39020' + # Rastro Campo — PWA da fiscalização volante. No Coolify, atribua o domínio + # (CAMPO_DOMAIN) a este serviço, porta 39030. HTTPS obrigatório (câmera). + campo: + image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-node:v4.3' + restart: unless-stopped + environment: + DATABASE_URL: 'postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public' + PREVIEW_ALLOWED_HOSTS: '${CAMPO_DOMAIN}' + command: 'sh -c "pnpm --filter campo preview --host 0.0.0.0 --port 39030"' + expose: + - '39030' + # OCR de placas do Rastro Campo (CPU, modelos embutidos na imagem). Só rede interna. + alpr: + image: 'gitea.externo.utic.app.br/ericoalmeida/rastro-alpr:v1' + restart: unless-stopped + expose: + - '8000' volumes: osrm_data: null dbdata2: null