proxytron

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Oct 14, 2026 License: MIT Imports: 34 Imported by: 0

README

proxytron

CI Go Reference

Persistent, scored proxy pools for Go with adaptive rotation, HTTP(S)/SOCKS transport, transfer leases and real exit IP detection. Application policy stays separate from inventory, discovery and transport. Requires Go 1.26 or later. MIT licensed.

go get github.com/JohanLindvall/proxytron

See the runnable examples for persistent request rotation and exit-confirmed transfers with complete client and lease cleanup.

Package Purpose
proxytron Request rotation, discovery, HTTP(S)/SOCKS dialing and real exit IP detection
proxytron/lease Reserved routes for setup and long transfers, address limits and adaptive upgrades
proxytron/store Transactional Bolt persistence and an interface for existing databases

Persistent request rotation

cfg := proxytron.DefaultConfig()
cfg.Endpoints = []string{"http://proxy.example.test:8080"}
// Optional: cfg.FeedURL = "https://feeds.example.test/{country}.json"
// Optional: cfg.Scopes = []string{"se", "de"}

pool, err := proxytron.LoadPool(ctx, &cfg, nil, "proxies.db")
if err != nil {
    return err
}
defer pool.Close()

route := pool.Acquire("catalog", false, nil, nil)
if route == "" {
    // No eligible route. WaitFor reports the next cooldown expiry.
    return errors.New("no proxy available")
}
transport, err := proxytron.NewTransport(route, proxytron.TransportOptions{})
if err != nil {
    return err
}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 30 * time.Second}
// Make the request using client and classify its outcome in the application.
pool.ReportOK(ctx, "catalog", route, elapsed)

Reuse a transport/client per route in a running application. Configure its cookie jar and browser identity in the application. Report Demote for a destination refusal, DemoteTransport for a failed proxy connection, and Punish for a WAF challenge. A response status alone cannot reliably classify these outcomes. Exclude user cancellations from proxy penalties.

The default selector uses Thompson sampling to estimate successful responses per second, including failure cost and optional caller-provided pacing. Round-robin is also available. Scopes are arbitrary application keys: a refusal or challenge in one scope does not penalize another. Broken transport health is endpoint-wide. Never-successful feed entries receive longer cooldowns than configured routes. Thousands of untried routes share one exploration draw.

Live retains the pool across passes, reconciles configuration, supports temporary overrides and refreshes feeds between passes. Feed failures retain the last good inventory. Country templates, JSON records, plain lists, feed metadata, retirement and refresh timestamps survive restarts. Feed scores never override measured scores.

Request orchestration

Pool.Do owns route rotation, cooldown waits, pacing and outcome reporting for a caller-supplied attempt. The callback constructs and finishes the request, closes its body, and returns an AttemptResult; it can retain the response data in a closure. Reuse your own clients and sessions across callbacks.

pacer := proxytron.NewPacer(proxytron.PacerConfig{
    MinInterval: time.Second,
    SlowFactor: 2, MaxPenalty: 8, Decay: 0.9,
}, nil)

err := pool.Do(ctx, "catalog", proxytron.RequestOptions{
    Attempts: 3,
    RouteAttempts: 20,
    MaxRouteWait: time.Minute,
    Pacer: pacer,
    Backoff: func(n int) time.Duration { return time.Duration(n) * time.Second },
}, func(ctx context.Context, endpoint string) proxytron.AttemptResult {
    // Your adapter performs and classifies one complete attempt.
    return fetchAndClassify(ctx, endpoint)
})

Return Succeeded, Refused, TransportFailed, Challenged or Unreported to choose how the runner scores the route. Do not report the same outcome again inside the callback. Retry requests another attempt; RouteFailure spends the separate route budget and skips operation backoff. Classification and the safety of replaying an operation remain the caller's decisions. Cancellation is unscored. ErrAttemptsExhausted, ErrNoRoute and ErrRouteWait distinguish why retries stopped and preserve the last attempt error for errors.Is/errors.As.

Cooldown waits consume one cumulative MaxRouteWait budget, without spending attempts. The context bounds the entire operation. Direct traffic requires both AllowDirect: true and a configured direct pool member.

Share a pacer across workers to space starts on the same proxy host, regardless of port, protocol or credentials. Refusals widen that host's floor and successes ease it. Other hosts proceed independently; canceled waiters consume no slot. Pacer.ForRoute, Wait, Delay, Refused and OK also work with custom request loops. Pacers own no goroutines or network resources.

Real exit IP detection

detector := proxytron.Detector{
    URL: "https://ip-check.example.test/ip", // your chosen IP-check service
    Timeout: 10 * time.Second,
}
identity, err := pool.DetectEgress(ctx, detector, route)
// identity.IP is the externally observed IPv4 or IPv6 address, not the gateway's DNS/IP.
// The identity and observation time are saved with the route's scored state.

comparison, err := detector.Compare(ctx, route)
// comparison.Direct.IP and comparison.Proxy.IP measure both paths explicitly.
// comparison.SameIP() reports whether their observed exit addresses match.

No IP service is hard-coded or contacted automatically. Responses may be a plain address or JSON with ip or origin; ambiguous lists and oversized responses fail. Explicit proxies never fall back to direct egress. Supply Detector.Client to use an application's own transport and session settings. A different exit address does not prove anonymity; rotating gateways can change addresses between requests.

Transfer leases

pool, err := lease.Open("transfers.db", endpoints, feeds, nil, nil,
    lease.WithPolicy(lease.Policy{
        RecoveryScopes: []string{"challenge-recovery"},
        CappedScopes: []string{"rate-limited-service"},
    }))
if err != nil { return err }
defer pool.Close()
pool.Start(ctx)

route := pool.AcquireFor("downloads", preferredRouteID, 1, remainingBytes, false)
if route == nil { return errors.New("no transfer route available") }
defer route.Release()
requestContext := route.Context(ctx)
// Use lease.HTTPClient.DoOnce, or your Client adapter, with requestContext.

A lease spans authentication, CAPTCHA/ticket setup, redirects and the transfer. Preferred route IDs allow reuse of unexpired IP-bound state. Address limits and cooldowns are shared across proxy ports/protocols. Calling pool.DetectEgress also groups different gateways by their observed public exit IP, including active leases. Untested gateway ports can be tried independently; confirmed exit claims enforce the per-address cap. Detection is explicit because probing every public-feed entry is expensive and rotating sessions require application-specific timing.

Lease.ClaimExit atomically claims a freshly observed IP before the destination sees the request. A cached identity reserves capacity when a lease is acquired, but Claimed remains false until a fresh observation confirms it. ProbeContext counts transport failures without treating an IP service's refusal as a destination refusal. WithExitTTL controls cached-identity expiry. Applications can use ExitProber to try configured IP services in order through the lease's existing client and session, then claim the observed address:

prober := lease.ExitProber{
    URLs: []string{"https://ip-check.example.test/ip", "https://backup.example.test/ip"},
    Timeout: 10 * time.Second,
}
identity, err := prober.Claim(ctx, route)
if err != nil { return err } // the deferred route.Release still applies
// Start destination work using route.Context(ctx).

A freshly claimed lease needs no second probe; a cached identity still needs confirmation. ErrExitInUse signals a reserved or cooling exit without penalizing the route. Invalid replies and probe-service refusals try the next URL; transport failures stop immediately. The caller's cancellation is neutral, while a probe's own timeout counts as a transport failure.

If every reply is unusable, the default is ErrExitUnknown, with no failure penalty. An optional Reachable(ctx) callback can check probe availability: true confirms and scores a route failure (ErrExitProbeFailed); false explicitly permits proceeding without a known exit during an outage. The caller chooses and may cache that diagnostic check. No direct check is implicit. IsResponseError adapts clients that return HTTP refusals as errors. Probe bodies and URLs are omitted from ProbeResponseError diagnostics. ExitProber owns no clients and never releases the caller's lease.

Progress learns bandwidth and setup delay before completion, excluding constrained windows. Upgrade atomically reserves a proven faster route when sustained expected savings justify switching; the application cancels the old attempt and resumes on the replacement. Challenge scores remain separate from ordinary service/transport scores. Policy controls which scopes borrow measurements and which speeds reflect service-imposed caps. SetDemand drives early replenishment using scored capacity.

Implement lease.Client to retain custom TLS, cookies, challenge classification and request-context routing. WithFeedFilter limits discovery to protocols that adapter supports. The provided HTTP adapter supports standard net/http clients.

Persistence and compatibility

Both pools commit outcomes and cooldowns transactionally; lease progress samples are batched by the refresh loop and flushed on close. Inventory includes learned priors, discovery memberships and actual egress identities. Active leases are never restored. Bolt files are created with mode 0600, and lock acquisition is bounded. Close a pool before another process opens its file.

Request pools use store.Store and WithStoreOpener to support existing databases; the default is Bolt. Adapters can retain their existing tables and historical JSON field names. WithLegacyImport(true) explicitly enables one-time migration from an owned proxy-memory.json. Lease pools preserve their existing Bolt schema. The two pool formats have separate namespaces; do not open the same file twice. Unreadable state fails loading rather than silently resetting health. lease.WithHealthEpoch lets applications explicitly invalidate health judged under an older policy while retaining inventory; it is disabled by default.

The protocol layer preserves remote DNS, authentication, tolerant CONNECT replies, buffered tunnel bytes and SOCKS4a support. HTTPS proxy TLS is separate from destination TLS. Both verify certificates by default; opting into relaxed proxy TLS does not relax destination verification. Browser fingerprinting and content classification stay in the applications.

Outcome methods log persistence failures; request-pool Save returns a checkpoint error to callers that require explicit durability confirmation. A request pool's Close detaches its store; a lease pool's Close stops background work and closes its clients and database. Release transfer leases before closing their pool.

Development

go test -race ./...
go vet ./...
go test -cover ./...

Tests use loopback servers, fake clocks and synthetic endpoints. They cover historical inventory migration, concurrency, selection, TLS separation, exit IP grouping and corrupt-state handling without using live proxies. See CONTRIBUTING.md for releases and compatibility policy.

Documentation

Overview

Package proxytron provides scored proxy rotation, discovery, persistence, proxy dialing and real egress IP detection.

Request pools rank routes by expected successful responses per second and maintain independent service scopes, transport cooldowns and challenge cooldowns. Use DefaultConfig and LoadPool to restore a durable pool before discovery. FromURLs constructs an in-memory pool from already validated URLs. A direct route must be explicitly enabled by the application; an empty acquisition means no eligible route, never permission to bypass the pool. Pool.Do adds callback-driven retries with separate operation and route budgets. Pacer shares adaptive request spacing across routes on the same proxy host.

The lease subpackage provides reservations spanning setup and long transfers, per-address concurrency limits and adaptive throughput selection. Neither package knows about application domains, page parsers or browser fingerprints. Applications report their classified outcomes and can supply HTTP adapters. lease.ExitProber probes and claims exits through a reserved session, with caller-selected probe services and an explicit policy for probe outages.

Network activity is explicit: LoadPool can refresh configured feeds, Detector probes only its configured IP-check URL, and NewTransport performs no I/O. Tests use local servers and synthetic endpoints, without public proxy feeds.

Index

Examples

Constants

View Source
const (
	SelectionThroughput = "throughput"
	SelectionRoundRobin = "round_robin"
)

Selection policies for request pools.

View Source
const Direct = "direct"

Direct is a pool member meaning "send this one straight out, no proxy". Listing it alongside real endpoints is what makes alternating between a tunnel and the machine's own address a rotation rather than a switch, and it earns the same health handling as any endpoint: if the home address is the one being walled, it demotes and the tunnel carries the run, and vice versa.

Variables

View Source
var (
	// ErrNoRoute means the pool has no route the scope is allowed to use.
	ErrNoRoute = errors.New("proxytron: no eligible route")
	// ErrRouteWait means eligible routes are cooling beyond the remaining wait budget.
	ErrRouteWait = errors.New("proxytron: route wait budget exhausted")
	// ErrAttemptsExhausted means one of the independent retry budgets was spent.
	ErrAttemptsExhausted = errors.New("proxytron: attempt budget exhausted")
)

Errors returned by Pool.Do describe why another attempt could not be made. They may wrap the last attempt's error as well; use errors.Is to inspect both.

View Source
var ErrNoResponse = errors.New("connection closed before a response")

ErrNoResponse indicates that a proxy closed before sending a status line.

View Source
var ErrNoScopes = errors.New("a {country} proxy refresh URL requires marketplace codes")

ErrNoScopes is a `{country}` refresh_url with no scope codes to expand it with: no [proxies] scopes and none scanned.

View Source
var ErrTunnelRefused = errors.New("proxy refused the tunnel")

ErrTunnelRefused indicates that an HTTP proxy refused CONNECT.

Functions

func CanonicalEndpoint

func CanonicalEndpoint(raw, defaultScheme string) (string, error)

CanonicalEndpoint also lowercases host names and normalizes URL escaping. Applications migrating existing records should retain their original key convention; ParseEndpoint preserves authority spelling instead.

func Configured

func Configured(cfg *Config) []string

Configured is the endpoints cfg describes, redacted — what the pool *would* hold.

Lets a status endpoint answer before the first scan has built the pool, without installing anything as a side effect.

func ConnectTunnel

func ConnectTunnel(c net.Conn, host string, port int, auth string) (net.Conn, error)

connectTunnel asks an http(s) proxy for a tunnel: the CONNECT line, Proxy-Authorization when the proxy URL carries credentials, then Host. Only a 200 reply opens the tunnel; any other is ErrTunnelRefused.

func IsDirect

func IsDirect(s string) bool

IsDirect reports whether s names Direct as a config line or a request body may write it: in any case, surrounding space ignored.

func NewTransport

func NewTransport(endpoint string, opts TransportOptions) (*http.Transport, error)

NewTransport returns an isolated HTTP transport. Explicit routes ignore HTTP_PROXY, HTTPS_PROXY and NO_PROXY. HTTP(S) proxies forward HTTP requests and use the tolerant CONNECT implementation for HTTPS requests.

func ParseEndpoint

func ParseEndpoint(raw, defaultScheme string) (string, error)

ParseEndpoint validates a proxy URL or host:port. It requires an explicit port, supports HTTP(S), SOCKS4(a), SOCKS5(h), and recognizes Direct. SOCKS5 entries are normalized to SOCKS5h for remote destination DNS. Credentials and authority spelling are retained as stable inventory keys.

func ReadConnectReply

func ReadConnectReply(br *bufio.Reader) (code int, status string, err error)

readConnectReply reads a proxy's reply to CONNECT to the end of its head: the status line, whose code and status ("407 Proxy Authentication Required") it returns, then the header lines, skipped unread. It is lenient on purpose. Free proxies answer with header lines a strict reader such as http.ReadResponse refuses, and refusing them would bench routes that carry the tunnel fine; so only a status line that is not "HTTP/... <code> [reason]" fails, besides the limits (maxLine, maxHeaders). A connection that closes inside the header lines ends them.

func Redact

func Redact(url string) string

Redact never lets proxy credentials into a log line or a page, if the endpoint carries any.

Example
package main

import (
	"fmt"

	"github.com/JohanLindvall/proxytron"
)

func main() {
	fmt.Println(proxytron.Redact("http://user:secret@proxy.example.test:8080"))
}
Output:
http://***@proxy.example.test:8080

func ResolveLocal

func ResolveLocal(ctx context.Context, host, network string) (string, error)

resolveLocal resolves host on this machine, for a SOCKS proxy that is given an address (socks5, socks4): the first address, or the first IPv4 one for SOCKS4.

func SOCKS4Connect

func SOCKS4Connect(ctx context.Context, c net.Conn, scheme, user, host string, port int) error

socks4Connect asks a SOCKS4 proxy to connect: by IPv4 address, or for socks4a by name (address 0.0.0.1 and the host after the user id).

func SetHTTPProxy

func SetHTTPProxy(tr *http.Transport, endpoint string, proxyTLS *tls.Config) error

SetHTTPProxy configures an unused net/http transport in place, preserving its destination TLS verification, dialer, CONNECT hooks and other settings. HTTP(S) and SOCKS5(h) use net/http's native proxy support and remote DNS. Direct clears proxy selection. Explicit routes ignore environment bypasses.

proxyTLS applies only to an HTTPS proxy's outer connection, even for forwarded HTTP requests. Nil verifies that certificate normally. Clone a transport before configuring it; changing a transport already serving requests is unsafe. Use NewTransport or Dialer when SOCKS4 or tolerant CONNECT replies are needed.

Types

type AttemptResult added in v0.2.0

type AttemptResult struct {
	Outcome      Outcome
	Err          error
	Retry        bool
	RouteFailure bool
}

AttemptResult separates scoring, retry policy and the error returned to the caller. Retry requests another attempt. RouteFailure charges a retry to the route budget instead of the operation budget, without applying Backoff. This lets applications decide whether a refusal or challenge costs the operation. Err may be nil for a classified response; exhausted retries still return an error. The zero value ends the operation successfully without scoring it.

type Bench

type Bench string

Bench is why a route is out of selection on a site; "" when it is not.

const (
	// BenchDead is a feed entry that has never served a page and failed to
	// connect: out everywhere for a day.
	BenchDead Bench = "dead"
	// BenchBroken is any other route that failed to connect: out everywhere
	// for minutes, doubling.
	BenchBroken Bench = "broken"
	// BenchWAF is a route the site's WAF challenged.
	BenchWAF Bench = "waf"
	// BenchRefused is a route the site refused.
	BenchRefused Bench = "refused"
)

The benches, spelled as /api/proxies and deals.json carry them.

type Clock

type Clock interface {
	Now() time.Time
}

Clock is where a Pool reads the time: every cooldown, bench and stored timestamp follows it.

type Config

type Config struct {
	// Revision lets caller-specific configuration changes invalidate a live override.
	Revision        string
	Endpoints       []string
	FeedURL         string
	DefaultScheme   string
	Scopes          []string
	RefreshInterval time.Duration
	MinReady        int
	Settings
}

Config configures static routes and an optional discovery feed. Scope names are caller-defined. A {country} feed template expands scopes in uppercase. Start with DefaultConfig and change the fields your application needs. Static endpoints retain their spelling for compatibility with existing stored keys. Call ParseEndpoint at the application's configuration boundary to validate new routes. Discovery feeds and the provided transports always validate routes.

func DefaultConfig

func DefaultConfig() Config

DefaultConfig returns conservative discovery and health defaults.

func (Config) Enabled

func (c Config) Enabled() bool

Enabled reports whether static routes or discovery are configured.

func (Config) Validate

func (c Config) Validate() error

Validate checks scheduling, selection and cooldown settings.

type DeadlineConn

type DeadlineConn struct {
	net.Conn
	Timeout time.Duration
}

DeadlineConn gives each Read and each Write on Conn a deadline of its own, Timeout from the moment the call starts. It bounds every operation rather than the exchange, as a socket timeout does: a peer that keeps sending is never cut off, and one that falls silent for Timeout is. Wrapped around a *tls.Conn, a Read or Write is one TLS operation, so its deadline covers every socket read the record takes, and a peer dripping a record a byte at a time still times out. A zero or negative Timeout sets no deadline.

The proxy feed downloads use it, under net/http. This package's own connections change their timeout as a request moves from connecting to reading and probe idle connections, and keep a wrapper of their own for that.

func (DeadlineConn) Read

func (c DeadlineConn) Read(p []byte) (int, error)

Read reads from Conn under a read deadline Timeout from now.

func (DeadlineConn) Write

func (c DeadlineConn) Write(p []byte) (int, error)

Write writes to Conn under a write deadline Timeout from now.

type Detector

type Detector struct {
	URL     string
	Timeout time.Duration
	Client  func(endpoint string) (HTTPDoer, error)
}

Detector checks egress via a caller-selected URL that returns a plain IP or a JSON object with an "ip" or "origin" field. No external service is contacted until Detect is called. Client optionally adapts an application's own stack. It must honor endpoint (including Direct), and must not fall back to direct.

func (Detector) Compare

func (d Detector) Compare(ctx context.Context, endpoint string) (EgressComparison, error)

Compare explicitly probes both Direct and endpoint, failing if either fails.

func (Detector) Detect

func (d Detector) Detect(ctx context.Context, endpoint string) (Egress, error)

Detect measures the route's externally observed IPv4 or IPv6 address. Replies are bounded to 4 KiB; malformed, ambiguous and unspecified addresses fail.

type Dialer

type Dialer struct {
	ProxyURL       string
	Timeout        time.Duration
	ProxyTLSConfig *tls.Config
	Dial           func(context.Context, string, string) (net.Conn, error)
}

Dialer opens explicit proxy routes, without environment proxy settings or fallback to a direct connection. Zero Timeout uses 20 seconds. Destination TLS belongs to the caller; ProxyTLSConfig configures only the outer hop.

func (Dialer) DialContext

func (d Dialer) DialContext(ctx context.Context, network, address string) (conn net.Conn, err error)

DialContext opens a TCP stream through HTTP CONNECT or SOCKS. An empty ProxyURL or Direct selects a direct connection. SOCKS5 resolves locally; SOCKS5h and SOCKS4a resolve at the proxy. Context cancellation closes a connection still negotiating, and successful connections have no deadlines.

type Egress

type Egress struct {
	IP        netip.Addr `json:"ip"`
	CheckedAt time.Time  `json:"checked_at"`
}

Egress records the actual address observed by an IP-check service, which may differ from a proxy's listening address or DNS name. It is a point-in-time measurement: rotating gateways can change addresses between requests.

type EgressComparison

type EgressComparison struct{ Direct, Proxy Egress }

EgressComparison compares a proxy's observed address with the local route. A different IP alone does not prove anonymity or absence of identifying headers.

func (EgressComparison) SameIP

func (c EgressComparison) SameIP() bool

SameIP reports that the proxy and direct connection used the same exit IP.

type Endpoint

type Endpoint struct {
	URL      string
	Metadata json.RawMessage
}

Endpoint is a validated feed entry. Metadata is retained for diagnostics; scores supplied by a feed never influence the pool's learned scores.

func ParseFeed

func ParseFeed(body []byte, defaultScheme, country string) ([]Endpoint, error)

ParseFeed accepts text lines, JSON URL arrays and Proxifly-style records. country optionally filters record metadata. Malformed nonempty feeds fail; an explicit empty feed succeeds. A feed can never enable direct egress.

Example
package main

import (
	"fmt"

	"github.com/JohanLindvall/proxytron"
)

func main() {
	entries, err := proxytron.ParseFeed([]byte(`[
	  {"proxy":"socks5://proxy.example.test:1080","geolocation":{"country":"SE"}},
	  "direct"
	]`), "http", "se")
	if err != nil {
		panic(err)
	}
	fmt.Println(entries[0].URL)
}
Output:
socks5h://proxy.example.test:1080

type HTTPDoer

type HTTPDoer interface {
	Do(*http.Request) (*http.Response, error)
}

HTTPDoer is the part of http.Client needed by an egress detector.

type HealthRow

type HealthRow struct {
	URL              string   `json:"url"` // redacted
	Scopes           []string `json:"marketplaces"`
	Latency          *float64 `json:"latency"`
	Refusal          *float64 `json:"refusal"`
	TransportFails   int      `json:"transport_fails"`
	TransportFailure float64  `json:"transport_failure"`
	Served           int      `json:"served"`
	Cooling          float64  `json:"cooling"`
	// Keyed by scope code (never ""). encoding/json writes it in code
	// order; iterate it in that order with slices.Sorted(maps.Keys(row.Sites)).
	Sites map[string]SiteBrief `json:"sites"`
}

HealthRow is one row of Pool.Health: the endpoint-wide picture plus a per-site breakdown.

type Live

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

Live is the process's proxy pool, kept from one pass to the next: rebuilding it per pass would discard cooldowns, rotation positions and measured health. cli owns one, hands it to every pass and to the server, and closes it on the way out.

Pool brings it into line with the config at the start of every pass, and RefreshLoop between passes; a config change goes through a refresh, which keeps the history of the endpoints that survive it. Override replaces the list from the API until the config changes. Current reads it.

Pool, Override and Close take turns, and Pool keeps its turn across the feed downloads it makes, so a refresh in progress finishes before the next starts. Current never waits. The Live's lock is taken before the pool's, never after.

func NewLive

func NewLive(opts ...Option) *Live

NewLive returns a Live with no pool yet. opts are the options every pool it builds is given: WithClock, WithHTTPClient.

func (*Live) Close

func (l *Live) Close() error

Close closes the live pool's database, if one is attached, and drops the pool; a later Pool builds a new one. It waits for a refresh in progress. Closing a nil Live does nothing.

func (*Live) Current

func (l *Live) Current() *Pool

Current is the live pool as it stands, nil before the first Pool or Override (and for a nil Live). It never waits for a refresh in progress: it is what the status endpoints read.

func (*Live) Override

func (l *Live) Override(ctx context.Context, urls []string, cfg *Config, marketplaces []string) (*Pool, error)

Override replaces the live pool's endpoints with urls right now, from outside a pass, and returns the pool. This is what backs POST /api/proxies: the list changes mid-flight without a restart, and anything still on it keeps its health, because the pool is refreshed rather than rebuilt.

cfg is the config in force, so that the override sticks: Pool reconciles with the config at the start of every pass, and without knowing that cfg is what the override was made under, the very next pass would read the override as a config change and undo it. With it, the override holds until someone actually edits [proxies], and that edit wins. cfg may be nil. Explicit URL spelling is preserved, as in Config.Endpoints.

func (*Live) Pool

func (l *Live) Pool(ctx context.Context, cfg *Config, marketplaces []string, database string) (*Pool, error)

Pool is the live pool brought into line with cfg (nil when [proxies] is not enabled, which closes and drops any pool there was), with database ("" = none) attached.

Country URL templates use uppercase scope codes, and each source keeps its last good list if a later download fails. Health survives list updates. A different database path than the attached one reopens the pool from that database; feeds are downloaded only when due. Once ctx has ended nothing is built or refreshed, and ctx's error is returned.

func (*Live) RefreshLoop

func (l *Live) RefreshLoop(ctx context.Context, loadCfg func() (*Config, []string, error), database string, logs ...*slog.Logger)

RefreshLoop brings the pool into line once a minute until ctx ends, so feeds refresh even between scans: each tick re-reads the config with loadCfg, so an edit applies without a restart, and downloads only the feeds that are due. database "" is none. A tick that fails, or panics, is logged and the next one runs.

type Option

type Option func(*Pool)

Option configures a Pool built by New, FromURLs or LoadPool, or every pool a Live builds (NewLive).

func WithClock

func WithClock(c Clock) Option

WithClock makes the pool read the time from c instead of the system clock; nil keeps the system clock.

func WithHTTPClient

func WithHTTPClient(c *http.Client) Option

WithHTTPClient makes the pool download its feeds through c instead of a client of its own (newFeedClient); nil keeps that. Tests hand it a client that answers from memory.

func WithLegacyImport

func WithLegacyImport(enabled bool) Option

WithLegacyImport enables importing proxy-memory.json beside the store once. Enable only when migrating an application that owns that file.

func WithLogger

func WithLogger(log *slog.Logger) Option

WithLogger supplies the logger for outcome and persistence diagnostics.

func WithScopeContext

func WithScopeContext(f func(context.Context, string) context.Context) Option

WithScopeContext allows an application to retain its contextual log metadata. The function must not call back into the pool.

func WithSettings

func WithSettings(s Settings) Option

WithSettings replaces the pool's settings, which default to those of an empty [proxies] section. LoadPool's pool takes them from its config regardless.

func WithStoreOpener

func WithStoreOpener(open StoreOpener) Option

WithStoreOpener replaces the default Bolt store, for example with an adapter to an application's existing persistent database.

type Outcome added in v0.2.0

type Outcome uint8

Outcome is an application's classification of an attempt for route scoring. It is independent of whether the application wants to retry the operation.

const (
	// Unreported leaves route health unchanged, for example for a local error.
	Unreported Outcome = iota
	// Succeeded records a usable response.
	Succeeded
	// Refused records a refusal limited to the request's scope.
	Refused
	// TransportFailed records an endpoint-wide connection failure.
	TransportFailed
	// Challenged records a challenge limited to the request's scope.
	Challenged
)

type Pacer added in v0.2.0

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

Pacer spaces request starts and adapts to caller-reported refusals. Share a root Pacer across workers and use ForRoute for each attempt. It is safe for concurrent use, owns no goroutines or clients, and needs no cleanup.

Example
package main

import (
	"fmt"
	"time"

	"github.com/JohanLindvall/proxytron"
)

func main() {
	pacer := proxytron.NewPacer(proxytron.PacerConfig{MinInterval: time.Second}, nil)
	httpRoute := pacer.ForRoute("http://proxy.example.test:8080")
	socksRoute := pacer.ForRoute("socks5h://proxy.example.test:1080")
	fmt.Println(httpRoute == socksRoute)
	// Call httpRoute.Wait(ctx) before sending and Refused or OK afterward.
	

func NewPacer added in v0.2.0

func NewPacer(config PacerConfig, clock SleepClock) *Pacer

NewPacer constructs a direct-route pacer and its shared registry of proxy hosts. A nil clock uses real time. Settings are copied at construction.

func (*Pacer) Delay added in v0.2.0

func (p *Pacer) Delay() time.Duration

Delay estimates the wait for this host without taking a slot. It never waits for a sleeping request and can be used in Pool.Acquire's wait callback.

func (*Pacer) ForRoute added in v0.2.0

func (p *Pacer) ForRoute(endpoint string) *Pacer

ForRoute returns the pacer shared by endpoint's host, ignoring credentials, protocol and port. Empty and Direct select the root pacer; this is only a pacing key and never authorizes direct routing. Invalid URLs use their full string as a private key and remain subject to transport validation.

func (*Pacer) OK added in v0.2.0

func (p *Pacer) OK()

OK eases this host's request floor toward its configured minimum.

func (*Pacer) Penalty added in v0.2.0

func (p *Pacer) Penalty() float64

Penalty returns this host's current multiplier on MinInterval. It starts at one and remains observable when pacing is disabled, for health diagnostics.

Example
package main

import (
	"fmt"

	"github.com/JohanLindvall/proxytron"
)

func main() {
	pacer := proxytron.NewPacer(proxytron.PacerConfig{SlowFactor: 2, Decay: 0.5}, nil)
	route := pacer.ForRoute("http://proxy.example.test:8080")
	route.Refused()
	fmt.Println(route.Penalty())
	route.OK()
	fmt.Println(route.Penalty())
}
Output:
2
1

func (*Pacer) Refused added in v0.2.0

func (p *Pacer) Refused()

Refused widens this host's request floor, without affecting other hosts.

func (*Pacer) Wait added in v0.2.0

func (p *Pacer) Wait(ctx context.Context) error

Wait takes the next slot for this host. Cancellation while sleeping or queued returns promptly without consuming a slot. Different hosts wait independently.

type PacerConfig added in v0.2.0

type PacerConfig struct {
	MinInterval time.Duration
	SlowFactor  float64
	MaxPenalty  float64
	Decay       float64
}

PacerConfig controls the request floor for each proxy host. MinInterval <= 0 disables pacing. Refusals multiply the floor by SlowFactor, up to MaxPenalty; successes multiply the penalty by Decay, never below one. Invalid or zero factors default to 2, 8 and 0.9 respectively. A factor of one disables that adjustment. Pacing is shared across scopes using the same host.

type Pool

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

Pool is a pool of proxy URLs, shared across scope goroutines.

Acquire picks the available endpoint expected to deliver valid responses fastest (or rotates in list order with selection = "round_robin"); Demote, DemoteTransport and Punish push a failing one into a growing cooldown; Refresh swaps in a new list without losing the health state of endpoints that carry over. Country feeds are restricted to their scope. "" from Acquire means no eligible route is available. Direct egress requires an explicitly configured Direct member and the caller's permission.

Build one with New, FromURLs or LoadPool. A nil *Pool is an empty one to Len.

Example
package main

import (
	"context"
	"fmt"
	"time"

	"github.com/JohanLindvall/proxytron"
)

func main() {
	pool := proxytron.FromURLs([]string{"http://proxy.example.test:8080"})
	defer pool.Close()
	route := pool.Acquire("catalog", false, nil, nil)
	// Send the request using route, then classify the response in the application.
	pool.ReportOK(context.Background(), "catalog", route, 200*time.Millisecond)
	fmt.Println(pool.SiteHealth("catalog")[0].Served)
}
Output:
1

func FromURLs

func FromURLs(urls []string, opts ...Option) *Pool

FromURLs is a pool of the static endpoints urls, blanks and repeats dropped.

func LoadPool

func LoadPool(ctx context.Context, cfg *Config, marketplaces []string, database string,
	opts ...Option) (*Pool, error)

LoadPool builds a pool from the database first (database "" = none), replenishing or refreshing feeds if due. Its settings are cfg's (SettingsFrom); opts may give it a Clock and the HTTP client its feeds download through.

Returns nil when nothing is configured. An enabled but empty pool is kept so a failed source can be retried on schedule. Static endpoints, if any, remain usable when a source fails.

func New

func New(opts ...Option) *Pool

New is an empty pool: the default settings, then the options.

func (*Pool) Acquire

func (p *Pool) Acquire(site string, allowDirect bool, exclude map[string]bool,
	wait func(url string) time.Duration) string

Acquire is the route for this scope's next attempt, or "" when every route it may use is benched.

Cooling routes and forbidden direct/foreign routes are never handed out, and a retry prefers a route it has not tried for this page (exclude). Among the rest, the default selection = "throughput" maximises valid responses per second. A scope's fetcher sends one request at a time, so its throughput is what an attempt yields over what it costs, and the best route is the one with the highest P(valid) / E[seconds] (a renewal-reward argument: the long-run rate of a sequence of attempts is E[reward] / E[time] per attempt).

P(valid) is a Beta posterior over this route's recent attempts on this site, and seconds is its mean attempt time whatever the outcome, plus what wait(url) says its host's pacing floor would add right now. wait may be nil; it runs under the pool's lock, so it must not call back into the pool. Each candidate's P(valid) is drawn from its posterior rather than taken at the mean (Thompson sampling), so a route that looks slightly worse still gets the occasional turn its uncertainty earns, and a route that starts failing loses its share within a few attempts. Untried routes are interchangeable until tried: one draw from the site's prior stands for all of them, and the next one in list order is the one tried. Drawing once per untried route instead would let four hundred of them, three in four dead, outbid a route known to work by sheer number of lottery tickets.

selection = "round_robin" restores the rotation this replaced: each scope keeps its own position in the list, the last URL rather than an index, so a feed refresh that reorders entries does not reset it.

func (*Pool) Close

func (p *Pool) Close() error

Close detaches (and closes) the database, if one is attached.

func (*Pool) Demote

func (p *Pool) Demote(ctx context.Context, site, url, reason string, took time.Duration)

Demote sends a route that site refused to cooldown on that site alone, after an attempt that took took. A url not in the pool is ignored.

Refusal is a per-domain verdict: it cools the route for that site and leaves the other four using it, since letting `se` evict an endpoint everywhere would throw away a path that four other sites are being served by perfectly well. The cooldown grows geometrically from Settings.BaseCooldown (a minute) per refusal in a row, up to half an hour. A failed connection is DemoteTransport's and an WAF challenge Punish's.

func (*Pool) DemoteTransport

func (p *Pool) DemoteTransport(ctx context.Context, site, url, reason string, took time.Duration)

DemoteTransport benches a route that did not carry the request at all — a dead tunnel, a refused connection — after an attempt for site that took took. That is endpoint-wide, so it cools the route for every site, but the record is kept until age-based cleanup. A url not in the pool is ignored.

A failed connection is punished far harder than a refusal, because a rotation gives every endpoint off its bench a full turn: on c016fcf's first pass one destination spent 429 of its 500 requests and another 396 on proxies that never connected, because each came back after a minute. A feed entry that has never served a page is a dead entry and sits out Settings.DeadCooldown (a day) at once. One that has served, or a static endpoint or Direct (the operator's own routes, not a list's guesses), sits out Settings.BrokenCooldown (ten minutes), doubling per failure in a row up to the same day.

func (*Pool) DetectEgress

func (p *Pool) DetectEgress(ctx context.Context, d Detector, endpoint string) (Egress, error)

DetectEgress probes a configured endpoint without holding the pool lock and persists the resulting identity with its scores. Failures leave prior data intact.

func (*Pool) Do added in v0.2.0

func (p *Pool) Do(ctx context.Context, scope string, options RequestOptions, attempt func(context.Context, string) AttemptResult) error

Do rotates routes, waits for cooldowns, paces request starts and scores the outcomes returned by attempt. The callback owns request construction, session state, response classification and body cleanup. Close or consume bodies before returning; a result can be retained in the callback's closure. Only retry operations the application can safely replay.

Do invokes callbacks synchronously, outside pool locks, and owns no clients or goroutines. Cancellation of ctx or a context.Canceled attempt is never scored. An empty pool never permits direct egress. Failed routes are avoided while alternatives are ready, then may be reused after their cooldowns, like Acquire.

Example
package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/JohanLindvall/proxytron"
)

func main() {
	pool := proxytron.FromURLs([]string{"http://first.example.test:8080", "http://second.example.test:8080"})
	defer pool.Close()
	attempts := 0
	var body string
	err := pool.Do(context.Background(), "catalog", proxytron.RequestOptions{Attempts: 1},
		func(ctx context.Context, endpoint string) proxytron.AttemptResult {
			attempts++
			// An application sends and classifies the request here. These
			// synthetic results demonstrate the two independent retry budgets.
			if endpoint == "http://first.example.test:8080" {
				return proxytron.AttemptResult{Outcome: proxytron.TransportFailed,
					Err: errors.New("connection failed"), Retry: true, RouteFailure: true}
			}
			body = "response data"
			return proxytron.AttemptResult{Outcome: proxytron.Succeeded}
		})
	fmt.Println(body, attempts, err)
}
Output:
response data 2 <nil>

func (*Pool) Egress

func (p *Pool) Egress(endpoint string) (Egress, bool)

Egress returns the latest stored exit address, false if none was measured.

func (*Pool) Health

func (p *Pool) Health() []HealthRow

Health is one row per endpoint: the endpoint-wide picture plus a per-site breakdown.

The breakdown is the point: a single blended refusal rate reads as "this endpoint is half-refused" when the truth is "one scope refuses everything and the other four are clean".

func (*Pool) Len

func (p *Pool) Len() int

Len is the number of routes the pool hands out; 0 for a nil pool, so `pool.Len() > 0` is the test for a pool worth asking.

func (*Pool) NewPass

func (p *Pool) NewPass()

NewPass starts every site's count of refusals in a row afresh.

That count doubles a route's cooldown per refusal and resets only on a served page, and the pool outlives the pass, so a pass whose warm-up was refused twice handed the next one, two hours later, a count of two. Its first 503 then benched the tunnel for 240 s instead of 60, the warm-up's second chance one minute later found no route, and the pass was lost as "refused" without a second request: one destination on 2026-10-03, 10-04 and 10-05 alike. Two hours is four times the longest cooldown, so the count carries nothing a new pass should inherit. What does carry over is kept: a cooldown still running is still honoured, and the refusal rate that describes the endpoints is evidence, not a streak.

func (*Pool) PathCount

func (p *Pool) PathCount(site string, allowDirect bool) int

PathCount counts this scope's routes, including ones cooling down.

func (*Pool) Punish

func (p *Pool) Punish(ctx context.Context, site, url, reason string, took time.Duration) time.Duration

Punish benches an endpoint on one scope after that site's WAF challenged it, in an attempt that took took. It returns the bench, 0 if url is not in the pool.

A challenge is the one verdict that is about the route rather than the request or the site: the WAF looked at the address and asked it to prove it is a browser, which it cannot. On 2026-10-09 seven routes in one destination's feed drew it on most requests, pass after pass, while the route carrying all 151 of a working destination's pages drew none — and because a challenge page is small, it came back fast and had been *raising* those routes' share. So the bench is hours rather than the minute a refusal earns (Settings.ChallengeCooldown, doubling per challenge in a row up to a day), the challenge counts in the site's refusal rate so its history remains visible once it is back, and only a page it actually serves clears the doubling. Per site: another scope's WAF has not judged it. Nothing is dropped — it can still prove it came back.

func (*Pool) Refresh

func (p *Pool) Refresh(ctx context.Context, urls []string, marketplaces map[string][]string, feed []string) (added, removed int)

Refresh replaces the endpoint list, preserving health state for carry-overs. scopes restricts endpoints (url -> codes; nil = every endpoint shared) and feed names the ones that came from a refresh feed.

Returns (added, removed). An endpoint present before and after keeps its fail count and cooldown, so a periodic refresh from the same source does not reset the cooldown on something that has been failing all along.

func (*Pool) RefusedCooling

func (p *Pool) RefusedCooling(site string, allowDirect bool) bool

RefusedCooling reports whether a route site may use is cooling right now because site refused it (Demote), rather than benched for a failed connection (DemoteTransport) or an WAF challenge (Punish): the difference the proxy list draws between "kyls ned" and "spärrad … (WAF)" (benchOf, whose endpoint-wide benches do not hide a refusal here). A site that runs out of routes while one of them is cooling for its refusal has been refused into stopping, whatever benched the rest; see fetch's HaltRefused. A route on a single-route site that WAF challenged was demoted, not punished, and counts.

func (*Pool) ReportOK

func (p *Pool) ReportOK(ctx context.Context, site, url string, took time.Duration)

ReportOK records that a real page came back through url for site, in an attempt that took took (the whole request, body read included). It clears the failure state for this site and updates the latency measurement. Transport health is cleared too: the endpoint demonstrably carries traffic. A url not in the pool is ignored.

The outcome methods log, and persist, under ctx tagged with site.

func (*Pool) Save

func (p *Pool) Save(ctx context.Context) error

Save checkpoints all state and reports errors synchronously. Outcome methods already persist individual records; Save also retries any previously failed save.

func (*Pool) SiteHealth

func (p *Pool) SiteHealth(site string) []SiteHealth

SiteHealth is one row per endpoint site may use, with that scope's own numbers.

func (*Pool) WaitFor

func (p *Pool) WaitFor(site string, allowDirect bool, exclude map[string]bool) (time.Duration, bool)

WaitFor is how long until an endpoint site may use comes off cooldown: 0 when one is usable now; ok is false when the pool holds none it may use at all.

This lets callers wait for a route instead of spending attempts that send nothing; Pool.Do uses it for cooldown waits. Both cooldowns count, the endpoint-wide one (transport) and the site's own (refusal), since either keeps Acquire from handing the endpoint out.

type ProbeResponseError added in v0.2.0

type ProbeResponseError struct {
	Status int
	Reason string
}

ProbeResponseError means an IP-check service answered but did not supply a usable address. It can be retried against another caller-configured service; by itself it is not evidence of a broken route. Diagnostics omit reply bodies and URLs, which may contain credentials. Status is zero for an invalid body.

func (*ProbeResponseError) Error added in v0.2.0

func (e *ProbeResponseError) Error() string

Error describes the rejected response without including its contents.

type RequestOptions added in v0.2.0

type RequestOptions struct {
	AllowDirect   bool // Direct must also be a configured pool member.
	Attempts      int
	RouteAttempts int
	// MaxRouteWait bounds the total time spent waiting for route cooldowns.
	// Zero disables those waits. Pacing, Backoff and I/O are bounded by ctx.
	MaxRouteWait time.Duration
	Pacer        *Pacer
	// Backoff receives the number of ordinary failed attempts, starting at one.
	// Nil retries immediately. It is never called for a route failure.
	Backoff func(failures int) time.Duration
	// Sleep defaults to a context-aware timer. Tests using WithClock should
	// supply a sleep function that advances the same clock.
	Sleep func(context.Context, time.Duration) error
}

RequestOptions controls one Pool.Do call. Attempts limits ordinary failed attempts and defaults to one. RouteAttempts limits failures charged to routes and defaults to max(Attempts, the scope's configured route count).

type Settings

type Settings struct {
	// BaseCooldown benches a route a site refused, on that site, doubling
	// per refusal in a row up to half an hour. See Pool.Demote.
	BaseCooldown time.Duration
	// ChallengeCooldown benches a route a site's WAF challenged, on that
	// site, doubling per challenge in a row up to a day. See Pool.Punish.
	ChallengeCooldown time.Duration
	// A failed connection benches the route everywhere. A feed entry that
	// has never served a page sits out DeadCooldown at once; anything else
	// sits out BrokenCooldown, doubling per failure in a row up to
	// DeadCooldown. See Pool.DemoteTransport.
	BrokenCooldown time.Duration
	DeadCooldown   time.Duration
	// Selection is SelectionThroughput (see Pool.Acquire) or
	// SelectionRoundRobin, the rotation it replaced: kept so the two
	// can be compared from the watchlist.
	Selection string
}

Settings are a Pool's tunables, as a [proxies] section sets them (SettingsFrom).

func SettingsFrom

func SettingsFrom(cfg *Config) Settings

SettingsFrom is the settings cfg configures. A pool reconciled with a config (LoadPool, and every refresh after) takes its settings from there, so an edit of the watchlist applies without a restart.

type SiteBrief

type SiteBrief struct {
	Latency    *float64 `json:"latency"`
	Refusal    *float64 `json:"refusal"`
	Fails      int      `json:"fails"`
	Challenges int      `json:"challenges"`
	Cooling    float64  `json:"cooling"`
}

SiteBrief is one site's numbers inside a HealthRow.

type SiteHealth

type SiteHealth struct {
	URL              string   `json:"url"` // redacted
	Site             string   `json:"site"`
	Latency          *float64 `json:"latency"` // nil until measured
	Refusal          *float64 `json:"refusal"` // nil until measured
	Fails            int      `json:"fails"`
	Challenges       int      `json:"challenges"`
	TransportFailure float64  `json:"transport_failure"`
	Served           int      `json:"served"`
	// Why it is out, if it is: BenchDead or BenchBroken (a failed
	// connection, endpoint-wide), BenchWAF (an WAF challenge) or
	// BenchRefused (this site); "" when it is not benched.
	Bench   Bench   `json:"bench"`
	Cooling float64 `json:"cooling"`
	// What throughput selection believes: the share of attempts that come
	// back valid, the seconds one takes, and so valid responses per minute.
	Tries          int      `json:"tries"`
	Valid          *float64 `json:"valid"`            // nil when never tried
	AttemptSeconds *float64 `json:"attempt_seconds"`  // nil until an attempt is timed
	ValidPerMinute *float64 `json:"valid_per_minute"` // nil when never tried
}

SiteHealth is one row of Pool.SiteHealth: that scope's own numbers for one endpoint — what the report reads as a site's proxy health.

type SleepClock added in v0.2.0

type SleepClock interface {
	Clock
	Sleep(context.Context, time.Duration) error
}

SleepClock supplies time and cancelable waits. A test clock should advance Now when Sleep succeeds. Implementations must be safe for concurrent use.

type StoreOpener

type StoreOpener func(context.Context, string) (store.Store, error)

StoreOpener opens an owned persistence store. The pool closes it on Close.

type TransportOptions

type TransportOptions struct {
	Timeout         time.Duration
	ProxyTLSConfig  *tls.Config
	TLSClientConfig *tls.Config
}

TransportOptions keeps proxy and destination TLS policy separate. Certificate verification is enabled by default on both hops. Insecure proxy TLS, when explicitly configured, never disables destination verification.

Directories

Path Synopsis
examples
rotate command
Command rotate fetches a URL using a persistent request pool and shared pacing.
Command rotate fetches a URL using a persistent request pool and shared pacing.
transfer command
Command transfer confirms a reserved route's exit before streaming a response.
Command transfer confirms a reserved route's exit before streaming a response.
internal
Package lease provides persistent, address-limited routes for long transfers.
Package lease provides persistent, address-limited routes for long transfers.
Package store defines the request pool's persistence boundary and provides a transactional Bolt implementation.
Package store defines the request pool's persistence boundary and provides a transactional Bolt implementation.

Jump to

Keyboard shortcuts

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