feat(monitor/collectors/cfradar): outages + hijacks repository with diff lookups

This commit is contained in:
CarterPerez-dev 2026-05-01 22:34:03 -04:00
parent fd861d587e
commit ada39ef6d9
4 changed files with 264 additions and 0 deletions

View File

@ -70,6 +70,7 @@ 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

View File

@ -131,6 +131,8 @@ github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLO
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=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=

View File

@ -0,0 +1,125 @@
// ©AngelaMos | 2026
// repo.go
package cfradar
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
)
type OutageRow struct {
ID string `db:"id"`
StartedAt time.Time `db:"started_at"`
EndedAt *time.Time `db:"ended_at"`
Locations pq.StringArray `db:"locations"`
ASNs pq.Int32Array `db:"asns"`
Cause string `db:"cause"`
OutageType string `db:"outage_type"`
Payload json.RawMessage `db:"payload"`
}
type HijackRow struct {
ID int64 `db:"id"`
DetectedAt time.Time `db:"detected_at"`
StartedAt time.Time `db:"started_at"`
DurationSec int32 `db:"duration_sec"`
Confidence int16 `db:"confidence"`
HijackerASN int32 `db:"hijacker_asn"`
VictimASNs pq.Int32Array `db:"victim_asns"`
Prefixes []string `db:"-"`
Payload json.RawMessage `db:"payload"`
}
type Repo struct {
db *sqlx.DB
}
func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} }
func (r *Repo) UpsertOutage(ctx context.Context, o OutageRow) error {
if o.Locations == nil {
o.Locations = pq.StringArray{}
}
if o.ASNs == nil {
o.ASNs = pq.Int32Array{}
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO outage_events (id, started_at, ended_at, locations, asns, cause, outage_type, payload)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (id) DO UPDATE SET
ended_at = EXCLUDED.ended_at,
locations = EXCLUDED.locations,
asns = EXCLUDED.asns,
cause = EXCLUDED.cause,
outage_type = EXCLUDED.outage_type,
payload = EXCLUDED.payload`,
o.ID, o.StartedAt, o.EndedAt, o.Locations, o.ASNs, o.Cause, o.OutageType, []byte(o.Payload),
)
if err != nil {
return fmt.Errorf("upsert outage %s: %w", o.ID, err)
}
return nil
}
func (r *Repo) UpsertHijack(ctx context.Context, h HijackRow) error {
if h.VictimASNs == nil {
h.VictimASNs = pq.Int32Array{}
}
if h.Prefixes == nil {
h.Prefixes = []string{}
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO bgp_hijack_events
(id, detected_at, started_at, duration_sec, confidence, hijacker_asn, victim_asns, prefixes, payload)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::cidr[], $9)
ON CONFLICT (id) DO UPDATE SET
detected_at = EXCLUDED.detected_at,
duration_sec = EXCLUDED.duration_sec,
confidence = EXCLUDED.confidence,
payload = EXCLUDED.payload`,
h.ID, h.DetectedAt, h.StartedAt, h.DurationSec, h.Confidence, h.HijackerASN,
h.VictimASNs, pq.Array(h.Prefixes), []byte(h.Payload),
)
if err != nil {
return fmt.Errorf("upsert hijack %d: %w", h.ID, err)
}
return nil
}
func (r *Repo) KnownOutageIDs(ctx context.Context, ids []string) (map[string]bool, error) {
if len(ids) == 0 {
return map[string]bool{}, nil
}
var found []string
if err := r.db.SelectContext(ctx, &found,
`SELECT id FROM outage_events WHERE id = ANY($1::text[])`, pq.Array(ids)); err != nil {
return nil, fmt.Errorf("known outage ids: %w", err)
}
out := make(map[string]bool, len(found))
for _, id := range found {
out[id] = true
}
return out, nil
}
func (r *Repo) KnownHijackIDs(ctx context.Context, ids []int64) (map[int64]bool, error) {
if len(ids) == 0 {
return map[int64]bool{}, nil
}
var found []int64
if err := r.db.SelectContext(ctx, &found,
`SELECT id FROM bgp_hijack_events WHERE id = ANY($1::bigint[])`, pq.Array(ids)); err != nil {
return nil, fmt.Errorf("known hijack ids: %w", err)
}
out := make(map[int64]bool, len(found))
for _, id := range found {
out[id] = true
}
return out, nil
}

View File

@ -0,0 +1,136 @@
// ©AngelaMos | 2026
// repo_test.go
package cfradar_test
import (
"context"
"encoding/json"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cfradar"
)
func setupDB(t *testing.T) *sqlx.DB {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
pg, err := postgres.Run(ctx, "postgres:17-alpine",
postgres.WithDatabase("monitor"),
postgres.WithUsername("monitor"),
postgres.WithPassword("monitor"),
postgres.BasicWaitStrategies(),
)
require.NoError(t, err)
t.Cleanup(func() { _ = pg.Terminate(context.Background()) })
dsn, err := pg.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
db, err := sqlx.ConnectContext(ctx, "pgx", dsn)
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
_, err = db.ExecContext(ctx, `
CREATE TABLE outage_events (
id text PRIMARY KEY,
started_at timestamptz NOT NULL,
ended_at timestamptz,
locations text[] NOT NULL DEFAULT '{}',
asns integer[] NOT NULL DEFAULT '{}',
cause text NOT NULL DEFAULT '',
outage_type text NOT NULL DEFAULT '',
payload jsonb NOT NULL
);
CREATE TABLE bgp_hijack_events (
id bigint PRIMARY KEY,
detected_at timestamptz NOT NULL,
started_at timestamptz NOT NULL,
duration_sec integer NOT NULL DEFAULT 0,
confidence smallint NOT NULL DEFAULT 0,
hijacker_asn integer NOT NULL DEFAULT 0,
victim_asns integer[] NOT NULL DEFAULT '{}',
prefixes cidr[] NOT NULL DEFAULT '{}',
payload jsonb NOT NULL
)`)
require.NoError(t, err)
return db
}
func TestRepo_UpsertOutageIsIdempotent(t *testing.T) {
db := setupDB(t)
repo := cfradar.NewRepo(db)
ctx := context.Background()
row := cfradar.OutageRow{
ID: "out-1",
StartedAt: time.Now().UTC().Add(-time.Hour),
Locations: pq.StringArray{"DE", "AT"},
ASNs: pq.Int32Array{15169},
Cause: "fiber-cut",
OutageType: "regional",
Payload: json.RawMessage(`{"id":"out-1"}`),
}
require.NoError(t, repo.UpsertOutage(ctx, row))
require.NoError(t, repo.UpsertOutage(ctx, row))
}
func TestRepo_UpsertHijackAndDiff(t *testing.T) {
db := setupDB(t)
repo := cfradar.NewRepo(db)
ctx := context.Background()
row := cfradar.HijackRow{
ID: 7700001,
DetectedAt: time.Now().UTC(),
StartedAt: time.Now().UTC().Add(-2 * time.Minute),
Confidence: 9,
HijackerASN: 64500,
VictimASNs: pq.Int32Array{15169, 13335},
Prefixes: []string{"203.0.113.0/24"},
Payload: json.RawMessage(`{"id":7700001}`),
}
require.NoError(t, repo.UpsertHijack(ctx, row))
known, err := repo.KnownHijackIDs(ctx, []int64{7700001, 7700002})
require.NoError(t, err)
require.Equal(t, map[int64]bool{7700001: true}, known)
}
func TestRepo_KnownOutageIDs(t *testing.T) {
db := setupDB(t)
repo := cfradar.NewRepo(db)
ctx := context.Background()
require.NoError(t, repo.UpsertOutage(ctx, cfradar.OutageRow{
ID: "out-known", StartedAt: time.Now().UTC(),
Payload: json.RawMessage(`{}`),
}))
known, err := repo.KnownOutageIDs(ctx, []string{"out-known", "out-new"})
require.NoError(t, err)
require.Equal(t, map[string]bool{"out-known": true}, known)
}
func TestRepo_KnownIDsHandlesEmptySlice(t *testing.T) {
db := setupDB(t)
repo := cfradar.NewRepo(db)
ctx := context.Background()
out, err := repo.KnownOutageIDs(ctx, nil)
require.NoError(t, err)
require.Empty(t, out)
hij, err := repo.KnownHijackIDs(ctx, nil)
require.NoError(t, err)
require.Empty(t, hij)
}