From 6fd847a90971f439f31373767365efc614b2d29f Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 13:10:42 +0700 Subject: [PATCH 01/12] =?UTF-8?q?feat(store):=20schedules,=20notification?= =?UTF-8?q?=5Fchannels,=20domain=20last=5Fcheck=5Fstatus=20+=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D0=BE=D0=B4=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/store/db/channels.sql.go | 148 ++++++++++ internal/store/db/domains.sql.go | 40 ++- internal/store/db/models.go | 20 ++ internal/store/db/schedules.sql.go | 110 ++++++++ .../store/migrations/0004_schedule_notify.sql | 24 ++ internal/store/queries/channels.sql | 15 + internal/store/queries/domains.sql | 6 + internal/store/queries/schedules.sql | 15 + internal/store/schedule_test.go | 267 ++++++++++++++++++ internal/store/tenant.go | 174 ++++++++++++ 10 files changed, 814 insertions(+), 5 deletions(-) create mode 100644 internal/store/db/channels.sql.go create mode 100644 internal/store/db/schedules.sql.go create mode 100644 internal/store/migrations/0004_schedule_notify.sql create mode 100644 internal/store/queries/channels.sql create mode 100644 internal/store/queries/schedules.sql create mode 100644 internal/store/schedule_test.go diff --git a/internal/store/db/channels.sql.go b/internal/store/db/channels.sql.go new file mode 100644 index 0000000..f6e6ffa --- /dev/null +++ b/internal/store/db/channels.sql.go @@ -0,0 +1,148 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: channels.sql + +package db + +import ( + "context" + + "github.com/google/uuid" +) + +const createChannel = `-- name: CreateChannel :one +INSERT INTO notification_channels (id, project_id, type, config, secret_enc) +VALUES ($1, $2, $3, $4, $5) RETURNING id, project_id, type, config, secret_enc, enabled, created_at +` + +type CreateChannelParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Type string `json:"type"` + Config []byte `json:"config"` + SecretEnc string `json:"secret_enc"` +} + +func (q *Queries) CreateChannel(ctx context.Context, arg CreateChannelParams) (NotificationChannel, error) { + row := q.db.QueryRow(ctx, createChannel, + arg.ID, + arg.ProjectID, + arg.Type, + arg.Config, + arg.SecretEnc, + ) + var i NotificationChannel + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Type, + &i.Config, + &i.SecretEnc, + &i.Enabled, + &i.CreatedAt, + ) + return i, err +} + +const deleteChannel = `-- name: DeleteChannel :exec +DELETE FROM notification_channels WHERE id = $1 AND project_id = $2 +` + +type DeleteChannelParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` +} + +func (q *Queries) DeleteChannel(ctx context.Context, arg DeleteChannelParams) error { + _, err := q.db.Exec(ctx, deleteChannel, arg.ID, arg.ProjectID) + return err +} + +const getChannel = `-- name: GetChannel :one +SELECT id, project_id, type, config, secret_enc, enabled, created_at FROM notification_channels WHERE id = $1 AND project_id = $2 +` + +type GetChannelParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` +} + +func (q *Queries) GetChannel(ctx context.Context, arg GetChannelParams) (NotificationChannel, error) { + row := q.db.QueryRow(ctx, getChannel, arg.ID, arg.ProjectID) + var i NotificationChannel + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Type, + &i.Config, + &i.SecretEnc, + &i.Enabled, + &i.CreatedAt, + ) + return i, err +} + +const listChannels = `-- name: ListChannels :many +SELECT id, project_id, type, config, secret_enc, enabled, created_at FROM notification_channels WHERE project_id = $1 ORDER BY created_at +` + +func (q *Queries) ListChannels(ctx context.Context, projectID uuid.UUID) ([]NotificationChannel, error) { + rows, err := q.db.Query(ctx, listChannels, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []NotificationChannel + for rows.Next() { + var i NotificationChannel + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.Type, + &i.Config, + &i.SecretEnc, + &i.Enabled, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listEnabledChannels = `-- name: ListEnabledChannels :many +SELECT id, project_id, type, config, secret_enc, enabled, created_at FROM notification_channels WHERE project_id = $1 AND enabled ORDER BY created_at +` + +func (q *Queries) ListEnabledChannels(ctx context.Context, projectID uuid.UUID) ([]NotificationChannel, error) { + rows, err := q.db.Query(ctx, listEnabledChannels, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []NotificationChannel + for rows.Next() { + var i NotificationChannel + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.Type, + &i.Config, + &i.SecretEnc, + &i.Enabled, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/store/db/domains.sql.go b/internal/store/db/domains.sql.go index 527bd66..dfa3255 100644 --- a/internal/store/db/domains.sql.go +++ b/internal/store/db/domains.sql.go @@ -15,7 +15,7 @@ import ( const createDomain = `-- name: CreateDomain :one INSERT INTO domains (id, project_id, provider_account_id, zone_name, zone_id, template_id) VALUES ($1, $2, $3, $4, $5, $6) -RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at +RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status ` type CreateDomainParams struct { @@ -45,6 +45,7 @@ func (q *Queries) CreateDomain(ctx context.Context, arg CreateDomainParams) (Dom &i.ZoneID, &i.TemplateID, &i.CreatedAt, + &i.LastCheckStatus, ) return i, err } @@ -64,7 +65,7 @@ func (q *Queries) DeleteDomain(ctx context.Context, arg DeleteDomainParams) erro } const getDomain = `-- name: GetDomain :one -SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at FROM domains WHERE id = $1 AND project_id = $2 +SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status FROM domains WHERE id = $1 AND project_id = $2 ` type GetDomainParams struct { @@ -83,15 +84,27 @@ func (q *Queries) GetDomain(ctx context.Context, arg GetDomainParams) (Domain, e &i.ZoneID, &i.TemplateID, &i.CreatedAt, + &i.LastCheckStatus, ) return i, err } +const getDomainStatus = `-- name: GetDomainStatus :one +SELECT last_check_status FROM domains WHERE id = $1 +` + +func (q *Queries) GetDomainStatus(ctx context.Context, id uuid.UUID) (string, error) { + row := q.db.QueryRow(ctx, getDomainStatus, id) + var last_check_status string + err := row.Scan(&last_check_status) + return last_check_status, err +} + const importDomain = `-- name: ImportDomain :one INSERT INTO domains (id, project_id, provider_account_id, zone_name, zone_id, template_id) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (project_id, zone_id) DO NOTHING -RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at +RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status ` type ImportDomainParams struct { @@ -121,12 +134,13 @@ func (q *Queries) ImportDomain(ctx context.Context, arg ImportDomainParams) (Dom &i.ZoneID, &i.TemplateID, &i.CreatedAt, + &i.LastCheckStatus, ) return i, err } const listDomains = `-- name: ListDomains :many -SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at FROM domains WHERE project_id = $1 ORDER BY created_at +SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status FROM domains WHERE project_id = $1 ORDER BY created_at ` func (q *Queries) ListDomains(ctx context.Context, projectID uuid.UUID) ([]Domain, error) { @@ -146,6 +160,7 @@ func (q *Queries) ListDomains(ctx context.Context, projectID uuid.UUID) ([]Domai &i.ZoneID, &i.TemplateID, &i.CreatedAt, + &i.LastCheckStatus, ); err != nil { return nil, err } @@ -189,9 +204,23 @@ func (q *Queries) LoadDomainFull(ctx context.Context, arg LoadDomainFullParams) return i, err } +const setDomainStatus = `-- name: SetDomainStatus :exec +UPDATE domains SET last_check_status = $2 WHERE id = $1 +` + +type SetDomainStatusParams struct { + ID uuid.UUID `json:"id"` + LastCheckStatus string `json:"last_check_status"` +} + +func (q *Queries) SetDomainStatus(ctx context.Context, arg SetDomainStatusParams) error { + _, err := q.db.Exec(ctx, setDomainStatus, arg.ID, arg.LastCheckStatus) + return err +} + const updateDomainTemplate = `-- name: UpdateDomainTemplate :one UPDATE domains SET template_id = $3 WHERE id = $1 AND project_id = $2 -RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at +RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status ` type UpdateDomainTemplateParams struct { @@ -211,6 +240,7 @@ func (q *Queries) UpdateDomainTemplate(ctx context.Context, arg UpdateDomainTemp &i.ZoneID, &i.TemplateID, &i.CreatedAt, + &i.LastCheckStatus, ) return i, err } diff --git a/internal/store/db/models.go b/internal/store/db/models.go index d541484..0c440e7 100644 --- a/internal/store/db/models.go +++ b/internal/store/db/models.go @@ -25,6 +25,17 @@ type Domain struct { ZoneID string `json:"zone_id"` TemplateID *uuid.UUID `json:"template_id"` CreatedAt pgtype.Timestamptz `json:"created_at"` + LastCheckStatus string `json:"last_check_status"` +} + +type NotificationChannel struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Type string `json:"type"` + Config []byte `json:"config"` + SecretEnc string `json:"secret_enc"` + Enabled bool `json:"enabled"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type Project struct { @@ -43,6 +54,15 @@ type ProviderAccount struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type Schedule struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + IntervalSeconds int32 `json:"interval_seconds"` + Enabled bool `json:"enabled"` + LastRunAt pgtype.Timestamptz `json:"last_run_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Session struct { ID uuid.UUID `json:"id"` UserID uuid.UUID `json:"user_id"` diff --git a/internal/store/db/schedules.sql.go b/internal/store/db/schedules.sql.go new file mode 100644 index 0000000..97b05ce --- /dev/null +++ b/internal/store/db/schedules.sql.go @@ -0,0 +1,110 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: schedules.sql + +package db + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const getSchedule = `-- name: GetSchedule :one +SELECT id, project_id, interval_seconds, enabled, last_run_at, created_at FROM schedules WHERE project_id = $1 +` + +func (q *Queries) GetSchedule(ctx context.Context, projectID uuid.UUID) (Schedule, error) { + row := q.db.QueryRow(ctx, getSchedule, projectID) + var i Schedule + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.IntervalSeconds, + &i.Enabled, + &i.LastRunAt, + &i.CreatedAt, + ) + return i, err +} + +const listDueSchedules = `-- name: ListDueSchedules :many +SELECT id, project_id, interval_seconds, enabled, last_run_at, created_at FROM schedules +WHERE enabled AND (last_run_at IS NULL OR last_run_at + (interval_seconds || ' seconds')::interval <= $1) +` + +func (q *Queries) ListDueSchedules(ctx context.Context, lastRunAt pgtype.Timestamptz) ([]Schedule, error) { + rows, err := q.db.Query(ctx, listDueSchedules, lastRunAt) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Schedule + for rows.Next() { + var i Schedule + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.IntervalSeconds, + &i.Enabled, + &i.LastRunAt, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const touchScheduleRun = `-- name: TouchScheduleRun :exec +UPDATE schedules SET last_run_at = $2 WHERE project_id = $1 +` + +type TouchScheduleRunParams struct { + ProjectID uuid.UUID `json:"project_id"` + LastRunAt pgtype.Timestamptz `json:"last_run_at"` +} + +func (q *Queries) TouchScheduleRun(ctx context.Context, arg TouchScheduleRunParams) error { + _, err := q.db.Exec(ctx, touchScheduleRun, arg.ProjectID, arg.LastRunAt) + return err +} + +const upsertSchedule = `-- name: UpsertSchedule :one +INSERT INTO schedules (id, project_id, interval_seconds, enabled) +VALUES ($1, $2, $3, $4) +ON CONFLICT (project_id) DO UPDATE SET interval_seconds = $3, enabled = $4 +RETURNING id, project_id, interval_seconds, enabled, last_run_at, created_at +` + +type UpsertScheduleParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + IntervalSeconds int32 `json:"interval_seconds"` + Enabled bool `json:"enabled"` +} + +func (q *Queries) UpsertSchedule(ctx context.Context, arg UpsertScheduleParams) (Schedule, error) { + row := q.db.QueryRow(ctx, upsertSchedule, + arg.ID, + arg.ProjectID, + arg.IntervalSeconds, + arg.Enabled, + ) + var i Schedule + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.IntervalSeconds, + &i.Enabled, + &i.LastRunAt, + &i.CreatedAt, + ) + return i, err +} diff --git a/internal/store/migrations/0004_schedule_notify.sql b/internal/store/migrations/0004_schedule_notify.sql new file mode 100644 index 0000000..c318dfd --- /dev/null +++ b/internal/store/migrations/0004_schedule_notify.sql @@ -0,0 +1,24 @@ +-- +goose Up +CREATE TABLE schedules ( + id uuid PRIMARY KEY, + project_id uuid NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE, + interval_seconds int NOT NULL DEFAULT 3600, + enabled boolean NOT NULL DEFAULT false, + last_run_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE TABLE notification_channels ( + id uuid PRIMARY KEY, + project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + type text NOT NULL, + config jsonb NOT NULL, + secret_enc text NOT NULL DEFAULT '', + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); +ALTER TABLE domains ADD COLUMN last_check_status text NOT NULL DEFAULT 'unknown'; + +-- +goose Down +ALTER TABLE domains DROP COLUMN last_check_status; +DROP TABLE notification_channels; +DROP TABLE schedules; diff --git a/internal/store/queries/channels.sql b/internal/store/queries/channels.sql new file mode 100644 index 0000000..6c2e75d --- /dev/null +++ b/internal/store/queries/channels.sql @@ -0,0 +1,15 @@ +-- name: CreateChannel :one +INSERT INTO notification_channels (id, project_id, type, config, secret_enc) +VALUES ($1, $2, $3, $4, $5) RETURNING *; + +-- name: ListChannels :many +SELECT * FROM notification_channels WHERE project_id = $1 ORDER BY created_at; + +-- name: ListEnabledChannels :many +SELECT * FROM notification_channels WHERE project_id = $1 AND enabled ORDER BY created_at; + +-- name: GetChannel :one +SELECT * FROM notification_channels WHERE id = $1 AND project_id = $2; + +-- name: DeleteChannel :exec +DELETE FROM notification_channels WHERE id = $1 AND project_id = $2; diff --git a/internal/store/queries/domains.sql b/internal/store/queries/domains.sql index 9e16759..72a8392 100644 --- a/internal/store/queries/domains.sql +++ b/internal/store/queries/domains.sql @@ -28,3 +28,9 @@ FROM domains d JOIN provider_accounts a ON a.id = d.provider_account_id LEFT JOIN templates t ON t.id = d.template_id WHERE d.id = $1 AND d.project_id = $2; + +-- name: GetDomainStatus :one +SELECT last_check_status FROM domains WHERE id = $1; + +-- name: SetDomainStatus :exec +UPDATE domains SET last_check_status = $2 WHERE id = $1; diff --git a/internal/store/queries/schedules.sql b/internal/store/queries/schedules.sql new file mode 100644 index 0000000..3075565 --- /dev/null +++ b/internal/store/queries/schedules.sql @@ -0,0 +1,15 @@ +-- name: GetSchedule :one +SELECT * FROM schedules WHERE project_id = $1; + +-- name: UpsertSchedule :one +INSERT INTO schedules (id, project_id, interval_seconds, enabled) +VALUES ($1, $2, $3, $4) +ON CONFLICT (project_id) DO UPDATE SET interval_seconds = $3, enabled = $4 +RETURNING *; + +-- name: ListDueSchedules :many +SELECT * FROM schedules +WHERE enabled AND (last_run_at IS NULL OR last_run_at + (interval_seconds || ' seconds')::interval <= $1); + +-- name: TouchScheduleRun :exec +UPDATE schedules SET last_run_at = $2 WHERE project_id = $1; diff --git a/internal/store/schedule_test.go b/internal/store/schedule_test.go new file mode 100644 index 0000000..1ef49aa --- /dev/null +++ b/internal/store/schedule_test.go @@ -0,0 +1,267 @@ +package store + +import ( + "encoding/json" + "errors" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// TestUpsertSchedule_InsertThenUpdate verifies UpsertSchedule inserts a new +// row for a project on the first call and updates that same row (rather +// than inserting a second one) on a subsequent call, per the +// ON CONFLICT (project_id) DO UPDATE clause. +func TestUpsertSchedule_InsertThenUpdate(t *testing.T) { + s, ctx := newStore(t) + _, p, err := s.RegisterUser(ctx, "sched-upsert@example.com", "argon2-hash") + if err != nil { + t.Fatal(err) + } + + created, err := s.UpsertSchedule(ctx, p.ID, 1800, true) + if err != nil { + t.Fatal(err) + } + if created.IntervalSeconds != 1800 || !created.Enabled { + t.Fatalf("unexpected created schedule: %+v", created) + } + + updated, err := s.UpsertSchedule(ctx, p.ID, 7200, false) + if err != nil { + t.Fatal(err) + } + if updated.ID != created.ID { + t.Fatalf("expected same row id, got created=%s updated=%s", created.ID, updated.ID) + } + if updated.IntervalSeconds != 7200 || updated.Enabled { + t.Fatalf("unexpected updated schedule: %+v", updated) + } + + got, err := s.GetSchedule(ctx, p.ID) + if err != nil { + t.Fatal(err) + } + if got.IntervalSeconds != 7200 || got.Enabled { + t.Fatalf("GetSchedule mismatch after update: %+v", got) + } +} + +// TestGetSchedule_NoRowReturnsErrNoRows verifies the contract used by the API +// layer (Task 5): a project with no schedule row yet returns pgx.ErrNoRows, +// which the API translates into the default {interval:3600, enabled:false}. +func TestGetSchedule_NoRowReturnsErrNoRows(t *testing.T) { + s, ctx := newStore(t) + _, p, err := s.RegisterUser(ctx, "sched-norow@example.com", "argon2-hash") + if err != nil { + t.Fatal(err) + } + + if _, err := s.GetSchedule(ctx, p.ID); !errors.Is(err, pgx.ErrNoRows) { + t.Fatalf("expected pgx.ErrNoRows, got %v", err) + } +} + +// TestListDueSchedules verifies the due-selection logic: an enabled schedule +// that never ran (last_run_at IS NULL) is due; a disabled schedule is never +// due; and an enabled schedule that ran recently with a long interval is not +// yet due. +func TestListDueSchedules(t *testing.T) { + s, ctx := newStore(t) + now := time.Now().UTC() + + _, neverRunProject, err := s.RegisterUser(ctx, "sched-neverrun@example.com", "argon2-hash") + if err != nil { + t.Fatal(err) + } + if _, err := s.UpsertSchedule(ctx, neverRunProject.ID, 3600, true); err != nil { + t.Fatal(err) + } + + _, disabledProject, err := s.RegisterUser(ctx, "sched-disabled@example.com", "argon2-hash") + if err != nil { + t.Fatal(err) + } + if _, err := s.UpsertSchedule(ctx, disabledProject.ID, 60, false); err != nil { + t.Fatal(err) + } + + _, recentProject, err := s.RegisterUser(ctx, "sched-recent@example.com", "argon2-hash") + if err != nil { + t.Fatal(err) + } + if _, err := s.UpsertSchedule(ctx, recentProject.ID, 3600, true); err != nil { + t.Fatal(err) + } + if err := s.TouchScheduleRun(ctx, recentProject.ID, now); err != nil { + t.Fatal(err) + } + + due, err := s.ListDueSchedules(ctx, now) + if err != nil { + t.Fatal(err) + } + + byProject := make(map[uuid.UUID]bool, len(due)) + for _, d := range due { + byProject[d.ProjectID] = true + } + + if !byProject[neverRunProject.ID] { + t.Errorf("expected enabled/never-run schedule for project %s to be due", neverRunProject.ID) + } + if byProject[disabledProject.ID] { + t.Errorf("did not expect disabled schedule for project %s to be due", disabledProject.ID) + } + if byProject[recentProject.ID] { + t.Errorf("did not expect recently-run schedule (long interval) for project %s to be due", recentProject.ID) + } +} + +// TestTouchScheduleRun_SetsLastRunAt verifies TouchScheduleRun persists +// last_run_at, which GetSchedule then returns as a non-nil *time.Time close +// to the value passed in. +func TestTouchScheduleRun_SetsLastRunAt(t *testing.T) { + s, ctx := newStore(t) + _, p, err := s.RegisterUser(ctx, "sched-touch@example.com", "argon2-hash") + if err != nil { + t.Fatal(err) + } + if _, err := s.UpsertSchedule(ctx, p.ID, 3600, true); err != nil { + t.Fatal(err) + } + + at := time.Now().UTC().Truncate(time.Second) + if err := s.TouchScheduleRun(ctx, p.ID, at); err != nil { + t.Fatal(err) + } + + got, err := s.GetSchedule(ctx, p.ID) + if err != nil { + t.Fatal(err) + } + if got.LastRunAt == nil { + t.Fatal("expected non-nil LastRunAt after TouchScheduleRun") + } + if diff := got.LastRunAt.Sub(at); diff < -time.Second || diff > time.Second { + t.Fatalf("expected LastRunAt ~%v, got %v", at, *got.LastRunAt) + } +} + +// TestChannelCRUD_ScopedByProject verifies CreateChannel/ListChannels/ +// GetChannel/DeleteChannel round-trip correctly and that GetChannel scopes +// by project_id: looking up a channel with the wrong project ID must fail +// with pgx.ErrNoRows rather than returning another tenant's channel. +func TestChannelCRUD_ScopedByProject(t *testing.T) { + s, ctx := newStore(t) + _, p1, err := s.RegisterUser(ctx, "chan-owner@example.com", "argon2-hash") + if err != nil { + t.Fatal(err) + } + _, p2, err := s.RegisterUser(ctx, "chan-other@example.com", "argon2-hash") + if err != nil { + t.Fatal(err) + } + + cfg := json.RawMessage(`{"webhook_url":"https://example.com/hook"}`) + ch, err := s.CreateChannel(ctx, p1.ID, "telegram", cfg, "enc-secret") + if err != nil { + t.Fatal(err) + } + // jsonb round-trips through Postgres with its own canonical formatting + // (e.g. a space after ':'), so compare decoded values rather than raw + // bytes. + var gotCfg, wantCfg map[string]string + if err := json.Unmarshal(ch.Config, &gotCfg); err != nil { + t.Fatalf("unmarshal returned config: %v", err) + } + if err := json.Unmarshal(cfg, &wantCfg); err != nil { + t.Fatalf("unmarshal expected config: %v", err) + } + if ch.Type != "telegram" || !ch.Enabled || gotCfg["webhook_url"] != wantCfg["webhook_url"] || ch.SecretEnc != "enc-secret" { + t.Fatalf("unexpected created channel: %+v", ch) + } + + list, err := s.ListChannels(ctx, p1.ID) + if err != nil { + t.Fatal(err) + } + if len(list) != 1 || list[0].ID != ch.ID { + t.Fatalf("unexpected ListChannels result: %+v", list) + } + + enabledList, err := s.ListEnabledChannels(ctx, p1.ID) + if err != nil { + t.Fatal(err) + } + if len(enabledList) != 1 || enabledList[0].ID != ch.ID { + t.Fatalf("unexpected ListEnabledChannels result: %+v", enabledList) + } + + got, err := s.GetChannel(ctx, ch.ID, p1.ID) + if err != nil { + t.Fatal(err) + } + if got.ID != ch.ID { + t.Fatalf("GetChannel mismatch: %+v", got) + } + + if _, err := s.GetChannel(ctx, ch.ID, p2.ID); !errors.Is(err, pgx.ErrNoRows) { + t.Fatalf("expected pgx.ErrNoRows for foreign project, got %v", err) + } + + if err := s.DeleteChannel(ctx, ch.ID, p1.ID); err != nil { + t.Fatal(err) + } + if _, err := s.GetChannel(ctx, ch.ID, p1.ID); !errors.Is(err, pgx.ErrNoRows) { + t.Fatalf("expected pgx.ErrNoRows after delete, got %v", err) + } +} + +// TestDomainStatus_RoundTrip verifies SetDomainStatus/GetDomainStatus +// round-trip, and that a freshly-imported domain defaults to "unknown" per +// the migration's DEFAULT 'unknown'. +func TestDomainStatus_RoundTrip(t *testing.T) { + s, ctx := newStore(t) + _, p, err := s.RegisterUser(ctx, "domain-status@example.com", "argon2-hash") + if err != nil { + t.Fatal(err) + } + acc, err := s.CreateAccount(ctx, p.ID, "selectel", "enc-blob", "test") + if err != nil { + t.Fatal(err) + } + d, err := s.CreateDomain(ctx, p.ID, acc.ID, "example.com", "zone-1", nil) + if err != nil { + t.Fatal(err) + } + + status, err := s.GetDomainStatus(ctx, d.ID) + if err != nil { + t.Fatal(err) + } + if status != "unknown" { + t.Fatalf("expected default status 'unknown', got %q", status) + } + + if err := s.SetDomainStatus(ctx, d.ID, "ok"); err != nil { + t.Fatal(err) + } + status, err = s.GetDomainStatus(ctx, d.ID) + if err != nil { + t.Fatal(err) + } + if status != "ok" { + t.Fatalf("expected status 'ok' after SetDomainStatus, got %q", status) + } + + domains, err := s.ListDomains(ctx, p.ID) + if err != nil { + t.Fatal(err) + } + if len(domains) != 1 || domains[0].LastCheckStatus != "ok" { + t.Fatalf("expected ListDomains to reflect updated status: %+v", domains) + } +} diff --git a/internal/store/tenant.go b/internal/store/tenant.go index fccc469..60c15eb 100644 --- a/internal/store/tenant.go +++ b/internal/store/tenant.go @@ -2,6 +2,7 @@ package store import ( "context" + "encoding/json" "errors" "time" @@ -137,12 +138,14 @@ type Domain struct { ZoneName string ZoneID string TemplateID *uuid.UUID + LastCheckStatus string } func domainFromDB(d db.Domain) Domain { return Domain{ ID: d.ID, ProjectID: d.ProjectID, ProviderAccountID: d.ProviderAccountID, ZoneName: d.ZoneName, ZoneID: d.ZoneID, TemplateID: d.TemplateID, + LastCheckStatus: d.LastCheckStatus, } } @@ -231,6 +234,19 @@ func (s *Store) SetDomainTemplate(ctx context.Context, domainID, projectID uuid. return domainFromDB(d), nil } +// GetDomainStatus returns the last known check status for a domain (Фаза 3 +// scheduler/checker). Callers scope access to the domain themselves (e.g. +// via a prior GetDomain) — this lookup is by primary key alone. +func (s *Store) GetDomainStatus(ctx context.Context, domainID uuid.UUID) (string, error) { + return s.q.GetDomainStatus(ctx, domainID) +} + +// SetDomainStatus records the outcome of the most recent check/apply run for +// a domain (e.g. "ok", "drift", "error"). +func (s *Store) SetDomainStatus(ctx context.Context, domainID uuid.UUID, status string) error { + return s.q.SetDomainStatus(ctx, db.SetDomainStatusParams{ID: domainID, LastCheckStatus: status}) +} + // User and Project are provider-neutral domain structs for the auth/tenant // layer (Фаза 2), mirroring the Account/Template/Domain wrappers above so // callers never need to import internal/store/db directly. @@ -369,3 +385,161 @@ func (s *Store) RegisterUser(ctx context.Context, email, passwordHash string) (U } return toUser(dbu), toProject(dbp), nil } + +// Schedule and Channel are provider-neutral domain structs for the +// scheduler/notifications layer (Фаза 3), mirroring the wrappers above so +// callers never need to import internal/store/db or pgtype directly. + +type Schedule struct { + ID uuid.UUID + ProjectID uuid.UUID + IntervalSeconds int32 + Enabled bool + LastRunAt *time.Time +} + +// timeFromTimestamptz converts a nullable pgtype.Timestamptz (schedules.last_run_at) +// into a *time.Time, nil when the column is NULL (schedule never ran). +func timeFromTimestamptz(t pgtype.Timestamptz) *time.Time { + if !t.Valid { + return nil + } + tt := t.Time + return &tt +} + +// timestamptzFromTime is the inverse of timeFromTimestamptz, used to pass a +// Go time.Time (or nil) into a nullable timestamptz query parameter. +func timestamptzFromTime(t *time.Time) pgtype.Timestamptz { + if t == nil { + return pgtype.Timestamptz{} + } + return pgtype.Timestamptz{Time: *t, Valid: true} +} + +func scheduleFromDB(s db.Schedule) Schedule { + return Schedule{ + ID: s.ID, + ProjectID: s.ProjectID, + IntervalSeconds: s.IntervalSeconds, + Enabled: s.Enabled, + LastRunAt: timeFromTimestamptz(s.LastRunAt), + } +} + +// GetSchedule looks up the schedule row for projectID. When no schedule has +// ever been created for the project it returns pgx.ErrNoRows unwrapped — +// the API layer (Task 5) is expected to treat that as the default schedule +// {interval: 3600, enabled: false} rather than an error. +func (s *Store) GetSchedule(ctx context.Context, projectID uuid.UUID) (Schedule, error) { + sc, err := s.q.GetSchedule(ctx, projectID) + if err != nil { + return Schedule{}, err + } + return scheduleFromDB(sc), nil +} + +// UpsertSchedule creates or updates the (single, UNIQUE) schedule row for a +// project: an existing row has its interval/enabled flag updated in place +// rather than a second row being inserted. +func (s *Store) UpsertSchedule(ctx context.Context, projectID uuid.UUID, interval int32, enabled bool) (Schedule, error) { + sc, err := s.q.UpsertSchedule(ctx, db.UpsertScheduleParams{ + ID: uuid.New(), ProjectID: projectID, IntervalSeconds: interval, Enabled: enabled, + }) + if err != nil { + return Schedule{}, err + } + return scheduleFromDB(sc), nil +} + +// ListDueSchedules returns every enabled schedule that is due to run at +// `now`: either it has never run (last_run_at IS NULL) or its interval has +// elapsed since the last run. +func (s *Store) ListDueSchedules(ctx context.Context, now time.Time) ([]Schedule, error) { + rows, err := s.q.ListDueSchedules(ctx, pgtype.Timestamptz{Time: now, Valid: true}) + if err != nil { + return nil, err + } + out := make([]Schedule, 0, len(rows)) + for _, r := range rows { + out = append(out, scheduleFromDB(r)) + } + return out, nil +} + +// TouchScheduleRun records that a project's schedule ran at `at`, so the +// next ListDueSchedules call excludes it until the interval elapses again. +func (s *Store) TouchScheduleRun(ctx context.Context, projectID uuid.UUID, at time.Time) error { + return s.q.TouchScheduleRun(ctx, db.TouchScheduleRunParams{ + ProjectID: projectID, LastRunAt: timestamptzFromTime(&at), + }) +} + +type Channel struct { + ID uuid.UUID + ProjectID uuid.UUID + Type string + Config json.RawMessage + SecretEnc string + Enabled bool +} + +// channelFromDB never logs SecretEnc — callers must not either (secrets are +// encrypted at rest, but the plaintext blob should still stay out of logs). +func channelFromDB(c db.NotificationChannel) Channel { + return Channel{ + ID: c.ID, ProjectID: c.ProjectID, Type: c.Type, + Config: json.RawMessage(c.Config), SecretEnc: c.SecretEnc, Enabled: c.Enabled, + } +} + +func (s *Store) CreateChannel(ctx context.Context, projectID uuid.UUID, ctype string, config json.RawMessage, secretEnc string) (Channel, error) { + c, err := s.q.CreateChannel(ctx, db.CreateChannelParams{ + ID: uuid.New(), ProjectID: projectID, Type: ctype, Config: []byte(config), SecretEnc: secretEnc, + }) + if err != nil { + return Channel{}, err + } + return channelFromDB(c), nil +} + +func (s *Store) ListChannels(ctx context.Context, projectID uuid.UUID) ([]Channel, error) { + rows, err := s.q.ListChannels(ctx, projectID) + if err != nil { + return nil, err + } + out := make([]Channel, 0, len(rows)) + for _, r := range rows { + out = append(out, channelFromDB(r)) + } + return out, nil +} + +// ListEnabledChannels returns only the channels a project has enabled — used +// by the notification dispatcher (Фаза 3) so disabled channels are silently +// skipped rather than filtered by every caller. +func (s *Store) ListEnabledChannels(ctx context.Context, projectID uuid.UUID) ([]Channel, error) { + rows, err := s.q.ListEnabledChannels(ctx, projectID) + if err != nil { + return nil, err + } + out := make([]Channel, 0, len(rows)) + for _, r := range rows { + out = append(out, channelFromDB(r)) + } + return out, nil +} + +// GetChannel is scoped by projectID: a channel ID belonging to another +// tenant's project returns pgx.ErrNoRows rather than the foreign channel. +func (s *Store) GetChannel(ctx context.Context, id, projectID uuid.UUID) (Channel, error) { + c, err := s.q.GetChannel(ctx, db.GetChannelParams{ID: id, ProjectID: projectID}) + if err != nil { + return Channel{}, err + } + return channelFromDB(c), nil +} + +func (s *Store) DeleteChannel(ctx context.Context, id, projectID uuid.UUID) error { + return s.q.DeleteChannel(ctx, db.DeleteChannelParams{ID: id, ProjectID: projectID}) +} From 98d8dee413bff672a530cdf73c440830587f85aa Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 13:18:58 +0700 Subject: [PATCH 02/12] feat(metrics): Prometheus registry (checks/drift/notifications) + /metrics handler --- go.mod | 9 ++++ go.sum | 18 ++++++++ internal/metrics/metrics.go | 74 ++++++++++++++++++++++++++++++++ internal/metrics/metrics_test.go | 38 ++++++++++++++++ 4 files changed, 139 insertions(+) create mode 100644 internal/metrics/metrics.go create mode 100644 internal/metrics/metrics_test.go diff --git a/go.mod b/go.mod index 59e8617..984679c 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( 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/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -36,6 +37,7 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.5 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mfridman/interpolate v0.0.2 // indirect @@ -48,10 +50,15 @@ require ( github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/shirou/gopsutil/v4 v4.26.5 // indirect github.com/sirupsen/logrus v1.9.4 // indirect @@ -65,8 +72,10 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 92810b7..19cda10 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -65,6 +67,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= @@ -95,6 +99,8 @@ 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -107,6 +113,14 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/pressly/goose/v3 v3.27.2 h1:FjKNzcmMdGrQlSIu5alMSmakQtJFBgtw+A0bb1p/LC8= github.com/pressly/goose/v3 v3.27.2/go.mod h1:qWW+/8dkVtJYjJrbIpwD5xxnEJTUKvxkQ9JKQp9LaIM= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -150,6 +164,8 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09 go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= @@ -164,6 +180,8 @@ golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/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= diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 0000000..5a80224 --- /dev/null +++ b/internal/metrics/metrics.go @@ -0,0 +1,74 @@ +// Package metrics предоставляет Prometheus-метрики DNS Autoresolver. +package metrics + +import ( + "net/http" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// Metrics агрегирует Prometheus-метрики приложения на собственном реестре. +type Metrics struct { + Registry *prometheus.Registry + ChecksTotal *prometheus.CounterVec + CheckDuration prometheus.Histogram + DriftDomains prometheus.Gauge + NotificationsTotal *prometheus.CounterVec +} + +// New создаёт реестр метрик, регистрирует стандартные Go/Process-коллекторы +// и все метрики приложения. +func New() *Metrics { + reg := prometheus.NewRegistry() + reg.MustRegister( + collectors.NewGoCollector(), + collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), + ) + + f := promauto.With(reg) + + return &Metrics{ + Registry: reg, + ChecksTotal: f.NewCounterVec(prometheus.CounterOpts{ + Name: "dns_ar_checks_total", + Help: "Общее количество выполненных проверок доменов по статусу.", + }, []string{"status"}), + CheckDuration: f.NewHistogram(prometheus.HistogramOpts{ + Name: "dns_ar_check_duration_seconds", + Help: "Длительность выполнения проверки домена в секундах.", + }), + DriftDomains: f.NewGauge(prometheus.GaugeOpts{ + Name: "dns_ar_drift_domains", + Help: "Текущее количество доменов в состоянии drift.", + }), + NotificationsTotal: f.NewCounterVec(prometheus.CounterOpts{ + Name: "dns_ar_notifications_total", + Help: "Общее количество отправленных уведомлений по каналу и статусу.", + }, []string{"channel", "status"}), + } +} + +// Handler возвращает HTTP-обработчик для отдачи метрик реестра. +func (m *Metrics) Handler() http.Handler { + return promhttp.HandlerFor(m.Registry, promhttp.HandlerOpts{}) +} + +// ObserveCheck фиксирует результат проверки: статус и длительность. +func (m *Metrics) ObserveCheck(status string, dur time.Duration) { + m.ChecksTotal.WithLabelValues(status).Inc() + m.CheckDuration.Observe(dur.Seconds()) +} + +// SetDrift устанавливает текущее количество доменов в состоянии drift. +func (m *Metrics) SetDrift(n int) { + m.DriftDomains.Set(float64(n)) +} + +// IncNotification фиксирует отправку уведомления по каналу и статусу. +func (m *Metrics) IncNotification(channel, status string) { + m.NotificationsTotal.WithLabelValues(channel, status).Inc() +} diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go new file mode 100644 index 0000000..bb19568 --- /dev/null +++ b/internal/metrics/metrics_test.go @@ -0,0 +1,38 @@ +package metrics + +import ( + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus/testutil" +) + +func TestMetricsRecord(t *testing.T) { + m := New() + m.ObserveCheck("drift", 100*time.Millisecond) + m.ObserveCheck("in_sync", 50*time.Millisecond) + m.IncNotification("telegram", "ok") + m.SetDrift(3) + + if got := testutil.ToFloat64(m.ChecksTotal.WithLabelValues("drift")); got != 1 { + t.Fatalf("checks drift = %v", got) + } + if got := testutil.ToFloat64(m.DriftDomains); got != 3 { + t.Fatalf("drift gauge = %v", got) + } + if got := testutil.ToFloat64(m.NotificationsTotal.WithLabelValues("telegram", "ok")); got != 1 { + t.Fatalf("notif = %v", got) + } +} + +func TestHandlerExposesMetrics(t *testing.T) { + m := New() + m.ObserveCheck("in_sync", time.Millisecond) + rec := httptest.NewRecorder() + m.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/metrics", nil)) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "dns_ar_checks_total") { + t.Fatalf("metrics not exposed: %d", rec.Code) + } +} From e82fb0b13d8c9606138bbb3a6ae806d61325f476 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 13:19:21 +0700 Subject: [PATCH 03/12] =?UTF-8?q?feat(notify):=20Telegram/Webhook=20=D0=BD?= =?UTF-8?q?=D0=BE=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D1=8B=20+=20Dispatcher=20=D0=BF=D0=BE=20=D0=BA=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=D0=B0=D0=BC=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/notify/dispatch.go | 75 ++++++++++++ internal/notify/notify.go | 27 ++++ internal/notify/notify_test.go | 217 +++++++++++++++++++++++++++++++++ internal/notify/telegram.go | 45 +++++++ internal/notify/webhook.go | 43 +++++++ 5 files changed, 407 insertions(+) create mode 100644 internal/notify/dispatch.go create mode 100644 internal/notify/notify.go create mode 100644 internal/notify/notify_test.go create mode 100644 internal/notify/telegram.go create mode 100644 internal/notify/webhook.go diff --git a/internal/notify/dispatch.go b/internal/notify/dispatch.go new file mode 100644 index 0000000..3d2329e --- /dev/null +++ b/internal/notify/dispatch.go @@ -0,0 +1,75 @@ +package notify + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/vasyakrg/dns-autoresolver/internal/store" +) + +// ChannelStore is the narrow store dependency Dispatcher needs: the set of +// enabled notification channels for a project. +type ChannelStore interface { + ListEnabledChannels(ctx context.Context, projectID uuid.UUID) ([]store.Channel, error) +} + +// Decryptor decrypts a channel's stored secret (bot token, signing key, ...). +type Decryptor interface { + Decrypt(enc string) ([]byte, error) +} + +// Dispatcher fans an Event out to every enabled channel of a project, +// picking the Notifier implementation by channel type. A failure on one +// channel does not stop delivery to the others; all errors are aggregated +// via errors.Join. +type Dispatcher struct { + store ChannelStore + cipher Decryptor + byType map[string]Notifier +} + +// NewDispatcher builds a Dispatcher wired with the default Telegram and +// Webhook notifiers. +func NewDispatcher(store ChannelStore, cipher Decryptor) *Dispatcher { + return &Dispatcher{ + store: store, + cipher: cipher, + byType: map[string]Notifier{ + "telegram": &Telegram{BaseURL: "https://api.telegram.org", HTTP: &http.Client{Timeout: 15 * time.Second}}, + "webhook": &Webhook{HTTP: &http.Client{Timeout: 15 * time.Second}}, + }, + } +} + +// Send delivers ev to every enabled channel of projectID. Errors from +// individual channels are aggregated (via errors.Join) rather than aborting +// delivery to the remaining channels. +func (d *Dispatcher) Send(ctx context.Context, projectID uuid.UUID, ev Event) error { + channels, err := d.store.ListEnabledChannels(ctx, projectID) + if err != nil { + return err + } + var errs []error + for _, ch := range channels { + n, ok := d.byType[ch.Type] + if !ok { + continue + } + secret := "" + if ch.SecretEnc != "" { + b, err := d.cipher.Decrypt(ch.SecretEnc) + if err != nil { + errs = append(errs, err) + continue + } + secret = string(b) + } + if err := n.Send(ctx, ch.Config, secret, ev); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..cc1e510 --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,27 @@ +// Package notify sends drift/error notifications to project-configured +// channels (Telegram, generic webhooks, ...). Notifier implementations must +// never log the secret they receive (bot tokens, HMAC keys, etc.). +package notify + +import ( + "context" + "encoding/json" + "time" +) + +// Event describes a single notification-worthy occurrence for a domain +// belonging to a project (e.g. a status change detected by the scheduler). +type Event struct { + Project string + Domain string + Status string + Summary string + At time.Time +} + +// Notifier delivers an Event to a channel described by cfg (channel-type +// specific JSON config) and secret (decrypted credential, e.g. a bot token). +// Implementations must not log secret. +type Notifier interface { + Send(ctx context.Context, cfg json.RawMessage, secret string, ev Event) error +} diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go new file mode 100644 index 0000000..835d4b4 --- /dev/null +++ b/internal/notify/notify_test.go @@ -0,0 +1,217 @@ +package notify + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/vasyakrg/dns-autoresolver/internal/store" +) + +func TestTelegramSendSuccess(t *testing.T) { + var gotPath string + var gotBody map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + tg := &Telegram{BaseURL: srv.URL, HTTP: srv.Client()} + ev := Event{Project: "proj", Domain: "example.com", Status: "drift", Summary: "A record changed", At: time.Now()} + + err := tg.Send(context.Background(), json.RawMessage(`{"chat_id":"12345"}`), "sekret-token", ev) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotPath != "/botsekret-token/sendMessage" { + t.Fatalf("unexpected path: %s", gotPath) + } + if gotBody["chat_id"] != "12345" { + t.Fatalf("unexpected chat_id: %+v", gotBody) + } + if !strings.Contains(gotBody["text"], "example.com") || !strings.Contains(gotBody["text"], "drift") { + t.Fatalf("unexpected text: %+v", gotBody) + } +} + +func TestTelegramSendServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + tg := &Telegram{BaseURL: srv.URL, HTTP: srv.Client()} + ev := Event{Project: "proj", Domain: "example.com", Status: "drift", Summary: "x", At: time.Now()} + + if err := tg.Send(context.Background(), json.RawMessage(`{"chat_id":"1"}`), "tok", ev); err == nil { + t.Fatal("expected error on 500 response") + } +} + +func TestWebhookSendSuccess(t *testing.T) { + var gotEvent Event + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("unexpected method: %s", r.Method) + } + _ = json.NewDecoder(r.Body).Decode(&gotEvent) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + wh := &Webhook{HTTP: srv.Client()} + ev := Event{Project: "proj", Domain: "example.com", Status: "in_sync", Summary: "resolved", At: time.Now()} + + cfg, _ := json.Marshal(map[string]string{"url": srv.URL}) + if err := wh.Send(context.Background(), cfg, "", ev); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotEvent.Domain != "example.com" || gotEvent.Status != "in_sync" { + t.Fatalf("unexpected event delivered: %+v", gotEvent) + } +} + +func TestWebhookSendNonSuccessStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + defer srv.Close() + + wh := &Webhook{HTTP: srv.Client()} + ev := Event{Project: "proj", Domain: "example.com", Status: "drift", Summary: "x", At: time.Now()} + cfg, _ := json.Marshal(map[string]string{"url": srv.URL}) + + if err := wh.Send(context.Background(), cfg, "", ev); err == nil { + t.Fatal("expected error on 400 response") + } +} + +// --- Dispatcher --- + +type mockChannelStore struct { + channels []store.Channel + err error +} + +func (m *mockChannelStore) ListEnabledChannels(ctx context.Context, projectID uuid.UUID) ([]store.Channel, error) { + return m.channels, m.err +} + +type mockDecryptor struct { + fail bool +} + +func (m *mockDecryptor) Decrypt(enc string) ([]byte, error) { + if m.fail { + return nil, errBoom + } + return []byte("decrypted-" + enc), nil +} + +var errBoom = &boomErr{} + +type boomErr struct{} + +func (*boomErr) Error() string { return "decrypt boom" } + +func TestDispatcherSendsToAllChannelsAndAggregatesErrors(t *testing.T) { + var tgCalled, whCalled bool + var tgSecret string + + tgSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tgCalled = true + w.WriteHeader(http.StatusOK) + })) + defer tgSrv.Close() + + whSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + whCalled = true + w.WriteHeader(http.StatusInternalServerError) // webhook fails + })) + defer whSrv.Close() + + projectID := uuid.New() + channels := []store.Channel{ + { + ID: uuid.New(), ProjectID: projectID, Type: "telegram", + Config: json.RawMessage(`{"chat_id":"1"}`), SecretEnc: "enc-token", Enabled: true, + }, + { + ID: uuid.New(), ProjectID: projectID, Type: "webhook", + Config: json.RawMessage(`{"url":"` + whSrv.URL + `"}`), SecretEnc: "", Enabled: true, + }, + } + + d := NewDispatcher(&mockChannelStore{channels: channels}, &mockDecryptor{}) + // Redirect telegram to the httptest server and capture the decrypted secret. + d.byType["telegram"] = notifierFunc(func(ctx context.Context, cfg json.RawMessage, secret string, ev Event) error { + tgSecret = secret + tg := &Telegram{BaseURL: tgSrv.URL, HTTP: tgSrv.Client()} + return tg.Send(ctx, cfg, secret, ev) + }) + + ev := Event{Project: "proj", Domain: "example.com", Status: "drift", Summary: "changed", At: time.Now()} + err := d.Send(context.Background(), projectID, ev) + + if !tgCalled { + t.Error("expected telegram notifier to be called") + } + if !whCalled { + t.Error("expected webhook notifier to be called") + } + if err == nil { + t.Fatal("expected aggregated error because webhook failed") + } + if tgSecret != "decrypted-enc-token" { + t.Fatalf("expected decrypted secret to be passed to telegram, got %q", tgSecret) + } +} + +func TestDispatcherSkipsUnknownChannelType(t *testing.T) { + projectID := uuid.New() + channels := []store.Channel{ + {ID: uuid.New(), ProjectID: projectID, Type: "carrier-pigeon", Config: json.RawMessage(`{}`), Enabled: true}, + } + d := NewDispatcher(&mockChannelStore{channels: channels}, &mockDecryptor{}) + if err := d.Send(context.Background(), projectID, Event{Project: "p", Domain: "d", Status: "drift"}); err != nil { + t.Fatalf("unexpected error for unknown channel type: %v", err) + } +} + +func TestDispatcherDecryptFailureIsAggregatedNotFatal(t *testing.T) { + var whCalled bool + whSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + whCalled = true + w.WriteHeader(http.StatusOK) + })) + defer whSrv.Close() + + projectID := uuid.New() + channels := []store.Channel{ + {ID: uuid.New(), ProjectID: projectID, Type: "telegram", Config: json.RawMessage(`{"chat_id":"1"}`), SecretEnc: "enc", Enabled: true}, + {ID: uuid.New(), ProjectID: projectID, Type: "webhook", Config: json.RawMessage(`{"url":"` + whSrv.URL + `"}`), Enabled: true}, + } + d := NewDispatcher(&mockChannelStore{channels: channels}, &mockDecryptor{fail: true}) + + err := d.Send(context.Background(), projectID, Event{Project: "p", Domain: "d", Status: "drift"}) + if err == nil { + t.Fatal("expected error due to decrypt failure") + } + if !whCalled { + t.Error("expected webhook channel to still be attempted after telegram decrypt failure") + } +} + +// notifierFunc adapts a function to the Notifier interface for tests. +type notifierFunc func(ctx context.Context, cfg json.RawMessage, secret string, ev Event) error + +func (f notifierFunc) Send(ctx context.Context, cfg json.RawMessage, secret string, ev Event) error { + return f(ctx, cfg, secret, ev) +} diff --git a/internal/notify/telegram.go b/internal/notify/telegram.go new file mode 100644 index 0000000..57d85b0 --- /dev/null +++ b/internal/notify/telegram.go @@ -0,0 +1,45 @@ +package notify + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" +) + +// Telegram delivers notifications via the Telegram Bot API sendMessage +// endpoint. Config is {"chat_id": "..."}; secret is the bot token and is +// never logged. +type Telegram struct { + BaseURL string + HTTP *http.Client +} + +func (t *Telegram) Send(ctx context.Context, cfg json.RawMessage, secret string, ev Event) error { + var c struct { + ChatID string `json:"chat_id"` + } + if err := json.Unmarshal(cfg, &c); err != nil { + return err + } + body, _ := json.Marshal(map[string]string{ + "chat_id": c.ChatID, + "text": fmt.Sprintf("[%s] %s → %s\n%s", ev.Project, ev.Domain, ev.Status, ev.Summary), + }) + url := t.BaseURL + "/bot" + secret + "/sendMessage" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := t.HTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("telegram: status %d", resp.StatusCode) + } + return nil +} diff --git a/internal/notify/webhook.go b/internal/notify/webhook.go new file mode 100644 index 0000000..06e62c2 --- /dev/null +++ b/internal/notify/webhook.go @@ -0,0 +1,43 @@ +package notify + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" +) + +// Webhook delivers notifications as a JSON POST of the Event to a +// project-configured URL. Config is {"url": "..."}. secret is currently +// unused (reserved for future request signing) and is never logged. +type Webhook struct { + HTTP *http.Client +} + +func (w *Webhook) Send(ctx context.Context, cfg json.RawMessage, secret string, ev Event) error { + var c struct { + URL string `json:"url"` + } + if err := json.Unmarshal(cfg, &c); err != nil { + return err + } + body, err := json.Marshal(ev) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := w.HTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("webhook: status %d", resp.StatusCode) + } + return nil +} From 7d4bf153d703419921d489bb19015d396bfa8a15 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 13:24:50 +0700 Subject: [PATCH 04/12] =?UTF-8?q?feat(api):=20CRUD=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F/=D0=BA=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=B2=20+=20=D1=82=D0=B5=D1=81=D1=82-=D0=BE?= =?UTF-8?q?=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20+=20=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 5 Фазы 3: GET/PUT /schedule (дефолт при отсутствии строки, валидация interval>=60), POST/GET/DELETE /channels (секрет шифруется Cipher, никогда не возвращается в ответах), POST /channels/{cid}/test через узкий TestSender-интерфейс (200/502 без утечки секрета), GET /domains/{did}/history (сначала GetDomain для project-scoping, затем ListCheckRuns — иначе IDOR через check_runs, который сам по себе не scoped по project). Добавлены store.GetDomain (обёртка над существующим sqlc-запросом) и store.ListCheckRuns (новый запрос + sqlc regen) для поддержки истории. --- internal/api/api.go | 14 + internal/api/schedule_handlers.go | 276 ++++++++++++++++ internal/api/schedule_test.go | 433 ++++++++++++++++++++++++++ internal/store/db/check_runs.sql.go | 29 ++ internal/store/loader.go | 35 +++ internal/store/queries/check_runs.sql | 3 + internal/store/tenant.go | 11 + 7 files changed, 801 insertions(+) create mode 100644 internal/api/schedule_handlers.go create mode 100644 internal/api/schedule_test.go diff --git a/internal/api/api.go b/internal/api/api.go index cd01cf4..d2ee30f 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -85,6 +85,8 @@ type API struct { Reg ProviderRegistry Auth AuthStore Sessions SessionManager + Schedule ScheduleStore + Dispatch TestSender } func NewRouter(a *API) http.Handler { @@ -114,6 +116,18 @@ func NewRouter(a *API) http.Handler { r.Post("/apply", a.handleApply) r.Patch("/", a.handleSetDomainTemplate) r.Delete("/", a.handleDeleteDomain) + r.Get("/history", a.handleDomainHistory) + }) + }) + + r.Get("/schedule", a.handleGetSchedule) + r.Put("/schedule", a.handlePutSchedule) + r.Route("/channels", func(r chi.Router) { + r.Post("/", a.handleCreateChannel) + r.Get("/", a.handleListChannels) + r.Route("/{cid}", func(r chi.Router) { + r.Delete("/", a.handleDeleteChannel) + r.Post("/test", a.handleTestChannel) }) }) diff --git a/internal/api/schedule_handlers.go b/internal/api/schedule_handlers.go new file mode 100644 index 0000000..7bc6e52 --- /dev/null +++ b/internal/api/schedule_handlers.go @@ -0,0 +1,276 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "log" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + "github.com/vasyakrg/dns-autoresolver/internal/store" +) + +// ScheduleStore is the persistence surface the schedule/channels/history +// handlers depend on. *store.Store satisfies it directly via its thin +// wrapper methods (see internal/store/tenant.go, internal/store/loader.go); +// tests can supply their own mock. +type ScheduleStore interface { + GetSchedule(ctx context.Context, projectID uuid.UUID) (store.Schedule, error) + UpsertSchedule(ctx context.Context, projectID uuid.UUID, interval int32, enabled bool) (store.Schedule, error) + + CreateChannel(ctx context.Context, projectID uuid.UUID, ctype string, config json.RawMessage, secretEnc string) (store.Channel, error) + ListChannels(ctx context.Context, projectID uuid.UUID) ([]store.Channel, error) + GetChannel(ctx context.Context, id, projectID uuid.UUID) (store.Channel, error) + DeleteChannel(ctx context.Context, id, projectID uuid.UUID) error + + // GetDomain verifies domainID belongs to projectID — required before + // ListCheckRuns, which is not itself scoped by project. + GetDomain(ctx context.Context, id, projectID uuid.UUID) (store.Domain, error) + ListCheckRuns(ctx context.Context, domainID uuid.UUID) ([]store.CheckRun, error) +} + +// TestSender sends a one-off test notification through a single +// notification channel (POST /channels/{cid}/test). It's a narrow surface +// deliberately decoupled from notify.Dispatcher (which fans a single event +// out to every enabled channel of a project by project ID, not a single +// channel by ID) — cmd/server wiring (Task 6) supplies a concrete adapter +// over internal/notify's Notifiers; tests supply a mock. +type TestSender interface { + SendTest(ctx context.Context, channelType string, config json.RawMessage, secret string) error +} + +const minScheduleIntervalSeconds = 60 + +// defaultScheduleResponse is what GET /schedule returns when the project has +// never created a schedule row (store.GetSchedule returns pgx.ErrNoRows). +var defaultScheduleResponse = scheduleResponse{IntervalSeconds: 3600, Enabled: false} + +type scheduleRequest struct { + IntervalSeconds int32 `json:"intervalSeconds"` + Enabled bool `json:"enabled"` +} + +type scheduleResponse struct { + IntervalSeconds int32 `json:"intervalSeconds"` + Enabled bool `json:"enabled"` + LastRunAt *time.Time `json:"lastRunAt,omitempty"` +} + +func toScheduleResponse(s store.Schedule) scheduleResponse { + return scheduleResponse{IntervalSeconds: s.IntervalSeconds, Enabled: s.Enabled, LastRunAt: s.LastRunAt} +} + +// --- schedule --- + +func (a *API) handleGetSchedule(w http.ResponseWriter, r *http.Request) { + // pid is guaranteed present and owned by the caller — RequireProjectAccess + // validated it before this handler ever runs. + pid, _ := projectIDFrom(r.Context()) + sc, err := a.Schedule.GetSchedule(r.Context(), pid) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + writeJSON(w, http.StatusOK, defaultScheduleResponse) + return + } + log.Printf("api: get schedule failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + writeJSON(w, http.StatusOK, toScheduleResponse(sc)) +} + +func (a *API) handlePutSchedule(w http.ResponseWriter, r *http.Request) { + // pid is guaranteed present and owned by the caller — RequireProjectAccess + // validated it before this handler ever runs. + pid, _ := projectIDFrom(r.Context()) + var req scheduleRequest + if !decodeBody(w, r, &req) { + return + } + if req.IntervalSeconds < minScheduleIntervalSeconds { + writeErr(w, http.StatusBadRequest, "intervalSeconds must be >= 60") + return + } + sc, err := a.Schedule.UpsertSchedule(r.Context(), pid, req.IntervalSeconds, req.Enabled) + if err != nil { + log.Printf("api: upsert schedule failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + writeJSON(w, http.StatusOK, toScheduleResponse(sc)) +} + +// --- channels --- + +type channelRequest struct { + Type string `json:"type"` + Config json.RawMessage `json:"config"` + Secret string `json:"secret"` +} + +// channelResponse deliberately excludes the secret (plaintext or encrypted) — +// bot tokens/webhook signing keys must never reach an API response. +type channelResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Config json.RawMessage `json:"config"` + Enabled bool `json:"enabled"` +} + +func toChannelResponse(c store.Channel) channelResponse { + return channelResponse{ID: c.ID.String(), Type: c.Type, Config: c.Config, Enabled: c.Enabled} +} + +func (a *API) handleCreateChannel(w http.ResponseWriter, r *http.Request) { + // pid is guaranteed present and owned by the caller — RequireProjectAccess + // validated it before this handler ever runs. + pid, _ := projectIDFrom(r.Context()) + var req channelRequest + if !decodeBody(w, r, &req) { + return + } + if req.Type == "" { + writeErr(w, http.StatusBadRequest, "type is required") + return + } + if len(req.Config) == 0 { + req.Config = json.RawMessage("{}") + } + secretEnc := "" + if req.Secret != "" { + enc, err := a.Cipher.Encrypt([]byte(req.Secret)) + if err != nil { + log.Printf("api: encrypt channel secret failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + secretEnc = enc + } + ch, err := a.Schedule.CreateChannel(r.Context(), pid, req.Type, req.Config, secretEnc) + if err != nil { + log.Printf("api: create channel failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + writeJSON(w, http.StatusCreated, toChannelResponse(ch)) +} + +func (a *API) handleListChannels(w http.ResponseWriter, r *http.Request) { + // pid is guaranteed present and owned by the caller — RequireProjectAccess + // validated it before this handler ever runs. + pid, _ := projectIDFrom(r.Context()) + chs, err := a.Schedule.ListChannels(r.Context(), pid) + if err != nil { + log.Printf("api: list channels failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + resp := make([]channelResponse, 0, len(chs)) + for _, c := range chs { + resp = append(resp, toChannelResponse(c)) + } + writeJSON(w, http.StatusOK, resp) +} + +func (a *API) handleDeleteChannel(w http.ResponseWriter, r *http.Request) { + // pid is guaranteed present and owned by the caller — RequireProjectAccess + // validated it before this handler ever runs. + pid, _ := projectIDFrom(r.Context()) + cid, err := uuid.Parse(chi.URLParam(r, "cid")) + if err != nil { + writeErr(w, http.StatusBadRequest, "invalid channel id") + return + } + if err := a.Schedule.DeleteChannel(r.Context(), cid, pid); err != nil { + log.Printf("api: delete channel failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleTestChannel sends a one-off test notification through a single +// channel so a user can verify their bot_token/chat_id or webhook URL work +// before enabling the schedule. The channel's secret is decrypted only in +// memory to make the outbound call — it's never echoed back, and a failure +// from the remote channel (bad token, unreachable webhook) is reported as +// 502 without including any secret material in the error body. +func (a *API) handleTestChannel(w http.ResponseWriter, r *http.Request) { + // pid is guaranteed present and owned by the caller — RequireProjectAccess + // validated it before this handler ever runs. + pid, _ := projectIDFrom(r.Context()) + cid, err := uuid.Parse(chi.URLParam(r, "cid")) + if err != nil { + writeErr(w, http.StatusBadRequest, "invalid channel id") + return + } + ch, err := a.Schedule.GetChannel(r.Context(), cid, pid) + if err != nil { + writeErr(w, http.StatusNotFound, "channel not found") + return + } + secret := "" + if ch.SecretEnc != "" { + dec, err := a.Cipher.Decrypt(ch.SecretEnc) + if err != nil { + log.Printf("api: decrypt channel secret failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + secret = string(dec) + } + if err := a.Dispatch.SendTest(r.Context(), ch.Type, ch.Config, secret); err != nil { + log.Printf("api: test channel %s failed: %v", cid, err) + writeErr(w, http.StatusBadGateway, "channel test failed") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// --- history --- + +type checkRunResponse struct { + ID string `json:"id"` + CreatedAt time.Time `json:"createdAt"` + Result json.RawMessage `json:"result"` +} + +func toCheckRunResponse(c store.CheckRun) checkRunResponse { + return checkRunResponse{ID: c.ID.String(), CreatedAt: c.CreatedAt, Result: c.Result} +} + +// handleDomainHistory returns the most recent check_runs for a domain. +// check_runs.domain_id has no project scoping of its own, so this handler +// must first confirm the domain belongs to the caller's project (GetDomain) +// before listing its history — otherwise a caller could enumerate another +// tenant's domain IDs to read their check history (IDOR). +func (a *API) handleDomainHistory(w http.ResponseWriter, r *http.Request) { + // pid is guaranteed present and owned by the caller — RequireProjectAccess + // validated it before this handler ever runs. + pid, _ := projectIDFrom(r.Context()) + did, err := uuid.Parse(chi.URLParam(r, "did")) + if err != nil { + writeErr(w, http.StatusBadRequest, "invalid domain id") + return + } + if _, err := a.Schedule.GetDomain(r.Context(), did, pid); err != nil { + writeErr(w, http.StatusNotFound, "domain not found") + return + } + runs, err := a.Schedule.ListCheckRuns(r.Context(), did) + if err != nil { + log.Printf("api: list check runs failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + resp := make([]checkRunResponse, 0, len(runs)) + for _, cr := range runs { + resp = append(resp, toCheckRunResponse(cr)) + } + writeJSON(w, http.StatusOK, resp) +} diff --git a/internal/api/schedule_test.go b/internal/api/schedule_test.go new file mode 100644 index 0000000..3e2dd13 --- /dev/null +++ b/internal/api/schedule_test.go @@ -0,0 +1,433 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + "github.com/vasyakrg/dns-autoresolver/internal/store" +) + +// --- mock ScheduleStore --- + +type mockScheduleStore struct { + schedule store.Schedule + scheduleErr error + + upsertCalled bool + upsertInterval int32 + upsertEnabled bool + upsertResult store.Schedule + upsertErr error + + channels map[uuid.UUID]store.Channel + createChannelIn []struct { + ctype string + config json.RawMessage + secretEnc string + } + createChannelErr error + deleteChannelCalled bool + deletedChannelID uuid.UUID + deleteChannelErr error + + domains map[uuid.UUID]store.Domain + checkRuns map[uuid.UUID][]store.CheckRun + listRunsErr error +} + +func newMockScheduleStore() *mockScheduleStore { + return &mockScheduleStore{ + channels: map[uuid.UUID]store.Channel{}, + domains: map[uuid.UUID]store.Domain{}, + checkRuns: map[uuid.UUID][]store.CheckRun{}, + } +} + +func (m *mockScheduleStore) GetSchedule(context.Context, uuid.UUID) (store.Schedule, error) { + if m.scheduleErr != nil { + return store.Schedule{}, m.scheduleErr + } + return m.schedule, nil +} + +func (m *mockScheduleStore) UpsertSchedule(_ context.Context, projectID uuid.UUID, interval int32, enabled bool) (store.Schedule, error) { + m.upsertCalled = true + m.upsertInterval = interval + m.upsertEnabled = enabled + if m.upsertErr != nil { + return store.Schedule{}, m.upsertErr + } + if m.upsertResult.ID != uuid.Nil { + return m.upsertResult, nil + } + return store.Schedule{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: interval, Enabled: enabled}, nil +} + +func (m *mockScheduleStore) CreateChannel(_ context.Context, projectID uuid.UUID, ctype string, config json.RawMessage, secretEnc string) (store.Channel, error) { + m.createChannelIn = append(m.createChannelIn, struct { + ctype string + config json.RawMessage + secretEnc string + }{ctype, config, secretEnc}) + if m.createChannelErr != nil { + return store.Channel{}, m.createChannelErr + } + ch := store.Channel{ID: uuid.New(), ProjectID: projectID, Type: ctype, Config: config, SecretEnc: secretEnc, Enabled: true} + m.channels[ch.ID] = ch + return ch, nil +} + +func (m *mockScheduleStore) ListChannels(context.Context, uuid.UUID) ([]store.Channel, error) { + out := make([]store.Channel, 0, len(m.channels)) + for _, c := range m.channels { + out = append(out, c) + } + return out, nil +} + +func (m *mockScheduleStore) GetChannel(_ context.Context, id, _ uuid.UUID) (store.Channel, error) { + c, ok := m.channels[id] + if !ok { + return store.Channel{}, errors.New("channel not found") + } + return c, nil +} + +func (m *mockScheduleStore) DeleteChannel(_ context.Context, id, _ uuid.UUID) error { + m.deleteChannelCalled = true + m.deletedChannelID = id + if m.deleteChannelErr != nil { + return m.deleteChannelErr + } + delete(m.channels, id) + return nil +} + +func (m *mockScheduleStore) GetDomain(_ context.Context, id, _ uuid.UUID) (store.Domain, error) { + d, ok := m.domains[id] + if !ok { + return store.Domain{}, errors.New("domain not found") + } + return d, nil +} + +func (m *mockScheduleStore) ListCheckRuns(_ context.Context, domainID uuid.UUID) ([]store.CheckRun, error) { + if m.listRunsErr != nil { + return nil, m.listRunsErr + } + return m.checkRuns[domainID], nil +} + +// --- mock TestSender --- + +type mockTestSender struct { + err error + calledType string + calledConfig json.RawMessage + calledSecret string + called bool +} + +func (m *mockTestSender) SendTest(_ context.Context, channelType string, config json.RawMessage, secret string) error { + m.called = true + m.calledType = channelType + m.calledConfig = config + m.calledSecret = secret + return m.err +} + +// newScheduleTestAPI wires a fixed authenticated user who owns whatever +// project id is requested (alwaysOwnedAuthStore/alwaysValidSessions, see +// middleware_test.go) — these tests exercise schedule/channels/history +// behavior past the RequireAuth/RequireProjectAccess boundary. +func newScheduleTestAPI() (*API, *mockScheduleStore, *mockTestSender) { + ms := newMockScheduleStore() + mts := &mockTestSender{} + a := &API{ + Schedule: ms, Dispatch: mts, Cipher: mockCipher{}, + Auth: alwaysOwnedAuthStore(), Sessions: alwaysValidSessions(uuid.New()), + } + return a, ms, mts +} + +// --- schedule --- + +func TestGetSchedule_DefaultWhenNoRow(t *testing.T) { + a, ms, _ := newScheduleTestAPI() + ms.scheduleErr = pgx.ErrNoRows + router := NewRouter(a) + + req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/schedule", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d body %s", w.Code, w.Body.String()) + } + var resp scheduleResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.IntervalSeconds != 3600 || resp.Enabled != false { + t.Fatalf("expected default {3600,false}, got %+v", resp) + } +} + +func TestGetSchedule_Existing(t *testing.T) { + a, ms, _ := newScheduleTestAPI() + ms.schedule = store.Schedule{ID: uuid.New(), IntervalSeconds: 120, Enabled: true} + router := NewRouter(a) + + req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/schedule", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d body %s", w.Code, w.Body.String()) + } + var resp scheduleResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.IntervalSeconds != 120 || !resp.Enabled { + t.Fatalf("expected {120,true}, got %+v", resp) + } +} + +func TestPutSchedule_RejectsIntervalBelow60(t *testing.T) { + a, ms, _ := newScheduleTestAPI() + router := NewRouter(a) + + body := `{"intervalSeconds":59,"enabled":true}` + req := requestWithSessionCookie(http.MethodPut, "/api/v1/projects/"+testPID+"/schedule", strings.NewReader(body)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for interval<60, got %d body %s", w.Code, w.Body.String()) + } + if ms.upsertCalled { + t.Fatal("UpsertSchedule must not be called when validation fails") + } +} + +func TestPutSchedule_Success(t *testing.T) { + a, ms, _ := newScheduleTestAPI() + router := NewRouter(a) + + body := `{"intervalSeconds":300,"enabled":true}` + req := requestWithSessionCookie(http.MethodPut, "/api/v1/projects/"+testPID+"/schedule", strings.NewReader(body)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d body %s", w.Code, w.Body.String()) + } + if !ms.upsertCalled || ms.upsertInterval != 300 || !ms.upsertEnabled { + t.Fatalf("expected UpsertSchedule(300,true), got called=%v interval=%d enabled=%v", ms.upsertCalled, ms.upsertInterval, ms.upsertEnabled) + } +} + +// --- channels --- + +func TestCreateChannel_EncryptsSecretAndOmitsFromResponse(t *testing.T) { + a, ms, _ := newScheduleTestAPI() + router := NewRouter(a) + + body := `{"type":"telegram","config":{"chat_id":"123"},"secret":"super-bot-token"}` + req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/channels", strings.NewReader(body)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("status %d body %s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "super-bot-token") { + t.Fatalf("response leaks plaintext secret: %s", w.Body.String()) + } + if len(ms.createChannelIn) != 1 { + t.Fatalf("expected 1 CreateChannel call, got %d", len(ms.createChannelIn)) + } + got := ms.createChannelIn[0] + if got.secretEnc == "" || got.secretEnc == "super-bot-token" || !strings.Contains(got.secretEnc, "super-bot-token") { + // mockCipher.Encrypt wraps as ENC(...) — assert it's the *encrypted* form, not the raw plaintext passed unchanged. + t.Fatalf("expected secret to be passed through cipher.Encrypt, got secretEnc=%q", got.secretEnc) + } + if got.secretEnc == "super-bot-token" { + t.Fatalf("secret was stored unencrypted") + } + + var resp channelResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Type != "telegram" || resp.ID == "" { + t.Fatalf("unexpected response: %+v", resp) + } +} + +func TestListChannels_NoSecrets(t *testing.T) { + a, ms, _ := newScheduleTestAPI() + ms.channels[uuid.New()] = store.Channel{ID: uuid.New(), Type: "webhook", Config: json.RawMessage(`{"url":"https://example.com"}`), SecretEnc: "ENC(top-secret)", Enabled: true} + router := NewRouter(a) + + req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/channels", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d body %s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "top-secret") || strings.Contains(w.Body.String(), "secretEnc") { + t.Fatalf("channel list leaks secret: %s", w.Body.String()) + } +} + +func TestDeleteChannel(t *testing.T) { + a, ms, _ := newScheduleTestAPI() + cid := uuid.New() + ms.channels[cid] = store.Channel{ID: cid, Type: "webhook"} + router := NewRouter(a) + + req := requestWithSessionCookie(http.MethodDelete, "/api/v1/projects/"+testPID+"/channels/"+cid.String(), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("status %d body %s", w.Code, w.Body.String()) + } + if !ms.deleteChannelCalled || ms.deletedChannelID != cid { + t.Fatalf("expected DeleteChannel(%s), called=%v got=%s", cid, ms.deleteChannelCalled, ms.deletedChannelID) + } +} + +func TestDeleteChannel_InvalidUUID(t *testing.T) { + a, _, _ := newScheduleTestAPI() + router := NewRouter(a) + + req := requestWithSessionCookie(http.MethodDelete, "/api/v1/projects/"+testPID+"/channels/not-a-uuid", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for bad channel uuid, got %d", w.Code) + } +} + +func TestTestChannel_Success(t *testing.T) { + a, ms, mts := newScheduleTestAPI() + cid := uuid.New() + ms.channels[cid] = store.Channel{ID: cid, Type: "telegram", Config: json.RawMessage(`{"chat_id":"1"}`), SecretEnc: "ENC(bot-token)"} + router := NewRouter(a) + + req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/channels/"+cid.String()+"/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d body %s", w.Code, w.Body.String()) + } + if !mts.called || mts.calledType != "telegram" || mts.calledSecret != "bot-token" { + t.Fatalf("expected SendTest(telegram,...,bot-token), got called=%v type=%s secret=%s", mts.called, mts.calledType, mts.calledSecret) + } +} + +func TestTestChannel_SenderError_Returns502WithoutSecret(t *testing.T) { + a, ms, mts := newScheduleTestAPI() + cid := uuid.New() + ms.channels[cid] = store.Channel{ID: cid, Type: "telegram", Config: json.RawMessage(`{"chat_id":"1"}`), SecretEnc: "ENC(bot-token)"} + mts.err = errors.New("telegram: status 401 Unauthorized (token=bot-token)") + router := NewRouter(a) + + req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/channels/"+cid.String()+"/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadGateway { + t.Fatalf("expected 502 on channel test failure, got %d body %s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "bot-token") { + t.Fatalf("error response leaks secret: %s", w.Body.String()) + } +} + +func TestTestChannel_UnknownChannel_Returns404(t *testing.T) { + a, _, _ := newScheduleTestAPI() + router := NewRouter(a) + + req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/channels/"+uuid.New().String()+"/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 for unknown channel, got %d", w.Code) + } +} + +// --- history --- + +func TestDomainHistory_List(t *testing.T) { + a, ms, _ := newScheduleTestAPI() + did := uuid.New() + ms.domains[did] = store.Domain{ID: did} + ms.checkRuns[did] = []store.CheckRun{ + {ID: uuid.New(), DomainID: did, Result: json.RawMessage(`{"updates":1,"prunes":0}`)}, + {ID: uuid.New(), DomainID: did, Result: json.RawMessage(`{"updates":0,"prunes":0}`)}, + } + router := NewRouter(a) + + req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/"+did.String()+"/history", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d body %s", w.Code, w.Body.String()) + } + var resp []checkRunResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if len(resp) != 2 { + t.Fatalf("expected 2 history entries, got %d", len(resp)) + } +} + +func TestDomainHistory_InvalidUUID(t *testing.T) { + a, _, _ := newScheduleTestAPI() + router := NewRouter(a) + + req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/not-a-uuid/history", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for bad domain uuid, got %d", w.Code) + } +} + +// TestDomainHistory_ForeignDomain_Returns404 is the IDOR guard for history: +// check_runs.domain_id has no project scoping of its own, so the handler +// must verify domain ownership via GetDomain before calling ListCheckRuns — +// a domain id the mock doesn't know about (i.e. not in this project) must +// 404 rather than fall through to an unscoped history lookup. +func TestDomainHistory_ForeignDomain_Returns404(t *testing.T) { + a, _, _ := newScheduleTestAPI() + router := NewRouter(a) + + did := uuid.New() // never registered in ms.domains + req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/"+did.String()+"/history", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 for a domain not owned by this project, got %d body %s", w.Code, w.Body.String()) + } +} diff --git a/internal/store/db/check_runs.sql.go b/internal/store/db/check_runs.sql.go index 3279246..0040bb8 100644 --- a/internal/store/db/check_runs.sql.go +++ b/internal/store/db/check_runs.sql.go @@ -34,3 +34,32 @@ func (q *Queries) CreateCheckRun(ctx context.Context, arg CreateCheckRunParams) ) return i, err } + +const listCheckRuns = `-- name: ListCheckRuns :many +SELECT id, domain_id, result, created_at FROM check_runs WHERE domain_id = $1 ORDER BY created_at DESC LIMIT 50 +` + +func (q *Queries) ListCheckRuns(ctx context.Context, domainID uuid.UUID) ([]CheckRun, error) { + rows, err := q.db.Query(ctx, listCheckRuns, domainID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CheckRun + for rows.Next() { + var i CheckRun + if err := rows.Scan( + &i.ID, + &i.DomainID, + &i.Result, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/store/loader.go b/internal/store/loader.go index 620db85..bc48f64 100644 --- a/internal/store/loader.go +++ b/internal/store/loader.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "time" "github.com/google/uuid" @@ -51,6 +52,40 @@ func (s *Store) SaveCheckRun(ctx context.Context, domainID uuid.UUID, cs diff.Ch return err } +// CheckRun is a provider-neutral summary of a past check/apply run, returned +// by ListCheckRuns for the domain history endpoint (Фаза 3). +type CheckRun struct { + ID uuid.UUID + DomainID uuid.UUID + Result json.RawMessage + CreatedAt time.Time +} + +func checkRunFromDB(c db.CheckRun) CheckRun { + return CheckRun{ + ID: c.ID, + DomainID: c.DomainID, + Result: json.RawMessage(c.Result), + CreatedAt: c.CreatedAt.Time, + } +} + +// ListCheckRuns returns the most recent check_runs rows for a domain (newest +// first, capped at 50). Not scoped by project itself — callers must verify +// the domain belongs to the caller's project first (e.g. via GetDomain) +// since check_runs only references domain_id. +func (s *Store) ListCheckRuns(ctx context.Context, domainID uuid.UUID) ([]CheckRun, error) { + rows, err := s.q.ListCheckRuns(ctx, domainID) + if err != nil { + return nil, err + } + out := make([]CheckRun, 0, len(rows)) + for _, r := range rows { + out = append(out, checkRunFromDB(r)) + } + return out, nil +} + // compile-time interface checks var _ service.Loader = (*Store)(nil) var _ service.Recorder = (*Store)(nil) diff --git a/internal/store/queries/check_runs.sql b/internal/store/queries/check_runs.sql index 3c5ea29..22d51e1 100644 --- a/internal/store/queries/check_runs.sql +++ b/internal/store/queries/check_runs.sql @@ -2,3 +2,6 @@ INSERT INTO check_runs (id, domain_id, result) VALUES ($1, $2, $3) RETURNING *; + +-- name: ListCheckRuns :many +SELECT * FROM check_runs WHERE domain_id = $1 ORDER BY created_at DESC LIMIT 50; diff --git a/internal/store/tenant.go b/internal/store/tenant.go index 60c15eb..7296f08 100644 --- a/internal/store/tenant.go +++ b/internal/store/tenant.go @@ -176,6 +176,17 @@ func (s *Store) DeleteDomain(ctx context.Context, id, projectID uuid.UUID) error return s.q.DeleteDomain(ctx, db.DeleteDomainParams{ID: id, ProjectID: projectID}) } +// GetDomain is a scoped lookup used to verify a domain belongs to projectID +// before it's referenced elsewhere (e.g. history — check_runs isn't itself +// scoped by project, so callers must confirm domain ownership first). +func (s *Store) GetDomain(ctx context.Context, id, projectID uuid.UUID) (Domain, error) { + d, err := s.q.GetDomain(ctx, db.GetDomainParams{ID: id, ProjectID: projectID}) + if err != nil { + return Domain{}, err + } + return domainFromDB(d), nil +} + // ImportDomains creates one domain per zone inside a single transaction: if // any zone fails to be created, the whole batch is rolled back so callers // never observe a partially-imported set of domains. From 29f448d4b5743fee6644aefabf3e9a06f17472f0 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 13:40:29 +0700 Subject: [PATCH 05/12] =?UTF-8?q?fix(sec):=20=D1=81=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20Telegram-=D0=BE?= =?UTF-8?q?=D1=88=D0=B8=D0=B1=D0=BE=D0=BA,=20SSRF-guard=20Webhook,=20?= =?UTF-8?q?=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=D0=B2=20test-=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D0=B0,=20go=20mod?= =?UTF-8?q?=20tidy,=20histogram-=D0=B1=D0=B0=D0=BA=D0=B5=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 +- go.sum | 2 + internal/api/schedule_handlers.go | 6 ++- internal/metrics/metrics.go | 6 +++ internal/metrics/metrics_test.go | 20 ++++++++ internal/notify/notify_test.go | 82 ++++++++++++++++++++++++++++++- internal/notify/telegram.go | 6 ++- internal/notify/webhook.go | 79 ++++++++++++++++++++++++++++- 8 files changed, 197 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 984679c..01466a4 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 github.com/pressly/goose/v3 v3.27.2 + github.com/prometheus/client_golang v1.23.2 github.com/testcontainers/testcontainers-go v0.43.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0 golang.org/x/crypto v0.53.0 @@ -55,7 +56,6 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.20.1 // indirect diff --git a/go.sum b/go.sum index 19cda10..221e696 100644 --- a/go.sum +++ b/go.sum @@ -162,6 +162,8 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= diff --git a/internal/api/schedule_handlers.go b/internal/api/schedule_handlers.go index 7bc6e52..099b957 100644 --- a/internal/api/schedule_handlers.go +++ b/internal/api/schedule_handlers.go @@ -225,7 +225,11 @@ func (a *API) handleTestChannel(w http.ResponseWriter, r *http.Request) { secret = string(dec) } if err := a.Dispatch.SendTest(r.Context(), ch.Type, ch.Config, secret); err != nil { - log.Printf("api: test channel %s failed: %v", cid, err) + // Defense-in-depth: notify implementations sanitize errors before + // returning them (no secret/URL material), but this log deliberately + // omits the raw error (%v) anyway so a lower-layer regression can + // never leak a bot token or webhook URL into logs. + log.Printf("api: test channel %s failed", cid) writeErr(w, http.StatusBadGateway, "channel test failed") return } diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 5a80224..cd730e1 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -40,6 +40,12 @@ func New() *Metrics { CheckDuration: f.NewHistogram(prometheus.HistogramOpts{ Name: "dns_ar_check_duration_seconds", Help: "Длительность выполнения проверки домена в секундах.", + // Проверка домена — сетевой вызов DNS-провайдера, а не + // внутрипроцессная операция (для которой рассчитан + // prometheus.DefBuckets, начинающийся с 5мс). Бакеты подобраны + // под реалистичный диапазон задержек такого вызова, включая + // таймауты/ретраи медленных провайдеров. + Buckets: []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30}, }), DriftDomains: f.NewGauge(prometheus.GaugeOpts{ Name: "dns_ar_drift_domains", diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go index bb19568..9968a7b 100644 --- a/internal/metrics/metrics_test.go +++ b/internal/metrics/metrics_test.go @@ -27,6 +27,26 @@ func TestMetricsRecord(t *testing.T) { } } +func TestCheckDurationUsesNetworkCallBuckets(t *testing.T) { + m := New() + m.ObserveCheck("in_sync", 100*time.Millisecond) + rec := httptest.NewRecorder() + m.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/metrics", nil)) + body := rec.Body.String() + + // DefBuckets (le="0.005", ...) is tuned for sub-10ms in-process calls; + // dns_ar_check_duration_seconds is a network call to a DNS provider, so + // it must use the wider explicit buckets instead. + for _, want := range []string{`le="0.05"`, `le="1"`, `le="30"`} { + if !strings.Contains(body, `dns_ar_check_duration_seconds_bucket{`+want) { + t.Fatalf("expected bucket %s in exposed metrics:\n%s", want, body) + } + } + if strings.Contains(body, `dns_ar_check_duration_seconds_bucket{le="0.005"`) { + t.Fatalf("found default histogram bucket 0.005, expected custom buckets:\n%s", body) + } +} + func TestHandlerExposesMetrics(t *testing.T) { m := New() m.ObserveCheck("in_sync", time.Millisecond) diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go index 835d4b4..329bac4 100644 --- a/internal/notify/notify_test.go +++ b/internal/notify/notify_test.go @@ -55,6 +55,30 @@ func TestTelegramSendServerError(t *testing.T) { } } +func TestTelegramSendTransportErrorDoesNotLeakSecret(t *testing.T) { + // Bind and immediately close a server: its address is now unreachable + // (connection refused), which makes http.Client.Do return a *url.Error + // whose Error() embeds the full request URL — including /bot/. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + deadURL := srv.URL + srv.Close() + + tg := &Telegram{BaseURL: deadURL, HTTP: srv.Client()} + ev := Event{Project: "proj", Domain: "example.com", Status: "drift", Summary: "x", At: time.Now()} + + const secret = "super-secret-bot-token" + err := tg.Send(context.Background(), json.RawMessage(`{"chat_id":"1"}`), secret, ev) + if err == nil { + t.Fatal("expected error for unreachable host") + } + if strings.Contains(err.Error(), secret) { + t.Fatalf("error leaks secret: %v", err) + } + if strings.Contains(err.Error(), deadURL) { + t.Fatalf("error leaks request URL: %v", err) + } +} + func TestWebhookSendSuccess(t *testing.T) { var gotEvent Event srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -66,7 +90,10 @@ func TestWebhookSendSuccess(t *testing.T) { })) defer srv.Close() - wh := &Webhook{HTTP: srv.Client()} + // allowPrivate: true — httptest.Server listens on 127.0.0.1, which the + // SSRF guard would otherwise reject; production dispatchers never set + // this (see TestIsAllowedURL / TestNewDispatcherDoesNotAllowPrivate). + wh := &Webhook{HTTP: srv.Client(), allowPrivate: true} ev := Event{Project: "proj", Domain: "example.com", Status: "in_sync", Summary: "resolved", At: time.Now()} cfg, _ := json.Marshal(map[string]string{"url": srv.URL}) @@ -84,7 +111,7 @@ func TestWebhookSendNonSuccessStatus(t *testing.T) { })) defer srv.Close() - wh := &Webhook{HTTP: srv.Client()} + wh := &Webhook{HTTP: srv.Client(), allowPrivate: true} ev := Event{Project: "proj", Domain: "example.com", Status: "drift", Summary: "x", At: time.Now()} cfg, _ := json.Marshal(map[string]string{"url": srv.URL}) @@ -93,6 +120,51 @@ func TestWebhookSendNonSuccessStatus(t *testing.T) { } } +func TestWebhookSendRejectsPrivateDestinationByDefault(t *testing.T) { + wh := &Webhook{HTTP: http.DefaultClient} // allowPrivate not set: SSRF guard active + ev := Event{Project: "proj", Domain: "example.com", Status: "drift", Summary: "x", At: time.Now()} + cfg, _ := json.Marshal(map[string]string{"url": "http://127.0.0.1:1/hook"}) + + err := wh.Send(context.Background(), cfg, "", ev) + if err == nil { + t.Fatal("expected error for loopback destination") + } + if !strings.Contains(err.Error(), "destination not allowed") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestIsAllowedURL(t *testing.T) { + cases := []struct { + name string + rawurl string + allowed bool + }{ + {"localhost hostname", "http://localhost/hook", false}, + {"loopback ip", "http://127.0.0.1/hook", false}, + {"loopback ipv6", "http://[::1]/hook", false}, + {"link-local metadata", "http://169.254.169.254/latest/meta-data", false}, + {"private class a", "http://10.0.0.1/hook", false}, + {"private class c", "http://192.168.1.1/hook", false}, + {"private class b", "http://172.16.0.1/hook", false}, + {"unspecified", "http://0.0.0.0/hook", false}, + {"multicast", "http://224.0.0.1/hook", false}, + {"non-http scheme", "ftp://example.com/hook", false}, + {"public ip", "http://93.184.216.34/hook", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := isAllowedURL(tc.rawurl) + if tc.allowed && err != nil { + t.Fatalf("expected %q to be allowed, got error: %v", tc.rawurl, err) + } + if !tc.allowed && err == nil { + t.Fatalf("expected %q to be rejected, got nil error", tc.rawurl) + } + }) + } +} + // --- Dispatcher --- type mockChannelStore struct { @@ -156,6 +228,9 @@ func TestDispatcherSendsToAllChannelsAndAggregatesErrors(t *testing.T) { tg := &Telegram{BaseURL: tgSrv.URL, HTTP: tgSrv.Client()} return tg.Send(ctx, cfg, secret, ev) }) + // httptest servers listen on loopback, which the SSRF guard rejects by + // default; swap in an allowPrivate webhook so this test can still hit it. + d.byType["webhook"] = &Webhook{HTTP: whSrv.Client(), allowPrivate: true} ev := Event{Project: "proj", Domain: "example.com", Status: "drift", Summary: "changed", At: time.Now()} err := d.Send(context.Background(), projectID, ev) @@ -199,6 +274,9 @@ func TestDispatcherDecryptFailureIsAggregatedNotFatal(t *testing.T) { {ID: uuid.New(), ProjectID: projectID, Type: "webhook", Config: json.RawMessage(`{"url":"` + whSrv.URL + `"}`), Enabled: true}, } d := NewDispatcher(&mockChannelStore{channels: channels}, &mockDecryptor{fail: true}) + // httptest servers listen on loopback, which the SSRF guard rejects by + // default; swap in an allowPrivate webhook so this test can still hit it. + d.byType["webhook"] = &Webhook{HTTP: whSrv.Client(), allowPrivate: true} err := d.Send(context.Background(), projectID, Event{Project: "p", Domain: "d", Status: "drift"}) if err == nil { diff --git a/internal/notify/telegram.go b/internal/notify/telegram.go index 57d85b0..4a5a768 100644 --- a/internal/notify/telegram.go +++ b/internal/notify/telegram.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" ) @@ -35,7 +36,10 @@ func (t *Telegram) Send(ctx context.Context, cfg json.RawMessage, secret string, req.Header.Set("Content-Type", "application/json") resp, err := t.HTTP.Do(req) if err != nil { - return err + // Do NOT wrap/return err as-is: *url.Error.Error() embeds the full + // request URL, which contains the bot token (/bot/...). A + // caller logging this error would leak the secret. + return errors.New("telegram: request failed") } defer resp.Body.Close() if resp.StatusCode >= 300 { diff --git a/internal/notify/webhook.go b/internal/notify/webhook.go index 06e62c2..1f86e23 100644 --- a/internal/notify/webhook.go +++ b/internal/notify/webhook.go @@ -4,15 +4,77 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" + "net" "net/http" + "net/url" ) // Webhook delivers notifications as a JSON POST of the Event to a // project-configured URL. Config is {"url": "..."}. secret is currently // unused (reserved for future request signing) and is never logged. +// +// The destination URL is project-controlled (any project owner can set it), +// so it is treated as untrusted input: isAllowedURL blocks requests to +// loopback/private/link-local/unspecified addresses to prevent SSRF against +// internal services and cloud metadata endpoints (e.g. 169.254.169.254). +// Redirects are not followed, since a redirect response could otherwise be +// used to bypass the destination check. type Webhook struct { HTTP *http.Client + + // allowPrivate disables the SSRF guard. It exists only so tests can + // exercise Send happy-paths against httptest servers, which listen on + // loopback. Production Dispatchers (NewDispatcher) must never set this. + allowPrivate bool +} + +// isAllowedURL rejects any URL that is not a plain http/https request to a +// public, resolvable address. It resolves hostnames and checks every +// returned address — a hostname that resolves to even one +// private/loopback/link-local/unspecified address is rejected, since DNS +// answers are attacker-influenceable (rebinding) and partial trust is not +// safe. +func isAllowedURL(rawurl string) error { + u, err := url.Parse(rawurl) + if err != nil { + return fmt.Errorf("webhook: invalid url: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return errors.New("webhook: destination not allowed") + } + host := u.Hostname() + if host == "" { + return errors.New("webhook: destination not allowed") + } + + var ips []net.IP + if ip := net.ParseIP(host); ip != nil { + ips = []net.IP{ip} + } else { + resolved, err := net.LookupIP(host) + if err != nil { + return errors.New("webhook: destination not allowed") + } + ips = resolved + } + + for _, ip := range ips { + if isDisallowedIP(ip) { + return errors.New("webhook: destination not allowed") + } + } + return nil +} + +func isDisallowedIP(ip net.IP) bool { + return ip.IsLoopback() || + ip.IsPrivate() || + ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || + ip.IsUnspecified() || + ip.IsMulticast() } func (w *Webhook) Send(ctx context.Context, cfg json.RawMessage, secret string, ev Event) error { @@ -22,6 +84,11 @@ func (w *Webhook) Send(ctx context.Context, cfg json.RawMessage, secret string, if err := json.Unmarshal(cfg, &c); err != nil { return err } + if !w.allowPrivate { + if err := isAllowedURL(c.URL); err != nil { + return err + } + } body, err := json.Marshal(ev) if err != nil { return err @@ -31,7 +98,17 @@ func (w *Webhook) Send(ctx context.Context, cfg json.RawMessage, secret string, return err } req.Header.Set("Content-Type", "application/json") - resp, err := w.HTTP.Do(req) + + client := w.HTTP + if client.CheckRedirect == nil { + clientCopy := *client + clientCopy.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + client = &clientCopy + } + + resp, err := client.Do(req) if err != nil { return err } From 070a32717fbbaa722518b0a7530628568664dc9a Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 13:48:22 +0700 Subject: [PATCH 06/12] =?UTF-8?q?fix(sec):=20webhook=20SSRF-guard=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20Dialer.Control=20(=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20DNS-rebinding=20TOCTOU)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/notify/dispatch.go | 5 +- internal/notify/notify_test.go | 71 +++++++++++++++++++++++++++++ internal/notify/webhook.go | 83 ++++++++++++++++++++++++++++++---- 3 files changed, 148 insertions(+), 11 deletions(-) diff --git a/internal/notify/dispatch.go b/internal/notify/dispatch.go index 3d2329e..c8d76db 100644 --- a/internal/notify/dispatch.go +++ b/internal/notify/dispatch.go @@ -39,7 +39,10 @@ func NewDispatcher(store ChannelStore, cipher Decryptor) *Dispatcher { cipher: cipher, byType: map[string]Notifier{ "telegram": &Telegram{BaseURL: "https://api.telegram.org", HTTP: &http.Client{Timeout: 15 * time.Second}}, - "webhook": &Webhook{HTTP: &http.Client{Timeout: 15 * time.Second}}, + "webhook": &Webhook{HTTP: &http.Client{ + Timeout: 15 * time.Second, + Transport: newWebhookTransport(false), + }}, }, } } diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go index 329bac4..9c1126d 100644 --- a/internal/notify/notify_test.go +++ b/internal/notify/notify_test.go @@ -165,6 +165,77 @@ func TestIsAllowedURL(t *testing.T) { } } +func TestDialControlBlocksActualConnectingAddress(t *testing.T) { + cases := []struct { + name string + address string + blocked bool + }{ + {"loopback v4", "127.0.0.1:80", true}, + {"loopback v6", "[::1]:80", true}, + {"metadata link-local", "169.254.169.254:80", true}, + {"private class a", "10.0.0.1:80", true}, + {"private class b", "172.16.0.1:80", true}, + {"private class c", "192.168.1.1:80", true}, + {"unspecified", "0.0.0.0:80", true}, + {"multicast", "224.0.0.1:80", true}, + {"public ip", "93.184.216.34:443", false}, + } + control := dialControl(false) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := control("tcp", tc.address, nil) + if tc.blocked && err == nil { + t.Fatalf("expected %q to be blocked", tc.address) + } + if !tc.blocked && err != nil { + t.Fatalf("expected %q to be allowed, got: %v", tc.address, err) + } + }) + } +} + +func TestDialControlAllowsEverythingWhenAllowPrivate(t *testing.T) { + control := dialControl(true) + if err := control("tcp", "127.0.0.1:80", nil); err != nil { + t.Fatalf("expected allowPrivate to skip the dial guard, got: %v", err) + } +} + +// TestWebhookControlBlocksConnectionEvenWhenPreCheckPasses simulates the +// DNS-rebinding TOCTOU: allowPrivate=true skips the pre-request isAllowedURL +// check (standing in for a rebinding attacker answering a public IP to that +// lookup), but the Transport's Control func — wired independently of +// Webhook.allowPrivate — still inspects the literal address the dialer +// connects to and must still reject it. If Control did not exist, this +// request would reach the httptest handler; it must not. +func TestWebhookControlBlocksConnectionEvenWhenPreCheckPasses(t *testing.T) { + var handlerCalled bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlerCalled = true + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + wh := &Webhook{ + HTTP: &http.Client{Transport: newWebhookTransport(false)}, + allowPrivate: true, // pre-check bypassed on purpose; Control is not + } + ev := Event{Project: "proj", Domain: "example.com", Status: "drift", Summary: "x", At: time.Now()} + cfg, _ := json.Marshal(map[string]string{"url": srv.URL}) + + err := wh.Send(context.Background(), cfg, "", ev) + if err == nil { + t.Fatal("expected error: Control should have blocked the loopback connection") + } + if !strings.Contains(err.Error(), "destination not allowed") { + t.Fatalf("unexpected error: %v", err) + } + if handlerCalled { + t.Fatal("Control should have rejected the dial before the handler ran") + } +} + // --- Dispatcher --- type mockChannelStore struct { diff --git a/internal/notify/webhook.go b/internal/notify/webhook.go index 1f86e23..d15a5ea 100644 --- a/internal/notify/webhook.go +++ b/internal/notify/webhook.go @@ -9,6 +9,8 @@ import ( "net" "net/http" "net/url" + "syscall" + "time" ) // Webhook delivers notifications as a JSON POST of the Event to a @@ -16,17 +18,30 @@ import ( // unused (reserved for future request signing) and is never logged. // // The destination URL is project-controlled (any project owner can set it), -// so it is treated as untrusted input: isAllowedURL blocks requests to -// loopback/private/link-local/unspecified addresses to prevent SSRF against -// internal services and cloud metadata endpoints (e.g. 169.254.169.254). -// Redirects are not followed, since a redirect response could otherwise be -// used to bypass the destination check. +// so it is treated as untrusted input. Two layers guard against SSRF: +// +// 1. isAllowedURL is a pre-request fast-fail check on the URL's scheme and +// (resolved) hostname. +// 2. HTTP's Transport, when built via newWebhookTransport, wires a +// net.Dialer.Control that re-checks the actual "ip:port" being dialed for +// every connection net/http opens — including the DNS resolution +// http.Client.Do performs internally, independent of (1). +// +// Layer (2) is the source of truth: DNS answers are attacker-influenceable +// (an attacker with authoritative DNS and a low TTL can answer a public IP to +// a pre-request lookup and a private/loopback IP to the actual connection — +// DNS rebinding). Relying on (1) alone leaves that TOCTOU window open; (2) +// closes it because it inspects the address the connection is actually made +// to, not a name. Redirects are not followed, since a redirect response +// could otherwise be used to bypass the destination checks. type Webhook struct { HTTP *http.Client - // allowPrivate disables the SSRF guard. It exists only so tests can - // exercise Send happy-paths against httptest servers, which listen on - // loopback. Production Dispatchers (NewDispatcher) must never set this. + // allowPrivate disables the isAllowedURL pre-check. It exists only so + // tests can exercise Send happy-paths against httptest servers, which + // listen on loopback. Production Dispatchers (NewDispatcher) must never + // set this; they also wire a Transport whose Control func enforces the + // same guard at dial time regardless of this flag. allowPrivate bool } @@ -61,14 +76,18 @@ func isAllowedURL(rawurl string) error { } for _, ip := range ips { - if isDisallowedIP(ip) { + if isBlockedIP(ip) { return errors.New("webhook: destination not allowed") } } return nil } -func isDisallowedIP(ip net.IP) bool { +// isBlockedIP reports whether ip must never be connected to: loopback, +// private (RFC1918 etc.), link-local, unspecified, or multicast. Used both +// by isAllowedURL's pre-request check and by dialControl's per-connection +// check. +func isBlockedIP(ip net.IP) bool { return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || @@ -77,6 +96,50 @@ func isDisallowedIP(ip net.IP) bool { ip.IsMulticast() } +// dialControl returns a net.Dialer.Control function enforcing the SSRF guard +// on the literal address ("ip:port") that net/http is about to connect to. +// It runs after any DNS resolution net/http performs internally — including +// resolution done independently of, and possibly later than, isAllowedURL's +// own lookup — so it sees the real connecting IP and closes the DNS-rebinding +// TOCTOU window described on Webhook. +// +// allowPrivate disables the check entirely; it exists so tests can dial +// httptest servers, which listen on loopback. +func dialControl(allowPrivate bool) func(network, address string, c syscall.RawConn) error { + return func(network, address string, c syscall.RawConn) error { + if allowPrivate { + return nil + } + host, _, err := net.SplitHostPort(address) + if err != nil { + return errors.New("webhook: destination not allowed") + } + ip := net.ParseIP(host) + if ip == nil { + return errors.New("webhook: destination not allowed") + } + if isBlockedIP(ip) { + return errors.New("webhook: destination not allowed") + } + return nil + } +} + +// newWebhookTransport builds an http.Transport whose dialer enforces the +// SSRF guard on the actual address being connected to, for every connection +// it opens (see dialControl). This is the guard of record; isAllowedURL is +// only a fast pre-request rejection layered in front of it. +func newWebhookTransport(allowPrivate bool) *http.Transport { + dialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + Control: dialControl(allowPrivate), + } + t := http.DefaultTransport.(*http.Transport).Clone() + t.DialContext = dialer.DialContext + return t +} + func (w *Webhook) Send(ctx context.Context, cfg json.RawMessage, secret string, ev Event) error { var c struct { URL string `json:"url"` From 23e02d68049313af9a15ea1674b07184c3975db2 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 13:53:06 +0700 Subject: [PATCH 07/12] =?UTF-8?q?feat(scheduler):=20in-process=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D1=89=D0=B8=D0=BA=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BE=D0=BA=20+=20=D1=81?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B0=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81?= =?UTF-8?q?=D0=B0=20+=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20+=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/scheduler/scheduler.go | 196 ++++++++++++++++++++ internal/scheduler/scheduler_test.go | 265 +++++++++++++++++++++++++++ 2 files changed, 461 insertions(+) create mode 100644 internal/scheduler/scheduler.go create mode 100644 internal/scheduler/scheduler_test.go diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..8dba3b4 --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -0,0 +1,196 @@ +// Package scheduler runs an in-process loop that periodically checks every +// domain of every due project schedule, records the resulting status, and +// notifies configured channels on meaningful status transitions. +package scheduler + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/google/uuid" + + "github.com/vasyakrg/dns-autoresolver/internal/diff" + "github.com/vasyakrg/dns-autoresolver/internal/metrics" + "github.com/vasyakrg/dns-autoresolver/internal/notify" + "github.com/vasyakrg/dns-autoresolver/internal/store" +) + +// Domain check statuses persisted via SchedStore.SetDomainStatus / +// surfaced via GetDomainStatus. "unknown" is the DB default for a domain +// that has never been checked (see migrations/0004_schedule_notify.sql). +const ( + StatusUnknown = "unknown" + StatusInSync = "in_sync" + StatusDrift = "drift" + StatusError = "error" +) + +// SchedStore is the narrow store dependency the scheduler needs: due +// schedules, their domains, and per-domain status bookkeeping. +type SchedStore interface { + ListDueSchedules(ctx context.Context, now time.Time) ([]store.Schedule, error) + TouchScheduleRun(ctx context.Context, projectID uuid.UUID, at time.Time) error + ListDomains(ctx context.Context, projectID uuid.UUID) ([]store.Domain, error) + GetDomainStatus(ctx context.Context, domainID uuid.UUID) (string, error) + SetDomainStatus(ctx context.Context, domainID uuid.UUID, status string) error + SaveCheckRun(ctx context.Context, domainID uuid.UUID, cs diff.Changeset) error +} + +// Checker computes the diff between a domain's desired template and its +// actual zone state. internal/service.DomainService satisfies this. +type Checker interface { + Check(ctx context.Context, projectID, domainID uuid.UUID) (diff.Changeset, error) +} + +// NotifySender delivers a status-change event to a project's notification +// channels. internal/notify.Dispatcher satisfies this. +type NotifySender interface { + Send(ctx context.Context, projectID uuid.UUID, ev notify.Event) error +} + +// Scheduler drives periodic domain checks for every due project schedule. +type Scheduler struct { + store SchedStore + checker Checker + notifier NotifySender + metrics *metrics.Metrics +} + +// New builds a Scheduler wired with its store, checker, notifier and metrics +// dependencies. +func New(store SchedStore, checker Checker, notifier NotifySender, m *metrics.Metrics) *Scheduler { + return &Scheduler{store: store, checker: checker, notifier: notifier, metrics: m} +} + +// Run ticks every `tick` and calls RunOnce until ctx is cancelled. A failed +// iteration is logged, never fatal — the loop keeps ticking so a transient +// store/provider outage does not permanently stop future checks. +func (s *Scheduler) Run(ctx context.Context, tick time.Duration) { + ticker := time.NewTicker(tick) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := s.RunOnce(ctx, time.Now()); err != nil { + log.Printf("scheduler: run once failed: %v", err) + } + } + } +} + +// RunOnce performs a single scheduling pass: every due project schedule is +// checked, each of its domains is diffed against its template, its status +// is updated, and channels are notified on a meaningful status transition. +func (s *Scheduler) RunOnce(ctx context.Context, now time.Time) error { + due, err := s.store.ListDueSchedules(ctx, now) + if err != nil { + return fmt.Errorf("list due schedules: %w", err) + } + + driftCount := 0 + + for _, sch := range due { + domains, err := s.store.ListDomains(ctx, sch.ProjectID) + if err != nil { + log.Printf("scheduler: list domains for project %s failed: %v", sch.ProjectID, err) + continue + } + + for _, d := range domains { + if s.checkDomain(ctx, sch.ProjectID, d, now) == StatusDrift { + driftCount++ + } + } + + if err := s.store.TouchScheduleRun(ctx, sch.ProjectID, now); err != nil { + log.Printf("scheduler: touch schedule run for project %s failed: %v", sch.ProjectID, err) + } + } + + s.metrics.SetDrift(driftCount) + return nil +} + +// checkDomain runs a single domain's check, persists the outcome, and fires +// a notification if the status transition warrants one. It returns the new +// status. +func (s *Scheduler) checkDomain(ctx context.Context, projectID uuid.UUID, d store.Domain, now time.Time) string { + start := time.Now() + cs, checkErr := s.checker.Check(ctx, projectID, d.ID) + dur := time.Since(start) + + newStatus := StatusInSync + switch { + case checkErr != nil: + newStatus = StatusError + case len(cs.Actionable()) > 0: + newStatus = StatusDrift + } + s.metrics.ObserveCheck(newStatus, dur) + + prev, err := s.store.GetDomainStatus(ctx, d.ID) + if err != nil { + log.Printf("scheduler: get domain status for %s failed: %v", d.ID, err) + prev = StatusUnknown + } + + // A failed Check has no changeset worth recording; a successful one does. + if checkErr == nil { + if err := s.store.SaveCheckRun(ctx, d.ID, cs); err != nil { + log.Printf("scheduler: save check run for %s failed: %v", d.ID, err) + } + } + + if err := s.store.SetDomainStatus(ctx, d.ID, newStatus); err != nil { + log.Printf("scheduler: set domain status for %s failed: %v", d.ID, err) + } + + if shouldNotify(prev, newStatus) { + ev := notify.Event{ + Project: projectID.String(), + Domain: d.ID.String(), + Status: newStatus, + Summary: summarize(newStatus, cs, checkErr), + At: now, + } + if err := s.notifier.Send(ctx, projectID, ev); err != nil { + log.Printf("scheduler: notify send for project %s domain %s failed: %v", projectID, d.ID, err) + } + s.metrics.IncNotification("dispatch", newStatus) + } + + return newStatus +} + +// shouldNotify decides whether a prev -> new status transition is worth +// alerting on: +// - entering drift or error from any other status is always notified; +// - recovering from drift back to in_sync ("resolved") is notified; +// - the initial unknown -> in_sync transition (first successful check of a +// domain that never drifted) is NOT notified — it is not news, it is the +// expected steady state. +func shouldNotify(prev, newStatus string) bool { + if (newStatus == StatusDrift || newStatus == StatusError) && newStatus != prev { + return true + } + if prev == StatusDrift && newStatus == StatusInSync { + return true + } + return false +} + +// summarize builds a short, secret-free human-readable message for an Event. +func summarize(status string, cs diff.Changeset, checkErr error) string { + if checkErr != nil { + return fmt.Sprintf("check failed: %v", checkErr) + } + if status == StatusDrift { + return fmt.Sprintf("%d actionable diff(s) detected", len(cs.Actionable())) + } + return "zone back in sync with template" +} diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go new file mode 100644 index 0000000..ecc0ef0 --- /dev/null +++ b/internal/scheduler/scheduler_test.go @@ -0,0 +1,265 @@ +package scheduler + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus/testutil" + + "github.com/vasyakrg/dns-autoresolver/internal/diff" + "github.com/vasyakrg/dns-autoresolver/internal/metrics" + "github.com/vasyakrg/dns-autoresolver/internal/notify" + "github.com/vasyakrg/dns-autoresolver/internal/store" +) + +// mockStore is an in-memory SchedStore double. +type mockStore struct { + mu sync.Mutex + + schedules []store.Schedule + domains map[uuid.UUID][]store.Domain + status map[uuid.UUID]string + + savedCheckRuns []uuid.UUID + touchedProjects []uuid.UUID +} + +func newMockStore() *mockStore { + return &mockStore{ + domains: make(map[uuid.UUID][]store.Domain), + status: make(map[uuid.UUID]string), + } +} + +func (m *mockStore) ListDueSchedules(ctx context.Context, now time.Time) ([]store.Schedule, error) { + return m.schedules, nil +} + +func (m *mockStore) TouchScheduleRun(ctx context.Context, projectID uuid.UUID, at time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + m.touchedProjects = append(m.touchedProjects, projectID) + return nil +} + +func (m *mockStore) ListDomains(ctx context.Context, projectID uuid.UUID) ([]store.Domain, error) { + return m.domains[projectID], nil +} + +func (m *mockStore) GetDomainStatus(ctx context.Context, domainID uuid.UUID) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if st, ok := m.status[domainID]; ok { + return st, nil + } + return StatusUnknown, nil +} + +func (m *mockStore) SetDomainStatus(ctx context.Context, domainID uuid.UUID, status string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.status[domainID] = status + return nil +} + +func (m *mockStore) SaveCheckRun(ctx context.Context, domainID uuid.UUID, cs diff.Changeset) error { + m.mu.Lock() + defer m.mu.Unlock() + m.savedCheckRuns = append(m.savedCheckRuns, domainID) + return nil +} + +// mockChecker returns a preset Changeset or error per domainID. +type mockChecker struct { + results map[uuid.UUID]diff.Changeset + errs map[uuid.UUID]error +} + +func (c *mockChecker) Check(ctx context.Context, projectID, domainID uuid.UUID) (diff.Changeset, error) { + if err, ok := c.errs[domainID]; ok { + return diff.Changeset{}, err + } + return c.results[domainID], nil +} + +// mockNotifier records every Event it is asked to Send. +type mockNotifier struct { + mu sync.Mutex + events []notify.Event +} + +func (n *mockNotifier) Send(ctx context.Context, projectID uuid.UUID, ev notify.Event) error { + n.mu.Lock() + defer n.mu.Unlock() + n.events = append(n.events, ev) + return nil +} + +func (n *mockNotifier) count() int { + n.mu.Lock() + defer n.mu.Unlock() + return len(n.events) +} + +func driftChangeset() diff.Changeset { + return diff.Changeset{Diffs: []diff.RecordDiff{{Kind: diff.Update, Name: "www"}}} +} + +func TestRunOnce_NotifiesOnDriftNotOnFirstInSync(t *testing.T) { + projectID := uuid.New() + domainA := store.Domain{ID: uuid.New(), ProjectID: projectID} + domainB := store.Domain{ID: uuid.New(), ProjectID: projectID} + + st := newMockStore() + st.schedules = []store.Schedule{{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: 3600, Enabled: true}} + st.domains[projectID] = []store.Domain{domainA, domainB} + + checker := &mockChecker{ + results: map[uuid.UUID]diff.Changeset{ + domainA.ID: driftChangeset(), + domainB.ID: {}, + }, + } + notifier := &mockNotifier{} + m := metrics.New() + + sched := New(st, checker, notifier, m) + + if err := sched.RunOnce(context.Background(), time.Now()); err != nil { + t.Fatalf("RunOnce: %v", err) + } + + if st.status[domainA.ID] != StatusDrift { + t.Fatalf("domain A status = %q, want drift", st.status[domainA.ID]) + } + if st.status[domainB.ID] != StatusInSync { + t.Fatalf("domain B status = %q, want in_sync", st.status[domainB.ID]) + } + + if got := notifier.count(); got != 1 { + t.Fatalf("notifications sent = %d, want 1 (only domain A)", got) + } + if notifier.events[0].Domain != domainA.ID.String() { + t.Fatalf("notified domain = %q, want domain A (%s)", notifier.events[0].Domain, domainA.ID) + } + if notifier.events[0].Status != StatusDrift { + t.Fatalf("notified status = %q, want drift", notifier.events[0].Status) + } + + if len(st.savedCheckRuns) != 2 { + t.Fatalf("SaveCheckRun calls = %d, want 2", len(st.savedCheckRuns)) + } + if len(st.touchedProjects) != 1 || st.touchedProjects[0] != projectID { + t.Fatalf("TouchScheduleRun calls = %v, want [%s]", st.touchedProjects, projectID) + } + + if got := testutil.ToFloat64(m.ChecksTotal.WithLabelValues(StatusDrift)); got != 1 { + t.Fatalf("ChecksTotal{drift} = %v, want 1", got) + } + if got := testutil.ToFloat64(m.ChecksTotal.WithLabelValues(StatusInSync)); got != 1 { + t.Fatalf("ChecksTotal{in_sync} = %v, want 1", got) + } + if got := testutil.ToFloat64(m.DriftDomains); got != 1 { + t.Fatalf("DriftDomains gauge = %v, want 1", got) + } +} + +func TestRunOnce_Idempotent_NoRepeatNotifyOnUnchangedDrift(t *testing.T) { + projectID := uuid.New() + domainA := store.Domain{ID: uuid.New(), ProjectID: projectID} + + st := newMockStore() + st.schedules = []store.Schedule{{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: 3600, Enabled: true}} + st.domains[projectID] = []store.Domain{domainA} + + checker := &mockChecker{ + results: map[uuid.UUID]diff.Changeset{domainA.ID: driftChangeset()}, + } + notifier := &mockNotifier{} + m := metrics.New() + sched := New(st, checker, notifier, m) + + if err := sched.RunOnce(context.Background(), time.Now()); err != nil { + t.Fatalf("first RunOnce: %v", err) + } + if got := notifier.count(); got != 1 { + t.Fatalf("after first run notifications = %d, want 1", got) + } + + if err := sched.RunOnce(context.Background(), time.Now()); err != nil { + t.Fatalf("second RunOnce: %v", err) + } + if got := notifier.count(); got != 1 { + t.Fatalf("after second run (drift->drift) notifications = %d, want still 1 (no repeat)", got) + } +} + +func TestRunOnce_CheckError_StatusErrorAndNotify(t *testing.T) { + projectID := uuid.New() + domainA := store.Domain{ID: uuid.New(), ProjectID: projectID} + + st := newMockStore() + st.schedules = []store.Schedule{{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: 3600, Enabled: true}} + st.domains[projectID] = []store.Domain{domainA} + + checker := &mockChecker{ + errs: map[uuid.UUID]error{domainA.ID: errors.New("provider timeout")}, + } + notifier := &mockNotifier{} + m := metrics.New() + sched := New(st, checker, notifier, m) + + if err := sched.RunOnce(context.Background(), time.Now()); err != nil { + t.Fatalf("RunOnce: %v", err) + } + + if st.status[domainA.ID] != StatusError { + t.Fatalf("domain A status = %q, want error", st.status[domainA.ID]) + } + if got := notifier.count(); got != 1 { + t.Fatalf("notifications = %d, want 1 (unknown->error)", got) + } + if notifier.events[0].Status != StatusError { + t.Fatalf("notified status = %q, want error", notifier.events[0].Status) + } + if got := testutil.ToFloat64(m.ChecksTotal.WithLabelValues(StatusError)); got != 1 { + t.Fatalf("ChecksTotal{error} = %v, want 1", got) + } + // A failed Check has no changeset worth recording. + if len(st.savedCheckRuns) != 0 { + t.Fatalf("SaveCheckRun calls on error = %d, want 0", len(st.savedCheckRuns)) + } +} + +func TestShouldNotify(t *testing.T) { + cases := []struct { + name string + prev string + new string + want bool + }{ + {"unknown->drift notifies", StatusUnknown, StatusDrift, true}, + {"unknown->error notifies", StatusUnknown, StatusError, true}, + {"unknown->in_sync is silent (first sync is not news)", StatusUnknown, StatusInSync, false}, + {"drift->drift does not repeat", StatusDrift, StatusDrift, false}, + {"error->error does not repeat", StatusError, StatusError, false}, + {"drift->in_sync notifies (resolved)", StatusDrift, StatusInSync, true}, + {"in_sync->drift notifies", StatusInSync, StatusDrift, true}, + {"in_sync->error notifies", StatusInSync, StatusError, true}, + {"in_sync->in_sync is silent", StatusInSync, StatusInSync, false}, + {"error->drift notifies (still bad, different bad)", StatusError, StatusDrift, true}, + {"error->in_sync is not the 'resolved' case, per spec", StatusError, StatusInSync, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := shouldNotify(tc.prev, tc.new); got != tc.want { + t.Fatalf("shouldNotify(%q, %q) = %v, want %v", tc.prev, tc.new, got, tc.want) + } + }) + } +} From 9475af441ee1ba62192be2c51c53be0c211783b8 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 14:03:49 +0700 Subject: [PATCH 08/12] =?UTF-8?q?fix(scheduler):=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=B4=D0=B2=D0=BE=D0=B9=D0=BD=D0=BE=D0=B9?= =?UTF-8?q?=20SaveCheckRun=20(Checker=20=D0=BF=D0=B5=D1=80=D1=81=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D1=82),=20SetDrift=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20CountDriftDomains,=20resolved=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/scheduler/scheduler.go | 41 ++++++++++++++++------------ internal/scheduler/scheduler_test.go | 29 ++++++++++---------- internal/store/db/domains.sql.go | 11 ++++++++ internal/store/queries/domains.sql | 3 ++ internal/store/tenant.go | 8 ++++++ 5 files changed, 60 insertions(+), 32 deletions(-) diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 8dba3b4..c470d09 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -28,14 +28,16 @@ const ( ) // SchedStore is the narrow store dependency the scheduler needs: due -// schedules, their domains, and per-domain status bookkeeping. +// schedules, their domains, and per-domain status bookkeeping. Persisting +// the check result itself (check_runs) is the Checker's job — see Checker +// below — not the scheduler's. type SchedStore interface { ListDueSchedules(ctx context.Context, now time.Time) ([]store.Schedule, error) TouchScheduleRun(ctx context.Context, projectID uuid.UUID, at time.Time) error ListDomains(ctx context.Context, projectID uuid.UUID) ([]store.Domain, error) GetDomainStatus(ctx context.Context, domainID uuid.UUID) (string, error) SetDomainStatus(ctx context.Context, domainID uuid.UUID, status string) error - SaveCheckRun(ctx context.Context, domainID uuid.UUID, cs diff.Changeset) error + CountDriftDomains(ctx context.Context) (int, error) } // Checker computes the diff between a domain's desired template and its @@ -92,8 +94,6 @@ func (s *Scheduler) RunOnce(ctx context.Context, now time.Time) error { return fmt.Errorf("list due schedules: %w", err) } - driftCount := 0 - for _, sch := range due { domains, err := s.store.ListDomains(ctx, sch.ProjectID) if err != nil { @@ -102,9 +102,7 @@ func (s *Scheduler) RunOnce(ctx context.Context, now time.Time) error { } for _, d := range domains { - if s.checkDomain(ctx, sch.ProjectID, d, now) == StatusDrift { - driftCount++ - } + s.checkDomain(ctx, sch.ProjectID, d, now) } if err := s.store.TouchScheduleRun(ctx, sch.ProjectID, now); err != nil { @@ -112,7 +110,15 @@ func (s *Scheduler) RunOnce(ctx context.Context, now time.Time) error { } } - s.metrics.SetDrift(driftCount) + // The real, system-wide count of drift domains — not a local + // accumulator scoped to this tick's due projects — so the gauge + // reflects reality even across ticks where different projects are due. + count, err := s.store.CountDriftDomains(ctx) + if err != nil { + log.Printf("scheduler: count drift domains failed: %v", err) + } else { + s.metrics.SetDrift(count) + } return nil } @@ -139,12 +145,10 @@ func (s *Scheduler) checkDomain(ctx context.Context, projectID uuid.UUID, d stor prev = StatusUnknown } - // A failed Check has no changeset worth recording; a successful one does. - if checkErr == nil { - if err := s.store.SaveCheckRun(ctx, d.ID, cs); err != nil { - log.Printf("scheduler: save check run for %s failed: %v", d.ID, err) - } - } + // Persisting the check_runs row is the Checker's job: DomainService.Check + // already calls Recorder.SaveCheckRun internally on every successful + // check (drift or in_sync). Calling it again here would double-write + // check_runs history for the same check. if err := s.store.SetDomainStatus(ctx, d.ID, newStatus); err != nil { log.Printf("scheduler: set domain status for %s failed: %v", d.ID, err) @@ -170,15 +174,16 @@ func (s *Scheduler) checkDomain(ctx context.Context, projectID uuid.UUID, d stor // shouldNotify decides whether a prev -> new status transition is worth // alerting on: // - entering drift or error from any other status is always notified; -// - recovering from drift back to in_sync ("resolved") is notified; +// - recovering from drift OR error back to in_sync ("resolved") is +// notified — including recovery after a provider/check failure; // - the initial unknown -> in_sync transition (first successful check of a -// domain that never drifted) is NOT notified — it is not news, it is the -// expected steady state. +// domain that never drifted or errored) is NOT notified — it is not +// news, it is the expected steady state. func shouldNotify(prev, newStatus string) bool { if (newStatus == StatusDrift || newStatus == StatusError) && newStatus != prev { return true } - if prev == StatusDrift && newStatus == StatusInSync { + if (prev == StatusDrift || prev == StatusError) && newStatus == StatusInSync { return true } return false diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index ecc0ef0..dda06ee 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -24,8 +24,11 @@ type mockStore struct { domains map[uuid.UUID][]store.Domain status map[uuid.UUID]string - savedCheckRuns []uuid.UUID touchedProjects []uuid.UUID + + // driftCount is what CountDriftDomains returns — a canned system-wide + // count, independent of what this RunOnce's due projects touched. + driftCount int } func newMockStore() *mockStore { @@ -66,11 +69,10 @@ func (m *mockStore) SetDomainStatus(ctx context.Context, domainID uuid.UUID, sta return nil } -func (m *mockStore) SaveCheckRun(ctx context.Context, domainID uuid.UUID, cs diff.Changeset) error { +func (m *mockStore) CountDriftDomains(ctx context.Context) (int, error) { m.mu.Lock() defer m.mu.Unlock() - m.savedCheckRuns = append(m.savedCheckRuns, domainID) - return nil + return m.driftCount, nil } // mockChecker returns a preset Changeset or error per domainID. @@ -127,6 +129,12 @@ func TestRunOnce_NotifiesOnDriftNotOnFirstInSync(t *testing.T) { notifier := &mockNotifier{} m := metrics.New() + // CountDriftDomains is the real system-wide count, independent of what + // this tick touched — set it to something that would NOT match a local + // per-tick accumulator (only 1 of 2 domains here drifted) to prove the + // gauge comes from the store call, not a local tally. + st.driftCount = 7 + sched := New(st, checker, notifier, m) if err := sched.RunOnce(context.Background(), time.Now()); err != nil { @@ -150,9 +158,6 @@ func TestRunOnce_NotifiesOnDriftNotOnFirstInSync(t *testing.T) { t.Fatalf("notified status = %q, want drift", notifier.events[0].Status) } - if len(st.savedCheckRuns) != 2 { - t.Fatalf("SaveCheckRun calls = %d, want 2", len(st.savedCheckRuns)) - } if len(st.touchedProjects) != 1 || st.touchedProjects[0] != projectID { t.Fatalf("TouchScheduleRun calls = %v, want [%s]", st.touchedProjects, projectID) } @@ -163,8 +168,8 @@ func TestRunOnce_NotifiesOnDriftNotOnFirstInSync(t *testing.T) { if got := testutil.ToFloat64(m.ChecksTotal.WithLabelValues(StatusInSync)); got != 1 { t.Fatalf("ChecksTotal{in_sync} = %v, want 1", got) } - if got := testutil.ToFloat64(m.DriftDomains); got != 1 { - t.Fatalf("DriftDomains gauge = %v, want 1", got) + if got := testutil.ToFloat64(m.DriftDomains); got != float64(st.driftCount) { + t.Fatalf("DriftDomains gauge = %v, want %d (from CountDriftDomains)", got, st.driftCount) } } @@ -229,10 +234,6 @@ func TestRunOnce_CheckError_StatusErrorAndNotify(t *testing.T) { if got := testutil.ToFloat64(m.ChecksTotal.WithLabelValues(StatusError)); got != 1 { t.Fatalf("ChecksTotal{error} = %v, want 1", got) } - // A failed Check has no changeset worth recording. - if len(st.savedCheckRuns) != 0 { - t.Fatalf("SaveCheckRun calls on error = %d, want 0", len(st.savedCheckRuns)) - } } func TestShouldNotify(t *testing.T) { @@ -252,7 +253,7 @@ func TestShouldNotify(t *testing.T) { {"in_sync->error notifies", StatusInSync, StatusError, true}, {"in_sync->in_sync is silent", StatusInSync, StatusInSync, false}, {"error->drift notifies (still bad, different bad)", StatusError, StatusDrift, true}, - {"error->in_sync is not the 'resolved' case, per spec", StatusError, StatusInSync, false}, + {"error->in_sync notifies (resolved after failure)", StatusError, StatusInSync, true}, } for _, tc := range cases { diff --git a/internal/store/db/domains.sql.go b/internal/store/db/domains.sql.go index dfa3255..805bd7d 100644 --- a/internal/store/db/domains.sql.go +++ b/internal/store/db/domains.sql.go @@ -12,6 +12,17 @@ import ( dto "github.com/vasyakrg/dns-autoresolver/internal/store/dto" ) +const countDriftDomains = `-- name: CountDriftDomains :one +SELECT count(*) FROM domains WHERE last_check_status = 'drift' +` + +func (q *Queries) CountDriftDomains(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, countDriftDomains) + var count int64 + err := row.Scan(&count) + return count, err +} + const createDomain = `-- name: CreateDomain :one INSERT INTO domains (id, project_id, provider_account_id, zone_name, zone_id, template_id) VALUES ($1, $2, $3, $4, $5, $6) diff --git a/internal/store/queries/domains.sql b/internal/store/queries/domains.sql index 72a8392..6cd3fcf 100644 --- a/internal/store/queries/domains.sql +++ b/internal/store/queries/domains.sql @@ -34,3 +34,6 @@ SELECT last_check_status FROM domains WHERE id = $1; -- name: SetDomainStatus :exec UPDATE domains SET last_check_status = $2 WHERE id = $1; + +-- name: CountDriftDomains :one +SELECT count(*) FROM domains WHERE last_check_status = 'drift'; diff --git a/internal/store/tenant.go b/internal/store/tenant.go index 7296f08..d5dec6f 100644 --- a/internal/store/tenant.go +++ b/internal/store/tenant.go @@ -258,6 +258,14 @@ func (s *Store) SetDomainStatus(ctx context.Context, domainID uuid.UUID, status return s.q.SetDomainStatus(ctx, db.SetDomainStatusParams{ID: domainID, LastCheckStatus: status}) } +// CountDriftDomains returns the current number of domains system-wide whose +// last check status is "drift". This is a global count (not per-project) — +// it backs the dns_ar_drift_domains gauge, which is a system-level metric. +func (s *Store) CountDriftDomains(ctx context.Context) (int, error) { + n, err := s.q.CountDriftDomains(ctx) + return int(n), err +} + // User and Project are provider-neutral domain structs for the auth/tenant // layer (Фаза 2), mirroring the Account/Template/Domain wrappers above so // callers never need to import internal/store/db directly. From b31f886ae20fc9a56a8a503ff4107215006998a1 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 14:14:00 +0700 Subject: [PATCH 09/12] =?UTF-8?q?feat(server):=20=D0=B7=D0=B0=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D0=BA=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D1=89=D0=B8=D0=BA=D0=B0,=20/metrics,=20graceful=20shutdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/main.go | 81 ++++++++++++++++++++++++++++++++----- internal/notify/dispatch.go | 23 +++++++++++ 2 files changed, 93 insertions(+), 11 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 7ab73fc..c29fbb0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,9 +2,13 @@ package main import ( "context" + "errors" "log" "net/http" + "os" + "os/signal" "strings" + "syscall" "time" "github.com/jackc/pgx/v5/pgxpool" @@ -13,8 +17,11 @@ import ( "github.com/vasyakrg/dns-autoresolver/internal/auth" "github.com/vasyakrg/dns-autoresolver/internal/config" "github.com/vasyakrg/dns-autoresolver/internal/crypto" + "github.com/vasyakrg/dns-autoresolver/internal/metrics" + "github.com/vasyakrg/dns-autoresolver/internal/notify" "github.com/vasyakrg/dns-autoresolver/internal/provider/registry" "github.com/vasyakrg/dns-autoresolver/internal/provider/selectel" + "github.com/vasyakrg/dns-autoresolver/internal/scheduler" "github.com/vasyakrg/dns-autoresolver/internal/service" "github.com/vasyakrg/dns-autoresolver/internal/store" "github.com/vasyakrg/dns-autoresolver/internal/web" @@ -24,6 +31,16 @@ import ( // user must re-authenticate. const sessionTTL = 720 * time.Hour +// schedulerTick is how often the in-process scheduler checks for due project +// schedules. Individual projects only actually run when their own +// schedules.interval_seconds has elapsed (see internal/store ListDueSchedules) — +// this is just the polling granularity. +const schedulerTick = time.Minute + +// shutdownTimeout bounds how long graceful shutdown waits for in-flight HTTP +// requests to finish before forcing the listener closed. +const shutdownTimeout = 10 * time.Second + // isAPIPath reports whether path must be routed to the API router rather // than the SPA. "/api" (no trailing slash) counts as an API path too — // only strings.HasPrefix(path, "/api/") would otherwise miss it and fall @@ -33,7 +50,9 @@ func isAPIPath(path string) bool { } func main() { - ctx := context.Background() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + cfg, err := config.Load() if err != nil { log.Fatalf("config: %v", err) @@ -58,7 +77,14 @@ func main() { reg.Register(selectel.New()) svc := service.New(st, st, reg, cipher) - a := &api.API{Svc: svc, Store: st, Cipher: cipher, Reg: reg, Auth: st, Sessions: sessions} + + m := metrics.New() + dispatcher := notify.NewDispatcher(st, cipher) + + a := &api.API{ + Svc: svc, Store: st, Cipher: cipher, Reg: reg, Auth: st, Sessions: sessions, + Schedule: st, Dispatch: dispatcher, + } apiRouter := api.NewRouter(a) webHandler, err := web.Handler() @@ -66,20 +92,53 @@ func main() { log.Printf("web: static UI unavailable: %v", err) } + // The scheduler only checks and notifies — it never applies zone changes + // (Apply stays a manual, explicit API call). Its own errors are logged + // internally and never stop the loop; ctx cancellation (signal) is the + // only thing that ends Run. + sched := scheduler.New(st, svc, dispatcher, m) + go sched.Run(ctx, schedulerTick) + mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if isAPIPath(r.URL.Path) { + switch { + case r.URL.Path == "/metrics": + // Public by design (no auth) — Metrics.Handler only ever exposes + // aggregate counters/gauges, never per-domain or secret data. + m.Handler().ServeHTTP(w, r) + case isAPIPath(r.URL.Path): apiRouter.ServeHTTP(w, r) - return - } - if webHandler != nil { + case webHandler != nil: webHandler.ServeHTTP(w, r) - return + default: + http.NotFound(w, r) } - http.NotFound(w, r) }) - log.Printf("listening on %s", cfg.ListenAddr) - if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil { - log.Fatal(err) + srv := &http.Server{Addr: cfg.ListenAddr, Handler: mux} + + serveErr := make(chan error, 1) + go func() { + log.Printf("listening on %s", cfg.ListenAddr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + serveErr <- err + return + } + serveErr <- nil + }() + + select { + case <-ctx.Done(): + log.Printf("shutdown signal received, draining connections (timeout %s)", shutdownTimeout) + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("server: graceful shutdown failed: %v", err) + } + <-serveErr + log.Printf("server stopped") + case err := <-serveErr: + if err != nil { + log.Fatalf("server: %v", err) + } } } diff --git a/internal/notify/dispatch.go b/internal/notify/dispatch.go index c8d76db..357e772 100644 --- a/internal/notify/dispatch.go +++ b/internal/notify/dispatch.go @@ -2,7 +2,9 @@ package notify import ( "context" + "encoding/json" "errors" + "fmt" "net/http" "time" @@ -76,3 +78,24 @@ func (d *Dispatcher) Send(ctx context.Context, projectID uuid.UUID, ev Event) er } return errors.Join(errs...) } + +// SendTest sends a single synthetic Event directly through the Notifier for +// channelType, bypassing project/channel lookup entirely. It satisfies +// api.TestSender and backs POST /channels/{cid}/test, letting a user verify +// a channel's bot_token/chat_id or webhook URL works before enabling the +// schedule — the api layer resolves the channel and decrypts its secret; this +// method only performs the actual delivery attempt. +func (d *Dispatcher) SendTest(ctx context.Context, channelType string, config json.RawMessage, secret string) error { + n, ok := d.byType[channelType] + if !ok { + return fmt.Errorf("notify: unknown channel type %q", channelType) + } + ev := Event{ + Project: "test", + Domain: "test", + Status: "test", + Summary: "test notification", + At: time.Now(), + } + return n.Send(ctx, config, secret, ev) +} From 45259b97205ad4dddf40a8421ad1ce0b8ef34660 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 14:24:02 +0700 Subject: [PATCH 10/12] =?UTF-8?q?feat(web,api):=20=D0=BA=D0=BB=D0=B8=D0=B5?= =?UTF-8?q?=D0=BD=D1=82/=D1=85=D1=83=D0=BA=D0=B8=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F/=D0=BA=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=B2/=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B8=20+=20lastCheckStatus=20=D0=B2=20domainResponse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3 --- internal/api/tenant_dto.go | 3 +- web/src/api/client.test.ts | 68 ++++++++++++++++++++++++++++++++++++ web/src/api/client.ts | 15 ++++++++ web/src/api/types.ts | 8 +++++ web/src/hooks/useApi.ts | 70 +++++++++++++++++++++++++++++++++++++- 5 files changed, 162 insertions(+), 2 deletions(-) diff --git a/internal/api/tenant_dto.go b/internal/api/tenant_dto.go index b3dd1a2..613b1c8 100644 --- a/internal/api/tenant_dto.go +++ b/internal/api/tenant_dto.go @@ -59,12 +59,13 @@ type domainResponse struct { ZoneName string `json:"zoneName"` ZoneID string `json:"zoneId"` TemplateID *string `json:"templateId,omitempty"` + LastCheckStatus string `json:"lastCheckStatus"` } func toDomainResponse(d store.Domain) domainResponse { resp := domainResponse{ ID: d.ID.String(), ProviderAccountID: d.ProviderAccountID.String(), - ZoneName: d.ZoneName, ZoneID: d.ZoneID, + ZoneName: d.ZoneName, ZoneID: d.ZoneID, LastCheckStatus: d.LastCheckStatus, } if d.TemplateID != nil { s := d.TemplateID.String() diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 43a966f..f9ad71d 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -128,4 +128,72 @@ describe("api client", () => { expect(url).toBe(`/api/v1/projects/${PROJECT_ID}/domains/d1`) expect((opts as RequestInit).method).toBe("PATCH") }) + + describe("schedule", () => { + it("getSchedule(projectId) GETs /schedule", async () => { + const spy = mockFetch({ intervalSeconds: 3600, enabled: false }) + await api.getSchedule(PROJECT_ID) + expect(spy).toHaveBeenCalledWith( + `/api/v1/projects/${PROJECT_ID}/schedule`, + expect.objectContaining({ method: "GET", credentials: "include" }), + ) + }) + + it("putSchedule(projectId, {intervalSeconds,enabled}) PUTs /schedule", async () => { + const spy = mockFetch({ intervalSeconds: 120, enabled: true }) + await api.putSchedule(PROJECT_ID, { intervalSeconds: 120, enabled: true }) + const [url, opts] = spy.mock.calls[0] + expect(url).toBe(`/api/v1/projects/${PROJECT_ID}/schedule`) + expect((opts as RequestInit).method).toBe("PUT") + expect((opts as RequestInit).credentials).toBe("include") + expect(String((opts as RequestInit).body)).toContain("intervalSeconds") + }) + }) + + describe("channels", () => { + it("listChannels(projectId) GETs /channels", async () => { + const spy = mockFetch([]) + await api.listChannels(PROJECT_ID) + expect(spy).toHaveBeenCalledWith( + `/api/v1/projects/${PROJECT_ID}/channels`, + expect.objectContaining({ method: "GET" }), + ) + }) + + it("createChannel(projectId, {type,config,secret}) POSTs /channels with secret in body", async () => { + const spy = mockFetch({ id: "c1", type: "telegram", config: { chat_id: "1" }, enabled: true }) + await api.createChannel(PROJECT_ID, { type: "telegram", config: { chat_id: "1" }, secret: "BOT_TOKEN" }) + const [url, opts] = spy.mock.calls[0] + expect(url).toBe(`/api/v1/projects/${PROJECT_ID}/channels`) + expect((opts as RequestInit).method).toBe("POST") + expect(String((opts as RequestInit).body)).toContain("BOT_TOKEN") + }) + + it("deleteChannel(projectId, id) DELETEs /channels/{id}", async () => { + const spy = mockFetch(undefined, true, 204) + await api.deleteChannel(PROJECT_ID, "c1") + expect(spy).toHaveBeenCalledWith( + `/api/v1/projects/${PROJECT_ID}/channels/c1`, + expect.objectContaining({ method: "DELETE" }), + ) + }) + + it("testChannel(projectId, id) POSTs /channels/{id}/test", async () => { + const spy = mockFetch({ status: "ok" }) + await api.testChannel(PROJECT_ID, "c1") + expect(spy).toHaveBeenCalledWith( + `/api/v1/projects/${PROJECT_ID}/channels/c1/test`, + expect.objectContaining({ method: "POST" }), + ) + }) + }) + + it("domainHistory(projectId, domainId) GETs /domains/{did}/history", async () => { + const spy = mockFetch([]) + await api.domainHistory(PROJECT_ID, "d1") + expect(spy).toHaveBeenCalledWith( + `/api/v1/projects/${PROJECT_ID}/domains/d1/history`, + expect.objectContaining({ method: "GET", credentials: "include" }), + ) + }) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 748581d..a55bc2e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -3,6 +3,7 @@ import type { AuthState, Account, CreateAccountInput, Template, CreateTemplateInput, Domain, CreateDomainInput, ChangesetResponse, ApplyRequest, + Schedule, Channel, CreateChannelInput, CheckRun, } from "./types" export class UnauthorizedError extends Error { @@ -77,4 +78,18 @@ export const api = { req(projectPath(projectId, `/domains/${id}/check`)), applyDomain: (projectId: string, id: string, body: ApplyRequest) => req(projectPath(projectId, `/domains/${id}/apply`), { method: "POST", body: JSON.stringify(body) }), + domainHistory: (projectId: string, id: string) => + req(projectPath(projectId, `/domains/${id}/history`)), + + getSchedule: (projectId: string) => req(projectPath(projectId, "/schedule")), + putSchedule: (projectId: string, input: Schedule) => + req(projectPath(projectId, "/schedule"), { method: "PUT", body: JSON.stringify(input) }), + + listChannels: (projectId: string) => req(projectPath(projectId, "/channels")), + createChannel: (projectId: string, input: CreateChannelInput) => + req(projectPath(projectId, "/channels"), { method: "POST", body: JSON.stringify(input) }), + deleteChannel: (projectId: string, id: string) => + req(projectPath(projectId, `/channels/${id}`), { method: "DELETE" }), + testChannel: (projectId: string, id: string) => + req<{ status: string }>(projectPath(projectId, `/channels/${id}/test`), { method: "POST" }), } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 7546eeb..da63682 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -15,6 +15,7 @@ export interface Domain { zoneName: string zoneId: string templateId?: string | null // Go omitempty: поле может отсутствовать (undefined), а не null + lastCheckStatus?: string // "unknown" | "in_sync" | "drift" | "error" } export interface CreateDomainInput { providerAccountId: string @@ -23,6 +24,13 @@ export interface CreateDomainInput { templateId?: string | null } +export interface Schedule { intervalSeconds: number; enabled: boolean } + +export interface Channel { id: string; type: string; config: object; enabled: boolean } +export interface CreateChannelInput { type: string; config: object; secret: string } + +export interface CheckRun { id?: string; createdAt: string; result: object } + export interface RecordView { kind: string // add | update | delete | in_sync type: string diff --git a/web/src/hooks/useApi.ts b/web/src/hooks/useApi.ts index 2c8b3cd..7a7afd2 100644 --- a/web/src/hooks/useApi.ts +++ b/web/src/hooks/useApi.ts @@ -1,7 +1,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { api } from "@/api/client" import { useAuth } from "@/auth/AuthContext" -import type { CreateAccountInput, CreateTemplateInput, ApplyRequest, Project } from "@/api/types" +import type { CreateAccountInput, CreateTemplateInput, ApplyRequest, Project, Schedule, CreateChannelInput } from "@/api/types" function requireProjectId(project: Project | null): string { if (!project) throw new Error("no active project") @@ -141,3 +141,71 @@ export function useApplyDomain(id: string) { onSuccess: () => qc.invalidateQueries({ queryKey: ["check", project?.id, id] }), }) } +export function useDomainHistory(id: string) { + const { project } = useAuth() + return useQuery({ + queryKey: ["domainHistory", project?.id, id], + queryFn: () => api.domainHistory(project!.id, id), + enabled: !!project && !!id, + }) +} + +export function useSchedule() { + const { project } = useAuth() + return useQuery({ + queryKey: ["schedule", project?.id], + queryFn: () => api.getSchedule(project!.id), + enabled: !!project, + }) +} +export function useUpdateSchedule() { + const { project } = useAuth() + const qc = useQueryClient() + return useMutation({ + mutationFn: (input: Schedule) => { + const pid = requireProjectId(project) + return api.putSchedule(pid, input) + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["schedule", project?.id] }), + }) +} + +export function useChannels() { + const { project } = useAuth() + return useQuery({ + queryKey: ["channels", project?.id], + queryFn: () => api.listChannels(project!.id), + enabled: !!project, + }) +} +export function useCreateChannel() { + const { project } = useAuth() + const qc = useQueryClient() + return useMutation({ + mutationFn: (input: CreateChannelInput) => { + const pid = requireProjectId(project) + return api.createChannel(pid, input) + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["channels", project?.id] }), + }) +} +export function useDeleteChannel() { + const { project } = useAuth() + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => { + const pid = requireProjectId(project) + return api.deleteChannel(pid, id) + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["channels", project?.id] }), + }) +} +export function useTestChannel() { + const { project } = useAuth() + return useMutation({ + mutationFn: (id: string) => { + const pid = requireProjectId(project) + return api.testChannel(pid, id) + }, + }) +} From 34422420cac4ec53d77173dd48a69368315818cb Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 14:40:29 +0700 Subject: [PATCH 11/12] =?UTF-8?q?feat(web):=20=D1=80=D0=B0=D1=81=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B5,=20=D0=BA=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=D1=8B=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9,=20=D0=B8=D1=81=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BE=D0=BA?= =?UTF-8?q?,=20drift-badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/App.tsx | 4 + web/src/components/DomainHistory.test.tsx | 47 +++ web/src/components/DomainHistory.tsx | 66 +++++ web/src/components/Layout.tsx | 4 +- web/src/components/StatusBadge.test.tsx | 39 +++ web/src/components/StatusBadge.tsx | 42 +++ web/src/pages/ChannelsPage.test.tsx | 146 +++++++++ web/src/pages/ChannelsPage.tsx | 342 ++++++++++++++++++++++ web/src/pages/DomainDiffPage.test.tsx | 1 + web/src/pages/DomainDiffPage.tsx | 3 + web/src/pages/DomainsPage.test.tsx | 13 +- web/src/pages/DomainsPage.tsx | 5 + web/src/pages/SchedulePage.test.tsx | 83 ++++++ web/src/pages/SchedulePage.tsx | 145 +++++++++ 14 files changed, 937 insertions(+), 3 deletions(-) create mode 100644 web/src/components/DomainHistory.test.tsx create mode 100644 web/src/components/DomainHistory.tsx create mode 100644 web/src/components/StatusBadge.test.tsx create mode 100644 web/src/components/StatusBadge.tsx create mode 100644 web/src/pages/ChannelsPage.test.tsx create mode 100644 web/src/pages/ChannelsPage.tsx create mode 100644 web/src/pages/SchedulePage.test.tsx create mode 100644 web/src/pages/SchedulePage.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 3f2fd46..8f81bb1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,10 +3,12 @@ import { Routes, Route, Navigate } from "react-router-dom" import { ProtectedRoute } from "@/auth/ProtectedRoute" import { Layout } from "@/components/Layout" import { AccountsPage } from "@/pages/AccountsPage" +import { ChannelsPage } from "@/pages/ChannelsPage" import { DomainDiffPage } from "@/pages/DomainDiffPage" import { DomainsPage } from "@/pages/DomainsPage" import { LoginPage } from "@/pages/LoginPage" import { RegisterPage } from "@/pages/RegisterPage" +import { SchedulePage } from "@/pages/SchedulePage" import { TemplatesPage } from "@/pages/TemplatesPage" // Every non-auth route shares the same guard + chrome; wrapping here keeps @@ -29,6 +31,8 @@ export function App() { } /> } /> } /> + } /> + } /> ) } diff --git a/web/src/components/DomainHistory.test.tsx b/web/src/components/DomainHistory.test.tsx new file mode 100644 index 0000000..50f8f3b --- /dev/null +++ b/web/src/components/DomainHistory.test.tsx @@ -0,0 +1,47 @@ +import { render, screen } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { AuthProvider } from "@/auth/AuthContext" +import { api } from "@/api/client" +import { vi, beforeEach, test, expect } from "vitest" +import { DomainHistory } from "./DomainHistory" + +const PROJECT_ID = "p1" + +function renderComponent() { + const qc = new QueryClient() + return render( + + + + + , + ) +} + +beforeEach(() => { + vi.restoreAllMocks() + vi.spyOn(api.auth, "me").mockResolvedValue({ + user: { id: "u1", email: "a@b.com" }, + project: { id: PROJECT_ID, name: "Default" }, + }) +}) + +test("отрисовывает список проверок со сводкой updates/prunes", async () => { + vi.spyOn(api, "domainHistory").mockResolvedValue([ + { id: "r1", createdAt: "2026-07-01T10:00:00Z", result: { updates: 2, prunes: 1 } }, + { id: "r2", createdAt: "2026-06-30T10:00:00Z", result: { updates: 0, prunes: 0 } }, + ]) + + renderComponent() + + expect(await screen.findByText(/updates:\s*2/i)).toBeInTheDocument() + expect(screen.getByText(/prunes:\s*1/i)).toBeInTheDocument() +}) + +test("пустое состояние при отсутствии истории", async () => { + vi.spyOn(api, "domainHistory").mockResolvedValue([]) + + renderComponent() + + expect(await screen.findByText(/проверок пока нет/i)).toBeInTheDocument() +}) diff --git a/web/src/components/DomainHistory.tsx b/web/src/components/DomainHistory.tsx new file mode 100644 index 0000000..e1e40f7 --- /dev/null +++ b/web/src/components/DomainHistory.tsx @@ -0,0 +1,66 @@ +import { History, Loader2 } from "lucide-react" +import { useDomainHistory } from "@/hooks/useApi" + +// check_runs.result is a provider-neutral JSON summary written by +// store.SaveCheckRun (Фаза 3, T1/T4): {"updates": N, "prunes": N}. We read it +// defensively since it's typed as `object` end-to-end and older/foreign rows +// could in principle omit a key. +function summarize(result: object): string { + const r = result as Record + const updates = typeof r.updates === "number" ? r.updates : 0 + const prunes = typeof r.prunes === "number" ? r.prunes : 0 + return `updates: ${updates} · prunes: ${prunes}` +} + +function formatTimestamp(iso: string): string { + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return iso + return date.toLocaleString("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) +} + +export function DomainHistory({ domainId }: { domainId: string }) { + const history = useDomainHistory(domainId) + const runs = history.data ?? [] + + return ( +
+
+ +

+ История проверок +

+
+ + {history.isPending ? ( +
+ + Загружаю историю… +
+ ) : runs.length === 0 ? ( +

+ Проверок пока нет — история появится после первого запуска планировщика. +

+ ) : ( +
+ {runs.map((run, i) => ( +
+ + {formatTimestamp(run.createdAt)} + + {summarize(run.result)} +
+ ))} +
+ )} +
+ ) +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index f0c6ffc..ba4d200 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react" import { NavLink, useLocation, useNavigate } from "react-router-dom" -import { Globe, LogOut, Users, LayoutTemplate, SquareTerminal } from "lucide-react" +import { BellRing, CalendarClock, Globe, LogOut, Users, LayoutTemplate, SquareTerminal } from "lucide-react" import { useAuth } from "@/auth/AuthContext" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" @@ -9,6 +9,8 @@ const NAV = [ { to: "/domains", label: "Domains", icon: Globe }, { to: "/accounts", label: "Accounts", icon: Users }, { to: "/templates", label: "Templates", icon: LayoutTemplate }, + { to: "/schedule", label: "Schedule", icon: CalendarClock }, + { to: "/channels", label: "Channels", icon: BellRing }, ] as const export function Layout({ children }: { children: ReactNode }) { diff --git a/web/src/components/StatusBadge.test.tsx b/web/src/components/StatusBadge.test.tsx new file mode 100644 index 0000000..194bb63 --- /dev/null +++ b/web/src/components/StatusBadge.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react" +import { test, expect } from "vitest" +import { StatusBadge } from "./StatusBadge" + +test("in_sync — emerald, текст «in sync»", () => { + render() + expect(screen.getByText("in sync")).toBeInTheDocument() + const badge = screen.getByText("in sync").closest('[data-slot="status-badge"]') + expect(badge).toHaveAttribute("data-status", "in_sync") + expect(screen.getByTestId("status-dot")).toHaveStyle({ background: "var(--diff-add)" }) +}) + +test("drift — amber, текст «drift»", () => { + render() + expect(screen.getByText("drift")).toBeInTheDocument() + expect(screen.getByTestId("status-dot")).toHaveStyle({ background: "var(--diff-update)" }) +}) + +test("error — rose, текст «error»", () => { + render() + expect(screen.getByText("error")).toBeInTheDocument() + expect(screen.getByTestId("status-dot")).toHaveStyle({ background: "var(--diff-delete)" }) +}) + +test("unknown — muted, текст «unknown»", () => { + render() + expect(screen.getByText("unknown")).toBeInTheDocument() + expect(screen.getByTestId("status-dot")).toHaveStyle({ background: "var(--diff-readonly)" }) +}) + +test("отсутствие статуса трактуется как unknown", () => { + render() + expect(screen.getByText("unknown")).toBeInTheDocument() +}) + +test("неизвестное значение статуса не падает и рендерит unknown", () => { + render() + expect(screen.getByText("unknown")).toBeInTheDocument() +}) diff --git a/web/src/components/StatusBadge.tsx b/web/src/components/StatusBadge.tsx new file mode 100644 index 0000000..6d3711c --- /dev/null +++ b/web/src/components/StatusBadge.tsx @@ -0,0 +1,42 @@ +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" + +// Mirrors backend check status (store.Domain.LastCheckStatus / T4-T5): +// unknown | in_sync | drift | error. Colors reuse the diff-* tokens already +// established for the domain-diff console so a drifted zone reads the same +// "amber" whether you're looking at the list or the diff view. +export type CheckStatus = "unknown" | "in_sync" | "drift" | "error" + +const STATUS_META: Record = { + in_sync: { label: "in sync", color: "var(--diff-add)" }, + drift: { label: "drift", color: "var(--diff-update)" }, + error: { label: "error", color: "var(--diff-delete)" }, + unknown: { label: "unknown", color: "var(--diff-readonly)" }, +} + +function resolveStatus(status?: string): CheckStatus { + if (status === "in_sync" || status === "drift" || status === "error") return status + return "unknown" +} + +export function StatusBadge({ status, className }: { status?: string; className?: string }) { + const resolved = resolveStatus(status) + const meta = STATUS_META[resolved] + + return ( + + + {meta.label} + + ) +} diff --git a/web/src/pages/ChannelsPage.test.tsx b/web/src/pages/ChannelsPage.test.tsx new file mode 100644 index 0000000..162041f --- /dev/null +++ b/web/src/pages/ChannelsPage.test.tsx @@ -0,0 +1,146 @@ +import { render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { MemoryRouter } from "react-router-dom" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { ChannelsPage } from "./ChannelsPage" +import { AuthProvider } from "@/auth/AuthContext" +import { api } from "@/api/client" +import { vi, beforeEach, test, expect } from "vitest" +import type { Channel } from "@/api/types" + +const PROJECT_ID = "p1" +const channels: Channel[] = [ + { id: "c1", type: "telegram", config: { chat_id: "123456" }, enabled: true }, + { id: "c2", type: "webhook", config: { url: "https://hooks.example.com/x" }, enabled: false }, +] + +function renderPage() { + const qc = new QueryClient() + return render( + + + + + + + , + ) +} + +beforeEach(() => { + vi.restoreAllMocks() + vi.spyOn(api.auth, "me").mockResolvedValue({ + user: { id: "u1", email: "a@b.com" }, + project: { id: PROJECT_ID, name: "Default" }, + }) + vi.spyOn(api, "listChannels").mockResolvedValue(channels) +}) + +test("отрисовывает список каналов без секрета", async () => { + renderPage() + + expect(await screen.findByText("telegram")).toBeInTheDocument() + expect(screen.getByText("webhook")).toBeInTheDocument() + expect(screen.getByText(/123456/)).toBeInTheDocument() + expect(screen.getByText(/hooks\.example\.com/)).toBeInTheDocument() + + expect(document.body.textContent).not.toMatch(/bot_token/i) + expect(screen.queryByDisplayValue(/123456/)).not.toBeInTheDocument() +}) + +test("создание telegram-канала собирает config.chat_id + secret=bot_token", async () => { + const createSpy = vi.spyOn(api, "createChannel").mockResolvedValue({ + id: "c3", type: "telegram", config: { chat_id: "999" }, enabled: true, + }) + const user = userEvent.setup() + renderPage() + + await screen.findByText("telegram") + + await user.click(screen.getByRole("combobox", { name: /тип канала/i })) + await user.click(await screen.findByRole("option", { name: /telegram/i })) + + await user.type(screen.getByLabelText(/chat id/i), "999") + await user.type(screen.getByLabelText(/bot token/i), "SECRET_TOKEN") + await user.click(screen.getByRole("button", { name: /добавить канал/i })) + + await waitFor(() => + expect(createSpy).toHaveBeenCalledWith(PROJECT_ID, { + type: "telegram", + config: { chat_id: "999" }, + secret: "SECRET_TOKEN", + }), + ) + + expect(document.body.textContent).not.toMatch(/SECRET_TOKEN/) +}) + +test("создание webhook-канала собирает config.url без секрета", async () => { + const createSpy = vi.spyOn(api, "createChannel").mockResolvedValue({ + id: "c4", type: "webhook", config: { url: "https://hooks.example.com/y" }, enabled: true, + }) + const user = userEvent.setup() + renderPage() + + await screen.findByText("telegram") + + await user.click(screen.getByRole("combobox", { name: /тип канала/i })) + await user.click(await screen.findByRole("option", { name: /webhook/i })) + + await user.type(screen.getByLabelText(/url/i), "https://hooks.example.com/y") + await user.click(screen.getByRole("button", { name: /добавить канал/i })) + + await waitFor(() => + expect(createSpy).toHaveBeenCalledWith(PROJECT_ID, { + type: "webhook", + config: { url: "https://hooks.example.com/y" }, + secret: "", + }), + ) +}) + +test("удаление канала вызывает api.deleteChannel", async () => { + const deleteSpy = vi.spyOn(api, "deleteChannel").mockResolvedValue(undefined) + vi.spyOn(window, "confirm").mockReturnValue(true) + const user = userEvent.setup() + renderPage() + + await screen.findByText("telegram") + + await user.click(screen.getByRole("button", { name: /удалить канал telegram/i })) + + await waitFor(() => expect(deleteSpy).toHaveBeenCalledWith(PROJECT_ID, "c1")) +}) + +test("кнопка «Тест» вызывает api.testChannel", async () => { + const testSpy = vi.spyOn(api, "testChannel").mockResolvedValue({ status: "ok" }) + const user = userEvent.setup() + renderPage() + + await screen.findByText("telegram") + + const testButtons = screen.getAllByRole("button", { name: /тест/i }) + await user.click(testButtons[0]) + + await waitFor(() => expect(testSpy).toHaveBeenCalledWith(PROJECT_ID, "c1")) +}) + +test("ошибка тест-отправки отображается как alert", async () => { + vi.spyOn(api, "testChannel").mockRejectedValue(new Error("Канал не отвечает")) + const user = userEvent.setup() + renderPage() + + await screen.findByText("telegram") + + const testButtons = screen.getAllByRole("button", { name: /тест/i }) + await user.click(testButtons[0]) + + expect(await screen.findByRole("alert")).toHaveTextContent("Канал не отвечает") +}) + +test("пустое состояние при отсутствии каналов", async () => { + vi.spyOn(api, "listChannels").mockResolvedValue([]) + renderPage() + + expect(await screen.findByText(/каналов пока нет/i)).toBeInTheDocument() +}) diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx new file mode 100644 index 0000000..ad51923 --- /dev/null +++ b/web/src/pages/ChannelsPage.tsx @@ -0,0 +1,342 @@ +import { useId, useState } from "react" +import { Controller, useForm, useWatch } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { Inbox, Loader2, Plus, Send, Trash2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Field, + FieldContent, + FieldError, + FieldGroup, + FieldLabel, + FieldSet, +} from "@/components/ui/field" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { useChannels, useCreateChannel, useDeleteChannel, useTestChannel } from "@/hooks/useApi" +import { cn } from "@/lib/utils" +import type { Channel, CreateChannelInput } from "@/api/types" + +const CHANNEL_TYPES = [ + { value: "telegram", label: "Telegram" }, + { value: "webhook", label: "Webhook" }, +] as const + +const channelFormSchema = z + .object({ + type: z.enum(["telegram", "webhook"]), + chatId: z.string(), + botToken: z.string(), + url: z.string(), + }) + .superRefine((values, ctx) => { + if (values.type === "telegram") { + if (!values.chatId.trim()) { + ctx.addIssue({ code: "custom", path: ["chatId"], message: "Укажите chat_id" }) + } + if (!values.botToken.trim()) { + ctx.addIssue({ code: "custom", path: ["botToken"], message: "Укажите bot token" }) + } + return + } + if (!values.url.trim()) { + ctx.addIssue({ code: "custom", path: ["url"], message: "Укажите URL" }) + return + } + try { + new URL(values.url) + } catch { + ctx.addIssue({ code: "custom", path: ["url"], message: "Некорректный URL, включая протокол http(s)://" }) + } + }) + +type ChannelForm = z.infer + +const EMPTY_FORM: ChannelForm = { type: "telegram", chatId: "", botToken: "", url: "" } + +// Channel.config is a generic `object` end-to-end (T5/T7) — it never carries +// the secret (bot_token/signing key), only the public half (chat_id/url), so +// rendering every key is always safe to show in the list. +function formatConfig(config: object): string { + const entries = Object.entries(config as Record) + if (entries.length === 0) return "—" + return entries.map(([k, v]) => `${k}=${String(v)}`).join(" · ") +} + +function ChannelForm({ onCreated }: { onCreated: () => void }) { + const createChannel = useCreateChannel() + const typeFieldId = useId() + const chatIdFieldId = useId() + const botTokenFieldId = useId() + const urlFieldId = useId() + + const { + control, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(channelFormSchema), + defaultValues: EMPTY_FORM, + }) + + const type = useWatch({ control, name: "type" }) + + function onSubmit(values: ChannelForm) { + const input: CreateChannelInput = + values.type === "telegram" + ? { type: "telegram", config: { chat_id: values.chatId.trim() }, secret: values.botToken.trim() } + : { type: "webhook", config: { url: values.url.trim() }, secret: "" } + + createChannel.mutate(input, { + onSuccess: () => { + reset(EMPTY_FORM) + onCreated() + }, + }) + } + + return ( +
+
+ + + Тип канала + + ( + + )} + /> + + + + {type === "telegram" ? ( + <> + + Chat ID + + ( + + )} + /> + + + + + Bot token + + ( + + )} + /> + + + + + ) : ( + + URL + + ( + + )} + /> + + + + )} + +
+ +
+ {createChannel.isError && ( + + {createChannel.error.message} + + )} + +
+
+ ) +} + +export function ChannelsPage() { + const channels = useChannels() + const deleteChannel = useDeleteChannel() + const testChannel = useTestChannel() + const [testingId, setTestingId] = useState(null) + + const channelList = channels.data ?? [] + + function onDelete(channel: Channel) { + if (window.confirm(`Удалить канал «${channel.type}»? Действие необратимо.`)) { + deleteChannel.mutate(channel.id) + } + } + + function onTest(channel: Channel) { + setTestingId(channel.id) + testChannel.mutate(channel.id) + } + + return ( +
+
+ + notifications + +

Каналы уведомлений

+
+ + setTestingId(null)} /> + + {deleteChannel.isError && ( + + {deleteChannel.error.message} + + )} + {testChannel.isError && ( + + {testChannel.error.message} + + )} + + {channelList.length === 0 ? ( +
+ + Каналов пока нет — добавьте Telegram или Webhook выше. +
+ ) : ( + + + + Тип + Конфигурация + Статус + Действия + + + + {channelList.map((c) => ( + + {c.type} + + {formatConfig(c.config)} + + + + {c.enabled ? "включён" : "выключен"} + + + +
+ + +
+
+
+ ))} +
+
+ )} +
+ ) +} diff --git a/web/src/pages/DomainDiffPage.test.tsx b/web/src/pages/DomainDiffPage.test.tsx index 4c64314..b66adc6 100644 --- a/web/src/pages/DomainDiffPage.test.tsx +++ b/web/src/pages/DomainDiffPage.test.tsx @@ -28,6 +28,7 @@ beforeEach(() => { user: { id: "u1", email: "a@b.com" }, project: { id: PROJECT_ID, name: "Default" }, }) + vi.spyOn(api, "domainHistory").mockResolvedValue([]) }) test("apply sends applyPrunes=false by default, true only after opting in", async () => { diff --git a/web/src/pages/DomainDiffPage.tsx b/web/src/pages/DomainDiffPage.tsx index 7749a99..c93ab41 100644 --- a/web/src/pages/DomainDiffPage.tsx +++ b/web/src/pages/DomainDiffPage.tsx @@ -2,6 +2,7 @@ import { useId, useState } from "react" import { useParams } from "react-router-dom" import { AlertTriangle, Loader2, Play, RefreshCw, TriangleAlert } from "lucide-react" import { DiffView } from "@/components/DiffView" +import { DomainHistory } from "@/components/DomainHistory" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { Label } from "@/components/ui/label" @@ -137,6 +138,8 @@ export function DomainDiffPage() { )} + + ) } diff --git a/web/src/pages/DomainsPage.test.tsx b/web/src/pages/DomainsPage.test.tsx index 3822faf..d4e93ba 100644 --- a/web/src/pages/DomainsPage.test.tsx +++ b/web/src/pages/DomainsPage.test.tsx @@ -18,8 +18,8 @@ const templates: Template[] = [ { id: "t2", name: "Minimal", records: [], version: 1 }, ] const domains: Domain[] = [ - { id: "d1", providerAccountId: "acc1", zoneName: "example.com.", zoneId: "z1", templateId: null }, - { id: "d2", providerAccountId: "acc2", zoneName: "test.org.", zoneId: "z2", templateId: "t1" }, + { id: "d1", providerAccountId: "acc1", zoneName: "example.com.", zoneId: "z1", templateId: null, lastCheckStatus: "drift" }, + { id: "d2", providerAccountId: "acc2", zoneName: "test.org.", zoneId: "z2", templateId: "t1", lastCheckStatus: "in_sync" }, ] function renderPage() { @@ -108,3 +108,12 @@ test("пустое состояние при отсутствии доменов expect(await screen.findByText(/доменов пока нет/i)).toBeInTheDocument() }) + +test("drift-badge отражает lastCheckStatus каждого домена", async () => { + renderPage() + + await screen.findByText("example.com.") + + expect(screen.getByText("drift")).toBeInTheDocument() + expect(screen.getByText("in sync")).toBeInTheDocument() +}) diff --git a/web/src/pages/DomainsPage.tsx b/web/src/pages/DomainsPage.tsx index 580f23e..ad4afa2 100644 --- a/web/src/pages/DomainsPage.tsx +++ b/web/src/pages/DomainsPage.tsx @@ -2,6 +2,7 @@ import { useState } from "react" import { Link } from "react-router-dom" import { Inbox, Loader2, Trash2, Upload } from "lucide-react" import { Button } from "@/components/ui/button" +import { StatusBadge } from "@/components/StatusBadge" import { Select, SelectContent, @@ -134,6 +135,7 @@ export function DomainsPage() { Zone Учётка Шаблон + Статус Действия @@ -167,6 +169,9 @@ export function DomainsPage() { + + +
+
+ + ) +} + +export function SchedulePage() { + const schedule = useSchedule() + + return ( +
+
+ + scheduler + +

Расписание проверок

+
+ + {schedule.isPending ? ( +
+ + Загружаю расписание… +
+ ) : ( + + )} +
+ ) +} From 504c4c081fc018bb644756518695e51b6079958d Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 14:58:09 +0700 Subject: [PATCH 12/12] fix(phase3): skip templateless domains in scheduler; block CGNAT range in webhook SSRF guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Domains imported without a template (TemplateID == nil) are a valid, unconfigured state, not a failure — RunOnce now skips them before calling checkDomain instead of letting LoadDomain's "no template" error turn into StatusError and a spammy unknown->error notification. isBlockedIP now also rejects 100.64.0.0/10 (RFC 6598 carrier-grade NAT), which net.IP.IsPrivate() does not cover, closing an SSRF gap in the webhook destination guard (both the pre-request check and the per-dial check use isBlockedIP). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3 --- internal/notify/notify_test.go | 26 +++++++++++ internal/notify/webhook.go | 25 +++++++++-- internal/scheduler/scheduler.go | 10 +++++ internal/scheduler/scheduler_test.go | 65 +++++++++++++++++++++++++--- 4 files changed, 117 insertions(+), 9 deletions(-) diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go index 9c1126d..62a7c0d 100644 --- a/internal/notify/notify_test.go +++ b/internal/notify/notify_test.go @@ -3,6 +3,7 @@ package notify import ( "context" "encoding/json" + "net" "net/http" "net/http/httptest" "strings" @@ -195,6 +196,31 @@ func TestDialControlBlocksActualConnectingAddress(t *testing.T) { } } +func TestIsBlockedIPCGNATRange(t *testing.T) { + cases := []struct { + name string + ip string + blocked bool + }{ + {"cgnat start", "100.64.0.1", true}, + {"cgnat end", "100.127.255.255", true}, + {"just below cgnat", "100.63.255.255", false}, + {"just above cgnat", "100.128.0.0", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ip := net.ParseIP(tc.ip) + if ip == nil { + t.Fatalf("failed to parse %q", tc.ip) + } + got := isBlockedIP(ip) + if got != tc.blocked { + t.Fatalf("isBlockedIP(%q) = %v, want %v", tc.ip, got, tc.blocked) + } + }) + } +} + func TestDialControlAllowsEverythingWhenAllowPrivate(t *testing.T) { control := dialControl(true) if err := control("tcp", "127.0.0.1:80", nil); err != nil { diff --git a/internal/notify/webhook.go b/internal/notify/webhook.go index d15a5ea..60567d5 100644 --- a/internal/notify/webhook.go +++ b/internal/notify/webhook.go @@ -83,17 +83,34 @@ func isAllowedURL(rawurl string) error { return nil } +// cgnatBlock is the shared address space reserved for carrier-grade NAT +// (RFC 6598, 100.64.0.0/10). net.IP.IsPrivate() only covers RFC1918/RFC4193 +// and does not treat this range as private, so it must be checked +// explicitly or CGNAT-addressed internal services would be reachable via +// webhook SSRF. +var cgnatBlock = func() *net.IPNet { + _, block, err := net.ParseCIDR("100.64.0.0/10") + if err != nil { + panic(err) + } + return block +}() + // isBlockedIP reports whether ip must never be connected to: loopback, -// private (RFC1918 etc.), link-local, unspecified, or multicast. Used both -// by isAllowedURL's pre-request check and by dialControl's per-connection -// check. +// private (RFC1918 etc.), link-local, unspecified, multicast, or +// carrier-grade NAT (RFC 6598). Used both by isAllowedURL's pre-request +// check and by dialControl's per-connection check. func isBlockedIP(ip net.IP) bool { + if v4 := ip.To4(); v4 != nil { + ip = v4 + } return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || - ip.IsMulticast() + ip.IsMulticast() || + cgnatBlock.Contains(ip) } // dialControl returns a net.Dialer.Control function enforcing the SSRF guard diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index c470d09..240ad9d 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -102,6 +102,16 @@ func (s *Scheduler) RunOnce(ctx context.Context, now time.Time) error { } for _, d := range domains { + // A domain with no template attached is not yet configured for + // checking (a valid, expected state right after import) — not a + // failure. Checking it would make LoadDomain return "domain has + // no template", turning into a StatusError that spams a + // notification and shows a red badge for a domain the user + // simply hasn't set up yet. Skip it silently: no check, no + // status change, no notification. + if d.TemplateID == nil { + continue + } s.checkDomain(ctx, sch.ProjectID, d, now) } diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index dda06ee..280e1d6 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -75,13 +75,19 @@ func (m *mockStore) CountDriftDomains(ctx context.Context) (int, error) { return m.driftCount, nil } -// mockChecker returns a preset Changeset or error per domainID. +// mockChecker returns a preset Changeset or error per domainID, and records +// which domain IDs it was called with. type mockChecker struct { + mu sync.Mutex results map[uuid.UUID]diff.Changeset errs map[uuid.UUID]error + calls []uuid.UUID } func (c *mockChecker) Check(ctx context.Context, projectID, domainID uuid.UUID) (diff.Changeset, error) { + c.mu.Lock() + c.calls = append(c.calls, domainID) + c.mu.Unlock() if err, ok := c.errs[domainID]; ok { return diff.Changeset{}, err } @@ -113,8 +119,9 @@ func driftChangeset() diff.Changeset { func TestRunOnce_NotifiesOnDriftNotOnFirstInSync(t *testing.T) { projectID := uuid.New() - domainA := store.Domain{ID: uuid.New(), ProjectID: projectID} - domainB := store.Domain{ID: uuid.New(), ProjectID: projectID} + templateID := uuid.New() + domainA := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: &templateID} + domainB := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: &templateID} st := newMockStore() st.schedules = []store.Schedule{{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: 3600, Enabled: true}} @@ -175,7 +182,8 @@ func TestRunOnce_NotifiesOnDriftNotOnFirstInSync(t *testing.T) { func TestRunOnce_Idempotent_NoRepeatNotifyOnUnchangedDrift(t *testing.T) { projectID := uuid.New() - domainA := store.Domain{ID: uuid.New(), ProjectID: projectID} + templateID := uuid.New() + domainA := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: &templateID} st := newMockStore() st.schedules = []store.Schedule{{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: 3600, Enabled: true}} @@ -205,7 +213,8 @@ func TestRunOnce_Idempotent_NoRepeatNotifyOnUnchangedDrift(t *testing.T) { func TestRunOnce_CheckError_StatusErrorAndNotify(t *testing.T) { projectID := uuid.New() - domainA := store.Domain{ID: uuid.New(), ProjectID: projectID} + templateID := uuid.New() + domainA := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: &templateID} st := newMockStore() st.schedules = []store.Schedule{{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: 3600, Enabled: true}} @@ -236,6 +245,52 @@ func TestRunOnce_CheckError_StatusErrorAndNotify(t *testing.T) { } } +func TestRunOnce_SkipsDomainWithoutTemplate(t *testing.T) { + projectID := uuid.New() + templateID := uuid.New() + domainNoTemplate := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: nil} + domainWithTemplate := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: &templateID} + + st := newMockStore() + st.schedules = []store.Schedule{{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: 3600, Enabled: true}} + st.domains[projectID] = []store.Domain{domainNoTemplate, domainWithTemplate} + + checker := &mockChecker{ + results: map[uuid.UUID]diff.Changeset{domainWithTemplate.ID: {}}, + } + notifier := &mockNotifier{} + m := metrics.New() + sched := New(st, checker, notifier, m) + + if err := sched.RunOnce(context.Background(), time.Now()); err != nil { + t.Fatalf("RunOnce: %v", err) + } + + for _, id := range checker.calls { + if id == domainNoTemplate.ID { + t.Fatalf("Checker.Check was called for templateless domain %s, want skipped", id) + } + } + if len(checker.calls) != 1 || checker.calls[0] != domainWithTemplate.ID { + t.Fatalf("Checker.Check calls = %v, want exactly [%s]", checker.calls, domainWithTemplate.ID) + } + + if _, ok := st.status[domainNoTemplate.ID]; ok { + t.Fatalf("templateless domain status = %q, want no status set (never checked)", st.status[domainNoTemplate.ID]) + } + if st.status[domainWithTemplate.ID] != StatusInSync { + t.Fatalf("domain with template status = %q, want in_sync", st.status[domainWithTemplate.ID]) + } + + if got := notifier.count(); got != 0 { + t.Fatalf("notifications sent = %d, want 0 (templateless skip is silent, and template domain unknown->in_sync is not news)", got) + } + + if got := testutil.ToFloat64(m.ChecksTotal.WithLabelValues(StatusInSync)); got != 1 { + t.Fatalf("ChecksTotal{in_sync} = %v, want 1 (only the templated domain was checked)", got) + } +} + func TestShouldNotify(t *testing.T) { cases := []struct { name string