mirror of
https://git.deuxfleurs.fr/Deuxfleurs/garage.git
synced 2024-12-01 04:21:00 +00:00
Merge pull request 'Support website publishing' (#7) from feature/website into master
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/7
This commit is contained in:
commit
97494b4a19
17 changed files with 480 additions and 14 deletions
19
Cargo.lock
generated
19
Cargo.lock
generated
|
@ -425,6 +425,7 @@ dependencies = [
|
||||||
"garage_rpc 0.1.0",
|
"garage_rpc 0.1.0",
|
||||||
"garage_table 0.1.1",
|
"garage_table 0.1.1",
|
||||||
"garage_util 0.1.0",
|
"garage_util 0.1.0",
|
||||||
|
"garage_web",
|
||||||
"hex",
|
"hex",
|
||||||
"log",
|
"log",
|
||||||
"pretty_env_logger",
|
"pretty_env_logger",
|
||||||
|
@ -666,6 +667,24 @@ dependencies = [
|
||||||
"webpki",
|
"webpki",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "garage_web"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"err-derive",
|
||||||
|
"futures",
|
||||||
|
"garage_api",
|
||||||
|
"garage_model 0.1.1",
|
||||||
|
"garage_table 0.1.1",
|
||||||
|
"garage_util 0.1.0",
|
||||||
|
"http",
|
||||||
|
"hyper",
|
||||||
|
"idna",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"roxmltree",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "generator"
|
name = "generator"
|
||||||
version = "0.6.21"
|
version = "0.6.21"
|
||||||
|
|
|
@ -5,6 +5,7 @@ members = [
|
||||||
"src/table",
|
"src/table",
|
||||||
"src/model",
|
"src/model",
|
||||||
"src/api",
|
"src/api",
|
||||||
|
"src/web",
|
||||||
"src/garage",
|
"src/garage",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -92,7 +92,9 @@ api_bind_addr = "[::1]:3900" # the S3 API port, HTTP without TLS. Add a reverse
|
||||||
s3_region = "garage" # set this to anything. S3 API calls will fail if they are not made against the region set here.
|
s3_region = "garage" # set this to anything. S3 API calls will fail if they are not made against the region set here.
|
||||||
|
|
||||||
[s3_web]
|
[s3_web]
|
||||||
web_bind_addr = "[::1]:3902"
|
bind_addr = "[::1]:3902"
|
||||||
|
root_domain = ".garage.tld"
|
||||||
|
index = "index.html"
|
||||||
```
|
```
|
||||||
|
|
||||||
Build Garage using `cargo build --release`.
|
Build Garage using `cargo build --release`.
|
||||||
|
|
|
@ -17,4 +17,6 @@ api_bind_addr = "[::1]:3900" # the S3 API port, HTTP without TLS. Add a reverse
|
||||||
s3_region = "garage" # set this to anything. S3 API calls will fail if they are not made against the region set here.
|
s3_region = "garage" # set this to anything. S3 API calls will fail if they are not made against the region set here.
|
||||||
|
|
||||||
[s3_web]
|
[s3_web]
|
||||||
web_bind_addr = "[::1]:3902"
|
bind_addr = "[::1]:3902"
|
||||||
|
root_domain = ".garage.tld"
|
||||||
|
index = "index.html"
|
||||||
|
|
|
@ -41,6 +41,8 @@ s3_region = "garage" # set this to anything. S3 API calls will fail if they a
|
||||||
|
|
||||||
[s3_web]
|
[s3_web]
|
||||||
bind_addr = "[::]:$((3920+$count))"
|
bind_addr = "[::]:$((3920+$count))"
|
||||||
|
root_domain = ".garage.tld"
|
||||||
|
index = "index.html"
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo -en "$LABEL configuration written to $CONF_PATH\n"
|
echo -en "$LABEL configuration written to $CONF_PATH\n"
|
||||||
|
|
|
@ -6,6 +6,7 @@ shopt -s expand_aliases
|
||||||
SCRIPT_FOLDER="`dirname \"$0\"`"
|
SCRIPT_FOLDER="`dirname \"$0\"`"
|
||||||
REPO_FOLDER="${SCRIPT_FOLDER}/../"
|
REPO_FOLDER="${SCRIPT_FOLDER}/../"
|
||||||
|
|
||||||
|
echo "setup"
|
||||||
cargo build
|
cargo build
|
||||||
${SCRIPT_FOLDER}/dev-clean.sh
|
${SCRIPT_FOLDER}/dev-clean.sh
|
||||||
${SCRIPT_FOLDER}/dev-cluster.sh > /tmp/garage.log 2>&1 &
|
${SCRIPT_FOLDER}/dev-cluster.sh > /tmp/garage.log 2>&1 &
|
||||||
|
@ -22,6 +23,7 @@ dd if=/dev/urandom of=/tmp/garage.1.rnd bs=1k count=2 # < INLINE_THRESHOLD = 307
|
||||||
dd if=/dev/urandom of=/tmp/garage.2.rnd bs=1M count=5
|
dd if=/dev/urandom of=/tmp/garage.2.rnd bs=1M count=5
|
||||||
dd if=/dev/urandom of=/tmp/garage.3.rnd bs=1M count=10
|
dd if=/dev/urandom of=/tmp/garage.3.rnd bs=1M count=10
|
||||||
|
|
||||||
|
echo "s3 api testing..."
|
||||||
for idx in $(seq 1 3); do
|
for idx in $(seq 1 3); do
|
||||||
# AWS sends
|
# AWS sends
|
||||||
awsgrg cp /tmp/garage.$idx.rnd s3://eprouvette/garage.$idx.aws
|
awsgrg cp /tmp/garage.$idx.rnd s3://eprouvette/garage.$idx.aws
|
||||||
|
@ -55,6 +57,18 @@ for idx in $(seq 1 3); do
|
||||||
done
|
done
|
||||||
rm /tmp/garage.{1,2,3}.rnd
|
rm /tmp/garage.{1,2,3}.rnd
|
||||||
|
|
||||||
|
echo "website testing"
|
||||||
|
echo "<h1>hello world</h1>" > /tmp/garage-index.html
|
||||||
|
awsgrg cp /tmp/garage-index.html s3://eprouvette/index.html
|
||||||
|
[ `curl -s -o /dev/null -w "%{http_code}" --header "Host: eprouvette.garage.tld" http://127.0.0.1:3923/ ` == 404 ]
|
||||||
|
garage bucket website --allow eprouvette
|
||||||
|
[ `curl -s -o /dev/null -w "%{http_code}" --header "Host: eprouvette.garage.tld" http://127.0.0.1:3923/ ` == 200 ]
|
||||||
|
garage bucket website --deny eprouvette
|
||||||
|
[ `curl -s -o /dev/null -w "%{http_code}" --header "Host: eprouvette.garage.tld" http://127.0.0.1:3923/ ` == 404 ]
|
||||||
|
awsgrg rm s3://eprouvette/index.html
|
||||||
|
rm /tmp/garage-index.html
|
||||||
|
|
||||||
|
echo "teardown"
|
||||||
garage bucket deny --read --write eprouvette --key $AWS_ACCESS_KEY_ID
|
garage bucket deny --read --write eprouvette --key $AWS_ACCESS_KEY_ID
|
||||||
garage bucket delete --yes eprouvette
|
garage bucket delete --yes eprouvette
|
||||||
garage key delete --yes $AWS_ACCESS_KEY_ID
|
garage key delete --yes $AWS_ACCESS_KEY_ID
|
||||||
|
|
|
@ -19,6 +19,7 @@ garage_rpc = { version = "0.1", path = "../rpc" }
|
||||||
garage_table = { version = "0.1.1", path = "../table" }
|
garage_table = { version = "0.1.1", path = "../table" }
|
||||||
garage_model = { version = "0.1.1", path = "../model" }
|
garage_model = { version = "0.1.1", path = "../model" }
|
||||||
garage_api = { version = "0.1.1", path = "../api" }
|
garage_api = { version = "0.1.1", path = "../api" }
|
||||||
|
garage_web = { version = "0.1", path = "../web" }
|
||||||
|
|
||||||
bytes = "0.4"
|
bytes = "0.4"
|
||||||
rand = "0.7"
|
rand = "0.7"
|
||||||
|
|
|
@ -89,7 +89,7 @@ impl AdminRpcHandler {
|
||||||
}
|
}
|
||||||
bucket
|
bucket
|
||||||
.state
|
.state
|
||||||
.update(BucketState::Present(crdt::LWWMap::new()));
|
.update(BucketState::Present(BucketParams::new()));
|
||||||
bucket
|
bucket
|
||||||
}
|
}
|
||||||
None => Bucket::new(query.name.clone()),
|
None => Bucket::new(query.name.clone()),
|
||||||
|
@ -155,6 +155,29 @@ impl AdminRpcHandler {
|
||||||
&query.key_id, &query.bucket, allow_read, allow_write
|
&query.key_id, &query.bucket, allow_read, allow_write
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
BucketOperation::Website(query) => {
|
||||||
|
let mut bucket = self.get_existing_bucket(&query.bucket).await?;
|
||||||
|
|
||||||
|
if !(query.allow ^ query.deny) {
|
||||||
|
return Err(Error::Message(format!(
|
||||||
|
"You must specify exactly one flag, either --allow or --deny"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let BucketState::Present(state) = bucket.state.get_mut() {
|
||||||
|
state.website.update(query.allow);
|
||||||
|
self.garage.bucket_table.insert(&bucket).await?;
|
||||||
|
let msg = if query.allow {
|
||||||
|
format!("Website access allowed for {}", &query.bucket)
|
||||||
|
} else {
|
||||||
|
format!("Website access denied for {}", &query.bucket)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(AdminRPC::Ok(msg.to_string()))
|
||||||
|
} else {
|
||||||
|
unreachable!();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -237,6 +260,7 @@ impl AdminRpcHandler {
|
||||||
.unwrap_or(Err(Error::BadRPC(format!("Key {} does not exist", id))))
|
.unwrap_or(Err(Error::BadRPC(format!("Key {} does not exist", id))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update **bucket table** to inform of the new linked key
|
||||||
async fn update_bucket_key(
|
async fn update_bucket_key(
|
||||||
&self,
|
&self,
|
||||||
mut bucket: Bucket,
|
mut bucket: Bucket,
|
||||||
|
@ -244,7 +268,8 @@ impl AdminRpcHandler {
|
||||||
allow_read: bool,
|
allow_read: bool,
|
||||||
allow_write: bool,
|
allow_write: bool,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
if let BucketState::Present(ak) = bucket.state.get_mut() {
|
if let BucketState::Present(params) = bucket.state.get_mut() {
|
||||||
|
let ak = &mut params.authorized_keys;
|
||||||
let old_ak = ak.take_and_clear();
|
let old_ak = ak.take_and_clear();
|
||||||
ak.merge(&old_ak.update_mutator(
|
ak.merge(&old_ak.update_mutator(
|
||||||
key_id.to_string(),
|
key_id.to_string(),
|
||||||
|
@ -262,6 +287,7 @@ impl AdminRpcHandler {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update **key table** to inform of the new linked bucket
|
||||||
async fn update_key_bucket(
|
async fn update_key_bucket(
|
||||||
&self,
|
&self,
|
||||||
mut key: Key,
|
mut key: Key,
|
||||||
|
|
|
@ -141,6 +141,24 @@ pub enum BucketOperation {
|
||||||
/// Allow key to read or write to bucket
|
/// Allow key to read or write to bucket
|
||||||
#[structopt(name = "deny")]
|
#[structopt(name = "deny")]
|
||||||
Deny(PermBucketOpt),
|
Deny(PermBucketOpt),
|
||||||
|
|
||||||
|
/// Expose as website or not
|
||||||
|
#[structopt(name = "website")]
|
||||||
|
Website(WebsiteOpt),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, StructOpt, Debug)]
|
||||||
|
pub struct WebsiteOpt {
|
||||||
|
/// Create
|
||||||
|
#[structopt(long = "allow")]
|
||||||
|
pub allow: bool,
|
||||||
|
|
||||||
|
/// Delete
|
||||||
|
#[structopt(long = "deny")]
|
||||||
|
pub deny: bool,
|
||||||
|
|
||||||
|
/// Bucket name
|
||||||
|
pub bucket: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, StructOpt, Debug)]
|
#[derive(Serialize, Deserialize, StructOpt, Debug)]
|
||||||
|
|
|
@ -11,6 +11,7 @@ use garage_util::error::Error;
|
||||||
use garage_api::api_server;
|
use garage_api::api_server;
|
||||||
use garage_model::garage::Garage;
|
use garage_model::garage::Garage;
|
||||||
use garage_rpc::rpc_server::RpcServer;
|
use garage_rpc::rpc_server::RpcServer;
|
||||||
|
use garage_web::web_server;
|
||||||
|
|
||||||
use crate::admin_rpc::*;
|
use crate::admin_rpc::*;
|
||||||
|
|
||||||
|
@ -56,6 +57,7 @@ pub async fn run_server(config_file: PathBuf) -> Result<(), Error> {
|
||||||
info!("Initializing RPC and API servers...");
|
info!("Initializing RPC and API servers...");
|
||||||
let run_rpc_server = Arc::new(rpc_server).run(wait_from(watch_cancel.clone()));
|
let run_rpc_server = Arc::new(rpc_server).run(wait_from(watch_cancel.clone()));
|
||||||
let api_server = api_server::run_api_server(garage.clone(), wait_from(watch_cancel.clone()));
|
let api_server = api_server::run_api_server(garage.clone(), wait_from(watch_cancel.clone()));
|
||||||
|
let web_server = web_server::run_web_server(garage.clone(), wait_from(watch_cancel.clone()));
|
||||||
|
|
||||||
futures::try_join!(
|
futures::try_join!(
|
||||||
garage
|
garage
|
||||||
|
@ -78,6 +80,10 @@ pub async fn run_server(config_file: PathBuf) -> Result<(), Error> {
|
||||||
info!("API server exited");
|
info!("API server exited");
|
||||||
rv
|
rv
|
||||||
}),
|
}),
|
||||||
|
web_server.map(|rv| {
|
||||||
|
info!("Web server exited");
|
||||||
|
rv
|
||||||
|
}),
|
||||||
background.run().map(|rv| {
|
background.run().map(|rv| {
|
||||||
info!("Background runner exited");
|
info!("Background runner exited");
|
||||||
Ok(rv)
|
Ok(rv)
|
||||||
|
|
|
@ -13,6 +13,11 @@ use crate::key_table::PermissionSet;
|
||||||
// We use them to perform migrations.
|
// We use them to perform migrations.
|
||||||
use model010::bucket_table as prev;
|
use model010::bucket_table as prev;
|
||||||
|
|
||||||
|
/// A bucket is a collection of objects
|
||||||
|
///
|
||||||
|
/// Its parameters are not directly accessible as:
|
||||||
|
/// - It must be possible to merge paramaters, hence the use of a LWW CRDT.
|
||||||
|
/// - A bucket has 2 states, Present or Deleted and parameters make sense only if present.
|
||||||
#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)]
|
#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct Bucket {
|
pub struct Bucket {
|
||||||
// Primary key
|
// Primary key
|
||||||
|
@ -24,27 +29,49 @@ pub struct Bucket {
|
||||||
#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)]
|
#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)]
|
||||||
pub enum BucketState {
|
pub enum BucketState {
|
||||||
Deleted,
|
Deleted,
|
||||||
Present(crdt::LWWMap<String, PermissionSet>),
|
Present(BucketParams),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CRDT for BucketState {
|
impl CRDT for BucketState {
|
||||||
fn merge(&mut self, o: &Self) {
|
fn merge(&mut self, o: &Self) {
|
||||||
match o {
|
match o {
|
||||||
BucketState::Deleted => *self = BucketState::Deleted,
|
BucketState::Deleted => *self = BucketState::Deleted,
|
||||||
BucketState::Present(other_ak) => {
|
BucketState::Present(other_params) => {
|
||||||
if let BucketState::Present(ak) = self {
|
if let BucketState::Present(params) = self {
|
||||||
ak.merge(other_ak);
|
params.merge(other_params);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct BucketParams {
|
||||||
|
pub authorized_keys: crdt::LWWMap<String, PermissionSet>,
|
||||||
|
pub website: crdt::LWW<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CRDT for BucketParams {
|
||||||
|
fn merge(&mut self, o: &Self) {
|
||||||
|
self.authorized_keys.merge(&o.authorized_keys);
|
||||||
|
self.website.merge(&o.website);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BucketParams {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
BucketParams {
|
||||||
|
authorized_keys: crdt::LWWMap::new(),
|
||||||
|
website: crdt::LWW::new(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Bucket {
|
impl Bucket {
|
||||||
pub fn new(name: String) -> Self {
|
pub fn new(name: String) -> Self {
|
||||||
Bucket {
|
Bucket {
|
||||||
name,
|
name,
|
||||||
state: crdt::LWW::new(BucketState::Present(crdt::LWWMap::new())),
|
state: crdt::LWW::new(BucketState::Present(BucketParams::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn is_deleted(&self) -> bool {
|
pub fn is_deleted(&self) -> bool {
|
||||||
|
@ -53,7 +80,7 @@ impl Bucket {
|
||||||
pub fn authorized_keys(&self) -> &[(String, u64, PermissionSet)] {
|
pub fn authorized_keys(&self) -> &[(String, u64, PermissionSet)] {
|
||||||
match self.state.get() {
|
match self.state.get() {
|
||||||
BucketState::Deleted => &[],
|
BucketState::Deleted => &[],
|
||||||
BucketState::Present(ak) => ak.items(),
|
BucketState::Present(state) => state.authorized_keys.items(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -110,9 +137,15 @@ impl TableSchema for BucketTable {
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let params = BucketParams {
|
||||||
|
authorized_keys: keys,
|
||||||
|
website: crdt::LWW::new(false),
|
||||||
|
};
|
||||||
|
|
||||||
Some(Bucket {
|
Some(Bucket {
|
||||||
name: old.name,
|
name: old.name,
|
||||||
state: crdt::LWW::migrate_from_raw(old.timestamp, BucketState::Present(keys)),
|
state: crdt::LWW::migrate_from_raw(old.timestamp, BucketState::Present(params)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -239,7 +239,7 @@ where
|
||||||
///
|
///
|
||||||
/// Typically, to update the value associated to a key in the map, you would do the following:
|
/// Typically, to update the value associated to a key in the map, you would do the following:
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```ignore
|
||||||
/// let my_update = my_crdt.update_mutator(key_to_modify, new_value);
|
/// let my_update = my_crdt.update_mutator(key_to_modify, new_value);
|
||||||
/// my_crdt.merge(&my_update);
|
/// my_crdt.merge(&my_update);
|
||||||
/// ```
|
/// ```
|
||||||
|
@ -261,7 +261,7 @@ where
|
||||||
/// empty map. This is very usefull to produce in-place a new map that contains only a delta
|
/// empty map. This is very usefull to produce in-place a new map that contains only a delta
|
||||||
/// that modifies a certain value:
|
/// that modifies a certain value:
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```ignore
|
||||||
/// let mut a = get_my_crdt_value();
|
/// let mut a = get_my_crdt_value();
|
||||||
/// let old_a = a.take_and_clear();
|
/// let old_a = a.take_and_clear();
|
||||||
/// a.merge(&old_a.update_mutator(key_to_modify, new_value));
|
/// a.merge(&old_a.update_mutator(key_to_modify, new_value));
|
||||||
|
@ -273,7 +273,7 @@ where
|
||||||
/// but in the case where the map is a field in a struct for instance (as is always the case),
|
/// but in the case where the map is a field in a struct for instance (as is always the case),
|
||||||
/// this becomes very handy:
|
/// this becomes very handy:
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```ignore
|
||||||
/// let mut a = get_my_crdt_value();
|
/// let mut a = get_my_crdt_value();
|
||||||
/// let old_a_map = a.map_field.take_and_clear();
|
/// let old_a_map = a.map_field.take_and_clear();
|
||||||
/// a.map_field.merge(&old_a_map.update_mutator(key_to_modify, new_value));
|
/// a.map_field.merge(&old_a_map.update_mutator(key_to_modify, new_value));
|
||||||
|
|
|
@ -35,6 +35,8 @@ pub struct Config {
|
||||||
pub rpc_tls: Option<TlsConfig>,
|
pub rpc_tls: Option<TlsConfig>,
|
||||||
|
|
||||||
pub s3_api: ApiConfig,
|
pub s3_api: ApiConfig,
|
||||||
|
|
||||||
|
pub s3_web: WebConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug, Clone)]
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
|
@ -50,6 +52,13 @@ pub struct ApiConfig {
|
||||||
pub s3_region: String,
|
pub s3_region: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
|
pub struct WebConfig {
|
||||||
|
pub bind_addr: SocketAddr,
|
||||||
|
pub root_domain: String,
|
||||||
|
pub index: String,
|
||||||
|
}
|
||||||
|
|
||||||
fn default_max_concurrent_rpc_requests() -> usize {
|
fn default_max_concurrent_rpc_requests() -> usize {
|
||||||
12
|
12
|
||||||
}
|
}
|
||||||
|
|
28
src/web/Cargo.toml
Normal file
28
src/web/Cargo.toml
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
[package]
|
||||||
|
name = "garage_web"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = ["Alex Auvolat <alex@adnab.me>", "Quentin Dufour <quentin@dufour.io>"]
|
||||||
|
edition = "2018"
|
||||||
|
license = "GPL-3.0"
|
||||||
|
description = "S3-like website endpoint crate for the Garage object store"
|
||||||
|
repository = "https://git.deuxfleurs.fr/Deuxfleurs/garage"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "lib.rs"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
garage_util = { version = "0.1", path = "../util" }
|
||||||
|
garage_table = { version = "0.1.1", path = "../table" }
|
||||||
|
garage_model = { version = "0.1.1", path = "../model" }
|
||||||
|
garage_api = { version = "0.1.1", path = "../api" }
|
||||||
|
|
||||||
|
err-derive = "0.2.3"
|
||||||
|
log = "0.4"
|
||||||
|
futures = "0.3"
|
||||||
|
http = "0.2"
|
||||||
|
hyper = "0.13"
|
||||||
|
percent-encoding = "2.1.0"
|
||||||
|
roxmltree = "0.11"
|
||||||
|
idna = "0.2"
|
39
src/web/error.rs
Normal file
39
src/web/error.rs
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
use err_derive::Error;
|
||||||
|
use hyper::StatusCode;
|
||||||
|
|
||||||
|
use garage_util::error::Error as GarageError;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error(display = "API error: {}", _0)]
|
||||||
|
ApiError(#[error(source)] garage_api::error::Error),
|
||||||
|
|
||||||
|
// Category: internal error
|
||||||
|
#[error(display = "Internal error: {}", _0)]
|
||||||
|
InternalError(#[error(source)] GarageError),
|
||||||
|
|
||||||
|
#[error(display = "Not found")]
|
||||||
|
NotFound,
|
||||||
|
|
||||||
|
// Category: bad request
|
||||||
|
#[error(display = "Invalid UTF-8: {}", _0)]
|
||||||
|
InvalidUTF8(#[error(source)] std::str::Utf8Error),
|
||||||
|
|
||||||
|
#[error(display = "Invalid header value: {}", _0)]
|
||||||
|
InvalidHeader(#[error(source)] hyper::header::ToStrError),
|
||||||
|
|
||||||
|
#[error(display = "Bad request: {}", _0)]
|
||||||
|
BadRequest(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
pub fn http_status_code(&self) -> StatusCode {
|
||||||
|
match self {
|
||||||
|
Error::NotFound => StatusCode::NOT_FOUND,
|
||||||
|
Error::ApiError(e) => e.http_status_code(),
|
||||||
|
Error::InternalError(GarageError::RPC(_)) => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Error::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
_ => StatusCode::BAD_REQUEST,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
6
src/web/lib.rs
Normal file
6
src/web/lib.rs
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
#[macro_use]
|
||||||
|
extern crate log;
|
||||||
|
|
||||||
|
pub mod error;
|
||||||
|
|
||||||
|
pub mod web_server;
|
260
src/web/web_server.rs
Normal file
260
src/web/web_server.rs
Normal file
|
@ -0,0 +1,260 @@
|
||||||
|
use std::{borrow::Cow, convert::Infallible, net::SocketAddr, sync::Arc};
|
||||||
|
|
||||||
|
use futures::future::Future;
|
||||||
|
|
||||||
|
use hyper::{
|
||||||
|
header::HOST,
|
||||||
|
server::conn::AddrStream,
|
||||||
|
service::{make_service_fn, service_fn},
|
||||||
|
Body, Method, Request, Response, Server,
|
||||||
|
};
|
||||||
|
|
||||||
|
use idna::domain_to_unicode;
|
||||||
|
|
||||||
|
use crate::error::*;
|
||||||
|
use garage_api::s3_get::{handle_get, handle_head};
|
||||||
|
use garage_model::bucket_table::*;
|
||||||
|
use garage_model::garage::Garage;
|
||||||
|
use garage_table::*;
|
||||||
|
use garage_util::error::Error as GarageError;
|
||||||
|
|
||||||
|
pub async fn run_web_server(
|
||||||
|
garage: Arc<Garage>,
|
||||||
|
shutdown_signal: impl Future<Output = ()>,
|
||||||
|
) -> Result<(), GarageError> {
|
||||||
|
let addr = &garage.config.s3_web.bind_addr;
|
||||||
|
|
||||||
|
let service = make_service_fn(|conn: &AddrStream| {
|
||||||
|
let garage = garage.clone();
|
||||||
|
let client_addr = conn.remote_addr();
|
||||||
|
async move {
|
||||||
|
Ok::<_, Error>(service_fn(move |req: Request<Body>| {
|
||||||
|
let garage = garage.clone();
|
||||||
|
handle_request(garage, req, client_addr)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let server = Server::bind(&addr).serve(service);
|
||||||
|
let graceful = server.with_graceful_shutdown(shutdown_signal);
|
||||||
|
info!("Web server listening on http://{}", addr);
|
||||||
|
|
||||||
|
graceful.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_request(
|
||||||
|
garage: Arc<Garage>,
|
||||||
|
req: Request<Body>,
|
||||||
|
addr: SocketAddr,
|
||||||
|
) -> Result<Response<Body>, Infallible> {
|
||||||
|
info!("{} {} {}", addr, req.method(), req.uri());
|
||||||
|
let res = serve_file(garage, req).await;
|
||||||
|
match &res {
|
||||||
|
Ok(r) => debug!("{} {:?}", r.status(), r.headers()),
|
||||||
|
Err(e) => warn!("Response: error {}, {}", e.http_status_code(), e),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(res.unwrap_or_else(error_to_res))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_to_res(e: Error) -> Response<Body> {
|
||||||
|
let body: Body = Body::from(format!("{}\n", e));
|
||||||
|
let mut http_error = Response::new(body);
|
||||||
|
*http_error.status_mut() = e.http_status_code();
|
||||||
|
http_error
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_file(garage: Arc<Garage>, req: Request<Body>) -> Result<Response<Body>, Error> {
|
||||||
|
// Get http authority string (eg. [::1]:3902 or garage.tld:80)
|
||||||
|
let authority = req
|
||||||
|
.headers()
|
||||||
|
.get(HOST)
|
||||||
|
.ok_or(Error::BadRequest(format!("HOST header required")))?
|
||||||
|
.to_str()?;
|
||||||
|
|
||||||
|
// Get bucket
|
||||||
|
let (host, _) = domain_to_unicode(authority_to_host(authority)?);
|
||||||
|
let root = &garage.config.s3_web.root_domain;
|
||||||
|
let bucket = host_to_bucket(&host, root);
|
||||||
|
|
||||||
|
// Check bucket is exposed as a website
|
||||||
|
let bucket_desc = garage
|
||||||
|
.bucket_table
|
||||||
|
.get(&EmptyKey, &bucket.to_string())
|
||||||
|
.await?
|
||||||
|
.filter(|b| !b.is_deleted())
|
||||||
|
.ok_or(Error::NotFound)?;
|
||||||
|
|
||||||
|
match bucket_desc.state.get() {
|
||||||
|
BucketState::Present(params) if *params.website.get() => Ok(()),
|
||||||
|
_ => Err(Error::NotFound),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
// Get path
|
||||||
|
let path = req.uri().path().to_string();
|
||||||
|
let index = &garage.config.s3_web.index;
|
||||||
|
let key = path_to_key(&path, &index)?;
|
||||||
|
|
||||||
|
info!("Selected bucket: \"{}\", selected key: \"{}\"", bucket, key);
|
||||||
|
|
||||||
|
let res = match req.method() {
|
||||||
|
&Method::HEAD => handle_head(garage, &bucket, &key).await?,
|
||||||
|
&Method::GET => handle_get(garage, &req, bucket, &key).await?,
|
||||||
|
_ => return Err(Error::BadRequest(format!("HTTP method not supported"))),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract host from the authority section given by the HTTP host header
|
||||||
|
///
|
||||||
|
/// The HTTP host contains both a host and a port.
|
||||||
|
/// Extracting the port is more complex than just finding the colon (:) symbol due to IPv6
|
||||||
|
/// We do not use the collect pattern as there is no way in std rust to collect over a stack allocated value
|
||||||
|
/// check here: https://docs.rs/collect_slice/1.2.0/collect_slice/
|
||||||
|
fn authority_to_host(authority: &str) -> Result<&str, Error> {
|
||||||
|
let mut iter = authority.chars().enumerate();
|
||||||
|
let (_, first_char) = iter
|
||||||
|
.next()
|
||||||
|
.ok_or(Error::BadRequest(format!("Authority is empty")))?;
|
||||||
|
|
||||||
|
let split = match first_char {
|
||||||
|
'[' => {
|
||||||
|
let mut iter = iter.skip_while(|(_, c)| c != &']');
|
||||||
|
match iter.next() {
|
||||||
|
Some((_, ']')) => iter.next(),
|
||||||
|
_ => {
|
||||||
|
return Err(Error::BadRequest(format!(
|
||||||
|
"Authority {} has an illegal format",
|
||||||
|
authority
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => iter.skip_while(|(_, c)| c != &':').next(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match split {
|
||||||
|
Some((i, ':')) => Ok(&authority[..i]),
|
||||||
|
None => Ok(authority),
|
||||||
|
Some((_, _)) => Err(Error::BadRequest(format!(
|
||||||
|
"Authority {} has an illegal format",
|
||||||
|
authority
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host to bucket
|
||||||
|
///
|
||||||
|
/// Convert a host, like "bucket.garage-site.tld" or "john.doe.com"
|
||||||
|
/// to the corresponding bucket, resp. "bucket" and "john.doe.com"
|
||||||
|
/// considering that ".garage-site.tld" is the "root domain".
|
||||||
|
/// This behavior has been chosen to follow AWS S3 semantic.
|
||||||
|
fn host_to_bucket<'a>(host: &'a str, root: &str) -> &'a str {
|
||||||
|
if root.len() >= host.len() || !host.ends_with(root) {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
let len_diff = host.len() - root.len();
|
||||||
|
let missing_starting_dot = root.chars().next() != Some('.');
|
||||||
|
let cursor = if missing_starting_dot {
|
||||||
|
len_diff - 1
|
||||||
|
} else {
|
||||||
|
len_diff
|
||||||
|
};
|
||||||
|
&host[..cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path to key
|
||||||
|
///
|
||||||
|
/// Convert the provided path to the internal key
|
||||||
|
/// When a path ends with "/", we append the index name to match traditional web server behavior
|
||||||
|
/// which is also AWS S3 behavior.
|
||||||
|
fn path_to_key<'a>(path: &'a str, index: &str) -> Result<Cow<'a, str>, Error> {
|
||||||
|
let path_utf8 = percent_encoding::percent_decode_str(&path).decode_utf8()?;
|
||||||
|
|
||||||
|
if path_utf8.chars().next() != Some('/') {
|
||||||
|
return Err(Error::BadRequest(format!(
|
||||||
|
"Path must start with a / (slash)"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
match path_utf8.chars().last() {
|
||||||
|
None => unreachable!(),
|
||||||
|
Some('/') => {
|
||||||
|
let mut key = String::with_capacity(path_utf8.len() + index.len());
|
||||||
|
key.push_str(&path_utf8[1..]);
|
||||||
|
key.push_str(index);
|
||||||
|
Ok(key.into())
|
||||||
|
}
|
||||||
|
Some(_) => match path_utf8 {
|
||||||
|
Cow::Borrowed(pu8) => Ok((&pu8[1..]).into()),
|
||||||
|
Cow::Owned(pu8) => Ok((&pu8[1..]).to_string().into()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authority_to_host_with_port() -> Result<(), Error> {
|
||||||
|
let domain = authority_to_host("[::1]:3902")?;
|
||||||
|
assert_eq!(domain, "[::1]");
|
||||||
|
let domain2 = authority_to_host("garage.tld:65200")?;
|
||||||
|
assert_eq!(domain2, "garage.tld");
|
||||||
|
let domain3 = authority_to_host("127.0.0.1:80")?;
|
||||||
|
assert_eq!(domain3, "127.0.0.1");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authority_to_host_without_port() -> Result<(), Error> {
|
||||||
|
let domain = authority_to_host("[::1]")?;
|
||||||
|
assert_eq!(domain, "[::1]");
|
||||||
|
let domain2 = authority_to_host("garage.tld")?;
|
||||||
|
assert_eq!(domain2, "garage.tld");
|
||||||
|
let domain3 = authority_to_host("127.0.0.1")?;
|
||||||
|
assert_eq!(domain3, "127.0.0.1");
|
||||||
|
assert!(authority_to_host("[").is_err());
|
||||||
|
assert!(authority_to_host("[hello").is_err());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_to_bucket_test() {
|
||||||
|
assert_eq!(
|
||||||
|
host_to_bucket("john.doe.garage.tld", ".garage.tld"),
|
||||||
|
"john.doe"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
host_to_bucket("john.doe.garage.tld", "garage.tld"),
|
||||||
|
"john.doe"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(host_to_bucket("john.doe.com", "garage.tld"), "john.doe.com");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
host_to_bucket("john.doe.com", ".garage.tld"),
|
||||||
|
"john.doe.com"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(host_to_bucket("garage.tld", "garage.tld"), "garage.tld");
|
||||||
|
|
||||||
|
assert_eq!(host_to_bucket("garage.tld", ".garage.tld"), "garage.tld");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_to_key_test() -> Result<(), Error> {
|
||||||
|
assert_eq!(path_to_key("/file%20.jpg", "index.html")?, "file .jpg");
|
||||||
|
assert_eq!(path_to_key("/%20t/", "index.html")?, " t/index.html");
|
||||||
|
assert_eq!(path_to_key("/", "index.html")?, "index.html");
|
||||||
|
assert_eq!(path_to_key("/hello", "index.html")?, "hello");
|
||||||
|
assert!(path_to_key("", "index.html").is_err());
|
||||||
|
assert!(path_to_key("i/am/relative", "index.html").is_err());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue