collect

package
v0.2.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 10, 2026 License: AGPL-3.0 Imports: 18 Imported by: 0

Documentation

Overview

Package collect is the FIRST-PARTY PASSIVE data connector architecture (v0.0.2, milestones M14/M15, owner directive §2).

What a connector is

A connector turns one data provider's records into GSEER facts — (asset, optional service, observation) triples — through the SAME ingestion path every other writer uses (store.IngestFacts). There is no side door: the suppression gate (M13) and the assets backstop trigger see everything a collector produces.

The four honesty contracts

  1. Search ≠ Scan. A collector only QUERIES providers the operator configured about identifiers the operator or the index already named (seeds, or assets already in the index). Nothing here port- scans, banner-grabs, or enumerates the internet. The P2 active observation regime (gseer scan, PAC gates, scopes table) is a different milestone with different gates.

  2. Terms before traffic. Every connector carries a Review verdict. A connector whose provider terms do not clearly permit automated collection and indexing is DISABLED — the runner refuses to run it (E_SOURCE_TERMS) even if a database row claims enabled=true. docs/SOURCES.md is the review register; each connector's Review.DocRef points at its section.

  3. Discovery ≠ Observation. Passive data (DNS answers, certificate log entries, registry objects) proves an identifier EXISTS or a mapping was PUBLISHED — never that a service answered on a port. P1 connectors therefore write ASSET-LEVEL facts only (Service == nil): no port, protocol, HTTP, TLS or banner information is ever invented from passive sources. In search_index every such record appears as row_kind='asset-only'. Service observations require a service observation, which is P2 with its own gates.

  4. Provenance on every record. Facts carry the delivering source's identity (observation.source_id), the connector category in provenance, the observation timestamp (the provider's where it has one; the lookup time otherwise) and evidence with a confidence label. Nothing a connector writes can be mistaken for the labeled synthetic fixture corpus (provenance=fixture is reserved for it).

Data quality (owner directive §4)

  • Normalization: every identifier passes the suppress package's canonicalizers (lowercase IDNA, trailing-dot strip, netip form) — the same functions the suppression gate uses, so normalization can never diverge from suppression.
  • Idempotency: L1 delivery identity (source, cursor) + L2 fact identity (source, asset, service, observed_at, payload_hash) make re-runs no-ops at the store level; connectors additionally dedupe within a batch.
  • First-seen / last-seen / freshness: the asset/service upserts keep first_seen, GREATEST last_seen and clear stale on fresh sightings — a re-observed identifier ages forward, a missing one ages stale.
  • Retry + checkpoint: the Runner retries transport failures with backoff, stores per-source cursors (source_checkpoints), and parks poison records in ingestion_dead_letter instead of dropping them.

Layering

collect → storage/postgres → storage, suppress
collect → suppress (normalizers only)

The package never imports internal/gql (search stays read-only over the index) and never opens listening sockets. The governance tests pin both.

Index

Constants

View Source
const (
	DefaultRPS          = 1.0
	DefaultTimeout      = 15 * time.Second
	DefaultMaxBodyBytes = 8 << 20 // 8 MiB — no provider answer is that big
	DefaultUserAgent    = "GSEER/0.0.2 (passive connector; https://github.com/Lutfifakee-Project/GSEER)"
)

Politeness defaults (documented in docs/SOURCES.md per source; a source's own documented limit may be lower — the connector applies it through its ClientConfig).

View Source
const (
	ReviewEnabled  = "reviewed-enabled"
	ReviewDisabled = "disabled"
)

Review verdicts.

View Source
const (
	DefaultMaxAttempts = 3
	DefaultBackoff     = 2 * time.Second
	DefaultMaxBatches  = 100
)

Runner defaults: passive-polite transport behavior.

View Source
const DefaultRunLimit = 500

DefaultRunLimit bounds one run when the config says nothing: passive politeness by default.

Variables

This section is empty.

Functions

func Fatal

func Fatal(err error) error

Fatal wraps err into a FatalError (nil passes through).

func Registry

func Registry() map[string]Collector

Registry returns a copy of the built-in connector registry keyed by connector kind. Review verdicts live with the connectors (see docs/SOURCES.md for the full review register).

Types

type BadRecord

type BadRecord struct {
	Raw    RawFact
	Reason string
}

BadRecord is a raw record the pipeline refused (dead-lettered, never silently dropped).

func BuildFacts

func BuildFacts(connectorKind string, raws []RawFact) ([]storage.Fact, []BadRecord)

BuildFacts canonicalizes and dedupes raw records into ingestible asset-level facts (data-quality pipeline; owner directive §4):

  • identifiers normalize through the suppression package's canonicalizers — ONE normalization definition shared with the gate;
  • invalid records come back as BadRecords (dead-letter fodder), they are never silently dropped;
  • the batch is deduped on (asset kind, canonical value) so one provider response yields one fact per identifier;
  • every fact is asset-level: no service rows are ever built here (discovery ≠ observation);
  • provenance is the connector kind; confidence lands in evidence.

type Batch

type Batch struct {
	Raws   []RawFact
	Cursor string
	Done   bool
	// Notes are connector-reported counters surfaced in the job stats.
	Notes map[string]int
}

Batch is one connector's output: RAW provider records only. The Runner owns the fact pipeline (normalize → dedupe → ingest) so normalization can never diverge between connectors, and every record passes the same suppression gate at the store. Records MUST be asset-level (P1 honesty contract 3): a RawFact can never express a service, and the runner would refuse one that tried. Cursor resumes the next run (” = begin; Done means the connector has nothing further this pass).

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is the shared, rate-limited HTTP fetcher for passive connectors. Defaults are deliberately conservative: passive means the provider never feels us.

func NewClient

func NewClient(cfg ClientConfig) *Client

NewClient builds a Client.

func (*Client) Get

func (c *Client) Get(ctx context.Context, url string) (io.ReadCloser, error)

Get fetches url with rate limiting and the body-size cap. The body must be closed by the caller. Only https/http URLs are honored.

func (*Client) GetJSON

func (c *Client) GetJSON(ctx context.Context, url string, out any) error

GetJSON fetches url and decodes the JSON body into out.

type ClientConfig

type ClientConfig struct {
	// RequestsPerSecond caps the request rate (0 = DefaultRPS).
	RequestsPerSecond float64
	// Timeout bounds one request (0 = DefaultTimeout).
	Timeout time.Duration
	// MaxBodyBytes bounds one response body (0 = DefaultMaxBodyBytes).
	MaxBodyBytes int64
	// UserAgent identifies GSEER honestly (0 value = DefaultUserAgent).
	UserAgent string
}

ClientConfig tunes one Client.

type Collector

type Collector interface {
	// Kind is the connector type ("dns", "rdap", "asn", "ct").
	Kind() string
	// Review is the terms-compliance verdict (see doc.go contract 2).
	Review() Review
	// Collect runs one passive pass and returns the batch.  A connector
	// NEVER ingests: it only produces facts (the Runner owns ingestion,
	// checkpoints, dead letters and job bookkeeping).
	Collect(ctx Ctx) (*Batch, error)
}

Collector is one data provider's passive connector. Implementations are stateless: everything they need arrives in CollectInput.

type Ctx

type Ctx struct {
	Context context.Context // the run's context; never nil from the Runner
	Source  SourceRef
	Config  SourceConfig
	Cursor  string
	Limit   int     // per-run record budget (0 = DefaultRunLimit)
	Client  *Client // shared rate-limited HTTP client
	// IndexSeeds loads seed identifiers already in the index (seed_mode
	// "index"): no third-party target lists are ever used.
	IndexSeeds func(kind string, limit int) ([]string, error)
	// Now is injectable for tests.
	Now func() time.Time
}

Ctx is the connector's view of one run: its configuration, the resume cursor, the shared HTTP client (nil for DNS-only connectors), the run context (cancellation flows to provider calls) and the seed loader for index-driven enrichment.

type Fact

type Fact = storage.Fact

Fact aliases the storage-level ingest unit so connector code and the runner read naturally ([]storage.Fact IS []Fact — identical alias).

type FatalError

type FatalError struct{ Err error }

FatalError marks a connector error that retries cannot fix (a refused query, a malformed request). Wrap at the point the failure is known permanent; the runner's retry loop passes it through unwrapped.

func (FatalError) Error

func (e FatalError) Error() string

func (FatalError) Unwrap

func (e FatalError) Unwrap() error

Unwrap gives errors.Is/As access to the wrapped cause.

type RawFact

type RawFact struct {
	AssetKind  string // "ip" | "domain" | "hostname"
	Value      string // raw surface form; canonicalized by the pipeline
	ObservedAt time.Time
	Confidence string // "high" | "medium" (provider-dependent)

	// Enrichment fields (asset metadata the upsert merges without
	// blanking stored values): the ASN/BGP connector fills ASN/ASNOrg/
	// GeoCountry; RDAP fills GeoCountry.  They ride on the asset row —
	// never invented beyond what the provider stated.
	ASN        int
	ASNOrg     string
	GeoCountry string

	// Extra lands inside observation.evidence under the connector's key.
	Extra map[string]any
}

RawFact is one provider record a connector derived from a response, pre-ingestion (the fact pipeline turns these into storage.Fact values, dead-lettering the invalid ones). A raw record is ALWAYS asset-level: there is no way to express a service here (P1 honesty contract 3).

type RedriveOptions

type RedriveOptions struct {
	// SourceID restricts to one source; 0 = all sources.
	SourceID int64
	// Limit bounds the pass (0 = 500).
	Limit int
	// Discard marks the pending rows discarded instead of re-driving.
	Discard bool
}

RedriveOptions bounds one re-drive pass.

type RedriveStats

type RedriveStats struct {
	Considered   int `json:"considered"`
	Redriven     int `json:"redriven"`      // ingestion attempted
	Resolved     int `json:"resolved"`      // applied successfully
	StillFailing int `json:"still_failing"` // attempted, failed again (kept pending, attempts bumped)
	Suppressed   int `json:"suppressed"`    // refused by the suppression gate (kept pending)
	Discarded    int `json:"discarded"`     // only with Discard
}

RedriveStats reports one re-drive pass.

func Redrive

func Redrive(ctx context.Context, store *postgres.Store, opts RedriveOptions) (*RedriveStats, error)

Redrive runs one pass over pending dead letters.

type Review

type Review struct {
	// Verdict is "reviewed-enabled" (clear compatible terms: the
	// connector may be enabled by the operator) or "disabled"
	// (credentials/payment/unclear permission for indexing or reuse —
	// the runner refuses to run it, period).
	Verdict string `json:"verdict"`
	// Why is the one-line justification (what the terms say or lack).
	Why string `json:"why"`
	// DocRef anchors the full review in docs/SOURCES.md.
	DocRef string `json:"doc_ref"`
}

Review is the terms-compliance verdict for one connector's provider.

type RunStats

type RunStats struct {
	Source     string `json:"source"`
	Connector  string `json:"connector"`
	JobID      int64  `json:"job_id"`
	Records    int    `json:"records"`
	Facts      int    `json:"facts"`
	Ingested   int    `json:"ingested"`
	Updated    int    `json:"updated"`
	Deduped    int    `json:"deduped"`
	Suppressed int    `json:"suppressed"`
	DeadLetter int    `json:"dead_lettered"`
	Batches    int    `json:"batches"`
	Cursor     string `json:"cursor"`
	Done       bool   `json:"done"`
	// Skipped is set (with the reason) when nothing ran.
	Skipped string `json:"skipped,omitempty"`
}

RunStats reports one run; marshalled into the collection_jobs.stats JSONB and rendered by the CLI.

type Runner

type Runner struct {
	Store  *postgres.Store
	Client *Client // shared default client (per-source config may override)

	// Now is injectable for tests; zero value = time.Now.
	Now func() time.Time
	// MaxAttempts bounds transport retries per batch (0 = DefaultMaxAttempts).
	MaxAttempts int
	// Backoff is the base delay between retries, doubled per attempt
	// (0 = DefaultBackoff).
	Backoff time.Duration
	// MaxBatches bounds one run's batch loop (0 = DefaultMaxBatches) —
	// a stuck cursor must not spin forever.
	MaxBatches int
	// contains filtered or unexported fields
}

Runner executes connector passes against the store.

func NewRunner

func NewRunner(store *postgres.Store, client *Client) *Runner

NewRunner builds a Runner over store.

func (*Runner) Run

func (r *Runner) Run(ctx context.Context, sourceName string) (*RunStats, error)

Run executes one passive pass for the named registered source.

Gate order (fail-closed):

  1. unknown connector kind → E_SOURCE_CONNECTOR (hard error)
  2. connector review verdict disabled → E_SOURCE_TERMS (hard error: the terms gate outranks a hand-flipped enabled=true row)
  3. source enabled=false → clean skip (Skipped="disabled")

type SourceConfig

type SourceConfig struct {
	// Endpoint is the provider's API base URL (HTTP connectors).
	Endpoint string `json:"endpoint,omitempty" yaml:"endpoint,omitempty"`
	// Seeds are operator-configured identifiers (seed_mode "config").
	Seeds []string `json:"seeds,omitempty" yaml:"seeds,omitempty"`
	// SeedMode: "config" (use Seeds) or "index" (use identifiers already
	// in the GSEER index — the connector enriches the operator's own
	// corpus; it never consults a third-party target list).
	SeedMode string `json:"seed_mode,omitempty" yaml:"seed_mode,omitempty"`
	// SeedKind is the asset kind index seeds are drawn from
	// ("ip" for rdap/asn enrichment; unused for "config" mode).
	SeedKind string `json:"seed_kind,omitempty" yaml:"seed_kind,omitempty"`
	// Resolver is an optional DNS server address ("host:port") for the
	// dns/asn connectors; empty = the system resolver.
	Resolver string `json:"resolver,omitempty" yaml:"resolver,omitempty"`
	// RequestsPerSecond caps the HTTP client (0 = connector default).
	RequestsPerSecond float64 `json:"requests_per_second,omitempty" yaml:"requests_per_second,omitempty"`
	// TimeoutMs bounds one provider request (0 = connector default).
	TimeoutMs int `json:"timeout_ms,omitempty" yaml:"timeout_ms,omitempty"`
	// LimitPerRun bounds one run's record budget (0 = DefaultRunLimit).
	LimitPerRun int `json:"limit_per_run,omitempty" yaml:"limit_per_run,omitempty"`
}

SourceConfig is the decoded data_sources.config / YAML source block.

func DecodeSourceConfig

func DecodeSourceConfig(raw []byte) (SourceConfig, error)

DecodeSourceConfig parses the raw JSONB config; unknown keys are kept out — configs are small and explicit.

type SourceRef

type SourceRef struct {
	ID   int64
	Name string
}

SourceRef identifies the data_sources row driving the run.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL