2 Commits
Author SHA1 Message Date
clawbot a7bb770e60 Answer image requests with 503 in maintenance mode (closes #71)
check / check (push) Successful in 2m54s
maintenance_mode was only reported by the health check; every request
was still served. One middleware in routes.go, applied to /v1/image/
and /v1/e/ only, now answers them with 503, a Retry-After of
MaintenanceRetryAfterSeconds and the JSON error body while it is on.
It calls Server.MaintenanceMode(), which had no caller.

The health check stays 200 and reports maintenance_mode: the image's
Docker HEALTHCHECK requests it, a 503 there would make the container
unhealthy, and upaas marks a deploy failed when its container is
unhealthy. The login and URL generator pages and /metrics keep working.
Documented in README.md and config.example.yml.

Model: opus-5-5
2026-09-29 05:10:06 +00:00
clawbot 89ab5378a1 Test that maintenance mode refuses image requests (closes #71)
Tests ahead of the change: with maintenance_mode on, both image routes
must answer 503 with a Retry-After header and the JSON error body, while
the health check keeps answering 200 and reporting maintenance_mode, and
the login page and /metrics keep answering 200; these fail until the next
commit. With it off, image requests must still reach the image handlers.
The test server now also builds the health check, which these tests
request.

Model: opus-5-5
2026-09-29 05:10:06 +00:00
22 changed files with 310 additions and 712 deletions
+17 -16
View File
@@ -3,14 +3,13 @@
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint
RUN apk add --no-cache make build-base vips-dev libheif-dev pkgconfig
WORKDIR /src
# script/bootstrap installs the build dependencies and downloads the Go
# modules. Only script/, go.mod and go.sum are copied first, so this
# layer is reused until one of them changes.
COPY script/ ./script/
# Copy go mod files first for better layer caching
COPY go.mod go.sum ./
RUN script/bootstrap
RUN go mod download
# Copy source code
COPY . .
@@ -29,12 +28,20 @@ FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66
# Depend on lint stage passing
COPY --from=lint /src/go.sum /dev/null
ARG VERSION=dev
# Install build dependencies for CGO image libraries
RUN apk add --no-cache \
build-base \
vips-dev \
libheif-dev \
pkgconfig
WORKDIR /src
# Build dependencies and Go modules, as in the lint stage
COPY script/ ./script/
# Copy go mod files first for better layer caching
COPY go.mod go.sum ./
RUN script/bootstrap
RUN GOTOOLCHAIN=auto go mod download
# Copy source code
COPY . .
@@ -42,14 +49,8 @@ COPY . .
# Run tests
RUN make test
# VERSION is declared here, not earlier: a new value reruns only the
# build, not script/bootstrap or the tests. CGO stays enabled for
# govips; -trimpath keeps build paths out of the binary, and -s -w
# leave out the symbol table and debug information.
ARG VERSION=dev
RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -trimpath \
-ldflags "-s -w -X main.Version=${VERSION}" \
-o /pixad ./cmd/pixad
# Build with CGO enabled
RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -ldflags "-X main.Version=${VERSION}" -o /pixad ./cmd/pixad
# Runtime stage
# alpine:3.21, 2026-02-25
+7 -7
View File
@@ -6,16 +6,16 @@
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60
# pixa is CGO/libvips: the type-aware linters compile every package, so
# this image needs the same C libraries the build does.
RUN apk add --no-cache build-base vips-dev libheif-dev pkgconfig
WORKDIR /src
# pixa is CGO/libvips: the type-aware linters compile every package, so
# this image needs the same C libraries the build does. script/bootstrap
# installs them and downloads the Go modules. Only script/, go.mod and
# go.sum are copied first; they settle this layer's result, so it may
# safely be reused between runs.
COPY script/ ./script/
# Modules first for layer caching; go.mod/go.sum settle this layer's
# result, so it may safely be reused between runs.
COPY go.mod go.sum ./
RUN script/bootstrap
RUN go mod download
COPY . .
+12 -21
View File
@@ -235,26 +235,15 @@ variables set by the file's `env:` section are checked the same way.
| `PIXA_TRUSTED_PROXIES` | `trusted_proxies` | CIDR ranges of proxies whose `X-Forwarded-For` is believed; default RFC 1918 |
| `PIXA_ALLOW_HTTP` | `allow_http` | Allow plain-HTTP upstreams, for testing only; default `false` |
| `PIXA_UPSTREAM_CONNECTIONS_PER_HOST` | `upstream_connections_per_host` | Concurrent connections per upstream host; default `20` |
| `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` |
| `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB |
| `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` |
| `PIXA_ACCESS_CONTROL_ALLOW_ORIGIN` | `access_control_allow_origin` | CORS origin allowed to read responses: `*` or one origin; default `*` |
| `PIXA_METRICS_USERNAME` | `metrics.username` | Username for `/metrics`, which is served only when both are set |
| `PIXA_METRICS_PASSWORD` | `metrics.password` | Password for `/metrics`; set together with the username |
| `PIXA_SENTRY_DSN` | `sentry_dsn` | Sentry DSN for error reporting; empty disables it |
| `PIXA_DEBUG` | `debug` | Debug logging and plain-HTTP local development; default `false` |
| `PIXA_MAINTENANCE_MODE` | `maintenance_mode` | Maintenance flag reported by the health check; default `false` |
| `PIXA_MAINTENANCE_MODE` | `maintenance_mode` | Answer image requests with 503; the health check stays 200; default `false` |
Key settings in more detail:
- `access_control_allow_origin` — the origin a browser lets read pixa's
responses, sent as the CORS `Access-Control-Allow-Origin` header: `*`, the
default, is any site; otherwise one `http` or `https` origin such as
`https://example.com`, whose host is a lowercase host name (letters,
digits, hyphens and dots, with a letter in its last part) or an IP address
(IPv6 in brackets, in its shortest form), with an optional port 1-65535
that has no leading zero and is not the scheme's default. Any other value,
including another scheme such as a browser extension's, aborts startup
- `access_control_allow_origin` — CORS origin
- `allowlist_hosts` — list of allowed upstream hosts
- `blocked_networks` — list of CIDR ranges to refuse for SSRF protection,
added to the always-enforced built-in ranges (loopback, private,
@@ -280,18 +269,20 @@ Key settings in more detail:
the host's addresses is seen with that address. To be sure which address it
is, set this to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request
through the proxy, and read `remoteIP` in pixa's request log line for it
- `upstream_fetch_timeout` — time allowed for one fetch from an upstream
host, as a duration such as `30s` (the default) or `2m`
- `upstream_max_response_size` — largest upstream response accepted, in
bytes; default `52428800` (50 MiB). It also limits the image data pixa
decodes
- `downstream_timeout` — time allowed for answering one client request, as a
duration; default `60s`. The upstream fetch counts toward it, so keep it
longer than `upstream_fetch_timeout`
- `upstream_fetch_timeout` — timeout for origin requests
- `upstream_max_response_size` — max origin response size
- `downstream_timeout` — client response timeout
- `signing_key` — HMAC secret for URL signatures
- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the
disk cache entirely; omitted defaults to 75% of the free space on
the filesystem containing `<state_dir>/cache/` (minimum 500 MiB)
- `maintenance_mode` — while `true`, the image routes (`/v1/image/` and
`/v1/e/`) answer every request with 503, a `Retry-After` header and a JSON
error body. The health check (`/.well-known/healthcheck.json`) still answers
200 and reports `"maintenance_mode": true`. It stays 200 because the image's
Docker `HEALTHCHECK` requests it: a 503 there would make the container
unhealthy, and upaas marks a deploy failed when its container is unhealthy.
The login and URL generator pages and `/metrics` keep working
See `config.example.yml` for all options with defaults.
+9 -26
View File
@@ -30,21 +30,14 @@ exhaustion
# Completed Steps
- 2026-09-29 Dockerfiles install through `script/bootstrap` (closes #95): the
`Dockerfile` lint and build stages and `Dockerfile.lint` copy `script/`,
`go.mod` and `go.sum`, then run `script/bootstrap` in place of their own
`apk add` lines, so the build dependencies are listed in one place;
`script/bootstrap` now also installs a C compiler when `gcc` is missing; the
build uses `-trimpath` and `-s -w` and keeps `CGO_ENABLED=1` for govips;
`ARG VERSION` sits just above the build, so a new version reruns neither
`script/bootstrap` nor the tests.
- 2026-09-29 migrations at the path `REPO_POLICIES.md` sets (closes #96): the
migration files moved, contents unchanged, from `internal/database/schema/`
to `internal/db/migrations/` as `000_migration.sql` and `001_schema.sql`; the
`internal/db/migrations` package embeds them and `internal/database` reads
them through its `FS()`; the `internal/database` package itself stays; the
version still comes from the filename prefix, so a database that has recorded
versions 0 and 1 runs neither again.
- 2026-09-29 maintenance mode refuses image requests (closes #71): while
`maintenance_mode` is on, `/v1/image/` and `/v1/e/` answer 503 with a
`Retry-After` header and the JSON error body, from one middleware in
`internal/server/routes.go`; the health check stays 200 and reports
`maintenance_mode`, as the image's Docker `HEALTHCHECK` requests it and upaas
marks a deploy failed when its container is unhealthy; the login and URL
generator pages and `/metrics` keep working; documented in `README.md` and
`config.example.yml`.
- 2026-09-29 `trusted_proxies` advice and signature padding in `README.md`
(closes #150): the login-limit paragraph, the `trusted_proxies` entry and
`config.example.yml` say to set `trusted_proxies` to the address pixa sees for
@@ -65,17 +58,6 @@ exhaustion
that is sooner, never negative; an allowlisted host's URL that has an `exp`
follows it too; `immutable` stays, as freshness now ends at the expiry;
documented in `README.md`.
- 2026-09-28 add the four settings `README.md` documented but pixa did not
have, which aborted startup as unknown keys (closes #61):
`access_control_allow_origin` (default `*`, the CORS origin),
`upstream_fetch_timeout` (default `30s`), `upstream_max_response_size`
(default 50 MiB) and `downstream_timeout` (default `60s`, both the
server's write timeout and the per-request timeout); each has a
`PIXA_` variable; durations are positive Go duration strings, the size a
whole number of bytes up to 1 GiB, the origin `*` or one `http` or
`https` origin as `README.md` describes it; an invalid value
aborts startup naming the key and the value; documented in
`config.example.yml` and `README.md`.
- 2026-09-28 cache stats report real numbers (closes #56): `Cache.Stats`
counts the cached source images and processed variants (`source_content`
plus `variant_content`) and takes their size from `Cache.UsageBytes`,
@@ -325,6 +307,7 @@ exhaustion
- X-Request-ID propagation
- P2: auto format selection (format=auto based on Accept header)
- P2: configuration
- add all configuration options from README
- YAML config file support
- P2: operational
- optional Sentry error reporting
+6 -25
View File
@@ -8,14 +8,16 @@
# this file's env: section is set while the file loads, so it overrides
# both the environment the process was started with and this file's own
# key.
#
# Durations are Go duration strings such as 30s or 2m and must be
# positive; a bare number has no unit and aborts startup. Sizes are a
# whole number of bytes.
# Server settings
port: 8080
debug: false
# While true, the image routes (/v1/image/ and /v1/e/) answer every request
# with 503 and a Retry-After header. The health check keeps answering 200 and
# reports maintenance_mode as true. It stays 200 because the image's Docker
# HEALTHCHECK requests it: a 503 there would make the container unhealthy, and
# upaas marks a deploy failed when its container is unhealthy.
maintenance_mode: false
# Data directory for SQLite database and cache files
@@ -71,27 +73,6 @@ allow_http: false
# Maximum concurrent connections per upstream host (default: 20)
upstream_connections_per_host: 20
# Time allowed for one fetch from an upstream host (default: 30s)
upstream_fetch_timeout: 30s
# Largest upstream response accepted, in bytes, at most 1073741824
# (1 GiB) (default: 52428800, 50 MiB)
upstream_max_response_size: 52428800
# Time allowed for answering one client request, the upstream fetch
# included, so keep it longer than upstream_fetch_timeout (default: 60s)
downstream_timeout: 60s
# The origin a browser lets read pixa's responses, sent as the CORS
# Access-Control-Allow-Origin header: "*" (the default) is any site;
# otherwise one http or https origin such as https://example.com, whose
# host is a lowercase host name (letters, digits, hyphens and dots, with a
# letter in its last part) or an IP address (IPv6 in brackets, in its
# shortest form), with an optional port 1-65535 that has no leading zero
# and is not the scheme's default. Any other value, including another
# scheme such as a browser extension's, aborts startup.
access_control_allow_origin: "*"
# Maximum disk cache size in bytes. Explicit values are used exactly as
# given; 0 disables the disk cache entirely (every request fetches and
# processes uncached). When omitted, the default is 75% of the free
+1 -175
View File
@@ -13,7 +13,6 @@ import (
"sort"
"strconv"
"strings"
"time"
"git.eeqj.de/sneak/smartconfig"
"go.uber.org/fx"
@@ -25,10 +24,6 @@ const (
DefaultPort = 8080
DefaultStateDir = "/var/lib/pixa"
DefaultUpstreamConnectionsPerHost = 20
DefaultAccessControlAllowOrigin = "*"
DefaultUpstreamFetchTimeout = 30 * time.Second
DefaultUpstreamMaxResponseSize = 50 << 20 // 50 MiB
DefaultDownstreamTimeout = 60 * time.Second
)
// Configuration key names.
@@ -49,10 +44,6 @@ const (
keyCacheMaxBytes = "cache_max_bytes"
keyBlockedNetworks = "blocked_networks"
keyTrustedProxies = "trusted_proxies"
keyAccessControlAllowOrigin = "access_control_allow_origin"
keyUpstreamFetchTimeout = "upstream_fetch_timeout"
keyUpstreamMaxResponseSize = "upstream_max_response_size"
keyDownstreamTimeout = "downstream_timeout"
)
// placeholderSigningKey is the dummy signing_key shipped in
@@ -78,7 +69,6 @@ var (
errEmptyEntry = errors.New("contains an empty entry")
errNotAValidURL = errors.New("not a valid URL")
errPortOutOfRange = errors.New("outside the valid port range")
errSizeOutOfRange = errors.New("outside the accepted range")
errTooFewConnections = errors.New("must be at least 1")
errValueTooShort = errors.New("value too short")
errPlaceholderKey = errors.New(
@@ -96,10 +86,6 @@ var (
errNotBareHostname = errors.New(
"must be a bare hostname without scheme, path, or whitespace")
errNoHostnameLabels = errors.New("contains no hostname labels")
errNotADuration = errors.New("not a duration such as 30s or 2m")
errMustBePositive = errors.New("must be positive")
errNotAnOrigin = errors.New(
`not "*" or an origin such as https://example.com`)
)
// Params defines dependencies for Config.
@@ -126,23 +112,6 @@ type Config struct {
AllowHTTP bool // Allow non-TLS upstream (testing only)
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
// UpstreamFetchTimeout is the time allowed for one fetch from an
// upstream host. UpstreamMaxResponseSize is the largest upstream
// response accepted, in bytes, and also the image processor's input
// limit.
UpstreamFetchTimeout time.Duration
UpstreamMaxResponseSize int64
// AccessControlAllowOrigin is the origin the CORS middleware allows
// to read responses: "*" for any, or one origin such as
// https://example.com.
AccessControlAllowOrigin string
// DownstreamTimeout bounds how long answering one client request may
// take. It is both the HTTP server's write timeout and the deadline
// of the per-request timeout middleware.
DownstreamTimeout time.Duration
// BlockedNetworks are operator-supplied CIDR ranges to refuse in
// addition to the built-in SSRF blocklist. Enforced by the upstream
// fetcher's dialer; the built-in ranges always apply.
@@ -271,14 +240,6 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
AllowHTTP: loader.boolVal(keyAllowHTTP, false),
UpstreamConnectionsPerHost: loader.intVal(
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
UpstreamFetchTimeout: loader.durationVal(
keyUpstreamFetchTimeout, DefaultUpstreamFetchTimeout),
UpstreamMaxResponseSize: loader.int64Val(
keyUpstreamMaxResponseSize, DefaultUpstreamMaxResponseSize),
AccessControlAllowOrigin: loader.stringVal(
keyAccessControlAllowOrigin, DefaultAccessControlAllowOrigin),
DownstreamTimeout: loader.durationVal(
keyDownstreamTimeout, DefaultDownstreamTimeout),
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
BlockedNetworks: blockedNetworks,
TrustedProxies: trustedProxies,
@@ -393,8 +354,7 @@ func isKnownConfigKey(key string) bool {
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, keyBlockedNetworks,
keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout,
keyUpstreamMaxResponseSize, keyDownstreamTimeout, "env":
keyTrustedProxies, "env":
return true
}
@@ -422,10 +382,6 @@ func envVarNames() map[string]string {
keyCacheMaxBytes: "PIXA_CACHE_MAX_BYTES",
keyBlockedNetworks: "PIXA_BLOCKED_NETWORKS",
keyTrustedProxies: "PIXA_TRUSTED_PROXIES",
keyAccessControlAllowOrigin: "PIXA_ACCESS_CONTROL_ALLOW_ORIGIN",
keyUpstreamFetchTimeout: "PIXA_UPSTREAM_FETCH_TIMEOUT",
keyUpstreamMaxResponseSize: "PIXA_UPSTREAM_MAX_RESPONSE_SIZE",
keyDownstreamTimeout: "PIXA_DOWNSTREAM_TIMEOUT",
}
}
@@ -579,11 +535,6 @@ func (c *Config) validate() error {
settingName(keyCacheMaxBytes), c.CacheMaxBytes, errMustNotBeNegative)
}
err = c.validateUpstreamMaxResponseSize()
if err != nil {
return err
}
for _, host := range c.AllowlistHosts {
err := validateAllowlistHost(host)
if err != nil {
@@ -605,82 +556,6 @@ func (c *Config) validate() error {
errMustBeSetTogether)
}
return c.validateAccessControlAllowOrigin()
}
// validateUpstreamMaxResponseSize checks that upstream_max_response_size
// is from 1 byte to 1 GiB. An upstream response is read whole into
// memory, and the image processor reads one byte past this limit, which
// must not overflow.
func (c *Config) validateUpstreamMaxResponseSize() error {
const maxUpstreamMaxResponseSize = 1 << 30 // 1 GiB
if c.UpstreamMaxResponseSize < 1 ||
c.UpstreamMaxResponseSize > maxUpstreamMaxResponseSize {
return fmt.Errorf("%s: value %d is %w 1-%d",
settingName(keyUpstreamMaxResponseSize), c.UpstreamMaxResponseSize,
errSizeOutOfRange, maxUpstreamMaxResponseSize)
}
return nil
}
// validateAccessControlAllowOrigin accepts "*" or an origin exactly as a browser
// sends it: http or https, an IP address as netip writes it or a lowercase name
// with a letter in its last part, and an optional port 1-65535, not the default.
func (c *Config) validateAccessControlAllowOrigin() error {
origin := c.AccessControlAllowOrigin
if origin == "*" {
return nil
}
errOrigin := fmt.Errorf("%s: value %q is %w",
settingName(keyAccessControlAllowOrigin), origin, errNotAnOrigin)
parsed, err := url.Parse(origin)
if err != nil {
return errOrigin
}
defaultPort := map[string]string{"http": "80", "https": "443"}[parsed.Scheme]
if defaultPort == "" {
return errOrigin
}
const letters = "abcdefghijklmnopqrstuvwxyz"
host := parsed.Hostname()
lastPart := host[strings.LastIndex(host, ".")+1:]
addr, err := netip.ParseAddr(host)
switch {
case err == nil && addr.Is6():
host = "[" + addr.String() + "]"
case err == nil:
host = addr.String()
case strings.Trim(host, letters+"0123456789-.") != "": // a character other than these
return errOrigin
case !strings.ContainsAny(lastPart, letters):
return errOrigin
}
// The value must be exactly the origin rebuilt from its parts.
rebuilt := parsed.Scheme + "://" + host
port := parsed.Port()
if port != "" {
_, err := strconv.ParseUint(port, 10, 16)
if err != nil || port[0] == '0' || port == defaultPort {
return errOrigin
}
rebuilt += ":" + port
}
if rebuilt != origin {
return errOrigin
}
return nil
}
@@ -798,19 +673,6 @@ func (l *strictLoader) int64Val(key string, defaultVal int64) int64 {
return val
}
func (l *strictLoader) durationVal(key string, defaultVal time.Duration) time.Duration {
if l.err != nil {
return 0
}
val, err := getDuration(l.sc, key, defaultVal)
if err != nil {
l.err = err
}
return val
}
func (l *strictLoader) boolVal(key string, defaultVal bool) bool {
if l.err != nil {
return false
@@ -932,42 +794,6 @@ func getInt64(sc *smartconfig.Config, key string, defaultVal int64) (int64, erro
}
}
// getDuration returns the duration value for key, or defaultVal if the
// key is omitted. A present value must be a positive Go duration string
// such as "30s" or "2m", read with time.ParseDuration; a bare number has
// no unit and is an error, as is an explicit null.
func getDuration(
sc *smartconfig.Config, key string, defaultVal time.Duration,
) (time.Duration, error) {
raw, ok := lookupValue(sc, key)
if !ok {
return defaultVal, nil
}
if raw == nil {
return 0, errNullConfigValue(key)
}
str, ok := raw.(string)
if !ok {
return 0, fmt.Errorf("config key %q: value %v (%T) is %w",
key, raw, raw, errNotADuration)
}
parsed, err := time.ParseDuration(strings.TrimSpace(str))
if err != nil {
return 0, fmt.Errorf("%s: value %q is %w",
settingName(key), str, errNotADuration)
}
if parsed <= 0 {
return 0, fmt.Errorf("%s: value %q %w",
settingName(key), str, errMustBePositive)
}
return parsed, nil
}
// getBool returns the boolean value for key, or defaultVal if the key
// is omitted. A present value that is not a boolean (or a ParseBool-able
// string), or is explicitly null, is an error; numbers are not accepted
@@ -6,7 +6,6 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"git.eeqj.de/sneak/smartconfig"
)
@@ -600,244 +599,3 @@ func TestEnsureStateDirFailsOnUncreatablePath(t *testing.T) {
t.Errorf("error %q does not name the offending key state_dir", err.Error())
}
}
// TestOmittedOriginTimeoutsAndSizeUseDefaults checks that the CORS
// origin, the upstream fetch timeout, the upstream response size limit
// and the downstream timeout default to the values pixa used before they
// could be configured.
func TestOmittedOriginTimeoutsAndSizeUseDefaults(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, signingKeyLine)
if err != nil {
t.Fatalf("minimal config should be valid, got error: %v", err)
}
if c.AccessControlAllowOrigin != "*" {
t.Errorf("AccessControlAllowOrigin = %q, want *", c.AccessControlAllowOrigin)
}
if c.UpstreamFetchTimeout != 30*time.Second {
t.Errorf("UpstreamFetchTimeout = %v, want 30s", c.UpstreamFetchTimeout)
}
if c.UpstreamMaxResponseSize != 50<<20 {
t.Errorf("UpstreamMaxResponseSize = %d, want %d (50 MiB)",
c.UpstreamMaxResponseSize, 50<<20)
}
if c.DownstreamTimeout != 60*time.Second {
t.Errorf("DownstreamTimeout = %v, want 60s", c.DownstreamTimeout)
}
}
// TestExplicitOriginTimeoutsAndSizeAreUsed checks that valid values for
// the CORS origin, the two timeouts and the response size limit are used
// as given. The size is the largest accepted, 1 GiB.
func TestExplicitOriginTimeoutsAndSizeAreUsed(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, signingKeyLine+`
access_control_allow_origin: https://app.example.com
upstream_fetch_timeout: 10s
upstream_max_response_size: 1073741824
downstream_timeout: 2m
`)
if err != nil {
t.Fatalf("valid config should load, got error: %v", err)
}
if c.AccessControlAllowOrigin != "https://app.example.com" {
t.Errorf("AccessControlAllowOrigin = %q, want https://app.example.com",
c.AccessControlAllowOrigin)
}
if c.UpstreamFetchTimeout != 10*time.Second {
t.Errorf("UpstreamFetchTimeout = %v, want 10s", c.UpstreamFetchTimeout)
}
if c.UpstreamMaxResponseSize != 1073741824 {
t.Errorf("UpstreamMaxResponseSize = %d, want 1073741824",
c.UpstreamMaxResponseSize)
}
if c.DownstreamTimeout != 2*time.Minute {
t.Errorf("DownstreamTimeout = %v, want 2m", c.DownstreamTimeout)
}
}
// TestOriginWithPortOrAnyOriginIsAccepted checks the other accepted forms
// of access_control_allow_origin: "*", an origin with a port, and origins
// whose host is an IPv4 or IPv6 address.
func TestOriginWithPortOrAnyOriginIsAccepted(t *testing.T) {
t.Parallel()
for _, origin := range []string{
"*", "http://localhost:3000", "http://192.0.2.1", "http://[2001:db8::1]:8080",
} {
c, err := configFromYAML(t, signingKeyLine+
"access_control_allow_origin: \""+origin+"\"\n")
if err != nil {
t.Fatalf("origin %q should be accepted, got error: %v", origin, err)
}
if c.AccessControlAllowOrigin != origin {
t.Errorf("AccessControlAllowOrigin = %q, want %q",
c.AccessControlAllowOrigin, origin)
}
}
}
// invalidTimeoutCases are configs where upstream_fetch_timeout or
// downstream_timeout is not a positive Go duration string; each must
// abort startup naming the key and the value.
func invalidTimeoutCases() []abortCase {
return []abortCase{
{
name: "upstream_fetch_timeout not a duration",
yaml: signingKeyLine + "upstream_fetch_timeout: soon\n",
wantErrSubstrings: []string{keyUpstreamFetchTimeout, "soon"},
},
{
name: "upstream_fetch_timeout number without a unit",
yaml: signingKeyLine + "upstream_fetch_timeout: 45\n",
wantErrSubstrings: []string{keyUpstreamFetchTimeout, "45"},
},
{
name: "upstream_fetch_timeout zero",
yaml: signingKeyLine + "upstream_fetch_timeout: 0s\n",
wantErrSubstrings: []string{keyUpstreamFetchTimeout, "0s"},
},
{
name: "upstream_fetch_timeout negative",
yaml: signingKeyLine + "upstream_fetch_timeout: -5s\n",
wantErrSubstrings: []string{keyUpstreamFetchTimeout, "-5s"},
},
{
name: "upstream_fetch_timeout null",
yaml: signingKeyLine + "upstream_fetch_timeout: null\n",
wantErrSubstrings: []string{keyUpstreamFetchTimeout, nullValueText},
},
{
name: "downstream_timeout not a duration",
yaml: signingKeyLine + "downstream_timeout: 1 minute\n",
wantErrSubstrings: []string{keyDownstreamTimeout, "1 minute"},
},
{
name: "downstream_timeout zero",
yaml: signingKeyLine + "downstream_timeout: 0s\n",
wantErrSubstrings: []string{keyDownstreamTimeout, "0s"},
},
{
name: "downstream_timeout negative",
yaml: signingKeyLine + "downstream_timeout: -1m\n",
wantErrSubstrings: []string{keyDownstreamTimeout, "-1m"},
},
{
name: "downstream_timeout null",
yaml: signingKeyLine + "downstream_timeout:\n",
wantErrSubstrings: []string{keyDownstreamTimeout, nullValueText},
},
}
}
// invalidSizeAndOriginCases are configs where upstream_max_response_size
// is not a whole number of bytes from 1 to 1 GiB, or
// access_control_allow_origin is neither "*" nor an origin; each must
// abort startup naming the key and the value.
func invalidSizeAndOriginCases() []abortCase {
badOrigins := []string{
"", // empty
"example.com", // no scheme
"https://example.com/images", // a path
"https://example.com/", // a trailing slash
// The CORS middleware reads a * inside an origin as a pattern
// that lets other sites read responses.
"https://*",
"https://*.example.com",
"https://*example.com",
"https://a.com,b.com", // two hosts
"https://example.com:", // an empty port
"https://:8443", // no host
"https://example.com:0", // a port below 1
"https://example.com:99999", // a port above 65535
"https://exämple.com", // a host name that is not ASCII
"https://example.com:443", // the default port for https
"http://example.com:80", // the default port for http
"https://example.com:08080", // a port with a leading zero
"https://01.2.3.4", // an IPv4 address with a leading zero
"https://10.0.0", // an IPv4 address with three parts
"https://192.168.1.256", // an IPv4 address part above 255
"https://example.123", // a host name whose last part is a number
"https://[0:0:0:0:0:0:0:1]", // an IPv6 address not in its shortest form
"file://example.com", // a scheme other than http or https
"HTTPS://example.com", // a scheme in upper case
"https://Example.com", // a host name in upper case
}
cases := make([]abortCase, 0, len(badOrigins))
for _, origin := range badOrigins {
cases = append(cases, abortCase{
name: "access_control_allow_origin " + origin,
yaml: signingKeyLine +
"access_control_allow_origin: \"" + origin + "\"\n",
wantErrSubstrings: []string{keyAccessControlAllowOrigin, origin},
})
}
return append(cases, []abortCase{
{
name: "access_control_allow_origin null",
yaml: signingKeyLine + "access_control_allow_origin: null\n",
wantErrSubstrings: []string{keyAccessControlAllowOrigin, nullValueText},
},
{
name: "upstream_max_response_size with a unit",
yaml: signingKeyLine + "upstream_max_response_size: 50MB\n",
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "50MB"},
},
{
name: "upstream_max_response_size fractional",
yaml: signingKeyLine + "upstream_max_response_size: 1.5\n",
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "1.5"},
},
{
name: "upstream_max_response_size zero",
yaml: signingKeyLine + "upstream_max_response_size: 0\n",
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "0"},
},
{
name: "upstream_max_response_size negative",
yaml: signingKeyLine + "upstream_max_response_size: -1\n",
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "-1"},
},
{
name: "upstream_max_response_size null",
yaml: signingKeyLine + "upstream_max_response_size: null\n",
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, nullValueText},
},
{
name: "upstream_max_response_size above 1 GiB",
yaml: signingKeyLine + "upstream_max_response_size: 1073741825\n",
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "1073741825"},
},
{
name: "upstream_max_response_size largest 64-bit integer",
yaml: signingKeyLine +
"upstream_max_response_size: 9223372036854775807\n",
wantErrSubstrings: []string{
keyUpstreamMaxResponseSize, "9223372036854775807",
},
},
}...)
}
// TestInvalidOriginTimeoutOrSizeAbortsStartup verifies the
// no-silent-fallback rule for the CORS origin, the two timeouts and the
// response size limit: a value that does not parse or is out of range
// aborts startup naming the key and the value.
func TestInvalidOriginTimeoutOrSizeAbortsStartup(t *testing.T) {
t.Parallel()
runAbortCases(t, append(invalidTimeoutCases(), invalidSizeAndOriginCases()...))
}
-33
View File
@@ -8,7 +8,6 @@ import (
"slices"
"strings"
"testing"
"time"
"sneak.berlin/go/pixa/internal/globals"
"sneak.berlin/go/pixa/internal/logger"
@@ -70,10 +69,6 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
t.Setenv("PIXA_CACHE_MAX_BYTES", "1024")
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24")
t.Setenv("PIXA_TRUSTED_PROXIES", "192.0.2.0/24")
t.Setenv("PIXA_ACCESS_CONTROL_ALLOW_ORIGIN", "https://app.example.com")
t.Setenv("PIXA_UPSTREAM_FETCH_TIMEOUT", "10s")
t.Setenv("PIXA_UPSTREAM_MAX_RESPONSE_SIZE", "1048576")
t.Setenv("PIXA_DOWNSTREAM_TIMEOUT", "2m")
c, err := newFromSmartConfig(nil)
if err != nil {
@@ -97,10 +92,6 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
cacheMaxBytesExplicit: true,
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")},
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
AccessControlAllowOrigin: "https://app.example.com",
UpstreamFetchTimeout: 10 * time.Second,
UpstreamMaxResponseSize: 1048576,
DownstreamTimeout: 2 * time.Minute,
}
if !reflect.DeepEqual(*c, want) {
@@ -289,30 +280,6 @@ func TestInvalidDebugFromEnvironmentAbortsStartup(t *testing.T) {
wantStartupError(t, err, "PIXA_DEBUG", "maybe")
}
// TestInvalidOriginTimeoutOrSizeFromEnvironmentAbortsStartup checks that
// an invalid CORS origin, timeout or response size limit in its variable
// aborts startup naming the variable and the value.
func TestInvalidOriginTimeoutOrSizeFromEnvironmentAbortsStartup(t *testing.T) {
cases := []struct {
variable string
value string
}{
{"PIXA_ACCESS_CONTROL_ALLOW_ORIGIN", "example.com"},
{"PIXA_UPSTREAM_FETCH_TIMEOUT", "soon"},
{"PIXA_UPSTREAM_MAX_RESPONSE_SIZE", "50MB"},
{"PIXA_DOWNSTREAM_TIMEOUT", "0s"},
}
for _, tc := range cases {
t.Run(tc.variable, func(t *testing.T) {
t.Setenv(tc.variable, tc.value)
_, err := configFromYAML(t, signingKeyLine)
wantStartupError(t, err, tc.variable, tc.value)
})
}
}
// TestConfigFileAloneBehavesAsBefore checks that with no variables set
// (TestMain unsets them) the config file's values are used and omitted
// keys take their defaults.
+17 -15
View File
@@ -4,9 +4,9 @@ package database
import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"io/fs"
"log/slog"
"path/filepath"
"sort"
@@ -15,12 +15,14 @@ import (
"go.uber.org/fx"
"sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/db/migrations"
"sneak.berlin/go/pixa/internal/logger"
_ "modernc.org/sqlite" // SQLite driver registration
)
//go:embed schema/*.sql
var schemaFS embed.FS
// bootstrapVersion is the migration that creates the schema_migrations
// table itself. It is applied before the normal migration loop.
const bootstrapVersion = 0
@@ -111,29 +113,29 @@ func New(lc fx.Lifecycle, params Params) (*Database, error) {
return s, nil
}
// collectMigrations reads the embedded migrations directory and returns
// collectMigrations reads the embedded schema directory and returns
// migration filenames sorted lexicographically.
func collectMigrations() ([]string, error) {
entries, err := fs.ReadDir(migrations.FS(), ".")
entries, err := schemaFS.ReadDir("schema")
if err != nil {
return nil, fmt.Errorf("failed to read migrations directory: %w", err)
return nil, fmt.Errorf("failed to read schema directory: %w", err)
}
var filenames []string
var migrations []string
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
filenames = append(filenames, entry.Name())
migrations = append(migrations, entry.Name())
}
}
sort.Strings(filenames)
sort.Strings(migrations)
return filenames, nil
return migrations, nil
}
// bootstrapMigrationsTable ensures the schema_migrations table exists
// by applying 000_migration.sql if the table is missing.
// by applying 000.sql if the table is missing.
func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error {
var tableExists int
@@ -148,9 +150,9 @@ func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger)
return nil
}
content, err := fs.ReadFile(migrations.FS(), "000_migration.sql")
content, err := schemaFS.ReadFile("schema/000.sql")
if err != nil {
return fmt.Errorf("failed to read bootstrap migration 000_migration.sql: %w", err)
return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err)
}
if log != nil {
@@ -175,12 +177,12 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
return err
}
filenames, err := collectMigrations()
migrations, err := collectMigrations()
if err != nil {
return err
}
for _, migration := range filenames {
for _, migration := range migrations {
version, parseErr := ParseMigrationVersion(migration)
if parseErr != nil {
return parseErr
@@ -206,7 +208,7 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
}
// Read and apply migration.
content, readErr := fs.ReadFile(migrations.FS(), migration)
content, readErr := schemaFS.ReadFile(filepath.Join("schema", migration))
if readErr != nil {
return fmt.Errorf("failed to read migration %s: %w", migration, readErr)
}
@@ -1,54 +0,0 @@
package database
import (
"bytes"
"database/sql"
"log/slog"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite" // SQLite driver registration
)
// TestApplyMigrations_SecondRunAppliesNothing applies the migrations twice
// to one database file, as happens when pixad starts again on the database
// it created, and checks that the second run applies none of them.
// ApplyMigrations logs a message starting with "applying" before it runs
// any migration, the bootstrap one included.
func TestApplyMigrations_SecondRunAppliesNothing(t *testing.T) {
t.Parallel()
ctx := t.Context()
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "state.sqlite3"))
if err != nil {
t.Fatalf("failed to open test db: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
var firstLog bytes.Buffer
err = ApplyMigrations(ctx, db, slog.New(slog.NewTextHandler(&firstLog, nil)))
if err != nil {
t.Fatalf("first ApplyMigrations failed: %v", err)
}
if !strings.Contains(firstLog.String(), "applying") {
t.Fatalf("first ApplyMigrations logged no applied migration:\n%s",
firstLog.String())
}
var secondLog bytes.Buffer
err = ApplyMigrations(ctx, db, slog.New(slog.NewTextHandler(&secondLog, nil)))
if err != nil {
t.Fatalf("second ApplyMigrations failed: %v", err)
}
if strings.Contains(secondLog.String(), "applying") {
t.Errorf("second ApplyMigrations ran a migration again:\n%s",
secondLog.String())
}
}
-15
View File
@@ -1,15 +0,0 @@
// Package migrations provides the embedded SQL migration files.
package migrations
import (
"embed"
"io/fs"
)
//go:embed *.sql
var files embed.FS
// FS returns the embedded filesystem containing the migration files.
func FS() fs.FS {
return files
}
-2
View File
@@ -106,8 +106,6 @@ func (s *Handlers) initImageService() error {
// Create the fetcher config
fetcherCfg := httpfetcher.DefaultConfig()
fetcherCfg.AllowHTTP = s.config.AllowHTTP
fetcherCfg.Timeout = s.config.UpstreamFetchTimeout
fetcherCfg.MaxResponseSize = s.config.UpstreamMaxResponseSize
if s.config.UpstreamConnectionsPerHost > 0 {
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
+1 -1
View File
@@ -172,7 +172,7 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
// CORS returns a CORS middleware.
func (s *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{
AllowedOrigins: []string{s.config.AccessControlAllowOrigin},
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "HEAD", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
ExposedHeaders: []string{"Link"},
@@ -9,53 +9,6 @@ import (
"sneak.berlin/go/pixa/internal/config"
)
// TestCORSAnswersWithConfiguredOrigin checks that the CORS middleware
// answers with access_control_allow_origin, where "*" lets any origin read
// responses and a single origin lets only that origin read them.
func TestCORSAnswersWithConfiguredOrigin(t *testing.T) {
t.Parallel()
const appOrigin = "https://app.example.com"
cases := []struct {
configured string
requestOrigin string
want string
}{
{"*", "https://any.example.com", "*"},
{appOrigin, appOrigin, appOrigin},
{appOrigin, "https://other.example.com", ""},
}
testHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
for _, tc := range cases {
mw := &Middleware{
log: slog.Default(),
config: &config.Config{AccessControlAllowOrigin: tc.configured},
}
handler := mw.CORS()(testHandler)
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/v1/image/example.com/a.jpg/1x1.png", nil)
req.Header.Set("Origin", tc.requestOrigin)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
got := rec.Header().Get("Access-Control-Allow-Origin")
if got != tc.want {
t.Errorf("configured %q, request from %q: "+
"Access-Control-Allow-Origin = %q, want %q",
tc.configured, tc.requestOrigin, got, tc.want)
}
}
}
func TestSecurityHeaders(t *testing.T) {
t.Parallel()
+2 -1
View File
@@ -14,6 +14,7 @@ const (
// short, so a slowloris client dribbling headers is dropped well
// before it ties up a connection for the whole ReadTimeout window.
HTTPReadHeaderTimeout = 10 * time.Second
HTTPWriteTimeout = 60 * time.Second
// HTTPIdleTimeout bounds how long an idle keep-alive connection is
// held open, so idle connections cannot accumulate without limit on a
// service targeting high concurrency.
@@ -29,7 +30,7 @@ func (s *Server) newHTTPServer() *http.Server {
Addr: fmt.Sprintf(":%d", s.config.Port),
ReadTimeout: HTTPReadTimeout,
ReadHeaderTimeout: HTTPReadHeaderTimeout,
WriteTimeout: s.config.DownstreamTimeout,
WriteTimeout: HTTPWriteTimeout,
IdleTimeout: HTTPIdleTimeout,
MaxHeaderBytes: HTTPMaxHeaderBytes,
Handler: s,
+3 -7
View File
@@ -11,15 +11,11 @@ import (
// carries every hardening timeout wired onto it, including the slowloris
// defense (ReadHeaderTimeout) and the keep-alive bound (IdleTimeout). This
// guards against a field being defined but never set on the server, so
// each assertion compares the server field to its constant, or, for
// WriteTimeout, to downstream_timeout from the config.
// each assertion compares the server field to its constant.
func TestNewHTTPServerTimeouts(t *testing.T) {
t.Parallel()
s := &Server{config: &config.Config{
Port: 8080,
DownstreamTimeout: 45 * time.Second,
}}
s := &Server{config: &config.Config{Port: 8080}}
srv := s.newHTTPServer()
@@ -30,7 +26,7 @@ func TestNewHTTPServerTimeouts(t *testing.T) {
}{
{"ReadTimeout", srv.ReadTimeout, HTTPReadTimeout},
{"ReadHeaderTimeout", srv.ReadHeaderTimeout, HTTPReadHeaderTimeout},
{"WriteTimeout", srv.WriteTimeout, 45 * time.Second},
{"WriteTimeout", srv.WriteTimeout, HTTPWriteTimeout},
{"IdleTimeout", srv.IdleTimeout, HTTPIdleTimeout},
}
@@ -18,6 +18,7 @@ import (
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/globals"
"sneak.berlin/go/pixa/internal/handlers"
"sneak.berlin/go/pixa/internal/healthcheck"
"sneak.berlin/go/pixa/internal/logger"
"sneak.berlin/go/pixa/internal/middleware"
)
@@ -51,12 +52,11 @@ func newTestServer(t *testing.T) *Server {
stateDir := t.TempDir()
cfg := &config.Config{
Debug: true,
SigningKey: testSigningKey,
StateDir: stateDir,
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
DownstreamTimeout: config.DefaultDownstreamTimeout,
Debug: true,
SigningKey: testSigningKey,
StateDir: stateDir,
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
}
lc := fxtest.NewLifecycle(t)
@@ -71,8 +71,15 @@ func newTestServer(t *testing.T) *Server {
t.Fatalf("database.New() error = %v", err)
}
hc, err := healthcheck.New(lc, healthcheck.Params{
Globals: &globals.Globals{}, Config: cfg, Logger: log, Database: db,
})
if err != nil {
t.Fatalf("healthcheck.New() error = %v", err)
}
h, err := handlers.New(lc, handlers.Params{
Logger: log, Database: db, Config: cfg,
Logger: log, Healthcheck: hc, Database: db, Config: cfg,
})
if err != nil {
t.Fatalf("handlers.New() error = %v", err)
@@ -0,0 +1,169 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"sneak.berlin/go/pixa/internal/healthcheck"
)
// unsignedImagePath is an image URL that carries no signature.
const unsignedImagePath = "/v1/image/cdn.example.com/cat.jpg/100x100.jpeg"
// TestMaintenanceModeRefusesImageRequests verifies that while maintenance
// mode is on, both image routes answer 503 Service Unavailable with a
// Retry-After header and the JSON error body the image handlers send.
func TestMaintenanceModeRefusesImageRequests(t *testing.T) {
t.Parallel()
s := newTestServer(t)
s.config.MaintenanceMode = true
requests := []struct {
method string
path string
}{
{http.MethodGet, unsignedImagePath},
{http.MethodHead, unsignedImagePath},
{http.MethodGet, "/v1/e/token/cat.jpg"},
}
for _, tc := range requests {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
s.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), tc.method, tc.path, nil))
t.Logf("status %d, body %s", rec.Code, rec.Body.String())
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d",
rec.Code, http.StatusServiceUnavailable)
}
retryAfter := rec.Header().Get("Retry-After")
seconds, err := strconv.Atoi(retryAfter)
if err != nil || seconds <= 0 {
t.Errorf("Retry-After = %q, want a positive number of seconds",
retryAfter)
}
// A HEAD response carries no body.
if tc.method == http.MethodHead {
return
}
var body struct {
Error string `json:"error"`
Status int `json:"status"`
Timestamp string `json:"timestamp"`
}
err = json.NewDecoder(rec.Body).Decode(&body)
if err != nil {
t.Fatalf("body is not JSON: %v", err)
}
if body.Error == "" || body.Status != http.StatusServiceUnavailable ||
body.Timestamp == "" {
t.Errorf("body = %+v, want an error, status %d and a timestamp",
body, http.StatusServiceUnavailable)
}
})
}
}
// TestImageRequestsServedWithoutMaintenanceMode verifies that while
// maintenance mode is off, image requests reach the image handlers instead
// of the 503. The handlers refuse an unsigned image URL with 401 and a token
// they cannot decrypt with 400, so either status shows a request got through.
func TestImageRequestsServedWithoutMaintenanceMode(t *testing.T) {
t.Parallel()
s := newTestServer(t)
s.config.MaintenanceMode = false
requests := []struct {
method string
path string
want int
}{
{http.MethodGet, unsignedImagePath, http.StatusUnauthorized},
{http.MethodHead, unsignedImagePath, http.StatusUnauthorized},
{http.MethodGet, "/v1/e/token/cat.jpg", http.StatusBadRequest},
}
for _, tc := range requests {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
s.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), tc.method, tc.path, nil))
t.Logf("status %d, body %s", rec.Code, rec.Body.String())
if rec.Code != tc.want {
t.Errorf("status = %d, want %d from the image handler",
rec.Code, tc.want)
}
})
}
}
// TestMaintenanceModeKeepsOtherRoutes verifies that while maintenance mode
// is on, the health check still answers 200 and reports it, and the login
// page and /metrics still answer 200. The image's Docker HEALTHCHECK
// requests the health check: a 503 there would make the container
// unhealthy, and upaas marks a deploy failed when its container is
// unhealthy.
func TestMaintenanceModeKeepsOtherRoutes(t *testing.T) {
t.Parallel()
s := newTestServer(t)
s.config.MaintenanceMode = true
// /metrics is routed only when its username is set.
s.config.MetricsUsername = "metrics"
s.config.MetricsPassword = "metrics-password"
s.SetupRoutes()
rec := httptest.NewRecorder()
s.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(),
http.MethodGet, "/.well-known/healthcheck.json", nil))
t.Logf("health check status %d, body %s", rec.Code, rec.Body.String())
if rec.Code != http.StatusOK {
t.Fatalf("health check status = %d, want %d", rec.Code, http.StatusOK)
}
var health healthcheck.Response
err := json.NewDecoder(rec.Body).Decode(&health)
if err != nil || !health.Maintenance {
t.Errorf("health check maintenance_mode = %v (error %v), want true",
health.Maintenance, err)
}
rec = httptest.NewRecorder()
s.ServeHTTP(rec, clientRequest(t, http.MethodGet, nil, firstClient, ""))
if rec.Code != http.StatusOK {
t.Errorf("login page status = %d, want %d", rec.Code, http.StatusOK)
}
req := httptest.NewRequestWithContext(t.Context(),
http.MethodGet, "/metrics", nil)
req.SetBasicAuth(s.config.MetricsUsername, s.config.MetricsPassword)
rec = httptest.NewRecorder()
s.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("/metrics status = %d, want %d", rec.Code, http.StatusOK)
}
}
+50 -9
View File
@@ -1,7 +1,9 @@
package server
import (
"encoding/json"
"net/http"
"strconv"
"time"
sentryhttp "github.com/getsentry/sentry-go/http"
@@ -17,6 +19,10 @@ import (
// make per minute; the next is refused with 429 Too Many Requests.
const LoginAttemptsPerMinute = 5
// MaintenanceRetryAfterSeconds is the Retry-After, in seconds, sent with
// the 503 that the image routes answer while maintenance mode is on.
const MaintenanceRetryAfterSeconds = 300
// SetupRoutes configures all HTTP routes.
func (s *Server) SetupRoutes() {
s.router = chi.NewRouter()
@@ -33,7 +39,7 @@ func (s *Server) SetupRoutes() {
}
s.router.Use(s.mw.CORS())
s.router.Use(middleware.Timeout(s.config.DownstreamTimeout))
s.router.Use(middleware.Timeout(HTTPWriteTimeout))
if s.sentryEnabled {
sentryHandler := sentryhttp.New(sentryhttp.Options{
@@ -68,15 +74,23 @@ func (s *Server) SetupRoutes() {
s.router.Get("/logout", s.h.HandleLogout())
// Main image proxy route
// /v1/image/<host>/<path>/<width>x<height>.<format>
s.router.Get("/v1/image/*", s.h.HandleImage())
s.router.Head("/v1/image/*", s.h.HandleImage())
// Image routes, refused while maintenance mode is on. Only these: the
// image's Docker HEALTHCHECK requests the health check, a 503 there
// would make the container unhealthy, and upaas marks a deploy failed
// when its container is unhealthy.
s.router.Group(func(r chi.Router) {
r.Use(s.refuseDuringMaintenance)
// Encrypted image URL route
// The trailing filename (e.g., /img.jpg) is ignored but helps
// browsers with content type
s.router.Get("/v1/e/{token}/*", s.h.HandleImageEnc())
// Main image proxy route
// /v1/image/<host>/<path>/<width>x<height>.<format>
r.Get("/v1/image/*", s.h.HandleImage())
r.Head("/v1/image/*", s.h.HandleImage())
// Encrypted image URL route
// The trailing filename (e.g., /img.jpg) is ignored but helps
// browsers with content type
r.Get("/v1/e/{token}/*", s.h.HandleImageEnc())
})
// Metrics endpoint with auth
if s.config.MetricsUsername != "" {
@@ -86,3 +100,30 @@ func (s *Server) SetupRoutes() {
})
}
}
// refuseDuringMaintenance answers a request with 503 Service Unavailable,
// a Retry-After header and a JSON error body while maintenance mode is on,
// and passes it on otherwise. The body has the fields of the JSON errors
// the image handlers send.
func (s *Server) refuseDuringMaintenance(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.MaintenanceMode() {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Retry-After", strconv.Itoa(MaintenanceRetryAfterSeconds))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
err := json.NewEncoder(w).Encode(map[string]any{
"error": "down for maintenance, try again later",
"status": http.StatusServiceUnavailable,
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
if err != nil {
s.log.Error("json encode error", "error", err)
}
})
}
+2 -9
View File
@@ -5,10 +5,8 @@
# or apk (detected in that order); assumes NOTHING is present (not git,
# make, or go). The linter is never installed on the host: golangci-lint
# runs only inside a container, Dockerfile.lint or the Dockerfile lint
# stage (see script/lint). A C compiler and the CGO image libraries
# (pkg-config, vips, libheif) are installed for the govips bindings.
# Both Dockerfiles run this script too, so their build dependencies are
# the ones listed here.
# stage (see script/lint). CGO image libraries (pkg-config, vips,
# libheif) are installed for the govips bindings.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -55,11 +53,6 @@ missing() {
# CGO dependencies for govips (image processing)
ensure_cgo_deps() {
# cgo compiles with gcc on Linux; build-base and build-essential
# also bring the C library headers.
if missing gcc; then
pkg_install gcc build-essential gcc build-base
fi
if missing pkg-config; then
pkg_install pkg-config pkg-config pkg-config pkgconfig
fi