Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1c1bffb5a | ||
|
|
557b4f621a |
+5
-2
@@ -13,9 +13,12 @@ RUN go mod download
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Run formatting check and linter
|
||||
# Run formatting check and linter. The linter is invoked directly, not
|
||||
# via `make lint`: `make lint` now builds Dockerfile.lint, and there is
|
||||
# no Docker inside a Docker build. This is the same linter, image, and
|
||||
# config that Dockerfile.lint and script/lint run.
|
||||
RUN make fmt-check
|
||||
RUN make lint
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
# Build stage
|
||||
# golang:1.25.4-alpine, 2026-02-25
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Dockerfile.lint: the one and only path that runs golangci-lint.
|
||||
#
|
||||
# golangci-lint is never installed on the host; it runs only inside this
|
||||
# build. A clean build of this file therefore IS a clean lint over the
|
||||
# whole tree. It runs the same linter and config as Dockerfile's lint
|
||||
# stage, pinned to the same image so the two cannot drift to different
|
||||
# linter versions.
|
||||
#
|
||||
# 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
|
||||
|
||||
# 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 go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Caching is deliberately waived for the lint step: an unchanged tree
|
||||
# must still run the linter, not return a cached success in well under a
|
||||
# second having linted nothing. CACHEBUST carries a value that differs
|
||||
# on every run (script/lint supplies it and refuses to build without
|
||||
# one). The lint RUN below references it, so BuildKit cannot serve that
|
||||
# step from cache. Keep the ${CACHEBUST} reference on that step: dropping
|
||||
# it lets the linter cache again and report a green that linted nothing.
|
||||
ARG CACHEBUST
|
||||
RUN test -n "${CACHEBUST}" || { \
|
||||
echo "Dockerfile.lint requires the CACHEBUST build-arg; build it via script/lint." >&2; \
|
||||
exit 1; }
|
||||
|
||||
# `golangci-lint config verify` is deliberately not run: it fetches its
|
||||
# JSON schema over an unpinned live HTTPS call, which REPO_POLICIES.md
|
||||
# forbids for external references.
|
||||
RUN echo "pixa-lint: running golangci-lint (${CACHEBUST})" && \
|
||||
golangci-lint run --config .golangci.yml ./...
|
||||
@@ -10,7 +10,7 @@ ifdef HAS_PKGCONFIG
|
||||
NIX_RUN_PREFIX =
|
||||
NIX_RUN_SUFFIX =
|
||||
else
|
||||
NIX_RUN_PREFIX = nix-shell -p pkg-config vips libheif golangci-lint git --run '
|
||||
NIX_RUN_PREFIX = nix-shell -p pkg-config vips libheif git --run '
|
||||
NIX_RUN_SUFFIX = '
|
||||
endif
|
||||
|
||||
|
||||
@@ -126,15 +126,6 @@ Configured via YAML file (`--config`). Key settings:
|
||||
added to the always-enforced built-in ranges (loopback, private,
|
||||
link-local, CGNAT, benchmark, NAT64, and the like); an invalid CIDR
|
||||
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 rightmost forwarded entry that is not itself a
|
||||
trusted proxy. Otherwise the direct peer address is used and the header
|
||||
is ignored, so a client connecting directly cannot spoof its address.
|
||||
Omitted or empty trusts no one; an invalid CIDR aborts startup. Set
|
||||
this to your proxy's address range when deploying behind a reverse
|
||||
proxy
|
||||
- `upstream_fetch_timeout` — timeout for origin requests
|
||||
- `upstream_max_response_size` — max origin response size
|
||||
- `downstream_timeout` — client response timeout
|
||||
|
||||
@@ -30,17 +30,6 @@ exhaustion
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-09-21 trusted-proxy client IP resolution (closes #94): a
|
||||
`trusted_proxies` config key taking a list of CIDRs, parsed by the same
|
||||
`net/netip` list parser as `blocked_networks` (an invalid entry aborts
|
||||
startup naming the key and value; omitted or empty trusts no one); a new
|
||||
`internal/clientip` package resolves the client address by honoring
|
||||
`X-Forwarded-For` only when the direct peer is a trusted proxy, walking
|
||||
the chain right-to-left to the rightmost non-proxy entry, so a client
|
||||
connecting directly cannot spoof its address; the resolved address is
|
||||
stored in the request context by a new middleware and used by the
|
||||
request-logging middleware and the login-attempt logs in place of the
|
||||
raw peer address; documented in `README.md` and `config.example.yml`.
|
||||
- 2026-09-21 blocked networks configuration extending SSRF protection: a
|
||||
`blocked_networks` config key taking a list of CIDRs (parsed with
|
||||
`net/netip`, an invalid entry aborts startup naming the key and value),
|
||||
@@ -50,6 +39,17 @@ exhaustion
|
||||
(IPv4-mapped forms covered); enforcement stays in the dial-time
|
||||
re-resolution so the DNS-rebinding window remains closed; documented in
|
||||
`README.md` and `config.example.yml`.
|
||||
- 2026-09-21 run all linting in Docker via `Dockerfile.lint` +
|
||||
`script/lint` (closes #104): `script/lint` builds a hash-pinned root
|
||||
`Dockerfile.lint`, and no host or nix-shell `golangci-lint` path
|
||||
remains (`script/bootstrap` installs no linter, and the Makefile and
|
||||
`script/test` nix-shell package lists carry only build/test deps); a
|
||||
per-run `CACHEBUST` build-arg forces the lint step to execute every
|
||||
run, so an unchanged tree cannot return a cached green that linted
|
||||
nothing; `Dockerfile`'s lint stage runs `golangci-lint` directly,
|
||||
since `make lint` now builds a container and there is no Docker inside
|
||||
a build; `golangci-lint config verify` stays out, as it fetches its
|
||||
schema over an unpinned live HTTPS call
|
||||
- 2026-09-21 http.Server hardening (closes #92): added
|
||||
`HTTPReadHeaderTimeout` (10s, bounds the slowloris header dribble) and
|
||||
`HTTPIdleTimeout` (120s, bounds keep-alive reuse) alongside the
|
||||
|
||||
@@ -31,18 +31,6 @@ allowlist_hosts:
|
||||
# - 100.64.0.0/10
|
||||
# - 2001:db8::/32
|
||||
|
||||
# CIDR ranges of the reverse proxies in front of pixa. X-Forwarded-For
|
||||
# is believed only when the direct peer is inside one of these ranges;
|
||||
# the client address in the access log and login records is then the
|
||||
# rightmost forwarded entry that is not itself a trusted proxy. A client
|
||||
# connecting directly (peer outside these ranges) cannot spoof its
|
||||
# address: the header is ignored and the peer address is used. Omitted or
|
||||
# empty trusts no one; an invalid CIDR aborts startup. Set this when
|
||||
# deploying behind a proxy.
|
||||
# trusted_proxies:
|
||||
# - 10.0.0.0/8
|
||||
# - 2001:db8::/32
|
||||
|
||||
# Allow HTTP upstream (only for testing, always use HTTPS in production)
|
||||
allow_http: false
|
||||
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
// Package clientip resolves the real client IP address of an HTTP request
|
||||
// when pixa runs behind a reverse proxy. Forwarding headers are believed
|
||||
// only when the immediate peer is a configured trusted proxy, so an
|
||||
// untrusted client cannot spoof its address by sending the header.
|
||||
package clientip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ForwardedForHeader is the request header carrying the proxy chain. It is
|
||||
// honored only when the immediate peer is a trusted proxy.
|
||||
const ForwardedForHeader = "X-Forwarded-For"
|
||||
|
||||
// Resolver determines the client IP of a request against a fixed set of
|
||||
// trusted proxy networks.
|
||||
type Resolver struct {
|
||||
trusted []netip.Prefix
|
||||
}
|
||||
|
||||
// NewResolver returns a Resolver that trusts forwarding headers only from
|
||||
// peers inside the given CIDR ranges. A nil or empty list trusts no one,
|
||||
// so the peer address is always used.
|
||||
func NewResolver(trusted []netip.Prefix) *Resolver {
|
||||
return &Resolver{trusted: trusted}
|
||||
}
|
||||
|
||||
// Resolve returns the client IP for a request whose direct peer is
|
||||
// remoteAddr (a "host:port" string as in http.Request.RemoteAddr) and
|
||||
// whose X-Forwarded-For header lines are forwardedFor (as returned by
|
||||
// http.Header.Values). When the peer is not a trusted proxy, the peer
|
||||
// address is returned and the header is ignored entirely. When the peer is
|
||||
// trusted, the header is walked right to left and the first address that is
|
||||
// not itself a trusted proxy is returned; this is the client the outermost
|
||||
// trusted proxy observed, and entries an untrusted client may have prepended
|
||||
// sit to its left and are never reached.
|
||||
func (r *Resolver) Resolve(remoteAddr string, forwardedFor []string) string {
|
||||
peer := hostOnly(remoteAddr)
|
||||
|
||||
peerAddr, err := netip.ParseAddr(peer)
|
||||
if err != nil || !r.isTrusted(peerAddr) {
|
||||
return peer
|
||||
}
|
||||
|
||||
for _, hop := range slices.Backward(forwardedForChain(forwardedFor)) {
|
||||
hopAddr, err := netip.ParseAddr(hop)
|
||||
if err != nil || r.isTrusted(hopAddr) {
|
||||
continue
|
||||
}
|
||||
|
||||
return hopAddr.String()
|
||||
}
|
||||
|
||||
return peerAddr.String()
|
||||
}
|
||||
|
||||
// isTrusted reports whether addr falls inside one of the trusted proxy
|
||||
// ranges. Addresses are unmapped first so an IPv4-mapped IPv6 form matches
|
||||
// an IPv4 range, matching the fetcher's blocklist comparison.
|
||||
func (r *Resolver) isTrusted(addr netip.Addr) bool {
|
||||
if !addr.IsValid() {
|
||||
return false
|
||||
}
|
||||
|
||||
unmapped := addr.Unmap()
|
||||
|
||||
return slices.ContainsFunc(r.trusted, func(prefix netip.Prefix) bool {
|
||||
return prefix.Contains(unmapped)
|
||||
})
|
||||
}
|
||||
|
||||
// hostOnly strips the port from a "host:port" address. A value without a
|
||||
// port (already a bare host) is returned unchanged.
|
||||
func hostOnly(remoteAddr string) string {
|
||||
host, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
return remoteAddr
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
// forwardedForChain flattens the comma-separated entries of every
|
||||
// X-Forwarded-For header line into a single ordered, trimmed list.
|
||||
func forwardedForChain(values []string) []string {
|
||||
var chain []string
|
||||
|
||||
for _, value := range values {
|
||||
for part := range strings.SplitSeq(value, ",") {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
chain = append(chain, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
// contextKey is the private key type under which the resolved client IP is
|
||||
// stored in a request context.
|
||||
type contextKey struct{}
|
||||
|
||||
// WithClientIP returns a copy of ctx carrying the resolved client IP.
|
||||
func WithClientIP(ctx context.Context, ip string) context.Context {
|
||||
return context.WithValue(ctx, contextKey{}, ip)
|
||||
}
|
||||
|
||||
// FromContext returns the resolved client IP stored in ctx, or an empty
|
||||
// string if none was set.
|
||||
func FromContext(ctx context.Context) string {
|
||||
ip, _ := ctx.Value(contextKey{}).(string)
|
||||
|
||||
return ip
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
package clientip_test
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/clientip"
|
||||
)
|
||||
|
||||
// Addresses reused across the resolver cases.
|
||||
const (
|
||||
trustedRangeV4 = "10.0.0.0/8"
|
||||
forwardedV4 = "203.0.113.7"
|
||||
untrustedV4 = "198.51.100.9"
|
||||
trustedPeer = "10.0.0.1:5000"
|
||||
)
|
||||
|
||||
// mustPrefixes parses CIDR strings into prefixes for building a resolver.
|
||||
func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix {
|
||||
t.Helper()
|
||||
|
||||
prefixes := make([]netip.Prefix, 0, len(cidrs))
|
||||
|
||||
for _, c := range cidrs {
|
||||
p, err := netip.ParsePrefix(c)
|
||||
if err != nil {
|
||||
t.Fatalf("netip.ParsePrefix(%q) error = %v", c, err)
|
||||
}
|
||||
|
||||
prefixes = append(prefixes, p)
|
||||
}
|
||||
|
||||
return prefixes
|
||||
}
|
||||
|
||||
type resolveCase struct {
|
||||
name string
|
||||
trusted []string
|
||||
remoteAddr string
|
||||
forwardedFor []string
|
||||
want string
|
||||
}
|
||||
|
||||
// runResolveCases runs each case against a resolver built from its trusted
|
||||
// list and checks the resolved address.
|
||||
func runResolveCases(t *testing.T, cases []resolveCase) {
|
||||
t.Helper()
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := clientip.NewResolver(mustPrefixes(t, tt.trusted...))
|
||||
|
||||
got := r.Resolve(tt.remoteAddr, tt.forwardedFor)
|
||||
if got != tt.want {
|
||||
t.Errorf("Resolve(%q, %v) = %q, want %q",
|
||||
tt.remoteAddr, tt.forwardedFor, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvePeerTrust covers the trust decision on the direct peer: a
|
||||
// forwarded header is believed only from a trusted peer, and a client
|
||||
// connecting directly cannot spoof its address.
|
||||
func TestResolvePeerTrust(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runResolveCases(t, []resolveCase{
|
||||
{
|
||||
name: "trusted peer honors forwarded client",
|
||||
trusted: []string{trustedRangeV4},
|
||||
remoteAddr: trustedPeer,
|
||||
forwardedFor: []string{forwardedV4},
|
||||
want: forwardedV4,
|
||||
},
|
||||
{
|
||||
name: "untrusted peer ignores forwarded header",
|
||||
trusted: []string{trustedRangeV4},
|
||||
remoteAddr: untrustedV4 + ":33333",
|
||||
forwardedFor: []string{forwardedV4},
|
||||
want: untrustedV4,
|
||||
},
|
||||
{
|
||||
name: "spoofed chain from untrusted peer cannot influence result",
|
||||
trusted: []string{trustedRangeV4},
|
||||
remoteAddr: untrustedV4 + ":33333",
|
||||
forwardedFor: []string{"1.2.3.4, 10.9.9.9, 127.0.0.1"},
|
||||
want: untrustedV4,
|
||||
},
|
||||
{
|
||||
name: "empty trusted list always uses peer",
|
||||
trusted: nil,
|
||||
remoteAddr: forwardedV4 + ":80",
|
||||
forwardedFor: []string{"10.0.0.5"},
|
||||
want: forwardedV4,
|
||||
},
|
||||
{
|
||||
name: "trusted peer with no forwarded header uses peer",
|
||||
trusted: []string{trustedRangeV4},
|
||||
remoteAddr: trustedPeer,
|
||||
forwardedFor: nil,
|
||||
want: "10.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "unparseable peer is returned unchanged",
|
||||
trusted: []string{trustedRangeV4},
|
||||
remoteAddr: "garbage",
|
||||
forwardedFor: []string{forwardedV4},
|
||||
want: "garbage",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestResolveChainWalk covers walking the X-Forwarded-For chain from a
|
||||
// trusted peer to the rightmost entry that is not itself a trusted proxy.
|
||||
func TestResolveChainWalk(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runResolveCases(t, []resolveCase{
|
||||
{
|
||||
name: "rightmost untrusted entry across a mixed chain",
|
||||
trusted: []string{trustedRangeV4, "192.168.0.0/16"},
|
||||
remoteAddr: trustedPeer,
|
||||
forwardedFor: []string{forwardedV4 + ", 192.168.1.1, 10.0.0.2"},
|
||||
want: forwardedV4,
|
||||
},
|
||||
{
|
||||
name: "spoofed client behind a trusted proxy is not believed",
|
||||
trusted: []string{trustedRangeV4},
|
||||
remoteAddr: trustedPeer,
|
||||
forwardedFor: []string{"1.2.3.4, " + untrustedV4},
|
||||
want: untrustedV4,
|
||||
},
|
||||
{
|
||||
name: "chain split across multiple header lines",
|
||||
trusted: []string{trustedRangeV4},
|
||||
remoteAddr: trustedPeer,
|
||||
forwardedFor: []string{forwardedV4, "10.0.0.2"},
|
||||
want: forwardedV4,
|
||||
},
|
||||
{
|
||||
name: "garbage entries are skipped",
|
||||
trusted: []string{trustedRangeV4},
|
||||
remoteAddr: trustedPeer,
|
||||
forwardedFor: []string{forwardedV4 + ", not-an-ip"},
|
||||
want: forwardedV4,
|
||||
},
|
||||
{
|
||||
name: "all-trusted chain falls back to peer",
|
||||
trusted: []string{trustedRangeV4},
|
||||
remoteAddr: trustedPeer,
|
||||
forwardedFor: []string{"10.0.0.9, 10.0.0.2"},
|
||||
want: "10.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "trusted IPv6 peer honors forwarded client",
|
||||
trusted: []string{"2001:db8::/32"},
|
||||
remoteAddr: "[2001:db8::1]:9000",
|
||||
forwardedFor: []string{forwardedV4},
|
||||
want: forwardedV4,
|
||||
},
|
||||
{
|
||||
name: "IPv4-mapped peer matches IPv4 trusted range",
|
||||
trusted: []string{trustedRangeV4},
|
||||
remoteAddr: "[::ffff:10.0.0.1]:5000",
|
||||
forwardedFor: []string{forwardedV4},
|
||||
want: forwardedV4,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestContextRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := clientip.WithClientIP(t.Context(), forwardedV4)
|
||||
if got := clientip.FromContext(ctx); got != forwardedV4 {
|
||||
t.Errorf("FromContext = %q, want %q", got, forwardedV4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromContextAbsent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := clientip.FromContext(t.Context()); got != "" {
|
||||
t.Errorf("FromContext with no value = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
+19
-35
@@ -44,7 +44,6 @@ const (
|
||||
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
|
||||
keyCacheMaxBytes = "cache_max_bytes"
|
||||
keyBlockedNetworks = "blocked_networks"
|
||||
keyTrustedProxies = "trusted_proxies"
|
||||
)
|
||||
|
||||
// placeholderSigningKey is the dummy signing_key shipped in
|
||||
@@ -118,14 +117,6 @@ type Config struct {
|
||||
// fetcher's dialer; the built-in ranges always apply.
|
||||
BlockedNetworks []netip.Prefix
|
||||
|
||||
// TrustedProxies are the CIDR ranges of reverse proxies whose
|
||||
// forwarding headers may be believed. Forwarded headers are honored
|
||||
// only when the immediate peer falls inside one of these ranges;
|
||||
// otherwise the peer address is used and the headers are ignored, so
|
||||
// an untrusted client cannot spoof its address. Empty means trust
|
||||
// nothing and always use the peer address.
|
||||
TrustedProxies []netip.Prefix
|
||||
|
||||
// CacheMaxBytes is the disk cache size limit in bytes. Zero
|
||||
// disables the disk cache entirely. When cache_max_bytes is
|
||||
// omitted from the configuration, this holds the computed default
|
||||
@@ -194,12 +185,7 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
blockedNetworks, err := parseCIDRList(sc, keyBlockedNetworks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trustedProxies, err := parseCIDRList(sc, keyTrustedProxies)
|
||||
blockedNetworks, err := getBlockedNetworks(sc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -221,7 +207,6 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
|
||||
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
||||
BlockedNetworks: blockedNetworks,
|
||||
TrustedProxies: trustedProxies,
|
||||
}
|
||||
|
||||
// The computed default for cache_max_bytes needs a validated
|
||||
@@ -337,8 +322,7 @@ func isKnownConfigKey(key string) bool {
|
||||
switch key {
|
||||
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
|
||||
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
|
||||
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, keyBlockedNetworks,
|
||||
keyTrustedProxies, "env":
|
||||
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, keyBlockedNetworks, "env":
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -833,27 +817,27 @@ func getStringSlice(sc *smartconfig.Config) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseCIDRList parses the value of the named config key into CIDR
|
||||
// prefixes, 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 entry, a non-string entry, or an unparseable CIDR
|
||||
// aborts startup naming the key and the offending value; the default
|
||||
// (an empty list) applies only to an omitted key.
|
||||
func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
|
||||
// getBlockedNetworks parses the blocked_networks value into CIDR prefixes,
|
||||
// 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 entry, a non-string entry, or an unparseable CIDR aborts startup
|
||||
// naming the key and the offending value; a default (the built-in
|
||||
// blocklist alone) applies only to an omitted key.
|
||||
func getBlockedNetworks(sc *smartconfig.Config) ([]netip.Prefix, error) {
|
||||
if sc == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
raw, ok := sc.Get(key)
|
||||
raw, ok := sc.Get(keyBlockedNetworks)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if raw == nil {
|
||||
return nil, errNullConfigValue(key)
|
||||
return nil, errNullConfigValue(keyBlockedNetworks)
|
||||
}
|
||||
|
||||
entries, err := cidrListEntries(raw, key)
|
||||
entries, err := blockedNetworkEntries(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -864,7 +848,7 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
|
||||
prefix, err := netip.ParsePrefix(entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config key %q: value %q is %w",
|
||||
key, entry, errNotAValidCIDR)
|
||||
keyBlockedNetworks, entry, errNotAValidCIDR)
|
||||
}
|
||||
|
||||
prefixes = append(prefixes, prefix)
|
||||
@@ -873,10 +857,10 @@ func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
|
||||
return prefixes, nil
|
||||
}
|
||||
|
||||
// cidrListEntries extracts the raw entries of the named CIDR-list key as
|
||||
// blockedNetworkEntries extracts the raw blocked_networks entries as
|
||||
// trimmed, non-empty strings, from either a YAML list of strings or a
|
||||
// comma-separated string. Any other shape is a configuration error.
|
||||
func cidrListEntries(raw any, key string) ([]string, error) {
|
||||
func blockedNetworkEntries(raw any) ([]string, error) {
|
||||
switch val := raw.(type) {
|
||||
case []any:
|
||||
entries := make([]string, 0, len(val))
|
||||
@@ -885,12 +869,12 @@ func cidrListEntries(raw any, key string) ([]string, error) {
|
||||
str, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("config key %q: list entry %v (%T) is %w",
|
||||
key, item, item, errNotAString)
|
||||
keyBlockedNetworks, item, item, errNotAString)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(str) == "" {
|
||||
return nil, fmt.Errorf("config key %q: %w",
|
||||
key, errEmptyListEntry)
|
||||
keyBlockedNetworks, errEmptyListEntry)
|
||||
}
|
||||
|
||||
entries = append(entries, strings.TrimSpace(str))
|
||||
@@ -904,7 +888,7 @@ func cidrListEntries(raw any, key string) ([]string, error) {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("config key %q: value %q %w",
|
||||
key, val, errEmptyEntry)
|
||||
keyBlockedNetworks, val, errEmptyEntry)
|
||||
}
|
||||
|
||||
entries = append(entries, trimmed)
|
||||
@@ -913,6 +897,6 @@ func cidrListEntries(raw any, key string) ([]string, error) {
|
||||
return entries, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("config key %q: value %v (%T) is %w",
|
||||
key, raw, raw, errNotAStringList)
|
||||
keyBlockedNetworks, raw, raw, errNotAStringList)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestTrustedProxiesConfig checks the trusted_proxies key wiring: a valid
|
||||
// CIDR list lands in TrustedProxies in order, and an omitted key trusts no
|
||||
// one. The list parser itself is shared with blocked_networks and is
|
||||
// exercised in depth by that key's tests.
|
||||
func TestTrustedProxiesConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("valid list is parsed in order", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := configFromYAML(t,
|
||||
signingKeyLine+`trusted_proxies: ["10.0.0.0/8", "2001:db8::/32"]`+"\n")
|
||||
if err != nil {
|
||||
t.Fatalf("valid trusted_proxies should load: %v", err)
|
||||
}
|
||||
|
||||
got := make([]string, len(c.TrustedProxies))
|
||||
for i, p := range c.TrustedProxies {
|
||||
got[i] = p.String()
|
||||
}
|
||||
|
||||
if joined := strings.Join(got, ","); joined != "10.0.0.0/8,2001:db8::/32" {
|
||||
t.Errorf("TrustedProxies = %v, want the two ranges in order", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("omitted key trusts no one", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := configFromYAML(t, signingKeyLine)
|
||||
if err != nil {
|
||||
t.Fatalf("minimal config should load: %v", err)
|
||||
}
|
||||
|
||||
if len(c.TrustedProxies) != 0 {
|
||||
t.Errorf("TrustedProxies = %v, want empty", c.TrustedProxies)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTrustedProxiesInvalidAbortsStartup checks that an invalid or null
|
||||
// value aborts startup with an error naming the key and the offending value.
|
||||
func TestTrustedProxiesInvalidAbortsStartup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runAbortCases(t, []abortCase{
|
||||
{
|
||||
name: "invalid cidr",
|
||||
yaml: signingKeyLine + `trusted_proxies: ["999.0.0.0/8"]` + "\n",
|
||||
wantErrSubstrings: []string{keyTrustedProxies, "999.0.0.0/8"},
|
||||
},
|
||||
{
|
||||
name: "null value",
|
||||
yaml: signingKeyLine + "trusted_proxies:\n",
|
||||
wantErrSubstrings: []string{keyTrustedProxies, nullValueText},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/clientip"
|
||||
"sneak.berlin/go/pixa/internal/encurl"
|
||||
"sneak.berlin/go/pixa/internal/imgcache"
|
||||
"sneak.berlin/go/pixa/internal/templates"
|
||||
@@ -48,8 +47,7 @@ func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
if subtle.ConstantTimeCompare([]byte(submittedKey), []byte(s.config.SigningKey)) != 1 {
|
||||
s.log.Warn("failed login attempt",
|
||||
"remote_addr", clientip.FromContext(r.Context()))
|
||||
s.log.Warn("failed login attempt", "remote_addr", r.RemoteAddr)
|
||||
s.renderLogin(w, r, "Invalid signing key")
|
||||
|
||||
return
|
||||
@@ -64,8 +62,7 @@ func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Info("successful login",
|
||||
"remote_addr", clientip.FromContext(r.Context()))
|
||||
s.log.Info("successful login", "remote_addr", r.RemoteAddr)
|
||||
|
||||
// Redirect to generator page
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/clientip"
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
)
|
||||
|
||||
// TestFailedLoginLogsResolvedClientIP verifies the failed-login record
|
||||
// carries the resolved client IP from the request context, not the raw
|
||||
// proxy peer address.
|
||||
func TestFailedLoginLogsResolvedClientIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
h := &Handlers{
|
||||
log: slog.New(slog.NewJSONHandler(&buf, nil)),
|
||||
config: &config.Config{SigningKey: testSigningKey},
|
||||
}
|
||||
|
||||
form := url.Values{loginKeyField: {"wrong-key"}}
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodPost, "/",
|
||||
strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req = req.WithContext(clientip.WithClientIP(req.Context(), "203.0.113.7"))
|
||||
|
||||
h.handleLoginPost(httptest.NewRecorder(), req)
|
||||
|
||||
if !strings.Contains(buf.String(), `"remote_addr":"203.0.113.7"`) {
|
||||
t.Errorf("failed-login log missing resolved client IP; got %q", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/clientip"
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
)
|
||||
|
||||
// testForwardedClient is the client address the proxy forwards.
|
||||
const testForwardedClient = "203.0.113.7"
|
||||
|
||||
// newTestMiddleware builds a Middleware whose resolver trusts the given
|
||||
// CIDRs and whose logger writes JSON to buf.
|
||||
func newTestMiddleware(t *testing.T, buf *bytes.Buffer, trusted ...string) *Middleware {
|
||||
t.Helper()
|
||||
|
||||
prefixes := make([]netip.Prefix, 0, len(trusted))
|
||||
|
||||
for _, c := range trusted {
|
||||
p, err := netip.ParsePrefix(c)
|
||||
if err != nil {
|
||||
t.Fatalf("netip.ParsePrefix(%q) error = %v", c, err)
|
||||
}
|
||||
|
||||
prefixes = append(prefixes, p)
|
||||
}
|
||||
|
||||
return &Middleware{
|
||||
log: slog.New(slog.NewJSONHandler(buf, nil)),
|
||||
config: &config.Config{TrustedProxies: prefixes},
|
||||
clientIP: clientip.NewResolver(prefixes),
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientIPMiddlewareStoresResolvedIP verifies the ClientIP middleware
|
||||
// puts the resolved address into the request context for a trusted and an
|
||||
// untrusted peer.
|
||||
func TestClientIPMiddlewareStoresResolvedIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
forwarded string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "trusted peer honors forwarded client",
|
||||
remoteAddr: "10.0.0.1:5000",
|
||||
forwarded: testForwardedClient,
|
||||
want: testForwardedClient,
|
||||
},
|
||||
{
|
||||
name: "untrusted peer ignores forwarded header",
|
||||
remoteAddr: "198.51.100.9:5000",
|
||||
forwarded: testForwardedClient,
|
||||
want: "198.51.100.9",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mw := newTestMiddleware(t, &bytes.Buffer{}, "10.0.0.0/8")
|
||||
|
||||
var got string
|
||||
|
||||
handler := mw.ClientIP()(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, r *http.Request) {
|
||||
got = clientip.FromContext(r.Context())
|
||||
}))
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = tt.remoteAddr
|
||||
req.Header.Set("X-Forwarded-For", tt.forwarded)
|
||||
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if got != tt.want {
|
||||
t.Errorf("client IP in context = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoggingUsesResolvedClientIP verifies the logging middleware records
|
||||
// the resolved forwarded client IP rather than the proxy peer address.
|
||||
func TestLoggingUsesResolvedClientIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
mw := newTestMiddleware(t, &buf, "10.0.0.0/8")
|
||||
|
||||
handler := mw.ClientIP()(mw.Logging()(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})))
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "10.0.0.1:5000"
|
||||
req.Header.Set("X-Forwarded-For", testForwardedClient)
|
||||
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if !strings.Contains(buf.String(), `"remoteIP":"`+testForwardedClient+`"`) {
|
||||
t.Errorf("log output missing resolved client IP; got %q", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -13,7 +14,6 @@ import (
|
||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||
"github.com/slok/go-http-metrics/middleware/std"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/pixa/internal/clientip"
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
"sneak.berlin/go/pixa/internal/logger"
|
||||
)
|
||||
@@ -58,34 +58,31 @@ type Params struct {
|
||||
|
||||
// Middleware provides HTTP middleware functions.
|
||||
type Middleware struct {
|
||||
log *slog.Logger
|
||||
config *config.Config
|
||||
clientIP *clientip.Resolver
|
||||
log *slog.Logger
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// New creates a new Middleware instance.
|
||||
func New(_ fx.Lifecycle, params Params) (*Middleware, error) {
|
||||
s := &Middleware{
|
||||
log: params.Logger.Get(),
|
||||
config: params.Config,
|
||||
clientIP: clientip.NewResolver(params.Config.TrustedProxies),
|
||||
log: params.Logger.Get(),
|
||||
config: params.Config,
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ClientIP returns a middleware that resolves the real client IP,
|
||||
// honoring X-Forwarded-For only from trusted proxies, and stores it in
|
||||
// the request context for the logging middleware and handlers to read.
|
||||
func (s *Middleware) ClientIP() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := s.clientIP.Resolve(
|
||||
r.RemoteAddr, r.Header.Values(clientip.ForwardedForHeader))
|
||||
ctx := clientip.WithClientIP(r.Context(), ip)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
func ipFromHostPort(hp string) string {
|
||||
h, _, err := net.SplitHostPort(hp)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(h) > 0 && h[0] == '[' {
|
||||
return h[1 : len(h)-1]
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
type loggingResponseWriter struct {
|
||||
@@ -130,7 +127,7 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
||||
"request_id", reqID,
|
||||
"referer", r.Referer(),
|
||||
"proto", r.Proto,
|
||||
"remoteIP", clientip.FromContext(ctx),
|
||||
"remoteIP", ipFromHostPort(r.RemoteAddr),
|
||||
"status", lrw.statusCode,
|
||||
"response_bytes", lrw.bytesWritten,
|
||||
"latency_ms", latency.Milliseconds(),
|
||||
|
||||
@@ -18,7 +18,6 @@ func (s *Server) SetupRoutes() {
|
||||
|
||||
s.router.Use(middleware.Recoverer)
|
||||
s.router.Use(middleware.RequestID)
|
||||
s.router.Use(s.mw.ClientIP())
|
||||
s.router.Use(s.mw.SecurityHeaders())
|
||||
s.router.Use(s.mw.Logging())
|
||||
|
||||
|
||||
+4
-58
@@ -3,20 +3,13 @@
|
||||
# this repo. Idempotent: every install is guarded by a check so already
|
||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||
# make, or go). golangci-lint is packaged in nix, brew, and apk; on apt
|
||||
# it is installed from a hash-verified GitHub release archive (never
|
||||
# curl | sh). CGO image libraries (pkg-config, vips, libheif) are
|
||||
# installed for the govips bindings.
|
||||
# make, or go). The linter is never installed on the host: golangci-lint
|
||||
# runs only inside Dockerfile.lint (see script/lint). CGO image libraries
|
||||
# (pkg-config, vips, libheif) are installed for the govips bindings.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# Pinned versions, 2026-08-07. Never "latest"; exact versions only.
|
||||
GOLANGCI_LINT_VERSION="2.12.2"
|
||||
# sha256 of golangci-lint-2.12.2-linux-<arch>.tar.gz release archives
|
||||
GOLANGCI_LINT_SHA256_AMD64="8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553"
|
||||
GOLANGCI_LINT_SHA256_ARM64="44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a"
|
||||
|
||||
PKGMGR=""
|
||||
SUDO=""
|
||||
|
||||
@@ -57,52 +50,6 @@ missing() {
|
||||
! command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# verify_sha256 <file> <expected-hash>
|
||||
verify_sha256() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
actual="$(sha256sum "$1" | cut -d' ' -f1)"
|
||||
else
|
||||
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
|
||||
fi
|
||||
if [ "$actual" != "$2" ]; then
|
||||
echo "bootstrap: sha256 mismatch for $1" >&2
|
||||
echo " expected: $2" >&2
|
||||
echo " actual: $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# apt has no golangci-lint package: install a pinned release archive
|
||||
# from GitHub, verified by hardcoded sha256 (never curl | sh).
|
||||
install_golangci_lint_release() {
|
||||
case "$(uname -m)" in
|
||||
x86_64) goarch="amd64"; sha="$GOLANGCI_LINT_SHA256_AMD64" ;;
|
||||
aarch64|arm64) goarch="arm64"; sha="$GOLANGCI_LINT_SHA256_ARM64" ;;
|
||||
*)
|
||||
echo "bootstrap: unsupported architecture $(uname -m)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if missing curl; then pkg_install curl curl curl curl; fi
|
||||
name="golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${goarch}"
|
||||
tmp="$(mktemp -d)"
|
||||
curl -fsSL -o "$tmp/$name.tar.gz" \
|
||||
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${name}.tar.gz"
|
||||
verify_sha256 "$tmp/$name.tar.gz" "$sha"
|
||||
tar -xzf "$tmp/$name.tar.gz" -C "$tmp"
|
||||
$SUDO install -m 0755 "$tmp/$name/golangci-lint" /usr/local/bin/golangci-lint
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
ensure_golangci_lint() {
|
||||
if ! missing golangci-lint; then return 0; fi
|
||||
detect_pkgmgr
|
||||
case "$PKGMGR" in
|
||||
apt) install_golangci_lint_release ;;
|
||||
*) pkg_install golangci-lint golangci-lint golangci-lint golangci-lint ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# CGO dependencies for govips (image processing)
|
||||
ensure_cgo_deps() {
|
||||
if missing pkg-config; then
|
||||
@@ -123,9 +70,8 @@ main() {
|
||||
if missing git; then pkg_install git git git git; fi
|
||||
if missing make; then pkg_install gnumake make make make; fi
|
||||
|
||||
# Go toolchain and linter
|
||||
# Go toolchain
|
||||
if missing go; then pkg_install go golang go go; fi
|
||||
ensure_golangci_lint
|
||||
|
||||
# CGO image libraries
|
||||
ensure_cgo_deps
|
||||
|
||||
+46
-14
@@ -1,23 +1,55 @@
|
||||
#!/bin/sh
|
||||
# script/lint: run the linter. CGO dependencies (pkg-config, vips,
|
||||
# libheif) come from nix-shell when not already available (e.g. inside
|
||||
# a Docker build or an existing nix-shell).
|
||||
# script/lint: run golangci-lint over the whole tree.
|
||||
#
|
||||
# The linter is never installed on the host: it runs only inside the
|
||||
# Dockerfile.lint build, one way, everywhere. A clean build is a clean
|
||||
# lint. See Dockerfile.lint for why the lint step cannot be cached.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
run_with_cgo_deps() {
|
||||
if command -v pkg-config >/dev/null 2>&1; then
|
||||
sh -c "$1"
|
||||
else
|
||||
nix-shell -p pkg-config vips libheif golangci-lint git --run "$1"
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
|
||||
# A value no other run repeats. Dockerfile.lint folds it into the
|
||||
# lint step's cache key, so the linter re-executes every run instead
|
||||
# of an unchanged tree returning a cached success having linted
|
||||
# nothing.
|
||||
cachebust="$(date +%s)-$$"
|
||||
|
||||
tmp="$(mktemp -d "${TMPDIR:-/tmp}/pixa-lint.XXXXXX")"
|
||||
trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||
|
||||
# --progress=plain so the lint step's own output reaches the log we
|
||||
# check below; --output=type=cacheonly because we want the linter's
|
||||
# verdict, not an image left in the local store. The build status
|
||||
# travels through a file: a pipeline's exit status is tee's, not the
|
||||
# build's.
|
||||
(
|
||||
set +e
|
||||
docker build \
|
||||
--progress=plain \
|
||||
--build-arg CACHEBUST="$cachebust" \
|
||||
--output=type=cacheonly \
|
||||
-f Dockerfile.lint . 2>&1
|
||||
echo "$?" >"$tmp/status"
|
||||
) | tee "$tmp/build.log"
|
||||
|
||||
status="$(cat "$tmp/status" 2>/dev/null || echo 1)"
|
||||
[ "${status:-1}" -eq 0 ] || exit "${status:-1}"
|
||||
|
||||
# The linter's start line must appear as build output, not only in
|
||||
# the build's echo of the RUN instruction. A step served from cache
|
||||
# prints the instruction and none of its output; a step that runs
|
||||
# prints a "#<n> <elapsed> ..." output line. Requiring that output
|
||||
# line means a future edit dropping the CACHEBUST reference from
|
||||
# Dockerfile.lint fails here rather than passing having linted
|
||||
# nothing.
|
||||
if ! grep -Eq '^#[0-9]+ +[0-9]+\.[0-9]+ +pixa-lint: running golangci-lint' \
|
||||
"$tmp/build.log"; then
|
||||
echo "script/lint: golangci-lint did not execute (cached step?)." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
echo "Running linter..."
|
||||
run_with_cgo_deps "golangci-lint run"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ run_with_cgo_deps() {
|
||||
if command -v pkg-config >/dev/null 2>&1; then
|
||||
sh -c "$1"
|
||||
else
|
||||
nix-shell -p pkg-config vips libheif golangci-lint git --run "$1"
|
||||
nix-shell -p pkg-config vips libheif git --run "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user