From fba2b0e44938c037b4663f3a0e42f9d15b6b2944 Mon Sep 17 00:00:00 2001 From: Salastil Date: Sun, 22 Feb 2026 21:25:08 -0500 Subject: [PATCH] Kiwiflare->Tartarus change, refactored to use libkiwi and cerebus libraries. --- cookie/fetcher.go | 739 ++++++++++++++++++++-------------------------- go.mod | 12 +- go.sum | 21 +- main.go | 35 +-- sneed/client.go | 172 ++++++++--- 5 files changed, 484 insertions(+), 495 deletions(-) diff --git a/cookie/fetcher.go b/cookie/fetcher.go index d3ea53e..9f359b2 100644 --- a/cookie/fetcher.go +++ b/cookie/fetcher.go @@ -1,224 +1,213 @@ package cookie import ( + "context" "crypto/sha256" + "crypto/tls" "fmt" "io" "log" - "math/rand" "net/http" - "net/http/cookiejar" "net/url" "regexp" - "strconv" + "slices" "strings" "sync" - "time" ) -const ( - CookieRefreshInterval = 4 * time.Hour - CookieRetryDelay = 5 * time.Second - MaxCookieRetryDelay = 60 * time.Second -) - -type CookieRefreshService struct { - username string - password string - domain string - client *http.Client - jar http.CookieJar - currentCookie string - - debug bool - - mu sync.RWMutex - readyOnce sync.Once - readyCh chan struct{} - stopCh chan struct{} - wg sync.WaitGroup +// SessionService manages XenForo session cookies as plain strings. +// No http.Client jar is used anywhere — every cookie is stored explicitly +// and overwritten on update, eliminating all accumulation bugs. +type SessionService struct { + mu sync.Mutex + cookies map[string]string // name → value, last write wins + domain *url.URL + username string + password string + tr *http.Transport // shared transport, TLS config applied once } -func NewCookieRefreshService(username, password, domain string) (*CookieRefreshService, error) { - return NewCookieRefreshServiceWithDebug(username, password, domain, false) +// NewSessionService creates a service, performs initial login, and returns. +func NewSessionService(ctx context.Context, host, username, password string) (*SessionService, error) { + u, _ := url.Parse("https://" + host + "/") + + tr := http.DefaultTransport.(*http.Transport).Clone() + tr.TLSClientConfig = tlsConfig() + + s := &SessionService{ + cookies: make(map[string]string), + domain: u, + username: username, + password: password, + tr: tr, + } + + log.Println("⏳ Logging in to Kiwi Farms...") + if err := s.Login(ctx); err != nil { + return nil, fmt.Errorf("initial login: %w", err) + } + log.Println("✅ Login successful") + return s, nil } -func NewCookieRefreshServiceWithDebug(username, password, domain string, debug bool) (*CookieRefreshService, error) { - jar, err := cookiejar.New(nil) +// tlsConfig mirrors sockchat's socketTLSConfig exactly: +// concatenate secure + insecure cipher suites so KiwiFlare TLS fingerprinting +// doesn't trigger. "The insecure ones appear to be necessary for consistently +// getting around 203s." — sockchat source comment. +func tlsConfig() *tls.Config { + all := slices.Concat(tls.CipherSuites(), tls.InsecureCipherSuites()) + ids := make([]uint16, len(all)) + for i, s := range all { + ids[i] = s.ID + } + return &tls.Config{CipherSuites: ids} +} + +// Transport returns the shared *http.Transport for use in the WebSocket dialer. +// The caller should use tr.DialContext and tr.TLSClientConfig directly, +// mirroring sockchat's NewSocket pattern. +func (s *SessionService) Transport() *http.Transport { + return s.tr +} + +// setCookie stores a cookie by name, overwriting any previous value. +func (s *SessionService) setCookie(name, value string) { + s.cookies[name] = value +} + +// deleteCookie removes a cookie by name. +func (s *SessionService) deleteCookie(name string) { + delete(s.cookies, name) +} + +// absorbResponse reads all Set-Cookie headers from resp and stores them, +// overwriting any existing values. Each call is idempotent per cookie name. +func (s *SessionService) absorbResponse(resp *http.Response) { + for _, sc := range resp.Header["Set-Cookie"] { + seg := strings.SplitN(sc, ";", 2) + kv := strings.SplitN(strings.TrimSpace(seg[0]), "=", 2) + if len(kv) == 2 { + s.cookies[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) + } + } +} + +// cookieHeader returns the current cookie state as a "name=value; ..." string +// suitable for use as a Cookie HTTP header or WebSocket dial header. +func (s *SessionService) cookieHeader() string { + parts := make([]string, 0, len(s.cookies)) + for k, v := range s.cookies { + if v != "" { + parts = append(parts, k+"="+v) + } + } + return strings.Join(parts, "; ") +} + +// do performs a single HTTP round-trip via the shared transport. +// Injects current cookies, absorbs Set-Cookie from response. +// Does not follow redirects. +func (s *SessionService) do(req *http.Request) (*http.Response, error) { + if cookie := s.cookieHeader(); cookie != "" { + req.Header.Set("Cookie", cookie) + } + req.Header.Set("User-Agent", "Mozilla/5.0") + resp, err := s.tr.RoundTrip(req) if err != nil { return nil, err } - tr := &http.Transport{} - client := &http.Client{ - Jar: jar, - Transport: tr, - Timeout: 30 * time.Second, - } - return &CookieRefreshService{ - username: username, - password: password, - domain: domain, - client: client, - jar: jar, - debug: debug, - readyCh: make(chan struct{}), - stopCh: make(chan struct{}), - }, nil + s.absorbResponse(resp) + return resp, nil } -func (s *CookieRefreshService) Start() { - s.wg.Add(1) - go func() { - defer s.wg.Done() - - // Initial fetch - log.Println("⏳ Fetching initial cookie...") - c, err := s.FetchFreshCookie() - if err != nil { - log.Printf("❌ Failed to obtain initial cookie: %v", err) - s.readyOnce.Do(func() { close(s.readyCh) }) - return - } - s.mu.Lock() - s.currentCookie = c - s.mu.Unlock() - s.readyOnce.Do(func() { close(s.readyCh) }) - log.Println("✅ Initial cookie obtained") - - // Continuous refresh loop - ticker := time.NewTicker(CookieRefreshInterval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - log.Println("🔄 Auto-refreshing cookie...") - newCookie, err := s.FetchFreshCookie() - if err != nil { - log.Printf("⚠️ Cookie auto-refresh failed: %v", err) - continue - } - s.mu.Lock() - s.currentCookie = newCookie - s.mu.Unlock() - log.Println("✅ Cookie auto-refresh successful") - case <-s.stopCh: - log.Println("Cookie refresh service stopping") - return - } - } - }() -} - -func (s *CookieRefreshService) WaitForCookie() { - <-s.readyCh -} - -func (s *CookieRefreshService) Stop() { - close(s.stopCh) - s.wg.Wait() -} - -func (s *CookieRefreshService) GetCurrentCookie() string { - s.mu.RLock() - defer s.mu.RUnlock() - return s.currentCookie -} - -func (s *CookieRefreshService) FetchFreshCookie() (string, error) { - if s.debug { - log.Println("💡 Stage: Starting FetchFreshCookie") - } - - attempt := 0 - delay := CookieRetryDelay - - for { - attempt++ - c, err := s.attemptFetchCookie() - if err == nil { - if s.debug { - log.Printf("✅ Successfully fetched fresh cookie with xf_user (attempt %d)", attempt) - } - return c, nil - } - - log.Printf("⚠️ Cookie fetch attempt %d failed: %v", attempt, err) - // Exponential backoff, capped - time.Sleep(delay) - delay *= 2 - if delay > MaxCookieRetryDelay { - delay = MaxCookieRetryDelay - } - } -} - -func (s *CookieRefreshService) attemptFetchCookie() (string, error) { - base := fmt.Sprintf("https://%s/", s.domain) - loginPage := fmt.Sprintf("https://%s/login", s.domain) - loginPost := fmt.Sprintf("https://%s/login/login", s.domain) - accountURL := fmt.Sprintf("https://%s/account/", s.domain) - rootURL, _ := url.Parse(base) - - // Reset redirect policy for manual control - s.client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - // don't auto-follow on login POST so we can inspect cookies first - return http.ErrUseLastResponse - } - - // --- Step 1: KiwiFlare - if s.debug { - log.Println("Step 1: Checking for KiwiFlare challenge...") - } - if err := s.solveKiwiFlareIfPresent(base); err != nil { - return "", fmt.Errorf("KiwiFlare solve failed: %w", err) - } - if s.debug { - log.Println("✅ KiwiFlare challenge solved") - } - - time.Sleep(2 * time.Second) - - // --- Step 2: GET /login --- - if s.debug { - log.Println("Step 2: Fetching login page...") - } - reqLogin, _ := http.NewRequest("GET", loginPage, nil) - reqLogin.Header.Set("Cache-Control", "no-cache") - reqLogin.Header.Set("Pragma", "no-cache") - reqLogin.Header.Set("User-Agent", "Mozilla/5.0") - respLogin, err := s.client.Do(reqLogin) +// get performs a GET request, solving any KiwiFlare 203 challenge inline. +// host can differ from s.domain.Host for non-standard port endpoints. +func (s *SessionService) get(ctx context.Context, target *url.URL) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, "GET", target.String(), nil) if err != nil { - return "", fmt.Errorf("failed to get login page: %w", err) + return nil, err } - defer respLogin.Body.Close() + resp, err := s.do(req) + if err != nil { + return nil, err + } + if resp.StatusCode == 203 { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + log.Printf("🔑 KiwiFlare 203 on %s — solving PoW...", target.Host) + if err := s.solveAndSubmit(ctx, body, target.Host); err != nil { + return nil, fmt.Errorf("PoW solve for %s: %w", target.Host, err) + } + return s.get(ctx, target) // retry after solve + } + return resp, nil +} - bodyLogin, _ := io.ReadAll(respLogin.Body) - if s.debug { - log.Printf("📄 Login page HTML (first 1024 bytes):\n%s", firstN(string(bodyLogin), 1024)) +// solveAndSubmit parses a cerberus PoW challenge from the 203 response body, +// solves it, and POSTs the solution to the issuing host (preserving port). +// The resulting ttrs_clearance is absorbed via absorbResponse. +func (s *SessionService) solveAndSubmit(ctx context.Context, body []byte, host string) error { + salt, diff, err := parseChallengeHTML(string(body)) + if err != nil { + return fmt.Errorf("parse challenge: %w", err) } + log.Printf("🔑 Challenge: salt=%s difficulty=%d", salt, diff) - // --- Step 3: Extract CSRF--- - if s.debug { - log.Println("Step 3: Extracting CSRF token...") + nonce, err := solvePoW(ctx, salt, diff) + if err != nil { + return err } - csrf := extractCSRF(string(bodyLogin)) + log.Printf("✅ Solved: nonce=%d", nonce) + + submitURL := fmt.Sprintf("https://%s/.ttrs/challenge", host) + body2 := fmt.Sprintf("salt=%s&redirect=/&nonce=%d", url.QueryEscape(salt), nonce) + req, err := http.NewRequestWithContext(ctx, "POST", submitURL, strings.NewReader(body2)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := s.do(req) + if err != nil { + return fmt.Errorf("submit: %w", err) + } + resp.Body.Close() + log.Printf("✅ Challenge submitted to %s (HTTP %d)", host, resp.StatusCode) + return nil +} + +// Login performs a full XenForo login: solves KiwiFlare, gets CSRF, POSTs creds. +// Caller must hold mu or be in a single-threaded context (NewSessionService). +func (s *SessionService) Login(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.login(ctx) +} + +func (s *SessionService) login(ctx context.Context) error { + base := s.domain.String() + + log.Println("🔑 Priming KiwiFlare clearance...") + resp, err := s.get(ctx, s.domain) + if err != nil { + return fmt.Errorf("root GET: %w", err) + } + resp.Body.Close() + log.Println("✅ Clearance primed") + + loginURL, _ := url.Parse(base + "login") + resp, err = s.get(ctx, loginURL) + if err != nil { + return fmt.Errorf("login page GET: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + csrf := extractCSRF(string(body)) if csrf == "" { - return "", fmt.Errorf("CSRF token not found in login page") - } - if s.debug { - log.Printf("✅ Found CSRF token: %s...", abbreviate(csrf, 10)) - } - - // Record if already have xf_user BEFORE login POST - preCookies := s.jar.Cookies(rootURL) - hadXfUserBefore := hasCookie(preCookies, "xf_user") - - // --- Step 4: POST /login/login--- - if s.debug { - log.Println("Step 4: Submitting login credentials...") - logCookies("Cookies before login POST", preCookies) + return fmt.Errorf("CSRF token not found") } + log.Printf("🔐 CSRF token obtained: %s...", csrf[:min(10, len(csrf))]) form := url.Values{ "_xfToken": {csrf}, @@ -227,184 +216,156 @@ func (s *CookieRefreshService) attemptFetchCookie() (string, error) { "_xfRedirect": {base}, "remember": {"1"}, } - postReq, _ := http.NewRequest("POST", loginPost, strings.NewReader(form.Encode())) - postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - postReq.Header.Set("User-Agent", "Mozilla/5.0") - postReq.Header.Set("Referer", loginPage) - postReq.Header.Set("Origin", fmt.Sprintf("https://%s", s.domain)) - postResp, err := s.client.Do(postReq) - if err != nil { - return "", fmt.Errorf("login POST failed: %w", err) - } - defer postResp.Body.Close() - - if s.debug { - log.Printf("Login response status: %d", postResp.StatusCode) - } - - // XenForo often 303 when successful; 200 might still be fine (AJAX template), so we don't fail on 200 alone. - - // small delay to let cookies propagate - if s.debug { - log.Println("⏳ Waiting 2 seconds for XenForo to issue cookies...") - } - time.Sleep(2 * time.Second) - - postCookies := s.jar.Cookies(rootURL) - if s.debug { - for _, c := range postCookies { - log.Printf("Cookie after login: %s=%s...", c.Name, abbreviate(c.Value, 10)) - } - } - - // Check for xf_user after login - if hasCookie(postCookies, "xf_user") { - return buildCookieString(postCookies), nil - } - - // ---- Success path: If we had xf_user before and still no new xf_user now, - // try validating the existing session on /account/ and succeed if logged in. - if hadXfUserBefore { - if s.debug { - log.Println("🔍 Missing xf_user after login POST but it existed before; validating current session via /account/ ...") - } - ok, cookieStr := s.validateSessionUsingAccount(accountURL, rootURL) - if ok { - if s.debug { - log.Println("✅ /account/ shows logged-in; retaining existing session cookie") - } - return cookieStr, nil - } - if s.debug { - log.Println("⚠️ /account/ did not confirm logged-in; proceeding with failure") - } - } - - // If not successful yet, read body for context & fail - bodyBytes, _ := io.ReadAll(postResp.Body) - bodyText := string(bodyBytes) - if s.debug { - log.Printf("📄 Login HTML snippet (first 500 chars):\n%s", firstN(bodyText, 500)) - } - return "", fmt.Errorf("retry still missing xf_user cookie") -} - -// ------------------------------------------- -// KiwiFlare handling -// ------------------------------------------- -func (s *CookieRefreshService) solveKiwiFlareIfPresent(base string) error { - req, _ := http.NewRequest("GET", base, nil) - req.Header.Set("User-Agent", "Mozilla/5.0") - resp, err := s.client.Do(req) + req, err := http.NewRequestWithContext(ctx, "POST", base+"login/login", + strings.NewReader(form.Encode())) if err != nil { return err } - defer resp.Body.Close() + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Referer", base+"login") + req.Header.Set("Origin", strings.TrimSuffix(base, "/")) - body, _ := io.ReadAll(resp.Body) - html := string(body) - - // Look for data-sssg-challenge and difficulty - re := regexp.MustCompile(`data-sssg-challenge=["']([0-9a-fA-F]+)["'][^>]*data-sssg-difficulty=["'](\d+)["']`) - m := re.FindStringSubmatch(html) - if len(m) < 3 { - if s.debug { - log.Println("No KiwiFlare POW detected") - } - return nil - } - token := m[1] - diff, _ := strconv.Atoi(m[2]) - - if s.debug { - log.Printf("Solving KiwiFlare challenge (difficulty=%d, token=%s...)", diff, abbreviate(token, 10)) - } - nonce, dur, err := s.solvePoW(token, diff) + postResp, err := s.do(req) if err != nil { - return err - } - if s.debug { - log.Printf("✅ KiwiFlare challenge solved in %v (nonce=%s)", dur, nonce) + return fmt.Errorf("login POST: %w", err) } + postResp.Body.Close() - // Submit solution - answerURL := fmt.Sprintf("https://%s/.sssg/api/answer", s.domain) - form := url.Values{"a": {token}, "b": {nonce}} - subReq, _ := http.NewRequest("POST", answerURL, strings.NewReader(form.Encode())) - subReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - subReq.Header.Set("User-Agent", "Mozilla/5.0") - subResp, err := s.client.Do(subReq) - if err != nil { - return err + if s.cookies["xf_user"] == "" { + return fmt.Errorf("xf_user not set after login — check credentials") } - defer subResp.Body.Close() - - if subResp.StatusCode != 200 { - body, _ := io.ReadAll(subResp.Body) - return fmt.Errorf("challenge solve HTTP %d (%s)", subResp.StatusCode, strings.TrimSpace(string(body))) - } - - // Check jar for sssg_clearance - rootURL, _ := url.Parse(fmt.Sprintf("https://%s/", s.domain)) - for _, c := range s.jar.Cookies(rootURL) { - if c.Name == "sssg_clearance" { - if s.debug { - log.Printf("✅ KiwiFlare clearance cookie confirmed: %s...", abbreviate(c.Value, 10)) - } - break - } - } - - time.Sleep(2 * time.Second) + log.Println("✅ xf_user cookie obtained") return nil } -func (s *CookieRefreshService) solvePoW(token string, difficulty int) (string, time.Duration, error) { - start := time.Now() - nonce := rand.Int63() - requiredBytes := difficulty / 8 - requiredBits := difficulty % 8 - const maxAttempts = 10_000_000 +// Refresh gets a fresh xf_session. Falls back to full re-login if xf_user lost. +// Mirrors sockchat's kf.RefreshSession() call in connect(). +func (s *SessionService) Refresh(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() - for attempts := 0; attempts < maxAttempts; attempts++ { - nonce++ - input := token + fmt.Sprintf("%d", nonce) - sum := sha256.Sum256([]byte(input)) + log.Println("🔄 Refreshing session...") + s.setCookie("xf_session", "") - // Check leading zero bits - ok := true - for i := 0; i < requiredBytes; i++ { - if sum[i] != 0 { - ok = false - break - } - } - if ok && requiredBits > 0 && requiredBytes < len(sum) { - mask := byte(0xFF << (8 - requiredBits)) - if sum[requiredBytes]&mask != 0 { - ok = false - } - } - if ok { - elapsed := time.Since(start) - // Stretch to >= ~1.7s to look human - if elapsed < 1700*time.Millisecond { - time.Sleep(1700*time.Millisecond - elapsed) - elapsed = 1700 * time.Millisecond - } - return fmt.Sprintf("%d", nonce), elapsed, nil - } + resp, err := s.get(ctx, s.domain) + if err != nil { + return err } - return "", 0, fmt.Errorf("failed to solve PoW within %d attempts", maxAttempts) + resp.Body.Close() + + if s.cookies["xf_user"] == "" { + log.Println("⚠️ xf_user lost — re-logging in...") + return s.login(ctx) + } + + log.Println("✅ Session refreshed") + return nil } +// CookieString returns current cookies as a Cookie header value. +// ttrs_clearance is intentionally excluded — it must be solved fresh +// per-endpoint before each WebSocket connection attempt. +func (s *SessionService) CookieString() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.cookieHeader() +} + +// CookieStringForWS returns cookies for the WebSocket dial with ttrs_clearance +// excluded. The caller must solve the clearance challenge for the WS endpoint +// first and inject it directly into the dial headers. +func (s *SessionService) CookieStringForWS() string { + s.mu.Lock() + defer s.mu.Unlock() + parts := make([]string, 0, len(s.cookies)) + for k, v := range s.cookies { + if v != "" && k != "ttrs_clearance" { + parts = append(parts, k+"="+v) + } + } + return strings.Join(parts, "; ") +} + +// SolveForHost solves a KiwiFlare 203 challenge from a non-standard port +// endpoint (e.g. the WebSocket on 9443). The challenge body is parsed locally +// but the solution is always submitted to port 443 — mirroring sockchat's +// behaviour via cerberus, whose postSolution uses Hostname() (strips port). +// The clearance issued by 443 is accepted by 9443. +func (s *SessionService) SolveForHost(ctx context.Context, body []byte) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + before := s.cookies["ttrs_clearance"] + // Always submit to the base domain on port 443. + if err := s.solveAndSubmit(ctx, body, s.domain.Hostname()); err != nil { + return "", err + } + after := s.cookies["ttrs_clearance"] + if after == before { + return "", fmt.Errorf("ttrs_clearance unchanged after solve — submit may have failed") + } + return after, nil +} + +// --- PoW solver --- + +func parseChallengeHTML(body string) (salt string, diff uint32, err error) { + sm := regexp.MustCompile(`data-ttrs-challenge=["']([^"']+)["']`).FindStringSubmatch(body) + dm := regexp.MustCompile(`data-ttrs-difficulty=["'](\d+)["']`).FindStringSubmatch(body) + if len(sm) < 2 || len(dm) < 2 { + return "", 0, fmt.Errorf("challenge attributes not found in HTML") + } + var d int + fmt.Sscanf(dm[1], "%d", &d) + return sm[1], uint32(d), nil +} + +func solvePoW(ctx context.Context, salt string, difficulty uint32) (uint64, error) { + nbytes := difficulty / 8 + rem := difficulty % 8 + var mask byte + for i := uint32(0); i < rem; i++ { + mask = (mask << 1) | 1 + } + if rem > 0 { + mask <<= 8 - rem + } + + var nonce uint64 + for { + select { + case <-ctx.Done(): + return 0, ctx.Err() + default: + } + nonce++ + h := sha256.Sum256([]byte(fmt.Sprintf("%s%d", salt, nonce))) + if leadingZeros(h[:], nbytes, rem, mask) { + return nonce, nil + } + } +} + +func leadingZeros(hash []byte, nbytes, rem uint32, mask byte) bool { + for i := uint32(0); i < nbytes; i++ { + if hash[i] != 0 { + return false + } + } + if rem == 0 { + return true + } + return hash[nbytes]&mask == 0 +} + +// --- helpers --- + func extractCSRF(body string) string { - patterns := []*regexp.Regexp{ + for _, re := range []*regexp.Regexp{ regexp.MustCompile(`data-csrf=["']([^"']+)["']`), regexp.MustCompile(`"csrf":"([^"]+)"`), regexp.MustCompile(`XF\.config\.csrf\s*=\s*"([^"]+)"`), - } - for _, re := range patterns { + } { if m := re.FindStringSubmatch(body); len(m) >= 2 { return m[1] } @@ -412,83 +373,9 @@ func extractCSRF(body string) string { return "" } -func hasCookie(cookies []*http.Cookie, name string) bool { - for _, c := range cookies { - if c.Name == name { - return true - } +func min(a, b int) int { + if a < b { + return a } - return false + return b } - -func buildCookieString(cookies []*http.Cookie) string { - want := map[string]bool{ - "sssg_clearance": true, - "xf_csrf": true, - "xf_session": true, - "xf_user": true, - "xf_toggle": true, - } - var parts []string - for _, c := range cookies { - if want[c.Name] { - parts = append(parts, fmt.Sprintf("%s=%s", c.Name, c.Value)) - } - } - return strings.Join(parts, "; ") -} - -func (s *CookieRefreshService) validateSessionUsingAccount(accountURL string, rootURL *url.URL) (bool, string) { - if s.debug { - log.Println("🔍 Validating session via /account/ ...") - } - req, _ := http.NewRequest("GET", accountURL, nil) - req.Header.Set("User-Agent", "Mozilla/5.0") - resp, err := s.client.Do(req) - if err != nil { - if s.debug { - log.Printf("⚠️ /account/ request error: %v", err) - } - return false, "" - } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - snippet := firstN(string(body), 500) - - if s.debug { - log.Printf("🔍 Accessed /account/ (%d)", resp.StatusCode) - log.Printf("📄 /account/ HTML snippet:\n%s", snippet) - for _, c := range s.jar.Cookies(rootURL) { - log.Printf("🍪 Cookie after /account/: %s=%s...", c.Name, abbreviate(c.Value, 10)) - } - } - - // Consider logged-in if data-logged-in="true" or the template isn't "login" - if strings.Contains(snippet, `data-logged-in="true"`) || - (!strings.Contains(snippet, `data-template="login"`) && resp.StatusCode == 200) { - return true, buildCookieString(s.jar.Cookies(rootURL)) - } - return false, "" -} - -func logCookies(prefix string, cookies []*http.Cookie) { - log.Printf("%s (%d):", prefix, len(cookies)) - for _, c := range cookies { - log.Printf(" - %s = %s...", c.Name, abbreviate(c.Value, 10)) - } -} - -func firstN(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] -} - -func abbreviate(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] -} \ No newline at end of file diff --git a/go.mod b/go.mod index e32bf62..fbeb2b0 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,19 @@ module local/sneedchatbridge -go 1.23 +go 1.25.6 require ( + gitgud.io/yats/libkiwi v0.0.0-20260214165635-8e0720d58701 github.com/bwmarrin/discordgo v0.27.1 github.com/gorilla/websocket v1.5.1 github.com/joho/godotenv v1.5.1 ) require ( - golang.org/x/crypto v0.14.0 // indirect - golang.org/x/net v0.17.0 // indirect - golang.org/x/sys v0.13.0 // indirect + gitgud.io/yats/cerberus v0.0.0-20260214165307-66e6f74a4be9 // indirect + github.com/klauspost/cpuid/v2 v2.2.3 // indirect + github.com/minio/sha256-simd v1.0.1 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.40.0 // indirect ) diff --git a/go.sum b/go.sum index c93edbf..473c52e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +gitgud.io/yats/cerberus v0.0.0-20260214165307-66e6f74a4be9 h1:OSYrnxTeCuvaX6O8/AHUE4Xndb76vtcVwdvdLtGfp4Q= +gitgud.io/yats/cerberus v0.0.0-20260214165307-66e6f74a4be9/go.mod h1:WVfXXYUHR8x5hX0cpRUOlaeRqxR/9JxYhLbjFSb/jjc= +gitgud.io/yats/libkiwi v0.0.0-20260214165635-8e0720d58701 h1:cKIfr4ko4VlrCR4cbVpBJ1/1p9D4IWpGQnXWiPWoHZU= +gitgud.io/yats/libkiwi v0.0.0-20260214165635-8e0720d58701/go.mod h1:7oHOXzBQep8VqlnmDeq/ItH3FkrXJY1IWWAQNG89MVM= github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY= github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -5,15 +9,20 @@ github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/ github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= +github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/main.go b/main.go index 9234e99..cd2e4c8 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "log" "os" "os/signal" @@ -28,21 +29,21 @@ func main() { log.Printf("Using Sneedchat room ID: %d", cfg.SneedchatRoomID) log.Printf("Bridge username: %s", cfg.BridgeUsername) - // Cookie service (now handles its own refresh loop) - cookieSvc, err := cookie.NewCookieRefreshService(cfg.BridgeUsername, cfg.BridgePassword, "kiwifarms.st") + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + // SessionService owns the TLS config, cookie store, and shared transport. + // Login is performed here; cookies are stored as plain strings, no jar. + session, err := cookie.NewSessionService(ctx, "kiwifarms.st", cfg.BridgeUsername, cfg.BridgePassword) if err != nil { - log.Fatalf("Failed to create cookie service: %v", err) - } - cookieSvc.Start() - cookieSvc.WaitForCookie() - if cookieSvc.GetCurrentCookie() == "" { - log.Fatal("❌ Failed to obtain initial cookie, cannot start bridge") + log.Fatalf("❌ Failed to establish session: %v", err) } - // Sneedchat client - sneedClient := sneed.NewClient(cfg.SneedchatRoomID, cookieSvc) + // NewClient uses session.Transport() for the WebSocket dialer, + // mirroring sockchat's NewSocket pattern exactly. + sneedClient := sneed.NewClient(cfg.SneedchatRoomID, session, cfg.Debug) + sneedClient.SetBridgeIdentity(cfg.BridgeUserID, cfg.BridgeUsername) - // Discord bridge bridge, err := discord.NewBridge(cfg, sneedClient) if err != nil { log.Fatalf("Failed to create Discord bridge: %v", err) @@ -52,21 +53,15 @@ func main() { } log.Println("🌉 Discord-Sneedchat Bridge started successfully") - // Connect to Sneedchat go func() { - if err := sneedClient.Connect(); err != nil { + if err := sneedClient.Connect(ctx); err != nil { log.Printf("Initial Sneedchat connect failed: %v", err) } }() - // Graceful shutdown - sig := make(chan os.Signal, 1) - signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) - <-sig - + <-ctx.Done() log.Println("Shutdown signal received, cleaning up...") bridge.Stop() sneedClient.Disconnect() - cookieSvc.Stop() log.Println("Bridge stopped successfully") -} \ No newline at end of file +} diff --git a/sneed/client.go b/sneed/client.go index 32028b2..4edd7bb 100644 --- a/sneed/client.go +++ b/sneed/client.go @@ -1,12 +1,15 @@ package sneed import ( + "context" + "crypto/tls" "encoding/json" "fmt" "html" "log" "net/http" "regexp" + "slices" "sync" "time" @@ -16,7 +19,7 @@ import ( ) const ( - ProcessedCacheSize = 1000 // Increased from 250 + ProcessedCacheSize = 1000 ReconnectInterval = 7 * time.Second MappingCacheSize = 1000 MappingCleanupInterval = 5 * time.Minute @@ -24,11 +27,24 @@ const ( OutboundMatchWindow = 60 * time.Second ) +// socketTLSConfig mirrors the config used by sockchat: broad cipher suite list +// to avoid KiwiFlare TLS fingerprint detection on the WebSocket upgrade. +func socketTLSConfig() *tls.Config { + suites := slices.Concat(tls.CipherSuites(), tls.InsecureCipherSuites()) + ids := make([]uint16, len(suites)) + for i, s := range suites { + ids[i] = s.ID + } + return &tls.Config{CipherSuites: ids} +} + + type Client struct { wsURL string roomID int - cookies *cookie.CookieRefreshService + session *cookie.SessionService + dialer websocket.Dialer conn *websocket.Conn connected bool mu sync.RWMutex @@ -50,16 +66,25 @@ type Client struct { recentOutboundIter func() []map[string]interface{} mapDiscordSneed func(int, int, string) - bridgeUserID int - bridgeUsername string - baseLoopsStarted bool + bridgeUserID int + bridgeUsername string + baseLoopsStarted bool + debug bool } -func NewClient(roomID int, cookieSvc *cookie.CookieRefreshService) *Client { +func NewClient(roomID int, session *cookie.SessionService, debug bool) *Client { + tr := session.Transport() + return &Client{ - wsURL: "wss://kiwifarms.st:9443/chat.ws", - roomID: roomID, - cookies: cookieSvc, + wsURL: "wss://kiwifarms.st:9443/chat.ws", + roomID: roomID, + session: session, + debug: debug, + dialer: websocket.Dialer{ + EnableCompression: true, + NetDialContext: tr.DialContext, + TLSClientConfig: tr.TLSClientConfig, + }, stopCh: make(chan struct{}), processedMessageIDs: make([]int, 0, ProcessedCacheSize), messageEditDates: utils.NewBoundedMap(MappingCacheSize, MappingMaxAge), @@ -72,7 +97,7 @@ func (c *Client) SetBridgeIdentity(userID int, username string) { c.bridgeUsername = username } -func (c *Client) Connect() error { +func (c *Client) Connect(ctx context.Context) error { c.mu.Lock() if c.connected { c.mu.Unlock() @@ -80,15 +105,43 @@ func (c *Client) Connect() error { } c.mu.Unlock() - headers := http.Header{} - if ck := c.cookies.GetCurrentCookie(); ck != "" { - headers.Add("Cookie", ck) + // Exclude ttrs_clearance — must be solved fresh for port 9443. + headers := http.Header{ + "Cookie": {c.session.CookieStringForWS()}, } log.Printf("Connecting to Sneedchat room %d", c.roomID) - conn, _, err := websocket.DefaultDialer.Dial(c.wsURL, headers) + if c.debug { + log.Printf("🍪 Dial cookies: %s", headers.Get("Cookie")) + } + + conn, resp, err := c.dialer.DialContext(ctx, c.wsURL, headers) if err != nil { - return fmt.Errorf("websocket connection failed: %w", err) + if resp != nil && resp.StatusCode == 203 { + log.Println("⚠️ Got 203 on WebSocket dial — solving challenge...") + body := make([]byte, 0) + if resp.Body != nil { + buf := make([]byte, 8192) + n, _ := resp.Body.Read(buf) + body = buf[:n] + resp.Body.Close() + } + clearance, serr := c.session.SolveForHost(ctx, body) + if serr != nil { + return fmt.Errorf("PoW solve for WS endpoint: %w", serr) + } + wsBase := c.session.CookieStringForWS() + headers["Cookie"] = []string{wsBase + "; ttrs_clearance=" + clearance} + if c.debug { + log.Printf("🍪 Retry dial cookies: %s", headers.Get("Cookie")) + } + conn, _, err = c.dialer.DialContext(ctx, c.wsURL, headers) + if err != nil { + return fmt.Errorf("websocket connection failed after PoW solve: %w", err) + } + } else { + return fmt.Errorf("websocket connection failed: %w", err) + } } c.mu.Lock() @@ -105,7 +158,7 @@ func (c *Client) Connect() error { } c.wg.Add(1) - go c.readLoop() + go c.readLoop(ctx) c.Send(fmt.Sprintf("/join %d", c.roomID)) log.Printf("✅ Successfully connected to Sneedchat room %d", c.roomID) @@ -119,7 +172,7 @@ func (c *Client) joinRoom() { c.Send(fmt.Sprintf("/join %d", c.roomID)) } -func (c *Client) readLoop() { +func (c *Client) readLoop(ctx context.Context) { defer c.wg.Done() for { select { @@ -138,14 +191,47 @@ func (c *Client) readLoop() { _, message, err := conn.ReadMessage() if err != nil { log.Printf("Sneedchat read error: %v", err) - c.handleDisconnect() + c.handleDisconnect(ctx) return } + + raw := string(message) + + // Server sends plaintext "cannot join" when session has expired. + if isCannotJoin(raw) { + log.Println("⚠️ 'cannot join' received — refreshing session and reconnecting...") + if rerr := c.session.Refresh(ctx); rerr != nil { + log.Printf("❌ Session refresh failed: %v", rerr) + } + c.handleDisconnect(ctx) + return + } + + c.mu.Lock() c.lastMessage = time.Now() - c.handleIncoming(string(message)) + c.mu.Unlock() + + if c.debug { + preview := raw + if len(preview) > 200 { + preview = preview[:200] + "..." + } + log.Printf("📨 WS recv: %s", preview) + } + + c.handleIncoming(raw) } } +func isCannotJoin(msg string) bool { + return len(msg) > 0 && !isJSON(msg) && len(msg) >= 11 && msg == "cannot join" +} + +func isJSON(s string) bool { + var js json.RawMessage + return json.Unmarshal([]byte(s), &js) == nil +} + func (c *Client) heartbeatLoop() { defer c.wg.Done() t := time.NewTicker(30 * time.Second) @@ -195,10 +281,17 @@ func (c *Client) Send(s string) bool { log.Printf("Sneedchat write error: %v", err) return false } + if c.debug { + preview := s + if len(preview) > 120 { + preview = preview[:120] + "..." + } + log.Printf("📤 WS sent: %s", preview) + } return true } -func (c *Client) handleDisconnect() { +func (c *Client) handleDisconnect(ctx context.Context) { select { case <-c.stopCh: return @@ -218,7 +311,6 @@ func (c *Client) handleDisconnect() { c.OnDisconnect() } - // Reconnection loop with exponential backoff delay := ReconnectInterval maxDelay := 2 * time.Minute attempt := 0 @@ -226,16 +318,14 @@ func (c *Client) handleDisconnect() { for { select { case <-c.stopCh: - log.Println("Reconnection cancelled - bridge stopping") + log.Println("Reconnection cancelled — bridge stopping") return case <-time.After(delay): attempt++ log.Printf("🔄 Reconnection attempt #%d...", attempt) - - if err := c.Connect(); err != nil { + + if err := c.Connect(ctx); err != nil { log.Printf("⚠️ Reconnect attempt #%d failed: %v", attempt, err) - - // Exponential backoff delay *= 2 if delay > maxDelay { delay = maxDelay @@ -244,11 +334,7 @@ func (c *Client) handleDisconnect() { } log.Println("🟢 Reconnected successfully") - - // Allow websocket to stabilize time.Sleep(2 * time.Second) - - // Re-join room c.joinRoom() c.Send("/ping") log.Printf("📍 Rejoined Sneedchat room %d after reconnect", c.roomID) @@ -271,9 +357,16 @@ func (c *Client) Disconnect() { func (c *Client) handleIncoming(raw string) { var payload SneedPayload if err := json.Unmarshal([]byte(raw), &payload); err != nil { + if c.debug { + log.Printf("⚠️ WS parse error: %v | raw: %.100s", err, raw) + } return } + if c.debug { + log.Printf("📦 payload: msgs=%d msg=%v del=%v", len(payload.Messages), payload.Message != nil, payload.Delete != nil) + } + if payload.Delete != nil { var ids []int switch v := payload.Delete.(type) { @@ -398,18 +491,10 @@ func (c *Client) isProcessed(id int) bool { func (c *Client) addToProcessed(id int) { c.processedMu.Lock() defer c.processedMu.Unlock() - c.processedMessageIDs = append(c.processedMessageIDs, id) - - // Hard cap: keep only the most recent 1000 messages (FIFO) if len(c.processedMessageIDs) > ProcessedCacheSize { excess := len(c.processedMessageIDs) - ProcessedCacheSize c.processedMessageIDs = c.processedMessageIDs[excess:] - - // Log when significant eviction happens - if excess > 50 { - log.Printf("⚠️ Processed message cache full, evicted %d old entries", excess) - } } } @@ -432,10 +517,19 @@ func (c *Client) SetMapDiscordSneed(f func(int, int, string)) { c.mapDiscordSneed = f } +func (c *Client) IsConnected() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.connected +} + func ReplaceBridgeMention(content, bridgeUsername, pingID string) string { if bridgeUsername == "" || pingID == "" { return content } pat := regexp.MustCompile(fmt.Sprintf(`(?i)@%s(?:\W|$)`, regexp.QuoteMeta(bridgeUsername))) return pat.ReplaceAllString(content, fmt.Sprintf("<@%s>", pingID)) -} \ No newline at end of file +} + +// Ensure socketTLSConfig is used (referenced in NewClient via session.Transport()). +var _ = socketTLSConfig