Files
versitygw/website/server.go
T
Tung Lam e63998fb31 fix: log the client IP from a trusted proxy header
Behind a reverse proxy the request log and the S3, admin, website and WebUI
access logs record the proxy's address instead of the client's. Add a
client-ip-header option that takes the logged address from X-Forwarded-For or
X-Real-Ip. It is off by default, so an existing deployment logs exactly what it
logged before.

The header is only honoured when the connecting peer is listed in the new
trusted-proxies list of IPs and CIDRs. Without that, the value is whatever the
client sent: nginx's standard
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for appends to a
client-supplied header, leaving the client's own address leftmost.
X-Forwarded-For is walked right to left, skipping the listed proxies, so the
first unlisted address is the client and a forged leftmost entry is never
reached; the two-proxy case from #2243 works once both proxies are listed.
Every header line is walked (a proxy may add its own line instead of
appending), ip:port entries are accepted, and a request from an unlisted peer
keeps the socket address. Setting the header with an empty trusted-proxies list
fails startup rather than silently trusting every peer.

X-Real-Ip reads the last header line, so a proxy that adds a line rather than
replacing the client's is the one that speaks. An address carrying an IPv6 zone
is rejected: the zone is free text that the space-delimited access log would
write verbatim, shifting every field after RemoteIP. When every hop in
X-Forwarded-For is a trusted proxy, the leftmost hop is logged, as nginx's
real_ip_recursive does, since the socket peer is then the nearest proxy. The
trusted-proxies list accepts the entry "unix" to trust the peer of a listener
bound to a unix socket path, which fasthttp reports as 0.0.0.0.

The resolver, middleware, log tag and parsers live in internal/clientip, shared
by s3api, iamapi, website and webui; s3api/utils re-exports them for the S3
callers, so the standalone IAM service no longer pulls in the S3 side. On this
branch go list -deps ./iamapi drops from 375 to 299 packages, against 298 on
main.

ctx.IP() is unchanged and still returns the socket peer address, because it
feeds aws:SourceIp in IAM policy conditions and SourceIP in policy evaluation;
only the address written to the logs and S3 event records comes from the
header.

Fixes #2243.
2026-10-16 00:51:53 +00:00

194 lines
5.7 KiB
Go

// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package website
import (
"fmt"
"net"
"os"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/logger"
"github.com/gofiber/fiber/v3/middleware/recover"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/debuglogger"
"github.com/versity/versitygw/internal/netutil"
"github.com/versity/versitygw/s3api/middlewares"
"github.com/versity/versitygw/s3api/utils"
)
// Server is the static website hosting endpoint.
type Server struct {
app *fiber.App
CertStorage *netutil.CertStorage
domain string
quiet bool
socketPerm os.FileMode
clientIPHeader string
trustedProxies utils.TrustedProxies
// clientIPHeaderErr holds an invalid WithClientIPHeader value; NewServer
// has no error return, so ServeMultiPort reports it at startup rather
// than logging the socket address silently.
clientIPHeaderErr error
}
// Option sets various options for NewServer().
type Option func(*Server)
// WithQuiet silences default logging output.
func WithQuiet() Option {
return func(s *Server) { s.quiet = true }
}
// WithTLS sets TLS credentials.
func WithTLS(cs *netutil.CertStorage) Option {
return func(s *Server) { s.CertStorage = cs }
}
// WithSocketPerm sets the file-mode permissions applied to file-backed UNIX
// domain sockets after binding. It has no effect on TCP/IP or abstract
// namespace sockets.
func WithSocketPerm(perm os.FileMode) Option {
return func(s *Server) { s.socketPerm = perm }
}
// WithClientIPHeader records the client address from the named proxy header in
// the website request log. The accepted values are
// utils.ClientIPHeaderForwardedFor and utils.ClientIPHeaderRealIP; an empty
// value (the default) keeps the socket peer address. The header is only
// honoured from a peer listed in WithTrustedProxies.
func WithClientIPHeader(header string) Option {
return func(s *Server) { s.clientIPHeader = header }
}
// WithTrustedProxies sets the peers whose client IP header is trusted, as
// parsed by utils.ParseTrustedProxies. It only has an effect together with
// WithClientIPHeader.
func WithTrustedProxies(trusted utils.TrustedProxies) Option {
return func(s *Server) { s.trustedProxies = trusted }
}
// NewServer creates a new static website hosting server.
// The domain parameter is the base domain for virtual-host routing:
// - Host "blog.<domain>" resolves to bucket "blog"
// - Host "<domain>" (apex, no subdomain) resolves to bucket "<domain>"
func NewServer(be backend.Backend, domain string, opts ...Option) *Server {
app := fiber.New(fiber.Config{
AppName: "versitygw-website",
ServerHeader: "VERSITYGW",
})
server := &Server{
app: app,
domain: domain,
}
for _, opt := range opts {
opt(server)
}
// validate the header here, so the request path can compare it directly
// instead of re-normalizing on every request; an invalid value is
// reported by ServeMultiPort
if server.clientIPHeader != "" {
header, err := utils.NormalizeClientIPHeader(server.clientIPHeader)
if err != nil {
server.clientIPHeaderErr = err
server.clientIPHeader = ""
} else {
server.clientIPHeader = header
}
}
domainInfo := "catch-all"
if domain != "" {
domainInfo = "domain: " + domain
}
// Panic recovery
app.Use("*", recover.New())
// Request logging
if !server.quiet {
fmt.Printf("initializing website endpoint (%s)\n", domainInfo)
app.Use("*", logger.New(logger.Config{
Format: "${time} | website | ${status} | ${latency} | ${ip} | ${method} | ${path}\n",
CustomTags: map[string]logger.LogFunc{
logger.TagIP: utils.ClientIPTag,
},
}))
}
// resolve the logged client address from a trusted proxy header, when
// one is configured
if server.clientIPHeader != "" {
app.Use("*", utils.ClientIPMiddleware(server.clientIPHeader, server.trustedProxies))
}
// initialize the debug logger in debug mode
if debuglogger.IsDebugEnabled() {
app.Use("*", middlewares.DebugLogger())
}
registerWebsiteRoutes(app, be, domain)
return server
}
// ServeMultiPort creates listeners for multiple address specifications and serves
// on all of them simultaneously.
func (s *Server) ServeMultiPort(ports []string) error {
if s.clientIPHeaderErr != nil {
return s.clientIPHeaderErr
}
if len(ports) == 0 {
return fmt.Errorf("no addresses specified")
}
var listeners []net.Listener
for _, addrSpec := range ports {
var ln net.Listener
var err error
if s.CertStorage != nil {
ln, err = netutil.NewMultiAddrTLSListener(fiber.NetworkTCP, addrSpec, s.CertStorage.GetCertificate, netutil.ListenerOptions{SocketPerm: s.socketPerm})
} else {
ln, err = netutil.NewMultiAddrListener(fiber.NetworkTCP, addrSpec, netutil.ListenerOptions{SocketPerm: s.socketPerm})
}
if err != nil {
return fmt.Errorf("failed to bind website listener %s: %w", addrSpec, err)
}
listeners = append(listeners, ln)
}
if len(listeners) == 0 {
return fmt.Errorf("failed to create any website listeners")
}
finalListener := netutil.NewMultiListener(listeners...)
return s.app.Listener(finalListener, fiber.ListenConfig{
DisableStartupMessage: true,
})
}
// Shutdown gracefully shuts down the server.
func (s *Server) Shutdown() error {
return s.app.Shutdown()
}