3 Commits
Author SHA1 Message Date
clawbot d293ab74d1 Space reconciliation passes by ten times the last pass's time (closes #250)
check / check (push) Canceled after 0s
The reconciliation pass reads a row and stats a file for every cached
file, and it ran on every eviction tick, so on a large cache a pass
longer than the tick was followed by the next one at once. It still runs
at startup and is considered on each tick, but it now starts no sooner
than ten times as long after the last pass ended as that pass took.
Eviction and the removal of expired negative cache rows still run on
every tick. The comments that bound the drift, and README.md, give the
new bound.

Model: opus-5-5
2026-10-19 02:28:23 +00:00
clawbot 7933619692 Test that reconciliation passes are spaced by ten times their time
The new test makes every read of the reconciliation pass sleep 10 ms
through reconciliationReadTestHook, runs the eviction loop on a 10 ms
interval, and checks that each pass starts at least ten times as long
after the previous one ended as that one took. It fails while the pass
runs on every eviction tick, since a slow pass is then followed by the
next one at once.

Model: opus-5-5
2026-10-19 02:26:58 +00:00
clawbot 0ab15324f7 Read a blocked_networks entry in the IPv4-mapped form as its IPv4 range (closes #301)
check / check (push) Canceled after 0s
The fetcher matches an IPv4-mapped address (::ffff:a.b.c.d) as the
IPv4 address it holds, so a blocked_networks entry written in that form
was accepted and matched nothing. Such an entry is now kept as the IPv4
range it covers: ::ffff:10.0.0.0/104 becomes 10.0.0.0/8, and
::ffff:0:0/96 becomes 0.0.0.0/0. One shorter than /96 covers more than
IPv4 addresses and aborts startup naming the setting. README.md says
both. trusted_proxies shares the list parser, has the same fault and is
unchanged here (issue 303).

Model: opus-5-5
2026-10-19 03:00:43 +02:00
5 changed files with 115 additions and 20 deletions
+7 -3
View File
@@ -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 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 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 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 every cached file, so pixa skips each five-minute look that would start less
ten times as long as it took before looking again. 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 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 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 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 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 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 - `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 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 of these ranges; the logged and login-recorded client address is then the
+6 -2
View File
@@ -30,8 +30,12 @@ P2: security: per-origin rate limiting
# Completed Steps # Completed Steps
- 2026-10-18 the next reconciliation pass waits ten times as long as the last - 2026-10-18 the next reconciliation pass waits at least ten times as long as
one took, so passes on a large cache no longer run back to back (closes #250). 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 - 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 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 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},
},
})
}
+35 -1
View File
@@ -91,6 +91,7 @@ var (
errNotABoolean = errors.New("not a boolean") errNotABoolean = errors.New("not a boolean")
errNotAStringList = errors.New("not a list of strings") errNotAStringList = errors.New("not a list of strings")
errNotAValidCIDR = errors.New("not a valid CIDR network") 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") errNotAMetricsMap = errors.New("not a map of metrics settings")
errEmptyListEntry = errors.New("list contains an empty entry") errEmptyListEntry = errors.New("list contains an empty entry")
errEmptyEntry = errors.New("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 return nil, err
} }
blockedNetworks, err := parseCIDRList(sc, keyBlockedNetworks) blockedNetworks, err := parseBlockedNetworks(sc)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1278,6 +1279,39 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
return prefixes, nil 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, // 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 // 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 // comma-separated string. An explicitly null value, a wrong type, an empty
+14 -14
View File
@@ -19,8 +19,8 @@ import (
const DefaultEvictionInterval = 5 * time.Minute const DefaultEvictionInterval = 5 * time.Minute
// reconciliationWaitFactor is how many times as long as a // reconciliationWaitFactor is how many times as long as a
// reconciliation pass took the eviction loop waits, after that pass // reconciliation pass took the eviction loop waits at least, after that
// ends, before it starts the next one. A pass reads a row and stats a // 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 // 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, // ten times as long keeps reconciliation to at most a tenth of the time,
// however large the cache is. // 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 // removes the expired negative cache rows at startup and again on every
// periodic tick thereafter, reconciles the database accounting with the // periodic tick thereafter, reconciles the database accounting with the
// cache directories at startup and again on the first periodic tick // cache directories at startup and again on the first periodic tick
// that comes ten times as long after a reconciliation pass ended as // that comes at least ten times as long after a reconciliation pass
// that pass took, and evicts to the configured limit on the given // ended as that pass took, and evicts to the configured limit on the given
// periodic interval and on write-pressure notifications. On a // periodic interval and on write-pressure notifications. On a
// disabled cache it only removes the expired negative cache rows, as // 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 // 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 // Reconciliation walks the cache directories and reads a
// row for every file, so it runs only on the periodic // row for every file, so it runs only on the periodic
// ticker rather than on every write-pressure wakeup, and // ticker rather than on every write-pressure wakeup, and
// not before nextReconciliation, which is ten times as long // only on the first tick at or after nextReconciliation,
// after the last pass ended as that pass took. Passes then // which is ten times as long after the last pass ended as
// never run back to back, however long one takes. A file // that pass took. Passes then never run back to back,
// whose best-effort accounting insert failed in a store // however long one takes. A file whose best-effort
// stays unaccounted for until the next pass, for about the // accounting insert failed in a store stays unaccounted for
// longer of one eviction interval and ten times the last // until the next pass, up to about one eviction interval
// pass's time. // plus ten times the last pass's time.
if !time.Now().Before(nextReconciliation) { if !time.Now().Before(nextReconciliation) {
nextReconciliation = c.runReconciliationPass(ctx) nextReconciliation = c.runReconciliationPass(ctx)
} }
@@ -578,7 +578,7 @@ func (c *Cache) runCleanExpired(ctx context.Context) {
// reconcileAccounting synchronizes the database size accounting with // reconcileAccounting synchronizes the database size accounting with
// the actual contents of the cache directories. It runs at startup and // 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 // 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 // hot path: it adopts variant files that predate the accounting table
// (or whose accounting insert failed, e.g. StoreVariant's best-effort // (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 // 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. // 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 // Running it periodically, not just once, bounds how long such drift can
// accumulate unaccounted for on a long-running process to about the // accumulate unaccounted for on a long-running process to about one
// longer of one eviction interval and ten times the last pass's time. // eviction interval plus ten times the last pass's time.
// Once ctx is cancelled, it stops at the next file or row and returns // Once ctx is cancelled, it stops at the next file or row and returns
// ctx's error. // ctx's error.
func (c *Cache) reconcileAccounting(ctx context.Context) error { func (c *Cache) reconcileAccounting(ctx context.Context) error {