Files
versitygw/embedgw/embedgw.go
Ben McClelland 6f1dfe84e5 feat: add options extensions to embed config
Expose S3 option injection in embed gateway config so
integrators can attach request middleware and other
server behaviors without needing to re-implement
RunVersityGW().
2026-06-24 08:54:38 -07:00

1724 lines
55 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 embedgw provides a high-level entry point for running the VersityGW
// S3 gateway as a library, making it easy to embed the gateway into other
// applications.
//
// Note: only a single gateway instance per process is currently supported.
// Several subsystems (bucket-name validation, debug logging) rely on
// package-level globals that would race if RunVersityGW were called
// concurrently from multiple goroutines.
package embedgw
import (
"context"
"fmt"
"log"
"net"
"net/url"
"os"
"strconv"
"strings"
"sync/atomic"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/debuglogger"
"github.com/versity/versitygw/metrics"
"github.com/versity/versitygw/s3api"
"github.com/versity/versitygw/s3api/middlewares"
"github.com/versity/versitygw/s3api/utils"
"github.com/versity/versitygw/s3event"
"github.com/versity/versitygw/s3log"
"github.com/versity/versitygw/website"
"github.com/versity/versitygw/webui"
)
const awsDefaultRegion = "us-east-1"
// Config holds all configuration options for running the VersityGW gateway.
type Config struct {
// RootUserAccess is the access key ID for the root account. The root
// account is granted full authorization to all API requests after
// authentication. Required.
RootUserAccess string
// RootUserSecret is the secret access key for the root account. Required.
RootUserSecret string
// Region is the AWS region name reported to S3 clients (e.g. "us-east-1").
// Defaults to "us-east-1" when empty.
Region string
// Ports is the list of S3 API listening addresses. Each entry can be
// "host:port" to bind a specific interface, or ":port" to bind all
// interfaces. Hostnames are resolved to all matching IPs. UNIX domain
// sockets are supported as absolute or relative paths, or Linux abstract
// namespace sockets prefixed with "@" (e.g. "@versitygw-s3"). Multiple
// entries are supported (e.g. [":7070", "localhost:9090"]). Required.
Ports []string
// AdminPorts is the list of admin API listening addresses. Accepts the
// same formats as Ports. When empty, the admin API is served on the same
// endpoints as the S3 API. Setting this allows finer-grained firewall
// control over the admin endpoint with optionally separate TLS certs.
AdminPorts []string
// MaxConnections is the maximum number of concurrent TCP connections
// accepted by the S3 API server.
MaxConnections int
// MaxRequests is the maximum number of concurrent in-flight S3 requests.
// Should not exceed MaxConnections; if it does, a warning is logged.
MaxRequests int
// AdminMaxConnections is the maximum concurrent TCP connections for the
// separate admin server. Only used when AdminPorts is non-empty.
AdminMaxConnections int
// AdminMaxRequests is the maximum concurrent in-flight requests for the
// admin server. Should not exceed AdminMaxConnections.
AdminMaxRequests int
// MultipartMaxParts is the maximum number of parts allowed in a single
// multipart upload. The S3 specification allows up to 10,000 parts;
// the default value of 10000 matches the AWS S3 maximum. Clients that
// attempt to upload more parts than this limit receive an error.
MultipartMaxParts int
// CertFile is the path to the TLS certificate file for the S3 API server.
// Both CertFile and KeyFile must be provided together to enable TLS.
CertFile string
// KeyFile is the path to the TLS private key file for the S3 API server.
KeyFile string
// AdminCertFile is the path to the TLS certificate for the admin server.
// Both AdminCertFile and AdminKeyFile must be provided together. When
// empty and AdminPorts is set, the admin server runs without TLS.
AdminCertFile string
// AdminKeyFile is the path to the TLS private key for the admin server.
AdminKeyFile string
// CORSAllowOrigin sets the default Access-Control-Allow-Origin response
// header value applied when no bucket-level CORS configuration exists and
// for all admin API responses. When WebuiPorts is set and this is empty,
// it defaults to "*". For production, set this to a specific origin
// (e.g. "https://webui.example.com") to restrict cross-origin access.
CORSAllowOrigin string
// Debug enables verbose debug logging to stdout, including details for
// signature verification steps. Not intended for production use.
Debug bool
// IAMDebug enables verbose IAM subsystem debug logging.
IAMDebug bool
// Quiet suppresses per-request summary logging to stdout.
Quiet bool
// Readonly restricts the gateway to read-only S3 operations; all write
// requests are rejected.
Readonly bool
// KeepAlive enables HTTP keep-alive on S3 API connections.
KeepAlive bool
// DisableACLs disables ACL enforcement at the gateway level. All ACL
// headers on requests are ignored and no access control is enforced via
// bucket ACLs. PutBucketAcl returns AccessControlListNotSupported.
// Prefer bucket policies over ACLs when this is enabled.
DisableACLs bool
// DisableStrictBucketNames allows legacy or non-DNS-compliant bucket
// names by skipping strict validation. By default, bucket name validation
// follows the rules described in the AWS S3 documentation.
DisableStrictBucketNames bool
// VirtualDomain enables virtual-hosted-style bucket addressing. Set to
// the base domain name (e.g. "s3.example.com") so that bucket access uses
// the form "https://<bucket>.s3.example.com/". Path-style addressing
// remains enabled alongside it. Each bucket typically requires a DNS
// entry pointing to the gateway.
VirtualDomain string
// HealthPath is the URL path for unauthenticated health-check requests
// (e.g. "/healthz"). The endpoint returns HTTP 200 for GET requests and
// is commonly used by load balancers. Any bucket whose name matches the
// path segment is masked while this is set.
HealthPath string
// SocketPerm is the octal file-mode string for UNIX domain socket
// permissions (e.g. "0660" for owner+group read/write). Has no effect on
// TCP/IP addresses or Linux abstract "@" namespace sockets. When empty,
// permissions are determined by the process umask.
SocketPerm string
// IAM Backends
//
// The gateway supports five external IAM backends. At most one may be
// active at a time. When the fields for more than one backend are
// populated, the first match in the following priority order wins:
//
// 1. IAMDir -- local directory
// 2. LDAPServerURL -- LDAP
// 3. S3IAMEndpoint -- S3-backed
// 4. VaultEndpointURL -- HashiCorp Vault
// 5. IpaHost -- FreeIPA
//
// Configuring an IAM backend is optional. When none of the trigger fields
// above are set, the gateway runs in single-account mode: only the root
// account (RootUserAccess/RootUserSecret) exists and the user management
// API is unavailable.
//
// The IAMCache fields below apply to all backends except single-account
// mode.
// IAMDir enables the local file-based IAM backend. Set to the directory
// path where account files are stored. Account data is plain text
// protected only by filesystem permissions; suitable for development but
// not recommended for production deployments.
IAMDir string
// LDAP IAM backend. Activated when LDAPServerURL is non-empty.
// LDAPServerURL is the URL of the LDAP server (e.g. "ldap://ldap.example.com:389").
LDAPServerURL string
// LDAPBindDN is the distinguished name used to bind to the LDAP server.
LDAPBindDN string
// LDAPPassword is the password for LDAPBindDN.
LDAPPassword string
// LDAPQueryBase is the base DN for user search queries.
LDAPQueryBase string
// LDAPObjClasses is the LDAP object class filter for user entries.
LDAPObjClasses string
// LDAPAccessAttr is the LDAP attribute that holds the S3 access key ID.
LDAPAccessAttr string
// LDAPSecretAttr is the LDAP attribute that holds the S3 secret key.
LDAPSecretAttr string
// LDAPRoleAttr is the LDAP attribute that holds the user role.
LDAPRoleAttr string
// LDAPUserIDAttr is the LDAP attribute that holds the POSIX user ID.
LDAPUserIDAttr string
// LDAPGroupIDAttr is the LDAP attribute that holds the POSIX group ID.
LDAPGroupIDAttr string
// LDAPProjectIDAttr is the LDAP attribute that holds the project ID.
LDAPProjectIDAttr string
// LDAPTLSSkipVerify disables TLS certificate verification for the LDAP
// connection. Use only in development or trusted internal environments.
LDAPTLSSkipVerify bool
// HashiCorp Vault IAM backend. Activated when VaultEndpointURL is non-empty.
// VaultEndpointURL is the HashiCorp Vault server URL
// (e.g. "https://vault.example.com:8200").
VaultEndpointURL string
// VaultNamespace is the Vault namespace to use (Vault Enterprise only).
VaultNamespace string
// VaultSecretStoragePath is the KV secrets engine path where account
// data is stored.
VaultSecretStoragePath string
// VaultSecretStorageNamespace is the Vault namespace for the secrets
// storage path (Vault Enterprise only).
VaultSecretStorageNamespace string
// VaultAuthMethod is the Vault authentication method to use
// (e.g. "token", "approle").
VaultAuthMethod string
// VaultAuthNamespace is the Vault namespace used for authentication
// (Vault Enterprise only).
VaultAuthNamespace string
// VaultMountPath is the mount path of the auth method in Vault.
VaultMountPath string
// VaultRootToken is the Vault token used when VaultAuthMethod is "token".
VaultRootToken string
// VaultRoleID is the AppRole role ID used when VaultAuthMethod is "approle".
VaultRoleID string
// VaultRoleSecret is the AppRole secret ID.
VaultRoleSecret string
// VaultServerCert is the path to the CA certificate used to verify the
// Vault server's TLS certificate.
VaultServerCert string
// VaultClientCert is the path to the client TLS certificate for mutual
// TLS authentication with Vault.
VaultClientCert string
// VaultClientCertKey is the path to the private key for VaultClientCert.
VaultClientCertKey string
// S3-backed IAM backend. Activated when S3IAMEndpoint is non-empty.
// S3IAMAccess is the access key ID for the S3-backed IAM backend.
S3IAMAccess string
// S3IAMSecret is the secret key for the S3-backed IAM backend.
S3IAMSecret string
// S3IAMRegion is the AWS region of the S3-backed IAM bucket.
S3IAMRegion string
// S3IAMBucket is the bucket name that stores IAM account data.
S3IAMBucket string
// S3IAMEndpoint is the endpoint URL for the S3-backed IAM service.
// Useful when using a non-AWS S3-compatible store.
S3IAMEndpoint string
// S3IAMDisableSSLVerify disables TLS certificate verification for the
// S3-backed IAM connection. Use only in development or trusted internal
// environments.
S3IAMDisableSSLVerify bool
// FreeIPA IAM backend. Activated when IpaHost is non-empty.
// IpaHost is the hostname or URL of the FreeIPA server.
IpaHost string
// IpaVaultName is the name of the FreeIPA vault used to store credentials.
IpaVaultName string
// IpaUser is the FreeIPA username for authentication.
IpaUser string
// IpaPassword is the FreeIPA password for authentication.
IpaPassword string
// IpaInsecure disables TLS certificate verification for the FreeIPA
// connection.
IpaInsecure bool
// IAM Cache
//
// The gateway maintains an in-memory cache of IAM account lookups to
// reduce load on the external IAM backend. The cache applies to all
// backends except single-account mode. All fields are optional.
// IAMCacheDisable disables the in-memory IAM account cache. By default,
// accounts are cached to reduce backend lookup frequency.
IAMCacheDisable bool
// IAMCacheTTL is the time-to-live in seconds for cached IAM entries.
IAMCacheTTL int
// IAMCachePrune is the interval in seconds between cache prune runs that
// remove expired entries.
IAMCachePrune int
// Access Logging
//
// Records details of every S3 and admin API request. All three outputs
// are independent and can be enabled simultaneously in any combination.
// All are optional; omit or leave empty to disable that output.
// AccessLog is the file path for S3 request access logs in the AWS S3
// access log format. Use absolute paths; relative paths may break if the
// server changes its working directory. Empty disables file logging.
AccessLog string
// LogWebhookURL is an HTTP(S) URL that receives S3 access log entries as
// JSON-encoded POST requests. Can be set alongside AccessLog.
LogWebhookURL string
// AdminLogFile is the file path for admin API request logs.
AdminLogFile string
// Metrics
//
// The gateway can emit operational metrics to StatsD and DogStatsD.
// Both backends may be active simultaneously; set either or both.
// All fields are optional. When neither StatsdServers nor DogstatsServers
// is set, metrics are disabled.
// MetricsService is the service name label attached to all emitted metrics.
// Defaults to the system hostname when empty.
MetricsService string
// StatsdServers is a comma-separated list of StatsD server addresses
// (e.g. "localhost:8125").
StatsdServers string
// DogstatsServers is a comma-separated list of DogStatsD server addresses.
DogstatsServers string
// Bucket Event Notifications
//
// The gateway can forward S3 bucket events (object created, deleted, etc.)
// to an external message broker or webhook. At most one event sink may be
// active at a time. When more than one sink's fields are populated, the
// first match in the following priority order wins:
//
// 1. EventWebhookURL -- HTTP/S webhook
// 2. KafkaURL -- Apache Kafka
// 3. NatsURL -- NATS
// 4. RabbitmqURL -- RabbitMQ
//
// Configuring event notifications is optional. When none of the trigger
// fields above are set, event notifications are disabled.
//
// EventConfigFilePath applies to whichever sink is active and can be set
// regardless of which sink is chosen.
// KafkaURL is the broker URL for Kafka event notifications
// (e.g. "kafka://broker:9092").
KafkaURL string
// KafkaTopic is the Kafka topic name for bucket event messages.
KafkaTopic string
// KafkaKey is the optional Kafka message key.
KafkaKey string
// NatsURL is the NATS server URL for event notifications
// (e.g. "nats://localhost:4222").
NatsURL string
// NatsTopic is the NATS subject for bucket event messages.
NatsTopic string
// RabbitmqURL is the RabbitMQ connection URL
// (e.g. "amqp://user:pass@rabbitmq:5672/").
RabbitmqURL string
// RabbitmqExchange is the RabbitMQ exchange to publish events to.
// Leave empty to use the default exchange.
RabbitmqExchange string
// RabbitmqRoutingKey is the routing key for RabbitMQ event messages.
// Leave empty to use no routing key.
RabbitmqRoutingKey string
// EventWebhookURL is an HTTP(S) URL that receives bucket event
// notifications as POST requests.
EventWebhookURL string
// EventConfigFilePath is the path to a JSON event filter configuration
// file that controls which events are forwarded to the active event sink.
// When empty, all events are forwarded. Generate a default config with:
// versitygw utils gen-event-filter-config --path <dir>
EventConfigFilePath string
// WebUI
//
// The browser-based management WebUI can be served in two independent
// modes, which may be enabled simultaneously:
//
// - Standalone server (WebuiPorts): the WebUI runs on its own dedicated
// listening address(es), separate from the S3 endpoint.
//
// - Embedded on the S3 endpoint (WebuiS3Prefix): the WebUI is served
// directly from the S3 port under a URL path prefix. Useful when only
// one listening port is available.
//
// Both modes are optional. Leave WebuiPorts empty and WebuiS3Prefix empty
// to disable the WebUI entirely.
// WebuiPorts is the list of listening addresses for the standalone WebUI
// server. Accepts the same formats as Ports. When empty, the WebUI server
// is disabled.
WebuiPorts []string
// WebuiCertFile is the path to the TLS certificate for the WebUI server.
// When empty and gateway TLS (CertFile/KeyFile) is configured, the WebUI
// inherits those certs. Both WebuiCertFile and WebuiKeyFile must be
// provided together.
WebuiCertFile string
// WebuiKeyFile is the path to the TLS private key for the WebUI server.
WebuiKeyFile string
// WebuiNoTLS forces the WebUI to use plain HTTP even when TLS certificates
// are available. Useful when TLS is terminated by a reverse proxy in front
// of the WebUI.
WebuiNoTLS bool
// WebuiGateways overrides the S3 gateway URLs provided to the WebUI. By
// default the gateway auto-detects URLs from Ports. Set this when running
// behind a reverse proxy or load balancer where the auto-detected URLs are
// incorrect (e.g. ["https://s3.example.com", "http://192.168.1.1:7070"]).
WebuiGateways []string
// WebuiAdminGateways overrides the admin gateway URLs provided to the
// WebUI. By default the gateway auto-detects URLs from AdminPorts, or
// reuses WebuiGateways when AdminPorts is empty.
WebuiAdminGateways []string
// WebuiPathPrefix is the URL path prefix under which the WebUI and its
// API endpoints are served (e.g. "/ui"). Must start with "/" and be a
// single path segment with no trailing slash. Leave empty to serve from
// the root path.
WebuiPathPrefix string
// WebuiS3Prefix mounts the WebUI directly on the S3 API endpoint at the
// given path prefix (e.g. "/ui"). Requests matching the prefix are routed
// to the WebUI instead of S3. Any bucket whose name equals the prefix
// segment is masked. Leave empty to disable WebUI hosting on the S3
// endpoint.
WebuiS3Prefix string
// Static website hosting endpoint
//
// WebsitePorts is the list of listening addresses for the static website
// hosting endpoint. Accepts the same formats as Ports. When empty, the
// website endpoint is disabled.
WebsitePorts []string
// WebsiteDomain is the base domain for website virtual-host routing. For
// example, host "blog.example.com" serves bucket "blog" when this is
// "example.com". When empty, the full request hostname is used as the
// bucket name.
WebsiteDomain string
// WebsiteCertFile is the path to the TLS certificate for the website
// endpoint. When empty and gateway TLS (CertFile/KeyFile) is configured,
// the website endpoint inherits those certs. Both WebsiteCertFile and
// WebsiteKeyFile must be provided together.
WebsiteCertFile string
// WebsiteKeyFile is the path to the TLS private key for the website
// endpoint.
WebsiteKeyFile string
// WebsiteNoTLS forces the website endpoint to use plain HTTP even when TLS
// certificates are available.
WebsiteNoTLS bool
// SigHup is an optional channel that signals the gateway to reload TLS
// certificates and rotate log files (equivalent to SIGHUP). When nil,
// this feature is disabled.
SigHup <-chan struct{}
// S3Options are appended to the internally built s3api options before the
// S3 server is created. This allows callers to inject custom behavior such
// as request middleware.
S3Options []s3api.Option
// Version, Build, and BuildTime are displayed in the startup banner.
// All three are optional; omit or leave empty to suppress the field.
Version string
Build string
BuildTime string
}
// TODO: remove gatewayRunning once package-level globals (bucket-name
// validation, debug logging) are eliminated and concurrent calls are safe.
var gatewayRunning atomic.Bool
// RunVersityGW starts the VersityGW gateway with the supplied backend and
// configuration. It blocks until ctx is cancelled, or an error occurs. All
// subsystems are gracefully shut down before the function returns.
//
// Only one instance may run per process at a time. Calling RunVersityGW
// concurrently or a second time before the first call returns will return an
// error.
func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error {
if !gatewayRunning.CompareAndSwap(false, true) {
return fmt.Errorf("embedgw: RunVersityGW is already running; only one instance per process is supported")
}
defer gatewayRunning.Store(false)
if cfg.RootUserAccess == "" || cfg.RootUserSecret == "" {
return fmt.Errorf("root user access and secret key must be provided")
}
err := validateWebUIPathPrefix("WebuiPathPrefix", cfg.WebuiPathPrefix)
if err != nil {
return err
}
if cfg.MaxConnections < 1 {
return fmt.Errorf("max-connections must be positive")
}
if cfg.MaxRequests < 1 {
return fmt.Errorf("max-requests must be positive")
}
if cfg.MaxRequests > cfg.MaxConnections {
log.Printf("WARNING: max-requests (%d) exceeds max-connections (%d) which could allow for gateway to panic before throttling requests",
cfg.MaxRequests, cfg.MaxConnections)
}
if cfg.MultipartMaxParts < 1 {
return fmt.Errorf("mp-max-parts must be positive")
}
if len(cfg.Ports) == 0 {
return fmt.Errorf("no ports specified")
}
if cfg.Region == "" {
cfg.Region = awsDefaultRegion
}
// WebUI runs in a browser and typically talks to the gateway/admin APIs cross-origin
// (different port). If no bucket CORS configuration exists, those API responses need
// a default Access-Control-Allow-Origin to be usable from the WebUI.
corsAllowOrigin := cfg.CORSAllowOrigin
if len(cfg.WebuiPorts) > 0 && strings.TrimSpace(corsAllowOrigin) == "" {
corsAllowOrigin = "*"
webuiScheme := "http"
if !cfg.WebuiNoTLS && (strings.TrimSpace(cfg.WebuiCertFile) != "" || strings.TrimSpace(cfg.CertFile) != "") {
webuiScheme = "https"
}
var suggestion string
var allOrigins []string
for _, addr := range cfg.WebuiPorts {
ips, ipsErr := getMatchingIPs(addr)
_, webPrt, prtErr := net.SplitHostPort(addr)
if ipsErr == nil && prtErr == nil && len(ips) > 0 {
for _, ip := range ips {
allOrigins = append(allOrigins, fmt.Sprintf("%s://%s:%s", webuiScheme, ip, webPrt))
}
}
}
if len(allOrigins) > 0 {
suggestion = fmt.Sprintf("consider setting it to one of: %s (or your public hostname)", strings.Join(allOrigins, ", "))
} else {
suggestion = fmt.Sprintf("consider setting it to %s://<host>:<port>", webuiScheme)
}
fmt.Fprintf(os.Stderr, "WARNING: WebuiPorts is set but CORSAllowOrigin is not; defaulting to '*'; %s\n", suggestion)
}
if err := validatePortConflicts(cfg.Ports, cfg.AdminPorts, cfg.WebuiPorts, cfg.WebsitePorts); err != nil {
return err
}
if err := validateWebUIPathPrefix("WebuiS3Prefix", cfg.WebuiS3Prefix); err != nil {
return err
}
// Pre-validate gateway URL lists once; both the WebuiS3Prefix block and the
// WebuiPorts block need these, so validate here to avoid doing it twice.
var validatedWebuiGateways []string
if len(cfg.WebuiGateways) > 0 {
validatedWebuiGateways, err = validateGatewayURLs(cfg.WebuiGateways, "WebuiGateways")
if err != nil {
return err
}
}
var validatedWebuiAdminGateways []string
if len(cfg.WebuiAdminGateways) > 0 {
validatedWebuiAdminGateways, err = validateGatewayURLs(cfg.WebuiAdminGateways, "WebuiAdminGateways")
if err != nil {
return err
}
}
utils.SetBucketNameValidationStrict(!cfg.DisableStrictBucketNames)
var parsedSocketPerm os.FileMode
if cfg.SocketPerm != "" {
perm, err := strconv.ParseUint(cfg.SocketPerm, 8, 32)
if err != nil {
return fmt.Errorf("invalid SocketPerm value %q: must be an octal integer (e.g. '0660'): %w", cfg.SocketPerm, err)
}
parsedSocketPerm = os.FileMode(perm)
}
opts := []s3api.Option{
s3api.WithConcurrencyLimiter(cfg.MaxConnections, cfg.MaxRequests),
s3api.WithMpMaxParts(cfg.MultipartMaxParts),
}
if cfg.SocketPerm != "" {
opts = append(opts, s3api.WithSocketPerm(parsedSocketPerm))
}
if corsAllowOrigin != "" {
opts = append(opts, s3api.WithCORSAllowOrigin(corsAllowOrigin))
}
if cfg.CertFile != "" || cfg.KeyFile != "" {
if cfg.CertFile == "" {
return fmt.Errorf("TLS key specified without cert file")
}
if cfg.KeyFile == "" {
return fmt.Errorf("TLS cert specified without key file")
}
cs := utils.NewCertStorage()
if err := cs.SetCertificate(cfg.CertFile, cfg.KeyFile); err != nil {
return fmt.Errorf("tls: load certs: %v", err)
}
opts = append(opts, s3api.WithTLS(cs))
}
if len(cfg.AdminPorts) == 0 {
opts = append(opts, s3api.WithAdminServer())
}
if cfg.Quiet {
opts = append(opts, s3api.WithQuiet())
}
if cfg.HealthPath != "" {
opts = append(opts, s3api.WithHealth(cfg.HealthPath))
}
if cfg.Readonly {
opts = append(opts, s3api.WithReadOnly())
}
if cfg.VirtualDomain != "" {
opts = append(opts, s3api.WithHostStyle(cfg.VirtualDomain))
}
if cfg.KeepAlive {
opts = append(opts, s3api.WithKeepAlive())
}
if cfg.DisableACLs {
opts = append(opts, s3api.WithDisableACL())
}
if len(cfg.S3Options) > 0 {
opts = append(opts, cfg.S3Options...)
}
if cfg.Debug {
debuglogger.SetDebugEnabled()
}
if cfg.IAMDebug {
debuglogger.SetIAMDebugEnabled()
}
iam, err := auth.New(&auth.Opts{
RootAccount: auth.Account{
Access: cfg.RootUserAccess,
Secret: cfg.RootUserSecret,
Role: auth.RoleAdmin,
},
Dir: cfg.IAMDir,
LDAPServerURL: cfg.LDAPServerURL,
LDAPBindDN: cfg.LDAPBindDN,
LDAPPassword: cfg.LDAPPassword,
LDAPQueryBase: cfg.LDAPQueryBase,
LDAPObjClasses: cfg.LDAPObjClasses,
LDAPAccessAtr: cfg.LDAPAccessAttr,
LDAPSecretAtr: cfg.LDAPSecretAttr,
LDAPRoleAtr: cfg.LDAPRoleAttr,
LDAPUserIdAtr: cfg.LDAPUserIDAttr,
LDAPGroupIdAtr: cfg.LDAPGroupIDAttr,
LDAPProjectIdAtr: cfg.LDAPProjectIDAttr,
LDAPTLSSkipVerify: cfg.LDAPTLSSkipVerify,
VaultEndpointURL: cfg.VaultEndpointURL,
VaultNamespace: cfg.VaultNamespace,
VaultSecretStoragePath: cfg.VaultSecretStoragePath,
VaultSecretStorageNamespace: cfg.VaultSecretStorageNamespace,
VaultAuthMethod: cfg.VaultAuthMethod,
VaultAuthNamespace: cfg.VaultAuthNamespace,
VaultMountPath: cfg.VaultMountPath,
VaultRootToken: cfg.VaultRootToken,
VaultRoleId: cfg.VaultRoleID,
VaultRoleSecret: cfg.VaultRoleSecret,
VaultServerCert: cfg.VaultServerCert,
VaultClientCert: cfg.VaultClientCert,
VaultClientCertKey: cfg.VaultClientCertKey,
S3Access: cfg.S3IAMAccess,
S3Secret: cfg.S3IAMSecret,
S3Region: cfg.S3IAMRegion,
S3Bucket: cfg.S3IAMBucket,
S3Endpoint: cfg.S3IAMEndpoint,
S3DisableSSlVerfiy: cfg.S3IAMDisableSSLVerify,
CacheDisable: cfg.IAMCacheDisable,
CacheTTL: cfg.IAMCacheTTL,
CachePrune: cfg.IAMCachePrune,
IpaHost: cfg.IpaHost,
IpaVaultName: cfg.IpaVaultName,
IpaUser: cfg.IpaUser,
IpaPassword: cfg.IpaPassword,
IpaInsecure: cfg.IpaInsecure,
})
if err != nil {
return fmt.Errorf("setup iam: %w", err)
}
loggers, err := s3log.InitLogger(&s3log.LogConfig{
LogFile: cfg.AccessLog,
WebhookURL: cfg.LogWebhookURL,
AdminLogFile: cfg.AdminLogFile,
})
if err != nil {
return fmt.Errorf("setup logger: %w", err)
}
metricsManager, err := metrics.NewManager(ctx, metrics.Config{
ServiceName: cfg.MetricsService,
StatsdServers: cfg.StatsdServers,
DogStatsdServers: cfg.DogstatsServers,
})
if err != nil {
return fmt.Errorf("init metrics manager: %w", err)
}
evSender, err := s3event.InitEventSender(&s3event.EventConfig{
KafkaURL: cfg.KafkaURL,
KafkaTopic: cfg.KafkaTopic,
KafkaTopicKey: cfg.KafkaKey,
NatsURL: cfg.NatsURL,
NatsTopic: cfg.NatsTopic,
RabbitmqURL: cfg.RabbitmqURL,
RabbitmqExchange: cfg.RabbitmqExchange,
RabbitmqRoutingKey: cfg.RabbitmqRoutingKey,
WebhookURL: cfg.EventWebhookURL,
FilterConfigFilePath: cfg.EventConfigFilePath,
})
if err != nil {
return fmt.Errorf("init bucket event notifications: %w", err)
}
if cfg.WebuiS3Prefix != "" {
s3SSLEnabled := cfg.CertFile != ""
s3AdmSSLEnabled := s3SSLEnabled
if len(cfg.AdminPorts) > 0 {
s3AdmSSLEnabled = cfg.AdminCertFile != ""
}
var s3WebGateways []string
if len(validatedWebuiGateways) > 0 {
s3WebGateways = validatedWebuiGateways
} else {
for _, p := range cfg.Ports {
urls, err := buildServiceURLs(p, s3SSLEnabled)
if err != nil {
return fmt.Errorf("webui-s3-prefix: build gateway URLs: %w", err)
}
s3WebGateways = append(s3WebGateways, urls...)
}
sortGatewayURLs(s3WebGateways)
}
s3WebAdminGateways := s3WebGateways
if len(validatedWebuiAdminGateways) > 0 {
s3WebAdminGateways = validatedWebuiAdminGateways
} else if len(cfg.AdminPorts) > 0 {
s3WebAdminGateways = nil
for _, admPort := range cfg.AdminPorts {
urls, err := buildServiceURLs(admPort, s3AdmSSLEnabled)
if err != nil {
return fmt.Errorf("webui-s3-prefix: build admin gateway URLs: %w", err)
}
s3WebAdminGateways = append(s3WebAdminGateways, urls...)
}
sortGatewayURLs(s3WebAdminGateways)
}
opts = append(opts, s3api.WithWebUI(cfg.WebuiS3Prefix, &webui.ServerConfig{
Gateways: s3WebGateways,
AdminGateways: s3WebAdminGateways,
Region: cfg.Region,
}))
}
srv, err := s3api.New(be, middlewares.RootUserConfig{
Access: cfg.RootUserAccess,
Secret: cfg.RootUserSecret,
}, cfg.Region, iam, loggers.S3Logger, loggers.AdminLogger, evSender, metricsManager, opts...)
if err != nil {
return fmt.Errorf("init gateway: %v", err)
}
var admSrv *s3api.S3AdminServer
if len(cfg.AdminPorts) > 0 {
if cfg.AdminMaxConnections < 1 {
return fmt.Errorf("admin-max-connections must be positive")
}
if cfg.AdminMaxRequests < 1 {
return fmt.Errorf("admin-max-requests must be positive")
}
if cfg.AdminMaxRequests > cfg.AdminMaxConnections {
log.Printf("WARNING: admin-max-requests (%d) exceeds admin-max-connections (%d) which could allow for gateway to panic before throttling requests",
cfg.AdminMaxRequests, cfg.AdminMaxConnections)
}
admOpts := []s3api.AdminOpt{
s3api.WithAdminConcurrencyLimiter(cfg.AdminMaxConnections, cfg.AdminMaxRequests),
}
if corsAllowOrigin != "" {
admOpts = append(admOpts, s3api.WithAdminCORSAllowOrigin(corsAllowOrigin))
}
if cfg.AdminCertFile != "" || cfg.AdminKeyFile != "" {
if cfg.AdminCertFile == "" {
return fmt.Errorf("TLS key specified without cert file")
}
if cfg.AdminKeyFile == "" {
return fmt.Errorf("TLS cert specified without key file")
}
cs := utils.NewCertStorage()
if err = cs.SetCertificate(cfg.AdminCertFile, cfg.AdminKeyFile); err != nil {
return fmt.Errorf("tls: load certs: %v", err)
}
admOpts = append(admOpts, s3api.WithAdminSrvTLS(cs))
}
if cfg.Quiet {
admOpts = append(admOpts, s3api.WithAdminQuiet())
}
if cfg.Debug {
admOpts = append(admOpts, s3api.WithAdminDebug())
}
if cfg.SocketPerm != "" {
admOpts = append(admOpts, s3api.WithAdminSocketPerm(parsedSocketPerm))
}
admSrv = s3api.NewAdminServer(be, middlewares.RootUserConfig{Access: cfg.RootUserAccess, Secret: cfg.RootUserSecret}, cfg.Region, iam, loggers.AdminLogger, srv.Router.Ctrl, admOpts...)
}
var webSrv *webui.Server
webTLSCert := ""
webTLSKey := ""
if len(cfg.WebuiPorts) > 0 {
for _, addr := range cfg.WebuiPorts {
if utils.IsUnixSocketPath(addr) {
continue
}
_, webPrt, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("webui listen address must be in the form ':port' or 'host:port': %w", err)
}
webPortNum, err := strconv.Atoi(webPrt)
if err != nil {
return fmt.Errorf("webui port must be a number: %w", err)
}
if webPortNum < 0 || webPortNum > 65535 {
return fmt.Errorf("webui port must be between 0 and 65535")
}
}
var webOpts []webui.Option
if !cfg.WebuiNoTLS {
webTLSCert = cfg.WebuiCertFile
webTLSKey = cfg.WebuiKeyFile
if webTLSCert == "" && webTLSKey == "" {
webTLSCert = cfg.CertFile
webTLSKey = cfg.KeyFile
}
if webTLSCert != "" || webTLSKey != "" {
if webTLSCert == "" {
return fmt.Errorf("webui TLS key specified without cert file")
}
if webTLSKey == "" {
return fmt.Errorf("webui TLS cert specified without key file")
}
cs := utils.NewCertStorage()
if err := cs.SetCertificate(webTLSCert, webTLSKey); err != nil {
return fmt.Errorf("tls: load certs: %v", err)
}
webOpts = append(webOpts, webui.WithTLS(cs))
}
}
sslEnabled := cfg.CertFile != ""
admSSLEnabled := sslEnabled
if len(cfg.AdminPorts) > 0 {
admSSLEnabled = cfg.AdminCertFile != ""
}
var gateways []string
if len(validatedWebuiGateways) > 0 {
gateways = validatedWebuiGateways
} else {
for _, p := range cfg.Ports {
urls, err := buildServiceURLs(p, sslEnabled)
if err != nil {
return fmt.Errorf("webui: build gateway URLs: %w", err)
}
gateways = append(gateways, urls...)
}
sortGatewayURLs(gateways)
}
adminGateways := gateways
if len(validatedWebuiAdminGateways) > 0 {
adminGateways = validatedWebuiAdminGateways
} else if len(cfg.AdminPorts) > 0 {
adminGateways = nil
for _, admPort := range cfg.AdminPorts {
urls, err := buildServiceURLs(admPort, admSSLEnabled)
if err != nil {
return fmt.Errorf("webui: build admin gateway URLs: %w", err)
}
adminGateways = append(adminGateways, urls...)
}
sortGatewayURLs(adminGateways)
}
if cfg.Quiet {
webOpts = append(webOpts, webui.WithQuiet())
}
if cfg.WebuiPathPrefix != "" {
webOpts = append(webOpts, webui.WithPathPrefix(cfg.WebuiPathPrefix))
}
if cfg.SocketPerm != "" {
webOpts = append(webOpts, webui.WithSocketPerm(parsedSocketPerm))
}
webSrv, err = webui.NewServer(&webui.ServerConfig{
Gateways: gateways,
AdminGateways: adminGateways,
Region: cfg.Region,
}, webOpts...)
if err != nil {
return fmt.Errorf("init webui: %w", err)
}
}
var wsSrv *website.Server
wsTLSCert := ""
wsTLSKey := ""
if len(cfg.WebsitePorts) > 0 {
for _, addr := range cfg.WebsitePorts {
if utils.IsUnixSocketPath(addr) {
continue
}
_, wsPrt, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("website listen address must be in the form ':port' or 'host:port': %w", err)
}
wsPortNum, err := strconv.Atoi(wsPrt)
if err != nil {
return fmt.Errorf("website port must be a number: %w", err)
}
if wsPortNum < 0 || wsPortNum > 65535 {
return fmt.Errorf("website port must be between 0 and 65535")
}
}
var wsOpts []website.Option
if !cfg.WebsiteNoTLS {
wsTLSCert = cfg.WebsiteCertFile
wsTLSKey = cfg.WebsiteKeyFile
if wsTLSCert == "" && wsTLSKey == "" {
wsTLSCert = cfg.CertFile
wsTLSKey = cfg.KeyFile
}
if wsTLSCert != "" || wsTLSKey != "" {
if wsTLSCert == "" {
return fmt.Errorf("website TLS key specified without cert file")
}
if wsTLSKey == "" {
return fmt.Errorf("website TLS cert specified without key file")
}
cs := utils.NewCertStorage()
if err := cs.SetCertificate(wsTLSCert, wsTLSKey); err != nil {
return fmt.Errorf("tls: load certs: %v", err)
}
wsOpts = append(wsOpts, website.WithTLS(cs))
}
}
if cfg.Quiet {
wsOpts = append(wsOpts, website.WithQuiet())
}
if cfg.SocketPerm != "" {
wsOpts = append(wsOpts, website.WithSocketPerm(parsedSocketPerm))
}
wsSrv = website.NewServer(be, cfg.WebsiteDomain, wsOpts...)
}
if !cfg.Quiet {
cfg.printBanner()
}
servers := 1
if len(cfg.AdminPorts) > 0 {
servers++
}
if len(cfg.WebuiPorts) > 0 {
servers++
}
if len(cfg.WebsitePorts) > 0 {
servers++
}
c := make(chan error, servers)
go func() { c <- srv.ServeMultiPort(cfg.Ports) }()
if len(cfg.AdminPorts) > 0 {
go func() { c <- admSrv.ServeMultiPort(cfg.AdminPorts) }()
}
if len(cfg.WebuiPorts) > 0 {
go func() { c <- webSrv.ServeMultiPort(cfg.WebuiPorts) }()
}
if len(cfg.WebsitePorts) > 0 {
go func() { c <- wsSrv.ServeMultiPort(cfg.WebsitePorts) }()
}
// build a nil-safe sighup channel so the select below is always valid
var sigHup <-chan struct{}
if cfg.SigHup != nil {
sigHup = cfg.SigHup
} else {
sigHup = make(chan struct{}) // never receives
}
Loop:
for {
select {
case <-ctx.Done():
break Loop
case err = <-c:
break Loop
case <-sigHup:
if loggers.S3Logger != nil {
err = loggers.S3Logger.HangUp()
if err != nil {
err = fmt.Errorf("HUP s3 logger: %w", err)
break Loop
}
}
if loggers.AdminLogger != nil {
err = loggers.AdminLogger.HangUp()
if err != nil {
err = fmt.Errorf("HUP admin logger: %w", err)
break Loop
}
}
if cfg.CertFile != "" && cfg.KeyFile != "" {
reloadErr := srv.CertStorage.SetCertificate(cfg.CertFile, cfg.KeyFile)
if reloadErr != nil {
debuglogger.InternalError(fmt.Errorf("srv cert reload failed: %w", reloadErr))
} else {
fmt.Printf("srv cert reloaded (cert: %s, key: %s)\n", cfg.CertFile, cfg.KeyFile)
}
}
if len(cfg.AdminPorts) > 0 && cfg.AdminCertFile != "" && cfg.AdminKeyFile != "" {
reloadErr := admSrv.CertStorage.SetCertificate(cfg.AdminCertFile, cfg.AdminKeyFile)
if reloadErr != nil {
debuglogger.InternalError(fmt.Errorf("admSrv cert reload failed: %w", reloadErr))
} else {
fmt.Printf("admSrv cert reloaded (cert: %s, key: %s)\n", cfg.AdminCertFile, cfg.AdminKeyFile)
}
}
if len(cfg.WebuiPorts) > 0 && webTLSCert != "" && webTLSKey != "" {
reloadErr := webSrv.CertStorage.SetCertificate(webTLSCert, webTLSKey)
if reloadErr != nil {
debuglogger.InternalError(fmt.Errorf("webSrv cert reload failed: %w", reloadErr))
} else {
fmt.Printf("webSrv cert reloaded (cert: %s, key: %s)\n", webTLSCert, webTLSKey)
}
}
if len(cfg.WebsitePorts) > 0 && wsTLSCert != "" && wsTLSKey != "" {
reloadErr := wsSrv.CertStorage.SetCertificate(wsTLSCert, wsTLSKey)
if reloadErr != nil {
debuglogger.InternalError(fmt.Errorf("wsSrv cert reload failed: %w", reloadErr))
} else {
fmt.Printf("wsSrv cert reloaded (cert: %s, key: %s)\n", wsTLSCert, wsTLSKey)
}
}
}
}
saveErr := err
err = srv.ShutDown()
if err != nil {
fmt.Fprintf(os.Stderr, "shutdown api server: %v\n", err)
}
if admSrv != nil {
err := admSrv.Shutdown()
if err != nil {
fmt.Fprintf(os.Stderr, "shutdown admin server: %v\n", err)
}
}
if webSrv != nil {
err := webSrv.Shutdown()
if err != nil {
fmt.Fprintf(os.Stderr, "shutdown webui server: %v\n", err)
}
}
if wsSrv != nil {
err := wsSrv.Shutdown()
if err != nil {
fmt.Fprintf(os.Stderr, "shutdown website server: %v\n", err)
}
}
be.Shutdown()
err = iam.Shutdown()
if err != nil {
fmt.Fprintf(os.Stderr, "shutdown iam: %v\n", err)
}
if loggers.S3Logger != nil {
err := loggers.S3Logger.Shutdown()
if err != nil {
fmt.Fprintf(os.Stderr, "shutdown s3 logger: %v\n", err)
}
}
if loggers.AdminLogger != nil {
err := loggers.AdminLogger.Shutdown()
if err != nil {
fmt.Fprintf(os.Stderr, "shutdown admin logger: %v\n", err)
}
}
if evSender != nil {
err := evSender.Close()
if err != nil {
fmt.Fprintf(os.Stderr, "close event sender: %v\n", err)
}
}
if metricsManager != nil {
metricsManager.Close()
}
return saveErr
}
const (
columnWidth = 70
title = "VersityGW"
)
func (cfg Config) printBanner() {
ssl := cfg.CertFile != "" || cfg.KeyFile != ""
admSSL := cfg.AdminCertFile != "" || cfg.AdminKeyFile != ""
webuiSsl := !cfg.WebuiNoTLS && (cfg.WebuiCertFile != "" || cfg.WebuiKeyFile != "" || cfg.CertFile != "" || cfg.KeyFile != "")
websiteSsl := !cfg.WebsiteNoTLS && (cfg.WebsiteCertFile != "" || cfg.WebsiteKeyFile != "" || cfg.CertFile != "" || cfg.KeyFile != "")
if len(cfg.Ports) == 0 {
fmt.Fprintf(os.Stderr, "No ports specified\n")
return
}
var allInterfaces []string
var allPorts []string
interfaceMap := make(map[string]bool)
for _, portSpec := range cfg.Ports {
if utils.IsUnixSocketPath(portSpec) {
allPorts = append(allPorts, portSpec)
if !interfaceMap[portSpec] {
interfaceMap[portSpec] = true
allInterfaces = append(allInterfaces, portSpec)
}
continue
}
interfaces, err := getMatchingIPs(portSpec)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to match local IP addresses for %s: %v\n", portSpec, err)
continue
}
_, prt, err := net.SplitHostPort(portSpec)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to parse port %s: %v\n", portSpec, err)
continue
}
allPorts = append(allPorts, prt)
for _, ip := range interfaces {
key := net.JoinHostPort(ip, prt)
if !interfaceMap[key] {
interfaceMap[key] = true
allInterfaces = append(allInterfaces, key)
}
}
}
if len(allInterfaces) == 0 {
fmt.Fprintf(os.Stderr, "Failed to resolve any listening addresses\n")
return
}
var allAdmInterfaces []string
admInterfaceMap := make(map[string]bool)
for _, admPort := range cfg.AdminPorts {
if utils.IsUnixSocketPath(admPort) {
if !admInterfaceMap[admPort] {
admInterfaceMap[admPort] = true
allAdmInterfaces = append(allAdmInterfaces, admPort)
}
continue
}
interfaces, err := getMatchingIPs(admPort)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to match admin port local IP addresses for %s: %v\n", admPort, err)
continue
}
_, prt, err := net.SplitHostPort(admPort)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to parse admin port %s: %v\n", admPort, err)
continue
}
for _, ip := range interfaces {
key := net.JoinHostPort(ip, prt)
if !admInterfaceMap[key] {
admInterfaceMap[key] = true
allAdmInterfaces = append(allAdmInterfaces, key)
}
}
}
versionStr := fmt.Sprintf("Version %v, Build %v", cfg.Version, cfg.Build)
if cfg.BuildTime != "" {
versionStr += fmt.Sprintf(", BuildTime %v", cfg.BuildTime)
}
var urls []string
for _, addrPort := range allInterfaces {
if utils.IsUnixSocketPath(addrPort) {
urls = append(urls, "unix:"+addrPort)
continue
}
ip, prt, err := net.SplitHostPort(addrPort)
if err != nil {
continue
}
hostPort := net.JoinHostPort(ip, prt)
u := fmt.Sprintf("http://%s", hostPort)
if ssl {
u = fmt.Sprintf("https://%s", hostPort)
}
urls = append(urls, u)
}
var boundHost string
if len(cfg.Ports) == 1 {
if utils.IsUnixSocketPath(cfg.Ports[0]) {
boundHost = fmt.Sprintf("(unix socket: %s)", cfg.Ports[0])
} else {
hst, prt, _ := net.SplitHostPort(cfg.Ports[0])
if hst == "" {
hst = "0.0.0.0"
}
boundHost = fmt.Sprintf("(bound on host %s and port %s)", hst, prt)
}
} else {
portList := strings.Join(allPorts, ", ")
boundHost = fmt.Sprintf("(bound on ports: %s)", portList)
}
lines := []string{
centerText(title),
centerText(versionStr),
centerText(boundHost),
centerText(""),
}
if len(allAdmInterfaces) > 0 {
lines = append(lines, leftText("S3 service listening on:"))
} else {
lines = append(lines, leftText("Admin/S3 service listening on:"))
}
for _, u := range urls {
lines = append(lines, leftText(" "+u))
}
if len(allAdmInterfaces) > 0 {
lines = append(lines, centerText(""), leftText("Admin service listening on:"))
for _, addrPort := range allAdmInterfaces {
if utils.IsUnixSocketPath(addrPort) {
lines = append(lines, leftText(" unix:"+addrPort))
continue
}
ip, prt, err := net.SplitHostPort(addrPort)
if err != nil {
continue
}
hostPort := net.JoinHostPort(ip, prt)
u := fmt.Sprintf("http://%s", hostPort)
if admSSL {
u = fmt.Sprintf("https://%s", hostPort)
}
lines = append(lines, leftText(" "+u))
}
}
if len(cfg.WebuiPorts) > 0 {
var allWebInterfaces []string
webInterfaceMap := make(map[string]bool)
for _, webuiAddr := range cfg.WebuiPorts {
if strings.TrimSpace(webuiAddr) == "" {
continue
}
if utils.IsUnixSocketPath(webuiAddr) {
if !webInterfaceMap[webuiAddr] {
webInterfaceMap[webuiAddr] = true
allWebInterfaces = append(allWebInterfaces, webuiAddr)
}
continue
}
webInterfaces, err := getMatchingIPs(webuiAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to match webui port local IP addresses for %s: %v\n", webuiAddr, err)
continue
}
_, webPrt, err := net.SplitHostPort(webuiAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to parse webui port %s: %v\n", webuiAddr, err)
continue
}
for _, ip := range webInterfaces {
key := net.JoinHostPort(ip, webPrt)
if !webInterfaceMap[key] {
webInterfaceMap[key] = true
allWebInterfaces = append(allWebInterfaces, key)
}
}
}
if len(allWebInterfaces) > 0 {
lines = append(lines, centerText(""), leftText("WebUI listening on:"))
for _, addrPort := range allWebInterfaces {
if utils.IsUnixSocketPath(addrPort) {
lines = append(lines, leftText(" unix:"+addrPort))
continue
}
ip, prt, err := net.SplitHostPort(addrPort)
if err != nil {
continue
}
hostPort := net.JoinHostPort(ip, prt)
u := fmt.Sprintf("http://%s", hostPort)
if webuiSsl {
u = fmt.Sprintf("https://%s", hostPort)
}
lines = append(lines, leftText(" "+u+cfg.WebuiPathPrefix))
}
}
}
if cfg.WebuiS3Prefix != "" {
lines = append(lines, centerText(""), leftText("WebUI embedded on S3 service at:"))
for _, addrPort := range allInterfaces {
ip, prt, err := net.SplitHostPort(addrPort)
if err != nil {
continue
}
hostPort := net.JoinHostPort(ip, prt)
u := fmt.Sprintf("http://%s", hostPort)
if ssl {
u = fmt.Sprintf("https://%s", hostPort)
}
lines = append(lines, leftText(" "+u+cfg.WebuiS3Prefix))
}
}
if len(cfg.WebsitePorts) > 0 {
var allWebsiteInterfaces []string
websiteInterfaceMap := make(map[string]bool)
for _, websiteAddr := range cfg.WebsitePorts {
if strings.TrimSpace(websiteAddr) == "" {
continue
}
if utils.IsUnixSocketPath(websiteAddr) {
if !websiteInterfaceMap[websiteAddr] {
websiteInterfaceMap[websiteAddr] = true
allWebsiteInterfaces = append(allWebsiteInterfaces, websiteAddr)
}
continue
}
websiteInterfaces, err := getMatchingIPs(websiteAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to match website port local IP addresses for %s: %v\n", websiteAddr, err)
continue
}
_, websitePrt, err := net.SplitHostPort(websiteAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to parse website port %s: %v\n", websiteAddr, err)
continue
}
for _, ip := range websiteInterfaces {
key := net.JoinHostPort(ip, websitePrt)
if !websiteInterfaceMap[key] {
websiteInterfaceMap[key] = true
allWebsiteInterfaces = append(allWebsiteInterfaces, key)
}
}
}
if len(allWebsiteInterfaces) > 0 {
domainInfo := ""
if cfg.WebsiteDomain != "" {
domainInfo = fmt.Sprintf(" (domain: %s)", cfg.WebsiteDomain)
}
lines = append(lines,
centerText(""),
leftText("Website endpoint listening on:"+domainInfo),
)
for _, addrPort := range allWebsiteInterfaces {
if utils.IsUnixSocketPath(addrPort) {
lines = append(lines, leftText(" unix:"+addrPort))
continue
}
ip, prt, err := net.SplitHostPort(addrPort)
if err != nil {
continue
}
hostPort := net.JoinHostPort(ip, prt)
u := fmt.Sprintf("http://%s", hostPort)
if websiteSsl {
u = fmt.Sprintf("https://%s", hostPort)
}
lines = append(lines, leftText(" "+u))
}
}
}
fmt.Println("┌" + strings.Repeat("─", columnWidth-2) + "┐")
for _, line := range lines {
fmt.Printf("│%-*s│\n", columnWidth-2, line)
}
fmt.Println("└" + strings.Repeat("─", columnWidth-2) + "┘")
}
func centerText(text string) string {
padding := max((columnWidth-2-len(text))/2, 0)
return strings.Repeat(" ", padding) + text
}
func leftText(text string) string {
if len(text) > columnWidth-2 {
return text
}
return text + strings.Repeat(" ", columnWidth-2-len(text))
}
// getMatchingIPs returns all IP addresses that the server will listen on
// for the given address specification.
func getMatchingIPs(spec string) ([]string, error) {
if utils.IsUnixSocketPath(spec) {
return []string{spec}, nil
}
ips, err := utils.ResolveHostnameIPs(spec)
if err != nil {
return nil, fmt.Errorf("resolve hostname: %v", err)
}
if len(ips) == 1 && ips[0] == "" {
return getAllLocalIPs()
}
var result []string
for _, ip := range ips {
parsedIP := net.ParseIP(ip)
if parsedIP == nil {
continue
}
if parsedIP.IsLinkLocalUnicast() || parsedIP.IsLinkLocalMulticast() || parsedIP.IsInterfaceLocalMulticast() {
continue
}
result = append(result, ip)
}
return result, nil
}
// getAllLocalIPs returns all non-link-local IP addresses from local interfaces.
func getAllLocalIPs() ([]string, error) {
var result []string
interfaces, err := net.Interfaces()
if err != nil {
return nil, err
}
for _, iface := range interfaces {
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
ipAddr, _, err := net.ParseCIDR(addr.String())
if err != nil {
continue
}
if ipAddr.IsLinkLocalUnicast() || ipAddr.IsInterfaceLocalMulticast() || ipAddr.IsLinkLocalMulticast() {
continue
}
result = append(result, ipAddr.String())
}
}
return result, nil
}
func buildServiceURLs(spec string, ssl bool) ([]string, error) {
if utils.IsUnixSocketPath(spec) {
return nil, nil
}
interfaces, err := getMatchingIPs(spec)
if err != nil {
return nil, err
}
_, prt, err := net.SplitHostPort(spec)
if err != nil {
return nil, fmt.Errorf("parse address/port: %w", err)
}
if len(interfaces) == 0 {
interfaces = []string{"localhost"}
}
scheme := "http"
if ssl {
scheme = "https"
}
urls := make([]string, 0, len(interfaces))
for _, ip := range interfaces {
urls = append(urls, fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(ip, prt)))
}
return urls, nil
}
func isLocalhost(u string) bool {
return strings.Contains(u, "localhost") ||
strings.Contains(u, "127.0.0.1") ||
strings.Contains(u, "[::1]")
}
func validateGatewayURLs(urls []string, urlType string) ([]string, error) {
if len(urls) == 0 {
return urls, nil
}
var validURLs []string
for _, urlStr := range urls {
if strings.TrimSpace(urlStr) == "" {
continue
}
parsedURL, err := url.Parse(urlStr)
if err != nil {
fmt.Fprintf(os.Stderr, "WARNING: invalid %s URL %q: %v\n", urlType, urlStr, err)
continue
}
if parsedURL.Scheme == "" {
fmt.Fprintf(os.Stderr, "WARNING: invalid %s URL %q: missing scheme (must be http:// or https://)\n", urlType, urlStr)
continue
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
fmt.Fprintf(os.Stderr, "WARNING: invalid %s URL %q: unsupported scheme %q (must be http or https)\n", urlType, urlStr, parsedURL.Scheme)
continue
}
if parsedURL.Host == "" {
fmt.Fprintf(os.Stderr, "WARNING: invalid %s URL %q: missing host\n", urlType, urlStr)
continue
}
validURLs = append(validURLs, urlStr)
}
if len(validURLs) == 0 {
return nil, fmt.Errorf("%s URLs specified but none are valid", urlType)
}
return validURLs, nil
}
func validateWebUIPathPrefix(option, prefix string) error {
if prefix == "" {
return nil
}
if strings.TrimSpace(prefix) != prefix {
return fmt.Errorf("invalid %v %q: must not contain leading or trailing whitespace", option, prefix)
}
if !strings.HasPrefix(prefix, "/") {
return fmt.Errorf("invalid %v %q: must start with '/' (example: '/ui')", option, prefix)
}
if strings.HasSuffix(prefix, "/") {
return fmt.Errorf("invalid %v %q: must not end with '/'", option, prefix)
}
if strings.Count(prefix, "/") > 1 {
return fmt.Errorf("invalid %v %q: only a single path segment is allowed (example: '/ui')", option, prefix)
}
if strings.ContainsAny(prefix, "?#") {
return fmt.Errorf("invalid %v %q: query strings and fragments are not allowed", option, prefix)
}
if strings.Contains(prefix, "\\") {
return fmt.Errorf("invalid %v %q: backslashes are not allowed", option, prefix)
}
return nil
}
func sortGatewayURLs(urls []string) {
if len(urls) <= 1 {
return
}
var nonLocal []string
var local []string
for _, u := range urls {
if isLocalhost(u) {
local = append(local, u)
} else {
nonLocal = append(nonLocal, u)
}
}
copy(urls, nonLocal)
copy(urls[len(nonLocal):], local)
}
// validatePortConflicts checks for port conflicts across the S3 API, admin,
// WebUI, and website port lists before the servers are started.
//
// A bare port spec (e.g. ":7071") binds to all interfaces and conflicts with
// any other spec on the same port number. Two identical "ip:port" specs are
// allowed and will be caught by the OS later. UNIX socket paths are checked
// for duplicate path conflicts only and never conflict with TCP specs.
func validatePortConflicts(ports, admPorts, webuiPorts, websitePorts []string) error {
type portSpec struct {
spec string
port string
isBare bool
isUnix bool
portType string
}
var allSpecs []portSpec
for _, p := range ports {
if utils.IsUnixSocketPath(p) {
allSpecs = append(allSpecs, portSpec{spec: p, port: p, isUnix: true, portType: "s3"})
continue
}
_, port, err := net.SplitHostPort(p)
if err != nil {
continue
}
allSpecs = append(allSpecs, portSpec{
spec: p,
port: port,
isBare: strings.HasPrefix(p, ":"),
portType: "s3",
})
}
for _, p := range admPorts {
if utils.IsUnixSocketPath(p) {
allSpecs = append(allSpecs, portSpec{spec: p, port: p, isUnix: true, portType: "admin"})
continue
}
_, port, err := net.SplitHostPort(p)
if err != nil {
continue
}
allSpecs = append(allSpecs, portSpec{
spec: p,
port: port,
isBare: strings.HasPrefix(p, ":"),
portType: "admin",
})
}
for _, p := range webuiPorts {
if utils.IsUnixSocketPath(p) {
allSpecs = append(allSpecs, portSpec{spec: p, port: p, isUnix: true, portType: "webui"})
continue
}
_, port, err := net.SplitHostPort(p)
if err != nil {
continue
}
allSpecs = append(allSpecs, portSpec{
spec: p,
port: port,
isBare: strings.HasPrefix(p, ":"),
portType: "webui",
})
}
for _, p := range websitePorts {
if utils.IsUnixSocketPath(p) {
allSpecs = append(allSpecs, portSpec{spec: p, port: p, isUnix: true, portType: "website"})
continue
}
_, port, err := net.SplitHostPort(p)
if err != nil {
continue
}
allSpecs = append(allSpecs, portSpec{
spec: p,
port: port,
isBare: strings.HasPrefix(p, ":"),
portType: "website",
})
}
for i, spec1 := range allSpecs {
for j, spec2 := range allSpecs {
if i >= j {
continue
}
if spec1.isUnix || spec2.isUnix {
if spec1.isUnix && spec2.isUnix && spec1.spec == spec2.spec {
return fmt.Errorf("duplicate unix socket path: %s port %s conflicts with %s port %s",
spec1.portType, spec1.spec, spec2.portType, spec2.spec)
}
continue
}
if spec1.port != spec2.port {
continue
}
if !spec1.isBare && !spec2.isBare && spec1.spec == spec2.spec {
continue
}
if spec1.isBare || spec2.isBare {
return fmt.Errorf("port conflict: %s port %s conflicts with %s port %s (bare port specs bind to all interfaces)",
spec1.portType, spec1.spec, spec2.portType, spec2.spec)
}
}
}
return nil
}