Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d293ab74d1 | ||
|
|
7933619692 | ||
|
|
0ab15324f7 |
@@ -200,8 +200,10 @@ When pixa starts, and every five minutes after, it removes each cached source or
|
||||
transformed image whose file is empty, or not the size the database records for
|
||||
it, with a warning naming the file; the next request for it fetches the source
|
||||
or converts the image again. Looking for those files reads the database once for
|
||||
every cached file, so after a look that took longer than 30 seconds, pixa waits
|
||||
ten times as long as it took before looking again.
|
||||
every cached file, so pixa skips each five-minute look that would start less
|
||||
than ten times as long after the last look ended as that look took. After a look
|
||||
that took one minute, the next one starts 10 to 15 minutes after that look
|
||||
ended.
|
||||
|
||||
pixa's target is 1-5k r/s, which has not been measured at that rate (see Load
|
||||
Test). Toward it, pixa keeps in memory the content types of the 10,000
|
||||
@@ -699,7 +701,9 @@ Key settings in more detail:
|
||||
benchmark, NAT64, and the like); an invalid CIDR aborts startup. An IPv6
|
||||
address in the IPv4-mapped form (`::ffff:a.b.c.d`) is matched only as the IPv4
|
||||
address it holds, so an IPv6 range such as `::/0` refuses neither it nor a
|
||||
plain IPv4 address
|
||||
plain IPv4 address. An entry written in that form is read as the IPv4 range it
|
||||
covers, so `::ffff:10.0.0.0/104` refuses what `10.0.0.0/8` refuses; such an
|
||||
entry shorter than `/96` covers more than IPv4 addresses and aborts startup
|
||||
- `trusted_proxies` — list of CIDR ranges of the reverse proxies in front of
|
||||
pixa. `X-Forwarded-For` is believed only when the direct peer falls inside one
|
||||
of these ranges; the logged and login-recorded client address is then the
|
||||
|
||||
@@ -30,8 +30,12 @@ P2: security: per-origin rate limiting
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-18 the next reconciliation pass waits ten times as long as the last
|
||||
one took, so passes on a large cache no longer run back to back (closes #250).
|
||||
- 2026-10-18 the next reconciliation pass waits at least ten times as long as
|
||||
the last one took, so passes on a large cache no longer run back to back
|
||||
(closes #250).
|
||||
- 2026-10-18 a `blocked_networks` entry in the IPv4-mapped form refused nothing;
|
||||
`::ffff:10.0.0.0/104` is now read as `10.0.0.0/8`, and such an entry shorter
|
||||
than `/96` aborts startup (closes #301).
|
||||
- 2026-10-16 P1 security bug: the check that keeps pixa from fetching internal
|
||||
addresses refuses the local-use NAT64 range (`64:ff9b:1::/48`, RFC 8215) as it
|
||||
refuses `64:ff9b::/96`, and refuses an IPv6 address in the IPv4-compatible
|
||||
|
||||
@@ -88,3 +88,56 @@ func TestBlockedNetworksInvalidAbortsStartup(t *testing.T) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestBlockedNetworksMappedEntryIsIPv4Range checks that an entry written in
|
||||
// the IPv4-mapped form (::ffff:a.b.c.d) is kept as the IPv4 range it covers,
|
||||
// since the fetcher matches an IPv4-mapped address as the IPv4 address it
|
||||
// holds. /96, the shortest such entry, covers every IPv4 address.
|
||||
func TestBlockedNetworksMappedEntryIsIPv4Range(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
yamlContent := signingKeyLine + `blocked_networks:
|
||||
- "::ffff:10.0.0.0/104"
|
||||
- "::ffff:0:0/96"
|
||||
`
|
||||
|
||||
c, err := configFromYAML(t, yamlContent)
|
||||
if err != nil {
|
||||
t.Fatalf("IPv4-mapped blocked_networks entries should load, got error: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"10.0.0.0/8", "0.0.0.0/0"}
|
||||
if len(c.BlockedNetworks) != len(want) {
|
||||
t.Fatalf("BlockedNetworks = %v, want %d entries", c.BlockedNetworks, len(want))
|
||||
}
|
||||
|
||||
for i, w := range want {
|
||||
if got := c.BlockedNetworks[i].String(); got != w {
|
||||
t.Errorf("BlockedNetworks[%d] = %q, want %q", i, got, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlockedNetworksMappedEntryShorterThan96AbortsStartup checks that an
|
||||
// entry in the IPv4-mapped form shorter than /96, which covers more than the
|
||||
// IPv4 addresses, aborts startup with an error naming the key and the value.
|
||||
func TestBlockedNetworksMappedEntryShorterThan96AbortsStartup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runAbortCases(t, []abortCase{
|
||||
{
|
||||
name: "mapped-shorter-than-96",
|
||||
yaml: signingKeyLine + `blocked_networks:
|
||||
- "::ffff:0.0.0.0/80"
|
||||
`,
|
||||
wantErrSubstrings: []string{keyBlockedNetworks, "::ffff:0.0.0.0/80"},
|
||||
},
|
||||
{
|
||||
name: "mapped-95",
|
||||
yaml: signingKeyLine + `blocked_networks:
|
||||
- "::ffff:0:0/95"
|
||||
`,
|
||||
wantErrSubstrings: []string{keyBlockedNetworks},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ var (
|
||||
errNotABoolean = errors.New("not a boolean")
|
||||
errNotAStringList = errors.New("not a list of strings")
|
||||
errNotAValidCIDR = errors.New("not a valid CIDR network")
|
||||
errMappedTooShort = errors.New("in the IPv4-mapped form but shorter than /96")
|
||||
errNotAMetricsMap = errors.New("not a map of metrics settings")
|
||||
errEmptyListEntry = errors.New("list contains an empty entry")
|
||||
errEmptyEntry = errors.New("contains an empty entry")
|
||||
@@ -271,7 +272,7 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
blockedNetworks, err := parseCIDRList(sc, keyBlockedNetworks)
|
||||
blockedNetworks, err := parseBlockedNetworks(sc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1278,6 +1279,39 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
|
||||
return prefixes, nil
|
||||
}
|
||||
|
||||
// parseBlockedNetworks parses blocked_networks with parseCIDRList, and keeps
|
||||
// an entry written in the IPv4-mapped form (::ffff:a.b.c.d) as the IPv4
|
||||
// range it covers: ::ffff:10.0.0.0/104 is kept as 10.0.0.0/8. The fetcher
|
||||
// matches an IPv4-mapped address as the IPv4 address it holds, so the entry
|
||||
// as written would match nothing. Such an entry shorter than /96 covers more
|
||||
// than the IPv4 addresses and aborts startup.
|
||||
func parseBlockedNetworks(sc *smartconfig.Config) ([]netip.Prefix, error) {
|
||||
prefixes, err := parseCIDRList(sc, keyBlockedNetworks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// An IPv4-mapped address is the 96 bits of ::ffff:0:0/96 followed by
|
||||
// the 32 bits of the IPv4 address it holds.
|
||||
const mappedPrefixBits = 96
|
||||
|
||||
for i, prefix := range prefixes {
|
||||
if !prefix.Addr().Is4In6() {
|
||||
continue
|
||||
}
|
||||
|
||||
if prefix.Bits() < mappedPrefixBits {
|
||||
return nil, fmt.Errorf("%s: value %q is %w",
|
||||
settingName(keyBlockedNetworks), prefix, errMappedTooShort)
|
||||
}
|
||||
|
||||
prefixes[i] = netip.PrefixFrom(prefix.Addr().Unmap(),
|
||||
prefix.Bits()-mappedPrefixBits)
|
||||
}
|
||||
|
||||
return prefixes, nil
|
||||
}
|
||||
|
||||
// parseHostList parses the value of the named config key into host patterns,
|
||||
// or returns nil if the key is omitted. It accepts a YAML list of strings or a
|
||||
// comma-separated string. An explicitly null value, a wrong type, an empty
|
||||
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
const DefaultEvictionInterval = 5 * time.Minute
|
||||
|
||||
// reconciliationWaitFactor is how many times as long as a
|
||||
// reconciliation pass took the eviction loop waits, after that pass
|
||||
// ends, before it starts the next one. A pass reads a row and stats a
|
||||
// reconciliation pass took the eviction loop waits at least, after that
|
||||
// pass ends, before it starts the next one. A pass reads a row and stats a
|
||||
// file for every cached file, so its time grows with the cache; waiting
|
||||
// ten times as long keeps reconciliation to at most a tenth of the time,
|
||||
// however large the cache is.
|
||||
@@ -449,8 +449,8 @@ func (c *Cache) notifyWritePressure() {
|
||||
// removes the expired negative cache rows at startup and again on every
|
||||
// periodic tick thereafter, reconciles the database accounting with the
|
||||
// cache directories at startup and again on the first periodic tick
|
||||
// that comes ten times as long after a reconciliation pass ended as
|
||||
// that pass took, and evicts to the configured limit on the given
|
||||
// that comes at least ten times as long after a reconciliation pass
|
||||
// ended as that pass took, and evicts to the configured limit on the given
|
||||
// periodic interval and on write-pressure notifications. On a
|
||||
// disabled cache it only removes the expired negative cache rows, as
|
||||
// the negative cache is used either way. It is a no-op when already
|
||||
@@ -507,13 +507,13 @@ func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) {
|
||||
// Reconciliation walks the cache directories and reads a
|
||||
// row for every file, so it runs only on the periodic
|
||||
// ticker rather than on every write-pressure wakeup, and
|
||||
// not before nextReconciliation, which is ten times as long
|
||||
// after the last pass ended as that pass took. Passes then
|
||||
// never run back to back, however long one takes. A file
|
||||
// whose best-effort accounting insert failed in a store
|
||||
// stays unaccounted for until the next pass, for about the
|
||||
// longer of one eviction interval and ten times the last
|
||||
// pass's time.
|
||||
// only on the first tick at or after nextReconciliation,
|
||||
// which is ten times as long after the last pass ended as
|
||||
// that pass took. Passes then never run back to back,
|
||||
// however long one takes. A file whose best-effort
|
||||
// accounting insert failed in a store stays unaccounted for
|
||||
// until the next pass, up to about one eviction interval
|
||||
// plus ten times the last pass's time.
|
||||
if !time.Now().Before(nextReconciliation) {
|
||||
nextReconciliation = c.runReconciliationPass(ctx)
|
||||
}
|
||||
@@ -578,7 +578,7 @@ func (c *Cache) runCleanExpired(ctx context.Context) {
|
||||
|
||||
// reconcileAccounting synchronizes the database size accounting with
|
||||
// the actual contents of the cache directories. It runs at startup and
|
||||
// again on later periodic eviction ticks, each no sooner than ten times
|
||||
// then on the first periodic eviction tick that comes at least ten times
|
||||
// as long after the last pass ended as that pass took, off the request
|
||||
// hot path: it adopts variant files that predate the accounting table
|
||||
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
|
||||
@@ -589,8 +589,8 @@ func (c *Cache) runCleanExpired(ctx context.Context) {
|
||||
// files left behind by crashed writes, and last checks the total cache
|
||||
// usage against the tables. It reads the tables a page at a time.
|
||||
// Running it periodically, not just once, bounds how long such drift can
|
||||
// accumulate unaccounted for on a long-running process to about the
|
||||
// longer of one eviction interval and ten times the last pass's time.
|
||||
// accumulate unaccounted for on a long-running process to about one
|
||||
// eviction interval plus ten times the last pass's time.
|
||||
// Once ctx is cancelled, it stops at the next file or row and returns
|
||||
// ctx's error.
|
||||
func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
||||
|
||||
Reference in New Issue
Block a user