This commit is contained in:
selfhoster selfhoster 2023-08-18 10:59:50 +02:00
commit 7e7359cd09
18 changed files with 767 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
target
Cargo.lock
**/.*.sw*

15
Cargo.toml Normal file
View File

@ -0,0 +1,15 @@
[package]
name = "yunohome"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
futures = "0.3"
tower = "0.4"
tokio = { version = "1", features = [ "full" ] }
hyper = { version = "0.14", features = [ "full" ] }
axum = { version = "0.6", features = [ "headers", "http2", "macros", "tracing" ] }
clap = { version = "4.3", features = [ "derive" ] }
snafu = "0.7"

11
src/cli.rs Normal file
View File

@ -0,0 +1,11 @@
use clap::Parser;
use std::path::PathBuf;
/// The main SSOWat program
#[derive(Debug, Parser)]
#[command(author, version, about, long_about = None)]
pub struct Cli {
/// Where to place the UNIX socket for SSOWat
pub path: PathBuf,
}

13
src/error.rs Normal file
View File

@ -0,0 +1,13 @@
use snafu::Snafu;
use std::path::PathBuf;
#[derive(Debug, Snafu)]
#[snafu(visibility(pub))]
pub enum Error {
#[snafu(display("Failed to spawn unix socket at {}", path.display()))]
SocketCreate { path: PathBuf, source: std::io::Error },
#[snafu(display("Failed to spawn a web server"))]
Server { source: hyper::Error },
}

29
src/main.rs Normal file
View File

@ -0,0 +1,29 @@
use clap::Parser;
use axum::{
routing::get,
Router,
};
mod cli;
mod error;
mod webserver;
use webserver::{handler, serve_socket};
#[tokio::main]
async fn main() -> Result<(), error::Error> {
// env_logger::init();
let args = cli::Cli::parse();
let path = args.path.clone();
let _ = tokio::fs::remove_file(&path).await;
tokio::fs::create_dir_all(path.parent().unwrap())
.await
.unwrap();
let app = Router::new().route("/", get(handler));
serve_socket(&path, app).await?;
Ok(())
}

26
src/webserver/mod.rs Normal file
View File

@ -0,0 +1,26 @@
use axum::Router;
use axum::extract::connect_info::ConnectInfo;
use snafu::prelude::*;
use tokio::net::UnixListener;
use std::path::Path;
use crate::error::*;
mod utils;
pub use utils::*;
pub async fn serve_socket(path: &Path, app: Router) -> Result<(), Error> {
let uds = UnixListener::bind(path.clone())
.context(SocketCreateSnafu { path: path.clone() })?;
hyper::Server::builder(ServerAccept::new(uds))
.serve(app.into_make_service_with_connect_info::<UdsConnectInfo>())
.await.context(ServerSnafu)?;
Ok(())
}
pub async fn handler(ConnectInfo(info): ConnectInfo<UdsConnectInfo>) -> &'static str {
println!("new connection from `{:?}`", info);
"Hello, World!"
}

106
src/webserver/utils.rs Normal file
View File

@ -0,0 +1,106 @@
use axum::extract::connect_info;
use futures::ready;
use hyper::{
client::connect::{Connected, Connection},
server::accept::Accept,
};
use std::{
io,
// path::PathBuf,
pin::Pin,
sync::Arc,
task::{Context, Poll},
};
use tokio::{
io::{AsyncRead, AsyncWrite},
net::{unix::UCred, UnixListener, UnixStream},
};
use tower::BoxError;
pub struct ServerAccept {
uds: UnixListener,
}
impl ServerAccept {
pub fn new(uds: UnixListener) -> ServerAccept {
ServerAccept {
uds,
}
}
}
impl Accept for ServerAccept {
type Conn = UnixStream;
type Error = BoxError;
fn poll_accept(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Self::Conn, Self::Error>>> {
let (stream, _addr) = ready!(self.uds.poll_accept(cx))?;
Poll::Ready(Some(Ok(stream)))
}
}
pub struct ClientConnection {
stream: UnixStream,
}
impl AsyncWrite for ClientConnection {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<Result<usize, io::Error>> {
Pin::new(&mut self.stream).poll_write(cx, buf)
}
fn poll_flush(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Result<(), io::Error>> {
Pin::new(&mut self.stream).poll_flush(cx)
}
fn poll_shutdown(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Result<(), io::Error>> {
Pin::new(&mut self.stream).poll_shutdown(cx)
}
}
impl AsyncRead for ClientConnection {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> Poll<io::Result<()>> {
Pin::new(&mut self.stream).poll_read(cx, buf)
}
}
impl Connection for ClientConnection {
fn connected(&self) -> Connected {
Connected::new()
}
}
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub struct UdsConnectInfo {
peer_addr: Arc<tokio::net::unix::SocketAddr>,
peer_cred: UCred,
}
impl connect_info::Connected<&UnixStream> for UdsConnectInfo {
fn connect_info(target: &UnixStream) -> Self {
let peer_addr = target.peer_addr().unwrap();
let peer_cred = target.peer_cred().unwrap();
Self {
peer_addr: Arc::new(peer_addr),
peer_cred,
}
}
}

21
yunohost-api/Cargo.toml Normal file
View File

@ -0,0 +1,21 @@
[package]
name = "yunohost-api"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
ldap3 = "0.11"
env_logger = "0.10"
log = "0.4"
tokio = "1"
snafu = "0.7"
serde = { version = "1", features = [ "derive" ] }
serde_json = "1"
regex = "1.9"
url = "2.4"
[dev-dependencies]
scan-rules = "0.2"
tokio = { version = "1", features = [ "sync", "rt" ] }

View File

@ -0,0 +1,39 @@
use yunohost_api::{YunohostUsers, Username};
use std::io::{BufRead, Stdin, stdin};
use std::str::FromStr;
fn acquire_loop<T: FromStr>(input: &Stdin, message: &str) -> T
where <T as FromStr>::Err: std::fmt::Display {
loop {
println!("{message}");
let value = input.lock().lines().next().unwrap().unwrap();
match T::from_str(&value) {
Ok(v) => {
return v;
}, Err(e) => {
println!("{}", e);
}
}
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
env_logger::init();
let input = stdin();
let users = YunohostUsers::new(500).await.unwrap();
loop {
println!("Welcome to Yunohost! What user would you like to know about?");
let username: Username = acquire_loop(&input, "Username:");
if users.get_user(&username).await.unwrap() {
println!("User found!");
} else {
println!("User not found!");
}
}
}

View File

@ -0,0 +1,40 @@
use yunohost_api::{YunohostUsers, Username, Password};
use std::io::{BufRead, Stdin, stdin};
use std::str::FromStr;
fn acquire_loop<T: FromStr>(input: &Stdin, message: &str) -> T
where <T as FromStr>::Err: std::fmt::Display {
loop {
println!("{message}");
let value = input.lock().lines().next().unwrap().unwrap();
match T::from_str(&value) {
Ok(v) => {
return v;
}, Err(e) => {
println!("{}", e);
}
}
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
env_logger::init();
let input = stdin();
let users = YunohostUsers::new(500).await.unwrap();
loop {
println!("Welcome to Yunohost! Please login to continue...");
let username: Username = acquire_loop(&input, "Username:");
let password: Password = acquire_loop(&input, "Password:");
if users.check_credentials(&username, &password).await.unwrap() {
println!("Successful login");
} else {
println!("Failed login");
}
}
}

91
yunohost-api/src/cache.rs Executable file
View File

@ -0,0 +1,91 @@
use serde::Deserialize;
use snafu::prelude::*;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use crate::error::*;
// TODO: Can we return reference to the inner data without cloning?
// Cloning is MUCH cheaper than reading from disk so it's not really a problem
// TODO: Should we TryFrom<Vec<u8>> so we support more than JSON/FromStr?
pub trait Cachable: Clone + std::fmt::Debug + for <'a> Deserialize<'a> + Default {}
impl<T: Clone + std::fmt::Debug + for<'a> Deserialize<'a> + Default> Cachable for T {}
#[derive(Debug)]
pub struct JsonCacheInner<T: Cachable> {
mtime: i64,
content: T,
}
impl<T: Cachable> JsonCacheInner<T> {
pub fn new() -> JsonCacheInner<T> {
JsonCacheInner {
mtime: i64::MIN,
content: T::default(),
}
}
pub fn get(&self) -> T {
self.content.clone()
}
}
#[derive(Debug)]
pub struct JsonCache<T: Cachable> {
path: PathBuf,
inner: RwLock<JsonCacheInner<T>>,
}
impl<T: Cachable> JsonCache<T> {
/// Prepares a cached JSON file. Does not load from disk.
pub fn new(path: &Path) -> JsonCache<T> {
JsonCache {
path: path.to_path_buf(),
inner: RwLock::new(JsonCacheInner::new()),
}
}
/// Loads a cached JSON file from disk to memory
pub fn load(path: &Path) -> Result<JsonCache<T>, Error> {
let cache = Self::new(path);
cache.reload(0)?;
Ok(cache)
}
pub fn get(&self) -> Result<T, Error> {
if let Some(new_mtime) = self.stale()? {
self.reload(new_mtime)?;
}
let lock = self.inner.read().unwrap();
let content = lock.get();
Ok(content)
}
pub fn stale(&self) -> Result<Option<i64>, Error> {
let metadata = self.path.metadata().context(ReadFileSnafu { path: self.path.clone() })?;
let inner = self.inner.read().unwrap();
let mtime = metadata.mtime();
if mtime != inner.mtime {
// Needs reloading
Ok(Some(mtime))
} else {
// Data still valid
Ok(None)
}
}
fn reload(&self, mtime: i64) -> Result<(), Error> {
let mut inner = self.inner.write().unwrap();
let content = std::fs::read_to_string(&self.path).context(ReadFileSnafu { path: self.path.clone() })?;
//let content = T::from_str(&content)?;
let content: T = serde_json::from_str(&content).context(InvalidJsonSnafu { path: self.path.clone() })?;
inner.content = content;
inner.mtime = mtime;
Ok(())
}
}

View File

@ -0,0 +1,79 @@
use ldap3::dn_escape;
use serde::{Serialize, Deserialize};
use snafu::OptionExt;
use std::str::FromStr;
use crate::error::*;
fn non_empty_string(s: &str) -> Option<String> {
if s.trim() == "" {
None
} else {
Some(s.to_string())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Username(String);
impl Username {
pub fn new(s: &str) -> Result<Username, Error> {
non_empty_string(s).context(EmptyUsernameSnafu)
.map(|s| Username(s))
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
pub fn ldap_escape(&self) -> String {
dn_escape(self.as_str()).to_string()
}
pub fn to_dn(&self, domains: &[&str]) -> String {
let mut dn = format!("uid={},ou=users", self.ldap_escape());
for domain in domains {
dn.push_str(",dc=");
dn.push_str(domain);
}
dn
}
}
impl FromStr for Username {
type Err = Error;
fn from_str(s: &str) -> Result<Username, Error> {
Username::new(s)
}
}
#[derive(Clone, Debug)]
pub struct Password(String);
impl Password {
pub fn new(s: &str) -> Result<Password, Error> {
non_empty_string(s).context(EmptyPasswordSnafu)
.map(|s| Password(s))
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
pub fn ldap_escape(&self) -> String {
dn_escape(self.as_str()).to_string()
}
}
impl FromStr for Password {
type Err = Error;
fn from_str(s: &str) -> Result<Password, Error> {
Password::new(s)
}
}

33
yunohost-api/src/error.rs Normal file
View File

@ -0,0 +1,33 @@
use snafu::Snafu;
use std::path::PathBuf;
use crate::Username;
#[derive(Debug, Snafu)]
#[snafu(visibility(pub))]
pub enum Error {
#[snafu(display("Failed to establish connection to the LDAP database at path {uri}"))]
LdapInit { uri: String, source: ldap3::result::LdapError },
#[snafu(display("Failed to bind on the LDAP database"))]
LdapBind { source: ldap3::result::LdapError },
#[snafu(display("Failed to search the LDAP database"))]
LdapSearch { source: ldap3::result::LdapError },
#[snafu(display("No such user: {}", username.as_str()))]
LdapNoSuchUser { username: Username },
#[snafu(display("Empty username provided for login"))]
EmptyUsername,
#[snafu(display("Empty password provided for login"))]
EmptyPassword,
#[snafu(display("Failed to read file {}", path.display()))]
ReadFile { path: PathBuf, source: std::io::Error },
#[snafu(display("Invalid JSON in file {}", path.display()))]
InvalidJson { path: PathBuf, source: serde_json::Error },
}

View File

@ -0,0 +1,57 @@
// /// Remove the leading http(s) part of a URI.
// /// ```
// /// # use yunohost_api::SSOWatConfig;
// /// let stripped = SSOWatConfig::strip_url_protocol("http://theanarchistlibrary.org/");
// /// # assert_eq!("theanarchistlibrary.org/", stripped)
// /// ```
// /// Result: theanarchistlibrary.org/
// /// ```
// /// # use yunohost_api::SSOWatConfig;
// /// let stripped = SSOWatConfig::strip_url_protocol("misformedhttpdomain/");
// /// # assert_eq!("misformedhttpdomain/", stripped);
// /// ```
// /// Result: misformedhttpdomain/
// pub fn strip_url_protocol<'a>(uri: &'a str) -> &'a str {
// let s = uri.strip_prefix("http").unwrap_or(&uri);
// let s = s.strip_prefix("s").unwrap_or(&s);
// let s = s.strip_prefix("://").unwrap_or(&s);
// s
// // uri
// // .strip_prefix("http")
// // .and_then(|s| s.strip_prefix(""))
// // .trim_start_matches("http")
// // .trim_start_matches("s")
// // .trim_start_matches("://")
// }
/// Extracts the domain part of a http(s) URI.
/// ```
/// # use yunohost_api::SSOWatConfig;
/// let domain = SSOWatConfig::extract_domain("https://mediaslibres.org/spip.php?page=sedna-rss");
/// assert_eq!("mediaslibres.org", domain);
/// ```
/// Result: mediaslibres.org
/// ```
/// # use yunohost_api::SSOWatConfig;
/// let domain = SSOWatConfig::extract_domain("http://foo.bar.example.com");
/// assert_eq!("foo.bar.example.com", domain);
/// ```
/// Result: foo.bar.example.com
/// ```
/// # use yunohost_api::SSOWatConfig;
/// let domain = SSOWatConfig::extract_domain("http://foo.bar.example.com/bar/baz");
/// assert_eq!("foo.bar.example.com", domain);
/// ```
/// Result: foo.bar.example.com
/// ```
/// # use yunohost_api::SSOWatConfig;
/// let domain = SSOWatConfig::extract_domain("misformedhttpdomain");
/// # assert_eq!("misformedhttpdomain", domain)
/// ```
/// Result: misformedhttpdomain
pub fn extract_domain<'a>(uri: &'a str) -> &'a str {
let mut split = Self::strip_url_protocol(uri).split('/');
// Even if nothing was matched, there should always be one split part
// If there was an additional slash, we just extracted the domain
split.next().unwrap()
}

110
yunohost-api/src/ldap.rs Normal file
View File

@ -0,0 +1,110 @@
use ldap3::{LdapConnAsync, LdapConnSettings, Ldap, Scope, exop::WhoAmI, SearchEntry};
use snafu::prelude::*;
use tokio::sync::RwLock;
use std::sync::Arc;
use std::time::Duration;
use crate::credentials::{Username, Password};
use crate::error::*;
const LDAP_PATH: &'static str = "ldapi://%2Fvar%2Frun%2Fslapd%2Fldapi";
/// Opens a new LDAP connection. Does not guarantee it will stay alive
async fn new_ldap(timeout: Duration) -> Result<Ldap, Error> {
let settings = LdapConnSettings::new().set_conn_timeout(timeout);
log::info!("Opening new LDAP connection with timeout: {}ms", timeout.as_millis());
let (conn, ldap) = LdapConnAsync::with_settings(
settings,
LDAP_PATH
).await.context(LdapInitSnafu { uri: LDAP_PATH.to_string() })?;
tokio::spawn(async move {
if let Err(e) = conn.drive().await {
log::error!("{}", e);
}
});
Ok(ldap)
}
#[derive(Clone, Debug)]
pub struct YunohostUsers {
timeout: Duration,
inner: Arc<RwLock<Ldap>>,
}
impl YunohostUsers {
/// Open a new connection to Yunohost LDAP database with a specific timeout in milliseconds
pub async fn new(timeout: u64) -> Result<YunohostUsers, Error> {
let timeout = Duration::from_millis(timeout);
Ok(YunohostUsers {
timeout: timeout.clone(),
inner: Arc::new(RwLock::new(
new_ldap(timeout).await?
)),
})
}
pub async fn keepalive(&self) -> Result<(), Error> {
{
let mut ldap = self.inner.write().await;
if ! ldap.is_closed() {
// check connection is still alive with WhoAmI
ldap.with_timeout(self.timeout.clone());
if ldap.extended(WhoAmI).await.is_ok() {
return Ok(());
}
}
}
log::warn!("LDAP connection has been closed. Opening again.");
let mut ldap = self.inner.clone().write_owned().await;
*ldap = new_ldap(self.timeout.clone()).await?;
Ok(())
}
pub async fn check_credentials(&self, username: &Username, password: &Password) -> Result<bool, Error> {
self.keepalive().await?;
let mut ldap = self.inner.write().await;
let username = username.to_dn(& [ "yunohost", "org" ]);
let password = password.ldap_escape();
log::debug!("Attempting LDAP login for {}", &username);
ldap.with_timeout(self.timeout.clone());
let reply = ldap.simple_bind(
&username,
&password,
).await.context(LdapBindSnafu)?;
log::debug!("{:#?}", reply);
// Successful auth if return code is 0
Ok(reply.rc == 0)
}
pub async fn get_user(&self, username: &Username) -> Result<bool, Error> {
self.keepalive().await?;
let mut ldap = self.inner.write().await;
ldap.with_timeout(self.timeout.clone());
let res = ldap.search(
&username.to_dn(& [ "yunohost", "org" ]),
Scope::Base,
"(objectclass=*)",
[ "+" ],
).await.context(LdapSearchSnafu)?;
if let Ok((res, _)) = res.success() {
// There should be only one user with this uid
let res = res.into_iter().take(1).next().unwrap();
let res = SearchEntry::construct(res);
log::debug!("Found:\n{:#?}", res);
} else {
return Err(Error::LdapNoSuchUser { username: username.clone() });
}
Ok(true)
}
}

10
yunohost-api/src/lib.rs Normal file
View File

@ -0,0 +1,10 @@
mod cache;
pub use cache::JsonCache;
mod credentials;
pub use credentials::{Username, Password};
mod error;
pub use error::Error;
mod ldap;
pub use ldap::YunohostUsers;
mod permissions;
pub use permissions::{YunohostPermissions, SSOWatConfig, PermissionName};

View File

@ -0,0 +1,30 @@
use std::path::Path;
use crate::error::*;
use crate::JsonCache;
mod ssowat;
pub use ssowat::{SSOWatConfig, PermissionName};
#[derive(Debug)]
pub struct YunohostPermissions {
ssowat: JsonCache<SSOWatConfig>,
}
impl YunohostPermissions {
pub fn new() -> Result<YunohostPermissions, Error> {
Self::from_path("/etc/ssowat/conf.json")
}
pub fn from_path<T: AsRef<Path>>(path: T) -> Result<YunohostPermissions, Error> {
let path = path.as_ref();
let ssowat: JsonCache<SSOWatConfig> = JsonCache::load(path)?;
Ok(YunohostPermissions {
ssowat,
})
}
pub fn ssowat_config(&self) -> Result<SSOWatConfig, Error> {
self.ssowat.get()
}
}

View File

@ -0,0 +1,54 @@
use serde::{Serialize, Deserialize};
use std::collections::HashMap;
use crate::{Username};
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SSOWatConfig {
domains: Vec<String>,
permissions: HashMap<PermissionName, Permission>,
portal_domain: String,
portal_path: String,
redirected_urls: HashMap<String, String>,
theme: String,
}
impl SSOWatConfig {
pub fn permission_for_uri(&self, uri: &Url) -> Option<PermissionName> {
// First check if the domain is actually managed by SSOWat
if let Some(domain) = uri.domain() {
if ! self.domains.contains(&domain.to_string()) {
// Domain not managed
return None;
}
} else {
// No domain (eg. http://8.8.8.8/)
return None;
}
let domain = Self::extract_domain(uri);
if self.domains.contains(&domain.to_string()) {
todo!();
}
todo!();
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct Permission {
auth_header: bool,
label: String,
public: bool,
show_tile: bool,
uris: Vec<String>,
use_remote_user_in_nginx_conf: bool,
users: Vec<Username>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct PermissionName {
#[serde(flatten)]
name: String,
}