CookieRefresh will no longer crash bridge if Xenforo refuses to give a new cookie because its already logged in.

This commit is contained in:
Salastil
2025-10-22 15:57:15 -04:00
parent 1c409694fd
commit ad58e4720c
2 changed files with 360 additions and 586 deletions

View File

@@ -2,8 +2,6 @@ package cookie
import (
"crypto/sha256"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
@@ -18,632 +16,452 @@ import (
"time"
)
// CookieRefreshService manages periodic cookie refreshing.
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
debug bool
jar http.CookieJar
currentCookie string
cookieMu sync.RWMutex
cookieReady chan struct{}
stopChan chan struct{}
wg sync.WaitGroup
debug bool
mu sync.RWMutex
readyOnce sync.Once
readyCh chan struct{}
stopCh chan struct{}
wg sync.WaitGroup
}
// NewCookieRefreshService initializes a new cookie service.
func NewCookieRefreshService(username, password, domain string, debug bool) (*CookieRefreshService, error) {
// Standard cookie jar (no additional dependencies needed)
func NewCookieRefreshService(username, password, domain string) (*CookieRefreshService, error) {
return NewCookieRefreshServiceWithDebug(username, password, domain, false)
}
func NewCookieRefreshServiceWithDebug(username, password, domain string, debug bool) (*CookieRefreshService, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
// Configure TLS to match Python's behavior
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: false,
// Support modern cipher suites
CipherSuites: []uint16{
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
},
}
// Configure transport to match Python's behavior
transport := &http.Transport{
TLSClientConfig: tlsConfig,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
DisableCompression: false,
ForceAttemptHTTP2: true, // Force HTTP/2 like Python's aiohttp
TLSHandshakeTimeout: 10 * time.Second,
}
tr := &http.Transport{}
client := &http.Client{
Jar: jar,
Timeout: 45 * time.Second,
Transport: transport,
// CRITICAL: Disable automatic redirects - we handle them manually
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Transport: tr,
Timeout: 30 * time.Second,
}
return &CookieRefreshService{
username: username,
password: password,
domain: domain,
client: client,
debug: debug,
cookieReady: make(chan struct{}),
stopChan: make(chan struct{}),
username: username,
password: password,
domain: domain,
client: client,
jar: jar,
debug: debug,
readyCh: make(chan struct{}),
stopCh: make(chan struct{}),
}, nil
}
//
// ---------- Public methods ----------
//
func (crs *CookieRefreshService) Start() {
crs.wg.Add(1)
go crs.refreshLoop()
}
func (crs *CookieRefreshService) Stop() {
close(crs.stopChan)
crs.wg.Wait()
}
func (crs *CookieRefreshService) WaitForCookie() {
<-crs.cookieReady
}
func (crs *CookieRefreshService) GetCurrentCookie() string {
crs.cookieMu.RLock()
defer crs.cookieMu.RUnlock()
return crs.currentCookie
}
//
// ---------- Internal core ----------
//
func (crs *CookieRefreshService) refreshLoop() {
defer crs.wg.Done()
log.Println("🔑 Fetching initial cookie...")
fresh, err := crs.FetchFreshCookie()
if err != nil {
log.Printf("❌ Initial cookie fetch failed: %v", err)
return
}
crs.cookieMu.Lock()
crs.currentCookie = fresh
crs.cookieMu.Unlock()
close(crs.cookieReady)
log.Println("✅ Initial cookie acquired")
ticker := time.NewTicker(4 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
log.Println("🔄 Automatic cookie refresh cycle started")
cookie, err := crs.FetchFreshCookie()
if err != nil {
log.Printf("⚠️ Cookie refresh failed: %v", err)
continue
}
crs.cookieMu.Lock()
crs.currentCookie = cookie
crs.cookieMu.Unlock()
log.Println("✅ Cookie refresh completed")
case <-crs.stopChan:
func (s *CookieRefreshService) Start() {
s.wg.Add(1)
go func() {
defer s.wg.Done()
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) })
}()
}
// FetchFreshCookie attempts full login until success.
func (crs *CookieRefreshService) FetchFreshCookie() (string, error) {
attempt := 1
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 {
log.Printf("🔑 Attempting cookie fetch (attempt %d)", attempt)
cookie, err := crs.attemptFetchCookie()
if err == nil && strings.Contains(cookie, "xf_user=") {
log.Printf("✅ Successfully fetched fresh cookie with xf_user (attempt %d)", attempt)
return cookie, nil
}
if err != nil {
log.Printf("⚠️ Cookie fetch attempt %d failed: %v", attempt, err)
} else {
log.Printf("⚠️ Cookie fetch attempt %d failed: retry still missing xf_user cookie", attempt)
}
time.Sleep(5 * time.Second)
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
}
}
}
// attemptFetchCookie performs one complete login cycle.
func (crs *CookieRefreshService) attemptFetchCookie() (string, error) {
baseURL := fmt.Sprintf("https://%s", crs.domain)
// Validate credentials
if crs.username == "" || crs.password == "" {
return "", fmt.Errorf("username or password is empty")
}
if crs.debug {
log.Printf("🔐 Attempting login for user: '%s' (password length: %d)", crs.username, len(crs.password))
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 clearance
log.Println("Step 1: Checking for KiwiFlare challenge...")
req, _ := http.NewRequest("GET", baseURL+"/", nil)
req.Header.Set("User-Agent", randomUserAgent())
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Upgrade-Insecure-Requests", "1")
start := time.Now()
resp, err := crs.client.Do(req)
if err != nil {
return "", fmt.Errorf("initial GET failed: %w", err)
// --- Step 1: KiwiFlare
if s.debug {
log.Println("Step 1: Checking for KiwiFlare challenge...")
}
defer resp.Body.Close()
if crs.debug {
log.Printf("📩 [HTTP GET] %s/ -> %d %s", baseURL, resp.StatusCode, resp.Status)
for k, v := range resp.Header {
for _, val := range v {
log.Printf(" ← %s: %s", k, val)
}
}
if err := s.solveKiwiFlareIfPresent(base); err != nil {
return "", fmt.Errorf("KiwiFlare solve failed: %w", err)
}
body, _ := io.ReadAll(resp.Body)
log.Printf("⏱️ KiwiFlare challenge page loaded in %v", time.Since(start))
log.Printf("📄 Body length: %d bytes", len(body))
if strings.Contains(string(body), "data-sssg-challenge") {
auth, err := crs.solveKiwiFlare(body)
if err != nil {
return "", fmt.Errorf("KiwiFlare solve error: %w", err)
}
log.Printf("✅ KiwiFlare clearance cookie confirmed: %s...", trimLong(auth, 10))
if s.debug {
log.Println("✅ KiwiFlare challenge solved")
log.Println("⏳ Waiting 2 seconds for cookie propagation...")
time.Sleep(2 * time.Second)
}
// Step 2: Login page
log.Println("Step 2: Fetching login page...")
loginURL := fmt.Sprintf("%s/login", baseURL)
req, _ = http.NewRequest("GET", loginURL, nil)
req.Header.Set("User-Agent", randomUserAgent())
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Upgrade-Insecure-Requests", "1")
time.Sleep(2 * time.Second)
resp, err = crs.client.Do(req)
// --- 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)
if err != nil {
return "", fmt.Errorf("failed to get login page: %w", err)
}
defer resp.Body.Close()
defer respLogin.Body.Close()
body, _ = io.ReadAll(resp.Body)
log.Printf("→ Using protocol for login page: %s", resp.Proto)
time.Sleep(1 * time.Second)
// Step 3: Extract CSRF token
csrfToken := extractCSRF(string(body))
if csrfToken == "" {
if crs.debug {
log.Printf("🔍 HTML body (first 2000 chars):\n%s", string(body)[:min(2000, len(body))])
}
return "", fmt.Errorf("missing CSRF token")
}
log.Printf("✅ Found CSRF token: %s...", trimLong(csrfToken, 10))
// Step 4: POST login credentials (matching Python exactly)
loginPost := fmt.Sprintf("%s/login/login", baseURL)
// Build form data exactly like Python
data := url.Values{
"_xfToken": {csrfToken},
"login": {crs.username},
"password": {crs.password},
"remember": {"1"},
}
// Add redirect URL as separate parameter (without the key from Python that was causing issues)
data.Set("_xfRedirect", baseURL+"/")
formData := data.Encode()
if crs.debug {
log.Printf("🔐 POST form data: %s", strings.ReplaceAll(formData, crs.password, "***"))
bodyLogin, _ := io.ReadAll(respLogin.Body)
if s.debug {
log.Printf("📄 Login page HTML (first 1024 bytes):\n%s", firstN(string(bodyLogin), 1024))
}
postReq, _ := http.NewRequest("POST", loginPost, strings.NewReader(formData))
// --- Step 3: Extract CSRF---
if s.debug {
log.Println("Step 3: Extracting CSRF token...")
}
csrf := extractCSRF(string(bodyLogin))
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)
}
form := url.Values{
"_xfToken": {csrf},
"login": {s.username},
"password": {s.password},
"_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("Content-Length", fmt.Sprintf("%d", len(formData)))
postReq.Header.Set("User-Agent", randomUserAgent())
postReq.Header.Set("Referer", loginURL)
postReq.Header.Set("Origin", baseURL)
postReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
postReq.Header.Set("Accept-Language", "en-US,en;q=0.5")
postReq.Header.Set("Accept-Encoding", "gzip, deflate, br")
postReq.Header.Set("Connection", "keep-alive")
postReq.Header.Set("Upgrade-Insecure-Requests", "1")
postReq.Header.Set("Cache-Control", "max-age=0")
loginResp, err := crs.client.Do(postReq)
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 loginResp.Body.Close()
defer postResp.Body.Close()
log.Printf("Login POST response: %d %s (proto: %s)", loginResp.StatusCode, loginResp.Status, loginResp.Proto)
if crs.debug {
log.Println("Login response headers:")
for k, v := range loginResp.Header {
for _, val := range v {
log.Printf(" ← %s: %s", k, val)
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")
}
}
// Check if we got a redirect (successful login returns 303)
if loginResp.StatusCode >= 300 && loginResp.StatusCode < 400 {
location := loginResp.Header.Get("Location")
log.Printf("✅ Login successful - got redirect to: %s", location)
io.Copy(io.Discard, loginResp.Body)
} else if loginResp.StatusCode == 200 {
// Status 200 on login POST might mean:
// 1. Failed login (shows error)
// 2. Already logged in / session reuse
// 3. Two-factor auth required
bodyBytes, _ := io.ReadAll(loginResp.Body)
snippet := string(bodyBytes)
// Check what kind of 200 response this is
isLoggedIn := strings.Contains(snippet, "data-logged-in=\"true\"")
hasError := strings.Contains(snippet, "Incorrect password") ||
strings.Contains(snippet, "requested user") ||
strings.Contains(snippet, "error")
if isLoggedIn {
log.Println("✅ Login successful - already authenticated (200 OK with logged-in=true)")
} else if !hasError {
// No error message but not logged in = might be session propagation delay
log.Println("⚠️ Login POST returned 200 without errors - checking session state...")
} else if strings.Contains(snippet, "Incorrect password") {
return "", fmt.Errorf("login failed: incorrect password")
} else if strings.Contains(snippet, "requested user") {
return "", fmt.Errorf("login failed: user not found")
} else {
log.Println("⚠️ Login POST returned 200 with possible error - will check cookies")
// 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)
if err != nil {
return err
}
defer resp.Body.Close()
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")
}
if crs.debug && len(snippet) > 1000 {
snippet = snippet[:1000]
}
if crs.debug {
log.Printf("🧩 Login 200 body snippet:\n%s", snippet)
}
} else {
// Other status codes
io.Copy(io.Discard, loginResp.Body)
log.Printf("⚠️ Unexpected login response: %d %s", loginResp.StatusCode, loginResp.Status)
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)
if err != nil {
return err
}
if s.debug {
log.Printf("✅ KiwiFlare challenge solved in %v (nonce=%s)", dur, nonce)
}
time.Sleep(3 * time.Second) // Increased from 2s - give server more time
// 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
}
defer subResp.Body.Close()
// Step 5: Manually extract Set-Cookie headers from POST response
// This is critical - Go's cookie jar might not automatically process
// Set-Cookie headers when we disable redirects
log.Println("Step 5: Extracting cookies from POST response headers...")
if setCookies := loginResp.Header["Set-Cookie"]; len(setCookies) > 0 {
for _, sc := range setCookies {
log.Printf("📩 Set-Cookie header: %s", trimLong(sc, 80))
// Parse and add to jar manually if needed
// The jar should do this automatically, but let's be explicit
}
}
// Step 6: Check cookies in jar
cookieURL, _ := url.Parse(baseURL)
cookies := crs.client.Jar.Cookies(cookieURL)
hasXfUser := false
hasXfSession := false
for _, c := range cookies {
log.Printf("🍪 [After Login POST] %s=%s", c.Name, trimLong(c.Value, 10))
if c.Name == "xf_user" {
hasXfUser = true
}
if c.Name == "xf_session" {
hasXfSession = true
}
}
// If we have xf_session but not xf_user, login succeeded but we need to trigger xf_user
if hasXfSession && !hasXfUser {
log.Println("✅ Login successful (have xf_session) - will trigger xf_user cookie")
} else if !hasXfSession {
log.Println("❌ Login may have failed - no xf_session cookie present")
return "", fmt.Errorf("login failed - no session cookie received")
if subResp.StatusCode != 200 {
body, _ := io.ReadAll(subResp.Body)
return fmt.Errorf("challenge solve HTTP %d (%s)", subResp.StatusCode, strings.TrimSpace(string(body)))
}
// Step 6: CRITICAL - Manually follow redirects if xf_user missing (Python does this automatically)
maxRedirects := 5
redirectCount := 0
currentURL := baseURL + "/"
for !hasXfUser && redirectCount < maxRedirects {
redirectCount++
log.Printf("🧭 Following redirect manually (attempt %d) to capture xf_user...", redirectCount)
time.Sleep(1 * time.Second)
followReq, _ := http.NewRequest("GET", currentURL, nil)
followReq.Header.Set("User-Agent", randomUserAgent())
followReq.Header.Set("Referer", loginPost)
followReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
followReq.Header.Set("Accept-Language", "en-US,en;q=0.5")
followReq.Header.Set("Connection", "keep-alive")
followReq.Header.Set("Upgrade-Insecure-Requests", "1")
followResp, ferr := crs.client.Do(followReq)
if ferr != nil {
log.Printf("⚠️ Redirect follow failed: %v", ferr)
// 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
}
}
log.Printf("📩 [HTTP GET] %s -> %s (proto: %s)", currentURL, followResp.Status, followResp.Proto)
time.Sleep(2 * time.Second)
return nil
}
// Check for additional redirects
if followResp.StatusCode >= 300 && followResp.StatusCode < 400 {
location := followResp.Header.Get("Location")
if location != "" {
if !strings.HasPrefix(location, "http") {
location = baseURL + location
}
currentURL = location
log.Printf("🔄 Server returned redirect to: %s", currentURL)
}
}
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
io.Copy(io.Discard, followResp.Body)
followResp.Body.Close()
time.Sleep(1 * time.Second)
for attempts := 0; attempts < maxAttempts; attempts++ {
nonce++
input := token + fmt.Sprintf("%d", nonce)
sum := sha256.Sum256([]byte(input))
// Check cookies again
cookies = crs.client.Jar.Cookies(cookieURL)
for _, c := range cookies {
log.Printf("🍪 [After Redirect %d] %s=%s", redirectCount, c.Name, trimLong(c.Value, 10))
if c.Name == "xf_user" {
hasXfUser = true
// Check leading zero bits
ok := true
for i := 0; i < requiredBytes; i++ {
if sum[i] != 0 {
ok = false
break
}
}
if hasXfUser {
log.Println("✅ xf_user cookie acquired after redirect follow")
break
}
}
// Step 7: Secondary check — trigger /account/ to issue xf_user if still missing
// CRITICAL: For rooms that don't require auth to view, we need to force
// session validation by accessing an authenticated endpoint
if !hasXfUser {
log.Println("🧭 Performing secondary authenticated fetch to /account/ to trigger xf_user...")
time.Sleep(2 * time.Second) // Increased wait time for session propagation
accountReq, _ := http.NewRequest("GET", baseURL+"/account/", nil)
accountReq.Header.Set("User-Agent", randomUserAgent())
accountReq.Header.Set("Referer", loginPost)
accountReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
accountReq.Header.Set("Accept-Language", "en-US,en;q=0.5")
accountReq.Header.Set("Connection", "keep-alive")
accountReq.Header.Set("Upgrade-Insecure-Requests", "1")
accountResp, accErr := crs.client.Do(accountReq)
if accErr != nil {
log.Printf("⚠️ Account fetch failed: %v", accErr)
} else {
io.Copy(io.Discard, accountResp.Body)
accountResp.Body.Close()
log.Printf("📩 [HTTP GET] %s/account/ -> %s", baseURL, accountResp.Status)
}
time.Sleep(2 * time.Second) // Additional wait after /account/ fetch
cookies = crs.client.Jar.Cookies(cookieURL)
for _, c := range cookies {
log.Printf("🍪 [After /account/] %s=%s", c.Name, trimLong(c.Value, 10))
if c.Name == "xf_user" {
hasXfUser = true
if ok && requiredBits > 0 && requiredBytes < len(sum) {
mask := byte(0xFF << (8 - requiredBits))
if sum[requiredBytes]&mask != 0 {
ok = false
}
}
if hasXfUser {
log.Println("✅ xf_user cookie acquired after /account/ fetch")
}
}
// Step 8: FINAL ATTEMPT - Try accessing the actual forum to force session validation
// This is critical for non-auth-required rooms like room 16
if !hasXfUser {
log.Println("🧭 Final attempt: accessing forum index to force session validation...")
time.Sleep(2 * time.Second)
forumReq, _ := http.NewRequest("GET", baseURL+"/forums/", nil)
forumReq.Header.Set("User-Agent", randomUserAgent())
forumReq.Header.Set("Referer", baseURL)
forumReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
forumReq.Header.Set("Accept-Language", "en-US,en;q=0.5")
forumReq.Header.Set("Connection", "keep-alive")
forumReq.Header.Set("Upgrade-Insecure-Requests", "1")
forumResp, forumErr := crs.client.Do(forumReq)
if forumErr != nil {
log.Printf("⚠️ Forum fetch failed: %v", forumErr)
} else {
bodyBytes, _ := io.ReadAll(forumResp.Body)
forumResp.Body.Close()
log.Printf("📩 [HTTP GET] %s/forums/ -> %s", baseURL, forumResp.Status)
// Check if we're actually logged in by looking for username in page
if strings.Contains(string(bodyBytes), crs.username) {
log.Println("✅ Confirmed logged in - username found in forum page")
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
}
}
time.Sleep(2 * time.Second)
cookies = crs.client.Jar.Cookies(cookieURL)
for _, c := range cookies {
log.Printf("🍪 [After /forums/] %s=%s", c.Name, trimLong(c.Value, 10))
if c.Name == "xf_user" {
hasXfUser = true
}
}
if hasXfUser {
log.Println("✅ xf_user cookie acquired after forum page fetch")
} else {
log.Println("⚠️ xf_user cookie still missing after all follow-ups")
// Don't return error - build cookie string with what we have
// The xf_session and xf_csrf might be enough for websocket auth
log.Println("⚠️ Proceeding with available cookies (xf_session + xf_csrf)")
return fmt.Sprintf("%d", nonce), elapsed, nil
}
}
// Build final cookie string
var parts []string
for _, c := range cookies {
if strings.HasPrefix(c.Name, "xf_") || c.Name == "sssg_clearance" {
parts = append(parts, fmt.Sprintf("%s=%s", c.Name, c.Value))
}
}
return strings.Join(parts, "; "), nil
return "", 0, fmt.Errorf("failed to solve PoW within %d attempts", maxAttempts)
}
//
// ---------- Utilities ----------
//
func extractCSRF(body string) string {
reList := []*regexp.Regexp{
patterns := []*regexp.Regexp{
regexp.MustCompile(`data-csrf=["']([^"']+)["']`),
regexp.MustCompile(`"csrf":"([^"]+)"`),
regexp.MustCompile(`XF\.config\.csrf\s*=\s*"([^"]+)"`),
}
for _, re := range reList {
if m := re.FindStringSubmatch(body); len(m) > 1 {
for _, re := range patterns {
if m := re.FindStringSubmatch(body); len(m) >= 2 {
return m[1]
}
}
return ""
}
func trimLong(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func randomUserAgent() string {
uas := []string{
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
}
return uas[rand.Intn(len(uas))]
}
//
// ---------- KiwiFlare Solver ----------
//
func (crs *CookieRefreshService) solveKiwiFlare(body []byte) (string, error) {
html := string(body)
challenge := regexp.MustCompile(`data-sssg-challenge=["']([^"']+)["']`).FindStringSubmatch(html)
difficultyStr := regexp.MustCompile(`data-sssg-difficulty=["'](\d+)["']`).FindStringSubmatch(html)
if len(challenge) < 2 || len(difficultyStr) < 2 {
return "", fmt.Errorf("missing challenge or difficulty")
}
salt := challenge[1]
diff, _ := strconv.Atoi(difficultyStr[1])
log.Printf("Solving KiwiFlare challenge (difficulty=%d)", diff)
nonce := rand.Int63()
start := time.Now()
for {
nonce++
input := fmt.Sprintf("%s%d", salt, nonce)
hash := sha256.Sum256([]byte(input))
fullBytes := diff / 8
remainder := diff % 8
valid := true
for i := 0; i < fullBytes; i++ {
if hash[i] != 0 {
valid = false
break
}
}
if valid && remainder > 0 && fullBytes < len(hash) {
mask := byte(0xFF << (8 - remainder))
if hash[fullBytes]&mask != 0 {
valid = false
}
}
if valid {
log.Printf("✅ Solved KiwiFlare PoW: salt=%s nonce=%d difficulty=%d", trimLong(salt, 16), nonce, diff)
elapsed := time.Since(start)
if elapsed < 2*time.Second {
time.Sleep(2*time.Second - elapsed)
}
break
func hasCookie(cookies []*http.Cookie, name string) bool {
for _, c := range cookies {
if c.Name == name {
return true
}
}
return false
}
submit := fmt.Sprintf("https://%s/.sssg/api/answer", crs.domain)
form := url.Values{"a": {salt}, "b": {fmt.Sprintf("%d", nonce)}}
req, _ := http.NewRequest("POST", submit, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", randomUserAgent())
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, "; ")
}
resp, err := crs.client.Do(req)
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 {
return "", err
if s.debug {
log.Printf("⚠️ /account/ request error: %v", err)
}
return false, ""
}
defer resp.Body.Close()
bodyResp, _ := io.ReadAll(resp.Body)
var parsed map[string]any
_ = json.Unmarshal(bodyResp, &parsed)
if auth, ok := parsed["auth"].(string); ok {
return auth, nil
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))
}
}
return "", fmt.Errorf("no auth field in KiwiFlare response")
}
// 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]
}

52
main.go
View File

@@ -1,11 +1,9 @@
package main
import (
"io"
"log"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
@@ -23,62 +21,20 @@ func main() {
}
}
// Load configuration
cfg, err := config.Load(envFile)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// Parse --debug flag from CLI (overrides .env)
debug := false
for _, a := range os.Args {
if a == "--debug" {
debug = true
}
}
cfg.Debug = debug
// ---- File logging (env-driven) ------------------------------------------
// Use LOG_FILE or BRIDGE_LOG_FILE (first non-empty wins)
logPath := os.Getenv("LOG_FILE")
if logPath == "" {
logPath = os.Getenv("BRIDGE_LOG_FILE")
}
if logPath != "" {
// Ensure parent dir exists
if dir := filepath.Dir(logPath); dir != "" && dir != "." {
_ = os.MkdirAll(dir, 0o755)
}
f, ferr := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if ferr != nil {
log.Printf("⚠️ Failed to open log file '%s' (%v). Continuing with stdout only.", logPath, ferr)
} else {
// Tee logs to both stdout and file
log.SetOutput(io.MultiWriter(os.Stdout, f))
// microseconds for tighter timing on auth/debug traces
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
log.Printf("📝 File logging enabled: %s", logPath)
}
} else {
// keep default stdout with microseconds for consistency
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
}
// -------------------------------------------------------------------------
log.Printf("Using .env file: %s", envFile)
log.Printf("Using Sneedchat room ID: %d", cfg.SneedchatRoomID)
log.Printf("Bridge username: %s", cfg.BridgeUsername)
if cfg.Debug {
log.Println("🪲 Debug mode enabled — full HTTP and cookie trace logging active")
}
// Cookie service (HTTP/1.1/2, KiwiFlare PoW, CSRF, deep debug)
cookieSvc, err := cookie.NewCookieRefreshService(cfg.BridgeUsername, cfg.BridgePassword, "kiwifarms.st", cfg.Debug)
// Cookie service
cookieSvc, err := cookie.NewCookieRefreshService(cfg.BridgeUsername, cfg.BridgePassword, "kiwifarms.st")
if err != nil {
log.Fatalf("Failed to create cookie service: %v", err)
}
cookieSvc.Start()
log.Println("⏳ Waiting for initial cookie...")
cookieSvc.WaitForCookie()
if cookieSvc.GetCurrentCookie() == "" {
log.Fatal("❌ Failed to obtain initial cookie, cannot start bridge")
@@ -87,7 +43,7 @@ func main() {
// Sneedchat client
sneedClient := sneed.NewClient(cfg.SneedchatRoomID, cookieSvc)
// Discord bridge (full parity features)
// Discord bridge
bridge, err := discord.NewBridge(cfg, sneedClient)
if err != nil {
log.Fatalf("Failed to create Discord bridge: %v", err)
@@ -97,7 +53,7 @@ func main() {
}
log.Println("🌉 Discord-Sneedchat Bridge started successfully")
// Auto cookie refresh every 4h (in addition to background loop inside service)
// Background 4h refresh loop
go func() {
t := time.NewTicker(4 * time.Hour)
defer t.Stop()