chore: commit history pruned
This commit is contained in:
commit
306eb86d74
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
/target
|
||||
|
||||
tests/data/**
|
||||
tests/output/**
|
||||
|
||||
!tests/output/.placeholder
|
||||
!tests/data/.placeholder
|
8
.pre-commit-config.yaml
Normal file
8
.pre-commit-config.yaml
Normal file
@ -0,0 +1,8 @@
|
||||
repos:
|
||||
- repo: https://github.com/doublify/pre-commit-rust
|
||||
rev: v1.0
|
||||
hooks:
|
||||
- id: fmt
|
||||
args: ["--verbose", "--"]
|
||||
- id: cargo-check
|
||||
- id: clippy
|
1499
Cargo.lock
generated
Normal file
1499
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
Cargo.toml
Normal file
24
Cargo.toml
Normal file
@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "contego"
|
||||
version = "0.4.0"
|
||||
authors = ["ae"]
|
||||
description = "Cryptographically secure file transfer CLI tool "
|
||||
repository = "https://umbrella.haus/ae/contego"
|
||||
license = "MIT"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.28.1", features = ["full"] }
|
||||
rand = "0.7.0"
|
||||
x25519-dalek = "1.2.0"
|
||||
aes-gcm = "0.10.3"
|
||||
base64 = "0.21.2"
|
||||
sha256 = "1.1.3"
|
||||
ureq = "2.6.2"
|
||||
clap = { version = "4.3.0", features = ["derive"] }
|
||||
log = "0.4.17"
|
||||
env_logger = "0.10.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4.2"
|
||||
ntest = "0.9.0"
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 ae
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
43
README.md
Normal file
43
README.md
Normal file
@ -0,0 +1,43 @@
|
||||
<div align="center" style="text-align:center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/contego-light.png">
|
||||
<img src="docs/contego-dark.png" width="800">
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
## Cryptographic specifications
|
||||
|
||||
The initial key exchange is performed with elliptic-curve Diffie-Hellman. General data exchange is encrypted with AES-GCM. During regular communication payloads are Base64 encoded before being encrypted to prevent delimiter conflicts. SHA-256 hashes of files are compared to ensure data integrity.
|
||||
|
||||
## Usage
|
||||
|
||||
Build the optimized binary with `cargo build --release`.
|
||||
|
||||
### Server
|
||||
|
||||
```
|
||||
Usage: contego host [OPTIONS] --key <KEY> <--source <SOURCE>|--files <FILES>...>
|
||||
|
||||
Options:
|
||||
-k, --key <KEY> Access key
|
||||
-s, --source <SOURCE> Path to a source file (alternative to --files)
|
||||
-f, --files <FILES>... Paths to shareable files (alternative to --source)
|
||||
-p, --port <PORT> Host port [default: 8080]
|
||||
-6, --ipv6 IPv6 instead of IPv4
|
||||
-c, --chunksize <CHUNKSIZE> Transmit chunksize in bytes [default: 8192]
|
||||
-l, --local Host locally
|
||||
-h, --help Print help
|
||||
|
||||
```
|
||||
|
||||
### Client
|
||||
|
||||
```
|
||||
Usage: contego connect --addr <ADDR> --out <OUT> --key <KEY>
|
||||
|
||||
Options:
|
||||
-a, --addr <ADDR> IP address of the instance
|
||||
-o, --out <OUT> Path to an output folder
|
||||
-k, --key <KEY> Access key
|
||||
-h, --help Print help
|
||||
```
|
BIN
docs/contego-dark.png
Normal file
BIN
docs/contego-dark.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
BIN
docs/contego-light.png
Normal file
BIN
docs/contego-light.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
148
src/client.rs
Normal file
148
src/client.rs
Normal file
@ -0,0 +1,148 @@
|
||||
use std::{error::Error, net::SocketAddr, path::PathBuf};
|
||||
|
||||
use log::{debug, error, info};
|
||||
use tokio::{io::AsyncWriteExt, net::TcpStream};
|
||||
|
||||
use crate::{
|
||||
crypto::{self, Crypto},
|
||||
sockets::SocketHandler,
|
||||
util::{new_file, FileInfo},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Client {
|
||||
addr: SocketAddr,
|
||||
key: String,
|
||||
output: PathBuf,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn new(addr: SocketAddr, key: String, output: PathBuf) -> Self {
|
||||
Self { addr, key, output }
|
||||
}
|
||||
|
||||
pub async fn connection(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
info!("Trying to connect to the server at {}", self.addr);
|
||||
|
||||
let mut socket = TcpStream::connect(self.addr).await?;
|
||||
|
||||
debug!("Connected to the TCP socket at {}", self.addr);
|
||||
|
||||
let mut handler = SocketHandler::new(&mut socket);
|
||||
let crypto = Crypto::new(&mut handler, true).await?;
|
||||
handler.set_crypto(crypto);
|
||||
|
||||
info!("Encrypted connection to {} established", self.addr);
|
||||
|
||||
if !self.authorize(&mut handler).await? {
|
||||
error!(
|
||||
"Authorization failed due to an invalid access key '{}'",
|
||||
self.key
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let metadata = self.metadata(&mut handler).await?;
|
||||
self.requests(&mut handler, metadata).await?;
|
||||
|
||||
debug!("Connection sequence done, shutting down");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn authorize(
|
||||
&self,
|
||||
handler: &mut SocketHandler<'_>,
|
||||
) -> Result<bool, Box<dyn Error + Send + Sync>> {
|
||||
debug!("Starting authorization");
|
||||
|
||||
let msg = self.key.as_bytes().to_vec();
|
||||
handler.send(&msg).await?;
|
||||
|
||||
let buf = handler.recv().await?;
|
||||
let msg = String::from_utf8(buf)?;
|
||||
let msg = msg.trim();
|
||||
|
||||
if msg == "DISCONNECT" {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
debug!("Authorization successfully done");
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn metadata(
|
||||
&self,
|
||||
handler: &mut SocketHandler<'_>,
|
||||
) -> Result<Vec<FileInfo>, Box<dyn Error + Send + Sync>> {
|
||||
debug!("Starting to receive metadata");
|
||||
|
||||
let buf = handler.recv().await?;
|
||||
let amt = String::from_utf8(buf.clone())?.parse::<usize>()?;
|
||||
handler.send(&buf).await?; // confirmation
|
||||
|
||||
debug!("Confirmed metadata amount ({})", amt);
|
||||
|
||||
let mut metadata = Vec::new();
|
||||
|
||||
while metadata.len() < amt {
|
||||
let buf = handler.recv().await?;
|
||||
let data = String::from_utf8(buf)?;
|
||||
|
||||
let split = data.split(':').collect::<Vec<&str>>();
|
||||
let name = split[0].trim().to_string();
|
||||
let size = split[1].trim().parse::<u64>()?;
|
||||
let hash = split[2].trim().to_string();
|
||||
|
||||
debug!("Metadata of file '{}' received successfully", name);
|
||||
|
||||
let info = FileInfo::new(name, size, hash);
|
||||
|
||||
metadata.push(info);
|
||||
}
|
||||
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
async fn requests(
|
||||
&self,
|
||||
handler: &mut SocketHandler<'_>,
|
||||
metadata: Vec<FileInfo>,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
info!("Starting to send requests");
|
||||
|
||||
for file in metadata {
|
||||
let (mut handle, path) = new_file(self.output.clone(), &file.name).await?;
|
||||
let msg = file.hash.as_bytes().to_vec();
|
||||
handler.send(&msg).await?;
|
||||
|
||||
info!("Requesting file '{}'", file.hash);
|
||||
|
||||
let mut remaining = file.size;
|
||||
|
||||
while remaining != 0 {
|
||||
let buf = handler.recv().await?;
|
||||
handle.write_all(&buf).await?;
|
||||
handle.flush().await?;
|
||||
remaining -= buf.len() as u64;
|
||||
|
||||
debug!("File '{}': {} bytes remaining", file.hash, remaining);
|
||||
}
|
||||
|
||||
let check_hash = crypto::try_hash(&path).unwrap();
|
||||
let msg = check_hash.as_bytes().to_vec();
|
||||
handler.send(&msg).await?;
|
||||
|
||||
if check_hash != file.hash {
|
||||
return Err("Unsuccessful file transfer, hashes don't match".into());
|
||||
}
|
||||
|
||||
info!("File '{}' successfully transferred", file.hash);
|
||||
}
|
||||
|
||||
info!("All requests successfully done");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
111
src/crypto.rs
Normal file
111
src/crypto.rs
Normal file
@ -0,0 +1,111 @@
|
||||
use std::{error::Error, path::Path};
|
||||
|
||||
use aes_gcm::{
|
||||
aead::{consts::U12, Aead},
|
||||
aes::Aes256,
|
||||
Aes256Gcm, AesGcm, KeyInit, Nonce,
|
||||
};
|
||||
use log::debug;
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret};
|
||||
|
||||
use crate::sockets::SocketHandler;
|
||||
|
||||
const AES_NONCE_SIZE: usize = 12;
|
||||
const DH_PBK_SIZE: usize = 32;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Crypto {
|
||||
cipher: AesGcm<Aes256, U12>,
|
||||
rng: OsRng,
|
||||
}
|
||||
|
||||
impl Crypto {
|
||||
pub async fn new(
|
||||
handler: &mut SocketHandler<'_>,
|
||||
go_first: bool,
|
||||
) -> Result<Self, Box<dyn Error + Sync + Send>> {
|
||||
let secret = Self::ecdh(handler, go_first).await?;
|
||||
let cipher = Aes256Gcm::new(secret.as_bytes().into());
|
||||
let rng = OsRng;
|
||||
|
||||
Ok(Self { cipher, rng })
|
||||
}
|
||||
|
||||
async fn ecdh(
|
||||
handler: &mut SocketHandler<'_>,
|
||||
go_first: bool,
|
||||
) -> Result<SharedSecret, Box<dyn Error + Send + Sync>> {
|
||||
debug!("Starting ECDH key exchange");
|
||||
|
||||
let buf: Vec<u8>;
|
||||
let own_sec = EphemeralSecret::new(OsRng);
|
||||
let own_pbk = PublicKey::from(&own_sec);
|
||||
let mut msg = own_pbk.as_bytes().to_vec();
|
||||
|
||||
msg.push(b':'); // manual delimiter
|
||||
|
||||
if go_first {
|
||||
handler.send_raw(&msg).await?;
|
||||
buf = handler.recv_raw(DH_PBK_SIZE).await?;
|
||||
} else {
|
||||
buf = handler.recv_raw(DH_PBK_SIZE).await?;
|
||||
handler.send_raw(&msg).await?;
|
||||
}
|
||||
|
||||
debug!("Calculating PPK from the shared secret");
|
||||
|
||||
let slice: [u8; DH_PBK_SIZE] = buf[..DH_PBK_SIZE].try_into()?;
|
||||
let recv_pbk = PublicKey::from(slice);
|
||||
let pvk = own_sec.diffie_hellman(&recv_pbk);
|
||||
|
||||
debug!("PPK successfully generated");
|
||||
|
||||
Ok(pvk)
|
||||
}
|
||||
|
||||
fn nonce(&mut self) -> Nonce<U12> {
|
||||
debug!("Generating new unique nonce (AEAD)");
|
||||
|
||||
let mut nonce = Nonce::default();
|
||||
self.rng.fill_bytes(&mut nonce);
|
||||
|
||||
nonce
|
||||
}
|
||||
|
||||
pub async fn encrypt(&mut self, data: &[u8]) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
|
||||
debug!("Encrypting {} bytes payload", data.len());
|
||||
|
||||
let nonce = self.nonce();
|
||||
let encrypted = match self.cipher.encrypt(&nonce, data.as_ref()) {
|
||||
Ok(data) => data,
|
||||
Err(e) => return Err(format!("Encryption failed: {}", e).into()),
|
||||
};
|
||||
|
||||
let mut data = nonce.to_vec();
|
||||
data.extend_from_slice(&encrypted);
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub async fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
|
||||
debug!("Decrypting {} bytes payload", data.len());
|
||||
|
||||
let (nonce_bytes, data) = data.split_at(AES_NONCE_SIZE);
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
let decrypted = match self.cipher.decrypt(nonce, data.as_ref()) {
|
||||
Ok(data) => data,
|
||||
Err(e) => return Err(format!("Decryption failed: {}", e).into()),
|
||||
};
|
||||
|
||||
Ok(decrypted)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_hash(path: &Path) -> Result<String, Box<dyn Error>> {
|
||||
debug!("Calculating SHA hash");
|
||||
|
||||
let hash = sha256::try_digest(path)?;
|
||||
|
||||
Ok(hash)
|
||||
}
|
6
src/lib.rs
Executable file
6
src/lib.rs
Executable file
@ -0,0 +1,6 @@
|
||||
pub mod client;
|
||||
pub mod crypto;
|
||||
pub mod parser;
|
||||
pub mod server;
|
||||
pub mod sockets;
|
||||
pub mod util;
|
115
src/main.rs
Executable file
115
src/main.rs
Executable file
@ -0,0 +1,115 @@
|
||||
use std::{error::Error, net::SocketAddr, path::PathBuf};
|
||||
|
||||
use clap::{command, ArgGroup, Parser, Subcommand};
|
||||
|
||||
use contego::{
|
||||
client::Client,
|
||||
parser::{addr_parser, dirpath_parser, filepath_parser},
|
||||
server::Server,
|
||||
util::{ascii, filepaths, metadata, Ip},
|
||||
};
|
||||
use env_logger::Env;
|
||||
use log::{error, info};
|
||||
use tokio::{signal, sync::mpsc};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about, version)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Commands {
|
||||
#[clap(group(ArgGroup::new("input").required(true).args(&["source", "files"])))]
|
||||
Host {
|
||||
/// Access key
|
||||
#[clap(short = 'k', long)]
|
||||
key: String,
|
||||
/// Path to a source file (alternative to --files)
|
||||
#[clap(short = 's', long, value_parser = filepath_parser, conflicts_with = "files", group = "input")]
|
||||
source: Option<PathBuf>,
|
||||
/// Paths to shareable files (alternative to --source)
|
||||
#[clap(short = 'f', long, num_args = 1.., value_parser = filepath_parser, conflicts_with = "source", group = "input")]
|
||||
files: Option<Vec<PathBuf>>,
|
||||
/// Host port
|
||||
#[clap(short = 'p', long, default_value_t = 8080)]
|
||||
port: u16,
|
||||
/// IPv6 instead of IPv4
|
||||
#[clap(short = '6', long, default_value_t = false)]
|
||||
ipv6: bool,
|
||||
/// Transmit chunksize in bytes
|
||||
#[clap(short = 'c', long, default_value_t = 8192)]
|
||||
chunksize: usize,
|
||||
/// Host locally
|
||||
#[clap(short = 'l', long, default_value_t = false)]
|
||||
local: bool,
|
||||
},
|
||||
Connect {
|
||||
/// IP address of the instance
|
||||
#[clap(short = 'a', long, value_parser = addr_parser)]
|
||||
addr: SocketAddr,
|
||||
/// Path to an output folder
|
||||
#[clap(short = 'o', long, value_parser = dirpath_parser)]
|
||||
out: PathBuf,
|
||||
/// Access key
|
||||
#[clap(short = 'k', long)]
|
||||
key: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
ascii();
|
||||
|
||||
env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Host {
|
||||
port,
|
||||
ipv6,
|
||||
source,
|
||||
files,
|
||||
chunksize,
|
||||
local,
|
||||
key,
|
||||
} => {
|
||||
let (tx, rx) = mpsc::channel::<()>(1);
|
||||
|
||||
let paths = filepaths(source, files)?;
|
||||
let (metadata, index) = metadata(&paths).await?;
|
||||
let (display_addr, bind_addr) = match (local, ipv6) {
|
||||
(true, _) => Ip::Local.fetch(port)?,
|
||||
(false, true) => Ip::V6.fetch(port)?,
|
||||
(false, false) => Ip::V4.fetch(port)?,
|
||||
};
|
||||
|
||||
let server = Server::new(display_addr, key, chunksize, metadata, index);
|
||||
|
||||
tokio::spawn(async move {
|
||||
match server.start(rx, &bind_addr).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => error!("Error during server execution: {}", e),
|
||||
};
|
||||
});
|
||||
|
||||
match signal::ctrl_c().await {
|
||||
Ok(_) => {
|
||||
tx.send(()).await?;
|
||||
info!("Captured Ctrl+C, shutting down");
|
||||
}
|
||||
Err(_) => error!("Failed to listen for a Ctrl+C event"),
|
||||
};
|
||||
}
|
||||
Commands::Connect { addr, out, key } => {
|
||||
let client = Client::new(addr, key, out);
|
||||
match client.connection().await {
|
||||
Ok(_) => {}
|
||||
Err(e) => error!("Error during client execution: {}", e),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
94
src/parser.rs
Normal file
94
src/parser.rs
Normal file
@ -0,0 +1,94 @@
|
||||
use std::{
|
||||
env,
|
||||
io::{Error, ErrorKind::NotFound},
|
||||
net::{AddrParseError, SocketAddr},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use log::debug;
|
||||
|
||||
pub fn addr_parser(addr: &str) -> Result<SocketAddr, AddrParseError> {
|
||||
let addr = addr
|
||||
.parse::<SocketAddr>()
|
||||
.expect("Failed to parse IP address");
|
||||
|
||||
Ok(addr)
|
||||
}
|
||||
|
||||
pub fn filepath_parser(path: &str) -> Result<PathBuf, Error> {
|
||||
debug!("Validating filepath '{}'", path);
|
||||
|
||||
let home = env::var("HOME").unwrap();
|
||||
let path = path
|
||||
.replace('~', &home)
|
||||
.parse::<PathBuf>()
|
||||
.expect("Failed to parse path");
|
||||
|
||||
if path.exists() && path.is_file() {
|
||||
Ok(path)
|
||||
} else {
|
||||
Err(Error::new(NotFound, "File not found"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dirpath_parser(path: &str) -> Result<PathBuf, Error> {
|
||||
debug!("Validating dirpath '{}'", path);
|
||||
|
||||
let path = path.parse::<PathBuf>().expect("Failed to parse path");
|
||||
|
||||
if path.exists() && path.is_dir() {
|
||||
Ok(path)
|
||||
} else {
|
||||
Err(Error::new(NotFound, "Directory not found"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn valid_ip() {
|
||||
use std::net::Ipv6Addr;
|
||||
|
||||
let ipv4 = "10.1.2.3:8888";
|
||||
let ipv6 = "[2001:db8::1]:8888";
|
||||
|
||||
let parsed_ipv4 = addr_parser(ipv4).unwrap();
|
||||
let parsed_ipv6 = addr_parser(ipv6).unwrap();
|
||||
|
||||
assert_eq!(parsed_ipv4, SocketAddr::from(([10, 1, 2, 3], 8888)));
|
||||
assert_eq!(
|
||||
parsed_ipv6,
|
||||
SocketAddr::from((Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1), 8888))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn short_ip() {
|
||||
let ip = "10.1.2:8888";
|
||||
addr_parser(ip).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn long_ip() {
|
||||
let ip = "[2001:0db8:ac10:fe01:0000:0000:0000:0000:0000]:8888";
|
||||
addr_parser(ip).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn ipv6_no_brackets() {
|
||||
let ip = "2001:db8::1:8888";
|
||||
addr_parser(ip).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn ip_missing_port() {
|
||||
let ip = "10.1.2.3";
|
||||
addr_parser(ip).unwrap();
|
||||
}
|
||||
}
|
200
src/server.rs
Normal file
200
src/server.rs
Normal file
@ -0,0 +1,200 @@
|
||||
use std::{collections::HashMap, error::Error, net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
|
||||
use log::{debug, error, info};
|
||||
use tokio::{
|
||||
fs::File,
|
||||
io::AsyncReadExt,
|
||||
net::{TcpListener, TcpStream},
|
||||
sync::mpsc,
|
||||
};
|
||||
|
||||
use crate::{crypto::Crypto, sockets::SocketHandler, util::FileInfo};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Server {
|
||||
addr: SocketAddr,
|
||||
key: String,
|
||||
chunksize: usize,
|
||||
metadata: Vec<FileInfo>,
|
||||
index: HashMap<String, PathBuf>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn new(
|
||||
addr: SocketAddr,
|
||||
key: String,
|
||||
chunksize: usize,
|
||||
metadata: Vec<FileInfo>,
|
||||
index: HashMap<String, PathBuf>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
addr,
|
||||
key,
|
||||
chunksize,
|
||||
metadata,
|
||||
index,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn start(
|
||||
self: Arc<Self>,
|
||||
mut kill: mpsc::Receiver<()>,
|
||||
bind_addr: &SocketAddr,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
tokio::select! {
|
||||
_ = self.listen(bind_addr) => Ok(()),
|
||||
_ = kill.recv() => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn listen(
|
||||
self: Arc<Self>,
|
||||
bind_addr: &SocketAddr,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let listener = TcpListener::bind(bind_addr).await?;
|
||||
|
||||
info!("Listening on {} - Access key: {}", self.addr, self.key);
|
||||
|
||||
loop {
|
||||
let this_self = self.clone();
|
||||
let (mut socket, addr) = listener.accept().await?;
|
||||
|
||||
info!("New client connected: {}", addr);
|
||||
|
||||
match tokio::spawn(async move { this_self.connection(&mut socket, &addr).await }).await
|
||||
{
|
||||
Ok(_) => info!("Client disconnected: {}", addr),
|
||||
Err(e) => error!("Fatal error in connection {}: {}", addr, e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async fn connection(
|
||||
&self,
|
||||
socket: &mut TcpStream,
|
||||
addr: &SocketAddr,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let mut handler = SocketHandler::new(socket);
|
||||
let crypto = Crypto::new(&mut handler, false).await?;
|
||||
handler.set_crypto(crypto);
|
||||
|
||||
debug!("({}): Connection established", addr);
|
||||
|
||||
if !self.authorize(&mut handler, addr).await? {
|
||||
info!("({}): Invalid access key", addr);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.metadata(&mut handler, addr).await?;
|
||||
self.requests(&mut handler, addr).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn authorize(
|
||||
&self,
|
||||
handler: &mut SocketHandler<'_>,
|
||||
addr: &SocketAddr,
|
||||
) -> Result<bool, Box<dyn Error + Send + Sync>> {
|
||||
debug!("({}): Starting authorization", addr);
|
||||
|
||||
let buf = handler.recv().await?;
|
||||
let key = String::from_utf8(buf)?;
|
||||
|
||||
let is_valid: bool;
|
||||
let res_msg: Vec<u8>;
|
||||
|
||||
if key != self.key {
|
||||
is_valid = false;
|
||||
res_msg = b"DISCONNECT".to_vec();
|
||||
} else {
|
||||
is_valid = true;
|
||||
res_msg = b"VALID".to_vec();
|
||||
}
|
||||
|
||||
handler.send(&res_msg).await?;
|
||||
|
||||
debug!("({}): Authorization finished", addr);
|
||||
|
||||
Ok(is_valid)
|
||||
}
|
||||
|
||||
async fn metadata(
|
||||
&self,
|
||||
handler: &mut SocketHandler<'_>,
|
||||
addr: &SocketAddr,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
debug!("({}): Starting to send metadata", addr);
|
||||
|
||||
let amt = self.metadata.len();
|
||||
let msg = amt.to_string().as_bytes().to_vec();
|
||||
|
||||
handler.send(&msg).await?;
|
||||
|
||||
let buf = handler.recv().await?;
|
||||
let res_amt = String::from_utf8(buf)?.trim().parse::<usize>()?;
|
||||
|
||||
if res_amt != amt {
|
||||
return Err("Broken message sequence during metadata exchange".into());
|
||||
}
|
||||
|
||||
debug!("({}): Metadata amount confirmed successfully", addr);
|
||||
|
||||
for file in &self.metadata {
|
||||
let msg = format!("{}:{}:{}", file.name, file.size, file.hash)
|
||||
.as_bytes()
|
||||
.to_vec();
|
||||
handler.send(&msg).await?;
|
||||
|
||||
debug!("({}): Sent metadata of file '{}'", addr, file.hash);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn requests(
|
||||
&self,
|
||||
handler: &mut SocketHandler<'_>,
|
||||
addr: &SocketAddr,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
debug!("({}): Waiting for file requests", addr);
|
||||
|
||||
loop {
|
||||
let buf = handler.recv().await?;
|
||||
let hash = String::from_utf8(buf)?;
|
||||
let hash = hash.trim();
|
||||
|
||||
if hash == "DISCONNECT" {
|
||||
break;
|
||||
}
|
||||
|
||||
debug!("({}): Received request for file '{}'", addr, hash);
|
||||
|
||||
let mut file = File::open(self.index[hash].clone()).await?;
|
||||
let mut remaining = file.metadata().await?.len();
|
||||
let mut sendbuf = vec![0u8; self.chunksize];
|
||||
|
||||
debug!("({}): Sending bytes of '{}'", addr, hash);
|
||||
|
||||
while remaining != 0 {
|
||||
let n = file.read(&mut sendbuf).await?;
|
||||
handler.send(&sendbuf[..n]).await?;
|
||||
remaining -= n as u64;
|
||||
|
||||
debug!("({}): {} bytes remaining", addr, remaining);
|
||||
}
|
||||
|
||||
let buf = handler.recv().await?;
|
||||
let confirmation = String::from_utf8(buf)?;
|
||||
let confirmation = confirmation.trim();
|
||||
|
||||
if confirmation != hash {
|
||||
return Err("Unsuccessful file transfer, hashes don't match".into());
|
||||
}
|
||||
|
||||
debug!("({}): File '{}' successfully transferred", addr, hash);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
103
src/sockets.rs
Normal file
103
src/sockets.rs
Normal file
@ -0,0 +1,103 @@
|
||||
use std::error::Error;
|
||||
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use log::debug;
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter},
|
||||
net::{
|
||||
tcp::{ReadHalf, WriteHalf},
|
||||
TcpStream,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::crypto::Crypto;
|
||||
|
||||
pub struct SocketHandler<'a> {
|
||||
writer: BufWriter<WriteHalf<'a>>,
|
||||
reader: BufReader<ReadHalf<'a>>,
|
||||
crypto: Option<Crypto>,
|
||||
}
|
||||
|
||||
impl<'a> SocketHandler<'a> {
|
||||
pub fn new(socket: &'a mut TcpStream) -> Self {
|
||||
let (reader, writer) = socket.split();
|
||||
let reader = BufReader::new(reader);
|
||||
let writer = BufWriter::new(writer);
|
||||
|
||||
Self {
|
||||
writer,
|
||||
reader,
|
||||
crypto: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_crypto(&mut self, crypto: Crypto) {
|
||||
// setting up AES cipher requires DH key exchange in plaintext,
|
||||
// meaning crypto can't be initialized at the same time as the socket handler
|
||||
debug!("Cryptography module initialized to the connection");
|
||||
self.crypto = Some(crypto);
|
||||
}
|
||||
|
||||
pub async fn send(&mut self, data: &[u8]) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let data = match &mut self.crypto {
|
||||
Some(c) => c.encrypt(data).await?,
|
||||
None => data.to_vec(), // syntactic sugar, never actually called
|
||||
};
|
||||
|
||||
let mut encoded = general_purpose::STANDARD_NO_PAD
|
||||
.encode(data)
|
||||
.as_bytes()
|
||||
.to_vec();
|
||||
encoded.push(b':');
|
||||
|
||||
self.send_raw(&encoded).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_raw(&mut self, data: &[u8]) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
self.writer.write_all(data).await?;
|
||||
self.writer.flush().await?;
|
||||
|
||||
debug!("Sent {} bytes to the socket", data.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn recv(&mut self) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
|
||||
let mut buf = self.recv_raw(1).await?;
|
||||
buf.pop();
|
||||
buf = general_purpose::STANDARD_NO_PAD.decode(&buf)?.to_vec();
|
||||
|
||||
let data = match &self.crypto {
|
||||
Some(c) => c.decrypt(&buf).await?,
|
||||
None => buf,
|
||||
};
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub async fn recv_raw(
|
||||
&mut self,
|
||||
min_limit: usize,
|
||||
) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
|
||||
let mut buf = Vec::new();
|
||||
|
||||
while buf.len() <= min_limit {
|
||||
let n = self.reader.read_until(b':', &mut buf).await?;
|
||||
|
||||
if n == 0 {
|
||||
return Err("Received 0 bytes from the socket".into());
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
TODO: use min_limit to check whether read_until has reached EOF before reading all the necessary bytes
|
||||
(e.g. regarding ecdh key exchange) --> loop and read until buf.len() == min_limit
|
||||
*/
|
||||
|
||||
debug!("Received {} bytes from the socket", buf.len());
|
||||
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
132
src/util.rs
Normal file
132
src/util.rs
Normal file
@ -0,0 +1,132 @@
|
||||
use std::{collections::HashMap, env, error::Error, fs, net::SocketAddr, path::PathBuf};
|
||||
|
||||
use log::{debug, info};
|
||||
use tokio::{fs::File, io::BufWriter};
|
||||
|
||||
use crate::crypto;
|
||||
|
||||
const PUBLIC_IPV4: &str = "https://ipinfo.io/ip";
|
||||
const PUBLIC_IPV6: &str = "https://ipv6.icanhazip.com";
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub enum Ip {
|
||||
V4,
|
||||
V6,
|
||||
Local,
|
||||
}
|
||||
|
||||
impl Ip {
|
||||
pub fn fetch(self, port: u16) -> Result<(SocketAddr, SocketAddr), Box<dyn Error>> {
|
||||
let addr = match self {
|
||||
Ip::V4 => PUBLIC_IPV4,
|
||||
Ip::V6 => PUBLIC_IPV6,
|
||||
Ip::Local => {
|
||||
let addr_str = format!("127.0.0.1:{}", port);
|
||||
let addr = addr_str.parse::<SocketAddr>()?;
|
||||
return Ok((addr, addr));
|
||||
}
|
||||
};
|
||||
|
||||
info!("Fetching IP information from {}", addr);
|
||||
|
||||
let res = format!("{}:{}", ureq::get(addr).call()?.into_string()?.trim(), port);
|
||||
let display_addr = res.parse::<SocketAddr>()?;
|
||||
let bind_addr = format!("0.0.0.0:{}", port).parse::<SocketAddr>()?;
|
||||
|
||||
debug!("IP: {}", res);
|
||||
|
||||
Ok((display_addr, bind_addr))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FileInfo {
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
impl FileInfo {
|
||||
pub fn new(name: String, size: u64, hash: String) -> Self {
|
||||
Self { name, size, hash }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filepaths(
|
||||
source: Option<PathBuf>,
|
||||
files: Option<Vec<PathBuf>>,
|
||||
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
|
||||
info!("Collecting filepaths");
|
||||
|
||||
let mut paths = Vec::new();
|
||||
|
||||
if let Some(source) = source {
|
||||
let home = env::var("HOME")?;
|
||||
let content = fs::read_to_string(source)?;
|
||||
paths = content
|
||||
.lines()
|
||||
.into_iter()
|
||||
.map(|p| PathBuf::from(p.replace('~', &home)))
|
||||
.collect();
|
||||
} else if let Some(files) = files {
|
||||
paths = files;
|
||||
}
|
||||
|
||||
debug!("Filepaths collection finished (total: {})", paths.len());
|
||||
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
pub async fn metadata(
|
||||
files: &Vec<PathBuf>,
|
||||
) -> Result<(Vec<FileInfo>, HashMap<String, PathBuf>), Box<dyn Error>> {
|
||||
info!("Collecting metadata");
|
||||
|
||||
let mut metadata = Vec::new();
|
||||
let mut index = HashMap::new();
|
||||
|
||||
for path in files {
|
||||
debug!("Collecting '{}' metadata", path.to_str().unwrap());
|
||||
|
||||
let split = path.to_str().unwrap().split('/').collect::<Vec<&str>>();
|
||||
let name = split[split.len() - 1].to_string();
|
||||
let handle = File::open(path).await?;
|
||||
let size = handle.metadata().await?.len();
|
||||
let hash = crypto::try_hash(path)?;
|
||||
|
||||
if size > 0 {
|
||||
let info = FileInfo::new(name, size, hash.clone());
|
||||
metadata.push(info);
|
||||
index.insert(hash, path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Metadata collection successfully done (total: {})",
|
||||
metadata.len()
|
||||
);
|
||||
|
||||
Ok((metadata, index))
|
||||
}
|
||||
|
||||
pub async fn new_file(
|
||||
mut path: PathBuf,
|
||||
name: &str,
|
||||
) -> Result<(BufWriter<File>, PathBuf), Box<dyn Error + Send + Sync>> {
|
||||
debug!("New file handle for '{}'", name);
|
||||
|
||||
path.push(name);
|
||||
let handle = File::create(&path).await?;
|
||||
|
||||
Ok((BufWriter::new(handle), path))
|
||||
}
|
||||
|
||||
pub fn ascii() {
|
||||
let ascii = " __
|
||||
_________ ____ / /____ ____ _____
|
||||
/ ___/ __ \\/ __ \\/ __/ _ \\/ __ `/ __ \\
|
||||
/ /__/ /_/ / / / / /_/ __/ /_/ / /_/ /
|
||||
\\___/\\____/_/ /_/\\__/\\___/\\__, /\\____/
|
||||
/____/ ";
|
||||
println!("{}\n", ascii);
|
||||
}
|
0
tests/data/.placeholder
Normal file
0
tests/data/.placeholder
Normal file
0
tests/output/.placeholder
Normal file
0
tests/output/.placeholder
Normal file
101
tests/sockets_integration.rs
Normal file
101
tests/sockets_integration.rs
Normal file
@ -0,0 +1,101 @@
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
io::{BufWriter, Write},
|
||||
path::PathBuf,
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
use contego::{
|
||||
client::Client,
|
||||
server::Server,
|
||||
util::{metadata, Ip},
|
||||
};
|
||||
use env_logger::Env;
|
||||
use log::debug;
|
||||
use ntest::timeout;
|
||||
use rand::{distributions::Alphanumeric, thread_rng, Rng};
|
||||
use tokio::{fs::read_to_string, sync::mpsc};
|
||||
|
||||
#[tokio::test]
|
||||
#[timeout(2000)]
|
||||
/// Ensures backend communications integrity & the ability to handle individual requests.
|
||||
async fn sockets_integration() {
|
||||
env_logger::Builder::from_env(Env::default().default_filter_or("debug"))
|
||||
.is_test(true)
|
||||
.try_init()
|
||||
.unwrap();
|
||||
|
||||
debug!("Initializing and starting the test");
|
||||
|
||||
let (testdata, paths) = testdata();
|
||||
let (metadata, index) = metadata(&paths).await.unwrap();
|
||||
|
||||
let (display_addr, bind_addr) = Ip::Local.fetch(8080).unwrap();
|
||||
let outdir = PathBuf::from("./tests/output/");
|
||||
let key = String::from("testkey");
|
||||
let c_key = key.clone();
|
||||
|
||||
let (tx, rx) = mpsc::channel::<()>(1);
|
||||
|
||||
let server_handle = tokio::spawn(async move {
|
||||
debug!("Initializing the asynchronous server task");
|
||||
let server = Server::new(display_addr, key, 8192, metadata, index);
|
||||
debug!("Starting to listen to incoming connections");
|
||||
server.start(rx, &bind_addr).await.unwrap();
|
||||
});
|
||||
|
||||
let client_handle = tokio::spawn(async move {
|
||||
debug!("Initializing the asynchronous client task");
|
||||
let client = Client::new(display_addr, c_key, outdir);
|
||||
debug!("Connecting to the server");
|
||||
client.connection().await.unwrap();
|
||||
});
|
||||
|
||||
client_handle.await.unwrap();
|
||||
tx.send(()).await.unwrap();
|
||||
server_handle.await.unwrap();
|
||||
|
||||
debug!("Checking for file integrity");
|
||||
|
||||
for file in testdata {
|
||||
let path = String::from("./tests/output/") + file.0;
|
||||
let recv_content = read_to_string(path).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
recv_content, file.1,
|
||||
"Output '{}' doesn't match the input '{}'",
|
||||
recv_content, file.1
|
||||
);
|
||||
|
||||
fs::remove_file(String::from("./tests/output/") + file.0).unwrap();
|
||||
fs::remove_file(String::from("./tests/data/") + file.0).unwrap();
|
||||
|
||||
debug!("File '{}' checked and removed successfully", file.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn testdata() -> (Vec<(&'static str, String)>, Vec<PathBuf>) {
|
||||
let mut paths = Vec::new();
|
||||
let testdata = vec![
|
||||
("1.txt", generate_data()),
|
||||
("2.txt", generate_data()),
|
||||
("3.txt", generate_data()),
|
||||
];
|
||||
|
||||
for file in &testdata {
|
||||
let filepath = PathBuf::from_str("./tests/data/").unwrap().join(file.0);
|
||||
let mut writer = BufWriter::new(File::create(filepath.clone()).unwrap());
|
||||
paths.push(filepath);
|
||||
writer.write_all(file.1.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
(testdata, paths)
|
||||
}
|
||||
|
||||
fn generate_data() -> String {
|
||||
thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(30)
|
||||
.map(char::from)
|
||||
.collect::<String>()
|
||||
}
|
Loading…
Reference in New Issue
Block a user