diff --git a/.github/workflows/helm-lint.yml b/.github/workflows/helm-lint.yml index 5615c294..89edccb6 100644 --- a/.github/workflows/helm-lint.yml +++ b/.github/workflows/helm-lint.yml @@ -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 diff --git a/chart/Chart.yaml b/chart/Chart.yaml index 0aeb0636..a04b537d 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -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 diff --git a/chart/README.md b/chart/README.md index 504a0e76..828675c9 100644 --- a/chart/README.md +++ b/chart/README.md @@ -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 diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl index 5b12140d..b0b50f77 100644 --- a/chart/templates/_helpers.tpl +++ b/chart/templates/_helpers.tpl @@ -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. diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 8d8bf006..8a221171 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -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 / 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: diff --git a/chart/templates/pvc.yaml b/chart/templates/pvc.yaml index ed1a23f6..d7e2b544 100644 --- a/chart/templates/pvc.yaml +++ b/chart/templates/pvc.yaml @@ -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 }} diff --git a/chart/values.yaml b/chart/values.yaml index 1a6f2587..3a4a2d8f 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -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 diff --git a/cmd/versitygw/main.go b/cmd/versitygw/main.go index 69dbc9ae..97cb2ce5 100644 --- a/cmd/versitygw/main.go +++ b/cmd/versitygw/main.go @@ -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, diff --git a/cmd/vgwrdma/main.go b/cmd/vgwrdma/main.go index bd7579d9..753de36d 100644 --- a/cmd/vgwrdma/main.go +++ b/cmd/vgwrdma/main.go @@ -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, diff --git a/embedgw/embedgw.go b/embedgw/embedgw.go index b5b9d9f8..dd65d738 100644 --- a/embedgw/embedgw.go +++ b/embedgw/embedgw.go @@ -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 diff --git a/embedgw/embedgw_test.go b/embedgw/embedgw_test.go index a692cd7d..e8967a29 100644 --- a/embedgw/embedgw_test.go +++ b/embedgw/embedgw_test.go @@ -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) + } + }) + } +} diff --git a/extra/example.conf b/extra/example.conf index 5ab7bd32..589564cb 100644 --- a/extra/example.conf +++ b/extra/example.conf @@ -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= diff --git a/s3api/admin-router.go b/s3api/admin-router.go index a4d4b240..3420a2f1 100644 --- a/s3api/admin-router.go +++ b/s3api/admin-router.go @@ -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), diff --git a/s3api/admin-server.go b/s3api/admin-server.go index 3be14529..ebd0420d 100644 --- a/s3api/admin-server.go +++ b/s3api/admin-server.go @@ -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 diff --git a/s3api/admin_path_prefix_test.go b/s3api/admin_path_prefix_test.go new file mode 100644 index 00000000..22e9c80b --- /dev/null +++ b/s3api/admin_path_prefix_test.go @@ -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 +} diff --git a/s3api/router.go b/s3api/router.go index 3ab8c66b..2cc674ab 100644 --- a/s3api/router.go +++ b/s3api/router.go @@ -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{ diff --git a/s3api/server.go b/s3api/server.go index 48543c13..3a1436ef 100644 --- a/s3api/server.go +++ b/s3api/server.go @@ -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 }