usenet

package
v0.2.21 Latest Latest
Warning

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

Go to latest
Published: Oct 28, 2026 License: GPL-3.0 Imports: 24 Imported by: 0

Documentation

Overview

Package usenet is Ether's Usenet layer: an NNTP client, per-provider connection pools with priority scheduling and circuit breakers, and a multi-provider fetcher that implements core.ArticleFetcher with primary-first ordering and backfill from block accounts.

Index

Constants

View Source
const (
	StateOK       = "ok"
	StateBackoff  = "backoff"     // connection failures; retrying after a delay
	StateAuthFail = "auth_failed" // disabled; re-probed after a growing backoff or on a settings change
	StateClosed   = "closed"
)

Provider states reported in statistics.

View Source
const (
	DefaultDialTimeout     = 15 * time.Second
	DefaultCommandTimeout  = 60 * time.Second
	DefaultIdleReadTimeout = 5 * time.Second
)

Default timeouts for a single connection.

View Source
const (
	DefaultConnections       = 8
	DefaultBodyPipelineDepth = 4
	DefaultMinIdle           = 2
	DefaultPrefetchFraction  = 0.25
	DefaultIdleTimeout       = 90 * time.Second
	DefaultKeepAlive         = 30 * time.Second
	DefaultMissTTL           = 2 * time.Minute
	DefaultMissCacheSize     = 100_000
	DefaultRetries           = 2
	DefaultBackoffMin        = time.Second
	DefaultBackoffMax        = 2 * time.Minute
	DefaultFailThreshold     = 2
	DefaultLimitCooloff      = 5 * time.Minute
	DefaultAuthRetryMin      = 15 * time.Minute
	DefaultAuthRetryMax      = 6 * time.Hour
)

Defaults for Options.

View Source
const (
	ProblemConfig      = "config"      // missing or invalid settings
	ProblemDNS         = "dns"         // host name does not resolve
	ProblemUnreachable = "unreachable" // refused, no route, reset
	ProblemTimeout     = "timeout"     // no answer in time
	ProblemTLS         = "tls"         // handshake or certificate failure
	ProblemAuth        = "auth"        // credentials rejected
	ProblemUnavailable = "unavailable" // server refused service (e.g. too many connections)
	ProblemProtocol    = "protocol"    // not an NNTP server, or unexpected replies
)

Problem kinds reported by TestProvider.

View Source
const MaxShareWeight = 100

MaxShareWeight bounds a node's weight in Shares.

Variables

View Source
var (
	// ErrNoSuchArticle is a 430 (or 423) reply: this provider does not have
	// the article. The fetcher turns "every provider said 430" into
	// core.ErrArticleMissing.
	ErrNoSuchArticle = errors.New("nntp: no such article")
	// ErrAuthFailed means the provider rejected the credentials (481/482).
	ErrAuthFailed = errors.New("nntp: authentication failed")
	// ErrAuthRequired is a 480 reply to a command.
	ErrAuthRequired = errors.New("nntp: authentication required")
	// ErrServiceUnavailable is a 400/502 greeting or reply: the server is
	// refusing service, usually because of too many connections.
	ErrServiceUnavailable = errors.New("nntp: service unavailable")
	// ErrNoSuchGroup is a 411 reply to GROUP.
	ErrNoSuchGroup = errors.New("nntp: no such group")
	// ErrCorrupt means an article was received but did not decode as valid
	// yEnc (bad CRC, wrong size, missing headers).
	ErrCorrupt = core.ErrArticleCorrupt
	// ErrInvalidMessageID rejects message-ids that would break the protocol.
	ErrInvalidMessageID = errors.New("usenet: invalid message-id")
	// ErrNoProviders means no provider is configured and enabled.
	ErrNoProviders = fmt.Errorf("usenet: no usable providers: %w", core.ErrSourceUnavailable)
	// ErrProviderBackoff means a provider is temporarily skipped after
	// repeated connection failures.
	ErrProviderBackoff = fmt.Errorf("usenet: provider backing off after connection failures: %w", core.ErrSourceUnavailable)
	// ErrProviderDisabled means a provider is disabled after an
	// authentication failure, until a background login probe succeeds or
	// its settings change.
	ErrProviderDisabled = fmt.Errorf("usenet: provider disabled after authentication failure: %w", core.ErrSourceUnavailable)
	// ErrPoolClosed means the provider was removed or the fetcher closed.
	ErrPoolClosed = errors.New("usenet: connection pool closed")
	// ErrConnBroken means a connection was used after an I/O failure.
	ErrConnBroken = errors.New("nntp: connection broken")
)

Errors reported by the NNTP client and the fetcher. They are wrapped with context; test with errors.Is.

Functions

func ShareOf

func ShareOf(capacity int, self string, weights map[string]int) int

ShareOf is self's part of capacity under Shares; capacity itself when self is the only node or not a member.

func Shares

func Shares(capacity int, weights map[string]int) map[string]int

Shares divides capacity (an account-wide connection limit) among live cluster nodes in proportion to their weights (0 or less counts as 1): node i gets floor(capacity*w_i/Σw), raised to at least 1. When raising pushes the total over capacity, the largest shares give one back each (ties: the higher node ID first) until the total fits, so the cluster never opens more than capacity connections while capacity ≥ nodes.

When capacity is smaller than the number of nodes every node still gets one connection: a node without connections could not stream at all, and a provider that refuses the extra logins is handled by the pool's server-limit adaptation. The result is deterministic for a membership list, so every node computes the same division independently.

func TestProvider

func TestProvider(ctx context.Context, p config.UsenetProvider) error

TestProvider is the setup wizard's check with default options.

Types

type BodyReader

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

BodyReader streams a multi-line response, undoing dot-stuffing. It implements yenc.LineReader (zero-copy lines straight from the connection buffer) and io.Reader (lines with their CRLF terminators).

func (*BodyReader) Close

func (b *BodyReader) Close() error

Close drains the remainder of the body so the connection can be reused.

func (*BodyReader) Read

func (b *BodyReader) Read(p []byte) (int, error)

Read implements io.Reader over the unstuffed body.

func (*BodyReader) ReadLine

func (b *BodyReader) ReadLine() ([]byte, error)

ReadLine returns the next body line without its terminator. The slice is valid until the next call. It returns io.EOF after the terminating ".".

type Conn

type Conn struct {

	// PostingAllowed reports a 200 (vs 201) greeting.
	PostingAllowed bool
	// Greeting is the server's welcome text.
	Greeting string
	// contains filtered or unexported fields
}

Conn is one NNTP client connection. It is not safe for concurrent use; the pool hands each connection to one request at a time.

func Dial

func Dial(ctx context.Context, o DialOptions) (*Conn, error)

Dial connects, reads the greeting and authenticates if credentials are given. Errors wrap ErrAuthFailed, ErrServiceUnavailable, a *tls error or a network error so callers can tell bad credentials from an unreachable server.

func (*Conn) Body

func (c *Conn) Body(ctx context.Context, messageID string) (*BodyReader, error)

Body issues BODY and returns a reader over the dot-unstuffed body. The reader must be closed (which drains any unread remainder) before the connection is used again.

func (*Conn) BodyDecode

func (c *Conn) BodyDecode(ctx context.Context, messageID string, opt *yenc.DecodeOptions) (*yenc.Part, error)

BodyDecode fetches an article and yEnc-decodes it straight from the connection buffer. Decoding problems wrap ErrCorrupt; the connection stays usable after them because the body is drained to its terminator.

func (*Conn) Broken

func (c *Conn) Broken() bool

Broken reports whether the connection failed and must not be reused.

func (*Conn) Close

func (c *Conn) Close() error

Close closes the connection without QUIT.

func (*Conn) CurrentGroup

func (c *Conn) CurrentGroup() string

CurrentGroup is the last group selected with Group.

func (*Conn) Date

func (c *Conn) Date(ctx context.Context) (time.Time, error)

Date returns the server's clock (DATE, 111). It doubles as a cheap keepalive.

func (*Conn) Group

func (c *Conn) Group(ctx context.Context, name string) error

Group selects a newsgroup.

func (*Conn) Quit

func (c *Conn) Quit() error

Quit says goodbye and closes the connection.

func (*Conn) Stat

func (c *Conn) Stat(ctx context.Context, messageID string) (bool, error)

Stat reports whether the server has the article (223) or not (430).

func (*Conn) StatMany

func (c *Conn) StatMany(ctx context.Context, messageIDs []string) (map[string]bool, error)

StatMany pipelines STAT commands on this connection: all commands are flushed before any response is read. The connection is discarded by the pool if a pipeline is interrupted or its replies become desynchronised.

type DialOptions

type DialOptions struct {
	Host     string
	Port     int // 0 = 563 with TLS, 119 without
	TLS      bool
	Username string
	Password string
	// TLSConfig is the base TLS configuration (e.g. custom roots in tests).
	// ServerName defaults to Host.
	TLSConfig *tls.Config
	// Timeout bounds dial, TLS handshake, greeting and authentication.
	Timeout time.Duration
	// CommandTimeout bounds each command including reading its response.
	CommandTimeout time.Duration
	// IdleReadTimeout bounds a stalled read; progress renews it. Negative disables.
	IdleReadTimeout time.Duration
	// DialContext overrides the network dialer.
	DialContext func(ctx context.Context, network, addr string) (net.Conn, error)
	// BytesRead, when set, is incremented with every byte read off the wire.
	BytesRead *atomic.Int64
	// OnPhase receives durations for tcp, tls, greeting, and auth setup phases.
	OnPhase func(string, time.Duration)
	// OnStatus receives the raw server status line for trace diagnostics.
	OnStatus func(string)
	// OnCommand receives a protocol command before it is sent. AUTHINFO
	// credentials are redacted so trace logging cannot expose them.
	OnCommand func(string)
}

DialOptions describe how to reach and log in to one NNTP server.

type Fetcher

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

Fetcher implements core.ArticleFetcher over the owner's providers.

Ordering: non-backfill providers are tried in ascending Priority; providers sharing a Priority form a tier and share load (the least busy is tried first). Only when every non-backfill provider answered 430 are backfill (block) providers tried, in the same way. Transient errors are retried on fresh connections and then on the next provider.

func New

func New(providers []config.UsenetProvider, opts Options) *Fetcher

New builds a fetcher for providers. Connections are dialled lazily, except each primary provider's MinIdle warm floor, which the pool janitors establish (and keep) in the background.

func (*Fetcher) Body

func (f *Fetcher) Body(ctx context.Context, messageID string, groups []string, prio core.Priority) ([]byte, error)

Body returns the decoded payload of one article.

func (*Fetcher) BodyPart

func (f *Fetcher) BodyPart(ctx context.Context, messageID string, groups []string, prio core.Priority) ([]byte, core.ArticlePart, error)

BodyPart returns the decoded payload and its yEnc part header. It returns an error wrapping core.ErrArticleMissing only when every provider able to answer said 430. Providers locked out after an authentication failure cannot answer and are not counted; providers in connection backoff are.

func (*Fetcher) BodyPartBurst added in v0.2.8

func (f *Fetcher) BodyPartBurst(ctx context.Context, id string, groups []string, prio core.Priority, until time.Time) ([]byte, core.ArticlePart, error)

func (*Fetcher) CheckBody

func (f *Fetcher) CheckBody(ctx context.Context, messageID string, groups []string, prio core.Priority) error

CheckBody verifies actual BODY availability without downloading a full article. See CheckBodyPart.

func (*Fetcher) CheckBodyPart

func (f *Fetcher) CheckBodyPart(ctx context.Context, messageID string, groups []string, prio core.Priority, want core.ArticlePart) error

CheckBodyPart verifies that BODY really serves the expected yEnc part: providers often answer 223 to STAT while BODY returns a 451/430 DMCA takedown. It reads only the =ybegin/=ypart header lines (bounded, so a malicious unterminated line cannot consume memory), checks them against want where want is known (zero fields are unchecked), and then either drains a small remainder so the connection stays reusable or closes the connection so unread bytes cannot poison reuse. It returns an error wrapping core.ErrArticleMissing when every provider answered 430/451, and ErrCorrupt when a provider served something that is not the expected yEnc part.

func (*Fetcher) Close

func (f *Fetcher) Close() error

Close closes every pool. Further requests fail with ErrNoProviders.

func (*Fetcher) MaxConnections

func (f *Fetcher) MaxConnections() int

MaxConnections is the sum of all enabled primary providers' connection limits on this node: the configured counts, or this node's shares of them when SetConnectionShare divides them among cluster nodes.

func (*Fetcher) SetConnectionShare

func (f *Fetcher) SetConnectionShare(share func(configured int) int)

SetConnectionShare divides provider connection limits among cluster nodes: share receives a provider's configured connection count (the account-wide limit) and returns this node's part of it, clamped to 1..configured. Nil restores the configured counts (a standalone server). Pools resize in place and gracefully (see pool.setShare); providers added later by Update get their share as they are created. Call it again whenever membership changes.

func (*Fetcher) Stat

func (f *Fetcher) Stat(ctx context.Context, messageID string) (bool, error)

Stat reports whether any provider has the article (STAT, at live priority since health checks gate playback). It returns (false, nil) when every provider able to answer said 430, and an error when that could not be established.

func (*Fetcher) StatMany

func (f *Fetcher) StatMany(ctx context.Context, messageIDs []string) (map[string]bool, error)

StatMany checks a set of articles while pipelining commands over several connections to the first available provider. Missing articles are retried through the normal provider chain, preserving block-account backfill. Results are keyed by the message-ids exactly as given.

func (*Fetcher) StatStream

func (f *Fetcher) StatStream(ctx context.Context, messageIDs []string, fn func(id string, present bool) bool) error

StatStream checks a set of articles with pipelined STAT, reporting each article to fn as soon as its fate is decided: present=true the moment any provider answers 223, and present=false once every provider that could answer said 430 (primary tiers first, then backfill). Results flow per pipelined chunk as they arrive instead of waiting for the slowest batch, so a caller that only needs the first confirmed miss (a health check) can stop early: fn returning false cancels all outstanding work and StatStream returns nil.

An article is never reported missing on the strength of provider errors: if some provider failed to answer for it, it stays undecided and StatStream returns the first such error after reporting everything that was decided. fn is called from one goroutine at a time, with ids exactly as the caller passed them.

func (*Fetcher) Stats

func (f *Fetcher) Stats() []ProviderStats

Stats returns a snapshot per enabled provider, in configuration order.

func (*Fetcher) TestProvider

func (f *Fetcher) TestProvider(ctx context.Context, p config.UsenetProvider) error

TestProvider connects to p, logs in and runs DATE, using the fetcher's TLS and dialer options. It does not touch the fetcher's pools.

func (*Fetcher) Update

func (f *Fetcher) Update(providers []config.UsenetProvider)

Update swaps in a new provider list. Providers whose settings are unchanged keep their pool, connections, statistics and circuit-breaker state; changed providers get a fresh pool (which also clears an authentication lockout); removed or disabled ones are closed gracefully: queued requests move on to other providers and busy connections close when their current article finishes.

func (*Fetcher) UpdateConfig

func (f *Fetcher) UpdateConfig(u config.Usenet)

UpdateConfig applies an edited Usenet config: the provider list (see Update) and the MinIdle warm floor, live and without a restart. MinIdle 0 takes the default; negative disables the floor.

func (*Fetcher) Warm

func (f *Fetcher) Warm(ctx context.Context, perProvider int) error

Warm dials, negotiates TLS, and authenticates idle connections before a playback request needs them. perProvider is clamped to each provider pool.

type Options

type Options struct {
	// BodyPipelineDepth bounds queued BODY commands per socket (default 4,
	// maximum 16). One disables pipelining. Readahead normally uses at most two,
	// with a reserved live connection; explicit short bursts can borrow idle
	// capacity and use this depth. Prefetch, recovery probes and single-connection readahead
	// use one. Speculative batches stop growing when live work waits.
	BodyPipelineDepth int
	Logger            *slog.Logger
	// PrefetchFraction is the share of each provider's connections that
	// PriorityPrefetch requests may hold at once (at least one).
	PrefetchFraction float64
	// DialTimeout bounds connect + TLS + greeting + login.
	DialTimeout time.Duration
	// CommandTimeout bounds one command, including reading an article body.
	CommandTimeout time.Duration
	// IdleReadTimeout bounds stalled command/body reads; default 5 seconds.
	IdleReadTimeout time.Duration
	// MinIdle keeps this many primary (non-backfill) connections per
	// provider warm at all times: established in the background, held
	// past IdleTimeout and DATE-pinged so Play never pays a cold dial.
	// 0 = DefaultMinIdle; negative disables. A provider's own
	// config.UsenetProvider.MinIdle overrides it.
	MinIdle int
	// IdleTimeout closes excess connections unused for this long.
	IdleTimeout time.Duration
	// KeepAlive pings (DATE) connections idle for this long so servers do not
	// drop them; negative disables pings.
	KeepAlive time.Duration
	// MissTTL is how long a 430 from a provider is remembered per article;
	// negative disables the miss cache.
	MissTTL       time.Duration
	MissCacheSize int
	// Retries is how many extra attempts a provider gets, each on another
	// connection, after transient (network/protocol) errors.
	Retries int
	// Circuit breaker: after FailThreshold consecutive failed connects a
	// provider is skipped for BackoffMin, doubling up to BackoffMax.
	BackoffMin    time.Duration
	BackoffMax    time.Duration
	FailThreshold int
	// AuthRetryMin/Max: a provider that rejected the credentials (481/482)
	// is re-probed with one login after AuthRetryMin, doubling per failed
	// probe up to AuthRetryMax, so transient auth outages self-heal.
	AuthRetryMin time.Duration
	AuthRetryMax time.Duration
	// LimitCooloff: when a server refuses connections beyond some number
	// while others work, the pool shrinks to that number for this long.
	LimitCooloff time.Duration
	// TLSConfig is the base TLS client configuration (custom roots, e.g. in
	// tests). Nil uses the system roots.
	TLSConfig *tls.Config
	// DialContext overrides the network dialer.
	DialContext func(ctx context.Context, network, addr string) (net.Conn, error)
	// UseGroup issues GROUP (first of the segment's groups) before BODY. Most
	// providers serve BODY <message-id> without it.
	UseGroup bool
	// MaxArticleSize caps a decoded article; 0 = yenc.DefaultMaxSize.
	MaxArticleSize int64
	// TraceProtocol emits raw NNTP status lines at debug level.
	TraceProtocol bool
}

Options tune the fetcher. Zero values take the defaults above.

type ProtocolError

type ProtocolError struct {
	Cmd  string // command verb, e.g. "BODY"
	Code int
	Msg  string
}

ProtocolError is an unexpected NNTP status reply.

func (*ProtocolError) Error

func (e *ProtocolError) Error() string

func (*ProtocolError) Unwrap

func (e *ProtocolError) Unwrap() error

Unwrap maps well-known codes to sentinel errors.

type ProviderError

type ProviderError struct {
	Kind    string
	Message string
	Err     error // underlying error
}

ProviderError is TestProvider's failure: a stable Kind for the UI and a human-readable Message suitable for showing to the owner as-is.

func (*ProviderError) Error

func (e *ProviderError) Error() string

func (*ProviderError) Unwrap

func (e *ProviderError) Unwrap() error

type ProviderStats

type ProviderStats struct {
	ID       string `json:"id"`
	Name     string `json:"name"`
	Host     string `json:"host"`
	Priority int    `json:"priority"`
	Backfill bool   `json:"backfill"`

	// State is StateOK, StateBackoff or StateAuthFail.
	State        string    `json:"state"`
	BackoffUntil time.Time `json:"backoffUntil,omitempty"`
	LastError    string    `json:"lastError,omitempty"`
	LastErrorAt  time.Time `json:"lastErrorAt,omitempty"`

	// MaxConnections is the effective limit; it drops below
	// ConfiguredConnections for a while when the server refuses more.
	MaxConnections        int `json:"maxConnections"`
	ConfiguredConnections int `json:"configuredConnections"`
	LiveConnections       int `json:"liveConnections"`   // open sockets
	ActiveConnections     int `json:"activeConnections"` // slots in use
	IdleConnections       int `json:"idleConnections"`
	// MinIdle is the configured warm floor (0 = none for this pool);
	// WarmIdleConnections is how much of it is currently held idle.
	MinIdle             int   `json:"minIdle"`
	WarmIdleConnections int   `json:"warmIdleConnections"`
	BackgroundRedials   int64 `json:"backgroundRedials"` // floor janitor dials
	RedialFailures      int64 `json:"redialFailures"`
	PrefetchActive      int   `json:"prefetchActive"`
	WaitingLive         int   `json:"waitingLive"`
	WaitingReadahead    int   `json:"waitingReadahead"`
	WaitingPrefetch     int   `json:"waitingPrefetch"`
	// NodeShare is this node's part of ConfiguredConnections when the
	// account's connections are divided among cluster nodes (0 otherwise).
	NodeShare int `json:"nodeShare,omitempty"`

	Bytes         int64 `json:"bytes"` // received off the wire
	Articles      int64 `json:"articles"`
	Misses        int64 `json:"misses"`
	BodyMisses    int64 `json:"bodyMisses"`    // BODY responses unavailable on this provider
	BodyTakedowns int64 `json:"bodyTakedowns"` // subset of BodyMisses with status 451
	BackfillSaves int64 `json:"backfillSaves"` // body fetches saved after earlier provider misses
	Errors        int64 `json:"errors"`
	Dials         int64 `json:"dials"`

	AvgLatency       time.Duration `json:"avgLatency"`   // command round trip (EWMA)
	AvgFetchTime     time.Duration `json:"avgFetchTime"` // whole article (EWMA)
	TCPDialTime      time.Duration `json:"tcpDialTime"`
	TLSHandshakeTime time.Duration `json:"tlsHandshakeTime"`
	GreetingTime     time.Duration `json:"greetingTime"`
	AuthTime         time.Duration `json:"authTime"`
	BODYBatches      int64         `json:"bodyBatches"`
	BODYPipelined    int64         `json:"bodyPipelined"`
	STATBatches      int64         `json:"statBatches"`
	STATBatchTime    time.Duration `json:"statBatchTime"`
}

ProviderStats is a snapshot of one provider for the admin dashboard.

Directories

Path Synopsis
Package nntptest provides an in-memory NNTP server for tests across Ether: articles keyed by message-id (yEnc-encoded on the way in), optional AUTHINFO, TLS, missing articles, latency, a connection limit and fault injection, plus Post, which uploads files as multipart yEnc segments and returns a matching NZB document.
Package nntptest provides an in-memory NNTP server for tests across Ether: articles keyed by message-id (yEnc-encoded on the way in), optional AUTHINFO, TLS, missing articles, latency, a connection limit and fault injection, plus Post, which uploads files as multipart yEnc segments and returns a matching NZB document.

Jump to

Keyboard shortcuts

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