Help us shape the future of Go! Take the 2026 Go Developer Survey

onionbravesearch

package module
v0.0.0-...-ceb2cfb Latest Latest
Warning

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

Go to latest
Published: Oct 16, 2026 License: MIT-0 Imports: 25 Imported by: 0

README

onionbravesearch

Unofficial Go clients for Brave Search and DuckDuckGo over their onion services, via a local Tor SOCKS5 proxy, plus a unified client that tries DuckDuckGo first and falls back to Brave.

The point is non-traceable, free search for agents and private work: no search API keys, no clearnet search hop, no account. Traffic stays inside Tor and terminates at the search engine’s hidden service (no exit node), and every request carries a coherent, randomized browser fingerprint instead of Go’s very recognizable crypto/tls one.

This is not Brave’s official API. It scrapes the public onion frontend.

Privacy — Brave Search (notice re-read 2026-10-16)

Quoted from the Brave Search privacy notice. Brave rewrote this notice after the 2026-09-01 draft of this README, so the old headline quote — “we don’t collect personal information about you, your device or your searches” — is gone. What it says now:

To ensure that Brave Search is relevant, useful, and secure, we collect and store de-identified search query data which includes the plain text of the search query itself, the device platform, operating system, country, language and the date. We do not store any additional data associated with the search query that could link it with an individual user, their device, or IP address. Search queries are not grouped based on the user, device or IP address the search query originated from.

So: query text and device platform/OS are retained, just de-identified and not grouped by user or IP.

The only IP handling they document for the search service itself:

We temporarily process IP addresses to detect and prevent bots in order to ensure the integrity and availability of the service for all users. IP addresses are not retained but are deleted within seconds.

On ads (this client does not click ads):

We also infer the country location associated with clicks and views of Ads on Brave Search. This is based on a device IP address. We do not retain IP addresses.

On “near me” results (this client sends useLocation=0):

With anonymous local results, when you search for “cafes near me” or “food near me” Brave Search will use the IP address broadcast by your device but without sharing that IP address and without storing it.

Ask Brave is not used here. For completeness they state conversation state can live up to 24h on AWS (now via Nebius Token Factory, with zero data retention), and: “Brave does not retain your IP address.”

One more clause was added in the rewrite, and it is the one that matters for bot defence:

Brave Search uses local state in your browser for service integrity purposes. This state does not reveal specific user behavior and cannot be used to profile or track you. You can delete this data through your browser settings, though doing so may degrade the security or usability of Brave Search.

What that means on this path

Queries are de-identified but retained, not zero-data-retention. The plain-text query, device platform/OS, country, language and date are stored; they cannot be tied back to you, your device or an IP. An earlier version of this README called that “effectively zero-data-retention” — that conclusion no longer holds.

This client only talks to:

https://search.brave4u7jddbv7cyviptqjc7jusxh72uik7zt6adtckl5f4nwy2v72qd.onion/

over Tor. There is no clearnet exit. Whatever the integrity path processes is a Tor relay / rendezvous point, not your IP.

That is an interpretation of the public notice as of 2026-10-16, plus how onion services work. It is not a guarantee from Brave, and it is not legal advice.

DuckDuckGo

Not covered by the notice above. DuckDuckGo’s own privacy policy is the source of truth for it; the same onion-service reasoning applies (Brave’s or DuckDuckGo’s integrity checks see a Tor rendezvous point, not you). This repo makes no claim about it beyond “the traffic path is onion-only”.

Requirements

A running Tor SOCKS5 proxy, default 127.0.0.1:9050.

Docker

From this repo:

docker compose up -d --wait

That builds a local Tor image, binds SOCKS5 to 127.0.0.1:9050 only, and waits until the daemon is listening. First bootstrap is usually 10–30s. Stop with docker compose down.

If something else already owns 9050, change the host side in compose.yaml and pass the same address to WithProxy.

systemd

On Arch / CachyOS the distro tor package already ships a unit that listens on 127.0.0.1:9050:

sudo pacman -S tor
sudo systemctl enable --now tor
go get github.com/torisetxd/onionbravesearch

Usage

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/torisetxd/onionbravesearch"
)

func main() {
	c, err := onionbravesearch.New(
		onionbravesearch.WithProxy("127.0.0.1:9050"),
		onionbravesearch.WithTimeout(90*time.Second),
		onionbravesearch.WithProfile(onionbravesearch.ProfileRandom),
		onionbravesearch.WithRetries(3),
	)
	if err != nil {
		panic(err)
	}
	defer c.Close()

	res, err := c.Search(context.Background(), "golang socks5", onionbravesearch.SearchOptions{
		Category: onionbravesearch.CategoryWeb,
		Page:     1,
	})
	if err != nil {
		panic(err)
	}

	for _, r := range res.Results {
		fmt.Println(r.Title, r.URL)
	}
}

SearchOptions also covers news (CategoryNews), safesearch, country, UI language, time range, and spellcheck.

Typical latency on a warm circuit is about 1–2 s per query (measured: DuckDuckGo ~0.9 s, Brave ~1.5 s). The first request pays for circuit construction, which has been observed anywhere from 5 s to 50 s.

DuckDuckGo
c, err := onionbravesearch.NewDuckDuckGo(
	onionbravesearch.WithProxy("127.0.0.1:9050"),
	onionbravesearch.WithTimeout(90*time.Second),
)
if err != nil {
	panic(err)
}
defer c.Close()

res, err := c.Search(context.Background(), "golang socks5", onionbravesearch.SearchOptions{})

That talks to https://duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion/html (the no-JS interface), with kl / kp / df / s / dc mapped from the same SearchOptions. DuckDuckGo has no news vertical here, so CategoryNews returns ErrBadCategory.

Unified: DuckDuckGo first, Brave second
u, err := onionbravesearch.NewUnified(
	onionbravesearch.WithProxy("127.0.0.1:9050"),
	onionbravesearch.WithRetries(2),
)
if err != nil {
	panic(err)
}
defer u.Close()

res, err := u.Search(context.Background(), "golang socks5", onionbravesearch.SearchOptions{})
fmt.Println(res.Engine, len(res.Results)) // "duckduckgo" or "brave"

Unified.Search walks the engines in order and returns the first usable answer. An engine is skipped when it cannot serve the requested category (so CategoryNews goes straight to Brave) and — unless WithFallbackOnEmpty(false) — when it answers with zero results. Response.Engine says which backend answered. The engines run on separate clients, so they get separate circuits and separate fingerprints.

Engines at a glance
Engine Constructor Onion host Endpoint
Brave Search New / NewBrave search.brave4u7jddbv7cyviptqjc7jusxh72uik7zt6adtckl5f4nwy2v72qd.onion /search, /news
DuckDuckGo NewDuckDuckGo duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion /html

Point them somewhere else with WithBraveBaseURL / WithDuckDuckGoBaseURL (or plain WithBaseURL for a single-engine client) — handy for the clearnet hosts while debugging without Tor.

Fingerprints

Two layers get randomized:

  • TLS — the ClientHello is a real browser parroted through uTLS (Chrome 131/133, Firefox 120, Safari 16, iOS Safari 14), not Go’s crypto/tls fingerprint. ALPN is rewritten to http/1.1 so the handshake stays honest with the HTTP/1.1 the transport actually speaks.
  • HTTP — User-Agent, sec-ch-ua*, Sec-Fetch-*, Accept, Accept-Encoding and the header order all come from the same profile, so the metadata is coherent instead of a mismatched pile of defaults.

A profile is drawn at random per client, and re-rolled on every circuit rotation. Within one circuit it stays fixed — a real browser does not change its UA between two searches, so a per-request mix would be the giveaway rather than the fix.

The transport writes HTTP/1.1 by hand over the tunneled TLS connection (transport.go), which is what makes header order and coherence controllable at all; net/http always sorts headers alphabetically.

Accept-Encoding advertises gzip, deflate, br, zstd and responses are decoded transparently.

Deliberately not randomized: the functional cookies (safesearch, useLocation, summarizer, country, ui_lang) because they drive the results. Their order is shuffled, and any Set-Cookie the edge returns (integrity/challenge state) is kept in memory and echoed back for the life of the client.

Pin a family with WithProfile(onionbravesearch.ProfileChrome) (ProfileFirefox, ProfileSafari, ProfileIOSSafari); ProfileRandom is the default.

Rate limits (429)

429 and 503 surface as a *RateLimitError (it unwraps to ErrRateLimited) and are retried automatically. Between attempts the client:

  1. switches to a new Tor circuit — each client dials SOCKS5 with a unique username, and Tor’s default IsolateSOCKSAuth routes each username over its own circuit. No control port, no authentication, and not subject to the ~10s NEWNYM throttle.
  2. re-rolls the browser profile, so the new circuit also gets a new fingerprint.
  3. waits Retry-After / X-RateLimit-Reset when the server sends one, otherwise exponential backoff with jitter.
onionbravesearch.WithRetries(3),                  // extra attempts after the first (default 2)
onionbravesearch.WithRetryBackoff(2*time.Second), // base backoff (default 2s)
onionbravesearch.WithCircuitRotation(false),      // opt out of rotating circuits

Over .onion there is no exit node and no client IP for the search engine to throttle on, so a 429 that survives rotation is not circuit-scoped — it is either request-fingerprint-scoped or the engine’s introduction-point DoS defence, and slowing down is the only honest lever left.

Measured against the live Brave onion service: a strictly sequential burst hit 429 on roughly 1 query in 8. With WithRetries(2) all ten queries in the follow-up run succeeded, one of them after a rotation-induced 8.4 s detour.

What the client sends

  • Path: onion /search or /news (q, source=web, spellcheck=0, optional offset / tf)
  • Cookies: useLocation=0, summarizer=0, safesearch=off unless you change it, plus any integrity cookie the service sets
  • DuckDuckGo: onion /html (q, kl, kp, optional df, s, dc), no cookies of its own
  • Engine: HTTP/1.1 over a uTLS browser parroted through SOCKS5, with profile-consistent headers
  • No Ask Brave, no Google fallback mixing, no metrics opt-in, no accounts

License

MIT-0. Use it however you want. Credit is appreciated, not required.

The software is provided as is, without warranty of any kind, including fitness for a purpose or that it will keep working. There is no promise of updates, maintenance, or support. You are solely responsible for what you do with it. The authors are not liable for any claim, damage, or other liability arising from the software or its use.

Documentation

Index

Constants

View Source
const (
	DefaultProxy     = "127.0.0.1:9050"
	DefaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)
View Source
const DefaultDuckDuckGoBase = "https://duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion/"

DefaultDuckDuckGoBase is DuckDuckGo's official v3 onion service.

View Source
const DefaultOnionBase = "https://search.brave4u7jddbv7cyviptqjc7jusxh72uik7zt6adtckl5f4nwy2v72qd.onion/"

DefaultOnionBase is Brave Search's official onion service.

Variables

View Source
var (
	ErrBlocked     = errors.New("onionbravesearch: request blocked")
	ErrEmptyQuery  = errors.New("onionbravesearch: empty query")
	ErrBadPage     = errors.New("onionbravesearch: page must be between 1 and 10")
	ErrBadCategory = errors.New("onionbravesearch: unsupported category")
	ErrRateLimited = errors.New("onionbravesearch: rate limited")
	ErrNoResults   = errors.New("onionbravesearch: no results")
)

Functions

This section is empty.

Types

type Category

type Category string
const (
	CategoryWeb  Category = "search"
	CategoryNews Category = "news"
)

type Client

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

func New

func New(opts ...Option) (*Client, error)

New returns a client for Brave Search, i.e. NewBrave.

func NewBrave

func NewBrave(opts ...Option) (*Client, error)

NewBrave returns a client for Brave Search's onion service.

func NewDuckDuckGo

func NewDuckDuckGo(opts ...Option) (*Client, error)

NewDuckDuckGo returns a client for DuckDuckGo's onion service.

func (*Client) Close

func (c *Client) Close()

Close drops pooled connections.

func (*Client) Engine

func (c *Client) Engine() string

Engine reports which backend this client talks to.

func (*Client) Search

func (c *Client) Search(ctx context.Context, query string, opt SearchOptions) (*Response, error)

type Option

type Option func(*clientConfig) error

func WithBaseURL

func WithBaseURL(raw string) Option

WithBaseURL overrides the base for whichever engine is being built. Use WithBraveBaseURL / WithDuckDuckGoBaseURL to target one engine specifically, which is what the unified client needs.

func WithBraveBaseURL

func WithBraveBaseURL(raw string) Option

func WithCircuitRotation

func WithCircuitRotation(v bool) Option

WithCircuitRotation toggles switching to a new Tor circuit (and fingerprint) on retry.

func WithDuckDuckGoBaseURL

func WithDuckDuckGoBaseURL(raw string) Option

func WithFallbackOnEmpty

func WithFallbackOnEmpty(v bool) Option

WithFallbackOnEmpty controls whether the unified client moves on to the next engine when an engine answers successfully but with zero results. Defaults to true.

func WithHTTPClient

func WithHTTPClient(hc *http.Client) Option

func WithInsecureSkipVerify

func WithInsecureSkipVerify(v bool) Option

func WithProfile

func WithProfile(p Profile) Option

WithProfile pins the browser fingerprint family. ProfileRandom (the default) draws a fresh identity per circuit rotation.

func WithProxy

func WithProxy(addr string) Option

func WithRetries

func WithRetries(n int) Option

WithRetries sets how many extra attempts are made after a throttle or a dropped circuit. Rotation happens between attempts.

func WithRetryBackoff

func WithRetryBackoff(d time.Duration) Option

func WithTimeout

func WithTimeout(d time.Duration) Option

func WithUserAgent

func WithUserAgent(ua string) Option

type Profile

type Profile string

Profile selects a browser fingerprint family (TLS ClientHello + HTTP metadata).

const (
	// ProfileRandom picks a random profile per client, and a fresh one again on
	// every circuit rotation.
	ProfileRandom Profile = "random"
	// ProfileChrome pins the fingerprint to a Chromium desktop build.
	ProfileChrome Profile = "chrome"
	// ProfileFirefox pins the fingerprint to a Firefox desktop build.
	ProfileFirefox Profile = "firefox"
	// ProfileSafari pins the fingerprint to a Safari macOS build.
	ProfileSafari Profile = "safari"
	// ProfileIOSSafari pins the fingerprint to an iOS Safari build.
	ProfileIOSSafari Profile = "ios-safari"
)

type RateLimitError

type RateLimitError struct {
	Status     int
	RetryAfter time.Duration
	Attempts   int
}

RateLimitError reports a throttled response. It unwraps to ErrRateLimited.

func (*RateLimitError) Error

func (e *RateLimitError) Error() string

func (*RateLimitError) Unwrap

func (e *RateLimitError) Unwrap() error

type Response

type Response struct {
	Query   string   `json:"query"`
	Engine  string   `json:"engine,omitempty"`
	Results []Result `json:"results"`
	Related []string `json:"related,omitempty"`
}

func ParseDuckDuckGoHTML

func ParseDuckDuckGoHTML(raw []byte) (*Response, error)

ParseDuckDuckGoHTML parses the HTML-only result page.

func ParseSearchHTML

func ParseSearchHTML(raw []byte) (*Response, error)

type Result

type Result struct {
	Title       string `json:"title"`
	URL         string `json:"url"`
	Description string `json:"description,omitempty"`
	Site        string `json:"site,omitempty"`
	DisplayURL  string `json:"display_url,omitempty"`
	Age         string `json:"age,omitempty"`
	Favicon     string `json:"favicon,omitempty"`
	Thumbnail   string `json:"thumbnail,omitempty"`
	Type        string `json:"type,omitempty"`
}

type SafeSearch

type SafeSearch string
const (
	SafeSearchOff      SafeSearch = "off"
	SafeSearchModerate SafeSearch = "moderate"
	SafeSearchStrict   SafeSearch = "strict"
)

type SearchOptions

type SearchOptions struct {
	Category   Category
	Page       int
	Country    string
	UILang     string
	SafeSearch SafeSearch
	TimeRange  TimeRange
	Spellcheck bool
}

type TimeRange

type TimeRange string
const (
	TimeAny   TimeRange = ""
	TimeDay   TimeRange = "pd"
	TimeWeek  TimeRange = "pw"
	TimeMonth TimeRange = "pm"
	TimeYear  TimeRange = "py"
)

type Unified

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

Unified tries several search backends in order, DuckDuckGo first and Brave second by default, and returns the first usable answer.

func NewUnified

func NewUnified(opts ...Option) (*Unified, error)

NewUnified builds a DuckDuckGo-first, Brave-second client. Options apply to both backends; use WithDuckDuckGoBaseURL / WithBraveBaseURL to point them at different hosts.

func NewUnifiedFrom

func NewUnifiedFrom(clients ...*Client) (*Unified, error)

NewUnifiedFrom wraps ready-made clients and tries them in the given order.

func (*Unified) Clients

func (u *Unified) Clients() []*Client

Clients exposes the underlying clients, in fallback order.

func (*Unified) Close

func (u *Unified) Close()

func (*Unified) Engines

func (u *Unified) Engines() []string

Engines lists the backends in fallback order.

func (*Unified) Search

func (u *Unified) Search(ctx context.Context, query string, opt SearchOptions) (*Response, error)

Search walks the engines in order. An engine is skipped when it cannot serve the requested category, and - when fallbackOnEmpty is on - when it answers with no results at all.

Jump to

Keyboard shortcuts

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