I'm being gangstalked by the CIA, they put a chip in my brain
This commit is contained in:
parent
6e35f49d2a
commit
5e7f2c36de
|
|
@ -1,5 +1,5 @@
|
|||
# ©AngelaMos | 2026
|
||||
# .env.example
|
||||
# .env.example.dev
|
||||
|
||||
APP_NAME=monitor
|
||||
PUBLIC_URL=http://localhost:8432
|
||||
|
|
@ -16,7 +16,9 @@ POSTGRES_DB=monitor
|
|||
|
||||
REDIS_PASSWORD=
|
||||
|
||||
JWT_SECRET=
|
||||
# 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=
|
||||
|
|
@ -24,6 +26,12 @@ 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
|
||||
|
||||
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=
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.prod
|
||||
|
||||
backend/tmp/
|
||||
backend/bin/
|
||||
|
|
@ -11,6 +12,7 @@ backend/coverage.out
|
|||
backend/coverage.html
|
||||
backend/keys/
|
||||
|
||||
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
frontend/.vite/
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ lint-fix:
|
|||
|
||||
[group('lint')]
|
||||
format:
|
||||
cd backend && gofumpt -w . && goimports -w .
|
||||
cd backend && golangci-lint fmt
|
||||
|
||||
[group('lint')]
|
||||
tidy:
|
||||
|
|
@ -106,7 +106,7 @@ install:
|
|||
|
||||
[group('db')]
|
||||
migrate *ARGS:
|
||||
docker compose -f compose.yml exec backend sh -c 'goose -dir /migrations postgres "$DATABASE_URL" up' {{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:
|
||||
|
|
@ -134,27 +134,27 @@ redis-cli:
|
|||
|
||||
[group('docker')]
|
||||
up *ARGS:
|
||||
docker compose -f compose.yml up {{ARGS}}
|
||||
docker compose --env-file .env.prod -f compose.yml up {{ARGS}}
|
||||
|
||||
[group('docker')]
|
||||
start *ARGS:
|
||||
docker compose -f compose.yml up -d --build {{ARGS}}
|
||||
docker compose --env-file .env.prod -f compose.yml up -d --build {{ARGS}}
|
||||
|
||||
[group('docker')]
|
||||
down *ARGS:
|
||||
docker compose -f compose.yml down {{ARGS}}
|
||||
docker compose --env-file .env.prod -f compose.yml down {{ARGS}}
|
||||
|
||||
[group('docker')]
|
||||
logs *SERVICE:
|
||||
docker compose -f compose.yml logs -f {{SERVICE}}
|
||||
docker compose --env-file .env.prod -f compose.yml logs -f {{SERVICE}}
|
||||
|
||||
[group('docker')]
|
||||
ps:
|
||||
docker compose -f compose.yml ps
|
||||
docker compose --env-file .env.prod -f compose.yml ps
|
||||
|
||||
[group('docker')]
|
||||
build-prod:
|
||||
docker compose -f compose.yml build
|
||||
docker compose --env-file .env.prod -f compose.yml build
|
||||
|
||||
# =============================================================================
|
||||
# Docker (development)
|
||||
|
|
@ -184,6 +184,12 @@ dev-ps:
|
|||
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
|
||||
|
|
@ -194,19 +200,35 @@ dev-clean:
|
|||
|
||||
[group('tunnel')]
|
||||
tunnel-up *ARGS:
|
||||
docker compose -f compose.yml -f cloudflared.compose.yml up {{ARGS}}
|
||||
docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml up {{ARGS}}
|
||||
|
||||
[group('tunnel')]
|
||||
tunnel-start *ARGS:
|
||||
docker compose -f compose.yml -f cloudflared.compose.yml up -d --build {{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 -f compose.yml -f cloudflared.compose.yml down {{ARGS}}
|
||||
docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml down {{ARGS}}
|
||||
|
||||
[group('tunnel')]
|
||||
tunnel-logs:
|
||||
docker compose -f compose.yml -f cloudflared.compose.yml logs -f cloudflared
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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/>.
|
||||
|
|
@ -3,65 +3,82 @@
|
|||
README.md
|
||||
-->
|
||||
|
||||
# Monitor the Situation
|
||||
```json
|
||||
██████╗██╗ █████╗
|
||||
██╔════╝██║██╔══██╗
|
||||
██║ ██║███████║
|
||||
██║ ██║██╔══██║
|
||||
╚██████╗██║██║ ██║
|
||||
╚═════╝╚═╝╚═╝ ╚═╝
|
||||
```
|
||||
|
||||
Operator-grade real-time situational awareness dashboard. Single-binary Go backend, React 19 frontend, Postgres + Redis, fronted by nginx and (optionally) a Cloudflare Tunnel.
|
||||
[](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)
|
||||
|
||||
> The phrase "Monitoring the situation" is a Twitter/X meme from June 2025. This is the version that actually monitors the situation.
|
||||
> 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
|
||||
|
||||
| Layer | Tech |
|
||||
| ----------- | ---------------------------------------------------------- |
|
||||
| Backend | Go 1.22, chi router, `coder/websocket`, `goose` migrations |
|
||||
| Frontend | React 19, Vite, TanStack Query, Zustand, MapLibre, D3 |
|
||||
| Storage | Postgres 16 (BRIN-indexed time-series), Redis 7 |
|
||||
| Ingress | nginx (dev + prod), Cloudflare Tunnel (prod) |
|
||||
| Build / run | `just` recipes, multi-stage Docker, air for live reload |
|
||||
**Backend:** Go 1.25, chi v5, `coder/websocket`, pgx + pgxpool, goose migrations, errgroup-driven collectors, Argon2id, JWT (Ed25519)
|
||||
|
||||
## Data sources
|
||||
**Frontend:** React 19, TypeScript, Vite, TanStack Query v5, Zustand, MapLibre GL, D3, SCSS Modules
|
||||
|
||||
| Panel | Source | Cadence | Auth |
|
||||
| ------------------------------ | ---------------------------- | ---------- | --------------------------------------------- |
|
||||
| Mass-scan firehose | DShield (SANS ISC) | 1h | none |
|
||||
| Internet outages + BGP hijacks | Cloudflare Radar | 5m | `CF_RADAR_TOKEN` (Radar:Read scope) |
|
||||
| CVE velocity + EPSS | NVD CVE 2.0 + FIRST EPSS | 2h | `NVD_API_KEY` (optional, raises rate limit) |
|
||||
| CISA KEV | CISA KEV catalog | 1h | none |
|
||||
| Ransomware victims | ransomware.live | 15m | none |
|
||||
| Live BTC + ETH ticks | Coinbase Advanced Trade WS | persistent | none |
|
||||
| Earthquakes (M2.5+) | USGS GeoJSON feed | 1m | none |
|
||||
| Space weather | NOAA SWPC (5 endpoints) | 1m / 3h | none |
|
||||
| World events | Wikipedia ITN + GDELT v2 API | 5m / 15m | none |
|
||||
| ISS position | wheretheiss.at + CelesTrak | 10s / 24h | none |
|
||||
| IP enrichment (BGP hijacks) | AbuseIPDB | on-demand | `ABUSEIPDB_API_KEY` (optional, free tier 1k/day) |
|
||||
**Data:** PostgreSQL 16 (BRIN time-series indexes), Redis 7
|
||||
|
||||
## Quickstart (development)
|
||||
**Infrastructure:** Docker Compose, nginx reverse proxy, Cloudflare Tunnel (prod), multi-stage builds, air for live reload
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# fill .env: POSTGRES_PASSWORD, JWT_SECRET, NOTIFICATION_ENCRYPTION_KEY
|
||||
just dev-start
|
||||
just migrate-dev
|
||||
open http://localhost:8432
|
||||
```
|
||||
## Data Sources
|
||||
|
||||
JWT signing keys auto-generate at `backend/keys/private.pem` on first boot. The dev stack binds host ports `8432` (nginx) / `5432` (backend) / `4432` (postgres) / `6432` (redis) / `3432` (vite).
|
||||
|
||||
### Smoke checks
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8432/api/v1/healthz
|
||||
curl -s http://localhost:8432/api/v1/snapshot | jq .
|
||||
docker run --rm -i --network host ghcr.io/vi/websocat:latest \
|
||||
"ws://localhost:8432/api/v1/ws?topics=heartbeat"
|
||||
```
|
||||
| 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
|
||||
# fill production secrets including CLOUDFLARE_TUNNEL_TOKEN
|
||||
just tunnel-start
|
||||
just prod-redeploy
|
||||
just migrate
|
||||
```
|
||||
|
||||
|
|
@ -71,11 +88,18 @@ just migrate
|
|||
cd backend && go test -race ./...
|
||||
```
|
||||
|
||||
## Layout
|
||||
## Learn
|
||||
|
||||
```
|
||||
backend/ Go services (cmd/api, internal/{events,bus,ws,snapshot,collectors,...})
|
||||
frontend/ React 19 dashboard
|
||||
conf/ nginx and per-environment Docker configs
|
||||
migrations/ goose SQL migrations (mounted into the backend container)
|
||||
```
|
||||
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
|
||||
|
|
|
|||
|
|
@ -28,7 +28,12 @@ linters:
|
|||
settings:
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
check-blank: 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
|
||||
|
|
@ -71,25 +76,67 @@ linters:
|
|||
|
||||
gosec:
|
||||
excludes:
|
||||
- G104
|
||||
- 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
|
||||
|
||||
issues:
|
||||
max-same-issues: 50
|
||||
exclude-dirs:
|
||||
- vendor
|
||||
- testdata
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- funlen
|
||||
- dupl
|
||||
- goconst
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"errors"
|
||||
"flag"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
|
|
@ -18,6 +19,7 @@ import (
|
|||
"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"
|
||||
|
|
@ -36,8 +38,11 @@ import (
|
|||
"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"
|
||||
|
|
@ -53,7 +58,10 @@ type abuseipdbEnricher struct {
|
|||
client *abuseipdb.Client
|
||||
}
|
||||
|
||||
func (a abuseipdbEnricher) Lookup(ctx context.Context, ip string) (cfradar.Enrichment, error) {
|
||||
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
|
||||
|
|
@ -65,16 +73,62 @@ func (a abuseipdbEnricher) Lookup(ctx context.Context, ip string) (cfradar.Enric
|
|||
}, 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(
|
||||
|
|
@ -128,8 +182,8 @@ func run(configPath string) error {
|
|||
"pool_size", cfg.Redis.PoolSize,
|
||||
)
|
||||
|
||||
if err := ensureJWTKeys(cfg.JWT, logger); err != nil {
|
||||
return err
|
||||
if keyErr := ensureJWTKeys(cfg.JWT, logger); keyErr != nil {
|
||||
return keyErr
|
||||
}
|
||||
|
||||
jwtManager, err := auth.NewJWTManager(cfg.JWT)
|
||||
|
|
@ -146,8 +200,60 @@ func run(configPath string) error {
|
|||
userHandler := user.NewHandler(userSvc)
|
||||
|
||||
authRepo := auth.NewRepository(db.DB)
|
||||
authSvc := auth.NewService(authRepo, jwtManager, userSvc, redis.Client)
|
||||
authHandler := auth.NewHandler(authSvc)
|
||||
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)
|
||||
|
||||
|
|
@ -161,8 +267,28 @@ func run(configPath string) error {
|
|||
snapStore := snapshot.NewStore(redis.Client)
|
||||
snapHandler := snapshot.NewHandler(snapStore)
|
||||
|
||||
hub := ws.NewHub(ws.HubConfig{Logger: logger})
|
||||
wsHandler := ws.NewHandler(hub)
|
||||
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,
|
||||
|
|
@ -182,13 +308,66 @@ func run(configPath string) error {
|
|||
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) })
|
||||
|
|
@ -196,20 +375,29 @@ func run(configPath string) error {
|
|||
|
||||
if cfg.Collectors.CFRadar.Enabled {
|
||||
var enricher cfradar.Enricher
|
||||
if cfg.Collectors.AbuseIPDB.Enabled && cfg.Collectors.AbuseIPDB.APIKey != "" {
|
||||
if cfg.Collectors.AbuseIPDB.Enabled &&
|
||||
cfg.Collectors.AbuseIPDB.APIKey != "" {
|
||||
enricher = abuseipdbEnricher{
|
||||
client: abuseipdb.NewClient(abuseipdb.ClientConfig{APIKey: cfg.Collectors.AbuseIPDB.APIKey}),
|
||||
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"),
|
||||
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) })
|
||||
}
|
||||
|
|
@ -218,12 +406,14 @@ func run(configPath string) error {
|
|||
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"),
|
||||
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) })
|
||||
}
|
||||
|
|
@ -278,7 +468,10 @@ func run(configPath string) error {
|
|||
}
|
||||
|
||||
if cfg.Collectors.SWPC.Enabled {
|
||||
ring := redisring.New(redis.Client, redisring.Config{Retention: 24 * time.Hour})
|
||||
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,
|
||||
|
|
@ -355,10 +548,12 @@ func run(configPath string) error {
|
|||
router.Use(middleware.Logger(logger))
|
||||
router.Use(
|
||||
middleware.NewRateLimiter(redis.Client, middleware.RateLimitConfig{
|
||||
Limit: middleware.PerMinute(
|
||||
Limit: middleware.PerWindow(
|
||||
cfg.RateLimit.Requests,
|
||||
cfg.RateLimit.Burst,
|
||||
cfg.RateLimit.Window,
|
||||
),
|
||||
KeyFunc: middleware.KeyByClientIP(cfg.Server.TrustedProxyHops),
|
||||
FailOpen: true,
|
||||
}).Handler,
|
||||
)
|
||||
|
|
@ -369,7 +564,7 @@ func run(configPath string) error {
|
|||
|
||||
router.Get("/.well-known/jwks.json", jwtManager.GetJWKSHandler())
|
||||
|
||||
authenticator := middleware.Authenticator(jwtManager)
|
||||
authenticator := middleware.Authenticator(authVerifier)
|
||||
adminOnly := middleware.RequireAdmin
|
||||
|
||||
router.Route("/v1", func(r chi.Router) {
|
||||
|
|
@ -379,13 +574,20 @@ func run(configPath string) error {
|
|||
r.Get("/snapshot", snapHandler.ServeHTTP)
|
||||
r.Get("/ws", wsHandler.ServeHTTP)
|
||||
|
||||
authHandler.RegisterRoutes(r, authenticator)
|
||||
intelHandler.RegisterRoutes(r)
|
||||
|
||||
r.Post("/users", authHandler.Register)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -3,38 +3,46 @@ 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/PuerkitoBio/goquery v1.12.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // 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
|
||||
|
|
@ -62,7 +70,6 @@ require (
|
|||
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/joshuaferrara/go-satellite v0.0.0-20220611180459-512638c64e5b // 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
|
||||
|
|
@ -73,7 +80,6 @@ require (
|
|||
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/lib/pq v1.12.3 // 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
|
||||
|
|
@ -95,10 +101,8 @@ require (
|
|||
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/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/testcontainers/testcontainers-go v0.42.0 // indirect
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres 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
|
||||
|
|
@ -108,13 +112,13 @@ require (
|
|||
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.3 // 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/sync v0.20.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.8 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
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=
|
||||
|
|
@ -12,6 +14,8 @@ github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO
|
|||
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=
|
||||
|
|
@ -82,6 +86,8 @@ 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=
|
||||
|
|
@ -136,7 +142,6 @@ github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNB
|
|||
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 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
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=
|
||||
|
|
@ -170,7 +175,9 @@ 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=
|
||||
|
|
@ -242,18 +249,18 @@ go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1
|
|||
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.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
|
||||
go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
|
||||
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
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=
|
||||
|
|
@ -268,8 +275,6 @@ 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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
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=
|
||||
|
|
@ -306,9 +311,8 @@ 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.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
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=
|
||||
|
|
@ -318,8 +322,6 @@ 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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
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=
|
||||
|
|
@ -338,11 +340,12 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:
|
|||
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.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ type RegisterRequest struct {
|
|||
}
|
||||
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" validate:"required"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@ package auth
|
|||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-playground/validator/v10"
|
||||
|
|
@ -17,18 +16,79 @@ import (
|
|||
"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
|
||||
service *Service
|
||||
validator *validator.Validate
|
||||
trustedProxyHops int
|
||||
secureCookies bool
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
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: service,
|
||||
validator: validator.New(validator.WithRequiredStructEnabled()),
|
||||
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,
|
||||
|
|
@ -63,7 +123,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
userAgent := r.UserAgent()
|
||||
ipAddress := extractIPAddress(r)
|
||||
ipAddress := h.extractIPAddress(r)
|
||||
|
||||
resp, err := h.service.Login(r.Context(), req, userAgent, ipAddress)
|
||||
if err != nil {
|
||||
|
|
@ -78,6 +138,11 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
h.setRefreshCookie(
|
||||
w,
|
||||
resp.Tokens.RefreshToken,
|
||||
time.Now().Add(refreshCookieMaxAge),
|
||||
)
|
||||
core.OK(w, resp)
|
||||
}
|
||||
|
||||
|
|
@ -94,7 +159,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
userAgent := r.UserAgent()
|
||||
ipAddress := extractIPAddress(r)
|
||||
ipAddress := h.extractIPAddress(r)
|
||||
|
||||
resp, err := h.service.Register(r.Context(), req, userAgent, ipAddress)
|
||||
if err != nil {
|
||||
|
|
@ -106,27 +171,33 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
|||
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 {
|
||||
core.BadRequest(w, "invalid request body")
|
||||
return
|
||||
// Body may be empty when frontend uses cookie-only flow.
|
||||
req = RefreshRequest{}
|
||||
}
|
||||
|
||||
if err := h.validator.Struct(req); err != nil {
|
||||
core.BadRequest(w, core.FormatValidationError(err))
|
||||
refreshToken := h.refreshTokenFromRequest(r, req.RefreshToken)
|
||||
if refreshToken == "" {
|
||||
core.BadRequest(w, "refresh_token required (cookie or body)")
|
||||
return
|
||||
}
|
||||
|
||||
userAgent := r.UserAgent()
|
||||
ipAddress := extractIPAddress(r)
|
||||
ipAddress := h.extractIPAddress(r)
|
||||
|
||||
resp, err := h.service.Refresh(
|
||||
r.Context(),
|
||||
req.RefreshToken,
|
||||
refreshToken,
|
||||
userAgent,
|
||||
ipAddress,
|
||||
)
|
||||
|
|
@ -156,6 +227,11 @@ func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
h.setRefreshCookie(
|
||||
w,
|
||||
resp.Tokens.RefreshToken,
|
||||
time.Now().Add(refreshCookieMaxAge),
|
||||
)
|
||||
core.OK(w, resp)
|
||||
}
|
||||
|
||||
|
|
@ -168,11 +244,24 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
var req RefreshRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
core.BadRequest(w, "invalid request body")
|
||||
return
|
||||
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(), req.RefreshToken, userID); err != nil {
|
||||
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
|
||||
|
|
@ -181,6 +270,7 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
h.clearRefreshCookie(w)
|
||||
core.NoContent(w)
|
||||
}
|
||||
|
||||
|
|
@ -228,7 +318,11 @@ func (h *Handler) RevokeSession(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if err := h.service.RevokeSession(r.Context(), userID, sessionID); err != nil {
|
||||
if err := h.service.RevokeSession(
|
||||
r.Context(),
|
||||
userID,
|
||||
sessionID,
|
||||
); err != nil {
|
||||
if errors.Is(err, core.ErrNotFound) {
|
||||
core.NotFound(w, "session")
|
||||
return
|
||||
|
|
@ -262,7 +356,12 @@ func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if err := h.service.ChangePassword(r.Context(), userID, req.CurrentPassword, req.NewPassword); err != nil {
|
||||
if err := h.service.ChangePassword(
|
||||
r.Context(),
|
||||
userID,
|
||||
req.CurrentPassword,
|
||||
req.NewPassword,
|
||||
); err != nil {
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
core.JSONError(
|
||||
w,
|
||||
|
|
@ -297,20 +396,6 @@ func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
|
|||
core.OK(w, user)
|
||||
}
|
||||
|
||||
func extractIPAddress(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
ips := strings.Split(xff, ",")
|
||||
return strings.TrimSpace(ips[len(ips)-1])
|
||||
}
|
||||
|
||||
if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
||||
return xri
|
||||
}
|
||||
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
return ip
|
||||
func (h *Handler) extractIPAddress(r *http.Request) string {
|
||||
return middleware.ClientIP(r, h.trustedProxyHops)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,14 @@ package auth
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
_ "crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
|
@ -25,6 +29,8 @@ import (
|
|||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware"
|
||||
)
|
||||
|
||||
const kidLength = 8
|
||||
|
||||
type JWTManager struct {
|
||||
privateKey jwk.Key
|
||||
publicKey jwk.Key
|
||||
|
|
@ -47,16 +53,22 @@ func NewJWTManager(cfg config.JWTConfig) (*JWTManager, error) {
|
|||
return nil, fmt.Errorf("set algorithm: %w", setErr)
|
||||
}
|
||||
|
||||
keyID := uuid.New().String()[:8]
|
||||
if setErr := privateKey.Set(jwk.KeyIDKey, keyID); setErr != nil {
|
||||
return nil, fmt.Errorf("set key id: %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)
|
||||
}
|
||||
|
|
@ -85,10 +97,6 @@ func GenerateKeyPair(privateKeyPath, publicKeyPath string) error {
|
|||
return fmt.Errorf("import private key: %w", err)
|
||||
}
|
||||
|
||||
keyID := uuid.New().String()[:8]
|
||||
if setErr := jwkPrivate.Set(jwk.KeyIDKey, keyID); setErr != nil {
|
||||
return fmt.Errorf("set key id: %w", setErr)
|
||||
}
|
||||
if setErr := jwkPrivate.Set(jwk.AlgorithmKey, jwa.ES256()); setErr != nil {
|
||||
return fmt.Errorf("set algorithm: %w", setErr)
|
||||
}
|
||||
|
|
@ -98,7 +106,11 @@ func GenerateKeyPair(privateKeyPath, publicKeyPath string) error {
|
|||
return fmt.Errorf("encode private key: %w", err)
|
||||
}
|
||||
|
||||
if writeErr := os.WriteFile(privateKeyPath, privatePEM, 0o600); writeErr != nil {
|
||||
if writeErr := os.WriteFile(
|
||||
privateKeyPath,
|
||||
privatePEM,
|
||||
0o600,
|
||||
); writeErr != nil {
|
||||
return fmt.Errorf("write private key: %w", writeErr)
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +125,11 @@ func GenerateKeyPair(privateKeyPath, publicKeyPath string) error {
|
|||
}
|
||||
|
||||
//nolint:gosec // G306: public key is intentionally world-readable
|
||||
if writeErr := os.WriteFile(publicKeyPath, publicPEM, 0o644); writeErr != nil {
|
||||
if writeErr := os.WriteFile(
|
||||
publicKeyPath,
|
||||
publicPEM,
|
||||
0o644,
|
||||
); writeErr != nil {
|
||||
return fmt.Errorf("write public key: %w", writeErr)
|
||||
}
|
||||
|
||||
|
|
@ -216,11 +232,16 @@ func (m *JWTManager) VerifyAccessToken(
|
|||
)
|
||||
}
|
||||
|
||||
jti, _ := token.JwtID()
|
||||
expiresAt, _ := token.Expiration()
|
||||
|
||||
return &middleware.AccessTokenClaims{
|
||||
UserID: subject,
|
||||
Role: roleStr,
|
||||
Tier: tierStr,
|
||||
TokenVersion: int(versionFloat),
|
||||
JTI: jti,
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -228,11 +249,22 @@ 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")
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
|
@ -40,14 +41,39 @@ type UserProvider interface {
|
|||
) (*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(
|
||||
|
|
@ -56,15 +82,46 @@ func NewService(
|
|||
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: repo,
|
||||
jwt: jwt,
|
||||
userProvider: userProvider,
|
||||
redis: redisClient,
|
||||
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,
|
||||
|
|
@ -97,6 +154,15 @@ func (s *Service) Login(
|
|||
_ = 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)
|
||||
}
|
||||
|
||||
|
|
@ -118,6 +184,14 @@ func (s *Service) Register(
|
|||
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)
|
||||
}
|
||||
|
||||
|
|
@ -165,13 +239,25 @@ func (s *Service) Refresh(
|
|||
|
||||
func (s *Service) Logout(
|
||||
ctx context.Context,
|
||||
refreshToken, userID string,
|
||||
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)
|
||||
|
|
@ -186,6 +272,16 @@ func (s *Service) Logout(
|
|||
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
|
||||
}
|
||||
|
||||
|
|
@ -372,6 +468,29 @@ func (s *Service) createAuthResponse(
|
|||
|
||||
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,
|
||||
|
|
@ -386,11 +505,6 @@ func (s *Service) createAuthResponse(
|
|||
return nil, fmt.Errorf("store refresh token: %w", err)
|
||||
}
|
||||
|
||||
if oldTokenID != nil {
|
||||
//nolint:errcheck // best-effort token chain tracking
|
||||
_ = s.repo.MarkAsUsed(ctx, *oldTokenID, newTokenID)
|
||||
}
|
||||
|
||||
return &AuthResponse{
|
||||
User: UserResponse{
|
||||
ID: user.ID,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -7,12 +7,16 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||
)
|
||||
|
||||
const defaultBufferSize = 512
|
||||
const (
|
||||
defaultBufferSize = 512
|
||||
defaultSubscriberSize = 256
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BufferSize int
|
||||
|
|
@ -27,6 +31,10 @@ type Bus struct {
|
|||
broadcaster Broadcaster
|
||||
logger *slog.Logger
|
||||
dropped atomic.Uint64
|
||||
|
||||
subsMu sync.RWMutex
|
||||
subscribers []chan events.Event
|
||||
subDropped atomic.Uint64
|
||||
}
|
||||
|
||||
func New(cfg Config) *Bus {
|
||||
|
|
@ -59,7 +67,26 @@ 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():
|
||||
|
|
@ -80,6 +107,29 @@ func (b *Bus) Run(ctx context.Context) error {
|
|||
}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,5 +95,5 @@ func TestBus_DropOnFullBuffer(t *testing.T) {
|
|||
for i := 0; i < 50; i++ {
|
||||
b.Emit(events.Event{Topic: events.TopicHeartbeat, Source: "stress"})
|
||||
}
|
||||
require.Greater(t, b.DroppedCount(), uint64(0))
|
||||
require.Positive(t, b.DroppedCount())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,12 +105,18 @@ func (c *Client) FetchOutages(ctx context.Context) (OutageResultBody, error) {
|
|||
return OutageResultBody{}, fmt.Errorf("fetch outages: %w", err)
|
||||
}
|
||||
if !env.Success {
|
||||
return OutageResultBody{}, fmt.Errorf("radar outages: success=false errors=%v", env.Errors)
|
||||
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) {
|
||||
func (c *Client) FetchHijacks(
|
||||
ctx context.Context,
|
||||
minConfidence int,
|
||||
) (HijackBody, error) {
|
||||
q := url.Values{
|
||||
"dateRange": []string{defaultDateRange},
|
||||
"minConfidence": []string{strconv.Itoa(minConfidence)},
|
||||
|
|
@ -120,7 +126,10 @@ func (c *Client) FetchHijacks(ctx context.Context, minConfidence int) (HijackBod
|
|||
return HijackBody{}, fmt.Errorf("fetch hijacks: %w", err)
|
||||
}
|
||||
if !env.Success {
|
||||
return HijackBody{}, fmt.Errorf("radar hijacks: success=false errors=%v", env.Errors)
|
||||
return HijackBody{}, fmt.Errorf(
|
||||
"radar hijacks: success=false errors=%v",
|
||||
env.Errors,
|
||||
)
|
||||
}
|
||||
return env.Result, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,21 +20,27 @@ import (
|
|||
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)
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -82,12 +88,20 @@ func TestClient_FetchHijacksDecodes(t *testing.T) {
|
|||
}
|
||||
|
||||
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"}]}`))
|
||||
}))
|
||||
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"})
|
||||
c := cfradar.NewClient(
|
||||
cfradar.ClientConfig{BaseURL: srv.URL, BearerToken: "x"},
|
||||
)
|
||||
_, err := c.FetchOutages(context.Background())
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ type Emitter interface {
|
|||
}
|
||||
|
||||
type StateRecorder interface {
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64) error
|
||||
RecordError(ctx context.Context, name, errMsg string) error
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64)
|
||||
RecordError(ctx context.Context, name, errMsg string)
|
||||
}
|
||||
|
||||
type Enricher interface {
|
||||
|
|
@ -111,7 +111,7 @@ func (c *Collector) tick(ctx context.Context) {
|
|||
|
||||
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())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
hadError = true
|
||||
} else {
|
||||
emitted += n
|
||||
|
|
@ -119,14 +119,14 @@ func (c *Collector) tick(ctx context.Context) {
|
|||
|
||||
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())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
hadError = true
|
||||
} else {
|
||||
emitted += n
|
||||
}
|
||||
|
||||
if !hadError {
|
||||
_ = c.cfg.State.RecordSuccess(ctx, Name, emitted)
|
||||
c.cfg.State.RecordSuccess(ctx, Name, emitted)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,11 +21,16 @@ type fakeFetcher struct {
|
|||
hijacks cfradar.HijackBody
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchOutages(context.Context) (cfradar.OutageResultBody, error) {
|
||||
func (f *fakeFetcher) FetchOutages(
|
||||
context.Context,
|
||||
) (cfradar.OutageResultBody, error) {
|
||||
return f.outages, nil
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchHijacks(context.Context, int) (cfradar.HijackBody, error) {
|
||||
func (f *fakeFetcher) FetchHijacks(
|
||||
context.Context,
|
||||
int,
|
||||
) (cfradar.HijackBody, error) {
|
||||
return f.hijacks, nil
|
||||
}
|
||||
|
||||
|
|
@ -51,7 +56,10 @@ func (r *fakeRepo) UpsertHijack(_ context.Context, _ cfradar.HijackRow) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) KnownOutageIDs(_ context.Context, ids []string) (map[string]bool, error) {
|
||||
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] {
|
||||
|
|
@ -61,7 +69,10 @@ func (r *fakeRepo) KnownOutageIDs(_ context.Context, ids []string) (map[string]b
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) KnownHijackIDs(_ context.Context, ids []int64) (map[int64]bool, error) {
|
||||
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] {
|
||||
|
|
@ -92,8 +103,8 @@ func (e *fakeEmitter) Events() []events.Event {
|
|||
|
||||
type noopState struct{}
|
||||
|
||||
func (noopState) RecordSuccess(context.Context, string, int64) error { return nil }
|
||||
func (noopState) RecordError(context.Context, string, string) error { return nil }
|
||||
func (noopState) RecordSuccess(context.Context, string, int64) {}
|
||||
func (noopState) RecordError(context.Context, string, string) {}
|
||||
|
||||
type fakeEnricher struct {
|
||||
calls int
|
||||
|
|
@ -102,7 +113,10 @@ type fakeEnricher struct {
|
|||
returnFn func(ip string) (cfradar.Enrichment, error)
|
||||
}
|
||||
|
||||
func (e *fakeEnricher) Lookup(_ context.Context, 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 {
|
||||
|
|
@ -114,10 +128,12 @@ func (e *fakeEnricher) Lookup(_ context.Context, ip string) (cfradar.Enrichment,
|
|||
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},
|
||||
}},
|
||||
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},
|
||||
|
|
@ -138,7 +154,10 @@ func TestCollector_OnlyEmitsNetNew(t *testing.T) {
|
|||
State: noopState{},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
|
|
@ -194,7 +213,10 @@ func TestCollector_EnrichesHijackPayloadWhenEnricherProvided(t *testing.T) {
|
|||
Enricher: enr,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
|
|
@ -219,7 +241,13 @@ 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"}},
|
||||
{
|
||||
ID: 777,
|
||||
DetectedAt: now,
|
||||
StartedAt: now,
|
||||
Confidence: 9,
|
||||
Prefixes: []string{"198.51.100.0/24"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
repo := &fakeRepo{knownHijacks: map[int64]bool{}}
|
||||
|
|
@ -234,7 +262,10 @@ func TestCollector_HijackEmitsRawWhenNoEnricher(t *testing.T) {
|
|||
State: noopState{},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
|
|
@ -250,9 +281,11 @@ func TestCollector_HijackEmitsRawWhenNoEnricher(t *testing.T) {
|
|||
func TestCollector_RepeatedTickIsIdempotent(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
ftch := &fakeFetcher{
|
||||
outages: cfradar.OutageResultBody{Annotations: []cfradar.OutageAnnotation{
|
||||
{ID: "out-x", StartDate: now},
|
||||
}},
|
||||
outages: cfradar.OutageResultBody{
|
||||
Annotations: []cfradar.OutageAnnotation{
|
||||
{ID: "out-x", StartDate: now},
|
||||
},
|
||||
},
|
||||
}
|
||||
emit := &fakeEmitter{}
|
||||
|
||||
|
|
@ -268,7 +301,10 @@ func TestCollector_RepeatedTickIsIdempotent(t *testing.T) {
|
|||
State: noopState{},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
25*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
known["out-x"] = true
|
||||
|
|
@ -282,11 +318,19 @@ func TestCollector_RepeatedTickIsIdempotent(t *testing.T) {
|
|||
Emitter: emit2,
|
||||
State: noopState{},
|
||||
})
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 25*time.Millisecond)
|
||||
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")
|
||||
require.NotEqual(
|
||||
t,
|
||||
events.TopicInternetOutage,
|
||||
ev.Topic,
|
||||
"should not re-emit known outage",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,9 @@ func (r *Repo) UpsertOutage(ctx context.Context, o OutageRow) error {
|
|||
if o.ASNs == nil {
|
||||
o.ASNs = pq.Int32Array{}
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
_, 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
|
||||
|
|
@ -59,7 +61,14 @@ func (r *Repo) UpsertOutage(ctx context.Context, o OutageRow) error {
|
|||
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),
|
||||
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)
|
||||
|
|
@ -74,7 +83,9 @@ func (r *Repo) UpsertHijack(ctx context.Context, h HijackRow) error {
|
|||
if h.Prefixes == nil {
|
||||
h.Prefixes = []string{}
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
_, 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)
|
||||
|
|
@ -83,8 +94,15 @@ func (r *Repo) UpsertHijack(ctx context.Context, h HijackRow) error {
|
|||
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),
|
||||
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)
|
||||
|
|
@ -92,13 +110,20 @@ func (r *Repo) UpsertHijack(ctx context.Context, h HijackRow) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *Repo) KnownOutageIDs(ctx context.Context, ids []string) (map[string]bool, error) {
|
||||
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 {
|
||||
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))
|
||||
|
|
@ -108,13 +133,20 @@ func (r *Repo) KnownOutageIDs(ctx context.Context, ids []string) (map[string]boo
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repo) KnownHijackIDs(ctx context.Context, ids []int64) (map[int64]bool, error) {
|
||||
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 {
|
||||
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))
|
||||
|
|
@ -123,3 +155,42 @@ func (r *Repo) KnownHijackIDs(ctx context.Context, ids []int64) (map[int64]bool,
|
|||
}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,19 +13,23 @@ 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,
|
||||
Volume: t.Volume24h,
|
||||
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
|
||||
|
|
@ -34,13 +38,13 @@ func (a *Aggregator) Push(t Tick) (*MinuteBar, MinuteBar) {
|
|||
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,
|
||||
Volume: t.Volume24h,
|
||||
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
|
||||
|
|
@ -53,9 +57,7 @@ func (a *Aggregator) Push(t Tick) (*MinuteBar, MinuteBar) {
|
|||
cur.Low = t.Price
|
||||
}
|
||||
cur.Close = t.Price
|
||||
if t.Volume24h.GreaterThan(cur.Volume) {
|
||||
cur.Volume = t.Volume24h
|
||||
}
|
||||
cur.Volume24hAtClose = t.Volume24h
|
||||
a.open[t.Symbol] = cur
|
||||
return nil, cur
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,18 +30,39 @@ func TestAggregator_FirstTickOpensBar(t *testing.T) {
|
|||
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")})
|
||||
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"),
|
||||
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.Volume.Equal(decimal.RequireFromString("120")))
|
||||
require.True(
|
||||
t,
|
||||
cur.Volume24hAtClose.Equal(decimal.RequireFromString("120")),
|
||||
)
|
||||
}
|
||||
|
||||
func TestAggregator_MinuteRolloverEmitsClosedBar(t *testing.T) {
|
||||
|
|
@ -49,12 +70,30 @@ func TestAggregator_MinuteRolloverEmitsClosedBar(t *testing.T) {
|
|||
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")})
|
||||
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"),
|
||||
Symbol: "BTC-USD",
|
||||
TS: min2.Add(5 * time.Second),
|
||||
Price: decimal.RequireFromString(
|
||||
"42150",
|
||||
),
|
||||
Volume24h: decimal.RequireFromString("110"),
|
||||
})
|
||||
|
||||
require.NotNil(t, closed)
|
||||
|
|
@ -69,8 +108,22 @@ 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")})
|
||||
_, 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)
|
||||
|
|
@ -81,8 +134,22 @@ func TestAggregator_BarsArePerProduct(t *testing.T) {
|
|||
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")})
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,11 @@ func (d *WSDialer) Dial(ctx context.Context) (*Conn, error) {
|
|||
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 {
|
||||
if err := conn.subscribe(
|
||||
subCtx,
|
||||
channelTicker,
|
||||
d.cfg.ProductIDs,
|
||||
); err != nil {
|
||||
_ = c.Close(websocket.StatusInternalError, "subscribe ticker")
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -116,8 +120,16 @@ type subscribeMsg struct {
|
|||
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}
|
||||
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)
|
||||
|
|
@ -151,7 +163,10 @@ func parseCoinbaseTime(s string) time.Time {
|
|||
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 {
|
||||
if t, err := time.Parse(
|
||||
"2006-01-02 15:04:05.999999999 -0700 MST",
|
||||
s,
|
||||
); err == nil {
|
||||
return t
|
||||
}
|
||||
return time.Time{}
|
||||
|
|
|
|||
|
|
@ -171,7 +171,9 @@ func TestClient_DecodeHeartbeatExposesCurrentTime(t *testing.T) {
|
|||
loadFixture(t, "heartbeats.json"),
|
||||
)
|
||||
|
||||
d := coinbase.NewWSDialer(coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}})
|
||||
d := coinbase.NewWSDialer(
|
||||
coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}},
|
||||
)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ type Emitter interface {
|
|||
}
|
||||
|
||||
type StateRecorder interface {
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64) error
|
||||
RecordError(ctx context.Context, name, errMsg string) error
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64)
|
||||
RecordError(ctx context.Context, name, errMsg string)
|
||||
}
|
||||
|
||||
type CollectorConfig struct {
|
||||
|
|
@ -50,12 +50,28 @@ type CollectorConfig struct {
|
|||
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
|
||||
cfg CollectorConfig
|
||||
dialer Dialer
|
||||
logger *slog.Logger
|
||||
mu sync.Mutex
|
||||
lastEmit map[string]time.Time
|
||||
priceHistory map[string][]priceObs
|
||||
}
|
||||
|
||||
func NewCollector(cfg CollectorConfig) *Collector {
|
||||
|
|
@ -78,10 +94,11 @@ func NewCollector(cfg CollectorConfig) *Collector {
|
|||
}
|
||||
|
||||
return &Collector{
|
||||
cfg: cfg,
|
||||
dialer: d,
|
||||
logger: cfg.Logger,
|
||||
lastEmit: make(map[string]time.Time),
|
||||
cfg: cfg,
|
||||
dialer: d,
|
||||
logger: cfg.Logger,
|
||||
lastEmit: make(map[string]time.Time),
|
||||
priceHistory: make(map[string][]priceObs),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -89,8 +106,9 @@ 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())
|
||||
if err != nil && !errors.Is(err, context.Canceled) &&
|
||||
!errors.Is(err, context.DeadlineExceeded) {
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
@ -100,39 +118,61 @@ func (c *Collector) handleConn(ctx context.Context, conn *Conn) error {
|
|||
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,
|
||||
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()
|
||||
}
|
||||
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)
|
||||
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++
|
||||
}
|
||||
}
|
||||
if c.shouldEmit(tick.Symbol) {
|
||||
c.emitTick(tick)
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
c.logger.Info("coinbase loop exit",
|
||||
"err", loopErrString(loopErr),
|
||||
|
|
@ -140,7 +180,7 @@ func (c *Collector) handleConn(ctx context.Context, conn *Conn) error {
|
|||
"agg_open", aggLen(agg),
|
||||
)
|
||||
if loopErr == nil || errors.Is(loopErr, ErrSequenceGap) {
|
||||
_ = c.cfg.State.RecordSuccess(ctx, Name, count)
|
||||
c.cfg.State.RecordSuccess(ctx, Name, count)
|
||||
}
|
||||
return loopErr
|
||||
}
|
||||
|
|
@ -172,19 +212,25 @@ func (c *Collector) shouldEmit(symbol string) bool {
|
|||
}
|
||||
|
||||
type tickPayload struct {
|
||||
Symbol string `json:"symbol"`
|
||||
TS time.Time `json:"ts"`
|
||||
Price decimal.Decimal `json:"price"`
|
||||
Volume24h decimal.Decimal `json:"volume_24h"`
|
||||
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) {
|
||||
body, _ := json.Marshal(tickPayload{
|
||||
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,
|
||||
|
|
@ -192,3 +238,50 @@ func (c *Collector) emitTick(t Tick) {
|
|||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,11 +37,17 @@ func (r *fakeRepo) UpsertMinute(_ context.Context, b coinbase.MinuteBar) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) LatestTick(_ context.Context, _ string) (coinbase.Tick, error) {
|
||||
func (r *fakeRepo) LatestTick(
|
||||
_ context.Context,
|
||||
_ string,
|
||||
) (coinbase.Tick, error) {
|
||||
return coinbase.Tick{}, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) History1h(_ context.Context, _ string) ([]coinbase.MinuteBar, error) {
|
||||
func (r *fakeRepo) History1h(
|
||||
_ context.Context,
|
||||
_ string,
|
||||
) ([]coinbase.MinuteBar, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
|
@ -74,18 +80,16 @@ type fakeState struct {
|
|||
failures int
|
||||
}
|
||||
|
||||
func (s *fakeState) RecordSuccess(_ context.Context, _ string, _ int64) error {
|
||||
func (s *fakeState) RecordSuccess(_ context.Context, _ string, _ int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.successes++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeState) RecordError(_ context.Context, _, _ string) error {
|
||||
func (s *fakeState) RecordError(_ context.Context, _, _ string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.failures++
|
||||
return nil
|
||||
}
|
||||
|
||||
func tickerFrame(seq int64, productID, price string, ts time.Time) []byte {
|
||||
|
|
@ -116,7 +120,10 @@ func TestCollector_RunPersistsAndEmits(t *testing.T) {
|
|||
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))
|
||||
frames = append(
|
||||
frames,
|
||||
tickerFrame(int64(i+1), "BTC-USD", fmt.Sprintf("4200%d.00", i), ts),
|
||||
)
|
||||
}
|
||||
fs := newFakeServer(t, frames...)
|
||||
|
||||
|
|
@ -137,12 +144,20 @@ func TestCollector_RunPersistsAndEmits(t *testing.T) {
|
|||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 800*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")
|
||||
require.GreaterOrEqual(
|
||||
t,
|
||||
emt.Count(),
|
||||
1,
|
||||
"should emit at least one event after throttle",
|
||||
)
|
||||
}
|
||||
|
||||
func TestCollector_GapTriggersReconnect(t *testing.T) {
|
||||
|
|
@ -171,7 +186,10 @@ func TestCollector_GapTriggersReconnect(t *testing.T) {
|
|||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
600*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,12 @@ 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 {
|
||||
func ReadLoop(
|
||||
ctx context.Context,
|
||||
conn *Conn,
|
||||
seq *Sequencer,
|
||||
handler FrameHandler,
|
||||
) error {
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -16,13 +16,20 @@ import (
|
|||
)
|
||||
|
||||
func TestReadLoop_DeliversTickerFrames(t *testing.T) {
|
||||
fs := newFakeServer(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"}]}]}`),
|
||||
[]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"}})
|
||||
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)
|
||||
|
|
@ -36,29 +43,48 @@ func TestReadLoop_DeliversTickerFrames(t *testing.T) {
|
|||
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)
|
||||
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,
|
||||
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"}]}]}`),
|
||||
[]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)
|
||||
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)
|
||||
|
|
@ -66,24 +92,43 @@ func TestReadLoop_GapIsLoggedButLoopContinues(t *testing.T) {
|
|||
|
||||
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")
|
||||
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,
|
||||
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"}]}]}`),
|
||||
[]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"}})
|
||||
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)
|
||||
|
|
@ -96,13 +141,22 @@ func TestReadLoop_SnapshotResetsSequencer(t *testing.T) {
|
|||
|
||||
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)
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -27,7 +27,12 @@ func PermanentReconnectError(err error) error {
|
|||
return backoff.Permanent(err)
|
||||
}
|
||||
|
||||
func Reconnect(ctx context.Context, d Dialer, cfg ReconnectConfig, handler ConnHandler) error {
|
||||
func Reconnect(
|
||||
ctx context.Context,
|
||||
d Dialer,
|
||||
cfg ReconnectConfig,
|
||||
handler ConnHandler,
|
||||
) error {
|
||||
if cfg.InitialInterval <= 0 {
|
||||
cfg.InitialInterval = defaultReconnectInitial
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,10 @@ func (d *stubDialer) Dial(_ context.Context) (*coinbase.Conn, error) {
|
|||
func TestReconnect_StopsOnContextCancel(t *testing.T) {
|
||||
d := &stubDialer{failsBeforeOK: 0}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
80*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
calls := atomic.Int32{}
|
||||
|
|
@ -42,13 +45,25 @@ func TestReconnect_StopsOnContextCancel(t *testing.T) {
|
|||
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")
|
||||
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)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
500*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
cbInvocations := atomic.Int32{}
|
||||
|
|
@ -59,8 +74,17 @@ func TestReconnect_TransientDialFailureBacksOffThenSucceeds(t *testing.T) {
|
|||
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.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))
|
||||
}
|
||||
|
||||
|
|
@ -68,7 +92,10 @@ func TestReconnect_PermanentErrorFromCallbackPropagates(t *testing.T) {
|
|||
d := &stubDialer{failsBeforeOK: 0}
|
||||
sentinel := errors.New("hard stop")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
200*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
err := coinbase.Reconnect(ctx, d, coinbase.ReconnectConfig{
|
||||
|
|
|
|||
|
|
@ -23,14 +23,18 @@ type Tick struct {
|
|||
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"`
|
||||
Volume decimal.Decimal `db:"volume"`
|
||||
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 {
|
||||
|
|
@ -54,15 +58,15 @@ func (r *Repo) InsertTick(ctx context.Context, t Tick) error {
|
|||
|
||||
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)
|
||||
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 = EXCLUDED.volume`,
|
||||
b.Symbol, b.Minute, b.Open, b.High, b.Low, b.Close, b.Volume,
|
||||
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)
|
||||
|
|
@ -83,10 +87,13 @@ func (r *Repo) LatestTick(ctx context.Context, symbol string) (Tick, error) {
|
|||
return t, nil
|
||||
}
|
||||
|
||||
func (r *Repo) History1h(ctx context.Context, symbol string) ([]MinuteBar, error) {
|
||||
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
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -49,13 +49,13 @@ func setupDB(t *testing.T) *sqlx.DB {
|
|||
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 numeric(20,8),
|
||||
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)
|
||||
|
|
@ -91,13 +91,13 @@ func TestRepo_UpsertMinuteUpdatesOHLC(t *testing.T) {
|
|||
|
||||
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"),
|
||||
Volume: decimal.RequireFromString("88.12300000"),
|
||||
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))
|
||||
|
||||
|
|
@ -120,13 +120,13 @@ func TestRepo_History1hReturnsLast60MinutesOldestFirst(t *testing.T) {
|
|||
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)),
|
||||
Volume: decimal.NewFromInt(int64(i)),
|
||||
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))
|
||||
}
|
||||
|
|
@ -134,7 +134,11 @@ func TestRepo_History1hReturnsLast60MinutesOldestFirst(t *testing.T) {
|
|||
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")
|
||||
require.True(
|
||||
t,
|
||||
hist[0].Minute.Before(hist[len(hist)-1].Minute),
|
||||
"history must be oldest → newest",
|
||||
)
|
||||
}
|
||||
|
||||
func TestRepo_LatestTickMissingReturnsErrNoRows(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -23,12 +23,19 @@ type NVDFetcher interface {
|
|||
}
|
||||
|
||||
type EPSSFetcher interface {
|
||||
LookupBatch(ctx context.Context, cveIDs []string) (map[string]EPSSScore, error)
|
||||
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
|
||||
UpdateEPSS(
|
||||
ctx context.Context,
|
||||
cveID string,
|
||||
score, percentile float64,
|
||||
) error
|
||||
}
|
||||
|
||||
type Emitter interface {
|
||||
|
|
@ -36,8 +43,8 @@ type Emitter interface {
|
|||
}
|
||||
|
||||
type StateRecorder interface {
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64) error
|
||||
RecordError(ctx context.Context, name, errMsg string) error
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64)
|
||||
RecordError(ctx context.Context, name, errMsg string)
|
||||
}
|
||||
|
||||
type CollectorConfig struct {
|
||||
|
|
@ -94,7 +101,7 @@ func (c *Collector) tick(ctx context.Context) {
|
|||
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())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -127,7 +134,12 @@ func (c *Collector) tick(ctx context.Context) {
|
|||
continue
|
||||
}
|
||||
if s, ok := scores[row.CveID]; ok {
|
||||
if err := c.cfg.Repo.UpdateEPSS(ctx, row.CveID, s.Score, s.Percentile); err != nil {
|
||||
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
|
||||
|
|
@ -142,5 +154,5 @@ func (c *Collector) tick(ctx context.Context) {
|
|||
})
|
||||
emitted++
|
||||
}
|
||||
_ = c.cfg.State.RecordSuccess(ctx, Name, emitted)
|
||||
c.cfg.State.RecordSuccess(ctx, Name, emitted)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,11 @@ type stubNVD struct {
|
|||
err error
|
||||
}
|
||||
|
||||
func (s *stubNVD) Fetch(context.Context, time.Time, time.Time) (cve.NVDResponse, error) {
|
||||
func (s *stubNVD) Fetch(
|
||||
context.Context,
|
||||
time.Time,
|
||||
time.Time,
|
||||
) (cve.NVDResponse, error) {
|
||||
return s.resp, s.err
|
||||
}
|
||||
|
||||
|
|
@ -30,7 +34,10 @@ type stubEPSS struct {
|
|||
err error
|
||||
}
|
||||
|
||||
func (s *stubEPSS) LookupBatch(context.Context, []string) (map[string]cve.EPSSScore, error) {
|
||||
func (s *stubEPSS) LookupBatch(
|
||||
context.Context,
|
||||
[]string,
|
||||
) (map[string]cve.EPSSScore, error) {
|
||||
return s.scores, s.err
|
||||
}
|
||||
|
||||
|
|
@ -54,7 +61,11 @@ func (r *stubCVERepo) Upsert(_ context.Context, row cve.Row) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *stubCVERepo) UpdateEPSS(_ context.Context, id string, score, pct float64) error {
|
||||
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}
|
||||
|
|
@ -94,22 +105,29 @@ func (e *stubCVEEmitter) Events() []events.Event {
|
|||
|
||||
type stubCVEState struct{}
|
||||
|
||||
func (stubCVEState) RecordSuccess(context.Context, string, int64) error { return nil }
|
||||
func (stubCVEState) RecordError(context.Context, string, string) error { return nil }
|
||||
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)},
|
||||
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"}},
|
||||
{
|
||||
CVSSData: cve.NVDCVSSData{
|
||||
BaseScore: 9.8,
|
||||
BaseSeverity: "CRITICAL",
|
||||
},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
{CVE: cve.NVDCVE{
|
||||
ID: "CVE-2026-0002", Published: cve.NVDTime{Time: now.Add(-time.Hour)},
|
||||
ID: "CVE-2026-0002",
|
||||
Published: cve.NVDTime{Time: now.Add(-time.Hour)},
|
||||
LastModified: cve.NVDTime{Time: now},
|
||||
}},
|
||||
},
|
||||
|
|
@ -130,7 +148,10 @@ func TestCVECollector_FetchesEnrichesAndEmits(t *testing.T) {
|
|||
State: stubCVEState{},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
|
|
@ -162,11 +183,14 @@ func TestCVECollector_NVDFetchErrorRecordsState(t *testing.T) {
|
|||
State: stubCVEState{},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
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.Zero(t, len(emt.Events()))
|
||||
require.Empty(t, emt.Events())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,10 @@ type EPSSResponse struct {
|
|||
Data []EPSSScore `json:"data"`
|
||||
}
|
||||
|
||||
func (c *EPSSClient) LookupBatch(ctx context.Context, cveIDs []string) (map[string]EPSSScore, error) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
|
|
@ -18,17 +17,22 @@ import (
|
|||
)
|
||||
|
||||
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.True(t, strings.Contains(ids, "CVE-2024-3094"))
|
||||
body, err := os.ReadFile("testdata/epss_batch.json")
|
||||
require.NoError(t, err)
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
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"})
|
||||
scores, err := c.LookupBatch(
|
||||
context.Background(),
|
||||
[]string{"CVE-2024-3094", "CVE-2024-21413"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, scores)
|
||||
|
||||
|
|
@ -39,10 +43,12 @@ func TestEPSSClient_BatchLookupDecodesScores(t *testing.T) {
|
|||
|
||||
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":[]}`))
|
||||
}))
|
||||
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})
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
|
|
@ -23,6 +24,8 @@ const (
|
|||
defaultNVDBreakerWin = 120 * time.Second
|
||||
nvdTimeFormat = "2006-01-02T15:04:05.000"
|
||||
nvdAPIKeyHeader = "apiKey"
|
||||
nvdResultsPerPage = 2000
|
||||
nvdMaxPages = 50
|
||||
)
|
||||
|
||||
type NVDClientConfig struct {
|
||||
|
|
@ -110,16 +113,45 @@ type NVDCVSSData struct {
|
|||
BaseSeverity string `json:"baseSeverity"`
|
||||
}
|
||||
|
||||
func (c *NVDClient) Fetch(ctx context.Context, start, end time.Time) (NVDResponse, error) {
|
||||
q := url.Values{
|
||||
"lastModStartDate": []string{start.UTC().Format(nvdTimeFormat)},
|
||||
"lastModEndDate": []string{end.UTC().Format(nvdTimeFormat)},
|
||||
// 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
|
||||
}
|
||||
}
|
||||
var resp NVDResponse
|
||||
if err := c.hx.GetJSON(ctx, pathNVDCVE2, q, &resp); err != nil {
|
||||
return NVDResponse{}, fmt.Errorf("nvd fetch: %w", err)
|
||||
}
|
||||
return resp, nil
|
||||
combined.ResultsPerPage = len(combined.Vulnerabilities)
|
||||
combined.StartIndex = 0
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
func (v NVDVulnRoot) PrimarySeverity() (float64, string) {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,13 @@ package cve_test
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -18,18 +22,22 @@ import (
|
|||
|
||||
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)
|
||||
}))
|
||||
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"})
|
||||
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)
|
||||
|
|
@ -47,7 +55,12 @@ func TestNVDClient_PrimarySeverityFromV31(t *testing.T) {
|
|||
ID: "CVE-2026-X",
|
||||
Metrics: cve.NVDMetrics{
|
||||
CVSSv31: []cve.NVDMetricEntry{
|
||||
{CVSSData: cve.NVDCVSSData{BaseScore: 9.8, BaseSeverity: "CRITICAL"}},
|
||||
{
|
||||
CVSSData: cve.NVDCVSSData{
|
||||
BaseScore: 9.8,
|
||||
BaseSeverity: "CRITICAL",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
|
@ -61,7 +74,12 @@ func TestNVDClient_PrimarySeverityFallsBackToV30(t *testing.T) {
|
|||
ID: "CVE-2018-X",
|
||||
Metrics: cve.NVDMetrics{
|
||||
CVSSv30: []cve.NVDMetricEntry{
|
||||
{CVSSData: cve.NVDCVSSData{BaseScore: 7.5, BaseSeverity: "HIGH"}},
|
||||
{
|
||||
CVSSData: cve.NVDCVSSData{
|
||||
BaseScore: 7.5,
|
||||
BaseSeverity: "HIGH",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
|
@ -76,3 +94,57 @@ func TestNVDClient_PrimarySeverityZeroWhenMissing(t *testing.T) {
|
|||
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",
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ type Repo struct {
|
|||
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, `
|
||||
_, 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
|
||||
|
|
@ -39,7 +41,12 @@ func (r *Repo) Upsert(ctx context.Context, row Row) error {
|
|||
severity = EXCLUDED.severity,
|
||||
cvss_score = EXCLUDED.cvss_score,
|
||||
payload = EXCLUDED.payload`,
|
||||
row.CveID, row.Published, row.LastModified, row.Severity, row.CVSS, []byte(row.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)
|
||||
|
|
@ -47,10 +54,17 @@ func (r *Repo) Upsert(ctx context.Context, row Row) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *Repo) UpdateEPSS(ctx context.Context, cveID string, score, percentile float64) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
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,
|
||||
cveID,
|
||||
score,
|
||||
percentile,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update epss %s: %w", cveID, err)
|
||||
|
|
@ -70,7 +84,10 @@ func (r *Repo) Get(ctx context.Context, cveID string) (Row, error) {
|
|||
return row, nil
|
||||
}
|
||||
|
||||
func (r *Repo) RecentByLastModified(ctx context.Context, limit int) ([]Row, error) {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -68,7 +68,12 @@ func (c *Client) FetchAll(ctx context.Context) ([]SnapshotPayload, error) {
|
|||
Kind string
|
||||
Path string
|
||||
}{
|
||||
{KindTopPorts, "/api/topports/records/" + strconv.Itoa(c.topN) + "/" + yesterday + "?json"},
|
||||
{
|
||||
KindTopPorts,
|
||||
"/api/topports/records/" + strconv.Itoa(
|
||||
c.topN,
|
||||
) + "/" + yesterday + "?json",
|
||||
},
|
||||
{KindTopIPs, "/api/topips/?json"},
|
||||
{KindDailySummary, "/api/dailysummary/" + yesterday + "/?json"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,26 +21,37 @@ import (
|
|||
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)
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -70,17 +81,26 @@ func TestClient_FetchAllReturnsThreeKinds(t *testing.T) {
|
|||
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(`[]`))
|
||||
})
|
||||
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()
|
||||
|
||||
|
|
|
|||
|
|
@ -21,8 +21,23 @@ 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
|
||||
PutSnapshot(
|
||||
ctx context.Context,
|
||||
ts time.Time,
|
||||
kind string,
|
||||
payload json.RawMessage,
|
||||
) error
|
||||
}
|
||||
|
||||
type Emitter interface {
|
||||
|
|
@ -30,8 +45,8 @@ type Emitter interface {
|
|||
}
|
||||
|
||||
type StateRecorder interface {
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64) error
|
||||
RecordError(ctx context.Context, name, errMsg string) error
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64)
|
||||
RecordError(ctx context.Context, name, errMsg string)
|
||||
}
|
||||
|
||||
type CollectorConfig struct {
|
||||
|
|
@ -40,6 +55,7 @@ type CollectorConfig struct {
|
|||
Persister Persister
|
||||
Emitter Emitter
|
||||
State StateRecorder
|
||||
Enricher Enricher
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
|
|
@ -80,7 +96,7 @@ 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())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -88,22 +104,31 @@ func (c *Collector) tick(ctx context.Context) {
|
|||
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())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
merged := map[string]json.RawMessage{"ts": tsRaw}
|
||||
for _, s := range snaps {
|
||||
if perr := c.cfg.Persister.PutSnapshot(ctx, now, s.Kind, s.Payload); perr != nil {
|
||||
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] = s.Payload
|
||||
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())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -113,5 +138,38 @@ func (c *Collector) tick(ctx context.Context) {
|
|||
Source: Name,
|
||||
Payload: json.RawMessage(body),
|
||||
})
|
||||
_ = c.cfg.State.RecordSuccess(ctx, Name, 1)
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ type fakeFetcher struct {
|
|||
err error
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchAll(_ context.Context) ([]dshield.SnapshotPayload, error) {
|
||||
func (f *fakeFetcher) FetchAll(
|
||||
_ context.Context,
|
||||
) ([]dshield.SnapshotPayload, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls++
|
||||
|
|
@ -32,8 +34,14 @@ func (f *fakeFetcher) FetchAll(_ context.Context) ([]dshield.SnapshotPayload, er
|
|||
}
|
||||
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}]`)},
|
||||
{
|
||||
Kind: dshield.KindTopIPs,
|
||||
Payload: json.RawMessage(`[{"ip":"1.2.3.4"}]`),
|
||||
},
|
||||
{
|
||||
Kind: dshield.KindDailySummary,
|
||||
Payload: json.RawMessage(`[{"records":42}]`),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +61,12 @@ type fakePersister struct {
|
|||
rows []persistedRow
|
||||
}
|
||||
|
||||
func (p *fakePersister) PutSnapshot(_ context.Context, _ time.Time, kind string, body json.RawMessage) error {
|
||||
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})
|
||||
|
|
@ -95,20 +108,18 @@ type recordingState struct {
|
|||
lastErr string
|
||||
}
|
||||
|
||||
func (s *recordingState) RecordSuccess(_ context.Context, _ string, n int64) error {
|
||||
func (s *recordingState) RecordSuccess(_ context.Context, _ string, n int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.successes++
|
||||
s.successN += n
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingState) RecordError(_ context.Context, _, msg string) error {
|
||||
func (s *recordingState) RecordError(_ context.Context, _, msg string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.failures++
|
||||
s.lastErr = msg
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCollector_TickPersistsAllAndEmitsScanFirehose(t *testing.T) {
|
||||
|
|
@ -125,7 +136,10 @@ func TestCollector_TickPersistsAllAndEmitsScanFirehose(t *testing.T) {
|
|||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 70*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
70*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
|
|
@ -148,7 +162,7 @@ func TestCollector_TickPersistsAllAndEmitsScanFirehose(t *testing.T) {
|
|||
require.Contains(t, merged, "ts")
|
||||
}
|
||||
|
||||
require.Greater(t, st.successes, 0)
|
||||
require.Positive(t, st.successes)
|
||||
require.Equal(t, 0, st.failures)
|
||||
}
|
||||
|
||||
|
|
@ -166,12 +180,15 @@ func TestCollector_FetchErrorRecordsState(t *testing.T) {
|
|||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
require.Equal(t, 0, len(prst.Rows()))
|
||||
require.Equal(t, 0, len(emt.Events()))
|
||||
require.Greater(t, st.failures, 0)
|
||||
require.Empty(t, prst.Rows())
|
||||
require.Empty(t, emt.Events())
|
||||
require.Positive(t, st.failures)
|
||||
require.Contains(t, st.lastErr, "upstream 503")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,12 @@ type Repo struct {
|
|||
|
||||
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 {
|
||||
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)
|
||||
|
|
@ -37,7 +42,11 @@ func (r *Repo) PutSnapshot(ctx context.Context, ts time.Time, kind string, paylo
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *Repo) LatestByKind(ctx context.Context, kind string, limit int) ([]Snapshot, error) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -95,9 +95,18 @@ func TestRepo_LatestByKindIsOrderedDesc(t *testing.T) {
|
|||
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}]`)))
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -22,7 +22,13 @@ 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)})
|
||||
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)
|
||||
|
|
@ -32,7 +38,13 @@ 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)})
|
||||
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)
|
||||
|
|
@ -50,7 +62,13 @@ 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})
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
|
|
@ -74,7 +74,10 @@ type rawTimeline struct {
|
|||
} `json:"timeline"`
|
||||
}
|
||||
|
||||
func (c *Client) FetchTheme(ctx context.Context, theme string) ([]ThemeBucket, error) {
|
||||
func (c *Client) FetchTheme(
|
||||
ctx context.Context,
|
||||
theme string,
|
||||
) ([]ThemeBucket, error) {
|
||||
q := url.Values{}
|
||||
q.Set("query", "theme:"+theme)
|
||||
q.Set("mode", "timelinevol")
|
||||
|
|
@ -88,20 +91,13 @@ func (c *Client) FetchTheme(ctx context.Context, theme string) ([]ThemeBucket, e
|
|||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body := strings.Builder{}
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
body.Write(buf[:n])
|
||||
}
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
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([]byte(body.String()), &raw); err != nil {
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, fmt.Errorf("decode gdelt theme %s: %w", theme, err)
|
||||
}
|
||||
if len(raw.Timeline) == 0 {
|
||||
|
|
|
|||
|
|
@ -20,13 +20,19 @@ 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)
|
||||
}))
|
||||
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})
|
||||
|
|
@ -38,7 +44,7 @@ func TestClient_FetchThemeDecodesBuckets(t *testing.T) {
|
|||
require.GreaterOrEqual(t, len(buckets), 1)
|
||||
for _, b := range buckets {
|
||||
require.False(t, b.Time.IsZero())
|
||||
require.Greater(t, b.Count, 0)
|
||||
require.Positive(t, b.Count)
|
||||
require.Equal(t, "NATURAL_DISASTER", b.Theme)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ type Fetcher interface {
|
|||
}
|
||||
|
||||
type Repository interface {
|
||||
Insert(ctx context.Context, row SpikeRow) error
|
||||
Insert(ctx context.Context, row SpikeRow) (inserted bool, err error)
|
||||
}
|
||||
|
||||
type Emitter interface {
|
||||
|
|
@ -36,8 +36,8 @@ type Emitter interface {
|
|||
}
|
||||
|
||||
type StateRecorder interface {
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64) error
|
||||
RecordError(ctx context.Context, name, errMsg string) error
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64)
|
||||
RecordError(ctx context.Context, name, errMsg string)
|
||||
}
|
||||
|
||||
type CollectorConfig struct {
|
||||
|
|
@ -55,7 +55,6 @@ type Collector struct {
|
|||
cfg CollectorConfig
|
||||
logger *slog.Logger
|
||||
baselines map[string]*ThemeState
|
||||
emitted map[string]bool
|
||||
}
|
||||
|
||||
func NewCollector(cfg CollectorConfig) *Collector {
|
||||
|
|
@ -75,7 +74,6 @@ func NewCollector(cfg CollectorConfig) *Collector {
|
|||
cfg: cfg,
|
||||
logger: cfg.Logger,
|
||||
baselines: make(map[string]*ThemeState, len(cfg.Themes)),
|
||||
emitted: make(map[string]bool),
|
||||
}
|
||||
for _, t := range cfg.Themes {
|
||||
c.baselines[t] = NewThemeState(cfg.BaselineCap)
|
||||
|
|
@ -107,7 +105,7 @@ func (c *Collector) tick(ctx context.Context) {
|
|||
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())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
hadError = true
|
||||
continue
|
||||
}
|
||||
|
|
@ -120,26 +118,41 @@ func (c *Collector) tick(ctx context.Context) {
|
|||
continue
|
||||
}
|
||||
id := spikeID(theme, b.Time)
|
||||
if c.emitted[id] {
|
||||
continue
|
||||
}
|
||||
c.emitted[id] = true
|
||||
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
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,
|
||||
Headline: fmt.Sprintf(
|
||||
"Theme spike: %s (z=%.2f, count=%d)",
|
||||
theme,
|
||||
z,
|
||||
b.Count,
|
||||
),
|
||||
Payload: payload,
|
||||
}
|
||||
if err := c.cfg.Repo.Insert(ctx, row); err != nil {
|
||||
c.logger.Warn("gdelt insert", "id", id, "err", err)
|
||||
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{
|
||||
|
|
@ -153,7 +166,7 @@ func (c *Collector) tick(ctx context.Context) {
|
|||
}
|
||||
|
||||
if !hadError {
|
||||
_ = c.cfg.State.RecordSuccess(ctx, Name, emitted)
|
||||
c.cfg.State.RecordSuccess(ctx, Name, emitted)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ type fakeFetcher struct {
|
|||
err error
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchTheme(_ context.Context, theme string) ([]gdelt.ThemeBucket, error) {
|
||||
func (f *fakeFetcher) FetchTheme(
|
||||
_ context.Context,
|
||||
theme string,
|
||||
) ([]gdelt.ThemeBucket, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls++
|
||||
|
|
@ -36,13 +39,21 @@ func (f *fakeFetcher) FetchTheme(_ context.Context, theme string) ([]gdelt.Theme
|
|||
type fakeRepo struct {
|
||||
mu sync.Mutex
|
||||
inserts []gdelt.SpikeRow
|
||||
seen map[string]struct{}
|
||||
}
|
||||
|
||||
func (r *fakeRepo) Insert(_ context.Context, row gdelt.SpikeRow) error {
|
||||
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 nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) Inserts() int {
|
||||
|
|
@ -74,18 +85,16 @@ type recordingState struct {
|
|||
failures int
|
||||
}
|
||||
|
||||
func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) error {
|
||||
func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.successes++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingState) RecordError(_ context.Context, _, _ string) error {
|
||||
func (s *recordingState) RecordError(_ context.Context, _, _ string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.failures++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCollector_NoBaselineNoSpike(t *testing.T) {
|
||||
|
|
@ -108,13 +117,21 @@ func TestCollector_NoBaselineNoSpike(t *testing.T) {
|
|||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
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,
|
||||
repo.Inserts(),
|
||||
"first observation has no baseline → cannot detect spike",
|
||||
)
|
||||
require.Equal(t, 0, emt.Count())
|
||||
require.Greater(t, st.successes, 0)
|
||||
require.Positive(t, st.successes)
|
||||
}
|
||||
|
||||
func TestCollector_StableBaselinePlusSpikeEmitsOnce(t *testing.T) {
|
||||
|
|
@ -127,7 +144,11 @@ func TestCollector_StableBaselinePlusSpikeEmitsOnce(t *testing.T) {
|
|||
Count: 100 + i,
|
||||
})
|
||||
}
|
||||
spikeBucket := gdelt.ThemeBucket{Theme: "X", Time: base.Add(15 * 15 * time.Minute), Count: 5000}
|
||||
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}}
|
||||
|
|
@ -145,7 +166,10 @@ func TestCollector_StableBaselinePlusSpikeEmitsOnce(t *testing.T) {
|
|||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
80*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
|
|
@ -172,10 +196,13 @@ func TestCollector_FetchErrorsRecordsState(t *testing.T) {
|
|||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
60*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
require.Equal(t, 0, repo.Inserts())
|
||||
require.Greater(t, st.failures, 0)
|
||||
require.Positive(t, st.failures)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ package gdelt
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
|
|
@ -30,15 +32,27 @@ type Repo struct {
|
|||
|
||||
func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} }
|
||||
|
||||
func (r *Repo) Insert(ctx context.Context, row SpikeRow) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
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`,
|
||||
row.ID, sourceGDELTSpike, row.OccurredAt, row.Headline, []byte(row.Payload),
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
RETURNING id`,
|
||||
row.ID,
|
||||
sourceGDELTSpike,
|
||||
row.OccurredAt,
|
||||
row.Headline,
|
||||
[]byte(row.Payload),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert gdelt spike %s: %w", row.ID, err)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return nil
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("insert gdelt spike %s: %w", row.ID, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,10 @@ func TestCollector_EmitsAtInterval(t *testing.T) {
|
|||
Emitter: emitter,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
200*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
err := c.Run(ctx)
|
||||
|
|
@ -64,9 +67,17 @@ func TestCollector_EmitsImmediatelyOnStart(t *testing.T) {
|
|||
Emitter: emitter,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
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")
|
||||
require.Equal(
|
||||
t,
|
||||
1,
|
||||
emitter.Count(),
|
||||
"should emit one tick immediately on start",
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ package iss
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -97,21 +98,17 @@ func (c *Client) FetchTLE(ctx context.Context) (TLE, error) {
|
|||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body := strings.Builder{}
|
||||
buf := make([]byte, 1024)
|
||||
for {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
body.Write(buf[:n])
|
||||
}
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return TLE{}, fmt.Errorf("read iss tle body: %w", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(body.String()), "\n")
|
||||
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{}, fmt.Errorf(
|
||||
"iss tle: expected 3 lines, got %d",
|
||||
len(lines),
|
||||
)
|
||||
}
|
||||
return TLE{
|
||||
Line1: strings.TrimRight(lines[1], " \r"),
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ type Emitter interface {
|
|||
}
|
||||
|
||||
type StateRecorder interface {
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64) error
|
||||
RecordError(ctx context.Context, name, errMsg string) error
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64)
|
||||
RecordError(ctx context.Context, name, errMsg string)
|
||||
}
|
||||
|
||||
type CollectorConfig struct {
|
||||
|
|
@ -92,7 +92,7 @@ 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())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
body, _ := json.Marshal(p)
|
||||
|
|
@ -102,18 +102,18 @@ func (c *Collector) tickPosition(ctx context.Context) {
|
|||
Source: Name,
|
||||
Payload: json.RawMessage(body),
|
||||
})
|
||||
_ = c.cfg.State.RecordSuccess(ctx, Name, 1)
|
||||
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())
|
||||
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())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,23 +86,28 @@ type recordingState struct {
|
|||
failures int
|
||||
}
|
||||
|
||||
func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) error {
|
||||
func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.successes++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingState) RecordError(_ context.Context, _, _ string) error {
|
||||
func (s *recordingState) RecordError(_ context.Context, _, _ string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.failures++
|
||||
return nil
|
||||
}
|
||||
|
||||
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()},
|
||||
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{}
|
||||
|
|
@ -118,21 +123,34 @@ func TestCollector_PositionLoopEmitsAndRecords(t *testing.T) {
|
|||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 70*time.Millisecond)
|
||||
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,
|
||||
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.Greater(t, st.successes, 0)
|
||||
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()},
|
||||
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{}
|
||||
|
|
@ -148,11 +166,19 @@ func TestCollector_TLELoopSavesToStore(t *testing.T) {
|
|||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
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.GreaterOrEqual(
|
||||
t,
|
||||
store.saves,
|
||||
2,
|
||||
"TLE should refresh multiple times in 100ms with 30ms interval",
|
||||
)
|
||||
require.Equal(t, "1 25544U test", store.saved.Line1)
|
||||
}
|
||||
|
||||
|
|
@ -174,10 +200,13 @@ func TestCollector_PositionFetchErrorRecordsState(t *testing.T) {
|
|||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
require.Equal(t, 0, emt.Count())
|
||||
require.Greater(t, st.failures, 0)
|
||||
require.Positive(t, st.failures)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,28 +18,68 @@ type Sat struct {
|
|||
|
||||
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 '")
|
||||
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{}, 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())
|
||||
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) {
|
||||
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())
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -19,8 +19,19 @@ func loadTLE(t *testing.T) (string, string) {
|
|||
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")
|
||||
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) {
|
||||
|
|
@ -33,7 +44,12 @@ func TestPropagator_PositionWithinReasonableBounds(t *testing.T) {
|
|||
|
||||
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)
|
||||
require.True(
|
||||
t,
|
||||
alt >= 350 && alt <= 500,
|
||||
"ISS altitude is roughly 380-420 km, got %f",
|
||||
alt,
|
||||
)
|
||||
}
|
||||
|
||||
func TestPropagator_LookAnglesInValidRange(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -16,12 +16,14 @@ import (
|
|||
)
|
||||
|
||||
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)
|
||||
}))
|
||||
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})
|
||||
|
|
@ -32,9 +34,11 @@ func TestKEVClient_FetchCatalogDecodes(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestKEVClient_RejectsServerError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}))
|
||||
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})
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ type Emitter interface {
|
|||
}
|
||||
|
||||
type StateRecorder interface {
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64) error
|
||||
RecordError(ctx context.Context, name, errMsg string) error
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64)
|
||||
RecordError(ctx context.Context, name, errMsg string)
|
||||
}
|
||||
|
||||
type CollectorConfig struct {
|
||||
|
|
@ -82,7 +82,7 @@ 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())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -94,7 +94,7 @@ func (c *Collector) tick(ctx context.Context) {
|
|||
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())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -136,5 +136,5 @@ func (c *Collector) tick(ctx context.Context) {
|
|||
})
|
||||
emitted++
|
||||
}
|
||||
_ = c.cfg.State.RecordSuccess(ctx, Name, emitted)
|
||||
c.cfg.State.RecordSuccess(ctx, Name, emitted)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,10 @@ func (r *stubKEVRepo) Insert(_ context.Context, row kev.Row) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *stubKEVRepo) KnownIDs(_ context.Context, ids []string) (map[string]bool, error) {
|
||||
func (r *stubKEVRepo) KnownIDs(
|
||||
_ context.Context,
|
||||
ids []string,
|
||||
) (map[string]bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make(map[string]bool)
|
||||
|
|
@ -82,8 +85,8 @@ func (e *stubKEVEmitter) Events() []events.Event {
|
|||
|
||||
type stubKEVState struct{}
|
||||
|
||||
func (stubKEVState) RecordSuccess(context.Context, string, int64) error { return nil }
|
||||
func (stubKEVState) RecordError(context.Context, string, string) error { return nil }
|
||||
func (stubKEVState) RecordSuccess(context.Context, string, int64) {}
|
||||
func (stubKEVState) RecordError(context.Context, string, string) {}
|
||||
|
||||
func TestCollector_OnlyEmitsNewKEVs(t *testing.T) {
|
||||
ftch := &stubFetcher{cat: kev.Catalog{Vulnerabilities: []kev.Vulnerability{
|
||||
|
|
@ -101,7 +104,10 @@ func TestCollector_OnlyEmitsNewKEVs(t *testing.T) {
|
|||
State: stubKEVState{},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
|
|
@ -137,7 +143,10 @@ func TestCollector_EmptyKnownInsertsAll(t *testing.T) {
|
|||
State: stubKEVState{},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
30*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
|
|
|
|||
|
|
@ -52,13 +52,20 @@ func (r *Repo) Insert(ctx context.Context, row Row) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *Repo) KnownIDs(ctx context.Context, ids []string) (map[string]bool, error) {
|
||||
func (r *Repo) KnownIDs(
|
||||
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 cve_id FROM kev_entries WHERE cve_id = ANY($1::text[])`, pq.Array(ids)); err != nil {
|
||||
if err := r.db.SelectContext(
|
||||
ctx,
|
||||
&found,
|
||||
`SELECT cve_id FROM kev_entries WHERE cve_id = ANY($1::text[])`,
|
||||
pq.Array(ids),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("kev known ids: %w", err)
|
||||
}
|
||||
out := make(map[string]bool, len(found))
|
||||
|
|
@ -70,8 +77,32 @@ func (r *Repo) KnownIDs(ctx context.Context, ids []string) (map[string]bool, err
|
|||
|
||||
func (r *Repo) Count(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
if err := r.db.GetContext(ctx, &n, `SELECT count(*) FROM kev_entries`); err != nil {
|
||||
if err := r.db.GetContext(
|
||||
ctx,
|
||||
&n,
|
||||
`SELECT count(*) FROM kev_entries`,
|
||||
); err != nil {
|
||||
return 0, fmt.Errorf("kev count: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *Repo) RecentByDateAdded(
|
||||
ctx context.Context,
|
||||
limit int,
|
||||
) ([]Row, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
var rows []Row
|
||||
err := r.db.SelectContext(ctx, &rows, `
|
||||
SELECT cve_id, vendor, product, vulnerability_name, date_added, due_date,
|
||||
ransomware_use, payload
|
||||
FROM kev_entries
|
||||
ORDER BY date_added DESC, cve_id DESC
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recent kev: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,10 @@ func TestRepo_InsertAndKnownIDs(t *testing.T) {
|
|||
Payload: json.RawMessage(`{"cveID":"CVE-2024-3094"}`),
|
||||
}))
|
||||
|
||||
known, err := repo.KnownIDs(ctx, []string{"CVE-2024-3094", "CVE-2024-MISSING"})
|
||||
known, err := repo.KnownIDs(
|
||||
ctx,
|
||||
[]string{"CVE-2024-3094", "CVE-2024-MISSING"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.True(t, known["CVE-2024-3094"])
|
||||
require.False(t, known["CVE-2024-MISSING"])
|
||||
|
|
|
|||
|
|
@ -59,7 +59,12 @@ type Victim struct {
|
|||
}
|
||||
|
||||
func (v Victim) ID() string {
|
||||
h := sha256.Sum256([]byte(v.PostTitle + "|" + v.GroupName + "|" + v.Discovered.UTC().Format(time.RFC3339)))
|
||||
h := sha256.Sum256(
|
||||
[]byte(
|
||||
v.PostTitle + "|" + v.GroupName + "|" + v.Discovered.UTC().
|
||||
Format(time.RFC3339),
|
||||
),
|
||||
)
|
||||
return hex.EncodeToString(h[:idHashBytes])
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,12 +17,14 @@ import (
|
|||
)
|
||||
|
||||
func TestClient_FetchRecentVictimsDecodes(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
body, err := os.ReadFile("testdata/recentvictims.json")
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
body, err := os.ReadFile("testdata/recentvictims.json")
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}),
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
c := ransomware.NewClient(ransomware.ClientConfig{BaseURL: srv.URL})
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ type Emitter interface {
|
|||
}
|
||||
|
||||
type StateRecorder interface {
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64) error
|
||||
RecordError(ctx context.Context, name, errMsg string) error
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64)
|
||||
RecordError(ctx context.Context, name, errMsg string)
|
||||
}
|
||||
|
||||
type CollectorConfig struct {
|
||||
|
|
@ -81,7 +81,7 @@ func (c *Collector) tick(ctx context.Context) {
|
|||
vs, err := c.cfg.Fetcher.FetchRecent(ctx)
|
||||
if err != nil {
|
||||
c.logger.Warn("ransomware fetch", "err", err)
|
||||
_ = c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -93,7 +93,7 @@ func (c *Collector) tick(ctx context.Context) {
|
|||
known, err := c.cfg.Repo.KnownIDs(ctx, ids)
|
||||
if err != nil {
|
||||
c.logger.Warn("ransomware known ids", "err", err)
|
||||
_ = c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -129,5 +129,5 @@ func (c *Collector) tick(ctx context.Context) {
|
|||
})
|
||||
emitted++
|
||||
}
|
||||
_ = c.cfg.State.RecordSuccess(ctx, Name, emitted)
|
||||
c.cfg.State.RecordSuccess(ctx, Name, emitted)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ type stubFetcher struct {
|
|||
victims []ransomware.Victim
|
||||
}
|
||||
|
||||
func (s *stubFetcher) FetchRecent(context.Context) ([]ransomware.Victim, error) {
|
||||
func (s *stubFetcher) FetchRecent(
|
||||
context.Context,
|
||||
) ([]ransomware.Victim, error) {
|
||||
return s.victims, nil
|
||||
}
|
||||
|
||||
|
|
@ -40,7 +42,10 @@ func (r *stubRansomRepo) Insert(_ context.Context, row ransomware.Row) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *stubRansomRepo) KnownIDs(_ context.Context, ids []string) (map[string]bool, error) {
|
||||
func (r *stubRansomRepo) KnownIDs(
|
||||
_ context.Context,
|
||||
ids []string,
|
||||
) (map[string]bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make(map[string]bool)
|
||||
|
|
@ -81,14 +86,26 @@ func (e *stubRansomEmitter) Events() []events.Event {
|
|||
|
||||
type stubRansomState struct{}
|
||||
|
||||
func (stubRansomState) RecordSuccess(context.Context, string, int64) error { return nil }
|
||||
func (stubRansomState) RecordError(context.Context, string, string) error { return nil }
|
||||
func (stubRansomState) RecordSuccess(context.Context, string, int64) {}
|
||||
func (stubRansomState) RecordError(context.Context, string, string) {}
|
||||
|
||||
func TestCollector_OnlyEmitsNewVictims(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
known := ransomware.Victim{PostTitle: "Old", GroupName: "lockbit", Discovered: now.Add(-time.Hour)}
|
||||
new1 := ransomware.Victim{PostTitle: "Acme", GroupName: "blackcat", Discovered: now}
|
||||
new2 := ransomware.Victim{PostTitle: "Banco", GroupName: "play", Discovered: now}
|
||||
known := ransomware.Victim{
|
||||
PostTitle: "Old",
|
||||
GroupName: "lockbit",
|
||||
Discovered: now.Add(-time.Hour),
|
||||
}
|
||||
new1 := ransomware.Victim{
|
||||
PostTitle: "Acme",
|
||||
GroupName: "blackcat",
|
||||
Discovered: now,
|
||||
}
|
||||
new2 := ransomware.Victim{
|
||||
PostTitle: "Banco",
|
||||
GroupName: "play",
|
||||
Discovered: now,
|
||||
}
|
||||
|
||||
ftch := &stubFetcher{victims: []ransomware.Victim{known, new1, new2}}
|
||||
repo := &stubRansomRepo{known: map[string]bool{known.ID(): true}}
|
||||
|
|
@ -102,7 +119,10 @@ func TestCollector_OnlyEmitsNewVictims(t *testing.T) {
|
|||
State: stubRansomState{},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
|
|
|
|||
|
|
@ -30,12 +30,20 @@ type Repo struct {
|
|||
func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} }
|
||||
|
||||
func (r *Repo) Insert(ctx context.Context, row Row) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
_, err := r.db.ExecContext(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO ransomware_victims
|
||||
(id, post_title, group_name, discovered_at, country, sector, payload)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
row.ID, row.PostTitle, row.GroupName, row.DiscoveredAt, row.Country, row.Sector, []byte(row.Payload),
|
||||
row.ID,
|
||||
row.PostTitle,
|
||||
row.GroupName,
|
||||
row.DiscoveredAt,
|
||||
row.Country,
|
||||
row.Sector,
|
||||
[]byte(row.Payload),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert ransom %s: %w", row.ID, err)
|
||||
|
|
@ -43,13 +51,20 @@ func (r *Repo) Insert(ctx context.Context, row Row) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *Repo) KnownIDs(ctx context.Context, ids []string) (map[string]bool, error) {
|
||||
func (r *Repo) KnownIDs(
|
||||
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 ransomware_victims WHERE id = ANY($1::text[])`, pq.Array(ids)); err != nil {
|
||||
if err := r.db.SelectContext(
|
||||
ctx,
|
||||
&found,
|
||||
`SELECT id FROM ransomware_victims WHERE id = ANY($1::text[])`,
|
||||
pq.Array(ids),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("ransom known ids: %w", err)
|
||||
}
|
||||
out := make(map[string]bool, len(found))
|
||||
|
|
@ -58,3 +73,19 @@ func (r *Repo) KnownIDs(ctx context.Context, ids []string) (map[string]bool, err
|
|||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repo) Recent(ctx context.Context, limit int) ([]Row, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
var rows []Row
|
||||
err := r.db.SelectContext(ctx, &rows, `
|
||||
SELECT id, post_title, group_name, discovered_at, country, sector, payload
|
||||
FROM ransomware_victims
|
||||
ORDER BY discovered_at DESC
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recent ransomware: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ package state
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
|
@ -29,12 +29,26 @@ type Row struct {
|
|||
}
|
||||
|
||||
type Repo struct {
|
||||
db *sqlx.DB
|
||||
db *sqlx.DB
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} }
|
||||
func NewRepo(db *sqlx.DB) *Repo {
|
||||
return &Repo{db: db, logger: slog.Default()}
|
||||
}
|
||||
|
||||
func (r *Repo) RecordSuccess(ctx context.Context, name string, eventCount int64) error {
|
||||
func NewRepoWithLogger(db *sqlx.DB, logger *slog.Logger) *Repo {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Repo{db: db, logger: logger}
|
||||
}
|
||||
|
||||
func (r *Repo) RecordSuccess(
|
||||
ctx context.Context,
|
||||
name string,
|
||||
eventCount int64,
|
||||
) {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO collector_state (name, state, last_success_at, last_event_count, updated_at)
|
||||
VALUES ($1, $2, now(), $3, now())
|
||||
|
|
@ -46,12 +60,12 @@ func (r *Repo) RecordSuccess(ctx context.Context, name string, eventCount int64)
|
|||
name, StateHealthy, eventCount,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert healthy %s: %w", name, err)
|
||||
r.logger.Warn("collector_state record success failed",
|
||||
"collector", name, "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repo) RecordError(ctx context.Context, name, errMsg string) error {
|
||||
func (r *Repo) RecordError(ctx context.Context, name, errMsg string) {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO collector_state (name, state, last_error_at, last_error, updated_at)
|
||||
VALUES ($1, $2, now(), $3, now())
|
||||
|
|
@ -63,21 +77,21 @@ func (r *Repo) RecordError(ctx context.Context, name, errMsg string) error {
|
|||
name, StateDegraded, errMsg,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert degraded %s: %w", name, err)
|
||||
r.logger.Warn("collector_state record error failed",
|
||||
"collector", name, "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repo) Get(ctx context.Context, name string) (Row, error) {
|
||||
func (r *Repo) Get(ctx context.Context, name string) (Row, bool, error) {
|
||||
var row Row
|
||||
err := r.db.GetContext(ctx, &row, `
|
||||
SELECT name, state, last_success_at, last_error_at, last_error,
|
||||
last_event_count, updated_at
|
||||
FROM collector_state WHERE name = $1`, name)
|
||||
if err != nil {
|
||||
return Row{}, fmt.Errorf("get state %s: %w", name, err)
|
||||
return Row{}, false, err
|
||||
}
|
||||
return row, nil
|
||||
return row, true, nil
|
||||
}
|
||||
|
||||
func (r *Repo) All(ctx context.Context) ([]Row, error) {
|
||||
|
|
@ -87,7 +101,7 @@ func (r *Repo) All(ctx context.Context) ([]Row, error) {
|
|||
last_event_count, updated_at
|
||||
FROM collector_state ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("select all collector_state: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,20 +59,22 @@ func TestRepo_RecordSuccessThenError(t *testing.T) {
|
|||
repo := state.NewRepo(db)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, repo.RecordSuccess(ctx, "dshield", 12))
|
||||
repo.RecordSuccess(ctx, "dshield", 12)
|
||||
|
||||
got, err := repo.Get(ctx, "dshield")
|
||||
got, found, err := repo.Get(ctx, "dshield")
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, "dshield", got.Name)
|
||||
require.Equal(t, state.StateHealthy, got.State)
|
||||
require.EqualValues(t, 12, got.LastEventCount)
|
||||
require.NotNil(t, got.LastSuccessAt)
|
||||
require.WithinDuration(t, time.Now(), *got.LastSuccessAt, 5*time.Second)
|
||||
|
||||
require.NoError(t, repo.RecordError(ctx, "dshield", "upstream 503"))
|
||||
repo.RecordError(ctx, "dshield", "upstream 503")
|
||||
|
||||
got, err = repo.Get(ctx, "dshield")
|
||||
got, found, err = repo.Get(ctx, "dshield")
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, state.StateDegraded, got.State)
|
||||
require.Equal(t, "upstream 503", got.LastError)
|
||||
require.NotNil(t, got.LastErrorAt)
|
||||
|
|
@ -84,11 +86,12 @@ func TestRepo_SuccessAccumulatesCount(t *testing.T) {
|
|||
repo := state.NewRepo(db)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, repo.RecordSuccess(ctx, "kev", 3))
|
||||
require.NoError(t, repo.RecordSuccess(ctx, "kev", 5))
|
||||
repo.RecordSuccess(ctx, "kev", 3)
|
||||
repo.RecordSuccess(ctx, "kev", 5)
|
||||
|
||||
got, err := repo.Get(ctx, "kev")
|
||||
got, found, err := repo.Get(ctx, "kev")
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.EqualValues(t, 8, got.LastEventCount)
|
||||
}
|
||||
|
||||
|
|
@ -97,9 +100,9 @@ func TestRepo_AllReturnsRowsSorted(t *testing.T) {
|
|||
repo := state.NewRepo(db)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, repo.RecordSuccess(ctx, "ransomware", 1))
|
||||
require.NoError(t, repo.RecordSuccess(ctx, "cve", 1))
|
||||
require.NoError(t, repo.RecordSuccess(ctx, "kev", 1))
|
||||
repo.RecordSuccess(ctx, "ransomware", 1)
|
||||
repo.RecordSuccess(ctx, "cve", 1)
|
||||
repo.RecordSuccess(ctx, "kev", 1)
|
||||
|
||||
rows, err := repo.All(ctx)
|
||||
require.NoError(t, err)
|
||||
|
|
|
|||
|
|
@ -204,7 +204,10 @@ func (c *Client) FetchAlerts(ctx context.Context) ([]AlertItem, error) {
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchRowArray(ctx context.Context, path string) ([]map[string]string, error) {
|
||||
func (c *Client) fetchRowArray(
|
||||
ctx context.Context,
|
||||
path string,
|
||||
) ([]map[string]string, error) {
|
||||
var raw [][]any
|
||||
if err := c.hx.GetJSON(ctx, path, nil, &raw); err != nil {
|
||||
return nil, fmt.Errorf("fetch row-array %s: %w", path, err)
|
||||
|
|
|
|||
|
|
@ -17,18 +17,20 @@ import (
|
|||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/swpc"
|
||||
)
|
||||
|
||||
func newFakeServer(t *testing.T, route string, fixture string) *httptest.Server {
|
||||
func newFakeServer(t *testing.T, route, fixture string) *httptest.Server {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile("testdata/" + fixture)
|
||||
require.NoError(t, err)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.URL.Path, route) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.URL.Path, route) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}),
|
||||
)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,8 +49,8 @@ type Emitter interface {
|
|||
}
|
||||
|
||||
type StateRecorder interface {
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64) error
|
||||
RecordError(ctx context.Context, name, errMsg string) error
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64)
|
||||
RecordError(ctx context.Context, name, errMsg string)
|
||||
}
|
||||
|
||||
type CollectorConfig struct {
|
||||
|
|
@ -115,28 +115,28 @@ func (c *Collector) tickFast(ctx context.Context) {
|
|||
|
||||
if n, err := c.pushPlasma(ctx); err != nil {
|
||||
c.logger.Warn("swpc plasma", "err", err)
|
||||
_ = c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
hadError = true
|
||||
} else {
|
||||
pushed += n
|
||||
}
|
||||
if n, err := c.pushMag(ctx); err != nil {
|
||||
c.logger.Warn("swpc mag", "err", err)
|
||||
_ = c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
hadError = true
|
||||
} else {
|
||||
pushed += n
|
||||
}
|
||||
if n, err := c.pushXray(ctx); err != nil {
|
||||
c.logger.Warn("swpc xray", "err", err)
|
||||
_ = c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
hadError = true
|
||||
} else {
|
||||
pushed += n
|
||||
}
|
||||
if n, err := c.pushAlerts(ctx); err != nil {
|
||||
c.logger.Warn("swpc alerts", "err", err)
|
||||
_ = c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
hadError = true
|
||||
} else {
|
||||
pushed += n
|
||||
|
|
@ -147,7 +147,7 @@ func (c *Collector) tickFast(ctx context.Context) {
|
|||
}
|
||||
|
||||
if !hadError {
|
||||
_ = c.cfg.State.RecordSuccess(ctx, Name, pushed)
|
||||
c.cfg.State.RecordSuccess(ctx, Name, pushed)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -155,7 +155,7 @@ func (c *Collector) tickSlow(ctx context.Context) {
|
|||
n, err := c.pushKp(ctx)
|
||||
if err != nil {
|
||||
c.logger.Warn("swpc kp", "err", err)
|
||||
_ = c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
|
|
@ -208,10 +208,19 @@ func (c *Collector) pushPlasma(ctx context.Context) (int64, error) {
|
|||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if latest := lastNonZero(rows, func(r PlasmaTick) bool { return !r.TimeTag.IsZero() }); latest != nil {
|
||||
if latest := lastNonZero(
|
||||
rows,
|
||||
func(r PlasmaTick) bool { return !r.TimeTag.IsZero() },
|
||||
); latest != nil {
|
||||
c.latestPlasma = latest
|
||||
}
|
||||
return pushAll(ctx, c.cfg.Ring, keyPlasma, rows, func(r PlasmaTick) int64 { return r.TimeTag.UnixMilli() })
|
||||
return pushAll(
|
||||
ctx,
|
||||
c.cfg.Ring,
|
||||
keyPlasma,
|
||||
rows,
|
||||
func(r PlasmaTick) int64 { return r.TimeTag.UnixMilli() },
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Collector) pushMag(ctx context.Context) (int64, error) {
|
||||
|
|
@ -219,10 +228,19 @@ func (c *Collector) pushMag(ctx context.Context) (int64, error) {
|
|||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if latest := lastNonZero(rows, func(r MagTick) bool { return !r.TimeTag.IsZero() }); latest != nil {
|
||||
if latest := lastNonZero(
|
||||
rows,
|
||||
func(r MagTick) bool { return !r.TimeTag.IsZero() },
|
||||
); latest != nil {
|
||||
c.latestMag = latest
|
||||
}
|
||||
return pushAll(ctx, c.cfg.Ring, keyMag, rows, func(r MagTick) int64 { return r.TimeTag.UnixMilli() })
|
||||
return pushAll(
|
||||
ctx,
|
||||
c.cfg.Ring,
|
||||
keyMag,
|
||||
rows,
|
||||
func(r MagTick) int64 { return r.TimeTag.UnixMilli() },
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Collector) pushKp(ctx context.Context) (int64, error) {
|
||||
|
|
@ -230,10 +248,19 @@ func (c *Collector) pushKp(ctx context.Context) (int64, error) {
|
|||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if latest := lastNonZero(rows, func(r KpTick) bool { return !r.TimeTag.IsZero() }); latest != nil {
|
||||
if latest := lastNonZero(
|
||||
rows,
|
||||
func(r KpTick) bool { return !r.TimeTag.IsZero() },
|
||||
); latest != nil {
|
||||
c.latestKp = latest
|
||||
}
|
||||
return pushAll(ctx, c.cfg.Ring, keyKp, rows, func(r KpTick) int64 { return r.TimeTag.UnixMilli() })
|
||||
return pushAll(
|
||||
ctx,
|
||||
c.cfg.Ring,
|
||||
keyKp,
|
||||
rows,
|
||||
func(r KpTick) int64 { return r.TimeTag.UnixMilli() },
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Collector) pushXray(ctx context.Context) (int64, error) {
|
||||
|
|
@ -241,10 +268,19 @@ func (c *Collector) pushXray(ctx context.Context) (int64, error) {
|
|||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if latest := lastNonZero(rows, func(r XrayTick) bool { return !r.TimeTag.IsZero() }); latest != nil {
|
||||
if latest := lastNonZero(
|
||||
rows,
|
||||
func(r XrayTick) bool { return !r.TimeTag.IsZero() },
|
||||
); latest != nil {
|
||||
c.latestXray = latest
|
||||
}
|
||||
return pushAll(ctx, c.cfg.Ring, keyXray, rows, func(r XrayTick) int64 { return r.TimeTag.UnixMilli() })
|
||||
return pushAll(
|
||||
ctx,
|
||||
c.cfg.Ring,
|
||||
keyXray,
|
||||
rows,
|
||||
func(r XrayTick) int64 { return r.TimeTag.UnixMilli() },
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Collector) pushAlerts(ctx context.Context) (int64, error) {
|
||||
|
|
@ -252,10 +288,22 @@ func (c *Collector) pushAlerts(ctx context.Context) (int64, error) {
|
|||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return pushAll(ctx, c.cfg.Ring, keyAlerts, rows, func(r AlertItem) int64 { return r.IssueDatetime.UnixMilli() })
|
||||
return pushAll(
|
||||
ctx,
|
||||
c.cfg.Ring,
|
||||
keyAlerts,
|
||||
rows,
|
||||
func(r AlertItem) int64 { return r.IssueDatetime.UnixMilli() },
|
||||
)
|
||||
}
|
||||
|
||||
func pushAll[T any](ctx context.Context, ring Ring, key string, rows []T, score func(T) int64) (int64, error) {
|
||||
func pushAll[T any](
|
||||
ctx context.Context,
|
||||
ring Ring,
|
||||
key string,
|
||||
rows []T,
|
||||
score func(T) int64,
|
||||
) (int64, error) {
|
||||
pushed := int64(0)
|
||||
for _, r := range rows {
|
||||
s := score(r)
|
||||
|
|
|
|||
|
|
@ -26,14 +26,28 @@ type fakeFetcher struct {
|
|||
err error
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchPlasma(_ context.Context) ([]swpc.PlasmaTick, error) {
|
||||
func (f *fakeFetcher) FetchPlasma(
|
||||
_ context.Context,
|
||||
) ([]swpc.PlasmaTick, error) {
|
||||
return f.plasma, f.err
|
||||
}
|
||||
func (f *fakeFetcher) FetchMag(_ context.Context) ([]swpc.MagTick, error) { return f.mag, f.err }
|
||||
func (f *fakeFetcher) FetchKp(_ context.Context) ([]swpc.KpTick, error) { return f.kp, f.err }
|
||||
|
||||
func (f *fakeFetcher) FetchMag(
|
||||
_ context.Context,
|
||||
) ([]swpc.MagTick, error) {
|
||||
return f.mag, f.err
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchKp(
|
||||
_ context.Context,
|
||||
) ([]swpc.KpTick, error) {
|
||||
return f.kp, f.err
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchXray(_ context.Context) ([]swpc.XrayTick, error) {
|
||||
return f.xray, f.err
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchAlerts(_ context.Context) ([]swpc.AlertItem, error) {
|
||||
return f.alerts, f.err
|
||||
}
|
||||
|
|
@ -43,7 +57,12 @@ type fakeRing struct {
|
|||
pushes map[string]int
|
||||
}
|
||||
|
||||
func (r *fakeRing) Push(_ context.Context, key string, _ int64, _ []byte) error {
|
||||
func (r *fakeRing) Push(
|
||||
_ context.Context,
|
||||
key string,
|
||||
_ int64,
|
||||
_ []byte,
|
||||
) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.pushes == nil {
|
||||
|
|
@ -82,28 +101,32 @@ type recordingState struct {
|
|||
failures int
|
||||
}
|
||||
|
||||
func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) error {
|
||||
func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.successes++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingState) RecordError(_ context.Context, _, _ string) error {
|
||||
func (s *recordingState) RecordError(_ context.Context, _, _ string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.failures++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCollector_FastTickPushesToRingsAndEmits(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
ftch := &fakeFetcher{
|
||||
plasma: []swpc.PlasmaTick{{TimeTag: now, Density: "2.94", Speed: "450", Temperature: "93030"}},
|
||||
mag: []swpc.MagTick{{TimeTag: now, Bt: "5.6"}},
|
||||
xray: []swpc.XrayTick{{TimeTag: now, Flux: 1e-7, Energy: "0.1-0.8nm"}},
|
||||
alerts: []swpc.AlertItem{{ProductID: "TIIA", IssueDatetime: now, Message: "test alert"}},
|
||||
kp: []swpc.KpTick{{TimeTag: now, Kp: 3.0}},
|
||||
plasma: []swpc.PlasmaTick{
|
||||
{TimeTag: now, Density: "2.94", Speed: "450", Temperature: "93030"},
|
||||
},
|
||||
mag: []swpc.MagTick{{TimeTag: now, Bt: "5.6"}},
|
||||
xray: []swpc.XrayTick{
|
||||
{TimeTag: now, Flux: 1e-7, Energy: "0.1-0.8nm"},
|
||||
},
|
||||
alerts: []swpc.AlertItem{
|
||||
{ProductID: "TIIA", IssueDatetime: now, Message: "test alert"},
|
||||
},
|
||||
kp: []swpc.KpTick{{TimeTag: now, Kp: 3.0}},
|
||||
}
|
||||
ring := &fakeRing{}
|
||||
emt := &fakeEmitter{}
|
||||
|
|
@ -118,7 +141,10 @@ func TestCollector_FastTickPushesToRingsAndEmits(t *testing.T) {
|
|||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
80*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
|
|
@ -132,7 +158,7 @@ func TestCollector_FastTickPushesToRingsAndEmits(t *testing.T) {
|
|||
for _, ev := range emt.events {
|
||||
require.Equal(t, events.TopicSpaceWeather, ev.Topic)
|
||||
}
|
||||
require.Greater(t, st.successes, 0)
|
||||
require.Positive(t, st.successes)
|
||||
require.Equal(t, 0, st.failures)
|
||||
}
|
||||
|
||||
|
|
@ -167,7 +193,10 @@ func TestCollector_EmitsRichPayloadWithLatestReadings(t *testing.T) {
|
|||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
60*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
|
|
@ -205,10 +234,13 @@ func TestCollector_FetchErrorsRecordsState(t *testing.T) {
|
|||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
60*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
require.Equal(t, 0, ring.PushCount("swpc:plasma"))
|
||||
require.Greater(t, st.failures, 0)
|
||||
require.Positive(t, st.failures)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,10 +20,12 @@ func TestClient_FetchDecodesFeatures(t *testing.T) {
|
|||
body, err := os.ReadFile("testdata/2_5_day.geojson")
|
||||
require.NoError(t, err)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/geo+json")
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/geo+json")
|
||||
_, _ = w.Write(body)
|
||||
}),
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
c := usgs.NewClient(usgs.ClientConfig{BaseURL: srv.URL})
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ type Emitter interface {
|
|||
}
|
||||
|
||||
type StateRecorder interface {
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64) error
|
||||
RecordError(ctx context.Context, name, errMsg string) error
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64)
|
||||
RecordError(ctx context.Context, name, errMsg string)
|
||||
}
|
||||
|
||||
type CollectorConfig struct {
|
||||
|
|
@ -80,7 +80,7 @@ func (c *Collector) tick(ctx context.Context) {
|
|||
feed, err := c.cfg.Fetcher.Fetch(ctx)
|
||||
if err != nil {
|
||||
c.logger.Warn("usgs fetch", "err", err)
|
||||
_ = c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -91,7 +91,7 @@ func (c *Collector) tick(ctx context.Context) {
|
|||
known, err := c.cfg.Repo.KnownIDs(ctx, ids)
|
||||
if err != nil {
|
||||
c.logger.Warn("usgs known ids", "err", err)
|
||||
_ = c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +124,7 @@ func (c *Collector) tick(ctx context.Context) {
|
|||
})
|
||||
emitted++
|
||||
}
|
||||
_ = c.cfg.State.RecordSuccess(ctx, Name, emitted)
|
||||
c.cfg.State.RecordSuccess(ctx, Name, emitted)
|
||||
}
|
||||
|
||||
func coord(c []float64, i int) float64 {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,10 @@ func (r *fakeRepo) Upsert(_ context.Context, row usgs.Row) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) KnownIDs(_ context.Context, ids []string) (map[string]bool, error) {
|
||||
func (r *fakeRepo) KnownIDs(
|
||||
_ context.Context,
|
||||
ids []string,
|
||||
) (map[string]bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make(map[string]bool, len(ids))
|
||||
|
|
@ -92,28 +95,50 @@ type recordingState struct {
|
|||
lastErr string
|
||||
}
|
||||
|
||||
func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) error {
|
||||
func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.successes++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingState) RecordError(_ context.Context, _, msg string) error {
|
||||
func (s *recordingState) RecordError(_ context.Context, _, msg string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.failures++
|
||||
s.lastErr = msg
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCollector_TickPersistsAndEmitsNewQuakes(t *testing.T) {
|
||||
feed := usgs.Feed{
|
||||
Type: "FeatureCollection",
|
||||
Features: []usgs.Feature{
|
||||
{ID: "q1", Properties: usgs.Properties{Mag: 4.5, Place: "test 1", Time: time.Now().UnixMilli()}, Geometry: usgs.Geometry{Coordinates: []float64{-120, 49, 5}}},
|
||||
{ID: "q2", Properties: usgs.Properties{Mag: 6.5, Place: "test 2", Time: time.Now().UnixMilli()}, Geometry: usgs.Geometry{Coordinates: []float64{140, -30, 10}}},
|
||||
{ID: "q3", Properties: usgs.Properties{Mag: 3.0, Place: "test 3", Time: time.Now().UnixMilli()}, Geometry: usgs.Geometry{Coordinates: []float64{0, 0, 1}}},
|
||||
{
|
||||
ID: "q1",
|
||||
Properties: usgs.Properties{
|
||||
Mag: 4.5,
|
||||
Place: "test 1",
|
||||
Time: time.Now().UnixMilli(),
|
||||
},
|
||||
Geometry: usgs.Geometry{Coordinates: []float64{-120, 49, 5}},
|
||||
},
|
||||
{
|
||||
ID: "q2",
|
||||
Properties: usgs.Properties{
|
||||
Mag: 6.5,
|
||||
Place: "test 2",
|
||||
Time: time.Now().UnixMilli(),
|
||||
},
|
||||
Geometry: usgs.Geometry{Coordinates: []float64{140, -30, 10}},
|
||||
},
|
||||
{
|
||||
ID: "q3",
|
||||
Properties: usgs.Properties{
|
||||
Mag: 3.0,
|
||||
Place: "test 3",
|
||||
Time: time.Now().UnixMilli(),
|
||||
},
|
||||
Geometry: usgs.Geometry{Coordinates: []float64{0, 0, 1}},
|
||||
},
|
||||
},
|
||||
}
|
||||
ftch := &fakeFetcher{feed: feed}
|
||||
|
|
@ -129,17 +154,25 @@ func TestCollector_TickPersistsAndEmitsNewQuakes(t *testing.T) {
|
|||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 70*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
70*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
require.GreaterOrEqual(t, repo.Upserts(), 3, "should upsert all 3 features at least once")
|
||||
require.GreaterOrEqual(
|
||||
t,
|
||||
repo.Upserts(),
|
||||
3,
|
||||
"should upsert all 3 features at least once",
|
||||
)
|
||||
require.GreaterOrEqual(t, emt.Count(), 3, "should emit 3 new-event events")
|
||||
for _, ev := range emt.events {
|
||||
require.Equal(t, events.TopicEarthquake, ev.Topic)
|
||||
require.Equal(t, usgs.Name, ev.Source)
|
||||
}
|
||||
require.Greater(t, st.successes, 0)
|
||||
require.Positive(t, st.successes)
|
||||
require.Equal(t, 0, st.failures)
|
||||
}
|
||||
|
||||
|
|
@ -157,20 +190,30 @@ func TestCollector_FetchErrorRecordsState(t *testing.T) {
|
|||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
require.Equal(t, 0, repo.Upserts())
|
||||
require.Equal(t, 0, emt.Count())
|
||||
require.Greater(t, st.failures, 0)
|
||||
require.Positive(t, st.failures)
|
||||
require.Contains(t, st.lastErr, "upstream 503")
|
||||
}
|
||||
|
||||
func TestCollector_KnownQuakesNotReEmitted(t *testing.T) {
|
||||
feed := usgs.Feed{
|
||||
Features: []usgs.Feature{
|
||||
{ID: "qx", Properties: usgs.Properties{Mag: 4.5, Time: time.Now().UnixMilli()}, Geometry: usgs.Geometry{Coordinates: []float64{0, 0, 1}}},
|
||||
{
|
||||
ID: "qx",
|
||||
Properties: usgs.Properties{
|
||||
Mag: 4.5,
|
||||
Time: time.Now().UnixMilli(),
|
||||
},
|
||||
Geometry: usgs.Geometry{Coordinates: []float64{0, 0, 1}},
|
||||
},
|
||||
},
|
||||
}
|
||||
ftch := &fakeFetcher{feed: feed}
|
||||
|
|
@ -186,7 +229,10 @@ func TestCollector_KnownQuakesNotReEmitted(t *testing.T) {
|
|||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,10 @@ func (r *Repo) Upsert(ctx context.Context, row Row) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *Repo) KnownIDs(ctx context.Context, ids []string) (map[string]bool, error) {
|
||||
func (r *Repo) KnownIDs(
|
||||
ctx context.Context,
|
||||
ids []string,
|
||||
) (map[string]bool, error) {
|
||||
out := make(map[string]bool, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
|
|
@ -82,3 +85,19 @@ func (r *Repo) RecentByMag(ctx context.Context, limit int) ([]Row, error) {
|
|||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repo) RecentByTime(ctx context.Context, limit int) ([]Row, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
var rows []Row
|
||||
err := r.db.SelectContext(ctx, &rows, `
|
||||
SELECT id, occurred_at, mag, place, geom_lon, geom_lat, depth_km, payload
|
||||
FROM earthquakes
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recent earthquakes by time: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,9 +107,30 @@ func TestRepo_RecentByMagOrdered(t *testing.T) {
|
|||
|
||||
now := time.Now().UTC()
|
||||
rows := []usgs.Row{
|
||||
{ID: "small", OccurredAt: now, Mag: 4.5, GeomLon: 0, GeomLat: 0, Payload: json.RawMessage(`{}`)},
|
||||
{ID: "huge", OccurredAt: now, Mag: 7.8, GeomLon: 0, GeomLat: 0, Payload: json.RawMessage(`{}`)},
|
||||
{ID: "mid", OccurredAt: now, Mag: 6.0, GeomLon: 0, GeomLat: 0, Payload: json.RawMessage(`{}`)},
|
||||
{
|
||||
ID: "small",
|
||||
OccurredAt: now,
|
||||
Mag: 4.5,
|
||||
GeomLon: 0,
|
||||
GeomLat: 0,
|
||||
Payload: json.RawMessage(`{}`),
|
||||
},
|
||||
{
|
||||
ID: "huge",
|
||||
OccurredAt: now,
|
||||
Mag: 7.8,
|
||||
GeomLon: 0,
|
||||
GeomLat: 0,
|
||||
Payload: json.RawMessage(`{}`),
|
||||
},
|
||||
{
|
||||
ID: "mid",
|
||||
OccurredAt: now,
|
||||
Mag: 6.0,
|
||||
GeomLon: 0,
|
||||
GeomLat: 0,
|
||||
Payload: json.RawMessage(`{}`),
|
||||
},
|
||||
}
|
||||
for _, r := range rows {
|
||||
require.NoError(t, repo.Upsert(ctx, r))
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ package wikipedia
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
|
|
@ -60,16 +61,9 @@ func (c *Client) Fetch(ctx context.Context) (Response, error) {
|
|||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body := make([]byte, 0, 32*1024)
|
||||
buf := make([]byte, 4*1024)
|
||||
for {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
body = append(body, buf[:n]...)
|
||||
}
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return Response{}, fmt.Errorf("read wikipedia ITN body: %w", err)
|
||||
}
|
||||
return DecodeResponse(body)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,13 +20,15 @@ func TestClient_FetchDecodesITNResponse(t *testing.T) {
|
|||
body, err := os.ReadFile("testdata/itn_response.json")
|
||||
require.NoError(t, err)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/w/api.php", r.URL.Path)
|
||||
require.Equal(t, "parse", r.URL.Query().Get("action"))
|
||||
require.Equal(t, "Template:In_the_news", r.URL.Query().Get("page"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/w/api.php", r.URL.Path)
|
||||
require.Equal(t, "parse", r.URL.Query().Get("action"))
|
||||
require.Equal(t, "Template:In_the_news", r.URL.Query().Get("page"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}),
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
c := wikipedia.NewClient(wikipedia.ClientConfig{BaseURL: srv.URL})
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ type Emitter interface {
|
|||
}
|
||||
|
||||
type StateRecorder interface {
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64) error
|
||||
RecordError(ctx context.Context, name, errMsg string) error
|
||||
RecordSuccess(ctx context.Context, name string, eventCount int64)
|
||||
RecordError(ctx context.Context, name, errMsg string)
|
||||
}
|
||||
|
||||
type CollectorConfig struct {
|
||||
|
|
@ -83,18 +83,18 @@ func (c *Collector) tick(ctx context.Context) {
|
|||
resp, err := c.cfg.Fetcher.Fetch(ctx)
|
||||
if err != nil {
|
||||
c.logger.Warn("wikipedia fetch", "err", err)
|
||||
_ = c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
last, found, err := c.cfg.Repo.LastRevID(ctx)
|
||||
if err != nil {
|
||||
c.logger.Warn("wikipedia revid lookup", "err", err)
|
||||
_ = c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||
return
|
||||
}
|
||||
if found && last == resp.RevID {
|
||||
_ = c.cfg.State.RecordSuccess(ctx, Name, 0)
|
||||
c.cfg.State.RecordSuccess(ctx, Name, 0)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -129,7 +129,7 @@ func (c *Collector) tick(ctx context.Context) {
|
|||
if err := c.cfg.Repo.RememberRevID(ctx, resp.RevID); err != nil {
|
||||
c.logger.Warn("wikipedia remember revid", "err", err)
|
||||
}
|
||||
_ = c.cfg.State.RecordSuccess(ctx, Name, emitted)
|
||||
c.cfg.State.RecordSuccess(ctx, Name, emitted)
|
||||
}
|
||||
|
||||
func entryID(e ITNEntry) string {
|
||||
|
|
|
|||
|
|
@ -84,24 +84,25 @@ type recordingState struct {
|
|||
failures int
|
||||
}
|
||||
|
||||
func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) error {
|
||||
func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.successes++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingState) RecordError(_ context.Context, _, _ string) error {
|
||||
func (s *recordingState) RecordError(_ context.Context, _, _ string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.failures++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCollector_NewRevidInsertsAndEmits(t *testing.T) {
|
||||
resp := wikipedia.Response{
|
||||
RevID: 999,
|
||||
HTML: `<ul><li>Story <a href="/wiki/A">link</a></li><li>Another <a href="/wiki/B">link</a></li></ul>`,
|
||||
HTML: `<ul>` +
|
||||
`<li>A long enough ITN headline mentioning <a href="/wiki/A">subject A</a> for context.</li>` +
|
||||
`<li>Another long enough ITN headline referencing <a href="/wiki/B">subject B</a> in the news.</li>` +
|
||||
`</ul>`,
|
||||
}
|
||||
ftch := &fakeFetcher{resp: resp}
|
||||
repo := &fakeRepo{}
|
||||
|
|
@ -116,7 +117,10 @@ func TestCollector_NewRevidInsertsAndEmits(t *testing.T) {
|
|||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
|
|
@ -125,7 +129,7 @@ func TestCollector_NewRevidInsertsAndEmits(t *testing.T) {
|
|||
for _, ev := range emt.events {
|
||||
require.Equal(t, events.TopicWikipediaITN, ev.Topic)
|
||||
}
|
||||
require.Greater(t, st.successes, 0)
|
||||
require.Positive(t, st.successes)
|
||||
}
|
||||
|
||||
func TestCollector_RevIDUnchangedSkipsInsert(t *testing.T) {
|
||||
|
|
@ -146,13 +150,16 @@ func TestCollector_RevIDUnchangedSkipsInsert(t *testing.T) {
|
|||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
require.Equal(t, 0, repo.Inserts())
|
||||
require.Equal(t, 0, emt.Count())
|
||||
require.Greater(t, st.successes, 0)
|
||||
require.Positive(t, st.successes)
|
||||
}
|
||||
|
||||
func TestCollector_FetchErrorRecordsState(t *testing.T) {
|
||||
|
|
@ -169,10 +176,13 @@ func TestCollector_FetchErrorRecordsState(t *testing.T) {
|
|||
State: st,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
50*time.Millisecond,
|
||||
)
|
||||
defer cancel()
|
||||
_ = c.Run(ctx)
|
||||
|
||||
require.Equal(t, 0, repo.Inserts())
|
||||
require.Greater(t, st.failures, 0)
|
||||
require.Positive(t, st.failures)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ func DecodeResponse(body []byte) (Response, error) {
|
|||
return Response{RevID: r.Parse.RevID, HTML: r.Parse.Text.Star}, nil
|
||||
}
|
||||
|
||||
const minEntryLen = 50
|
||||
|
||||
func ParseEntries(html string) []ITNEntry {
|
||||
if strings.TrimSpace(html) == "" {
|
||||
return nil
|
||||
|
|
@ -50,16 +52,19 @@ func ParseEntries(html string) []ITNEntry {
|
|||
var out []ITNEntry
|
||||
doc.Find("ul li").Each(func(_ int, s *goquery.Selection) {
|
||||
text := strings.TrimSpace(s.Text())
|
||||
if text == "" {
|
||||
if text == "" || len(text) < minEntryLen ||
|
||||
!strings.Contains(text, " ") {
|
||||
return
|
||||
}
|
||||
entry := ITNEntry{Text: text}
|
||||
if href, ok := s.Find("a").First().Attr("href"); ok {
|
||||
if slug := slugFromHref(href); slug != "" {
|
||||
entry.ArticleSlug = slug
|
||||
}
|
||||
href, ok := s.Find("a").First().Attr("href")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out = append(out, entry)
|
||||
slug := slugFromHref(href)
|
||||
if slug == "" {
|
||||
return
|
||||
}
|
||||
out = append(out, ITNEntry{Text: text, ArticleSlug: slug})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,18 +41,24 @@ func TestParser_HandlesEmptyHTML(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestParser_StripsHTMLTagsFromText(t *testing.T) {
|
||||
entries := wikipedia.ParseEntries(`<ul><li>Plain <b>bold</b> headline with <a href="/wiki/Topic">link</a>.</li></ul>`)
|
||||
entries := wikipedia.ParseEntries(
|
||||
`<ul><li>A reasonably long ITN-style sentence with <b>bold</b> and an inline <a href="/wiki/Topic">linked phrase</a> for context.</li></ul>`,
|
||||
)
|
||||
require.Len(t, entries, 1)
|
||||
require.Contains(t, entries[0].Text, "Plain")
|
||||
require.Contains(t, entries[0].Text, "bold")
|
||||
require.Contains(t, entries[0].Text, "link")
|
||||
require.Contains(t, entries[0].Text, "linked phrase")
|
||||
require.NotContains(t, entries[0].Text, "<b>")
|
||||
require.Equal(t, "Topic", entries[0].ArticleSlug)
|
||||
}
|
||||
|
||||
func TestParser_SkipsListItemsWithoutLinks(t *testing.T) {
|
||||
entries := wikipedia.ParseEntries(`<ul><li>Has <a href="/wiki/Foo">link</a></li><li>No link here</li></ul>`)
|
||||
require.Len(t, entries, 2)
|
||||
entries := wikipedia.ParseEntries(
|
||||
`<ul><li>A long enough sentence with an actual linked <a href="/wiki/Foo">article reference</a> embedded.</li><li>Another long sentence that contains no link element at all in the body text.</li></ul>`,
|
||||
)
|
||||
require.Len(
|
||||
t,
|
||||
entries,
|
||||
1,
|
||||
"items without an article slug should be filtered out",
|
||||
)
|
||||
require.Equal(t, "Foo", entries[0].ArticleSlug)
|
||||
require.Empty(t, entries[1].ArticleSlug)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ func NewRepo(db *sqlx.DB, rdb *redis.Client) *Repo {
|
|||
}
|
||||
|
||||
func (r *Repo) RememberRevID(ctx context.Context, revID int64) error {
|
||||
if err := r.rdb.Set(ctx, keyRevID, strconv.FormatInt(revID, 10), 0).Err(); err != nil {
|
||||
if err := r.rdb.Set(ctx, keyRevID, strconv.FormatInt(revID, 10), 0).
|
||||
Err(); err != nil {
|
||||
return fmt.Errorf("save wiki revid: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -15,16 +15,17 @@ import (
|
|||
)
|
||||
|
||||
type Config struct {
|
||||
App AppConfig `koanf:"app"`
|
||||
Server ServerConfig `koanf:"server"`
|
||||
Database DatabaseConfig `koanf:"database"`
|
||||
Redis RedisConfig `koanf:"redis"`
|
||||
JWT JWTConfig `koanf:"jwt"`
|
||||
RateLimit RateLimitConfig `koanf:"rate_limit"`
|
||||
CORS CORSConfig `koanf:"cors"`
|
||||
Log LogConfig `koanf:"log"`
|
||||
Otel OtelConfig `koanf:"otel"`
|
||||
Collectors CollectorsConfig `koanf:"collectors"`
|
||||
App AppConfig `koanf:"app"`
|
||||
Server ServerConfig `koanf:"server"`
|
||||
Database DatabaseConfig `koanf:"database"`
|
||||
Redis RedisConfig `koanf:"redis"`
|
||||
JWT JWTConfig `koanf:"jwt"`
|
||||
RateLimit RateLimitConfig `koanf:"rate_limit"`
|
||||
CORS CORSConfig `koanf:"cors"`
|
||||
Log LogConfig `koanf:"log"`
|
||||
Otel OtelConfig `koanf:"otel"`
|
||||
Collectors CollectorsConfig `koanf:"collectors"`
|
||||
Notifications NotificationsConfig `koanf:"notifications"`
|
||||
}
|
||||
|
||||
type CollectorsConfig struct {
|
||||
|
|
@ -95,15 +96,24 @@ type AppConfig struct {
|
|||
Name string `koanf:"name"`
|
||||
Version string `koanf:"version"`
|
||||
Environment string `koanf:"environment"`
|
||||
PublicURL string `koanf:"public_url"`
|
||||
AdminEmail string `koanf:"admin_email"`
|
||||
}
|
||||
|
||||
type NotificationsConfig struct {
|
||||
EncryptionKey string `koanf:"encryption_key"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string `koanf:"host"`
|
||||
Port int `koanf:"port"`
|
||||
ReadTimeout time.Duration `koanf:"read_timeout"`
|
||||
WriteTimeout time.Duration `koanf:"write_timeout"`
|
||||
IdleTimeout time.Duration `koanf:"idle_timeout"`
|
||||
ShutdownTimeout time.Duration `koanf:"shutdown_timeout"`
|
||||
Host string `koanf:"host"`
|
||||
Port int `koanf:"port"`
|
||||
ReadTimeout time.Duration `koanf:"read_timeout"`
|
||||
WriteTimeout time.Duration `koanf:"write_timeout"`
|
||||
IdleTimeout time.Duration `koanf:"idle_timeout"`
|
||||
ShutdownTimeout time.Duration `koanf:"shutdown_timeout"`
|
||||
TrustedProxyHops int `koanf:"trusted_proxy_hops"`
|
||||
WSMaxConnsPerIP int `koanf:"ws_max_conns_per_ip"`
|
||||
WSMaxSubscribers int `koanf:"ws_max_subscribers"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
|
|
@ -173,13 +183,19 @@ func Load(configPath string) (*Config, error) {
|
|||
}
|
||||
|
||||
if configPath != "" {
|
||||
if err := k.Load(file.Provider(configPath), yaml.Parser()); err != nil {
|
||||
if err := k.Load(
|
||||
file.Provider(configPath),
|
||||
yaml.Parser(),
|
||||
); err != nil {
|
||||
loadErr = fmt.Errorf("load config file: %w", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := k.Load(env.Provider("", ".", envKeyReplacer), nil); err != nil {
|
||||
if err := k.Load(
|
||||
env.Provider("", ".", envKeyReplacer),
|
||||
nil,
|
||||
); err != nil {
|
||||
loadErr = fmt.Errorf("load env vars: %w", err)
|
||||
return
|
||||
}
|
||||
|
|
@ -216,12 +232,15 @@ func loadDefaults(k *koanf.Koanf) error {
|
|||
"app.version": "1.0.0",
|
||||
"app.environment": "development",
|
||||
|
||||
"server.host": "0.0.0.0",
|
||||
"server.port": 8080,
|
||||
"server.read_timeout": "30s",
|
||||
"server.write_timeout": "30s",
|
||||
"server.idle_timeout": "120s",
|
||||
"server.shutdown_timeout": "15s",
|
||||
"server.host": "0.0.0.0",
|
||||
"server.port": 8080,
|
||||
"server.read_timeout": "30s",
|
||||
"server.write_timeout": "30s",
|
||||
"server.idle_timeout": "120s",
|
||||
"server.shutdown_timeout": "15s",
|
||||
"server.trusted_proxy_hops": 0,
|
||||
"server.ws_max_conns_per_ip": 25,
|
||||
"server.ws_max_subscribers": 5000,
|
||||
|
||||
"database.max_open_conns": 25,
|
||||
"database.max_idle_conns": 5,
|
||||
|
|
@ -274,8 +293,8 @@ func loadDefaults(k *koanf.Koanf) error {
|
|||
"collectors.cfradar.interval": "5m",
|
||||
"collectors.cfradar.min_confidence": 7,
|
||||
"collectors.cve.enabled": true,
|
||||
"collectors.cve.interval": "2h",
|
||||
"collectors.cve.window": "2h",
|
||||
"collectors.cve.interval": "5m",
|
||||
"collectors.cve.window": "6m",
|
||||
"collectors.kev.enabled": true,
|
||||
"collectors.kev.interval": "1h",
|
||||
"collectors.ransomware.enabled": true,
|
||||
|
|
@ -315,6 +334,9 @@ var envKeyMap = map[string]string{
|
|||
"ENVIRONMENT": "app.environment",
|
||||
"HOST": "server.host",
|
||||
"PORT": "server.port",
|
||||
"TRUSTED_PROXY_HOPS": "server.trusted_proxy_hops",
|
||||
"WS_MAX_CONNS_PER_IP": "server.ws_max_conns_per_ip",
|
||||
"WS_MAX_SUBSCRIBERS": "server.ws_max_subscribers",
|
||||
"LOG_LEVEL": "log.level",
|
||||
"LOG_FORMAT": "log.format",
|
||||
"JWT_PRIVATE_KEY_PATH": "jwt.private_key_path",
|
||||
|
|
@ -336,6 +358,9 @@ var envKeyMap = map[string]string{
|
|||
"CF_RADAR_TOKEN": "collectors.cfradar.bearer_token",
|
||||
"GREYNOISE_API_KEY": "collectors.greynoise.api_key",
|
||||
"ABUSEIPDB_API_KEY": "collectors.abuseipdb.api_key",
|
||||
"NOTIFICATION_ENCRYPTION_KEY": "notifications.encryption_key",
|
||||
"PUBLIC_URL": "app.public_url",
|
||||
"ADMIN_EMAIL": "app.admin_email",
|
||||
}
|
||||
|
||||
func envKeyReplacer(s string) string {
|
||||
|
|
|
|||
|
|
@ -51,7 +51,12 @@ func NewTelemetry(
|
|||
otlptracegrpc.WithTLSCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
} else {
|
||||
opts = append(opts, otlptracegrpc.WithTLSCredentials(credentials.NewClientTLSFromCert(nil, "")))
|
||||
opts = append(
|
||||
opts,
|
||||
otlptracegrpc.WithTLSCredentials(
|
||||
credentials.NewClientTLSFromCert(nil, ""),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
exporter, err := otlptracegrpc.New(ctx, opts...)
|
||||
|
|
|
|||
|
|
@ -16,19 +16,23 @@ import (
|
|||
)
|
||||
|
||||
func TestClient_LookupDecodesVerdict(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/api/v2/check", r.URL.Path)
|
||||
require.Equal(t, "118.25.6.39", r.URL.Query().Get("ipAddress"))
|
||||
require.Equal(t, "90", r.URL.Query().Get("maxAgeInDays"))
|
||||
require.Equal(t, "test-key", r.Header.Get("Key"))
|
||||
body, err := os.ReadFile("testdata/check_lookup.json")
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/api/v2/check", r.URL.Path)
|
||||
require.Equal(t, "118.25.6.39", r.URL.Query().Get("ipAddress"))
|
||||
require.Equal(t, "90", r.URL.Query().Get("maxAgeInDays"))
|
||||
require.Equal(t, "test-key", r.Header.Get("Key"))
|
||||
body, err := os.ReadFile("testdata/check_lookup.json")
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}),
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
c := abuseipdb.NewClient(abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "test-key"})
|
||||
c := abuseipdb.NewClient(
|
||||
abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "test-key"},
|
||||
)
|
||||
v, err := c.Lookup(context.Background(), "118.25.6.39")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "118.25.6.39", v.IPAddress)
|
||||
|
|
@ -38,24 +42,32 @@ func TestClient_LookupDecodesVerdict(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestClient_LookupReturnsErrUnknownIPOn404(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}),
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
c := abuseipdb.NewClient(abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "k"})
|
||||
c := abuseipdb.NewClient(
|
||||
abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "k"},
|
||||
)
|
||||
_, err := c.Lookup(context.Background(), "10.0.0.1")
|
||||
require.ErrorIs(t, err, abuseipdb.ErrUnknownIP)
|
||||
}
|
||||
|
||||
func TestClient_LookupReturnsErrUnknownIPOnEmptyData(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{}}`))
|
||||
}))
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{}}`))
|
||||
}),
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
c := abuseipdb.NewClient(abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "k"})
|
||||
c := abuseipdb.NewClient(
|
||||
abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "k"},
|
||||
)
|
||||
_, err := c.Lookup(context.Background(), "10.0.0.1")
|
||||
require.ErrorIs(t, err, abuseipdb.ErrUnknownIP)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
// ©AngelaMos | 2026
|
||||
// cached.go
|
||||
|
||||
package greynoise
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
cacheKeyPrefix = "gn:lookup:"
|
||||
cacheHitTTL = 6 * time.Hour
|
||||
cacheMissTTL = 30 * time.Minute
|
||||
cacheMissValue = "__miss__"
|
||||
)
|
||||
|
||||
type Cached struct {
|
||||
inner *Client
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
func NewCached(inner *Client, rdb *redis.Client) *Cached {
|
||||
return &Cached{inner: inner, rdb: rdb}
|
||||
}
|
||||
|
||||
func (c *Cached) Lookup(ctx context.Context, ip string) (Verdict, error) {
|
||||
key := cacheKeyPrefix + ip
|
||||
|
||||
if c.rdb != nil {
|
||||
raw, err := c.rdb.Get(ctx, key).Result()
|
||||
if err == nil {
|
||||
if raw == cacheMissValue {
|
||||
return Verdict{}, ErrUnknownIP
|
||||
}
|
||||
var v Verdict
|
||||
if jerr := json.Unmarshal([]byte(raw), &v); jerr == nil {
|
||||
return v, nil
|
||||
}
|
||||
} else if !errors.Is(err, redis.Nil) {
|
||||
// Redis is degraded — fall through to live lookup. Don't cache
|
||||
// the result either since the cache layer is itself unhealthy.
|
||||
v, lerr := c.inner.Lookup(ctx, ip)
|
||||
return v, lerr
|
||||
}
|
||||
}
|
||||
|
||||
v, err := c.inner.Lookup(ctx, ip)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUnknownIP) && c.rdb != nil {
|
||||
_ = c.rdb.Set(ctx, key, cacheMissValue, cacheMissTTL).Err()
|
||||
}
|
||||
return Verdict{}, err
|
||||
}
|
||||
|
||||
if c.rdb != nil {
|
||||
if payload, merr := json.Marshal(v); merr == nil {
|
||||
_ = c.rdb.Set(ctx, key, string(payload), cacheHitTTL).Err()
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
|
@ -16,17 +16,21 @@ import (
|
|||
)
|
||||
|
||||
func TestClient_LookupDecodesVerdict(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/v3/community/8.8.8.8", r.URL.Path)
|
||||
require.Equal(t, "test-key", r.Header.Get("key"))
|
||||
body, err := os.ReadFile("testdata/ip_lookup.json")
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/v3/community/8.8.8.8", r.URL.Path)
|
||||
require.Equal(t, "test-key", r.Header.Get("key"))
|
||||
body, err := os.ReadFile("testdata/ip_lookup.json")
|
||||
require.NoError(t, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}),
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
c := greynoise.NewClient(greynoise.ClientConfig{BaseURL: srv.URL, APIKey: "test-key"})
|
||||
c := greynoise.NewClient(
|
||||
greynoise.ClientConfig{BaseURL: srv.URL, APIKey: "test-key"},
|
||||
)
|
||||
v, err := c.Lookup(context.Background(), "8.8.8.8")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "8.8.8.8", v.IP)
|
||||
|
|
@ -35,12 +39,16 @@ func TestClient_LookupDecodesVerdict(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestClient_LookupReturnsErrUnknownIPOn404(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}),
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
c := greynoise.NewClient(greynoise.ClientConfig{BaseURL: srv.URL, APIKey: "k"})
|
||||
c := greynoise.NewClient(
|
||||
greynoise.ClientConfig{BaseURL: srv.URL, APIKey: "k"},
|
||||
)
|
||||
_, err := c.Lookup(context.Background(), "10.0.0.1")
|
||||
require.ErrorIs(t, err, greynoise.ErrUnknownIP)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,12 @@ func New(cfg Config) *Client {
|
|||
}
|
||||
}
|
||||
|
||||
func (c *Client) GetJSON(ctx context.Context, path string, query url.Values, out any) error {
|
||||
func (c *Client) GetJSON(
|
||||
ctx context.Context,
|
||||
path string,
|
||||
query url.Values,
|
||||
out any,
|
||||
) error {
|
||||
resp, err := c.Get(ctx, path, query)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -94,7 +99,11 @@ func (c *Client) GetJSON(ctx context.Context, path string, query url.Values, out
|
|||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
func (c *Client) Get(ctx context.Context, path string, query url.Values) (*http.Response, error) {
|
||||
func (c *Client) Get(
|
||||
ctx context.Context,
|
||||
path string,
|
||||
query url.Values,
|
||||
) (*http.Response, error) {
|
||||
target, err := c.resolveURL(path, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -107,9 +116,12 @@ func (c *Client) Get(ctx context.Context, path string, query url.Values) (*http.
|
|||
|
||||
var resp *http.Response
|
||||
op := func() error {
|
||||
r, opErr := c.limiter.Do(ctx, func(rctx context.Context) (*http.Response, error) {
|
||||
return c.do(rctx, target)
|
||||
})
|
||||
r, opErr := c.limiter.Do(
|
||||
ctx,
|
||||
func(rctx context.Context) (*http.Response, error) {
|
||||
return c.do(rctx, target)
|
||||
},
|
||||
)
|
||||
if opErr != nil {
|
||||
return opErr
|
||||
}
|
||||
|
|
@ -131,7 +143,9 @@ func (c *Client) Get(ctx context.Context, path string, query url.Values) (*http.
|
|||
case r.StatusCode >= 400:
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, clientErrorBodyLimit))
|
||||
drainAndClose(r)
|
||||
return backoff.Permanent(&StatusError{Code: r.StatusCode, Body: string(body)})
|
||||
return backoff.Permanent(
|
||||
&StatusError{Code: r.StatusCode, Body: string(body)},
|
||||
)
|
||||
}
|
||||
resp = r
|
||||
return nil
|
||||
|
|
@ -142,7 +156,10 @@ func (c *Client) Get(ctx context.Context, path string, query url.Values) (*http.
|
|||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, target string) (*http.Response, error) {
|
||||
func (c *Client) do(
|
||||
ctx context.Context,
|
||||
target string,
|
||||
) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue