Files
mycroft cf961ab94f
build / test (push) Successful in 9s
build / build (push) Successful in 10s
build / build-image (push) Successful in 1m4s
feat: initial commit
2026-06-04 14:19:37 +02:00

270 lines
8.2 KiB
Go

package anubis
import (
"fmt"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
const (
// AuthCookie is the signed-JWT cookie Anubis sets once a challenge passes.
AuthCookie = "techaro.lol-anubis-auth"
// passChallengePath is the endpoint that validates a solved challenge.
passChallengePath = "/.within.website/x/cmd/anubis/api/pass-challenge"
// DefaultUserAgent is a stable, browser-like UA. The Anubis JWT is bound to
// request metadata (incl. User-Agent), so every request through this
// transport must use the same value.
DefaultUserAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
)
// maxBodyPeek caps how much of a response body we read to detect/parse a
// challenge. Anubis interstitials are small; real pages can be large, so we
// only buffer when the response looks like an interstitial.
const maxChallengePeek = 1 << 20 // 1 MiB
// Transport is an http.RoundTripper that transparently solves Anubis
// proof-of-work challenges. When a request hits an interstitial, it solves the
// PoW, calls pass-challenge to obtain the auth cookie (stored in a shared
// jar), then replays the original request.
//
// A singleflight group collapses concurrent solves so only one goroutine pays
// the PoW cost while others wait for the resulting cookie.
type Transport struct {
Base http.RoundTripper
UserAgent string
Jar http.CookieJar
Logger *slog.Logger
sf singleflight.Group
mu sync.Mutex // guards client construction
hc *http.Client
}
// New returns a Transport with a fresh cookie jar and default settings.
func New() (*Transport, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, fmt.Errorf("anubis: create cookie jar: %w", err)
}
return &Transport{
Base: http.DefaultTransport,
UserAgent: DefaultUserAgent,
Jar: jar,
Logger: slog.Default(),
}, nil
}
// Client returns an *http.Client that uses this Transport and shares its cookie
// jar, so callers benefit from both the auto-solving and the persisted cookie.
func (t *Transport) Client() *http.Client {
t.mu.Lock()
defer t.mu.Unlock()
if t.hc == nil {
t.hc = &http.Client{Transport: t, Jar: t.Jar, Timeout: 30 * time.Second}
}
return t.hc
}
// RoundTrip implements http.RoundTripper.
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
t.setUA(req)
resp, err := t.base().RoundTrip(req)
if err != nil {
return nil, err
}
// Anubis serves interstitials as 200/text-html. Cheap content-type gate
// before we buffer anything.
if !maybeHTML(resp) {
return resp, nil
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxChallengePeek))
if err != nil {
resp.Body.Close()
return nil, fmt.Errorf("anubis: read body: %w", err)
}
resp.Body.Close()
if !isInterstitial(body) {
// Not a challenge; hand back a response with a replayable body.
return withBody(resp, body), nil
}
if t.Logger != nil {
t.Logger.Debug("anubis interstitial detected", "url", req.URL.String())
}
// The interstitial sets a cookie-verification cookie that pass-challenge
// requires. The outer http.Client only commits response cookies to the jar
// after RoundTrip returns, so capture them now — before we solve.
t.Jar.SetCookies(req.URL, resp.Cookies())
if err := t.solveFor(req, body); err != nil {
return nil, err
}
// Replay the original request now that we hold the auth cookie. The replay
// goes through the base transport directly, so attach jar cookies manually.
replay := req.Clone(req.Context())
t.setUA(replay)
t.applyCookies(replay)
resp2, err := t.base().RoundTrip(replay)
if err != nil {
return nil, err
}
if maybeHTML(resp2) {
body2, err := io.ReadAll(io.LimitReader(resp2.Body, maxChallengePeek))
resp2.Body.Close()
if err != nil {
return nil, fmt.Errorf("anubis: read replayed body: %w", err)
}
if isInterstitial(body2) {
return nil, fmt.Errorf("anubis: still challenged after passing challenge for %s", req.URL)
}
return withBody(resp2, body2), nil
}
return resp2, nil
}
// solveFor parses the challenge from body, computes the answer, and calls
// pass-challenge to install the auth cookie. Concurrent calls for the same
// host are collapsed via singleflight.
func (t *Transport) solveFor(req *http.Request, body []byte) error {
key := req.URL.Host
_, err, _ := t.sf.Do(key, func() (any, error) {
c, err := parseChallenge(body)
if err != nil {
return nil, err
}
passURL, err := t.answer(req, c)
if err != nil {
return nil, err
}
return nil, t.passChallenge(req, passURL)
})
return err
}
// answer computes the pass-challenge URL for the given challenge, performing
// any required wait or proof-of-work. It supports the "preact" challenge (a
// plain SHA-256 of randomData plus a timing gate) and the "fast"/"slow"
// proof-of-work challenges (nonce search).
func (t *Transport) answer(orig *http.Request, c *challenge) (string, error) {
start := time.Now()
switch c.algorithm {
case "preact":
// Answer is SHA-256(randomData); the server enforces a minimum wait of
// difficulty*80ms. Mirror the frontend's difficulty*125ms delay.
result := sha256Hex(c.randomData)
wait := time.Duration(c.difficulty) * 125 * time.Millisecond
time.Sleep(wait)
u := resolveRef(orig.URL, c.redir)
q := u.Query()
q.Set("result", result)
u.RawQuery = q.Encode()
if t.Logger != nil {
t.Logger.Info("answered anubis preact challenge",
"difficulty", c.difficulty, "waited", wait)
}
return u.String(), nil
case "fast", "slow", "proofofwork", "":
hash, nonce := Solve(c.randomData, c.difficulty)
u := &url.URL{Scheme: orig.URL.Scheme, Host: orig.URL.Host, Path: passChallengePath}
q := url.Values{
"id": {c.id},
"response": {hash},
"nonce": {strconv.FormatUint(nonce, 10)},
"redir": {orig.URL.RequestURI()},
"elapsedTime": {strconv.FormatInt(time.Since(start).Milliseconds(), 10)},
}
u.RawQuery = q.Encode()
if t.Logger != nil {
t.Logger.Info("solved anubis pow challenge",
"difficulty", c.difficulty, "nonce", nonce, "elapsed", time.Since(start))
}
return u.String(), nil
default:
return "", fmt.Errorf("anubis: unsupported challenge algorithm %q", c.algorithm)
}
}
// passChallenge GETs the pass-challenge URL so Anubis sets the auth cookie in
// the shared jar.
func (t *Transport) passChallenge(orig *http.Request, passURL string) error {
preq, err := http.NewRequestWithContext(orig.Context(), http.MethodGet, passURL, nil)
if err != nil {
return fmt.Errorf("anubis: build pass-challenge request: %w", err)
}
t.setUA(preq)
preq.Header.Set("Referer", orig.URL.String())
// Don't follow the post-pass redirect; we only need the Set-Cookie. Use a
// client bound to the shared jar so the cookie is captured.
hc := &http.Client{
Transport: t.base(),
Jar: t.Jar,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
Timeout: 30 * time.Second,
}
resp, err := hc.Do(preq)
if err != nil {
return fmt.Errorf("anubis: pass-challenge: %w", err)
}
defer resp.Body.Close()
io.Copy(io.Discard, io.LimitReader(resp.Body, maxChallengePeek))
if !t.hasAuthCookie(orig.URL) {
return fmt.Errorf("anubis: pass-challenge did not yield %s cookie (status %d)", AuthCookie, resp.StatusCode)
}
return nil
}
// hasAuthCookie reports whether the jar holds an Anubis auth cookie for u.
// Deployments customize the cookie prefix (default "techaro.lol-anubis-auth",
// root-me uses "anubis-cookie-auth"), so we match any non-empty cookie whose
// name ends in "-auth" while excluding the "-cookie-verification" helper.
func (t *Transport) hasAuthCookie(u *url.URL) bool {
for _, c := range t.Jar.Cookies(u) {
if c.Value == "" || strings.Contains(c.Name, "verification") {
continue
}
if c.Name == AuthCookie || strings.HasSuffix(c.Name, "-auth") {
return true
}
}
return false
}
func (t *Transport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return http.DefaultTransport
}
func (t *Transport) setUA(req *http.Request) {
ua := t.UserAgent
if ua == "" {
ua = DefaultUserAgent
}
req.Header.Set("User-Agent", ua)
if req.Header.Get("Accept-Language") == "" {
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
}
}