Merge pull request #204 from CarterPerez-dev/project/monitor-the-situation-dashboard
Project/monitor the situation dashboard
This commit is contained in:
commit
1a04003ce1
|
|
@ -164,12 +164,22 @@ repos:
|
|||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: check-yaml
|
||||
exclude: (\.pnpm-store|node_modules)/
|
||||
- id: check-json
|
||||
exclude: (\.pnpm-store|node_modules)/
|
||||
- id: check-toml
|
||||
exclude: (\.pnpm-store|node_modules)/
|
||||
- id: check-ast
|
||||
exclude: (\.pnpm-store|node_modules|\.venv)/
|
||||
- id: check-symlinks
|
||||
exclude: (\.pnpm-store|node_modules)/
|
||||
- id: end-of-file-fixer
|
||||
exclude: (\.pnpm-store|node_modules)/
|
||||
- id: trailing-whitespace
|
||||
exclude: (\.pnpm-store|node_modules)/
|
||||
- id: check-illegal-windows-names
|
||||
exclude: (\.pnpm-store|node_modules)/
|
||||
- id: check-executables-have-shebangs
|
||||
exclude: (\.pnpm-store|node_modules)/
|
||||
- id: check-shebang-scripts-are-executable
|
||||
exclude: (\.pnpm-store|node_modules)/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
# ©AngelaMos | 2026
|
||||
# .env.example.dev
|
||||
|
||||
APP_NAME=monitor
|
||||
PUBLIC_URL=http://localhost:8432
|
||||
|
||||
NGINX_HOST_PORT=8432
|
||||
BACKEND_HOST_PORT=5432
|
||||
FRONTEND_HOST_PORT=3432
|
||||
POSTGRES_HOST_PORT=4432
|
||||
REDIS_HOST_PORT=6432
|
||||
|
||||
POSTGRES_USER=monitor
|
||||
POSTGRES_PASSWORD=changeme
|
||||
POSTGRES_DB=monitor
|
||||
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# AES-256 key for at-rest encryption of webhook URLs / bot tokens.
|
||||
# Generate with: openssl rand -base64 32
|
||||
# Without this set, the entire notifications + alerts pipeline is disabled.
|
||||
NOTIFICATION_ENCRYPTION_KEY=
|
||||
|
||||
NVD_API_KEY=
|
||||
CF_RADAR_TOKEN=
|
||||
GREYNOISE_API_KEY=
|
||||
ABUSEIPDB_API_KEY=
|
||||
|
||||
# Number of trusted reverse-proxy hops in front of the backend.
|
||||
# Dev: nginx -> backend = 1. Prod with Cloudflare Tunnel = 2 (CF + nginx).
|
||||
TRUSTED_PROXY_HOPS=1
|
||||
|
||||
# Email for the bootstrap admin. The matching account is auto-promoted to
|
||||
# role=admin on registration / login. Leave blank to disable auto-promotion.
|
||||
ADMIN_EMAIL=
|
||||
|
||||
VITE_API_URL=/api
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# ©AngelaMos | 2026
|
||||
# .env.example
|
||||
|
||||
APP_NAME=monitor
|
||||
PUBLIC_URL=http://localhost:8432
|
||||
|
||||
NGINX_HOST_PORT=8432
|
||||
BACKEND_HOST_PORT=5432
|
||||
FRONTEND_HOST_PORT=3432
|
||||
POSTGRES_HOST_PORT=4432
|
||||
REDIS_HOST_PORT=6432
|
||||
|
||||
POSTGRES_USER=monitor
|
||||
POSTGRES_PASSWORD=changeme
|
||||
POSTGRES_DB=monitor
|
||||
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# AES-256 key for at-rest encryption of webhook URLs / bot tokens.
|
||||
# Generate with: openssl rand -base64 32
|
||||
# Without this set, the entire notifications + alerts pipeline is disabled.
|
||||
NOTIFICATION_ENCRYPTION_KEY=
|
||||
|
||||
NVD_API_KEY=
|
||||
CF_RADAR_TOKEN=
|
||||
GREYNOISE_API_KEY=
|
||||
ABUSEIPDB_API_KEY=
|
||||
|
||||
CLOUDFLARE_TUNNEL_TOKEN=
|
||||
|
||||
# Number of trusted reverse-proxy hops in front of the backend.
|
||||
# Dev: nginx -> backend = 1. Prod with Cloudflare Tunnel = 2 (CF + nginx).
|
||||
TRUSTED_PROXY_HOPS=1
|
||||
|
||||
# Email for the bootstrap admin. The matching account is auto-promoted to
|
||||
# role=admin on registration / login. Leave blank to disable auto-promotion.
|
||||
ADMIN_EMAIL=
|
||||
|
||||
VITE_API_URL=/api
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# ©AngelaMos | 2026
|
||||
# .env.example.prod
|
||||
|
||||
APP_NAME=situation-monitor-prod
|
||||
PUBLIC_URL=https://xxx.xxx
|
||||
|
||||
NGINX_HOST_PORT=7729
|
||||
BACKEND_HOST_PORT=9292
|
||||
FRONTEND_HOST_PORT=49967
|
||||
POSTGRES_HOST_PORT=47844
|
||||
REDIS_HOST_PORT=55675
|
||||
|
||||
POSTGRES_USER=
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_DB=
|
||||
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# AES-256 key for at-rest encryption of webhook URLs / bot tokens.
|
||||
# Generate with: openssl rand -base64 32
|
||||
# Without this set, the entire notifications + alerts pipeline is disabled.
|
||||
NOTIFICATION_ENCRYPTION_KEY=
|
||||
|
||||
NVD_API_KEY=
|
||||
CF_RADAR_TOKEN=c
|
||||
GREYNOISE_API_KEY=
|
||||
ABUSEIPDB_API_KEY=
|
||||
|
||||
CLOUDFLARE_TUNNEL_TOKEN=
|
||||
|
||||
VITE_API_URL=/api
|
||||
|
||||
# Number of trusted reverse-proxy hops in front of the backend.
|
||||
# Dev: nginx -> backend = 1. Prod with Cloudflare Tunnel = 2 (CF + nginx).
|
||||
TRUSTED_PROXY_HOPS=2
|
||||
|
||||
# Email for the bootstrap admin. The matching account is auto-promoted to
|
||||
# role=admin on registration / login. Leave blank to disable auto-promotion.
|
||||
ADMIN_EMAIL=
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# ©AngelaMos | 2026
|
||||
# .gitignore
|
||||
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.prod
|
||||
|
||||
backend/tmp/
|
||||
backend/bin/
|
||||
backend/coverage.out
|
||||
backend/coverage.html
|
||||
backend/keys/
|
||||
|
||||
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
frontend/.vite/
|
||||
frontend/.pnpm-store/
|
||||
|
||||
docs/
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
# ©AngelaMos | 2026
|
||||
# Justfile
|
||||
|
||||
set export
|
||||
set shell := ["bash", "-uc"]
|
||||
|
||||
project := file_name(justfile_directory())
|
||||
version := `git describe --tags --always 2>/dev/null || echo "dev"`
|
||||
binary := "monitor"
|
||||
|
||||
# =============================================================================
|
||||
# Default
|
||||
# =============================================================================
|
||||
|
||||
default:
|
||||
@just --list --unsorted
|
||||
|
||||
# =============================================================================
|
||||
# Linting and Formatting
|
||||
# =============================================================================
|
||||
|
||||
[group('lint')]
|
||||
lint *ARGS:
|
||||
cd backend && golangci-lint run --timeout=5m {{ARGS}}
|
||||
|
||||
[group('lint')]
|
||||
lint-fix:
|
||||
cd backend && golangci-lint run --timeout=5m --fix
|
||||
|
||||
[group('lint')]
|
||||
format:
|
||||
cd backend && golangci-lint fmt
|
||||
|
||||
[group('lint')]
|
||||
tidy:
|
||||
cd backend && go mod tidy
|
||||
|
||||
[group('lint')]
|
||||
vet:
|
||||
cd backend && go vet ./...
|
||||
|
||||
# =============================================================================
|
||||
# Testing
|
||||
# =============================================================================
|
||||
|
||||
[group('test')]
|
||||
test *ARGS:
|
||||
cd backend && go test -race ./... {{ARGS}}
|
||||
|
||||
[group('test')]
|
||||
test-v *ARGS:
|
||||
cd backend && go test -race -v ./... {{ARGS}}
|
||||
|
||||
[group('test')]
|
||||
cover:
|
||||
cd backend && go test -race -cover ./...
|
||||
|
||||
# =============================================================================
|
||||
# CI / Quality
|
||||
# =============================================================================
|
||||
|
||||
[group('ci')]
|
||||
ci: lint test
|
||||
@echo "All checks passed."
|
||||
|
||||
[group('ci')]
|
||||
check: lint vet
|
||||
|
||||
# =============================================================================
|
||||
# Development (host go)
|
||||
# =============================================================================
|
||||
|
||||
[group('dev')]
|
||||
run *ARGS:
|
||||
cd backend && go run ./cmd/api {{ARGS}}
|
||||
|
||||
[group('dev')]
|
||||
dev-serve:
|
||||
cd backend && go run ./cmd/api -config config.yaml
|
||||
|
||||
# =============================================================================
|
||||
# Build (Production)
|
||||
# =============================================================================
|
||||
|
||||
[group('prod')]
|
||||
build:
|
||||
cd backend && go build -ldflags="-s -w" -o ../bin/{{binary}} ./cmd/api
|
||||
@echo "Built: bin/{{binary}} ($(du -h bin/{{binary}} | cut -f1))"
|
||||
|
||||
[group('prod')]
|
||||
build-static:
|
||||
cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o ../bin/{{binary}} ./cmd/api
|
||||
@echo "Built static: bin/{{binary}} ($(du -h bin/{{binary}} | cut -f1))"
|
||||
|
||||
[group('prod')]
|
||||
build-debug:
|
||||
cd backend && go build -o ../bin/{{binary}} ./cmd/api
|
||||
|
||||
[group('prod')]
|
||||
install:
|
||||
cd backend && go install ./cmd/api
|
||||
|
||||
# =============================================================================
|
||||
# Database
|
||||
# =============================================================================
|
||||
|
||||
[group('db')]
|
||||
migrate *ARGS:
|
||||
docker compose --env-file .env.prod -f compose.yml exec backend sh -c 'goose -dir /migrations postgres "$DATABASE_URL" up' {{ARGS}}
|
||||
|
||||
[group('db')]
|
||||
migrate-dev *ARGS:
|
||||
docker compose -f dev.compose.yml exec backend sh -c 'cd /app && goose -dir migrations postgres "$DATABASE_URL" up' {{ARGS}}
|
||||
|
||||
[group('db')]
|
||||
migrate-down:
|
||||
docker compose -f dev.compose.yml exec backend sh -c 'cd /app && goose -dir migrations postgres "$DATABASE_URL" down'
|
||||
|
||||
[group('db')]
|
||||
migrate-status:
|
||||
docker compose -f dev.compose.yml exec backend sh -c 'cd /app && goose -dir migrations postgres "$DATABASE_URL" status'
|
||||
|
||||
[group('db')]
|
||||
psql:
|
||||
docker compose -f dev.compose.yml exec postgres psql -U $${POSTGRES_USER:-monitor} -d $${POSTGRES_DB:-monitor}
|
||||
|
||||
[group('db')]
|
||||
redis-cli:
|
||||
docker compose -f dev.compose.yml exec redis redis-cli
|
||||
|
||||
# =============================================================================
|
||||
# Docker (production)
|
||||
# =============================================================================
|
||||
|
||||
[group('docker')]
|
||||
up *ARGS:
|
||||
docker compose --env-file .env.prod -f compose.yml up {{ARGS}}
|
||||
|
||||
[group('docker')]
|
||||
start *ARGS:
|
||||
docker compose --env-file .env.prod -f compose.yml up -d --build {{ARGS}}
|
||||
|
||||
[group('docker')]
|
||||
down *ARGS:
|
||||
docker compose --env-file .env.prod -f compose.yml down {{ARGS}}
|
||||
|
||||
[group('docker')]
|
||||
logs *SERVICE:
|
||||
docker compose --env-file .env.prod -f compose.yml logs -f {{SERVICE}}
|
||||
|
||||
[group('docker')]
|
||||
ps:
|
||||
docker compose --env-file .env.prod -f compose.yml ps
|
||||
|
||||
[group('docker')]
|
||||
build-prod:
|
||||
docker compose --env-file .env.prod -f compose.yml build
|
||||
|
||||
# =============================================================================
|
||||
# Docker (development)
|
||||
# =============================================================================
|
||||
|
||||
[group('docker')]
|
||||
dev-up *ARGS:
|
||||
docker compose -f dev.compose.yml up {{ARGS}}
|
||||
|
||||
[group('docker')]
|
||||
dev-start *ARGS:
|
||||
docker compose -f dev.compose.yml up -d --build {{ARGS}}
|
||||
|
||||
[group('docker')]
|
||||
dev-down *ARGS:
|
||||
docker compose -f dev.compose.yml down {{ARGS}}
|
||||
|
||||
[group('docker')]
|
||||
dev-logs *SERVICE:
|
||||
docker compose -f dev.compose.yml logs -f {{SERVICE}}
|
||||
|
||||
[group('docker')]
|
||||
dev-ps:
|
||||
docker compose -f dev.compose.yml ps
|
||||
|
||||
[group('docker')]
|
||||
dev-shell service='backend':
|
||||
docker compose -f dev.compose.yml exec -it {{service}} /bin/sh
|
||||
|
||||
[group('docker')]
|
||||
dev-restart:
|
||||
docker compose -f dev.compose.yml down
|
||||
docker compose -f dev.compose.yml up -d --build
|
||||
docker compose -f dev.compose.yml logs -f
|
||||
|
||||
[group('docker')]
|
||||
dev-clean:
|
||||
docker compose -f dev.compose.yml down -v
|
||||
|
||||
# =============================================================================
|
||||
# Cloudflare Tunnel
|
||||
# =============================================================================
|
||||
|
||||
[group('tunnel')]
|
||||
tunnel-up *ARGS:
|
||||
docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml up {{ARGS}}
|
||||
|
||||
[group('tunnel')]
|
||||
tunnel-start *ARGS:
|
||||
docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml up -d --build {{ARGS}}
|
||||
|
||||
[group('tunnel')]
|
||||
tunnel-down *ARGS:
|
||||
docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml down {{ARGS}}
|
||||
|
||||
[group('tunnel')]
|
||||
tunnel-logs:
|
||||
docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml logs -f cloudflared
|
||||
|
||||
[group('tunnel')]
|
||||
prod-restart:
|
||||
docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml down
|
||||
docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml up -d
|
||||
docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml logs -f
|
||||
|
||||
[group('tunnel')]
|
||||
prod-redeploy:
|
||||
docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml down
|
||||
docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml up -d --build
|
||||
docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml logs -f
|
||||
|
||||
[group('tunnel')]
|
||||
prod-logs *SERVICE:
|
||||
docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml logs -f {{SERVICE}}
|
||||
|
||||
# =============================================================================
|
||||
# Utilities
|
||||
# =============================================================================
|
||||
|
||||
[group('util')]
|
||||
info:
|
||||
@echo "Project: {{project}}"
|
||||
@echo "Version: {{version}}"
|
||||
@echo "Go: $(cd backend && go version | cut -d' ' -f3)"
|
||||
@echo "OS: {{os()}} ({{arch()}})"
|
||||
@echo "Module: $(head -1 backend/go.mod | cut -d' ' -f2)"
|
||||
|
||||
[group('util')]
|
||||
clean:
|
||||
-rm -rf bin/
|
||||
-rm -rf backend/tmp/
|
||||
@echo "Cleaned build artifacts."
|
||||
|
||||
[group('util')]
|
||||
env-init:
|
||||
@if [ ! -f .env ]; then cp .env.example .env && echo "created .env from .env.example — fill in secrets"; else echo ".env already exists"; fi
|
||||
|
|
@ -0,0 +1,661 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
<!--
|
||||
©AngelaMos | 2026
|
||||
README.md
|
||||
-->
|
||||
|
||||
```json
|
||||
██████╗██╗ █████╗
|
||||
██╔════╝██║██╔══██╗
|
||||
██║ ██║███████║
|
||||
██║ ██║██╔══██║
|
||||
╚██████╗██║██║ ██║
|
||||
╚═════╝╚═╝╚═╝ ╚═╝
|
||||
```
|
||||
|
||||
[](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/advanced/monitor-the-situation-dashboard)
|
||||
[](https://go.dev)
|
||||
[](https://react.dev)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](https://iminthewalls.com/)
|
||||
[](https://www.docker.com)
|
||||
|
||||
> Operator-grade real-time situational awareness dashboard. Eleven live feeds across cyber, world, and finance — fused into a single 3D-globe SOC view with WebSocket delivery and configurable alerting.
|
||||
|
||||
*This is a quick overview — security theory, architecture, and full walkthroughs are in the [learn modules](#learn).*
|
||||
|
||||
> The phrase "monitoring the situation" is a Twitter/X meme from June 2025. This is the version that actually monitors the situation.
|
||||
|
||||
## What It Does
|
||||
|
||||
- Aggregates 11 high-signal data feeds (DShield, Cloudflare Radar, NVD/EPSS, CISA KEV, ransomware.live, Coinbase WS, USGS, NOAA SWPC, Wikipedia ITN, GDELT, ISS) with per-source cadences from sub-second to daily
|
||||
- WebSocket fan-out from a single Go binary — collectors run as errgroup goroutines, events flow through an in-process bus to all connected clients
|
||||
- 3D MapLibre globe centerpiece with country-level outage shading, BGP hijack regions, mass-scan source ASN dots, ransomware victim markers, earthquake epicenters, and live ISS orbital track
|
||||
- CVE velocity timeline with EPSS-weighted prioritization and CISA KEV diff alerts
|
||||
- Configurable alerts (toast / banner / chime / Telegram / Discord) with AES-256 encryption of webhook secrets at rest
|
||||
- BRIN-indexed Postgres time-series storage tuned for append-mostly event streams
|
||||
- JWT auth with auto-rotating Ed25519 keys, public read-only mode, multi-device session management
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
just dev-up
|
||||
```
|
||||
|
||||
Visit `http://localhost:8432` or the live demo at [iminthewalls.com](https://iminthewalls.com/)
|
||||
|
||||
> [!TIP]
|
||||
> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available commands.
|
||||
>
|
||||
> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin`
|
||||
|
||||
## Stack
|
||||
|
||||
**Backend:** Go 1.25, chi v5, `coder/websocket`, pgx + pgxpool, goose migrations, errgroup-driven collectors, Argon2id, JWT (Ed25519)
|
||||
|
||||
**Frontend:** React 19, TypeScript, Vite, TanStack Query v5, Zustand, MapLibre GL, D3, SCSS Modules
|
||||
|
||||
**Data:** PostgreSQL 16 (BRIN time-series indexes), Redis 7
|
||||
|
||||
**Infrastructure:** Docker Compose, nginx reverse proxy, Cloudflare Tunnel (prod), multi-stage builds, air for live reload
|
||||
|
||||
## Data Sources
|
||||
|
||||
| Panel | Source | Cadence | Auth |
|
||||
|-------|--------|---------|------|
|
||||
| Mass-scan firehose | DShield (SANS ISC) | 1h | none |
|
||||
| Internet outages + BGP hijacks | Cloudflare Radar | 5m | `CF_RADAR_TOKEN` |
|
||||
| CVE velocity + EPSS | NVD CVE 2.0 + FIRST EPSS | 2h | `NVD_API_KEY` (optional) |
|
||||
| CISA KEV (in-the-wild) | CISA KEV catalog | 1h | none |
|
||||
| Ransomware victims | ransomware.live | 15m | none |
|
||||
| BTC + ETH live ticks | Coinbase Advanced Trade WS | persistent | none |
|
||||
| Earthquakes (M2.5+) | USGS GeoJSON | 1m | none |
|
||||
| Space weather (Kp / Bz / X-flux) | NOAA SWPC | 1m / 3h | none |
|
||||
| World events | Wikipedia ITN + GDELT v2 | 5m / 15m | none |
|
||||
| ISS position + passes | wheretheiss.at + CelesTrak | 10s / 24h | none |
|
||||
| IP enrichment (BGP) | AbuseIPDB | on-demand | `ABUSEIPDB_API_KEY` (optional) |
|
||||
|
||||
## Production (Cloudflare Tunnel)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
just prod-redeploy
|
||||
just migrate
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd backend && go test -race ./...
|
||||
```
|
||||
|
||||
## Learn
|
||||
|
||||
This project includes step-by-step learning materials covering security theory, architecture, and implementation.
|
||||
|
||||
| Module | Topic |
|
||||
|--------|-------|
|
||||
| [00 - Overview](learn/00-OVERVIEW.md) | Prerequisites and quick start |
|
||||
| [01 - Concepts](learn/01-CONCEPTS.md) | Threat intel feeds, BGP hijacks, EPSS, KEV, situational awareness theory |
|
||||
| [02 - Architecture](learn/02-ARCHITECTURE.md) | Single-binary collector pipeline, in-process event bus, WebSocket fan-out |
|
||||
| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Code walkthrough across collectors, snapshot, ws, alerts |
|
||||
| [04 - Challenges](learn/04-CHALLENGES.md) | Extension ideas (additional feeds, custom alerts, deployment) |
|
||||
|
||||
## License
|
||||
|
||||
AGPL 3.0
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
# AngelaMos | 2026
|
||||
# .air.toml - Hot reload configuration
|
||||
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o ./tmp/main ./cmd/api"
|
||||
bin = "tmp/main"
|
||||
full_bin = "./tmp/main"
|
||||
include_ext = ["go", "yaml", "yml"]
|
||||
exclude_dir = ["tmp", "vendor", "bin", "keys", "migrations"]
|
||||
exclude_regex = ["_test\\.go"]
|
||||
delay = 1000
|
||||
stop_on_error = true
|
||||
send_interrupt = true
|
||||
kill_delay = 500
|
||||
|
||||
[log]
|
||||
time = false
|
||||
|
||||
[color]
|
||||
main = "cyan"
|
||||
watcher = "magenta"
|
||||
build = "yellow"
|
||||
runner = "green"
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
# AngelaMos | 2026
|
||||
# .env.example
|
||||
|
||||
# Environment: development, staging, production
|
||||
ENVIRONMENT=development
|
||||
|
||||
# Server
|
||||
HOST=0.0.0.0
|
||||
PORT=8080
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/app?sslmode=disable
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# JWT
|
||||
JWT_PRIVATE_KEY_PATH=keys/private.pem
|
||||
JWT_PUBLIC_KEY_PATH=keys/public.pem
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_REQUESTS=100
|
||||
RATE_LIMIT_WINDOW=1m
|
||||
|
||||
# OpenTelemetry
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
||||
OTEL_SERVICE_NAME=go-backend
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=debug
|
||||
LOG_FORMAT=text
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
# AngelaMos | 2026
|
||||
# .gitignore
|
||||
|
||||
# Binaries
|
||||
bin/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test
|
||||
*.test
|
||||
coverage.out
|
||||
coverage.html
|
||||
|
||||
# Build
|
||||
tmp/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Keys (sensitive)
|
||||
keys/*.pem
|
||||
keys/*.key
|
||||
|
||||
# Vendor (if using)
|
||||
vendor/
|
||||
|
||||
# Debug
|
||||
__debug_bin*
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
# AngelaMos | 2026
|
||||
# .golangci.yml
|
||||
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- errcheck
|
||||
- govet
|
||||
- gosec
|
||||
- bodyclose
|
||||
- nilerr
|
||||
- errorlint
|
||||
- exhaustive
|
||||
- gocritic
|
||||
- funlen
|
||||
- gocognit
|
||||
- dupl
|
||||
- goconst
|
||||
- ineffassign
|
||||
- unused
|
||||
- unconvert
|
||||
- unparam
|
||||
- testifylint
|
||||
- fatcontext
|
||||
|
||||
settings:
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
check-blank: false
|
||||
exclude-functions:
|
||||
- (io.Closer).Close
|
||||
- (*github.com/coder/websocket.Conn).Close
|
||||
- (*nhooyr.io/websocket.Conn).Close
|
||||
- encoding/json.Marshal
|
||||
|
||||
funlen:
|
||||
lines: 100
|
||||
statements: 50
|
||||
|
||||
gocognit:
|
||||
min-complexity: 20
|
||||
|
||||
govet:
|
||||
enable-all: true
|
||||
disable:
|
||||
- fieldalignment
|
||||
|
||||
revive:
|
||||
rules:
|
||||
- name: blank-imports
|
||||
- name: context-as-argument
|
||||
- name: context-keys-type
|
||||
- name: error-return
|
||||
- name: error-strings
|
||||
- name: error-naming
|
||||
- name: exported
|
||||
- name: increment-decrement
|
||||
- name: var-declaration
|
||||
- name: package-comments
|
||||
disabled: true
|
||||
- name: range
|
||||
- name: receiver-naming
|
||||
- name: time-naming
|
||||
- name: unexported-return
|
||||
- name: indent-error-flow
|
||||
- name: errorf
|
||||
- name: empty-block
|
||||
- name: superfluous-else
|
||||
- name: unreachable-code
|
||||
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
|
||||
gosec:
|
||||
excludes:
|
||||
- G104 # unhandled errors — covered by errcheck where actionable
|
||||
- G101 # hardcoded credentials — false on env-var-name maps + HTTP header constants
|
||||
- G117 # exported field name matches secret pattern — false on every Password/APIKey/Token DTO field
|
||||
- G115 # int conversions — values are config-bounded, no realistic overflow
|
||||
- G304 # file inclusion via variable — false on test fixtures + safe internal paths
|
||||
- G704 # SSRF via taint — webhook senders POST to user-supplied URLs by design
|
||||
- G706 # log injection — using slog structured fields, not concat
|
||||
|
||||
testifylint:
|
||||
disable:
|
||||
- go-require # fires inside httptest handler closures; known false-positive pattern
|
||||
|
||||
sloglint:
|
||||
no-mixed-args: true
|
||||
kv-only: true
|
||||
context: all
|
||||
|
||||
exclusions:
|
||||
paths:
|
||||
- vendor
|
||||
- testdata
|
||||
rules:
|
||||
# test files: don't enforce production-grade hygiene on fixtures + mocks
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- funlen
|
||||
- dupl
|
||||
- goconst
|
||||
- gocritic
|
||||
- errcheck
|
||||
- exhaustive
|
||||
|
||||
# bodyclose: coder/websocket Dial returns a *http.Response whose body
|
||||
# is empty by protocol — closing it isn't part of the websocket idiom
|
||||
- path: internal/ws/
|
||||
linters:
|
||||
- bodyclose
|
||||
|
||||
# bodyclose: ratelimit.New[*http.Response](...) is a generic type param,
|
||||
# not an actual response body — linter pattern-matches the type symbol
|
||||
- path: internal/httpx/client\.go
|
||||
linters:
|
||||
- bodyclose
|
||||
|
||||
# main.go bootstrap is inherently long + branchy
|
||||
- path: cmd/api/main\.go
|
||||
linters:
|
||||
- gocognit
|
||||
- funlen
|
||||
|
||||
# auth.Login vs auth.Register: structurally similar, semantically
|
||||
# different (different DTOs, errors, status codes). Deduping with a
|
||||
# generic helper would obscure the code more than the duplication does.
|
||||
- path: internal/auth/handler\.go
|
||||
linters:
|
||||
- dupl
|
||||
|
||||
# coinbase websocket state machine: handleConn is inherently branchy
|
||||
- path: internal/collectors/coinbase/collector\.go
|
||||
linters:
|
||||
- gocognit
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gci # Groups imports
|
||||
- gofumpt # Whitespace
|
||||
- golines # Vertical wrap
|
||||
settings:
|
||||
golines:
|
||||
max-len: 80
|
||||
reformat-tags: true
|
||||
goimports:
|
||||
local-prefixes:
|
||||
- github.com/carterperez-dev/monitor-the-situation/backend
|
||||
gci:
|
||||
sections:
|
||||
- standard
|
||||
- default
|
||||
- prefix(github.com/carterperez-dev)
|
||||
custom-order: true
|
||||
gofumpt:
|
||||
extra-rules: true
|
||||
|
|
@ -0,0 +1,686 @@
|
|||
// ©AngelaMos | 2026
|
||||
// main.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/admin"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/alerts"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/auth"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/bus"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cfradar"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/dshield"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/gdelt"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/heartbeat"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/iss"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/kev"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/ransomware"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/state"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/swpc"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/usgs"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/wikipedia"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/config"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/core"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/enrich/abuseipdb"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/enrich/greynoise"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/health"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/intel"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/notifications"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/redisring"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/server"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/snapshot"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/user"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/ws"
|
||||
)
|
||||
|
||||
const (
|
||||
drainDelay = 5 * time.Second
|
||||
)
|
||||
|
||||
type abuseipdbEnricher struct {
|
||||
client *abuseipdb.Client
|
||||
}
|
||||
|
||||
func (a abuseipdbEnricher) Lookup(
|
||||
ctx context.Context,
|
||||
ip string,
|
||||
) (cfradar.Enrichment, error) {
|
||||
v, err := a.client.Lookup(ctx, ip)
|
||||
if err != nil {
|
||||
return cfradar.Enrichment{}, err
|
||||
}
|
||||
return cfradar.Enrichment{
|
||||
Country: v.CountryCode,
|
||||
AbuseConfidence: v.AbuseConfidenceScore,
|
||||
ISP: v.ISP,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type compositeDshieldEnricher struct {
|
||||
abuse *abuseipdb.Client
|
||||
gn *greynoise.Cached
|
||||
}
|
||||
|
||||
func (c compositeDshieldEnricher) Lookup(
|
||||
ctx context.Context,
|
||||
ip string,
|
||||
) (dshield.DShieldEnrichment, error) {
|
||||
out := dshield.DShieldEnrichment{}
|
||||
if c.abuse != nil {
|
||||
if v, err := c.abuse.Lookup(ctx, ip); err == nil {
|
||||
out.Country = v.CountryCode
|
||||
}
|
||||
}
|
||||
if c.gn != nil {
|
||||
if v, err := c.gn.Lookup(ctx, ip); err == nil {
|
||||
out.Classification = v.Classification
|
||||
out.Actor = v.Name
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "config.yaml", "path to config file")
|
||||
healthcheck := flag.Bool(
|
||||
"healthcheck",
|
||||
false,
|
||||
"probe local /healthz and exit 0 if healthy, 1 otherwise",
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if *healthcheck {
|
||||
os.Exit(runHealthcheck())
|
||||
}
|
||||
|
||||
if err := run(*configPath); err != nil {
|
||||
slog.Error("application error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runHealthcheck() int {
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := client.Get("http://127.0.0.1:8080/healthz")
|
||||
if err != nil {
|
||||
return 1
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
//nolint:funlen // bootstrap code is inherently verbose
|
||||
func run(configPath string) error {
|
||||
ctx, stop := signal.NotifyContext(
|
||||
context.Background(),
|
||||
syscall.SIGINT,
|
||||
syscall.SIGTERM,
|
||||
)
|
||||
defer stop()
|
||||
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger := setupLogger(cfg.Log)
|
||||
slog.SetDefault(logger)
|
||||
|
||||
logger.Info("starting application",
|
||||
"name", cfg.App.Name,
|
||||
"version", cfg.App.Version,
|
||||
"environment", cfg.App.Environment,
|
||||
)
|
||||
|
||||
var telemetry *core.Telemetry
|
||||
if cfg.Otel.Enabled {
|
||||
tel, telErr := core.NewTelemetry(ctx, cfg.Otel, cfg.App)
|
||||
if telErr != nil {
|
||||
logger.Warn("failed to initialize telemetry", "error", telErr)
|
||||
} else {
|
||||
telemetry = tel
|
||||
logger.Info("OpenTelemetry tracer initialized",
|
||||
"endpoint", cfg.Otel.Endpoint,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
db, err := core.NewDatabase(ctx, cfg.Database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("database connected",
|
||||
"max_open_conns", cfg.Database.MaxOpenConns,
|
||||
"max_idle_conns", cfg.Database.MaxIdleConns,
|
||||
)
|
||||
|
||||
redis, err := core.NewRedis(ctx, cfg.Redis)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("redis connected",
|
||||
"pool_size", cfg.Redis.PoolSize,
|
||||
)
|
||||
|
||||
if keyErr := ensureJWTKeys(cfg.JWT, logger); keyErr != nil {
|
||||
return keyErr
|
||||
}
|
||||
|
||||
jwtManager, err := auth.NewJWTManager(cfg.JWT)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("JWT manager initialized",
|
||||
"algorithm", "ES256",
|
||||
"key_id", jwtManager.GetKeyID(),
|
||||
)
|
||||
|
||||
userRepo := user.NewRepository(db.DB)
|
||||
userSvc := user.NewService(userRepo)
|
||||
userHandler := user.NewHandler(userSvc)
|
||||
|
||||
authRepo := auth.NewRepository(db.DB)
|
||||
authSvc := auth.NewServiceWithConfig(auth.ServiceConfig{
|
||||
Repo: authRepo,
|
||||
JWT: jwtManager,
|
||||
UserProvider: userSvc,
|
||||
Redis: redis.Client,
|
||||
AdminEmail: cfg.App.AdminEmail,
|
||||
})
|
||||
authVerifier := auth.NewVerifier(jwtManager, authSvc, userSvc)
|
||||
authHandler := auth.NewHandlerWithConfig(auth.HandlerConfig{
|
||||
Service: authSvc,
|
||||
TrustedProxyHops: cfg.Server.TrustedProxyHops,
|
||||
SecureCookies: cfg.IsProduction(),
|
||||
})
|
||||
|
||||
var notifHandler *notifications.Handler
|
||||
var notifBridge *notifications.Bridge
|
||||
if cfg.Notifications.EncryptionKey != "" {
|
||||
enc, encErr := notifications.NewEncryptor(
|
||||
cfg.Notifications.EncryptionKey,
|
||||
)
|
||||
if encErr != nil {
|
||||
logger.Warn(
|
||||
"notifications disabled: invalid encryption key",
|
||||
"error", encErr,
|
||||
)
|
||||
} else {
|
||||
notifRepo := notifications.NewRepository(db.DB)
|
||||
notifSender := notifications.NewSender()
|
||||
notifSvc := notifications.NewService(
|
||||
notifRepo,
|
||||
enc,
|
||||
notifSender,
|
||||
cfg.App.PublicURL,
|
||||
logger.With("service", "notifications"),
|
||||
)
|
||||
notifHandler = notifications.NewHandler(notifSvc)
|
||||
notifBridge = notifications.NewBridge(
|
||||
notifRepo,
|
||||
enc,
|
||||
notifSender,
|
||||
logger.With("service", "alerts"),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logger.Warn(
|
||||
"notifications disabled: NOTIFICATION_ENCRYPTION_KEY not set",
|
||||
)
|
||||
}
|
||||
|
||||
alertsRepo := alerts.NewRepository(db.DB)
|
||||
authSvc.SetRuleSeeder(func(ctx context.Context, userID string) error {
|
||||
return alertsRepo.SeedDefaults(ctx, userID)
|
||||
})
|
||||
alertsHandler := alerts.NewHandler(alertsRepo)
|
||||
|
||||
healthHandler := health.NewHandler(db, redis)
|
||||
|
||||
adminHandler := admin.NewHandler(admin.HandlerConfig{
|
||||
DBStats: db.Stats,
|
||||
RedisStats: redis.PoolStats,
|
||||
DBPing: db.Ping,
|
||||
RedisPing: redis.Ping,
|
||||
})
|
||||
|
||||
snapStore := snapshot.NewStore(redis.Client)
|
||||
snapHandler := snapshot.NewHandler(snapStore)
|
||||
|
||||
cveRepo := cve.NewRepo(db.DB)
|
||||
kevRepo := kev.NewRepo(db.DB)
|
||||
cfradarRepo := cfradar.NewRepo(db.DB)
|
||||
ransomwareRepo := ransomware.NewRepo(db.DB)
|
||||
usgsRepo := usgs.NewRepo(db.DB)
|
||||
intelHandler := intel.NewHandler(intel.HandlerConfig{
|
||||
CVE: cveRepo,
|
||||
KEV: kevRepo,
|
||||
CFRadar: cfradarRepo,
|
||||
Ransomware: ransomwareRepo,
|
||||
USGS: usgsRepo,
|
||||
})
|
||||
|
||||
hub := ws.NewHub(ws.HubConfig{
|
||||
Logger: logger,
|
||||
MaxSubscribers: cfg.Server.WSMaxSubscribers,
|
||||
})
|
||||
wsHandler := ws.NewHandlerWithConfig(ws.HandlerConfig{
|
||||
Hub: hub,
|
||||
MaxConnsPerIP: cfg.Server.WSMaxConnsPerIP,
|
||||
TrustedProxyHops: cfg.Server.TrustedProxyHops,
|
||||
})
|
||||
|
||||
eventBus := bus.New(bus.Config{
|
||||
BufferSize: 512,
|
||||
Persister: snapshot.StorePersister{Store: snapStore},
|
||||
Broadcaster: ws.HubBroadcaster{Hub: hub},
|
||||
Logger: logger,
|
||||
})
|
||||
|
||||
beat := heartbeat.New(heartbeat.Config{
|
||||
Interval: 5 * time.Second,
|
||||
Emitter: eventBus,
|
||||
})
|
||||
|
||||
collectorState := state.NewRepo(db.DB)
|
||||
|
||||
collectorGroup, collectorCtx := errgroup.WithContext(ctx)
|
||||
collectorGroup.Go(func() error { return eventBus.Run(collectorCtx) })
|
||||
collectorGroup.Go(func() error { return beat.Run(collectorCtx) })
|
||||
|
||||
if notifBridge != nil {
|
||||
alertsEngine, err := alerts.NewEngine(alerts.EngineConfig{
|
||||
Repo: alertsRepo,
|
||||
Notifier: notifBridge,
|
||||
Loader: notifBridge,
|
||||
Cooldowns: alerts.NewRedisCooldown(redis.Client),
|
||||
Logger: logger.With("component", "alerts.engine"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dispatcher := alerts.NewDispatcher(
|
||||
eventBus.Subscribe(),
|
||||
alertsEngine,
|
||||
logger.With("component", "alerts.dispatcher"),
|
||||
)
|
||||
collectorGroup.Go(
|
||||
func() error { return alertsEngine.RefreshLoop(collectorCtx) },
|
||||
)
|
||||
collectorGroup.Go(func() error { return dispatcher.Run(collectorCtx) })
|
||||
} else {
|
||||
logger.Info(
|
||||
"alerts engine disabled (notifications module not configured)",
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.Collectors.DShield.Enabled {
|
||||
var dsEnricher dshield.Enricher
|
||||
var abuseClient *abuseipdb.Client
|
||||
var gnCached *greynoise.Cached
|
||||
if cfg.Collectors.AbuseIPDB.Enabled &&
|
||||
cfg.Collectors.AbuseIPDB.APIKey != "" {
|
||||
abuseClient = abuseipdb.NewClient(
|
||||
abuseipdb.ClientConfig{APIKey: cfg.Collectors.AbuseIPDB.APIKey},
|
||||
)
|
||||
}
|
||||
if cfg.Collectors.GreyNoise.Enabled &&
|
||||
cfg.Collectors.GreyNoise.APIKey != "" {
|
||||
gnCached = greynoise.NewCached(
|
||||
greynoise.NewClient(
|
||||
greynoise.ClientConfig{
|
||||
APIKey: cfg.Collectors.GreyNoise.APIKey,
|
||||
},
|
||||
),
|
||||
redis.Client,
|
||||
)
|
||||
}
|
||||
if abuseClient != nil || gnCached != nil {
|
||||
dsEnricher = compositeDshieldEnricher{
|
||||
abuse: abuseClient,
|
||||
gn: gnCached,
|
||||
}
|
||||
}
|
||||
coll := dshield.NewCollector(dshield.CollectorConfig{
|
||||
Interval: cfg.Collectors.DShield.Interval,
|
||||
Fetcher: dshield.NewClient(dshield.ClientConfig{}),
|
||||
Persister: dshield.NewRepo(db.DB),
|
||||
Emitter: eventBus,
|
||||
State: collectorState,
|
||||
Enricher: dsEnricher,
|
||||
Logger: logger.With("collector", "dshield"),
|
||||
})
|
||||
collectorGroup.Go(func() error { return coll.Run(collectorCtx) })
|
||||
}
|
||||
|
||||
if cfg.Collectors.CFRadar.Enabled {
|
||||
var enricher cfradar.Enricher
|
||||
if cfg.Collectors.AbuseIPDB.Enabled &&
|
||||
cfg.Collectors.AbuseIPDB.APIKey != "" {
|
||||
enricher = abuseipdbEnricher{
|
||||
client: abuseipdb.NewClient(
|
||||
abuseipdb.ClientConfig{
|
||||
APIKey: cfg.Collectors.AbuseIPDB.APIKey,
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
coll := cfradar.NewCollector(cfradar.CollectorConfig{
|
||||
Interval: cfg.Collectors.CFRadar.Interval,
|
||||
MinConfidence: cfg.Collectors.CFRadar.MinConfidence,
|
||||
Fetcher: cfradar.NewClient(
|
||||
cfradar.ClientConfig{
|
||||
BearerToken: cfg.Collectors.CFRadar.BearerToken,
|
||||
},
|
||||
),
|
||||
Repo: cfradar.NewRepo(db.DB),
|
||||
Emitter: eventBus,
|
||||
State: collectorState,
|
||||
Enricher: enricher,
|
||||
Logger: logger.With("collector", "cfradar"),
|
||||
})
|
||||
collectorGroup.Go(func() error { return coll.Run(collectorCtx) })
|
||||
}
|
||||
|
||||
if cfg.Collectors.CVE.Enabled {
|
||||
coll := cve.NewCollector(cve.CollectorConfig{
|
||||
Interval: cfg.Collectors.CVE.Interval,
|
||||
Window: cfg.Collectors.CVE.Window,
|
||||
NVD: cve.NewNVDClient(
|
||||
cve.NVDClientConfig{APIKey: cfg.Collectors.CVE.NVDAPIKey},
|
||||
),
|
||||
EPSS: cve.NewEPSSClient(cve.EPSSClientConfig{}),
|
||||
Repo: cve.NewRepo(db.DB),
|
||||
Emitter: eventBus,
|
||||
State: collectorState,
|
||||
Logger: logger.With("collector", "cve"),
|
||||
})
|
||||
collectorGroup.Go(func() error { return coll.Run(collectorCtx) })
|
||||
}
|
||||
|
||||
if cfg.Collectors.KEV.Enabled {
|
||||
coll := kev.NewCollector(kev.CollectorConfig{
|
||||
Interval: cfg.Collectors.KEV.Interval,
|
||||
Fetcher: kev.NewClient(kev.ClientConfig{}),
|
||||
Repo: kev.NewRepo(db.DB),
|
||||
Emitter: eventBus,
|
||||
State: collectorState,
|
||||
Logger: logger.With("collector", "kev"),
|
||||
})
|
||||
collectorGroup.Go(func() error { return coll.Run(collectorCtx) })
|
||||
}
|
||||
|
||||
if cfg.Collectors.Ransomware.Enabled {
|
||||
coll := ransomware.NewCollector(ransomware.CollectorConfig{
|
||||
Interval: cfg.Collectors.Ransomware.Interval,
|
||||
Fetcher: ransomware.NewClient(ransomware.ClientConfig{}),
|
||||
Repo: ransomware.NewRepo(db.DB),
|
||||
Emitter: eventBus,
|
||||
State: collectorState,
|
||||
Logger: logger.With("collector", "ransomware"),
|
||||
})
|
||||
collectorGroup.Go(func() error { return coll.Run(collectorCtx) })
|
||||
}
|
||||
|
||||
if cfg.Collectors.Coinbase.Enabled {
|
||||
coll := coinbase.NewCollector(coinbase.CollectorConfig{
|
||||
URL: cfg.Collectors.Coinbase.URL,
|
||||
ProductIDs: cfg.Collectors.Coinbase.ProductIDs,
|
||||
Repo: coinbase.NewRepo(db.DB),
|
||||
Emitter: eventBus,
|
||||
State: collectorState,
|
||||
Throttle: cfg.Collectors.Coinbase.Throttle,
|
||||
Logger: logger.With("collector", "coinbase"),
|
||||
})
|
||||
collectorGroup.Go(func() error { return coll.Run(collectorCtx) })
|
||||
}
|
||||
|
||||
if cfg.Collectors.USGS.Enabled {
|
||||
coll := usgs.NewCollector(usgs.CollectorConfig{
|
||||
Interval: cfg.Collectors.USGS.Interval,
|
||||
Fetcher: usgs.NewClient(usgs.ClientConfig{}),
|
||||
Repo: usgs.NewRepo(db.DB),
|
||||
Emitter: eventBus,
|
||||
State: collectorState,
|
||||
Logger: logger.With("collector", "usgs"),
|
||||
})
|
||||
collectorGroup.Go(func() error { return coll.Run(collectorCtx) })
|
||||
}
|
||||
|
||||
if cfg.Collectors.SWPC.Enabled {
|
||||
ring := redisring.New(
|
||||
redis.Client,
|
||||
redisring.Config{Retention: 24 * time.Hour},
|
||||
)
|
||||
coll := swpc.NewCollector(swpc.CollectorConfig{
|
||||
FastInterval: cfg.Collectors.SWPC.FastInterval,
|
||||
SlowInterval: cfg.Collectors.SWPC.SlowInterval,
|
||||
Fetcher: swpc.NewClient(swpc.ClientConfig{}),
|
||||
Ring: ring,
|
||||
Emitter: eventBus,
|
||||
State: collectorState,
|
||||
Logger: logger.With("collector", "swpc"),
|
||||
})
|
||||
collectorGroup.Go(func() error { return coll.Run(collectorCtx) })
|
||||
}
|
||||
|
||||
if cfg.Collectors.Wikipedia.Enabled {
|
||||
coll := wikipedia.NewCollector(wikipedia.CollectorConfig{
|
||||
Interval: cfg.Collectors.Wikipedia.Interval,
|
||||
Fetcher: wikipedia.NewClient(wikipedia.ClientConfig{}),
|
||||
Repo: wikipedia.NewRepo(db.DB, redis.Client),
|
||||
Emitter: eventBus,
|
||||
State: collectorState,
|
||||
Logger: logger.With("collector", "wikipedia"),
|
||||
})
|
||||
collectorGroup.Go(func() error { return coll.Run(collectorCtx) })
|
||||
}
|
||||
|
||||
if cfg.Collectors.GDELT.Enabled {
|
||||
coll := gdelt.NewCollector(gdelt.CollectorConfig{
|
||||
Interval: cfg.Collectors.GDELT.Interval,
|
||||
Fetcher: gdelt.NewClient(gdelt.ClientConfig{}),
|
||||
Repo: gdelt.NewRepo(db.DB),
|
||||
Emitter: eventBus,
|
||||
State: collectorState,
|
||||
Logger: logger.With("collector", "gdelt"),
|
||||
})
|
||||
collectorGroup.Go(func() error { return coll.Run(collectorCtx) })
|
||||
}
|
||||
|
||||
if cfg.Collectors.ISS.Enabled {
|
||||
coll := iss.NewCollector(iss.CollectorConfig{
|
||||
PositionInterval: cfg.Collectors.ISS.PositionInterval,
|
||||
TLEInterval: cfg.Collectors.ISS.TLEInterval,
|
||||
Fetcher: iss.NewClient(iss.ClientConfig{}),
|
||||
TLEStore: iss.NewTLEStore(redis.Client),
|
||||
Emitter: eventBus,
|
||||
State: collectorState,
|
||||
Logger: logger.With("collector", "iss"),
|
||||
})
|
||||
collectorGroup.Go(func() error { return coll.Run(collectorCtx) })
|
||||
}
|
||||
|
||||
logger.Info("collectors started",
|
||||
"heartbeat", true,
|
||||
"dshield", cfg.Collectors.DShield.Enabled,
|
||||
"cfradar", cfg.Collectors.CFRadar.Enabled,
|
||||
"cve", cfg.Collectors.CVE.Enabled,
|
||||
"kev", cfg.Collectors.KEV.Enabled,
|
||||
"ransomware", cfg.Collectors.Ransomware.Enabled,
|
||||
"coinbase", cfg.Collectors.Coinbase.Enabled,
|
||||
"usgs", cfg.Collectors.USGS.Enabled,
|
||||
"swpc", cfg.Collectors.SWPC.Enabled,
|
||||
"wikipedia", cfg.Collectors.Wikipedia.Enabled,
|
||||
"gdelt", cfg.Collectors.GDELT.Enabled,
|
||||
"iss", cfg.Collectors.ISS.Enabled,
|
||||
)
|
||||
|
||||
srv := server.New(server.Config{
|
||||
ServerConfig: cfg.Server,
|
||||
HealthHandler: healthHandler,
|
||||
Logger: logger,
|
||||
})
|
||||
|
||||
router := srv.Router()
|
||||
|
||||
router.Use(middleware.RequestID)
|
||||
router.Use(middleware.Logger(logger))
|
||||
router.Use(
|
||||
middleware.NewRateLimiter(redis.Client, middleware.RateLimitConfig{
|
||||
Limit: middleware.PerWindow(
|
||||
cfg.RateLimit.Requests,
|
||||
cfg.RateLimit.Burst,
|
||||
cfg.RateLimit.Window,
|
||||
),
|
||||
KeyFunc: middleware.KeyByClientIP(cfg.Server.TrustedProxyHops),
|
||||
FailOpen: true,
|
||||
}).Handler,
|
||||
)
|
||||
router.Use(middleware.SecurityHeaders(cfg.App.Environment == "production"))
|
||||
router.Use(middleware.CORS(cfg.CORS))
|
||||
|
||||
healthHandler.RegisterRoutes(router)
|
||||
|
||||
router.Get("/.well-known/jwks.json", jwtManager.GetJWKSHandler())
|
||||
|
||||
authenticator := middleware.Authenticator(authVerifier)
|
||||
adminOnly := middleware.RequireAdmin
|
||||
|
||||
router.Route("/v1", func(r chi.Router) {
|
||||
r.Get("/healthz", healthHandler.Liveness)
|
||||
r.Get("/readyz", healthHandler.Readiness)
|
||||
|
||||
r.Get("/snapshot", snapHandler.ServeHTTP)
|
||||
r.Get("/ws", wsHandler.ServeHTTP)
|
||||
|
||||
intelHandler.RegisterRoutes(r)
|
||||
|
||||
authHandler.RegisterRoutes(r, authenticator)
|
||||
|
||||
userHandler.RegisterRoutes(r, authenticator)
|
||||
userHandler.RegisterAdminRoutes(r, authenticator, adminOnly)
|
||||
adminHandler.RegisterRoutes(r, authenticator, adminOnly)
|
||||
|
||||
if notifHandler != nil {
|
||||
notifHandler.RegisterRoutes(r, authenticator)
|
||||
}
|
||||
if alertsHandler != nil {
|
||||
alertsHandler.RegisterRoutes(r, authenticator)
|
||||
}
|
||||
})
|
||||
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
errChan <- srv.Start()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errChan:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
logger.Info("shutdown signal received")
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
cfg.Server.ShutdownTimeout+drainDelay+5*time.Second,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(shutdownCtx, drainDelay); err != nil {
|
||||
logger.Error("server shutdown error", "error", err)
|
||||
}
|
||||
|
||||
if telemetry != nil {
|
||||
if err := telemetry.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Error("telemetry shutdown error", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := collectorGroup.Wait(); err != nil &&
|
||||
!errors.Is(err, context.Canceled) &&
|
||||
!errors.Is(err, context.DeadlineExceeded) {
|
||||
logger.Error("collector group exit", "error", err)
|
||||
}
|
||||
|
||||
if err := redis.Close(); err != nil {
|
||||
logger.Error("redis close error", "error", err)
|
||||
}
|
||||
|
||||
if err := db.Close(); err != nil {
|
||||
logger.Error("database close error", "error", err)
|
||||
}
|
||||
|
||||
logger.Info("application stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureJWTKeys(cfg config.JWTConfig, logger *slog.Logger) error {
|
||||
if _, err := os.Stat(cfg.PrivateKeyPath); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
if dir := filepath.Dir(cfg.PrivateKeyPath); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if dir := filepath.Dir(cfg.PublicKeyPath); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
logger.Warn("JWT keys missing, generating",
|
||||
"private", cfg.PrivateKeyPath,
|
||||
"public", cfg.PublicKeyPath,
|
||||
)
|
||||
return auth.GenerateKeyPair(cfg.PrivateKeyPath, cfg.PublicKeyPath)
|
||||
}
|
||||
|
||||
func setupLogger(cfg config.LogConfig) *slog.Logger {
|
||||
var handler slog.Handler
|
||||
|
||||
level := slog.LevelInfo
|
||||
switch cfg.Level {
|
||||
case "debug":
|
||||
level = slog.LevelDebug
|
||||
case "warn":
|
||||
level = slog.LevelWarn
|
||||
case "error":
|
||||
level = slog.LevelError
|
||||
}
|
||||
|
||||
opts := &slog.HandlerOptions{Level: level}
|
||||
|
||||
if cfg.Format == "json" {
|
||||
handler = slog.NewJSONHandler(os.Stdout, opts)
|
||||
} else {
|
||||
handler = slog.NewTextHandler(os.Stdout, opts)
|
||||
}
|
||||
|
||||
return slog.New(handler)
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# AngelaMos | 2026
|
||||
# config.yaml - Default configuration
|
||||
|
||||
app:
|
||||
name: "Go Backend Template"
|
||||
version: "1.0.0"
|
||||
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
read_timeout: 30s
|
||||
write_timeout: 30s
|
||||
idle_timeout: 120s
|
||||
shutdown_timeout: 15s
|
||||
|
||||
database:
|
||||
max_open_conns: 25
|
||||
max_idle_conns: 5
|
||||
conn_max_lifetime: 1h
|
||||
conn_max_idle_time: 30m
|
||||
|
||||
redis:
|
||||
pool_size: 10
|
||||
min_idle_conns: 5
|
||||
|
||||
jwt:
|
||||
access_token_expire: 15m
|
||||
refresh_token_expire: 168h
|
||||
issuer: "go-backend"
|
||||
|
||||
rate_limit:
|
||||
requests: 100
|
||||
window: 1m
|
||||
burst: 20
|
||||
|
||||
cors:
|
||||
allowed_origins:
|
||||
- "http://localhost:3000"
|
||||
- "http://localhost:3420"
|
||||
allowed_methods:
|
||||
- "GET"
|
||||
- "POST"
|
||||
- "PUT"
|
||||
- "PATCH"
|
||||
- "DELETE"
|
||||
- "OPTIONS"
|
||||
allowed_headers:
|
||||
- "Accept"
|
||||
- "Authorization"
|
||||
- "Content-Type"
|
||||
- "X-Request-ID"
|
||||
allow_credentials: true
|
||||
max_age: 300
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
module github.com/carterperez-dev/monitor-the-situation/backend
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/cenkalti/backoff/v4 v4.3.0
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/go-playground/validator/v10 v10.23.0
|
||||
github.com/go-redis/redis_rate/v10 v10.0.1
|
||||
github.com/google/cel-go v0.28.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.2
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/joshuaferrara/go-satellite v0.0.0-20220611180459-512638c64e5b
|
||||
github.com/knadh/koanf/parsers/yaml v1.1.0
|
||||
github.com/knadh/koanf/providers/env v1.1.0
|
||||
github.com/knadh/koanf/providers/file v1.2.1
|
||||
github.com/knadh/koanf/v2 v2.1.2
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.12
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/redis/go-redis/v9 v9.7.3
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/sony/gobreaker/v2 v2.4.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
|
||||
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0
|
||||
go.opentelemetry.io/otel v1.41.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0
|
||||
go.opentelemetry.io/otel/sdk v1.35.0
|
||||
go.opentelemetry.io/otel/trace v1.41.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/grpc v1.68.1
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/knadh/koanf/maps v0.1.2 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
|
||||
github.com/lestrrat-go/dsig v1.0.0 // indirect
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.1 // indirect
|
||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mdelapenya/tlscert v0.2.0 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/moby/api v1.54.1 // indirect
|
||||
github.com/moby/moby/client v0.4.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/testcontainers/testcontainers-go v0.42.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/valyala/fastjson v1.6.4 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.4.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
@ -0,0 +1,356 @@
|
|||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
|
||||
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-redis/redis_rate/v10 v10.0.1 h1:calPxi7tVlxojKunJwQ72kwfozdy25RjA0bCj1h0MUo=
|
||||
github.com/go-redis/redis_rate/v10 v10.0.1/go.mod h1:EMiuO9+cjRkR7UvdvwMO7vbgqJkltQHtwbdIQvaBKIU=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc=
|
||||
github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
||||
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/joshuaferrara/go-satellite v0.0.0-20220611180459-512638c64e5b h1:JlltDRgni6FuoFwluvoZCrE6cmpojccO4WsqeYlFJLE=
|
||||
github.com/joshuaferrara/go-satellite v0.0.0-20220611180459-512638c64e5b/go.mod h1:msW2QeN9IsnRyvuK8OBAzBwn6DHwXpiAiqBk8dbLfrU=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
||||
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
|
||||
github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4=
|
||||
github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg=
|
||||
github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc=
|
||||
github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY=
|
||||
github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM=
|
||||
github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
|
||||
github.com/knadh/koanf/v2 v2.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ=
|
||||
github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38=
|
||||
github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.1 h1:3n7Es68YYGZb2Jf+k//llA4FTZMl3yCwIjFIk4ubevI=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.1/go.mod h1:2uAvmbXE4Xq8kAUjVrZOq1tZVYYYs5iP62Cmtru00xk=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.12 h1:p25r68Y4KrbBdYjIsQweYxq794CtGCzcrc5dGzJIRjg=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.12/go.mod h1:HiUSaNmMLXgZ08OmGBaPVvoZQgJVOQphSrGr5zMamS8=
|
||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
|
||||
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
|
||||
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/onsi/ginkgo v1.2.1-0.20160509182050-5437a97bf824 h1:MbMqwlWoESqhGm4Sslfdyeq7Ww8R9ppeKS5DcO3xDI0=
|
||||
github.com/onsi/ginkgo v1.2.1-0.20160509182050-5437a97bf824/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v0.0.0-20160516222431-c73e51675ad2 h1:38zSYUaJJkzreBjLz7tx4AUTVjnFI7EQBnlRoWt4QFA=
|
||||
github.com/onsi/gomega v0.0.0-20160516222431-c73e51675ad2/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
|
||||
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg=
|
||||
github.com/sony/gobreaker/v2 v2.4.0/go.mod h1:pTyFJgcZ3h2tdQVLZZruK2C0eoFL1fb/G83wK1ZQl+s=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs=
|
||||
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 h1:id/6LH8ZeDrtAUVSuNvZUAJ1kVpb82y1pr9yweAWsRg=
|
||||
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0/go.mod h1:uF0jI8FITagQpBNOgweGBmPf6rP4K0SeL1XFPbsZSSY=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
|
||||
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg=
|
||||
go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA=
|
||||
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
|
||||
google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0=
|
||||
google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.0.0-20160301204022-a83829b6f129 h1:RBgb9aPUbZ9nu66ecQNIBNsA7j3mB5h8PNDIfhPjaJg=
|
||||
gopkg.in/yaml.v2 v2.0.0-20160301204022-a83829b6f129/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
// AngelaMos | 2026
|
||||
// handler.go
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"runtime"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/core"
|
||||
)
|
||||
|
||||
type AuthService interface {
|
||||
InvalidateAllSessions(ctx context.Context) error
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
dbStats func() sql.DBStats
|
||||
redisStats func() *redis.PoolStats
|
||||
redisPing func(ctx context.Context) error
|
||||
dbPing func(ctx context.Context) error
|
||||
authSvc AuthService
|
||||
}
|
||||
|
||||
type HandlerConfig struct {
|
||||
DBStats func() sql.DBStats
|
||||
RedisStats func() *redis.PoolStats
|
||||
RedisPing func(ctx context.Context) error
|
||||
DBPing func(ctx context.Context) error
|
||||
AuthSvc AuthService
|
||||
}
|
||||
|
||||
func NewHandler(cfg HandlerConfig) *Handler {
|
||||
return &Handler{
|
||||
dbStats: cfg.DBStats,
|
||||
redisStats: cfg.RedisStats,
|
||||
redisPing: cfg.RedisPing,
|
||||
dbPing: cfg.DBPing,
|
||||
authSvc: cfg.AuthSvc,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(
|
||||
r chi.Router,
|
||||
authenticator, adminOnly func(http.Handler) http.Handler,
|
||||
) {
|
||||
r.Route("/admin", func(r chi.Router) {
|
||||
r.Use(authenticator)
|
||||
r.Use(adminOnly)
|
||||
|
||||
r.Get("/stats", h.GetSystemStats)
|
||||
r.Get("/stats/db", h.GetDatabaseStats)
|
||||
r.Get("/stats/redis", h.GetRedisStats)
|
||||
r.Get("/stats/runtime", h.GetRuntimeStats)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) GetSystemStats(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
dbHealthy := true
|
||||
if h.dbPing != nil {
|
||||
if err := h.dbPing(ctx); err != nil {
|
||||
dbHealthy = false
|
||||
}
|
||||
}
|
||||
|
||||
redisHealthy := true
|
||||
if h.redisPing != nil {
|
||||
if err := h.redisPing(ctx); err != nil {
|
||||
redisHealthy = false
|
||||
}
|
||||
}
|
||||
|
||||
var memStats runtime.MemStats
|
||||
runtime.ReadMemStats(&memStats)
|
||||
|
||||
response := SystemStatsResponse{
|
||||
Database: DatabaseStatus{
|
||||
Healthy: dbHealthy,
|
||||
Stats: h.getDBStats(),
|
||||
},
|
||||
Redis: RedisStatus{
|
||||
Healthy: redisHealthy,
|
||||
Stats: h.getRedisStats(),
|
||||
},
|
||||
Runtime: RuntimeStats{
|
||||
GoVersion: runtime.Version(),
|
||||
NumGoroutine: runtime.NumGoroutine(),
|
||||
NumCPU: runtime.NumCPU(),
|
||||
MemAlloc: memStats.Alloc,
|
||||
MemSys: memStats.Sys,
|
||||
NumGC: memStats.NumGC,
|
||||
},
|
||||
}
|
||||
|
||||
core.OK(w, response)
|
||||
}
|
||||
|
||||
func (h *Handler) GetDatabaseStats(w http.ResponseWriter, r *http.Request) {
|
||||
core.OK(w, h.getDBStats())
|
||||
}
|
||||
|
||||
func (h *Handler) GetRedisStats(w http.ResponseWriter, r *http.Request) {
|
||||
core.OK(w, h.getRedisStats())
|
||||
}
|
||||
|
||||
func (h *Handler) GetRuntimeStats(w http.ResponseWriter, r *http.Request) {
|
||||
var memStats runtime.MemStats
|
||||
runtime.ReadMemStats(&memStats)
|
||||
|
||||
response := RuntimeStats{
|
||||
GoVersion: runtime.Version(),
|
||||
NumGoroutine: runtime.NumGoroutine(),
|
||||
NumCPU: runtime.NumCPU(),
|
||||
MemAlloc: memStats.Alloc,
|
||||
MemSys: memStats.Sys,
|
||||
NumGC: memStats.NumGC,
|
||||
}
|
||||
|
||||
core.OK(w, response)
|
||||
}
|
||||
|
||||
func (h *Handler) getDBStats() *DBPoolStats {
|
||||
if h.dbStats == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
stats := h.dbStats()
|
||||
return &DBPoolStats{
|
||||
MaxOpenConnections: stats.MaxOpenConnections,
|
||||
OpenConnections: stats.OpenConnections,
|
||||
InUse: stats.InUse,
|
||||
Idle: stats.Idle,
|
||||
WaitCount: stats.WaitCount,
|
||||
WaitDuration: stats.WaitDuration.String(),
|
||||
MaxIdleClosed: stats.MaxIdleClosed,
|
||||
MaxIdleTimeClosed: stats.MaxIdleTimeClosed,
|
||||
MaxLifetimeClosed: stats.MaxLifetimeClosed,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) getRedisStats() *RedisPoolStats {
|
||||
if h.redisStats == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
stats := h.redisStats()
|
||||
return &RedisPoolStats{
|
||||
Hits: stats.Hits,
|
||||
Misses: stats.Misses,
|
||||
Timeouts: stats.Timeouts,
|
||||
TotalConns: stats.TotalConns,
|
||||
IdleConns: stats.IdleConns,
|
||||
StaleConns: stats.StaleConns,
|
||||
}
|
||||
}
|
||||
|
||||
type SystemStatsResponse struct {
|
||||
Database DatabaseStatus `json:"database"`
|
||||
Redis RedisStatus `json:"redis"`
|
||||
Runtime RuntimeStats `json:"runtime"`
|
||||
}
|
||||
|
||||
type DatabaseStatus struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
Stats *DBPoolStats `json:"stats,omitempty"`
|
||||
}
|
||||
|
||||
type RedisStatus struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
Stats *RedisPoolStats `json:"stats,omitempty"`
|
||||
}
|
||||
|
||||
type DBPoolStats struct {
|
||||
MaxOpenConnections int `json:"max_open_connections"`
|
||||
OpenConnections int `json:"open_connections"`
|
||||
InUse int `json:"in_use"`
|
||||
Idle int `json:"idle"`
|
||||
WaitCount int64 `json:"wait_count"`
|
||||
WaitDuration string `json:"wait_duration"`
|
||||
MaxIdleClosed int64 `json:"max_idle_closed"`
|
||||
MaxIdleTimeClosed int64 `json:"max_idle_time_closed"`
|
||||
MaxLifetimeClosed int64 `json:"max_lifetime_closed"`
|
||||
}
|
||||
|
||||
type RedisPoolStats struct {
|
||||
Hits uint32 `json:"hits"`
|
||||
Misses uint32 `json:"misses"`
|
||||
Timeouts uint32 `json:"timeouts"`
|
||||
TotalConns uint32 `json:"total_conns"`
|
||||
IdleConns uint32 `json:"idle_conns"`
|
||||
StaleConns uint32 `json:"stale_conns"`
|
||||
}
|
||||
|
||||
type RuntimeStats struct {
|
||||
GoVersion string `json:"go_version"`
|
||||
NumGoroutine int `json:"num_goroutine"`
|
||||
NumCPU int `json:"num_cpu"`
|
||||
MemAlloc uint64 `json:"mem_alloc_bytes"`
|
||||
MemSys uint64 `json:"mem_sys_bytes"`
|
||||
NumGC uint32 `json:"num_gc"`
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
// ©AngelaMos | 2026
|
||||
// dispatcher.go
|
||||
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
// Dispatcher reads from a bus subscriber channel and pushes each event
|
||||
// into the engine. Separate from the engine itself so the bus->engine
|
||||
// wiring is testable in isolation.
|
||||
type Dispatcher struct {
|
||||
in <-chan events.Event
|
||||
engine *Engine
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewDispatcher(
|
||||
in <-chan events.Event,
|
||||
engine *Engine,
|
||||
logger *slog.Logger,
|
||||
) *Dispatcher {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Dispatcher{in: in, engine: engine, logger: logger}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) Run(ctx context.Context) error {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case ev, ok := <-d.in:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
d.engine.Evaluate(ctx, ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,377 @@
|
|||
// ©AngelaMos | 2026
|
||||
// engine.go
|
||||
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"github.com/google/cel-go/common/types/ref"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
// Channel is the abstract destination an alert is sent to. Slack/Discord
|
||||
// webhooks and Telegram bots all reduce to "send this text via this
|
||||
// transport". The engine doesn't care about the specific kind — that's
|
||||
// the Notifier's job.
|
||||
type Channel struct {
|
||||
ID string
|
||||
Type string
|
||||
Label string
|
||||
// Decoded config — for slack/discord this is the webhook URL,
|
||||
// for telegram it's a (bot_token, chat_id) pair. The Notifier
|
||||
// implementation handles the type-specific details.
|
||||
WebhookURL string
|
||||
BotToken string
|
||||
ChatID int64
|
||||
}
|
||||
|
||||
// Notifier is the transport for sending an alert. The notifications
|
||||
// package implements one — this seam is here so tests can mock it.
|
||||
type Notifier interface {
|
||||
SendAlert(ctx context.Context, ch Channel, message string) error
|
||||
}
|
||||
|
||||
// ChannelLoader returns the configured destinations for a user. The
|
||||
// notifications package decrypts channel configs on demand and produces
|
||||
// these. Engine invokes it once per matching rule.
|
||||
type ChannelLoader interface {
|
||||
LoadChannels(ctx context.Context, userID string) ([]Channel, error)
|
||||
}
|
||||
|
||||
type CooldownStore interface {
|
||||
TryAcquire(ctx context.Context, key string, ttl time.Duration) (bool, error)
|
||||
}
|
||||
|
||||
type EngineConfig struct {
|
||||
Repo Repository
|
||||
Notifier Notifier
|
||||
Loader ChannelLoader
|
||||
Cooldowns CooldownStore
|
||||
Logger *slog.Logger
|
||||
ReloadTick time.Duration
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
repo Repository
|
||||
notifier Notifier
|
||||
loader ChannelLoader
|
||||
cooldowns CooldownStore
|
||||
logger *slog.Logger
|
||||
reloadTick time.Duration
|
||||
celEnv *cel.Env
|
||||
rulesByTopic atomic.Pointer[map[string][]compiledRule]
|
||||
}
|
||||
|
||||
type compiledRule struct {
|
||||
rule Rule
|
||||
program cel.Program
|
||||
}
|
||||
|
||||
func NewEngine(cfg EngineConfig) (*Engine, error) {
|
||||
logger := cfg.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
if cfg.ReloadTick <= 0 {
|
||||
cfg.ReloadTick = 30 * time.Second
|
||||
}
|
||||
env, err := cel.NewEnv(
|
||||
cel.Variable("event", cel.DynType),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cel env: %w", err)
|
||||
}
|
||||
e := &Engine{
|
||||
repo: cfg.Repo,
|
||||
notifier: cfg.Notifier,
|
||||
loader: cfg.Loader,
|
||||
cooldowns: cfg.Cooldowns,
|
||||
logger: logger,
|
||||
reloadTick: cfg.ReloadTick,
|
||||
celEnv: env,
|
||||
}
|
||||
empty := map[string][]compiledRule{}
|
||||
e.rulesByTopic.Store(&empty)
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// RefreshLoop reloads rules from the database periodically. Cheap — the
|
||||
// table is small (one user × ~6 default rules). Hot path stays in-memory.
|
||||
func (e *Engine) RefreshLoop(ctx context.Context) error {
|
||||
if err := e.reload(ctx); err != nil {
|
||||
e.logger.Warn("alerts: initial reload failed", "err", err)
|
||||
}
|
||||
t := time.NewTicker(e.reloadTick)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
if err := e.reload(ctx); err != nil {
|
||||
e.logger.Warn("alerts: reload failed", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) reload(ctx context.Context) error {
|
||||
all, err := e.repo.ListAll(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
indexed := make(map[string][]compiledRule, len(all))
|
||||
for _, r := range all {
|
||||
prog, err := e.compile(r.Predicate)
|
||||
if err != nil {
|
||||
e.logger.Warn("alerts: skipping rule with bad predicate",
|
||||
"rule_id", r.ID, "topic", r.Topic, "err", err)
|
||||
continue
|
||||
}
|
||||
indexed[r.Topic] = append(
|
||||
indexed[r.Topic],
|
||||
compiledRule{rule: r, program: prog},
|
||||
)
|
||||
}
|
||||
e.rulesByTopic.Store(&indexed)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) compile(predicate string) (cel.Program, error) {
|
||||
if predicate == "" {
|
||||
// Empty predicate = always fire. Use a constant-true program so
|
||||
// the eval path is uniform.
|
||||
ast, iss := e.celEnv.Compile("true")
|
||||
if iss != nil && iss.Err() != nil {
|
||||
return nil, iss.Err()
|
||||
}
|
||||
return e.celEnv.Program(ast)
|
||||
}
|
||||
ast, iss := e.celEnv.Compile(predicate)
|
||||
if iss != nil && iss.Err() != nil {
|
||||
return nil, iss.Err()
|
||||
}
|
||||
return e.celEnv.Program(ast)
|
||||
}
|
||||
|
||||
// Evaluate is the per-event hot path. Looks up rules indexed by topic,
|
||||
// evaluates each predicate against the event payload, then for matches
|
||||
// loads the user's channels and dispatches via the notifier. Cooldowns
|
||||
// are per (rule, channel) so a critical Telegram alert and a less-
|
||||
// critical Discord alert for the same rule have independent windows.
|
||||
func (e *Engine) Evaluate(ctx context.Context, ev events.Event) {
|
||||
idx := e.rulesByTopic.Load()
|
||||
if idx == nil {
|
||||
return
|
||||
}
|
||||
rules, ok := (*idx)[string(ev.Topic)]
|
||||
if !ok || len(rules) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := normalizePayload(ev.Payload)
|
||||
if err != nil {
|
||||
e.logger.Warn(
|
||||
"alerts: payload normalize failed",
|
||||
"topic",
|
||||
ev.Topic,
|
||||
"err",
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
for _, cr := range rules {
|
||||
if !cr.rule.Enabled {
|
||||
continue
|
||||
}
|
||||
match, err := evalPredicate(cr.program, payload)
|
||||
if err != nil {
|
||||
e.logger.Warn("alerts: predicate eval failed",
|
||||
"rule_id", cr.rule.ID, "topic", ev.Topic, "err", err)
|
||||
continue
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
e.fire(ctx, cr.rule, ev, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) fire(
|
||||
ctx context.Context,
|
||||
rule Rule,
|
||||
ev events.Event,
|
||||
payload map[string]any,
|
||||
) {
|
||||
channels, err := e.loader.LoadChannels(ctx, rule.UserID)
|
||||
if err != nil {
|
||||
e.logger.Warn("alerts: load channels failed",
|
||||
"rule_id", rule.ID, "user_id", rule.UserID, "err", err)
|
||||
return
|
||||
}
|
||||
if len(channels) == 0 {
|
||||
return
|
||||
}
|
||||
cooldown := time.Duration(rule.CooldownSec) * time.Second
|
||||
message := formatMessage(rule, ev, payload)
|
||||
|
||||
delivered := []string{}
|
||||
deliveryErrs := map[string]string{}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
for _, ch := range channels {
|
||||
ch := ch
|
||||
key := fmt.Sprintf("alert_cooldown:%s:%s:%s", rule.ID, ch.Type, ch.ID)
|
||||
ok, err := e.cooldowns.TryAcquire(ctx, key, cooldown)
|
||||
if err != nil {
|
||||
e.logger.Warn("alerts: cooldown lookup failed",
|
||||
"rule_id", rule.ID, "channel", ch.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := e.notifier.SendAlert(ctx, ch, message)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
deliveryErrs[ch.ID] = err.Error()
|
||||
e.logger.Warn("alerts: deliver failed",
|
||||
"rule_id", rule.ID, "channel_type", ch.Type, "err", err)
|
||||
return
|
||||
}
|
||||
delivered = append(delivered, ch.ID)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
errBody, _ := json.Marshal(deliveryErrs)
|
||||
if err := e.repo.RecordFire(ctx, HistoryRow{
|
||||
RuleID: rule.ID,
|
||||
UserID: rule.UserID,
|
||||
FiredAt: time.Now().UTC(),
|
||||
Payload: body,
|
||||
DeliveredTo: delivered,
|
||||
DeliveryErrors: errBody,
|
||||
}); err != nil {
|
||||
e.logger.Warn(
|
||||
"alerts: record fire failed",
|
||||
"rule_id",
|
||||
rule.ID,
|
||||
"err",
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func evalPredicate(program cel.Program, payload map[string]any) (bool, error) {
|
||||
out, _, err := program.Eval(map[string]any{"event": payload})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
v, err := celBool(out)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func celBool(v ref.Val) (bool, error) {
|
||||
if v == nil {
|
||||
return false, nil
|
||||
}
|
||||
switch v.Type() {
|
||||
case types.BoolType:
|
||||
b, ok := v.Value().(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf(
|
||||
"cel bool: unexpected concrete type %T",
|
||||
v.Value(),
|
||||
)
|
||||
}
|
||||
return b, nil
|
||||
default:
|
||||
return false, fmt.Errorf("cel result is not bool: %v", v.Type())
|
||||
}
|
||||
}
|
||||
|
||||
// normalizePayload turns whatever the bus carries (map[string]any,
|
||||
// json.RawMessage, struct, ...) into a map suitable for CEL. CEL needs
|
||||
// a map for field access syntax like `event.kp` to work.
|
||||
func normalizePayload(p any) (map[string]any, error) {
|
||||
if p == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
if m, ok := p.(map[string]any); ok {
|
||||
return m, nil
|
||||
}
|
||||
if raw, ok := p.(json.RawMessage); ok {
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func formatMessage(rule Rule, ev events.Event, payload map[string]any) string {
|
||||
body, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
body = []byte(fmt.Sprintf("%v", payload))
|
||||
}
|
||||
return fmt.Sprintf("[%s] %s\n\nTopic: %s\nFired: %s\n\n%s",
|
||||
rule.Name, ev.Source, ev.Topic, ev.Timestamp.UTC().Format(time.RFC3339),
|
||||
body,
|
||||
)
|
||||
}
|
||||
|
||||
// RedisCooldown is a default CooldownStore using SET NX EX.
|
||||
type RedisCooldown struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
func NewRedisCooldown(rdb *redis.Client) *RedisCooldown {
|
||||
return &RedisCooldown{rdb: rdb}
|
||||
}
|
||||
|
||||
func (c *RedisCooldown) TryAcquire(
|
||||
ctx context.Context,
|
||||
key string,
|
||||
ttl time.Duration,
|
||||
) (bool, error) {
|
||||
if ttl <= 0 {
|
||||
return true, nil
|
||||
}
|
||||
ok, err := c.rdb.SetNX(ctx, key, "1", ttl).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
// ©AngelaMos | 2026
|
||||
// entity.go
|
||||
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Rule mirrors the alert_rules table. Predicate is a CEL expression
|
||||
// evaluated against {event: <topic payload>}. Empty predicate means
|
||||
// "always fire" — used for default rules like "every KEV add".
|
||||
type Rule struct {
|
||||
ID string `db:"id"`
|
||||
UserID string `db:"user_id"`
|
||||
Name string `db:"name"`
|
||||
Topic string `db:"topic"`
|
||||
Predicate string `db:"predicate"`
|
||||
CooldownSec int `db:"cooldown_sec"`
|
||||
Enabled bool `db:"enabled"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
// HistoryRow mirrors alert_history. Used both for delivery audit and so
|
||||
// the UI can show "last fired".
|
||||
type HistoryRow struct {
|
||||
ID int64 `db:"id"`
|
||||
RuleID string `db:"rule_id"`
|
||||
UserID string `db:"user_id"`
|
||||
FiredAt time.Time `db:"fired_at"`
|
||||
Payload json.RawMessage `db:"payload"`
|
||||
DeliveredTo []string `db:"delivered_to"`
|
||||
DeliveryErrors json.RawMessage `db:"delivery_errors"`
|
||||
}
|
||||
|
||||
// DefaultRuleSeed is what registers as the bootstrap rule set for a new
|
||||
// user. The predicates come straight from
|
||||
// docs/specs/2026-05-01-monitor-the-situation-design.md.
|
||||
type DefaultRuleSeed struct {
|
||||
Name string
|
||||
Topic string
|
||||
Predicate string
|
||||
CooldownSec int
|
||||
}
|
||||
|
||||
// Defaults returns the seed rules. KEV is the highest-priority alarm
|
||||
// (per spec); CVE filters to severe items only; earthquake / space
|
||||
// weather use the spec thresholds; ransomware fires on every entry.
|
||||
// BTC / ETH ride on the `change_pct_1h` field the coinbase collector
|
||||
// computes from a 1-hour sliding window — `has(...)` guards against
|
||||
// firing during the warmup period when the window isn't full yet.
|
||||
// Cooldowns are sized so a sustained crash doesn't spam — 30 min for
|
||||
// the price drops; you can shorten in the settings UI per-rule.
|
||||
func Defaults() []DefaultRuleSeed {
|
||||
return []DefaultRuleSeed{
|
||||
{
|
||||
Name: "KEV: every newly-added entry",
|
||||
Topic: "kev_added",
|
||||
Predicate: "",
|
||||
CooldownSec: 0,
|
||||
},
|
||||
{
|
||||
Name: "CVE: critical (CVSS≥9 or EPSS≥0.95)",
|
||||
Topic: "cve_new",
|
||||
Predicate: "event.CVSS >= 9.0 || (has(event.EPSSPercentile) && event.EPSSPercentile != null && event.EPSSPercentile >= 0.95)",
|
||||
CooldownSec: 300,
|
||||
},
|
||||
{
|
||||
Name: "Earthquake: M6.5+",
|
||||
Topic: "earthquake",
|
||||
Predicate: "event.properties.mag >= 6.5",
|
||||
CooldownSec: 60,
|
||||
},
|
||||
{
|
||||
Name: "Space weather: Kp≥7 or X-class flare",
|
||||
Topic: "space_weather",
|
||||
Predicate: "(has(event.kp) && event.kp >= 7.0) || (has(event.xray_class) && event.xray_class.startsWith('X'))",
|
||||
CooldownSec: 3600,
|
||||
},
|
||||
{
|
||||
Name: "Ransomware: every new victim",
|
||||
Topic: "ransomware_victim",
|
||||
Predicate: "",
|
||||
CooldownSec: 60,
|
||||
},
|
||||
{
|
||||
Name: "BGP hijack: confidence≥9",
|
||||
Topic: "bgp_hijack",
|
||||
Predicate: "event.confidenceScore >= 9",
|
||||
CooldownSec: 300,
|
||||
},
|
||||
{
|
||||
Name: "BTC: 1h drop ≥5%",
|
||||
Topic: "coinbase_price",
|
||||
Predicate: "event.symbol == 'BTC-USD' && has(event.change_pct_1h) && event.change_pct_1h <= -5.0",
|
||||
CooldownSec: 1800,
|
||||
},
|
||||
{
|
||||
Name: "ETH: 1h drop ≥5%",
|
||||
Topic: "coinbase_price",
|
||||
Predicate: "event.symbol == 'ETH-USD' && has(event.change_pct_1h) && event.change_pct_1h <= -5.0",
|
||||
CooldownSec: 1800,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
// ©AngelaMos | 2026
|
||||
// handler.go
|
||||
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-playground/validator/v10"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/core"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
repo Repository
|
||||
validator *validator.Validate
|
||||
}
|
||||
|
||||
func NewHandler(repo Repository) *Handler {
|
||||
return &Handler{
|
||||
repo: repo,
|
||||
validator: validator.New(validator.WithRequiredStructEnabled()),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(
|
||||
r chi.Router,
|
||||
authenticator func(http.Handler) http.Handler,
|
||||
) {
|
||||
r.Route("/me/alerts", func(r chi.Router) {
|
||||
r.Use(authenticator)
|
||||
|
||||
r.Get("/", h.list)
|
||||
r.Post("/", h.create)
|
||||
r.Patch("/{id}", h.update)
|
||||
r.Delete("/{id}", h.delete)
|
||||
|
||||
r.Get("/history", h.history)
|
||||
})
|
||||
}
|
||||
|
||||
type ruleDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Topic string `json:"topic"`
|
||||
Predicate string `json:"predicate"`
|
||||
CooldownSec int `json:"cooldown_sec"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func toRuleDTO(r Rule) ruleDTO {
|
||||
return ruleDTO{
|
||||
ID: r.ID,
|
||||
Name: r.Name,
|
||||
Topic: r.Topic,
|
||||
Predicate: r.Predicate,
|
||||
CooldownSec: r.CooldownSec,
|
||||
Enabled: r.Enabled,
|
||||
CreatedAt: r.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: r.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
rules, err := h.repo.ListByUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
core.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]ruleDTO, 0, len(rules))
|
||||
for _, ru := range rules {
|
||||
out = append(out, toRuleDTO(ru))
|
||||
}
|
||||
core.OK(w, out)
|
||||
}
|
||||
|
||||
type createRuleRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=200"`
|
||||
Topic string `json:"topic" validate:"required,min=1,max=64"`
|
||||
Predicate string `json:"predicate" validate:"omitempty,max=2048"`
|
||||
CooldownSec int `json:"cooldown_sec" validate:"gte=0,lte=86400"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
var req createRuleRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
core.BadRequest(w, "invalid request body")
|
||||
return
|
||||
}
|
||||
if err := h.validator.Struct(req); err != nil {
|
||||
core.BadRequest(w, core.FormatValidationError(err))
|
||||
return
|
||||
}
|
||||
rule := &Rule{
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Topic: req.Topic,
|
||||
Predicate: req.Predicate,
|
||||
CooldownSec: req.CooldownSec,
|
||||
Enabled: req.Enabled,
|
||||
}
|
||||
if err := h.repo.Create(r.Context(), rule); err != nil {
|
||||
core.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
core.Created(w, toRuleDTO(*rule))
|
||||
}
|
||||
|
||||
type updateRuleRequest struct {
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=200"`
|
||||
Predicate *string `json:"predicate,omitempty" validate:"omitempty,max=2048"`
|
||||
CooldownSec *int `json:"cooldown_sec,omitempty" validate:"omitempty,gte=0,lte=86400"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var req updateRuleRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
core.BadRequest(w, "invalid request body")
|
||||
return
|
||||
}
|
||||
if err := h.validator.Struct(req); err != nil {
|
||||
core.BadRequest(w, core.FormatValidationError(err))
|
||||
return
|
||||
}
|
||||
|
||||
rule, err := h.repo.Get(r.Context(), id, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, core.ErrNotFound) {
|
||||
core.NotFound(w, "rule")
|
||||
return
|
||||
}
|
||||
core.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
if req.Name != nil {
|
||||
rule.Name = *req.Name
|
||||
}
|
||||
if req.Predicate != nil {
|
||||
rule.Predicate = *req.Predicate
|
||||
}
|
||||
if req.CooldownSec != nil {
|
||||
rule.CooldownSec = *req.CooldownSec
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
rule.Enabled = *req.Enabled
|
||||
}
|
||||
if err := h.repo.Update(r.Context(), rule); err != nil {
|
||||
core.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
core.OK(w, toRuleDTO(*rule))
|
||||
}
|
||||
|
||||
func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.repo.Delete(r.Context(), id, userID); err != nil {
|
||||
if errors.Is(err, core.ErrNotFound) {
|
||||
core.NotFound(w, "rule")
|
||||
return
|
||||
}
|
||||
core.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
core.NoContent(w)
|
||||
}
|
||||
|
||||
func (h *Handler) history(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
limit := 50
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
rows, err := h.repo.RecentHistory(r.Context(), userID, limit)
|
||||
if err != nil {
|
||||
core.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
core.OK(w, rows)
|
||||
}
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
// ©AngelaMos | 2026
|
||||
// repository.go
|
||||
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/core"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
ListAll(ctx context.Context) ([]Rule, error)
|
||||
ListByUser(ctx context.Context, userID string) ([]Rule, error)
|
||||
Get(ctx context.Context, id, userID string) (*Rule, error)
|
||||
Create(ctx context.Context, r *Rule) error
|
||||
Update(ctx context.Context, r *Rule) error
|
||||
Delete(ctx context.Context, id, userID string) error
|
||||
SeedDefaults(ctx context.Context, userID string) error
|
||||
RecordFire(ctx context.Context, h HistoryRow) error
|
||||
RecentHistory(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
limit int,
|
||||
) ([]HistoryRow, error)
|
||||
}
|
||||
|
||||
type repository struct {
|
||||
db core.DBTX
|
||||
}
|
||||
|
||||
func NewRepository(db core.DBTX) Repository {
|
||||
return &repository{db: db}
|
||||
}
|
||||
|
||||
func (r *repository) ListAll(ctx context.Context) ([]Rule, error) {
|
||||
var rules []Rule
|
||||
err := r.db.SelectContext(ctx, &rules, `
|
||||
SELECT id, user_id, name, topic, predicate, cooldown_sec, enabled, created_at, updated_at
|
||||
FROM alert_rules
|
||||
WHERE enabled = true`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alerts list all: %w", err)
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func (r *repository) ListByUser(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
) ([]Rule, error) {
|
||||
var rules []Rule
|
||||
err := r.db.SelectContext(ctx, &rules, `
|
||||
SELECT id, user_id, name, topic, predicate, cooldown_sec, enabled, created_at, updated_at
|
||||
FROM alert_rules
|
||||
WHERE user_id = $1
|
||||
ORDER BY topic, name`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alerts list user: %w", err)
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func (r *repository) Get(
|
||||
ctx context.Context,
|
||||
id, userID string,
|
||||
) (*Rule, error) {
|
||||
var rule Rule
|
||||
err := r.db.GetContext(ctx, &rule, `
|
||||
SELECT id, user_id, name, topic, predicate, cooldown_sec, enabled, created_at, updated_at
|
||||
FROM alert_rules
|
||||
WHERE id = $1 AND user_id = $2`, id, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("alerts get: %w", core.ErrNotFound)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alerts get: %w", err)
|
||||
}
|
||||
return &rule, nil
|
||||
}
|
||||
|
||||
func (r *repository) Create(ctx context.Context, rule *Rule) error {
|
||||
if rule.ID == "" {
|
||||
rule.ID = uuid.New().String()
|
||||
}
|
||||
err := r.db.GetContext(
|
||||
ctx,
|
||||
&rule.CreatedAt,
|
||||
`
|
||||
INSERT INTO alert_rules (id, user_id, name, topic, predicate, cooldown_sec, enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING created_at`,
|
||||
rule.ID,
|
||||
rule.UserID,
|
||||
rule.Name,
|
||||
rule.Topic,
|
||||
rule.Predicate,
|
||||
rule.CooldownSec,
|
||||
rule.Enabled,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("alerts create: %w", err)
|
||||
}
|
||||
rule.UpdatedAt = rule.CreatedAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repository) Update(ctx context.Context, rule *Rule) error {
|
||||
res, err := r.db.ExecContext(
|
||||
ctx,
|
||||
`
|
||||
UPDATE alert_rules
|
||||
SET name = $3,
|
||||
predicate = $4,
|
||||
cooldown_sec = $5,
|
||||
enabled = $6,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 AND user_id = $2`,
|
||||
rule.ID,
|
||||
rule.UserID,
|
||||
rule.Name,
|
||||
rule.Predicate,
|
||||
rule.CooldownSec,
|
||||
rule.Enabled,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("alerts update: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("alerts update: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("alerts update: %w", core.ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repository) Delete(ctx context.Context, id, userID string) error {
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`DELETE FROM alert_rules WHERE id = $1 AND user_id = $2`, id, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("alerts delete: %w", err)
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("alerts delete: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("alerts delete: %w", core.ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedDefaults inserts the default rule set for a user, skipping rules
|
||||
// they already have. Dedup is by (user_id, name) — not (user_id, topic) —
|
||||
// so multiple defaults can target the same topic. BTC and ETH both fire
|
||||
// on `coinbase_price`, and the original (user_id, topic) check would
|
||||
// have inserted only the first one. Idempotent: safe to call on every
|
||||
// login, which is how existing accounts pick up newly-added defaults.
|
||||
func (r *repository) SeedDefaults(ctx context.Context, userID string) error {
|
||||
for _, def := range Defaults() {
|
||||
_, err := r.db.ExecContext(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO alert_rules (id, user_id, name, topic, predicate, cooldown_sec, enabled)
|
||||
SELECT $1, $2, $3, $4, $5, $6, true
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM alert_rules WHERE user_id = $2 AND name = $3
|
||||
)`,
|
||||
uuid.New().
|
||||
String(),
|
||||
userID,
|
||||
def.Name,
|
||||
def.Topic,
|
||||
def.Predicate,
|
||||
def.CooldownSec,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed default %s: %w", def.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repository) RecordFire(ctx context.Context, h HistoryRow) error {
|
||||
if h.FiredAt.IsZero() {
|
||||
h.FiredAt = time.Now().UTC()
|
||||
}
|
||||
if len(h.Payload) == 0 {
|
||||
h.Payload = json.RawMessage("{}")
|
||||
}
|
||||
delivered := pq.StringArray(h.DeliveredTo)
|
||||
_, err := r.db.ExecContext(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO alert_history (rule_id, user_id, fired_at, payload, delivered_to, delivery_errors)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
h.RuleID,
|
||||
h.UserID,
|
||||
h.FiredAt,
|
||||
[]byte(h.Payload),
|
||||
delivered,
|
||||
[]byte(h.DeliveryErrors),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("alerts record fire: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repository) RecentHistory(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
limit int,
|
||||
) ([]HistoryRow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
var rows []HistoryRow
|
||||
err := r.db.SelectContext(ctx, &rows, `
|
||||
SELECT id, rule_id, user_id, fired_at, payload, delivered_to, delivery_errors
|
||||
FROM alert_history
|
||||
WHERE user_id = $1
|
||||
ORDER BY fired_at DESC
|
||||
LIMIT $2`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alerts recent history: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
// AngelaMos | 2026
|
||||
// dto.go
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" validate:"required,email,max=255"`
|
||||
Password string `json:"password" validate:"required,min=8,max=128"`
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" validate:"required,email,max=255"`
|
||||
Password string `json:"password" validate:"required,min=8,max=128"`
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
}
|
||||
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Tier string `json:"tier"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
User UserResponse `json:"user"`
|
||||
Tokens TokenResponse `json:"tokens"`
|
||||
}
|
||||
|
||||
type SessionInfo struct {
|
||||
ID string `json:"id"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
type SessionsResponse struct {
|
||||
Sessions []SessionInfo `json:"sessions"`
|
||||
}
|
||||
|
||||
type ChangePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password" validate:"required"`
|
||||
NewPassword string `json:"new_password" validate:"required,min=8,max=128"`
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
// AngelaMos | 2026
|
||||
// entity.go
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type RefreshToken struct {
|
||||
ID string `db:"id"`
|
||||
UserID string `db:"user_id"`
|
||||
TokenHash string `db:"token_hash"`
|
||||
FamilyID string `db:"family_id"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
IsUsed bool `db:"is_used"`
|
||||
UsedAt *time.Time `db:"used_at"`
|
||||
RevokedAt *time.Time `db:"revoked_at"`
|
||||
ReplacedByID *string `db:"replaced_by_id"`
|
||||
UserAgent string `db:"user_agent"`
|
||||
IPAddress string `db:"ip_address"`
|
||||
}
|
||||
|
||||
func (t *RefreshToken) IsExpired() bool {
|
||||
return time.Now().After(t.ExpiresAt)
|
||||
}
|
||||
|
||||
func (t *RefreshToken) IsRevoked() bool {
|
||||
return t.RevokedAt != nil
|
||||
}
|
||||
|
||||
func (t *RefreshToken) IsValid() bool {
|
||||
return !t.IsExpired() && !t.IsRevoked() && !t.IsUsed
|
||||
}
|
||||
|
||||
func (t *RefreshToken) MarkAsUsed(replacedByID string) {
|
||||
now := time.Now()
|
||||
t.IsUsed = true
|
||||
t.UsedAt = &now
|
||||
t.ReplacedByID = &replacedByID
|
||||
}
|
||||
|
||||
func (t *RefreshToken) Revoke() {
|
||||
now := time.Now()
|
||||
t.RevokedAt = &now
|
||||
}
|
||||
|
|
@ -0,0 +1,401 @@
|
|||
// AngelaMos | 2026
|
||||
// handler.go
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-playground/validator/v10"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/core"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware"
|
||||
)
|
||||
|
||||
const (
|
||||
refreshCookieName = "mts_rt"
|
||||
refreshCookiePath = "/api/v1/auth"
|
||||
refreshCookieMaxAge = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
validator *validator.Validate
|
||||
trustedProxyHops int
|
||||
secureCookies bool
|
||||
}
|
||||
|
||||
type HandlerConfig struct {
|
||||
Service *Service
|
||||
TrustedProxyHops int
|
||||
SecureCookies bool
|
||||
}
|
||||
|
||||
func NewHandler(service *Service, trustedProxyHops int) *Handler {
|
||||
return NewHandlerWithConfig(HandlerConfig{
|
||||
Service: service,
|
||||
TrustedProxyHops: trustedProxyHops,
|
||||
})
|
||||
}
|
||||
|
||||
func NewHandlerWithConfig(cfg HandlerConfig) *Handler {
|
||||
return &Handler{
|
||||
service: cfg.Service,
|
||||
validator: validator.New(validator.WithRequiredStructEnabled()),
|
||||
trustedProxyHops: cfg.TrustedProxyHops,
|
||||
secureCookies: cfg.SecureCookies,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) setRefreshCookie(
|
||||
w http.ResponseWriter,
|
||||
token string,
|
||||
expiresAt time.Time,
|
||||
) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: refreshCookieName,
|
||||
Value: token,
|
||||
Path: refreshCookiePath,
|
||||
HttpOnly: true,
|
||||
Secure: h.secureCookies,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: expiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) clearRefreshCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: refreshCookieName,
|
||||
Value: "",
|
||||
Path: refreshCookiePath,
|
||||
HttpOnly: true,
|
||||
Secure: h.secureCookies,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) refreshTokenFromRequest(r *http.Request, body string) string {
|
||||
if body != "" {
|
||||
return body
|
||||
}
|
||||
if c, err := r.Cookie(refreshCookieName); err == nil {
|
||||
return c.Value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(
|
||||
r chi.Router,
|
||||
authenticator func(http.Handler) http.Handler,
|
||||
) {
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/login", h.Login)
|
||||
r.Post("/register", h.Register)
|
||||
r.Post("/refresh", h.Refresh)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authenticator)
|
||||
r.Get("/me", h.GetMe)
|
||||
r.Post("/logout", h.Logout)
|
||||
r.Post("/logout-all", h.LogoutAll)
|
||||
r.Get("/sessions", h.GetSessions)
|
||||
r.Delete("/sessions/{sessionID}", h.RevokeSession)
|
||||
r.Post("/change-password", h.ChangePassword)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
core.BadRequest(w, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.validator.Struct(req); err != nil {
|
||||
core.BadRequest(w, core.FormatValidationError(err))
|
||||
return
|
||||
}
|
||||
|
||||
userAgent := r.UserAgent()
|
||||
ipAddress := h.extractIPAddress(r)
|
||||
|
||||
resp, err := h.service.Login(r.Context(), req, userAgent, ipAddress)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
core.JSONError(
|
||||
w,
|
||||
core.UnauthorizedError("invalid email or password"),
|
||||
)
|
||||
return
|
||||
}
|
||||
core.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.setRefreshCookie(
|
||||
w,
|
||||
resp.Tokens.RefreshToken,
|
||||
time.Now().Add(refreshCookieMaxAge),
|
||||
)
|
||||
core.OK(w, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var req RegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
core.BadRequest(w, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.validator.Struct(req); err != nil {
|
||||
core.BadRequest(w, core.FormatValidationError(err))
|
||||
return
|
||||
}
|
||||
|
||||
userAgent := r.UserAgent()
|
||||
ipAddress := h.extractIPAddress(r)
|
||||
|
||||
resp, err := h.service.Register(r.Context(), req, userAgent, ipAddress)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrEmailExists) {
|
||||
core.JSONError(w, core.DuplicateError("email"))
|
||||
return
|
||||
}
|
||||
core.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.setRefreshCookie(
|
||||
w,
|
||||
resp.Tokens.RefreshToken,
|
||||
time.Now().Add(refreshCookieMaxAge),
|
||||
)
|
||||
core.Created(w, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
var req RefreshRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
// Body may be empty when frontend uses cookie-only flow.
|
||||
req = RefreshRequest{}
|
||||
}
|
||||
|
||||
refreshToken := h.refreshTokenFromRequest(r, req.RefreshToken)
|
||||
if refreshToken == "" {
|
||||
core.BadRequest(w, "refresh_token required (cookie or body)")
|
||||
return
|
||||
}
|
||||
|
||||
userAgent := r.UserAgent()
|
||||
ipAddress := h.extractIPAddress(r)
|
||||
|
||||
resp, err := h.service.Refresh(
|
||||
r.Context(),
|
||||
refreshToken,
|
||||
userAgent,
|
||||
ipAddress,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrTokenReuse) {
|
||||
core.JSONError(w, core.NewAppError(
|
||||
core.ErrTokenRevoked,
|
||||
"security alert: token reuse detected, all sessions revoked",
|
||||
http.StatusUnauthorized,
|
||||
"TOKEN_REUSE_DETECTED",
|
||||
))
|
||||
return
|
||||
}
|
||||
if errors.Is(err, core.ErrTokenExpired) {
|
||||
core.JSONError(w, core.TokenExpiredError())
|
||||
return
|
||||
}
|
||||
if errors.Is(err, core.ErrTokenRevoked) {
|
||||
core.JSONError(w, core.TokenRevokedError())
|
||||
return
|
||||
}
|
||||
if errors.Is(err, core.ErrTokenInvalid) {
|
||||
core.JSONError(w, core.TokenInvalidError())
|
||||
return
|
||||
}
|
||||
core.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.setRefreshCookie(
|
||||
w,
|
||||
resp.Tokens.RefreshToken,
|
||||
time.Now().Add(refreshCookieMaxAge),
|
||||
)
|
||||
core.OK(w, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
if userID == "" {
|
||||
core.Unauthorized(w, "")
|
||||
return
|
||||
}
|
||||
|
||||
var req RefreshRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
req = RefreshRequest{}
|
||||
}
|
||||
refreshToken := h.refreshTokenFromRequest(r, req.RefreshToken)
|
||||
|
||||
var jti string
|
||||
var expiresAt time.Time
|
||||
if claims := middleware.GetClaims(r.Context()); claims != nil {
|
||||
jti = claims.JTI
|
||||
expiresAt = claims.ExpiresAt
|
||||
}
|
||||
|
||||
if err := h.service.Logout(
|
||||
r.Context(),
|
||||
refreshToken,
|
||||
userID,
|
||||
jti,
|
||||
expiresAt,
|
||||
); err != nil {
|
||||
if errors.Is(err, core.ErrForbidden) {
|
||||
core.Forbidden(w, "cannot revoke another user's token")
|
||||
return
|
||||
}
|
||||
core.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.clearRefreshCookie(w)
|
||||
core.NoContent(w)
|
||||
}
|
||||
|
||||
func (h *Handler) LogoutAll(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
if userID == "" {
|
||||
core.Unauthorized(w, "")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.LogoutAll(r.Context(), userID); err != nil {
|
||||
core.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
core.NoContent(w)
|
||||
}
|
||||
|
||||
func (h *Handler) GetSessions(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
if userID == "" {
|
||||
core.Unauthorized(w, "")
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := h.service.GetActiveSessions(r.Context(), userID)
|
||||
if err != nil {
|
||||
core.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
core.OK(w, SessionsResponse{Sessions: sessions})
|
||||
}
|
||||
|
||||
func (h *Handler) RevokeSession(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
if userID == "" {
|
||||
core.Unauthorized(w, "")
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := chi.URLParam(r, "sessionID")
|
||||
if sessionID == "" {
|
||||
core.BadRequest(w, "session ID required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.RevokeSession(
|
||||
r.Context(),
|
||||
userID,
|
||||
sessionID,
|
||||
); err != nil {
|
||||
if errors.Is(err, core.ErrNotFound) {
|
||||
core.NotFound(w, "session")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, core.ErrForbidden) {
|
||||
core.Forbidden(w, "cannot revoke another user's session")
|
||||
return
|
||||
}
|
||||
core.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
core.NoContent(w)
|
||||
}
|
||||
|
||||
func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
if userID == "" {
|
||||
core.Unauthorized(w, "")
|
||||
return
|
||||
}
|
||||
|
||||
var req ChangePasswordRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
core.BadRequest(w, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.validator.Struct(req); err != nil {
|
||||
core.BadRequest(w, core.FormatValidationError(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.ChangePassword(
|
||||
r.Context(),
|
||||
userID,
|
||||
req.CurrentPassword,
|
||||
req.NewPassword,
|
||||
); err != nil {
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
core.JSONError(
|
||||
w,
|
||||
core.UnauthorizedError("current password is incorrect"),
|
||||
)
|
||||
return
|
||||
}
|
||||
core.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
core.NoContent(w)
|
||||
}
|
||||
|
||||
func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
if userID == "" {
|
||||
core.Unauthorized(w, "")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.service.GetCurrentUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, core.ErrNotFound) {
|
||||
core.NotFound(w, "user")
|
||||
return
|
||||
}
|
||||
core.InternalServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
core.OK(w, user)
|
||||
}
|
||||
|
||||
func (h *Handler) extractIPAddress(r *http.Request) string {
|
||||
return middleware.ClientIP(r, h.trustedProxyHops)
|
||||
}
|
||||
|
|
@ -0,0 +1,327 @@
|
|||
// AngelaMos | 2026
|
||||
// jwt.go
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
_ "crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/config"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/core"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware"
|
||||
)
|
||||
|
||||
const kidLength = 8
|
||||
|
||||
type JWTManager struct {
|
||||
privateKey jwk.Key
|
||||
publicKey jwk.Key
|
||||
publicJWKS jwk.Set
|
||||
config config.JWTConfig
|
||||
}
|
||||
|
||||
func NewJWTManager(cfg config.JWTConfig) (*JWTManager, error) {
|
||||
privateKeyPEM, err := os.ReadFile(cfg.PrivateKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read private key: %w", err)
|
||||
}
|
||||
|
||||
privateKey, err := jwk.ParseKey(privateKeyPEM, jwk.WithPEM(true))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key: %w", err)
|
||||
}
|
||||
|
||||
if setErr := privateKey.Set(jwk.AlgorithmKey, jwa.ES256()); setErr != nil {
|
||||
return nil, fmt.Errorf("set algorithm: %w", setErr)
|
||||
}
|
||||
|
||||
publicKey, err := privateKey.PublicKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("derive public key: %w", err)
|
||||
}
|
||||
|
||||
keyID, err := deriveKeyID(publicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("derive key id: %w", err)
|
||||
}
|
||||
if setErr := privateKey.Set(jwk.KeyIDKey, keyID); setErr != nil {
|
||||
return nil, fmt.Errorf("set key id: %w", setErr)
|
||||
}
|
||||
if setErr := publicKey.Set(jwk.KeyIDKey, keyID); setErr != nil {
|
||||
return nil, fmt.Errorf("set public key id: %w", setErr)
|
||||
}
|
||||
|
||||
if setErr := publicKey.Set(jwk.KeyUsageKey, "sig"); setErr != nil {
|
||||
return nil, fmt.Errorf("set key usage: %w", setErr)
|
||||
}
|
||||
|
||||
publicJWKS := jwk.NewSet()
|
||||
if addErr := publicJWKS.AddKey(publicKey); addErr != nil {
|
||||
return nil, fmt.Errorf("add key to set: %w", addErr)
|
||||
}
|
||||
|
||||
return &JWTManager{
|
||||
privateKey: privateKey,
|
||||
publicKey: publicKey,
|
||||
publicJWKS: publicJWKS,
|
||||
config: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func GenerateKeyPair(privateKeyPath, publicKeyPath string) error {
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
|
||||
jwkPrivate, err := jwk.Import(privateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("import private key: %w", err)
|
||||
}
|
||||
|
||||
if setErr := jwkPrivate.Set(jwk.AlgorithmKey, jwa.ES256()); setErr != nil {
|
||||
return fmt.Errorf("set algorithm: %w", setErr)
|
||||
}
|
||||
|
||||
privatePEM, err := jwk.Pem(jwkPrivate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode private key: %w", err)
|
||||
}
|
||||
|
||||
if writeErr := os.WriteFile(
|
||||
privateKeyPath,
|
||||
privatePEM,
|
||||
0o600,
|
||||
); writeErr != nil {
|
||||
return fmt.Errorf("write private key: %w", writeErr)
|
||||
}
|
||||
|
||||
jwkPublic, err := jwkPrivate.PublicKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive public key: %w", err)
|
||||
}
|
||||
|
||||
publicPEM, err := jwk.Pem(jwkPublic)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode public key: %w", err)
|
||||
}
|
||||
|
||||
//nolint:gosec // G306: public key is intentionally world-readable
|
||||
if writeErr := os.WriteFile(
|
||||
publicKeyPath,
|
||||
publicPEM,
|
||||
0o644,
|
||||
); writeErr != nil {
|
||||
return fmt.Errorf("write public key: %w", writeErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type AccessTokenClaims struct {
|
||||
UserID string `json:"sub"`
|
||||
Role string `json:"role"`
|
||||
Tier string `json:"tier"`
|
||||
TokenVersion int `json:"token_version"`
|
||||
}
|
||||
|
||||
func (m *JWTManager) CreateAccessToken(
|
||||
claims AccessTokenClaims,
|
||||
) (string, error) {
|
||||
now := time.Now()
|
||||
|
||||
token, err := jwt.NewBuilder().
|
||||
JwtID(uuid.New().String()).
|
||||
Issuer(m.config.Issuer).
|
||||
Audience([]string{m.config.Audience}).
|
||||
Subject(claims.UserID).
|
||||
IssuedAt(now).
|
||||
Expiration(now.Add(m.config.AccessTokenExpire)).
|
||||
NotBefore(now).
|
||||
Claim("role", claims.Role).
|
||||
Claim("tier", claims.Tier).
|
||||
Claim("token_version", claims.TokenVersion).
|
||||
Claim("type", "access").
|
||||
Build()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build token: %w", err)
|
||||
}
|
||||
|
||||
signed, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), m.privateKey))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sign token: %w", err)
|
||||
}
|
||||
|
||||
return string(signed), nil
|
||||
}
|
||||
|
||||
func (m *JWTManager) VerifyAccessToken(
|
||||
ctx context.Context,
|
||||
tokenString string,
|
||||
) (*middleware.AccessTokenClaims, error) {
|
||||
token, err := jwt.Parse(
|
||||
[]byte(tokenString),
|
||||
jwt.WithKey(jwa.ES256(), m.publicKey),
|
||||
jwt.WithValidate(true),
|
||||
jwt.WithIssuer(m.config.Issuer),
|
||||
jwt.WithAudience(m.config.Audience),
|
||||
)
|
||||
if err != nil {
|
||||
if isTokenExpiredError(err) {
|
||||
return nil, fmt.Errorf("verify token: %w", core.ErrTokenExpired)
|
||||
}
|
||||
return nil, fmt.Errorf("verify token: %w", core.ErrTokenInvalid)
|
||||
}
|
||||
|
||||
var tokenType string
|
||||
if err := token.Get("type", &tokenType); err != nil ||
|
||||
tokenType != "access" {
|
||||
return nil, fmt.Errorf(
|
||||
"verify token: invalid token type: %w",
|
||||
core.ErrTokenInvalid,
|
||||
)
|
||||
}
|
||||
|
||||
subject, ok := token.Subject()
|
||||
if !ok || subject == "" {
|
||||
return nil, fmt.Errorf(
|
||||
"verify token: missing subject: %w",
|
||||
core.ErrTokenInvalid,
|
||||
)
|
||||
}
|
||||
|
||||
var roleStr string
|
||||
if err := token.Get("role", &roleStr); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"verify token: missing role claim: %w",
|
||||
core.ErrTokenInvalid,
|
||||
)
|
||||
}
|
||||
|
||||
var tierStr string
|
||||
if err := token.Get("tier", &tierStr); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"verify token: missing tier claim: %w",
|
||||
core.ErrTokenInvalid,
|
||||
)
|
||||
}
|
||||
|
||||
var versionFloat float64
|
||||
if err := token.Get("token_version", &versionFloat); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"verify token: missing token_version claim: %w",
|
||||
core.ErrTokenInvalid,
|
||||
)
|
||||
}
|
||||
|
||||
jti, _ := token.JwtID()
|
||||
expiresAt, _ := token.Expiration()
|
||||
|
||||
return &middleware.AccessTokenClaims{
|
||||
UserID: subject,
|
||||
Role: roleStr,
|
||||
Tier: tierStr,
|
||||
TokenVersion: int(versionFloat),
|
||||
JTI: jti,
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isTokenExpiredError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, jwt.TokenExpiredError()) {
|
||||
return true
|
||||
}
|
||||
errStr := err.Error()
|
||||
return strings.Contains(errStr, "exp") &&
|
||||
strings.Contains(errStr, "not satisfied")
|
||||
}
|
||||
|
||||
func deriveKeyID(publicKey jwk.Key) (string, error) {
|
||||
thumb, err := publicKey.Thumbprint(crypto.SHA256)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("thumbprint: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(thumb)[:kidLength], nil
|
||||
}
|
||||
|
||||
func (m *JWTManager) GetJWKSHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(m.publicJWKS); err != nil {
|
||||
http.Error(
|
||||
w,
|
||||
"Internal Server Error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *JWTManager) GetPublicKey() jwk.Key {
|
||||
return m.publicKey
|
||||
}
|
||||
|
||||
func (m *JWTManager) GetKeyID() string {
|
||||
var kid string
|
||||
//nolint:errcheck // key ID always set during NewJWTManager init
|
||||
_ = m.privateKey.Get(jwk.KeyIDKey, &kid)
|
||||
return kid
|
||||
}
|
||||
|
||||
type RefreshTokenData struct {
|
||||
Token string
|
||||
Hash string
|
||||
ExpiresAt time.Time
|
||||
FamilyID string
|
||||
}
|
||||
|
||||
func (m *JWTManager) CreateRefreshToken(
|
||||
userID, familyID string,
|
||||
) (*RefreshTokenData, error) {
|
||||
token, err := core.GenerateRefreshToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate refresh token: %w", err)
|
||||
}
|
||||
|
||||
hash := core.HashToken(token)
|
||||
expiresAt := time.Now().Add(m.config.RefreshTokenExpire)
|
||||
|
||||
if familyID == "" {
|
||||
familyID = uuid.New().String()
|
||||
}
|
||||
|
||||
return &RefreshTokenData{
|
||||
Token: token,
|
||||
Hash: hash,
|
||||
ExpiresAt: expiresAt,
|
||||
FamilyID: familyID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *JWTManager) VerifyRefreshTokenHash(token, storedHash string) bool {
|
||||
return core.CompareTokenHash(token, storedHash)
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
// AngelaMos | 2026
|
||||
// repository.go
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/core"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, token *RefreshToken) error
|
||||
FindByHash(ctx context.Context, tokenHash string) (*RefreshToken, error)
|
||||
FindByID(ctx context.Context, id string) (*RefreshToken, error)
|
||||
MarkAsUsed(ctx context.Context, id, replacedByID string) error
|
||||
RevokeByID(ctx context.Context, id string) error
|
||||
RevokeByFamilyID(ctx context.Context, familyID string) error
|
||||
RevokeAllForUser(ctx context.Context, userID string) error
|
||||
GetActiveSessionsForUser(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
) ([]RefreshToken, error)
|
||||
DeleteExpired(ctx context.Context) (int64, error)
|
||||
}
|
||||
|
||||
type repository struct {
|
||||
db core.DBTX
|
||||
}
|
||||
|
||||
func NewRepository(db core.DBTX) Repository {
|
||||
return &repository{db: db}
|
||||
}
|
||||
|
||||
func (r *repository) Create(ctx context.Context, token *RefreshToken) error {
|
||||
query := `
|
||||
INSERT INTO refresh_tokens (
|
||||
id, user_id, token_hash, family_id, expires_at,
|
||||
user_agent, ip_address
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7
|
||||
)
|
||||
RETURNING created_at`
|
||||
|
||||
err := r.db.GetContext(ctx, &token.CreatedAt, query,
|
||||
token.ID,
|
||||
token.UserID,
|
||||
token.TokenHash,
|
||||
token.FamilyID,
|
||||
token.ExpiresAt,
|
||||
token.UserAgent,
|
||||
token.IPAddress,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create refresh token: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repository) FindByHash(
|
||||
ctx context.Context,
|
||||
tokenHash string,
|
||||
) (*RefreshToken, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id, user_id, token_hash, family_id, expires_at, created_at,
|
||||
is_used, used_at, revoked_at, replaced_by_id, user_agent, ip_address
|
||||
FROM refresh_tokens
|
||||
WHERE token_hash = $1`
|
||||
|
||||
var token RefreshToken
|
||||
err := r.db.GetContext(ctx, &token, query, tokenHash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("find refresh token: %w", core.ErrNotFound)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
func (r *repository) FindByID(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
) (*RefreshToken, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id, user_id, token_hash, family_id, expires_at, created_at,
|
||||
is_used, used_at, revoked_at, replaced_by_id, user_agent, ip_address
|
||||
FROM refresh_tokens
|
||||
WHERE id = $1`
|
||||
|
||||
var token RefreshToken
|
||||
err := r.db.GetContext(ctx, &token, query, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("find refresh token: %w", core.ErrNotFound)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
func (r *repository) MarkAsUsed(
|
||||
ctx context.Context,
|
||||
id, replacedByID string,
|
||||
) error {
|
||||
query := `
|
||||
UPDATE refresh_tokens
|
||||
SET is_used = true, used_at = NOW(), replaced_by_id = $2
|
||||
WHERE id = $1 AND is_used = false`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, id, replacedByID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark refresh token as used: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark refresh token as used: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("mark refresh token as used: %w", core.ErrNotFound)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repository) RevokeByID(ctx context.Context, id string) error {
|
||||
query := `
|
||||
UPDATE refresh_tokens
|
||||
SET revoked_at = NOW()
|
||||
WHERE id = $1 AND revoked_at IS NULL`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke refresh token: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke refresh token: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("revoke refresh token: %w", core.ErrNotFound)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repository) RevokeByFamilyID(
|
||||
ctx context.Context,
|
||||
familyID string,
|
||||
) error {
|
||||
query := `
|
||||
UPDATE refresh_tokens
|
||||
SET revoked_at = NOW()
|
||||
WHERE family_id = $1 AND revoked_at IS NULL`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query, familyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke token family: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repository) RevokeAllForUser(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
) error {
|
||||
query := `
|
||||
UPDATE refresh_tokens
|
||||
SET revoked_at = NOW()
|
||||
WHERE user_id = $1 AND revoked_at IS NULL`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke all user tokens: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repository) GetActiveSessionsForUser(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
) ([]RefreshToken, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id, user_id, token_hash, family_id, expires_at, created_at,
|
||||
is_used, used_at, revoked_at, replaced_by_id, user_agent, ip_address
|
||||
FROM refresh_tokens
|
||||
WHERE user_id = $1
|
||||
AND revoked_at IS NULL
|
||||
AND is_used = false
|
||||
AND expires_at > NOW()
|
||||
ORDER BY created_at DESC`
|
||||
|
||||
var tokens []RefreshToken
|
||||
err := r.db.SelectContext(ctx, &tokens, query, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get active sessions: %w", err)
|
||||
}
|
||||
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
func (r *repository) DeleteExpired(ctx context.Context) (int64, error) {
|
||||
query := `
|
||||
DELETE FROM refresh_tokens
|
||||
WHERE expires_at < $1`
|
||||
|
||||
cutoff := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, cutoff)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete expired tokens: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete expired tokens: %w", err)
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,525 @@
|
|||
// AngelaMos | 2026
|
||||
// service.go
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/core"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrTokenReuse = errors.New("token reuse detected")
|
||||
ErrEmailExists = errors.New("email already exists")
|
||||
)
|
||||
|
||||
type UserInfo struct {
|
||||
ID string
|
||||
Email string
|
||||
Name string
|
||||
PasswordHash string
|
||||
Role string
|
||||
Tier string
|
||||
TokenVersion int
|
||||
}
|
||||
|
||||
type UserProvider interface {
|
||||
GetByEmail(ctx context.Context, email string) (*UserInfo, error)
|
||||
GetByID(ctx context.Context, id string) (*UserInfo, error)
|
||||
Create(
|
||||
ctx context.Context,
|
||||
email, passwordHash, name string,
|
||||
) (*UserInfo, error)
|
||||
IncrementTokenVersion(ctx context.Context, userID string) error
|
||||
UpdatePassword(ctx context.Context, userID, passwordHash string) error
|
||||
SetRole(ctx context.Context, userID, role string) error
|
||||
}
|
||||
|
||||
// RuleSeeder is called after a fresh user registers, to populate
|
||||
// default alert rules. Wired by main.go using alerts.Repo.SeedDefaults.
|
||||
// Optional — auth works without it; user just won't get alerts until
|
||||
// they create rules manually.
|
||||
type RuleSeeder func(ctx context.Context, userID string) error
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
jwt *JWTManager
|
||||
userProvider UserProvider
|
||||
redis *redis.Client
|
||||
blacklistTTL time.Duration
|
||||
adminEmail string
|
||||
ruleSeeder RuleSeeder
|
||||
}
|
||||
|
||||
// SetRuleSeeder registers a callback fired after Register succeeds.
|
||||
// Called from main.go after both auth.Service and alerts.Repo are
|
||||
// constructed; resolves the cyclic dependency (auth doesn't import
|
||||
// alerts, alerts doesn't import auth).
|
||||
func (s *Service) SetRuleSeeder(fn RuleSeeder) {
|
||||
s.ruleSeeder = fn
|
||||
}
|
||||
|
||||
type ServiceConfig struct {
|
||||
Repo Repository
|
||||
JWT *JWTManager
|
||||
UserProvider UserProvider
|
||||
Redis *redis.Client
|
||||
AdminEmail string
|
||||
}
|
||||
|
||||
func NewService(
|
||||
repo Repository,
|
||||
jwt *JWTManager,
|
||||
userProvider UserProvider,
|
||||
redisClient *redis.Client,
|
||||
) *Service {
|
||||
return NewServiceWithConfig(ServiceConfig{
|
||||
Repo: repo,
|
||||
JWT: jwt,
|
||||
UserProvider: userProvider,
|
||||
Redis: redisClient,
|
||||
})
|
||||
}
|
||||
|
||||
func NewServiceWithConfig(cfg ServiceConfig) *Service {
|
||||
return &Service{
|
||||
repo: cfg.Repo,
|
||||
jwt: cfg.JWT,
|
||||
userProvider: cfg.UserProvider,
|
||||
redis: cfg.Redis,
|
||||
blacklistTTL: 15 * time.Minute,
|
||||
adminEmail: strings.ToLower(strings.TrimSpace(cfg.AdminEmail)),
|
||||
}
|
||||
}
|
||||
|
||||
// promoteIfAdminEmail flips role to admin when the user's email matches
|
||||
// the configured ADMIN_EMAIL. Idempotent — does nothing if already admin
|
||||
// or if no admin email is configured. Mutates `user` in place so the
|
||||
// returned auth response reflects the new role.
|
||||
func (s *Service) promoteIfAdminEmail(ctx context.Context, user *UserInfo) {
|
||||
if s.adminEmail == "" || user == nil {
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(user.Email, s.adminEmail) {
|
||||
return
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
return
|
||||
}
|
||||
if err := s.userProvider.SetRole(ctx, user.ID, "admin"); err != nil {
|
||||
// Best-effort. Logging is the caller's job.
|
||||
return
|
||||
}
|
||||
user.Role = "admin"
|
||||
}
|
||||
|
||||
func (s *Service) Login(
|
||||
ctx context.Context,
|
||||
req LoginRequest,
|
||||
userAgent, ipAddress string,
|
||||
) (*AuthResponse, error) {
|
||||
user, err := s.userProvider.GetByEmail(ctx, req.Email)
|
||||
if err != nil {
|
||||
if errors.Is(err, core.ErrNotFound) {
|
||||
//nolint:errcheck // timing attack prevention - always verify to prevent enumeration
|
||||
_, _, _ = core.VerifyPasswordTimingSafe(req.Password, nil)
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
|
||||
valid, newHash, err := core.VerifyPasswordTimingSafe(
|
||||
req.Password,
|
||||
&user.PasswordHash,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("verify password: %w", err)
|
||||
}
|
||||
|
||||
if !valid {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if newHash != "" {
|
||||
//nolint:errcheck // best-effort rehash upgrade
|
||||
_ = s.userProvider.UpdatePassword(ctx, user.ID, newHash)
|
||||
}
|
||||
|
||||
s.promoteIfAdminEmail(ctx, user)
|
||||
|
||||
if s.ruleSeeder != nil {
|
||||
// Idempotent. Cheap (one INSERT-WHERE-NOT-EXISTS per default
|
||||
// rule) and gives existing accounts new defaults the moment we
|
||||
// ship them, without needing a one-off migration.
|
||||
_ = s.ruleSeeder(ctx, user.ID)
|
||||
}
|
||||
|
||||
return s.createAuthResponse(ctx, user, userAgent, ipAddress, "", nil)
|
||||
}
|
||||
|
||||
func (s *Service) Register(
|
||||
ctx context.Context,
|
||||
req RegisterRequest,
|
||||
userAgent, ipAddress string,
|
||||
) (*AuthResponse, error) {
|
||||
passwordHash, err := core.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
user, err := s.userProvider.Create(ctx, req.Email, passwordHash, req.Name)
|
||||
if err != nil {
|
||||
if errors.Is(err, core.ErrDuplicateKey) {
|
||||
return nil, ErrEmailExists
|
||||
}
|
||||
return nil, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
|
||||
s.promoteIfAdminEmail(ctx, user)
|
||||
|
||||
if s.ruleSeeder != nil {
|
||||
// Best-effort. Don't block registration if seeding fails — the
|
||||
// user can configure rules manually.
|
||||
_ = s.ruleSeeder(ctx, user.ID)
|
||||
}
|
||||
|
||||
return s.createAuthResponse(ctx, user, userAgent, ipAddress, "", nil)
|
||||
}
|
||||
|
||||
func (s *Service) Refresh(
|
||||
ctx context.Context,
|
||||
refreshToken, userAgent, ipAddress string,
|
||||
) (*AuthResponse, error) {
|
||||
tokenHash := core.HashToken(refreshToken)
|
||||
|
||||
storedToken, err := s.repo.FindByHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, core.ErrNotFound) {
|
||||
return nil, fmt.Errorf("refresh: %w", core.ErrTokenInvalid)
|
||||
}
|
||||
return nil, fmt.Errorf("find token: %w", err)
|
||||
}
|
||||
|
||||
if storedToken.IsUsed {
|
||||
//nolint:errcheck // security revocation continues regardless
|
||||
_ = s.repo.RevokeByFamilyID(ctx, storedToken.FamilyID)
|
||||
return nil, ErrTokenReuse
|
||||
}
|
||||
|
||||
if !storedToken.IsValid() {
|
||||
if storedToken.IsRevoked() {
|
||||
return nil, fmt.Errorf("refresh: %w", core.ErrTokenRevoked)
|
||||
}
|
||||
return nil, fmt.Errorf("refresh: %w", core.ErrTokenExpired)
|
||||
}
|
||||
|
||||
user, err := s.userProvider.GetByID(ctx, storedToken.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
|
||||
return s.createAuthResponse(
|
||||
ctx,
|
||||
user,
|
||||
userAgent,
|
||||
ipAddress,
|
||||
storedToken.FamilyID,
|
||||
&storedToken.ID,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) Logout(
|
||||
ctx context.Context,
|
||||
refreshToken, userID, accessJTI string,
|
||||
accessExpiresAt time.Time,
|
||||
) error {
|
||||
tokenHash := core.HashToken(refreshToken)
|
||||
|
||||
storedToken, err := s.repo.FindByHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, core.ErrNotFound) {
|
||||
// Refresh token already gone — still blacklist the access JTI
|
||||
// so the current session can't make authenticated calls.
|
||||
if accessJTI != "" {
|
||||
if revErr := s.RevokeAccessToken(
|
||||
ctx,
|
||||
accessJTI,
|
||||
accessExpiresAt,
|
||||
); revErr != nil {
|
||||
return fmt.Errorf("blacklist access token: %w", revErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("find token: %w", err)
|
||||
}
|
||||
|
||||
if storedToken.UserID != userID {
|
||||
return fmt.Errorf("logout: %w", core.ErrForbidden)
|
||||
}
|
||||
|
||||
if err := s.repo.RevokeByID(ctx, storedToken.ID); err != nil &&
|
||||
!errors.Is(err, core.ErrNotFound) {
|
||||
return fmt.Errorf("revoke token: %w", err)
|
||||
}
|
||||
|
||||
if accessJTI != "" {
|
||||
if err := s.RevokeAccessToken(
|
||||
ctx,
|
||||
accessJTI,
|
||||
accessExpiresAt,
|
||||
); err != nil {
|
||||
return fmt.Errorf("blacklist access token: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) LogoutAll(ctx context.Context, userID string) error {
|
||||
if err := s.repo.RevokeAllForUser(ctx, userID); err != nil {
|
||||
return fmt.Errorf("revoke all tokens: %w", err)
|
||||
}
|
||||
|
||||
if err := s.userProvider.IncrementTokenVersion(ctx, userID); err != nil {
|
||||
return fmt.Errorf("increment token version: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) RevokeAccessToken(
|
||||
ctx context.Context,
|
||||
jti string,
|
||||
expiresAt time.Time,
|
||||
) error {
|
||||
key := "blacklist:" + jti
|
||||
ttl := time.Until(expiresAt)
|
||||
|
||||
if ttl <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.redis.Set(ctx, key, "1", ttl).Err(); err != nil {
|
||||
return fmt.Errorf("blacklist token: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) IsAccessTokenBlacklisted(
|
||||
ctx context.Context,
|
||||
jti string,
|
||||
) (bool, error) {
|
||||
key := "blacklist:" + jti
|
||||
|
||||
exists, err := s.redis.Exists(ctx, key).Result()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check blacklist: %w", err)
|
||||
}
|
||||
|
||||
return exists > 0, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetActiveSessions(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
) ([]SessionInfo, error) {
|
||||
tokens, err := s.repo.GetActiveSessionsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get sessions: %w", err)
|
||||
}
|
||||
|
||||
sessions := make([]SessionInfo, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
sessions = append(sessions, SessionInfo{
|
||||
ID: t.ID,
|
||||
UserAgent: t.UserAgent,
|
||||
IPAddress: t.IPAddress,
|
||||
CreatedAt: t.CreatedAt,
|
||||
ExpiresAt: t.ExpiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (s *Service) RevokeSession(
|
||||
ctx context.Context,
|
||||
userID, sessionID string,
|
||||
) error {
|
||||
token, err := s.repo.FindByID(ctx, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find session: %w", err)
|
||||
}
|
||||
|
||||
if token.UserID != userID {
|
||||
return fmt.Errorf("revoke session: %w", core.ErrForbidden)
|
||||
}
|
||||
|
||||
if err := s.repo.RevokeByID(ctx, sessionID); err != nil {
|
||||
return fmt.Errorf("revoke session: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ChangePassword(
|
||||
ctx context.Context,
|
||||
userID, currentPassword, newPassword string,
|
||||
) error {
|
||||
user, err := s.userProvider.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
|
||||
valid, _, err := core.VerifyPasswordWithRehash(
|
||||
currentPassword,
|
||||
user.PasswordHash,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify password: %w", err)
|
||||
}
|
||||
|
||||
if !valid {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
|
||||
newHash, err := core.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
if err := s.userProvider.UpdatePassword(ctx, userID, newHash); err != nil {
|
||||
return fmt.Errorf("update password: %w", err)
|
||||
}
|
||||
|
||||
if err := s.LogoutAll(ctx, userID); err != nil {
|
||||
return fmt.Errorf("logout all: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ValidateTokenVersion(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
tokenVersion int,
|
||||
) error {
|
||||
user, err := s.userProvider.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
|
||||
if tokenVersion < user.TokenVersion {
|
||||
return fmt.Errorf("validate token version: %w", core.ErrTokenRevoked)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetCurrentUser(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
) (*UserResponse, error) {
|
||||
user, err := s.userProvider.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &UserResponse{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
Name: user.Name,
|
||||
Role: user.Role,
|
||||
Tier: user.Tier,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) createAuthResponse(
|
||||
ctx context.Context,
|
||||
user *UserInfo,
|
||||
userAgent, ipAddress, familyID string,
|
||||
oldTokenID *string,
|
||||
) (*AuthResponse, error) {
|
||||
accessToken, err := s.jwt.CreateAccessToken(AccessTokenClaims{
|
||||
UserID: user.ID,
|
||||
Role: user.Role,
|
||||
Tier: user.Tier,
|
||||
TokenVersion: user.TokenVersion,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create access token: %w", err)
|
||||
}
|
||||
|
||||
refreshData, err := s.jwt.CreateRefreshToken(user.ID, familyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create refresh token: %w", err)
|
||||
}
|
||||
|
||||
newTokenID := uuid.New().String()
|
||||
|
||||
// Claim the old token BEFORE inserting the new one. Two concurrent
|
||||
// refresh attempts with the same token race on this UPDATE; only one
|
||||
// flips is_used=false→true. The loser sees ErrNotFound and returns
|
||||
// ErrTokenReuse without ever creating a new refresh row, which keeps
|
||||
// the rotation chain single-use.
|
||||
if oldTokenID != nil {
|
||||
if err := s.repo.MarkAsUsed(ctx, *oldTokenID, newTokenID); err != nil {
|
||||
if errors.Is(err, core.ErrNotFound) {
|
||||
if revErr := s.repo.RevokeByFamilyID(
|
||||
ctx,
|
||||
familyID,
|
||||
); revErr != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"rotate refresh token: revoke family on race-loss: %w",
|
||||
revErr,
|
||||
)
|
||||
}
|
||||
return nil, ErrTokenReuse
|
||||
}
|
||||
return nil, fmt.Errorf("rotate refresh token: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
refreshTokenEntity := &RefreshToken{
|
||||
ID: newTokenID,
|
||||
UserID: user.ID,
|
||||
TokenHash: refreshData.Hash,
|
||||
FamilyID: refreshData.FamilyID,
|
||||
ExpiresAt: refreshData.ExpiresAt,
|
||||
UserAgent: userAgent,
|
||||
IPAddress: ipAddress,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ctx, refreshTokenEntity); err != nil {
|
||||
return nil, fmt.Errorf("store refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &AuthResponse{
|
||||
User: UserResponse{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
Name: user.Name,
|
||||
Role: user.Role,
|
||||
Tier: user.Tier,
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
Tokens: TokenResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshData.Token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(15 * time.Minute / time.Second),
|
||||
ExpiresAt: time.Now().Add(15 * time.Minute),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
// ©AngelaMos | 2026
|
||||
// verifier.go
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/core"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware"
|
||||
)
|
||||
|
||||
// Verifier composes JWT validation, token-version check, and JTI blacklist
|
||||
// check into a single TokenVerifier the middleware can use. This is what
|
||||
// wires the auth-service's revocation infrastructure (LogoutAll's
|
||||
// IncrementTokenVersion, Logout's RevokeAccessToken) into the request
|
||||
// path — without it, those calls were dead code.
|
||||
type Verifier struct {
|
||||
jwt *JWTManager
|
||||
service *Service
|
||||
userProvider UserProvider
|
||||
}
|
||||
|
||||
func NewVerifier(
|
||||
jwt *JWTManager,
|
||||
service *Service,
|
||||
userProvider UserProvider,
|
||||
) *Verifier {
|
||||
return &Verifier{
|
||||
jwt: jwt,
|
||||
service: service,
|
||||
userProvider: userProvider,
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyAccessToken parses the JWT, then enforces:
|
||||
// - The user's current token_version is <= the version embedded in this
|
||||
// token. A LogoutAll bump invalidates every previously-issued access
|
||||
// token by raising the floor.
|
||||
// - The token's JTI is not in the Redis blacklist (Logout adds it there
|
||||
// to revoke a single session immediately, not just the refresh token).
|
||||
func (v *Verifier) VerifyAccessToken(
|
||||
ctx context.Context,
|
||||
tokenString string,
|
||||
) (*middleware.AccessTokenClaims, error) {
|
||||
claims, err := v.jwt.VerifyAccessToken(ctx, tokenString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims.JTI != "" {
|
||||
blacklisted, blErr := v.service.IsAccessTokenBlacklisted(
|
||||
ctx,
|
||||
claims.JTI,
|
||||
)
|
||||
if blErr != nil {
|
||||
return nil, fmt.Errorf("verify token: blacklist lookup: %w", blErr)
|
||||
}
|
||||
if blacklisted {
|
||||
return nil, fmt.Errorf("verify token: %w", core.ErrTokenRevoked)
|
||||
}
|
||||
}
|
||||
|
||||
user, err := v.userProvider.GetByID(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, core.ErrNotFound) {
|
||||
return nil, fmt.Errorf("verify token: %w", core.ErrTokenInvalid)
|
||||
}
|
||||
return nil, fmt.Errorf("verify token: load user: %w", err)
|
||||
}
|
||||
if claims.TokenVersion < user.TokenVersion {
|
||||
return nil, fmt.Errorf(
|
||||
"verify token: version superseded: %w",
|
||||
core.ErrTokenRevoked,
|
||||
)
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
// ©AngelaMos | 2026
|
||||
// bus.go
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBufferSize = 512
|
||||
defaultSubscriberSize = 256
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BufferSize int
|
||||
Persister Persister
|
||||
Broadcaster Broadcaster
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
type Bus struct {
|
||||
ch chan events.Event
|
||||
persister Persister
|
||||
broadcaster Broadcaster
|
||||
logger *slog.Logger
|
||||
dropped atomic.Uint64
|
||||
|
||||
subsMu sync.RWMutex
|
||||
subscribers []chan events.Event
|
||||
subDropped atomic.Uint64
|
||||
}
|
||||
|
||||
func New(cfg Config) *Bus {
|
||||
if cfg.BufferSize <= 0 {
|
||||
cfg.BufferSize = defaultBufferSize
|
||||
}
|
||||
logger := cfg.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Bus{
|
||||
ch: make(chan events.Event, cfg.BufferSize),
|
||||
persister: cfg.Persister,
|
||||
broadcaster: cfg.Broadcaster,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bus) Emit(ev events.Event) {
|
||||
select {
|
||||
case b.ch <- ev:
|
||||
default:
|
||||
b.dropped.Add(1)
|
||||
b.logger.Warn("event bus full, dropped",
|
||||
"topic", ev.Topic, "source", ev.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bus) DroppedCount() uint64 {
|
||||
return b.dropped.Load()
|
||||
}
|
||||
|
||||
// SubscriberDroppedCount counts events dropped to channel subscribers
|
||||
// (separate from the main bus drop counter).
|
||||
func (b *Bus) SubscriberDroppedCount() uint64 {
|
||||
return b.subDropped.Load()
|
||||
}
|
||||
|
||||
// Subscribe returns a buffered channel that receives every event the bus
|
||||
// sees. Slow subscribers drop events rather than block the persist+
|
||||
// broadcast hot path; SubscriberDroppedCount reports total drops. Wire
|
||||
// subscribers at startup before Run() begins.
|
||||
func (b *Bus) Subscribe() <-chan events.Event {
|
||||
b.subsMu.Lock()
|
||||
defer b.subsMu.Unlock()
|
||||
ch := make(chan events.Event, defaultSubscriberSize)
|
||||
b.subscribers = append(b.subscribers, ch)
|
||||
return ch
|
||||
}
|
||||
|
||||
func (b *Bus) Run(ctx context.Context) error {
|
||||
defer b.closeSubscribers()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case ev := <-b.ch:
|
||||
if b.persister != nil {
|
||||
if err := b.persister.Save(ctx, ev); err != nil {
|
||||
b.logger.Error("persist event failed",
|
||||
"err", err, "topic", ev.Topic)
|
||||
}
|
||||
}
|
||||
if b.broadcaster != nil {
|
||||
payload, err := json.Marshal(ev.Payload)
|
||||
if err != nil {
|
||||
b.logger.Error("marshal payload failed",
|
||||
"err", err, "topic", ev.Topic)
|
||||
continue
|
||||
}
|
||||
b.broadcaster.Broadcast(string(ev.Topic), payload)
|
||||
}
|
||||
b.fanout(ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bus) fanout(ev events.Event) {
|
||||
b.subsMu.RLock()
|
||||
subs := b.subscribers
|
||||
b.subsMu.RUnlock()
|
||||
for _, ch := range subs {
|
||||
select {
|
||||
case ch <- ev:
|
||||
default:
|
||||
b.subDropped.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bus) closeSubscribers() {
|
||||
b.subsMu.Lock()
|
||||
defer b.subsMu.Unlock()
|
||||
for _, ch := range b.subscribers {
|
||||
close(ch)
|
||||
}
|
||||
b.subscribers = nil
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
// ©AngelaMos | 2026
|
||||
// bus_test.go
|
||||
|
||||
package bus_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/bus"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
type capturePersister struct {
|
||||
mu sync.Mutex
|
||||
saved []events.Event
|
||||
}
|
||||
|
||||
func (c *capturePersister) Save(_ context.Context, ev events.Event) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.saved = append(c.saved, ev)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *capturePersister) Count() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return len(c.saved)
|
||||
}
|
||||
|
||||
type captureBroadcaster struct {
|
||||
mu sync.Mutex
|
||||
sent []bus.Broadcast
|
||||
}
|
||||
|
||||
func (b *captureBroadcaster) Broadcast(topic string, payload []byte) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.sent = append(b.sent, bus.Broadcast{Topic: topic, Payload: payload})
|
||||
}
|
||||
|
||||
func (b *captureBroadcaster) Count() int {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return len(b.sent)
|
||||
}
|
||||
|
||||
func TestBus_EmitFlowsThroughDispatcher(t *testing.T) {
|
||||
persist := &capturePersister{}
|
||||
cast := &captureBroadcaster{}
|
||||
|
||||
b := bus.New(bus.Config{
|
||||
BufferSize: 16,
|
||||
Persister: persist,
|
||||
Broadcaster: cast,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- b.Run(ctx) }()
|
||||
|
||||
now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||
b.Emit(events.Event{
|
||||
Topic: events.TopicHeartbeat,
|
||||
Timestamp: now,
|
||||
Source: "heartbeat",
|
||||
Payload: map[string]string{"ts": now.Format(time.RFC3339Nano)},
|
||||
})
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return persist.Count() == 1 && cast.Count() == 1
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
|
||||
cancel()
|
||||
require.ErrorIs(t, <-done, context.Canceled)
|
||||
}
|
||||
|
||||
func TestBus_DropOnFullBuffer(t *testing.T) {
|
||||
persist := &capturePersister{}
|
||||
cast := &captureBroadcaster{}
|
||||
|
||||
b := bus.New(bus.Config{
|
||||
BufferSize: 1,
|
||||
Persister: persist,
|
||||
Broadcaster: cast,
|
||||
})
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
b.Emit(events.Event{Topic: events.TopicHeartbeat, Source: "stress"})
|
||||
}
|
||||
require.Positive(t, b.DroppedCount())
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// ©AngelaMos | 2026
|
||||
// persister.go
|
||||
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
type Persister interface {
|
||||
Save(ctx context.Context, ev events.Event) error
|
||||
}
|
||||
|
||||
type Broadcaster interface {
|
||||
Broadcast(topic string, payload []byte)
|
||||
}
|
||||
|
||||
type Broadcast struct {
|
||||
Topic string
|
||||
Payload []byte
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
// ©AngelaMos | 2026
|
||||
// client.go
|
||||
|
||||
package cfradar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCFBaseURL = "https://api.cloudflare.com"
|
||||
defaultCFRate = 250 * time.Millisecond
|
||||
defaultCFBurst = 5
|
||||
defaultCFBudget = 5
|
||||
defaultCFBreaker = 60 * time.Second
|
||||
pathRadarOutages = "/client/v4/radar/annotations/outages"
|
||||
pathRadarHijacks = "/client/v4/radar/bgp/hijacks/events"
|
||||
defaultDateRange = "1d"
|
||||
)
|
||||
|
||||
type ClientConfig struct {
|
||||
BaseURL string
|
||||
BearerToken string
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
hx *httpx.Client
|
||||
}
|
||||
|
||||
func NewClient(cfg ClientConfig) *Client {
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = defaultCFBaseURL
|
||||
}
|
||||
return &Client{
|
||||
hx: httpx.New(httpx.Config{
|
||||
Name: "cfradar",
|
||||
BaseURL: cfg.BaseURL,
|
||||
BearerToken: cfg.BearerToken,
|
||||
Rate: rate.Every(defaultCFRate),
|
||||
Burst: defaultCFBurst,
|
||||
ConsecutiveFailureBudget: defaultCFBudget,
|
||||
BreakerTimeout: defaultCFBreaker,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
type OutageEnvelope struct {
|
||||
Success bool `json:"success"`
|
||||
Result OutageResultBody `json:"result"`
|
||||
Errors []ErrorEntry `json:"errors"`
|
||||
}
|
||||
|
||||
type OutageResultBody struct {
|
||||
Annotations []OutageAnnotation `json:"annotations"`
|
||||
}
|
||||
|
||||
type OutageAnnotation struct {
|
||||
ID string `json:"id"`
|
||||
StartDate time.Time `json:"startDate"`
|
||||
EndDate *time.Time `json:"endDate"`
|
||||
Locations []string `json:"locations"`
|
||||
ASNs []int32 `json:"asns"`
|
||||
Reason string `json:"reason"`
|
||||
OutageType string `json:"outageType"`
|
||||
}
|
||||
|
||||
type HijackEnvelope struct {
|
||||
Success bool `json:"success"`
|
||||
Result HijackBody `json:"result"`
|
||||
Errors []ErrorEntry `json:"errors"`
|
||||
}
|
||||
|
||||
type HijackBody struct {
|
||||
Events []HijackEvent `json:"events"`
|
||||
}
|
||||
|
||||
type HijackEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
DetectedAt time.Time `json:"detectedAt"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
DurationSec int32 `json:"duration"`
|
||||
Confidence int16 `json:"confidenceScore"`
|
||||
HijackerASN int32 `json:"hijackerAsn"`
|
||||
VictimASNs []int32 `json:"victimAsns"`
|
||||
Prefixes []string `json:"prefixes"`
|
||||
}
|
||||
|
||||
type ErrorEntry struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (c *Client) FetchOutages(ctx context.Context) (OutageResultBody, error) {
|
||||
q := url.Values{"dateRange": []string{defaultDateRange}}
|
||||
var env OutageEnvelope
|
||||
if err := c.hx.GetJSON(ctx, pathRadarOutages, q, &env); err != nil {
|
||||
return OutageResultBody{}, fmt.Errorf("fetch outages: %w", err)
|
||||
}
|
||||
if !env.Success {
|
||||
return OutageResultBody{}, fmt.Errorf(
|
||||
"radar outages: success=false errors=%v",
|
||||
env.Errors,
|
||||
)
|
||||
}
|
||||
return env.Result, nil
|
||||
}
|
||||
|
||||
func (c *Client) FetchHijacks(
|
||||
ctx context.Context,
|
||||
minConfidence int,
|
||||
) (HijackBody, error) {
|
||||
q := url.Values{
|
||||
"dateRange": []string{defaultDateRange},
|
||||
"minConfidence": []string{strconv.Itoa(minConfidence)},
|
||||
}
|
||||
var env HijackEnvelope
|
||||
if err := c.hx.GetJSON(ctx, pathRadarHijacks, q, &env); err != nil {
|
||||
return HijackBody{}, fmt.Errorf("fetch hijacks: %w", err)
|
||||
}
|
||||
if !env.Success {
|
||||
return HijackBody{}, fmt.Errorf(
|
||||
"radar hijacks: success=false errors=%v",
|
||||
env.Errors,
|
||||
)
|
||||
}
|
||||
return env.Result, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
// ©AngelaMos | 2026
|
||||
// client_test.go
|
||||
|
||||
package cfradar_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cfradar"
|
||||
)
|
||||
|
||||
func newRadarServer(t *testing.T, authHits *atomic.Int32) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(
|
||||
"/client/v4/radar/annotations/outages",
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "Bearer test-token" {
|
||||
authHits.Add(1)
|
||||
}
|
||||
body, err := os.ReadFile("testdata/outages.json")
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
},
|
||||
)
|
||||
mux.HandleFunc(
|
||||
"/client/v4/radar/bgp/hijacks/events",
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
body, err := os.ReadFile("testdata/hijacks.json")
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
},
|
||||
)
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func TestClient_FetchOutagesSendsBearerAndDecodes(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
srv := newRadarServer(t, &hits)
|
||||
defer srv.Close()
|
||||
|
||||
c := cfradar.NewClient(cfradar.ClientConfig{
|
||||
BaseURL: srv.URL,
|
||||
BearerToken: "test-token",
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
body, err := c.FetchOutages(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, body.Annotations, 2)
|
||||
require.Equal(t, "outage-2026-04-30-DE-DTAG", body.Annotations[0].ID)
|
||||
require.Equal(t, []string{"DE"}, body.Annotations[0].Locations)
|
||||
require.Equal(t, []int32{3320}, body.Annotations[0].ASNs)
|
||||
require.EqualValues(t, 1, hits.Load())
|
||||
}
|
||||
|
||||
func TestClient_FetchHijacksDecodes(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
srv := newRadarServer(t, &hits)
|
||||
defer srv.Close()
|
||||
|
||||
c := cfradar.NewClient(cfradar.ClientConfig{
|
||||
BaseURL: srv.URL,
|
||||
BearerToken: "test-token",
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
body, err := c.FetchHijacks(ctx, 7)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, body.Events, 2)
|
||||
require.EqualValues(t, 7700001, body.Events[0].ID)
|
||||
require.EqualValues(t, 9, body.Events[0].Confidence)
|
||||
require.Equal(t, []string{"203.0.113.0/24"}, body.Events[0].Prefixes)
|
||||
}
|
||||
|
||||
func TestClient_FetchOutagesFailsOnSuccessFalse(t *testing.T) {
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write(
|
||||
[]byte(
|
||||
`{"success":false,"errors":[{"message":"unauthorized"}]}`,
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
c := cfradar.NewClient(
|
||||
cfradar.ClientConfig{BaseURL: srv.URL, BearerToken: "x"},
|
||||
)
|
||||
_, err := c.FetchOutages(context.Background())
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
// ©AngelaMos | 2026
|
||||
// collector.go
|
||||
|
||||
package cfradar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
const (
|
||||
Name = "cfradar"
|
||||
defaultCollectorInterval = 5 * time.Minute
|
||||
defaultCollectorConfidence = 7
|
||||
)
|
||||
|
||||
type Fetcher interface {
|
||||
FetchOutages(ctx context.Context) (OutageResultBody, error)
|
||||
FetchHijacks(ctx context.Context, minConfidence int) (HijackBody, error)
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
UpsertOutage(ctx context.Context, o OutageRow) error
|
||||
UpsertHijack(ctx context.Context, h HijackRow) error
|
||||
KnownOutageIDs(ctx context.Context, ids []string) (map[string]bool, error)
|
||||
KnownHijackIDs(ctx context.Context, ids []int64) (map[int64]bool, error)
|
||||
}
|
||||
|
||||
type Emitter interface {
|
||||
Emit(ev events.Event)
|
||||
}
|
||||
|
||||
type StateRecorder interface {
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64)
|
||||
RecordError(ctx context.Context, name, errMsg string)
|
||||
}
|
||||
|
||||
type Enricher interface {
|
||||
Lookup(ctx context.Context, ip string) (Enrichment, error)
|
||||
}
|
||||
|
||||
type Enrichment struct {
|
||||
Country string `json:"country,omitempty"`
|
||||
AbuseConfidence int `json:"abuse_confidence,omitempty"`
|
||||
ISP string `json:"isp,omitempty"`
|
||||
CheckedIP string `json:"checked_ip,omitempty"`
|
||||
}
|
||||
|
||||
type EnrichedHijack struct {
|
||||
HijackEvent
|
||||
Enrichment *Enrichment `json:"enrichment,omitempty"`
|
||||
}
|
||||
|
||||
type CollectorConfig struct {
|
||||
Interval time.Duration
|
||||
MinConfidence int
|
||||
Fetcher Fetcher
|
||||
Repo Repository
|
||||
Emitter Emitter
|
||||
State StateRecorder
|
||||
Enricher Enricher
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
type Collector struct {
|
||||
cfg CollectorConfig
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewCollector(cfg CollectorConfig) *Collector {
|
||||
if cfg.Interval <= 0 {
|
||||
cfg.Interval = defaultCollectorInterval
|
||||
}
|
||||
if cfg.MinConfidence <= 0 {
|
||||
cfg.MinConfidence = defaultCollectorConfidence
|
||||
}
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
}
|
||||
return &Collector{cfg: cfg, logger: cfg.Logger}
|
||||
}
|
||||
|
||||
func (c *Collector) Name() string { return Name }
|
||||
|
||||
func (c *Collector) Run(ctx context.Context) error {
|
||||
ticker := time.NewTicker(c.cfg.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
c.tick(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
c.tick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) tick(ctx context.Context) {
|
||||
emitted := int64(0)
|
||||
hadError := false
|
||||
|
||||
if n, err := c.tickOutages(ctx); err != nil {
|
||||
c.logger.Warn("cfradar outages tick failed", "err", err)
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
hadError = true
|
||||
} else {
|
||||
emitted += n
|
||||
}
|
||||
|
||||
if n, err := c.tickHijacks(ctx); err != nil {
|
||||
c.logger.Warn("cfradar hijacks tick failed", "err", err)
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
hadError = true
|
||||
} else {
|
||||
emitted += n
|
||||
}
|
||||
|
||||
if !hadError {
|
||||
c.cfg.State.RecordSuccess(ctx, Name, emitted)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) tickOutages(ctx context.Context) (int64, error) {
|
||||
body, err := c.cfg.Fetcher.FetchOutages(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(body.Annotations))
|
||||
for _, a := range body.Annotations {
|
||||
ids = append(ids, a.ID)
|
||||
}
|
||||
|
||||
known, err := c.cfg.Repo.KnownOutageIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
emitted := int64(0)
|
||||
for _, a := range body.Annotations {
|
||||
if known[a.ID] {
|
||||
continue
|
||||
}
|
||||
rawBytes, _ := json.Marshal(a)
|
||||
raw := json.RawMessage(rawBytes)
|
||||
row := OutageRow{
|
||||
ID: a.ID,
|
||||
StartedAt: a.StartDate,
|
||||
EndedAt: a.EndDate,
|
||||
Locations: pq.StringArray(a.Locations),
|
||||
ASNs: pq.Int32Array(a.ASNs),
|
||||
Cause: a.Reason,
|
||||
OutageType: a.OutageType,
|
||||
Payload: raw,
|
||||
}
|
||||
if uerr := c.cfg.Repo.UpsertOutage(ctx, row); uerr != nil {
|
||||
c.logger.Warn("upsert outage", "id", a.ID, "err", uerr)
|
||||
continue
|
||||
}
|
||||
c.cfg.Emitter.Emit(events.Event{
|
||||
Topic: events.TopicInternetOutage,
|
||||
Timestamp: now,
|
||||
Source: Name,
|
||||
Payload: raw,
|
||||
})
|
||||
emitted++
|
||||
}
|
||||
return emitted, nil
|
||||
}
|
||||
|
||||
func (c *Collector) tickHijacks(ctx context.Context) (int64, error) {
|
||||
body, err := c.cfg.Fetcher.FetchHijacks(ctx, c.cfg.MinConfidence)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(body.Events))
|
||||
for _, e := range body.Events {
|
||||
ids = append(ids, e.ID)
|
||||
}
|
||||
|
||||
known, err := c.cfg.Repo.KnownHijackIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
emitted := int64(0)
|
||||
for _, e := range body.Events {
|
||||
if known[e.ID] {
|
||||
continue
|
||||
}
|
||||
enriched := EnrichedHijack{HijackEvent: e}
|
||||
if c.cfg.Enricher != nil && len(e.Prefixes) > 0 {
|
||||
if ip, ok := representativeIP(e.Prefixes[0]); ok {
|
||||
if v, lerr := c.cfg.Enricher.Lookup(ctx, ip); lerr == nil {
|
||||
enriched.Enrichment = &Enrichment{
|
||||
Country: v.Country,
|
||||
AbuseConfidence: v.AbuseConfidence,
|
||||
ISP: v.ISP,
|
||||
CheckedIP: ip,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rawBytes, _ := json.Marshal(enriched)
|
||||
raw := json.RawMessage(rawBytes)
|
||||
row := HijackRow{
|
||||
ID: e.ID,
|
||||
DetectedAt: e.DetectedAt,
|
||||
StartedAt: e.StartedAt,
|
||||
DurationSec: e.DurationSec,
|
||||
Confidence: e.Confidence,
|
||||
HijackerASN: e.HijackerASN,
|
||||
VictimASNs: pq.Int32Array(e.VictimASNs),
|
||||
Prefixes: e.Prefixes,
|
||||
Payload: raw,
|
||||
}
|
||||
if uerr := c.cfg.Repo.UpsertHijack(ctx, row); uerr != nil {
|
||||
c.logger.Warn("upsert hijack", "id", e.ID, "err", uerr)
|
||||
continue
|
||||
}
|
||||
c.cfg.Emitter.Emit(events.Event{
|
||||
Topic: events.TopicBGPHijack,
|
||||
Timestamp: now,
|
||||
Source: Name,
|
||||
Payload: raw,
|
||||
})
|
||||
emitted++
|
||||
}
|
||||
return emitted, nil
|
||||
}
|
||||
|
||||
func representativeIP(cidr string) (string, bool) {
|
||||
p, err := netip.ParsePrefix(cidr)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return p.Addr().String(), true
|
||||
}
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
// ©AngelaMos | 2026
|
||||
// collector_test.go
|
||||
|
||||
package cfradar_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cfradar"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
type fakeFetcher struct {
|
||||
outages cfradar.OutageResultBody
|
||||
hijacks cfradar.HijackBody
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchOutages(
|
||||
context.Context,
|
||||
) (cfradar.OutageResultBody, error) {
|
||||
return f.outages, nil
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchHijacks(
|
||||
context.Context,
|
||||
int,
|
||||
) (cfradar.HijackBody, error) {
|
||||
return f.hijacks, nil
|
||||
}
|
||||
|
||||
type fakeRepo struct {
|
||||
mu sync.Mutex
|
||||
outageUpserts int
|
||||
hijackUpserts int
|
||||
knownOutages map[string]bool
|
||||
knownHijacks map[int64]bool
|
||||
}
|
||||
|
||||
func (r *fakeRepo) UpsertOutage(_ context.Context, _ cfradar.OutageRow) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.outageUpserts++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) UpsertHijack(_ context.Context, _ cfradar.HijackRow) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.hijackUpserts++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) KnownOutageIDs(
|
||||
_ context.Context,
|
||||
ids []string,
|
||||
) (map[string]bool, error) {
|
||||
out := make(map[string]bool)
|
||||
for _, id := range ids {
|
||||
if r.knownOutages[id] {
|
||||
out[id] = true
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) KnownHijackIDs(
|
||||
_ context.Context,
|
||||
ids []int64,
|
||||
) (map[int64]bool, error) {
|
||||
out := make(map[int64]bool)
|
||||
for _, id := range ids {
|
||||
if r.knownHijacks[id] {
|
||||
out[id] = true
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type fakeEmitter struct {
|
||||
mu sync.Mutex
|
||||
events []events.Event
|
||||
}
|
||||
|
||||
func (e *fakeEmitter) Emit(ev events.Event) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.events = append(e.events, ev)
|
||||
}
|
||||
|
||||
func (e *fakeEmitter) Events() []events.Event {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
out := make([]events.Event, len(e.events))
|
||||
copy(out, e.events)
|
||||
return out
|
||||
}
|
||||
|
||||
type noopState struct{}
|
||||
|
||||
func (noopState) RecordSuccess(context.Context, string, int64) {}
|
||||
func (noopState) RecordError(context.Context, string, string) {}
|
||||
|
||||
type fakeEnricher struct {
|
||||
calls int
|
||||
verdict cfradar.Enrichment
|
||||
lastIP string
|
||||
returnFn func(ip string) (cfradar.Enrichment, error)
|
||||
}
|
||||
|
||||
func (e *fakeEnricher) Lookup(
|
||||
_ context.Context,
|
||||
ip string,
|
||||
) (cfradar.Enrichment, error) {
|
||||
e.calls++
|
||||
e.lastIP = ip
|
||||
if e.returnFn != nil {
|
||||
return e.returnFn(ip)
|
||||
}
|
||||
return e.verdict, nil
|
||||
}
|
||||
|
||||
func TestCollector_OnlyEmitsNetNew(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
ftch := &fakeFetcher{
|
||||
outages: cfradar.OutageResultBody{
|
||||
Annotations: []cfradar.OutageAnnotation{
|
||||
{ID: "out-known", StartDate: now},
|
||||
{ID: "out-new", StartDate: now},
|
||||
},
|
||||
},
|
||||
hijacks: cfradar.HijackBody{Events: []cfradar.HijackEvent{
|
||||
{ID: 100, DetectedAt: now, StartedAt: now, Confidence: 9},
|
||||
{ID: 200, DetectedAt: now, StartedAt: now, Confidence: 8},
|
||||
}},
|
||||
}
|
||||
repo := &fakeRepo{
|
||||
knownOutages: map[string]bool{"out-known": true},
|
||||
knownHijacks: map[int64]bool{200: true},
|
||||
}
|
||||
emt := &fakeEmitter{}
|
||||
|
||||
c := cfradar.NewCollector(cfradar.CollectorConfig{
|
||||
Interval: 30 * time.Millisecond,
|
||||
MinConfidence: 7,
|
||||
Fetcher: ftch,
|
||||
Repo: repo,
|
||||
Emitter: emt,
|
||||
State: noopState{},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
evs := emt.Events()
|
||||
|
||||
var outageEvents, hijackEvents int
|
||||
for _, ev := range evs {
|
||||
switch ev.Topic {
|
||||
case events.TopicInternetOutage:
|
||||
outageEvents++
|
||||
body, _ := json.Marshal(ev.Payload)
|
||||
require.Contains(t, string(body), "out-new")
|
||||
require.NotContains(t, string(body), "out-known")
|
||||
case events.TopicBGPHijack:
|
||||
hijackEvents++
|
||||
body, _ := json.Marshal(ev.Payload)
|
||||
require.Contains(t, string(body), "100")
|
||||
}
|
||||
}
|
||||
require.GreaterOrEqual(t, outageEvents, 1)
|
||||
require.GreaterOrEqual(t, hijackEvents, 1)
|
||||
}
|
||||
|
||||
func TestCollector_EnrichesHijackPayloadWhenEnricherProvided(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
ftch := &fakeFetcher{
|
||||
hijacks: cfradar.HijackBody{Events: []cfradar.HijackEvent{
|
||||
{
|
||||
ID: 501,
|
||||
DetectedAt: now,
|
||||
StartedAt: now,
|
||||
Confidence: 9,
|
||||
HijackerASN: 4242,
|
||||
Prefixes: []string{"203.0.113.0/24"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
repo := &fakeRepo{knownHijacks: map[int64]bool{}}
|
||||
emt := &fakeEmitter{}
|
||||
enr := &fakeEnricher{verdict: cfradar.Enrichment{
|
||||
Country: "RU",
|
||||
AbuseConfidence: 95,
|
||||
ISP: "ExampleNet",
|
||||
}}
|
||||
|
||||
c := cfradar.NewCollector(cfradar.CollectorConfig{
|
||||
Interval: 30 * time.Millisecond,
|
||||
MinConfidence: 7,
|
||||
Fetcher: ftch,
|
||||
Repo: repo,
|
||||
Emitter: emt,
|
||||
State: noopState{},
|
||||
Enricher: enr,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
require.GreaterOrEqual(t, enr.calls, 1)
|
||||
require.Equal(t, "203.0.113.0", enr.lastIP)
|
||||
|
||||
var found bool
|
||||
for _, ev := range emt.Events() {
|
||||
if ev.Topic != events.TopicBGPHijack {
|
||||
continue
|
||||
}
|
||||
body, _ := json.Marshal(ev.Payload)
|
||||
require.Contains(t, string(body), `"country":"RU"`)
|
||||
require.Contains(t, string(body), `"abuse_confidence":95`)
|
||||
require.Contains(t, string(body), `"checked_ip":"203.0.113.0"`)
|
||||
found = true
|
||||
}
|
||||
require.True(t, found, "expected at least one hijack event with enrichment")
|
||||
}
|
||||
|
||||
func TestCollector_HijackEmitsRawWhenNoEnricher(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
ftch := &fakeFetcher{
|
||||
hijacks: cfradar.HijackBody{Events: []cfradar.HijackEvent{
|
||||
{
|
||||
ID: 777,
|
||||
DetectedAt: now,
|
||||
StartedAt: now,
|
||||
Confidence: 9,
|
||||
Prefixes: []string{"198.51.100.0/24"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
repo := &fakeRepo{knownHijacks: map[int64]bool{}}
|
||||
emt := &fakeEmitter{}
|
||||
|
||||
c := cfradar.NewCollector(cfradar.CollectorConfig{
|
||||
Interval: 30 * time.Millisecond,
|
||||
MinConfidence: 7,
|
||||
Fetcher: ftch,
|
||||
Repo: repo,
|
||||
Emitter: emt,
|
||||
State: noopState{},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
for _, ev := range emt.Events() {
|
||||
if ev.Topic != events.TopicBGPHijack {
|
||||
continue
|
||||
}
|
||||
body, _ := json.Marshal(ev.Payload)
|
||||
require.NotContains(t, string(body), `"enrichment"`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollector_RepeatedTickIsIdempotent(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
ftch := &fakeFetcher{
|
||||
outages: cfradar.OutageResultBody{
|
||||
Annotations: []cfradar.OutageAnnotation{
|
||||
{ID: "out-x", StartDate: now},
|
||||
},
|
||||
},
|
||||
}
|
||||
emit := &fakeEmitter{}
|
||||
|
||||
known := map[string]bool{}
|
||||
repo := &fakeRepo{knownOutages: known}
|
||||
|
||||
c := cfradar.NewCollector(cfradar.CollectorConfig{
|
||||
Interval: 20 * time.Millisecond,
|
||||
MinConfidence: 7,
|
||||
Fetcher: ftch,
|
||||
Repo: repo,
|
||||
Emitter: emit,
|
||||
State: noopState{},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
25*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
known["out-x"] = true
|
||||
|
||||
emit2 := &fakeEmitter{}
|
||||
c2 := cfradar.NewCollector(cfradar.CollectorConfig{
|
||||
Interval: 20 * time.Millisecond,
|
||||
MinConfidence: 7,
|
||||
Fetcher: ftch,
|
||||
Repo: repo,
|
||||
Emitter: emit2,
|
||||
State: noopState{},
|
||||
})
|
||||
ctx2, cancel2 := context.WithTimeout(
|
||||
context.Background(),
|
||||
25*time.Millisecond,
|
||||
)
|
||||
defer cancel2()
|
||||
_ = c2.Run(ctx2)
|
||||
|
||||
for _, ev := range emit2.Events() {
|
||||
require.NotEqual(
|
||||
t,
|
||||
events.TopicInternetOutage,
|
||||
ev.Topic,
|
||||
"should not re-emit known outage",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
// ©AngelaMos | 2026
|
||||
// repo.go
|
||||
|
||||
package cfradar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type OutageRow struct {
|
||||
ID string `db:"id"`
|
||||
StartedAt time.Time `db:"started_at"`
|
||||
EndedAt *time.Time `db:"ended_at"`
|
||||
Locations pq.StringArray `db:"locations"`
|
||||
ASNs pq.Int32Array `db:"asns"`
|
||||
Cause string `db:"cause"`
|
||||
OutageType string `db:"outage_type"`
|
||||
Payload json.RawMessage `db:"payload"`
|
||||
}
|
||||
|
||||
type HijackRow struct {
|
||||
ID int64 `db:"id"`
|
||||
DetectedAt time.Time `db:"detected_at"`
|
||||
StartedAt time.Time `db:"started_at"`
|
||||
DurationSec int32 `db:"duration_sec"`
|
||||
Confidence int16 `db:"confidence"`
|
||||
HijackerASN int32 `db:"hijacker_asn"`
|
||||
VictimASNs pq.Int32Array `db:"victim_asns"`
|
||||
Prefixes []string `db:"-"`
|
||||
Payload json.RawMessage `db:"payload"`
|
||||
}
|
||||
|
||||
type Repo struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} }
|
||||
|
||||
func (r *Repo) UpsertOutage(ctx context.Context, o OutageRow) error {
|
||||
if o.Locations == nil {
|
||||
o.Locations = pq.StringArray{}
|
||||
}
|
||||
if o.ASNs == nil {
|
||||
o.ASNs = pq.Int32Array{}
|
||||
}
|
||||
_, err := r.db.ExecContext(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO outage_events (id, started_at, ended_at, locations, asns, cause, outage_type, payload)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
ended_at = EXCLUDED.ended_at,
|
||||
locations = EXCLUDED.locations,
|
||||
asns = EXCLUDED.asns,
|
||||
cause = EXCLUDED.cause,
|
||||
outage_type = EXCLUDED.outage_type,
|
||||
payload = EXCLUDED.payload`,
|
||||
o.ID,
|
||||
o.StartedAt,
|
||||
o.EndedAt,
|
||||
o.Locations,
|
||||
o.ASNs,
|
||||
o.Cause,
|
||||
o.OutageType,
|
||||
[]byte(o.Payload),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert outage %s: %w", o.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repo) UpsertHijack(ctx context.Context, h HijackRow) error {
|
||||
if h.VictimASNs == nil {
|
||||
h.VictimASNs = pq.Int32Array{}
|
||||
}
|
||||
if h.Prefixes == nil {
|
||||
h.Prefixes = []string{}
|
||||
}
|
||||
_, err := r.db.ExecContext(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO bgp_hijack_events
|
||||
(id, detected_at, started_at, duration_sec, confidence, hijacker_asn, victim_asns, prefixes, payload)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::cidr[], $9)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
detected_at = EXCLUDED.detected_at,
|
||||
duration_sec = EXCLUDED.duration_sec,
|
||||
confidence = EXCLUDED.confidence,
|
||||
payload = EXCLUDED.payload`,
|
||||
h.ID,
|
||||
h.DetectedAt,
|
||||
h.StartedAt,
|
||||
h.DurationSec,
|
||||
h.Confidence,
|
||||
h.HijackerASN,
|
||||
h.VictimASNs,
|
||||
pq.Array(h.Prefixes),
|
||||
[]byte(h.Payload),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert hijack %d: %w", h.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repo) KnownOutageIDs(
|
||||
ctx context.Context,
|
||||
ids []string,
|
||||
) (map[string]bool, error) {
|
||||
if len(ids) == 0 {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
var found []string
|
||||
if err := r.db.SelectContext(
|
||||
ctx,
|
||||
&found,
|
||||
`SELECT id FROM outage_events WHERE id = ANY($1::text[])`,
|
||||
pq.Array(ids),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("known outage ids: %w", err)
|
||||
}
|
||||
out := make(map[string]bool, len(found))
|
||||
for _, id := range found {
|
||||
out[id] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repo) KnownHijackIDs(
|
||||
ctx context.Context,
|
||||
ids []int64,
|
||||
) (map[int64]bool, error) {
|
||||
if len(ids) == 0 {
|
||||
return map[int64]bool{}, nil
|
||||
}
|
||||
var found []int64
|
||||
if err := r.db.SelectContext(
|
||||
ctx,
|
||||
&found,
|
||||
`SELECT id FROM bgp_hijack_events WHERE id = ANY($1::bigint[])`,
|
||||
pq.Array(ids),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("known hijack ids: %w", err)
|
||||
}
|
||||
out := make(map[int64]bool, len(found))
|
||||
for _, id := range found {
|
||||
out[id] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repo) RecentOutages(
|
||||
ctx context.Context,
|
||||
limit int,
|
||||
) ([]OutageRow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
var rows []OutageRow
|
||||
err := r.db.SelectContext(ctx, &rows, `
|
||||
SELECT id, started_at, ended_at, locations, asns, cause, outage_type, payload
|
||||
FROM outage_events
|
||||
ORDER BY started_at DESC
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recent outages: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repo) RecentHijacks(
|
||||
ctx context.Context,
|
||||
limit int,
|
||||
) ([]HijackRow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
var rows []HijackRow
|
||||
err := r.db.SelectContext(ctx, &rows, `
|
||||
SELECT id, detected_at, started_at, duration_sec, confidence, hijacker_asn,
|
||||
victim_asns, payload
|
||||
FROM bgp_hijack_events
|
||||
ORDER BY detected_at DESC
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recent hijacks: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
// ©AngelaMos | 2026
|
||||
// repo_test.go
|
||||
|
||||
package cfradar_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cfradar"
|
||||
)
|
||||
|
||||
func setupDB(t *testing.T) *sqlx.DB {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pg, err := postgres.Run(ctx, "postgres:17-alpine",
|
||||
postgres.WithDatabase("monitor"),
|
||||
postgres.WithUsername("monitor"),
|
||||
postgres.WithPassword("monitor"),
|
||||
postgres.BasicWaitStrategies(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = pg.Terminate(context.Background()) })
|
||||
|
||||
dsn, err := pg.ConnectionString(ctx, "sslmode=disable")
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := sqlx.ConnectContext(ctx, "pgx", dsn)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
_, err = db.ExecContext(ctx, `
|
||||
CREATE TABLE outage_events (
|
||||
id text PRIMARY KEY,
|
||||
started_at timestamptz NOT NULL,
|
||||
ended_at timestamptz,
|
||||
locations text[] NOT NULL DEFAULT '{}',
|
||||
asns integer[] NOT NULL DEFAULT '{}',
|
||||
cause text NOT NULL DEFAULT '',
|
||||
outage_type text NOT NULL DEFAULT '',
|
||||
payload jsonb NOT NULL
|
||||
);
|
||||
CREATE TABLE bgp_hijack_events (
|
||||
id bigint PRIMARY KEY,
|
||||
detected_at timestamptz NOT NULL,
|
||||
started_at timestamptz NOT NULL,
|
||||
duration_sec integer NOT NULL DEFAULT 0,
|
||||
confidence smallint NOT NULL DEFAULT 0,
|
||||
hijacker_asn integer NOT NULL DEFAULT 0,
|
||||
victim_asns integer[] NOT NULL DEFAULT '{}',
|
||||
prefixes cidr[] NOT NULL DEFAULT '{}',
|
||||
payload jsonb NOT NULL
|
||||
)`)
|
||||
require.NoError(t, err)
|
||||
return db
|
||||
}
|
||||
|
||||
func TestRepo_UpsertOutageIsIdempotent(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := cfradar.NewRepo(db)
|
||||
ctx := context.Background()
|
||||
|
||||
row := cfradar.OutageRow{
|
||||
ID: "out-1",
|
||||
StartedAt: time.Now().UTC().Add(-time.Hour),
|
||||
Locations: pq.StringArray{"DE", "AT"},
|
||||
ASNs: pq.Int32Array{15169},
|
||||
Cause: "fiber-cut",
|
||||
OutageType: "regional",
|
||||
Payload: json.RawMessage(`{"id":"out-1"}`),
|
||||
}
|
||||
require.NoError(t, repo.UpsertOutage(ctx, row))
|
||||
require.NoError(t, repo.UpsertOutage(ctx, row))
|
||||
}
|
||||
|
||||
func TestRepo_UpsertHijackAndDiff(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := cfradar.NewRepo(db)
|
||||
ctx := context.Background()
|
||||
|
||||
row := cfradar.HijackRow{
|
||||
ID: 7700001,
|
||||
DetectedAt: time.Now().UTC(),
|
||||
StartedAt: time.Now().UTC().Add(-2 * time.Minute),
|
||||
Confidence: 9,
|
||||
HijackerASN: 64500,
|
||||
VictimASNs: pq.Int32Array{15169, 13335},
|
||||
Prefixes: []string{"203.0.113.0/24"},
|
||||
Payload: json.RawMessage(`{"id":7700001}`),
|
||||
}
|
||||
require.NoError(t, repo.UpsertHijack(ctx, row))
|
||||
|
||||
known, err := repo.KnownHijackIDs(ctx, []int64{7700001, 7700002})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[int64]bool{7700001: true}, known)
|
||||
}
|
||||
|
||||
func TestRepo_KnownOutageIDs(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := cfradar.NewRepo(db)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, repo.UpsertOutage(ctx, cfradar.OutageRow{
|
||||
ID: "out-known", StartedAt: time.Now().UTC(),
|
||||
Payload: json.RawMessage(`{}`),
|
||||
}))
|
||||
|
||||
known, err := repo.KnownOutageIDs(ctx, []string{"out-known", "out-new"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]bool{"out-known": true}, known)
|
||||
}
|
||||
|
||||
func TestRepo_KnownIDsHandlesEmptySlice(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := cfradar.NewRepo(db)
|
||||
ctx := context.Background()
|
||||
|
||||
out, err := repo.KnownOutageIDs(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, out)
|
||||
|
||||
hij, err := repo.KnownHijackIDs(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, hij)
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"success": true,
|
||||
"errors": [],
|
||||
"messages": [],
|
||||
"result": {
|
||||
"events": [
|
||||
{
|
||||
"id": 7700001,
|
||||
"detectedAt": "2026-05-01T17:23:14Z",
|
||||
"startedAt": "2026-05-01T17:21:50Z",
|
||||
"duration": 600,
|
||||
"confidenceScore": 9,
|
||||
"hijackerAsn": 64500,
|
||||
"victimAsns": [15169, 13335],
|
||||
"prefixes": ["203.0.113.0/24"]
|
||||
},
|
||||
{
|
||||
"id": 7700002,
|
||||
"detectedAt": "2026-05-01T18:02:09Z",
|
||||
"startedAt": "2026-05-01T18:00:00Z",
|
||||
"duration": 180,
|
||||
"confidenceScore": 7,
|
||||
"hijackerAsn": 64512,
|
||||
"victimAsns": [3356],
|
||||
"prefixes": ["198.51.100.0/24", "198.51.101.0/24"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"success": true,
|
||||
"errors": [],
|
||||
"messages": [],
|
||||
"result": {
|
||||
"annotations": [
|
||||
{
|
||||
"id": "outage-2026-04-30-DE-DTAG",
|
||||
"startDate": "2026-04-30T14:00:00Z",
|
||||
"endDate": "2026-04-30T15:30:00Z",
|
||||
"locations": ["DE"],
|
||||
"asns": [3320],
|
||||
"reason": "fiber cut, regional infrastructure",
|
||||
"outageType": "REGIONAL"
|
||||
},
|
||||
{
|
||||
"id": "outage-2026-05-01-IR-MCI",
|
||||
"startDate": "2026-05-01T09:15:00Z",
|
||||
"endDate": null,
|
||||
"locations": ["IR"],
|
||||
"asns": [197207],
|
||||
"reason": "government-directed shutdown",
|
||||
"outageType": "NATION_WIDE"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
// ©AngelaMos | 2026
|
||||
// aggregator.go
|
||||
|
||||
package coinbase
|
||||
|
||||
import "time"
|
||||
|
||||
type Aggregator struct {
|
||||
open map[string]MinuteBar
|
||||
}
|
||||
|
||||
func NewAggregator() *Aggregator {
|
||||
return &Aggregator{open: make(map[string]MinuteBar)}
|
||||
}
|
||||
|
||||
// Push folds a tick into the open minute bar for its symbol. Volume24h
|
||||
// from the latest tick within the minute wins — the column on
|
||||
// MinuteBar is documented as "24h volume sampled at close of minute",
|
||||
// not as per-minute volume. Per-trade size isn't on the ticker channel.
|
||||
func (a *Aggregator) Push(t Tick) (*MinuteBar, MinuteBar) {
|
||||
minute := t.TS.UTC().Truncate(time.Minute)
|
||||
cur, exists := a.open[t.Symbol]
|
||||
|
||||
if !exists {
|
||||
cur = MinuteBar{
|
||||
Symbol: t.Symbol,
|
||||
Minute: minute,
|
||||
Open: t.Price,
|
||||
High: t.Price,
|
||||
Low: t.Price,
|
||||
Close: t.Price,
|
||||
Volume24hAtClose: t.Volume24h,
|
||||
}
|
||||
a.open[t.Symbol] = cur
|
||||
return nil, cur
|
||||
}
|
||||
|
||||
if minute.After(cur.Minute) {
|
||||
closed := cur
|
||||
cur = MinuteBar{
|
||||
Symbol: t.Symbol,
|
||||
Minute: minute,
|
||||
Open: t.Price,
|
||||
High: t.Price,
|
||||
Low: t.Price,
|
||||
Close: t.Price,
|
||||
Volume24hAtClose: t.Volume24h,
|
||||
}
|
||||
a.open[t.Symbol] = cur
|
||||
return &closed, cur
|
||||
}
|
||||
|
||||
if t.Price.GreaterThan(cur.High) {
|
||||
cur.High = t.Price
|
||||
}
|
||||
if t.Price.LessThan(cur.Low) {
|
||||
cur.Low = t.Price
|
||||
}
|
||||
cur.Close = t.Price
|
||||
cur.Volume24hAtClose = t.Volume24h
|
||||
a.open[t.Symbol] = cur
|
||||
return nil, cur
|
||||
}
|
||||
|
||||
func (a *Aggregator) Flush() []MinuteBar {
|
||||
out := make([]MinuteBar, 0, len(a.open))
|
||||
for _, bar := range a.open {
|
||||
out = append(out, bar)
|
||||
}
|
||||
a.open = make(map[string]MinuteBar)
|
||||
return out
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
// ©AngelaMos | 2026
|
||||
// aggregator_test.go
|
||||
|
||||
package coinbase_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase"
|
||||
)
|
||||
|
||||
func TestAggregator_FirstTickOpensBar(t *testing.T) {
|
||||
a := coinbase.NewAggregator()
|
||||
ts := time.Date(2026, 5, 1, 12, 0, 30, 0, time.UTC)
|
||||
closed, cur := a.Push(coinbase.Tick{
|
||||
Symbol: "BTC-USD", TS: ts,
|
||||
Price: decimal.RequireFromString("42000"),
|
||||
Volume24h: decimal.RequireFromString("100"),
|
||||
})
|
||||
require.Nil(t, closed)
|
||||
require.Equal(t, ts.Truncate(time.Minute), cur.Minute)
|
||||
require.True(t, cur.Open.Equal(decimal.RequireFromString("42000")))
|
||||
require.True(t, cur.Close.Equal(decimal.RequireFromString("42000")))
|
||||
}
|
||||
|
||||
func TestAggregator_FoldUpdatesHighLowClose(t *testing.T) {
|
||||
a := coinbase.NewAggregator()
|
||||
min := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||
a.Push(
|
||||
coinbase.Tick{
|
||||
Symbol: "BTC-USD",
|
||||
TS: min.Add(10 * time.Second),
|
||||
Price: decimal.RequireFromString("42000"),
|
||||
Volume24h: decimal.RequireFromString("100"),
|
||||
},
|
||||
)
|
||||
a.Push(
|
||||
coinbase.Tick{
|
||||
Symbol: "BTC-USD",
|
||||
TS: min.Add(20 * time.Second),
|
||||
Price: decimal.RequireFromString("42050"),
|
||||
Volume24h: decimal.RequireFromString("110"),
|
||||
},
|
||||
)
|
||||
closed, cur := a.Push(coinbase.Tick{
|
||||
Symbol: "BTC-USD",
|
||||
TS: min.Add(30 * time.Second),
|
||||
Price: decimal.RequireFromString(
|
||||
"41950",
|
||||
),
|
||||
Volume24h: decimal.RequireFromString("120"),
|
||||
})
|
||||
require.Nil(t, closed)
|
||||
require.True(t, cur.Open.Equal(decimal.RequireFromString("42000")))
|
||||
require.True(t, cur.High.Equal(decimal.RequireFromString("42050")))
|
||||
require.True(t, cur.Low.Equal(decimal.RequireFromString("41950")))
|
||||
require.True(t, cur.Close.Equal(decimal.RequireFromString("41950")))
|
||||
require.True(
|
||||
t,
|
||||
cur.Volume24hAtClose.Equal(decimal.RequireFromString("120")),
|
||||
)
|
||||
}
|
||||
|
||||
func TestAggregator_MinuteRolloverEmitsClosedBar(t *testing.T) {
|
||||
a := coinbase.NewAggregator()
|
||||
min1 := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||
min2 := min1.Add(time.Minute)
|
||||
|
||||
a.Push(
|
||||
coinbase.Tick{
|
||||
Symbol: "BTC-USD",
|
||||
TS: min1.Add(10 * time.Second),
|
||||
Price: decimal.RequireFromString("42000"),
|
||||
Volume24h: decimal.RequireFromString("100"),
|
||||
},
|
||||
)
|
||||
a.Push(
|
||||
coinbase.Tick{
|
||||
Symbol: "BTC-USD",
|
||||
TS: min1.Add(50 * time.Second),
|
||||
Price: decimal.RequireFromString("42100"),
|
||||
Volume24h: decimal.RequireFromString("105"),
|
||||
},
|
||||
)
|
||||
|
||||
closed, cur := a.Push(coinbase.Tick{
|
||||
Symbol: "BTC-USD",
|
||||
TS: min2.Add(5 * time.Second),
|
||||
Price: decimal.RequireFromString(
|
||||
"42150",
|
||||
),
|
||||
Volume24h: decimal.RequireFromString("110"),
|
||||
})
|
||||
|
||||
require.NotNil(t, closed)
|
||||
require.Equal(t, min1, closed.Minute)
|
||||
require.True(t, closed.Close.Equal(decimal.RequireFromString("42100")))
|
||||
|
||||
require.Equal(t, min2, cur.Minute)
|
||||
require.True(t, cur.Open.Equal(decimal.RequireFromString("42150")))
|
||||
}
|
||||
|
||||
func TestAggregator_BarsArePerProduct(t *testing.T) {
|
||||
a := coinbase.NewAggregator()
|
||||
min := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
_, btc := a.Push(
|
||||
coinbase.Tick{
|
||||
Symbol: "BTC-USD",
|
||||
TS: min.Add(10 * time.Second),
|
||||
Price: decimal.RequireFromString("42000"),
|
||||
Volume24h: decimal.RequireFromString("100"),
|
||||
},
|
||||
)
|
||||
_, eth := a.Push(
|
||||
coinbase.Tick{
|
||||
Symbol: "ETH-USD",
|
||||
TS: min.Add(11 * time.Second),
|
||||
Price: decimal.RequireFromString("2300"),
|
||||
Volume24h: decimal.RequireFromString("80"),
|
||||
},
|
||||
)
|
||||
|
||||
require.Equal(t, "BTC-USD", btc.Symbol)
|
||||
require.Equal(t, "ETH-USD", eth.Symbol)
|
||||
require.True(t, btc.Open.Equal(decimal.RequireFromString("42000")))
|
||||
require.True(t, eth.Open.Equal(decimal.RequireFromString("2300")))
|
||||
}
|
||||
|
||||
func TestAggregator_FlushReturnsAllOpenBars(t *testing.T) {
|
||||
a := coinbase.NewAggregator()
|
||||
min := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||
a.Push(
|
||||
coinbase.Tick{
|
||||
Symbol: "BTC-USD",
|
||||
TS: min.Add(10 * time.Second),
|
||||
Price: decimal.RequireFromString("42000"),
|
||||
Volume24h: decimal.RequireFromString("100"),
|
||||
},
|
||||
)
|
||||
a.Push(
|
||||
coinbase.Tick{
|
||||
Symbol: "ETH-USD",
|
||||
TS: min.Add(15 * time.Second),
|
||||
Price: decimal.RequireFromString("2300"),
|
||||
Volume24h: decimal.RequireFromString("80"),
|
||||
},
|
||||
)
|
||||
bars := a.Flush()
|
||||
require.Len(t, bars, 2)
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
// ©AngelaMos | 2026
|
||||
// client.go
|
||||
|
||||
package coinbase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultProductBTC = "BTC-USD"
|
||||
defaultProductETH = "ETH-USD"
|
||||
channelTicker = "ticker"
|
||||
channelHeartbeats = "heartbeats"
|
||||
channelSubscriptions = "subscriptions"
|
||||
subscribeWriteTimeout = 5 * time.Second
|
||||
defaultReadFrameSize = 1 << 20
|
||||
)
|
||||
|
||||
type FrameType int
|
||||
|
||||
const (
|
||||
FrameTypeUnknown FrameType = iota
|
||||
FrameTypeSubscriptions
|
||||
FrameTypeTicker
|
||||
FrameTypeSnapshot
|
||||
FrameTypeHeartbeats
|
||||
)
|
||||
|
||||
type TickerEntry struct {
|
||||
ProductID string `json:"product_id"`
|
||||
Price decimal.Decimal `json:"price"`
|
||||
Volume24h decimal.Decimal `json:"volume_24_h"`
|
||||
Time time.Time `json:"time"`
|
||||
}
|
||||
|
||||
type Frame struct {
|
||||
Kind FrameType
|
||||
SequenceNum int64
|
||||
Timestamp time.Time
|
||||
Tickers []TickerEntry
|
||||
HeartbeatTime time.Time
|
||||
}
|
||||
|
||||
type DialerConfig struct {
|
||||
URL string
|
||||
ProductIDs []string
|
||||
WriteTimeout time.Duration
|
||||
ReadFrameMax int64
|
||||
}
|
||||
|
||||
type Dialer interface {
|
||||
Dial(ctx context.Context) (*Conn, error)
|
||||
}
|
||||
|
||||
type WSDialer struct {
|
||||
cfg DialerConfig
|
||||
}
|
||||
|
||||
func NewWSDialer(cfg DialerConfig) *WSDialer {
|
||||
if len(cfg.ProductIDs) == 0 {
|
||||
cfg.ProductIDs = []string{defaultProductBTC, defaultProductETH}
|
||||
}
|
||||
if cfg.WriteTimeout <= 0 {
|
||||
cfg.WriteTimeout = subscribeWriteTimeout
|
||||
}
|
||||
if cfg.ReadFrameMax <= 0 {
|
||||
cfg.ReadFrameMax = defaultReadFrameSize
|
||||
}
|
||||
return &WSDialer{cfg: cfg}
|
||||
}
|
||||
|
||||
func (d *WSDialer) Dial(ctx context.Context) (*Conn, error) {
|
||||
c, _, err := websocket.Dial(ctx, d.cfg.URL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial coinbase ws: %w", err)
|
||||
}
|
||||
c.SetReadLimit(d.cfg.ReadFrameMax)
|
||||
|
||||
conn := &Conn{c: c}
|
||||
subCtx, cancel := context.WithTimeout(ctx, d.cfg.WriteTimeout)
|
||||
defer cancel()
|
||||
if err := conn.subscribe(
|
||||
subCtx,
|
||||
channelTicker,
|
||||
d.cfg.ProductIDs,
|
||||
); err != nil {
|
||||
_ = c.Close(websocket.StatusInternalError, "subscribe ticker")
|
||||
return nil, err
|
||||
}
|
||||
if err := conn.subscribe(subCtx, channelHeartbeats, nil); err != nil {
|
||||
_ = c.Close(websocket.StatusInternalError, "subscribe heartbeats")
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
type Conn struct {
|
||||
c *websocket.Conn
|
||||
}
|
||||
|
||||
func (c *Conn) Close() error {
|
||||
if c == nil || c.c == nil {
|
||||
return nil
|
||||
}
|
||||
return c.c.Close(websocket.StatusNormalClosure, "")
|
||||
}
|
||||
|
||||
type subscribeMsg struct {
|
||||
Type string `json:"type"`
|
||||
Channel string `json:"channel"`
|
||||
ProductIDs []string `json:"product_ids,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Conn) subscribe(
|
||||
ctx context.Context,
|
||||
channel string,
|
||||
productIDs []string,
|
||||
) error {
|
||||
msg := subscribeMsg{
|
||||
Type: "subscribe",
|
||||
Channel: channel,
|
||||
ProductIDs: productIDs,
|
||||
}
|
||||
body, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal subscribe %s: %w", channel, err)
|
||||
}
|
||||
if err := c.c.Write(ctx, websocket.MessageText, body); err != nil {
|
||||
return fmt.Errorf("write subscribe %s: %w", channel, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type rawEnvelope struct {
|
||||
Channel string `json:"channel"`
|
||||
SequenceNum int64 `json:"sequence_num"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Events []rawEnvelopeEv `json:"events"`
|
||||
}
|
||||
|
||||
type rawEnvelopeEv struct {
|
||||
Type string `json:"type"`
|
||||
Tickers []TickerEntry `json:"tickers,omitempty"`
|
||||
CurrentTime string `json:"current_time,omitempty"`
|
||||
}
|
||||
|
||||
func parseCoinbaseTime(s string) time.Time {
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||
return t
|
||||
}
|
||||
if idx := strings.Index(s, " m=+"); idx > 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
if t, err := time.Parse(
|
||||
"2006-01-02 15:04:05.999999999 -0700 MST",
|
||||
s,
|
||||
); err == nil {
|
||||
return t
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
var ErrFrameMalformed = errors.New("coinbase: malformed frame")
|
||||
|
||||
func (c *Conn) ReadFrame(ctx context.Context) (Frame, error) {
|
||||
_, msg, err := c.c.Read(ctx)
|
||||
if err != nil {
|
||||
return Frame{}, fmt.Errorf("read coinbase frame: %w", err)
|
||||
}
|
||||
return decodeFrame(msg)
|
||||
}
|
||||
|
||||
func decodeFrame(msg []byte) (Frame, error) {
|
||||
var env rawEnvelope
|
||||
if err := json.Unmarshal(msg, &env); err != nil {
|
||||
return Frame{}, fmt.Errorf("%w: %w", ErrFrameMalformed, err)
|
||||
}
|
||||
|
||||
frame := Frame{
|
||||
SequenceNum: env.SequenceNum,
|
||||
Timestamp: env.Timestamp,
|
||||
}
|
||||
|
||||
switch env.Channel {
|
||||
case channelSubscriptions:
|
||||
frame.Kind = FrameTypeSubscriptions
|
||||
return frame, nil
|
||||
|
||||
case channelHeartbeats:
|
||||
frame.Kind = FrameTypeHeartbeats
|
||||
if len(env.Events) > 0 {
|
||||
frame.HeartbeatTime = parseCoinbaseTime(env.Events[0].CurrentTime)
|
||||
}
|
||||
return frame, nil
|
||||
|
||||
case channelTicker:
|
||||
for _, ev := range env.Events {
|
||||
frame.Tickers = append(frame.Tickers, ev.Tickers...)
|
||||
if ev.Type == "snapshot" {
|
||||
frame.Kind = FrameTypeSnapshot
|
||||
}
|
||||
}
|
||||
if frame.Kind != FrameTypeSnapshot {
|
||||
frame.Kind = FrameTypeTicker
|
||||
}
|
||||
return frame, nil
|
||||
}
|
||||
|
||||
frame.Kind = FrameTypeUnknown
|
||||
return frame, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
// ©AngelaMos | 2026
|
||||
// client_test.go
|
||||
|
||||
package coinbase_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase"
|
||||
)
|
||||
|
||||
type fakeServer struct {
|
||||
t *testing.T
|
||||
frames [][]byte
|
||||
received []string
|
||||
mu sync.Mutex
|
||||
srv *httptest.Server
|
||||
}
|
||||
|
||||
func newFakeServer(t *testing.T, frames ...[]byte) *fakeServer {
|
||||
t.Helper()
|
||||
fs := &fakeServer{t: t, frames: frames}
|
||||
fs.srv = httptest.NewServer(http.HandlerFunc(fs.handle))
|
||||
t.Cleanup(fs.srv.Close)
|
||||
return fs
|
||||
}
|
||||
|
||||
func (fs *fakeServer) URL() string {
|
||||
return "ws" + strings.TrimPrefix(fs.srv.URL, "http")
|
||||
}
|
||||
|
||||
func (fs *fakeServer) Received() []string {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
out := make([]string, len(fs.received))
|
||||
copy(out, fs.received)
|
||||
return out
|
||||
}
|
||||
|
||||
func (fs *fakeServer) handle(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := websocket.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "")
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
_, msg, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fs.mu.Lock()
|
||||
fs.received = append(fs.received, string(msg))
|
||||
fs.mu.Unlock()
|
||||
}
|
||||
|
||||
for _, frame := range fs.frames {
|
||||
if err := conn.Write(ctx, websocket.MessageText, frame); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
}
|
||||
|
||||
func loadFixture(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile("testdata/" + name)
|
||||
require.NoError(t, err)
|
||||
return b
|
||||
}
|
||||
|
||||
func TestClient_DialSubscribesTickerAndHeartbeats(t *testing.T) {
|
||||
fs := newFakeServer(t,
|
||||
loadFixture(t, "subscriptions.json"),
|
||||
loadFixture(t, "ticker.json"),
|
||||
)
|
||||
|
||||
d := coinbase.NewWSDialer(coinbase.DialerConfig{
|
||||
URL: fs.URL(),
|
||||
ProductIDs: []string{"BTC-USD", "ETH-USD"},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := d.Dial(ctx)
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
frame, err := conn.ReadFrame(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, coinbase.FrameTypeSubscriptions, frame.Kind)
|
||||
|
||||
frame, err = conn.ReadFrame(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, coinbase.FrameTypeTicker, frame.Kind)
|
||||
require.Len(t, frame.Tickers, 1)
|
||||
require.Equal(t, "BTC-USD", frame.Tickers[0].ProductID)
|
||||
require.Equal(t, "42163.45", frame.Tickers[0].Price.String())
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
recv := fs.Received()
|
||||
if len(recv) < 2 {
|
||||
return false
|
||||
}
|
||||
var subTicker, subHB struct {
|
||||
Type string `json:"type"`
|
||||
ProductIDs []string `json:"product_ids"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(recv[0]), &subTicker); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal([]byte(recv[1]), &subHB); err != nil {
|
||||
return false
|
||||
}
|
||||
return subTicker.Type == "subscribe" &&
|
||||
subTicker.Channel == "ticker" &&
|
||||
len(subTicker.ProductIDs) == 2 &&
|
||||
subHB.Type == "subscribe" &&
|
||||
subHB.Channel == "heartbeats"
|
||||
}, time.Second, 20*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestClient_DecodeSnapshotFrameYieldsAllTickers(t *testing.T) {
|
||||
fs := newFakeServer(t,
|
||||
loadFixture(t, "subscriptions.json"),
|
||||
loadFixture(t, "snapshot.json"),
|
||||
)
|
||||
|
||||
d := coinbase.NewWSDialer(coinbase.DialerConfig{
|
||||
URL: fs.URL(),
|
||||
ProductIDs: []string{"BTC-USD", "ETH-USD"},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := d.Dial(ctx)
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
for {
|
||||
frame, err := conn.ReadFrame(ctx)
|
||||
require.NoError(t, err)
|
||||
if frame.Kind == coinbase.FrameTypeSnapshot {
|
||||
require.Len(t, frame.Tickers, 2)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_DecodeHeartbeatExposesCurrentTime(t *testing.T) {
|
||||
fs := newFakeServer(t,
|
||||
loadFixture(t, "subscriptions.json"),
|
||||
loadFixture(t, "heartbeats.json"),
|
||||
)
|
||||
|
||||
d := coinbase.NewWSDialer(
|
||||
coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}},
|
||||
)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := d.Dial(ctx)
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
for {
|
||||
frame, err := conn.ReadFrame(ctx)
|
||||
require.NoError(t, err)
|
||||
if frame.Kind == coinbase.FrameTypeHeartbeats {
|
||||
require.False(t, frame.HeartbeatTime.IsZero())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
// ©AngelaMos | 2026
|
||||
// collector.go
|
||||
|
||||
package coinbase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
const (
|
||||
Name = "coinbase"
|
||||
defaultURL = "wss://advanced-trade-ws.coinbase.com"
|
||||
defaultThrottle = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
InsertTick(ctx context.Context, t Tick) error
|
||||
UpsertMinute(ctx context.Context, b MinuteBar) error
|
||||
LatestTick(ctx context.Context, symbol string) (Tick, error)
|
||||
History1h(ctx context.Context, symbol string) ([]MinuteBar, error)
|
||||
}
|
||||
|
||||
type Emitter interface {
|
||||
Emit(ev events.Event)
|
||||
}
|
||||
|
||||
type StateRecorder interface {
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64)
|
||||
RecordError(ctx context.Context, name, errMsg string)
|
||||
}
|
||||
|
||||
type CollectorConfig struct {
|
||||
URL string
|
||||
ProductIDs []string
|
||||
Repo Repository
|
||||
Emitter Emitter
|
||||
State StateRecorder
|
||||
Dialer Dialer
|
||||
Throttle time.Duration
|
||||
Reconnect ReconnectConfig
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
type priceObs struct {
|
||||
ts time.Time
|
||||
price decimal.Decimal
|
||||
}
|
||||
|
||||
const (
|
||||
priceHistoryWindow = time.Hour
|
||||
// minHistoryAge guards against false alarms after a backend restart:
|
||||
// if we only have ~5 minutes of price data and the price moved 5%
|
||||
// in that window, that's not "1h drop ≥5%" — it's a 5-minute spike
|
||||
// that happens to round to the threshold. Wait until we have at
|
||||
// least 30 min of observations before reporting change_pct_1h.
|
||||
minHistoryAge = 30 * time.Minute
|
||||
)
|
||||
|
||||
type Collector struct {
|
||||
cfg CollectorConfig
|
||||
dialer Dialer
|
||||
logger *slog.Logger
|
||||
mu sync.Mutex
|
||||
lastEmit map[string]time.Time
|
||||
priceHistory map[string][]priceObs
|
||||
}
|
||||
|
||||
func NewCollector(cfg CollectorConfig) *Collector {
|
||||
if cfg.URL == "" {
|
||||
cfg.URL = defaultURL
|
||||
}
|
||||
if len(cfg.ProductIDs) == 0 {
|
||||
cfg.ProductIDs = []string{defaultProductBTC, defaultProductETH}
|
||||
}
|
||||
if cfg.Throttle <= 0 {
|
||||
cfg.Throttle = defaultThrottle
|
||||
}
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
}
|
||||
|
||||
d := cfg.Dialer
|
||||
if d == nil {
|
||||
d = NewWSDialer(DialerConfig{URL: cfg.URL, ProductIDs: cfg.ProductIDs})
|
||||
}
|
||||
|
||||
return &Collector{
|
||||
cfg: cfg,
|
||||
dialer: d,
|
||||
logger: cfg.Logger,
|
||||
lastEmit: make(map[string]time.Time),
|
||||
priceHistory: make(map[string][]priceObs),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) Name() string { return Name }
|
||||
|
||||
func (c *Collector) Run(ctx context.Context) error {
|
||||
err := Reconnect(ctx, c.dialer, c.cfg.Reconnect, c.handleConn)
|
||||
if err != nil && !errors.Is(err, context.Canceled) &&
|
||||
!errors.Is(err, context.DeadlineExceeded) {
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Collector) handleConn(ctx context.Context, conn *Conn) error {
|
||||
seq := NewSequencer()
|
||||
agg := NewAggregator()
|
||||
count := int64(0)
|
||||
|
||||
loopErr := ReadLoop(
|
||||
ctx,
|
||||
conn,
|
||||
seq,
|
||||
func(hctx context.Context, f Frame) error {
|
||||
switch f.Kind {
|
||||
case FrameTypeUnknown, FrameTypeSubscriptions, FrameTypeHeartbeats:
|
||||
return nil
|
||||
case FrameTypeTicker, FrameTypeSnapshot:
|
||||
ts := f.Timestamp.UTC()
|
||||
if ts.IsZero() {
|
||||
ts = time.Now().UTC()
|
||||
}
|
||||
for _, tk := range f.Tickers {
|
||||
tick := Tick{
|
||||
Symbol: tk.ProductID,
|
||||
TS: ts,
|
||||
Price: tk.Price,
|
||||
Volume24h: tk.Volume24h,
|
||||
}
|
||||
if err := c.cfg.Repo.InsertTick(hctx, tick); err != nil {
|
||||
c.logger.Warn(
|
||||
"insert tick",
|
||||
"symbol",
|
||||
tick.Symbol,
|
||||
"err",
|
||||
err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if closed, _ := agg.Push(tick); closed != nil {
|
||||
if err := c.cfg.Repo.UpsertMinute(
|
||||
hctx,
|
||||
*closed,
|
||||
); err != nil {
|
||||
c.logger.Warn(
|
||||
"upsert minute",
|
||||
"symbol",
|
||||
closed.Symbol,
|
||||
"minute",
|
||||
closed.Minute,
|
||||
"err",
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
if c.shouldEmit(tick.Symbol) {
|
||||
c.emitTick(tick)
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
c.logger.Info("coinbase loop exit",
|
||||
"err", loopErrString(loopErr),
|
||||
"emit_count", count,
|
||||
"agg_open", aggLen(agg),
|
||||
)
|
||||
if loopErr == nil || errors.Is(loopErr, ErrSequenceGap) {
|
||||
c.cfg.State.RecordSuccess(ctx, Name, count)
|
||||
}
|
||||
return loopErr
|
||||
}
|
||||
|
||||
func loopErrString(err error) string {
|
||||
if err == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func aggLen(a *Aggregator) int {
|
||||
if a == nil {
|
||||
return 0
|
||||
}
|
||||
return len(a.open)
|
||||
}
|
||||
|
||||
func (c *Collector) shouldEmit(symbol string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
last, ok := c.lastEmit[symbol]
|
||||
now := time.Now()
|
||||
if !ok || now.Sub(last) >= c.cfg.Throttle {
|
||||
c.lastEmit[symbol] = now
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type tickPayload struct {
|
||||
Symbol string `json:"symbol"`
|
||||
TS time.Time `json:"ts"`
|
||||
Price decimal.Decimal `json:"price"`
|
||||
Volume24h decimal.Decimal `json:"volume_24h"`
|
||||
ChangePct1h *float64 `json:"change_pct_1h,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Collector) emitTick(t Tick) {
|
||||
c.recordPrice(t.Symbol, t.TS, t.Price)
|
||||
payload := tickPayload{
|
||||
Symbol: t.Symbol,
|
||||
TS: t.TS,
|
||||
Price: t.Price,
|
||||
Volume24h: t.Volume24h,
|
||||
}
|
||||
if pct, ok := c.changePct1h(t.Symbol, t.TS, t.Price); ok {
|
||||
payload.ChangePct1h = &pct
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
c.cfg.Emitter.Emit(events.Event{
|
||||
Topic: events.TopicCoinbasePrice,
|
||||
Timestamp: t.TS,
|
||||
Source: Name,
|
||||
Payload: json.RawMessage(body),
|
||||
})
|
||||
}
|
||||
|
||||
// recordPrice keeps a sliding 1-hour window of (timestamp, price)
|
||||
// observations per symbol so we can compute trailing percentage change
|
||||
// without hitting the database on every tick.
|
||||
func (c *Collector) recordPrice(
|
||||
symbol string,
|
||||
ts time.Time,
|
||||
price decimal.Decimal,
|
||||
) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
cutoff := ts.Add(-priceHistoryWindow)
|
||||
hist := c.priceHistory[symbol]
|
||||
drop := 0
|
||||
for drop < len(hist) && hist[drop].ts.Before(cutoff) {
|
||||
drop++
|
||||
}
|
||||
hist = append(hist[drop:], priceObs{ts: ts, price: price})
|
||||
c.priceHistory[symbol] = hist
|
||||
}
|
||||
|
||||
// changePct1h returns the percent change from the oldest observation in
|
||||
// the sliding window to `current`. Returns false if we haven't yet seen
|
||||
// minHistoryAge of data — predicates against early-life ticks would
|
||||
// otherwise fire on noisy short windows. Used by the alerts engine via
|
||||
// the emitted `change_pct_1h` field.
|
||||
func (c *Collector) changePct1h(
|
||||
symbol string,
|
||||
currentTs time.Time,
|
||||
current decimal.Decimal,
|
||||
) (float64, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
hist := c.priceHistory[symbol]
|
||||
if len(hist) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
oldest := hist[0]
|
||||
if currentTs.Sub(oldest.ts) < minHistoryAge {
|
||||
return 0, false
|
||||
}
|
||||
if oldest.price.IsZero() {
|
||||
return 0, false
|
||||
}
|
||||
pct, _ := current.Sub(oldest.price).Div(oldest.price).Float64()
|
||||
return pct * 100, true
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
// ©AngelaMos | 2026
|
||||
// collector_test.go
|
||||
|
||||
package coinbase_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
type fakeRepo struct {
|
||||
mu sync.Mutex
|
||||
ticks []coinbase.Tick
|
||||
minutes []coinbase.MinuteBar
|
||||
}
|
||||
|
||||
func (r *fakeRepo) InsertTick(_ context.Context, t coinbase.Tick) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.ticks = append(r.ticks, t)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) UpsertMinute(_ context.Context, b coinbase.MinuteBar) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.minutes = append(r.minutes, b)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) LatestTick(
|
||||
_ context.Context,
|
||||
_ string,
|
||||
) (coinbase.Tick, error) {
|
||||
return coinbase.Tick{}, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) History1h(
|
||||
_ context.Context,
|
||||
_ string,
|
||||
) ([]coinbase.MinuteBar, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) Tickers() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.ticks)
|
||||
}
|
||||
|
||||
type fakeEmitter struct {
|
||||
mu sync.Mutex
|
||||
events []events.Event
|
||||
}
|
||||
|
||||
func (e *fakeEmitter) Emit(ev events.Event) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.events = append(e.events, ev)
|
||||
}
|
||||
|
||||
func (e *fakeEmitter) Count() int {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return len(e.events)
|
||||
}
|
||||
|
||||
type fakeState struct {
|
||||
mu sync.Mutex
|
||||
successes int
|
||||
failures int
|
||||
}
|
||||
|
||||
func (s *fakeState) RecordSuccess(_ context.Context, _ string, _ int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.successes++
|
||||
}
|
||||
|
||||
func (s *fakeState) RecordError(_ context.Context, _, _ string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.failures++
|
||||
}
|
||||
|
||||
func tickerFrame(seq int64, productID, price string, ts time.Time) []byte {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"channel": "ticker",
|
||||
"sequence_num": seq,
|
||||
"timestamp": ts.Format(time.RFC3339Nano),
|
||||
"events": []any{
|
||||
map[string]any{
|
||||
"type": "update",
|
||||
"tickers": []any{
|
||||
map[string]any{
|
||||
"type": "ticker",
|
||||
"product_id": productID,
|
||||
"price": price,
|
||||
"volume_24_h": "1.0",
|
||||
"time": ts.Format(time.RFC3339Nano),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return body
|
||||
}
|
||||
|
||||
func TestCollector_RunPersistsAndEmits(t *testing.T) {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
frames := [][]byte{loadFixture(t, "subscriptions.json")}
|
||||
for i := 0; i < 6; i++ {
|
||||
ts := now.Add(time.Duration(i) * time.Second)
|
||||
frames = append(
|
||||
frames,
|
||||
tickerFrame(int64(i+1), "BTC-USD", fmt.Sprintf("4200%d.00", i), ts),
|
||||
)
|
||||
}
|
||||
fs := newFakeServer(t, frames...)
|
||||
|
||||
repo := &fakeRepo{}
|
||||
emt := &fakeEmitter{}
|
||||
st := &fakeState{}
|
||||
|
||||
c := coinbase.NewCollector(coinbase.CollectorConfig{
|
||||
URL: fs.URL(),
|
||||
ProductIDs: []string{"BTC-USD"},
|
||||
Repo: repo,
|
||||
Emitter: emt,
|
||||
State: st,
|
||||
Throttle: 10 * time.Millisecond,
|
||||
Reconnect: coinbase.ReconnectConfig{
|
||||
InitialInterval: 5 * time.Millisecond,
|
||||
MaxInterval: 20 * time.Millisecond,
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
800*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
require.GreaterOrEqual(t, repo.Tickers(), 3, "should persist most ticks")
|
||||
require.GreaterOrEqual(
|
||||
t,
|
||||
emt.Count(),
|
||||
1,
|
||||
"should emit at least one event after throttle",
|
||||
)
|
||||
}
|
||||
|
||||
func TestCollector_GapTriggersReconnect(t *testing.T) {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
frames := [][]byte{
|
||||
loadFixture(t, "subscriptions.json"),
|
||||
tickerFrame(100, "BTC-USD", "42000.00", now),
|
||||
tickerFrame(500, "BTC-USD", "42100.00", now),
|
||||
}
|
||||
fs := newFakeServer(t, frames...)
|
||||
|
||||
repo := &fakeRepo{}
|
||||
emt := &fakeEmitter{}
|
||||
st := &fakeState{}
|
||||
|
||||
c := coinbase.NewCollector(coinbase.CollectorConfig{
|
||||
URL: fs.URL(),
|
||||
ProductIDs: []string{"BTC-USD"},
|
||||
Repo: repo,
|
||||
Emitter: emt,
|
||||
State: st,
|
||||
Throttle: 10 * time.Millisecond,
|
||||
Reconnect: coinbase.ReconnectConfig{
|
||||
InitialInterval: 5 * time.Millisecond,
|
||||
MaxInterval: 20 * time.Millisecond,
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
600*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
// ©AngelaMos | 2026
|
||||
// readloop.go
|
||||
|
||||
package coinbase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
var ErrSequenceGap = errors.New("coinbase: sequence gap detected")
|
||||
|
||||
type FrameHandler func(ctx context.Context, f Frame) error
|
||||
|
||||
func ReadLoop(
|
||||
ctx context.Context,
|
||||
conn *Conn,
|
||||
seq *Sequencer,
|
||||
handler FrameHandler,
|
||||
) error {
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
frame, err := conn.ReadFrame(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
switch frame.Kind {
|
||||
case FrameTypeUnknown, FrameTypeSubscriptions:
|
||||
case FrameTypeSnapshot:
|
||||
seq.Reset()
|
||||
_ = seq.Observe(frame.SequenceNum)
|
||||
case FrameTypeTicker, FrameTypeHeartbeats:
|
||||
if seq.Observe(frame.SequenceNum) {
|
||||
slog.Default().Warn("coinbase seq gap (non-fatal)",
|
||||
"channel_kind", frame.Kind,
|
||||
"seq", frame.SequenceNum,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := handler(ctx, frame); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
// ©AngelaMos | 2026
|
||||
// readloop_test.go
|
||||
|
||||
package coinbase_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase"
|
||||
)
|
||||
|
||||
func TestReadLoop_DeliversTickerFrames(t *testing.T) {
|
||||
fs := newFakeServer(
|
||||
t,
|
||||
loadFixture(t, "subscriptions.json"),
|
||||
[]byte(
|
||||
`{"channel":"ticker","sequence_num":1000,"timestamp":"2026-05-01T22:30:00Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42000.00","volume_24_h":"1.0","time":"2026-05-01T22:30:00Z"}]}]}`,
|
||||
),
|
||||
[]byte(
|
||||
`{"channel":"ticker","sequence_num":1001,"timestamp":"2026-05-01T22:30:01Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42001.00","volume_24_h":"1.0","time":"2026-05-01T22:30:01Z"}]}]}`,
|
||||
),
|
||||
)
|
||||
|
||||
d := coinbase.NewWSDialer(
|
||||
coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}},
|
||||
)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
conn, err := d.Dial(ctx)
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
seq := coinbase.NewSequencer()
|
||||
mu := sync.Mutex{}
|
||||
tickerFrames := 0
|
||||
|
||||
loopCtx, loopCancel := context.WithTimeout(ctx, 500*time.Millisecond)
|
||||
defer loopCancel()
|
||||
|
||||
err = coinbase.ReadLoop(
|
||||
loopCtx,
|
||||
conn,
|
||||
seq,
|
||||
func(_ context.Context, f coinbase.Frame) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if f.Kind == coinbase.FrameTypeTicker {
|
||||
tickerFrames++
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
require.True(
|
||||
t,
|
||||
errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||
|
||||
err == nil,
|
||||
)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.GreaterOrEqual(t, tickerFrames, 2)
|
||||
}
|
||||
|
||||
func TestReadLoop_GapIsLoggedButLoopContinues(t *testing.T) {
|
||||
fs := newFakeServer(
|
||||
t,
|
||||
loadFixture(t, "subscriptions.json"),
|
||||
[]byte(
|
||||
`{"channel":"ticker","sequence_num":100,"timestamp":"2026-05-01T22:30:00Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42000.00","volume_24_h":"1.0","time":"2026-05-01T22:30:00Z"}]}]}`,
|
||||
),
|
||||
[]byte(
|
||||
`{"channel":"ticker","sequence_num":250,"timestamp":"2026-05-01T22:30:01Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42001.00","volume_24_h":"1.0","time":"2026-05-01T22:30:01Z"}]}]}`,
|
||||
),
|
||||
)
|
||||
|
||||
d := coinbase.NewWSDialer(
|
||||
coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}},
|
||||
)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
600*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
conn, err := d.Dial(ctx)
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
seq := coinbase.NewSequencer()
|
||||
delivered := 0
|
||||
loopErr := coinbase.ReadLoop(
|
||||
ctx,
|
||||
conn,
|
||||
seq,
|
||||
func(_ context.Context, f coinbase.Frame) error {
|
||||
if f.Kind == coinbase.FrameTypeTicker {
|
||||
delivered++
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
require.True(
|
||||
t,
|
||||
errors.Is(loopErr, context.DeadlineExceeded) || errors.Is(loopErr, context.Canceled) ||
|
||||
loopErr == nil,
|
||||
)
|
||||
require.Equal(
|
||||
t,
|
||||
2,
|
||||
delivered,
|
||||
"both ticker frames must be delivered despite the gap",
|
||||
)
|
||||
}
|
||||
|
||||
func TestReadLoop_SnapshotResetsSequencer(t *testing.T) {
|
||||
fs := newFakeServer(
|
||||
t,
|
||||
loadFixture(t, "subscriptions.json"),
|
||||
loadFixture(t, "snapshot.json"),
|
||||
[]byte(
|
||||
`{"channel":"ticker","sequence_num":2,"timestamp":"2026-05-01T22:30:02Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42164.00","volume_24_h":"1.0","time":"2026-05-01T22:30:02Z"}]}]}`,
|
||||
),
|
||||
)
|
||||
|
||||
d := coinbase.NewWSDialer(
|
||||
coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}},
|
||||
)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
conn, err := d.Dial(ctx)
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
seq := coinbase.NewSequencer()
|
||||
mu := sync.Mutex{}
|
||||
kinds := []coinbase.FrameType{}
|
||||
|
||||
loopCtx, loopCancel := context.WithTimeout(ctx, 800*time.Millisecond)
|
||||
defer loopCancel()
|
||||
err = coinbase.ReadLoop(
|
||||
loopCtx,
|
||||
conn,
|
||||
seq,
|
||||
func(_ context.Context, f coinbase.Frame) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
kinds = append(kinds, f.Kind)
|
||||
return nil
|
||||
},
|
||||
)
|
||||
require.True(
|
||||
t,
|
||||
errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||
|
||||
err == nil,
|
||||
)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.Contains(t, kinds, coinbase.FrameTypeSnapshot)
|
||||
require.Contains(t, kinds, coinbase.FrameTypeTicker)
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
// ©AngelaMos | 2026
|
||||
// reconnect.go
|
||||
|
||||
package coinbase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultReconnectInitial = time.Second
|
||||
defaultReconnectMax = 60 * time.Second
|
||||
)
|
||||
|
||||
type ReconnectConfig struct {
|
||||
InitialInterval time.Duration
|
||||
MaxInterval time.Duration
|
||||
}
|
||||
|
||||
type ConnHandler func(ctx context.Context, conn *Conn) error
|
||||
|
||||
func PermanentReconnectError(err error) error {
|
||||
return backoff.Permanent(err)
|
||||
}
|
||||
|
||||
func Reconnect(
|
||||
ctx context.Context,
|
||||
d Dialer,
|
||||
cfg ReconnectConfig,
|
||||
handler ConnHandler,
|
||||
) error {
|
||||
if cfg.InitialInterval <= 0 {
|
||||
cfg.InitialInterval = defaultReconnectInitial
|
||||
}
|
||||
if cfg.MaxInterval <= 0 {
|
||||
cfg.MaxInterval = defaultReconnectMax
|
||||
}
|
||||
|
||||
bo := backoff.NewExponentialBackOff()
|
||||
bo.InitialInterval = cfg.InitialInterval
|
||||
bo.MaxInterval = cfg.MaxInterval
|
||||
bo.MaxElapsedTime = 0
|
||||
|
||||
op := func() error {
|
||||
if ctx.Err() != nil {
|
||||
return backoff.Permanent(ctx.Err())
|
||||
}
|
||||
conn, err := d.Dial(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return backoff.Permanent(ctx.Err())
|
||||
}
|
||||
return err
|
||||
}
|
||||
hErr := handler(ctx, conn)
|
||||
_ = conn.Close()
|
||||
if hErr == nil {
|
||||
return nil
|
||||
}
|
||||
var perm *backoff.PermanentError
|
||||
if errors.As(hErr, &perm) {
|
||||
return hErr
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return backoff.Permanent(ctx.Err())
|
||||
}
|
||||
return hErr
|
||||
}
|
||||
|
||||
return backoff.Retry(op, backoff.WithContext(bo, ctx))
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
// ©AngelaMos | 2026
|
||||
// reconnect_test.go
|
||||
|
||||
package coinbase_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase"
|
||||
)
|
||||
|
||||
type stubDialer struct {
|
||||
failsBeforeOK int32
|
||||
called atomic.Int32
|
||||
}
|
||||
|
||||
func (d *stubDialer) Dial(_ context.Context) (*coinbase.Conn, error) {
|
||||
n := d.called.Add(1)
|
||||
if n <= d.failsBeforeOK {
|
||||
return nil, errors.New("transient dial failure")
|
||||
}
|
||||
return &coinbase.Conn{}, nil
|
||||
}
|
||||
|
||||
func TestReconnect_StopsOnContextCancel(t *testing.T) {
|
||||
d := &stubDialer{failsBeforeOK: 0}
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
80*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
calls := atomic.Int32{}
|
||||
err := coinbase.Reconnect(ctx, d, coinbase.ReconnectConfig{
|
||||
InitialInterval: 5 * time.Millisecond,
|
||||
MaxInterval: 20 * time.Millisecond,
|
||||
}, func(_ context.Context, _ *coinbase.Conn) error {
|
||||
calls.Add(1)
|
||||
return errors.New("upstream closed")
|
||||
})
|
||||
require.True(
|
||||
t,
|
||||
errors.Is(err, context.DeadlineExceeded) ||
|
||||
errors.Is(err, context.Canceled),
|
||||
)
|
||||
require.Greater(
|
||||
t,
|
||||
calls.Load(),
|
||||
int32(1),
|
||||
"callback should fire multiple times before ctx expires",
|
||||
)
|
||||
}
|
||||
|
||||
func TestReconnect_TransientDialFailureBacksOffThenSucceeds(t *testing.T) {
|
||||
d := &stubDialer{failsBeforeOK: 3}
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
500*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
cbInvocations := atomic.Int32{}
|
||||
err := coinbase.Reconnect(ctx, d, coinbase.ReconnectConfig{
|
||||
InitialInterval: 10 * time.Millisecond,
|
||||
MaxInterval: 50 * time.Millisecond,
|
||||
}, func(_ context.Context, _ *coinbase.Conn) error {
|
||||
cbInvocations.Add(1)
|
||||
return errors.New("force loop")
|
||||
})
|
||||
require.True(
|
||||
t,
|
||||
errors.Is(err, context.DeadlineExceeded) ||
|
||||
errors.Is(err, context.Canceled),
|
||||
)
|
||||
require.GreaterOrEqual(
|
||||
t,
|
||||
d.called.Load(),
|
||||
int32(4),
|
||||
"must dial at least 4 times (3 failures + 1 success)",
|
||||
)
|
||||
require.GreaterOrEqual(t, cbInvocations.Load(), int32(1))
|
||||
}
|
||||
|
||||
func TestReconnect_PermanentErrorFromCallbackPropagates(t *testing.T) {
|
||||
d := &stubDialer{failsBeforeOK: 0}
|
||||
sentinel := errors.New("hard stop")
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
200*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
err := coinbase.Reconnect(ctx, d, coinbase.ReconnectConfig{
|
||||
InitialInterval: 5 * time.Millisecond,
|
||||
MaxInterval: 10 * time.Millisecond,
|
||||
}, func(_ context.Context, _ *coinbase.Conn) error {
|
||||
return coinbase.PermanentReconnectError(sentinel)
|
||||
})
|
||||
require.ErrorIs(t, err, sentinel)
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
// ©AngelaMos | 2026
|
||||
// repo.go
|
||||
|
||||
package coinbase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
const (
|
||||
history1hLimit = 60
|
||||
)
|
||||
|
||||
type Tick struct {
|
||||
Symbol string `db:"symbol"`
|
||||
TS time.Time `db:"ts"`
|
||||
Price decimal.Decimal `db:"price"`
|
||||
Volume24h decimal.Decimal `db:"volume_24h"`
|
||||
}
|
||||
|
||||
// MinuteBar holds a per-minute OHLC for a symbol plus the rolling 24h
|
||||
// volume sampled at the minute's close. The Coinbase ticker channel only
|
||||
// publishes 24h volume, not per-trade size, so true per-minute volume
|
||||
// would require the market_trades channel — out of scope here.
|
||||
type MinuteBar struct {
|
||||
Symbol string `db:"symbol"`
|
||||
Minute time.Time `db:"minute"`
|
||||
Open decimal.Decimal `db:"open"`
|
||||
High decimal.Decimal `db:"high"`
|
||||
Low decimal.Decimal `db:"low"`
|
||||
Close decimal.Decimal `db:"close"`
|
||||
Volume24hAtClose decimal.Decimal `db:"volume_24h_at_close"`
|
||||
}
|
||||
|
||||
type Repo struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} }
|
||||
|
||||
func (r *Repo) InsertTick(ctx context.Context, t Tick) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO btc_eth_ticks (symbol, ts, price, volume_24h)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (symbol, ts) DO NOTHING`,
|
||||
t.Symbol, t.TS, t.Price, t.Volume24h,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert tick %s @ %s: %w", t.Symbol, t.TS, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repo) UpsertMinute(ctx context.Context, b MinuteBar) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO btc_eth_minute (symbol, minute, open, high, low, close, volume_24h_at_close)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (symbol, minute) DO UPDATE SET
|
||||
open = EXCLUDED.open,
|
||||
high = EXCLUDED.high,
|
||||
low = EXCLUDED.low,
|
||||
close = EXCLUDED.close,
|
||||
volume_24h_at_close = EXCLUDED.volume_24h_at_close`,
|
||||
b.Symbol, b.Minute, b.Open, b.High, b.Low, b.Close, b.Volume24hAtClose,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert minute %s @ %s: %w", b.Symbol, b.Minute, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repo) LatestTick(ctx context.Context, symbol string) (Tick, error) {
|
||||
var t Tick
|
||||
err := r.db.GetContext(ctx, &t, `
|
||||
SELECT symbol, ts, price, volume_24h
|
||||
FROM btc_eth_ticks
|
||||
WHERE symbol = $1
|
||||
ORDER BY ts DESC LIMIT 1`, symbol)
|
||||
if err != nil {
|
||||
return Tick{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (r *Repo) History1h(
|
||||
ctx context.Context,
|
||||
symbol string,
|
||||
) ([]MinuteBar, error) {
|
||||
var rows []MinuteBar
|
||||
err := r.db.SelectContext(ctx, &rows, `
|
||||
SELECT symbol, minute, open, high, low, close, volume_24h_at_close
|
||||
FROM btc_eth_minute
|
||||
WHERE symbol = $1
|
||||
ORDER BY minute DESC LIMIT $2`, symbol, history1hLimit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("history1h %s: %w", symbol, err)
|
||||
}
|
||||
for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 {
|
||||
rows[i], rows[j] = rows[j], rows[i]
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
// ©AngelaMos | 2026
|
||||
// repo_test.go
|
||||
|
||||
package coinbase_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase"
|
||||
)
|
||||
|
||||
func setupDB(t *testing.T) *sqlx.DB {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pg, err := postgres.Run(ctx, "postgres:17-alpine",
|
||||
postgres.WithDatabase("monitor"),
|
||||
postgres.WithUsername("monitor"),
|
||||
postgres.WithPassword("monitor"),
|
||||
postgres.BasicWaitStrategies(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = pg.Terminate(context.Background()) })
|
||||
|
||||
dsn, err := pg.ConnectionString(ctx, "sslmode=disable")
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := sqlx.ConnectContext(ctx, "pgx", dsn)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
_, err = db.ExecContext(ctx, `
|
||||
CREATE TABLE btc_eth_ticks (
|
||||
symbol text NOT NULL,
|
||||
ts timestamptz NOT NULL,
|
||||
price numeric(18,8) NOT NULL,
|
||||
volume_24h numeric(20,8),
|
||||
PRIMARY KEY (symbol, ts)
|
||||
);
|
||||
CREATE TABLE btc_eth_minute (
|
||||
symbol text NOT NULL,
|
||||
minute timestamptz NOT NULL,
|
||||
open numeric(18,8) NOT NULL,
|
||||
high numeric(18,8) NOT NULL,
|
||||
low numeric(18,8) NOT NULL,
|
||||
close numeric(18,8) NOT NULL,
|
||||
volume_24h_at_close numeric(20,8),
|
||||
PRIMARY KEY (symbol, minute)
|
||||
);`)
|
||||
require.NoError(t, err)
|
||||
return db
|
||||
}
|
||||
|
||||
func TestRepo_InsertTickIdempotent(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := coinbase.NewRepo(db)
|
||||
ctx := context.Background()
|
||||
|
||||
ts := time.Now().UTC().Truncate(time.Microsecond)
|
||||
tk := coinbase.Tick{
|
||||
Symbol: "BTC-USD",
|
||||
TS: ts,
|
||||
Price: decimal.RequireFromString("42163.45"),
|
||||
Volume24h: decimal.RequireFromString("19834.91230000"),
|
||||
}
|
||||
require.NoError(t, repo.InsertTick(ctx, tk))
|
||||
require.NoError(t, repo.InsertTick(ctx, tk))
|
||||
|
||||
got, err := repo.LatestTick(ctx, "BTC-USD")
|
||||
require.NoError(t, err)
|
||||
require.True(t, got.TS.Equal(ts))
|
||||
require.True(t, got.Price.Equal(tk.Price))
|
||||
require.True(t, got.Volume24h.Equal(tk.Volume24h))
|
||||
}
|
||||
|
||||
func TestRepo_UpsertMinuteUpdatesOHLC(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := coinbase.NewRepo(db)
|
||||
ctx := context.Background()
|
||||
|
||||
minute := time.Now().UTC().Truncate(time.Minute)
|
||||
bar := coinbase.MinuteBar{
|
||||
Symbol: "ETH-USD",
|
||||
Minute: minute,
|
||||
Open: decimal.RequireFromString("2310.00"),
|
||||
High: decimal.RequireFromString("2315.50"),
|
||||
Low: decimal.RequireFromString("2308.10"),
|
||||
Close: decimal.RequireFromString("2312.75"),
|
||||
Volume24hAtClose: decimal.RequireFromString("88.12300000"),
|
||||
}
|
||||
require.NoError(t, repo.UpsertMinute(ctx, bar))
|
||||
|
||||
bar.Close = decimal.RequireFromString("2316.00")
|
||||
bar.High = decimal.RequireFromString("2317.00")
|
||||
require.NoError(t, repo.UpsertMinute(ctx, bar))
|
||||
|
||||
hist, err := repo.History1h(ctx, "ETH-USD")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, hist, 1)
|
||||
require.True(t, hist[0].Close.Equal(decimal.RequireFromString("2316.00")))
|
||||
require.True(t, hist[0].High.Equal(decimal.RequireFromString("2317.00")))
|
||||
}
|
||||
|
||||
func TestRepo_History1hReturnsLast60MinutesOldestFirst(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := coinbase.NewRepo(db)
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Minute)
|
||||
for i := 0; i < 75; i++ {
|
||||
bar := coinbase.MinuteBar{
|
||||
Symbol: "BTC-USD",
|
||||
Minute: now.Add(-time.Duration(i) * time.Minute),
|
||||
Open: decimal.NewFromInt(int64(40000 + i)),
|
||||
High: decimal.NewFromInt(int64(40050 + i)),
|
||||
Low: decimal.NewFromInt(int64(39950 + i)),
|
||||
Close: decimal.NewFromInt(int64(40010 + i)),
|
||||
Volume24hAtClose: decimal.NewFromInt(int64(i)),
|
||||
}
|
||||
require.NoError(t, repo.UpsertMinute(ctx, bar))
|
||||
}
|
||||
|
||||
hist, err := repo.History1h(ctx, "BTC-USD")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, hist, 60)
|
||||
require.True(
|
||||
t,
|
||||
hist[0].Minute.Before(hist[len(hist)-1].Minute),
|
||||
"history must be oldest → newest",
|
||||
)
|
||||
}
|
||||
|
||||
func TestRepo_LatestTickMissingReturnsErrNoRows(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := coinbase.NewRepo(db)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := repo.LatestTick(ctx, "DOES-NOT-EXIST")
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
// ©AngelaMos | 2026
|
||||
// sequencer.go
|
||||
|
||||
package coinbase
|
||||
|
||||
import "sync"
|
||||
|
||||
type Sequencer struct {
|
||||
mu sync.Mutex
|
||||
last int64
|
||||
set bool
|
||||
}
|
||||
|
||||
func NewSequencer() *Sequencer {
|
||||
return &Sequencer{}
|
||||
}
|
||||
|
||||
func (s *Sequencer) Observe(seq int64) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.set {
|
||||
s.last = seq
|
||||
s.set = true
|
||||
return false
|
||||
}
|
||||
expected := s.last + 1
|
||||
s.last = seq
|
||||
return seq != expected
|
||||
}
|
||||
|
||||
func (s *Sequencer) Reset() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.set = false
|
||||
s.last = 0
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
// ©AngelaMos | 2026
|
||||
// sequencer_test.go
|
||||
|
||||
package coinbase_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase"
|
||||
)
|
||||
|
||||
func TestSequencer_FirstObservationIsBaseline(t *testing.T) {
|
||||
s := coinbase.NewSequencer()
|
||||
require.False(t, s.Observe(100), "first sequence number must not be a gap")
|
||||
}
|
||||
|
||||
func TestSequencer_ConsecutiveSequencesAreInOrder(t *testing.T) {
|
||||
s := coinbase.NewSequencer()
|
||||
for i := int64(50); i < 60; i++ {
|
||||
require.False(t, s.Observe(i), "i=%d", i)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSequencer_GapTriggersReportSignal(t *testing.T) {
|
||||
s := coinbase.NewSequencer()
|
||||
require.False(t, s.Observe(50))
|
||||
require.False(t, s.Observe(51))
|
||||
require.True(t, s.Observe(60), "skipping 52-59 must report gap")
|
||||
}
|
||||
|
||||
func TestSequencer_ResetClearsBaseline(t *testing.T) {
|
||||
s := coinbase.NewSequencer()
|
||||
s.Observe(100)
|
||||
s.Reset()
|
||||
require.False(t, s.Observe(9999))
|
||||
}
|
||||
|
||||
func TestSequencer_DuplicateSequenceTreatedAsGap(t *testing.T) {
|
||||
s := coinbase.NewSequencer()
|
||||
require.False(t, s.Observe(100))
|
||||
require.True(t, s.Observe(100), "replaying the same seq is a gap")
|
||||
}
|
||||
|
||||
func TestSequencer_BackwardSequenceTreatedAsGap(t *testing.T) {
|
||||
s := coinbase.NewSequencer()
|
||||
require.False(t, s.Observe(100))
|
||||
require.True(t, s.Observe(90))
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"channel": "heartbeats",
|
||||
"client_id": "",
|
||||
"timestamp": "2026-05-01T22:30:00.000Z",
|
||||
"sequence_num": 12346,
|
||||
"events": [
|
||||
{"current_time": "2026-05-01T22:30:00.000Z", "heartbeat_counter": 42}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"channel": "ticker",
|
||||
"client_id": "",
|
||||
"timestamp": "2026-05-01T22:30:01.000Z",
|
||||
"sequence_num": 1,
|
||||
"events": [
|
||||
{
|
||||
"type": "snapshot",
|
||||
"tickers": [
|
||||
{"type": "ticker", "product_id": "BTC-USD", "price": "42163.45", "volume_24_h": "19834.9123", "time": "2026-05-01T22:30:01.000Z"},
|
||||
{"type": "ticker", "product_id": "ETH-USD", "price": "2312.75", "volume_24_h": "88123.4567", "time": "2026-05-01T22:30:01.000Z"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"channel": "subscriptions",
|
||||
"client_id": "",
|
||||
"timestamp": "2026-05-01T22:29:59.000Z",
|
||||
"sequence_num": 0,
|
||||
"events": [
|
||||
{"subscriptions": {"ticker": ["BTC-USD","ETH-USD"], "heartbeats": [""]}}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"channel": "ticker",
|
||||
"client_id": "",
|
||||
"timestamp": "2026-05-01T22:30:00.000Z",
|
||||
"sequence_num": 12345,
|
||||
"events": [
|
||||
{
|
||||
"type": "update",
|
||||
"tickers": [
|
||||
{
|
||||
"type": "ticker",
|
||||
"product_id": "BTC-USD",
|
||||
"price": "42163.45",
|
||||
"volume_24_h": "19834.9123",
|
||||
"time": "2026-05-01T22:30:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
// ©AngelaMos | 2026
|
||||
// collector.go
|
||||
|
||||
package cve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
const (
|
||||
Name = "cve"
|
||||
defaultCVEInterval = 2 * time.Hour
|
||||
defaultCVEWindow = 2 * time.Hour
|
||||
)
|
||||
|
||||
type NVDFetcher interface {
|
||||
Fetch(ctx context.Context, start, end time.Time) (NVDResponse, error)
|
||||
}
|
||||
|
||||
type EPSSFetcher interface {
|
||||
LookupBatch(
|
||||
ctx context.Context,
|
||||
cveIDs []string,
|
||||
) (map[string]EPSSScore, error)
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
Upsert(ctx context.Context, row Row) error
|
||||
UpdateEPSS(
|
||||
ctx context.Context,
|
||||
cveID string,
|
||||
score, percentile float64,
|
||||
) error
|
||||
}
|
||||
|
||||
type Emitter interface {
|
||||
Emit(ev events.Event)
|
||||
}
|
||||
|
||||
type StateRecorder interface {
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64)
|
||||
RecordError(ctx context.Context, name, errMsg string)
|
||||
}
|
||||
|
||||
type CollectorConfig struct {
|
||||
Interval time.Duration
|
||||
Window time.Duration
|
||||
NVD NVDFetcher
|
||||
EPSS EPSSFetcher
|
||||
Repo Repository
|
||||
Emitter Emitter
|
||||
State StateRecorder
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
type Collector struct {
|
||||
cfg CollectorConfig
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewCollector(cfg CollectorConfig) *Collector {
|
||||
if cfg.Interval <= 0 {
|
||||
cfg.Interval = defaultCVEInterval
|
||||
}
|
||||
if cfg.Window <= 0 {
|
||||
cfg.Window = defaultCVEWindow
|
||||
}
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
}
|
||||
return &Collector{cfg: cfg, logger: cfg.Logger}
|
||||
}
|
||||
|
||||
func (c *Collector) Name() string { return Name }
|
||||
|
||||
func (c *Collector) Run(ctx context.Context) error {
|
||||
ticker := time.NewTicker(c.cfg.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
c.tick(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
c.tick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) tick(ctx context.Context) {
|
||||
end := time.Now().UTC()
|
||||
start := end.Add(-c.cfg.Window)
|
||||
|
||||
resp, err := c.cfg.NVD.Fetch(ctx, start, end)
|
||||
if err != nil {
|
||||
c.logger.Warn("nvd fetch", "err", err)
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(resp.Vulnerabilities))
|
||||
rows := make([]Row, 0, len(resp.Vulnerabilities))
|
||||
for _, v := range resp.Vulnerabilities {
|
||||
score, severity := v.PrimarySeverity()
|
||||
raw, _ := json.Marshal(v)
|
||||
row := Row{
|
||||
CveID: v.CVE.ID,
|
||||
Published: v.CVE.Published.Time,
|
||||
LastModified: v.CVE.LastModified.Time,
|
||||
Severity: severity,
|
||||
CVSS: score,
|
||||
Payload: raw,
|
||||
}
|
||||
rows = append(rows, row)
|
||||
ids = append(ids, v.CVE.ID)
|
||||
}
|
||||
|
||||
scores, err := c.cfg.EPSS.LookupBatch(ctx, ids)
|
||||
if err != nil {
|
||||
c.logger.Warn("epss lookup", "err", err)
|
||||
}
|
||||
|
||||
emitted := int64(0)
|
||||
for _, row := range rows {
|
||||
if err := c.cfg.Repo.Upsert(ctx, row); err != nil {
|
||||
c.logger.Warn("upsert cve", "id", row.CveID, "err", err)
|
||||
continue
|
||||
}
|
||||
if s, ok := scores[row.CveID]; ok {
|
||||
if err := c.cfg.Repo.UpdateEPSS(
|
||||
ctx,
|
||||
row.CveID,
|
||||
s.Score,
|
||||
s.Percentile,
|
||||
); err != nil {
|
||||
c.logger.Warn("update epss", "id", row.CveID, "err", err)
|
||||
}
|
||||
row.EPSSScore = &s.Score
|
||||
row.EPSSPercentile = &s.Percentile
|
||||
}
|
||||
body, _ := json.Marshal(row)
|
||||
c.cfg.Emitter.Emit(events.Event{
|
||||
Topic: events.TopicCVENew,
|
||||
Timestamp: end,
|
||||
Source: Name,
|
||||
Payload: json.RawMessage(body),
|
||||
})
|
||||
emitted++
|
||||
}
|
||||
c.cfg.State.RecordSuccess(ctx, Name, emitted)
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
// ©AngelaMos | 2026
|
||||
// collector_test.go
|
||||
|
||||
package cve_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
type stubNVD struct {
|
||||
resp cve.NVDResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubNVD) Fetch(
|
||||
context.Context,
|
||||
time.Time,
|
||||
time.Time,
|
||||
) (cve.NVDResponse, error) {
|
||||
return s.resp, s.err
|
||||
}
|
||||
|
||||
type stubEPSS struct {
|
||||
scores map[string]cve.EPSSScore
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubEPSS) LookupBatch(
|
||||
context.Context,
|
||||
[]string,
|
||||
) (map[string]cve.EPSSScore, error) {
|
||||
return s.scores, s.err
|
||||
}
|
||||
|
||||
type stubCVERepo struct {
|
||||
mu sync.Mutex
|
||||
upserts map[string]cve.Row
|
||||
patches map[string][2]float64
|
||||
}
|
||||
|
||||
func newStubCVERepo() *stubCVERepo {
|
||||
return &stubCVERepo{
|
||||
upserts: map[string]cve.Row{},
|
||||
patches: map[string][2]float64{},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *stubCVERepo) Upsert(_ context.Context, row cve.Row) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.upserts[row.CveID] = row
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubCVERepo) UpdateEPSS(
|
||||
_ context.Context,
|
||||
id string,
|
||||
score, pct float64,
|
||||
) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.patches[id] = [2]float64{score, pct}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubCVERepo) Upserts() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.upserts)
|
||||
}
|
||||
|
||||
func (r *stubCVERepo) Patches() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.patches)
|
||||
}
|
||||
|
||||
type stubCVEEmitter struct {
|
||||
mu sync.Mutex
|
||||
events []events.Event
|
||||
}
|
||||
|
||||
func (e *stubCVEEmitter) Emit(ev events.Event) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.events = append(e.events, ev)
|
||||
}
|
||||
|
||||
func (e *stubCVEEmitter) Events() []events.Event {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
out := make([]events.Event, len(e.events))
|
||||
copy(out, e.events)
|
||||
return out
|
||||
}
|
||||
|
||||
type stubCVEState struct{}
|
||||
|
||||
func (stubCVEState) RecordSuccess(context.Context, string, int64) {}
|
||||
func (stubCVEState) RecordError(context.Context, string, string) {}
|
||||
|
||||
func TestCVECollector_FetchesEnrichesAndEmits(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
nvd := &stubNVD{resp: cve.NVDResponse{
|
||||
Vulnerabilities: []cve.NVDVulnRoot{
|
||||
{CVE: cve.NVDCVE{
|
||||
ID: "CVE-2026-0001",
|
||||
Published: cve.NVDTime{Time: now.Add(-time.Hour)},
|
||||
LastModified: cve.NVDTime{Time: now},
|
||||
Metrics: cve.NVDMetrics{CVSSv31: []cve.NVDMetricEntry{
|
||||
{
|
||||
CVSSData: cve.NVDCVSSData{
|
||||
BaseScore: 9.8,
|
||||
BaseSeverity: "CRITICAL",
|
||||
},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
{CVE: cve.NVDCVE{
|
||||
ID: "CVE-2026-0002",
|
||||
Published: cve.NVDTime{Time: now.Add(-time.Hour)},
|
||||
LastModified: cve.NVDTime{Time: now},
|
||||
}},
|
||||
},
|
||||
}}
|
||||
epss := &stubEPSS{scores: map[string]cve.EPSSScore{
|
||||
"CVE-2026-0001": {CveID: "CVE-2026-0001", Score: 0.9, Percentile: 0.99},
|
||||
"CVE-2026-0002": {CveID: "CVE-2026-0002", Score: 0.1, Percentile: 0.5},
|
||||
}}
|
||||
repo := newStubCVERepo()
|
||||
emt := &stubCVEEmitter{}
|
||||
|
||||
c := cve.NewCollector(cve.CollectorConfig{
|
||||
Interval: 30 * time.Millisecond,
|
||||
NVD: nvd,
|
||||
EPSS: epss,
|
||||
Repo: repo,
|
||||
Emitter: emt,
|
||||
State: stubCVEState{},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
require.GreaterOrEqual(t, repo.Upserts(), 2)
|
||||
require.GreaterOrEqual(t, repo.Patches(), 2)
|
||||
|
||||
evs := emt.Events()
|
||||
require.GreaterOrEqual(t, len(evs), 2)
|
||||
for _, ev := range evs {
|
||||
require.Equal(t, events.TopicCVENew, ev.Topic)
|
||||
require.Equal(t, cve.Name, ev.Source)
|
||||
raw, ok := ev.Payload.(json.RawMessage)
|
||||
require.True(t, ok)
|
||||
require.Contains(t, string(raw), "CVE-2026-")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCVECollector_NVDFetchErrorRecordsState(t *testing.T) {
|
||||
nvd := &stubNVD{err: context.DeadlineExceeded}
|
||||
repo := newStubCVERepo()
|
||||
emt := &stubCVEEmitter{}
|
||||
|
||||
c := cve.NewCollector(cve.CollectorConfig{
|
||||
Interval: 20 * time.Millisecond,
|
||||
NVD: nvd,
|
||||
EPSS: &stubEPSS{},
|
||||
Repo: repo,
|
||||
Emitter: emt,
|
||||
State: stubCVEState{},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
30*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
require.Zero(t, repo.Upserts())
|
||||
require.Zero(t, repo.Patches())
|
||||
require.Empty(t, emt.Events())
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
// ©AngelaMos | 2026
|
||||
// epss_client.go
|
||||
|
||||
package cve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx"
|
||||
)
|
||||
|
||||
const (
|
||||
epssBatchSize = 100
|
||||
defaultEPSSBaseURL = "https://api.first.org"
|
||||
pathEPSS = "/data/v1/epss"
|
||||
defaultEPSSRate = 500 * time.Millisecond
|
||||
defaultEPSSBurst = 5
|
||||
defaultEPSSBudget = 5
|
||||
defaultEPSSBreakerWin = 60 * time.Second
|
||||
)
|
||||
|
||||
type EPSSClientConfig struct {
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type EPSSClient struct {
|
||||
hx *httpx.Client
|
||||
}
|
||||
|
||||
func NewEPSSClient(cfg EPSSClientConfig) *EPSSClient {
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = defaultEPSSBaseURL
|
||||
}
|
||||
return &EPSSClient{
|
||||
hx: httpx.New(httpx.Config{
|
||||
Name: "epss",
|
||||
BaseURL: cfg.BaseURL,
|
||||
Rate: rate.Every(defaultEPSSRate),
|
||||
Burst: defaultEPSSBurst,
|
||||
ConsecutiveFailureBudget: defaultEPSSBudget,
|
||||
BreakerTimeout: defaultEPSSBreakerWin,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
type EPSSScore struct {
|
||||
CveID string `json:"cve"`
|
||||
Score float64 `json:"-"`
|
||||
Percentile float64 `json:"-"`
|
||||
ScoreRaw string `json:"epss"`
|
||||
PctileRaw string `json:"percentile"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
|
||||
type EPSSResponse struct {
|
||||
Data []EPSSScore `json:"data"`
|
||||
}
|
||||
|
||||
func (c *EPSSClient) LookupBatch(
|
||||
ctx context.Context,
|
||||
cveIDs []string,
|
||||
) (map[string]EPSSScore, error) {
|
||||
out := map[string]EPSSScore{}
|
||||
for i := 0; i < len(cveIDs); i += epssBatchSize {
|
||||
end := i + epssBatchSize
|
||||
if end > len(cveIDs) {
|
||||
end = len(cveIDs)
|
||||
}
|
||||
chunk := cveIDs[i:end]
|
||||
q := url.Values{"cve": []string{strings.Join(chunk, ",")}}
|
||||
var resp EPSSResponse
|
||||
if err := c.hx.GetJSON(ctx, pathEPSS, q, &resp); err != nil {
|
||||
return nil, fmt.Errorf("epss batch [%d:%d]: %w", i, end, err)
|
||||
}
|
||||
for _, s := range resp.Data {
|
||||
s.Score, _ = strconv.ParseFloat(s.ScoreRaw, 64)
|
||||
s.Percentile, _ = strconv.ParseFloat(s.PctileRaw, 64)
|
||||
out[s.CveID] = s
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
// ©AngelaMos | 2026
|
||||
// epss_client_test.go
|
||||
|
||||
package cve_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve"
|
||||
)
|
||||
|
||||
func TestEPSSClient_BatchLookupDecodesScores(t *testing.T) {
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ids := r.URL.Query().Get("cve")
|
||||
require.Contains(t, ids, "CVE-2024-3094")
|
||||
body, err := os.ReadFile("testdata/epss_batch.json")
|
||||
require.NoError(t, err)
|
||||
_, _ = w.Write(body)
|
||||
}),
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
c := cve.NewEPSSClient(cve.EPSSClientConfig{BaseURL: srv.URL})
|
||||
scores, err := c.LookupBatch(
|
||||
context.Background(),
|
||||
[]string{"CVE-2024-3094", "CVE-2024-21413"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, scores)
|
||||
|
||||
got := scores["CVE-2024-3094"]
|
||||
require.InDelta(t, 0.84588, got.Score, 0.0001)
|
||||
require.InDelta(t, 0.99335, got.Percentile, 0.0001)
|
||||
}
|
||||
|
||||
func TestEPSSClient_ChunksOver100PerRequest(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hits.Add(1)
|
||||
_, _ = w.Write([]byte(`{"data":[]}`))
|
||||
}),
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
c := cve.NewEPSSClient(cve.EPSSClientConfig{BaseURL: srv.URL})
|
||||
|
||||
ids := make([]string, 250)
|
||||
for i := range ids {
|
||||
ids[i] = "CVE-2024-X"
|
||||
}
|
||||
_, err := c.LookupBatch(context.Background(), ids)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 3, hits.Load())
|
||||
}
|
||||
|
||||
func TestEPSSClient_EmptyInputReturnsEmptyMap(t *testing.T) {
|
||||
c := cve.NewEPSSClient(cve.EPSSClientConfig{BaseURL: "http://invalid"})
|
||||
out, err := c.LookupBatch(context.Background(), nil)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, out)
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
// ©AngelaMos | 2026
|
||||
// nvd_client.go
|
||||
|
||||
package cve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultNVDBaseURL = "https://services.nvd.nist.gov"
|
||||
pathNVDCVE2 = "/rest/json/cves/2.0"
|
||||
defaultNVDRate = 600 * time.Millisecond
|
||||
defaultNVDBurst = 5
|
||||
defaultNVDBudget = 5
|
||||
defaultNVDBreakerWin = 120 * time.Second
|
||||
nvdTimeFormat = "2006-01-02T15:04:05.000"
|
||||
nvdAPIKeyHeader = "apiKey"
|
||||
nvdResultsPerPage = 2000
|
||||
nvdMaxPages = 50
|
||||
)
|
||||
|
||||
type NVDClientConfig struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
}
|
||||
|
||||
type NVDClient struct {
|
||||
hx *httpx.Client
|
||||
}
|
||||
|
||||
func NewNVDClient(cfg NVDClientConfig) *NVDClient {
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = defaultNVDBaseURL
|
||||
}
|
||||
return &NVDClient{
|
||||
hx: httpx.New(httpx.Config{
|
||||
Name: "nvd",
|
||||
BaseURL: cfg.BaseURL,
|
||||
APIKey: cfg.APIKey,
|
||||
APIKeyHeader: nvdAPIKeyHeader,
|
||||
Rate: rate.Every(defaultNVDRate),
|
||||
Burst: defaultNVDBurst,
|
||||
ConsecutiveFailureBudget: defaultNVDBudget,
|
||||
BreakerTimeout: defaultNVDBreakerWin,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
type NVDResponse struct {
|
||||
ResultsPerPage int `json:"resultsPerPage"`
|
||||
StartIndex int `json:"startIndex"`
|
||||
TotalResults int `json:"totalResults"`
|
||||
Vulnerabilities []NVDVulnRoot `json:"vulnerabilities"`
|
||||
}
|
||||
|
||||
type NVDVulnRoot struct {
|
||||
CVE NVDCVE `json:"cve"`
|
||||
}
|
||||
|
||||
type NVDCVE struct {
|
||||
ID string `json:"id"`
|
||||
Published NVDTime `json:"published"`
|
||||
LastModified NVDTime `json:"lastModified"`
|
||||
Metrics NVDMetrics `json:"metrics"`
|
||||
}
|
||||
|
||||
type NVDTime struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
func (t *NVDTime) UnmarshalJSON(b []byte) error {
|
||||
s := string(b)
|
||||
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
||||
s = s[1 : len(s)-1]
|
||||
}
|
||||
if s == "" || s == "null" {
|
||||
return nil
|
||||
}
|
||||
for _, layout := range []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05.999",
|
||||
"2006-01-02T15:04:05",
|
||||
} {
|
||||
if parsed, err := time.Parse(layout, s); err == nil {
|
||||
t.Time = parsed.UTC()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("nvd time: unrecognized format %q", s)
|
||||
}
|
||||
|
||||
type NVDMetrics struct {
|
||||
CVSSv31 []NVDMetricEntry `json:"cvssMetricV31"`
|
||||
CVSSv30 []NVDMetricEntry `json:"cvssMetricV30"`
|
||||
}
|
||||
|
||||
type NVDMetricEntry struct {
|
||||
CVSSData NVDCVSSData `json:"cvssData"`
|
||||
}
|
||||
|
||||
type NVDCVSSData struct {
|
||||
BaseScore float64 `json:"baseScore"`
|
||||
BaseSeverity string `json:"baseSeverity"`
|
||||
}
|
||||
|
||||
// Fetch returns every NVD CVE updated in [start, end], paging through the
|
||||
// API as needed. NVD caps a single response at 2000 vulns; without
|
||||
// pagination, big-disclosure days (Patch Tuesday) silently drop everything
|
||||
// past the first page. This is what the audit's Finding 4 was about.
|
||||
func (c *NVDClient) Fetch(
|
||||
ctx context.Context,
|
||||
start, end time.Time,
|
||||
) (NVDResponse, error) {
|
||||
combined := NVDResponse{}
|
||||
startIdx := 0
|
||||
|
||||
for page := 0; page < nvdMaxPages; page++ {
|
||||
q := url.Values{
|
||||
"lastModStartDate": []string{start.UTC().Format(nvdTimeFormat)},
|
||||
"lastModEndDate": []string{end.UTC().Format(nvdTimeFormat)},
|
||||
"resultsPerPage": []string{strconv.Itoa(nvdResultsPerPage)},
|
||||
"startIndex": []string{strconv.Itoa(startIdx)},
|
||||
}
|
||||
var pageResp NVDResponse
|
||||
if err := c.hx.GetJSON(ctx, pathNVDCVE2, q, &pageResp); err != nil {
|
||||
return NVDResponse{}, fmt.Errorf("nvd fetch page %d: %w", page, err)
|
||||
}
|
||||
combined.Vulnerabilities = append(
|
||||
combined.Vulnerabilities,
|
||||
pageResp.Vulnerabilities...)
|
||||
combined.TotalResults = pageResp.TotalResults
|
||||
|
||||
fetched := len(pageResp.Vulnerabilities)
|
||||
if fetched == 0 {
|
||||
break
|
||||
}
|
||||
startIdx += fetched
|
||||
if startIdx >= pageResp.TotalResults {
|
||||
break
|
||||
}
|
||||
}
|
||||
combined.ResultsPerPage = len(combined.Vulnerabilities)
|
||||
combined.StartIndex = 0
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
func (v NVDVulnRoot) PrimarySeverity() (float64, string) {
|
||||
if len(v.CVE.Metrics.CVSSv31) > 0 {
|
||||
return v.CVE.Metrics.CVSSv31[0].CVSSData.BaseScore, v.CVE.Metrics.CVSSv31[0].CVSSData.BaseSeverity
|
||||
}
|
||||
if len(v.CVE.Metrics.CVSSv30) > 0 {
|
||||
return v.CVE.Metrics.CVSSv30[0].CVSSData.BaseScore, v.CVE.Metrics.CVSSv30[0].CVSSData.BaseSeverity
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
// ©AngelaMos | 2026
|
||||
// nvd_client_test.go
|
||||
|
||||
package cve_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve"
|
||||
)
|
||||
|
||||
func TestNVDClient_FetchSendsAPIKeyAndDecodes(t *testing.T) {
|
||||
var sawKey, sawStart, sawEnd string
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sawKey = r.Header.Get("apiKey")
|
||||
sawStart = r.URL.Query().Get("lastModStartDate")
|
||||
sawEnd = r.URL.Query().Get("lastModEndDate")
|
||||
body, err := os.ReadFile("testdata/nvd_2h_window.json")
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}),
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
c := cve.NewNVDClient(
|
||||
cve.NVDClientConfig{BaseURL: srv.URL, APIKey: "test-key"},
|
||||
)
|
||||
|
||||
end := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
start := end.Add(-2 * time.Hour)
|
||||
|
||||
resp, err := c.Fetch(context.Background(), start, end)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, resp.Vulnerabilities)
|
||||
require.Equal(t, "test-key", sawKey)
|
||||
require.NotEmpty(t, sawStart)
|
||||
require.NotEmpty(t, sawEnd)
|
||||
}
|
||||
|
||||
func TestNVDClient_PrimarySeverityFromV31(t *testing.T) {
|
||||
v := cve.NVDVulnRoot{CVE: cve.NVDCVE{
|
||||
ID: "CVE-2026-X",
|
||||
Metrics: cve.NVDMetrics{
|
||||
CVSSv31: []cve.NVDMetricEntry{
|
||||
{
|
||||
CVSSData: cve.NVDCVSSData{
|
||||
BaseScore: 9.8,
|
||||
BaseSeverity: "CRITICAL",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
score, sev := v.PrimarySeverity()
|
||||
require.InDelta(t, 9.8, score, 0.0001)
|
||||
require.Equal(t, "CRITICAL", sev)
|
||||
}
|
||||
|
||||
func TestNVDClient_PrimarySeverityFallsBackToV30(t *testing.T) {
|
||||
v := cve.NVDVulnRoot{CVE: cve.NVDCVE{
|
||||
ID: "CVE-2018-X",
|
||||
Metrics: cve.NVDMetrics{
|
||||
CVSSv30: []cve.NVDMetricEntry{
|
||||
{
|
||||
CVSSData: cve.NVDCVSSData{
|
||||
BaseScore: 7.5,
|
||||
BaseSeverity: "HIGH",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
score, sev := v.PrimarySeverity()
|
||||
require.InDelta(t, 7.5, score, 0.0001)
|
||||
require.Equal(t, "HIGH", sev)
|
||||
}
|
||||
|
||||
func TestNVDClient_PrimarySeverityZeroWhenMissing(t *testing.T) {
|
||||
v := cve.NVDVulnRoot{CVE: cve.NVDCVE{ID: "CVE-X"}}
|
||||
score, sev := v.PrimarySeverity()
|
||||
require.Zero(t, score)
|
||||
require.Empty(t, sev)
|
||||
}
|
||||
|
||||
// TestNVDClient_FetchPaginatesAcrossMultiplePages: NVD caps a single
|
||||
// response at 2000 vulns. With a window like Patch Tuesday that exceeds
|
||||
// that, we have to walk startIndex until startIndex >= totalResults.
|
||||
// Pre-fix the collector silently dropped everything past page 1.
|
||||
func TestNVDClient_FetchPaginatesAcrossMultiplePages(t *testing.T) {
|
||||
const total = 4500
|
||||
var hits atomic.Int32
|
||||
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
startIdx, _ := strconv.Atoi(r.URL.Query().Get("startIndex"))
|
||||
perPage, _ := strconv.Atoi(r.URL.Query().Get("resultsPerPage"))
|
||||
if perPage <= 0 {
|
||||
perPage = 2000
|
||||
}
|
||||
end := startIdx + perPage
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
vulns := make([]cve.NVDVulnRoot, 0, end-startIdx)
|
||||
for i := startIdx; i < end; i++ {
|
||||
vulns = append(vulns, cve.NVDVulnRoot{
|
||||
CVE: cve.NVDCVE{ID: fmt.Sprintf("CVE-2026-%05d", i)},
|
||||
})
|
||||
}
|
||||
resp := cve.NVDResponse{
|
||||
ResultsPerPage: perPage,
|
||||
StartIndex: startIdx,
|
||||
TotalResults: total,
|
||||
Vulnerabilities: vulns,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}),
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
c := cve.NewNVDClient(cve.NVDClientConfig{BaseURL: srv.URL})
|
||||
end := time.Now().UTC()
|
||||
start := end.Add(-2 * time.Hour)
|
||||
|
||||
resp, err := c.Fetch(context.Background(), start, end)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp.Vulnerabilities, total, "every page should be combined")
|
||||
require.Equal(t, total, resp.TotalResults)
|
||||
require.GreaterOrEqual(
|
||||
t,
|
||||
hits.Load(),
|
||||
int32(3),
|
||||
"4500/2000 needs >=3 page calls",
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
// ©AngelaMos | 2026
|
||||
// repo.go
|
||||
|
||||
package cve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type Row struct {
|
||||
CveID string `db:"cve_id"`
|
||||
Published time.Time `db:"published"`
|
||||
LastModified time.Time `db:"last_modified"`
|
||||
Severity string `db:"severity"`
|
||||
CVSS float64 `db:"cvss_score"`
|
||||
EPSSScore *float64 `db:"epss_score"`
|
||||
EPSSPercentile *float64 `db:"epss_percentile"`
|
||||
InKEV bool `db:"in_kev"`
|
||||
Payload json.RawMessage `db:"payload"`
|
||||
}
|
||||
|
||||
type Repo struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} }
|
||||
|
||||
func (r *Repo) Upsert(ctx context.Context, row Row) error {
|
||||
_, err := r.db.ExecContext(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO cve_events (cve_id, published, last_modified, severity, cvss_score, payload)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (cve_id) DO UPDATE SET
|
||||
last_modified = EXCLUDED.last_modified,
|
||||
severity = EXCLUDED.severity,
|
||||
cvss_score = EXCLUDED.cvss_score,
|
||||
payload = EXCLUDED.payload`,
|
||||
row.CveID,
|
||||
row.Published,
|
||||
row.LastModified,
|
||||
row.Severity,
|
||||
row.CVSS,
|
||||
[]byte(row.Payload),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert cve %s: %w", row.CveID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repo) UpdateEPSS(
|
||||
ctx context.Context,
|
||||
cveID string,
|
||||
score, percentile float64,
|
||||
) error {
|
||||
_, err := r.db.ExecContext(
|
||||
ctx,
|
||||
`UPDATE cve_events SET epss_score = $2, epss_percentile = $3 WHERE cve_id = $1`,
|
||||
cveID,
|
||||
score,
|
||||
percentile,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update epss %s: %w", cveID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repo) Get(ctx context.Context, cveID string) (Row, error) {
|
||||
var row Row
|
||||
err := r.db.GetContext(ctx, &row, `
|
||||
SELECT cve_id, published, last_modified, severity, cvss_score,
|
||||
epss_score, epss_percentile, in_kev, payload
|
||||
FROM cve_events WHERE cve_id = $1`, cveID)
|
||||
if err != nil {
|
||||
return Row{}, fmt.Errorf("get cve %s: %w", cveID, err)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (r *Repo) RecentByLastModified(
|
||||
ctx context.Context,
|
||||
limit int,
|
||||
) ([]Row, error) {
|
||||
var rows []Row
|
||||
err := r.db.SelectContext(ctx, &rows, `
|
||||
SELECT cve_id, published, last_modified, severity, cvss_score,
|
||||
epss_score, epss_percentile, in_kev, payload
|
||||
FROM cve_events
|
||||
ORDER BY last_modified DESC LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recent cves: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
// ©AngelaMos | 2026
|
||||
// repo_test.go
|
||||
|
||||
package cve_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve"
|
||||
)
|
||||
|
||||
func setupDB(t *testing.T) *sqlx.DB {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pg, err := postgres.Run(ctx, "postgres:17-alpine",
|
||||
postgres.WithDatabase("monitor"),
|
||||
postgres.WithUsername("monitor"),
|
||||
postgres.WithPassword("monitor"),
|
||||
postgres.BasicWaitStrategies(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = pg.Terminate(context.Background()) })
|
||||
|
||||
dsn, err := pg.ConnectionString(ctx, "sslmode=disable")
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := sqlx.ConnectContext(ctx, "pgx", dsn)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
_, err = db.ExecContext(ctx, `
|
||||
CREATE TABLE cve_events (
|
||||
cve_id text PRIMARY KEY,
|
||||
published timestamptz,
|
||||
last_modified timestamptz NOT NULL,
|
||||
severity text NOT NULL DEFAULT '',
|
||||
cvss_score numeric(3,1) NOT NULL DEFAULT 0,
|
||||
epss_score numeric(5,4),
|
||||
epss_percentile numeric(5,4),
|
||||
in_kev boolean NOT NULL DEFAULT false,
|
||||
payload jsonb NOT NULL
|
||||
)`)
|
||||
require.NoError(t, err)
|
||||
return db
|
||||
}
|
||||
|
||||
func TestRepo_UpsertThenEPSSUpdate(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := cve.NewRepo(db)
|
||||
ctx := context.Background()
|
||||
|
||||
row := cve.Row{
|
||||
CveID: "CVE-2026-0001",
|
||||
Published: time.Now().UTC().Add(-time.Hour),
|
||||
LastModified: time.Now().UTC(),
|
||||
Severity: "CRITICAL",
|
||||
CVSS: 9.8,
|
||||
Payload: json.RawMessage(`{"id":"CVE-2026-0001"}`),
|
||||
}
|
||||
require.NoError(t, repo.Upsert(ctx, row))
|
||||
|
||||
require.NoError(t, repo.UpdateEPSS(ctx, "CVE-2026-0001", 0.9512, 0.9988))
|
||||
|
||||
got, err := repo.Get(ctx, "CVE-2026-0001")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.EPSSScore)
|
||||
require.InDelta(t, 0.9512, *got.EPSSScore, 0.0001)
|
||||
require.NotNil(t, got.EPSSPercentile)
|
||||
require.InDelta(t, 0.9988, *got.EPSSPercentile, 0.0001)
|
||||
require.Equal(t, "CRITICAL", got.Severity)
|
||||
require.InDelta(t, 9.8, got.CVSS, 0.0001)
|
||||
}
|
||||
|
||||
func TestRepo_UpsertOnConflictUpdatesLastModified(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := cve.NewRepo(db)
|
||||
ctx := context.Background()
|
||||
|
||||
first := cve.Row{
|
||||
CveID: "CVE-2026-0002",
|
||||
Published: time.Now().UTC().Add(-2 * time.Hour),
|
||||
LastModified: time.Now().UTC().Add(-time.Hour),
|
||||
Severity: "MEDIUM",
|
||||
CVSS: 5.0,
|
||||
Payload: json.RawMessage(`{"v":1}`),
|
||||
}
|
||||
require.NoError(t, repo.Upsert(ctx, first))
|
||||
|
||||
updated := first
|
||||
updated.LastModified = time.Now().UTC()
|
||||
updated.Severity = "HIGH"
|
||||
updated.CVSS = 7.5
|
||||
updated.Payload = json.RawMessage(`{"v":2}`)
|
||||
require.NoError(t, repo.Upsert(ctx, updated))
|
||||
|
||||
got, err := repo.Get(ctx, "CVE-2026-0002")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "HIGH", got.Severity)
|
||||
require.InDelta(t, 7.5, got.CVSS, 0.0001)
|
||||
}
|
||||
|
||||
func TestRepo_RecentByLastModifiedOrdered(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := cve.NewRepo(db)
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now().UTC()
|
||||
for i, name := range []string{"CVE-A", "CVE-B", "CVE-C"} {
|
||||
require.NoError(t, repo.Upsert(ctx, cve.Row{
|
||||
CveID: name,
|
||||
Published: now.Add(-time.Duration(10-i) * time.Hour),
|
||||
LastModified: now.Add(-time.Duration(10-i) * time.Hour),
|
||||
Severity: "LOW",
|
||||
Payload: json.RawMessage(`{}`),
|
||||
}))
|
||||
}
|
||||
|
||||
rows, err := repo.RecentByLastModified(ctx, 3)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 3)
|
||||
require.Equal(t, "CVE-C", rows[0].CveID)
|
||||
require.Equal(t, "CVE-B", rows[1].CveID)
|
||||
require.Equal(t, "CVE-A", rows[2].CveID)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"status":"OK","status-code":200,"version":"1.0","access":"public","total":2,"offset":0,"limit":100,"data":[{"cve":"CVE-2024-3094","epss":"0.845880000","percentile":"0.993350000","date":"2026-05-01"},{"cve":"CVE-2024-21413","epss":"0.929920000","percentile":"0.997830000","date":"2026-05-01"}]}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,90 @@
|
|||
// ©AngelaMos | 2026
|
||||
// client.go
|
||||
|
||||
package dshield
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx"
|
||||
)
|
||||
|
||||
const (
|
||||
KindTopPorts = "topports"
|
||||
KindTopIPs = "topips"
|
||||
KindDailySummary = "dailysummary"
|
||||
|
||||
defaultTopN = 10
|
||||
defaultDShieldRate = 2 * time.Second
|
||||
defaultDShieldBudget = 5
|
||||
defaultBreakerWindow = 60 * time.Second
|
||||
)
|
||||
|
||||
type ClientConfig struct {
|
||||
BaseURL string
|
||||
TopN int
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
hx *httpx.Client
|
||||
topN int
|
||||
}
|
||||
|
||||
func NewClient(cfg ClientConfig) *Client {
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = "https://isc.sans.edu"
|
||||
}
|
||||
if cfg.TopN <= 0 {
|
||||
cfg.TopN = defaultTopN
|
||||
}
|
||||
return &Client{
|
||||
hx: httpx.New(httpx.Config{
|
||||
Name: "dshield",
|
||||
BaseURL: cfg.BaseURL,
|
||||
Rate: rate.Every(defaultDShieldRate),
|
||||
Burst: 1,
|
||||
ConsecutiveFailureBudget: defaultDShieldBudget,
|
||||
BreakerTimeout: defaultBreakerWindow,
|
||||
}),
|
||||
topN: cfg.TopN,
|
||||
}
|
||||
}
|
||||
|
||||
type SnapshotPayload struct {
|
||||
Kind string
|
||||
Payload json.RawMessage
|
||||
}
|
||||
|
||||
func (c *Client) FetchAll(ctx context.Context) ([]SnapshotPayload, error) {
|
||||
yesterday := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02")
|
||||
|
||||
endpoints := []struct {
|
||||
Kind string
|
||||
Path string
|
||||
}{
|
||||
{
|
||||
KindTopPorts,
|
||||
"/api/topports/records/" + strconv.Itoa(
|
||||
c.topN,
|
||||
) + "/" + yesterday + "?json",
|
||||
},
|
||||
{KindTopIPs, "/api/topips/?json"},
|
||||
{KindDailySummary, "/api/dailysummary/" + yesterday + "/?json"},
|
||||
}
|
||||
|
||||
out := make([]SnapshotPayload, 0, len(endpoints))
|
||||
for _, ep := range endpoints {
|
||||
var raw json.RawMessage
|
||||
if err := c.hx.GetJSON(ctx, ep.Path, nil, &raw); err != nil {
|
||||
return nil, fmt.Errorf("dshield %s: %w", ep.Kind, err)
|
||||
}
|
||||
out = append(out, SnapshotPayload{Kind: ep.Kind, Payload: raw})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
// ©AngelaMos | 2026
|
||||
// client_test.go
|
||||
|
||||
package dshield_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/dshield"
|
||||
)
|
||||
|
||||
func newFixtureServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(
|
||||
"/api/topports/",
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Contains(t, r.URL.Path, "/api/topports/records/")
|
||||
body, err := os.ReadFile(filepath.Join("testdata", "topports.json"))
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
},
|
||||
)
|
||||
mux.HandleFunc(
|
||||
"/api/topips/",
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
body, err := os.ReadFile(filepath.Join("testdata", "topips.json"))
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
},
|
||||
)
|
||||
mux.HandleFunc(
|
||||
"/api/dailysummary/",
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
require.True(t, strings.HasPrefix(r.URL.Path, "/api/dailysummary/"))
|
||||
body, err := os.ReadFile(
|
||||
filepath.Join("testdata", "dailysummary.json"),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
},
|
||||
)
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func TestClient_FetchAllReturnsThreeKinds(t *testing.T) {
|
||||
srv := newFixtureServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
c := dshield.NewClient(dshield.ClientConfig{BaseURL: srv.URL, TopN: 10})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
snaps, err := c.FetchAll(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, snaps, 3)
|
||||
|
||||
kinds := map[string]bool{}
|
||||
for _, s := range snaps {
|
||||
kinds[s.Kind] = true
|
||||
require.NotEmpty(t, s.Payload, "kind %s payload empty", s.Kind)
|
||||
}
|
||||
require.True(t, kinds[dshield.KindTopPorts])
|
||||
require.True(t, kinds[dshield.KindTopIPs])
|
||||
require.True(t, kinds[dshield.KindDailySummary])
|
||||
}
|
||||
|
||||
func TestClient_FetchAllUsesYesterdayDate(t *testing.T) {
|
||||
var topPortsPath, dailyPath string
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(
|
||||
"/api/topports/",
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
topPortsPath = r.URL.Path
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
},
|
||||
)
|
||||
mux.HandleFunc(
|
||||
"/api/topips/",
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
},
|
||||
)
|
||||
mux.HandleFunc(
|
||||
"/api/dailysummary/",
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
dailyPath = r.URL.Path
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
},
|
||||
)
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
c := dshield.NewClient(dshield.ClientConfig{BaseURL: srv.URL, TopN: 5})
|
||||
_, _ = c.FetchAll(context.Background())
|
||||
|
||||
yesterday := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02")
|
||||
require.Contains(t, topPortsPath, "/records/5/"+yesterday)
|
||||
require.Contains(t, dailyPath, "/dailysummary/"+yesterday)
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
// ©AngelaMos | 2026
|
||||
// collector.go
|
||||
|
||||
package dshield
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
const (
|
||||
Name = "dshield"
|
||||
defaultInterval = time.Hour
|
||||
)
|
||||
|
||||
type Fetcher interface {
|
||||
FetchAll(ctx context.Context) ([]SnapshotPayload, error)
|
||||
}
|
||||
|
||||
type DShieldEnrichment struct {
|
||||
Country string
|
||||
Classification string
|
||||
Actor string
|
||||
}
|
||||
|
||||
type Enricher interface {
|
||||
Lookup(ctx context.Context, ip string) (DShieldEnrichment, error)
|
||||
}
|
||||
|
||||
type Persister interface {
|
||||
PutSnapshot(
|
||||
ctx context.Context,
|
||||
ts time.Time,
|
||||
kind string,
|
||||
payload json.RawMessage,
|
||||
) error
|
||||
}
|
||||
|
||||
type Emitter interface {
|
||||
Emit(ev events.Event)
|
||||
}
|
||||
|
||||
type StateRecorder interface {
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64)
|
||||
RecordError(ctx context.Context, name, errMsg string)
|
||||
}
|
||||
|
||||
type CollectorConfig struct {
|
||||
Interval time.Duration
|
||||
Fetcher Fetcher
|
||||
Persister Persister
|
||||
Emitter Emitter
|
||||
State StateRecorder
|
||||
Enricher Enricher
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
type Collector struct {
|
||||
cfg CollectorConfig
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewCollector(cfg CollectorConfig) *Collector {
|
||||
if cfg.Interval <= 0 {
|
||||
cfg.Interval = defaultInterval
|
||||
}
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
}
|
||||
return &Collector{cfg: cfg, logger: cfg.Logger}
|
||||
}
|
||||
|
||||
func (c *Collector) Name() string { return Name }
|
||||
|
||||
func (c *Collector) Run(ctx context.Context) error {
|
||||
ticker := time.NewTicker(c.cfg.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
c.tick(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
c.tick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) tick(ctx context.Context) {
|
||||
snaps, err := c.cfg.Fetcher.FetchAll(ctx)
|
||||
if err != nil {
|
||||
c.logger.Warn("dshield fetch failed", "err", err)
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
tsRaw, err := json.Marshal(now.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
c.logger.Error("dshield marshal ts", "err", err)
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
merged := map[string]json.RawMessage{"ts": tsRaw}
|
||||
for _, s := range snaps {
|
||||
payload := s.Payload
|
||||
if s.Kind == KindTopIPs {
|
||||
payload = c.enrichSources(ctx, payload)
|
||||
}
|
||||
if perr := c.cfg.Persister.PutSnapshot(
|
||||
ctx,
|
||||
now,
|
||||
s.Kind,
|
||||
payload,
|
||||
); perr != nil {
|
||||
c.logger.Warn("dshield persist failed", "kind", s.Kind, "err", perr)
|
||||
}
|
||||
merged[s.Kind] = payload
|
||||
}
|
||||
|
||||
body, err := json.Marshal(merged)
|
||||
if err != nil {
|
||||
c.logger.Error("dshield marshal merged", "err", err)
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.cfg.Emitter.Emit(events.Event{
|
||||
Topic: events.TopicScanFirehose,
|
||||
Timestamp: now,
|
||||
Source: Name,
|
||||
Payload: json.RawMessage(body),
|
||||
})
|
||||
c.cfg.State.RecordSuccess(ctx, Name, 1)
|
||||
}
|
||||
|
||||
func (c *Collector) enrichSources(
|
||||
ctx context.Context,
|
||||
raw json.RawMessage,
|
||||
) json.RawMessage {
|
||||
if c.cfg.Enricher == nil {
|
||||
return raw
|
||||
}
|
||||
var srcs []RawSource
|
||||
if err := json.Unmarshal(raw, &srcs); err != nil {
|
||||
return raw
|
||||
}
|
||||
enriched := make([]EnrichedSource, 0, len(srcs))
|
||||
for _, s := range srcs {
|
||||
e := EnrichedSource{
|
||||
Rank: s.Rank,
|
||||
Source: s.Source,
|
||||
Reports: s.Reports,
|
||||
Targets: s.Targets,
|
||||
}
|
||||
if info, err := c.cfg.Enricher.Lookup(ctx, s.Source); err == nil {
|
||||
e.Country = info.Country
|
||||
e.Classification = info.Classification
|
||||
e.Actor = info.Actor
|
||||
}
|
||||
enriched = append(enriched, e)
|
||||
}
|
||||
out, err := json.Marshal(enriched)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
// ©AngelaMos | 2026
|
||||
// collector_test.go
|
||||
|
||||
package dshield_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/dshield"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
type fakeFetcher struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchAll(
|
||||
_ context.Context,
|
||||
) ([]dshield.SnapshotPayload, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return []dshield.SnapshotPayload{
|
||||
{Kind: dshield.KindTopPorts, Payload: json.RawMessage(`[{"port":22}]`)},
|
||||
{
|
||||
Kind: dshield.KindTopIPs,
|
||||
Payload: json.RawMessage(`[{"ip":"1.2.3.4"}]`),
|
||||
},
|
||||
{
|
||||
Kind: dshield.KindDailySummary,
|
||||
Payload: json.RawMessage(`[{"records":42}]`),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) Calls() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.calls
|
||||
}
|
||||
|
||||
type persistedRow struct {
|
||||
Kind string
|
||||
Body json.RawMessage
|
||||
}
|
||||
|
||||
type fakePersister struct {
|
||||
mu sync.Mutex
|
||||
rows []persistedRow
|
||||
}
|
||||
|
||||
func (p *fakePersister) PutSnapshot(
|
||||
_ context.Context,
|
||||
_ time.Time,
|
||||
kind string,
|
||||
body json.RawMessage,
|
||||
) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.rows = append(p.rows, persistedRow{Kind: kind, Body: body})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *fakePersister) Rows() []persistedRow {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
out := make([]persistedRow, len(p.rows))
|
||||
copy(out, p.rows)
|
||||
return out
|
||||
}
|
||||
|
||||
type fakeEmitter struct {
|
||||
mu sync.Mutex
|
||||
events []events.Event
|
||||
}
|
||||
|
||||
func (e *fakeEmitter) Emit(ev events.Event) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.events = append(e.events, ev)
|
||||
}
|
||||
|
||||
func (e *fakeEmitter) Events() []events.Event {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
out := make([]events.Event, len(e.events))
|
||||
copy(out, e.events)
|
||||
return out
|
||||
}
|
||||
|
||||
type recordingState struct {
|
||||
mu sync.Mutex
|
||||
successes int
|
||||
successN int64
|
||||
failures int
|
||||
lastErr string
|
||||
}
|
||||
|
||||
func (s *recordingState) RecordSuccess(_ context.Context, _ string, n int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.successes++
|
||||
s.successN += n
|
||||
}
|
||||
|
||||
func (s *recordingState) RecordError(_ context.Context, _, msg string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.failures++
|
||||
s.lastErr = msg
|
||||
}
|
||||
|
||||
func TestCollector_TickPersistsAllAndEmitsScanFirehose(t *testing.T) {
|
||||
ftch := &fakeFetcher{}
|
||||
prst := &fakePersister{}
|
||||
emt := &fakeEmitter{}
|
||||
st := &recordingState{}
|
||||
|
||||
c := dshield.NewCollector(dshield.CollectorConfig{
|
||||
Interval: 20 * time.Millisecond,
|
||||
Fetcher: ftch,
|
||||
Persister: prst,
|
||||
Emitter: emt,
|
||||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
70*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
require.GreaterOrEqual(t, ftch.Calls(), 2)
|
||||
require.GreaterOrEqual(t, len(prst.Rows()), 6)
|
||||
|
||||
evs := emt.Events()
|
||||
require.GreaterOrEqual(t, len(evs), 2)
|
||||
for _, ev := range evs {
|
||||
require.Equal(t, events.TopicScanFirehose, ev.Topic)
|
||||
require.Equal(t, dshield.Name, ev.Source)
|
||||
body, ok := ev.Payload.(json.RawMessage)
|
||||
require.True(t, ok, "payload must be json.RawMessage")
|
||||
|
||||
var merged map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(body, &merged))
|
||||
require.Contains(t, merged, dshield.KindTopPorts)
|
||||
require.Contains(t, merged, dshield.KindTopIPs)
|
||||
require.Contains(t, merged, dshield.KindDailySummary)
|
||||
require.Contains(t, merged, "ts")
|
||||
}
|
||||
|
||||
require.Positive(t, st.successes)
|
||||
require.Equal(t, 0, st.failures)
|
||||
}
|
||||
|
||||
func TestCollector_FetchErrorRecordsState(t *testing.T) {
|
||||
ftch := &fakeFetcher{err: errors.New("upstream 503")}
|
||||
prst := &fakePersister{}
|
||||
emt := &fakeEmitter{}
|
||||
st := &recordingState{}
|
||||
|
||||
c := dshield.NewCollector(dshield.CollectorConfig{
|
||||
Interval: 20 * time.Millisecond,
|
||||
Fetcher: ftch,
|
||||
Persister: prst,
|
||||
Emitter: emt,
|
||||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
require.Empty(t, prst.Rows())
|
||||
require.Empty(t, emt.Events())
|
||||
require.Positive(t, st.failures)
|
||||
require.Contains(t, st.lastErr, "upstream 503")
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
// ©AngelaMos | 2026
|
||||
// repo.go
|
||||
|
||||
package dshield
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type Snapshot struct {
|
||||
TS time.Time `db:"ts"`
|
||||
Kind string `db:"kind"`
|
||||
Payload json.RawMessage `db:"payload"`
|
||||
}
|
||||
|
||||
type Repo struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} }
|
||||
|
||||
func (r *Repo) PutSnapshot(
|
||||
ctx context.Context,
|
||||
ts time.Time,
|
||||
kind string,
|
||||
payload json.RawMessage,
|
||||
) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO dshield_snapshots (ts, kind, payload)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (ts, kind) DO UPDATE SET payload = EXCLUDED.payload`,
|
||||
ts, kind, []byte(payload),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("put dshield snapshot %s: %w", kind, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repo) LatestByKind(
|
||||
ctx context.Context,
|
||||
kind string,
|
||||
limit int,
|
||||
) ([]Snapshot, error) {
|
||||
var rows []Snapshot
|
||||
err := r.db.SelectContext(ctx, &rows, `
|
||||
SELECT ts, kind, payload FROM dshield_snapshots
|
||||
WHERE kind = $1
|
||||
ORDER BY ts DESC LIMIT $2`, kind, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("latest dshield by kind: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
// ©AngelaMos | 2026
|
||||
// repo_test.go
|
||||
|
||||
package dshield_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/dshield"
|
||||
)
|
||||
|
||||
func setupDB(t *testing.T) *sqlx.DB {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pg, err := postgres.Run(ctx, "postgres:17-alpine",
|
||||
postgres.WithDatabase("monitor"),
|
||||
postgres.WithUsername("monitor"),
|
||||
postgres.WithPassword("monitor"),
|
||||
postgres.BasicWaitStrategies(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = pg.Terminate(context.Background()) })
|
||||
|
||||
dsn, err := pg.ConnectionString(ctx, "sslmode=disable")
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := sqlx.ConnectContext(ctx, "pgx", dsn)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
_, err = db.ExecContext(ctx, `
|
||||
CREATE TABLE dshield_snapshots (
|
||||
ts timestamptz NOT NULL,
|
||||
kind text NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
PRIMARY KEY (ts, kind)
|
||||
)`)
|
||||
require.NoError(t, err)
|
||||
return db
|
||||
}
|
||||
|
||||
func TestRepo_PutSnapshotAndReadLatest(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := dshield.NewRepo(db)
|
||||
ctx := context.Background()
|
||||
|
||||
ts := time.Now().UTC().Truncate(time.Second)
|
||||
payload := json.RawMessage(`[{"port":"22","records":1234}]`)
|
||||
|
||||
require.NoError(t, repo.PutSnapshot(ctx, ts, "topports", payload))
|
||||
|
||||
rows, err := repo.LatestByKind(ctx, "topports", 1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
require.True(t, ts.Equal(rows[0].TS), "expected %s == %s", ts, rows[0].TS)
|
||||
require.Equal(t, "topports", rows[0].Kind)
|
||||
require.JSONEq(t, string(payload), string(rows[0].Payload))
|
||||
}
|
||||
|
||||
func TestRepo_PutSnapshotIsIdempotentOnConflict(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := dshield.NewRepo(db)
|
||||
ctx := context.Background()
|
||||
|
||||
ts := time.Now().UTC().Truncate(time.Second)
|
||||
first := json.RawMessage(`[{"port":"22"}]`)
|
||||
second := json.RawMessage(`[{"port":"22","records":99}]`)
|
||||
|
||||
require.NoError(t, repo.PutSnapshot(ctx, ts, "topports", first))
|
||||
require.NoError(t, repo.PutSnapshot(ctx, ts, "topports", second))
|
||||
|
||||
rows, err := repo.LatestByKind(ctx, "topports", 1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
require.JSONEq(t, string(second), string(rows[0].Payload))
|
||||
}
|
||||
|
||||
func TestRepo_LatestByKindIsOrderedDesc(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
repo := dshield.NewRepo(db)
|
||||
ctx := context.Background()
|
||||
|
||||
t1 := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Second)
|
||||
t2 := time.Now().UTC().Add(-1 * time.Hour).Truncate(time.Second)
|
||||
t3 := time.Now().UTC().Truncate(time.Second)
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
repo.PutSnapshot(ctx, t1, "sources", json.RawMessage(`[{"i":1}]`)),
|
||||
)
|
||||
require.NoError(
|
||||
t,
|
||||
repo.PutSnapshot(ctx, t3, "sources", json.RawMessage(`[{"i":3}]`)),
|
||||
)
|
||||
require.NoError(
|
||||
t,
|
||||
repo.PutSnapshot(ctx, t2, "sources", json.RawMessage(`[{"i":2}]`)),
|
||||
)
|
||||
|
||||
rows, err := repo.LatestByKind(ctx, "sources", 3)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 3)
|
||||
require.True(t, t3.Equal(rows[0].TS))
|
||||
require.True(t, t2.Equal(rows[1].TS))
|
||||
require.True(t, t1.Equal(rows[2].TS))
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
[{"date":"2026-05-01","records":17196921,"sources":146555,"targets":871}]
|
||||
|
|
@ -0,0 +1 @@
|
|||
[{"rank":1,"source":"13.94.254.200","reports":319739,"targets":1},{"rank":2,"source":"194.224.249.214","reports":309508,"targets":1},{"rank":3,"source":"52.157.207.201","reports":194780,"targets":1},{"rank":4,"source":"89.248.163.109","reports":96279,"targets":150},{"rank":5,"source":"143.244.186.35","reports":92007,"targets":8},{"rank":6,"source":"18.116.198.191","reports":59907,"targets":1},{"rank":7,"source":"152.89.198.163","reports":41378,"targets":478},{"rank":8,"source":"152.89.198.103","reports":41346,"targets":479},{"rank":9,"source":"152.89.198.187","reports":41332,"targets":477},{"rank":10,"source":"185.122.204.71","reports":41221,"targets":479}]
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"0":{"rank":1,"targetport":23,"records":5756817,"targets":316,"sources":52950},"1":{"rank":2,"targetport":80,"records":1151422,"targets":316,"sources":9421},"2":{"rank":3,"targetport":5555,"records":1106744,"targets":298,"sources":3668},"3":{"rank":4,"targetport":8080,"records":978049,"targets":309,"sources":7062},"4":{"rank":5,"targetport":22,"records":514078,"targets":326,"sources":9113},"5":{"rank":6,"targetport":2222,"records":371960,"targets":318,"sources":7388},"6":{"rank":7,"targetport":51223,"records":313702,"targets":15,"sources":1293},"7":{"rank":8,"targetport":8000,"records":241520,"targets":323,"sources":6078},"8":{"rank":9,"targetport":443,"records":231949,"targets":432,"sources":8308},"9":{"rank":10,"targetport":68,"records":215704,"targets":185,"sources":209},"date":"2026-05-01","limit":10}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// ©AngelaMos | 2026
|
||||
// types.go
|
||||
|
||||
package dshield
|
||||
|
||||
type RawSource struct {
|
||||
Rank int `json:"rank"`
|
||||
Source string `json:"source"`
|
||||
Reports int `json:"reports"`
|
||||
Targets int `json:"targets"`
|
||||
}
|
||||
|
||||
type EnrichedSource struct {
|
||||
Rank int `json:"rank"`
|
||||
Source string `json:"source"`
|
||||
Reports int `json:"reports"`
|
||||
Targets int `json:"targets"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Classification string `json:"classification,omitempty"`
|
||||
Actor string `json:"actor,omitempty"`
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
// ©AngelaMos | 2026
|
||||
// baseline.go
|
||||
|
||||
package gdelt
|
||||
|
||||
import "math"
|
||||
|
||||
const (
|
||||
minStdDev = 1.0
|
||||
)
|
||||
|
||||
type Bucket struct {
|
||||
Score int64
|
||||
Count int
|
||||
}
|
||||
|
||||
type ThemeState struct {
|
||||
buckets []Bucket
|
||||
capN int
|
||||
}
|
||||
|
||||
func NewThemeState(capN int) *ThemeState {
|
||||
if capN <= 0 {
|
||||
capN = 96
|
||||
}
|
||||
return &ThemeState{capN: capN}
|
||||
}
|
||||
|
||||
func (s *ThemeState) Push(b Bucket) {
|
||||
s.buckets = append(s.buckets, b)
|
||||
if len(s.buckets) > s.capN {
|
||||
s.buckets = s.buckets[len(s.buckets)-s.capN:]
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ThemeState) Len() int {
|
||||
return len(s.buckets)
|
||||
}
|
||||
|
||||
func (s *ThemeState) Stats() (mean, stddev float64) {
|
||||
if len(s.buckets) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
sum := 0.0
|
||||
for _, b := range s.buckets {
|
||||
sum += float64(b.Count)
|
||||
}
|
||||
mean = sum / float64(len(s.buckets))
|
||||
|
||||
variance := 0.0
|
||||
for _, b := range s.buckets {
|
||||
d := float64(b.Count) - mean
|
||||
variance += d * d
|
||||
}
|
||||
variance /= float64(len(s.buckets))
|
||||
stddev = math.Sqrt(variance)
|
||||
return mean, stddev
|
||||
}
|
||||
|
||||
func (s *ThemeState) ZScore(count int) float64 {
|
||||
mean, stddev := s.Stats()
|
||||
if stddev < minStdDev {
|
||||
return 0
|
||||
}
|
||||
return (float64(count) - mean) / stddev
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
// ©AngelaMos | 2026
|
||||
// baseline_test.go
|
||||
|
||||
package gdelt_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/gdelt"
|
||||
)
|
||||
|
||||
func TestBaseline_EmptyReturnsZeroZ(t *testing.T) {
|
||||
b := gdelt.NewThemeState(96)
|
||||
require.InDelta(t, 0.0, b.ZScore(100), 0.0001)
|
||||
}
|
||||
|
||||
func TestBaseline_StableThenSpikeReturnsHighZ(t *testing.T) {
|
||||
b := gdelt.NewThemeState(96)
|
||||
now := time.Now().UTC()
|
||||
for i := 0; i < 96; i++ {
|
||||
b.Push(
|
||||
gdelt.Bucket{
|
||||
Score: now.Add(-time.Duration(i) * 15 * time.Minute).
|
||||
UnixMilli(),
|
||||
Count: 100 + (i % 10),
|
||||
},
|
||||
)
|
||||
}
|
||||
z := b.ZScore(500)
|
||||
require.Greater(t, z, 3.0)
|
||||
}
|
||||
|
||||
func TestBaseline_StableThenNormalReturnsLowZ(t *testing.T) {
|
||||
b := gdelt.NewThemeState(96)
|
||||
now := time.Now().UTC()
|
||||
for i := 0; i < 96; i++ {
|
||||
b.Push(
|
||||
gdelt.Bucket{
|
||||
Score: now.Add(-time.Duration(i) * 15 * time.Minute).
|
||||
UnixMilli(),
|
||||
Count: 100 + (i % 10),
|
||||
},
|
||||
)
|
||||
}
|
||||
z := b.ZScore(108)
|
||||
require.Less(t, z, 3.0)
|
||||
}
|
||||
|
||||
func TestBaseline_CapacityCapsBuckets(t *testing.T) {
|
||||
b := gdelt.NewThemeState(10)
|
||||
for i := 0; i < 200; i++ {
|
||||
b.Push(gdelt.Bucket{Score: int64(i), Count: i})
|
||||
}
|
||||
require.Equal(t, 10, b.Len())
|
||||
}
|
||||
|
||||
func TestBaseline_TinyStdDevDoesNotDivByZero(t *testing.T) {
|
||||
b := gdelt.NewThemeState(96)
|
||||
now := time.Now().UTC()
|
||||
for i := 0; i < 96; i++ {
|
||||
b.Push(
|
||||
gdelt.Bucket{
|
||||
Score: now.Add(-time.Duration(i) * 15 * time.Minute).
|
||||
UnixMilli(),
|
||||
Count: 100,
|
||||
},
|
||||
)
|
||||
}
|
||||
z := b.ZScore(101)
|
||||
require.False(t, math.IsInf(z, 0))
|
||||
require.False(t, math.IsNaN(z))
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
// ©AngelaMos | 2026
|
||||
// client.go
|
||||
|
||||
package gdelt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultGDELTBaseURL = "https://api.gdeltproject.org"
|
||||
pathDoc = "/api/v2/doc/doc"
|
||||
defaultGDELTRate = 500 * time.Millisecond
|
||||
defaultGDELTBurst = 2
|
||||
defaultGDELTBudget = 5
|
||||
defaultGDELTBreaker = 60 * time.Second
|
||||
|
||||
gdeltDateFormat = "20060102T150405Z"
|
||||
)
|
||||
|
||||
var DefaultThemes = []string{
|
||||
"NATURAL_DISASTER",
|
||||
"ARMEDCONFLICT",
|
||||
"DISEASE_OUTBREAK",
|
||||
"ECON_BANKRUPTCY",
|
||||
"TERROR",
|
||||
}
|
||||
|
||||
type ClientConfig struct {
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
hx *httpx.Client
|
||||
}
|
||||
|
||||
func NewClient(cfg ClientConfig) *Client {
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = defaultGDELTBaseURL
|
||||
}
|
||||
return &Client{
|
||||
hx: httpx.New(httpx.Config{
|
||||
Name: "gdelt",
|
||||
BaseURL: cfg.BaseURL,
|
||||
Rate: rate.Every(defaultGDELTRate),
|
||||
Burst: defaultGDELTBurst,
|
||||
ConsecutiveFailureBudget: defaultGDELTBudget,
|
||||
BreakerTimeout: defaultGDELTBreaker,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
type ThemeBucket struct {
|
||||
Theme string
|
||||
Time time.Time
|
||||
Count int
|
||||
}
|
||||
|
||||
type rawTimeline struct {
|
||||
Timeline []struct {
|
||||
Data []struct {
|
||||
Date string `json:"date"`
|
||||
Value float64 `json:"value"`
|
||||
} `json:"data"`
|
||||
} `json:"timeline"`
|
||||
}
|
||||
|
||||
func (c *Client) FetchTheme(
|
||||
ctx context.Context,
|
||||
theme string,
|
||||
) ([]ThemeBucket, error) {
|
||||
q := url.Values{}
|
||||
q.Set("query", "theme:"+theme)
|
||||
q.Set("mode", "timelinevol")
|
||||
q.Set("TIMELINESMOOTH", "5")
|
||||
q.Set("format", "json")
|
||||
q.Set("timespan", "1d")
|
||||
|
||||
resp, err := c.hx.Get(ctx, pathDoc, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch gdelt theme %s: %w", theme, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read gdelt theme %s body: %w", theme, err)
|
||||
}
|
||||
|
||||
var raw rawTimeline
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, fmt.Errorf("decode gdelt theme %s: %w", theme, err)
|
||||
}
|
||||
if len(raw.Timeline) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]ThemeBucket, 0, len(raw.Timeline[0].Data))
|
||||
for _, d := range raw.Timeline[0].Data {
|
||||
ts, err := time.Parse(gdeltDateFormat, d.Date)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, ThemeBucket{
|
||||
Theme: theme,
|
||||
Time: ts.UTC(),
|
||||
Count: int(d.Value),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
// ©AngelaMos | 2026
|
||||
// client_test.go
|
||||
|
||||
package gdelt_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/gdelt"
|
||||
)
|
||||
|
||||
func TestClient_FetchThemeDecodesBuckets(t *testing.T) {
|
||||
body, err := os.ReadFile("testdata/timelinevol.json")
|
||||
require.NoError(t, err)
|
||||
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/api/v2/doc/doc", r.URL.Path)
|
||||
require.Equal(
|
||||
t,
|
||||
"theme:NATURAL_DISASTER",
|
||||
r.URL.Query().Get("query"),
|
||||
)
|
||||
require.Equal(t, "timelinevol", r.URL.Query().Get("mode"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}),
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
c := gdelt.NewClient(gdelt.ClientConfig{BaseURL: srv.URL})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
buckets, err := c.FetchTheme(ctx, "NATURAL_DISASTER")
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, len(buckets), 1)
|
||||
for _, b := range buckets {
|
||||
require.False(t, b.Time.IsZero())
|
||||
require.Positive(t, b.Count)
|
||||
require.Equal(t, "NATURAL_DISASTER", b.Theme)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
// ©AngelaMos | 2026
|
||||
// collector.go
|
||||
|
||||
package gdelt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
const (
|
||||
Name = "gdelt"
|
||||
defaultGDELTInterval = 15 * time.Minute
|
||||
defaultBaselineCap = 96
|
||||
zScoreSpikeThreshold = 3.0
|
||||
idHashBytes = 16
|
||||
)
|
||||
|
||||
type Fetcher interface {
|
||||
FetchTheme(ctx context.Context, theme string) ([]ThemeBucket, error)
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
Insert(ctx context.Context, row SpikeRow) (inserted bool, err error)
|
||||
}
|
||||
|
||||
type Emitter interface {
|
||||
Emit(ev events.Event)
|
||||
}
|
||||
|
||||
type StateRecorder interface {
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64)
|
||||
RecordError(ctx context.Context, name, errMsg string)
|
||||
}
|
||||
|
||||
type CollectorConfig struct {
|
||||
Interval time.Duration
|
||||
Themes []string
|
||||
BaselineCap int
|
||||
Fetcher Fetcher
|
||||
Repo Repository
|
||||
Emitter Emitter
|
||||
State StateRecorder
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
type Collector struct {
|
||||
cfg CollectorConfig
|
||||
logger *slog.Logger
|
||||
baselines map[string]*ThemeState
|
||||
}
|
||||
|
||||
func NewCollector(cfg CollectorConfig) *Collector {
|
||||
if cfg.Interval <= 0 {
|
||||
cfg.Interval = defaultGDELTInterval
|
||||
}
|
||||
if cfg.BaselineCap <= 0 {
|
||||
cfg.BaselineCap = defaultBaselineCap
|
||||
}
|
||||
if len(cfg.Themes) == 0 {
|
||||
cfg.Themes = DefaultThemes
|
||||
}
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
}
|
||||
c := &Collector{
|
||||
cfg: cfg,
|
||||
logger: cfg.Logger,
|
||||
baselines: make(map[string]*ThemeState, len(cfg.Themes)),
|
||||
}
|
||||
for _, t := range cfg.Themes {
|
||||
c.baselines[t] = NewThemeState(cfg.BaselineCap)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Collector) Name() string { return Name }
|
||||
|
||||
func (c *Collector) Run(ctx context.Context) error {
|
||||
ticker := time.NewTicker(c.cfg.Interval)
|
||||
defer ticker.Stop()
|
||||
c.tick(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
c.tick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) tick(ctx context.Context) {
|
||||
hadError := false
|
||||
emitted := int64(0)
|
||||
|
||||
for _, theme := range c.cfg.Themes {
|
||||
buckets, err := c.cfg.Fetcher.FetchTheme(ctx, theme)
|
||||
if err != nil {
|
||||
c.logger.Warn("gdelt fetch", "theme", theme, "err", err)
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
hadError = true
|
||||
continue
|
||||
}
|
||||
baseline := c.baselines[theme]
|
||||
for _, b := range buckets {
|
||||
z := baseline.ZScore(b.Count)
|
||||
baseline.Push(Bucket{Score: b.Time.UnixMilli(), Count: b.Count})
|
||||
|
||||
if z <= zScoreSpikeThreshold {
|
||||
continue
|
||||
}
|
||||
id := spikeID(theme, b.Time)
|
||||
|
||||
payload, perr := json.Marshal(map[string]any{
|
||||
"theme": theme,
|
||||
"time": b.Time,
|
||||
"count": b.Count,
|
||||
"zscore": z,
|
||||
})
|
||||
if perr != nil {
|
||||
c.logger.Warn(
|
||||
"gdelt marshal payload",
|
||||
"theme",
|
||||
theme,
|
||||
"err",
|
||||
perr,
|
||||
)
|
||||
continue
|
||||
}
|
||||
row := SpikeRow{
|
||||
ID: id,
|
||||
Theme: theme,
|
||||
OccurredAt: b.Time,
|
||||
Headline: fmt.Sprintf(
|
||||
"Theme spike: %s (z=%.2f, count=%d)",
|
||||
theme,
|
||||
z,
|
||||
b.Count,
|
||||
),
|
||||
Payload: payload,
|
||||
}
|
||||
inserted, ierr := c.cfg.Repo.Insert(ctx, row)
|
||||
if ierr != nil {
|
||||
c.logger.Warn("gdelt insert", "id", id, "err", ierr)
|
||||
continue
|
||||
}
|
||||
if !inserted {
|
||||
continue
|
||||
}
|
||||
c.cfg.Emitter.Emit(events.Event{
|
||||
Topic: events.TopicGDELTSpike,
|
||||
Timestamp: b.Time,
|
||||
Source: Name,
|
||||
Payload: json.RawMessage(payload),
|
||||
})
|
||||
emitted++
|
||||
}
|
||||
}
|
||||
|
||||
if !hadError {
|
||||
c.cfg.State.RecordSuccess(ctx, Name, emitted)
|
||||
}
|
||||
}
|
||||
|
||||
func spikeID(theme string, t time.Time) string {
|
||||
h := sha256.Sum256([]byte(theme + "|" + t.UTC().Format(time.RFC3339)))
|
||||
return hex.EncodeToString(h[:idHashBytes])
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
// ©AngelaMos | 2026
|
||||
// collector_test.go
|
||||
|
||||
package gdelt_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/gdelt"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
type fakeFetcher struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
buckets map[string][]gdelt.ThemeBucket
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchTheme(
|
||||
_ context.Context,
|
||||
theme string,
|
||||
) ([]gdelt.ThemeBucket, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.buckets[theme], nil
|
||||
}
|
||||
|
||||
type fakeRepo struct {
|
||||
mu sync.Mutex
|
||||
inserts []gdelt.SpikeRow
|
||||
seen map[string]struct{}
|
||||
}
|
||||
|
||||
func (r *fakeRepo) Insert(_ context.Context, row gdelt.SpikeRow) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.seen == nil {
|
||||
r.seen = map[string]struct{}{}
|
||||
}
|
||||
if _, ok := r.seen[row.ID]; ok {
|
||||
return false, nil
|
||||
}
|
||||
r.seen[row.ID] = struct{}{}
|
||||
r.inserts = append(r.inserts, row)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) Inserts() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.inserts)
|
||||
}
|
||||
|
||||
type fakeEmitter struct {
|
||||
mu sync.Mutex
|
||||
events []events.Event
|
||||
}
|
||||
|
||||
func (e *fakeEmitter) Emit(ev events.Event) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.events = append(e.events, ev)
|
||||
}
|
||||
|
||||
func (e *fakeEmitter) Count() int {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return len(e.events)
|
||||
}
|
||||
|
||||
type recordingState struct {
|
||||
mu sync.Mutex
|
||||
successes int
|
||||
failures int
|
||||
}
|
||||
|
||||
func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.successes++
|
||||
}
|
||||
|
||||
func (s *recordingState) RecordError(_ context.Context, _, _ string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.failures++
|
||||
}
|
||||
|
||||
func TestCollector_NoBaselineNoSpike(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
buckets := []gdelt.ThemeBucket{
|
||||
{Theme: "X", Time: now, Count: 5000},
|
||||
}
|
||||
ftch := &fakeFetcher{buckets: map[string][]gdelt.ThemeBucket{"X": buckets}}
|
||||
repo := &fakeRepo{}
|
||||
emt := &fakeEmitter{}
|
||||
st := &recordingState{}
|
||||
|
||||
c := gdelt.NewCollector(gdelt.CollectorConfig{
|
||||
Interval: 20 * time.Millisecond,
|
||||
Themes: []string{"X"},
|
||||
BaselineCap: 8,
|
||||
Fetcher: ftch,
|
||||
Repo: repo,
|
||||
Emitter: emt,
|
||||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
require.Equal(
|
||||
t,
|
||||
0,
|
||||
repo.Inserts(),
|
||||
"first observation has no baseline → cannot detect spike",
|
||||
)
|
||||
require.Equal(t, 0, emt.Count())
|
||||
require.Positive(t, st.successes)
|
||||
}
|
||||
|
||||
func TestCollector_StableBaselinePlusSpikeEmitsOnce(t *testing.T) {
|
||||
base := time.Now().UTC().Truncate(15 * time.Minute)
|
||||
stable := make([]gdelt.ThemeBucket, 0, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
stable = append(stable, gdelt.ThemeBucket{
|
||||
Theme: "X",
|
||||
Time: base.Add(time.Duration(i) * 15 * time.Minute),
|
||||
Count: 100 + i,
|
||||
})
|
||||
}
|
||||
spikeBucket := gdelt.ThemeBucket{
|
||||
Theme: "X",
|
||||
Time: base.Add(15 * 15 * time.Minute),
|
||||
Count: 5000,
|
||||
}
|
||||
buckets := append(stable, spikeBucket)
|
||||
|
||||
ftch := &fakeFetcher{buckets: map[string][]gdelt.ThemeBucket{"X": buckets}}
|
||||
repo := &fakeRepo{}
|
||||
emt := &fakeEmitter{}
|
||||
st := &recordingState{}
|
||||
|
||||
c := gdelt.NewCollector(gdelt.CollectorConfig{
|
||||
Interval: 20 * time.Millisecond,
|
||||
Themes: []string{"X"},
|
||||
BaselineCap: 8,
|
||||
Fetcher: ftch,
|
||||
Repo: repo,
|
||||
Emitter: emt,
|
||||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
80*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
require.GreaterOrEqual(t, repo.Inserts(), 1, "spike must be inserted")
|
||||
require.GreaterOrEqual(t, emt.Count(), 1)
|
||||
for _, ev := range emt.events {
|
||||
require.Equal(t, events.TopicGDELTSpike, ev.Topic)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollector_FetchErrorsRecordsState(t *testing.T) {
|
||||
ftch := &fakeFetcher{err: errors.New("upstream 503")}
|
||||
repo := &fakeRepo{}
|
||||
emt := &fakeEmitter{}
|
||||
st := &recordingState{}
|
||||
|
||||
c := gdelt.NewCollector(gdelt.CollectorConfig{
|
||||
Interval: 20 * time.Millisecond,
|
||||
Themes: []string{"X"},
|
||||
BaselineCap: 8,
|
||||
Fetcher: ftch,
|
||||
Repo: repo,
|
||||
Emitter: emt,
|
||||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
60*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
require.Equal(t, 0, repo.Inserts())
|
||||
require.Positive(t, st.failures)
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
// ©AngelaMos | 2026
|
||||
// repo.go
|
||||
|
||||
package gdelt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
const (
|
||||
sourceGDELTSpike = "gdelt_spike"
|
||||
)
|
||||
|
||||
type SpikeRow struct {
|
||||
ID string
|
||||
Theme string
|
||||
OccurredAt time.Time
|
||||
Headline string
|
||||
Payload json.RawMessage
|
||||
}
|
||||
|
||||
type Repo struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} }
|
||||
|
||||
func (r *Repo) Insert(ctx context.Context, row SpikeRow) (bool, error) {
|
||||
var id string
|
||||
err := r.db.GetContext(
|
||||
ctx,
|
||||
&id,
|
||||
`
|
||||
INSERT INTO world_events (id, source, occurred_at, headline, payload)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
RETURNING id`,
|
||||
row.ID,
|
||||
sourceGDELTSpike,
|
||||
row.OccurredAt,
|
||||
row.Headline,
|
||||
[]byte(row.Payload),
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("insert gdelt spike %s: %w", row.ID, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"timeline": [
|
||||
{
|
||||
"data": [
|
||||
{"date": "20260501T120000Z", "value": 100},
|
||||
{"date": "20260501T121500Z", "value": 105},
|
||||
{"date": "20260501T123000Z", "value": 102},
|
||||
{"date": "20260501T124500Z", "value": 108},
|
||||
{"date": "20260501T130000Z", "value": 112},
|
||||
{"date": "20260501T131500Z", "value": 98},
|
||||
{"date": "20260501T133000Z", "value": 103},
|
||||
{"date": "20260501T134500Z", "value": 107}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
// ©AngelaMos | 2026
|
||||
// collector.go
|
||||
|
||||
package heartbeat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
const defaultInterval = 5 * time.Second
|
||||
|
||||
type Emitter interface {
|
||||
Emit(ev events.Event)
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Interval time.Duration
|
||||
Emitter Emitter
|
||||
}
|
||||
|
||||
type Collector struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func New(cfg Config) *Collector {
|
||||
if cfg.Interval <= 0 {
|
||||
cfg.Interval = defaultInterval
|
||||
}
|
||||
return &Collector{cfg: cfg}
|
||||
}
|
||||
|
||||
func (c *Collector) Name() string { return "heartbeat" }
|
||||
|
||||
func (c *Collector) Run(ctx context.Context) error {
|
||||
ticker := time.NewTicker(c.cfg.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
c.emitNow()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
c.emitNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) emitNow() {
|
||||
now := time.Now().UTC()
|
||||
c.cfg.Emitter.Emit(events.Event{
|
||||
Topic: events.TopicHeartbeat,
|
||||
Timestamp: now,
|
||||
Source: c.Name(),
|
||||
Payload: map[string]string{
|
||||
"ts": now.Format(time.RFC3339Nano),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
// ©AngelaMos | 2026
|
||||
// collector_test.go
|
||||
|
||||
package heartbeat_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/heartbeat"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
type collectingEmitter struct {
|
||||
mu sync.Mutex
|
||||
events []events.Event
|
||||
}
|
||||
|
||||
func (c *collectingEmitter) Emit(ev events.Event) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.events = append(c.events, ev)
|
||||
}
|
||||
|
||||
func (c *collectingEmitter) Count() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return len(c.events)
|
||||
}
|
||||
|
||||
func (c *collectingEmitter) Last() events.Event {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.events[len(c.events)-1]
|
||||
}
|
||||
|
||||
func TestCollector_EmitsAtInterval(t *testing.T) {
|
||||
emitter := &collectingEmitter{}
|
||||
c := heartbeat.New(heartbeat.Config{
|
||||
Interval: 50 * time.Millisecond,
|
||||
Emitter: emitter,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
200*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
err := c.Run(ctx)
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
|
||||
require.GreaterOrEqual(t, emitter.Count(), 3)
|
||||
last := emitter.Last()
|
||||
require.Equal(t, events.TopicHeartbeat, last.Topic)
|
||||
require.Equal(t, "heartbeat", last.Source)
|
||||
}
|
||||
|
||||
func TestCollector_EmitsImmediatelyOnStart(t *testing.T) {
|
||||
emitter := &collectingEmitter{}
|
||||
c := heartbeat.New(heartbeat.Config{
|
||||
Interval: time.Hour,
|
||||
Emitter: emitter,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
_ = c.Run(ctx)
|
||||
require.Equal(
|
||||
t,
|
||||
1,
|
||||
emitter.Count(),
|
||||
"should emit one tick immediately on start",
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
// ©AngelaMos | 2026
|
||||
// client.go
|
||||
|
||||
package iss
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultISSPositionURL = "https://api.wheretheiss.at"
|
||||
defaultISSTLEURL = "https://celestrak.org"
|
||||
pathPosition = "/v1/satellites/25544"
|
||||
pathTLE = "/NORAD/elements/gp.php"
|
||||
defaultISSRate = 200 * time.Millisecond
|
||||
defaultISSBurst = 5
|
||||
defaultISSBudget = 5
|
||||
defaultISSBreaker = 60 * time.Second
|
||||
)
|
||||
|
||||
type Position struct {
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Altitude float64 `json:"altitude"`
|
||||
Velocity float64 `json:"velocity"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
|
||||
type TLE struct {
|
||||
Line1 string `json:"line1"`
|
||||
Line2 string `json:"line2"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
|
||||
type ClientConfig struct {
|
||||
PositionBaseURL string
|
||||
TLEBaseURL string
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
pos *httpx.Client
|
||||
tle *httpx.Client
|
||||
}
|
||||
|
||||
func NewClient(cfg ClientConfig) *Client {
|
||||
if cfg.PositionBaseURL == "" {
|
||||
cfg.PositionBaseURL = defaultISSPositionURL
|
||||
}
|
||||
if cfg.TLEBaseURL == "" {
|
||||
cfg.TLEBaseURL = defaultISSTLEURL
|
||||
}
|
||||
return &Client{
|
||||
pos: httpx.New(httpx.Config{
|
||||
Name: "iss-position",
|
||||
BaseURL: cfg.PositionBaseURL,
|
||||
Rate: rate.Every(defaultISSRate),
|
||||
Burst: defaultISSBurst,
|
||||
ConsecutiveFailureBudget: defaultISSBudget,
|
||||
BreakerTimeout: defaultISSBreaker,
|
||||
}),
|
||||
tle: httpx.New(httpx.Config{
|
||||
Name: "iss-tle",
|
||||
BaseURL: cfg.TLEBaseURL,
|
||||
Rate: rate.Every(time.Second),
|
||||
Burst: 1,
|
||||
ConsecutiveFailureBudget: defaultISSBudget,
|
||||
BreakerTimeout: defaultISSBreaker,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) FetchPosition(ctx context.Context) (Position, error) {
|
||||
var p Position
|
||||
if err := c.pos.GetJSON(ctx, pathPosition, nil, &p); err != nil {
|
||||
return Position{}, fmt.Errorf("fetch iss position: %w", err)
|
||||
}
|
||||
p.FetchedAt = time.Now().UTC()
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (c *Client) FetchTLE(ctx context.Context) (TLE, error) {
|
||||
q := map[string][]string{
|
||||
"CATNR": {"25544"},
|
||||
"FORMAT": {"TLE"},
|
||||
}
|
||||
resp, err := c.tle.Get(ctx, pathTLE, q)
|
||||
if err != nil {
|
||||
return TLE{}, fmt.Errorf("fetch iss tle: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return TLE{}, fmt.Errorf("read iss tle body: %w", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
|
||||
if len(lines) < 3 {
|
||||
return TLE{}, fmt.Errorf(
|
||||
"iss tle: expected 3 lines, got %d",
|
||||
len(lines),
|
||||
)
|
||||
}
|
||||
return TLE{
|
||||
Line1: strings.TrimRight(lines[1], " \r"),
|
||||
Line2: strings.TrimRight(lines[2], " \r"),
|
||||
FetchedAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
// ©AngelaMos | 2026
|
||||
// collector.go
|
||||
|
||||
package iss
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
const (
|
||||
Name = "iss"
|
||||
defaultPositionInterval = 10 * time.Second
|
||||
defaultTLEInterval = 24 * time.Hour
|
||||
)
|
||||
|
||||
type Fetcher interface {
|
||||
FetchPosition(ctx context.Context) (Position, error)
|
||||
FetchTLE(ctx context.Context) (TLE, error)
|
||||
}
|
||||
|
||||
type TLEStorer interface {
|
||||
Save(ctx context.Context, tle TLE) error
|
||||
Load(ctx context.Context) (TLE, bool, error)
|
||||
}
|
||||
|
||||
type Emitter interface {
|
||||
Emit(ev events.Event)
|
||||
}
|
||||
|
||||
type StateRecorder interface {
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64)
|
||||
RecordError(ctx context.Context, name, errMsg string)
|
||||
}
|
||||
|
||||
type CollectorConfig struct {
|
||||
PositionInterval time.Duration
|
||||
TLEInterval time.Duration
|
||||
Fetcher Fetcher
|
||||
TLEStore TLEStorer
|
||||
Emitter Emitter
|
||||
State StateRecorder
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
type Collector struct {
|
||||
cfg CollectorConfig
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewCollector(cfg CollectorConfig) *Collector {
|
||||
if cfg.PositionInterval <= 0 {
|
||||
cfg.PositionInterval = defaultPositionInterval
|
||||
}
|
||||
if cfg.TLEInterval <= 0 {
|
||||
cfg.TLEInterval = defaultTLEInterval
|
||||
}
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
}
|
||||
return &Collector{cfg: cfg, logger: cfg.Logger}
|
||||
}
|
||||
|
||||
func (c *Collector) Name() string { return Name }
|
||||
|
||||
func (c *Collector) Run(ctx context.Context) error {
|
||||
pos := time.NewTicker(c.cfg.PositionInterval)
|
||||
defer pos.Stop()
|
||||
tle := time.NewTicker(c.cfg.TLEInterval)
|
||||
defer tle.Stop()
|
||||
|
||||
c.tickPosition(ctx)
|
||||
c.tickTLE(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-pos.C:
|
||||
c.tickPosition(ctx)
|
||||
case <-tle.C:
|
||||
c.tickTLE(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) tickPosition(ctx context.Context) {
|
||||
p, err := c.cfg.Fetcher.FetchPosition(ctx)
|
||||
if err != nil {
|
||||
c.logger.Warn("iss position fetch", "err", err)
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
body, _ := json.Marshal(p)
|
||||
c.cfg.Emitter.Emit(events.Event{
|
||||
Topic: events.TopicISSPosition,
|
||||
Timestamp: p.FetchedAt,
|
||||
Source: Name,
|
||||
Payload: json.RawMessage(body),
|
||||
})
|
||||
c.cfg.State.RecordSuccess(ctx, Name, 1)
|
||||
}
|
||||
|
||||
func (c *Collector) tickTLE(ctx context.Context) {
|
||||
tle, err := c.cfg.Fetcher.FetchTLE(ctx)
|
||||
if err != nil {
|
||||
c.logger.Warn("iss tle fetch", "err", err)
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
if err := c.cfg.TLEStore.Save(ctx, tle); err != nil {
|
||||
c.logger.Warn("iss tle save", "err", err)
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
// ©AngelaMos | 2026
|
||||
// collector_test.go
|
||||
|
||||
package iss_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/iss"
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
type fakeFetcher struct {
|
||||
mu sync.Mutex
|
||||
pos iss.Position
|
||||
tle iss.TLE
|
||||
posErr error
|
||||
tleErr error
|
||||
posN int
|
||||
tleN int
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchPosition(_ context.Context) (iss.Position, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.posN++
|
||||
return f.pos, f.posErr
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchTLE(_ context.Context) (iss.TLE, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.tleN++
|
||||
return f.tle, f.tleErr
|
||||
}
|
||||
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
saves int
|
||||
saved iss.TLE
|
||||
}
|
||||
|
||||
func (s *fakeStore) Save(_ context.Context, tle iss.TLE) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.saves++
|
||||
s.saved = tle
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeStore) Load(_ context.Context) (iss.TLE, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.saves == 0 {
|
||||
return iss.TLE{}, false, nil
|
||||
}
|
||||
return s.saved, true, nil
|
||||
}
|
||||
|
||||
type fakeEmitter struct {
|
||||
mu sync.Mutex
|
||||
events []events.Event
|
||||
}
|
||||
|
||||
func (e *fakeEmitter) Emit(ev events.Event) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.events = append(e.events, ev)
|
||||
}
|
||||
|
||||
func (e *fakeEmitter) Count() int {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return len(e.events)
|
||||
}
|
||||
|
||||
type recordingState struct {
|
||||
mu sync.Mutex
|
||||
successes int
|
||||
failures int
|
||||
}
|
||||
|
||||
func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.successes++
|
||||
}
|
||||
|
||||
func (s *recordingState) RecordError(_ context.Context, _, _ string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.failures++
|
||||
}
|
||||
|
||||
func TestCollector_PositionLoopEmitsAndRecords(t *testing.T) {
|
||||
ftch := &fakeFetcher{
|
||||
pos: iss.Position{
|
||||
Latitude: 10,
|
||||
Longitude: 20,
|
||||
Altitude: 420,
|
||||
Velocity: 27500,
|
||||
Timestamp: 1234,
|
||||
FetchedAt: time.Now().UTC(),
|
||||
},
|
||||
tle: iss.TLE{Line1: "1 25544U ...", Line2: "2 25544 ..."},
|
||||
}
|
||||
store := &fakeStore{}
|
||||
emt := &fakeEmitter{}
|
||||
st := &recordingState{}
|
||||
|
||||
c := iss.NewCollector(iss.CollectorConfig{
|
||||
PositionInterval: 20 * time.Millisecond,
|
||||
TLEInterval: time.Hour,
|
||||
Fetcher: ftch,
|
||||
TLEStore: store,
|
||||
Emitter: emt,
|
||||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
70*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
require.GreaterOrEqual(
|
||||
t,
|
||||
ftch.posN,
|
||||
2,
|
||||
"position should fire multiple times",
|
||||
)
|
||||
require.GreaterOrEqual(t, emt.Count(), 2)
|
||||
for _, ev := range emt.events {
|
||||
require.Equal(t, events.TopicISSPosition, ev.Topic)
|
||||
}
|
||||
require.Positive(t, st.successes)
|
||||
}
|
||||
|
||||
func TestCollector_TLELoopSavesToStore(t *testing.T) {
|
||||
ftch := &fakeFetcher{
|
||||
pos: iss.Position{
|
||||
Latitude: 10,
|
||||
Longitude: 20,
|
||||
Altitude: 420,
|
||||
FetchedAt: time.Now().UTC(),
|
||||
},
|
||||
tle: iss.TLE{Line1: "1 25544U test", Line2: "2 25544 test"},
|
||||
}
|
||||
store := &fakeStore{}
|
||||
emt := &fakeEmitter{}
|
||||
st := &recordingState{}
|
||||
|
||||
c := iss.NewCollector(iss.CollectorConfig{
|
||||
PositionInterval: 50 * time.Millisecond,
|
||||
TLEInterval: 30 * time.Millisecond,
|
||||
Fetcher: ftch,
|
||||
TLEStore: store,
|
||||
Emitter: emt,
|
||||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
100*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
require.GreaterOrEqual(
|
||||
t,
|
||||
store.saves,
|
||||
2,
|
||||
"TLE should refresh multiple times in 100ms with 30ms interval",
|
||||
)
|
||||
require.Equal(t, "1 25544U test", store.saved.Line1)
|
||||
}
|
||||
|
||||
func TestCollector_PositionFetchErrorRecordsState(t *testing.T) {
|
||||
ftch := &fakeFetcher{
|
||||
posErr: errors.New("upstream 503"),
|
||||
tle: iss.TLE{Line1: "1 ...", Line2: "2 ..."},
|
||||
}
|
||||
store := &fakeStore{}
|
||||
emt := &fakeEmitter{}
|
||||
st := &recordingState{}
|
||||
|
||||
c := iss.NewCollector(iss.CollectorConfig{
|
||||
PositionInterval: 20 * time.Millisecond,
|
||||
TLEInterval: time.Hour,
|
||||
Fetcher: ftch,
|
||||
TLEStore: store,
|
||||
Emitter: emt,
|
||||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
require.Equal(t, 0, emt.Count())
|
||||
require.Positive(t, st.failures)
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
// ©AngelaMos | 2026
|
||||
// propagator.go
|
||||
|
||||
package iss
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
satellite "github.com/joshuaferrara/go-satellite"
|
||||
)
|
||||
|
||||
type Sat struct {
|
||||
inner satellite.Satellite
|
||||
}
|
||||
|
||||
func LoadTLE(line1, line2 string) (Sat, error) {
|
||||
if !strings.HasPrefix(line1, "1 ") || !strings.HasPrefix(line2, "2 ") {
|
||||
return Sat{}, fmt.Errorf(
|
||||
"invalid TLE: lines must start with '1 ' and '2 '",
|
||||
)
|
||||
}
|
||||
s := satellite.TLEToSat(line1, line2, satellite.GravityWGS84)
|
||||
if s.Error != 0 || s.ErrorStr != "" {
|
||||
return Sat{}, fmt.Errorf(
|
||||
"parse TLE: error %d / %s",
|
||||
s.Error,
|
||||
s.ErrorStr,
|
||||
)
|
||||
}
|
||||
return Sat{inner: s}, nil
|
||||
}
|
||||
|
||||
func Propagate(s Sat, t time.Time) (lat, lon, altKm float64) {
|
||||
t = t.UTC()
|
||||
pos, _ := satellite.Propagate(
|
||||
s.inner,
|
||||
t.Year(),
|
||||
int(t.Month()),
|
||||
t.Day(),
|
||||
t.Hour(),
|
||||
t.Minute(),
|
||||
t.Second(),
|
||||
)
|
||||
gmst := satellite.GSTimeFromDate(
|
||||
t.Year(),
|
||||
int(t.Month()),
|
||||
t.Day(),
|
||||
t.Hour(),
|
||||
t.Minute(),
|
||||
t.Second(),
|
||||
)
|
||||
altitude, _, ll := satellite.ECIToLLA(pos, gmst)
|
||||
deg := satellite.LatLongDeg(ll)
|
||||
return deg.Latitude, deg.Longitude, altitude
|
||||
}
|
||||
|
||||
func LookAngles(
|
||||
s Sat,
|
||||
t time.Time,
|
||||
observerLatDeg, observerLonDeg, observerAltKm float64,
|
||||
) (azimuthDeg, elevationDeg, rangeKm float64) {
|
||||
t = t.UTC()
|
||||
pos, _ := satellite.Propagate(
|
||||
s.inner,
|
||||
t.Year(),
|
||||
int(t.Month()),
|
||||
t.Day(),
|
||||
t.Hour(),
|
||||
t.Minute(),
|
||||
t.Second(),
|
||||
)
|
||||
jday := satellite.JDay(
|
||||
t.Year(),
|
||||
int(t.Month()),
|
||||
t.Day(),
|
||||
t.Hour(),
|
||||
t.Minute(),
|
||||
t.Second(),
|
||||
)
|
||||
obs := satellite.LatLong{
|
||||
Latitude: observerLatDeg * math.Pi / 180,
|
||||
Longitude: observerLonDeg * math.Pi / 180,
|
||||
}
|
||||
la := satellite.ECIToLookAngles(pos, obs, observerAltKm, jday)
|
||||
return la.Az * 180 / math.Pi, la.El * 180 / math.Pi, la.Rg
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
// ©AngelaMos | 2026
|
||||
// propagator_test.go
|
||||
|
||||
package iss_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/iss"
|
||||
)
|
||||
|
||||
func loadTLE(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile("testdata/tle.txt")
|
||||
require.NoError(t, err)
|
||||
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
|
||||
require.GreaterOrEqual(
|
||||
t,
|
||||
len(lines),
|
||||
3,
|
||||
"TLE file must have name + 2 element lines",
|
||||
)
|
||||
return strings.TrimRight(
|
||||
lines[1],
|
||||
" \r",
|
||||
), strings.TrimRight(
|
||||
lines[2],
|
||||
" \r",
|
||||
)
|
||||
}
|
||||
|
||||
func TestPropagator_PositionWithinReasonableBounds(t *testing.T) {
|
||||
l1, l2 := loadTLE(t)
|
||||
sat, err := iss.LoadTLE(l1, l2)
|
||||
require.NoError(t, err)
|
||||
|
||||
when := time.Date(2026, 5, 2, 3, 0, 0, 0, time.UTC)
|
||||
lat, lon, alt := iss.Propagate(sat, when)
|
||||
|
||||
require.True(t, lat >= -90 && lat <= 90, "lat=%f", lat)
|
||||
require.True(t, lon >= -180 && lon <= 180, "lon=%f", lon)
|
||||
require.True(
|
||||
t,
|
||||
alt >= 350 && alt <= 500,
|
||||
"ISS altitude is roughly 380-420 km, got %f",
|
||||
alt,
|
||||
)
|
||||
}
|
||||
|
||||
func TestPropagator_LookAnglesInValidRange(t *testing.T) {
|
||||
l1, l2 := loadTLE(t)
|
||||
sat, err := iss.LoadTLE(l1, l2)
|
||||
require.NoError(t, err)
|
||||
|
||||
when := time.Date(2026, 5, 2, 3, 0, 0, 0, time.UTC)
|
||||
az, el, rng := iss.LookAngles(sat, when, 40.7128, -74.0060, 0)
|
||||
|
||||
require.True(t, az >= 0 && az <= 360, "az=%f", az)
|
||||
require.True(t, el >= -90 && el <= 90, "el=%f", el)
|
||||
require.Greater(t, rng, 0.0)
|
||||
}
|
||||
|
||||
func TestPropagator_BadTLEReturnsError(t *testing.T) {
|
||||
_, err := iss.LoadTLE("not a tle", "really not a tle")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"name":"iss","id":25544,"latitude":10.63651293933,"longitude":-10.956952860344,"altitude":423.74642684314,"velocity":27564.677940445,"visibility":"daylight","footprint":4526.4746978082,"timestamp":1777710988,"daynum":2461162.8586574,"solar_lat":15.425197566858,"solar_lon":50.133029500693,"units":"kilometers"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
[{"OBJECT_NAME":"ISS (ZARYA)","OBJECT_ID":"1998-067A","EPOCH":"2026-05-02T03:14:52.956096","MEAN_MOTION":15.49057929,"ECCENTRICITY":0.0007234,"INCLINATION":51.6309,"RA_OF_ASC_NODE":168.049,"ARG_OF_PERICENTER":13.9157,"MEAN_ANOMALY":346.2029,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":25544,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":56461,"BSTAR":0.00014312,"MEAN_MOTION_DOT":7.461e-5,"MEAN_MOTION_DDOT":0}]
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
ISS (ZARYA)
|
||||
1 25544U 98067A 26122.13533514 .00007461 00000+0 14312-3 0 9999
|
||||
2 25544 51.6309 168.0490 0007234 13.9157 346.2029 15.49057929564615
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
// ©AngelaMos | 2026
|
||||
// tle_store.go
|
||||
|
||||
package iss
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
tleKey = "iss:tle"
|
||||
tleTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
type TLEStore struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
func NewTLEStore(rdb *redis.Client) *TLEStore {
|
||||
return &TLEStore{rdb: rdb}
|
||||
}
|
||||
|
||||
func (s *TLEStore) Save(ctx context.Context, tle TLE) error {
|
||||
body, err := json.Marshal(tle)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal tle: %w", err)
|
||||
}
|
||||
if err := s.rdb.Set(ctx, tleKey, body, tleTTL).Err(); err != nil {
|
||||
return fmt.Errorf("save iss tle: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TLEStore) Load(ctx context.Context) (TLE, bool, error) {
|
||||
body, err := s.rdb.Get(ctx, tleKey).Bytes()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return TLE{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return TLE{}, false, fmt.Errorf("load iss tle: %w", err)
|
||||
}
|
||||
var tle TLE
|
||||
if err := json.Unmarshal(body, &tle); err != nil {
|
||||
return TLE{}, false, fmt.Errorf("unmarshal tle: %w", err)
|
||||
}
|
||||
return tle, true, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
// ©AngelaMos | 2026
|
||||
// client.go
|
||||
|
||||
package kev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultKEVURL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
|
||||
defaultKEVRate = time.Second
|
||||
defaultKEVBudget = 5
|
||||
defaultKEVBreakerWin = 60 * time.Second
|
||||
)
|
||||
|
||||
type ClientConfig struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
hx *httpx.Client
|
||||
}
|
||||
|
||||
func NewClient(cfg ClientConfig) *Client {
|
||||
if cfg.URL == "" {
|
||||
cfg.URL = defaultKEVURL
|
||||
}
|
||||
return &Client{
|
||||
hx: httpx.New(httpx.Config{
|
||||
Name: "kev",
|
||||
BaseURL: cfg.URL,
|
||||
Rate: rate.Every(defaultKEVRate),
|
||||
Burst: 1,
|
||||
ConsecutiveFailureBudget: defaultKEVBudget,
|
||||
BreakerTimeout: defaultKEVBreakerWin,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
type Catalog struct {
|
||||
Title string `json:"title"`
|
||||
CatalogVersion string `json:"catalogVersion"`
|
||||
DateReleased string `json:"dateReleased"`
|
||||
Count int `json:"count"`
|
||||
Vulnerabilities []Vulnerability `json:"vulnerabilities"`
|
||||
}
|
||||
|
||||
type Vulnerability struct {
|
||||
CveID string `json:"cveID"`
|
||||
VendorProject string `json:"vendorProject"`
|
||||
Product string `json:"product"`
|
||||
VulnerabilityName string `json:"vulnerabilityName"`
|
||||
DateAdded string `json:"dateAdded"`
|
||||
DueDate string `json:"dueDate"`
|
||||
KnownRansomwareCampaignUse string `json:"knownRansomwareCampaignUse"`
|
||||
ShortDescription string `json:"shortDescription"`
|
||||
RequiredAction string `json:"requiredAction"`
|
||||
}
|
||||
|
||||
func (c *Client) FetchCatalog(ctx context.Context) (Catalog, error) {
|
||||
resp, err := c.hx.Get(ctx, "", nil)
|
||||
if err != nil {
|
||||
return Catalog{}, fmt.Errorf("fetch kev catalog: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var cat Catalog
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cat); err != nil {
|
||||
return Catalog{}, fmt.Errorf("decode kev: %w", err)
|
||||
}
|
||||
return cat, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
// ©AngelaMos | 2026
|
||||
// client_test.go
|
||||
|
||||
package kev_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/kev"
|
||||
)
|
||||
|
||||
func TestKEVClient_FetchCatalogDecodes(t *testing.T) {
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
body, err := os.ReadFile("testdata/kev_catalog.json")
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}),
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
c := kev.NewClient(kev.ClientConfig{URL: srv.URL})
|
||||
cat, err := c.FetchCatalog(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, cat.Vulnerabilities)
|
||||
require.NotEmpty(t, cat.Vulnerabilities[0].CveID)
|
||||
}
|
||||
|
||||
func TestKEVClient_RejectsServerError(t *testing.T) {
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}),
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
c := kev.NewClient(kev.ClientConfig{URL: srv.URL})
|
||||
_, err := c.FetchCatalog(context.Background())
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
// ©AngelaMos | 2026
|
||||
// collector.go
|
||||
|
||||
package kev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
const (
|
||||
Name = "kev"
|
||||
defaultKEVCadence = time.Hour
|
||||
dateLayout = "2006-01-02"
|
||||
)
|
||||
|
||||
type Fetcher interface {
|
||||
FetchCatalog(ctx context.Context) (Catalog, error)
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
Insert(ctx context.Context, row Row) error
|
||||
KnownIDs(ctx context.Context, ids []string) (map[string]bool, error)
|
||||
}
|
||||
|
||||
type Emitter interface {
|
||||
Emit(ev events.Event)
|
||||
}
|
||||
|
||||
type StateRecorder interface {
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64)
|
||||
RecordError(ctx context.Context, name, errMsg string)
|
||||
}
|
||||
|
||||
type CollectorConfig struct {
|
||||
Interval time.Duration
|
||||
Fetcher Fetcher
|
||||
Repo Repository
|
||||
Emitter Emitter
|
||||
State StateRecorder
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
type Collector struct {
|
||||
cfg CollectorConfig
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewCollector(cfg CollectorConfig) *Collector {
|
||||
if cfg.Interval <= 0 {
|
||||
cfg.Interval = defaultKEVCadence
|
||||
}
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
}
|
||||
return &Collector{cfg: cfg, logger: cfg.Logger}
|
||||
}
|
||||
|
||||
func (c *Collector) Name() string { return Name }
|
||||
|
||||
func (c *Collector) Run(ctx context.Context) error {
|
||||
ticker := time.NewTicker(c.cfg.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
c.tick(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
c.tick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) tick(ctx context.Context) {
|
||||
cat, err := c.cfg.Fetcher.FetchCatalog(ctx)
|
||||
if err != nil {
|
||||
c.logger.Warn("kev fetch", "err", err)
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(cat.Vulnerabilities))
|
||||
for _, v := range cat.Vulnerabilities {
|
||||
ids = append(ids, v.CveID)
|
||||
}
|
||||
|
||||
known, err := c.cfg.Repo.KnownIDs(ctx, ids)
|
||||
if err != nil {
|
||||
c.logger.Warn("kev known ids", "err", err)
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
emitted := int64(0)
|
||||
for _, v := range cat.Vulnerabilities {
|
||||
if known[v.CveID] {
|
||||
continue
|
||||
}
|
||||
|
||||
dateAdded, _ := time.Parse(dateLayout, v.DateAdded)
|
||||
var dueDate *time.Time
|
||||
if t, perr := time.Parse(dateLayout, v.DueDate); perr == nil {
|
||||
dueDate = &t
|
||||
}
|
||||
|
||||
raw, _ := json.Marshal(v)
|
||||
row := Row{
|
||||
CveID: v.CveID,
|
||||
Vendor: v.VendorProject,
|
||||
Product: v.Product,
|
||||
VulnerabilityName: v.VulnerabilityName,
|
||||
DateAdded: dateAdded,
|
||||
DueDate: dueDate,
|
||||
RansomwareUse: v.KnownRansomwareCampaignUse,
|
||||
Payload: raw,
|
||||
}
|
||||
|
||||
if ierr := c.cfg.Repo.Insert(ctx, row); ierr != nil {
|
||||
c.logger.Warn("kev insert", "id", v.CveID, "err", ierr)
|
||||
continue
|
||||
}
|
||||
|
||||
c.cfg.Emitter.Emit(events.Event{
|
||||
Topic: events.TopicKEVAdded,
|
||||
Timestamp: now,
|
||||
Source: Name,
|
||||
Payload: json.RawMessage(raw),
|
||||
})
|
||||
emitted++
|
||||
}
|
||||
c.cfg.State.RecordSuccess(ctx, Name, emitted)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue