e4a313e626
The headless browser leaves the API stack. It becomes its own compose unit
(chrome/docker-compose.yml) deployed on the home machine and reached over the
tailnet, returning 471 MiB of working set to a 1974 MiB VPS that has no swap.
No fallback sidecar is left behind.
The backend needs no code change: BROWSER_WS_URL was already the only coupling,
so relocation is one environment variable. Its default is now empty rather than
a pinned Docker IP — an unreachable or unconfigured browser degrades exactly as
it always has, with plain-TLS libraries unaffected, kagane and novelfull logged
and skipped, and stored covers still served.
The browser unit publishes CDP on ${BROWSER_BIND_ADDR} with no default, because
CDP authenticates nothing and the home machine has a real LAN: an unset value
must fail the deploy rather than silently expose an endpoint that is remote code
execution for anything that reaches it. Resource limits are sized against the
measured 645 MiB untuned peak and the CI runner that already holds 1.2 GiB of
that box.
bookmark-api gains the default network. Dropping `browser` left it on `db`
alone, which is internal: true — that meant no published port and, worse, no
egress for the poller at all. Caught by bringing the stack up.
Docs: ADR-0006 for the topology, DEPLOY.md §7 for first-time setup of the
browser machine, REDEPLOY.md §8 for its independent update cadence, plus the
architecture diagrams, config tables and troubleshooting rows.
95 lines
3.0 KiB
Go
95 lines
3.0 KiB
Go
package latest
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestSmokeKaganeImage is the live proof that the cover proxy's fetch actually
|
|
// clears Cloudflare and returns image bytes. It needs the real browser unit
|
|
// with outbound network, so it runs only when SMOKE_BROWSER_WS_URL is set:
|
|
//
|
|
// cd chrome && BROWSER_BIND_ADDR=127.0.0.1 docker compose up -d --build
|
|
// SMOKE_BROWSER_WS_URL=ws://127.0.0.1:9222 go test -run TestSmokeKaganeImage ./internal/latest
|
|
//
|
|
// Not chromedp/headless-shell: its challenge never clears (see chrome/Dockerfile),
|
|
// so a red run there proves nothing about kagane.
|
|
func TestSmokeKaganeImage(t *testing.T) {
|
|
ws := os.Getenv("SMOKE_BROWSER_WS_URL")
|
|
if ws == "" {
|
|
t.Skip("SMOKE_BROWSER_WS_URL unset")
|
|
}
|
|
const imageID = "019fe11a-84c3-7fc3-a84b-88787374b617" // SP Baby's cover
|
|
|
|
// The same URL through a plain client is what the web UI's <img> gets.
|
|
// Asserting on it keeps the test honest about why the browser is needed.
|
|
req, err := http.NewRequest(http.MethodGet,
|
|
"https://kagane.to/api/v2/image/"+imageID+"/compressed", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res, err := (&http.Client{Timeout: 15 * time.Second}).Do(req); err == nil {
|
|
res.Body.Close()
|
|
if res.StatusCode == http.StatusOK {
|
|
t.Log("note: kagane answered a plain request 200 — the challenge is not up right now")
|
|
}
|
|
}
|
|
|
|
f, err := NewBrowserFetcher(ws)
|
|
if err != nil {
|
|
t.Fatalf("NewBrowserFetcher: %v", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
|
defer cancel()
|
|
body, contentType, err := f.Image(ctx, imageID)
|
|
if err != nil {
|
|
t.Fatalf("Image: %v", err)
|
|
}
|
|
if len(body) < 1000 {
|
|
t.Fatalf("body is %d bytes, want a real image", len(body))
|
|
}
|
|
if contentType != "image/webp" {
|
|
t.Fatalf("content type = %q, want image/webp", contentType)
|
|
}
|
|
// WebP files start with "RIFF....WEBP".
|
|
if string(body[:4]) != "RIFF" || string(body[8:12]) != "WEBP" {
|
|
t.Fatalf("body is not a WebP: % x", body[:12])
|
|
}
|
|
t.Logf("fetched %d bytes of %s", len(body), contentType)
|
|
|
|
if _, _, err := f.Image(ctx, "not-a-uuid"); err == nil {
|
|
t.Fatal("Image accepted a non-uuid id")
|
|
}
|
|
}
|
|
|
|
// Control for the test above: the poller's own kagane path, same sidecar. If
|
|
// this fails too, the sidecar is not clearing the challenge at all and the
|
|
// image result says nothing about Image itself.
|
|
func TestSmokeKaganeGet(t *testing.T) {
|
|
ws := os.Getenv("SMOKE_BROWSER_WS_URL")
|
|
if ws == "" {
|
|
t.Skip("SMOKE_BROWSER_WS_URL unset")
|
|
}
|
|
f, err := NewBrowserFetcher(ws)
|
|
if err != nil {
|
|
t.Fatalf("NewBrowserFetcher: %v", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
|
defer cancel()
|
|
body, status, err := f.Get(ctx, "https://kagane.to/series/019fe11a-8670-7cf3-8343-0b02057d3787")
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
t.Logf("status=%d bytes=%d head=%.80q", status, len(body), body)
|
|
if status != 200 {
|
|
t.Fatalf("status = %d, want 200 — the sidecar is not clearing the challenge", status)
|
|
}
|
|
}
|