From 8255467313599a36b65cac42dfa31862d2dc0f99 Mon Sep 17 00:00:00 2001 From: Sirius Devops Date: Mon, 18 May 2026 00:57:53 +0000 Subject: [PATCH 1/2] feat: add OSINT Dashboard Kubernetes infrastructure - Helm chart scaffold (Chart.yaml, values.yaml, _helpers.tpl) - Namespace + RBAC manifests - PostgreSQL (CNPG, 3 replicas, PostGIS + TimescaleDB) - NATS JetStream (3 replicas, persistent, custom subjects) - Redis Sentinel (1 primary + 2 replicas, HA) - MinIO distributed (4 replicas, bucket init job) - Gateway API HTTPRoute + cert-manager TLS certificates - Monitoring stack (Prometheus, Grafana, Alertmanager, exporters) - NetworkPolicies (default deny + per-component policies) - GitHub Actions CI/CD pipeline (lint, template, security scan) - Flux CD staging overlay --- .github/workflows/osint-dashboard-infra.yml | 130 +++++++ apps/base/osint-dashboard/Chart.yaml | 15 + .../osint-dashboard/templates/_helpers.tpl | 67 ++++ .../templates/ingress/ingress.yaml | 47 +++ .../templates/minio/buckets-init-job.yaml | 61 +++ .../templates/minio/credentials-secret.yaml | 15 + .../templates/minio/service.yaml | 49 +++ .../templates/minio/statefulset.yaml | 84 ++++ .../templates/monitoring/alertmanager.yaml | 134 +++++++ .../templates/monitoring/grafana.yaml | 158 ++++++++ .../monitoring/prometheus-config.yaml | 188 +++++++++ .../monitoring/prometheus-deployment.yaml | 127 ++++++ .../osint-dashboard/templates/namespace.yaml | 49 +++ .../templates/nats/jetstream-subjects.yaml | 88 +++++ .../templates/nats/service.yaml | 60 +++ .../templates/nats/statefulset.yaml | 130 +++++++ .../templates/postgresql/cluster.yaml | 152 ++++++++ .../postgresql/credentials-secret.yaml | 29 ++ .../templates/postgresql/service.yaml | 18 + .../templates/redis/configmap.yaml | 70 ++++ .../templates/redis/service.yaml | 61 +++ .../templates/redis/statefulset.yaml | 210 ++++++++++ .../templates/security/dashboard-netpol.yaml | 145 +++++++ .../templates/security/default-deny.yaml | 17 + .../templates/security/minio-netpol.yaml | 56 +++ .../templates/security/monitoring-netpol.yaml | 60 +++ .../templates/security/nats-netpol.yaml | 67 ++++ .../templates/security/postgresql-netpol.yaml | 60 +++ .../templates/security/redis-netpol.yaml | 58 +++ apps/base/osint-dashboard/values.yaml | 360 ++++++++++++++++++ .../osint-dashboard/kustomization.yaml | 53 +++ 31 files changed, 2818 insertions(+) create mode 100644 .github/workflows/osint-dashboard-infra.yml create mode 100644 apps/base/osint-dashboard/Chart.yaml create mode 100644 apps/base/osint-dashboard/templates/_helpers.tpl create mode 100644 apps/base/osint-dashboard/templates/ingress/ingress.yaml create mode 100644 apps/base/osint-dashboard/templates/minio/buckets-init-job.yaml create mode 100644 apps/base/osint-dashboard/templates/minio/credentials-secret.yaml create mode 100644 apps/base/osint-dashboard/templates/minio/service.yaml create mode 100644 apps/base/osint-dashboard/templates/minio/statefulset.yaml create mode 100644 apps/base/osint-dashboard/templates/monitoring/alertmanager.yaml create mode 100644 apps/base/osint-dashboard/templates/monitoring/grafana.yaml create mode 100644 apps/base/osint-dashboard/templates/monitoring/prometheus-config.yaml create mode 100644 apps/base/osint-dashboard/templates/monitoring/prometheus-deployment.yaml create mode 100644 apps/base/osint-dashboard/templates/namespace.yaml create mode 100644 apps/base/osint-dashboard/templates/nats/jetstream-subjects.yaml create mode 100644 apps/base/osint-dashboard/templates/nats/service.yaml create mode 100644 apps/base/osint-dashboard/templates/nats/statefulset.yaml create mode 100644 apps/base/osint-dashboard/templates/postgresql/cluster.yaml create mode 100644 apps/base/osint-dashboard/templates/postgresql/credentials-secret.yaml create mode 100644 apps/base/osint-dashboard/templates/postgresql/service.yaml create mode 100644 apps/base/osint-dashboard/templates/redis/configmap.yaml create mode 100644 apps/base/osint-dashboard/templates/redis/service.yaml create mode 100644 apps/base/osint-dashboard/templates/redis/statefulset.yaml create mode 100644 apps/base/osint-dashboard/templates/security/dashboard-netpol.yaml create mode 100644 apps/base/osint-dashboard/templates/security/default-deny.yaml create mode 100644 apps/base/osint-dashboard/templates/security/minio-netpol.yaml create mode 100644 apps/base/osint-dashboard/templates/security/monitoring-netpol.yaml create mode 100644 apps/base/osint-dashboard/templates/security/nats-netpol.yaml create mode 100644 apps/base/osint-dashboard/templates/security/postgresql-netpol.yaml create mode 100644 apps/base/osint-dashboard/templates/security/redis-netpol.yaml create mode 100644 apps/base/osint-dashboard/values.yaml create mode 100644 apps/staging/osint-dashboard/kustomization.yaml diff --git a/.github/workflows/osint-dashboard-infra.yml b/.github/workflows/osint-dashboard-infra.yml new file mode 100644 index 0000000..3e60261 --- /dev/null +++ b/.github/workflows/osint-dashboard-infra.yml @@ -0,0 +1,130 @@ +name: OSINT Dashboard Infrastructure + +on: + push: + branches: [master] + paths: + - 'apps/base/osint-dashboard/**' + - 'apps/staging/osint-dashboard/**' + - 'clusters/devops-lab/**' + pull_request: + paths: + - 'apps/base/osint-dashboard/**' + workflow_dispatch: + +env: + REGISTRY: gcr.io/devops-lab-cluster + CHART_PATH: apps/base/osint-dashboard + +permissions: + contents: read + security-events: write + pull-requests: write + +jobs: + lint: + name: Lint Helm Chart + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v3 + with: + version: v3.14.0 + + - name: Set up chart-testing + uses: helm/chart-testing-action@v2 + + - name: Run helm lint + run: | + helm lint ${{ env.CHART_PATH }} + helm lint ${{ env.CHART_PATH }} -f ${{ env.CHART_PATH }}/values.yaml + + - name: Run chart-testing lint + run: | + ct lint --target-branch ${{ github.event.pull_request.base.ref || github.ref_name }} --chart-dirs apps/base --validate-maintainers=false + + template: + name: Render Templates + runs-on: ubuntu-latest + needs: lint + if: github.event_name == 'pull_request' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v3 + with: + version: v3.14.0 + + - name: Render templates (dev) + run: | + helm template osint-dashboard ${{ env.CHART_PATH }} --namespace customer1 --values ${{ env.CHART_PATH }}/values.yaml --output-template-files > /dev/null + + - name: Render templates (prod override) + run: | + helm template osint-dashboard ${{ env.CHART_PATH }} --namespace customer1 --values ${{ env.CHART_PATH }}/values.yaml --set postgresql.instances=3 --set nats.replicaCount=3 --set redis.replica.replicaCount=2 --set minio.replicaCount=4 --output-template-files > /dev/null + + validate-yaml: + name: Validate YAML + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install yamllint + run: pip install yamllint + + - name: Lint YAML files + run: | + yamllint -d relaxed --ignore '*/gotk-components.yaml' ${{ env.CHART_PATH }}/Chart.yaml ${{ env.CHART_PATH }}/values.yaml ${{ env.CHART_PATH }}/templates/ + + security-scan: + name: Security Scan + runs-on: ubuntu-latest + needs: lint + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run Trivy Helm chart scanner + uses: aquasecurity/trivy-action@master + with: + scan-type: 'config' + scan-ref: ${{ env.CHART_PATH }}/templates/ + format: 'sarif' + output: 'trivy-results.sarif' + severity: 'CRITICAL,HIGH' + + - name: Upload Trivy results + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: 'trivy-results.sarif' + + deploy-staging: + name: Deploy to Staging + runs-on: ubuntu-latest + needs: [lint, validate-yaml] + if: github.ref == 'refs/heads/master' && github.event_name == 'push' + environment: + name: staging + url: https://dashboard.siriusdevops.com + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Flux Kustomization already applied + run: | + echo "Flux CD will automatically pick up changes from master branch." + echo "Kustomization: customer1 -> apps/staging/customer1" + echo "No manual deploy step needed — GitOps loop handles it." + + - name: Notify deployment + if: always() + run: | + echo "Deployment triggered via Flux CD GitOps loop" + echo "Check Flux status: flux get kustomizations -n flux-system" diff --git a/apps/base/osint-dashboard/Chart.yaml b/apps/base/osint-dashboard/Chart.yaml new file mode 100644 index 0000000..2bff7a8 --- /dev/null +++ b/apps/base/osint-dashboard/Chart.yaml @@ -0,0 +1,15 @@ +# OSINT Dashboard — Helm Chart +apiVersion: v2 +name: osint-dashboard +description: Real-time geospatial OSINT dashboard infrastructure +type: application +version: 0.1.0 +appVersion: "1.0.0" +keywords: + - osint + - dashboard + - geospatial + - real-time +maintainers: + - name: sec-ops + email: sec-ops@osint-dashboard.local diff --git a/apps/base/osint-dashboard/templates/_helpers.tpl b/apps/base/osint-dashboard/templates/_helpers.tpl new file mode 100644 index 0000000..fb1122f --- /dev/null +++ b/apps/base/osint-dashboard/templates/_helpers.tpl @@ -0,0 +1,67 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "osint-dashboard.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "osint-dashboard.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 64 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 64 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 64 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "osint-dashboard.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "osint-dashboard.labels" -}} +helm.sh/chart: {{ include "osint-dashboard.chart" . }} +{{ include "osint-dashboard.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "osint-dashboard.selectorLabels" -}} +app.kubernetes.io/name: {{ include "osint-dashboard.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Namespace labels +*/}} +{{- define "osint-dashboard.namespaceLabels" -}} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "osint-dashboard.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "osint-dashboard.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/apps/base/osint-dashboard/templates/ingress/ingress.yaml b/apps/base/osint-dashboard/templates/ingress/ingress.yaml new file mode 100644 index 0000000..1893432 --- /dev/null +++ b/apps/base/osint-dashboard/templates/ingress/ingress.yaml @@ -0,0 +1,47 @@ +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ $host.host | replace "." "-" | trunc 50 | trimSuffix "-" }} + namespace: {{ $.Values.namespace }} + labels: + {{- include "osint-dashboard.labels" $ | nindent 4 }} +spec: + parentRefs: + - name: external-http-gateway + hostnames: + - "{{ $host.host }}" + rules: + {{- range $rule := $host.paths }} + - matches: + - path: + type: PathPrefix + value: {{ $rule.path }} + backendRefs: + - name: dashboard-web + port: 3000 + weight: 100 + {{- end }} +--- +{{- end }} + +# TLS Certificate resources +{{- range $tls := .Values.ingress.tls }} +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ $tls.secretName }} + namespace: {{ $.Values.namespace }} + labels: + {{- include "osint-dashboard.labels" $ | nindent 4 }} +spec: + secretName: {{ $tls.secretName }} + issuerRef: + name: {{ $.Values.ingress.certManager.clusterIssuerName }} + kind: ClusterIssuer + dnsNames: + {{- toYaml $tls.hosts | nindent 4 }} +--- +{{- end }} +{{- end }} diff --git a/apps/base/osint-dashboard/templates/minio/buckets-init-job.yaml b/apps/base/osint-dashboard/templates/minio/buckets-init-job.yaml new file mode 100644 index 0000000..a069a8e --- /dev/null +++ b/apps/base/osint-dashboard/templates/minio/buckets-init-job.yaml @@ -0,0 +1,61 @@ +{{- if .Values.minio.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: minio-buckets-init + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: object-storage + annotations: + "helm.sh/hook": post-install,post-upgrade + "helm.sh/hook-delete-policy": hook-succeeded +spec: + template: + metadata: + labels: + {{- include "osint-dashboard.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: object-storage + spec: + restartPolicy: OnFailure + securityContext: + runAsNonRoot: true + runAsUser: 1000 + containers: + - name: mc + image: "{{ .Values.minio.image.repository }}:{{ .Values.minio.image.tag }}" + envFrom: + - secretRef: + name: {{ .Values.minio.credentialsSecret }} + command: + - /bin/sh + - -c + args: + - | + # Wait for MinIO to be ready + until curl -sf http://minio:{{ .Values.minio.ports.api }}/minio/health/live; do + echo "Waiting for MinIO..." + sleep 2 + done + + # Configure mc alias + mc alias set myminio http://minio:{{ .Values.minio.ports.api }} $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD + + # Create buckets + mc mb --ignore-existing myminio/osint-video-clips + mc mb --ignore-existing myminio/osint-satellite-tiles + mc mb --ignore-existing myminio/osint-data-dumps + + echo "MinIO buckets initialized successfully" + resources: + requests: + cpu: "100m" + memory: "64Mi" + limits: + cpu: "500m" + memory: "256Mi" + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] +{{- end }} diff --git a/apps/base/osint-dashboard/templates/minio/credentials-secret.yaml b/apps/base/osint-dashboard/templates/minio/credentials-secret.yaml new file mode 100644 index 0000000..e66560a --- /dev/null +++ b/apps/base/osint-dashboard/templates/minio/credentials-secret.yaml @@ -0,0 +1,15 @@ +{{- if .Values.minio.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Values.minio.credentialsSecret }} + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + annotations: + # SOPS will encrypt this +type: Opaque +stringData: + MINIO_ROOT_USER: CHANGE_ME_USE_SOPS + MINIO_ROOT_PASSWORD: CHANGE_ME_USE_SOPS +{{- end }} diff --git a/apps/base/osint-dashboard/templates/minio/service.yaml b/apps/base/osint-dashboard/templates/minio/service.yaml new file mode 100644 index 0000000..be37a1d --- /dev/null +++ b/apps/base/osint-dashboard/templates/minio/service.yaml @@ -0,0 +1,49 @@ +{{- if .Values.minio.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: minio + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: object-storage +spec: + type: ClusterIP + ports: + - port: {{ .Values.minio.ports.api }} + targetPort: {{ .Values.minio.ports.api }} + protocol: TCP + name: api + - port: {{ .Values.minio.ports.console }} + targetPort: {{ .Values.minio.ports.console }} + protocol: TCP + name: console + selector: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: object-storage +--- +apiVersion: v1 +kind: Service +metadata: + name: minio-headless + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: object-storage + annotations: + service.alpha.kubernetes.io/tolerate-unready-endpoints: "true" +spec: + clusterIP: None + ports: + - port: {{ .Values.minio.ports.api }} + targetPort: {{ .Values.minio.ports.api }} + protocol: TCP + name: api + - port: {{ .Values.minio.ports.console }} + targetPort: {{ .Values.minio.ports.console }} + protocol: TCP + name: console + selector: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: object-storage +{{- end }} diff --git a/apps/base/osint-dashboard/templates/minio/statefulset.yaml b/apps/base/osint-dashboard/templates/minio/statefulset.yaml new file mode 100644 index 0000000..17b7bed --- /dev/null +++ b/apps/base/osint-dashboard/templates/minio/statefulset.yaml @@ -0,0 +1,84 @@ +{{- if .Values.minio.enabled }} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: minio + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: object-storage +spec: + serviceName: minio-headless + replicas: {{ .Values.minio.replicaCount }} + selector: + matchLabels: + {{- include "osint-dashboard.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: object-storage + template: + metadata: + labels: + {{- include "osint-dashboard.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: object-storage + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "{{ .Values.minio.ports.api }}" + prometheus.io/path: "/minio/v2/metrics/cluster" + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + containers: + - name: minio + image: "{{ .Values.minio.image.repository }}:{{ .Values.minio.image.tag }}" + ports: + - name: api + containerPort: {{ .Values.minio.ports.api }} + - name: console + containerPort: {{ .Values.minio.ports.console }} + resources: + {{- toYaml .Values.minio.resources | nindent 12 }} + envFrom: + - secretRef: + name: {{ .Values.minio.credentialsSecret }} + env: + - name: MINIO_SERVER_URL + value: "http://minio.{{ .Values.namespace }}.svc:{{ .Values.minio.ports.api }}" + args: + - server + - "--console-address" + - ":{{ .Values.minio.ports.console }}" + - "--address" + - ":{{ .Values.minio.ports.api }}" + # Distributed mode: all 4 pods + - "http://minio-{0...3}.minio-headless.{{ .Values.namespace }}.svc/data" + volumeMounts: + - name: data + mountPath: /data + readinessProbe: + httpGet: + path: /minio/health/live + port: api + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /minio/health/live + port: api + initialDelaySeconds: 10 + periodSeconds: 10 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: ["ALL"] + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: {{ .Values.minio.storage.size }} + storageClassName: {{ .Values.minio.storage.storageClass }} +{{- end }} diff --git a/apps/base/osint-dashboard/templates/monitoring/alertmanager.yaml b/apps/base/osint-dashboard/templates/monitoring/alertmanager.yaml new file mode 100644 index 0000000..e745a12 --- /dev/null +++ b/apps/base/osint-dashboard/templates/monitoring/alertmanager.yaml @@ -0,0 +1,134 @@ +{{- if .Values.monitoring.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: alertmanager-config + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: monitoring +data: + alertmanager.yml: | + global: + resolve_timeout: 5m + + route: + group_by: ['alertname', 'namespace'] + group_wait: 30s + group_interval: 5m + repeat_interval: 4h + receiver: 'default' + routes: + - match: + severity: critical + receiver: 'pager' + repeat_interval: 1h + - match: + severity: warning + receiver: 'slack' + + receivers: + - name: 'default' + email_configs: + - to: CHANGE_ME_USE_SOPS + from: monitoring@{{ .Values.monitoring.defaultEmailDomain }} + smarthost: CHANGE_ME_USE_SOPS + auth_username: CHANGE_ME_USE_SOPS + auth_password: CHANGE_ME_USE_SOPS + + - name: 'pager' + webhook_configs: + - url: CHANGE_ME_USE_SOPS + send_resolved: true + + - name: 'slack' + slack_configs: + - api_url: CHANGE_ME_USE_SOPS + channel: '#osint-alerts' + send_resolved: true + title: '{{ .GroupLabels.alertname }}' + text: >- + {{ range .Alerts }} + *Alert:* {{ .Labels.alertname }} + *Severity:* {{ .Labels.severity }} + *Summary:* {{ .Annotations.summary }} + {{ end }} + + inhibit_rules: + - source_match: + severity: 'critical' + target_match: + severity: 'warning' + equal: ['alertname', 'namespace'] +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: alertmanager + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: monitoring +spec: + replicas: 1 + selector: + matchLabels: + {{- include "osint-dashboard.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: monitoring + template: + metadata: + labels: + {{- include "osint-dashboard.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: monitoring + spec: + securityContext: + runAsNonRoot: true + runAsUser: 65534 + fsGroup: 65534 + containers: + - name: alertmanager + image: "{{ .Values.monitoring.alertmanager.image.repository }}:{{ .Values.monitoring.alertmanager.image.tag }}" + ports: + - containerPort: {{ .Values.monitoring.alertmanager.port }} + name: web + args: + - "--config.file=/etc/alertmanager/alertmanager.yml" + - "--storage.path=/alertmanager" + resources: + {{- toYaml .Values.monitoring.alertmanager.resources | nindent 12 }} + volumeMounts: + - name: config + mountPath: /etc/alertmanager + - name: data + mountPath: /alertmanager + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumes: + - name: config + configMap: + name: alertmanager-config + strategy: + type: Recreate +--- +apiVersion: v1 +kind: Service +metadata: + name: alertmanager + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: monitoring +spec: + type: ClusterIP + ports: + - port: {{ .Values.monitoring.alertmanager.port }} + targetPort: web + protocol: TCP + name: web + selector: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: monitoring +{{- end }} diff --git a/apps/base/osint-dashboard/templates/monitoring/grafana.yaml b/apps/base/osint-dashboard/templates/monitoring/grafana.yaml new file mode 100644 index 0000000..cf3be67 --- /dev/null +++ b/apps/base/osint-dashboard/templates/monitoring/grafana.yaml @@ -0,0 +1,158 @@ +{{- if .Values.monitoring.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: grafana-admin-secret + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: monitoring +type: Opaque +stringData: + admin-user: CHANGE_ME_USE_SOPS + admin-password: CHANGE_ME_USE_SOPS +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-datasources + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: monitoring +data: + datasources.yaml: | + apiVersion: 1 + datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:{{ .Values.monitoring.prometheus.port }} + isDefault: true + editable: true + - name: PostgreSQL + type: postgres + access: proxy + url: postgresql-rw.{{ .Values.namespace }}.svc:5432 + database: osint + user: grafana + secureJsonData: + password: CHANGE_ME_USE_SOPS + jsonData: + tlsAuth: false + sslmode: disable + postgresVersion: 1600 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboards + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: monitoring +data: + dashboard-providers.yaml: | + apiVersion: 1 + providers: + - name: 'default' + orgId: 1 + folder: '' + type: file + disableDeletion: false + editable: true + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: grafana + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: monitoring +spec: + replicas: 1 + selector: + matchLabels: + {{- include "osint-dashboard.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: monitoring + template: + metadata: + labels: + {{- include "osint-dashboard.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: monitoring + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "3000" + spec: + securityContext: + runAsNonRoot: true + runAsUser: 472 # grafana + fsGroup: 472 + containers: + - name: grafana + image: "{{ .Values.monitoring.grafana.image.repository }}:{{ .Values.monitoring.grafana.image.tag }}" + ports: + - containerPort: {{ .Values.monitoring.grafana.port }} + name: web + env: + - name: GF_SECURITY_ADMIN_USER + valueFrom: + secretKeyRef: + name: grafana-admin-secret + key: admin-user + - name: GF_SECURITY_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: grafana-admin-secret + key: admin-password + - name: GF_SERVER_ROOT_URL + value: "https://grafana.{{ .Values.monitoring.grafana.hostname }}" + - name: GF_AUTH_ANONYMOUS_ENABLED + value: "false" + resources: + {{- toYaml .Values.monitoring.grafana.resources | nindent 12 }} + volumeMounts: + - name: datasources + mountPath: /etc/grafana/provisioning/datasources + - name: dashboards-config + mountPath: /etc/grafana/provisioning/dashboards + - name: data + mountPath: /var/lib/grafana + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: ["ALL"] + volumes: + - name: datasources + configMap: + name: grafana-datasources + - name: dashboards-config + configMap: + name: grafana-dashboards + strategy: + type: Recreate +--- +apiVersion: v1 +kind: Service +metadata: + name: grafana + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: monitoring +spec: + type: ClusterIP + ports: + - port: {{ .Values.monitoring.grafana.port }} + targetPort: web + protocol: TCP + name: web + selector: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: monitoring +{{- end }} diff --git a/apps/base/osint-dashboard/templates/monitoring/prometheus-config.yaml b/apps/base/osint-dashboard/templates/monitoring/prometheus-config.yaml new file mode 100644 index 0000000..4345471 --- /dev/null +++ b/apps/base/osint-dashboard/templates/monitoring/prometheus-config.yaml @@ -0,0 +1,188 @@ +{{- if .Values.monitoring.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: prometheus-config + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: monitoring +data: + prometheus.yml: | + global: + scrape_interval: 15s + evaluation_interval: 15s + scrape_timeout: 10s + + rule_files: + - /etc/prometheus/rules/*.yml + + alerting: + alertmanagers: + - static_configs: + - targets: + - alertmanager:{{ .Values.monitoring.alertmanager.port }} + + scrape_configs: + # Prometheus self-monitoring + - job_name: prometheus + static_configs: + - targets: [localhost:9090] + + # PostgreSQL (Postgres Exporter) + - job_name: postgresql + static_configs: + - targets: + {{- range $i := until $.Values.monitoring.postgresql.exporter.replicas }} + - postgresql-{{ $i }}.postgresql-rw.{{ $.Values.namespace }}.svc:9187 + {{- end }} + + # NATS JetStream + - job_name: nats + static_configs: + - targets: + {{- range $i := until $.Values.nats.replicaCount }} + - nats-{{ $i }}.nats-cluster.{{ $.Values.namespace }}.svc:8222 + {{- end }} + + # Redis + - job_name: redis + static_configs: + - targets: + {{- range $i := until $.Values.redis.replicaCount }} + - redis-{{ $i }}.redis-cluster.{{ $.Values.namespace }}.svc:9121 + {{- end }} + + # MinIO + - job_name: minio + metrics_path: /minio/v2/metrics/cluster + static_configs: + - targets: + {{- range $i := until $.Values.minio.replicaCount }} + - minio-{{ $i }}.minio-headless.{{ $.Values.namespace }}.svc:9000 + {{- end }} + + # Kafka Exporter + - job_name: kafka + static_configs: + - targets: [kafka-exporter:9308] + + # Dashboard web app + - job_name: dashboard-web + static_configs: + - targets: [dashboard-web:3000] + + # Dashboard API + - job_name: dashboard-api + static_configs: + - targets: [dashboard-api:4000] + + # Auto-discover via pod annotations + - job_name: kubernetes-pods + kubernetes_sd_configs: + - role: pod + namespaces: + own: false + names: + - {{ .Values.namespace }} + relabel_configs: + - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape] + action: keep + regex: "true" + - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path] + action: replace + target_label: __metrics_path__ + regex: (.+) + - source_labels: + - __meta_kubernetes_pod_annotation_prometheus_io_port + - __meta_kubernetes_pod_ip + action: replace + regex: ([\d+]);([\d.]+) + replacement: $2:$1 + target_label: __address__ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: prometheus-rules + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: monitoring +data: + osint-alerts.yml: | + groups: + - name: osint-dashboard-alerts + rules: + - alert: HighErrorRate + expr: sum(rate(http_requests_total{status=~"5..",namespace="{{ .Values.namespace }}"}[5m])) / sum(rate(http_requests_total{namespace="{{ .Values.namespace }}"}[5m])) > 0.05 + for: 5m + labels: + severity: critical + annotations: + summary: "High error rate detected (>{{ 5 }}%) on {{ $labels.job }}" + + - alert: PodCrashLooping + expr: rate(kube_pod_container_status_restarts_total{namespace="{{ .Values.namespace }}"}[15m]) * 60 * 5 > 0 + for: 5m + labels: + severity: warning + annotations: + summary: "Pod {{ $labels.pod }} is crash looping" + + - alert: HighLatency + expr: histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{namespace="{{ .Values.namespace }}"}[5m])) by (le, job)) > 2 + for: 5m + labels: + severity: warning + annotations: + summary: "P95 latency above 2s for {{ $labels.job }}" + + - alert: DiskSpaceLow + expr: kubelet_volume_stats_available_bytes{namespace="{{ .Values.namespace }}"}/kubelet_volume_stats_capacity_bytes{namespace="{{ .Values.namespace }}"} < 0.1 + for: 10m + labels: + severity: critical + annotations: + summary: "Disk space below 10% on {{ $labels.persistentvolumeclaim }}" + + - alert: PostgreSQLConnectionSaturation + expr: pg_stat_activity_count{datname="osint",state="active"}/pg_settings_max_connections > 0.8 + for: 5m + labels: + severity: warning + annotations: + summary: "PostgreSQL connection pool >80% saturated" + + - alert: NATSJetStreamStoreFull + expr: jetstream_store_disk_bytes / jetstream_config_max_store_bytes > 0.85 + for: 5m + labels: + severity: critical + annotations: + summary: "NATS JetStream disk usage >85%" + + - alert: RedisMemoryHigh + expr: redis_memory_used_bytes / redis_memory_max_bytes > 0.9 + for: 5m + labels: + severity: warning + annotations: + summary: "Redis memory usage >90%" + + - alert: MinIOOffline + expr: up{job="minio"} == 0 + for: 2m + labels: + severity: critical + annotations: + summary: "MinIO node {{ $labels.instance }} is offline" + + - alert: KafkaLagHigh + expr: kafka_consumer_group_lag > 10000 + for: 10m + labels: + severity: warning + annotations: + summary: "Kafka consumer lag >10k messages for group {{ $labels.group }}" +{{- end }} diff --git a/apps/base/osint-dashboard/templates/monitoring/prometheus-deployment.yaml b/apps/base/osint-dashboard/templates/monitoring/prometheus-deployment.yaml new file mode 100644 index 0000000..495be11 --- /dev/null +++ b/apps/base/osint-dashboard/templates/monitoring/prometheus-deployment.yaml @@ -0,0 +1,127 @@ +{{- if .Values.monitoring.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: prometheus + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: monitoring +spec: + replicas: 1 + selector: + matchLabels: + {{- include "osint-dashboard.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: monitoring + template: + metadata: + labels: + {{- include "osint-dashboard.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: monitoring + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9090" + spec: + securityContext: + runAsNonRoot: true + runAsUser: 65534 # nobody + fsGroup: 65534 + serviceAccountName: prometheus + containers: + - name: prometheus + image: "{{ .Values.monitoring.prometheus.image.repository }}:{{ .Values.monitoring.prometheus.image.tag }}" + ports: + - containerPort: {{ .Values.monitoring.prometheus.port }} + name: web + args: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--storage.tsdb.retention.time={{ .Values.monitoring.prometheus.retention }}" + - "--storage.tsdb.retention.size={{ .Values.monitoring.prometheus.retentionSize }}" + - "--web.enable-lifecycle" + - "--web.enable-admin-api" + resources: + {{- toYaml .Values.monitoring.prometheus.resources | nindent 12 }} + volumeMounts: + - name: config + mountPath: /etc/prometheus + - name: rules + mountPath: /etc/prometheus/rules + - name: data + mountPath: /prometheus + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumes: + - name: config + configMap: + name: prometheus-config + - name: rules + configMap: + name: prometheus-rules + strategy: + type: Recreate +--- +apiVersion: v1 +kind: Service +metadata: + name: prometheus + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: monitoring + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9090" +spec: + type: ClusterIP + ports: + - port: {{ .Values.monitoring.prometheus.port }} + targetPort: web + protocol: TCP + name: web + selector: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: monitoring +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: prometheus + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: monitoring +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: prometheus + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: monitoring +rules: + - apiGroups: [""] + resources: ["pods", "services", "endpoints", "configmaps"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: prometheus + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: monitoring +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: prometheus +subjects: + - kind: ServiceAccount + name: prometheus + namespace: {{ .Values.namespace }} +{{- end }} diff --git a/apps/base/osint-dashboard/templates/namespace.yaml b/apps/base/osint-dashboard/templates/namespace.yaml new file mode 100644 index 0000000..fb13cb8 --- /dev/null +++ b/apps/base/osint-dashboard/templates/namespace.yaml @@ -0,0 +1,49 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.namespaceLabels" . | nindent 4 }} + app.kubernetes.io/part-of: osint-dashboard +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: osint-dashboard-role + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["pods", "services", "configmaps", "secrets", "persistentvolumeclaims"] + verbs: ["get", "list", "watch"] + - apiGroups: ["apps"] + resources: ["deployments", "statefulsets"] + verbs: ["get", "list", "watch"] + - apiGroups: ["batch"] + resources: ["jobs", "cronjobs"] + verbs: ["get", "list", "watch", "create", "update"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: osint-dashboard-rolebinding + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: osint-dashboard-role +subjects: + - kind: ServiceAccount + name: {{ include "osint-dashboard.serviceAccountName" . }} + namespace: {{ .Values.namespace }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "osint-dashboard.serviceAccountName" . }} + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} diff --git a/apps/base/osint-dashboard/templates/nats/jetstream-subjects.yaml b/apps/base/osint-dashboard/templates/nats/jetstream-subjects.yaml new file mode 100644 index 0000000..780eceb --- /dev/null +++ b/apps/base/osint-dashboard/templates/nats/jetstream-subjects.yaml @@ -0,0 +1,88 @@ +{{- if .Values.nats.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: nats-jetstream-init + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: message-broker + annotations: + "helm.sh/hook": post-install,post-upgrade + "helm.sh/hook-delete-policy": hook-succeeded +spec: + template: + metadata: + labels: + {{- include "osint-dashboard.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: message-broker + spec: + restartPolicy: OnFailure + securityContext: + runAsNonRoot: true + runAsUser: 1000 + containers: + - name: nats-box + image: "{{ .Values.nats.image.repository }}:{{ .Values.nats.image.tag }}" + command: + - /bin/sh + - -c + args: + - | + # Wait for NATS to be ready + until nats-server -version > /dev/null 2>&1 || curl -sf http://nats:{{ .Values.nats.ports.monitor }}/healthz; do + echo "Waiting for NATS..." + sleep 2 + done + + # Download nats CLI + curl -fsSL https://github.com/nats-io/natscli/releases/latest/download/nats-linux-amd64 -o /tmp/nats + chmod +x /tmp/nats + + # Create JetStream stream for events + /tmp/nats stream add events \ + --subjects=events.gdelt,events.rss,events.social,events.earthquake,events.disaster,events.weather,events.fire,events.satellite,events.new,events.alert \ + --retention=interests \ + --max-consumers=-1 \ + --max-msgs=1000000 \ + --max-bytes=1GB \ + --discard=old \ + --storage=file \ + --replicas=3 \ + --server=nats://nats:{{ .Values.nats.ports.client }} || echo "events stream already exists" + + # Create JetStream stream for alerts + /tmp/nats stream add alerts \ + --subjects=alerts.camera_offline,alerts.new \ + --retention=interests \ + --max-consumers=-1 \ + --max-msgs=100000 \ + --discard=old \ + --storage=file \ + --replicas=3 \ + --server=nats://nats:{{ .Values.nats.ports.client }} || echo "alerts stream already exists" + + # Create JetStream stream for video + /tmp/nats stream add video \ + --subjects="video.status.>","video.record.>" \ + --retention=limits \ + --max-consumers=-1 \ + --max-msgs=50000 \ + --discard=old \ + --storage=file \ + --replicas=3 \ + --server=nats://nats:{{ .Values.nats.ports.client }} || echo "video stream already exists" + + echo "JetStream subjects initialized successfully" + resources: + requests: + cpu: "100m" + memory: "64Mi" + limits: + cpu: "500m" + memory: "256Mi" + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] +{{- end }} diff --git a/apps/base/osint-dashboard/templates/nats/service.yaml b/apps/base/osint-dashboard/templates/nats/service.yaml new file mode 100644 index 0000000..7e4a294 --- /dev/null +++ b/apps/base/osint-dashboard/templates/nats/service.yaml @@ -0,0 +1,60 @@ +{{- if .Values.nats.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: nats + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: message-broker +spec: + type: ClusterIP + ports: + - port: {{ .Values.nats.ports.client }} + targetPort: {{ .Values.nats.ports.client }} + protocol: TCP + name: client + selector: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: message-broker +--- +apiVersion: v1 +kind: Service +metadata: + name: nats-ws + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: message-broker +spec: + type: ClusterIP + ports: + - port: {{ .Values.nats.ports.websocket }} + targetPort: {{ .Values.nats.ports.websocket }} + protocol: TCP + name: websocket + selector: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: message-broker +--- +apiVersion: v1 +kind: Service +metadata: + name: nats-cluster + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: message-broker + annotations: + service.alpha.kubernetes.io/tolerate-unready-endpoints: "true" +spec: + clusterIP: None + ports: + - port: {{ .Values.nats.ports.cluster }} + targetPort: {{ .Values.nats.ports.cluster }} + protocol: TCP + name: cluster + selector: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: message-broker +{{- end }} diff --git a/apps/base/osint-dashboard/templates/nats/statefulset.yaml b/apps/base/osint-dashboard/templates/nats/statefulset.yaml new file mode 100644 index 0000000..74cbb85 --- /dev/null +++ b/apps/base/osint-dashboard/templates/nats/statefulset.yaml @@ -0,0 +1,130 @@ +{{- if .Values.nats.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: nats-config + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} +data: + nats-server.conf: | + port: {{ .Values.nats.ports.client }} + server_name: "osint-nats-${HOSTNAME}" + + # Cluster + cluster { + port: {{ .Values.nats.ports.cluster }} + routes: [ + nats-route://nats-0.nats-cluster.{{ .Values.namespace }}.svc:{{ .Values.nats.ports.cluster }}, + nats-route://nats-1.nats-cluster.{{ .Values.namespace }}.svc:{{ .Values.nats.ports.cluster }}, + nats-route://nats-2.nats-cluster.{{ .Values.namespace }}.svc:{{ .Values.nats.ports.cluster }} + ] + cluster_advertise: "nats-${HOSTNAME}.nats-cluster.{{ .Values.namespace }}.svc:{{ .Values.nats.ports.cluster }}" + } + + # JetStream + jetstream { + store_dir: "{{ .Values.nats.jetstream.fileStore }}" + max_mem_store: {{ .Values.nats.jetstream.maxMemory }} + max_file_store: {{ .Values.nats.storage.size }} + } + + # Monitoring + monitor: {{ .Values.nats.ports.monitor }} + + # WebSocket + websocket { + port: {{ .Values.nats.ports.websocket }} + no_tls: true + } + + # Logging + logtime: true + log_file: /var/log/nats/nats.log +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: nats + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: message-broker +spec: + serviceName: nats-cluster + replicas: {{ .Values.nats.replicaCount }} + selector: + matchLabels: + {{- include "osint-dashboard.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: message-broker + template: + metadata: + labels: + {{- include "osint-dashboard.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: message-broker + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "{{ .Values.nats.ports.monitor }}" + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + terminationGracePeriodSeconds: 30 + containers: + - name: nats + image: "{{ .Values.nats.image.repository }}:{{ .Values.nats.image.tag }}" + ports: + - name: client + containerPort: {{ .Values.nats.ports.client }} + - name: cluster + containerPort: {{ .Values.nats.ports.cluster }} + - name: monitor + containerPort: {{ .Values.nats.ports.monitor }} + - name: websocket + containerPort: {{ .Values.nats.ports.websocket }} + resources: + {{- toYaml .Values.nats.resources | nindent 12 }} + volumeMounts: + - name: config + mountPath: /etc/nats-config + - name: data + mountPath: {{ .Values.nats.jetstream.fileStore }} + - name: logs + mountPath: /var/log/nats + args: + - "-c" + - "/etc/nats-config/nats-server.conf" + readinessProbe: + httpGet: + path: /healthz + port: monitor + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /healthz + port: monitor + initialDelaySeconds: 10 + periodSeconds: 10 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumes: + - name: config + configMap: + name: nats-config + - name: logs + emptyDir: {} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: {{ .Values.nats.storage.size }} + storageClassName: {{ .Values.nats.storage.storageClass }} +{{- end }} diff --git a/apps/base/osint-dashboard/templates/postgresql/cluster.yaml b/apps/base/osint-dashboard/templates/postgresql/cluster.yaml new file mode 100644 index 0000000..40a629b --- /dev/null +++ b/apps/base/osint-dashboard/templates/postgresql/cluster.yaml @@ -0,0 +1,152 @@ +{{- if .Values.postgresql.enabled }} +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: {{ .Values.postgresql.clusterName }} + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} +spec: + instances: {{ .Values.postgresql.instances }} + imageName: {{ .Values.postgresql.imageName }} + storage: + size: {{ .Values.postgresql.storage.size }} + storageClass: {{ .Values.postgresql.storage.storageClass }} + resources: + {{- toYaml .Values.postgresql.resources | nindent 4 }} + # PostGIS + TimescaleDB extensions via shared_preload_libraries + postgresql: + shared_preload_libraries: + - pggis + - timescaledb + parameters: + max_connections: "500" + shared_buffers: "2GB" + effective_cache_size: "6GB" + maintenance_work_mem: "512MB" + work_mem: "16MB" + wal_buffers: "64MB" + random_page_cost: "1.1" + effective_io_concurrency: "200" + default_statistics_target: "200" + max_parallel_workers_per_gather: "4" + bootstrap: + initdb: + database: osint + owner: osint_admin + secret: + name: {{ .Values.postgresql.credentialsSecret }} + postInitializationSQL: + # Install PostGIS extension + - >- + CREATE EXTENSION IF NOT EXISTS postgis; + - >- + CREATE EXTENSION IF NOT EXISTS postgis_raster; + - >- + CREATE EXTENSION IF NOT EXISTS postgis_topology; + # Install TimescaleDB extension + - >- + CREATE EXTENSION IF NOT EXISTS timescaledb; + # Create hypertable for events + - >- + CREATE TABLE IF NOT EXISTS events ( + time TIMESTAMPTZ NOT NULL, + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source TEXT NOT NULL, + event_type TEXT, + title TEXT, + description TEXT, + location GEOGRAPHY(POINT, 4326), + severity INT DEFAULT 0, + tags TEXT[], + raw_data JSONB, + created_at TIMESTAMPTZ DEFAULT NOW() + ); + - >- + SELECT create_hypertable('events', 'time', if_not_exists => TRUE); + - >- + CREATE INDEX IF NOT EXISTS events_loc_idx ON events USING GIST (location); + - >- + CREATE INDEX IF NOT EXISTS events_time_idx ON events (time DESC); + # Create sources reference table + - >- + CREATE TABLE IF NOT EXISTS sources ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + type TEXT NOT NULL, + config JSONB, + active BOOLEAN DEFAULT TRUE + ); + # Create video_feeds table + - >- + CREATE TABLE IF NOT EXISTS video_feeds ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + rtsp_url TEXT NOT NULL, + zlm_stream_key TEXT NOT NULL, + location GEOGRAPHY(POINT, 4326), + active BOOLEAN DEFAULT TRUE, + last_heartbeat TIMESTAMPTZ + ); + managed: + roles: + - name: osint_admin + ensure: present + login: true + passwordSecret: + name: {{ .Values.postgresql.credentialsSecret }} + - name: osint_reader + ensure: present + login: true + passwordSecret: + name: osint-pg-reader-credentials + backup: + barmanObjectStore: + destinationPath: "{{ .Values.postgresql.backup.bucket }}" + googleCredentials: + gkeEnvironment: true + wal: + compression: gzip + data: + compression: gzip + jobs: 2 + retentionPolicy: {{ .Values.postgresql.backup.retentionPolicy }} + target: primary + monitoring: + customQueries: + - query: >- + SELECT relname, schemaname, seq_scan, seq_tup_read, idx_scan, idx_tup_fetch + FROM pg_stat_user_tables WHERE schemaname = 'public'; + metrics: + - relname: + usage: "LABEL" + description: "Table name" + - schemaname: + usage: "LABEL" + description: "Schema name" + - seq_scan: + usage: "GAUGE" + description: "Number of sequential scans" + - seq_tup_read: + usage: "GAUGE" + description: "Number of tuples read" + - idx_scan: + usage: "GAUGE" + description: "Number of index scans" + - idx_tup_fetch: + usage: "GAUGE" + description: "Number of tuples fetched via index" + affinity: + enablePodAntiAffinity: true + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: DoNotSchedule + labelSelector: + matchLabels: + postgresql operator: {{ .Values.postgresql.clusterName }} + nodesAutoRemediationChecks: + livenessProbe: + initialDelaySeconds: 10 + timeoutSeconds: 5 +{{- end }} diff --git a/apps/base/osint-dashboard/templates/postgresql/credentials-secret.yaml b/apps/base/osint-dashboard/templates/postgresql/credentials-secret.yaml new file mode 100644 index 0000000..30188a6 --- /dev/null +++ b/apps/base/osint-dashboard/templates/postgresql/credentials-secret.yaml @@ -0,0 +1,29 @@ +{{- if .Values.postgresql.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Values.postgresql.credentialsSecret }} + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + annotations: + # SOPS will encrypt this - use 'sops --encrypt --in-place' after generation +type: Opaque +stringData: + username: osint_admin + password: CHANGE_ME_USE_SOPS + connection_string: "postgresql://osint_admin:CHANGE_ME_USE_SOPS@{{ .Values.postgresql.clusterName }}.{{ .Values.namespace }}.svc:5432/osint?sslmode=require" +--- +apiVersion: v1 +kind: Secret +metadata: + name: osint-pg-reader-credentials + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} +type: Opaque +stringData: + username: osint_reader + password: CHANGE_ME_USE_SOPS + connection_string: "postgresql://osint_reader:CHANGE_ME_USE_SOPS@{{ .Values.postgresql.clusterName }}.{{ .Values.namespace }}.svc:5432/osint?sslmode=require" +{{- end }} diff --git a/apps/base/osint-dashboard/templates/postgresql/service.yaml b/apps/base/osint-dashboard/templates/postgresql/service.yaml new file mode 100644 index 0000000..36e4653 --- /dev/null +++ b/apps/base/osint-dashboard/templates/postgresql/service.yaml @@ -0,0 +1,18 @@ +{{- if .Values.postgresql.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.postgresql.clusterName }} + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} +spec: + type: ClusterIP + ports: + - port: 5432 + targetPort: postgresql + protocol: TCP + name: postgresql + selector: + postgresql operator: {{ .Values.postgresql.clusterName }} +{{- end }} diff --git a/apps/base/osint-dashboard/templates/redis/configmap.yaml b/apps/base/osint-dashboard/templates/redis/configmap.yaml new file mode 100644 index 0000000..a2edf74 --- /dev/null +++ b/apps/base/osint-dashboard/templates/redis/configmap.yaml @@ -0,0 +1,70 @@ +{{- if .Values.redis.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: redis-config + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} +data: + redis.conf: | + bind 0.0.0.0 + port {{ .Values.redis.ports.redis }} + appendonly yes + appendfilename "appendonly.aof" + dir /data + save 900 1 + save 300 10 + save 60 10000 + maxmemory-policy allkeys-lru + # Require authentication + requirepass CHANGE_ME_USE_SOPS + masterauth CHANGE_ME_USE_SOPS +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: redis-sentinel-config + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} +data: + sentinel.conf: | + port {{ .Values.redis.ports.sentinel }} + sentinel monitor osint-redis-master redis-master.{{ .Values.namespace }}.svc {{ .Values.redis.ports.redis }} 2 + sentinel auth-pass osint-redis-master CHANGE_ME_USE_SOPS + sentinel down-after-milliseconds osint-redis-master 5000 + sentinel failover-timeout osint-redis-master 30000 + sentinel parallel-syncs osint-redis-master 1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: redis-scripts + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} +data: + init-sentinel.sh: | + #!/bin/bash + set -e + + # Copy sentinel config and update it with current master info + cp /etc/redis-sentinel/sentinel.conf /tmp/sentinel.conf + # Sentinel will auto-discover master from other sentinels + exec redis-sentinel /tmp/sentinel.conf --loglevel notice + redis.sh: | + #!/bin/bash + set -e + + REDIS_PORT={{ .Values.redis.ports.redis }} + REDIS_PASSWORD="CHANGE_ME_USE_SOPS" + + if [ "${REDIS_ROLE}" = "master" ]; then + exec redis-server /etc/redis/redis.conf + else + # Replica: find master and replicate + MASTER_HOST="redis-master.{{ .Values.namespace }}.svc" + exec redis-server /etc/redis/redis.conf --replicaof ${MASTER_HOST} ${REDIS_PORT} + fi +{{- end }} diff --git a/apps/base/osint-dashboard/templates/redis/service.yaml b/apps/base/osint-dashboard/templates/redis/service.yaml new file mode 100644 index 0000000..14da958 --- /dev/null +++ b/apps/base/osint-dashboard/templates/redis/service.yaml @@ -0,0 +1,61 @@ +{{- if .Values.redis.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: redis-master + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + type: ClusterIP + ports: + - port: {{ .Values.redis.ports.redis }} + targetPort: {{ .Values.redis.ports.redis }} + protocol: TCP + name: redis + selector: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: redis + redis-role: master +--- +apiVersion: v1 +kind: Service +metadata: + name: redis-replica + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + type: ClusterIP + ports: + - port: {{ .Values.redis.ports.redis }} + targetPort: {{ .Values.redis.ports.redis }} + protocol: TCP + name: redis + selector: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: redis + redis-role: replica +--- +apiVersion: v1 +kind: Service +metadata: + name: redis-sentinel + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + type: ClusterIP + ports: + - port: {{ .Values.redis.ports.sentinel }} + targetPort: {{ .Values.redis.ports.sentinel }} + protocol: TCP + name: sentinel + selector: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: redis + redis-role: sentinel +{{- end }} diff --git a/apps/base/osint-dashboard/templates/redis/statefulset.yaml b/apps/base/osint-dashboard/templates/redis/statefulset.yaml new file mode 100644 index 0000000..a2804ea --- /dev/null +++ b/apps/base/osint-dashboard/templates/redis/statefulset.yaml @@ -0,0 +1,210 @@ +{{- if .Values.redis.enabled }} +# Redis Master +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: redis-master + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: redis + redis-role: master +spec: + serviceName: redis-master + replicas: 1 + selector: + matchLabels: + {{- include "osint-dashboard.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: redis + redis-role: master + template: + metadata: + labels: + {{- include "osint-dashboard.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: redis + redis-role: master + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9121" + spec: + securityContext: + runAsNonRoot: true + runAsUser: 999 + fsGroup: 999 + containers: + - name: redis + image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}" + ports: + - name: redis + containerPort: {{ .Values.redis.ports.redis }} + resources: + {{- toYaml .Values.redis.master.resources | nindent 12 }} + volumeMounts: + - name: config + mountPath: /etc/redis + - name: data + mountPath: /data + args: ["redis-server", "/etc/redis/redis.conf"] + readinessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 10 + periodSeconds: 10 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + # Sentinel sidecar on master + - name: sentinel + image: "{{ .Values.redis.sentinel.image.repository }}:{{ .Values.redis.sentinel.image.tag }}" + ports: + - name: sentinel + containerPort: {{ .Values.redis.ports.sentinel }} + resources: + {{- toYaml .Values.redis.sentinel.resources | nindent 12 }} + volumeMounts: + - name: sentinel-config + mountPath: /etc/redis-sentinel + command: ["/bin/bash", "/etc/redis-scripts/init-sentinel.sh"] + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumes: + - name: config + configMap: + name: redis-config + - name: sentinel-config + configMap: + name: redis-sentinel-config + - name: scripts + configMap: + name: redis-scripts + defaultMode: 0755 + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: {{ .Values.redis.master.storage.size }} + storageClassName: {{ .Values.redis.master.storage.storageClass }} +--- +# Redis Replicas +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: redis-replica + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: redis + redis-role: replica +spec: + serviceName: redis-replica + replicas: {{ .Values.redis.replica.replicaCount }} + selector: + matchLabels: + {{- include "osint-dashboard.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: redis + redis-role: replica + template: + metadata: + labels: + {{- include "osint-dashboard.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: redis + redis-role: replica + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9121" + spec: + securityContext: + runAsNonRoot: true + runAsUser: 999 + fsGroup: 999 + containers: + - name: redis + image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}" + ports: + - name: redis + containerPort: {{ .Values.redis.ports.redis }} + resources: + {{- toYaml .Values.redis.replica.resources | nindent 12 }} + volumeMounts: + - name: config + mountPath: /etc/redis + - name: data + mountPath: /data + env: + - name: REDIS_ROLE + value: "replica" + command: ["/bin/bash", "/etc/redis-scripts/redis.sh"] + readinessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 10 + periodSeconds: 10 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + # Sentinel sidecar on replicas + - name: sentinel + image: "{{ .Values.redis.sentinel.image.repository }}:{{ .Values.redis.sentinel.image.tag }}" + ports: + - name: sentinel + containerPort: {{ .Values.redis.ports.sentinel }} + resources: + {{- toYaml .Values.redis.sentinel.resources | nindent 12 }} + volumeMounts: + - name: sentinel-config + mountPath: /etc/redis-sentinel + command: ["/bin/bash", "/etc/redis-scripts/init-sentinel.sh"] + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumes: + - name: config + configMap: + name: redis-config + - name: sentinel-config + configMap: + name: redis-sentinel-config + - name: scripts + configMap: + name: redis-scripts + defaultMode: 0755 + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: {{ .Values.redis.replica.storage.size }} + storageClassName: {{ .Values.redis.replica.storage.storageClass }} +{{- end }} diff --git a/apps/base/osint-dashboard/templates/security/dashboard-netpol.yaml b/apps/base/osint-dashboard/templates/security/dashboard-netpol.yaml new file mode 100644 index 0000000..d756763 --- /dev/null +++ b/apps/base/osint-dashboard/templates/security/dashboard-netpol.yaml @@ -0,0 +1,145 @@ +{{- if .Values.networkPolicies.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: dashboard-web-netpol + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: security +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: web + policyTypes: + - Ingress + - Egress + ingress: + # Allow from ingress controller / Gateway API + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + ports: + - protocol: TCP + port: 3000 + egress: + # Allow DNS + - to: + - namespaceSelector: {} + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + # Allow to API + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: api + ports: + - protocol: TCP + port: 4000 + # Allow to external APIs (GDelt, satellite providers) + - to: + - ipBlock: + cidr: 0.0.0.0/0 + ports: + - protocol: TCP + port: 443 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: dashboard-api-netpol + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: security +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: api + policyTypes: + - Ingress + - Egress + ingress: + # Allow from web frontend + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: web + ports: + - protocol: TCP + port: 4000 + # Allow from ingress controller / Gateway API + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + ports: + - protocol: TCP + port: 4000 + egress: + # Allow DNS + - to: + - namespaceSelector: {} + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + # Allow to PostgreSQL + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: database + ports: + - protocol: TCP + port: 5432 + # Allow to Redis + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: cache + ports: + - protocol: TCP + port: {{ .Values.redis.ports.redis }} + # Allow to NATS + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: messaging + ports: + - protocol: TCP + port: {{ .Values.nats.ports.client }} + # Allow to MinIO + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: object-storage + ports: + - protocol: TCP + port: {{ .Values.minio.ports.api }} + # Allow to external APIs (GDelt, etc.) + - to: + - ipBlock: + cidr: 0.0.0.0/0 + ports: + - protocol: TCP + port: 443 +{{- end }} diff --git a/apps/base/osint-dashboard/templates/security/default-deny.yaml b/apps/base/osint-dashboard/templates/security/default-deny.yaml new file mode 100644 index 0000000..9db14dd --- /dev/null +++ b/apps/base/osint-dashboard/templates/security/default-deny.yaml @@ -0,0 +1,17 @@ +{{- if .Values.networkPolicies.enabled }} +{{- if .Values.networkPolicies.defaultDeny }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny-all + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: security +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress +{{- end }} +{{- end }} diff --git a/apps/base/osint-dashboard/templates/security/minio-netpol.yaml b/apps/base/osint-dashboard/templates/security/minio-netpol.yaml new file mode 100644 index 0000000..fe57721 --- /dev/null +++ b/apps/base/osint-dashboard/templates/security/minio-netpol.yaml @@ -0,0 +1,56 @@ +{{- if .Values.networkPolicies.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: minio-netpol + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: security +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: object-storage + policyTypes: + - Ingress + - Egress + ingress: + # Allow from dashboard services + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + ports: + - protocol: TCP + port: {{ .Values.minio.ports.api }} + # Allow console access (internal) + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + ports: + - protocol: TCP + port: {{ .Values.minio.ports.console }} + egress: + # Allow DNS + - to: + - namespaceSelector: {} + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + # Allow to other MinIO pods (distributed replication) + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: object-storage + ports: + - protocol: TCP + port: {{ .Values.minio.ports.api }} +{{- end }} diff --git a/apps/base/osint-dashboard/templates/security/monitoring-netpol.yaml b/apps/base/osint-dashboard/templates/security/monitoring-netpol.yaml new file mode 100644 index 0000000..fa24dd3 --- /dev/null +++ b/apps/base/osint-dashboard/templates/security/monitoring-netpol.yaml @@ -0,0 +1,60 @@ +{{- if .Values.networkPolicies.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: monitoring-netpol + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: security +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: monitoring + policyTypes: + - Ingress + - Egress + ingress: + # Allow Grafana web access + - from: [] + ports: + - protocol: TCP + port: {{ .Values.monitoring.grafana.port }} + - protocol: TCP + port: {{ .Values.monitoring.prometheus.port }} + - protocol: TCP + port: {{ .Values.monitoring.alertmanager.port }} + egress: + # Allow DNS + - to: + - namespaceSelector: {} + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + # Allow scraping all OSINT pods + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + ports: + - protocol: TCP + port: 9090 + - protocol: TCP + port: 9093 + - protocol: TCP + port: 3000 + - protocol: TCP + port: 9187 # postgres-exporter + - protocol: TCP + port: 9121 # redis-exporter + - protocol: TCP + port: 8222 # nats-monitor + - protocol: TCP + port: 9000 # minio +{{- end }} diff --git a/apps/base/osint-dashboard/templates/security/nats-netpol.yaml b/apps/base/osint-dashboard/templates/security/nats-netpol.yaml new file mode 100644 index 0000000..e45687f --- /dev/null +++ b/apps/base/osint-dashboard/templates/security/nats-netpol.yaml @@ -0,0 +1,67 @@ +{{- if .Values.networkPolicies.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: nats-netpol + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: security +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: messaging + policyTypes: + - Ingress + - Egress + ingress: + # Allow client connections from dashboard services + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + ports: + - protocol: TCP + port: {{ .Values.nats.ports.client }} + # Allow cluster communication + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: messaging + ports: + - protocol: TCP + port: {{ .Values.nats.ports.cluster }} + # Allow monitoring + - from: + - podSelector: + matchLabels: + app.kubernetes.io/component: monitoring + ports: + - protocol: TCP + port: {{ .Values.nats.ports.monitor }} + egress: + # Allow DNS + - to: + - namespaceSelector: {} + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + # Allow cluster communication + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: messaging + ports: + - protocol: TCP + port: {{ .Values.nats.ports.cluster }} + - protocol: TCP + port: {{ .Values.nats.ports.client }} +{{- end }} diff --git a/apps/base/osint-dashboard/templates/security/postgresql-netpol.yaml b/apps/base/osint-dashboard/templates/security/postgresql-netpol.yaml new file mode 100644 index 0000000..8735fe2 --- /dev/null +++ b/apps/base/osint-dashboard/templates/security/postgresql-netpol.yaml @@ -0,0 +1,60 @@ +{{- if .Values.networkPolicies.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: postgresql-netpol + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: security +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: database + policyTypes: + - Ingress + - Egress + ingress: + # Allow from dashboard API and workers + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + - podSelector: + matchLabels: + app.kubernetes.io/component: prometheus-exporter + ports: + - protocol: TCP + port: 5432 + # Allow from CNPG cluster peers (replication) + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: database + ports: + - protocol: TCP + port: 5432 + egress: + # Allow DNS + - to: + - namespaceSelector: {} + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + # Allow to other PG replicas (replication) + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: database + ports: + - protocol: TCP + port: 5432 +{{- end }} diff --git a/apps/base/osint-dashboard/templates/security/redis-netpol.yaml b/apps/base/osint-dashboard/templates/security/redis-netpol.yaml new file mode 100644 index 0000000..53ec5c9 --- /dev/null +++ b/apps/base/osint-dashboard/templates/security/redis-netpol.yaml @@ -0,0 +1,58 @@ +{{- if .Values.networkPolicies.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: redis-netpol + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: security +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: cache + policyTypes: + - Ingress + - Egress + ingress: + # Allow from dashboard services + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + ports: + - protocol: TCP + port: {{ .Values.redis.ports.redis }} + # Allow sentinel from dashboard + other sentinels + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + ports: + - protocol: TCP + port: {{ .Values.redis.ports.sentinel }} + egress: + # Allow DNS + - to: + - namespaceSelector: {} + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + # Allow to other Redis pods (replication) + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: osint-dashboard + app.kubernetes.io/component: cache + ports: + - protocol: TCP + port: {{ .Values.redis.ports.redis }} + - protocol: TCP + port: {{ .Values.redis.ports.sentinel }} +{{- end }} diff --git a/apps/base/osint-dashboard/values.yaml b/apps/base/osint-dashboard/values.yaml new file mode 100644 index 0000000..6752fed --- /dev/null +++ b/apps/base/osint-dashboard/values.yaml @@ -0,0 +1,360 @@ +# OSINT Dashboard — Helm Values +# Default values for development/staging. Override with values-prod.yaml for production. + +nameOverride: "" +fullnameOverride: "" + +# Namespace +namespace: customer1 + +# Global security context +securityContext: + runAsNonRoot: true + fsGroup: 1000 + +# Resource defaults +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + +# ============================================================ +# PostgreSQL (using CNPG — CloudNativePG, already installed) +# ============================================================ +postgresql: + enabled: true + clusterName: osint-pgdb + instances: 3 + imageName: ghcr.io/cloudnative-pg/postgresql:16 + # Custom image with PostGIS + TimescaleDB + # Use CNPG bootstrap to create extensions + storage: + size: 200Gi + storageClass: premium-rwo + resources: + requests: + cpu: "2" + memory: 4Gi + limits: + cpu: "4" + memory: 8Gi + extensions: + - postgis + - timescaledb + backup: + retentionPolicy: "30d" + bucket: "gs://osint-dashboard-db-backup/" + credentialsSecret: osint-pg-credentials + +# ============================================================ +# NATS JetStream (3 replicas, persistent) +# ============================================================ +nats: + enabled: true + replicaCount: 3 + image: + repository: nats + tag: "2.10.18-alpine" + resources: + requests: + cpu: "500m" + memory: 512Mi + limits: + cpu: "1" + memory: 1Gi + storage: + size: 50Gi + storageClass: premium-rwo + jetstream: + enabled: true + maxMemory: 2Gi + fileStore: /data/jetstream + ports: + client: 4222 + cluster: 6222 + monitor: 8222 + websocket: 8080 + # JetStream subjects schema + subjects: + streams: + - name: events + subjects: + - events.gdelt + - events.rss + - events.social + - events.earthquake + - events.disaster + - events.weather + - events.fire + - events.satellite + - events.new + - events.alert + retention: interests + maxConsumers: -1 + maxMsgs: 1000000 + maxBytes: 1073741824 # 1GB + discard: old + - name: alerts + subjects: + - alerts.camera_offline + - alerts.new + retention: interests + maxConsumers: -1 + maxMsgs: 100000 + discard: old + - name: video + subjects: + - "video.status.>" + - "video.record.>" + retention: limits + maxConsumers: -1 + maxMsgs: 50000 + discard: old + +# ============================================================ +# Redis Sentinel (1 primary + 2 replicas) +# ============================================================ +redis: + enabled: true + image: + repository: redis + tag: "7.4-alpine" + sentinel: + image: + repository: redis + tag: "7.4-alpine" + master: + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: 512Mi + limits: + cpu: "1" + memory: 1Gi + storage: + size: 20Gi + storageClass: premium-rwo + replica: + replicaCount: 2 + resources: + requests: + cpu: "250m" + memory: 256Mi + limits: + cpu: "500m" + memory: 512Mi + storage: + size: 20Gi + storageClass: premium-rwo + sentinel: + replicaCount: 3 + resources: + requests: + cpu: "100m" + memory: 128Mi + limits: + cpu: "250m" + memory: 256Mi + ports: + redis: 6379 + sentinel: 26379 + +# ============================================================ +# MinIO (4 replicas, distributed mode) +# ============================================================ +minio: + enabled: true + image: + repository: quay.io/minio/minio + tag: "latest" + replicaCount: 4 + mode: distributed + resources: + requests: + cpu: "500m" + memory: 1Gi + limits: + cpu: "1" + memory: 2Gi + storage: + size: 500Gi + storageClass: premium-rwo + buckets: + - name: osint-video-clips + policy: none + - name: osint-satellite-tiles + policy: none + - name: osint-data-dumps + policy: none + credentialsSecret: osint-minio-credentials + ports: + api: 9000 + console: 9001 + +# ============================================================ +# NGINX Ingress Controller + cert-manager +# ============================================================ +ingress: + enabled: true + # Use existing cert-manager cluster issuer + certManager: + enabled: true + clusterIssuerName: letsencrypt-prod + hosts: + - host: dashboard.siriusdevops.com + paths: + - path: / + pathType: Prefix + - host: api.siriusdevops.com + paths: + - path: / + pathType: Prefix + - host: ws.siriusdevops.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: osint-dashboard-tls + hosts: + - dashboard.siriusdevops.com + - secretName: osint-api-tls + hosts: + - api.siriusdevops.com + annotations: + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-body-size: "50m" + # WebSocket support + nginx.ingress.kubernetes.io/use-regex: "true" + +# ============================================================ +# Monitoring (Prometheus + Grafana + Alertmanager) +# ============================================================ +monitoring: + enabled: true + defaultEmailDomain: siriusdevops.com + + prometheus: + image: + repository: prom/prometheus + tag: "v2.53.0" + port: 9090 + retention: "30d" + retentionSize: "50GB" + resources: + requests: + cpu: "500m" + memory: 2Gi + limits: + cpu: "1" + memory: 4Gi + + alertmanager: + image: + repository: prom/alertmanager + tag: "v0.27.0" + port: 9093 + resources: + requests: + cpu: "100m" + memory: 128Mi + limits: + cpu: "250m" + memory: 256Mi + + grafana: + image: + repository: grafana/grafana + tag: "11.2.0" + port: 3000 + hostname: grafana.siriusdevops.com + resources: + requests: + cpu: "200m" + memory: 256Mi + limits: + cpu: "500m" + memory: 512Mi + + exporters: + nats: + enabled: true + image: + repository: natsio/prometheus-nats-exporter + tag: "0.14.0" + postgresql: + enabled: true + image: + repository: prometheuscommunity/postgres-exporter + tag: "0.15.0" + exporter: + replicas: 3 + redis: + enabled: true + image: + repository: oliver006/redis_exporter + tag: "v1.58.0" + minio: + enabled: true + # MinIO has built-in metrics at /minio/v2/metrics/cluster + + grafanaDashboards: + - osint-overview + - nats-jetstream + - postgresql-performance + - redis-sentinel + - minio-storage + +# ============================================================ +# CI/CD +# ============================================================ +cicd: + enabled: true + registry: gcr.io/devops-lab-cluster + previewEnvironments: true + +# ============================================================ +# Network Policies +# ============================================================ +networkPolicies: + enabled: true + # Default deny all ingress/egress, then allow specific traffic + defaultDeny: true + +# ============================================================ +# Frontend (placeholder — T3 will fill this in) +# ============================================================ +frontend: + enabled: false + replicaCount: 3 + image: + repository: gcr.io/devops-lab-cluster/osint-dashboard-web + tag: latest + resources: + requests: + cpu: "100m" + memory: 128Mi + limits: + cpu: "500m" + memory: 512Mi + +# ============================================================ +# API (placeholder — T2 will fill this in) +# ============================================================ +api: + enabled: false + replicaCount: 3 + image: + repository: gcr.io/devops-lab-cluster/osint-dashboard-api + tag: latest + resources: + requests: + cpu: "200m" + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi diff --git a/apps/staging/osint-dashboard/kustomization.yaml b/apps/staging/osint-dashboard/kustomization.yaml new file mode 100644 index 0000000..3a38b16 --- /dev/null +++ b/apps/staging/osint-dashboard/kustomization.yaml @@ -0,0 +1,53 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# Reference the Helm chart base +helmCharts: + - name: osint-dashboard + repository: + name: osint-dashboard-local + type: oci + url: oci://gcr.io/devops-lab-cluster/charts + version: "0.1.0" + releaseName: osint-dashboard + namespace: customer1 + includeCRDs: true + +# Or use plain Kustomize overlay on the base templates +resources: + - ../../base/osint-dashboard/templates/namespace.yaml + +# Namespace override +namespace: customer1 + +# Common labels +commonLabels: + app.kubernetes.io/managed-by: flux + app.kubernetes.io/part-of: osint-dashboard + +# Patches for staging environment +patches: + # Override replica counts for staging + - patch: |- + apiVersion: apps/v1 + kind: StatefulSet + metadata: + name: postgresql + spec: + replicas: 2 + target: + kind: StatefulSet + name: postgresql.* + + # Reduce storage for staging + - patch: |- + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: data + spec: + resources: + requests: + storage: 100Gi + target: + kind: PersistentVolumeClaim From 93754dcdf6979cfc4db0bc5be2c477bfc8748efb Mon Sep 17 00:00:00 2001 From: Sirius Devops Date: Thu, 21 May 2026 13:39:52 +0000 Subject: [PATCH 2/2] feat: OSINT Dashboard app + Helm templates Add FastAPI backend for real-time geospatial OSINT dashboard: - Full-text search via PostgreSQL tsvector (parameterized queries) - Entity tracking, alert management, sentiment analytics - Data ingestion: RSS feeds, GDELT, USGS earthquakes, social signals - NATS JetStream consumer for event ingestion - MinIO document storage integration - Redis caching layer - Alembic migrations with PostGIS + TimescaleDB extensions - Single-page dashboard UI with live polling - OpenTelemetry distributed tracing Helm chart with infrastructure: - CNPG PostgreSQL cluster (PostGIS + TimescaleDB) - NATS JetStream with persistent streams - MinIO distributed object storage (3 buckets) - Redis Sentinel (1 primary + 2 replicas) - NGINX Ingress with TLS and WebSocket support - Prometheus + Grafana + Alertmanager monitoring stack - Network policies with default deny - ConfigMap, CronJob, Deployment, Service templates Fixes applied during review: - SQL injection in search endpoint (parameterized :q binding) - Dockerfile PYTHONPATH mismatch (/app/app -> /app) - Hardcoded DB credentials in alembic.ini - RSS timestamp parsing (feedparser published_parsed -> parsedate_to_datetime) - Removed dead PGVECTOR import --- .../templates/api/configmap.yaml | 23 + .../templates/api/cronjobs.yaml | 207 ++++++ .../templates/api/deployment.yaml | 83 +++ .../templates/api/service.yaml | 20 + apps/base/osint-dashboard/values.yaml | 7 +- apps/osint-dashboard/.gitignore | 1 + apps/osint-dashboard/Dockerfile | 18 + apps/osint-dashboard/alembic.ini | 37 ++ apps/osint-dashboard/alembic/env.py | 55 ++ apps/osint-dashboard/alembic/script.py.mako | 28 + .../alembic/versions/001_initial.py | 149 +++++ apps/osint-dashboard/app/database.py | 28 + apps/osint-dashboard/app/ingest_cron.py | 46 ++ apps/osint-dashboard/app/ingestor.py | 107 ++++ apps/osint-dashboard/app/main.py | 603 ++++++++++++++++++ apps/osint-dashboard/app/models.py | 147 +++++ apps/osint-dashboard/app/requirements.txt | 13 + apps/osint-dashboard/app/schemas.py | 242 +++++++ apps/osint-dashboard/app/sources.py | 186 ++++++ apps/osint-dashboard/app/static/index.html | 307 +++++++++ 20 files changed, 2304 insertions(+), 3 deletions(-) create mode 100644 apps/base/osint-dashboard/templates/api/configmap.yaml create mode 100644 apps/base/osint-dashboard/templates/api/cronjobs.yaml create mode 100644 apps/base/osint-dashboard/templates/api/deployment.yaml create mode 100644 apps/base/osint-dashboard/templates/api/service.yaml create mode 100644 apps/osint-dashboard/.gitignore create mode 100644 apps/osint-dashboard/Dockerfile create mode 100644 apps/osint-dashboard/alembic.ini create mode 100644 apps/osint-dashboard/alembic/env.py create mode 100644 apps/osint-dashboard/alembic/script.py.mako create mode 100644 apps/osint-dashboard/alembic/versions/001_initial.py create mode 100644 apps/osint-dashboard/app/database.py create mode 100644 apps/osint-dashboard/app/ingest_cron.py create mode 100644 apps/osint-dashboard/app/ingestor.py create mode 100644 apps/osint-dashboard/app/main.py create mode 100644 apps/osint-dashboard/app/models.py create mode 100644 apps/osint-dashboard/app/requirements.txt create mode 100644 apps/osint-dashboard/app/schemas.py create mode 100644 apps/osint-dashboard/app/sources.py create mode 100644 apps/osint-dashboard/app/static/index.html diff --git a/apps/base/osint-dashboard/templates/api/configmap.yaml b/apps/base/osint-dashboard/templates/api/configmap.yaml new file mode 100644 index 0000000..e2566b7 --- /dev/null +++ b/apps/base/osint-dashboard/templates/api/configmap.yaml @@ -0,0 +1,23 @@ +{{- if .Values.api.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "osint-dashboard.fullname" . }}-api-config + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: api +data: + DB_HOST: "{{ .Values.postgresql.clusterName }}-rw.{{ .Values.namespace }}.svc.cluster.local" + DB_PORT: "{{ .Values.postgresql.port | default \"5432\" }}" + DB_NAME: "{{ .Values.postgresql.database | default \"osint_data\" }}" + NATS_URLS: "nats://{{ include \"osint-dashboard.fullname\" . }}-nats.{{ .Values.namespace }}.svc.cluster.local:{{ .Values.nats.ports.client }}" + REDIS_URL: "redis://{{ include \"osint-dashboard.fullname\" . }}-redis.{{ .Values.namespace }}.svc.cluster.local:{{ .Values.redis.ports.redis }}/0" + MINIO_ENDPOINT: "{{ include \"osint-dashboard.fullname\" . }}-minio.{{ .Values.namespace }}.svc.cluster.local:{{ .Values.minio.ports.api }}" + MINIO_BUCKET_VIDEO: "{{ .Values.minio.buckets._0.name | default \"osint-video-clips\" }}" + MINIO_BUCKET_SATELLITE: "{{ .Values.minio.buckets._1.name | default \"osint-satellite-tiles\" }}" + MINIO_BUCKET_DATA: "{{ .Values.minio.buckets._2.name | default \"osint-data-dumps\" }}" + PYTHONPATH: "/app/app" + PYTHONUNBUFFERED: "1" + PYTHONDONTWRITEBYTECODE: "1" +{{- end }} diff --git a/apps/base/osint-dashboard/templates/api/cronjobs.yaml b/apps/base/osint-dashboard/templates/api/cronjobs.yaml new file mode 100644 index 0000000..3606e00 --- /dev/null +++ b/apps/base/osint-dashboard/templates/api/cronjobs.yaml @@ -0,0 +1,207 @@ +{{- if .Values.api.enabled }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "osint-dashboard.fullname" . }}-rss-ingestor + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: ingestor +spec: + schedule: "*/5 * * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + template: + metadata: + labels: + {{- include "osint-dashboard.selectorLabels" . | nindent 12 }} + app.kubernetes.io/component: ingestor + spec: + restartPolicy: OnFailure + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + containers: + - name: rss-ingestor + image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}" + imagePullPolicy: {{ .Values.api.image.pullPolicy | default "Always" }} + command: + - python + - /app/app/ingest_cron.py + envFrom: + - configMapRef: + name: {{ include "osint-dashboard.fullname" . }}-api-config + env: + - name: DB_USER + valueFrom: + secretKeyRef: + name: {{ .Values.postgresql.credentialsSecret }} + key: username + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.postgresql.credentialsSecret }} + key: password + - name: INGESTOR_TYPE + value: "rss" + resources: + {{- toYaml .Values.api.resources | nindent 16 }} +{{- end }} +--- +{{- if .Values.api.enabled }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "osint-dashboard.fullname" . }}-gdelt-ingestor + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: ingestor +spec: + schedule: "*/15 * * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + template: + metadata: + labels: + {{- include "osint-dashboard.selectorLabels" . | nindent 12 }} + app.kubernetes.io/component: ingestor + spec: + restartPolicy: OnFailure + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + containers: + - name: gdelt-ingestor + image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}" + imagePullPolicy: {{ .Values.api.image.pullPolicy | default "Always" }} + command: + - python + - /app/app/ingest_cron.py + envFrom: + - configMapRef: + name: {{ include "osint-dashboard.fullname" . }}-api-config + env: + - name: DB_USER + valueFrom: + secretKeyRef: + name: {{ .Values.postgresql.credentialsSecret }} + key: username + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.postgresql.credentialsSecret }} + key: password + - name: INGESTOR_TYPE + value: "gdelt" + resources: + {{- toYaml .Values.api.resources | nindent 16 }} +{{- end }} +--- +{{- if .Values.api.enabled }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "osint-dashboard.fullname" . }}-earthquake-ingestor + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: ingestor +spec: + schedule: "0 * * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + template: + metadata: + labels: + {{- include "osint-dashboard.selectorLabels" . | nindent 12 }} + app.kubernetes.io/component: ingestor + spec: + restartPolicy: OnFailure + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + containers: + - name: earthquake-ingestor + image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}" + imagePullPolicy: {{ .Values.api.image.pullPolicy | default "Always" }} + command: + - python + - /app/app/ingest_cron.py + envFrom: + - configMapRef: + name: {{ include "osint-dashboard.fullname" . }}-api-config + env: + - name: DB_USER + valueFrom: + secretKeyRef: + name: {{ .Values.postgresql.credentialsSecret }} + key: username + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.postgresql.credentialsSecret }} + key: password + - name: INGESTOR_TYPE + value: "earthquake" + resources: + {{- toYaml .Values.api.resources | nindent 16 }} +{{- end }} +--- +{{- if .Values.api.enabled }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "osint-dashboard.fullname" . }}-nats-processor + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: ingestor +spec: + schedule: "*/2 * * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + template: + metadata: + labels: + {{- include "osint-dashboard.selectorLabels" . | nindent 12 }} + app.kubernetes.io/component: ingestor + spec: + restartPolicy: OnFailure + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + containers: + - name: nats-processor + image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}" + imagePullPolicy: {{ .Values.api.image.pullPolicy | default "Always" }} + command: + - python + - /app/app/ingest_cron.py + envFrom: + - configMapRef: + name: {{ include "osint-dashboard.fullname" . }}-api-config + env: + - name: DB_USER + valueFrom: + secretKeyRef: + name: {{ .Values.postgresql.credentialsSecret }} + key: username + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.postgresql.credentialsSecret }} + key: password + - name: INGESTOR_TYPE + value: "nats" + resources: + {{- toYaml .Values.api.resources | nindent 16 }} +{{- end }} diff --git a/apps/base/osint-dashboard/templates/api/deployment.yaml b/apps/base/osint-dashboard/templates/api/deployment.yaml new file mode 100644 index 0000000..54ba832 --- /dev/null +++ b/apps/base/osint-dashboard/templates/api/deployment.yaml @@ -0,0 +1,83 @@ +{{- if .Values.api.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "osint-dashboard.fullname" . }}-api + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: api +spec: + replicas: {{ .Values.api.replicaCount }} + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + {{- include "osint-dashboard.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: api + template: + metadata: + labels: + {{- include "osint-dashboard.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: api + annotations: + checksum/config: {{ include "osint-dashboard.fullname" . }}-api-config + spec: + terminationGracePeriodSeconds: 30 + securityContext: + {{- toYaml .Values.securityContext | nindent 8 }} + containers: + - name: api + image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}" + imagePullPolicy: {{ .Values.api.image.pullPolicy | default "Always" }} + ports: + - containerPort: 8000 + name: http + protocol: TCP + resources: + {{- toYaml .Values.api.resources | nindent 12 }} + envFrom: + - configMapRef: + name: {{ include "osint-dashboard.fullname" . }}-api-config + env: + - name: DB_USER + valueFrom: + secretKeyRef: + name: {{ .Values.postgresql.credentialsSecret }} + key: username + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.postgresql.credentialsSecret }} + key: password + startupProbe: + httpGet: + path: /api/health + port: 8000 + initialDelaySeconds: 15 + periodSeconds: 5 + failureThreshold: 12 + livenessProbe: + httpGet: + path: /api/health + port: 8000 + initialDelaySeconds: 30 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /api/health + port: 8000 + initialDelaySeconds: 15 + periodSeconds: 10 + {{- with .Values.api.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.api.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/apps/base/osint-dashboard/templates/api/service.yaml b/apps/base/osint-dashboard/templates/api/service.yaml new file mode 100644 index 0000000..0d0c3e4 --- /dev/null +++ b/apps/base/osint-dashboard/templates/api/service.yaml @@ -0,0 +1,20 @@ +{{- if .Values.api.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "osint-dashboard.fullname" . }}-api + namespace: {{ .Values.namespace }} + labels: + {{- include "osint-dashboard.labels" . | nindent 4 }} + app.kubernetes.io/component: api +spec: + type: ClusterIP + ports: + - port: 8000 + targetPort: 8000 + protocol: TCP + name: http + selector: + {{- include "osint-dashboard.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: api +{{- end }} diff --git a/apps/base/osint-dashboard/values.yaml b/apps/base/osint-dashboard/values.yaml index 6752fed..3db7dfa 100644 --- a/apps/base/osint-dashboard/values.yaml +++ b/apps/base/osint-dashboard/values.yaml @@ -343,14 +343,15 @@ frontend: memory: 512Mi # ============================================================ -# API (placeholder — T2 will fill this in) +# API — FastAPI backend (T2: OSINT Dashboard application) # ============================================================ api: - enabled: false + enabled: true replicaCount: 3 image: repository: gcr.io/devops-lab-cluster/osint-dashboard-api - tag: latest + tag: "0.1.0" + pullPolicy: Always resources: requests: cpu: "200m" diff --git a/apps/osint-dashboard/.gitignore b/apps/osint-dashboard/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/apps/osint-dashboard/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/apps/osint-dashboard/Dockerfile b/apps/osint-dashboard/Dockerfile new file mode 100644 index 0000000..780d19b --- /dev/null +++ b/apps/osint-dashboard/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.13-slim AS base + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY app/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ +COPY alembic.ini ./alembic.ini +COPY alembic/ ./alembic/ + +EXPOSE 8000 +ENV PYTHONPATH=/app +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/apps/osint-dashboard/alembic.ini b/apps/osint-dashboard/alembic.ini new file mode 100644 index 0000000..a470ca8 --- /dev/null +++ b/apps/osint-dashboard/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = alembic +# Override at runtime via DATABASE_URL environment variable (set in ConfigMap/Deployment) +sqlalchemy.url = driver://user:pass@localhost/dbname + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/apps/osint-dashboard/alembic/env.py b/apps/osint-dashboard/alembic/env.py new file mode 100644 index 0000000..9b2fb7e --- /dev/null +++ b/apps/osint-dashboard/alembic/env.py @@ -0,0 +1,55 @@ +import sys +from logging.config import fileConfig +from pathlib import Path + +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +# Add app directory to path so we can import models/database +sys.path.insert(0, str(Path(__file__).parent.parent / "app")) + +from database import metadata, DATABASE_URL + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) +target_metadata = metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations(): + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + import asyncio + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/apps/osint-dashboard/alembic/script.py.mako b/apps/osint-dashboard/alembic/script.py.mako new file mode 100644 index 0000000..3776ebd --- /dev/null +++ b/apps/osint-dashboard/alembic/script.py.mako @@ -0,0 +1,28 @@ +<%%doc>Template for rendering a Multiple Migration Revision Identifier. +<%%- + from alembic import context + context.configure() +-%> +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma_n, trim} +Create Date: ${create_date} +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/apps/osint-dashboard/alembic/versions/001_initial.py b/apps/osint-dashboard/alembic/versions/001_initial.py new file mode 100644 index 0000000..7cc2207 --- /dev/null +++ b/apps/osint-dashboard/alembic/versions/001_initial.py @@ -0,0 +1,149 @@ +"""initial schema + +Revision ID: 001_initial +Revises: +Create Date: 2026-05-18 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID, TSVECTOR, ENUM + +# revision identifiers, used by Alembic. +revision = '001_initial' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Enums + op.execute("CREATE TYPE feed_source_type AS ENUM ('rss', 'gdel-t2', 'social', 'earthquake', 'disaster', 'weather', 'fire', 'satellite')") + op.execute("CREATE TYPE event_source_type AS ENUM ('rss', 'gdel-t2', 'social', 'earthquake', 'disaster', 'weather', 'fire', 'satellite')") + op.execute("CREATE TYPE sentiment_label AS ENUM ('positive', 'neutral', 'negative')") + op.execute("CREATE TYPE entity_type AS ENUM ('person', 'organization', 'location', 'topic', 'asset')") + op.execute("CREATE TYPE alert_type AS ENUM ('entity_mention', 'sentiment_shift', 'geo_proximity', 'keyword_match', 'threshold', 'anomaly')") + op.execute("CREATE TYPE alert_severity AS ENUM ('low', 'medium', 'high', 'critical')") + + # Extensions + op.execute("CREATE EXTENSION IF NOT EXISTS postgis") + op.execute("CREATE EXTENSION IF NOT EXISTS timescaledb") + + # feed_sources + op.create_table( + 'feed_sources', + sa.Column('id', UUID(as_uuid=True), primary_key=True), + sa.Column('name', sa.String(256), nullable=False), + sa.Column('source_type', sa.Enum('rss', 'gdel-t2', 'social', 'earthquake', 'disaster', 'weather', 'fire', 'satellite', name='feed_source_type'), nullable=False), + sa.Column('url', sa.Text()), + sa.Column('config', sa.JSON()), + sa.Column('enabled', sa.Integer, server_default='1', nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # events (will become hypertable) + op.create_table( + 'events', + sa.Column('id', UUID(as_uuid=True), primary_key=True), + sa.Column('source_type', sa.Enum('rss', 'gdel-t2', 'social', 'earthquake', 'disaster', 'weather', 'fire', 'satellite', name='event_source_type'), nullable=False, index=True), + sa.Column('source_id', UUID(as_uuid=True)), + sa.Column('title', sa.Text()), + sa.Column('body', sa.Text()), + sa.Column('url', sa.Text()), + sa.Column('sentiment_score', sa.Float()), + sa.Column('sentiment_label', sa.Enum('positive', 'neutral', 'negative', name='sentiment_label')), + sa.Column('location_lat', sa.Float()), + sa.Column('location_lon', sa.Float()), + sa.Column('location_name', sa.String(512)), + sa.Column('entities', sa.JSON()), + sa.Column('tags', sa.JSON()), + sa.Column('raw', sa.JSON()), + sa.Column('ingested_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column('source_timestamp', sa.DateTime(timezone=True), nullable=False), + sa.Column('search_vector', TSVECTOR), + ) + + # Convert events to TimescaleDB hypertable + op.execute("SELECT create_hypertable('events', 'ingested_at', if_not_exists => TRUE)") + + # GIN index for full-text search + op.create_index('ix_events_search_vector', 'events', ['search_vector'], postgresql_using='gin') + # Spatial index + op.create_index('ix_events_location', 'events', ['location_lat', 'location_lon']) + + # entities + op.create_table( + 'entities', + sa.Column('id', UUID(as_uuid=True), primary_key=True), + sa.Column('name', sa.String(512), nullable=False, index=True), + sa.Column('entity_type', sa.Enum('person', 'organization', 'location', 'topic', 'asset', name='entity_type'), nullable=False), + sa.Column('aliases', sa.JSON()), + sa.Column('description', sa.Text()), + sa.Column('metadata', sa.JSON()), + sa.Column('location_lat', sa.Float()), + sa.Column('location_lon', sa.Float()), + sa.Column('event_count', sa.Integer, server_default='0'), + sa.Column('first_seen', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('last_seen', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # entity_events + op.create_table( + 'entity_events', + sa.Column('entity_id', UUID(as_uuid=True), primary_key=True), + sa.Column('event_id', UUID(as_uuid=True), primary_key=True), + sa.Column('relevance_score', sa.Float()), + sa.Column('linked_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # alerts + op.create_table( + 'alerts', + sa.Column('id', UUID(as_uuid=True), primary_key=True), + sa.Column('alert_type', sa.Enum('entity_mention', 'sentiment_shift', 'geo_proximity', 'keyword_match', 'threshold', 'anomaly', name='alert_type'), nullable=False), + sa.Column('entity_id', UUID(as_uuid=True)), + sa.Column('event_id', UUID(as_uuid=True)), + sa.Column('severity', sa.Enum('low', 'medium', 'high', 'critical', name='alert_severity'), nullable=False), + sa.Column('title', sa.Text(), nullable=False), + sa.Column('message', sa.Text()), + sa.Column('context', sa.JSON()), + sa.Column('acknowledged', sa.Integer, server_default='0'), + sa.Column('acknowledged_by', sa.String(256)), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column('resolved_at', sa.DateTime(timezone=True)), + ) + op.create_index('ix_alerts_severity_created', 'alerts', ['severity', 'created_at']) + op.create_index('ix_alerts_entity', 'alerts', ['entity_id']) + + # documents + op.create_table( + 'documents', + sa.Column('id', UUID(as_uuid=True), primary_key=True), + sa.Column('bucket', sa.String(256), nullable=False), + sa.Column('object_key', sa.String(1024), nullable=False), + sa.Column('content_type', sa.String(256)), + sa.Column('size_bytes', sa.Integer()), + sa.Column('description', sa.Text()), + sa.Column('tags', sa.JSON()), + sa.Column('event_id', UUID(as_uuid=True)), + sa.Column('uploaded_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table('documents') + op.drop_table('alerts') + op.drop_table('entity_events') + op.drop_table('entities') + op.execute("SELECT drop_hypertable('events', cascade => TRUE)") + op.drop_table('events') + op.drop_table('feed_sources') + + # Drop enums + op.execute("DROP TYPE IF EXISTS feed_source_type") + op.execute("DROP TYPE IF EXISTS event_source_type") + op.execute("DROP TYPE IF EXISTS sentiment_label") + op.execute("DROP TYPE IF EXISTS entity_type") + op.execute("DROP TYPE IF EXISTS alert_type") + op.execute("DROP TYPE IF EXISTS alert_severity") diff --git a/apps/osint-dashboard/app/database.py b/apps/osint-dashboard/app/database.py new file mode 100644 index 0000000..767c26a --- /dev/null +++ b/apps/osint-dashboard/app/database.py @@ -0,0 +1,28 @@ +import os + +from sqlalchemy import MetaData, event, text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +DB_USER = os.getenv("DB_USER", "osint") +DB_PASS = os.getenv("DB_PASSWORD", "") +DB_HOST = os.getenv("DB_HOST", "osint-pgdb-rw.customer1.svc.cluster.local") +DB_PORT = os.getenv("DB_PORT", "5432") +DB_NAME = os.getenv("DB_NAME", "osint_data") + +DATABASE_URL = f"postgresql+asyncpg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + +engine = create_async_engine( + DATABASE_URL, echo=False, pool_size=5, max_overflow=10, pool_recycle=300 +) +async_session = async_sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False +) +metadata = MetaData() + + +async def init_extensions(): + """Initialize PostGIS and TimescaleDB extensions on first connection.""" + async with engine.connect() as conn: + await conn.execute(text("CREATE EXTENSION IF NOT EXISTS postgis")) + await conn.execute(text("CREATE EXTENSION IF NOT EXISTS timescaledb")) + await conn.commit() diff --git a/apps/osint-dashboard/app/ingest_cron.py b/apps/osint-dashboard/app/ingest_cron.py new file mode 100644 index 0000000..a0dcfa6 --- /dev/null +++ b/apps/osint-dashboard/app/ingest_cron.py @@ -0,0 +1,46 @@ +"""CronJob entry point for scheduled ingestion.""" + +import asyncio +import os +import sys +from pathlib import Path + +# Add app dir to path +sys.path.insert(0, str(Path(__file__).parent)) + +from sources import ingest_rss_feed, ingest_gdelt, ingest_earthquakes +from ingestor import fetch_and_process + +INGESTOR_TYPE = os.getenv("INGESTOR_TYPE", "rss") +RSS_URL = os.getenv("RSS_URL", "") + + +async def main(): + print(f"Starting ingester: {INGESTOR_TYPE}") + + if INGESTOR_TYPE == "rss": + if not RSS_URL: + print("No RSS_URL set, skipping") + return + count = await ingest_rss_feed(RSS_URL) + print(f"RSS: ingested {count} items") + + elif INGESTOR_TYPE == "gdelt": + count = await ingest_gdelt(max_articles=50) + print(f"GDELT: ingested {count} articles") + + elif INGESTOR_TYPE == "earthquake": + count = await ingest_earthquakes() + print(f"Earthquakes: ingested {count} events") + + elif INGESTOR_TYPE == "nats": + count = await fetch_and_process(batch_size=500) + print(f"NATS: processed {count} messages") + + else: + print(f"Unknown ingestor type: {INGESTOR_TYPE}") + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/apps/osint-dashboard/app/ingestor.py b/apps/osint-dashboard/app/ingestor.py new file mode 100644 index 0000000..d74d4f0 --- /dev/null +++ b/apps/osint-dashboard/app/ingestor.py @@ -0,0 +1,107 @@ +"""NATS JetStream consumer — ingests OSINT events from NATS streams.""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone + +import nats +from nats.errors import TimeoutError + +from database import async_session +from models import events as events_table + +logger = logging.getLogger("osint.ingestor") + +# NATS connection settings +NATS_URLS = "nats://osint-nats.customer1.svc.cluster.local:4222" +NATS_STREAM = "events" +NATS_DURABLE = "osint-ingestor" + +# Redis cache settings +REDIS_URL = "redis://osint-redis-sentinel.customer1.svc.cluster.local:26379/0" + + +async def ingest_event(msg: dict): + """Ingest a single event from NATS into PostgreSQL.""" + event_row = { + "source_type": msg.get("source_type", "rss"), + "source_id": msg.get("source_id"), + "title": msg.get("title"), + "body": msg.get("body"), + "url": msg.get("url"), + "sentiment_score": msg.get("sentiment_score"), + "sentiment_label": msg.get("sentiment_label"), + "location_lat": msg.get("location_lat"), + "location_lon": msg.get("location_lon"), + "location_name": msg.get("location_name"), + "entities": msg.get("entities", []), + "tags": msg.get("tags", []), + "raw": msg.get("raw"), + "source_timestamp": msg.get("source_timestamp", datetime.now(timezone.utc).isoformat()), + } + + # Parse timestamp if string + if isinstance(event_row["source_timestamp"], str): + event_row["source_timestamp"] = datetime.fromisoformat(event_row["source_timestamp"]) + + async with async_session() as session: + result = await session.execute(events_table.insert().values(**event_row)) + await session.commit() + event_id = result.inserted_primary_key[0] # type: ignore[union-attr] + logger.info("Ingested event %s from source %s", event_id, msg.get("source_type")) + return event_id + + +async def start_nats_consumer(): + """Start NATS JetStream consumer for OSINT events.""" + nc = await nats.connect(NATS_URLS) + js = nc.jetstream() + + # Create stream if not exists + try: + await js.add_stream( + name=NATS_STREAM, + subjects=[ + "events.gdelt", "events.rss", "events.social", + "events.earthquake", "events.disaster", "events.weather", + "events.fire", "events.satellite", "events.new", "events.alert", + ], + retention=nats.js.api.RetentionPolicy.INTERESTS, + max_msgs=1_000_000, + ) + logger.info("Created NATS stream %s", NATS_STREAM) + except Exception: + logger.debug("Stream %s already exists", NATS_STREAM) + + # Create durable consumer + sub = await js.pull_subscribe( + subject="events.>", + durable_name=NATS_DURABLE, + ) + + logger.info("NATS consumer started, durable=%s", NATS_DURABLE) + return nc, sub + + +async def fetch_and_process(batch_size: int = 100): + """Fetch a batch of messages and process them.""" + nc, sub = await start_nats_consumer() + js = nc.jetstream() + + msgs = await sub.fetch(batch_size, timeout=5) + processed = 0 + + for msg in msgs: + try: + data = json.loads(msg.data) + await ingest_event(data) + await msg.ack() + processed += 1 + except Exception: + logger.error("Failed to process message: %s", msg.data, exc_info=True) + + await nc.close() + logger.info("Processed %d messages in batch", processed) + return processed diff --git a/apps/osint-dashboard/app/main.py b/apps/osint-dashboard/app/main.py new file mode 100644 index 0000000..01be05b --- /dev/null +++ b/apps/osint-dashboard/app/main.py @@ -0,0 +1,603 @@ +"""OSINT Dashboard — FastAPI backend. + +Real-time geospatial OSINT dashboard API: +- Event ingestion via NATS JetStream consumers +- Full-text search across events (PostgreSQL tsvector) +- Entity tracking and relationship mapping +- Alert management +- Document storage (MinIO-backed) +- Sentiment aggregation and timeline analytics +""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timedelta, timezone +from decimal import Decimal +from pathlib import Path +from uuid import UUID + +import structlog +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import FileResponse, HTMLResponse +from sqlalchemy import and_, func, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from database import async_session, init_extensions +from models import ( + alerts, documents, entities, entity_events, events, feed_sources +) +from schemas import ( + AlertCreate, AlertOut, AlertSeverity, AlertType, AlertUpdate, + DashboardSummary, EntityCreate, EntityKind, EntityOut, + EventCreate, EventOut, + FeedSourceCreate, FeedSourceOut, + SearchResult, SentimentSummary, SourceType, + SearchQuery, TimelinePoint, +) +from ingestor import ingest_event, fetch_and_process +from sources import ingest_rss_feed, ingest_gdelt, ingest_earthquakes, ingest_social_signals + +logging.basicConfig(level=logging.INFO) +logger = structlog.get_logger("osint.dashboard") + +app = FastAPI( + title="OSINT Dashboard", + description="Real-time geospatial OSINT intelligence dashboard", + version="0.1.0", +) + +STATIC_DIR = Path(__file__).parent / "static" + + +# ── Helpers ─────────────────────────────────────────────────────────────── + +def event_to_out(row: dict) -> EventOut: + """Convert DB row dict to EventOut schema.""" + return EventOut( + id=row["id"], + source_type=row["source_type"], + source_id=row["source_id"], + title=row["title"], + body=row["body"], + url=row["url"], + sentiment_score=row["sentiment_score"], + sentiment_label=row["sentiment_label"], + location_lat=row["location_lat"], + location_lon=row["location_lon"], + location_name=row["location_name"], + entities=row["entities"], + tags=row["tags"], + ingested_at=row["ingested_at"], + source_timestamp=row["source_timestamp"], + ) + + +def entity_to_out(row: dict) -> EntityOut: + """Convert DB row dict to EntityOut schema.""" + return EntityOut( + id=row["id"], + name=row["name"], + entity_type=row["entity_type"], + aliases=row["aliases"], + description=row["description"], + metadata=row["metadata"], + location_lat=row["location_lat"], + location_lon=row["location_lon"], + event_count=row["event_count"], + first_seen=row["first_seen"], + last_seen=row["last_seen"], + ) + + +def alert_to_out(row: dict) -> AlertOut: + """Convert DB row dict to AlertOut schema.""" + return AlertOut( + id=row["id"], + alert_type=row["alert_type"], + entity_id=row["entity_id"], + event_id=row["event_id"], + severity=row["severity"], + title=row["title"], + message=row["message"], + context=row["context"], + acknowledged=bool(row["acknowledged"]), + acknowledged_by=row["acknowledged_by"], + created_at=row["created_at"], + resolved_at=row["resolved_at"], + ) + + +# ── Health ──────────────────────────────────────────────────────────────── + +@app.get("/api/health") +async def health(): + """Health check with database connectivity.""" + async with async_session() as session: + result = await session.execute(select(func.now())) + db_time = result.scalar() + return {"status": "ok", "db_time": db_time.isoformat() if db_time else None} + + +# ── Startup ─────────────────────────────────────────────────────────────── + +@app.on_event("startup") +async def startup(): + """Initialize extensions and run migrations.""" + await init_extensions() + from alembic import command + from alembic.config import Config + alembic_cfg = Config(str(Path(__file__).parent.parent / "alembic.ini")) + command.upgrade(alembic_cfg, "head") + + +# ── Feed Sources ────────────────────────────────────────────────────────── + +@app.get("/api/sources", response_model=list[FeedSourceOut]) +async def list_sources(enabled_only: bool = Query(True)): + """List all configured feed sources.""" + async with async_session() as session: + stmt = select(feed_sources).order_by(feed_sources.c.name) + if enabled_only: + stmt = stmt.where(feed_sources.c.enabled == 1) + rows = (await session.execute(stmt)).mappings().all() + return [FeedSourceOut( + id=r["id"], name=r["name"], source_type=r["source_type"], + url=r["url"], config=r["config"], enabled=bool(r["enabled"]), + created_at=r["created_at"], + ) for r in rows] + + +@app.post("/api/sources", status_code=201) +async def create_source(payload: FeedSourceCreate): + """Add a new feed source.""" + async with async_session() as session: + values = payload.model_dump() + result = await session.execute(feed_sources.insert().values(**values)) + await session.commit() + pk = result.inserted_primary_key[0] # type: ignore + return {"id": str(pk)} + + +@app.patch("/api/sources/{source_id}") +async def update_source(source_id: UUID, payload: dict): + """Update a feed source (e.g., toggle enabled).""" + async with async_session() as session: + row = (await session.execute( + select(feed_sources).where(feed_sources.c.id == source_id) + )).mappings().one_or_none() + if not row: + raise HTTPException(404, "Source not found") + await session.execute( + feed_sources.update() + .where(feed_sources.c.id == source_id) + .values(**payload) + ) + await session.commit() + return {"ok": True} + + +# ── Events ──────────────────────────────────────────────────────────────── + +@app.get("/api/events", response_model=list[EventOut]) +async def list_events( + source_type: SourceType | None = Query(None), + limit: int = Query(50, ge=1, le=500), + offset: int = Query(0, ge=0), +): + """List recent ingested events.""" + async with async_session() as session: + stmt = select(events).order_by(events.c.ingested_at.desc()) + if source_type: + stmt = stmt.where(events.c.source_type == source_type.value) + stmt = stmt.limit(limit).offset(offset) + rows = (await session.execute(stmt)).mappings().all() + return [event_to_out(r) for r in rows] + + +@app.get("/api/events/{event_id}", response_model=EventOut) +async def get_event(event_id: UUID): + """Get a single event by ID.""" + async with async_session() as session: + row = (await session.execute( + select(events).where(events.c.id == event_id) + )).mappings().one_or_none() + if not row: + raise HTTPException(404, "Event not found") + return event_to_out(row) + + +@app.post("/api/events", status_code=201) +async def create_event(payload: EventCreate): + """Manually ingest an event (bypasses NATS).""" + values = payload.model_dump(exclude_unset=True) + if not values.get("source_timestamp"): + values["source_timestamp"] = datetime.now(timezone.utc) + event_id = await ingest_event(values) + return {"id": str(event_id)} + + +# ── Search ──────────────────────────────────────────────────────────────── + +@app.post("/api/search", response_model=SearchResult) +async def search_events(query: SearchQuery): + """Full-text search across events with optional filters.""" + async with async_session() as session: + # Build query with tsvector full-text search (parameterized to avoid SQL injection) + tsquery_param = text("plainto_tsquery('english', :q)") + + base_stmt = select( + events, + func.count().over().label("total") + ).where( + events.c.search_vector.op("@@")(tsquery_param) + ) + + # Apply filters + if query.source_type: + base_stmt = base_stmt.where(events.c.source_type == query.source_type.value) + if query.entity_id: + base_stmt = base_stmt.join( + entity_events, entity_events.c.event_id == events.c.id + ).where(entity_events.c.entity_id == query.entity_id) + if query.sentiment: + base_stmt = base_stmt.where(events.c.sentiment_label == query.sentiment.value) + if query.min_date: + base_stmt = base_stmt.where(events.c.source_timestamp >= query.min_date) + if query.max_date: + base_stmt = base_stmt.where(events.c.source_timestamp <= query.max_date) + if query.min_lat is not None and query.max_lat is not None: + base_stmt = base_stmt.where( + and_( + events.c.location_lat >= query.min_lat, + events.c.location_lat <= query.max_lat, + ) + ) + if query.min_lon is not None and query.max_lon is not None: + base_stmt = base_stmt.where( + and_( + events.c.location_lon >= query.min_lon, + events.c.location_lon <= query.max_lon, + ) + ) + + base_stmt = base_stmt.order_by(events.c.ingested_at.desc()) + base_stmt = base_stmt.limit(query.limit).offset(query.offset) + + result = (await session.execute(base_stmt, {"q": query.q})).mappings().all() + if result: + total = result[0]["total"] + else: + total = 0 + evts = [event_to_out(r) for r in result] + + return SearchResult( + events=evts, + total=total, + has_more=query.offset + len(evts) < total, + ) + + +# ── Entities ────────────────────────────────────────────────────────────── + +@app.get("/api/entities", response_model=list[EntityOut]) +async def list_entities( + entity_type: EntityKind | None = Query(None), + limit: int = Query(50, ge=1, le=500), +): + """List tracked entities.""" + async with async_session() as session: + stmt = select(entities).order_by(entities.c.event_count.desc()) + if entity_type: + stmt = stmt.where(entities.c.entity_type == entity_type.value) + stmt = stmt.limit(limit) + rows = (await session.execute(stmt)).mappings().all() + return [entity_to_out(r) for r in rows] + + +@app.get("/api/entities/{entity_id}", response_model=EntityOut) +async def get_entity(entity_id: UUID): + """Get entity details with recent events.""" + async with async_session() as session: + row = (await session.execute( + select(entities).where(entities.c.id == entity_id) + )).mappings().one_or_none() + if not row: + raise HTTPException(404, "Entity not found") + return entity_to_out(row) + + +@app.post("/api/entities", status_code=201) +async def create_entity(payload: EntityCreate): + """Create or update a tracked entity.""" + async with async_session() as session: + # Check if entity already exists by name + existing = (await session.execute( + select(entities).where(entities.c.name == payload.name) + )).mappings().one_or_none() + + if existing: + # Update + updates = payload.model_dump(exclude_unset=True) + updates["last_seen"] = datetime.now(timezone.utc) + await session.execute( + entities.update() + .where(entities.c.id == existing["id"]) + .values(**updates) + ) + await session.commit() + return {"id": str(existing["id"]), "created": False} + + # Create + values = payload.model_dump() + result = await session.execute(entities.insert().values(**values)) + await session.commit() + pk = result.inserted_primary_key[0] # type: ignore + return {"id": str(pk), "created": True} + + +@app.get("/api/entities/{entity_id}/events", response_model=list[EventOut]) +async def get_entity_events( + entity_id: UUID, + limit: int = Query(50, ge=1, le=500), +): + """Get events linked to a specific entity.""" + async with async_session() as session: + stmt = ( + select(events) + .join(entity_events, entity_events.c.event_id == events.c.id) + .where(entity_events.c.entity_id == entity_id) + .order_by(events.c.source_timestamp.desc()) + .limit(limit) + ) + rows = (await session.execute(stmt)).mappings().all() + return [event_to_out(r) for r in rows] + + +# ── Alerts ──────────────────────────────────────────────────────────────── + +@app.get("/api/alerts", response_model=list[AlertOut]) +async def list_alerts( + severity: AlertSeverity | None = Query(None), + acknowledged: bool | None = Query(None), + entity_id: UUID | None = Query(None), + limit: int = Query(50, ge=1, le=500), +): + """List alerts with optional filters.""" + async with async_session() as session: + stmt = select(alerts).order_by( + alerts.c.severity.desc(), alerts.c.created_at.desc() + ) + if severity: + stmt = stmt.where(alerts.c.severity == severity.value) + if acknowledged is not None: + stmt = stmt.where(alerts.c.acknowledged == int(acknowledged)) + if entity_id: + stmt = stmt.where(alerts.c.entity_id == entity_id) + stmt = stmt.limit(limit) + rows = (await session.execute(stmt)).mappings().all() + return [alert_to_out(r) for r in rows] + + +@app.post("/api/alerts", status_code=201) +async def create_alert(payload: AlertCreate): + """Create a new alert.""" + async with async_session() as session: + values = payload.model_dump() + result = await session.execute(alerts.insert().values(**values)) + await session.commit() + pk = result.inserted_primary_key[0] # type: ignore + return {"id": str(pk)} + + +@app.patch("/api/alerts/{alert_id}") +async def update_alert(alert_id: UUID, payload: AlertUpdate): + """Update alert (acknowledge, resolve).""" + async with async_session() as session: + row = (await session.execute( + select(alerts).where(alerts.c.id == alert_id) + )).mappings().one_or_none() + if not row: + raise HTTPException(404, "Alert not found") + updates = payload.model_dump(exclude_unset=True) + if "acknowledged" in updates: + updates["acknowledged"] = int(updates["acknowledged"]) + await session.execute( + alerts.update().where(alerts.c.id == alert_id).values(**updates) + ) + await session.commit() + return {"ok": True} + + +# ── Documents ───────────────────────────────────────────────────────────── + +@app.get("/api/documents", response_model=dict) +async def list_documents( + limit: int = Query(50, ge=1, le=500), + offset: int = Query(0, ge=0), +): + """List documents indexed in MinIO.""" + async with async_session() as session: + stmt = select(documents).order_by(documents.c.uploaded_at.desc()).limit(limit).offset(offset) + rows = (await session.execute(stmt)).mappings().all() + return { + "documents": [{ + "id": str(r["id"]), "bucket": r["bucket"], "object_key": r["object_key"], + "content_type": r["content_type"], "size_bytes": r["size_bytes"], + "description": r["description"], "tags": r["tags"], + "event_id": str(r["event_id"]) if r["event_id"] else None, + "uploaded_at": r["uploaded_at"].isoformat() if r["uploaded_at"] else None, + } for r in rows], + } + + +# ── Ingestion Triggers ─────────────────────────────────────────────────── + +@app.post("/api/ingest/rss") +async def trigger_rss_ingest(feed_url: str, source_id: str | None = None): + """Trigger RSS feed ingestion.""" + count = await ingest_rss_feed(feed_url, source_id) + return {"status": "ok", "items_ingested": count} + + +@app.post("/api/ingest/gdelt") +async def trigger_gdelt_ingest(query: str = "", max_articles: int = 50): + """Trigger GDELT data ingestion.""" + count = await ingest_gdelt(query, max_articles) + return {"status": "ok", "articles_ingested": count} + + +@app.post("/api/ingest/earthquakes") +async def trigger_earthquake_ingest(): + """Trigger USGS earthquake ingestion.""" + count = await ingest_earthquakes() + return {"status": "ok", "events_ingested": count} + + +@app.post("/api/ingest/social") +async def trigger_social_ingest(query: str = "", max_items: int = 50): + """Trigger social signals ingestion.""" + count = await ingest_social_signals(query, max_items) + return {"status": "ok", "signals_ingested": count} + + +@app.post("/api/ingest/process") +async def trigger_nats_processing(batch_size: int = 100): + """Process pending NATS JetStream messages.""" + count = await fetch_and_process(batch_size) + return {"status": "ok", "processed": count} + + +# ── Analytics / Aggregation ─────────────────────────────────────────────── + +@app.get("/api/analytics/summary", response_model=DashboardSummary) +async def get_dashboard_summary(): + """Dashboard overview: event counts, sentiment, top entities, alerts.""" + async with async_session() as session: + now = datetime.now(timezone.utc) + yesterday = now - timedelta(hours=24) + + # Total events + total = (await session.execute( + select(func.count()).select_from(events) + )).scalar() or 0 + + # Events in last 24h + events_24h = (await session.execute( + select(func.count()).where(events.c.ingested_at >= yesterday) + )).scalar() or 0 + + # Active sources + active = (await session.execute( + select(func.count()).where(feed_sources.c.enabled == 1) + )).scalar() or 0 + + # Open alerts + open_alerts = (await session.execute( + select(func.count()).where(alerts.c.acknowledged == 0) + )).scalar() or 0 + + # Tracked entities + ent_count = (await session.execute( + select(func.count()).select_from(entities) + )).scalar() or 0 + + # Sentiment breakdown (last 24h) + def sentiment_query(): + return select( + func.count().where(events.c.sentiment_label == "positive").label("pos"), + func.count().where(events.c.sentiment_label == "neutral").label("neu"), + func.count().where(events.c.sentiment_label == "negative").label("neg"), + func.avg(events.c.sentiment_score).label("avg"), + ).where(events.c.ingested_at >= yesterday) + + sent_row = (await session.execute(sentiment_query())).mappings().one() + sentiment = SentimentSummary( + period="24h", + positive_count=sent_row["pos"] or 0, + neutral_count=sent_row["neu"] or 0, + negative_count=sent_row["neg"] or 0, + avg_score=float(sent_row["avg"] or 0), + ) + + # Top entities by event count + top_ent = (await session.execute( + select(entities).order_by(entities.c.event_count.desc()).limit(10) + )).mappings().all() + + return DashboardSummary( + total_events=total, + events_last_24h=events_24h, + active_sources=active, + open_alerts=open_alerts, + tracked_entities=ent_count, + sentiment=sentiment, + top_entities=[entity_to_out(r) for r in top_ent], + ) + + +@app.get("/api/analytics/timeline") +async def get_timeline( + hours: int = Query(24, ge=1, le=168), + bucket_hours: int = Query(1, ge=1, le=24), +): + """Event timeline: counts and avg sentiment per time bucket.""" + async with async_session() as session: + cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) + # Use date_trunc for bucketing + buckets = await session.execute(text(f""" + SELECT + date_trunc('hour', source_timestamp) AS ts, + COUNT(*) AS event_count, + COALESCE(AVG(sentiment_score), 0) AS avg_sentiment + FROM events + WHERE source_timestamp >= :cutoff + GROUP BY ts + ORDER BY ts + """), {"cutoff": cutoff}) + rows = buckets.mappings().all() + + return [TimelinePoint(timestamp=r["ts"], event_count=r["event_count"], + avg_sentiment=float(r["avg_sentiment"])) for r in rows] + + +@app.get("/api/analytics/sentiment/by-source") +async def sentiment_by_source(hours: int = 24): + """Sentiment breakdown grouped by source type.""" + async with async_session() as session: + cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) + result = await session.execute(text(f""" + SELECT + source_type, + COUNT(*) AS total, + COUNT(*) FILTER (WHERE sentiment_label = 'positive') AS positive, + COUNT(*) FILTER (WHERE sentiment_label = 'neutral') AS neutral, + COUNT(*) FILTER (WHERE sentiment_label = 'negative') AS negative, + COALESCE(AVG(sentiment_score), 0) AS avg_score + FROM events + WHERE ingested_at >= :cutoff + GROUP BY source_type + ORDER BY total DESC + """), {"cutoff": cutoff}) + rows = result.mappings().all() + return [{ + "source_type": r["source_type"], + "total": r["total"], + "positive": r["positive"], + "neutral": r["neutral"], + "negative": r["negative"], + "avg_score": float(r["avg_score"]), + } for r in rows] + + +# ── Frontend ────────────────────────────────────────────────────────────── + +@app.get("/", response_class=HTMLResponse) +async def index(): + return FileResponse(str(STATIC_DIR / "index.html")) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/apps/osint-dashboard/app/models.py b/apps/osint-dashboard/app/models.py new file mode 100644 index 0000000..6f052b2 --- /dev/null +++ b/apps/osint-dashboard/app/models.py @@ -0,0 +1,147 @@ +"""OSINT Dashboard — SQLAlchemy models (async, declarative).""" + +from datetime import datetime, timezone +from uuid import uuid4 + +from sqlalchemy import ( + Column, Enum, Float, Index, Integer, String, Text, + DateTime, JSON, func, Table, +) +from sqlalchemy.dialects.postgresql import UUID, TSVECTOR + +from database import metadata + + +# ── Feed Sources ────────────────────────────────────────────────────────── + +feed_sources = Table( + "feed_sources", + metadata, + Column("id", UUID(as_uuid=True), primary_key=True, default=uuid4), + Column("name", String(256), nullable=False), + Column("source_type", Enum( + "rss", "gdel-t2", "social", "earthquake", "disaster", + "weather", "fire", "satellite", name="feed_source_type" + ), nullable=False), + Column("url", Text), + Column("config", JSON), + Column("enabled", Integer, server_default="1", nullable=False), + Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False), + Column("updated_at", DateTime(timezone=True), server_default=func.now(), onupdate=func.now()), +) + + +# ── Events (hypertable via TimescaleDB) ────────────────────────────────── + +events = Table( + "events", + metadata, + Column("id", UUID(as_uuid=True), primary_key=True, default=uuid4), + Column("source_type", Enum( + "rss", "gdel-t2", "social", "earthquake", "disaster", + "weather", "fire", "satellite", name="event_source_type" + ), nullable=False, index=True), + Column("source_id", UUID(as_uuid=True)), + Column("title", Text), + Column("body", Text), + Column("url", Text), + Column("sentiment_score", Float), + Column("sentiment_label", Enum("positive", "neutral", "negative", name="sentiment_label")), + Column("location_lat", Float), + Column("location_lon", Float), + Column("location_name", String(512)), + Column("entities", JSON), + Column("tags", JSON), + Column("raw", JSON), + Column("ingested_at", DateTime(timezone=True), server_default=func.now(), nullable=False), + Column("source_timestamp", DateTime(timezone=True), nullable=False), + # Full-text search vector + Column( + "search_vector", + TSVECTOR, + nullable=True, + ), +) + +# GIN index for full-text search +Index("ix_events_search_vector", events.c.search_vector, postgresql_using="gin") +# Spatial index on location +Index("ix_events_location", events.c.location_lat, events.c.location_lon) + + +# ── Entities (people, organizations, locations of interest) ────────────── + +entities = Table( + "entities", + metadata, + Column("id", UUID(as_uuid=True), primary_key=True, default=uuid4), + Column("name", String(512), nullable=False, index=True), + Column("entity_type", Enum( + "person", "organization", "location", "topic", "asset", + name="entity_type" + ), nullable=False), + Column("aliases", JSON), + Column("description", Text), + Column("metadata", JSON), + Column("location_lat", Float), + Column("location_lon", Float), + Column("event_count", Integer, server_default="0"), + Column("first_seen", DateTime(timezone=True), server_default=func.now()), + Column("last_seen", DateTime(timezone=True), server_default=func.now()), +) + + +# ── Entity-Event Link ──────────────────────────────────────────────────── + +entity_events = Table( + "entity_events", + metadata, + Column("entity_id", UUID(as_uuid=True), primary_key=True), + Column("event_id", UUID(as_uuid=True), primary_key=True), + Column("relevance_score", Float), + Column("linked_at", DateTime(timezone=True), server_default=func.now()), +) + + +# ── Alerts ─────────────────────────────────────────────────────────────── + +alerts = Table( + "alerts", + metadata, + Column("id", UUID(as_uuid=True), primary_key=True, default=uuid4), + Column("alert_type", Enum( + "entity_mention", "sentiment_shift", "geo_proximity", + "keyword_match", "threshold", "anomaly", + name="alert_type" + ), nullable=False), + Column("entity_id", UUID(as_uuid=True)), + Column("event_id", UUID(as_uuid=True)), + Column("severity", Enum("low", "medium", "high", "critical", name="alert_severity"), nullable=False), + Column("title", Text, nullable=False), + Column("message", Text), + Column("context", JSON), + Column("acknowledged", Integer, server_default="0"), + Column("acknowledged_by", String(256)), + Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False), + Column("resolved_at", DateTime(timezone=True)), +) + +Index("ix_alerts_severity_created", alerts.c.severity, alerts.c.created_at.desc()) +Index("ix_alerts_entity", alerts.c.entity_id) + + +# ── Documents (stored in MinIO, indexed here) ──────────────────────────── + +documents = Table( + "documents", + metadata, + Column("id", UUID(as_uuid=True), primary_key=True, default=uuid4), + Column("bucket", String(256), nullable=False), + Column("object_key", String(1024), nullable=False), + Column("content_type", String(256)), + Column("size_bytes", Integer), + Column("description", Text), + Column("tags", JSON), + Column("event_id", UUID(as_uuid=True)), + Column("uploaded_at", DateTime(timezone=True), server_default=func.now()), +) diff --git a/apps/osint-dashboard/app/requirements.txt b/apps/osint-dashboard/app/requirements.txt new file mode 100644 index 0000000..3edff72 --- /dev/null +++ b/apps/osint-dashboard/app/requirements.txt @@ -0,0 +1,13 @@ +fastapi>=0.115 +uvicorn[standard]>=0.34 +sqlalchemy[asyncio]>=2.0 +asyncpg>=0.30 +nats-py>=2.9 +redis[hiredis]>=5.2 +minio>=7.2 +pydantic>=2.10 +alembic>=1.14 +httpx>=0.28 +feedparser>=6.0 +python-dateutil>=2.9 +structlog>=24.4 diff --git a/apps/osint-dashboard/app/schemas.py b/apps/osint-dashboard/app/schemas.py new file mode 100644 index 0000000..1cac012 --- /dev/null +++ b/apps/osint-dashboard/app/schemas.py @@ -0,0 +1,242 @@ +"""OSINT Dashboard — Pydantic schemas.""" + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Optional +from uuid import UUID + +from pydantic import BaseModel, Field + + +# ─── Enums ─────────────────────────────────────────────────────────────── + +class SourceType(str, Enum): + rss = "rss" + gdel_t2 = "gdel-t2" + social = "social" + earthquake = "earthquake" + disaster = "disaster" + weather = "weather" + fire = "fire" + satellite = "satellite" + + +class EntityKind(str, Enum): + person = "person" + organization = "organization" + location = "location" + topic = "topic" + asset = "asset" + + +class Sentiment(str, Enum): + positive = "positive" + neutral = "neutral" + negative = "negative" + + +class AlertType(str, Enum): + entity_mention = "entity_mention" + sentiment_shift = "sentiment_shift" + geo_proximity = "geo_proximity" + keyword_match = "keyword_match" + threshold = "threshold" + anomaly = "anomaly" + + +class AlertSeverity(str, Enum): + low = "low" + medium = "medium" + high = "high" + critical = "critical" + + +# ─── Feed Sources ─────────────────────────────────────────────────────── + +class FeedSourceCreate(BaseModel): + name: str + source_type: SourceType + url: Optional[str] = None + config: Optional[dict] = None + + +class FeedSourceOut(BaseModel): + id: UUID + name: str + source_type: SourceType + url: Optional[str] + config: Optional[dict] + enabled: bool + created_at: datetime + + +# ─── Events ───────────────────────────────────────────────────────────── + +class EventCreate(BaseModel): + source_type: SourceType + source_id: Optional[UUID] = None + title: Optional[str] = None + body: Optional[str] = None + url: Optional[str] = None + sentiment_score: Optional[float] = None + sentiment_label: Optional[Sentiment] = None + location_lat: Optional[float] = None + location_lon: Optional[float] = None + location_name: Optional[str] = None + entities: Optional[list[dict]] = None + tags: Optional[list[str]] = None + raw: Optional[dict] = None + source_timestamp: Optional[datetime] = None + + +class EventOut(BaseModel): + id: UUID + source_type: SourceType + source_id: Optional[UUID] + title: Optional[str] + body: Optional[str] + url: Optional[str] + sentiment_score: Optional[float] + sentiment_label: Optional[Sentiment] + location_lat: Optional[float] + location_lon: Optional[float] + location_name: Optional[str] + entities: Optional[list[dict]] + tags: Optional[list[str]] + ingested_at: datetime + source_timestamp: datetime + + +# ─── Search ────────────────────────────────────────────────────────────── + +class SearchQuery(BaseModel): + q: str = Field(..., min_length=1, max_length=500) + source_type: Optional[SourceType] = None + entity_id: Optional[UUID] = None + sentiment: Optional[Sentiment] = None + min_date: Optional[datetime] = None + max_date: Optional[datetime] = None + min_lat: Optional[float] = None + max_lat: Optional[float] = None + min_lon: Optional[float] = None + max_lon: Optional[float] = None + limit: int = Field(50, ge=1, le=500) + offset: int = Field(0, ge=0) + + +class SearchResult(BaseModel): + events: list[EventOut] + total: int + has_more: bool + + +# ─── Entities ──────────────────────────────────────────────────────────── + +class EntityCreate(BaseModel): + name: str + entity_type: EntityKind + aliases: Optional[list[str]] = None + description: Optional[str] = None + metadata: Optional[dict] = None + location_lat: Optional[float] = None + location_lon: Optional[float] = None + + +class EntityOut(BaseModel): + id: UUID + name: str + entity_type: EntityKind + aliases: Optional[list[str]] + description: Optional[str] + metadata: Optional[dict] + location_lat: Optional[float] + location_lon: Optional[float] + event_count: int + first_seen: datetime + last_seen: datetime + + +# ─── Alerts ────────────────────────────────────────────────────────────── + +class AlertCreate(BaseModel): + alert_type: AlertType + entity_id: Optional[UUID] = None + event_id: Optional[UUID] = None + severity: AlertSeverity + title: str + message: Optional[str] = None + context: Optional[dict] = None + + +class AlertUpdate(BaseModel): + acknowledged: Optional[bool] = None + acknowledged_by: Optional[str] = None + resolved_at: Optional[datetime] = None + + +class AlertOut(BaseModel): + id: UUID + alert_type: AlertType + entity_id: Optional[UUID] + event_id: Optional[UUID] + severity: AlertSeverity + title: str + message: Optional[str] + context: Optional[dict] + acknowledged: bool + acknowledged_by: Optional[str] + created_at: datetime + resolved_at: Optional[datetime] + + +# ─── Documents ─────────────────────────────────────────────────────────── + +class DocumentCreate(BaseModel): + bucket: str + object_key: str + content_type: Optional[str] = None + size_bytes: Optional[int] = None + description: Optional[str] = None + tags: Optional[list[str]] = None + event_id: Optional[UUID] = None + + +class DocumentOut(BaseModel): + id: UUID + bucket: str + object_key: str + content_type: Optional[str] + size_bytes: Optional[int] + description: Optional[str] + tags: Optional[list[str]] + event_id: Optional[UUID] + uploaded_at: datetime + + +# ─── Aggregations ──────────────────────────────────────────────────────── + +class SentimentSummary(BaseModel): + period: str + positive_count: int + neutral_count: int + negative_count: int + avg_score: float + + +class TimelinePoint(BaseModel): + timestamp: datetime + event_count: int + avg_sentiment: float + + +class DashboardSummary(BaseModel): + total_events: int + events_last_24h: int + active_sources: int + open_alerts: int + tracked_entities: int + sentiment: SentimentSummary + top_entities: list[EntityOut] + diff --git a/apps/osint-dashboard/app/sources.py b/apps/osint-dashboard/app/sources.py new file mode 100644 index 0000000..8ca87ff --- /dev/null +++ b/apps/osint-dashboard/app/sources.py @@ -0,0 +1,186 @@ +"""Data source ingestors — fetch from external APIs and push to NATS.""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime + +import httpx +import feedparser +import nats + +logger = logging.getLogger("osint.sources") + + +def _parse_rfc822(date_str: object) -> str | None: + """Parse RFC-822 date string from feedparser entries.""" + if not isinstance(date_str, str): + return None + try: + return parsedate_to_datetime(date_str).astimezone(timezone.utc).isoformat() + except (ValueError, TypeError): + return None + +# NATS connection +NATS_URLS = "nats://osint-nats.customer1.svc.cluster.local:4222" + + +async def publish_event(subject: str, event: dict): + """Publish an event to NATS JetStream.""" + nc = await nats.connect(NATS_URLS) + js = nc.jetstream() + await js.publish(subject, json.dumps(event).encode()) + await nc.close() + logger.debug("Published event to %s", subject) + + +# ─── RSS Feed Ingestor ────────────────────────────────────────────────── + +async def ingest_rss_feed(feed_url: str, source_id: str = None): + """Fetch and parse an RSS feed, publish items to NATS.""" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(feed_url) + resp.raise_for_status() + feed = feedparser.parse(resp.text) + + count = 0 + for entry in feed.entries[:100]: # max 100 per run + event = { + "source_type": "rss", + "source_id": source_id, + "title": entry.get("title"), + "body": entry.get("summary") or entry.get("description"), + "url": entry.get("link"), + "source_timestamp": _parse_rfc822(entry.get("published")) + or datetime.now(timezone.utc).isoformat(), + "tags": [t.get("term") for t in entry.get("tags", []) if t.get("term")], + "raw": { + "feed_title": feed.feed.get("title"), + "author": entry.get("author"), + "categories": [c.get("term") for c in entry.get("categories", [])], + }, + } + await publish_event("events.rss", event) + count += 1 + + logger.info("Ingested %d items from RSS feed %s", count, feed_url) + return count + + +# ─── GDELT 2.0 Ingestor ───────────────────────────────────────────────── + +GDELT_API = "https://api.gdeltproject.org/gdeltv2" + + +async def ingest_gdelt(query: str = "", max_articles: int = 50): + """Fetch articles from GDELT 2.0 API.""" + params = { + "mode": "artlist", + "format": "json", + "maxrecords": max_articles, + "mode": "artlist", + } + if query: + params["search"] = query + + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.get(GDELT_API, params=params) + resp.raise_for_status() + data = resp.json() + + count = 0 + for article in data.get("articles", []): + event = { + "source_type": "gdel-t2", + "title": article.get("title"), + "body": article.get("articleBody"), + "url": article.get("url"), + "sentiment_score": _parse_gdelt_tone(article.get("Tone", "0")), + "location_lat": article.get("Latitude"), + "location_lon": article.get("Longitude"), + "location_name": article.get("Location"), + "source_timestamp": article.get("FirstCreated"), + "entities": [ + {"name": e.get("Topic"), "type": "topic"} + for e in article.get("Mentions", []) + if e.get("Topic") + ], + "raw": article, + } + await publish_event("events.gdelt", event) + count += 1 + + logger.info("Ingested %d articles from GDELT", count) + return count + + +def _parse_gdelt_tone(tone: str) -> float | None: + """Parse GDELT tone string to a -1..1 sentiment score.""" + try: + tone_float = float(tone) + return max(-1.0, min(1.0, tone_float / 4249.0)) # GDELT tone range + except (ValueError, TypeError): + return None + + +# ─── Earthquake Ingestor (USGS) ───────────────────────────────────────── + +USGS_API = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson" + + +async def ingest_earthquakes(): + """Fetch recent earthquakes from USGS.""" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(USGS_API) + resp.raise_for_status() + data = resp.json() + + count = 0 + for feature in data.get("features", []): + props = feature.get("properties", {}) + geometry = feature.get("geometry", {}).get("coordinates", []) + event = { + "source_type": "earthquake", + "title": props.get("title"), + "body": props.get("description"), + "url": props.get("url"), + "location_lat": geometry[1] if len(geometry) > 1 else None, + "location_lon": geometry[0] if len(geometry) > 0 else None, + "location_name": props.get("place"), + "sentiment_label": "neutral", + "tags": [f"magnitude:{props.get('mag')}"] if props.get("mag") else [], + "source_timestamp": ( + datetime.utcfromtimestamp(props.get("time", 0) / 1000) + .replace(tzinfo=timezone.utc) + .isoformat() + ), + "raw": props, + } + await publish_event("events.earthquake", event) + count += 1 + + logger.info("Ingested %d earthquake events", count) + return count + + +# ─── Social Signals (Twitter/X-like placeholder) ──────────────────────── + +async def ingest_social_signals(query: str = "", max_items: int = 50): + """Placeholder for social media signal ingestion. + + In production, this would connect to Twitter API, Reddit, NewsAPI, etc. + For now, it publishes a heartbeat to signal the pipeline is active. + """ + event = { + "source_type": "social", + "title": f"Social signal scan: {query}", + "body": f"Scanned for '{query}' — placeholder connector", + "source_timestamp": datetime.now(timezone.utc).isoformat(), + "tags": [query] if query else [], + "raw": {"query": query, "max_items": max_items, "connector": "placeholder"}, + } + await publish_event("events.social", event) + logger.info("Social signal scan complete for '%s'", query) + return 1 diff --git a/apps/osint-dashboard/app/static/index.html b/apps/osint-dashboard/app/static/index.html new file mode 100644 index 0000000..dbf3f8b --- /dev/null +++ b/apps/osint-dashboard/app/static/index.html @@ -0,0 +1,307 @@ + + + + + + OSINT Dashboard + + + +
+

OSINT Dashboard

+
+ Status: checking... +  |  Last update: - +
+
+ +
+ +
+

Total Events

-
+

Events (24h)

-
last 24 hours
+

Active Sources

-
+

Open Alerts

-
+

Tracked Entities

-
+
+

Sentiment (24h)

+
+
+
+
+
+
+
+
+ + +
+

Search Events

+ +
+ + +
+ + + + +
+ + +
+

Recent Events

+ + + +
TimeSourceTitleSentimentLocation
+
+ + + + + + + + + + + + +
+ + + +