dab6c11ea8
kage launched Chrome with --no-sandbox unconditionally, which turns off the browser's main security boundary for every run, including ordinary desktop use where the sandbox works fine. Since kage renders pages from the open web, a renderer exploit could then reach the host. Reported in #10. Keep the sandbox on by default and drop it only where it genuinely cannot initialize: inside a container, or when running as root (Chrome refuses to start a sandbox as root). Containers are detected from IN_DOCKER or the /.dockerenv marker, and there kage also sets --disable-dev-shm-usage because the default 64 MB /dev/shm is too small for the renderer on large pages. Whenever the sandbox is dropped kage says so on stderr, so it is never silent. Thanks to Dimitrios Prasakis for the report and to the commenter on Hacker News who suggested the IN_DOCKER opt-in.
433 lines
11 KiB
Go
433 lines
11 KiB
Go
package clone
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/tamnd/kage/asset"
|
|
"github.com/tamnd/kage/browser"
|
|
"github.com/tamnd/kage/robots"
|
|
"github.com/tamnd/kage/sanitize"
|
|
"github.com/tamnd/kage/urlx"
|
|
"golang.org/x/net/html"
|
|
)
|
|
|
|
// Logf is an optional sink for human-readable progress lines.
|
|
type Logf func(format string, args ...any)
|
|
|
|
// Cloner runs one clone. Build it with New, then call Run.
|
|
type Cloner struct {
|
|
cfg Config
|
|
seed *url.URL
|
|
seedHost string
|
|
outRoot string
|
|
statePth string
|
|
|
|
pool *browser.Pool
|
|
dl *asset.Downloader
|
|
httpC *http.Client
|
|
robots *robots.Matcher
|
|
front *frontier
|
|
stats stats
|
|
logf Logf
|
|
|
|
mu sync.Mutex
|
|
seenAssets map[string]bool
|
|
enqueued int // pages offered to the queue
|
|
wg sync.WaitGroup
|
|
pageJobs chan pageItem
|
|
assetJobs chan assetItem
|
|
}
|
|
|
|
type pageItem struct {
|
|
u *url.URL
|
|
depth int
|
|
}
|
|
|
|
type assetItem struct {
|
|
u *url.URL
|
|
referer string
|
|
}
|
|
|
|
// New builds a Cloner for seed under cfg. It does not touch the network until
|
|
// Run is called.
|
|
func New(seed *url.URL, cfg Config, logf Logf) *Cloner {
|
|
if logf == nil {
|
|
logf = func(string, ...any) {}
|
|
}
|
|
host := seed.Hostname()
|
|
outRoot := cfg.HostDir(host)
|
|
return &Cloner{
|
|
cfg: cfg,
|
|
seed: seed,
|
|
seedHost: host,
|
|
outRoot: outRoot,
|
|
statePth: filepath.Join(outRoot, cfg.Reserved, "state.json"),
|
|
dl: asset.NewDownloader(cfg.UserAgent, cfg.Timeout, cfg.MaxAssetBytes),
|
|
httpC: &http.Client{Timeout: cfg.Timeout},
|
|
robots: robots.AllowAll(),
|
|
front: newFrontier(),
|
|
logf: logf,
|
|
seenAssets: map[string]bool{},
|
|
pageJobs: make(chan pageItem),
|
|
assetJobs: make(chan assetItem),
|
|
}
|
|
}
|
|
|
|
// Snapshot returns the current progress, for a CLI ticker.
|
|
func (c *Cloner) Snapshot() Progress { return c.stats.snapshot() }
|
|
|
|
// pageKey is the dedup identity for a page: its output file. Two URLs that would
|
|
// write to the same file (http vs https, "/" vs "/index.html", a trailing
|
|
// slash) are the same page and must be crawled only once.
|
|
func (c *Cloner) pageKey(u *url.URL) string {
|
|
return urlx.LocalPath(c.seedHost, u, urlx.Page, c.cfg.Reserved)
|
|
}
|
|
|
|
// assetKey is the dedup identity for an asset: its output file, so the same
|
|
// bytes referenced over http and https download once.
|
|
func (c *Cloner) assetKey(u *url.URL) string {
|
|
return urlx.LocalPath(c.seedHost, u, urlx.Asset, c.cfg.Reserved)
|
|
}
|
|
|
|
// Run executes the clone until the frontier drains, MaxPages is hit, or ctx is
|
|
// cancelled (which flushes the resume state). It returns the final Result.
|
|
func (c *Cloner) Run(ctx context.Context) (Result, error) {
|
|
if c.cfg.Force {
|
|
_ = os.RemoveAll(c.outRoot)
|
|
}
|
|
// Refresh re-renders every page in place, so it deliberately skips loading
|
|
// the prior visited set; everything else resumes from where it left off.
|
|
if c.cfg.Resume && !c.cfg.Refresh {
|
|
if err := c.front.load(c.statePth); err != nil {
|
|
c.logf("resume: could not load state: %v", err)
|
|
} else if n := c.front.visitedCount(); n > 0 {
|
|
c.logf("resume: %d pages already done", n)
|
|
}
|
|
}
|
|
|
|
c.pool = browser.New(browser.Options{
|
|
Headless: c.cfg.Headless,
|
|
Workers: c.cfg.BrowserPages,
|
|
Settle: c.cfg.Settle,
|
|
RenderTimeout: c.cfg.RenderTimeout,
|
|
Scroll: c.cfg.Scroll,
|
|
ChromeBin: c.cfg.ChromeBin,
|
|
ControlURL: c.cfg.ControlURL,
|
|
})
|
|
defer func() { _ = c.pool.Close() }()
|
|
|
|
c.loadRobots(ctx)
|
|
|
|
// Start workers.
|
|
var workers sync.WaitGroup
|
|
for range max1(c.cfg.Workers) {
|
|
workers.Go(func() {
|
|
for j := range c.pageJobs {
|
|
c.processPage(ctx, j)
|
|
c.wg.Done()
|
|
}
|
|
})
|
|
}
|
|
for range max1(c.cfg.AssetWorkers) {
|
|
workers.Go(func() {
|
|
for j := range c.assetJobs {
|
|
c.processAsset(ctx, j)
|
|
c.wg.Done()
|
|
}
|
|
})
|
|
}
|
|
|
|
// Seed.
|
|
c.enqueuePage(ctx, c.seed, 0)
|
|
if c.cfg.FollowSitemap {
|
|
c.seedSitemaps(ctx)
|
|
}
|
|
|
|
// Close the job channels once every outstanding item is processed.
|
|
go func() {
|
|
c.wg.Wait()
|
|
close(c.pageJobs)
|
|
close(c.assetJobs)
|
|
}()
|
|
workers.Wait()
|
|
|
|
if c.cfg.Persist {
|
|
if err := c.front.save(c.statePth); err != nil {
|
|
c.logf("could not save resume state: %v", err)
|
|
}
|
|
}
|
|
|
|
res := Result{Progress: c.stats.snapshot(), OutDir: c.outRoot, Failures: c.stats.recordedFailures()}
|
|
if ctx.Err() != nil {
|
|
return res, ctx.Err()
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// loadRobots fetches and parses robots.txt (unless disabled) and seeds the
|
|
// sitemap list it advertises.
|
|
func (c *Cloner) loadRobots(ctx context.Context) {
|
|
if !c.cfg.RespectRobots {
|
|
return
|
|
}
|
|
robotsURL := c.seed.Scheme + "://" + c.seed.Host + "/robots.txt"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, robotsURL, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
req.Header.Set("User-Agent", c.cfg.UserAgent)
|
|
resp, err := c.httpC.Do(req)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return
|
|
}
|
|
c.robots = robots.Parse(string(data), "kage")
|
|
}
|
|
|
|
// seedSitemaps adds in-scope sitemap URLs (from robots and the default path) to
|
|
// the frontier.
|
|
func (c *Cloner) seedSitemaps(ctx context.Context) {
|
|
seeds := append([]string{}, c.robots.Sitemaps...)
|
|
seeds = append(seeds, c.seed.Scheme+"://"+c.seed.Host+"/sitemap.xml")
|
|
locs := collectSitemaps(ctx, c.httpC, c.cfg.UserAgent, seeds)
|
|
added := 0
|
|
for _, loc := range locs {
|
|
u, err := urlx.Normalize(c.seed, loc)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if urlx.InScope(c.seed, u, c.cfg.scope()) && urlx.LikelyPage(u) {
|
|
if c.enqueuePage(ctx, u, 1) {
|
|
added++
|
|
}
|
|
}
|
|
}
|
|
if added > 0 {
|
|
c.logf("sitemap: seeded %d URLs", added)
|
|
}
|
|
}
|
|
|
|
// processPage renders one page, rewrites its references to local paths, strips
|
|
// every script, and writes the result.
|
|
func (c *Cloner) processPage(ctx context.Context, j pageItem) {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
key := c.pageKey(j.u)
|
|
if c.cfg.RespectRobots && !c.robots.Allowed(j.u.Path) {
|
|
c.stats.skipped.Add(1)
|
|
return
|
|
}
|
|
|
|
res, err := c.pool.Render(ctx, j.u.String())
|
|
if err != nil {
|
|
c.failPage(j.u.String(), fmt.Errorf("render: %w", err))
|
|
return
|
|
}
|
|
|
|
root, err := html.Parse(strings.NewReader(res.HTML))
|
|
if err != nil {
|
|
c.failPage(j.u.String(), fmt.Errorf("parse: %w", err))
|
|
return
|
|
}
|
|
|
|
localFile := urlx.LocalPath(c.seedHost, j.u, urlx.Page, c.cfg.Reserved)
|
|
fileDir := urlx.Dir(localFile)
|
|
|
|
sink := func(u *url.URL, kind urlx.Kind) string {
|
|
switch kind {
|
|
case urlx.Page:
|
|
if urlx.InScope(c.seed, u, c.cfg.scope()) {
|
|
c.enqueuePage(ctx, u, j.depth+1)
|
|
local := urlx.LocalPath(c.seedHost, u, urlx.Page, c.cfg.Reserved)
|
|
return urlx.Rel(fileDir, local)
|
|
}
|
|
return u.String() // external page link stays on the live web
|
|
default: // Asset
|
|
c.enqueueAsset(ctx, u, j.u.String())
|
|
local := urlx.LocalPath(c.seedHost, u, urlx.Asset, c.cfg.Reserved)
|
|
return urlx.Rel(fileDir, local)
|
|
}
|
|
}
|
|
|
|
asset.RewriteHTML(root, j.u, sink)
|
|
sanitize.CleanTree(root, sanitize.Options{
|
|
KeepNoscript: c.cfg.KeepNoscript,
|
|
Banner: "cloned by kage from " + j.u.String(),
|
|
})
|
|
|
|
var buf strings.Builder
|
|
if err := html.Render(&buf, root); err != nil {
|
|
c.failPage(j.u.String(), fmt.Errorf("render html: %w", err))
|
|
return
|
|
}
|
|
if err := c.writeFile(localFile, []byte(buf.String())); err != nil {
|
|
c.failPage(j.u.String(), fmt.Errorf("write %s: %w", localFile, err))
|
|
return
|
|
}
|
|
c.front.markVisited(key)
|
|
c.stats.pages.Add(1)
|
|
}
|
|
|
|
// processAsset downloads one asset, rewriting CSS references on the way, and
|
|
// writes it to its deterministic local path.
|
|
func (c *Cloner) processAsset(ctx context.Context, j assetItem) {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
res, err := c.dl.Get(ctx, j.u, j.referer)
|
|
if err != nil {
|
|
c.failAsset(j.u.String(), j.referer, err)
|
|
return
|
|
}
|
|
|
|
localFile := urlx.LocalPath(c.seedHost, j.u, urlx.Asset, c.cfg.Reserved)
|
|
body := res.Body
|
|
if res.IsCSS {
|
|
fileDir := urlx.Dir(localFile)
|
|
cssSink := func(u *url.URL, _ urlx.Kind) string {
|
|
c.enqueueAsset(ctx, u, j.u.String())
|
|
local := urlx.LocalPath(c.seedHost, u, urlx.Asset, c.cfg.Reserved)
|
|
return urlx.Rel(fileDir, local)
|
|
}
|
|
body = asset.RewriteCSS(body, j.u, cssSink)
|
|
}
|
|
if err := c.writeFile(localFile, body); err != nil {
|
|
c.failAsset(j.u.String(), j.referer, fmt.Errorf("write %s: %w", localFile, err))
|
|
return
|
|
}
|
|
c.stats.assets.Add(1)
|
|
}
|
|
|
|
// failAsset records and logs a failed asset, naming the page that referenced it
|
|
// so a 403 or 404 is traceable back to where it came from. The reason is
|
|
// classified (HTTP status, timeout, or other) for a readable line.
|
|
func (c *Cloner) failAsset(u, referer string, err error) {
|
|
c.stats.assetErrors.Add(1)
|
|
reason := classifyError(err)
|
|
c.stats.recordFailure(Failure{Kind: "asset", URL: u, Referer: referer, Reason: reason})
|
|
if referer != "" {
|
|
c.logf("asset error: %s\n %s\n referenced by %s", reason, u, referer)
|
|
} else {
|
|
c.logf("asset error: %s\n %s", reason, u)
|
|
}
|
|
}
|
|
|
|
// failPage records and logs a failed page.
|
|
func (c *Cloner) failPage(u string, err error) {
|
|
c.stats.pageErrors.Add(1)
|
|
reason := classifyError(err)
|
|
c.stats.recordFailure(Failure{Kind: "page", URL: u, Reason: reason})
|
|
c.logf("page error: %s\n %s", reason, u)
|
|
}
|
|
|
|
// classifyError turns an error into a short, human-readable reason for the log
|
|
// and the final report: an HTTP status with its name, a timeout, a cancellation,
|
|
// or the underlying message otherwise.
|
|
func classifyError(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
var se *asset.StatusError
|
|
if errors.As(err, &se) {
|
|
return se.Error()
|
|
}
|
|
switch {
|
|
case errors.Is(err, context.DeadlineExceeded):
|
|
return "timed out"
|
|
case errors.Is(err, context.Canceled):
|
|
return "cancelled"
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
// enqueuePage offers a page URL to the frontier, honouring the visited set, the
|
|
// depth cap, and the page budget. It reports whether the page was newly queued.
|
|
func (c *Cloner) enqueuePage(ctx context.Context, u *url.URL, depth int) bool {
|
|
if c.cfg.MaxDepth > 0 && depth > c.cfg.MaxDepth {
|
|
return false
|
|
}
|
|
key := c.pageKey(u)
|
|
if c.front.isVisited(key) {
|
|
return false
|
|
}
|
|
if !c.front.offer(key) {
|
|
return false
|
|
}
|
|
c.mu.Lock()
|
|
if c.cfg.MaxPages > 0 && c.enqueued >= c.cfg.MaxPages {
|
|
c.mu.Unlock()
|
|
return false
|
|
}
|
|
c.enqueued++
|
|
c.mu.Unlock()
|
|
|
|
c.wg.Add(1)
|
|
go func() {
|
|
select {
|
|
case c.pageJobs <- pageItem{u: u, depth: depth}:
|
|
case <-ctx.Done():
|
|
c.wg.Done()
|
|
}
|
|
}()
|
|
return true
|
|
}
|
|
|
|
// enqueueAsset offers an asset URL for download, deduping by canonical URL.
|
|
func (c *Cloner) enqueueAsset(ctx context.Context, u *url.URL, referer string) {
|
|
key := c.assetKey(u)
|
|
c.mu.Lock()
|
|
if c.seenAssets[key] {
|
|
c.mu.Unlock()
|
|
return
|
|
}
|
|
c.seenAssets[key] = true
|
|
c.mu.Unlock()
|
|
|
|
c.wg.Add(1)
|
|
go func() {
|
|
select {
|
|
case c.assetJobs <- assetItem{u: u, referer: referer}:
|
|
case <-ctx.Done():
|
|
c.wg.Done()
|
|
}
|
|
}()
|
|
}
|
|
|
|
// writeFile writes data to a slash path relative to the mirror root, creating
|
|
// parent directories. The path is cleaned so it can never escape the root.
|
|
func (c *Cloner) writeFile(relSlash string, data []byte) error {
|
|
rel := filepath.FromSlash(relSlash)
|
|
full := filepath.Join(c.outRoot, rel)
|
|
if !strings.HasPrefix(full, filepath.Clean(c.outRoot)+string(os.PathSeparator)) && full != c.outRoot {
|
|
return fmt.Errorf("refusing to write outside mirror root: %s", relSlash)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(full, data, 0o644)
|
|
}
|
|
|
|
func max1(n int) int {
|
|
if n < 1 {
|
|
return 1
|
|
}
|
|
return n
|
|
}
|