Merge pull request #2410 from versity/sis/helm-webui-prefix-iam-storage

feat: admin API path prefix, Helm WebUI prefixes and separate IAM storage
This commit is contained in:
Ben McClelland
2026-09-16 15:37:41 -07:00
committed by GitHub
17 changed files with 554 additions and 106 deletions
+20
View File
@@ -35,3 +35,23 @@ jobs:
helm lint chart/ --strict --set auth.existingSecret=root-credentials ${scenario}
echo "::endgroup::"
done
- name: Lint path prefix and internal IAM storage configurations
run: |
scenarios=(
# admin API and WebUI on their own ports under path prefixes
"--set admin.enabled=true --set admin.pathPrefix=/admin --set webui.enabled=true --set webui.pathPrefix=/ui"
# admin API and WebUI on the S3 port under path prefixes
"--set admin.pathPrefix=/admin --set webui.s3Prefix=/ui --set webui.apiGateways[0]=https://s3.example.test"
# internal IAM on its own PVC with ephemeral backend data
"--set iam.enabled=true --set iam.persistence.enabled=true --set iam.dir=/var/lib/versitygw/iam --set persistence.enabled=false"
# internal IAM on an existing PVC
"--set iam.enabled=true --set iam.persistence.enabled=true --set iam.persistence.create=false --set iam.persistence.claimName=versitygw-iam"
)
for scenario in "${scenarios[@]}"; do
echo "::group::helm lint --set ${scenario}"
# shellcheck disable=SC2086
helm lint chart/ --strict --set auth.existingSecret=root-credentials ${scenario}
echo "::endgroup::"
done
+1 -1
View File
@@ -2,7 +2,7 @@ apiVersion: v2
name: versitygw
description: A Helm chart for deploying the Versity S3 Gateway on Kubernetes
type: application
version: 0.4.3
version: 0.4.4
sources:
- https://github.com/versity/versitygw
icon: https://raw.githubusercontent.com/versity/versitygw/main/webui/web/assets/images/Versity-logo-blue-horizontal.png
+23 -5
View File
@@ -101,15 +101,33 @@ gateway:
| **cert-manager** | `certificate.create=true`, `certificate.issuerRef`, `certificate.dnsNames` |
| **Ingress** | `ingress.enabled=true`, `ingress.className`, `ingress.hosts`, `ingress.tls` |
| **HTTPRoute** | `httpRoute.enabled=true` — Gateway API successor to Ingress for S3 API; also `admin.httpRoute.enabled=true` and `webui.httpRoute.enabled=true` to expose the admin API and/or WebUI |
| **Admin API** | `admin.enabled=true` — exposes a separate management API on `admin.port` (default `7071`) |
| **WebUI** | `webui.enabled=true` — browser-based management UI on `webui.port` (default `8080`); set `webui.apiGateways` and `webui.adminGateways` to your externally reachable endpoints, and `webui.iamGateways` when `iam.type=standalone` so the login page offers the IAM service (the WebUI then ignores the admin API entirely — the IAM service manages users, and buckets are managed over the S3 API) |
| **Admin API** | `admin.enabled=true` — exposes a separate management API on `admin.port` (default `7071`); `admin.pathPrefix` (e.g. `/admin`) serves it under a path prefix, on the S3 port when `admin.enabled=false` — see [Serving Under One Hostname](#serving-under-one-hostname) |
| **WebUI** | `webui.enabled=true` — browser-based management UI on `webui.port` (default `8080`); set `webui.apiGateways` and `webui.adminGateways` to your externally reachable endpoints, and `webui.iamGateways` when `iam.type=standalone` so the login page offers the IAM service (the WebUI then ignores the admin API entirely — the IAM service manages users, and buckets are managed over the S3 API). `webui.pathPrefix` serves it under a path on `webui.port`; `webui.s3Prefix` also serves it on the S3 port |
| **Website Hosting** | `website.enabled=true` — static website hosting endpoint on `website.port` (default `8090`); optionally set `website.domain` for virtual-host routing (e.g. `example.com`), or omit it for catch-all mode where the full hostname is the bucket name |
| **IAM** | `iam.enabled=true` — identity and access management. `iam.type=internal` (default) stores accounts in a flat file alongside backend data; `iam.type=standalone` delegates to a separate standalone IAM API service — see [Standalone IAM Service](#standalone-iam-service) below |
| **Persistence** | `persistence.enabled=true` — provisions a PVC for backend data and IAM storage; defaults to `10Gi`, or uses a hostPath volume specified by `persistence.hostPath` |
| **IAM** | `iam.enabled=true` — identity and access management. `iam.type=internal` (default) stores accounts in a flat file under `iam.dir`, alongside backend data or on its own PVC with `iam.persistence.enabled=true`; `iam.type=standalone` delegates to a separate standalone IAM API service — see [Standalone IAM Service](#standalone-iam-service) below |
| **Persistence** | `persistence.enabled=true` — provisions a PVC for backend data and IAM storage (unless `iam.persistence.enabled=true`); defaults to `10Gi`, or uses a hostPath volume specified by `persistence.hostPath` |
| **NetworkPolicy** | `networkPolicy.enabled=true` — restricts ingress to selected pods/namespaces; allows all egress |
| **Debug logging** | `gateway.logLevel``silent` (default), `debug` (request/response logging, secrets masked), or `unsafe` (unmasked, local troubleshooting only) |
| **Scheduling** | `nodeSelector`, `affinity`, `tolerations`, and `topologySpreadConstraints` — control pod placement and spread replicas across nodes/zones for high availability |
## Serving Under One Hostname
The WebUI and admin API can share the S3 API's hostname under path prefixes instead of needing hostnames of their own. The simplest setup serves all three on the S3 port, so the existing S3 `ingress` or `httpRoute` covers them:
```yaml
admin:
pathPrefix: /admin # admin API on the S3 port under /admin
webui:
s3Prefix: /ui # WebUI on the S3 port under /ui
apiGateways:
- https://s3.example.com
# adminGateways defaults to apiGateways plus admin.pathPrefix
```
With `admin.enabled=true` and `webui.enabled=true`, set `admin.pathPrefix` and `webui.pathPrefix` instead, and route those paths to the admin and WebUI ports: `servicePort: admin` / `servicePort: webui` on `ingress.hosts[].paths[]`, or `backendPort` set to `admin.port` / `webui.port` (e.g. `7071` / `8080`) on `httpRoute.rules[]`. Set `webui.adminGateways` to the prefixed URL, e.g. `https://s3.example.com/admin`.
Admin requests are signed over the full path, so the proxy must forward the prefix unchanged. If it strips or rewrites the prefix, admin requests fail. A bucket whose name matches a prefix served outside the S3 API (`webui.s3Prefix`, or a path routed to the admin or WebUI port) is unreachable on that hostname.
## Standalone IAM Service
In addition to `iam.type=internal` (flat-file IAM stored inside the gateway pod), the chart can deploy the standalone IAM API server — an AWS-compatible IAM Query API — as its own Deployment with separate public and private Services, and configure one or more gateways to use it via `iam.type=standalone`.
@@ -166,7 +184,7 @@ When scaling `versitygw` horizontally by setting `replicaCount` greater than 1,
- Using **ReadWriteOnce (RWO)**: All replicas must be scheduled on the **same Kubernetes node** to share the same volume. This is useful for process-level concurrency (e.g., when using high-performance local block storage) but limits high availability across nodes.
- Using **ReadWriteMany (RWX)**: Replicas can be distributed across **multiple nodes** in the cluster. This is the recommended approach for true horizontal scaling and high availability. When using RWX, it is also recommended to use pod anti-affinity (via `affinity` in `values.yaml`) or topology spread constraints (via `topologySpreadConstraints` in `values.yaml`) to ensure pods are distributed across nodes/zones.
- **IAM**: `iam.type=internal` is limited to a single gateway replica because its file store does not coordinate concurrent writers. Use standalone IAM with Vault storage, LDAP, Vault-direct, or another external IAM backend before scaling the gateway above one replica.
- **Stateless Backends (S3, Azure)**: If you are using a stateless storage backend (e.g. proxying to another S3 store) **and** you are either not using IAM or using an external IAM provider (e.g. LDAP, Vault), persistence can be safely disabled by setting `persistence.enabled=false`.
- **Stateless Backends (S3, Azure)**: If you are using a stateless storage backend (e.g. proxying to another S3 store) **and** you are either not using IAM or using an external IAM provider (e.g. LDAP, Vault), persistence can be safely disabled by setting `persistence.enabled=false`. With `iam.type=internal`, set `iam.persistence.enabled=true` to keep the IAM data on its own smaller PVC.
### Deployment Strategy
+26
View File
@@ -101,6 +101,32 @@ Returns empty string if persistence is disabled.
{{- end }}
{{- end }}
{{/*
The name of the PVC holding the gateway's internal IAM data when
iam.persistence.enabled is set.
*/}}
{{- define "versitygw.iamPvcName" -}}
{{- $iamPersistence := .Values.iam.persistence | default dict -}}
{{- if $iamPersistence.claimName }}
{{- $iamPersistence.claimName }}
{{- else }}
{{- $base := include "versitygw.fullname" . | trunc 51 | trimSuffix "-" -}}
{{- printf "%s-gateway-iam" $base }}
{{- end }}
{{- end }}
{{/*
Returns "true" when two absolute paths are equal or one contains the other.
Takes a list of two paths.
*/}}
{{- define "versitygw.pathsOverlap" -}}
{{- $a := clean (index . 0) -}}
{{- $b := clean (index . 1) -}}
{{- if or (eq $a $b) (eq $a "/") (eq $b "/") (hasPrefix (printf "%s/" $a) $b) (hasPrefix (printf "%s/" $b) $a) -}}
true
{{- end -}}
{{- end }}
{{/*
The name of the TLS Secret used for HTTPS.
Uses certificate.secretName if set, otherwise derives a name from the release fullname.
+72 -2
View File
@@ -7,6 +7,13 @@
{{- $iamStandalone := .Values.iam.standalone | default dict -}}
{{- $iamStandaloneEndpoint := $iamStandalone.endpoint | default "" -}}
{{- $iamStandaloneCredentials := $iamStandalone.credentials | default dict -}}
{{- $internalIAM := and .Values.iam.enabled (eq .Values.iam.type "internal") -}}
{{- $iamDir := clean (.Values.iam.dir | default "/mnt/iam") -}}
{{- $iamPersistence := .Values.iam.persistence | default dict -}}
{{- $sidecarDir := .Values.gateway.backend.sidecarDir -}}
{{- $versioningDir := .Values.gateway.backend.versioningDir -}}
{{- $adminPathPrefix := .Values.admin.pathPrefix | default "" -}}
{{- $webuiS3Prefix := .Values.webui.s3Prefix | default "" -}}
{{- /* Safety check: multiple replicas with local state must have persistence enabled */}}
{{- if and (gt (int .Values.replicaCount) 1) .Values.iam.enabled (eq .Values.iam.type "internal") }}
{{- fail "replicaCount > 1 cannot use iam.type=internal because the file store does not coordinate concurrent writers; use standalone or another external IAM backend" }}
@@ -31,9 +38,44 @@
{{- if and .Values.gateway.backend.versioningDir (not (or (eq .Values.gateway.backend.type "posix") (eq .Values.gateway.backend.type "scoutfs"))) }}
{{- fail "gateway.backend.versioningDir is only supported with the posix and scoutfs backends" }}
{{- end }}
{{- /* Bucket deletion removes <dir>/<bucket> in these directories, so nesting one in another loses data */}}
{{- if and $sidecarDir (include "versitygw.pathsOverlap" (list $sidecarDir "/mnt/data")) }}
{{- fail "gateway.backend.sidecarDir must not overlap the backend data mount /mnt/data" }}
{{- end }}
{{- if and $versioningDir (include "versitygw.pathsOverlap" (list $versioningDir "/mnt/data")) }}
{{- fail "gateway.backend.versioningDir must not overlap the backend data mount /mnt/data" }}
{{- end }}
{{- if and $sidecarDir $versioningDir (include "versitygw.pathsOverlap" (list $sidecarDir $versioningDir)) }}
{{- fail "gateway.backend.sidecarDir and gateway.backend.versioningDir must not overlap" }}
{{- end }}
{{- if not (or (eq $gatewayLogLevel "silent") (eq $gatewayLogLevel "debug") (eq $gatewayLogLevel "unsafe")) }}
{{- fail "gateway.logLevel must be one of silent, debug, or unsafe" }}
{{- end }}
{{- if and $adminPathPrefix (or (not (regexMatch "^/[A-Za-z0-9._~-]+$" $adminPathPrefix)) (eq $adminPathPrefix "/.") (eq $adminPathPrefix "/..")) }}
{{- fail "admin.pathPrefix must be \"/\" followed by a single segment of letters, digits, '-', '.', '_' or '~', other than '.' or '..' (e.g. /admin)" }}
{{- end }}
{{- range $key, $prefix := dict "webui.pathPrefix" .Values.webui.pathPrefix "webui.s3Prefix" $webuiS3Prefix }}
{{- if and $prefix (not (regexMatch "^/[^/?#\\\\[:space:]]+$" $prefix)) }}
{{- fail (printf "%s must be \"/\" followed by a single path segment (e.g. /ui)" $key) }}
{{- end }}
{{- end }}
{{- if and (not .Values.admin.enabled) $adminPathPrefix (eq (lower $adminPathPrefix) (lower $webuiS3Prefix)) }}
{{- fail "admin.pathPrefix must differ from webui.s3Prefix when the admin API is served on the S3 port (admin.enabled=false)" }}
{{- end }}
{{- if $internalIAM }}
{{- $iamOverlap := not (isAbs $iamDir) }}
{{- range compact (list "/mnt/data" $sidecarDir $versioningDir) }}
{{- if include "versitygw.pathsOverlap" (list $iamDir .) }}
{{- $iamOverlap = true }}
{{- end }}
{{- end }}
{{- if $iamOverlap }}
{{- fail "iam.dir must be an absolute path that does not overlap /mnt/data, gateway.backend.sidecarDir or gateway.backend.versioningDir" }}
{{- end }}
{{- end }}
{{- if and $internalIAM $iamPersistence.enabled (not (dig "create" true $iamPersistence)) (not $iamPersistence.claimName) }}
{{- fail "iam.persistence.claimName is required when iam.persistence.create is false" }}
{{- end }}
{{- if and .Values.iam.enabled (eq .Values.iam.type "standalone") (not .Values.iam.standalone.endpoint) (not $iamServerEnabled) }}
{{- fail "iam.type=standalone requires either iam.standalone.endpoint or iamServer.enabled=true" }}
{{- end }}
@@ -179,6 +221,10 @@ spec:
- name: VGW_ADMIN_MAX_REQUESTS
value: {{ .Values.admin.maxRequests | quote }}
{{- end }}
{{- if $adminPathPrefix }}
- name: VGW_ADMIN_PATH_PREFIX
value: {{ $adminPathPrefix | quote }}
{{- end }}
# WebUI
{{- if .Values.webui.enabled }}
- name: VGW_WEBUI_PORT
@@ -187,6 +233,16 @@ spec:
- name: VGW_WEBUI_NO_TLS
value: "true"
{{- end }}
{{- if .Values.webui.pathPrefix }}
- name: VGW_WEBUI_PATH_PREFIX
value: {{ .Values.webui.pathPrefix | quote }}
{{- end }}
{{- end }}
{{- if $webuiS3Prefix }}
- name: VGW_WEBUI_S3_PREFIX
value: {{ $webuiS3Prefix | quote }}
{{- end }}
{{- if or .Values.webui.enabled $webuiS3Prefix }}
{{- if .Values.webui.apiGateways }}
- name: VGW_WEBUI_GATEWAYS
value: {{ .Values.webui.apiGateways | join "," | quote }}
@@ -215,7 +271,7 @@ spec:
# IAM settings
{{- if eq .Values.iam.type "internal" }}
- name: VGW_IAM_DIR
value: "/mnt/iam"
value: {{ $iamDir | quote }}
{{- else if eq .Values.iam.type "standalone" }}
- name: VGW_IAM_STANDALONE_ENDPOINT
value: {{ include "versitygw.standaloneIAMEndpoint" . | quote }}
@@ -326,7 +382,16 @@ spec:
subPath: versioning
readOnly: false
{{- end }}
{{- if or (not .Values.iam.enabled) (eq .Values.iam.type "internal") }}
{{- if and $internalIAM $iamPersistence.enabled }}
- name: iam
mountPath: {{ $iamDir | quote }}
readOnly: false
{{- else if $internalIAM }}
- name: data
mountPath: {{ $iamDir | quote }}
subPath: iam
readOnly: false
{{- else if not .Values.iam.enabled }}
- name: data
mountPath: /mnt/iam
subPath: iam
@@ -356,6 +421,11 @@ spec:
{{- else }}
emptyDir: {}
{{- end }}
{{- if and $internalIAM $iamPersistence.enabled }}
- name: iam
persistentVolumeClaim:
claimName: {{ include "versitygw.iamPvcName" . }}
{{- end }}
{{- if .Values.tls.enabled }}
- name: certificates
secret:
+19
View File
@@ -15,3 +15,22 @@ spec:
storageClassName: {{ .Values.persistence.storageClassName | quote }}
{{- end }}
{{- end }}
{{- $iamPersistence := .Values.iam.persistence | default dict }}
{{- if and .Values.iam.enabled (eq .Values.iam.type "internal") $iamPersistence.enabled (dig "create" true $iamPersistence) }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "versitygw.iamPvcName" . }}
labels:
{{- include "versitygw.labels" . | nindent 4 }}
spec:
accessModes:
- {{ $iamPersistence.accessMode | default "ReadWriteOnce" | quote }}
resources:
requests:
storage: {{ $iamPersistence.size | default "1Gi" }}
{{- if $iamPersistence.storageClassName }}
storageClassName: {{ $iamPersistence.storageClassName | quote }}
{{- end }}
{{- end }}
+37
View File
@@ -141,9 +141,11 @@ gateway:
args: "/mnt/data"
# Optional directory for POSIX sidecar metadata storage. When set, the chart
# mounts this path from persistent storage and exports VGW_META_SIDECAR.
# Must not overlap /mnt/data or versioningDir.
sidecarDir: ""
# Optional directory for POSIX or ScoutFS object version storage. When set,
# the chart mounts this path from persistent storage and exports VGW_VERSIONING_DIR.
# Must not overlap /mnt/data or sidecarDir.
versioningDir: ""
# for s3 backend:
# args: "--access 0123456 --secret 0xdeadbeef --endpoint http://s3.example.com"
@@ -192,6 +194,14 @@ admin:
maxConnections: 250000
# Maximum in-flight requests for the admin server.
maxRequests: 100000
# Serve the admin API under a path prefix (e.g. "/admin"), so an ingress or
# HTTPRoute can forward that path to it on a shared hostname. Proxies must
# not strip the prefix: requests are signed over the full path. Applies to
# admin.port, or to the S3 port when admin.enabled=false. Admin clients,
# including webui.adminGateways, must include the prefix in their URL.
# Must be "/" followed by one segment of letters, digits, '-', '.', '_', '~'
# (not "." or "..").
pathPrefix: ""
# --- Ingress ---
# Expose the Admin API via a Kubernetes Ingress resource.
# Requires an ingress controller (e.g. nginx, traefik) to be installed in the cluster.
@@ -236,6 +246,13 @@ webui:
port: 8080
# Disable TLS for the WebUI even when gateway TLS is enabled.
noTls: false
# Serve the WebUI under a path prefix on webui.port (e.g. "/ui"). Set the
# WebUI ingress/httpRoute path to the same prefix.
pathPrefix: ""
# Also serve the WebUI on the S3 port under this path prefix (e.g. "/ui"),
# reachable through the S3 ingress/httpRoute. Works without webui.enabled;
# a bucket named like the prefix is hidden from S3 clients.
s3Prefix: ""
# List of S3 endpoints used by the Versity Web UI
# The list that is auto-generated by Versity GW is wrong in the case of
# Kubernetes because it uses the internal pod IP addresses.
@@ -331,6 +348,26 @@ iam:
# Enable IAM-specific debug output (independent of gateway.logLevel).
debug: false
# --- internal (iam.type: internal) ---
# Directory holding the internal IAM data inside the gateway container.
# Must be an absolute path outside /mnt/data and the backend sidecarDir and
# versioningDir. Only used with iam.enabled and iam.type: internal.
dir: /mnt/iam
# Store internal IAM data on its own volume. When disabled, it is kept in
# the "iam" subdirectory of the backend data volume (see `persistence`).
# Existing accounts are not moved when enabling this: copy users.json from
# that subdirectory to the new volume first. A PVC created here is deleted
# once iam.enabled is false or iam.type is no longer internal.
persistence:
enabled: false
# Whether to create a new PVC. If false, iam.persistence.claimName must be provided.
create: true
# The name of the PVC that should be created or used (if iam.persistence.create=false)
claimName: ""
size: 1Gi
storageClassName: ""
accessMode: ReadWriteOnce
# --- standalone (iam.type: standalone) ---
standalone:
# Private endpoint of the standalone IAM service: a "host:port" TCP
+8
View File
@@ -38,6 +38,7 @@ var (
adminMaxConnections, adminMaxRequests int
corsAllowOrigin string
admCertFile, admKeyFile string
adminPathPrefix string
certFile, keyFile string
kafkaURL, kafkaTopic, kafkaKey string
natsURL, natsTopic string
@@ -393,6 +394,12 @@ func initFlags() []cli.Flag {
EnvVars: []string{"VGW_ADMIN_CERT_KEY"},
Destination: &admKeyFile,
},
&cli.StringFlag{
Name: "admin-path-prefix",
Usage: "mount the admin API under a path prefix (e.g. '/admin'), on --admin-port or, when that is unset, on the S3 port; must be '/' followed by a single segment of letters, digits, '-', '.', '_' or '~'",
EnvVars: []string{"VGW_ADMIN_PATH_PREFIX"},
Destination: &adminPathPrefix,
},
&cli.StringFlag{
Name: "log-level",
Usage: `debug logger verbosity: "silent" (default, no debug output), ` +
@@ -947,6 +954,7 @@ func runGateway(ctx context.Context, be backend.Backend) error {
KeyFile: keyFile,
AdminCertFile: admCertFile,
AdminKeyFile: admKeyFile,
AdminPathPrefix: adminPathPrefix,
CORSAllowOrigin: corsAllowOrigin,
LogLevel: logLvl,
IAMDebug: iamDebug,
+8
View File
@@ -53,6 +53,7 @@ var (
adminMaxConnections, adminMaxRequests int
corsAllowOrigin string
admCertFile, admKeyFile string
adminPathPrefix string
certFile, keyFile string
kafkaURL, kafkaTopic, kafkaKey string
natsURL, natsTopic string
@@ -460,6 +461,12 @@ func initFlags() []cli.Flag {
EnvVars: []string{"VGW_ADMIN_CERT_KEY"},
Destination: &admKeyFile,
},
&cli.StringFlag{
Name: "admin-path-prefix",
Usage: "mount the admin API under a path prefix (e.g. '/admin'), on --admin-port or, when that is unset, on the S3 port; must be '/' followed by a single segment of letters, digits, '-', '.', '_' or '~'",
EnvVars: []string{"VGW_ADMIN_PATH_PREFIX"},
Destination: &adminPathPrefix,
},
&cli.BoolFlag{
Name: "debug",
Usage: "enable debug output",
@@ -1149,6 +1156,7 @@ func runGateway(ctx context.Context, be backend.Backend) error {
KeyFile: keyFile,
AdminCertFile: admCertFile,
AdminKeyFile: admKeyFile,
AdminPathPrefix: adminPathPrefix,
CORSAllowOrigin: corsAllowOrigin,
LogLevel: debugLogLevel(),
IAMDebug: iamDebug,
+71 -3
View File
@@ -75,6 +75,13 @@ type Config struct {
// control over the admin endpoint with optionally separate TLS certs.
AdminPorts []string
// AdminPathPrefix mounts the admin API under a URL path prefix (e.g.
// "/admin"), on AdminPorts or, when those are empty, on the S3 endpoints.
// Must be "/" followed by a single segment of unreserved characters
// (letters, digits, '-', '.', '_', '~'), other than "." or "..". Leave
// empty to serve from the root path.
AdminPathPrefix string
// AdminOptions carries extra standalone-admin-server options from
// the embedding binary (e.g. additional admin routes). Only used
// when AdminPorts is non-empty.
@@ -465,7 +472,8 @@ type Config struct {
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.
// reuses WebuiGateways when AdminPorts is empty, appending
// AdminPathPrefix in both cases.
WebuiAdminGateways []string
// WebuiIAMGateways are the standalone IAM service (versitygw iam) URLs
// offered to the WebUI's optional IAM endpoint field. There is no
@@ -631,6 +639,9 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error {
if err != nil {
return err
}
if err := validateAdminPathPrefix(cfg.AdminPathPrefix); err != nil {
return err
}
if cfg.MaxConnections < 1 {
return fmt.Errorf("max-connections must be positive")
@@ -692,6 +703,10 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error {
if err := validateWebUIPathPrefix("WebuiS3Prefix", cfg.WebuiS3Prefix); err != nil {
return err
}
// The WebUI mount would shadow admin routes sharing its prefix.
if len(cfg.AdminPorts) == 0 && cfg.AdminPathPrefix != "" && strings.EqualFold(cfg.AdminPathPrefix, cfg.WebuiS3Prefix) {
return fmt.Errorf("AdminPathPrefix %q must differ from WebuiS3Prefix when the admin API is served on the S3 port", cfg.AdminPathPrefix)
}
// Pre-validate gateway URL lists once; both the WebuiS3Prefix block and the
// WebuiPorts block need these, so validate here to avoid doing it twice.
@@ -755,6 +770,9 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error {
}
if len(cfg.AdminPorts) == 0 {
opts = append(opts, s3api.WithAdminServer())
if cfg.AdminPathPrefix != "" {
opts = append(opts, s3api.WithAdminServerPathPrefix(cfg.AdminPathPrefix))
}
}
if cfg.Quiet {
opts = append(opts, s3api.WithQuiet())
@@ -868,6 +886,9 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error {
}
sortGatewayURLs(s3WebAdminGateways)
}
if len(validatedWebuiAdminGateways) == 0 {
s3WebAdminGateways = appendPathPrefix(s3WebAdminGateways, cfg.AdminPathPrefix)
}
opts = append(opts, s3api.WithWebUI(cfg.WebuiS3Prefix, &webui.ServerConfig{
Gateways: s3WebGateways,
@@ -938,6 +959,9 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error {
if cfg.SocketPerm != "" {
admOpts = append(admOpts, s3api.WithAdminSocketPerm(parsedSocketPerm))
}
if cfg.AdminPathPrefix != "" {
admOpts = append(admOpts, s3api.WithAdminPathPrefix(cfg.AdminPathPrefix))
}
admSrv = s3api.NewAdminServer(be, middlewares.RootUserConfig{Access: cfg.RootUserAccess, Secret: cfg.RootUserSecret}, cfg.Region, iam, loggers.AdminLogger, srv.Router.Ctrl, admOpts...)
}
@@ -1020,6 +1044,9 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error {
}
sortGatewayURLs(adminGateways)
}
if len(validatedWebuiAdminGateways) == 0 {
adminGateways = appendPathPrefix(adminGateways, cfg.AdminPathPrefix)
}
if cfg.Quiet {
webOpts = append(webOpts, webui.WithQuiet())
@@ -1385,7 +1412,7 @@ func (cfg Config) printBanner() {
centerText(""),
}
if len(allAdmInterfaces) > 0 {
if len(cfg.AdminPorts) > 0 || cfg.AdminPathPrefix != "" {
lines = append(lines, leftText("S3 service listening on:"))
} else {
lines = append(lines, leftText("Admin/S3 service listening on:"))
@@ -1395,7 +1422,7 @@ func (cfg Config) printBanner() {
lines = append(lines, leftText(" "+u))
}
if len(allAdmInterfaces) > 0 {
if len(cfg.AdminPorts) > 0 {
lines = append(lines, centerText(""), leftText("Admin service listening on:"))
for _, addrPort := range allAdmInterfaces {
if netutil.IsUnixSocketPath(addrPort) {
@@ -1411,6 +1438,14 @@ func (cfg Config) printBanner() {
if admSSL {
u = fmt.Sprintf("https://%s", hostPort)
}
lines = append(lines, leftText(" "+u+cfg.AdminPathPrefix))
}
} else if cfg.AdminPathPrefix != "" {
lines = append(lines, centerText(""), leftText("Admin service listening on:"))
for _, u := range urls {
if !strings.HasPrefix(u, "unix:") {
u += cfg.AdminPathPrefix
}
lines = append(lines, leftText(" "+u))
}
}
@@ -1723,6 +1758,39 @@ func validateWebUIPathPrefix(option, prefix string) error {
return nil
}
// validateAdminPathPrefix accepts only unreserved characters, since SigV4
// clients escape anything else in the signed path.
func validateAdminPathPrefix(prefix string) error {
if prefix == "" {
return nil
}
seg, ok := strings.CutPrefix(prefix, "/")
valid := ok && seg != "" && seg != "." && seg != ".."
for _, c := range seg {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("-._~", c)) {
valid = false
break
}
}
if !valid {
return fmt.Errorf("invalid AdminPathPrefix %q: must be '/' followed by a single segment of letters, digits, '-', '.', '_' or '~', other than '.' or '..' (example: '/admin')", prefix)
}
return nil
}
// appendPathPrefix returns urls with prefix appended to each, leaving urls
// unmodified.
func appendPathPrefix(urls []string, prefix string) []string {
if prefix == "" {
return urls
}
out := make([]string, 0, len(urls))
for _, u := range urls {
out = append(out, strings.TrimRight(u, "/")+prefix)
}
return out
}
func sortGatewayURLs(urls []string) {
if len(urls) <= 1 {
return
+100
View File
@@ -15,7 +15,12 @@
package embedgw
import (
"context"
"slices"
"strings"
"testing"
"github.com/versity/versitygw/backend"
)
func TestValidatePortConflicts(t *testing.T) {
@@ -157,3 +162,98 @@ func TestValidatePortConflicts(t *testing.T) {
})
}
}
func TestValidateAdminPathPrefix(t *testing.T) {
tests := []struct {
prefix string
wantErr bool
}{
{prefix: "", wantErr: false},
{prefix: "/admin", wantErr: false},
{prefix: "/vgw-admin_1.0~x", wantErr: false},
{prefix: "admin", wantErr: true},
{prefix: "/", wantErr: true},
{prefix: "/admin/", wantErr: true},
{prefix: "/api/admin", wantErr: true},
{prefix: "/.", wantErr: true},
{prefix: "/..", wantErr: true},
{prefix: "/ad min", wantErr: true},
{prefix: "/:bucket", wantErr: true},
{prefix: "/admin*", wantErr: true},
{prefix: "/admin%20", wantErr: true},
{prefix: "/admin?x", wantErr: true},
{prefix: " /admin", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.prefix, func(t *testing.T) {
err := validateAdminPathPrefix(tt.prefix)
if (err != nil) != tt.wantErr {
t.Fatalf("validateAdminPathPrefix(%q) = %v, wantErr %v", tt.prefix, err, tt.wantErr)
}
})
}
}
func TestAppendPathPrefix(t *testing.T) {
urls := []string{"http://127.0.0.1:7070", "https://s3.example.com/"}
got := appendPathPrefix(urls, "/admin")
want := []string{"http://127.0.0.1:7070/admin", "https://s3.example.com/admin"}
if !slices.Equal(got, want) {
t.Fatalf("appendPathPrefix = %v, want %v", got, want)
}
if urls[0] != "http://127.0.0.1:7070" {
t.Fatalf("appendPathPrefix modified its input: %v", urls)
}
if got := appendPathPrefix(urls, ""); !slices.Equal(got, urls) {
t.Fatalf("appendPathPrefix without prefix = %v, want %v", got, urls)
}
}
func TestRunVersityGWValidatesAdminPathPrefix(t *testing.T) {
tests := []struct {
name string
mutate func(*Config)
wantErr string
}{
{
name: "invalid prefix",
mutate: func(cfg *Config) {
cfg.AdminPathPrefix = "/api/admin"
},
wantErr: "invalid AdminPathPrefix",
},
{
name: "same as webui s3 prefix",
mutate: func(cfg *Config) {
cfg.AdminPathPrefix = "/ui"
cfg.WebuiS3Prefix = "/UI"
},
wantErr: "must differ from WebuiS3Prefix",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Config{
RootUserAccess: "root",
RootUserSecret: "secret",
Ports: []string{"127.0.0.1:0"},
MaxConnections: 1,
MaxRequests: 1,
MultipartMaxParts: 1,
Quiet: true,
}
tt.mutate(&cfg)
err := RunVersityGW(context.Background(), backend.BackendUnsupported{}, &cfg)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want substring %q", err, tt.wantErr)
}
})
}
}
+12 -2
View File
@@ -112,6 +112,15 @@ ROOT_SECRET_ACCESS_KEY=
#VGW_ADMIN_CERT=
#VGW_ADMIN_CERT_KEY=
# The VGW_ADMIN_PATH_PREFIX option serves the admin API under a URL path
# prefix (for example, '/admin'), on VGW_ADMIN_PORT or, when that is unset, on
# the S3 service endpoint. This lets a reverse proxy route the prefix to the
# admin API without rewriting the path, which would break request signatures.
# Admin clients must include the prefix in their endpoint URL. The prefix must
# be '/' followed by a single path segment of letters, digits, '-', '.', '_'
# or '~'. Leave unset to serve from '/'.
#VGW_ADMIN_PATH_PREFIX=
# The VGW_ADMIN_MAX_CONNECTIONS option sets the maximum number of concurrent
# connections the admin server may serve simultaneously. Connections beyond
# this limit are refused at the TCP level; clients will receive a connection error.
@@ -336,8 +345,9 @@ ROOT_SECRET_ACCESS_KEY=
# The VGW_WEBUI_ADMIN_GATEWAYS option allows you to override the auto-detected
# admin gateway URLs that are provided to the Web GUI. By default, the gateway
# auto-detects URLs based on the configured VGW_ADMIN_PORT settings (or uses the
# same URLs as VGW_WEBUI_GATEWAYS if no admin ports are configured). Use this
# option to specify custom admin URLs when the auto-detected values are incorrect.
# same URLs as VGW_WEBUI_GATEWAYS if no admin ports are configured), with
# VGW_ADMIN_PATH_PREFIX appended. Use this option to specify custom admin URLs
# when the auto-detected values are incorrect.
# Multiple URLs can be specified as a comma-separated list.
# Example: VGW_WEBUI_ADMIN_GATEWAYS=https://admin.example.com,http://192.168.1.100:7080
#VGW_WEBUI_ADMIN_GATEWAYS=
+2 -2
View File
@@ -28,7 +28,7 @@ type S3AdminRouter struct {
s3api controllers.S3ApiController
}
func (ar *S3AdminRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMService, logger s3log.AuditLogger, root middlewares.RootUserConfig, region string, debug bool, corsAllowOrigin string) {
func (ar *S3AdminRouter) Init(app fiber.Router, be backend.Backend, iam auth.IAMService, logger s3log.AuditLogger, root middlewares.RootUserConfig, region string, debug bool, corsAllowOrigin string) {
ctrl := controllers.NewAdminController(iam, be, logger, ar.s3api)
services := &controllers.Services{
Logger: logger,
@@ -107,7 +107,7 @@ func (ar *S3AdminRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMSe
)
app.Patch("/:bucket/create",
controllers.ProcessHandlers(ctrl.CreateBucket, metrics.ActionAdminListBuckets, services,
controllers.ProcessHandlers(ctrl.CreateBucket, metrics.ActionAdminCreateBucket, services,
middlewares.VerifyV4Signature(root, iam, region, false, true, false),
middlewares.IsAdmin(metrics.ActionAdminCreateBucket),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
+10 -2
View File
@@ -42,6 +42,7 @@ type S3AdminServer struct {
maxConnections int
maxRequests int
socketPerm os.FileMode
pathPrefix string
extraRoutes []adminRouteMount
}
@@ -102,14 +103,15 @@ func NewAdminServer(be backend.Backend, root middlewares.RootUserConfig, region
app.Use("*", middlewares.DebugLogger())
}
server.router.Init(app, be, iam, l, root, region, server.debug, server.corsAllowOrigin)
router := app.Group(server.pathPrefix)
server.router.Init(router, be, iam, l, root, region, server.debug, server.corsAllowOrigin)
for _, r := range server.extraRoutes {
args := make([]any, 0, len(r.handlers))
for _, h := range r.handlers {
args = append(args, h)
}
app.Add([]string{r.method}, r.path, args[0], args[1:]...)
router.Add([]string{r.method}, r.path, args[0], args[1:]...)
}
return server
@@ -153,6 +155,12 @@ func WithAdminSocketPerm(perm os.FileMode) AdminOpt {
return func(s *S3AdminServer) { s.socketPerm = perm }
}
// WithAdminPathPrefix mounts all admin routes under the given path prefix
// (e.g. "/admin").
func WithAdminPathPrefix(prefix string) AdminOpt {
return func(s *S3AdminServer) { s.pathPrefix = prefix }
}
// WithAdminRoute registers a route on the standalone admin server,
// after the built-in admin routes and their middleware chain. Use it
// for admin-surface endpoints that do not fit the S3 admin controller
+136
View File
@@ -0,0 +1,136 @@
// 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 s3api
import (
"context"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/gofiber/fiber/v3"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/s3api/controllers"
"github.com/versity/versitygw/s3api/middlewares"
)
func TestAdminPathPrefix(t *testing.T) {
servers := []struct {
name string
newApp func(t *testing.T, prefix string) *fiber.App
}{
{name: "standalone admin server", newApp: newTestAdminApp},
{name: "admin on s3 server", newApp: newTestS3AdminApp},
}
tests := []struct {
name string
prefix string
path string
handled bool
}{
{name: "no prefix", prefix: "", path: "/list-buckets", handled: true},
{name: "prefixed route", prefix: "/admin", path: "/admin/list-buckets", handled: true},
{name: "prefixed route with trailing slash", prefix: "/admin", path: "/admin/list-buckets/", handled: true},
{name: "root route with prefix set", prefix: "/admin", path: "/list-buckets", handled: false},
{name: "other prefix", prefix: "/admin", path: "/adminx/list-buckets", handled: false},
}
for _, srv := range servers {
for _, tt := range tests {
t.Run(srv.name+"/"+tt.name, func(t *testing.T) {
app := srv.newApp(t, tt.prefix)
resp, err := app.Test(signedAdminRequest(t, tt.path))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
// BackendUnsupported answers a signed, routed list-buckets call with 501.
handled := resp.StatusCode == http.StatusNotImplemented
if handled != tt.handled {
t.Fatalf("PATCH %s with prefix %q: status %d, want handled=%v", tt.path, tt.prefix, resp.StatusCode, tt.handled)
}
})
}
}
}
func TestAdminPathPrefixExtraRoutes(t *testing.T) {
srv := NewAdminServer(backend.BackendUnsupported{}, testAdminRoot, "us-east-1", testAdminIAM(), nil, controllers.S3ApiController{},
WithAdminConcurrencyLimiter(10, 10),
WithAdminQuiet(),
WithAdminPathPrefix("/admin"),
WithAdminRoute(http.MethodGet, "/extra", func(ctx fiber.Ctx) error {
return ctx.SendStatus(http.StatusNoContent)
}),
)
for path, want := range map[string]bool{"/admin/extra": true, "/extra": false} {
resp, err := srv.app.Test(httptest.NewRequest(http.MethodGet, path, nil))
if err != nil {
t.Fatalf("app.Test %s: %v", path, err)
}
resp.Body.Close()
if got := resp.StatusCode == http.StatusNoContent; got != want {
t.Errorf("GET %s: status %d, want handled=%v", path, resp.StatusCode, want)
}
}
}
var testAdminRoot = middlewares.RootUserConfig{Access: "access", Secret: "secret"}
func testAdminIAM() auth.IAMService {
return auth.NewIAMServiceSingle(auth.Account{Access: testAdminRoot.Access, Secret: testAdminRoot.Secret})
}
func newTestAdminApp(t *testing.T, prefix string) *fiber.App {
t.Helper()
srv := NewAdminServer(backend.BackendUnsupported{}, testAdminRoot, "us-east-1", testAdminIAM(), nil, controllers.S3ApiController{},
WithAdminConcurrencyLimiter(10, 10),
WithAdminQuiet(),
WithAdminPathPrefix(prefix),
)
return srv.app
}
func newTestS3AdminApp(t *testing.T, prefix string) *fiber.App {
t.Helper()
srv, err := newTestS3ApiServer(WithQuiet(), WithAdminServer(), WithAdminServerPathPrefix(prefix))
if err != nil {
t.Fatalf("new s3 server: %v", err)
}
return srv.app
}
func signedAdminRequest(t *testing.T, path string) *http.Request {
t.Helper()
sum := sha256.Sum256(nil)
payloadHash := hex.EncodeToString(sum[:])
req := httptest.NewRequest(http.MethodPatch, "http://localhost"+path, nil)
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
creds := aws.Credentials{AccessKeyID: testAdminRoot.Access, SecretAccessKey: testAdminRoot.Secret}
if err := v4.NewSigner().SignHTTP(context.Background(), creds, req, payloadHash, "s3", "us-east-1", time.Now()); err != nil {
t.Fatalf("sign request: %v", err)
}
return req
}
+3 -89
View File
@@ -45,106 +45,20 @@ type S3ApiRouter struct {
virtualDomain string
corsAllowOrigin string
mpMaxParts int
adminPathPrefix string
}
func (sa *S3ApiRouter) Init() {
ctrl := controllers.New(sa.be, sa.iam, sa.logger, sa.evs, sa.mm, sa.readonly, sa.disableACL, sa.virtualDomain, sa.mpMaxParts)
sa.Ctrl = ctrl
adminServices := &controllers.Services{
Logger: sa.aLogger,
}
// initialize global host-style parser middleware if virtual domain is specified
if sa.virtualDomain != "" {
sa.app.Use("*", middlewares.HostStyleParser(sa.virtualDomain))
}
if sa.WithAdmSrv {
adminController := controllers.NewAdminController(sa.iam, sa.be, sa.aLogger, ctrl)
// CreateUser admin api
sa.app.Patch("/create-user",
controllers.ProcessHandlers(adminController.CreateUser, metrics.ActionAdminCreateUser, adminServices,
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.IsAdmin(metrics.ActionAdminCreateUser),
middlewares.ApplyDefaultCORS(sa.corsAllowOrigin),
))
sa.app.Options("/create-user",
middlewares.ApplyDefaultCORSPreflight(sa.corsAllowOrigin),
middlewares.ApplyDefaultCORS(sa.corsAllowOrigin),
)
// DeleteUsers admin api
sa.app.Patch("/delete-user",
controllers.ProcessHandlers(adminController.DeleteUser, metrics.ActionAdminDeleteUser, adminServices,
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.IsAdmin(metrics.ActionAdminDeleteUser),
middlewares.ApplyDefaultCORS(sa.corsAllowOrigin),
))
sa.app.Options("/delete-user",
middlewares.ApplyDefaultCORSPreflight(sa.corsAllowOrigin),
middlewares.ApplyDefaultCORS(sa.corsAllowOrigin),
)
// UpdateUser admin api
sa.app.Patch("/update-user",
controllers.ProcessHandlers(adminController.UpdateUser, metrics.ActionAdminUpdateUser, adminServices,
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.IsAdmin(metrics.ActionAdminUpdateUser),
middlewares.ApplyDefaultCORS(sa.corsAllowOrigin),
))
sa.app.Options("/update-user",
middlewares.ApplyDefaultCORSPreflight(sa.corsAllowOrigin),
middlewares.ApplyDefaultCORS(sa.corsAllowOrigin),
)
// ListUsers admin api
sa.app.Patch("/list-users",
controllers.ProcessHandlers(adminController.ListUsers, metrics.ActionAdminListUsers, adminServices,
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.IsAdmin(metrics.ActionAdminListUsers),
middlewares.ApplyDefaultCORS(sa.corsAllowOrigin),
))
sa.app.Options("/list-users",
middlewares.ApplyDefaultCORSPreflight(sa.corsAllowOrigin),
middlewares.ApplyDefaultCORS(sa.corsAllowOrigin),
)
// ChangeBucketOwner admin api
sa.app.Patch("/change-bucket-owner",
controllers.ProcessHandlers(adminController.ChangeBucketOwner, metrics.ActionAdminChangeBucketOwner, adminServices,
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.IsAdmin(metrics.ActionAdminChangeBucketOwner),
middlewares.ApplyDefaultCORS(sa.corsAllowOrigin),
))
sa.app.Options("/change-bucket-owner",
middlewares.ApplyDefaultCORSPreflight(sa.corsAllowOrigin),
middlewares.ApplyDefaultCORS(sa.corsAllowOrigin),
)
// ListBucketsAndOwners admin api
sa.app.Patch("/list-buckets",
controllers.ProcessHandlers(adminController.ListBuckets, metrics.ActionAdminListBuckets, adminServices,
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.IsAdmin(metrics.ActionAdminListBuckets),
middlewares.ApplyDefaultCORS(sa.corsAllowOrigin),
))
sa.app.Options("/list-buckets",
middlewares.ApplyDefaultCORSPreflight(sa.corsAllowOrigin),
middlewares.ApplyDefaultCORS(sa.corsAllowOrigin),
)
// CreateBucket admin api
sa.app.Patch("/:bucket/create",
controllers.ProcessHandlers(adminController.CreateBucket, metrics.ActionAdminCreateBucket, adminServices,
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.IsAdmin(metrics.ActionAdminCreateBucket),
middlewares.ApplyDefaultCORS(sa.corsAllowOrigin),
))
sa.app.Options("/:bucket/create",
middlewares.ApplyDefaultCORSPreflight(sa.corsAllowOrigin),
middlewares.ApplyDefaultCORS(sa.corsAllowOrigin),
)
adminRouter := &S3AdminRouter{s3api: ctrl}
adminRouter.Init(sa.app.Group(sa.adminPathPrefix), sa.be, sa.iam, sa.aLogger, sa.root, sa.region, false, sa.corsAllowOrigin)
}
services := &controllers.Services{
+6
View File
@@ -253,6 +253,12 @@ func WithAdminServer() Option {
return func(s *S3ApiServer) { s.Router.WithAdmSrv = true }
}
// WithAdminServerPathPrefix mounts the admin endpoints served with the
// gateway under the given path prefix (e.g. "/admin").
func WithAdminServerPathPrefix(prefix string) Option {
return func(s *S3ApiServer) { s.Router.adminPathPrefix = prefix }
}
// WithQuiet silences default logging output
func WithQuiet() Option {
return func(s *S3ApiServer) { s.quiet = true }