Fetcher.Allowed and Key, the rendered comparison and the Chrome request guard accept an address only on the site's effective port (explicit, else 443 for https and 80 for http), so page code cannot make headless Chrome reach other services on the site host's address and a link to another port is not reported as a page of the site.
278 lines
9.1 KiB
Go
278 lines
9.1 KiB
Go
package crawl
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/fetchguard"
|
|
"github.com/chromedp/cdproto/browser"
|
|
"github.com/chromedp/cdproto/cdp"
|
|
"github.com/chromedp/cdproto/fetch"
|
|
"github.com/chromedp/cdproto/network"
|
|
"github.com/chromedp/chromedp"
|
|
)
|
|
|
|
// chromeRenderer renders pages in an operator-installed headless Chrome
|
|
// or Chromium through github.com/chromedp/chromedp (D-70, P33-55), held to
|
|
// the crawler's fetchguard limits:
|
|
//
|
|
// - Chrome resolves only the site host and its www twin, pinned through
|
|
// --host-resolver-rules to the addresses resolveHosts checked with
|
|
// fetchguard.IsPrivateAddr; every other host resolves to NOTFOUND;
|
|
// - every request is paused through the Fetch domain and continued only
|
|
// when allowRequest passes (https, http only in trusted mode, to an
|
|
// allowed host on the site's port, or data: and blob:); the others fail with
|
|
// BlockedByClient, so third-party scripts are never loaded;
|
|
// - a fresh temporary profile per crawl, removed by Close; no cookies,
|
|
// credentials or extensions; downloads denied;
|
|
// - one tab at a time (the crawler serialises Render), at most
|
|
// TabOpenTimeout (5 s) to open the tab and RenderTimeout (20 s) for the
|
|
// page, both under one deadline, so a hung Chrome never holds the
|
|
// crawl's render lock longer; the DOM capped at 5 MiB; Close stops
|
|
// Chrome.
|
|
//
|
|
// chromedp's default allocator flags are used as they are: nothing is
|
|
// added that weakens Chrome's sandbox.
|
|
type chromeRenderer struct {
|
|
allowed []string
|
|
port string
|
|
trusted bool
|
|
profile string
|
|
|
|
allocCancel context.CancelFunc
|
|
browserCtx context.Context
|
|
browserCancel context.CancelFunc
|
|
|
|
// newTab and startTab open a tab: chromedp.NewContext and chromedp.Do
|
|
// unless a test replaces them.
|
|
newTab func(parent context.Context) (context.Context, context.CancelFunc)
|
|
startTab func(tab context.Context) error
|
|
|
|
mu sync.Mutex
|
|
blocked []string
|
|
closed bool
|
|
}
|
|
|
|
// TabOpenTimeout bounds opening a Chrome tab (P33-60, L3 WR-06); Render
|
|
// bounds opening the tab and rendering the page with one deadline of
|
|
// TabOpenTimeout plus RenderTimeout.
|
|
var TabOpenTimeout = 5 * time.Second
|
|
|
|
// seconds writes d as whole seconds ("5 s") when it is one, else as
|
|
// time.Duration does.
|
|
func seconds(d time.Duration) string {
|
|
if d%time.Second == 0 {
|
|
return strconv.Itoa(int(d/time.Second)) + " s"
|
|
}
|
|
return d.String()
|
|
}
|
|
|
|
// newChromeRenderer starts Chrome at path for the hosts and their checked
|
|
// addresses, on the site's effective port.
|
|
func newChromeRenderer(path string, hosts map[string][]netip.Addr, port string, trusted bool, ua string) (*chromeRenderer, error) {
|
|
profile, err := os.MkdirTemp("", "summercms-seo-chrome-")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the Chrome profile cannot be created: %w", err)
|
|
}
|
|
r := &chromeRenderer{port: port, trusted: trusted, profile: profile}
|
|
for h := range hosts {
|
|
r.allowed = append(r.allowed, h)
|
|
}
|
|
sort.Strings(r.allowed)
|
|
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
|
chromedp.ExecPath(path),
|
|
chromedp.UserDataDir(profile),
|
|
chromedp.Flag("host-resolver-rules", resolverRules(hosts)),
|
|
chromedp.UserAgent(ua),
|
|
)
|
|
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
|
r.allocCancel = allocCancel
|
|
r.browserCtx, r.browserCancel = chromedp.NewContext(allocCtx)
|
|
// The first Run starts the browser; it runs without a timeout, since a
|
|
// timeout on this context would stop the whole browser.
|
|
if err := chromedp.Do(r.browserCtx); err != nil {
|
|
_ = r.Close()
|
|
return nil, fmt.Errorf("Chrome did not start: %w", err)
|
|
}
|
|
if _, err := chromedp.CallBrowser(r.browserCtx, browser.SetDownloadBehavior, browser.SetDownloadBehaviorParams{Behavior: browser.SetDownloadBehaviorBehaviorDeny}); err != nil {
|
|
_ = r.Close()
|
|
return nil, fmt.Errorf("Chrome downloads cannot be denied: %w", err)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// Render loads pageURL in a new tab and answers the DOM after JavaScript
|
|
// ran (the outer HTML of the document, capped at 5 MiB).
|
|
func (r *chromeRenderer) Render(ctx context.Context, pageURL string) (string, error) {
|
|
r.mu.Lock()
|
|
closed := r.closed
|
|
r.mu.Unlock()
|
|
if closed {
|
|
return "", fmt.Errorf("Chrome is closed")
|
|
}
|
|
if !allowRequest(pageURL, r.allowed, r.port, r.trusted) {
|
|
return "", fmt.Errorf("the address %s is outside the site", pageURL)
|
|
}
|
|
// One deadline bounds opening the tab and rendering the page; the tab
|
|
// ends with it or with the crawl's context (a cancelled crawl).
|
|
rctx, rcancel := context.WithTimeout(ctx, TabOpenTimeout+RenderTimeout)
|
|
defer rcancel()
|
|
newTab, startTab := r.newTab, r.startTab
|
|
if newTab == nil {
|
|
newTab = func(parent context.Context) (context.Context, context.CancelFunc) { return chromedp.NewContext(parent) }
|
|
}
|
|
if startTab == nil {
|
|
startTab = func(tab context.Context) error { return chromedp.Do(tab) }
|
|
}
|
|
tab, closeTab := newTab(r.browserCtx)
|
|
defer closeTab()
|
|
stop := context.AfterFunc(rctx, closeTab)
|
|
defer stop()
|
|
opened := make(chan error, 1)
|
|
go func() { opened <- startTab(tab) }()
|
|
openTimer := time.NewTimer(TabOpenTimeout)
|
|
defer openTimer.Stop()
|
|
select {
|
|
case err := <-opened:
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return "", ctx.Err()
|
|
}
|
|
return "", fmt.Errorf("Chrome cannot open a tab: %w", err)
|
|
}
|
|
case <-openTimer.C:
|
|
closeTab()
|
|
return "", fmt.Errorf("Chrome did not open a tab within %s", seconds(TabOpenTimeout))
|
|
case <-rctx.Done():
|
|
closeTab()
|
|
if ctx.Err() != nil {
|
|
return "", ctx.Err()
|
|
}
|
|
return "", fmt.Errorf("Chrome did not open a tab within %s", seconds(TabOpenTimeout))
|
|
}
|
|
page, cancel := context.WithTimeout(tab, RenderTimeout)
|
|
defer cancel()
|
|
target := chromedp.FromContext(tab).Target
|
|
paused := chromedp.Events(page, fetch.RequestPaused)
|
|
if _, err := chromedp.Call(page, fetch.Enable, fetch.EnableParams{Patterns: []*fetch.RequestPattern{{URLPattern: "*"}}}); err != nil {
|
|
return "", fmt.Errorf("Chrome cannot intercept requests: %w", err)
|
|
}
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for ev, err := range paused {
|
|
if err != nil {
|
|
return
|
|
}
|
|
r.decide(page, target, ev)
|
|
}
|
|
}()
|
|
html, err := chromedp.Run(page, func(ctx context.Context, t *chromedp.Target) (string, error) {
|
|
if _, err := chromedp.Navigate(pageURL)(ctx, t); err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := chromedp.WaitReady(chromedp.CSS("body"))(ctx, t); err != nil {
|
|
return "", err
|
|
}
|
|
return chromedp.OuterHTML(chromedp.CSS("html"))(ctx, t)
|
|
})
|
|
cancel()
|
|
closeTab()
|
|
wg.Wait()
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return "", ctx.Err()
|
|
}
|
|
return "", fmt.Errorf("Chrome could not render %s: %w", pageURL, err)
|
|
}
|
|
if len(html) > MaxDOM {
|
|
html = truncateUTF8(html, MaxDOM)
|
|
}
|
|
return html, nil
|
|
}
|
|
|
|
// decide continues a paused request that allowRequest passes and fails
|
|
// every other one with BlockedByClient, keeping its address.
|
|
func (r *chromeRenderer) decide(ctx context.Context, target cdp.Session, ev fetch.EventRequestPaused) {
|
|
u := ""
|
|
if ev.Request != nil {
|
|
u = ev.Request.URL
|
|
}
|
|
if allowRequest(u, r.allowed, r.port, r.trusted) {
|
|
_, _ = cdp.Call(ctx, target, fetch.ContinueRequest, fetch.ContinueRequestParams{RequestID: ev.RequestID})
|
|
return
|
|
}
|
|
r.mu.Lock()
|
|
r.blocked = append(r.blocked, u)
|
|
r.mu.Unlock()
|
|
_, _ = cdp.Call(ctx, target, fetch.FailRequest, fetch.FailRequestParams{RequestID: ev.RequestID, ErrorReason: network.ErrorReasonBlockedByClient})
|
|
}
|
|
|
|
// Blocked lists the addresses the browser was kept from loading.
|
|
func (r *chromeRenderer) Blocked() []string {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return append([]string(nil), r.blocked...)
|
|
}
|
|
|
|
// ProfileDir is the temporary profile of this crawl's browser.
|
|
func (r *chromeRenderer) ProfileDir() string { return r.profile }
|
|
|
|
// Close stops Chrome and removes the temporary profile.
|
|
func (r *chromeRenderer) Close() error {
|
|
r.mu.Lock()
|
|
if r.closed {
|
|
r.mu.Unlock()
|
|
return nil
|
|
}
|
|
r.closed = true
|
|
r.mu.Unlock()
|
|
if r.browserCancel != nil {
|
|
r.browserCancel()
|
|
}
|
|
if r.allocCancel != nil {
|
|
r.allocCancel()
|
|
}
|
|
return os.RemoveAll(r.profile)
|
|
}
|
|
|
|
// resolveHosts resolves the site host and its www twin. A private or
|
|
// reserved address (fetchguard.IsPrivateAddr) refuses outside trusted
|
|
// mode; a twin that does not resolve is left out.
|
|
func resolveHosts(ctx context.Context, hosts []string, trusted bool) (map[string][]netip.Addr, error) {
|
|
out := map[string][]netip.Addr{}
|
|
for i, host := range hosts {
|
|
if addr, err := netip.ParseAddr(strings.Trim(host, "[]")); err == nil {
|
|
if !trusted && fetchguard.IsPrivateAddr(addr) {
|
|
return nil, fmt.Errorf("the site host %s is a private address", addr)
|
|
}
|
|
out[host] = []netip.Addr{addr.Unmap()}
|
|
continue
|
|
}
|
|
addrs, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
|
|
if err != nil || len(addrs) == 0 {
|
|
if i == 0 {
|
|
return nil, fmt.Errorf("the site host %s does not resolve", host)
|
|
}
|
|
continue
|
|
}
|
|
for _, a := range addrs {
|
|
a = a.Unmap()
|
|
if !trusted && fetchguard.IsPrivateAddr(a) {
|
|
return nil, fmt.Errorf("the site host %s resolves to the private address %s", host, a)
|
|
}
|
|
out[host] = append(out[host], a)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|