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
|
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
|
||||||
|
|||||||
@@ -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},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user