This commit is contained in:
Kyattsukuro 2025-08-16 22:15:49 +02:00
commit 8ee4fe14ff
24 changed files with 4780 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
debug_config
data

4
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"nixEnvSelector.suggestion": false,
"nixEnvSelector.nixFile": "${workspaceFolder}/shell.nix"
}

4138
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "auto-decrypt"
version = "0.1.0"
edition = "2024"
[dependencies]
async-trait = "0.1.88"
reqwest = "0.12.22"
async-ssh2-tokio = "0.8.15"
serde = { version = "1.0", features = ["derive"] }
toml = "0.8"
clap = {version = "4.5.37", features = ["derive"]}
log = "0.4.27"
diesel = { version = "2.2.0", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] }
rocket = "0.5.1"
url = "2"
custom_error = "1.9.2"

9
diesel.toml Normal file
View File

@ -0,0 +1,9 @@
# For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/orm/schema.rs"
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
[migrations_directory]
dir = "./migrations"

0
migrations/.keep Normal file
View File

View File

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE IF EXISTS sessions;

View File

@ -0,0 +1,10 @@
-- Your SQL goes here
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_name TEXT NOT NULL,
request_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
state TEXT NOT NULL,
awnsered_at TIMESTAMP,
awnsered_by TEXT
)

View File

@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
DROP TABLE beggars_records;
ALTER TABLE providers_records RENAME TO sessions;

View File

@ -0,0 +1,10 @@
-- Your SQL goes here
CREATE TABLE beggars_records (
request_time TIMESTAMP PRIMARY KEY DEFAULT CURRENT_TIMESTAMP NOT NULL,
service_name TEXT NOT NULL,
http_result INTEGER NOT NULL,
note TEXT,
awnsered_by TEXT
);
ALTER TABLE sessions RENAME TO providers_records;

30
shell.nix Normal file
View File

@ -0,0 +1,30 @@
let
# Import nixpkgs and add the mozilla overlay
nixpkgs = import <nixpkgs> {
overlays = [
(import (builtins.fetchTarball {
url = "https://github.com/mozilla/nixpkgs-mozilla/archive/master.tar.gz";
}))
];
};
in
nixpkgs.mkShell {
buildInputs = [
(nixpkgs.rustChannelOf {
channel = "stable";
date = null; # null means latest
}).rust
nixpkgs.openssl
nixpkgs.pkg-config
nixpkgs.sqlite
nixpkgs.libpq
nixpkgs.libmysqlclient
];
shellHook = ''
export TMPDIR=/tmp
export PATH=$PATH:$HOME/.cargo/bin
cargo install diesel_cli
'';
}

60
src/api/guards.rs Normal file
View File

@ -0,0 +1,60 @@
use rocket::form::Strict;
use rocket::request::{Outcome, Request, FromRequest};
use url::form_urlencoded;
use crate::app_config::CONFIG;
use crate::orm::DbConn;
use crate::api::State;
use rocket::http::uri::Query;
use crate::services::providers::Providers;
pub(super) struct APIProviderRequest<'r> {
pub share: &'r Providers,
}
fn get_query_value(query: &Option<Query>, query_key: &str) -> Option<String> {
if let Some(query) = query {
// Parse the query string manually
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
if key == query_key {
return Some(value.to_string());
}
}
}
None
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for APIProviderRequest<'r> {
type Error = ();
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
//let db_conn = req.guard::<&State<DbConn>>().await.unwrap();
let path = req
.uri()
.path()
.segments();
let share_name: &str = path.get(path.len() - 1).unwrap_or(&"");
let access_key: &str = &get_query_value(&req.uri().query(), "trigger_key").unwrap_or("".to_string());
if share_name == "" {
Outcome::Error((rocket::http::Status::BadRequest, ()))
} else {
let share: Option<&Providers> = CONFIG.wait().providers.iter()
.find(|service| service.name == share_name)
.and_then(|service| {
if service.access_key == access_key {
Some(service)
} else {
None
}
});
if share.is_none() {
return Outcome::Error((rocket::http::Status::NotFound, ()))
}
return Outcome::Success(APIProviderRequest { share: share.unwrap() })
}
}
}

24
src/api/mod.rs Normal file
View File

@ -0,0 +1,24 @@
mod guards;
mod request;
use crate::app_config::CONFIG;
use crate::orm::DbConn;
// Alternativly iport rocket manually: use rocket::get;
use rocket::fs::FileServer;
use rocket::response::Redirect;
use rocket::fs::NamedFile;
use rocket::State;
#[get("/", rank = 1)]
async fn index() -> String {
"Welcome to the Auto-Decrypt API!".to_string()
}
#[rocket::main]
pub(crate) async fn start_api() -> () {
let _ = rocket::build()
.manage(DbConn::establish_connection(&CONFIG.wait().db_file)) // Manage the state here
.mount("/", routes![index])
.launch()
.await;
}

25
src/api/request.rs Normal file
View File

@ -0,0 +1,25 @@
use super::guards::APIProviderRequest;
use crate::services::providers::ConsentMethode;
use crate::app_config::CONFIG;
use rocket::get;
#[get("/<_>", rank = 1)]
async fn request_handler(guard: APIProviderRequest<'_>) -> String {
// We assume the request guard verified th access_key against the service_name
match guard.share.consent_methode {
ConsentMethode::None => {
// We do not wait for constent, direct feedback is impleied
todo!()
},
ConsentMethode::Boolean => {
todo!()
},
ConsentMethode::Passkey(_) => {
todo!()
},
}
}

115
src/app_config.rs Normal file
View File

@ -0,0 +1,115 @@
use std::sync::OnceLock;
use std::fs;
use std::path::Path;
use serde::{ser, Deserialize};
use crate::services::beggars::Beggars;
use crate::services::providers::Providers;
use crate::errors::AutoDecryptError;
#[derive(Debug, Deserialize)]
enum Platform {
OMV,
TrueNas,
}
#[derive(Debug, Deserialize)]
pub(crate) struct AppConfig {
pub(crate) port: u16,
pub(crate) addresses: Vec<String>,
pub(crate) platform: Platform,
pub(crate) ssh_known_host_file: String,
pub(crate) db_file: String,
pub(crate) beggars: Vec<Beggars>, // We request to unlock owned services
pub(crate) providers: Vec<Providers>, // We offer to unlock these services
}
impl Default for AppConfig {
fn default() -> Self {
AppConfig {
port: 8080,
addresses: vec!["*".to_string()],
beggars: vec![],
providers: vec![],
platform: Platform::OMV,
ssh_known_host_file: "".to_string(),
db_file: "/var/auto-decrypt/db.sqlite".to_string(),
}
}
}
impl AppConfig {
fn load_provided_services(at_path: &Path) -> Result<Vec<Providers>, AutoDecryptError> {
let mut provided_services = vec![];
if let Ok(entries) = fs::read_dir(at_path) {
for entry in entries {
let entry = entry.map_err(|e| AutoDecryptError::ConfigurationErroro { comment: e.to_string() })?;
if entry.path().extension().and_then(|s| s.to_str()) == Some("toml") {
let service: Providers = toml::from_str(&fs::read_to_string(entry.path()).map_err(|e| AutoDecryptError::ConfigurationErroro { comment: e.to_string() })?)
.map_err(|e| AutoDecryptError::ConfigurationErroro { comment: e.to_string() })?;
provided_services.push(service);
}
}
}
Ok(provided_services)
}
fn load_requesting_services(at_path: &Path) -> Result<Vec<Beggars>, AutoDecryptError> {
let mut requesting_services = vec![];
if let Ok(entries) = fs::read_dir(at_path) {
for entry in entries {
let entry = entry.map_err(|e| AutoDecryptError::ConfigurationErroro { comment: e.to_string() })?;
if entry.path().extension().and_then(|s| s.to_str()) == Some("toml") {
let service: Beggars = toml::from_str(&fs::read_to_string(entry.path()).map_err(|e| AutoDecryptError::ConfigurationErroro { comment: e.to_string() })?)
.map_err(|e| AutoDecryptError::ConfigurationErroro { comment: e.to_string() })?;
requesting_services.push(service);
}
}
}
Ok(requesting_services)
}
pub fn load_from_file(path: &Path ) -> Result<Self, AutoDecryptError> {
if !path.is_dir(){
return Err(AutoDecryptError::ConfigurationErroro { comment: ("The provided configuration path is not a Folder!".to_string()) })
}
let contents = fs::read_to_string(path.join("config.toml"))
.unwrap_or_else(|err| {
eprintln!("Failed to read config file: {err}. Using default configuration.");
return String::new(); // Return empty string to fall back to default
});
let mut basic_config = toml::from_str(&contents)
.unwrap_or_else(|err| {
eprintln!("Failed to parse config file: {err}. Using default configuration.");
Self::default()
});
basic_config.beggars = Self::load_requesting_services(&path.join("requesting_services"))
.unwrap_or_else(|err| {
eprintln!("Failed to load requesting services: {err}. Using empty list.");
vec![]
});
basic_config.providers = Self::load_provided_services(&path.join("provided_services"))
.unwrap_or_else(|err| {
eprintln!("Failed to load provided services: {err}. Using empty list.");
vec![]
});
return Ok(basic_config);
}
}
pub static CONFIG: OnceLock<AppConfig> = OnceLock::new();
pub fn init_config(file: &str) {
let config = AppConfig::load_from_file(Path::new(file))
.expect("Failed to load configuration file");
CONFIG.set(config).expect("AppConfig can only be initialized once");
}

7
src/errors.rs Normal file
View File

@ -0,0 +1,7 @@
use custom_error::custom_error;
custom_error!{pub AutoDecryptError
ConfigurationErroro{comment:String} = "{comment}",
APIError{comment:String} = "{comment}",
ORMError{comment:String} = "{comment}",
}

25
src/main.rs Normal file
View File

@ -0,0 +1,25 @@
mod services;
mod app_config;
mod api;
use clap::Parser;
use crate::app_config::{init_config, CONFIG};
mod orm;
mod errors;
#[macro_use] extern crate rocket;
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct InputArgs {
#[arg(short='c', long, help = "Path to the configuration file", default_value = "debug_config/")]
config_file: String,
}
fn main() {
let args = InputArgs::parse();
init_config(&args.config_file);
api::start_api();
}

33
src/orm/handler.rs Normal file
View File

@ -0,0 +1,33 @@
use diesel::RunQueryDsl;
use diesel::QueryDsl;
use diesel::ExpressionMethods;
use super::DbConn;
use super::structures::*;
use crate::orm::schema::{providers_records, beggars_records};
impl DbConn {
fn add_provider_record(self, record: &ProviderRecord) {
let conn = &mut *self.0.lock().unwrap();
diesel::insert_into(providers_records::table)
.values(record)
.execute(conn)
.expect("Error saving new service record");
}
fn update_provider_state(self, id: i32, new_state: RecordStates) {
let conn = &mut *self.0.lock().unwrap();
diesel::update(providers_records::table.filter(providers_records::dsl::id.eq(id)))
.set(providers_records::state.eq(new_state.to_string()))
.execute(conn)
.expect("Error updating service record state");
}
fn add_beggars_record(self, record: &BeggarsRecord) {
let conn = &mut *self.0.lock().unwrap();
diesel::insert_into(beggars_records::table)
.values(record)
.execute(conn)
.expect("Error saving new beggars record");
}
}

17
src/orm/mod.rs Normal file
View File

@ -0,0 +1,17 @@
use diesel::sqlite::SqliteConnection;
use diesel::Connection;
use std::sync::{Arc, Mutex};
mod schema;
mod handler;
pub mod structures;
pub struct DbConn(pub Arc<Mutex<SqliteConnection>>);
impl DbConn {
pub fn establish_connection(database_url: &str) -> Self {
let connection = SqliteConnection::establish(&database_url)
.expect(&format!("Error connecting to {}", database_url));
Self(Arc::new(Mutex::new(connection)))
}
}

28
src/orm/schema.rs Normal file
View File

@ -0,0 +1,28 @@
// @generated automatically by Diesel CLI.
diesel::table! {
beggars_records (request_time) {
request_time -> Timestamp,
service_name -> Text,
http_result -> Integer,
note -> Nullable<Text>,
awnsered_by -> Nullable<Text>,
}
}
diesel::table! {
providers_records (id) {
id -> Nullable<Integer>,
service_name -> Text,
request_time -> Nullable<Timestamp>,
expires_at -> Nullable<Timestamp>,
state -> Text,
awnsered_at -> Nullable<Timestamp>,
awnsered_by -> Nullable<Text>,
}
}
diesel::allow_tables_to_appear_in_same_query!(
beggars_records,
providers_records,
);

86
src/orm/structures.rs Normal file
View File

@ -0,0 +1,86 @@
use clap::builder::Str;
use diesel::{
deserialize::FromSql,
serialize::{Output, ToSql},
sql_types::Text,
Queryable,
Selectable,
Insertable,
sqlite::{Sqlite, SqliteValue},
AsExpression, FromSqlRow,
};
use std::fmt;
#[derive(FromSqlRow, Debug, AsExpression)]
#[diesel(sql_type = Text)]
pub(crate) enum RecordStates {
Pending,
Accepted,
Rejected,
Expired,
}
impl fmt::Display for RecordStates {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
RecordStates::Pending => write!(f, "Pending"),
RecordStates::Accepted => write!(f, "Accepted"),
RecordStates::Rejected => write!(f, "Rejected"),
RecordStates::Expired => write!(f, "Expired"),
}
}
}
impl TryFrom<&str> for RecordStates {
type Error = String;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value {
"Pending" => Ok(RecordStates::Pending),
"Accepted" => Ok(RecordStates::Accepted),
"Rejected" => Ok(RecordStates::Rejected),
"Expired" => Ok(RecordStates::Expired),
_ => Err(format!("Unknown state: {}", value)),
}
}
}
impl FromSql<Text, Sqlite> for RecordStates {
fn from_sql(bytes: SqliteValue) -> diesel::deserialize::Result<Self> {
let t = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?;
Ok(t.as_str().try_into()?)
}
}
impl ToSql<Text, Sqlite> for RecordStates {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Sqlite>) -> diesel::serialize::Result {
out.set_value(self.to_string());
Ok(diesel::serialize::IsNull::No)
}
}
#[derive(Debug)]
#[derive(Queryable, Selectable, Insertable)]
#[diesel(table_name = super::schema::providers_records)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub(crate) struct ProviderRecord{
service_name: String,
expires_at: Option<String>,
state: RecordStates,
awnsered_by: Option<String>,
}
#[derive(Debug)]
#[derive(Insertable)]
#[diesel(table_name = super::schema::beggars_records)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub(crate) struct BeggarsRecord{
//request_time: String, // automatically set by sqlite
service_name: String,
http_result: i32,
note: Option<String>,
awnsered_by: Option<String>,
}

36
src/services/beggars.rs Normal file
View File

@ -0,0 +1,36 @@
use reqwest::Client;
use reqwest::header::{HeaderName, HeaderValue};
use serde::Deserialize;
use log::{info, warn};
#[derive(Debug, Deserialize)]
pub struct Beggars {
name: String, // used in URI to identify the service
path: String, // path on this machine
server_to_unlock: String,
trigger_key: Option<String>,
}
impl Beggars {
pub(crate) async fn try_unlock(&self) -> Result<(), String> {
let client = Client::new();
let req_builder = client.request(
reqwest::Method::from_bytes(b"POST")
.map_err(|e| e.to_string())?,
format!("{}/request/{}?trigger_key={}",
self.server_to_unlock,
self.name,
self.trigger_key.clone().unwrap_or("".to_string()))
);
let response = req_builder.send().await
.map_err(|e| e.to_string())?;
if response.status().is_success() {
info!("Unlock request suecsessfully issued, expecting unlock: {}", self.name);
Ok(())
} else {
warn!("Failed request unlocking of service {}. Status: {}", self.name, response.status());
Err(response.status().to_string())
}
}
}

4
src/services/mod.rs Normal file
View File

@ -0,0 +1,4 @@
pub mod beggars;
pub mod providers;

94
src/services/providers.rs Normal file
View File

@ -0,0 +1,94 @@
use async_ssh2_tokio::Config;
use reqwest::Client;
use reqwest::header::{HeaderName, HeaderValue};
use async_trait::async_trait;
use async_ssh2_tokio::client::{self, AuthMethod, Client as SSHClient, ServerCheckMethod};
use serde::Deserialize;
use crate::app_config::CONFIG;
#[derive(Debug, Deserialize)]
pub(crate) enum ConsentMethode {
None,
Boolean,
Passkey(String),
}
#[derive(Debug, Deserialize)]
pub(crate) enum ActionType {
HTTPRequest(HTTPRequest),
SSHRequest(SSHRequest),
}
#[derive(Debug, Deserialize)]
pub struct Providers {
pub(crate) name: String,
pub(crate) access_key: String,
pub(crate) consent_methode: ConsentMethode,
pub(crate) execution_action: ActionType,
}
#[derive(Debug, Deserialize)]
struct HTTPRequest {
pub(crate) method: String,
pub(crate) uri: String,
pub(crate) headers: Option<Vec<(String, String)>>,
pub(crate) body: Option<String>,
}
#[async_trait]
impl ServiceAction for HTTPRequest {
async fn execute(&self) -> Result<String, String> {
let client = Client::new();
let mut req_builder = client.request(
reqwest::Method::from_bytes(self.method.as_bytes())
.map_err(|e| e.to_string())?,
&self.uri,
);
if let Some(headers) = &self.headers {
for (key, value) in headers {
req_builder = req_builder.header(
HeaderName::from_bytes(key.as_bytes()).map_err(|e| e.to_string())?,
HeaderValue::from_str(value).map_err(|e| e.to_string())?,
);
}
}
let response = req_builder.send().await
.map_err(|e| e.to_string())?;
response.text().await.map_err(|e| e.to_string())
}
}
#[derive(Debug, Deserialize)]
struct SSHRequest {
pub(crate) uri: String,
pub(crate) port: u16,
pub(crate) username: String,
pub(crate) private_key: String,
pub(crate) cmd: String,
}
#[async_trait]
impl ServiceAction for SSHRequest {
async fn execute(&self) -> Result<String, String> {
let auth_method = AuthMethod::PrivateKey { key_data: self.private_key.clone(), key_pass: None };
let client = SSHClient::connect(
(&*self.uri, self.port),
&self.username,
auth_method,
ServerCheckMethod::KnownHostsFile(CONFIG.wait().ssh_known_host_file.clone()),
).await.map_err(|e| e.to_string())?;
let result = client.execute(&self.cmd).await.map_err(|e| e.to_string())?;
if result.exit_status == 0 {
Ok(result.stdout)
} else {
Err(result.stderr)
}
}
}
#[async_trait]
trait ServiceAction: std::fmt::Debug + Send + Sync {
async fn execute(&self) -> Result<String, String>;
}