WIP
This commit is contained in:
		
							
								
								
									
										63
									
								
								virtweb_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										63
									
								
								virtweb_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							| @@ -1224,8 +1224,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c" | ||||
| dependencies = [ | ||||
|  "cfg-if", | ||||
|  "js-sys", | ||||
|  "libc", | ||||
|  "wasi", | ||||
|  "wasm-bindgen", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @@ -1596,6 +1598,21 @@ dependencies = [ | ||||
|  "wasm-bindgen", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "jsonwebtoken" | ||||
| version = "9.3.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f" | ||||
| dependencies = [ | ||||
|  "base64 0.21.7", | ||||
|  "js-sys", | ||||
|  "pem", | ||||
|  "ring", | ||||
|  "serde", | ||||
|  "serde_json", | ||||
|  "simple_asn1", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "language-tags" | ||||
| version = "0.3.2" | ||||
| @@ -2042,6 +2059,16 @@ version = "1.0.14" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" | ||||
|  | ||||
| [[package]] | ||||
| name = "pem" | ||||
| version = "3.0.4" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" | ||||
| dependencies = [ | ||||
|  "base64 0.22.0", | ||||
|  "serde", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "percent-encoding" | ||||
| version = "2.3.1" | ||||
| @@ -2376,6 +2403,21 @@ dependencies = [ | ||||
|  "bytemuck", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "ring" | ||||
| version = "0.17.8" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" | ||||
| dependencies = [ | ||||
|  "cc", | ||||
|  "cfg-if", | ||||
|  "getrandom", | ||||
|  "libc", | ||||
|  "spin", | ||||
|  "untrusted", | ||||
|  "windows-sys 0.52.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "rust-embed" | ||||
| version = "8.3.0" | ||||
| @@ -2620,6 +2662,18 @@ dependencies = [ | ||||
|  "quote", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "simple_asn1" | ||||
| version = "0.6.2" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" | ||||
| dependencies = [ | ||||
|  "num-bigint", | ||||
|  "num-traits", | ||||
|  "thiserror", | ||||
|  "time", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "slab" | ||||
| version = "0.4.9" | ||||
| @@ -3020,6 +3074,12 @@ dependencies = [ | ||||
|  "subtle", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "untrusted" | ||||
| version = "0.9.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" | ||||
|  | ||||
| [[package]] | ||||
| name = "url" | ||||
| version = "2.5.0" | ||||
| @@ -3137,6 +3197,7 @@ dependencies = [ | ||||
|  "futures-util", | ||||
|  "image", | ||||
|  "ipnetwork", | ||||
|  "jsonwebtoken", | ||||
|  "lazy-regex", | ||||
|  "lazy_static", | ||||
|  "light-openid", | ||||
| @@ -3144,9 +3205,11 @@ dependencies = [ | ||||
|  "mime_guess", | ||||
|  "nix", | ||||
|  "num", | ||||
|  "pem", | ||||
|  "quick-xml", | ||||
|  "rand", | ||||
|  "reqwest", | ||||
|  "ring", | ||||
|  "rust-embed", | ||||
|  "serde", | ||||
|  "serde_json", | ||||
|   | ||||
| @@ -45,3 +45,6 @@ rust-embed = { version = "8.3.0" } | ||||
| mime_guess = "2.0.4" | ||||
| dotenvy = "0.15.7" | ||||
| nix = { version = "0.28.0", features = ["net"] } | ||||
| jsonwebtoken = "9.3.0" | ||||
| ring = "0.17.8" | ||||
| pem = "3.0.4" | ||||
							
								
								
									
										105
									
								
								virtweb_backend/src/api_tokens.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								virtweb_backend/src/api_tokens.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,105 @@ | ||||
| //! # API tokens management | ||||
|  | ||||
| use crate::constants; | ||||
| use crate::utils::jwt_utils; | ||||
| use crate::utils::jwt_utils::{TokenPrivKey, TokenPubKey}; | ||||
| use crate::utils::time_utils::time; | ||||
|  | ||||
| #[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Debug)] | ||||
| pub struct TokenID(pub uuid::Uuid); | ||||
|  | ||||
| #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] | ||||
| pub struct Token { | ||||
|     pub name: String, | ||||
|     pub description: String, | ||||
|     pub id: TokenID, | ||||
|     created: u64, | ||||
|     updated: u64, | ||||
|     pub pub_key: TokenPubKey, | ||||
|     pub rights: Vec<TokenRights>, | ||||
|     pub last_used: Option<u64>, | ||||
|     pub ip_restriction: Option<ipnetwork::IpNetwork>, | ||||
|     pub delete_after_inactivity: Option<u64>, | ||||
| } | ||||
|  | ||||
| #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, Eq, PartialEq)] | ||||
| pub enum TokenVerb { | ||||
|     GET, | ||||
|     POST, | ||||
|     PUT, | ||||
|     PATCH, | ||||
|     DELETE, | ||||
| } | ||||
|  | ||||
| #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] | ||||
| pub struct TokenRights { | ||||
|     verb: TokenVerb, | ||||
|     uri: String, | ||||
| } | ||||
|  | ||||
| /// Structure used to create a token | ||||
| #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] | ||||
| pub struct NewToken { | ||||
|     pub name: String, | ||||
|     pub description: String, | ||||
|     pub rights: Vec<TokenRights>, | ||||
|     pub ip_restriction: Option<ipnetwork::IpNetwork>, | ||||
|     pub delete_after_inactivity: Option<u64>, | ||||
| } | ||||
|  | ||||
| impl NewToken { | ||||
|     /// Check for error in token | ||||
|     pub fn check_error(&self) -> Option<&'static str> { | ||||
|         if self.name.len() < constants::API_TOKEN_NAME_MIN_LENGTH { | ||||
|             return Some("Name is too short!"); | ||||
|         } | ||||
|  | ||||
|         if self.name.len() > constants::API_TOKEN_NAME_MAX_LENGTH { | ||||
|             return Some("Name is too long!"); | ||||
|         } | ||||
|  | ||||
|         if self.description.len() < constants::API_TOKEN_DESCRIPTION_MIN_LENGTH { | ||||
|             return Some("Description is too short!"); | ||||
|         } | ||||
|  | ||||
|         if self.description.len() > constants::API_TOKEN_DESCRIPTION_MAX_LENGTH { | ||||
|             return Some("Description is too long!"); | ||||
|         } | ||||
|  | ||||
|         for r in &self.rights { | ||||
|             if !r.uri.starts_with("/api/") { | ||||
|                 return Some("All API rights shall start with /api/"); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if let Some(t) = self.delete_after_inactivity { | ||||
|             if t < 3600 { | ||||
|                 return Some("API tokens shall be valid for at least 1 hour!"); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         None | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Create a new Token | ||||
| pub async fn create(token: &NewToken) -> anyhow::Result<(Token, TokenPrivKey)> { | ||||
|     let (pub_key, priv_key) = jwt_utils::generate_key_pair()?; | ||||
|  | ||||
|     let full_token = Token { | ||||
|         name: token.name.to_string(), | ||||
|         description: token.description.to_string(), | ||||
|         id: TokenID(uuid::Uuid::new_v4()), | ||||
|         created: time(), | ||||
|         updated: time(), | ||||
|         pub_key, | ||||
|         rights: token.rights.clone(), | ||||
|         last_used: Some(time()), | ||||
|         ip_restriction: token.ip_restriction, | ||||
|         delete_after_inactivity: token.delete_after_inactivity, | ||||
|     }; | ||||
|  | ||||
|     // TODO : save | ||||
|  | ||||
|     Ok((full_token, priv_key)) | ||||
| } | ||||
| @@ -268,6 +268,10 @@ impl AppConfig { | ||||
|         self.definitions_path() | ||||
|             .join(format!("nwfilter-{}.json", name.0)) | ||||
|     } | ||||
|  | ||||
|     pub fn api_tokens_path(&self) -> PathBuf { | ||||
|         self.storage_path().join(constants::STORAGE_TOKENS_DIR) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, serde::Serialize)] | ||||
|   | ||||
| @@ -89,3 +89,18 @@ pub const NAT_MODE_ENV_VAR_NAME: &str = "NAT_MODE"; | ||||
|  | ||||
| /// Nat hook file path | ||||
| pub const NAT_HOOK_PATH: &str = "/etc/libvirt/hooks/network"; | ||||
|  | ||||
| /// Directory where API tokens are stored, inside storage directory | ||||
| pub const STORAGE_TOKENS_DIR: &str = "tokens"; | ||||
|  | ||||
| /// API token name min length | ||||
| pub const API_TOKEN_NAME_MIN_LENGTH: usize = 3; | ||||
|  | ||||
| /// API token name max length | ||||
| pub const API_TOKEN_NAME_MAX_LENGTH: usize = 30; | ||||
|  | ||||
| /// API token description min length | ||||
| pub const API_TOKEN_DESCRIPTION_MIN_LENGTH: usize = 5; | ||||
|  | ||||
| /// API token description max length | ||||
| pub const API_TOKEN_DESCRIPTION_MAX_LENGTH: usize = 30; | ||||
|   | ||||
							
								
								
									
										49
									
								
								virtweb_backend/src/controllers/api_tokens_controller.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								virtweb_backend/src/controllers/api_tokens_controller.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| //! # API tokens management | ||||
|  | ||||
| use crate::api_tokens; | ||||
| use crate::api_tokens::NewToken; | ||||
| use crate::controllers::api_tokens_controller::rest_token::RestToken; | ||||
| use crate::controllers::HttpResult; | ||||
| use crate::utils::jwt_utils::TokenPrivKey; | ||||
| use actix_web::{web, HttpResponse}; | ||||
|  | ||||
| /// Create a special module for REST token to enforce usage of constructor function | ||||
| mod rest_token { | ||||
|     use crate::api_tokens::Token; | ||||
|     use crate::utils::jwt_utils::TokenPubKey; | ||||
|  | ||||
|     #[derive(serde::Serialize)] | ||||
|     pub struct RestToken { | ||||
|         token: Token, | ||||
|     } | ||||
|  | ||||
|     impl RestToken { | ||||
|         pub fn new(mut token: Token) -> Self { | ||||
|             token.pub_key = TokenPubKey::None; | ||||
|             Self { token } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(serde::Serialize)] | ||||
| struct CreateTokenResult { | ||||
|     token: RestToken, | ||||
|     priv_key: TokenPrivKey, | ||||
| } | ||||
|  | ||||
| /// Create a new API token | ||||
| pub async fn create(new_token: web::Json<NewToken>) -> HttpResult { | ||||
|     if let Some(err) = new_token.check_error() { | ||||
|         log::error!("Failed to validate new API token information! {err}"); | ||||
|         return Ok(HttpResponse::BadRequest().json(format!( | ||||
|             "Failed to validate new API token information! {err}" | ||||
|         ))); | ||||
|     } | ||||
|  | ||||
|     let (token, priv_key) = api_tokens::create(&new_token).await?; | ||||
|  | ||||
|     Ok(HttpResponse::Ok().json(CreateTokenResult { | ||||
|         token: RestToken::new(token), | ||||
|         priv_key, | ||||
|     })) | ||||
| } | ||||
| @@ -6,6 +6,7 @@ use std::error::Error; | ||||
| use std::fmt::{Display, Formatter}; | ||||
| use std::io::ErrorKind; | ||||
|  | ||||
| pub mod api_tokens_controller; | ||||
| pub mod auth_controller; | ||||
| pub mod iso_controller; | ||||
| pub mod network_controller; | ||||
|   | ||||
| @@ -51,6 +51,8 @@ struct ServerConstraints { | ||||
|     nwfilter_comment_size: LenConstraints, | ||||
|     nwfilter_priority: SLenConstraints, | ||||
|     nwfilter_selectors_count: LenConstraints, | ||||
|     api_token_name_size: LenConstraints, | ||||
|     api_token_description_size: LenConstraints, | ||||
| } | ||||
|  | ||||
| pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder { | ||||
| @@ -98,6 +100,16 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder { | ||||
|                 max: 1000, | ||||
|             }, | ||||
|             nwfilter_selectors_count: LenConstraints { min: 0, max: 1 }, | ||||
|  | ||||
|             api_token_name_size: LenConstraints { | ||||
|                 min: constants::API_TOKEN_NAME_MIN_LENGTH, | ||||
|                 max: constants::API_TOKEN_NAME_MAX_LENGTH, | ||||
|             }, | ||||
|  | ||||
|             api_token_description_size: LenConstraints { | ||||
|                 min: constants::API_TOKEN_DESCRIPTION_MIN_LENGTH, | ||||
|                 max: constants::API_TOKEN_DESCRIPTION_MAX_LENGTH, | ||||
|             }, | ||||
|         }, | ||||
|     }) | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| pub mod actors; | ||||
| pub mod api_tokens; | ||||
| pub mod app_config; | ||||
| pub mod constants; | ||||
| pub mod controllers; | ||||
|   | ||||
| @@ -22,8 +22,8 @@ use virtweb_backend::constants::{ | ||||
|     MAX_INACTIVITY_DURATION, MAX_SESSION_DURATION, SESSION_COOKIE_NAME, | ||||
| }; | ||||
| use virtweb_backend::controllers::{ | ||||
|     auth_controller, iso_controller, network_controller, nwfilter_controller, server_controller, | ||||
|     static_controller, vm_controller, | ||||
|     api_tokens_controller, auth_controller, iso_controller, network_controller, | ||||
|     nwfilter_controller, server_controller, static_controller, vm_controller, | ||||
| }; | ||||
| use virtweb_backend::libvirt_client::LibVirtClient; | ||||
| use virtweb_backend::middlewares::auth_middleware::AuthChecker; | ||||
| @@ -50,6 +50,7 @@ async fn main() -> std::io::Result<()> { | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().disks_storage_path()).unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().nat_path()).unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().definitions_path()).unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().api_tokens_path()).unwrap(); | ||||
|  | ||||
|     let conn = Data::new(LibVirtClient( | ||||
|         LibVirtActor::connect() | ||||
| @@ -276,6 +277,27 @@ async fn main() -> std::io::Result<()> { | ||||
|                 "/api/nwfilter/{uid}", | ||||
|                 web::delete().to(nwfilter_controller::delete), | ||||
|             ) | ||||
|             // API tokens controller | ||||
|             .route( | ||||
|                 "/api/tokens/create", | ||||
|                 web::post().to(api_tokens_controller::create), | ||||
|             ) | ||||
|             /* TODO .route( | ||||
|                 "/api/tokens/list", | ||||
|                 web::get().to(api_tokens_controller::list), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/tokens/{uid}", | ||||
|                 web::get().to(api_tokens_controller::get_single), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/tokens/{uid}", | ||||
|                 web::put().to(api_tokens_controller::update), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/tokens/{uid}", | ||||
|                 web::delete().to(api_tokens_controller::delete), | ||||
|             )*/ | ||||
|             // Static assets | ||||
|             .route("/", web::get().to(static_controller::root_index)) | ||||
|             .route( | ||||
|   | ||||
							
								
								
									
										109
									
								
								virtweb_backend/src/utils/jwt_utils.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								virtweb_backend/src/utils/jwt_utils.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,109 @@ | ||||
| use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Validation}; | ||||
| use ring::signature::{KeyPair, UnparsedPublicKey}; | ||||
| use serde::de::DeserializeOwned; | ||||
| use serde::Serialize; | ||||
|  | ||||
| #[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] | ||||
| #[serde(tag = "alg")] | ||||
| pub enum TokenPubKey { | ||||
|     /// This variant DOES make crash the program. It MUST NOT be serialized. | ||||
|     /// | ||||
|     /// It is a hack to hide public key when getting the list of tokens | ||||
|     None, | ||||
|  | ||||
|     /// ECDSA with SHA2-384 variant | ||||
|     ES384 { r#pub: String }, | ||||
| } | ||||
|  | ||||
| #[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] | ||||
| #[serde(tag = "alg")] | ||||
| pub enum TokenPrivKey { | ||||
|     ES384 { r#priv: String }, | ||||
| } | ||||
|  | ||||
| /// Generate a new token keypair | ||||
| pub fn generate_key_pair() -> anyhow::Result<(TokenPubKey, TokenPrivKey)> { | ||||
|     let doc = ring::signature::EcdsaKeyPair::generate_pkcs8( | ||||
|         &ring::signature::ECDSA_P384_SHA384_ASN1_SIGNING, | ||||
|         &ring::rand::SystemRandom::new(), | ||||
|     )?; | ||||
|  | ||||
|     let priv_pem = pem::encode(&pem::Pem::new("PRIVATE KEY", doc.as_ref())); | ||||
|  | ||||
|     let pair = ring::signature::EcdsaKeyPair::from_pkcs8( | ||||
|         &ring::signature::ECDSA_P384_SHA384_ASN1_SIGNING, | ||||
|         doc.as_ref(), | ||||
|         &ring::rand::SystemRandom::new(), | ||||
|     )?; | ||||
|     let pub_pem = pem::encode(&pem::Pem::new("PUBLIC KEY", pair.public_key().as_ref())); | ||||
|  | ||||
|  | ||||
|     let pk = pair.public_key(); | ||||
|     let unp = UnparsedPublicKey::new(&ring::signature::ECDSA_P384_SHA384_ASN1_SIGNING, pk.as_ref()); | ||||
|  | ||||
|     let decoding_key = DecodingKey::from_ec_pem(pub_pem.as_bytes()).expect("aie ai"); | ||||
|  | ||||
|     Ok(( | ||||
|         TokenPubKey::ES384 { r#pub: pub_pem }, | ||||
|         TokenPrivKey::ES384 { r#priv: priv_pem }, | ||||
|     )) | ||||
| } | ||||
|  | ||||
| /// Sign JWT with a private key | ||||
| pub fn sign_jwt<C: Serialize>(key: &TokenPrivKey, claims: &C) -> anyhow::Result<String> { | ||||
|     match key { | ||||
|         TokenPrivKey::ES384 { r#priv } => { | ||||
|             let encoding_key = EncodingKey::from_ec_pem(r#priv.as_bytes())?; | ||||
|  | ||||
|             Ok(jsonwebtoken::encode( | ||||
|                 &jsonwebtoken::Header::new(Algorithm::ES384), | ||||
|                 &claims, | ||||
|                 &encoding_key, | ||||
|             )?) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Validate a given JWT | ||||
| pub fn validate_jwt<E: DeserializeOwned>(key: &TokenPubKey, token: &str) -> anyhow::Result<E> { | ||||
|     match key { | ||||
|         TokenPubKey::ES384 { r#pub } => { | ||||
|             let decoding_key = DecodingKey::from_ec_pem(r#pub.as_bytes())?; | ||||
|  | ||||
|             let validation = Validation::new(Algorithm::ES384); | ||||
|             Ok(jsonwebtoken::decode::<E>(token, &decoding_key, &validation)?.claims) | ||||
|         } | ||||
|         TokenPubKey::None => { | ||||
|             panic!("A public key is required!") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod test { | ||||
|     use crate::utils::jwt_utils::{generate_key_pair, sign_jwt, validate_jwt}; | ||||
|     use crate::utils::time_utils::time; | ||||
|     use serde::{Deserialize, Serialize}; | ||||
|  | ||||
|     #[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] | ||||
|     pub struct Claims { | ||||
|         sub: String, | ||||
|         exp: u64, | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn jwt_encode_sign_verify_valid() { | ||||
|         let (pub_key, priv_key) = generate_key_pair().unwrap(); | ||||
|         let claims = Claims { | ||||
|             sub: "my-sub".to_string(), | ||||
|             exp: time() + 100, | ||||
|         }; | ||||
|         let jwt = sign_jwt(&priv_key, &claims).expect("Failed to sign JWT!"); | ||||
|  | ||||
|         println!("pub {pub_key:?}"); | ||||
|         println!("priv {priv_key:?}"); | ||||
|         let claims_out = validate_jwt(&pub_key, &jwt).expect("Failed to validate JWT!"); | ||||
|  | ||||
|         assert_eq!(claims, claims_out) | ||||
|     } | ||||
| } | ||||
| @@ -1,5 +1,6 @@ | ||||
| pub mod disks_utils; | ||||
| pub mod files_utils; | ||||
| pub mod jwt_utils; | ||||
| pub mod net_utils; | ||||
| pub mod rand_utils; | ||||
| pub mod time_utils; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user