diff --git a/.gitignore b/.gitignore index 088ba6b..ac2f250 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk + +# Test data created may accidentally be included +/tests/test_data/test_repos/repo_remove_me +/tests/test_data/test_repos/repo_remove_me_2 +/tmp_test_data \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f6553ba..4c6d001 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,14 @@ All notable changes to this project will be documented in this file. - Nightly builds, exclude arm64 darwin build until issue Publishing aarch64-apple-darwin failed #6 is fixed +- Update rust crate toml to 0.8 +- Deserialization with newest toml +- Clippy ### Documentation - Update readme, fix manifest -- Add continous deployment badge to readme +- Add continuous deployment badge to readme - Fix typo in html element - Add link to nightly - Add link to nightly downloads in documentation @@ -41,15 +44,26 @@ All notable changes to this project will be documented in this file. - Put action version to follow main branch while action is still in development - Switch ci to rustic-rs/create-binary-artifact action - Switch rest of ci to rustic-rs/create-binary-artifact action +- Change license +- Fix workflow name for create-binary-artifact action, and check breaking + changes package dependent +- Decrease build times on windows +- Fix github refs +- Set right package +- Use bash substring comparison to determine package name from branch +- Fix woggly github action comparison +- Add changelog generation +- Initialize cargo release, update changelog +- Add dev tooling +- Run git-cliff with latest tag during release +- Remove comment from cargo manifest +- Change workflow extensions to yml +- Add triaging of issues +- Run release checks also on release subbranches +- Add maskfile ### Refactor - Refactor to library and server binary -- Begin refactor to axum - -### Styling - -- Fmt -- Dprint fmt diff --git a/Cargo.toml b/Cargo.toml index 9f6e413..41b122e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustic_server" -version = "0.1.0" +version = "0.1.1-dev" authors = ["Alexander Weiss "] categories = ["command-line-utilities"] edition = "2021" @@ -11,26 +11,91 @@ repository = "https://github.com/rustic-rs/rustic_server" description = """ rustic server - a REST server built in rust to use with rustic and restic. """ +# cargo-binstall support +# https://github.com/cargo-bins/cargo-binstall/blob/HEAD/SUPPORT.md +[package.metadata.binstall] +pkg-url = "{ repo }/releases/download/v{ version }/{ repo }-v{ version }-{ target }{ archive-suffix }" +bin-dir = "{ bin }-{ target }/{ bin }{ binary-ext }" +pkg-fmt = "tar.gz" + +[package.metadata.binstall.signing] +algorithm = "minisign" +pubkey = "RWSWSCEJEEacVeCy0va71hlrVtiW8YzMzOyJeso0Bfy/ZXq5OryWi/8T" [dependencies] anyhow = "1.0.75" async-trait = "0.1" +# FIXME: Add "headers" feature to Axum? axum = { version = "0.7.3", features = ["tracing", "multipart", "http2"] } axum-auth = "0.7.0" -axum-extra = { version = "0.9.1", features = ["typed-header", "query", "async-read-body"] } +axum-extra = { version = "0.9.1", features = ["typed-header", "query", "async-read-body", "typed-routing"] } axum-macros = "0.4.0" +axum-range = "0.4" axum-server = { version = "0.6.0", features = ["tls-rustls"] } -clap = { version = "4.4.2", features = ["derive"] } -enum_dispatch = "0.3.12" -futures-util = "0.3.28" +clap = { version = "4.4", features = ["derive"] } +# enum_dispatch = "0.3.12" +futures = "0.3" +futures-util = "0.3" htpasswd-verify = "0.3" +http-body-util = "0.1.0" http-range = "0.1" -serde = "1" +once_cell = "1.17" +pin-project = "1.1" +rand = "0.8.5" +serde = { version = "1", default-features = false, features = ["derive"] } serde_derive = "1" thiserror = "1.0.48" tokio = { version = "1", features = ["full"] } -tokio-util = { version = "0.7.8", features = ["io-util"] } +tokio-util = { version = "0.7.8", features = ["io", "io-util"] } toml = "0.8.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } walkdir = "2" + +# see: https://nnethercote.github.io/perf-book/build-configuration.html +[profile.dev] +opt-level = 0 +debug = true +rpath = false +lto = false +debug-assertions = true +codegen-units = 4 + +# compile dependencies with optimizations in dev mode +# see: https://doc.rust-lang.org/stable/cargo/reference/profiles.html#overrides +[profile.dev.package."*"] +opt-level = 3 +debug = true + +[profile.release] +opt-level = 3 +debug = false # true for profiling +rpath = false +lto = "fat" +debug-assertions = false +codegen-units = 1 +strip = true +panic = "abort" + +[profile.test] +opt-level = 1 +debug = true +rpath = false +lto = false +debug-assertions = true +codegen-units = 4 + +[profile.bench] +opt-level = 3 +debug = true # true for profiling +rpath = false +lto = true +debug-assertions = false +codegen-units = 1 + +[dev-dependencies] +base64 = "0.21.2" +# reqwest = "0.11.18" +# serial_test = "*" +serde_json = "*" +tower = "*" diff --git a/README.md b/README.md index 67028fe..4cb3c36 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- +

REST server for rustic

@@ -30,6 +30,12 @@ Most features are already implemented. | Discord | [![Discord](https://dcbadge.vercel.app/api/server/WRUWENZnzQ)](https://discord.gg/WRUWENZnzQ) | | Discussions | [GitHub Discussions](https://github.com/rustic-rs/rustic/discussions) | +## Dependencies + +Is built using [tide](https://github.com/http-rs/tide), +[tide-rustls](https://github.com/http-rs/tide-rustls) and +[tide-http-auth](https://github.com/chrisdickinson/tide-http-auth). + ## Are binaries available? Yes, you can find them [here](https://rustic.cli.rs/docs/nightly_builds.html). @@ -52,6 +58,22 @@ alex = "Modify" bob = "Append" ``` +## Contributing + +Tried rustic-server and not satisfied? Don't just walk away! You can help: + +- You can report issues or suggest new features on our + [Discord server](https://discord.gg/WRUWENZnzQ) or using + [Github Issues](https://github.com/rustic-rs/rustic_server/issues/new/choose)! + +Do you know how to code or got an idea for an improvement? Don't keep it to +yourself! + +- Contribute fixes or new features via a pull requests! + +Please make sure, that you read the +[contribution guide](https://rustic.cli.rs/docs/contributing-to-rustic.html). + # License `rustic-server` is open-sourced software licensed under the diff --git a/cliff.toml b/cliff.toml index a0ae124..4196a0c 100644 --- a/cliff.toml +++ b/cliff.toml @@ -54,7 +54,7 @@ commit_parsers = [ { message = "^doc", group = "Documentation" }, { message = "^perf", group = "Performance" }, { message = "^refactor", group = "Refactor" }, - { message = "^style", group = "Styling" }, + { message = "^style", group = "Styling", skip = true }, # we ignore styling in the changelog { message = "^test", group = "Testing" }, { message = "^chore\\(release\\): prepare for", skip = true }, { message = "^chore\\(deps\\)", skip = true }, @@ -69,7 +69,7 @@ protect_breaking_commits = false # filter out the commits that are not matched by commit parsers filter_commits = false # glob pattern for matching git tags -tag_pattern = "v[0-9]*" +tag_pattern = "[0-9]*" # regex for skipping tags skip_tags = "v0.1.0-beta.1" # regex for ignoring tags diff --git a/config/README.md b/config/README.md new file mode 100644 index 0000000..f8648b2 --- /dev/null +++ b/config/README.md @@ -0,0 +1,76 @@ +# `rustic_server` configuration + +This folder contains a few configuration files as an example. + +`rustic_server` has a few configuration files: + +- access control list (acl.toml) +- server configuration (rustic_server.toml) +- basic http credential authentication (.htaccess) + +See also the rustic configuration, described in: +https://github.com/rustic-rs/rustic/tree/main/config + +## `acl.toml` + +This file may have any name, but requires valid toml formatting. + +Format: + +``` +[] + +... more users + +... more repositories +``` + +The `access_type` can have values: + +- "Read" --> allows read only access +- "Append" --> allows addition of new files, including initializing a new repo +- "Modify" --> allows write-access, including delete of a repo + +Todo: Describe "default" tag in the file. + +## `rustic_server.toml` + +This file may have any name, but requires valid toml formatting. + +File format: + +``` +[server] +host_dns_name = | +port = + +[repos] +storage_path = + +[authorization] +auth_path = +use_auth = + +[accesscontrol] +acl_path = +private_repo = +append_only = +``` + +On top of some additional configuration items, the content of this file points +to the `acl.toml`, and `.htaccess` files. + +## `.htaccess` + +This is a vanilla `Apache` `.htacces` file. + +# Configure `rustic_server` from the command line + +It is also possible to configure the server from the command line, and skip the +configuration file. + +To see all options, use: + +``` +rustic_server --help +``` diff --git a/config/rustic_config.toml b/config/rustic_config.toml new file mode 100644 index 0000000..4aa9469 --- /dev/null +++ b/config/rustic_config.toml @@ -0,0 +1,14 @@ +# Adapt to your own configuration +[global] +log-level = "Info" +log-file = "~/rustic.log" + +[repository] +repository = "rest:http://test:test_pw@localhost:8000/test_repo" +password = "test_pw" + +[backup] + +[[backup.sources]] +source = "~/your_path/rustic_server/test_data" +# git-ignore = true diff --git a/config/rustic_server.toml b/config/rustic_server.toml new file mode 100644 index 0000000..ef5cc66 --- /dev/null +++ b/config/rustic_server.toml @@ -0,0 +1,15 @@ +[server] +host_dns_name = "127.0.0.1" +port = 8000 + +[repos] +storage_path = "./test_data/test_repos/" + +[authorization] +auth_path = "/test_data/test_repo/htaccess" +use_auth = true + +[accesscontrol] +acl_path = "/test_data/test_repo/acl.toml" +private_repo = true +append_only = false diff --git a/src/acl.rs b/src/acl.rs index 2473096..9111bf9 100644 --- a/src/acl.rs +++ b/src/acl.rs @@ -1,11 +1,23 @@ -use anyhow::Result; -use enum_dispatch::enum_dispatch; +use crate::handlers::path_analysis::TPE_LOCKS; +use anyhow::{Context, Result}; +use once_cell::sync::OnceCell; +use serde_derive::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::PathBuf; +//Static storage of our credentials +pub static ACL: OnceCell = OnceCell::new(); + +pub fn init_acl(state: Acl) -> Result<()> { + if ACL.get().is_none() { + ACL.set(state).unwrap() + } + Ok(()) +} + // Access Types -#[derive(Debug, Clone, PartialEq, PartialOrd, serde_derive::Deserialize)] +#[derive(Debug, Clone, PartialEq, PartialOrd, Serialize, Deserialize)] pub enum AccessType { Nothing, Read, @@ -13,22 +25,15 @@ pub enum AccessType { Modify, } -#[derive(Debug, Clone)] -#[enum_dispatch] -pub(crate) enum AclCheckerEnum { - Acl(Acl), -} - -#[enum_dispatch(AclCheckerEnum)] pub trait AclChecker: Send + Sync + 'static { fn allowed(&self, user: &str, path: &str, tpe: &str, access: AccessType) -> bool; } // ACL for a repo -type RepoAcl = HashMap<&'static str, AccessType>; +type RepoAcl = HashMap; // Acl holds ACLs for all repos -#[derive(Debug, Clone)] +#[derive(Clone, Serialize, Deserialize, Debug)] pub struct Acl { repos: HashMap, append_only: bool, @@ -39,8 +44,8 @@ impl Default for Acl { fn default() -> Self { Self { repos: HashMap::new(), - append_only: false, - private_repo: false, + append_only: true, + private_repo: true, } } } @@ -77,13 +82,44 @@ impl Acl { repos, }) } + + // The default repo has not been removed from the self.repos list, so we do not need to add here + // But we still need to remove the ""-tag that was added during the from_file() + pub fn to_file(&self, pth: &PathBuf) -> Result<()> { + let mut clone = self.repos.clone(); + clone.remove(""); + let toml_string = + toml::to_string(&clone).context("Could not serialize ACL config to TOML value")?; + fs::write(pth, toml_string).context("Could not write ACL file!")?; + Ok(()) + } + + // If we do not have a key with ""-value then "default" is also not a key + // Since we guarantee this during the reading of a acl-file + pub fn default_repo_access(&mut self, user: &str, access: AccessType) { + if !self.repos.contains_key("") { + let mut acl = RepoAcl::new(); + acl.insert(user.into(), access); + self.repos.insert("default".to_owned(), acl.clone()); + self.repos.insert("".to_owned(), acl); + } else { + self.repos + .get_mut("default") + .unwrap() + .insert(user.into(), access.clone()); + self.repos + .get_mut("") + .unwrap() + .insert(user.into(), access.clone()); + } + } } impl AclChecker for Acl { // allowed yields whether these access to {path,tpe, access} is allowed by user fn allowed(&self, user: &str, path: &str, tpe: &str, access: AccessType) -> bool { // Access to locks is always treated as Read - let access = if tpe == "locks" { + let access = if tpe == TPE_LOCKS { AccessType::Read } else { access @@ -108,6 +144,30 @@ impl AclChecker for Acl { mod tests { use super::AccessType::*; use super::*; + use std::env; + + #[test] + fn test_static_acl_access() { + let cwd = env::current_dir().unwrap(); + let acl = PathBuf::new() + .join(cwd) + .join("tests") + .join("fixtures") + .join("test_data") + .join("acl.toml"); + + dbg!(&acl); + + let auth = Acl::from_file(false, true, Some(acl)).unwrap(); + init_acl(auth).unwrap(); + + let acl = ACL.get().unwrap(); + assert!(&acl.private_repo); + assert!(!&acl.append_only); + let access = acl.repos.get("test_repo").unwrap(); + let access_type = access.get("test").unwrap(); + assert_eq!(access_type, &Append); + } #[test] fn allowed_flags() { @@ -139,26 +199,22 @@ mod tests { #[test] fn repo_acl() { - let mut acl = Acl { - repos: HashMap::new(), - append_only: true, - private_repo: true, - }; + let mut acl = Acl::default(); let mut acl_all = HashMap::new(); - acl_all.insert("bob", Modify); - acl_all.insert("sam", Append); - acl_all.insert("paul", Read); - acl.repos.insert("all".to_owned(), acl_all); + acl_all.insert("bob".to_string(), Modify); + acl_all.insert("sam".to_string(), Append); + acl_all.insert("paul".to_string(), Read); + acl.repos.insert("all".to_string(), acl_all); let mut acl_bob = HashMap::new(); - acl_bob.insert("bob", Modify); - acl.repos.insert("bob".to_owned(), acl_bob); + acl_bob.insert("bob".to_string(), Modify); + acl.repos.insert("bob".to_string(), acl_bob); let mut acl_sam = HashMap::new(); - acl_sam.insert("sam", Append); - acl_sam.insert("bob", Read); - acl.repos.insert("sam".to_owned(), acl_sam); + acl_sam.insert("sam".to_string(), Append); + acl_sam.insert("bob".to_string(), Read); + acl.repos.insert("sam".to_string(), acl_sam); // test ACLs for repo all assert!(acl.allowed("bob", "all", "keys", Modify)); diff --git a/src/auth.rs b/src/auth.rs index b47e09a..58b5805 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,21 +1,30 @@ -use enum_dispatch::enum_dispatch; +use crate::error::ErrorKind; +use anyhow::Result; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use axum_auth::AuthBasic; +use once_cell::sync::OnceCell; +use serde_derive::Deserialize; use std::collections::HashMap; use std::path::PathBuf; use std::{fs, io}; -#[enum_dispatch] -#[derive(Debug, Clone)] -pub(crate) enum AuthCheckerEnum { - Auth(Auth), +//Static storage of our credentials +pub static AUTH: OnceCell = OnceCell::new(); + +pub(crate) fn init_auth(state: Auth) -> Result<()> { + if AUTH.get().is_none() { + AUTH.set(state).unwrap() + } + Ok(()) } -#[enum_dispatch(AuthCheckerEnum)] pub trait AuthChecker: Send + Sync + 'static { fn verify(&self, user: &str, passwd: &str) -> bool; } -// read_htpasswd is a helper func that reads the given file in .httpasswd format -// into a Hashmap mapping each user to the whole passwd line +/// read_htpasswd is a helper func that reads the given file in .httpasswd format +/// into a Hashmap mapping each user to the whole passwd line fn read_htpasswd(file_path: &PathBuf) -> io::Result> { let s = fs::read_to_string(file_path)?; // make the contents static in memory @@ -29,17 +38,11 @@ fn read_htpasswd(file_path: &PathBuf) -> io::Result>, } -impl Default for Auth { - fn default() -> Self { - Self { users: None } - } -} - impl Auth { pub fn from_file(no_auth: bool, path: &PathBuf) -> io::Result { Ok(Self { @@ -63,3 +66,203 @@ impl AuthChecker for Auth { } } } + +#[derive(Deserialize)] +pub struct AuthFromRequest { + pub(crate) user: String, + pub(crate) _password: String, +} + +#[async_trait::async_trait] +impl FromRequestParts for AuthFromRequest { + type Rejection = ErrorKind; + + // FIXME: We also have a configuration flag do run without authentication + // This must be handled here too ... otherwise we get an Auth header missing error. + async fn from_request_parts( + parts: &mut Parts, + state: &S, + ) -> std::result::Result { + let checker = AUTH.get().unwrap(); + let auth_result = AuthBasic::from_request_parts(parts, state).await; + tracing::debug!("Got authentication result ...:{:?}", &auth_result); + return match auth_result { + Ok(auth) => { + let AuthBasic((user, passw)) = auth; + let password = passw.unwrap_or_else(|| "".to_string()); + if checker.verify(user.as_str(), password.as_str()) { + Ok(Self { + user, + _password: password, + }) + } else { + Err(ErrorKind::UserAuthenticationError(user)) + } + } + Err(_) => { + let user = "".to_string(); + if checker.verify("", "") { + return Ok(Self { + user, + _password: "".to_string(), + }); + } + Err(ErrorKind::AuthenticationHeaderError) + } + }; + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::auth::Auth; + use crate::test_helpers::{basic_auth_header_value, init_test_environment}; + use anyhow::Result; + use axum::body::Body; + use axum::http::{Method, Request, StatusCode}; + use axum::routing::get; + use axum::Router; + use http_body_util::BodyExt; + use std::env; + use std::path::PathBuf; + use tower::ServiceExt; + + #[test] + fn test_auth() -> Result<()> { + let cwd = env::current_dir()?; + let htaccess = PathBuf::new() + .join(cwd) + .join("tests") + .join("fixtures") + .join("test_data") + .join("htaccess"); + let auth = Auth::from_file(false, &htaccess)?; + assert!(auth.verify("test", "test_pw")); + assert!(!auth.verify("test", "__test_pw")); + + Ok(()) + } + + #[test] + fn test_auth_from_file() { + let cwd = env::current_dir().unwrap(); + let htaccess = PathBuf::new() + .join(cwd) + .join("tests") + .join("fixtures") + .join("test_data") + .join("htaccess"); + + dbg!(&htaccess); + + let auth = Auth::from_file(false, &htaccess).unwrap(); + init_auth(auth).unwrap(); + + let auth = AUTH.get().unwrap(); + assert!(auth.verify("test", "test_pw")); + assert!(!auth.verify("test", "__test_pw")); + } + + async fn test_handler_basic(AuthBasic((id, password)): AuthBasic) -> String { + format!("Got {} and {:?}", id, password) + } + + async fn test_handler_from_request(auth: AuthFromRequest) -> String { + format!("User = {}", auth.user) + } + + /// The requests which should be returned OK + #[tokio::test] + async fn test_authentication() { + init_test_environment(); + + // ----------------------------------------- + // Try good basic + // ----------------------------------------- + let app = Router::new().route("/basic", get(test_handler_basic)); + + let request = Request::builder() + .uri("/basic") + .method(Method::GET) + .header( + "Authorization", + basic_auth_header_value("My Username", Some("My Password")), + ) + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status().as_u16(), StatusCode::OK.as_u16()); + let body = resp.into_parts().1; + let byte_vec = body.into_data_stream().collect().await.unwrap().to_bytes(); + let body_str = String::from_utf8(byte_vec.to_vec()).unwrap(); + assert_eq!( + body_str, + String::from("Got My Username and Some(\"My Password\")") + ); + + // ----------------------------------------- + // Try good using auth struct + // ----------------------------------------- + let app = Router::new().route("/rustic_server", get(test_handler_from_request)); + + let request = Request::builder() + .uri("/rustic_server") + .method(Method::GET) + .header( + "Authorization", + basic_auth_header_value("test", Some("test_pw")), + ) + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status().as_u16(), StatusCode::OK.as_u16()); + let body = resp.into_parts().1; + let byte_vec = body.collect().await.unwrap().to_bytes(); + let body_str = String::from_utf8(byte_vec.to_vec()).unwrap(); + assert_eq!(body_str, String::from("User = test")); + } + + #[tokio::test] + async fn test_authentication_errors() { + init_test_environment(); + + // ----------------------------------------- + // Try wrong password rustic_server + // ----------------------------------------- + let app = Router::new().route("/rustic_server", get(test_handler_from_request)); + + let request = Request::builder() + .uri("/rustic_server") + .method(Method::GET) + .header( + "Authorization", + basic_auth_header_value("test", Some("__test_pw")), + ) + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + // ----------------------------------------- + // Try without authentication header + // ----------------------------------------- + let app = Router::new().route("/rustic_server", get(test_handler_from_request)); + + let request = Request::builder() + .uri("/rustic_server") + .method(Method::GET) + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status().as_u16(), StatusCode::FORBIDDEN); + } +} diff --git a/src/bin/rustic-server.rs b/src/bin/rustic-server.rs index e90bcd8..850811e 100644 --- a/src/bin/rustic-server.rs +++ b/src/bin/rustic-server.rs @@ -1,75 +1,51 @@ -use clap::Parser; -use rustic_server::{ - acl::Acl, - auth::Auth, - log, - storage::LocalStorage, - web, - web::{AppState, Ports}, - Opts, -}; +use anyhow::Result; +use clap::{Parser, Subcommand}; +use rustic_server::commands::serve::{serve, Opts}; #[tokio::main] -async fn main() -> anyhow::Result<()> { - log::init_tracing(); - - let opts = Opts::parse(); - - let ports = Ports { - http: opts.http_port, - https: opts.https_port, - }; - - // spawn a second server to redirect http requests to this server - match (opt.cert, opt.key) { - (Some(cert), Some(key)) => { - tracing::info!("TLS is enabled, http requests will be redirected to https"); - tokio::spawn(redirect_http_to_https(ports)); - } - _ => { - tracing::warn!("TLS is not enabled, http requests will not be redirected to https"); - } - } - - let storage = LocalStorage::try_new(&opts.path)?; - let auth = Auth::from_file(opts.no_auth, &opts.path.join(".htpasswd"))?; - let acl = Acl::from_file(opts.append_only, opts.private_repo, opts.acl)?; - - let new_state = AppState::new(auth, acl, storage); - - web::main(new_state, opts.listen, ports, opts.tls, opts.cert, opts.key).await +async fn main() -> Result<()> { + let cmd = RusticServer::parse(); + cmd.exec().await?; + Ok(()) } -async fn redirect_http_to_https(ports: Ports) { - fn make_https(host: String, uri: Uri, ports: Ports) -> Result { - let mut parts = uri.into_parts(); - - parts.scheme = Some(axum::http::uri::Scheme::HTTPS); - - if parts.path_and_query.is_none() { - parts.path_and_query = Some("/".parse().unwrap()); - } - - let https_host = host.replace(&ports.http.to_string(), &ports.https.to_string()); - parts.authority = Some(https_host.parse()?); +/// rustic_server +/// A REST server built in rust for use with rustic and restic. +#[derive(Parser)] +#[command(version, bin_name = "rustic_server", disable_help_subcommand = false)] +struct RusticServer { + #[command(subcommand)] + command: Commands, +} - Ok(Uri::from_parts(parts)?) - } +#[derive(Subcommand)] +enum Commands { + /// Start the REST web-server. + Serve(Opts), + // Modify credentials in the .htaccess file. + //Auth(HtAccessCmd), + // Create a configuration from scratch. + //Config, +} - let redirect = move |Host(host): Host, uri: Uri| async move { - match make_https(host, uri, ports) { - Ok(uri) => Ok(Redirect::permanent(&uri.to_string())), - Err(error) => { - tracing::warn!(%error, "failed to convert URI to HTTPS"); - Err(StatusCode::BAD_REQUEST) +/// The server configuration file should point us to the `.htaccess` file. +/// If not we complain to the user. +/// +/// To be nice, if the `.htaccess` file pointed to does not exist, then we create it. +/// We do so, even if it is not called `.htaccess`. +impl RusticServer { + pub async fn exec(self) -> Result<()> { + match self.command { + // Commands::Auth(cmd) => { + // cmd.exec()?; + // } + // Commands::Config => { + // rustic_server_configuration()?; + // } + Commands::Serve(opts) => { + serve(opts).await.unwrap(); } } - }; - - let addr = SocketAddr::from(([127, 0, 0, 1], ports.http)); - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - tracing::debug!("listening on {}", listener.local_addr().unwrap()); - axum::serve(listener, redirect.into_make_service()) - .await - .unwrap(); + Ok(()) + } } diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..ac2b204 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1 @@ +pub mod serve; diff --git a/src/commands/serve.rs b/src/commands/serve.rs new file mode 100644 index 0000000..451486e --- /dev/null +++ b/src/commands/serve.rs @@ -0,0 +1,114 @@ +use crate::acl::Acl; +use crate::auth::Auth; +use crate::config::server_config::ServerConfig; +use crate::log::{init_trace_from, init_tracing}; +use crate::storage::LocalStorage; +use crate::web::start_web_server; +use anyhow::Result; +use clap::Parser; +use std::net::{SocketAddr, ToSocketAddrs}; +use std::path::PathBuf; +use std::str::FromStr; + +// FIXME: we should not return crate::error::Result here; maybe anyhow::Result, + +pub async fn serve(opts: Opts) -> Result<()> { + match &opts.config { + Some(config) => { + let config_path = PathBuf::new().join(config); + let server_config = + ServerConfig::from_file(&config_path).unwrap_or_else(|e| panic!("{}", e)); + + if let Some(level) = server_config.log_level { + init_trace_from(&level); + } else { + init_tracing(); + } + + // Repository storage + let storage_path = PathBuf::new().join(server_config.repos.storage_path); + let storage = LocalStorage::try_new(&storage_path)?; + + // Authorization user/password + let auth_config = server_config.authorization; + let no_auth = !auth_config.use_auth; + let path = match auth_config.auth_path { + None => PathBuf::new(), + Some(p) => PathBuf::new().join(p), + }; + let auth = Auth::from_file(no_auth, &path)?; + + // Access control to the repositories + let acl_config = server_config.accesscontrol; + let path = acl_config.acl_path.map(|p| PathBuf::new().join(p)); + let acl = Acl::from_file(acl_config.append_only, acl_config.private_repo, path)?; + + // Server definition + let s_addr = server_config.server; + let s_str = format!("{}:{}", s_addr.host_dns_name, s_addr.port); + tracing::debug!("[serve] Serving address: {}", &s_str); + let socket = s_str.to_socket_addrs().unwrap().next().unwrap(); + start_web_server(acl, auth, storage, socket, false, None, opts.key).await?; + } + None => { + init_trace_from(&opts.log); + + let storage = LocalStorage::try_new(&opts.path)?; + let auth = Auth::from_file(opts.no_auth, &opts.path.join(".htpasswd"))?; + let acl = Acl::from_file(opts.append_only, opts.private_repo, None)?; + + start_web_server( + acl, + auth, + storage, + SocketAddr::from_str(&opts.listen).unwrap(), + false, + None, + opts.key, + ) + .await?; + } + } + + Ok(()) +} + +/// A REST server build in rust for use with restic +#[derive(Parser)] +#[command(name = "rustic-server")] +#[command(bin_name = "rustic-server")] +pub struct Opts { + /// Server configuration file + #[arg(short, long)] + pub config: Option, + /// listen address + #[arg(short, long, default_value = "localhost:8000")] + pub listen: String, + /// data directory + #[arg(short, long, default_value = "/tmp/restic")] + pub path: PathBuf, + /// disable .htpasswd authentication + #[arg(long)] + pub no_auth: bool, + /// file to read per-repo ACLs from + #[arg(long)] + pub acl: Option, + /// set standard acl to append only mode + #[arg(long)] + pub append_only: bool, + /// set standard acl to only access private repos + #[arg(long)] + pub private_repo: bool, + /// turn on TLS support + #[arg(long)] + pub tls: bool, + /// TLS certificate path + #[arg(long)] + pub cert: Option, + /// TLS key path + #[arg(long)] + pub key: Option, + /// logging level (Off/Error/Warn/Info/Debug/Trace) + #[arg(long, default_value = "Info")] + pub log: String, +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..8276f32 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,2 @@ +pub mod auth_file; +pub mod server_config; diff --git a/src/config/auth_file.rs b/src/config/auth_file.rs new file mode 100644 index 0000000..5fe7895 --- /dev/null +++ b/src/config/auth_file.rs @@ -0,0 +1,175 @@ +use anyhow::Result; +use htpasswd_verify::md5::{format_hash, md5_apr1_encode}; +use rand::distributions::Alphanumeric; +use rand::{thread_rng, Rng}; +use std::collections::HashMap; +use std::fmt::{Display, Formatter}; +use std::fs; +use std::fs::read_to_string; +use std::io::Write; +use std::path::PathBuf; + +pub mod constants { + pub(super) const SALT_LEN: usize = 8; +} + +#[derive(Clone)] +pub struct HtAccess { + pub path: PathBuf, + pub credentials: HashMap, +} + +impl HtAccess { + pub fn from_file(pth: &PathBuf) -> Result { + let mut c: HashMap = HashMap::new(); + if pth.exists() { + read_to_string(pth)? + .lines() // split the string into an iterator of string slices + .map(String::from) // make each slice into a string + .for_each(|line| match Credential::from_line(line) { + None => {} + Some(cred) => { + c.insert(cred.name.clone(), cred); + } + }) + } + Ok(HtAccess { + path: pth.clone(), + credentials: c, + }) + } + + pub fn get(&self, name: &str) -> Option<&Credential> { + self.credentials.get(name) + } + + pub fn users(&self) -> Vec { + self.credentials.keys().cloned().collect() + } + + /// Update can be used for both new, and existing credentials + pub fn update(&mut self, name: &str, pass: &str) { + let cred = Credential::new(name, pass); + self.insert(cred); + } + + /// Removes one credential by user name + pub fn delete(&mut self, name: &str) { + self.credentials.remove(name); + } + + fn insert(&mut self, cred: Credential) { + self.credentials.insert(cred.name.clone(), cred); + } + + /// FIXME: Nicer error logging for when we can not write file ... + pub fn to_file(&self) -> Result<()> { + let mut file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&self.path)?; + + for (_n, c) in self.credentials.iter() { + let _e = file.write(c.to_line().as_bytes()).unwrap(); + } + Ok(()) + } +} + +#[derive(Clone)] +pub struct Credential { + name: String, + hash_val: Option, + pw: Option, +} + +impl Credential { + pub fn new(name: &str, pass: &str) -> Self { + let salt: String = thread_rng() + .sample_iter(&Alphanumeric) + .take(constants::SALT_LEN) + .map(char::from) + .collect(); + let hash = md5_apr1_encode(pass, salt.as_str()); + let hash = format_hash(hash.as_str(), salt.as_str()); + + Credential { + name: name.into(), + hash_val: Some(hash), + pw: Some(pass.into()), + } + } + + /// Returns a credential struct from a htaccess file line + /// Of cause without password :-) + pub fn from_line(line: String) -> Option { + let spl: Vec<&str> = line.split(':').collect(); + if !spl.is_empty() { + return Some(Credential { + name: spl.first().unwrap().to_string(), + hash_val: Some(spl.get(1).unwrap().to_string()), + pw: None, + }); + } + None + } + + pub fn to_line(&self) -> String { + if self.hash_val.is_some() { + format!( + "{}:{}\n", + self.name.as_str(), + self.hash_val.as_ref().unwrap() + ) + } else { + "".into() + } + } +} + +impl Display for Credential { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + writeln!(f, "Struct: Credential")?; + writeln!(f, "\tUser: {}", self.name.as_str())?; + writeln!(f, "\tHash: {}", self.hash_val.as_ref().unwrap())?; + if self.pw.is_none() { + writeln!(f, "\tPassword: None")?; + } else { + writeln!(f, "\tPassword: {}", &self.pw.as_ref().unwrap())?; + } + Ok(()) + } +} + +#[cfg(test)] +mod test { + use crate::auth::{Auth, AuthChecker}; + use crate::config::auth_file::HtAccess; + use anyhow::Result; + use std::fs; + use std::path::Path; + + #[test] + fn test_htaccess() -> Result<()> { + let htaccess_pth = Path::new("tmp_test_data").join("rustic"); + fs::create_dir_all(&htaccess_pth).unwrap(); + + let ht_file = htaccess_pth.join("htaccess"); + + let mut ht = HtAccess::from_file(&ht_file)?; + ht.update("Administrator", "stuff"); + ht.update("backup-user", "its_me"); + ht.to_file()?; + + let ht = HtAccess::from_file(&ht_file)?; + assert!(ht.get("Administrator").is_some()); + assert!(ht.get("backup-user").is_some()); + + let auth = Auth::from_file(false, &ht_file).unwrap(); + assert!(auth.verify("Administrator", "stuff")); + assert!(auth.verify("backup-user", "its_me")); + + Ok(()) + } +} diff --git a/src/config/server_config.rs b/src/config/server_config.rs new file mode 100644 index 0000000..27fdecc --- /dev/null +++ b/src/config/server_config.rs @@ -0,0 +1,138 @@ +use anyhow::{Context, Result}; +use serde_derive::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct ServerConfig { + pub server: Server, + pub repos: Repos, + pub tls: Option, + pub authorization: Authorization, + pub accesscontrol: AccessControl, + pub log_level: Option, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct Repos { + pub storage_path: String, +} + +// This assumes that it makes no sense to have one but not the other +// So we if acl_path is given, we require the auth_path too. +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct AccessControl { + pub acl_path: Option, + //if not private all repo are accessible for any user + pub private_repo: bool, + //force access to append only for all + pub append_only: bool, +} + +// This assumes that it makes no sense to have one but not the other +// So we if acl_path is given, we require the auth_path too. +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct Authorization { + pub auth_path: Option, + //use authorization file + pub use_auth: bool, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct Server { + pub host_dns_name: String, + pub port: usize, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct TLS { + pub key_path: String, + pub cert_path: String, +} + +impl ServerConfig { + pub fn from_file(pth: &Path) -> Result { + let s = fs::read_to_string(pth).context("Can not read server configuration file")?; + let config: ServerConfig = + toml::from_str(&s).context("Can not convert file to server configuration")?; + Ok(config) + } + + pub fn to_file(&self, pth: &Path) -> Result<()> { + let toml_string = + toml::to_string(&self).context("Could not serialize SeverConfig to TOML value")?; + fs::write(pth, toml_string).context("Could not write ServerConfig to file!")?; + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::Server; + use crate::config::server_config::{AccessControl, Authorization, Repos, ServerConfig, TLS}; + use std::fs; + use std::path::Path; + + #[test] + fn test_file_read() { + let config_path = Path::new("tests") + .join("fixtures") + .join("test_data") + .join("rustic_server.toml"); + //let config_path = Path::new("/data/rustic/rustic_server.toml"); + let config = ServerConfig::from_file(&config_path); + assert!(config.is_ok()); + + let config = config.unwrap(); + assert_eq!(config.server.host_dns_name, "127.0.0.1"); + assert_eq!(config.repos.storage_path, "./test_data/test_repos/"); + } + + #[test] + fn test_file_write() { + let server_path = Path::new("tmp_test_data").join("rustic"); + fs::create_dir_all(&server_path).unwrap(); + + let server = Server { + host_dns_name: "127.0.0.1".to_string(), + port: 2222, + }; + + let tls: Option = Some(TLS { + key_path: "somewhere".to_string(), + cert_path: "somewhere/else".to_string(), + }); + + let repos: Repos = Repos { + storage_path: server_path.join("repos").to_string_lossy().into(), + }; + + let auth = Authorization { + auth_path: Some("auth_path".to_string()), + use_auth: true, + }; + + let access = AccessControl { + acl_path: Some("acl_path".to_string()), + private_repo: true, + append_only: true, + }; + + let log = "debug".to_string(); + + // Try to write + let config = ServerConfig { + log_level: Some(log), + server, + repos, + tls, + authorization: auth, + accesscontrol: access, + }; + let config_file = server_path.join("rustic_server.rustic_config.toml"); + config.to_file(&config_file).unwrap(); + + // Try to read + let _tmp_config = ServerConfig::from_file(&config_file).unwrap(); + } +} diff --git a/src/error.rs b/src/error.rs index 939cb41..37f17b6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,6 +5,8 @@ pub type Result = std::result::Result; #[derive(Debug)] pub enum ErrorKind { + InternalError(String), + BadRequest(String), FilenameNotAllowed(String), PathNotAllowed(String), InvalidPath(String), @@ -21,13 +23,26 @@ pub enum ErrorKind { WritingToFileFailed, FinalizingFileFailed, GettingFileHandleFailed, - RemovingFileFailed, + RemovingFileFailed(String), ReadingFromStreamFailed, + RemovingRepositoryFailed(String), + AuthenticationHeaderError, + UserAuthenticationError(String), } impl IntoResponse for ErrorKind { fn into_response(self) -> Response { match self { + ErrorKind::InternalError(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Internal server error: {}", err), + ) + .into_response(), + ErrorKind::BadRequest(err) => ( + StatusCode::BAD_REQUEST, + format!("Internal server error: {}", err), + ) + .into_response(), ErrorKind::FilenameNotAllowed(filename) => ( StatusCode::FORBIDDEN, format!("filename {filename} not allowed"), @@ -93,9 +108,9 @@ impl IntoResponse for ErrorKind { "error getting file handle".to_string(), ) .into_response(), - ErrorKind::RemovingFileFailed => ( + ErrorKind::RemovingFileFailed(err) => ( StatusCode::INTERNAL_SERVER_ERROR, - "error removing file".to_string(), + format!("error removing file: {:?}", err), ) .into_response(), ErrorKind::GeneralRange => { @@ -106,6 +121,19 @@ impl IntoResponse for ErrorKind { "error reading from stream".to_string(), ) .into_response(), + ErrorKind::RemovingRepositoryFailed(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("error removing repository folder: {:?}", err), + ) + .into_response(), + ErrorKind::AuthenticationHeaderError => { + (StatusCode::FORBIDDEN, "Bad authentication header").into_response() + } + ErrorKind::UserAuthenticationError(err) => ( + StatusCode::FORBIDDEN, + format!("Failed to authenticate user: {:?}", err), + ) + .into_response(), } } } diff --git a/src/handlers.rs b/src/handlers.rs new file mode 100644 index 0000000..1b15da9 --- /dev/null +++ b/src/handlers.rs @@ -0,0 +1,12 @@ +// web server response handler modules +pub(crate) mod file_config; +pub(crate) mod file_exchange; +pub(crate) mod file_length; +pub(crate) mod files_list; +pub(crate) mod repository; + +// Support modules +mod access_check; +pub(crate) mod file_helpers; +pub(crate) mod path_analysis; +//mod ranged_stream; diff --git a/src/handlers/access_check.rs b/src/handlers/access_check.rs new file mode 100644 index 0000000..5053b32 --- /dev/null +++ b/src/handlers/access_check.rs @@ -0,0 +1,40 @@ +use crate::acl::{AccessType, AclChecker, ACL}; +use crate::error::ErrorKind; +use crate::error::Result; +use crate::handlers::path_analysis::TYPES; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use std::path::Path; + +pub(crate) fn check_auth_and_acl( + user: String, + tpe: &str, + path: &Path, + append: AccessType, +) -> Result { + // don't allow paths that includes any of the defined types + for part in path.iter() { + //FIXME: Rewrite to?? -> if TYPES.contains(part) {} + if let Some(part) = part.to_str() { + for tpe_i in TYPES.iter() { + if &part == tpe_i { + return Err(ErrorKind::PathNotAllowed(path.display().to_string())); + } + } + } + } + + let acl = ACL.get().unwrap(); + let path = if let Some(path) = path.to_str() { + path + } else { + return Err(ErrorKind::NonUnicodePath(path.display().to_string())); + }; + let allowed = acl.allowed(user.as_str(), path, tpe, append); + tracing::debug!("[auth] user: {user}, path: {path}, tpe: {tpe}, allowed: {allowed}"); + + match allowed { + true => Ok(StatusCode::OK), + false => Err(ErrorKind::PathNotAllowed(path.to_string())), + } +} diff --git a/src/handlers/file_config.rs b/src/handlers/file_config.rs new file mode 100644 index 0000000..7e931dc --- /dev/null +++ b/src/handlers/file_config.rs @@ -0,0 +1,298 @@ +use crate::acl::AccessType; +use crate::auth::AuthFromRequest; +use crate::error::ErrorKind; +use crate::error::Result; +use crate::handlers::access_check::check_auth_and_acl; +use crate::handlers::file_exchange::{check_name, get_file, get_save_file, save_body}; +use crate::handlers::path_analysis::{decompose_path, ArchivePathEnum}; +use crate::storage::STORAGE; +use axum::body::Body; +use axum::extract::{OriginalUri, Request}; +use axum::response::IntoResponse; +use axum_extra::headers::Range; +use axum_extra::TypedHeader; +use std::path::{Path, PathBuf}; + +/// has_config +/// Interface: HEAD {path}/config +pub(crate) async fn has_config( + auth: AuthFromRequest, + req: Request, +) -> Result { + //let path_string = path.map_or(DEFAULT_PATH.to_string(), |PathExtract(path_ext)| path_ext); + let path_string = req.uri().path(); + let archive_path = decompose_path(path_string)?; + tracing::debug!("[has_config] archive_path: {}", &archive_path); + let p_str = archive_path.path; + let tpe = archive_path.tpe; + let name = archive_path.name; + // assert_eq!( &archive_path.path_type, &ArchivePathEnum::CONFIG); --> Correct assert, but interferes with our tests + // assert_eq!( &tpe, TPE_CONFIG); --> Correct assert, but interferes with our tests + //assert_eq!(&name, "config"); + tracing::debug!("[has_config] path: {p_str}, tpe: {tpe}, name: {name}"); + + let path = Path::new(&p_str); + check_auth_and_acl(auth.user, tpe.as_str(), path, AccessType::Read)?; + + let storage = STORAGE.get().unwrap(); + let file = storage.filename(path, &tpe, &name); + if file.exists() { + Ok(()) + } else { + Err(ErrorKind::FileNotFound(p_str)) + } +} + +/// get_config +/// Interface: GET {path}/config +pub(crate) async fn get_config( + auth: AuthFromRequest, + uri: OriginalUri, + range: Option>, +) -> Result { + get_file(auth, uri, range).await +} + +/// add_config +/// Interface: POST {path}/config +pub(crate) async fn add_config( + auth: AuthFromRequest, + uri: OriginalUri, + request: Request, +) -> Result { + //let path_string = path.map_or(DEFAULT_PATH.to_string(), |PathExtract(path_ext)| path_ext); + let path_string = uri.path(); + let archive_path = decompose_path(path_string)?; + let p_str = archive_path.path; + let tpe = archive_path.tpe; + let name = archive_path.name; + assert_eq!(&archive_path.path_type, &ArchivePathEnum::Config); + assert_eq!(&name, "config"); + tracing::debug!("[add_config] path: {p_str}, tpe: {tpe}, name: {name}"); + + let pth = PathBuf::new().join(&p_str); + let file = get_save_file(auth.user, pth, tpe.as_str(), name).await?; + + let stream = request.into_body().into_data_stream(); + save_body(file, stream).await?; + Ok(()) +} + +/// delete_config +/// Interface: DELETE {path}/config +/// FIXME: The original restic spec does not define delete_config --> but rustic did ?? +pub(crate) async fn delete_config( + auth: AuthFromRequest, + uri: OriginalUri, +) -> Result { + //let path_string = path.map_or(DEFAULT_PATH.to_string(), |PathExtract(path_ext)| path_ext); + let path_string = uri.path(); + let archive_path = decompose_path(path_string)?; + let p_str = archive_path.path; + let tpe = archive_path.tpe; + let name = archive_path.name; + assert_eq!(&archive_path.path_type, &ArchivePathEnum::Config); + tracing::debug!("[delete_config] path: {p_str}, tpe: {tpe}, name: {name}"); + + check_name(tpe.as_str(), &name)?; + let path = Path::new(&p_str); + let pth = Path::new(path); + check_auth_and_acl(auth.user, tpe.as_str(), pth, AccessType::Append)?; + + let storage = STORAGE.get().unwrap(); + if storage.remove_file(path, tpe.as_str(), &name).is_err() { + return Err(ErrorKind::RemovingFileFailed(p_str)); + } + Ok(()) +} + +#[cfg(test)] +mod test { + use crate::handlers::file_config::{add_config, delete_config, get_config, has_config}; + use crate::handlers::repository::{create_repository, delete_repository}; + use crate::log::print_request_response; + use crate::test_helpers::{ + basic_auth_header_value, init_test_environment, request_uri_for_test, + }; + use axum::http::Method; + use axum::routing::{delete, get, head, post}; + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use axum::{middleware, Router}; + use http_body_util::BodyExt; + use std::path::PathBuf; + use std::{env, fs}; + use tower::ServiceExt; + + #[tokio::test] + async fn tester_has_config() { + init_test_environment(); + + // ----------------------- + // NOT CONFIG + // ----------------------- + let app = Router::new() + .route("/*path", head(has_config)) + .layer(middleware::from_fn(print_request_response)); + + let request = Request::builder() + .uri("/test_repo/data/config") + .method(Method::HEAD) + .header( + "Authorization", + basic_auth_header_value("test", Some("test_pw")), + ) + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + // ----------------------- + // HAS CONFIG + // ----------------------- + let app = Router::new() + .route("/*path", head(has_config)) + .layer(middleware::from_fn(print_request_response)); + + let request = Request::builder() + .uri("/test_repo/config") + .method(Method::HEAD) + .header( + "Authorization", + basic_auth_header_value("test", Some("test_pw")), + ) + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_add_delete_config() { + init_test_environment(); + + // ----------------------- + //Start with a clean slate + // ----------------------- + let repo = "repo_remove_me_2".to_string(); + //Start with a clean slate ... + let cwd = env::current_dir().unwrap(); + let path = PathBuf::new() + .join(cwd) + .join("tests") + .join("fixtures") + .join("test_data") + .join("test_repos") + .join(&repo); + if path.exists() { + fs::remove_dir_all(&path).unwrap(); + assert!(!path.exists()); + } + tracing::debug!("[test_add_delete_config] repo: {:?}", &path); + + // ----------------------- + // Create a new repository + // ----------------------- + let repo_name_uri = ["/", &repo, "?create=true"].concat(); + let app = Router::new() + .route("/*path", post(create_repository)) + .layer(middleware::from_fn(print_request_response)); + + let request = request_uri_for_test(&repo_name_uri, Method::POST); + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + // ----------------------- + // ADD CONFIG + // ----------------------- + let test_vec = "Fancy Config Content".to_string(); + let uri = ["/", &repo, "/index/config"].concat(); + let body = Body::new(test_vec.clone()); + + let app = Router::new() + .route("/*path", post(add_config)) + .layer(middleware::from_fn(print_request_response)); + + let request = Request::builder() + .uri(&uri) + .method(Method::POST) + .header( + "Authorization", + basic_auth_header_value("test", Some("test_pw")), + ) + .body(body) + .unwrap(); + + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let conf_pth = path.join("index").join("config"); + assert!(conf_pth.exists()); + let conf_str = fs::read_to_string(conf_pth).unwrap(); + assert_eq!(&conf_str, &test_vec); + + // ----------------------- + // GET CONFIG + // ----------------------- + let app = Router::new() + .route("/*path", get(get_config)) + .layer(middleware::from_fn(print_request_response)); + + let request = request_uri_for_test(&uri, Method::GET); + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let (_parts, body) = resp.into_parts(); + let byte_vec = body.collect().await.unwrap().to_bytes(); + let body_str = String::from_utf8(byte_vec.to_vec()).unwrap(); + assert_eq!(body_str, test_vec); + + // ----------------------- + // HAS CONFIG + // - differs from tester_has_config() that we have a non empty path now + // ----------------------- + let app = Router::new() + .route("/*path", head(has_config)) + .layer(middleware::from_fn(print_request_response)); + + let request = request_uri_for_test(&uri, Method::HEAD); + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + // ----------------------- + // DELETE CONFIG + // ----------------------- + let app = Router::new() + .route("/*path", delete(delete_config)) + .layer(middleware::from_fn(print_request_response)); + + let request = request_uri_for_test(&uri, Method::DELETE); + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let conf_pth = path.join("data").join("config"); + assert!(!conf_pth.exists()); + + // ----------------------- + // CLEAN UP DELETE REPO + // ----------------------- + let repo_name_uri = ["/", &repo].concat(); + let app = Router::new() + .route("/*path", post(delete_repository)) + .layer(middleware::from_fn(print_request_response)); + + let request = request_uri_for_test(&repo_name_uri, Method::POST); + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + assert!(!path.exists()); + } +} diff --git a/src/handlers/file_exchange.rs b/src/handlers/file_exchange.rs new file mode 100644 index 0000000..71311e0 --- /dev/null +++ b/src/handlers/file_exchange.rs @@ -0,0 +1,428 @@ +use crate::auth::AuthFromRequest; +use crate::error::ErrorKind; +use crate::handlers::access_check::check_auth_and_acl; +use crate::handlers::file_helpers::Finalizer; +use crate::handlers::path_analysis::{decompose_path, ArchivePathEnum}; +use crate::storage::STORAGE; +use crate::{acl::AccessType, error::Result}; +use ::futures::{Stream, TryStreamExt}; +use axum::extract::OriginalUri; +use axum::{body::Bytes, extract::Request, response::IntoResponse, BoxError}; +use axum_extra::headers::Range; +use axum_extra::TypedHeader; +use axum_range::KnownSize; +use axum_range::Ranged; +use futures_util::pin_mut; +use std::io; +use std::path::{Path, PathBuf}; +use tokio::io::AsyncWrite; +use tokio_util::io::StreamReader; + +/// add_file +/// Interface: POST {path}/{type}/{name} +/// Background info: https://github.com/tokio-rs/axum/blob/main/examples/stream-to-file/src/main.rs +/// Future on ranges: https://www.rfc-editor.org/rfc/rfc9110.html#name-partial-put +pub(crate) async fn add_file( + auth: AuthFromRequest, + uri: OriginalUri, + request: Request, +) -> Result { + //let path_string = path.map_or(DEFAULT_PATH.to_string(), |PathExtract(path_ext)| path_ext); + let path_string = uri.path(); + let archive_path = decompose_path(path_string)?; + let p_str = archive_path.path; + let tpe = archive_path.tpe; + let name = archive_path.name; + assert_ne!(archive_path.path_type, ArchivePathEnum::Config); + assert_ne!(&name, ""); + tracing::debug!("[get_file] path: {p_str}, tpe: {tpe}, name: {name}"); + + //credential & access check executed in get_save_file() + let pth = PathBuf::new().join(&p_str); + let file = get_save_file(auth.user, pth, tpe.as_str(), name).await?; + + let stream = request.into_body().into_data_stream(); + save_body(file, stream).await?; + + //FIXME: Do we need to check if the file exists here? (For now it seems we should get an error if NOK) + Ok(()) +} + +/// delete_file +/// Interface: DELETE {path}/{type}/{name} +pub(crate) async fn delete_file( + auth: AuthFromRequest, + uri: OriginalUri, +) -> Result { + //let path_string = path.map_or(DEFAULT_PATH.to_string(), |PathExtract(path_ext)| path_ext); + let path_string = uri.path(); + let archive_path = decompose_path(path_string)?; + let p_str = archive_path.path; + let tpe = archive_path.tpe; + let name = archive_path.name; + tracing::debug!("[delete_file] path: {p_str}, tpe: {tpe}, name: {name}"); + + check_name(tpe.as_str(), name.as_str())?; + let pth = Path::new(&p_str); + check_auth_and_acl(auth.user, tpe.as_str(), pth, AccessType::Append)?; + + let storage = STORAGE.get().unwrap(); + let pth = Path::new(&p_str); + + if let Err(e) = storage.remove_file(pth, tpe.as_str(), name.as_str()) { + tracing::debug!("[delete_file] IO error: {e:?}"); + return Err(ErrorKind::RemovingFileFailed(p_str)); + } + Ok(()) +} + +/// get_file +/// Interface: GET {path}/{type}/{name} +pub(crate) async fn get_file( + auth: AuthFromRequest, + uri: OriginalUri, + range: Option>, +) -> Result { + //let path_string = path.map_or(DEFAULT_PATH.to_string(), |PathExtract(path_ext)| path_ext); + let path_string = uri.path(); + let archive_path = decompose_path(path_string)?; + let p_str = archive_path.path; + let tpe = archive_path.tpe; + let name = archive_path.name; + tracing::debug!("[get_file] path: {p_str}, tpe: {tpe}, name: {name}"); + + check_name(tpe.as_str(), name.as_str())?; + let pth = Path::new(&p_str); + + check_auth_and_acl(auth.user, tpe.as_str(), pth, AccessType::Read)?; + + let storage = STORAGE.get().unwrap(); + let file = match storage.open_file(pth, &tpe, &name).await { + Ok(file) => file, + Err(_) => { + return Err(ErrorKind::FileNotFound(p_str)); + } + }; + + let body = KnownSize::file(file).await.unwrap(); + let range = range.map(|TypedHeader(range)| range); + Ok(Ranged::new(range, body).into_response()) +} + +//============================================================================== +// Support functions: +// +//============================================================================== + +/// Returns a stream for the given path in the repository. +pub(crate) async fn get_save_file( + user: String, + path: PathBuf, + tpe: &str, + name: String, +) -> Result { + tracing::debug!("[get_save_file] path: {path:?}, tpe: {tpe}, name: {name}"); + + check_name(tpe, name.as_str())?; + check_auth_and_acl(user, tpe, path.as_path(), AccessType::Append)?; + + let storage = STORAGE.get().unwrap(); + let file_writer = match storage.create_file(&path, tpe, &name).await { + Ok(w) => w, + Err(_) => { + return Err(ErrorKind::GettingFileHandleFailed); + } + }; + + Ok(file_writer) +} + +/// saves the content in the HTML request body to a file stream. +pub(crate) async fn save_body( + mut write_stream: impl AsyncWrite + Unpin + Finalizer, + stream: S, +) -> Result +where + S: Stream>, + E: Into, +{ + // Convert the stream into an `AsyncRead`. + let body_with_io_error = stream.map_err(|err| io::Error::new(io::ErrorKind::Other, err)); + let body_reader = StreamReader::new(body_with_io_error); + pin_mut!(body_reader); + let byte_count = match tokio::io::copy(&mut body_reader, &mut write_stream).await { + Ok(b) => b, + Err(_) => return Err(ErrorKind::FinalizingFileFailed), + }; + + tracing::debug!("[file written] bytes: {byte_count}"); + if write_stream.finalize().await.is_err() { + return Err(ErrorKind::FinalizingFileFailed); + }; + + Ok(()) +} + +#[cfg(test)] +fn check_string_sha256(_name: &str) -> bool { + true +} + +#[cfg(not(test))] +fn check_string_sha256(name: &str) -> bool { + if name.len() != 64 { + return false; + } + for c in name.chars() { + if !c.is_ascii_digit() && !('a'..='f').contains(&c) { + return false; + } + } + true +} + +///FIXME Move to suppport functoin file +pub(crate) fn check_name(tpe: &str, name: &str) -> Result { + match tpe { + "config" => Ok(()), + _ if check_string_sha256(name) => Ok(()), + _ => Err(ErrorKind::FilenameNotAllowed(name.to_string())), + } +} + +#[cfg(test)] +mod test { + use crate::handlers::file_exchange::{add_file, delete_file, get_file}; + use crate::log::print_request_response; + use crate::test_helpers::{ + basic_auth_header_value, init_test_environment, request_uri_for_test, + }; + use axum::http::{header, Method}; + use axum::routing::{delete, get, put}; + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use axum::{middleware, Router}; + use http_body_util::BodyExt; + use std::path::PathBuf; + use std::{env, fs}; + use tower::ServiceExt; + + #[tokio::test] + async fn server_add_delete_file_tester() { + init_test_environment(); + + let file_name = "__add_file_test_adds_this_one__"; + + //Start with a clean slate ... + let cwd = env::current_dir().unwrap(); + let path = PathBuf::new() + .join(cwd) + .join("tests") + .join("fixtures") + .join("test_data") + .join("test_repos") + .join("test_repo") + .join("keys") + .join(file_name); + if path.exists() { + fs::remove_file(&path).unwrap(); + assert!(!path.exists()); + } + + //---------------------------------------------- + // Write a complete file + //---------------------------------------------- + let app = Router::new() + .route("/*path", put(add_file)) + .layer(middleware::from_fn(print_request_response)); + + let test_vec = "Hello World".to_string(); + let body = Body::new(test_vec.clone()); + let uri = ["/test_repo/keys/", file_name].concat(); + let request = Request::builder() + .uri(uri) + .method(Method::PUT) + .header( + "Authorization", + basic_auth_header_value("test", Some("test_pw")), + ) + .body(body) + .unwrap(); + + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + assert!(path.exists()); + + let body = fs::read_to_string(&path).unwrap(); + assert_eq!(body, test_vec); + + //---------------------------------------------- + // Delete a complete file + //---------------------------------------------- + let app = Router::new() + .route("/*path", delete(delete_file)) + .layer(middleware::from_fn(print_request_response)); + + let uri = ["/test_repo/keys/", file_name].concat(); + let request = Request::builder() + .uri(uri) + .method(Method::DELETE) + .header( + "Authorization", + basic_auth_header_value("test", Some("test_pw")), + ) + .body(body) + .unwrap(); + + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + assert!(!path.exists()); + + // // Just to be sure ... + // fs::remove_file(&path).unwrap(); + // assert!( !path.exists() ); + } + + #[tokio::test] + async fn server_get_file_tester() { + init_test_environment(); + + let file_name = "__get_file_test_adds_this_two__"; + //Start with a clean slate ... + let cwd = env::current_dir().unwrap(); + let path = PathBuf::new() + .join(cwd) + .join("tests") + .join("fixtures") + .join("test_data") + .join("test_repos") + .join("test_repo") + .join("keys") + .join(file_name); + if path.exists() { + tracing::debug!("[server_get_file_tester] test file found and removed"); + fs::remove_file(&path).unwrap(); + assert!(!path.exists()); + } + + // Start with creating the file before we can test + let app = Router::new() + .route("/*path", put(add_file)) + .layer(middleware::from_fn(print_request_response)); + + let test_vec = "Hello Sweet World".to_string(); + let body = Body::new(test_vec.clone()); + let uri = ["/test_repo/keys/", file_name].concat(); + let request = Request::builder() + .uri(uri) + .method(Method::PUT) + .header( + "Authorization", + basic_auth_header_value("test", Some("test_pw")), + ) + .body(body) + .unwrap(); + + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + assert!(path.exists()); + let body = fs::read_to_string(&path).unwrap(); + assert_eq!(body, test_vec); + + // Now we can start to test + //---------------------------------------- + // Fetch the complete file + //---------------------------------------- + let app = Router::new() + .route("/*path", get(get_file)) + .layer(middleware::from_fn(print_request_response)); + + let uri = ["/test_repo/keys/", file_name].concat(); + let request = request_uri_for_test(&uri, Method::GET); + let resp = app.clone().oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let (_parts, body) = resp.into_parts(); + let byte_vec = body.collect().await.unwrap().to_bytes(); + let body_str = String::from_utf8(byte_vec.to_vec()).unwrap(); + assert_eq!(body_str, test_vec); + + //---------------------------------------- + // Read a partial file + //---------------------------------------- + // let test_vec = "Hello Sweet World".to_string(); + + let uri = ["/test_repo/keys/", file_name].concat(); + let request = Request::builder() + .uri(uri) + .method(Method::GET) + .header(header::RANGE, "bytes=6-12") + .header( + "Authorization", + basic_auth_header_value("test", Some("test_pw")), + ) + .body(Body::empty()) + .unwrap(); + + let resp = app.clone().oneshot(request).await.unwrap(); + + let test_vec = "Sweet W".to_string(); // bytes 6 - 13 from in the file + + assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT); + let (_parts, body) = resp.into_parts(); + let byte_vec = body.collect().await.unwrap().to_bytes(); + let body_str = String::from_utf8(byte_vec.to_vec()).unwrap(); + assert_eq!(body_str, test_vec); + + //---------------------------------------------- + // Clean up -> Delete test file + //---------------------------------------------- + // fs::remove_file(&path).unwrap(); + // assert!( !path.exists() ); + let app = Router::new() + .route("/*path", delete(delete_file)) + .layer(middleware::from_fn(print_request_response)); + + let uri = ["/test_repo/keys/", file_name].concat(); + let request = request_uri_for_test(&uri, Method::DELETE); + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + assert!(!path.exists()); + } + + #[tokio::test] + async fn test_get_config() { + init_test_environment(); + + let cwd = env::current_dir().unwrap(); + let path = PathBuf::new() + .join(cwd) + .join("tests") + .join("fixtures") + .join("test_data") + .join("test_repos") + .join("test_repo") + .join("config"); + let test_vec = fs::read(path).unwrap(); + + let app = Router::new() + .route("/*path", get(get_file)) + .layer(middleware::from_fn(print_request_response)); + + let uri = "/test_repo/config"; + let request = request_uri_for_test(uri, Method::GET); + let resp = app.clone().oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let (_parts, body) = resp.into_parts(); + let byte_vec = body.collect().await.unwrap().to_bytes(); + let body_str = byte_vec.to_vec(); + assert_eq!(body_str, test_vec); + } +} diff --git a/src/helpers.rs b/src/handlers/file_helpers.rs similarity index 97% rename from src/helpers.rs rename to src/handlers/file_helpers.rs index 4736ccd..f3bcad5 100644 --- a/src/helpers.rs +++ b/src/handlers/file_helpers.rs @@ -1,4 +1,5 @@ -// used by WriteOrDeleteFile +use serde::{Serialize, Serializer}; +use std::cell::RefCell; use std::fs; use std::io::Result as IoResult; use std::path::PathBuf; @@ -7,13 +8,6 @@ use std::task::{Context, Poll}; use tokio::fs::{File, OpenOptions}; use tokio::io::AsyncWrite; -// used by IteratorAdapter -use serde::{Serialize, Serializer}; -use std::cell::RefCell; - -// helper struct to make iterators serializable -pub struct IteratorAdapter(RefCell); - // helper struct which is like a async_std|tokio::fs::File but removes the file // if finalize() was not called. pub struct WriteOrDeleteFile { @@ -75,6 +69,9 @@ impl Drop for WriteOrDeleteFile { } } +// helper struct to make iterators serializable +pub struct IteratorAdapter(RefCell); + impl IteratorAdapter { pub fn new(iterator: I) -> Self { Self(RefCell::new(iterator)) diff --git a/src/handlers/file_length.rs b/src/handlers/file_length.rs new file mode 100644 index 0000000..9bee6c7 --- /dev/null +++ b/src/handlers/file_length.rs @@ -0,0 +1,124 @@ +use crate::auth::AuthFromRequest; +use crate::error::ErrorKind; +use crate::handlers::access_check::check_auth_and_acl; +use crate::handlers::path_analysis::{decompose_path, ArchivePathEnum}; +use crate::storage::STORAGE; +use crate::{acl::AccessType, error::Result}; +use axum::extract::OriginalUri; +use axum::{http::header, response::IntoResponse}; +use axum_extra::headers::HeaderMap; +use std::path::Path; + +/// Length +/// Interface: HEAD {path}/{type}/{name} +pub(crate) async fn file_length( + auth: AuthFromRequest, + uri: OriginalUri, +) -> Result { + //let path_string = path.map_or(DEFAULT_PATH.to_string(), |PathExtract(path_ext)| path_ext); + let path_string = uri.path(); + let archive_path = decompose_path(path_string)?; + let p_str = archive_path.path; + let tpe = archive_path.tpe; + let name = archive_path.name; + assert_ne!(archive_path.path_type, ArchivePathEnum::Config); + tracing::debug!("[length] path: {p_str}, tpe: {tpe}, name: {name}"); + + let path = Path::new(&p_str); + check_auth_and_acl(auth.user, tpe.as_str(), path, AccessType::Read)?; + + let storage = STORAGE.get().unwrap(); + let file = storage.filename(path, &tpe, &name); + return if file.exists() { + let storage = STORAGE.get().unwrap(); + let file = match storage.open_file(path, &tpe, &name).await { + Ok(file) => file, + Err(_) => { + return Err(ErrorKind::FileNotFound(p_str)); + } + }; + let length = match file.metadata().await { + Ok(meta) => meta.len(), + Err(_) => { + return Err(ErrorKind::GettingFileMetadataFailed); + } + }; + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_LENGTH, length.into()); + Ok(headers) + } else { + Err(ErrorKind::FileNotFound(p_str)) + }; +} + +#[cfg(test)] +mod test { + use crate::handlers::file_length::file_length; + use crate::log::print_request_response; + use crate::test_helpers::{init_test_environment, request_uri_for_test}; + use axum::http::StatusCode; + use axum::http::{header, Method}; + use axum::routing::head; + use axum::{middleware, Router}; + use http_body_util::BodyExt; + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + + #[tokio::test] + async fn server_file_length_tester() { + init_test_environment(); + + // ---------------------------------- + // File exists + // ---------------------------------- + let app = Router::new() + .route("/*path", head(file_length)) + .layer(middleware::from_fn(print_request_response)); + + let uri = + "/test_repo/keys/2e734da3fccb98724ece44efca027652ba7a335c224448a68772b41c0d9229d5"; + let request = request_uri_for_test(uri, Method::HEAD); + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(&resp.status(), &StatusCode::from_u16(200).unwrap()); + + let length = resp + .headers() + .get(header::CONTENT_LENGTH) + .unwrap() + .to_str() + .unwrap(); + assert_eq!(length, "363"); + + let b = resp + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(); + assert!(b.is_empty()); + + // ---------------------------------- + // File does NOT exist + // ---------------------------------- + let app = Router::new() + .route("/*path", head(file_length)) + .layer(middleware::from_fn(print_request_response)); + + let uri = "/test_repo/keys/__I_do_not_exist__"; + let request = request_uri_for_test(uri, Method::HEAD); + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + let b = resp + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(); + assert!(b.is_empty()); + } +} diff --git a/src/handlers/files_list.rs b/src/handlers/files_list.rs new file mode 100644 index 0000000..01ae3f6 --- /dev/null +++ b/src/handlers/files_list.rs @@ -0,0 +1,199 @@ +use crate::auth::AuthFromRequest; +use crate::handlers::access_check::check_auth_and_acl; +use crate::handlers::path_analysis::{decompose_path, ArchivePathEnum}; +use crate::storage::STORAGE; +use crate::{acl::AccessType, error::Result, handlers::file_helpers::IteratorAdapter}; +use axum::extract::OriginalUri; +use axum::http::header::AUTHORIZATION; +use axum::{ + http::{header, StatusCode}, + response::IntoResponse, + Json, +}; +use axum_extra::headers::HeaderMap; +use serde_derive::{Deserialize, Serialize}; +use std::path::Path; + +const API_V1: &str = "application/vnd.x.restic.rest.v1"; +const API_V2: &str = "application/vnd.x.restic.rest.v2"; + +/// List files +/// Interface: GET {path}/{type}/ +#[derive(Serialize, Deserialize)] +struct RepoPathEntry { + name: String, + size: u64, +} + +pub(crate) async fn list_files( + auth: AuthFromRequest, + uri: OriginalUri, + headers: HeaderMap, +) -> Result { + //let path_string = path.map_or(DEFAULT_PATH.to_string(), |PathExtract(path_ext)| path_ext); + let path_string = uri.path(); + let archive_path = decompose_path(path_string)?; + let p_str = archive_path.path; + let tpe = archive_path.tpe; + assert_ne!(archive_path.path_type, ArchivePathEnum::Config); + assert_eq!(archive_path.name, "".to_string()); + tracing::debug!("[list_files] path: {p_str}, tpe: {tpe}"); + + let pth = Path::new(&p_str); + check_auth_and_acl(auth.user, tpe.as_str(), pth, AccessType::Read)?; + + let storage = STORAGE.get().unwrap(); + let read_dir = storage.read_dir(pth, tpe.as_str()); + + let mut res = match headers + .get(header::ACCEPT) + .and_then(|header| header.to_str().ok()) + { + Some(API_V2) => { + let read_dir_version = read_dir.map(|e| { + RepoPathEntry { + name: e.file_name().to_str().unwrap().to_string(), + size: e.metadata().unwrap().len(), + // FIXME: return Err(ErrorKind::GettingFileMetadataFailed.into()); + } + }); + let mut response = Json(&IteratorAdapter::new(read_dir_version)).into_response(); + tracing::debug!("[list_files::dir_content(V2)] {:?}", response.body()); + response.headers_mut().insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static(API_V2), + ); + let status = response.status_mut(); + *status = StatusCode::OK; + response + } + _ => { + let read_dir_version = read_dir.map(|e| e.file_name().to_str().unwrap().to_string()); + let mut response = Json(&IteratorAdapter::new(read_dir_version)).into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static(API_V1), + ); + let status = response.status_mut(); + *status = StatusCode::OK; + response + } + }; + res.headers_mut() + .insert(AUTHORIZATION, headers.get(AUTHORIZATION).unwrap().clone()); + Ok(res) +} + +#[cfg(test)] +mod test { + use crate::handlers::files_list::{list_files, RepoPathEntry, API_V1, API_V2}; + use crate::log::print_request_response; + use crate::test_helpers::{basic_auth_header_value, init_test_environment}; + use axum::http::header::{ACCEPT, CONTENT_TYPE}; + use axum::routing::get; + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use axum::{middleware, Router}; + use http_body_util::BodyExt; + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + + #[tokio::test] + async fn get_list_files_test() { + init_test_environment(); + + // V1 + let app = Router::new() + .route("/*path", get(list_files)) + .layer(middleware::from_fn(print_request_response)); + + let request = Request::builder() + .uri("/test_repo/keys") + .header(ACCEPT, API_V1) + .header( + "Authorization", + basic_auth_header_value("test", Some("test_pw")), + ) + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + assert_eq!( + resp.headers().get(CONTENT_TYPE).unwrap().to_str().unwrap(), + API_V1 + ); + let b = resp + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(); + assert!(!b.is_empty()); + let body = std::str::from_utf8(&b).unwrap(); + let r: Vec = serde_json::from_str(body).unwrap(); + let mut found = false; + + for rpe in r { + if rpe == "2e734da3fccb98724ece44efca027652ba7a335c224448a68772b41c0d9229d5" { + found = true; + break; + } + } + assert!(found); + + // V2 + let app = Router::new() + .route("/*path", get(list_files)) + .layer(middleware::from_fn(print_request_response)); + + let requrest = Request::builder() + .uri("/test_repo/keys") + .header(ACCEPT, API_V2) + .header( + "Authorization", + basic_auth_header_value("test", Some("test_pw")), + ) + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(requrest).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + assert_eq!( + resp.headers().get(CONTENT_TYPE).unwrap().to_str().unwrap(), + API_V2 + ); + let b = resp + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(); + let body = std::str::from_utf8(&b).unwrap(); + let r: Vec = serde_json::from_str(body).unwrap(); + assert!(!r.is_empty()); + + let mut found = false; + + for rpe in r { + if rpe.name == "2e734da3fccb98724ece44efca027652ba7a335c224448a68772b41c0d9229d5" { + assert_eq!(rpe.size, 363); + found = true; + break; + } + } + assert!(found); + + // We may have more files, this does not work... + // let rr = r.first().unwrap(); + // assert_eq!( rr.name, "2e734da3fccb98724ece44efca027652ba7a335c224448a68772b41c0d9229d5"); + // assert_eq!(rr.size, 363); + } +} diff --git a/src/handlers/path_analysis.rs b/src/handlers/path_analysis.rs new file mode 100644 index 0000000..a8f121f --- /dev/null +++ b/src/handlers/path_analysis.rs @@ -0,0 +1,196 @@ +use crate::error::ErrorKind; +use crate::error::Result; +use std::fmt::{Display, Formatter}; + +//pub(crate) const DEFAULT_PATH: &str = ""; + +// TPE_LOCKS is is defined, but outside this types[] array. +// This allow us to loop over the types[] when generating "routes" +pub(crate) const TPE_DATA: &str = "data"; +pub(crate) const TPE_KEYS: &str = "keys"; +pub(crate) const TPE_LOCKS: &str = "locks"; +pub(crate) const TPE_SNAPSHOTS: &str = "snapshots"; +pub(crate) const TPE_INDEX: &str = "index"; +pub(crate) const TPE_CONFIG: &str = "config"; +pub(crate) const TYPES: [&str; 5] = [TPE_DATA, TPE_KEYS, TPE_LOCKS, TPE_SNAPSHOTS, TPE_INDEX]; + +/// ArchivePathEnum hints what kind of path we received from the user. +/// - ArchivePathEnum::Repo points to the root of the repository. +/// - All other enum values point to data_type inside the repository +#[derive(Debug, PartialEq)] +pub(crate) enum ArchivePathEnum { + Repo, + Data, + Keys, + Locks, + Snapshots, + Index, + Config, +} + +pub(crate) struct ArchivePath { + pub(crate) path_type: ArchivePathEnum, + pub(crate) tpe: String, + pub(crate) path: String, + pub(crate) name: String, +} + +impl Display for ArchivePath { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "[ArchivePath] path_type = {:?}, path: {}, tpe: {}, name: {:?}", + self.path_type, self.path, self.tpe, self.name, + ) + } +} + +pub(crate) fn decompose_path(path: &str) -> Result { + tracing::debug!("[decompose_path] received path: {}", &path); + + // Collect to a list of non empty path elements + let mut elem: Vec = path + .split('/') + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + .collect(); + let length = elem.len(); + tracing::debug!("[decompose_path] elem = {:?}", &elem); + + let mut ap = ArchivePath { + path_type: ArchivePathEnum::Repo, //will be overwritten later + tpe: "".to_string(), + path: "".to_string(), + name: "".to_string(), + }; + + if length == 0 { + tracing::debug!("[decompose_path] Empty path!"); + return Err(ErrorKind::FilenameNotAllowed(path.into())); + } + + // Analyse tail of the path to find name and type values + let tmp = elem.pop().unwrap(); + let (tpe, name) = if tmp.eq(TPE_CONFIG) { + ap.path_type = ArchivePathEnum::Config; + if length > 1 { + let tpe = elem.pop().unwrap(); + if TYPES.contains(&tpe.as_str()) { + (tpe, tmp) // path = /:path/:tpe/:config + } else { + elem.push(tpe); + (TPE_CONFIG.to_string(), tmp) // path = /:path/:config + } + } else { + (TPE_CONFIG.to_string(), tmp) // path = /:config + } + } else if TYPES.contains(&tmp.as_str()) { + ap.path_type = get_path_type(&tmp); + (tmp, "".to_string()) // path = /:path/:tpe --> but NOT "config" + } else if length > 1 { + let tpe = elem.pop().unwrap(); + if TYPES.contains(&tpe.as_str()) { + assert_ne!(tpe.as_str(), TPE_CONFIG); // not allowed: path = /:path/:config/:name + ap.path_type = get_path_type(&tpe); + (tpe, tmp) // path = /:path/:tpe/:name + } else { + ap.path_type = ArchivePathEnum::Repo; + elem.push(tpe); + elem.push(tmp); + ("".to_string(), "".to_string()) // path = /:path --> with length (>1) + } + } else { + ap.path_type = ArchivePathEnum::Repo; + elem.push(tmp); + ("".to_string(), "".to_string()) // path = /:path --> with length (1) + }; + + ap.tpe = tpe; + ap.name = name; + ap.path = elem.join("/"); + + tracing::debug!("[decompose_path] {:}", &ap); + + Ok(ap) +} + +fn get_path_type(s: &str) -> ArchivePathEnum { + match s { + TPE_CONFIG => ArchivePathEnum::Config, + TPE_DATA => ArchivePathEnum::Data, + TPE_KEYS => ArchivePathEnum::Keys, + TPE_LOCKS => ArchivePathEnum::Locks, + TPE_SNAPSHOTS => ArchivePathEnum::Snapshots, + TPE_INDEX => ArchivePathEnum::Index, + _ => ArchivePathEnum::Repo, + } +} + +#[cfg(test)] +mod test { + use crate::error::Result; + use crate::handlers::path_analysis::ArchivePathEnum::Config; + use crate::handlers::path_analysis::{decompose_path, TPE_DATA, TPE_LOCKS}; + use crate::test_helpers::init_tracing; + + #[test] + fn archive_path_struct() -> Result<()> { + init_tracing(); + + let path = "/a/b/data/name"; + let ap = decompose_path(path)?; + assert_eq!(ap.tpe, TPE_DATA); + assert_eq!(ap.name, "name".to_string()); + assert_eq!(ap.path, "a/b"); + + let path = "/data/name"; + let ap = decompose_path(path)?; + assert_eq!(ap.tpe, TPE_DATA); + assert_eq!(ap.name, "name".to_string()); + assert_eq!(ap.path, ""); + + let path = "/a/b/locks"; + let ap = decompose_path(path)?; + assert_eq!(ap.tpe, TPE_LOCKS); + assert_eq!(ap.name, "".to_string()); + assert_eq!(ap.path, "a/b"); + + let path = "/data"; + let ap = decompose_path(path)?; + assert_eq!(ap.tpe, TPE_DATA); + assert_eq!(ap.name, "".to_string()); + assert_eq!(ap.path, ""); + + let path = "/a/b/data/config"; + let ap = decompose_path(path)?; + assert_eq!(ap.path_type, Config); + assert_eq!(ap.tpe, TPE_DATA); + assert_eq!(ap.name, "config".to_string()); + assert_eq!(ap.path, "a/b"); + + // pub(crate) fn check_name(tpe: &str, name: &str) -> Result + // requires that we have type config --> keep similar with "old" rustic server implementation + let path = "/a/b/config"; + let ap = decompose_path(path)?; + assert_eq!(ap.path_type, Config); + assert_eq!(ap.tpe, "config".to_string()); + assert_eq!(ap.name, "config".to_string()); + assert_eq!(ap.path, "a/b"); + + let path = "/a/config"; + let ap = decompose_path(path)?; + assert_eq!(ap.path_type, Config); + assert_eq!(ap.tpe, "config".to_string()); + assert_eq!(ap.name, "config".to_string()); + assert_eq!(ap.path, "a"); + + let path = "/config"; + let ap = decompose_path(path)?; + assert_eq!(ap.path_type, Config); + assert_eq!(ap.tpe, "config".to_string()); + assert_eq!(ap.name, "config".to_string()); + assert_eq!(ap.path, ""); + + Ok(()) + } +} diff --git a/src/handlers/repository.rs b/src/handlers/repository.rs new file mode 100644 index 0000000..5af8e84 --- /dev/null +++ b/src/handlers/repository.rs @@ -0,0 +1,209 @@ +use crate::auth::AuthFromRequest; +use crate::error::ErrorKind; +use crate::handlers::access_check::check_auth_and_acl; +use crate::handlers::path_analysis::{decompose_path, ArchivePathEnum, TYPES}; +use crate::storage::STORAGE; +use crate::{acl::AccessType, error::Result}; +use axum::extract::OriginalUri; +use axum::extract::Query; +use axum::{http::StatusCode, response::IntoResponse}; +use serde_derive::Deserialize; +use std::path::Path; + +/// Create_repository +/// Interface: POST {path}?create=true +#[derive(Default, Deserialize)] +#[serde(default)] +pub(crate) struct Create { + create: bool, +} + +// FIXME: The input path should be 1 folder deep (right??) +pub(crate) async fn create_repository( + auth: AuthFromRequest, + uri: OriginalUri, + Query(params): Query, +) -> Result { + //let path_string = path.map_or(DEFAULT_PATH.to_string(), |PathExtract(path_ext)| path_ext); + let path_string = uri.path(); + let archive_path = decompose_path(path_string)?; + let p_str = archive_path.path; + let tpe = archive_path.tpe; + assert_eq!(&archive_path.path_type, &ArchivePathEnum::Repo); + assert_eq!(&tpe, ""); + tracing::debug!("[create_repository] repo_path: {p_str:?}"); + + let path = Path::new(&p_str); + //FIXME: Is Append the right access leven, or should we require Modify? + check_auth_and_acl(auth.user, &tpe, path, AccessType::Append)?; + + let storage = STORAGE.get().unwrap(); + match params.create { + true => { + for tpe_i in TYPES.iter() { + if let Err(e) = storage.create_dir(path, tpe_i) { + return Err(ErrorKind::CreatingDirectoryFailed(e.to_string())); + }; + } + + Ok(( + StatusCode::OK, + format!("Called create_files with path {:?}\n", path), + )) + } + false => Ok(( + StatusCode::OK, + format!("Called create_files with path {:?}, create=false\n", path), + )), + } +} + +/// Delete_repository +/// Interface: Delete {path} +/// FIXME: The input path should at least NOT point to a file in any repository +pub(crate) async fn delete_repository( + auth: AuthFromRequest, + uri: OriginalUri, +) -> Result { + //let path_string = path.map_or(DEFAULT_PATH.to_string(), |PathExtract(path_ext)| path_ext); + let path_string = uri.path(); + let archive_path = decompose_path(path_string)?; + let p_str = archive_path.path; + let tpe = archive_path.tpe; + assert_eq!(archive_path.path_type, ArchivePathEnum::Repo); + assert_eq!(&tpe, ""); + tracing::debug!("[delete_repository] repo_path: {p_str:?}"); + + let path = Path::new(&p_str); + //FIXME: We surely need modify access to delete right?? + check_auth_and_acl(auth.user, "", path, AccessType::Modify)?; + + let storage = STORAGE.get().unwrap(); + if let Err(e) = storage.remove_repository(path) { + tracing::debug!("[got IO error] {e:?}"); + return Err(ErrorKind::RemovingRepositoryFailed( + path.to_string_lossy().into(), + )); + } + + Ok(()) +} + +#[cfg(test)] +mod test { + use crate::handlers::repository::{create_repository, delete_repository}; + use crate::log::print_request_response; + use crate::test_helpers::{ + basic_auth_header_value, init_test_environment, request_uri_for_test, + }; + use axum::http::Method; + use axum::routing::{delete, post}; + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use axum::{middleware, Router}; + use std::path::PathBuf; + use std::{env, fs}; + use tower::ServiceExt; + + /// The acl.toml test allows the create of "repo_remove_me" + /// for user test with the correct password + #[tokio::test] + async fn repo_create_delete_test() { + init_test_environment(); + + //Start with a clean slate ... + let cwd = env::current_dir().unwrap(); + let path = PathBuf::new() + .join(cwd) + .join("tests") + .join("fixtures") + .join("test_data") + .join("test_repos") + .join("repo_remove_me"); + if path.exists() { + fs::remove_dir_all(&path).unwrap(); + assert!(!path.exists()); + } + + let cwd = env::current_dir().unwrap(); + let not_allowed_path = PathBuf::new() + .join(cwd) + .join("tests") + .join("fixtures") + .join("test_data") + .join("test_repos") + .join("repo_not_allowed"); + if not_allowed_path.exists() { + fs::remove_dir_all(¬_allowed_path).unwrap(); + assert!(!not_allowed_path.exists()); + } + + // ------------------------------------ + // Create a new repository: {path}?create=true + // ------------------------------------ + let repo_name_uri = "/repo_remove_me?create=true".to_string(); + let app = Router::new() + .route("/*path", post(create_repository)) + .layer(middleware::from_fn(print_request_response)); + + let request = request_uri_for_test(&repo_name_uri, Method::POST); + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + assert!(path.exists()); + + // ------------------------------------------ + // Create a new repository WITHOUT ACL access + // ------------------------------------------ + let repo_name_uri = "/repo_not_allowed?create=true".to_string(); + let app = Router::new() + .route("/*path", post(create_repository)) + .layer(middleware::from_fn(print_request_response)); + + let request = request_uri_for_test(&repo_name_uri, Method::POST); + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + assert!(!not_allowed_path.exists()); + + // ------------------------------------------ + // Delete a repository WITHOUT ACL access + // ------------------------------------------ + let repo_name_uri = "/repo_remove_me?create=true".to_string(); + let app = Router::new() + .route("/*path", post(delete_repository)) + .layer(middleware::from_fn(print_request_response)); + + let request = Request::builder() + .uri(&repo_name_uri) + .method(Method::POST) + .header( + "Authorization", + basic_auth_header_value("test", Some("__wrong_password__")), + ) + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + assert!(path.exists()); + + // ------------------------------------------ + // Delete a repository WITH access... + // ------------------------------------------ + assert!(path.exists()); // pre condition: repo exists + let repo_name_uri = "/repo_remove_me".to_string(); + let app = Router::new() + .route("/*path", delete(delete_repository)) + .layer(middleware::from_fn(print_request_response)); + + let request = request_uri_for_test(&repo_name_uri, Method::DELETE); + let resp = app.oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + assert!(!path.exists()); + } +} diff --git a/src/lib.rs b/src/lib.rs index ea4ec62..1c394ec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,55 +1,12 @@ -use clap::Parser; -use std::path::PathBuf; -use tracing_subscriber::filter::LevelFilter; - pub mod acl; pub mod auth; +pub mod commands; +pub mod config; pub mod error; -pub mod helpers; +pub mod handlers; pub mod log; -pub mod state; pub mod storage; pub mod web; -/// A REST server build in rust for use with rustic and restic -#[derive(Parser)] -#[command(name = "rustic-server")] -#[command(bin_name = "rustic-server")] -pub struct Opts { - /// listen address - #[arg(short, long, default_value = "localhost")] - pub host: String, - /// listen port https - #[arg(short, long, default_value_t = 8000)] - pub https_port: u16, - /// listen port http - #[arg(short, long, default_value_t = 8080)] - pub http_port: u16, - /// data directory - #[arg(short, long, default_value = "/tmp/restic")] - pub path: PathBuf, - /// disable .htpasswd authentication - #[arg(long)] - pub no_auth: bool, - /// file to read per-repo ACLs from - #[arg(long)] - pub acl: Option, - /// set standard acl to append only mode - #[arg(long)] - pub append_only: bool, - /// set standard acl to only access private repos - #[arg(long)] - pub private_repo: bool, - /// turn on TLS support - #[arg(long)] - pub tls: bool, - /// TLS certificate path - #[arg(long)] - pub cert: Option, - /// TLS key path - #[arg(long)] - pub key: Option, - /// logging level (Off/Error/Warn/Info/Debug/Trace) - #[arg(long, default_value = "Info")] - pub log: LevelFilter, -} +#[cfg(test)] +pub mod test_helpers; diff --git a/src/log.rs b/src/log.rs index 429adf8..764c2d5 100644 --- a/src/log.rs +++ b/src/log.rs @@ -1,3 +1,10 @@ +use crate::error::ErrorKind; +use axum::body::{Body, Bytes}; +use axum::extract::Request; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use http_body_util::BodyExt; +use std::str::FromStr; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; pub fn init_tracing() { @@ -7,5 +14,60 @@ pub fn init_tracing() { .unwrap_or_else(|_| "RUSTIC_SERVER_LOG_LEVEL=debug".into()), ) .with(tracing_subscriber::fmt::layer()) - .init() + .init(); +} + +pub fn init_trace_from(level: &str) { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::from_str(level).unwrap()) + .with(tracing_subscriber::fmt::layer()) + .init(); +} + +/// router middleware function to print additional information on the request, and response. +/// Usage: +/// app = Router::new().layer(middleware::from_fn(print_request_response)) +/// +pub async fn print_request_response( + req: Request, + next: Next, +) -> Result { + let (parts, body) = req.into_parts(); + for (k, v) in parts.headers.iter() { + tracing::debug!("request-header: {k:?} -> {v:?} "); + } + let bytes = buffer_and_print("request", body).await?; + let req = Request::from_parts(parts, Body::from(bytes)); + + let res = next.run(req).await; + + let (parts, body) = res.into_parts(); + for (k, v) in parts.headers.iter() { + tracing::debug!("reply-header: {k:?} -> {v:?} "); + } + let bytes = buffer_and_print("response", body).await?; + let res = Response::from_parts(parts, Body::from(bytes)); + + Ok(res) +} + +async fn buffer_and_print(direction: &str, body: B) -> Result +where + B: axum::body::HttpBody, + B::Error: std::fmt::Display, +{ + let bytes = match body.collect().await { + Ok(collected) => collected.to_bytes(), + Err(err) => { + return Err(ErrorKind::BadRequest(format!( + "failed to read {direction} body: {err}" + ))); + } + }; + + if let Ok(body) = std::str::from_utf8(&bytes) { + tracing::debug!("{direction} body = {body:?}"); + } + + Ok(bytes) } diff --git a/src/state.rs b/src/state.rs deleted file mode 100644 index 6229cc8..0000000 --- a/src/state.rs +++ /dev/null @@ -1,62 +0,0 @@ -use axum_macros::FromRef; - -use crate::{acl::Acl, auth::Auth, storage::LocalStorage}; - -use crate::acl::AclCheckerEnum; -use crate::auth::AuthCheckerEnum; -use crate::storage::StorageEnum; - -#[derive(Debug, Clone, FromRef)] -pub struct AppState { - auth: AuthCheckerEnum, - acl: AclCheckerEnum, - storage: StorageEnum, - tpe: String, -} - -impl Default for AppState { - fn default() -> Self { - Self { - auth: Auth::default().into(), - acl: Acl::default().into(), - storage: LocalStorage::default().into(), - tpe: "".to_string(), - } - } -} - -impl AppState { - pub fn new( - auth: AuthCheckerEnum, - acl: AclCheckerEnum, - storage: StorageEnum, - tpe: String, - ) -> Self { - Self { - storage, - auth, - acl, - tpe, - } - } - - pub fn auth(&self) -> &AuthCheckerEnum { - &self.auth - } - - pub fn acl(&self) -> &AclCheckerEnum { - &self.acl - } - - pub fn storage(&self) -> &StorageEnum { - &self.storage - } - - pub fn tpe(&self) -> &str { - &self.tpe - } - - pub fn set_tpe(&mut self, tpe: String) { - self.tpe = tpe; - } -} diff --git a/src/storage.rs b/src/storage.rs index eebe091..6ccdd80 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1,20 +1,26 @@ +use crate::handlers::file_helpers::WriteOrDeleteFile; +use anyhow::Result; +use once_cell::sync::OnceCell; use std::fs; -use std::path::{Path, PathBuf}; - -use crate::helpers::WriteOrDeleteFile; -use enum_dispatch::enum_dispatch; use std::io::Result as IoResult; +use std::path::{Path, PathBuf}; +use std::sync::Arc; use tokio::fs::File; use walkdir::WalkDir; -#[derive(Debug, Clone)] -#[enum_dispatch] -pub(crate) enum StorageEnum { - LocalStorage(LocalStorage), +//Static storage of our credentials +pub static STORAGE: OnceCell> = OnceCell::new(); + +pub(crate) fn init_storage(storage: impl Storage) -> Result<()> { + if STORAGE.get().is_none() { + let storage = Arc::new(storage); + let _ = STORAGE.set(storage); + } + Ok(()) } #[async_trait::async_trait] -#[enum_dispatch(StorageEnum)] +//#[enum_dispatch(StorageEnum)] pub trait Storage: Send + Sync + 'static { fn create_dir(&self, path: &Path, tpe: &str) -> IoResult<()>; fn read_dir(&self, path: &Path, tpe: &str) -> Box>; @@ -22,6 +28,7 @@ pub trait Storage: Send + Sync + 'static { async fn open_file(&self, path: &Path, tpe: &str, name: &str) -> IoResult; async fn create_file(&self, path: &Path, tpe: &str, name: &str) -> IoResult; fn remove_file(&self, path: &Path, tpe: &str, name: &str) -> IoResult<()>; + fn remove_repository(&self, path: &Path) -> IoResult<()>; } #[derive(Debug, Clone)] @@ -49,7 +56,6 @@ impl LocalStorage { }) } } - #[async_trait::async_trait] impl Storage for LocalStorage { fn create_dir(&self, path: &Path, tpe: &str) -> IoResult<()> { @@ -94,4 +100,69 @@ impl Storage for LocalStorage { let file_path = self.filename(path, tpe, name); fs::remove_file(file_path) } + + fn remove_repository(&self, path: &Path) -> IoResult<()> { + tracing::debug!( + "Deleting repository: {}", + self.path.join(path).to_string_lossy() + ); + fs::remove_dir_all(self.path.join(path)) + } +} + +#[cfg(test)] +mod test { + use crate::storage::{init_storage, LocalStorage, STORAGE}; + use std::env; + use std::path::PathBuf; + + #[test] + fn test_file_access() { + let cwd = env::current_dir().unwrap(); + let repo_path = PathBuf::new() + .join(cwd) + .join("tests") + .join("fixtures") + .join("test_data") + .join("test_repos"); + + let local_storage = LocalStorage::try_new(&repo_path).unwrap(); + init_storage(local_storage).unwrap(); + + let storage = STORAGE.get().unwrap(); + + // path must not start with slash !! that will skip the self.path from Storage! + let path = PathBuf::new().join("test_repo/"); + let c = storage.read_dir(&path, "keys"); + let mut found = false; + for a in c.into_iter() { + let file_name = a.file_name().to_string_lossy(); + if file_name == "2e734da3fccb98724ece44efca027652ba7a335c224448a68772b41c0d9229d5" { + found = true; + break; + } + } + assert!(found); + } + + #[tokio::test] + async fn test_config_access() { + let cwd = env::current_dir().unwrap(); + let repo_path = PathBuf::new() + .join(cwd) + .join("tests") + .join("fixtures") + .join("test_data") + .join("test_repos"); + + let local_storage = LocalStorage::try_new(&repo_path).unwrap(); + init_storage(local_storage).unwrap(); + + let storage = STORAGE.get().unwrap(); + + // path must not start with slash !! that will skip the self.path from Storage! + let path = PathBuf::new().join("test_repo/"); + let c = storage.open_file(&path, "", "config").await; + assert!(c.is_ok()) + } } diff --git a/src/test_helpers.rs b/src/test_helpers.rs new file mode 100644 index 0000000..2c1bd70 --- /dev/null +++ b/src/test_helpers.rs @@ -0,0 +1,132 @@ +use crate::acl::{init_acl, Acl}; +use crate::auth::{init_auth, Auth}; +use crate::storage::{init_storage, LocalStorage}; +use axum::body::Body; +use axum::http::{HeaderValue, Method}; +use once_cell::sync::OnceCell; +use std::env; +use std::path::PathBuf; +use std::sync::Mutex; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +// ------------------------------------------------ +// test facility prevent repeated calls in tests +// ------------------------------------------------ + +/// Common requests, using a password that should +/// be recognized as OK for the repository we are trying to access. +pub fn request_uri_for_test(uri: &str, method: Method) -> axum::http::Request { + axum::http::Request::builder() + .uri(uri) + .method(method) + .header( + "Authorization", + basic_auth_header_value("test", Some("test_pw")), + ) + .body(Body::empty()) + .unwrap() +} + +// ------------------------------------------------ +// test facility for tracing +// ------------------------------------------------ + +pub(crate) fn init_tracing() { + init_mutex(); +} + +/// When we initialise the global tracing subscriber, this must only happen once. +/// During tests, each test will initialise, to make sure we have at least tracing once. +/// This means that the init() call must be robust for this. +/// Since we do not need this in production code, it is located in the test code. +static TRACER: OnceCell> = OnceCell::new(); +fn init_mutex() { + TRACER.get_or_init(|| { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "RUSTIC_SERVER_LOG_LEVEL=debug".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + Mutex::new(0) + }); +} + +// ------------------------------------------------ +// test facility for creating a minimum test environment +// ------------------------------------------------ + +pub(crate) fn init_test_environment() { + init_tracing(); + test_init_static_htaccess(); + test_init_static_auth(); + test_init_static_storage(); +} + +fn test_init_static_htaccess() { + let cwd = env::current_dir().unwrap(); + let htaccess = PathBuf::new() + .join(cwd) + .join("tests") + .join("fixtures") + .join("test_data") + .join("htaccess"); + tracing::debug!("[test_init_static_storage] repo: {:?}", &htaccess); + let auth = Auth::from_file(false, &htaccess).unwrap(); + init_auth(auth).unwrap(); +} + +fn test_init_static_auth() { + let cwd = env::current_dir().unwrap(); + let acl_path = PathBuf::new() + .join(cwd) + .join("tests") + .join("fixtures") + .join("test_data") + .join("acl.toml"); + tracing::debug!("[test_init_static_storage] repo: {:?}", &acl_path); + let acl = Acl::from_file(false, true, Some(acl_path)).unwrap(); + init_acl(acl).unwrap(); +} + +fn test_init_static_storage() { + let cwd = env::current_dir().unwrap(); + let repo_path = PathBuf::new() + .join(cwd) + .join("tests") + .join("fixtures") + .join("test_data") + .join("test_repos"); + tracing::debug!("[test_init_static_storage] repo: {:?}", &repo_path); + let local_storage = LocalStorage::try_new(&repo_path).unwrap(); + init_storage(local_storage).unwrap(); +} + +// ------------------------------------------------ +// test facility for authentication +// ------------------------------------------------ + +/// Creates a header value from a username, and password. +/// Copy for the reqwest crate; +pub(crate) fn basic_auth_header_value(username: U, password: Option

) -> HeaderValue +where + U: std::fmt::Display, + P: std::fmt::Display, +{ + use base64::prelude::BASE64_STANDARD; + use base64::write::EncoderWriter; + use std::io::Write; + + let mut buf = b"Basic ".to_vec(); + { + let mut encoder = EncoderWriter::new(&mut buf, &BASE64_STANDARD); + let _ = write!(encoder, "{}:", username); + if let Some(password) = password { + let _ = write!(encoder, "{}", password); + } + } + let mut header = HeaderValue::from_bytes(&buf).expect("base64 is always valid HeaderValue"); + header.set_sensitive(true); + header +} diff --git a/src/web.rs b/src/web.rs index 2438184..c1423ca 100644 --- a/src/web.rs +++ b/src/web.rs @@ -7,515 +7,121 @@ // storage - to access the file system // auth - for user authentication // acl - for access control - -use axum::{ - body::Body, - extract::{Path as PathExtract, Query, State}, - http::{header, Request, StatusCode}, - response::{AppendHeaders, IntoResponse}, - routing::{get, head, post}, - Json, Router, -}; - -use axum_macros::debug_handler; - +// +// FIXME: decide either to keep, or change. Then remove the remarks below +// During the rout table creation, we loop over types. Rationale: +// We can not distinguish paths using `:tpe` matching in the router. +// The routing path would then become "/:path/:tpe/:name +// This seems not supported by the Serde parser (I assume that is what is used under the hood) +// +// So, instead, we loop over the types, and create a route path for each "explicit" type. +// The handlers will then analyse the path to determine (path, type, name/config). + +// An alternative design might be that we create helper functions like so: +// - get_file_data() --> calls get_file( ..., "data") +// - get_file_config() --> calls get_file( ..., "config") +// - get_file_keys() --> calls get_file( ..., "keys") +// etc, etc, +// When adding these to the router, we can use the Axum::Path to get the path without having +// to re-analyse the URI like we do now. TBI: does this speed up the server? + +use axum::routing::{get, head, post}; +use axum::{middleware, Router}; use axum_server::tls_rustls::RustlsConfig; -use futures_util::StreamExt; -use http_range::HttpRange; -use serde_derive::{Deserialize, Serialize}; -use std::{convert::TryInto, marker::Unpin, path::Path as StdPath, sync::Arc}; -use std::{net::SocketAddr, path::PathBuf}; -use tokio::io::AsyncWrite; -use tokio::io::SeekFrom::Start; -use tokio::{io::copy, io::AsyncSeekExt}; -use tokio_util::io::ReaderStream; - -use crate::{ - acl::{AccessType, AclChecker}, - error::{ErrorKind, Result}, - helpers::{Finalizer, IteratorAdapter}, - storage::Storage, -}; - -use crate::state::AppState; - -const API_V1: &str = "application/vnd.x.restic.rest.v1"; -const API_V2: &str = "application/vnd.x.restic.rest.v2"; -const TYPES: [&str; 5] = ["data", "keys", "locks", "snapshots", "index"]; -const DEFAULT_PATH: &str = ""; -const CONFIG_TYPE: &str = "config"; -const CONFIG_NAME: &str = ""; - -#[derive(Clone)] -struct TpeState(pub String); - -#[derive(Serialize)] -struct RepoPathEntry { - name: String, - size: u64, -} - -#[derive(Clone, Copy)] -pub struct Ports { - pub http: u16, - pub https: u16, -} - -// TODO! -// #[async_trait::async_trait] -// impl tide_http_auth::Storage for State { -// async fn get_user(&self, request: BasicAuthRequest) -> Result> { -// let user = request.username; -// match self.auth.verify(&user, &request.password) { -// true => Ok(Some(user)), -// false => Ok(None), -// } -// } -// } - -fn check_string_sha256(name: &str) -> bool { - if name.len() != 64 { - return false; - } - for c in name.chars() { - if !c.is_ascii_digit() && !('a'..='f').contains(&c) { - return false; - } - } - true -} - -fn check_name(tpe: &str, name: &str) -> Result { - match tpe { - "config" => Ok(()), - _ if check_string_sha256(name) => Ok(()), - _ => Err(ErrorKind::FilenameNotAllowed(name.to_string()).into()), - } -} - -fn check_auth_and_acl( - state: Arc, - path: &StdPath, - append: AccessType, -) -> Result { - // don't allow paths that includes any of the defined types - for part in path.iter() { - if let Some(part) = part.to_str() { - for tpe in TYPES.iter() { - if &part == tpe { - return Err(ErrorKind::PathNotAllowed(path.display().to_string()).into()); - } - } - } - } - - let empty = String::new(); - // TODO!: How to get extension value? - let user: &str = state.ext::().unwrap_or(&empty); - let Some(path) = path.to_str() else { - return Err(ErrorKind::NonUnicodePath(path.display().to_string()).into()); - }; - let allowed = state.acl().allowed(user, path, state.tpe(), append); - tracing::debug!( - "[auth] user: {user}, path: {path}, tpe: {:#?}, allowed: {allowed}", - state.tpe() - ); - - match allowed { - true => Ok(StatusCode::OK), - false => Err(ErrorKind::PathNotAllowed(path.to_string()).into()), - } -} - -#[derive(Default, Deserialize)] -#[serde(default)] -struct Create { - create: bool, -} - -#[debug_handler] -async fn create_dirs( - State(state): State>, - Query(params): Query, - path: Option>, -) -> Result { - let unpacked_path = path.map_or(DEFAULT_PATH.to_string(), |PathExtract(path_ext)| path_ext); - let path = StdPath::new(&unpacked_path); - - tracing::debug!("[create_dirs] path: {path:?}"); - - check_auth_and_acl(state.clone(), path, AccessType::Append)?; - let c: Create = params; - match c.create { - true => { - for tpe in TYPES.iter() { - match state.storage().create_dir(path, tpe) { - Ok(_) => (), - Err(e) => return Err(ErrorKind::CreatingDirectoryFailed(e.to_string()).into()), - }; - } - - return Ok(( - StatusCode::OK, - format!("Called create_files with path {:?}\n", path), - )); - } - false => { - return Ok(( - StatusCode::OK, - format!("Called create_files with path {:?}, create=false\n", path), - )) - } - } -} - -#[debug_handler] -async fn list_files( - State(state): State>, - path: Option>, - req: Request, -) -> Result { - let tpe = state.tpe(); - let unpacked_path = path.map_or(DEFAULT_PATH.to_string(), |PathExtract(path_ext)| path_ext); - let path = StdPath::new(&unpacked_path); - - tracing::debug!("[list_files] path: {path:?}, tpe: {tpe}"); - - check_auth_and_acl(state.clone(), path, AccessType::Read)?; - - let read_dir = state.storage().read_dir(path, tpe); - - // TODO: error handling - let res = match req.headers().get("Accept") { - Some(a) - if match a.to_str() { - Ok(s) => s == API_V2, - Err(_) => false, // possibly not a String - } => - { - let read_dir_version = read_dir.map(|e| RepoPathEntry { - name: e.file_name().to_str().unwrap().to_string(), - size: e.metadata().unwrap().len(), - }); - let mut response = Json(&IteratorAdapter::new(read_dir_version)).into_response(); - response.headers_mut().insert( - header::CONTENT_TYPE, - header::HeaderValue::from_static(API_V2), - ); - let status = response.status_mut(); - *status = StatusCode::OK; - response - } - _ => { - let read_dir_version = read_dir.map(|e| e.file_name().to_str().unwrap().to_string()); - let mut response = Json(&IteratorAdapter::new(read_dir_version)).into_response(); - response.headers_mut().insert( - header::CONTENT_TYPE, - header::HeaderValue::from_static(API_V1), - ); - let status = response.status_mut(); - *status = StatusCode::OK; - response - } - }; - - Ok(res) -} - -#[debug_handler] -async fn length( - PathExtract(path): PathExtract, - State(state): State>, - PathExtract(name): PathExtract, - _req: Request, -) -> Result<()> { - let tpe = state.tpe(); - tracing::debug!("[length] path: {path}, tpe: {tpe}, name: {name}"); - let path = StdPath::new(&path); - - check_name(tpe, name.as_str())?; - check_auth_and_acl(state.clone(), path, AccessType::Read)?; - - let _file = state.storage().filename(path, tpe, name.as_str()); - Err(ErrorKind::NotImplemented.into()) -} - -#[debug_handler] -async fn get_file( - State(state): State>, - PathExtract(name): PathExtract, - path: Option>, - req: Request, -) -> Result { - let tpe = state.tpe(); - let unpacked_path = path.map_or(DEFAULT_PATH.to_string(), |PathExtract(path_ext)| path_ext); - let path = StdPath::new(&unpacked_path); - - tracing::debug!("[get_file] path: {path:?}, tpe: {tpe}, name: {name}"); - - check_name(tpe, name.as_str())?; - let path = StdPath::new(path); - check_auth_and_acl(state.clone(), path, AccessType::Read)?; - - let Ok(mut file) = state.storage().open_file(path, tpe, name.as_str()).await else { - return Err(ErrorKind::FileNotFound(path.display().to_string()).into()); - }; - - let mut len = match file.metadata().await { - Ok(val) => val.len(), - Err(_) => { - return Err(ErrorKind::GettingFileMetadataFailed.into()); - } - }; - - let status; - match req.headers().get("Range") { - None => { - status = StatusCode::OK; - } - Some(header_value) => match HttpRange::parse( - match header_value.to_str() { - Ok(val) => val, - Err(_) => return Err(ErrorKind::RangeNotValid.into()), - }, - len, - ) { - Ok(range) if range.len() == 1 => { - let Ok(_) = file.seek(Start(range[0].start)).await else { - return Err(ErrorKind::SeekingFileFailed.into()); - }; - - len = range[0].length; - status = StatusCode::PARTIAL_CONTENT; - } - Ok(_) => return Err(ErrorKind::MultipartRangeNotImplemented.into()), - Err(_) => return Err(ErrorKind::GeneralRange.into()), - }, - }; - - // From: https://github.com/tokio-rs/axum/discussions/608#discussioncomment-1789020 - let stream = ReaderStream::with_capacity( - file, - match len.try_into() { - Ok(val) => val, - Err(_) => return Err(ErrorKind::ConversionToU64Failed.into()), - }, - ); - - let body = Body::from_stream(stream); - - let headers = AppendHeaders([(header::CONTENT_TYPE, "application/octet-stream")]); - Ok((status, headers, body)) -} - -async fn save_body( - mut file: impl AsyncWrite + Unpin + Finalizer, - stream: &mut Body, -) -> Result { - let mut bytes_written_overall = 0_u64; - - while let Some(chunk) = stream.into_data_stream().next().await { - let Ok(chunk) = chunk else { - return Err(ErrorKind::ReadingFromStreamFailed.into()); - }; - let bytes_written = match copy(&mut chunk, &mut file).await { - Ok(val) => val, - Err(_) => return Err(ErrorKind::WritingToFileFailed.into()), - }; - bytes_written_overall += bytes_written; - } - - tracing::debug!("[file written] bytes: {bytes_written_overall}"); - let Ok(_) = file.finalize().await else { - return Err(ErrorKind::FinalizingFileFailed.into()); - }; - Ok(StatusCode::OK) -} - -async fn get_save_file( - path: Option, - state: Arc, - name: String, -) -> std::result::Result { - let tpe = state.tpe(); - let unpacked_path = path.map_or(DEFAULT_PATH.to_string(), |path_ext| path_ext); - let path = StdPath::new(&unpacked_path); - - tracing::debug!("[get_save_file] path: {path:?}, tpe: {tpe}, name: {name}"); - - let Ok(_) = check_name(tpe, name.as_str()) else { - return Err(ErrorKind::FilenameNotAllowed(name).into()); - }; - - let Ok(_) = check_auth_and_acl(state.clone(), path, AccessType::Append) else { - return Err(ErrorKind::PathNotAllowed(path.display().to_string()).into()); - }; - - match state.storage().create_file(path, tpe, name.as_str()).await { - Ok(val) => Ok(val), - Err(_) => return Err(ErrorKind::GettingFileHandleFailed.into()), - } -} - -#[debug_handler] -async fn delete_file( - State(state): State>, - PathExtract(name): PathExtract, - path: Option>, -) -> Result { - let tpe = state.tpe(); - let unpacked_path = path.map_or(DEFAULT_PATH.to_string(), |PathExtract(path_ext)| path_ext); - let path = StdPath::new(&unpacked_path); - - let Ok(_) = check_name(tpe, name.as_str()) else { - return Err(ErrorKind::FilenameNotAllowed(name).into()); - }; - - let Ok(_) = check_auth_and_acl(state.clone(), path, AccessType::Modify) else { - return Err(ErrorKind::PathNotAllowed(path.display().to_string()).into()); - }; - - let Ok(_) = state.storage().remove_file(path, tpe, name.as_str()) else { - return Err(ErrorKind::RemovingFileFailed.into()); - }; - - Ok(StatusCode::OK) -} - -// TODO!: Authentication middleware -// async fn auth_handler(AuthBasic((id, password)): AuthBasic) -> Result { -// tracing::debug!("[auth_handler] id: {id}, password: {password}"); -// match id.as_str() { -// "user" if password == "password" => Ok(id), -// _ => Err(axum::Error::from_str(StatusCode::Forbidden, "not allowed")), -// } -// } - -// TODO!: https://github.com/tokio-rs/axum/blob/main/examples/tls-rustls/src/main.rs -// TODO!: https://github.com/tokio-rs/axum/blob/main/examples/readme/src/main.rs -pub async fn main( - mut state: AppState, - addr: String, - ports: Ports, +use std::net::SocketAddr; +use tokio::net::TcpListener; +use tracing::level_filters::LevelFilter; + +use crate::acl::{init_acl, Acl}; +use crate::auth::{init_auth, Auth}; +use crate::handlers::file_config::{add_config, delete_config, get_config, has_config}; +use crate::handlers::file_exchange::{add_file, delete_file, get_file}; +use crate::handlers::file_length::file_length; +use crate::handlers::files_list::list_files; +use crate::handlers::path_analysis::TYPES; +use crate::handlers::repository::{create_repository, delete_repository}; +use crate::log::print_request_response; +use crate::storage::init_storage; +use crate::storage::Storage; +use anyhow::Result; + +/// FIXME: original Restic interface seems not to provide a "delete repository" interface. +pub async fn start_web_server( + acl: Acl, + auth: Auth, + storage: impl Storage, + socket_address: SocketAddr, tls: bool, cert: Option, key: Option, ) -> Result<()> { - // TODO! - // let mid = tide_http_auth::Authentication::new(BasicAuthScheme); - - // app.with(mid); - - let shared_state = Arc::new(state); - - let mut app = Router::new(); - - app.route("/", post(create_dirs).with_state(shared_state)); - app.route("/:path/", post(create_dirs).with_state(shared_state)); + init_acl(acl)?; + init_auth(auth)?; + init_storage(storage)?; + + // ------------------------------------- + // Create routing structure + // ------------------------------------- + let mut app = Router::new().route( + "/:path/config", + head(has_config) + .post(add_config) + .get(get_config) + .delete(delete_config), + ); + // Fixme: Are we faster by creating a "function" per type and skip analysing the path in each call? for tpe in TYPES.into_iter() { - let path = &("/".to_string() + tpe + "/"); - tracing::debug!("add path: {path}"); - - shared_state.set_tpe(tpe.to_string()); - - app.route(path, get(list_files).with_state(shared_state)); - - let path = &("/".to_string() + tpe + "/:name"); - tracing::debug!("add path: {path}"); - app.route( - path, - head(length) - .get(get_file) - .post(move |mut req: Request| async move { - let PathExtract(name): PathExtract; - let file = get_save_file(None, shared_state, name).await?; - - let mut async_body: Body; - save_body(file, &mut async_body).await - }) - .delete(delete_file), - ) - .with_state(shared_state); - - let path = &("/:path/".to_string() + tpe + "/"); - tracing::debug!("add path: {path}"); - app.route(path, get(list_files)).with_state(shared_state); - - let path = &("/:path/".to_string() + tpe + "/:name"); - tracing::debug!("add path: {path}"); - app.route( - path, - head(length) + let path1 = format!("/:path/{}/", &tpe); + let path2 = format!("/:path/{}/:name", &tpe); + app = app.route(path1.as_str(), get(list_files)).route( + path2.as_str(), + head(file_length) .get(get_file) - .post(move |mut req: Request| async move { - let PathExtract(name): PathExtract; - let PathExtract(path): PathExtract; - let file = get_save_file(Some(path), shared_state, name).await?; - - let mut async_body: Body; - save_body(file, &mut async_body).await - }) + .post(add_file) .delete(delete_file), - ) - .with_state(shared_state); + ); } - app.route( - "config", - get(get_file) - .post(move |mut req: Request| async move { - shared_state.set_tpe(CONFIG_TYPE.to_string()); - let file = get_save_file(None, shared_state, CONFIG_NAME.to_string()).await?; - - let mut async_body: Body; - save_body(file, &mut async_body).await - }) - .delete(delete_file), - ); - - app.route( - "/:path/config", - get(get_file) - .post(move |mut req: Request| async move { - let PathExtract(name): PathExtract; - let PathExtract(path): PathExtract; - shared_state.set_tpe(CONFIG_TYPE.to_string()); - let file = get_save_file(Some(path), shared_state, CONFIG_NAME.to_string()).await?; - - let mut async_body: Body; - save_body(file, &mut async_body).await - }) - .delete(delete_file), - ); + app = app.route("/:path/", post(create_repository).delete(delete_repository)); - // configure certificate and private key used by https - let config = match tls { - true => Some( - RustlsConfig::from_pem_file( - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("self_signed_certs") - .join("cert.pem"), - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("self_signed_certs") - .join("key.pem"), - ) - .await - .unwrap(), - ), - false => None, + // ----------------------------------------------- + // Extra logging requested. Handlers will log too + // ---------------------------------------------- + let level_filter = LevelFilter::current(); + match level_filter { + LevelFilter::TRACE | LevelFilter::DEBUG | LevelFilter::INFO => { + app = app.layer(middleware::from_fn(print_request_response)); + } + _ => {} }; - // run https server - let addr = SocketAddr::from(([127, 0, 0, 1], ports.https)); - tracing::debug!("listening on {}", addr); - match config { - Some(config) => axum_server::bind_rustls(addr, config) - .serve(app) + // ----------------------------------------------- + // Start server with or without TLS + // ----------------------------------------------- + match tls { + false => { + println!("rustic_server listening on {}", &socket_address); + axum::serve( + TcpListener::bind(socket_address).await.unwrap(), + app.into_make_service(), + ) .await - .unwrap(), - None => axum_server::bind(addr).serve(app).await.unwrap(), + .unwrap(); + } + true => { + assert!(cert.is_some()); + assert!(key.is_some()); + let config = RustlsConfig::from_pem_file(cert.unwrap(), key.unwrap()) + .await + .unwrap(); + + println!("rustic_server listening on {}", &socket_address); + axum_server::bind_rustls(socket_address, config) + .serve(app.into_make_service()) + .await + .unwrap(); + } } - Ok(()) } diff --git a/tests/fixtures/test_data/README.md b/tests/fixtures/test_data/README.md new file mode 100644 index 0000000..fe175f3 --- /dev/null +++ b/tests/fixtures/test_data/README.md @@ -0,0 +1,64 @@ +# Test data folder + +The test data folder contains data required for testing the server. + +FIXME: Future move to a container to also allow rustic interfaces to be tested +abainst the rustic server? + +# Basic files for test access to a repository + +### `HTACCESS` + +File governing the access to the server. Without access all is rejected. + +htaccess file has one entry: + +- user: test +- password: test_pw + +### `acl.toml` + +Definition which user from the HTACCESS file has what privileges on which +repository. + +Most used seems to be the `test_repo` with members + +- user: test +- Access level: Read But there are 2 more in the file. + +### `rustic_server.toml` + +Server configuration file which allows the `rustic_server` to be started with +only a pointer to this file. This file points to: + +- HTACCESS file
Note: that the HTACCESS file does not need to be a hidden + file. Rustic will use the file you point to. +- acl.toml file +- path to: repository (where all your backups are) +- path to: https TLS certiciate and key file +- dns_hostname, and port to listen to + +This file allows a server to be started. + +### `rustic.toml` + +Configuration file for the `rustic` commands. Start as: + +``` +rustic -P /test.toml +``` + +In the configuration folder there is an example given. Adapt to your +configuration. To make use of the `test_repo`, the file has to contain the +following credentials: + +``` +[repository] +repository = "rest:http://test:test_pw@localhost:8000/test_repo" +password = "test_pw" +``` + +# Repository + +There are 2 folders with test data. One source folder, and a repository folder +which should be the folder that contains the rustic backup of the source folder. diff --git a/tests/fixtures/test_data/acl.toml b/tests/fixtures/test_data/acl.toml new file mode 100644 index 0000000..d7b9ba7 --- /dev/null +++ b/tests/fixtures/test_data/acl.toml @@ -0,0 +1,8 @@ +[test_repo] +test = "Append" + +[repo_remove_me] +test = "Modify" + +[repo_remove_me_2] +test = "Modify" diff --git a/tests/fixtures/test_data/htaccess b/tests/fixtures/test_data/htaccess new file mode 100644 index 0000000..2aef889 --- /dev/null +++ b/tests/fixtures/test_data/htaccess @@ -0,0 +1 @@ +test:$apr1$631R5SLJ$yQvTCYnaVXJsHq.pXctqB1 diff --git a/tests/fixtures/test_data/rustic_server.toml b/tests/fixtures/test_data/rustic_server.toml new file mode 100644 index 0000000..9571044 --- /dev/null +++ b/tests/fixtures/test_data/rustic_server.toml @@ -0,0 +1,16 @@ +[server] +host_dns_name = "127.0.0.1" +port = 8000 +protocol = "http" + +[repos] +storage_path = "./test_data/test_repos/" + +[authorization] +auth_path = "/test_data/test_repo/htaccess" +use_auth = true + +[accesscontrol] +acl_path = "/test_data/test_repo/acl.toml" +private_repo = true +append_only = false diff --git a/tests/fixtures/test_data/test_repo_source/my_file.html b/tests/fixtures/test_data/test_repo_source/my_file.html new file mode 100644 index 0000000..e0b296b --- /dev/null +++ b/tests/fixtures/test_data/test_repo_source/my_file.html @@ -0,0 +1 @@ +

hello wold

diff --git a/tests/fixtures/test_data/test_repo_source/my_file.txt b/tests/fixtures/test_data/test_repo_source/my_file.txt new file mode 100644 index 0000000..3b18e51 --- /dev/null +++ b/tests/fixtures/test_data/test_repo_source/my_file.txt @@ -0,0 +1 @@ +hello world diff --git a/tests/fixtures/test_data/test_repo_source/my_folder/my_file.html b/tests/fixtures/test_data/test_repo_source/my_folder/my_file.html new file mode 100644 index 0000000..1c478b5 --- /dev/null +++ b/tests/fixtures/test_data/test_repo_source/my_folder/my_file.html @@ -0,0 +1 @@ +

hello wold in folder

diff --git a/tests/fixtures/test_data/test_repo_source/my_folder/my_file.txt b/tests/fixtures/test_data/test_repo_source/my_folder/my_file.txt new file mode 100644 index 0000000..981feff --- /dev/null +++ b/tests/fixtures/test_data/test_repo_source/my_folder/my_file.txt @@ -0,0 +1 @@ +hello world in folder diff --git a/tests/fixtures/test_data/test_repo_source/my_folder/random_data.bin b/tests/fixtures/test_data/test_repo_source/my_folder/random_data.bin new file mode 100644 index 0000000..e468a6f Binary files /dev/null and b/tests/fixtures/test_data/test_repo_source/my_folder/random_data.bin differ diff --git a/tests/fixtures/test_data/test_repos/test_repo/config b/tests/fixtures/test_data/test_repos/test_repo/config new file mode 100644 index 0000000..0a91b7c --- /dev/null +++ b/tests/fixtures/test_data/test_repos/test_repo/config @@ -0,0 +1 @@ +0wsz}zB VՅhNX `w)FW'l&9orYp371Ԕ%$PLkG!?Ƙ&qʵ_uλzOw!`NWx_P0E-(? \ No newline at end of file diff --git a/tests/fixtures/test_data/test_repos/test_repo/index/.gitkeep b/tests/fixtures/test_data/test_repos/test_repo/index/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/test_data/test_repos/test_repo/keys/2e734da3fccb98724ece44efca027652ba7a335c224448a68772b41c0d9229d5 b/tests/fixtures/test_data/test_repos/test_repo/keys/2e734da3fccb98724ece44efca027652ba7a335c224448a68772b41c0d9229d5 new file mode 100644 index 0000000..0a402ed --- /dev/null +++ b/tests/fixtures/test_data/test_repos/test_repo/keys/2e734da3fccb98724ece44efca027652ba7a335c224448a68772b41c0d9229d5 @@ -0,0 +1 @@ +{"kdf":"scrypt","N":131072,"r":8,"p":1,"data":"Qm1Fk6IigCfeoy26El1UXb1DSLHtnqRLmZpGVXPxC2yTHxF+ML3Cj0m4eJ2InuIBUi5sbnT+Bpv6988ycGSp994GU2sLZQPrtvKg0SqABKYgcMkpKopiBv8nqiOsZ3/gIytEO5voyrewIVKrhOAmcv69AknNpFnCI5VhC77n4soP8U+E/F5TIBvKUPoVq8kGEyJ8ikoIciX/pGeKLH9EZQ==","salt":"cBmZojhWqBAktsSv9X6TKrifriWeT5zM26fpt4nsdbojRbzx31KO+Tj9TThRG4c3VHg7HW8bS/5shEZz4K0MoQ=="} \ No newline at end of file diff --git a/tests/fixtures/test_data/test_repos/test_repo/locks/.gitkeep b/tests/fixtures/test_data/test_repos/test_repo/locks/.gitkeep new file mode 100644 index 0000000..45adbb2 --- /dev/null +++ b/tests/fixtures/test_data/test_repos/test_repo/locks/.gitkeep @@ -0,0 +1 @@ +.gitkeep \ No newline at end of file diff --git a/tests/fixtures/test_data/test_repos/test_repo/snapshots/.gitkeep b/tests/fixtures/test_data/test_repos/test_repo/snapshots/.gitkeep new file mode 100644 index 0000000..e69de29