278 lines
12 KiB
Go
278 lines
12 KiB
Go
package main
|
|
|
|
// contextpull.go — ctx_extract fetches the SOURCE instead of being handed it.
|
|
//
|
|
// WHY THIS EXISTS. contextextract.go's own header wrote down the wall and the
|
|
// reason it could not be climbed yet: a management job is ONE WebSocket frame
|
|
// with its payload base64 in args, the host's 16 MiB WebSocket frame ceiling is reached at
|
|
// just under 12 MiB of plaintext (measured, ctxcap_measure_test.go), and so
|
|
// ctxExtractMaxBytes caps a source at 8 MiB. Fine for the text PDFs that
|
|
// shipped; not fine for OCR, where a scanned PDF routinely exceeds it. The
|
|
// blocker named there was auth: the host route that serves a context row's
|
|
// bytes requires an APPLICATION token, which this box
|
|
// does not have. VERIFIED, and still true: the host still carries that
|
|
// dependency, and its one internal-auth bypass is allow-listed to a single
|
|
// unrelated route, so it is not reachable from here either.
|
|
//
|
|
// So the host grew a route this box CAN authenticate to, with the credential it
|
|
// already holds: GET /api/network/bastions/context-source, bearer-authed
|
|
// exactly like /bastions/heartbeat and /bastions/git-credential.
|
|
//
|
|
// ── WHAT STOPS THIS BOX READING THE USER'S OTHER FILES ──────────────────────
|
|
//
|
|
// The request carries no context id. It carries a TICKET: 256 opaque bits the
|
|
// host minted when it decided to extract one specific row on this specific
|
|
// machine, bound to this bastion's token id, single-use, and good for two
|
|
// minutes. There is no id field to change, so "fetch a different document" is
|
|
// not a request that can be expressed — it does not have to be refused. A
|
|
// second GET with the same ticket gets 404 because the host popped it; a ticket
|
|
// minted for another machine gets 404 without being consumed, so one box cannot
|
|
// even burn another's.
|
|
//
|
|
// ── WHY THERE IS NO URL ON THE WIRE, unlike backup_fetch ────────────────────
|
|
//
|
|
// backup_fetch takes a URL from the host and therefore needs an SSRF wall
|
|
// (validateBackupURL): the host picks the address and this process sits INSIDE
|
|
// the user's network. Here the host picks nothing. This box builds the URL
|
|
// itself from its OWN configured host name and dials it through the SAME mTLS
|
|
// client that carries the heartbeat — pinned by run.go's transport to the
|
|
// host's tunnel address, with the TLS ServerName pinned to the host name. A
|
|
// hostile host cannot point this at the user's router, because it never gets to
|
|
// name a destination at all. That is strictly stronger than the check
|
|
// backup_fetch has to make, and it is the reason this file has no allow/deny
|
|
// list of addresses in it.
|
|
//
|
|
// backup_fetch's other refusals ARE carried over, because they were written for
|
|
// this shape of op: a declared maximum so a hostile or buggy host cannot fill
|
|
// the user's disk, verification BEFORE the bytes are used, and streaming at
|
|
// constant memory rather than buffering the document whole.
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ctxPullMaxBytes is THIS BOX'S wall on one pulled source, independent of
|
|
// whatever the host declares. The host proposes a max_bytes; the smaller of the
|
|
// two wins, so a host that asks for more gets this.
|
|
//
|
|
// 64 MiB, and the reasoning is the same on both sides of the wire (the host
|
|
// holds the same ceiling):
|
|
//
|
|
// - it cannot bind on anything the platform stores today — the host's upload
|
|
// cap is 25 MiB — so what a
|
|
// user may upload stays a product decision and this stays a guard;
|
|
// - it clears the case the pull path was built for: a scanned PDF at 150-300
|
|
// dpi runs tens of MB, which is 8x past the inline cap and well inside this;
|
|
// - production document-extraction services bracket it. AWS Textract takes
|
|
// 10 MB synchronously and 500 MB asynchronously; Google Document AI takes
|
|
// 20 MB online and 1 GB in batch; Azure AI Document Intelligence takes
|
|
// 500 MB on its paid tier. Every one of them draws a low line for "one
|
|
// request, answer now" and a high line for "queued job". This lane is the
|
|
// first kind, so it sits at the low end of that range with enough room for
|
|
// the scanned-PDF case the 10-20 MB lines would refuse.
|
|
//
|
|
// It is a ceiling on the PLATFORM's reach into this machine's disk and network,
|
|
// never a throttle on the user: nothing a user does here goes through it.
|
|
const ctxPullMaxBytes = 64 << 20
|
|
|
|
// ── the deadline, which the HOST grants ─────────────────────────────────────
|
|
//
|
|
// THE ORDERING IS THE POINT: this box must give up before the host does. If the
|
|
// host quits first it reports "did not answer" about a machine that is
|
|
// downloading happily, then discards the text when it finally arrives — the
|
|
// work done, the bytes moved, nothing written, and the user told their machine
|
|
// is unresponsive by the component that quit first.
|
|
//
|
|
// This was a flat 4 minutes, with a comment here claiming the host waited
|
|
// longer. MEASURED against the host's formula, that was false at every size the
|
|
// ceiling permits: a 64 MiB document got 124s from the host against 240s here,
|
|
// and it would have taken a 180 MiB source — well past the 64 MiB wall — for the
|
|
// sentence to come true. Two constants in two repos, with a comment on each side
|
|
// asserting a relationship between them, is exactly the thing that rots.
|
|
//
|
|
// So the number is not ours any more. The host sends `pull_budget` in the job
|
|
// and this box uses it, clamping DOWN and never up. The host's own wait is that
|
|
// same number plus a fixed allowance (40 s on the host, covering
|
|
// ctxExtractTimeout plus the frame round trip and the reply), so the
|
|
// ordering holds by construction at every
|
|
// size rather than by agreement between two files.
|
|
|
|
// ctxPullMaxTimeout clamps whatever the host grants. It MIRRORS the host's
|
|
// own budget cap: equal, so a host running the matching
|
|
// formula is never silently shortened, while a host asking for an hour gets
|
|
// this. TestPullHonoursTheHostsGrant pins both halves.
|
|
const ctxPullMaxTimeout = 240 * time.Second
|
|
|
|
// ctxPullDefaultTimeout is what a job granting nothing gets — a host predating
|
|
// `pull_budget`, or one that sent 0. It matches the host's own floor,
|
|
// so even then this box is the one that gives up first.
|
|
const ctxPullDefaultTimeout = 30 * time.Second
|
|
|
|
// Read buffer. Nothing to do with the document size.
|
|
const ctxPullChunk = 1 << 20
|
|
|
|
// ctxSourcePuller performs one authenticated GET for a ticket. A function
|
|
// rather than an *http.Client so the test can stand up a real host without
|
|
// reconstructing the tunnel, and so this file never has to know what a bearer
|
|
// is.
|
|
type ctxSourcePuller func(ctx context.Context, ticket string) (*http.Response, error)
|
|
|
|
var (
|
|
ctxPullMu sync.RWMutex
|
|
ctxPullFn ctxSourcePuller
|
|
)
|
|
|
|
// setCtxSourcePuller is called ONCE at daemon start (run.go), with the same
|
|
// mTLS client that carries the heartbeat. Until then the pull path is
|
|
// UNCONFIGURED, which is a setup state and is reported as one — never as a
|
|
// failure of the document or of the machine.
|
|
func setCtxSourcePuller(p ctxSourcePuller) {
|
|
ctxPullMu.Lock()
|
|
ctxPullFn = p
|
|
ctxPullMu.Unlock()
|
|
}
|
|
|
|
func ctxSourcePullerFn() ctxSourcePuller {
|
|
ctxPullMu.RLock()
|
|
defer ctxPullMu.RUnlock()
|
|
return ctxPullFn
|
|
}
|
|
|
|
// ctxPullConfigured says whether this box can fetch a source at all. It is what
|
|
// the heartbeat advertises (contextprep.go), so a host never hands a ticket to
|
|
// a build that could not redeem it.
|
|
func ctxPullConfigured() bool { return ctxSourcePullerFn() != nil }
|
|
|
|
// newHostSourcePuller binds the fetch to THIS bastion's host.
|
|
//
|
|
// The URL is built here, from the configured host name, and never comes off the
|
|
// wire — see the header. `c` must be the tunnel-pinned client from run.go: its
|
|
// DialContext ignores the address in the URL and dials the host's tunnel
|
|
// endpoint, and its TLS config pins ServerName to the host name. Both together
|
|
// are why there is no address check in this file.
|
|
func newHostSourcePuller(c *http.Client, hostName, bearer string) ctxSourcePuller {
|
|
return func(ctx context.Context, ticket string) (*http.Response, error) {
|
|
u := fmt.Sprintf("https://%s:8443/api/network/bastions/context-source?ticket=%s",
|
|
hostName, url.QueryEscape(ticket))
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+bearer)
|
|
return c.Do(req)
|
|
}
|
|
}
|
|
|
|
// validCtxTicket is the shape check. The host mints 32 random bytes as hex, so
|
|
// anything else is a bug or a probe and is refused before a request is made.
|
|
func validCtxTicket(s string) bool {
|
|
if len(s) != 64 {
|
|
return false
|
|
}
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') && !(c >= 'A' && c <= 'F') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ctxPullBudget turns the host's granted seconds into this box's deadline.
|
|
// Clamps DOWN to ctxPullMaxTimeout and never up; 0 or negative means the host
|
|
// granted nothing, which is an older host and gets the floor.
|
|
func ctxPullBudget(granted float64) time.Duration {
|
|
if granted <= 0 {
|
|
return ctxPullDefaultTimeout
|
|
}
|
|
d := time.Duration(granted * float64(time.Second))
|
|
if d > ctxPullMaxTimeout {
|
|
return ctxPullMaxTimeout
|
|
}
|
|
return d
|
|
}
|
|
|
|
// pullSourceInto streams the ticket's document into w, stopping at max or when
|
|
// the host's granted budget runs out.
|
|
//
|
|
// Returns the byte count. Constant memory by construction — the document is
|
|
// never held whole, which is the difference between this and the inline path
|
|
// and the reason the ceiling can be 8x higher.
|
|
func pullSourceInto(w io.Writer, ticket string, max int64, budget time.Duration) (int64, error) {
|
|
pull := ctxSourcePullerFn()
|
|
if pull == nil {
|
|
return 0, fmt.Errorf("this bastion has no host connection for fetching a " +
|
|
"document yet — that is a setup state, not a failure; the lane comes up " +
|
|
"with the daemon's tunnel")
|
|
}
|
|
if !validCtxTicket(ticket) {
|
|
return 0, fmt.Errorf("fetch ticket is not the shape this host mints")
|
|
}
|
|
if max <= 0 || max > ctxPullMaxBytes {
|
|
max = ctxPullMaxBytes
|
|
}
|
|
if budget <= 0 || budget > ctxPullMaxTimeout {
|
|
budget = ctxPullMaxTimeout
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), budget)
|
|
defer cancel()
|
|
resp, err := pull(ctx, ticket)
|
|
if err != nil {
|
|
if ctx.Err() == context.DeadlineExceeded {
|
|
return 0, slowTransfer(0, budget)
|
|
}
|
|
return 0, fmt.Errorf("could not reach this host to fetch the document: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
// The status IS the diagnosis and each one means something different:
|
|
// 404 = the ticket was already used, expired, or was never ours; 401 =
|
|
// this box's bearer is no longer good; 503 = the machine holding those
|
|
// bytes is offline, which is not this machine's problem to solve.
|
|
return 0, fmt.Errorf("this host answered HTTP %d for the document", resp.StatusCode)
|
|
}
|
|
buf := make([]byte, ctxPullChunk)
|
|
var total int64
|
|
for {
|
|
n, rerr := resp.Body.Read(buf)
|
|
if n > 0 {
|
|
total += int64(n)
|
|
if total > max {
|
|
return total, fmt.Errorf(
|
|
"the document is larger than the %d bytes this extraction was "+
|
|
"authorized for — stopped, nothing was extracted", max)
|
|
}
|
|
if _, werr := w.Write(buf[:n]); werr != nil {
|
|
return total, werr
|
|
}
|
|
}
|
|
if rerr == io.EOF {
|
|
return total, nil
|
|
}
|
|
if rerr != nil {
|
|
if ctx.Err() == context.DeadlineExceeded {
|
|
return total, slowTransfer(total, budget)
|
|
}
|
|
return total, rerr
|
|
}
|
|
}
|
|
}
|
|
|
|
// slowTransfer is the honest version of a deadline. A SLOW LINK IS NOT A BROKEN
|
|
// MACHINE, and the whole reason the budget now comes from the host is so this
|
|
// sentence — rather than the host's "your machine did not answer" — is what the
|
|
// user reads. It names how far it got, so "the link is slow" and "the transfer
|
|
// never started" are told apart.
|
|
func slowTransfer(got int64, budget time.Duration) error {
|
|
return fmt.Errorf(
|
|
"the document was still downloading after the %s this host allowed "+
|
|
"(%d bytes in) — the transfer is slower than this lane waits, which is "+
|
|
"a link speed and not a fault in this machine", budget, got)
|
|
}
|