mirror of
https://github.com/deuxfleurs-org/garage.git
synced 2026-08-13 23:56:53 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b275dc5000 | |||
| 0a48d504fe | |||
| c834f3f024 | |||
| a8d73682a4 | |||
| 8654eb19bf | |||
| 54ea412188 | |||
| 2ade8c86f6 | |||
| b15e2cbb6c | |||
| 0fd1b7342b | |||
| be16bc7a05 | |||
| bfaa1ca6b7 | |||
| de8eeab4ad | |||
| ae3f7ee76c | |||
| 2dc3a6dbbe | |||
| c6bc3f229b | |||
| bba9202f31 | |||
| a605a80806 | |||
| 539af12d21 | |||
| a2a9e3cec4 | |||
| 14274bc13c | |||
| bf4691d98a | |||
| ad151cb1dc | |||
| 3c20984a08 | |||
| e6e4e051a1 | |||
| 9b38cba6f3 | |||
| 4ef954d176 | |||
| 02498a93d0 | |||
| 4caad5425d | |||
| 9ec3f8cc3c | |||
| 14d2f2b18d | |||
| a7d845a999 | |||
| dd20e5d22a | |||
| 6906a4ff12 | |||
| 9053782d71 |
Generated
+469
-557
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -58,7 +58,6 @@ git-version = "0.3.4"
|
||||
hex = "0.4"
|
||||
hexdump = "0.1"
|
||||
hmac = "0.12"
|
||||
idna = "0.5"
|
||||
itertools = "0.12"
|
||||
ipnet = "2.9.0"
|
||||
lazy_static = "1.4"
|
||||
@@ -132,8 +131,8 @@ opentelemetry-contrib = "0.9"
|
||||
prometheus = "0.13"
|
||||
|
||||
# used by the k2v-client crate only
|
||||
aws-sigv4 = { version = "1.1" }
|
||||
hyper-rustls = { version = "0.26", features = ["http2"] }
|
||||
aws-sigv4 = { version = "1.1", default-features = false }
|
||||
hyper-rustls = { version = "0.26", default-features = false, features = ["http1", "http2", "ring", "rustls-native-certs"] }
|
||||
log = "0.4"
|
||||
thiserror = "1.0"
|
||||
|
||||
@@ -141,8 +140,9 @@ thiserror = "1.0"
|
||||
assert-json-diff = "2.0"
|
||||
rustc_version = "0.4.0"
|
||||
static_init = "1.0"
|
||||
aws-sdk-config = "1.62"
|
||||
aws-sdk-s3 = "=1.68"
|
||||
aws-smithy-runtime = { version = "1.8", default-features = false, features = ["tls-rustls"] }
|
||||
aws-sdk-config = { version = "1.62", default-features = false }
|
||||
aws-sdk-s3 = { version = "1.79", default-features = false, features = ["rt-tokio"] }
|
||||
|
||||
[profile.dev]
|
||||
#lto = "thin" # disabled for now, adds 2-4 min to each CI build
|
||||
|
||||
@@ -69,7 +69,7 @@ $CONFIG = array(
|
||||
'hostname' => '127.0.0.1', // Can also be a domain name, eg. garage.example.com
|
||||
'port' => 3900, // Put your reverse proxy port or your S3 API port
|
||||
'use_ssl' => false, // Set it to true if you have a TLS enabled reverse proxy
|
||||
'region' => 'garage', // Garage has only one region named "garage"
|
||||
'region' => 'garage', // Garage default region is named "garage", edit according to your cluster config
|
||||
'use_path_style' => true // Garage supports only path style, must be set to true
|
||||
],
|
||||
],
|
||||
@@ -135,7 +135,7 @@ bucket but doesn't also know the secret encryption key.
|
||||
*Click on the picture to zoom*
|
||||
|
||||
Add a new external storage. Put what you want in "folder name" (eg. "shared"). Select "Amazon S3". Keep "Access Key" for the Authentication field.
|
||||
In Configuration, put your bucket name (eg. nextcloud), the host (eg. 127.0.0.1), the port (eg. 3900 or 443), the region (garage). Tick the SSL box if you have put an HTTPS proxy in front of garage. You must tick the "Path access" box and you must leave the "Legacy authentication (v2)" box empty. Put your Key ID (eg. GK...) and your Secret Key in the last two input boxes. Finally click on the tick symbol on the right of your screen.
|
||||
In Configuration, put your bucket name (eg. nextcloud), the host (eg. 127.0.0.1), the port (eg. 3900 or 443), the region ("garage" if you use the default, or the one your configured in your `garage.toml`). Tick the SSL box if you have put an HTTPS proxy in front of garage. You must tick the "Path access" box and you must leave the "Legacy authentication (v2)" box empty. Put your Key ID (eg. GK...) and your Secret Key in the last two input boxes. Finally click on the tick symbol on the right of your screen.
|
||||
|
||||
Now go to your "Files" app and a new "linked folder" has appeared with the name you chose earlier (eg. "shared").
|
||||
|
||||
@@ -238,7 +238,7 @@ object_storage:
|
||||
# Put localhost only if you have a garage instance running on that node
|
||||
endpoint: 'http://localhost:3900' # or "garage.example.com" if you have TLS on port 443
|
||||
|
||||
# Garage supports only one region for now, named garage
|
||||
# Garage default region is named "garage", edit according to your config
|
||||
region: 'garage'
|
||||
|
||||
credentials:
|
||||
@@ -441,7 +441,7 @@ media_storage_providers:
|
||||
store_synchronous: True # do we want to wait that the file has been written before returning?
|
||||
config:
|
||||
bucket: matrix # the name of our bucket, we chose matrix earlier
|
||||
region_name: garage # only "garage" is supported for the region field
|
||||
region_name: garage # "garage" by default, edit according to your cluster config
|
||||
endpoint_url: http://localhost:3900 # the path to the S3 endpoint
|
||||
access_key_id: "GKxxx" # your Key ID
|
||||
secret_access_key: "xxxx" # your Secret Key
|
||||
|
||||
@@ -26,6 +26,13 @@ Or deploy with custom values:
|
||||
helm install --create-namespace --namespace garage garage ./garage -f values.override.yaml
|
||||
```
|
||||
|
||||
If you want to manage the CustomRessourceDefinition used by garage for its `kubernetes_discovery` outside of the helm chart, add `garage.kubernetesSkipCrd: true` to your custom values and use the kustomization before deploying the helm chart:
|
||||
|
||||
```bash
|
||||
kubectl apply -k ../k8s/crd
|
||||
helm install --create-namespace --namespace garage garage ./garage -f values.override.yaml
|
||||
```
|
||||
|
||||
After deploying, cluster layout must be configured manually as described in [Creating a cluster layout](@/documentation/quick-start/_index.md#creating-a-cluster-layout). Use the following command to access garage CLI:
|
||||
|
||||
```bash
|
||||
@@ -86,3 +93,62 @@ helm delete --namespace garage garage
|
||||
```
|
||||
|
||||
Note that this will leave behind custom CRD `garagenodes.deuxfleurs.fr`, which must be removed manually if desired.
|
||||
|
||||
## Increase PVC size on running Garage instances
|
||||
|
||||
Since the Garage Helm chart creates the data and meta PVC based on `StatefulSet` templates, increasing the PVC size can be a bit tricky.
|
||||
|
||||
### Confirm the `StorageClass` used for Garage supports volume expansion
|
||||
|
||||
Confirm the storage class used for garage.
|
||||
|
||||
```bash
|
||||
kubectl -n garage get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
|
||||
data-garage-0 Bound pvc-080360c9-8ce3-4acf-8579-1701e57b7f3f 30Gi RWO longhorn-local <unset> 77d
|
||||
data-garage-1 Bound pvc-ab8ba697-6030-4fc7-ab3c-0d6df9e3dbc0 30Gi RWO longhorn-local <unset> 5d8h
|
||||
data-garage-2 Bound pvc-3ab37551-0231-4604-986d-136d0fd950ec 30Gi RWO longhorn-local <unset> 5d5h
|
||||
meta-garage-0 Bound pvc-3b457302-3023-4169-846e-c928c5f2ea65 3Gi RWO longhorn-local <unset> 77d
|
||||
meta-garage-1 Bound pvc-49ace2b9-5c85-42df-9247-51c4cf64b460 3Gi RWO longhorn-local <unset> 5d8h
|
||||
meta-garage-2 Bound pvc-99e2e50f-42b4-4128-ae2f-b52629259723 3Gi RWO longhorn-local <unset> 5d5h
|
||||
```
|
||||
|
||||
In this case, the storage class is `longhorn-local`. Now, check if `ALLOWVOLUMEEXPANSION` is true for the used `StorageClass`.
|
||||
|
||||
```bash
|
||||
kubectl get storageclasses.storage.k8s.io longhorn-local
|
||||
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
|
||||
longhorn-local driver.longhorn.io Delete Immediate true 103d
|
||||
```
|
||||
|
||||
If your `StorageClass` does not support volume expansion, double check if you can enable it. Otherwise, your only real option is to spin up a new Garage cluster with increased size and migrate all data over.
|
||||
|
||||
If your `StorageClass` supports expansion, you are free to continue.
|
||||
|
||||
### Increase the size of the PVCs
|
||||
|
||||
Increase the size of all PVCs to your desired size.
|
||||
|
||||
```bash
|
||||
kubectl -n garage edit pvc data-garage-0
|
||||
kubectl -n garage edit pvc data-garage-1
|
||||
kubectl -n garage edit pvc data-garage-2
|
||||
kubectl -n garage edit pvc meta-garage-0
|
||||
kubectl -n garage edit pvc meta-garage-1
|
||||
kubectl -n garage edit pvc meta-garage-2
|
||||
```
|
||||
|
||||
### Increase the size of the `StatefulSet` PVC template
|
||||
|
||||
This is an optional step, but if not done, future instances of Garage will be created with the original size from the template.
|
||||
|
||||
```bash
|
||||
kubectl -n garage delete sts --cascade=orphan garage
|
||||
statefulset.apps "garage" deleted
|
||||
```
|
||||
|
||||
This will remove the Garage `StatefulSet` but leave the pods running. It may seem destructive but needs to be done this way since edits to the size of PVC templates are prohibited.
|
||||
|
||||
### Redeploy the `StatefulSet`
|
||||
|
||||
Now the size of future PVCs can be increased, and the Garage Helm chart can be upgraded. The new `StatefulSet` should take ownership of the orphaned pods again.
|
||||
|
||||
@@ -28,6 +28,7 @@ StateDirectory=garage
|
||||
DynamicUser=true
|
||||
ProtectHome=true
|
||||
NoNewPrivileges=true
|
||||
LimitNOFILE=42000
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -129,9 +129,9 @@ docker run \
|
||||
-d \
|
||||
--name garaged \
|
||||
-p 3900:3900 -p 3901:3901 -p 3902:3902 -p 3903:3903 \
|
||||
-v /etc/garage.toml:/path/to/garage.toml \
|
||||
-v /var/lib/garage/meta:/path/to/garage/meta \
|
||||
-v /var/lib/garage/data:/path/to/garage/data \
|
||||
-v /path/to/garage.toml:/etc/garage.toml \
|
||||
-v /path/to/garage/meta:/var/lib/garage/meta \
|
||||
-v /path/to/garage/data:/var/lib/garage/data \
|
||||
dxflrs/garage:v1.1.0
|
||||
```
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ bootstrap_peers = [
|
||||
"212fd62eeaca72c122b45a7f4fa0f55e012aa5e24ac384a72a3016413fa724ff@[fc00:F::1]:3901",
|
||||
]
|
||||
|
||||
allow_punycode = false
|
||||
|
||||
[consul_discovery]
|
||||
api = "catalog"
|
||||
@@ -115,6 +116,7 @@ Top-level configuration options:
|
||||
[`rpc_public_addr`](#rpc_public_addr),
|
||||
[`rpc_public_addr_subnet`](#rpc_public_addr_subnet)
|
||||
[`rpc_secret`/`rpc_secret_file`](#rpc_secret).
|
||||
[`allow_punycode`](#allow_punycode).
|
||||
|
||||
The `[consul_discovery]` section:
|
||||
[`api`](#consul_api),
|
||||
@@ -604,7 +606,7 @@ be obtained by running `garage node id` and then included directly in the
|
||||
key will be returned by `garage node id` and you will have to add the IP
|
||||
yourself.
|
||||
|
||||
### `allow_world_readable_secrets` or `GARAGE_ALLOW_WORLD_READABLE_SECRETS` (env) {#allow_world_readable_secrets}
|
||||
#### `allow_world_readable_secrets` or `GARAGE_ALLOW_WORLD_READABLE_SECRETS` (env) {#allow_world_readable_secrets}
|
||||
|
||||
Garage checks the permissions of your secret files to make sure they're not
|
||||
world-readable. In some cases, the check might fail and consider your files as
|
||||
@@ -616,6 +618,13 @@ permission verification.
|
||||
Alternatively, you can set the `GARAGE_ALLOW_WORLD_READABLE_SECRETS`
|
||||
environment variable to `true` to bypass the permissions check.
|
||||
|
||||
#### `allow_punycode` {#allow_punycode}
|
||||
|
||||
Allow creating buckets with names containing punycode. When used for buckets served
|
||||
as websites, this allows using almost any unicode character in the domain name.
|
||||
|
||||
Default to `false`.
|
||||
|
||||
### The `[consul_discovery]` section
|
||||
|
||||
Garage supports discovering other nodes of the cluster using Consul. For this
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
apiVersion: v2
|
||||
name: garage
|
||||
description: S3-compatible object store for small self-hosted geo-distributed deployments
|
||||
|
||||
# A chart can be either an 'application' or a 'library' chart.
|
||||
#
|
||||
# Application charts are a collection of templates that can be packaged into versioned archives
|
||||
# to be deployed.
|
||||
#
|
||||
# Library charts provide useful utilities or functions for the chart developer. They're included as
|
||||
# a dependency of application charts to inject those utilities and functions into the rendering
|
||||
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
|
||||
type: application
|
||||
|
||||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: 0.7.0
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||
# It is recommended to use it with quotes.
|
||||
appVersion: "v1.1.0"
|
||||
home: https://garagehq.deuxfleurs.fr/
|
||||
icon: https://garagehq.deuxfleurs.fr/images/garage-logo.svg
|
||||
|
||||
keywords:
|
||||
- geo-distributed
|
||||
- read-after-write-consistency
|
||||
- s3-compatible
|
||||
|
||||
sources:
|
||||
- https://git.deuxfleurs.fr/Deuxfleurs/garage.git
|
||||
|
||||
maintainers: []
|
||||
@@ -1,9 +1,15 @@
|
||||
# garage
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
S3-compatible object store for small self-hosted geo-distributed deployments
|
||||
|
||||
**Homepage:** <https://garagehq.deuxfleurs.fr/>
|
||||
|
||||
## Source Code
|
||||
|
||||
* <https://git.deuxfleurs.fr/Deuxfleurs/garage.git>
|
||||
|
||||
## Values
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
@@ -23,6 +29,7 @@ S3-compatible object store for small self-hosted geo-distributed deployments
|
||||
| garage.existingConfigMap | string | `""` | if not empty string, allow using an existing ConfigMap for the garage.toml, if set, ignores garage.toml |
|
||||
| garage.garageTomlString | string | `""` | String Template for the garage configuration if set, ignores above values. Values can be templated, see https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/ |
|
||||
| garage.kubernetesSkipCrd | bool | `false` | Set to true if you want to use k8s discovery but install the CRDs manually outside of the helm chart, for example if you operate at namespace level without cluster ressources |
|
||||
| garage.metadataAutoSnapshotInterval | string | `""` | If this value is set, Garage will automatically take a snapshot of the metadata DB file at a regular interval and save it in the metadata directory. https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#metadata_auto_snapshot_interval |
|
||||
| garage.replicationMode | string | `"3"` | Default to 3 replicas, see the replication_mode section at https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#replication-mode |
|
||||
| garage.rpcBindAddr | string | `"[::]:3901"` | |
|
||||
| garage.rpcSecret | string | `""` | If not given, a random secret will be generated and stored in a Secret object |
|
||||
@@ -49,6 +56,7 @@ S3-compatible object store for small self-hosted geo-distributed deployments
|
||||
| initImage.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| initImage.repository | string | `"busybox"` | |
|
||||
| initImage.tag | string | `"stable"` | |
|
||||
| livenessProbe | object | `{}` | Specifies a livenessProbe |
|
||||
| monitoring.metrics.enabled | bool | `false` | If true, a service for monitoring is created with a prometheus.io/scrape annotation |
|
||||
| monitoring.metrics.serviceMonitor.enabled | bool | `false` | If true, a ServiceMonitor CRD is created for a prometheus operator https://github.com/coreos/prometheus-operator |
|
||||
| monitoring.metrics.serviceMonitor.interval | string | `"15s"` | |
|
||||
@@ -71,6 +79,7 @@ S3-compatible object store for small self-hosted geo-distributed deployments
|
||||
| podSecurityContext.runAsGroup | int | `1000` | |
|
||||
| podSecurityContext.runAsNonRoot | bool | `true` | |
|
||||
| podSecurityContext.runAsUser | int | `1000` | |
|
||||
| readinessProbe | object | `{}` | Specifies a readinessProbe |
|
||||
| resources | object | `{}` | |
|
||||
| securityContext.capabilities | object | `{"drop":["ALL"]}` | The default security context is heavily restricted, feel free to tune it to your requirements |
|
||||
| securityContext.readOnlyRootFilesystem | bool | `true` | |
|
||||
|
||||
@@ -19,6 +19,10 @@ data:
|
||||
|
||||
compression_level = {{ .Values.garage.compressionLevel }}
|
||||
|
||||
{{- if .Values.garage.metadataAutoSnapshotInterval }}
|
||||
metadata_auto_snapshot_interval = {{ .Values.garage.metadataAutoSnapshotInterval | quote }}
|
||||
{{- end }}
|
||||
|
||||
rpc_bind_addr = "{{ .Values.garage.rpcBindAddr }}"
|
||||
# rpc_secret will be populated by the init container from a k8s secret object
|
||||
rpc_secret = "__RPC_SECRET_REPLACE__"
|
||||
|
||||
@@ -78,15 +78,14 @@ spec:
|
||||
{{- with .Values.extraVolumeMounts }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
# TODO
|
||||
# livenessProbe:
|
||||
# httpGet:
|
||||
# path: /
|
||||
# port: 3900
|
||||
# readinessProbe:
|
||||
# httpGet:
|
||||
# path: /
|
||||
# port: 3900
|
||||
{{- with .Values.livenessProbe }}
|
||||
livenessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.readinessProbe }}
|
||||
readinessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
volumes:
|
||||
|
||||
@@ -21,6 +21,10 @@ garage:
|
||||
# https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#compression-level
|
||||
compressionLevel: "1"
|
||||
|
||||
# -- If this value is set, Garage will automatically take a snapshot of the metadata DB file at a regular interval and save it in the metadata directory.
|
||||
# https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#metadata_auto_snapshot_interval
|
||||
metadataAutoSnapshotInterval: ""
|
||||
|
||||
rpcBindAddr: "[::]:3901"
|
||||
# -- If not given, a random secret will be generated and stored in a Secret object
|
||||
rpcSecret: ""
|
||||
@@ -191,6 +195,21 @@ resources: {}
|
||||
# cpu: 100m
|
||||
# memory: 512Mi
|
||||
|
||||
# -- Specifies a livenessProbe
|
||||
livenessProbe: {}
|
||||
#httpGet:
|
||||
# path: /health
|
||||
# port: 3903
|
||||
#initialDelaySeconds: 5
|
||||
#periodSeconds: 30
|
||||
# -- Specifies a readinessProbe
|
||||
readinessProbe: {}
|
||||
#httpGet:
|
||||
# path: /health
|
||||
# port: 3903
|
||||
#initialDelaySeconds: 5
|
||||
#periodSeconds: 30
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
name: garagenodes.deuxfleurs.fr
|
||||
spec:
|
||||
conversion:
|
||||
strategy: None
|
||||
group: deuxfleurs.fr
|
||||
names:
|
||||
kind: GarageNode
|
||||
listKind: GarageNodeList
|
||||
plural: garagenodes
|
||||
singular: garagenode
|
||||
scope: Namespaced
|
||||
versions:
|
||||
- name: v1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: Auto-generated derived type for Node via `CustomResource`
|
||||
properties:
|
||||
spec:
|
||||
properties:
|
||||
address:
|
||||
format: ip
|
||||
type: string
|
||||
hostname:
|
||||
type: string
|
||||
port:
|
||||
format: uint16
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- address
|
||||
- hostname
|
||||
- port
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
title: GarageNode
|
||||
type: object
|
||||
served: true
|
||||
storage: true
|
||||
subresources: {}
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- garagenodes.deuxfleurs.fr.yaml
|
||||
@@ -277,7 +277,7 @@ pub async fn handle_create_bucket(
|
||||
let helper = garage.locked_helper().await;
|
||||
|
||||
if let Some(ga) = &req.global_alias {
|
||||
if !is_valid_bucket_name(ga) {
|
||||
if !is_valid_bucket_name(ga, garage.config.allow_punycode) {
|
||||
return Err(Error::bad_request(format!(
|
||||
"{}: {}",
|
||||
ga, INVALID_BUCKET_NAME_MESSAGE
|
||||
@@ -292,7 +292,7 @@ pub async fn handle_create_bucket(
|
||||
}
|
||||
|
||||
if let Some(la) = &req.local_alias {
|
||||
if !is_valid_bucket_name(&la.alias) {
|
||||
if !is_valid_bucket_name(&la.alias, garage.config.allow_punycode) {
|
||||
return Err(Error::bad_request(format!(
|
||||
"{}: {}",
|
||||
la.alias, INVALID_BUCKET_NAME_MESSAGE
|
||||
@@ -382,7 +382,7 @@ pub async fn handle_delete_bucket(
|
||||
for ((key_id, alias), _, active) in state.local_aliases.items().iter() {
|
||||
if *active {
|
||||
helper
|
||||
.unset_local_bucket_alias(bucket.id, key_id, alias)
|
||||
.purge_local_bucket_alias(bucket.id, key_id, alias)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
@@ -419,11 +419,17 @@ pub async fn handle_update_bucket(
|
||||
|
||||
if let Some(wa) = req.website_access {
|
||||
if wa.enabled {
|
||||
let (redirect_all, routing_rules) = match state.website_config.get() {
|
||||
Some(wc) => (wc.redirect_all.clone(), wc.routing_rules.clone()),
|
||||
None => (None, Vec::new()),
|
||||
};
|
||||
state.website_config.update(Some(WebsiteConfig {
|
||||
index_document: wa.index_document.ok_or_bad_request(
|
||||
"Please specify indexDocument when enabling website access.",
|
||||
)?,
|
||||
error_document: wa.error_document,
|
||||
redirect_all,
|
||||
routing_rules,
|
||||
}));
|
||||
} else {
|
||||
if wa.index_document.is_some() || wa.error_document.is_some() {
|
||||
|
||||
@@ -28,7 +28,6 @@ err-derive.workspace = true
|
||||
hex.workspace = true
|
||||
hmac.workspace = true
|
||||
md-5.workspace = true
|
||||
idna.workspace = true
|
||||
tracing.workspace = true
|
||||
nom.workspace = true
|
||||
pin-project.workspace = true
|
||||
|
||||
@@ -8,7 +8,6 @@ use hyper::{
|
||||
body::{Body, Bytes},
|
||||
Request, Response,
|
||||
};
|
||||
use idna::domain_to_unicode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use garage_model::bucket_table::BucketParams;
|
||||
@@ -97,7 +96,7 @@ pub fn authority_to_host(authority: &str) -> Result<String, Error> {
|
||||
authority
|
||||
))),
|
||||
};
|
||||
authority.map(|h| domain_to_unicode(h).0)
|
||||
authority.map(|h| h.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// Extract the bucket name and the key name from an HTTP path and possibly a bucket provided in
|
||||
|
||||
@@ -172,7 +172,7 @@ pub async fn handle_create_bucket(
|
||||
}
|
||||
|
||||
// Create the bucket!
|
||||
if !is_valid_bucket_name(&bucket_name) {
|
||||
if !is_valid_bucket_name(&bucket_name, garage.config.allow_punycode) {
|
||||
return Err(Error::bad_request(format!(
|
||||
"{}: {}",
|
||||
bucket_name, INVALID_BUCKET_NAME_MESSAGE
|
||||
@@ -241,11 +241,11 @@ pub async fn handle_delete_bucket(ctx: ReqCtx) -> Result<Response<ResBody>, Erro
|
||||
// 1. delete bucket alias
|
||||
if is_local_alias {
|
||||
helper
|
||||
.unset_local_bucket_alias(*bucket_id, &api_key.key_id, bucket_name)
|
||||
.purge_local_bucket_alias(*bucket_id, &api_key.key_id, bucket_name)
|
||||
.await?;
|
||||
} else {
|
||||
helper
|
||||
.unset_global_bucket_alias(*bucket_id, bucket_name)
|
||||
.purge_global_bucket_alias(*bucket_id, bucket_name)
|
||||
.await?;
|
||||
}
|
||||
|
||||
|
||||
+13
-1
@@ -29,6 +29,7 @@ use crate::error::*;
|
||||
use crate::get::{full_object_byte_stream, PreconditionHeaders};
|
||||
use crate::multipart;
|
||||
use crate::put::{extract_metadata_headers, save_stream, ChecksumMode, SaveStreamResult};
|
||||
use crate::website::X_AMZ_WEBSITE_REDIRECT_LOCATION;
|
||||
use crate::xml::{self as s3_xml, xmlns_tag};
|
||||
|
||||
pub const X_AMZ_COPY_SOURCE_IF_MATCH: HeaderName =
|
||||
@@ -84,7 +85,18 @@ pub async fn handle_copy(
|
||||
Some(v) if v == hyper::header::HeaderValue::from_static("REPLACE") => {
|
||||
extract_metadata_headers(req.headers())?
|
||||
}
|
||||
_ => source_object_meta_inner.into_owned().headers,
|
||||
_ => {
|
||||
// The x-amz-website-redirect-location header is not copied, instead
|
||||
// it is replaced by the value from the request (or removed if no
|
||||
// value was specified)
|
||||
let is_redirect =
|
||||
|(key, _): &(String, String)| key == X_AMZ_WEBSITE_REDIRECT_LOCATION.as_str();
|
||||
let mut headers: Vec<_> = source_object_meta_inner.headers.clone();
|
||||
headers.retain(|h| !is_redirect(h));
|
||||
let new_headers = extract_metadata_headers(req.headers())?;
|
||||
headers.extend(new_headers.into_iter().filter(is_redirect));
|
||||
headers
|
||||
}
|
||||
},
|
||||
checksum: source_checksum,
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ pub async fn handle_get_lifecycle(ctx: ReqCtx) -> Result<Response<ResBody>, Erro
|
||||
.body(string_body(xml))?)
|
||||
} else {
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(empty_body())?)
|
||||
}
|
||||
}
|
||||
|
||||
+172
-54
@@ -3,7 +3,7 @@ use quick_xml::de::from_reader;
|
||||
use hyper::{header::HeaderName, Request, Response, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use garage_model::bucket_table::*;
|
||||
use garage_model::bucket_table::{self, *};
|
||||
|
||||
use garage_api_common::helpers::*;
|
||||
|
||||
@@ -26,7 +26,28 @@ pub async fn handle_get_website(ctx: ReqCtx) -> Result<Response<ResBody>, Error>
|
||||
suffix: Value(website.index_document.to_string()),
|
||||
}),
|
||||
redirect_all_requests_to: None,
|
||||
routing_rules: None,
|
||||
routing_rules: RoutingRules {
|
||||
rules: website
|
||||
.routing_rules
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|rule| RoutingRule {
|
||||
condition: rule.condition.map(|cond| Condition {
|
||||
http_error_code: cond.http_error_code.map(|c| IntValue(c as i64)),
|
||||
prefix: cond.prefix.map(Value),
|
||||
}),
|
||||
redirect: Redirect {
|
||||
hostname: rule.redirect.hostname.map(Value),
|
||||
http_redirect_code: Some(IntValue(
|
||||
rule.redirect.http_redirect_code as i64,
|
||||
)),
|
||||
protocol: rule.redirect.protocol.map(Value),
|
||||
replace_full: rule.redirect.replace_key.map(Value),
|
||||
replace_prefix: rule.redirect.replace_key_prefix.map(Value),
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
};
|
||||
let xml = to_xml_with_header(&wc)?;
|
||||
Ok(Response::builder()
|
||||
@@ -97,18 +118,28 @@ pub struct WebsiteConfiguration {
|
||||
pub index_document: Option<Suffix>,
|
||||
#[serde(rename = "RedirectAllRequestsTo")]
|
||||
pub redirect_all_requests_to: Option<Target>,
|
||||
#[serde(rename = "RoutingRules")]
|
||||
pub routing_rules: Option<Vec<RoutingRule>>,
|
||||
#[serde(
|
||||
rename = "RoutingRules",
|
||||
default,
|
||||
skip_serializing_if = "RoutingRules::is_empty"
|
||||
)]
|
||||
pub routing_rules: RoutingRules,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
|
||||
pub struct RoutingRules {
|
||||
#[serde(rename = "RoutingRule")]
|
||||
pub rules: Vec<RoutingRule>,
|
||||
}
|
||||
|
||||
impl RoutingRules {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct RoutingRule {
|
||||
#[serde(rename = "RoutingRule")]
|
||||
pub inner: RoutingRuleInner,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct RoutingRuleInner {
|
||||
#[serde(rename = "Condition")]
|
||||
pub condition: Option<Condition>,
|
||||
#[serde(rename = "Redirect")]
|
||||
@@ -162,7 +193,7 @@ impl WebsiteConfiguration {
|
||||
if self.redirect_all_requests_to.is_some()
|
||||
&& (self.error_document.is_some()
|
||||
|| self.index_document.is_some()
|
||||
|| self.routing_rules.is_some())
|
||||
|| !self.routing_rules.is_empty())
|
||||
{
|
||||
return Err(Error::bad_request(
|
||||
"Bad XML: can't have RedirectAllRequestsTo and other fields",
|
||||
@@ -177,10 +208,15 @@ impl WebsiteConfiguration {
|
||||
if let Some(ref rart) = self.redirect_all_requests_to {
|
||||
rart.validate()?;
|
||||
}
|
||||
if let Some(ref rrs) = self.routing_rules {
|
||||
for rr in rrs {
|
||||
rr.inner.validate()?;
|
||||
}
|
||||
for rr in &self.routing_rules.rules {
|
||||
rr.validate()?;
|
||||
}
|
||||
if self.routing_rules.rules.len() > 1000 {
|
||||
// we will do linear scans, best to avoid overly long configuration. The
|
||||
// limit was choosen arbitrarily
|
||||
return Err(Error::bad_request(
|
||||
"Bad XML: RoutingRules can't have more than 1000 child elements",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -189,11 +225,7 @@ impl WebsiteConfiguration {
|
||||
pub fn into_garage_website_config(self) -> Result<WebsiteConfig, Error> {
|
||||
if self.redirect_all_requests_to.is_some() {
|
||||
Err(Error::NotImplemented(
|
||||
"S3 website redirects are not currently implemented in Garage.".into(),
|
||||
))
|
||||
} else if self.routing_rules.map(|x| !x.is_empty()).unwrap_or(false) {
|
||||
Err(Error::NotImplemented(
|
||||
"S3 routing rules are not currently implemented in Garage.".into(),
|
||||
"RedirectAllRequestsTo is not currently implemented in Garage, however its effect can be emulated using a single inconditional RoutingRule.".into(),
|
||||
))
|
||||
} else {
|
||||
Ok(WebsiteConfig {
|
||||
@@ -202,6 +234,36 @@ impl WebsiteConfiguration {
|
||||
.map(|x| x.suffix.0)
|
||||
.unwrap_or_else(|| "index.html".to_string()),
|
||||
error_document: self.error_document.map(|x| x.key.0),
|
||||
redirect_all: None,
|
||||
routing_rules: self
|
||||
.routing_rules
|
||||
.rules
|
||||
.into_iter()
|
||||
.map(|rule| {
|
||||
bucket_table::RoutingRule {
|
||||
condition: rule.condition.map(|condition| {
|
||||
bucket_table::RedirectCondition {
|
||||
http_error_code: condition.http_error_code.map(|c| c.0 as u16),
|
||||
prefix: condition.prefix.map(|p| p.0),
|
||||
}
|
||||
}),
|
||||
redirect: bucket_table::Redirect {
|
||||
hostname: rule.redirect.hostname.map(|h| h.0),
|
||||
protocol: rule.redirect.protocol.map(|p| p.0),
|
||||
// aws default to 301, which i find punitive in case of
|
||||
// missconfiguration (can be permanently cached on the
|
||||
// user agent)
|
||||
http_redirect_code: rule
|
||||
.redirect
|
||||
.http_redirect_code
|
||||
.map(|c| c.0 as u16)
|
||||
.unwrap_or(302),
|
||||
replace_key_prefix: rule.redirect.replace_prefix.map(|k| k.0),
|
||||
replace_key: rule.redirect.replace_full.map(|k| k.0),
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -242,37 +304,69 @@ impl Target {
|
||||
}
|
||||
}
|
||||
|
||||
impl RoutingRuleInner {
|
||||
impl RoutingRule {
|
||||
pub fn validate(&self) -> Result<(), Error> {
|
||||
let has_prefix = self
|
||||
.condition
|
||||
.as_ref()
|
||||
.and_then(|c| c.prefix.as_ref())
|
||||
.is_some();
|
||||
self.redirect.validate(has_prefix)
|
||||
if let Some(condition) = &self.condition {
|
||||
condition.validate()?;
|
||||
}
|
||||
self.redirect.validate()
|
||||
}
|
||||
}
|
||||
|
||||
impl Condition {
|
||||
pub fn validate(&self) -> Result<bool, Error> {
|
||||
if let Some(ref error_code) = self.http_error_code {
|
||||
// TODO do other error codes make sense? Aws only allows 4xx and 5xx
|
||||
if error_code.0 != 404 {
|
||||
return Err(Error::bad_request(
|
||||
"Bad XML: HttpErrorCodeReturnedEquals must be 404 or absent",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(self.prefix.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
impl Redirect {
|
||||
pub fn validate(&self, has_prefix: bool) -> Result<(), Error> {
|
||||
if self.replace_prefix.is_some() {
|
||||
if self.replace_full.is_some() {
|
||||
return Err(Error::bad_request(
|
||||
"Bad XML: both ReplaceKeyPrefixWith and ReplaceKeyWith are set",
|
||||
));
|
||||
}
|
||||
if !has_prefix {
|
||||
return Err(Error::bad_request(
|
||||
"Bad XML: ReplaceKeyPrefixWith is set, but KeyPrefixEquals isn't",
|
||||
));
|
||||
}
|
||||
pub fn validate(&self) -> Result<(), Error> {
|
||||
if self.replace_prefix.is_some() && self.replace_full.is_some() {
|
||||
return Err(Error::bad_request(
|
||||
"Bad XML: both ReplaceKeyPrefixWith and ReplaceKeyWith are set",
|
||||
));
|
||||
}
|
||||
if let Some(ref protocol) = self.protocol {
|
||||
if protocol.0 != "http" && protocol.0 != "https" {
|
||||
return Err(Error::bad_request("Bad XML: invalid protocol"));
|
||||
}
|
||||
}
|
||||
// TODO there are probably more invalid cases, but which ones?
|
||||
if let Some(ref http_redirect_code) = self.http_redirect_code {
|
||||
match http_redirect_code.0 {
|
||||
// aws allows all 3xx except 300, but some are non-sensical (not modified,
|
||||
// use proxy...)
|
||||
301 | 302 | 303 | 307 | 308 => {
|
||||
if self.hostname.is_none() && self.protocol.is_some() {
|
||||
return Err(Error::bad_request(
|
||||
"Bad XML: HostName must be set if Protocol is set",
|
||||
));
|
||||
}
|
||||
}
|
||||
// aws doesn't allow these codes, but netlify does, and it seems like a
|
||||
// cool feature (change the page seen without changing the url shown by the
|
||||
// user agent)
|
||||
200 | 404 => {
|
||||
if self.hostname.is_some() || self.protocol.is_some() {
|
||||
// hostname would mean different bucket, protocol doesn't make
|
||||
// sense
|
||||
return Err(Error::bad_request(
|
||||
"Bad XML: an HttpRedirectCode of 200 is not acceptable alongside HostName or Protocol",
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::bad_request("Bad XML: invalid HttpRedirectCode"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -311,6 +405,15 @@ mod tests {
|
||||
<ReplaceKeyWith>fullkey</ReplaceKeyWith>
|
||||
</Redirect>
|
||||
</RoutingRule>
|
||||
<RoutingRule>
|
||||
<Condition>
|
||||
<KeyPrefixEquals></KeyPrefixEquals>
|
||||
</Condition>
|
||||
<Redirect>
|
||||
<HttpRedirectCode>404</HttpRedirectCode>
|
||||
<ReplaceKeyWith>missing</ReplaceKeyWith>
|
||||
</Redirect>
|
||||
</RoutingRule>
|
||||
</RoutingRules>
|
||||
</WebsiteConfiguration>"#;
|
||||
let conf: WebsiteConfiguration = from_str(message).unwrap();
|
||||
@@ -326,21 +429,36 @@ mod tests {
|
||||
hostname: Value("garage.tld".to_owned()),
|
||||
protocol: Some(Value("https".to_owned())),
|
||||
}),
|
||||
routing_rules: Some(vec![RoutingRule {
|
||||
inner: RoutingRuleInner {
|
||||
condition: Some(Condition {
|
||||
http_error_code: Some(IntValue(404)),
|
||||
prefix: Some(Value("prefix1".to_owned())),
|
||||
}),
|
||||
redirect: Redirect {
|
||||
hostname: Some(Value("gara.ge".to_owned())),
|
||||
protocol: Some(Value("http".to_owned())),
|
||||
http_redirect_code: Some(IntValue(303)),
|
||||
replace_prefix: Some(Value("prefix2".to_owned())),
|
||||
replace_full: Some(Value("fullkey".to_owned())),
|
||||
routing_rules: RoutingRules {
|
||||
rules: vec![
|
||||
RoutingRule {
|
||||
condition: Some(Condition {
|
||||
http_error_code: Some(IntValue(404)),
|
||||
prefix: Some(Value("prefix1".to_owned())),
|
||||
}),
|
||||
redirect: Redirect {
|
||||
hostname: Some(Value("gara.ge".to_owned())),
|
||||
protocol: Some(Value("http".to_owned())),
|
||||
http_redirect_code: Some(IntValue(303)),
|
||||
replace_prefix: Some(Value("prefix2".to_owned())),
|
||||
replace_full: Some(Value("fullkey".to_owned())),
|
||||
},
|
||||
},
|
||||
},
|
||||
}]),
|
||||
RoutingRule {
|
||||
condition: Some(Condition {
|
||||
http_error_code: None,
|
||||
prefix: Some(Value("".to_owned())),
|
||||
}),
|
||||
redirect: Redirect {
|
||||
hostname: None,
|
||||
protocol: None,
|
||||
http_redirect_code: Some(IntValue(404)),
|
||||
replace_prefix: None,
|
||||
replace_full: Some(Value("missing".to_owned())),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
assert_eq! {
|
||||
ref_value,
|
||||
|
||||
@@ -109,7 +109,7 @@ impl IDb for LmdbDb {
|
||||
let mut path = to.clone();
|
||||
path.push("data.mdb");
|
||||
self.db
|
||||
.copy_to_path(path, heed::CompactionOption::Disabled)?;
|
||||
.copy_to_path(path, heed::CompactionOption::Enabled)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ syslog-tracing = { workspace = true, optional = true }
|
||||
garage_api_common.workspace = true
|
||||
|
||||
aws-sdk-s3.workspace = true
|
||||
aws-smithy-runtime.workspace = true
|
||||
chrono.workspace = true
|
||||
http.workspace = true
|
||||
hmac.workspace = true
|
||||
|
||||
@@ -126,7 +126,7 @@ impl AdminRpcHandler {
|
||||
|
||||
#[allow(clippy::ptr_arg)]
|
||||
async fn handle_create_bucket(&self, name: &String) -> Result<AdminRpc, Error> {
|
||||
if !is_valid_bucket_name(name) {
|
||||
if !is_valid_bucket_name(name, self.garage.config.allow_punycode) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"{}: {}",
|
||||
name, INVALID_BUCKET_NAME_MESSAGE
|
||||
@@ -390,9 +390,15 @@ impl AdminRpcHandler {
|
||||
}
|
||||
|
||||
let website = if query.allow {
|
||||
let (redirect_all, routing_rules) = match bucket_state.website_config.get() {
|
||||
Some(wc) => (wc.redirect_all.clone(), wc.routing_rules.clone()),
|
||||
None => (None, Vec::new()),
|
||||
};
|
||||
Some(WebsiteConfig {
|
||||
index_document: query.index_document.clone(),
|
||||
error_document: query.error_document.clone(),
|
||||
redirect_all,
|
||||
routing_rules,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -478,6 +478,9 @@ pub enum RepairWhat {
|
||||
/// Recalculate block reference counters
|
||||
#[structopt(name = "block-rc", version = garage_version())]
|
||||
BlockRc,
|
||||
/// Fix inconsistency in bucket aliases (WARNING: EXPERIMENTAL)
|
||||
#[structopt(name = "aliases", version = garage_version())]
|
||||
Aliases,
|
||||
/// Verify integrity of all blocks on disc
|
||||
#[structopt(name = "scrub", version = garage_version())]
|
||||
Scrub {
|
||||
|
||||
@@ -88,6 +88,10 @@ pub async fn launch_online_repair(
|
||||
garage.block_manager.clone(),
|
||||
));
|
||||
}
|
||||
RepairWhat::Aliases => {
|
||||
info!("Repairing bucket aliases (foreground)");
|
||||
garage.locked_helper().await.repair_aliases().await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ rpc_bind_addr = "127.0.0.1:{rpc_port}"
|
||||
rpc_public_addr = "127.0.0.1:{rpc_port}"
|
||||
rpc_secret = "{secret}"
|
||||
|
||||
allow_punycode = true
|
||||
|
||||
[s3_api]
|
||||
s3_region = "{region}"
|
||||
api_bind_addr = "127.0.0.1:{s3_port}"
|
||||
|
||||
@@ -5,7 +5,10 @@ use crate::json_body;
|
||||
use assert_json_diff::assert_json_eq;
|
||||
use aws_sdk_s3::{
|
||||
primitives::ByteStream,
|
||||
types::{CorsConfiguration, CorsRule, ErrorDocument, IndexDocument, WebsiteConfiguration},
|
||||
types::{
|
||||
Condition, CorsConfiguration, CorsRule, ErrorDocument, IndexDocument, Protocol, Redirect,
|
||||
RoutingRule, WebsiteConfiguration,
|
||||
},
|
||||
};
|
||||
use http::{Request, StatusCode};
|
||||
use http_body_util::BodyExt;
|
||||
@@ -533,3 +536,517 @@ async fn test_website_check_domain() {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_website_redirect_full_bucket() {
|
||||
const BCKT_NAME: &str = "my-redirect-full";
|
||||
let ctx = common::context();
|
||||
let bucket = ctx.create_bucket(BCKT_NAME);
|
||||
|
||||
let conf = WebsiteConfiguration::builder()
|
||||
.routing_rules(
|
||||
RoutingRule::builder()
|
||||
.condition(Condition::builder().key_prefix_equals("").build())
|
||||
.redirect(
|
||||
Redirect::builder()
|
||||
.protocol(Protocol::Https)
|
||||
.host_name("other.tld")
|
||||
.replace_key_prefix_with("")
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build();
|
||||
|
||||
ctx.client
|
||||
.put_bucket_website()
|
||||
.bucket(&bucket)
|
||||
.website_configuration(conf)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!("http://127.0.0.1:{}/my-path", ctx.garage.web_port))
|
||||
.header("Host", format!("{}.web.garage", BCKT_NAME))
|
||||
.body(Body::new(Bytes::new()))
|
||||
.unwrap();
|
||||
|
||||
let client = Client::builder(TokioExecutor::new()).build_http();
|
||||
let resp = client.request(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get(hyper::header::LOCATION)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"https://other.tld/my-path"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_website_redirect() {
|
||||
const BCKT_NAME: &str = "my-redirect";
|
||||
let ctx = common::context();
|
||||
let bucket = ctx.create_bucket(BCKT_NAME);
|
||||
|
||||
ctx.client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key("index.html")
|
||||
.body(ByteStream::from_static(b"index"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
ctx.client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key("404.html")
|
||||
.body(ByteStream::from_static(b"main 404"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
ctx.client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key("static-file")
|
||||
.body(ByteStream::from_static(b"static file"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut conf = WebsiteConfiguration::builder()
|
||||
.index_document(
|
||||
IndexDocument::builder()
|
||||
.suffix("home.html")
|
||||
.build()
|
||||
.unwrap(),
|
||||
)
|
||||
.error_document(ErrorDocument::builder().key("404.html").build().unwrap());
|
||||
|
||||
for (prefix, condition) in [("unconditional", false), ("conditional", true)] {
|
||||
let code = condition.then(|| "404".to_string());
|
||||
conf = conf
|
||||
// simple redirect
|
||||
.routing_rules(
|
||||
RoutingRule::builder()
|
||||
.condition(
|
||||
Condition::builder()
|
||||
.set_http_error_code_returned_equals(code.clone())
|
||||
.key_prefix_equals(format!("{prefix}/redirect-prefix/"))
|
||||
.build(),
|
||||
)
|
||||
.redirect(
|
||||
Redirect::builder()
|
||||
.http_redirect_code("302")
|
||||
.replace_key_prefix_with("other-prefix/")
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.routing_rules(
|
||||
RoutingRule::builder()
|
||||
.condition(
|
||||
Condition::builder()
|
||||
.set_http_error_code_returned_equals(code.clone())
|
||||
.key_prefix_equals(format!("{prefix}/redirect-prefix-307/"))
|
||||
.build(),
|
||||
)
|
||||
.redirect(
|
||||
Redirect::builder()
|
||||
.http_redirect_code("307")
|
||||
.replace_key_prefix_with("other-prefix/")
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
// simple redirect
|
||||
.routing_rules(
|
||||
RoutingRule::builder()
|
||||
.condition(
|
||||
Condition::builder()
|
||||
.set_http_error_code_returned_equals(code.clone())
|
||||
.key_prefix_equals(format!("{prefix}/redirect-fixed/"))
|
||||
.build(),
|
||||
)
|
||||
.redirect(
|
||||
Redirect::builder()
|
||||
.http_redirect_code("302")
|
||||
.replace_key_with("fixed_key")
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
// stream other file
|
||||
.routing_rules(
|
||||
RoutingRule::builder()
|
||||
.condition(
|
||||
Condition::builder()
|
||||
.set_http_error_code_returned_equals(code.clone())
|
||||
.key_prefix_equals(format!("{prefix}/stream-fixed/"))
|
||||
.build(),
|
||||
)
|
||||
.redirect(
|
||||
Redirect::builder()
|
||||
.http_redirect_code("200")
|
||||
.replace_key_with("static-file")
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
// stream other file as error
|
||||
.routing_rules(
|
||||
RoutingRule::builder()
|
||||
.condition(
|
||||
Condition::builder()
|
||||
.set_http_error_code_returned_equals(code.clone())
|
||||
.key_prefix_equals(format!("{prefix}/stream-404/"))
|
||||
.build(),
|
||||
)
|
||||
.redirect(
|
||||
Redirect::builder()
|
||||
.http_redirect_code("404")
|
||||
.replace_key_with("static-file")
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
// fail to stream other file
|
||||
.routing_rules(
|
||||
RoutingRule::builder()
|
||||
.condition(
|
||||
Condition::builder()
|
||||
.set_http_error_code_returned_equals(code.clone())
|
||||
.key_prefix_equals(format!("{prefix}/stream-missing/"))
|
||||
.build(),
|
||||
)
|
||||
.redirect(
|
||||
Redirect::builder()
|
||||
.http_redirect_code("200")
|
||||
.replace_key_with("missing-file")
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
let conf = conf.build();
|
||||
|
||||
ctx.client
|
||||
.put_bucket_website()
|
||||
.bucket(&bucket)
|
||||
.website_configuration(conf.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored_cfg = ctx
|
||||
.client
|
||||
.get_bucket_website()
|
||||
.bucket(&bucket)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stored_cfg.index_document, conf.index_document);
|
||||
assert_eq!(stored_cfg.error_document, conf.error_document);
|
||||
assert_eq!(stored_cfg.routing_rules, conf.routing_rules);
|
||||
|
||||
let req = |path| {
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!(
|
||||
"http://127.0.0.1:{}/{}/path",
|
||||
ctx.garage.web_port, path
|
||||
))
|
||||
.header("Host", format!("{}.web.garage", BCKT_NAME))
|
||||
.body(Body::new(Bytes::new()))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
test_redirect_helper("unconditional", true, &req).await;
|
||||
test_redirect_helper("conditional", true, &req).await;
|
||||
for prefix in ["unconditional", "conditional"] {
|
||||
for rule_path in [
|
||||
"redirect-prefix",
|
||||
"redirect-prefix-307",
|
||||
"redirect-fixed",
|
||||
"stream-fixed",
|
||||
"stream-404",
|
||||
"stream-missing",
|
||||
] {
|
||||
ctx.client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(format!("{prefix}/{rule_path}/path"))
|
||||
.body(ByteStream::from_static(b"i exist"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
test_redirect_helper("unconditional", true, &req).await;
|
||||
test_redirect_helper("conditional", false, &req).await;
|
||||
}
|
||||
|
||||
async fn test_redirect_helper(
|
||||
prefix: &str,
|
||||
should_see_redirect: bool,
|
||||
req: impl Fn(String) -> Request<http_body_util::Full<Bytes>>,
|
||||
) {
|
||||
use http::header;
|
||||
let client = Client::builder(TokioExecutor::new()).build_http();
|
||||
let expected_body = b"i exist".as_ref();
|
||||
|
||||
let resp = client
|
||||
.request(req(format!("{prefix}/redirect-prefix")))
|
||||
.await
|
||||
.unwrap();
|
||||
if should_see_redirect {
|
||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get(header::LOCATION)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"/other-prefix/path"
|
||||
);
|
||||
assert!(resp
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes()
|
||||
.is_empty());
|
||||
} else {
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||
assert_eq!(
|
||||
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||
expected_body,
|
||||
);
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.request(req(format!("{prefix}/redirect-prefix-307")))
|
||||
.await
|
||||
.unwrap();
|
||||
if should_see_redirect {
|
||||
assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get(header::LOCATION)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"/other-prefix/path"
|
||||
);
|
||||
assert!(resp
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes()
|
||||
.is_empty());
|
||||
} else {
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||
assert_eq!(
|
||||
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||
expected_body,
|
||||
);
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.request(req(format!("{prefix}/redirect-fixed")))
|
||||
.await
|
||||
.unwrap();
|
||||
if should_see_redirect {
|
||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get(header::LOCATION)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"/fixed_key"
|
||||
);
|
||||
assert!(resp
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes()
|
||||
.is_empty());
|
||||
} else {
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||
assert_eq!(
|
||||
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||
expected_body,
|
||||
);
|
||||
}
|
||||
let resp = client
|
||||
.request(req(format!("{prefix}/stream-fixed")))
|
||||
.await
|
||||
.unwrap();
|
||||
if should_see_redirect {
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||
assert_eq!(
|
||||
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||
b"static file".as_ref(),
|
||||
);
|
||||
} else {
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||
assert_eq!(
|
||||
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||
expected_body,
|
||||
);
|
||||
}
|
||||
let resp = client
|
||||
.request(req(format!("{prefix}/stream-404")))
|
||||
.await
|
||||
.unwrap();
|
||||
if should_see_redirect {
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||
assert_eq!(
|
||||
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||
b"static file".as_ref(),
|
||||
);
|
||||
} else {
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||
assert_eq!(
|
||||
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||
expected_body,
|
||||
);
|
||||
}
|
||||
let resp = client
|
||||
.request(req(format!("{prefix}/stream-404")))
|
||||
.await
|
||||
.unwrap();
|
||||
if should_see_redirect {
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||
assert_eq!(
|
||||
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||
b"static file".as_ref(),
|
||||
);
|
||||
} else {
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||
assert_eq!(
|
||||
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||
expected_body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_website_invalid_redirect() {
|
||||
const BCKT_NAME: &str = "my-invalid-redirect";
|
||||
let ctx = common::context();
|
||||
let bucket = ctx.create_bucket(BCKT_NAME);
|
||||
|
||||
let conf = WebsiteConfiguration::builder()
|
||||
.routing_rules(
|
||||
RoutingRule::builder()
|
||||
.condition(Condition::builder().key_prefix_equals("").build())
|
||||
.redirect(
|
||||
Redirect::builder()
|
||||
.protocol(Protocol::Https)
|
||||
.host_name("other.tld")
|
||||
.replace_key_prefix_with("")
|
||||
// we don't allow 200 with hostname
|
||||
.http_redirect_code("200")
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build();
|
||||
|
||||
ctx.client
|
||||
.put_bucket_website()
|
||||
.bucket(&bucket)
|
||||
.website_configuration(conf)
|
||||
.send()
|
||||
.await
|
||||
.unwrap_err();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_website_puny() {
|
||||
const BCKT_NAME: &str = "xn--pda.eu";
|
||||
let ctx = common::context();
|
||||
let bucket = ctx.create_bucket(BCKT_NAME);
|
||||
|
||||
let data = ByteStream::from_static(BODY);
|
||||
|
||||
ctx.client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key("index.html")
|
||||
.body(data)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let client = Client::builder(TokioExecutor::new()).build_http();
|
||||
|
||||
let req = |suffix| {
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!("http://127.0.0.1:{}/", ctx.garage.web_port))
|
||||
.header("Host", format!("{}{}", BCKT_NAME, suffix))
|
||||
.body(Body::new(Bytes::new()))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
ctx.garage
|
||||
.command()
|
||||
.args(["bucket", "website", "--allow", BCKT_NAME])
|
||||
.quiet()
|
||||
.expect_success_status("Could not allow website on bucket");
|
||||
|
||||
let mut resp = client.request(req("")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||
BODY.as_ref()
|
||||
);
|
||||
|
||||
resp = client.request(req(".web.garage")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||
BODY.as_ref()
|
||||
);
|
||||
|
||||
for bname in [
|
||||
BCKT_NAME.to_string(),
|
||||
format!("{BCKT_NAME}.web.garage"),
|
||||
format!("{BCKT_NAME}.s3.garage"),
|
||||
] {
|
||||
let admin_req = || {
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!(
|
||||
"http://127.0.0.1:{0}/check?domain={1}",
|
||||
ctx.garage.admin_port, bname
|
||||
))
|
||||
.body(Body::new(Bytes::new()))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let admin_resp = client.request(admin_req()).await.unwrap();
|
||||
assert_eq!(admin_resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
admin_resp.into_body().collect().await.unwrap().to_bytes(),
|
||||
format!("Domain '{bname}' is managed by Garage").as_bytes()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,14 +22,10 @@ mod v08 {
|
||||
pub use v08::*;
|
||||
|
||||
impl BucketAlias {
|
||||
pub fn new(name: String, ts: u64, bucket_id: Option<Uuid>) -> Option<Self> {
|
||||
if !is_valid_bucket_name(&name) {
|
||||
None
|
||||
} else {
|
||||
Some(BucketAlias {
|
||||
name,
|
||||
state: crdt::Lww::raw(ts, bucket_id),
|
||||
})
|
||||
pub fn new(name: String, ts: u64, bucket_id: Option<Uuid>) -> Self {
|
||||
BucketAlias {
|
||||
name,
|
||||
state: crdt::Lww::raw(ts, bucket_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +76,7 @@ impl TableSchema for BucketAliasTable {
|
||||
/// In the case of Garage, bucket names must not be hex-encoded
|
||||
/// 32 byte string, which is excluded thanks to the
|
||||
/// maximum length of 63 bytes given in the spec.
|
||||
pub fn is_valid_bucket_name(n: &str) -> bool {
|
||||
pub fn is_valid_bucket_name(n: &str, puny: bool) -> bool {
|
||||
// Bucket names must be between 3 and 63 characters
|
||||
n.len() >= 3 && n.len() <= 63
|
||||
// Bucket names must be composed of lowercase letters, numbers,
|
||||
@@ -92,7 +88,9 @@ pub fn is_valid_bucket_name(n: &str) -> bool {
|
||||
// Bucket names must not be formatted as an IP address
|
||||
&& n.parse::<std::net::IpAddr>().is_err()
|
||||
// Bucket names must not start with "xn--"
|
||||
&& !n.starts_with("xn--")
|
||||
&& (!n.starts_with("xn--") || puny)
|
||||
// We are a bit stricter, to properly restrict punycode in all labels
|
||||
&& (!n.contains(".xn--") || puny)
|
||||
// Bucket names must not end with "-s3alias"
|
||||
&& !n.ends_with("-s3alias")
|
||||
}
|
||||
|
||||
+116
-1
@@ -119,7 +119,122 @@ mod v08 {
|
||||
impl garage_util::migrate::InitialFormat for Bucket {}
|
||||
}
|
||||
|
||||
pub use v08::*;
|
||||
mod v2 {
|
||||
use crate::permission::BucketKeyPerm;
|
||||
use garage_util::crdt;
|
||||
use garage_util::data::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::v08;
|
||||
|
||||
pub use v08::{BucketQuotas, CorsRule, LifecycleExpiration, LifecycleFilter, LifecycleRule};
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Bucket {
|
||||
/// ID of the bucket
|
||||
pub id: Uuid,
|
||||
/// State, and configuration if not deleted, of the bucket
|
||||
pub state: crdt::Deletable<BucketParams>,
|
||||
}
|
||||
|
||||
/// Configuration for a bucket
|
||||
#[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct BucketParams {
|
||||
/// Bucket's creation date
|
||||
pub creation_date: u64,
|
||||
/// Map of key with access to the bucket, and what kind of access they give
|
||||
pub authorized_keys: crdt::Map<String, BucketKeyPerm>,
|
||||
|
||||
/// Map of aliases that are or have been given to this bucket
|
||||
/// in the global namespace
|
||||
/// (not authoritative: this is just used as an indication to
|
||||
/// map back to aliases when doing ListBuckets)
|
||||
pub aliases: crdt::LwwMap<String, bool>,
|
||||
/// Map of aliases that are or have been given to this bucket
|
||||
/// in namespaces local to keys
|
||||
/// key = (access key id, alias name)
|
||||
pub local_aliases: crdt::LwwMap<(String, String), bool>,
|
||||
|
||||
/// Whether this bucket is allowed for website access
|
||||
/// (under all of its global alias names),
|
||||
/// and if so, the website configuration XML document
|
||||
pub website_config: crdt::Lww<Option<WebsiteConfig>>,
|
||||
/// CORS rules
|
||||
pub cors_config: crdt::Lww<Option<Vec<CorsRule>>>,
|
||||
/// Lifecycle configuration
|
||||
pub lifecycle_config: crdt::Lww<Option<Vec<LifecycleRule>>>,
|
||||
/// Bucket quotas
|
||||
pub quotas: crdt::Lww<BucketQuotas>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct WebsiteConfig {
|
||||
pub index_document: String,
|
||||
pub error_document: Option<String>,
|
||||
// this field is currently unused, but present so adding it in the future doesn't
|
||||
// need a new migration
|
||||
pub redirect_all: Option<RedirectAll>,
|
||||
pub routing_rules: Vec<RoutingRule>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RedirectAll {
|
||||
pub hostname: String,
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RoutingRule {
|
||||
pub condition: Option<RedirectCondition>,
|
||||
pub redirect: Redirect,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RedirectCondition {
|
||||
pub http_error_code: Option<u16>,
|
||||
pub prefix: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Redirect {
|
||||
pub hostname: Option<String>,
|
||||
pub http_redirect_code: u16,
|
||||
pub protocol: Option<String>,
|
||||
pub replace_key_prefix: Option<String>,
|
||||
pub replace_key: Option<String>,
|
||||
}
|
||||
|
||||
impl garage_util::migrate::Migrate for Bucket {
|
||||
const VERSION_MARKER: &'static [u8] = b"G2bkt";
|
||||
|
||||
type Previous = v08::Bucket;
|
||||
|
||||
fn migrate(old: v08::Bucket) -> Bucket {
|
||||
Bucket {
|
||||
id: old.id,
|
||||
state: old.state.map(|x| BucketParams {
|
||||
creation_date: x.creation_date,
|
||||
authorized_keys: x.authorized_keys,
|
||||
aliases: x.aliases,
|
||||
local_aliases: x.local_aliases,
|
||||
website_config: x.website_config.map(|wc_opt| {
|
||||
wc_opt.map(|wc| WebsiteConfig {
|
||||
index_document: wc.index_document,
|
||||
error_document: wc.error_document,
|
||||
redirect_all: None,
|
||||
routing_rules: vec![],
|
||||
})
|
||||
}),
|
||||
cors_config: x.cors_config,
|
||||
lifecycle_config: x.lifecycle_config,
|
||||
quotas: x.quotas,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use v2::*;
|
||||
|
||||
impl AutoCrdt for BucketQuotas {
|
||||
const WARN_IF_DIFFERENT: bool = true;
|
||||
|
||||
+277
-41
@@ -1,3 +1,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use garage_db as db;
|
||||
|
||||
use garage_util::crdt::*;
|
||||
use garage_util::data::*;
|
||||
use garage_util::error::{Error as GarageError, OkOrMessage};
|
||||
@@ -47,6 +51,10 @@ impl<'a> LockedHelper<'a> {
|
||||
KeyHelper(self.0)
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// global bucket aliases
|
||||
// ================================================
|
||||
|
||||
/// Sets a new alias for a bucket in global namespace.
|
||||
/// This function fails if:
|
||||
/// - alias name is not valid according to S3 spec
|
||||
@@ -57,7 +65,7 @@ impl<'a> LockedHelper<'a> {
|
||||
bucket_id: Uuid,
|
||||
alias_name: &String,
|
||||
) -> Result<(), Error> {
|
||||
if !is_valid_bucket_name(alias_name) {
|
||||
if !is_valid_bucket_name(alias_name, self.0.config.allow_punycode) {
|
||||
return Err(Error::InvalidBucketName(alias_name.to_string()));
|
||||
}
|
||||
|
||||
@@ -88,8 +96,7 @@ impl<'a> LockedHelper<'a> {
|
||||
// writes are now done and all writes use timestamp alias_ts
|
||||
|
||||
let alias = match alias {
|
||||
None => BucketAlias::new(alias_name.clone(), alias_ts, Some(bucket_id))
|
||||
.ok_or_else(|| Error::InvalidBucketName(alias_name.clone()))?,
|
||||
None => BucketAlias::new(alias_name.clone(), alias_ts, Some(bucket_id)),
|
||||
Some(mut a) => {
|
||||
a.state = Lww::raw(alias_ts, Some(bucket_id));
|
||||
a
|
||||
@@ -180,13 +187,14 @@ impl<'a> LockedHelper<'a> {
|
||||
.ok_or_else(|| Error::NoSuchBucket(alias_name.to_string()))?;
|
||||
|
||||
// Checks ok, remove alias
|
||||
let alias_ts = match bucket.state.as_option() {
|
||||
Some(bucket_state) => increment_logical_clock_2(
|
||||
alias.state.timestamp(),
|
||||
bucket_state.aliases.get_timestamp(alias_name),
|
||||
),
|
||||
None => increment_logical_clock(alias.state.timestamp()),
|
||||
};
|
||||
let alias_ts = increment_logical_clock_2(
|
||||
alias.state.timestamp(),
|
||||
bucket
|
||||
.state
|
||||
.as_option()
|
||||
.map(|p| p.aliases.get_timestamp(alias_name))
|
||||
.unwrap_or(0),
|
||||
);
|
||||
|
||||
// ---- timestamp-ensured causality barrier ----
|
||||
// writes are now done and all writes use timestamp alias_ts
|
||||
@@ -204,6 +212,10 @@ impl<'a> LockedHelper<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// local bucket aliases
|
||||
// ================================================
|
||||
|
||||
/// Sets a new alias for a bucket in the local namespace of a key.
|
||||
/// This function fails if:
|
||||
/// - alias name is not valid according to S3 spec
|
||||
@@ -216,14 +228,12 @@ impl<'a> LockedHelper<'a> {
|
||||
key_id: &String,
|
||||
alias_name: &String,
|
||||
) -> Result<(), Error> {
|
||||
let key_helper = KeyHelper(self.0);
|
||||
|
||||
if !is_valid_bucket_name(alias_name) {
|
||||
if !is_valid_bucket_name(alias_name, self.0.config.allow_punycode) {
|
||||
return Err(Error::InvalidBucketName(alias_name.to_string()));
|
||||
}
|
||||
|
||||
let mut bucket = self.bucket().get_existing_bucket(bucket_id).await?;
|
||||
let mut key = key_helper.get_existing_key(key_id).await?;
|
||||
let mut key = self.key().get_existing_key(key_id).await?;
|
||||
|
||||
let key_param = key.state.as_option_mut().unwrap();
|
||||
|
||||
@@ -272,23 +282,13 @@ impl<'a> LockedHelper<'a> {
|
||||
key_id: &String,
|
||||
alias_name: &String,
|
||||
) -> Result<(), Error> {
|
||||
let key_helper = KeyHelper(self.0);
|
||||
|
||||
let mut bucket = self.bucket().get_existing_bucket(bucket_id).await?;
|
||||
let mut key = key_helper.get_existing_key(key_id).await?;
|
||||
let mut key = self.key().get_existing_key(key_id).await?;
|
||||
|
||||
let key_p = key.state.as_option().unwrap();
|
||||
let bucket_p = bucket.state.as_option_mut().unwrap();
|
||||
|
||||
if key
|
||||
.state
|
||||
.as_option()
|
||||
.unwrap()
|
||||
.local_aliases
|
||||
.get(alias_name)
|
||||
.cloned()
|
||||
.flatten()
|
||||
!= Some(bucket_id)
|
||||
{
|
||||
if key_p.local_aliases.get(alias_name).cloned().flatten() != Some(bucket_id) {
|
||||
return Err(GarageError::Message(format!(
|
||||
"Bucket {:?} does not have alias {} in namespace of key {}",
|
||||
bucket_id, alias_name, key_id
|
||||
@@ -305,17 +305,17 @@ impl<'a> LockedHelper<'a> {
|
||||
.local_aliases
|
||||
.items()
|
||||
.iter()
|
||||
.any(|((k, n), _, active)| *k == key.key_id && n == alias_name && *active);
|
||||
.any(|((k, n), _, active)| (*k != key.key_id || n != alias_name) && *active);
|
||||
|
||||
if !has_other_global_aliases && !has_other_local_aliases {
|
||||
return Err(Error::BadRequest(format!("Bucket {} doesn't have other aliases, please delete it instead of just unaliasing.", alias_name)));
|
||||
}
|
||||
|
||||
// Checks ok, remove alias
|
||||
let key_param = key.state.as_option_mut().unwrap();
|
||||
let bucket_p_local_alias_key = (key.key_id.clone(), alias_name.clone());
|
||||
|
||||
let alias_ts = increment_logical_clock_2(
|
||||
key_param.local_aliases.get_timestamp(alias_name),
|
||||
key_p.local_aliases.get_timestamp(alias_name),
|
||||
bucket_p
|
||||
.local_aliases
|
||||
.get_timestamp(&bucket_p_local_alias_key),
|
||||
@@ -324,7 +324,8 @@ impl<'a> LockedHelper<'a> {
|
||||
// ---- timestamp-ensured causality barrier ----
|
||||
// writes are now done and all writes use timestamp alias_ts
|
||||
|
||||
key_param.local_aliases = LwwMap::raw_item(alias_name.clone(), alias_ts, None);
|
||||
key.state.as_option_mut().unwrap().local_aliases =
|
||||
LwwMap::raw_item(alias_name.clone(), alias_ts, None);
|
||||
self.0.key_table.insert(&key).await?;
|
||||
|
||||
bucket_p.local_aliases = LwwMap::raw_item(bucket_p_local_alias_key, alias_ts, false);
|
||||
@@ -333,21 +334,68 @@ impl<'a> LockedHelper<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensures a bucket does not have a certain local alias.
|
||||
/// Contrarily to unset_local_bucket_alias, this does not
|
||||
/// fail on any condition other than:
|
||||
/// - bucket cannot be found (its fine if it is in deleted state)
|
||||
/// - key cannot be found (its fine if alias in key points to nothing
|
||||
/// or to another bucket)
|
||||
pub async fn purge_local_bucket_alias(
|
||||
&self,
|
||||
bucket_id: Uuid,
|
||||
key_id: &String,
|
||||
alias_name: &String,
|
||||
) -> Result<(), Error> {
|
||||
let mut bucket = self.bucket().get_internal_bucket(bucket_id).await?;
|
||||
let mut key = self.key().get_internal_key(key_id).await?;
|
||||
|
||||
let bucket_p_local_alias_key = (key.key_id.clone(), alias_name.clone());
|
||||
|
||||
let alias_ts = increment_logical_clock_2(
|
||||
key.state
|
||||
.as_option()
|
||||
.map(|p| p.local_aliases.get_timestamp(alias_name))
|
||||
.unwrap_or(0),
|
||||
bucket
|
||||
.state
|
||||
.as_option()
|
||||
.map(|p| p.local_aliases.get_timestamp(&bucket_p_local_alias_key))
|
||||
.unwrap_or(0),
|
||||
);
|
||||
|
||||
// ---- timestamp-ensured causality barrier ----
|
||||
// writes are now done and all writes use timestamp alias_ts
|
||||
|
||||
if let Some(kp) = key.state.as_option_mut() {
|
||||
kp.local_aliases = LwwMap::raw_item(alias_name.clone(), alias_ts, None);
|
||||
self.0.key_table.insert(&key).await?;
|
||||
}
|
||||
|
||||
if let Some(bp) = bucket.state.as_option_mut() {
|
||||
bp.local_aliases = LwwMap::raw_item(bucket_p_local_alias_key, alias_ts, false);
|
||||
self.0.bucket_table.insert(&bucket).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// permissions
|
||||
// ================================================
|
||||
|
||||
/// Sets permissions for a key on a bucket.
|
||||
/// This function fails if:
|
||||
/// - bucket or key cannot be found at all (its ok if they are in deleted state)
|
||||
/// - bucket or key is in deleted state and we are trying to set permissions other than "deny
|
||||
/// all"
|
||||
/// - bucket or key is in deleted state and we are trying to set
|
||||
/// permissions other than "deny all"
|
||||
pub async fn set_bucket_key_permissions(
|
||||
&self,
|
||||
bucket_id: Uuid,
|
||||
key_id: &String,
|
||||
mut perm: BucketKeyPerm,
|
||||
) -> Result<(), Error> {
|
||||
let key_helper = KeyHelper(self.0);
|
||||
|
||||
let mut bucket = self.bucket().get_internal_bucket(bucket_id).await?;
|
||||
let mut key = key_helper.get_internal_key(key_id).await?;
|
||||
let mut key = self.key().get_internal_key(key_id).await?;
|
||||
|
||||
if let Some(bstate) = bucket.state.as_option() {
|
||||
if let Some(kp) = bstate.authorized_keys.get(key_id) {
|
||||
@@ -384,21 +432,20 @@ impl<'a> LockedHelper<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ----
|
||||
// ================================================
|
||||
// keys
|
||||
// ================================================
|
||||
|
||||
/// Deletes an API access key
|
||||
pub async fn delete_key(&self, key: &mut Key) -> Result<(), Error> {
|
||||
let state = key.state.as_option_mut().unwrap();
|
||||
|
||||
// --- done checking, now commit ---
|
||||
// (the step at unset_local_bucket_alias will fail if a bucket
|
||||
// does not have another alias, the deletion will be
|
||||
// interrupted in the middle if that happens)
|
||||
|
||||
// 1. Delete local aliases
|
||||
for (alias, _, to) in state.local_aliases.items().iter() {
|
||||
if let Some(bucket_id) = to {
|
||||
self.unset_local_bucket_alias(*bucket_id, &key.key_id, alias)
|
||||
self.purge_local_bucket_alias(*bucket_id, &key.key_id, alias)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
@@ -415,4 +462,193 @@ impl<'a> LockedHelper<'a> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// repair procedure
|
||||
// ================================================
|
||||
|
||||
pub async fn repair_aliases(&self) -> Result<(), GarageError> {
|
||||
self.0.db.transaction(|tx| {
|
||||
info!("--- begin repair_aliases transaction ----");
|
||||
|
||||
// 1. List all non-deleted buckets, so that we can fix bad aliases
|
||||
let mut all_buckets: HashSet<Uuid> = HashSet::new();
|
||||
|
||||
for item in tx.range::<&[u8], _>(&self.0.bucket_table.data.store, ..)? {
|
||||
let bucket = self
|
||||
.0
|
||||
.bucket_table
|
||||
.data
|
||||
.decode_entry(&(item?.1))
|
||||
.map_err(db::TxError::Abort)?;
|
||||
if !bucket.is_deleted() {
|
||||
all_buckets.insert(bucket.id);
|
||||
}
|
||||
}
|
||||
|
||||
info!("number of buckets: {}", all_buckets.len());
|
||||
|
||||
// 2. List all aliases declared in bucket_alias_table and key_table
|
||||
// Take note of aliases that point to non-existing buckets
|
||||
let mut global_aliases: HashMap<String, Uuid> = HashMap::new();
|
||||
|
||||
{
|
||||
let mut delete_global = vec![];
|
||||
for item in tx.range::<&[u8], _>(&self.0.bucket_alias_table.data.store, ..)? {
|
||||
let mut alias = self
|
||||
.0
|
||||
.bucket_alias_table
|
||||
.data
|
||||
.decode_entry(&(item?.1))
|
||||
.map_err(db::TxError::Abort)?;
|
||||
if let Some(id) = alias.state.get() {
|
||||
if all_buckets.contains(id) {
|
||||
// keep aliases
|
||||
global_aliases.insert(alias.name().to_string(), *id);
|
||||
} else {
|
||||
// delete alias
|
||||
warn!(
|
||||
"global alias: remove {} -> {:?} (bucket is deleted)",
|
||||
alias.name(),
|
||||
id
|
||||
);
|
||||
alias.state.update(None);
|
||||
delete_global.push(alias);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("number of global aliases: {}", global_aliases.len());
|
||||
|
||||
info!("global alias table: {} entries fixed", delete_global.len());
|
||||
for ga in delete_global {
|
||||
debug!("Enqueue update to global alias table: {:?}", ga);
|
||||
self.0.bucket_alias_table.queue_insert(tx, &ga)?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut local_aliases: HashMap<(String, String), Uuid> = HashMap::new();
|
||||
|
||||
{
|
||||
let mut delete_local = vec![];
|
||||
|
||||
for item in tx.range::<&[u8], _>(&self.0.key_table.data.store, ..)? {
|
||||
let mut key = self
|
||||
.0
|
||||
.key_table
|
||||
.data
|
||||
.decode_entry(&(item?.1))
|
||||
.map_err(db::TxError::Abort)?;
|
||||
let Some(p) = key.state.as_option_mut() else {
|
||||
continue;
|
||||
};
|
||||
let mut has_changes = false;
|
||||
for (name, _, to) in p.local_aliases.items().to_vec() {
|
||||
if let Some(id) = to {
|
||||
if all_buckets.contains(&id) {
|
||||
local_aliases.insert((key.key_id.clone(), name), id);
|
||||
} else {
|
||||
warn!(
|
||||
"local alias: remove ({}, {}) -> {:?} (bucket is deleted)",
|
||||
key.key_id, name, id
|
||||
);
|
||||
p.local_aliases.update_in_place(name, None);
|
||||
has_changes = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if has_changes {
|
||||
delete_local.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
info!("number of local aliases: {}", local_aliases.len());
|
||||
|
||||
info!("key table: {} entries fixed", delete_local.len());
|
||||
for la in delete_local {
|
||||
debug!("Enqueue update to key table: {:?}", la);
|
||||
self.0.key_table.queue_insert(tx, &la)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Reverse the alias maps to determine the aliases per-bucket
|
||||
let mut bucket_global: HashMap<Uuid, Vec<String>> = HashMap::new();
|
||||
let mut bucket_local: HashMap<Uuid, Vec<(String, String)>> = HashMap::new();
|
||||
|
||||
for (name, bucket) in global_aliases {
|
||||
bucket_global.entry(bucket).or_default().push(name);
|
||||
}
|
||||
for ((key, name), bucket) in local_aliases {
|
||||
bucket_local.entry(bucket).or_default().push((key, name));
|
||||
}
|
||||
|
||||
// 5. Fix the bucket table to ensure consistency
|
||||
let mut bucket_updates = vec![];
|
||||
|
||||
for item in tx.range::<&[u8], _>(&self.0.bucket_table.data.store, ..)? {
|
||||
let bucket = self
|
||||
.0
|
||||
.bucket_table
|
||||
.data
|
||||
.decode_entry(&(item?.1))
|
||||
.map_err(db::TxError::Abort)?;
|
||||
let mut bucket2 = bucket.clone();
|
||||
let Some(param) = bucket2.state.as_option_mut() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// fix global aliases
|
||||
{
|
||||
let ga = bucket_global.remove(&bucket.id).unwrap_or_default();
|
||||
for (name, _, active) in param.aliases.items().to_vec() {
|
||||
if active && !ga.contains(&name) {
|
||||
warn!("bucket {:?}: remove global alias {}", bucket.id, name);
|
||||
param.aliases.update_in_place(name, false);
|
||||
}
|
||||
}
|
||||
for name in ga {
|
||||
if param.aliases.get(&name).copied() != Some(true) {
|
||||
warn!("bucket {:?}: add global alias {}", bucket.id, name);
|
||||
param.aliases.update_in_place(name, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fix local aliases
|
||||
{
|
||||
let la = bucket_local.remove(&bucket.id).unwrap_or_default();
|
||||
for (pair, _, active) in param.local_aliases.items().to_vec() {
|
||||
if active && !la.contains(&pair) {
|
||||
warn!("bucket {:?}: remove local alias {:?}", bucket.id, pair);
|
||||
param.local_aliases.update_in_place(pair, false);
|
||||
}
|
||||
}
|
||||
for pair in la {
|
||||
if param.local_aliases.get(&pair).copied() != Some(true) {
|
||||
warn!("bucket {:?}: add local alias {:?}", bucket.id, pair);
|
||||
param.local_aliases.update_in_place(pair, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bucket2 != bucket {
|
||||
bucket_updates.push(bucket2);
|
||||
}
|
||||
}
|
||||
|
||||
info!("bucket table: {} entries fixed", bucket_updates.len());
|
||||
for b in bucket_updates {
|
||||
debug!("Enqueue update to bucket table: {:?}", b);
|
||||
self.0.bucket_table.queue_insert(tx, &b)?;
|
||||
}
|
||||
|
||||
info!("--- end repair_aliases transaction ----");
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
info!("repair_aliases is done");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,13 +43,10 @@ impl TableReplication for TableFullReplication {
|
||||
}
|
||||
fn write_quorum(&self) -> usize {
|
||||
let nmembers = self.system.cluster_layout().current().all_nodes().len();
|
||||
|
||||
let max_faults = if nmembers > 1 { 1 } else { 0 };
|
||||
|
||||
if nmembers > max_faults {
|
||||
nmembers - max_faults
|
||||
} else {
|
||||
if nmembers < 3 {
|
||||
1
|
||||
} else {
|
||||
nmembers.div_euclid(2) + 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,10 @@ pub struct Config {
|
||||
/// Configuration for the admin API endpoint
|
||||
#[serde(default = "Default::default")]
|
||||
pub admin: AdminConfig,
|
||||
|
||||
/// Allow punycode in bucket names
|
||||
#[serde(default)]
|
||||
pub allow_punycode: bool,
|
||||
}
|
||||
|
||||
/// Value for data_dir: either a single directory or a list of dirs with attributes
|
||||
|
||||
@@ -9,6 +9,16 @@ pub enum Deletable<T> {
|
||||
Deleted,
|
||||
}
|
||||
|
||||
impl<T> Deletable<T> {
|
||||
/// Map value, used for migrations
|
||||
pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Deletable<U> {
|
||||
match self {
|
||||
Self::Present(x) => Deletable::<U>::Present(f(x)),
|
||||
Self::Deleted => Deletable::<U>::Deleted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Crdt> Deletable<T> {
|
||||
/// Create a new deletable object that isn't deleted
|
||||
pub fn present(v: T) -> Self {
|
||||
|
||||
@@ -43,6 +43,16 @@ pub struct Lww<T> {
|
||||
v: T,
|
||||
}
|
||||
|
||||
impl<T> Lww<T> {
|
||||
/// Map value, used for migrations
|
||||
pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Lww<U> {
|
||||
Lww::<U> {
|
||||
ts: self.ts,
|
||||
v: f(self.v),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Lww<T>
|
||||
where
|
||||
T: Crdt,
|
||||
|
||||
+302
-72
@@ -25,12 +25,14 @@ use garage_api_common::cors::{
|
||||
};
|
||||
use garage_api_common::generic_server::{server_loop, UnixListenerOn};
|
||||
use garage_api_common::helpers::*;
|
||||
use garage_api_s3::api_server::ResBody;
|
||||
use garage_api_s3::error::{
|
||||
CommonErrorDerivative, Error as ApiError, OkOrBadRequest, OkOrInternalError,
|
||||
};
|
||||
use garage_api_s3::get::{handle_get_without_ctx, handle_head_without_ctx};
|
||||
use garage_api_s3::website::X_AMZ_WEBSITE_REDIRECT_LOCATION;
|
||||
|
||||
use garage_model::bucket_table::{self, RoutingRule};
|
||||
use garage_model::garage::Garage;
|
||||
|
||||
use garage_table::*;
|
||||
@@ -260,45 +262,71 @@ impl WebServer {
|
||||
// Get path
|
||||
let path = req.uri().path().to_string();
|
||||
let index = &website_config.index_document;
|
||||
let (key, may_redirect) = path_to_keys(&path, index)?;
|
||||
let routing_result = path_to_keys(&path, index, &website_config.routing_rules)?;
|
||||
|
||||
debug!(
|
||||
"Selected bucket: \"{}\" {:?}, target key: \"{}\", may redirect to: {:?}",
|
||||
bucket_name, bucket_id, key, may_redirect
|
||||
"Selected bucket: \"{}\" {:?}, routing to {:?}",
|
||||
bucket_name, bucket_id, routing_result,
|
||||
);
|
||||
|
||||
let ret_doc = match *req.method() {
|
||||
Method::OPTIONS => handle_options_for_bucket(req, &bucket_params)
|
||||
let ret_doc = match (req.method(), routing_result.main_target()) {
|
||||
(&Method::OPTIONS, _) => handle_options_for_bucket(req, &bucket_params)
|
||||
.map_err(ApiError::from)
|
||||
.map(|res| res.map(|_empty_body: EmptyBody| empty_body())),
|
||||
Method::HEAD => {
|
||||
handle_head_without_ctx(self.garage.clone(), req, bucket_id, &key, None).await
|
||||
(_, Err((url, code))) => Ok(Response::builder()
|
||||
.status(code)
|
||||
.header("Location", url)
|
||||
.body(empty_body())
|
||||
.unwrap()),
|
||||
(_, Ok((key, code))) => {
|
||||
handle_inner(self.garage.clone(), req, bucket_id, key, code).await
|
||||
}
|
||||
Method::GET => {
|
||||
handle_get_without_ctx(
|
||||
};
|
||||
|
||||
// Try handling errors if bucket configuration provided fallbacks
|
||||
let ret_doc_with_redir = match (&ret_doc, &routing_result) {
|
||||
(
|
||||
Err(ApiError::NoSuchKey),
|
||||
RoutingResult::LoadOrRedirect {
|
||||
redirect_if_exists,
|
||||
redirect_url,
|
||||
redirect_code,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
let redirect = if let Some(redirect_key) = redirect_if_exists {
|
||||
self.check_key_exists(bucket_id, redirect_key.as_str())
|
||||
.await?
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if redirect {
|
||||
Ok(Response::builder()
|
||||
.status(redirect_code)
|
||||
.header("Location", redirect_url)
|
||||
.body(empty_body())
|
||||
.unwrap())
|
||||
} else {
|
||||
ret_doc
|
||||
}
|
||||
}
|
||||
(
|
||||
Err(ApiError::NoSuchKey),
|
||||
RoutingResult::LoadOrAlternativeError {
|
||||
redirect_key,
|
||||
redirect_code,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
handle_inner(
|
||||
self.garage.clone(),
|
||||
req,
|
||||
bucket_id,
|
||||
&key,
|
||||
None,
|
||||
Default::default(),
|
||||
redirect_key,
|
||||
*redirect_code,
|
||||
)
|
||||
.await
|
||||
}
|
||||
_ => Err(ApiError::bad_request("HTTP method not supported")),
|
||||
};
|
||||
|
||||
// Try implicit redirect on error
|
||||
let ret_doc_with_redir = match (&ret_doc, may_redirect) {
|
||||
(Err(ApiError::NoSuchKey), ImplicitRedirect::To { key, url })
|
||||
if self.check_key_exists(bucket_id, key.as_str()).await? =>
|
||||
{
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::FOUND)
|
||||
.header(LOCATION, url)
|
||||
.body(empty_body())
|
||||
.unwrap())
|
||||
}
|
||||
(Ok(ret), _) if ret.headers().contains_key(X_AMZ_WEBSITE_REDIRECT_LOCATION) => {
|
||||
let redirect_location = ret.headers().get(X_AMZ_WEBSITE_REDIRECT_LOCATION).unwrap();
|
||||
Ok(Response::builder()
|
||||
@@ -332,17 +360,17 @@ impl WebServer {
|
||||
// We want to return the error document
|
||||
// Create a fake HTTP request with path = the error document
|
||||
let req2 = Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!("http://{}/{}", host, &error_document))
|
||||
.body(())
|
||||
.unwrap();
|
||||
|
||||
match handle_get_without_ctx(
|
||||
match handle_inner(
|
||||
self.garage.clone(),
|
||||
&req2,
|
||||
bucket_id,
|
||||
&error_document,
|
||||
None,
|
||||
Default::default(),
|
||||
error.http_status_code(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -357,8 +385,6 @@ impl WebServer {
|
||||
error
|
||||
);
|
||||
|
||||
*error_doc.status_mut() = error.http_status_code();
|
||||
|
||||
// Preserve error message in a special header
|
||||
for error_line in error.to_string().split('\n') {
|
||||
if let Ok(v) = HeaderValue::from_bytes(error_line.as_bytes()) {
|
||||
@@ -389,6 +415,52 @@ impl WebServer {
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_inner(
|
||||
garage: Arc<Garage>,
|
||||
req: &Request<()>,
|
||||
bucket_id: Uuid,
|
||||
key: &str,
|
||||
status_code: StatusCode,
|
||||
) -> Result<Response<ResBody>, ApiError> {
|
||||
if status_code != StatusCode::OK {
|
||||
// If we are returning an error document, discard all headers from
|
||||
// the original request that would have influenced the result:
|
||||
// - Range header, we don't want to return a subrange of the error document
|
||||
// - Caching directives such as If-None-Match, etc, which are not relevant
|
||||
let cleaned_req = Request::builder().uri(req.uri()).body(()).unwrap();
|
||||
|
||||
let mut ret = match req.method() {
|
||||
&Method::HEAD => {
|
||||
handle_head_without_ctx(garage, &cleaned_req, bucket_id, key, None).await?
|
||||
}
|
||||
&Method::GET => {
|
||||
handle_get_without_ctx(
|
||||
garage,
|
||||
&cleaned_req,
|
||||
bucket_id,
|
||||
key,
|
||||
None,
|
||||
Default::default(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
_ => return Err(ApiError::bad_request("HTTP method not supported")),
|
||||
};
|
||||
|
||||
*ret.status_mut() = status_code;
|
||||
|
||||
Ok(ret)
|
||||
} else {
|
||||
match req.method() {
|
||||
&Method::HEAD => handle_head_without_ctx(garage, req, bucket_id, key, None).await,
|
||||
&Method::GET => {
|
||||
handle_get_without_ctx(garage, req, bucket_id, key, None, Default::default()).await
|
||||
}
|
||||
_ => Err(ApiError::bad_request("HTTP method not supported")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn error_to_res(e: Error) -> Response<BoxBody<Error>> {
|
||||
// If we are here, it is either that:
|
||||
// - there was an error before trying to get the requested URL
|
||||
@@ -405,9 +477,44 @@ fn error_to_res(e: Error) -> Response<BoxBody<Error>> {
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum ImplicitRedirect {
|
||||
No,
|
||||
To { key: String, url: String },
|
||||
enum RoutingResult {
|
||||
// Load a key and use `code` as status, or fallback to normal 404 handler if not found
|
||||
LoadKey {
|
||||
key: String,
|
||||
code: StatusCode,
|
||||
},
|
||||
// Load a key and use `200` as status, or fallback with a redirection using `redirect_code`
|
||||
// as status
|
||||
LoadOrRedirect {
|
||||
key: String,
|
||||
redirect_if_exists: Option<String>,
|
||||
redirect_url: String,
|
||||
redirect_code: StatusCode,
|
||||
},
|
||||
// Load a key and use `200` as status, or fallback by loading a different key and use
|
||||
// `redirect_code` as status
|
||||
LoadOrAlternativeError {
|
||||
key: String,
|
||||
redirect_key: String,
|
||||
redirect_code: StatusCode,
|
||||
},
|
||||
// Send an http redirect with `code` as status
|
||||
Redirect {
|
||||
url: String,
|
||||
code: StatusCode,
|
||||
},
|
||||
}
|
||||
|
||||
impl RoutingResult {
|
||||
// return Ok((key_to_deref, status_code)) or Err((redirect_target, status_code))
|
||||
fn main_target(&self) -> Result<(&str, StatusCode), (&str, StatusCode)> {
|
||||
match self {
|
||||
RoutingResult::LoadKey { key, code } => Ok((key, *code)),
|
||||
RoutingResult::LoadOrRedirect { key, .. } => Ok((key, StatusCode::OK)),
|
||||
RoutingResult::LoadOrAlternativeError { key, .. } => Ok((key, StatusCode::OK)),
|
||||
RoutingResult::Redirect { url, code } => Err((url, *code)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Path to key
|
||||
@@ -417,35 +524,154 @@ enum ImplicitRedirect {
|
||||
/// which is also AWS S3 behavior.
|
||||
///
|
||||
/// Check: https://docs.aws.amazon.com/AmazonS3/latest/userguide/IndexDocumentSupport.html
|
||||
fn path_to_keys<'a>(path: &'a str, index: &str) -> Result<(String, ImplicitRedirect), Error> {
|
||||
fn path_to_keys(
|
||||
path: &str,
|
||||
index: &str,
|
||||
routing_rules: &[RoutingRule],
|
||||
) -> Result<RoutingResult, Error> {
|
||||
let path_utf8 = percent_encoding::percent_decode_str(path).decode_utf8()?;
|
||||
|
||||
let base_key = match path_utf8.strip_prefix("/") {
|
||||
Some(bk) => bk,
|
||||
None => return Err(Error::BadRequest("Path must start with a / (slash)".into())),
|
||||
};
|
||||
let is_bucket_root = base_key.len() == 0;
|
||||
|
||||
let is_bucket_root = base_key.is_empty();
|
||||
let is_trailing_slash = path_utf8.ends_with("/");
|
||||
|
||||
match (is_bucket_root, is_trailing_slash) {
|
||||
// It is not possible to store something at the root of the bucket (ie. empty key),
|
||||
// the only option is to fetch the index
|
||||
(true, _) => Ok((index.to_string(), ImplicitRedirect::No)),
|
||||
let key = if is_bucket_root || is_trailing_slash {
|
||||
// we can't store anything at the root, so we need to query the index
|
||||
// if the key end with a slash, we always query the index
|
||||
format!("{base_key}{index}")
|
||||
} else {
|
||||
// if the key doesn't end with `/`, leave it unmodified
|
||||
base_key.to_string()
|
||||
};
|
||||
|
||||
// "If you create a folder structure in your bucket, you must have an index document at each level. In each folder, the index document must have the same name, for example, index.html. When a user specifies a URL that resembles a folder lookup, the presence or absence of a trailing slash determines the behavior of the website. For example, the following URL, with a trailing slash, returns the photos/index.html index document."
|
||||
(false, true) => Ok((format!("{base_key}{index}"), ImplicitRedirect::No)),
|
||||
let mut routing_rules_iter = routing_rules.iter();
|
||||
let key = loop {
|
||||
let Some(routing_rule) = routing_rules_iter.next() else {
|
||||
break key;
|
||||
};
|
||||
|
||||
// "However, if you exclude the trailing slash from the preceding URL, Amazon S3 first looks for an object photos in the bucket. If the photos object is not found, it searches for an index document, photos/index.html. If that document is found, Amazon S3 returns a 302 Found message and points to the photos/ key. For subsequent requests to photos/, Amazon S3 returns photos/index.html. If the index document is not found, Amazon S3 returns an error."
|
||||
(false, false) => Ok((
|
||||
base_key.to_string(),
|
||||
ImplicitRedirect::To {
|
||||
key: format!("{base_key}/{index}"),
|
||||
url: format!("{path}/"),
|
||||
},
|
||||
)),
|
||||
let Ok(status_code) = StatusCode::from_u16(routing_rule.redirect.http_redirect_code) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(condition) = &routing_rule.condition {
|
||||
let suffix = if let Some(prefix) = &condition.prefix {
|
||||
let Some(suffix) = key.strip_prefix(prefix) else {
|
||||
continue;
|
||||
};
|
||||
Some(suffix)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut target = compute_redirect_target(&routing_rule.redirect, suffix);
|
||||
let query_alternative_key =
|
||||
status_code == StatusCode::OK || status_code == StatusCode::NOT_FOUND;
|
||||
let redirect_on_error =
|
||||
condition.http_error_code == Some(StatusCode::NOT_FOUND.as_u16());
|
||||
match (query_alternative_key, redirect_on_error) {
|
||||
(false, false) => {
|
||||
return Ok(RoutingResult::Redirect {
|
||||
url: target,
|
||||
code: status_code,
|
||||
})
|
||||
}
|
||||
(true, false) => {
|
||||
// we need to remove the leading /
|
||||
target.remove(0);
|
||||
if status_code == StatusCode::OK {
|
||||
break target;
|
||||
} else {
|
||||
return Ok(RoutingResult::LoadKey {
|
||||
key: target,
|
||||
code: status_code,
|
||||
});
|
||||
}
|
||||
}
|
||||
(false, true) => {
|
||||
return Ok(RoutingResult::LoadOrRedirect {
|
||||
key,
|
||||
redirect_if_exists: None,
|
||||
redirect_url: target,
|
||||
redirect_code: status_code,
|
||||
});
|
||||
}
|
||||
(true, true) => {
|
||||
target.remove(0);
|
||||
return Ok(RoutingResult::LoadOrAlternativeError {
|
||||
key,
|
||||
redirect_key: target,
|
||||
redirect_code: status_code,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let target = compute_redirect_target(&routing_rule.redirect, None);
|
||||
return Ok(RoutingResult::Redirect {
|
||||
url: target,
|
||||
code: status_code,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if is_bucket_root || is_trailing_slash {
|
||||
Ok(RoutingResult::LoadKey {
|
||||
key,
|
||||
code: StatusCode::OK,
|
||||
})
|
||||
} else {
|
||||
Ok(RoutingResult::LoadOrRedirect {
|
||||
redirect_if_exists: Some(format!("{key}/{index}")),
|
||||
// we can't use `path` because key might have changed substentially in case of
|
||||
// routing rules
|
||||
redirect_url: percent_encoding::percent_encode(
|
||||
format!("/{key}/").as_bytes(),
|
||||
PATH_ENCODING_SET,
|
||||
)
|
||||
.to_string(),
|
||||
key,
|
||||
redirect_code: StatusCode::FOUND,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// per https://url.spec.whatwg.org/#path-percent-encode-set
|
||||
const PATH_ENCODING_SET: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
|
||||
.add(b' ')
|
||||
.add(b'"')
|
||||
.add(b'#')
|
||||
.add(b'<')
|
||||
.add(b'>')
|
||||
.add(b'?')
|
||||
.add(b'`')
|
||||
.add(b'{')
|
||||
.add(b'}');
|
||||
|
||||
fn compute_redirect_target(redirect: &bucket_table::Redirect, suffix: Option<&str>) -> String {
|
||||
let mut res = String::new();
|
||||
if let Some(hostname) = &redirect.hostname {
|
||||
if let Some(protocol) = &redirect.protocol {
|
||||
res.push_str(protocol);
|
||||
res.push_str("://");
|
||||
} else {
|
||||
res.push_str("//");
|
||||
}
|
||||
res.push_str(hostname);
|
||||
}
|
||||
res.push('/');
|
||||
if let Some(replace_key_prefix) = &redirect.replace_key_prefix {
|
||||
res.push_str(replace_key_prefix);
|
||||
if let Some(suffix) = suffix {
|
||||
res.push_str(suffix)
|
||||
}
|
||||
} else if let Some(replace_key) = &redirect.replace_key {
|
||||
res.push_str(replace_key)
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -453,35 +679,39 @@ mod tests {
|
||||
#[test]
|
||||
fn path_to_keys_test() -> Result<(), Error> {
|
||||
assert_eq!(
|
||||
path_to_keys("/file%20.jpg", "index.html")?,
|
||||
(
|
||||
"file .jpg".to_string(),
|
||||
ImplicitRedirect::To {
|
||||
key: "file .jpg/index.html".to_string(),
|
||||
url: "/file%20.jpg/".to_string()
|
||||
}
|
||||
)
|
||||
path_to_keys("/file%20.jpg", "index.html", &[])?,
|
||||
RoutingResult::LoadOrRedirect {
|
||||
key: "file .jpg".to_string(),
|
||||
redirect_url: "/file%20.jpg/".to_string(),
|
||||
redirect_if_exists: Some("file .jpg/index.html".to_string()),
|
||||
redirect_code: StatusCode::FOUND,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
path_to_keys("/%20t/", "index.html")?,
|
||||
(" t/index.html".to_string(), ImplicitRedirect::No)
|
||||
path_to_keys("/%20t/", "index.html", &[])?,
|
||||
RoutingResult::LoadKey {
|
||||
key: " t/index.html".to_string(),
|
||||
code: StatusCode::OK
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
path_to_keys("/", "index.html")?,
|
||||
("index.html".to_string(), ImplicitRedirect::No)
|
||||
path_to_keys("/", "index.html", &[])?,
|
||||
RoutingResult::LoadKey {
|
||||
key: "index.html".to_string(),
|
||||
code: StatusCode::OK
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
path_to_keys("/hello", "index.html")?,
|
||||
(
|
||||
"hello".to_string(),
|
||||
ImplicitRedirect::To {
|
||||
key: "hello/index.html".to_string(),
|
||||
url: "/hello/".to_string()
|
||||
}
|
||||
)
|
||||
path_to_keys("/hello", "index.html", &[])?,
|
||||
RoutingResult::LoadOrRedirect {
|
||||
key: "hello".to_string(),
|
||||
redirect_url: "/hello/".to_string(),
|
||||
redirect_if_exists: Some("hello/index.html".to_string()),
|
||||
redirect_code: StatusCode::FOUND,
|
||||
}
|
||||
);
|
||||
assert!(path_to_keys("", "index.html").is_err());
|
||||
assert!(path_to_keys("i/am/relative", "index.html").is_err());
|
||||
assert!(path_to_keys("", "index.html", &[]).is_err());
|
||||
assert!(path_to_keys("i/am/relative", "index.html", &[]).is_err());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user