This commit is contained in:
2022-12-30 20:55:14 +08:00
parent 5a9c09d673
commit 118d6a5a1d
53 changed files with 4720 additions and 1 deletions

78
Cargo.toml Normal file
View File

@@ -0,0 +1,78 @@
[package]
name = "webdav-server"
# When releasing to crates.io:
# - Update html_root_url in src/main.rs
# - Update CHANGELOG.md.
# - Create git tag webdav-server-0.x.y
version = "0.4.0"
description = "webdav/http server with support for user accounts"
readme = "README.md"
documentation = "https://docs.rs/webdav-server"
repository = "https://github.com/miquels/webdav-server-rs"
homepage = "https://github.com/miquels/webdav-server-rs"
authors = ["Miquel van Smoorenburg <mike@langeraar.net>"]
edition = "2018"
license = "Apache-2.0"
categories = ["filesystem"]
[workspace]
[features]
# By default, the "pam" and "quota" features are enabled.
#
# Some systems do not have pam (like OpenBSD), so to compile this
# package without pam but with quota use:
#
# cargo build --release --no-default-features --features=quota
#
default = [ "pam", "quota" ]
# dependencies for the feature.
pam = [ "pam-sandboxed" ]
quota = [ "fs-quota" ]
# Include debug info in release builds.
[profile.release]
debug = true
# Build dependencies in optimized mode, even for debug builds.
[profile.dev.package."*"]
opt-level = 3
# Build dev-dependencies in non-optimized mode, even for release builds.
[profile.dev.build-override]
opt-level = 0
[dependencies]
clap = "2.33.3"
enum_from_str = "0.1.0"
enum_from_str_derive = "0.1.0"
env_logger = "0.8.3"
fs-quota = { path = "fs_quota", version = "0.1.0", optional = true }
futures = "0.3.15"
handlebars = "3.5.5"
headers = "0.3.4"
http = "0.2.4"
hyper = { version = "0.14.7", features = [ "http1", "http2", "server", "stream", "runtime" ] }
lazy_static = "1.4.0"
libc = "0.2.94"
log = "0.4.14"
nix = "0.21.0"
pam-sandboxed = { path = "pam", version = "0.2.0", optional = true }
percent-encoding = "2.1.0"
regex = "1.5.4"
rustls-pemfile = "1.0.0"
serde = { version = "1.0.125", features = ["derive"] }
serde_json = "1.0.64"
socket2 = "0.4.0"
time = "0.1.42"
tls-listener = { version = "0.5.1", features = [ "hyper-h1", "hyper-h2", "rustls" ] }
tokio = { version = "1.5.0", features = ["full"] }
tokio-rustls = "0.23.4"
toml = "0.5.8"
url = "2.2.2"
webdav-handler = { path = "../webdav-handler-rs", version = "=0.2.0" }
#webdav-handler = "0.2.0"
pwhash = "1.0.0"

View File

@@ -1,2 +1,60 @@
# webdav-server-rs
# WEBDAV-SERVER
An implementation of a webdav server with support for user accounts,
and switching uid/gid to those users accounts. That last feature
is Linux-only, since the server is threaded and no other OSes have
support for thread-local credentials.
Uses PAM authentication and local unix accounts.
This server does not implement logging. For now, it is assumed that
most users of this software want to put an NGNIX or Apache reverse-proxy
in front of it anyway, and that frontend can implement TLS, logging,
enforcing a maximum number of connections, and timeouts.
This crate uses futures 0.3 and async/await, so the minimum rust
compiler version is 1.39.
## Features.
- RFC4918: webdav, full support
- RFC4331: webdav quota support (linux quota, NFS quota, statfs)
- locking support (fake locking, enough for macOS and Windows clients)
- can be case insensitive for Windows clients
- files starting with a dot get the HIDDEN attribute on windows
- optimizations for macOS (spotlight indexing disabled, thumbnail previews
disabled, some light directory caching for `._` files)
- partial put support
- tested with Windows, macOS, Linux clients
## Building.
By default the server builds with **pam** and **quota** support. If your
OS does not support these one of features, use cargo command line options
to disable all features and enable only the ones your OS supports.
For example, to build on OpenBSD, which does not have pam:
```
cargo build --release --no-default-features --features=quota
```
## Configuration.
See the [example webdav-server.toml file](webdav-server.toml)
There is also an [example nginx proxy](examples/nginx-proxy.conf) configuration.
## Notes.
The built-in PAM client will add the client IP address to PAM requests.
If the client IP adress is localhost (127/8 or ::1) then the content of
the X-Forwarded-For header is used instead (if present) to allow for
aforementioned frontend proxies.
## Copyright and License.
* © 2018, 2019 XS4ALL Internet bv
* © 2018, 2019 Miquel van Smoorenburg
* [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0)

19
TODO.md Normal file
View File

@@ -0,0 +1,19 @@
## TODO list
- define list of "public dirs" where files/dirs are created 644/755,
and 600/700 everywhere else?
- blacklist accounts?
- document the PAM webdav config
- document nginx front-proxy config
# DONE:
- GET on directory -> directory listing
- configuration file
- systemd unit file
- configure path/prefix for accounts
- GET in / -- if file exists, do not authenticate. index support.
- rootfs: add serving files for GET
- parse index.tmpl file

25
debian/changelog vendored Normal file
View File

@@ -0,0 +1,25 @@
webdav-server-rs (0.4) unreleased; urgency=low
* update to 0.4 release.
-- Miquel van Smoorenburg (XS4ALL) <mikevs@xs4all.net> Wed, 12 May 2021 16:49:34 +0200
webdav-server-rs (0.3) unreleased; urgency=low
* update to webdav-handler-rs 0.2.0-alpha.1
-- Miquel van Smoorenburg (XS4ALL) <mikevs@xs4all.net> Thu, 21 Nov 2019 23:44:03 +0100
webdav-server-rs (0.2) buster; urgency=low
* update to webdav-handler-rs 0.1.2
* update to modern(erder) Rust
-- Miquel van Smoorenburg (XS4ALL) <mikevs@xs4all.net> Tue, 12 Nov 2019 15:41:53 +0100
webdav-server-rs (0.1) wheezy jessie stretch; urgency=low
* initial release
-- Miquel van Smoorenburg (XS4ALL) <mikevs@xs4all.net> Sun, 17 Mar 2019 23:20:52 +0100

1
debian/compat vendored Normal file
View File

@@ -0,0 +1 @@
12

12
debian/control vendored Normal file
View File

@@ -0,0 +1,12 @@
Source: webdav-server-rs
Section: net
Priority: extra
Maintainer: Miquel van Smoorenburg <mike@langeraar.net>
Uploaders: XS4ALL Unixbeheer <unixbeheer@xs4all.net>, Miquel van Smoorenburg (XS4ALL) <mikevs@xs4all.net>
Build-Depends: debhelper (>= 12), gcc, libc-dev, libc-dev-bin, libpam0g-dev
Standards-Version: 3.9.1
Package: webdav-server-rs
Architecture: any
Depends: ${shlibs:Depends}, systemd
Description: Webdav server

2
debian/copyright vendored Normal file
View File

@@ -0,0 +1,2 @@
Copyright 2018 - Miquel van Smoorenburg
Copyright 2019 - Miquel van Smoorenburg, XS4ALL Internet bv

1
debian/docs vendored Normal file
View File

@@ -0,0 +1 @@
README.md

1
debian/examples vendored Normal file
View File

@@ -0,0 +1 @@
webdav-server.toml

1
debian/install vendored Normal file
View File

@@ -0,0 +1 @@
target/release/webdav-server usr/sbin

19
debian/rules vendored Executable file
View File

@@ -0,0 +1,19 @@
#! /usr/bin/make -f
%:
dh $@
override_dh_auto_build:
@if ! command -v cargo >/dev/null; then \
echo "Rust is not installed ("cargo" command not found)" >&2; \
exit 1; fi
cargo build --release
override_dh_auto_clean:
override_dh_installsystemd:
override_dh_installinit:
dh_installinit --name webdav-server
dh_installsystemd --name webdav-server

1
debian/source/format vendored Normal file
View File

@@ -0,0 +1 @@
3.0 (native)

5
debian/source/options vendored Normal file
View File

@@ -0,0 +1,5 @@
--tar-ignore
--tar-ignore=target
--tar-ignore=TODO.md
--tar-ignore=*/src/*.toml
--tar-ignore=*test*.toml

View File

@@ -0,0 +1,19 @@
#! /bin/sh
### BEGIN INIT INFO
# Provides: webdav-server
# Required-Start: $network $remote_fs $local_fs
# Required-Stop: $network $remote_fs $local_fs
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: Stop/start webdav-server
### END INIT INFO
set -e
test -x /usr/sbin/webdav-server || exit 0
. /lib/lsb/init-functions
# If systemd is installed, we never actually get here.
exit 1

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Webdav server
After=network.target
ConditionPathExists=/etc/webdav-server.toml
[Service]
ExecStart=/usr/sbin/webdav-server
KillMode=process
[Install]
WantedBy=multi-user.target

63
examples/nginx-proxy.conf Normal file
View File

@@ -0,0 +1,63 @@
#
# Sample configuration for NGINX. Redirects http to https, and proxies
# https to localhost:4918.
#
# Replace SERVERNAME with the name of your server.
#
# On Debian, this config file can be put in /etc/nginx/sites-available/.
#
# Upstream server definition.
upstream webdav-rs {
server 127.0.0.1:4918;
keepalive 100;
keepalive_requests 100000;
keepalive_timeout 120s;
}
# Listener on port 80 that redirects to https.
server {
listen *:80;
listen [::]:80 ;
return 301 https://$host$request_uri;
autoindex off;
server_name SERVERNAME.example.com;
access_log /var/log/nginx/SERVERNAME.access.log;
error_log /var/log/nginx/SERVERNAME.error.log;
}
# The actual proxy on port 443.
server {
listen *:443 ssl http2;
listen [::]:443 ssl http2 ;
server_name SERVERNAME.example.com;
ssl_certificate /etc/letsencrypt/SERVERNAME/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/SERVERNAME/privkey.pem;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 5m;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH;
ssl_prefer_server_ciphers on;
access_log /var/log/nginx/SERVERNAME.access.log;
error_log /var/log/nginx/SERVERNAME-dev.error.log;
location / {
proxy_buffering off;
client_max_body_size 0;
proxy_pass http://webdav-rs;
proxy_read_timeout 120s;
proxy_connect_timeout 90s;
proxy_send_timeout 90s;
proxy_redirect off;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Ssl on;
proxy_set_header Connection "";
proxy_pass_header Date;
proxy_pass_header Server;
}
}

7
fs_quota/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
/target/
**/*.rs.bk
Cargo.lock
src/rquota.h
src/rquota_xdr.c

30
fs_quota/Cargo.toml Normal file
View File

@@ -0,0 +1,30 @@
[package]
name = "fs-quota"
# When releasing to crates.io:
# - Update html_root_url in src/lib.rs
# - Update CHANGELOG.md.
# - Run: cargo readme > README.md
# - Create git tag fs-quota-0.x.y
version = "0.1.0"
readme = "README.md"
documentation = "https://docs.rs/fs-quota"
repository = "https://github.com/miquels/webdav-server-rs"
homepage = "https://github.com/miquels/webdav-server-rs/tree/master/fs_quota"
authors = ["Miquel van Smoorenburg <mike@langeraar.net>"]
edition = "2018"
license = "Apache-2.0"
keywords = ["quota"]
categories = ["filesystem"]
[features]
nfs = []
default = ["nfs"]
[build-dependencies]
cc = "1.0.66"
[dependencies]
libc = "0.2.82"
log = "0.4.13"

36
fs_quota/README.md Normal file
View File

@@ -0,0 +1,36 @@
# fs-quota
Get filesystem disk space used and available for a unix user.
This crate has support for:
- the Linux quota system
- NFS quotas (via SUNRPC).
- `libc::vfsstat` lookups (like `df`).
Both the `quota` systemcall and `vfsstat` systemcall are different
on every system. That functionality is only implemented on Linux
right now. NFS quota support _should_ work everywhere.
NFS quota support can be left out by disabling the `nfs` feature.
Example application:
```rust
use fs_quota::*;
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
println!("usage: fs_quota <path>");
return;
}
println!("{:#?}", FsQuota::check(&args[1], None));
}
```
### Copyright and License.
* © 2018, 2019 XS4ALL Internet bv
* © 2018, 2019 Miquel van Smoorenburg
* [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0)

11
fs_quota/README.tpl Normal file
View File

@@ -0,0 +1,11 @@
# {{crate}}
{{readme}}
### Copyright and License.
* © 2018, 2019 XS4ALL Internet bv
* © 2018, 2019 Miquel van Smoorenburg
* [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0)

68
fs_quota/build.rs Normal file
View File

@@ -0,0 +1,68 @@
extern crate cc;
use std::fs::File;
use std::io::prelude::*;
use std::path::Path;
use std::process::Command;
fn run_rpcgen() {
let res = Command::new("rpcgen")
.arg("-c")
.arg("src/rquota.x")
.output()
.expect("failed to run rpcgen");
let csrc = String::from_utf8_lossy(&res.stdout);
let mut f = File::create("src/rquota_xdr.c").expect("src/rquota_xdr.c");
f.write_all(
csrc.replace("/usr/include/rpcsvc/rquota.h", "./rquota.h")
.replace("src/rquota.h", "./rquota.h")
.as_bytes(),
)
.unwrap();
let res = Command::new("rpcgen")
.arg("-h")
.arg("src/rquota.x")
.output()
.expect("failed to run rpcgen");
let hdr = String::from_utf8_lossy(&res.stdout);
let mut f = File::create("src/rquota.h").expect("src/rquota.h");
f.write_all(hdr.as_bytes()).unwrap();
}
fn main() {
#[cfg(feature = "nfs")]
run_rpcgen();
let mut builder = cc::Build::new();
#[cfg(target_os = "linux")]
builder.file("src/quota-linux.c");
#[cfg(feature = "nfs")]
{
if Path::new("/usr/include/tirpc").exists() {
// Fedora does not include RPC support in glibc anymore, so use tirpc instead.
builder.include("/usr/include/tirpc");
}
builder.file("src/quota-nfs.c").file("src/rquota_xdr.c");
}
builder
.flag_if_supported("-Wno-unused-variable")
.compile("fs_quota");
if Path::new("/usr/include/tirpc").exists() {
println!("cargo:rustc-link-lib=tirpc");
} else {
println!("cargo:rustc-link-lib=rpcsvc");
}
#[cfg(target_os = "linux")]
println!("cargo:rerun-if-changed=src/quota-linux.c");
#[cfg(feature = "nfs")]
{
println!("cargo:rerun-if-changed=src/rquota.x");
println!("cargo:rerun-if-changed=src/quota-nfs.c");
}
}

View File

@@ -0,0 +1,11 @@
extern crate fs_quota;
use fs_quota::*;
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
println!("usage: fs_quota <path>");
return;
}
println!("{:#?}", FsQuota::check(&args[1], None));
}

26
fs_quota/src/Makefile Normal file
View File

@@ -0,0 +1,26 @@
RPCGEN = rpcgen
CFLAGS = -Wall
rquota.a: quota-nfs.o rquota_xdr.o
rm -f rquota.a
ar r rquota.a quota-nfs.o rquota_xdr.o
rquota_xdr.c: rquota.h
(echo '#include <rpc/rpc.h>'; \
$(RPCGEN) -c rquota.x | \
sed -e 's/IXDR_PUT/(void)IXDR_PUT/g' \
-e 's,/usr/include/rpcsvc/rquota.h,rquota.h,' \
-e 's/^static char rcsid.*//' ) > rquota_xdr.c
rquota.h: Makefile rquota.x
$(RPCGEN) -h rquota.x > rquota.h
rquota_xdr.o: rquota_xdr.c
cc -c rquota_xdr.c
quota-nfs.o: rquota.h
clean:
rm -f *.a *.o rquota.h rquota_clnt.c rquota_svc.c rquota_xdr.c

View File

@@ -0,0 +1,15 @@
//
// No-op implementations.
//
use std::io;
use std::path::Path;
use crate::{FqError, FsQuota, Mtab};
pub(crate) fn get_quota(_device: impl AsRef<Path>, _uid: u32) -> Result<FsQuota, FqError> {
Err(FqError::NoQuota)
}
pub(crate) fn read_mtab() -> io::Result<Vec<Mtab>> {
Ok(Vec::new())
}

282
fs_quota/src/lib.rs Normal file
View File

@@ -0,0 +1,282 @@
#![doc(html_root_url = "https://docs.rs/fs-quota/0.1.0")]
//! Get filesystem disk space used and available for a unix user.
//!
//! This crate has support for:
//!
//! - the Linux quota system
//! - NFS quotas (via SUNRPC).
//! - `libc::vfsstat` lookups (like `df`).
//!
//! Both the `quota` systemcall and `vfsstat` systemcall are different
//! on every system. That functionality is only implemented on Linux
//! right now. NFS quota support _should_ work everywhere.
//!
//! NFS quota support can be left out by disabling the `nfs` feature.
//!
//! Example application:
//! ```no_run
//! use fs_quota::*;
//!
//! fn main() {
//! let args: Vec<String> = std::env::args().collect();
//! if args.len() < 2 {
//! println!("usage: fs_quota <path>");
//! return;
//! }
//! println!("{:#?}", FsQuota::check(&args[1], None));
//! }
//! ```
#[macro_use]
extern crate log;
extern crate libc;
use std::ffi::{CStr, CString, OsStr};
use std::io;
use std::os::raw::c_char;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
#[cfg(feature = "nfs")]
mod quota_nfs;
// Linux specific code lives in linux.rs.
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "linux")]
use linux::{get_quota, read_mtab};
// Unsupported OS.
#[cfg(not(target_os = "linux"))]
mod generic_os;
#[cfg(not(target_os = "linux"))]
use generic_os::{get_quota, read_mtab};
#[derive(Debug, PartialEq)]
pub(crate) enum FsType {
LinuxExt,
LinuxXfs,
Nfs,
Other,
}
// return filesystem major type.
fn fstype(tp: &str) -> FsType {
match tp {
"ext2" | "ext3" | "ext4" => FsType::LinuxExt,
"xfs" => FsType::LinuxXfs,
"nfs" | "nfs4" => FsType::Nfs,
_ => FsType::Other,
}
}
/// quota / vfsstat lookup result.
#[derive(Debug)]
pub struct FsQuota {
/// number of bytes used.
pub bytes_used: u64,
/// maximum number of bytes (available - used).
pub bytes_limit: Option<u64>,
/// number of files (inodes) in use.
pub files_used: u64,
/// maximum number of files (available - used).
pub files_limit: Option<u64>,
}
/// Error result.
#[derive(Debug)]
pub enum FqError {
/// Permission denied.
PermissionDenied,
/// Filesystem does not have quotas enabled.
NoQuota,
/// An I/O error occured.
IoError(io::Error),
/// Some other error.
Other,
}
impl FsQuota {
/// Get the filesystem quota for a `uid` on the filesystem where `path` is on.
///
/// If `uid` is `None`, get it for the current real user-id.
pub fn user(path: impl AsRef<Path>, uid: Option<u32>) -> Result<FsQuota, FqError> {
let id = uid.unwrap_or(unsafe { libc::getuid() as u32 });
let entry = get_mtab_entry(path)?;
#[cfg(feature = "nfs")]
{
let fst = fstype(&entry.fstype);
if fst == FsType::Nfs {
return quota_nfs::get_quota(&entry, id);
}
}
get_quota(&entry.device, id)
}
/// Get used and available disk space of the filesystem indicated by `path`.
///
/// This is not really a quota call; it simply calls `libc::vfsstat` (`df`).
pub fn system(path: impl AsRef<Path>) -> Result<FsQuota, FqError> {
// Call libc::vfsstat(). It's POSIX so should be supported everywhere.
let cpath = CString::new(path.as_ref().as_os_str().as_bytes())?;
let mut vfs = unsafe { std::mem::zeroed::<libc::statvfs>() };
let rc = unsafe { libc::statvfs(cpath.as_ptr(), &mut vfs) };
if rc != 0 {
return Err(FqError::IoError(io::Error::last_os_error()));
}
Ok(FsQuota {
bytes_used: ((vfs.f_blocks - vfs.f_bfree) * vfs.f_frsize) as u64,
bytes_limit: Some(((vfs.f_blocks - (vfs.f_bfree - vfs.f_bavail)) * vfs.f_frsize) as u64),
files_used: (vfs.f_files - vfs.f_ffree) as u64,
files_limit: Some((vfs.f_files - (vfs.f_ffree - vfs.f_favail)) as u64),
})
}
/// Lookup used and available disk space for a `uid`. First check user's quota,
/// if quotas are not enabled check the filesystem disk space usage.
///
/// This is the equivalent of
///
/// ```no_run
/// # let path = "/";
/// # let uid = None;
/// # use fs_quota::*;
/// FsQuota::user(path, uid)
/// .or_else(|e| if e == FqError::NoQuota { FsQuota::system(path) } else { Err(e) })
/// # ;
/// ```
///
pub fn check(path: impl AsRef<Path>, uid: Option<u32>) -> Result<FsQuota, FqError> {
let path = path.as_ref();
FsQuota::user(path, uid).or_else(|e| {
if e == FqError::NoQuota {
FsQuota::system(path)
} else {
Err(e)
}
})
}
}
// The libc realpath() function.
fn realpath<P: AsRef<Path>>(path: P) -> io::Result<PathBuf> {
let cpath = CString::new(path.as_ref().as_os_str().as_bytes())?;
let nullptr: *mut c_char = std::ptr::null_mut();
unsafe {
let r = libc::realpath(cpath.as_ptr(), nullptr);
if r == nullptr {
Err(io::Error::last_os_error())
} else {
let osstr = OsStr::from_bytes(CStr::from_ptr(r).to_bytes());
let p = PathBuf::from(osstr);
libc::free(r as *mut libc::c_void);
Ok(p)
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct Mtab {
host: Option<String>,
device: String,
directory: String,
fstype: String,
}
// find an entry in the mtab.
fn get_mtab_entry(path: impl AsRef<Path>) -> Result<Mtab, FqError> {
let path = path.as_ref();
let meta = std::fs::symlink_metadata(path)?;
// get all eligible entries.
let ents = read_mtab()?
.into_iter()
.filter(|e| fstype(&e.fstype) != FsType::Other)
.filter(|e| {
match std::fs::metadata(&e.directory) {
Ok(ref m) => m.dev() == meta.dev(),
Err(_) => false,
}
})
.collect::<Vec<Mtab>>();
// 0 matches, error. 1 match, fine. >1 match, need to look closer.
let entry = match ents.len() {
0 => return Err(FqError::NoQuota),
1 => ents[0].clone(),
_ => {
// multiple matching entries.. happens on NFS.
// get "realpath" of the path that was passed in.
let rp = match realpath(path) {
Ok(p) => p,
Err(e) => return Err(e.into()),
};
// realpath the remaining entries as well..
let mut v = Vec::new();
for mut e in ents.into_iter() {
match realpath(&e.directory) {
Ok(p) => {
let c = String::from_utf8_lossy(p.as_os_str().as_bytes());
e.directory = c.to_string();
v.push(e);
},
Err(_) => {},
}
}
if v.len() == 0 {
return Err(FqError::NoQuota);
}
// find longest match.
v.sort_by_key(|e| e.directory.clone());
v.reverse();
match v.iter().position(|ref x| rp.starts_with(&x.directory)) {
Some(p) => v[p].clone(),
None => {
return Err(FqError::NoQuota);
},
}
},
};
Ok(entry)
}
impl From<io::Error> for FqError {
fn from(e: io::Error) -> Self {
FqError::IoError(e)
}
}
impl From<std::ffi::NulError> for FqError {
fn from(e: std::ffi::NulError) -> Self {
FqError::IoError(e.into())
}
}
fn to_num(e: &FqError) -> u32 {
match e {
&FqError::PermissionDenied => 1,
&FqError::NoQuota => 2,
&FqError::IoError(_) => 3,
&FqError::Other => 4,
}
}
impl PartialEq for FqError {
fn eq(&self, other: &Self) -> bool {
match self {
&FqError::IoError(ref e) => {
if let &FqError::IoError(ref o) = other {
e.kind() == o.kind()
} else {
false
}
},
e => to_num(e) == to_num(other),
}
}
}

101
fs_quota/src/linux.rs Normal file
View File

@@ -0,0 +1,101 @@
//
// Linux specific systemcalls for quota.
//
use std::ffi::CString;
use std::fs::File;
use std::io;
use std::io::prelude::*;
use std::io::BufReader;
use std::os::raw::{c_char, c_int};
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
use crate::{FqError, FsQuota, Mtab};
// The actual implementation is done in C, and imported here.
extern "C" {
fn fs_quota_linux(
device: *const c_char,
id: c_int,
do_group: c_int,
bytes_used: *mut u64,
bytes_limit: *mut u64,
files_used: *mut u64,
files_limit: *mut u64,
) -> c_int;
}
// wrapper for the C functions.
pub(crate) fn get_quota(device: impl AsRef<Path>, uid: u32) -> Result<FsQuota, FqError> {
let id = uid as c_int;
let device = device.as_ref();
let mut bytes_used = 0u64;
let mut bytes_limit = 0u64;
let mut files_used = 0u64;
let mut files_limit = 0u64;
let path = CString::new(device.as_os_str().as_bytes())?;
let rc = unsafe {
fs_quota_linux(
path.as_ptr(),
id,
0,
&mut bytes_used as *mut u64,
&mut bytes_limit as *mut u64,
&mut files_used as *mut u64,
&mut files_limit as *mut u64,
)
};
// Error mapping.
match rc {
0 => {
let m = |v| if v == 0xffffffffffffffff { None } else { Some(v) };
Ok(FsQuota {
bytes_used: bytes_used,
bytes_limit: m(bytes_limit),
files_used: files_used,
files_limit: m(files_limit),
})
},
1 => Err(FqError::NoQuota),
_ => Err(FqError::IoError(io::Error::last_os_error())),
}
}
// read /etc/mtab.
pub(crate) fn read_mtab() -> io::Result<Vec<Mtab>> {
let f = File::open("/etc/mtab")?;
let reader = BufReader::new(f);
let mut result = Vec::new();
for l in reader.lines() {
let l2 = l?;
let line = l2.trim();
if line.len() == 0 || line.starts_with("#") {
continue;
}
let words = line.split_whitespace().collect::<Vec<_>>();
if words.len() < 3 {
continue;
}
let (host, device) = if words[2].starts_with("nfs") {
if !words[0].contains(":") {
continue;
}
let mut s = words[0].splitn(2, ':');
let host = s.next().unwrap();
let path = s.next().unwrap();
(Some(host.to_string()), path)
} else {
(None, words[2])
};
result.push(Mtab {
host: host,
device: device.to_string(),
directory: words[1].to_string(),
fstype: words[2].to_string(),
});
}
Ok(result)
}

View File

@@ -0,0 +1,44 @@
#include <sys/types.h>
#include <stdint.h>
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/quota.h>
#include <errno.h>
#ifdef HAVE_STRUCT_DQBLK_CURSPACE
# define dqb_curblocks dqb_curspace
#endif
int fs_quota_linux(char *path, int id, int do_group,
uint64_t *bytes_value_r, uint64_t *bytes_limit_r,
uint64_t *count_value_r, uint64_t *count_limit_r)
{
int type = do_group ? GRPQUOTA : USRQUOTA;
struct dqblk dqblk;
if (quotactl(QCMD(Q_GETQUOTA, type), path, id, (caddr_t)&dqblk) < 0) {
if (errno == ESRCH || errno == ENOENT) {
return 1;
}
return -1;
}
#if _LINUX_QUOTA_VERSION == 1
*bytes_value_r = dqblk.dqb_curblocks * 1024;
#else
*bytes_value_r = dqblk.dqb_curspace;
#endif
*bytes_limit_r = dqblk.dqb_bsoftlimit * 1024;
if (*bytes_limit_r == 0) {
*bytes_limit_r = dqblk.dqb_bhardlimit * 1024;
}
*count_value_r = dqblk.dqb_curinodes;
*count_limit_r = dqblk.dqb_isoftlimit;
if (*count_limit_r == 0) {
*count_limit_r = dqblk.dqb_ihardlimit;
}
return 0;
}

185
fs_quota/src/quota-nfs.c Normal file
View File

@@ -0,0 +1,185 @@
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/stat.h>
#include "./rquota.h"
#define RQUOTA_GETQUOTA_TIMEOUT_SECS 2
static const uint32_t unlimited32 = 0xffffffff;
static const uint64_t unlimited64 = 0xffffffffffffffff;
#define E_CLNT_CALL 0x00100000
#define E_CLNT_CREATE 0x00000001
#define E_NOQUOTA 0x00000002
#define E_PERM 0x00000003
#define E_UNKNOWN 0x0000f000
static void
rquota_get_result(const rquota *rq,
uint64_t *bytes_used_r, uint64_t *bytes_limit_r,
uint64_t *files_used_r, uint64_t *files_limit_r)
{
*bytes_used_r = (uint64_t)rq->rq_curblocks *
(uint64_t)rq->rq_bsize;
*bytes_limit_r = unlimited64;
if (rq->rq_bsoftlimit != 0 && rq->rq_bsoftlimit != unlimited32) {
*bytes_limit_r = (uint64_t)rq->rq_bsoftlimit *
(uint64_t)rq->rq_bsize;
} else if (rq->rq_bhardlimit != unlimited32) {
*bytes_limit_r = (uint64_t)rq->rq_bhardlimit *
(uint64_t)rq->rq_bsize;
}
*files_used_r = rq->rq_curfiles;
*files_limit_r = unlimited64;
if (rq->rq_fsoftlimit != 0 && rq->rq_fsoftlimit != unlimited32)
*files_limit_r = rq->rq_fsoftlimit;
else if (rq->rq_fhardlimit != unlimited32)
*files_limit_r = rq->rq_fhardlimit;
}
int fs_quota_nfs_user(char *host, char *path, int uid,
uint64_t *bytes_used_r, uint64_t *bytes_limit_r,
uint64_t *files_used_r, uint64_t *files_limit_r)
{
struct getquota_rslt result;
struct getquota_args args;
struct timeval timeout;
enum clnt_stat call_status;
CLIENT *cl;
/* clnt_create() polls for a while to establish a connection */
cl = clnt_create(host, RQUOTAPROG, RQUOTAVERS, "udp");
if (cl == NULL) {
return E_CLNT_CREATE;
}
/* Establish some RPC credentials */
auth_destroy(cl->cl_auth);
cl->cl_auth = authunix_create_default();
/* make the rquota call on the remote host */
args.gqa_pathp = path;
args.gqa_uid = uid;
timeout.tv_sec = RQUOTA_GETQUOTA_TIMEOUT_SECS;
timeout.tv_usec = 0;
call_status = clnt_call(cl, RQUOTAPROC_GETQUOTA,
(xdrproc_t)xdr_getquota_args, (char *)&args,
(xdrproc_t)xdr_getquota_rslt, (char *)&result,
timeout);
/* the result has been deserialized, let the client go */
auth_destroy(cl->cl_auth);
clnt_destroy(cl);
if (call_status != RPC_SUCCESS) {
return E_CLNT_CALL | call_status;
}
switch (result.status) {
case Q_OK: {
rquota_get_result(&result.getquota_rslt_u.gqr_rquota,
bytes_used_r, bytes_limit_r,
files_used_r, files_limit_r);
return 0;
}
case Q_NOQUOTA:
return E_NOQUOTA;
case Q_EPERM:
return E_PERM;
default:
return E_UNKNOWN;
}
}
int fs_quota_nfs_ext(char *host, char *path, int id, int do_group,
uint64_t *bytes_used_r, uint64_t *bytes_limit_r,
uint64_t *files_used_r, uint64_t *files_limit_r)
{
#if defined(EXT_RQUOTAVERS) && defined(GRPQUOTA)
struct getquota_rslt result;
ext_getquota_args args;
struct timeval timeout;
enum clnt_stat call_status;
CLIENT *cl;
/* clnt_create() polls for a while to establish a connection */
cl = clnt_create(host, RQUOTAPROG, EXT_RQUOTAVERS, "udp");
if (cl == NULL) {
return E_CLNT_CREATE;
}
/* Establish some RPC credentials */
auth_destroy(cl->cl_auth);
cl->cl_auth = authunix_create_default();
/* make the rquota call on the remote host */
args.gqa_pathp = path;
args.gqa_id = id;
args.gqa_type = do_group ? GRPQUOTA : USRQUOTA;
timeout.tv_sec = RQUOTA_GETQUOTA_TIMEOUT_SECS;
timeout.tv_usec = 0;
call_status = clnt_call(cl, RQUOTAPROC_GETQUOTA,
(xdrproc_t)xdr_ext_getquota_args, (char *)&args,
(xdrproc_t)xdr_getquota_rslt, (char *)&result,
timeout);
/* the result has been deserialized, let the client go */
auth_destroy(cl->cl_auth);
clnt_destroy(cl);
if (call_status != RPC_SUCCESS) {
return E_CLNT_CALL | call_status;
}
switch (result.status) {
case Q_OK: {
rquota_get_result(&result.getquota_rslt_u.gqr_rquota,
bytes_used_r, bytes_limit_r,
files_used_r, files_limit_r);
return 0;
}
case Q_NOQUOTA:
return E_NOQUOTA;
case Q_EPERM:
return E_PERM;
default:
return E_UNKNOWN;
}
return 0;
#else
(void)host; (void)path; (void)id; (void)do_group;
(void)bytes_used_r; (void)bytes_limit_r;
(void)files_used_r; (void)files_limit_r;
return E_NOQUOTA;
#endif
}
int fs_quota_nfs(char *host, char *path, char *nfsvers, int id, int do_group,
uint64_t *bytes_used_r, uint64_t *bytes_limit_r,
uint64_t *files_used_r, uint64_t *files_limit_r)
{
/* For NFSv4, we send the filesystem path without initial /. Server
prepends proper NFS pseudoroot automatically and uses this for
detection of NFSv4 mounts. */
if (strcmp(nfsvers, "nfs4") == 0) {
while (*path == '/')
path++;
}
if (do_group)
return fs_quota_nfs_ext(host, path, id, 1,
bytes_used_r, bytes_limit_r,
files_used_r, files_limit_r);
else
return fs_quota_nfs_user(host, path, id,
bytes_used_r, bytes_limit_r,
files_used_r, files_limit_r);
}

92
fs_quota/src/quota_nfs.rs Normal file
View File

@@ -0,0 +1,92 @@
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int};
use crate::{FqError, FsQuota, Mtab};
extern "C" {
fn fs_quota_nfs(
host: *const c_char,
path: *const c_char,
nfsvers: *const c_char,
id: c_int,
do_group: c_int,
bytes_used: *mut u64,
bytes_limit: *mut u64,
files_used: *mut u64,
files_limit: *mut u64,
) -> c_int;
}
mod ffi {
use super::*;
extern "C" {
pub(crate) fn clnt_sperrno(e: c_int) -> *const c_char;
}
}
// The rpcsvc clnt_sperrno function.
fn clnt_sperrno(e: c_int) -> &'static str {
unsafe {
let msg = ffi::clnt_sperrno(e);
std::str::from_utf8(CStr::from_ptr(msg).to_bytes()).unwrap()
}
}
pub(crate) fn get_quota(entry: &Mtab, uid: u32) -> Result<FsQuota, FqError> {
let host = CString::new(entry.host.as_ref().unwrap().as_bytes())?;
let path = CString::new(entry.device.as_bytes())?;
let fstype = CString::new(entry.fstype.as_bytes())?;
let mut bytes_used = 0u64;
let mut bytes_limit = 0u64;
let mut files_used = 0u64;
let mut files_limit = 0u64;
let rc = unsafe {
fs_quota_nfs(
host.as_ptr(),
path.as_ptr(),
fstype.as_ptr(),
uid as c_int,
0,
&mut bytes_used as *mut u64,
&mut bytes_limit as *mut u64,
&mut files_used as *mut u64,
&mut files_limit as *mut u64,
)
};
// Error mapping.
match rc {
0 => {},
0x00000001 => {
debug!("nfs: clnt_create error");
return Err(FqError::Other);
},
0x00000002 => {
return Err(FqError::NoQuota);
},
0x00000003 => {
debug!("nfs: permission denied");
return Err(FqError::PermissionDenied);
},
c @ 0x00100000..=0x001fffff => {
let e = c & 0x000fffff;
debug!("nfs: clnt_call error: {}", clnt_sperrno(e));
return Err(FqError::Other);
},
e => {
debug!("nfs: unknown error {}", e);
return Err(FqError::Other);
},
}
let m = |v| if v == 0xffffffffffffffff { None } else { Some(v) };
let res = FsQuota {
bytes_used: bytes_used,
bytes_limit: m(bytes_limit),
files_used: files_used,
files_limit: m(files_limit),
};
return Ok(res);
}

139
fs_quota/src/rquota.x Normal file
View File

@@ -0,0 +1,139 @@
/* @(#)rquota.x 2.1 88/08/01 4.0 RPCSRC */
/* @(#)rquota.x 1.2 87/09/20 Copyr 1987 Sun Micro */
/*
* Remote quota protocol
* Requires unix authentication
*/
const RQ_PATHLEN = 1024;
struct sq_dqblk {
unsigned int rq_bhardlimit; /* absolute limit on disk blks alloc */
unsigned int rq_bsoftlimit; /* preferred limit on disk blks */
unsigned int rq_curblocks; /* current block count */
unsigned int rq_fhardlimit; /* absolute limit on allocated files */
unsigned int rq_fsoftlimit; /* preferred file limit */
unsigned int rq_curfiles; /* current # allocated files */
unsigned int rq_btimeleft; /* time left for excessive disk use */
unsigned int rq_ftimeleft; /* time left for excessive files */
};
struct getquota_args {
string gqa_pathp<RQ_PATHLEN>; /* path to filesystem of interest */
int gqa_uid; /* Inquire about quota for uid */
};
struct setquota_args {
int sqa_qcmd;
string sqa_pathp<RQ_PATHLEN>; /* path to filesystem of interest */
int sqa_id; /* Set quota for uid */
sq_dqblk sqa_dqblk;
};
struct ext_getquota_args {
string gqa_pathp<RQ_PATHLEN>; /* path to filesystem of interest */
int gqa_type; /* Type of quota info is needed about */
int gqa_id; /* Inquire about quota for id */
};
struct ext_setquota_args {
int sqa_qcmd;
string sqa_pathp<RQ_PATHLEN>; /* path to filesystem of interest */
int sqa_id; /* Set quota for id */
int sqa_type; /* Type of quota to set */
sq_dqblk sqa_dqblk;
};
/*
* remote quota structure
*/
struct rquota {
int rq_bsize; /* block size for block counts */
bool rq_active; /* indicates whether quota is active */
unsigned int rq_bhardlimit; /* absolute limit on disk blks alloc */
unsigned int rq_bsoftlimit; /* preferred limit on disk blks */
unsigned int rq_curblocks; /* current block count */
unsigned int rq_fhardlimit; /* absolute limit on allocated files */
unsigned int rq_fsoftlimit; /* preferred file limit */
unsigned int rq_curfiles; /* current # allocated files */
unsigned int rq_btimeleft; /* time left for excessive disk use */
unsigned int rq_ftimeleft; /* time left for excessive files */
};
enum qr_status {
Q_OK = 1, /* quota returned */
Q_NOQUOTA = 2, /* noquota for uid */
Q_EPERM = 3 /* no permission to access quota */
};
union getquota_rslt switch (qr_status status) {
case Q_OK:
rquota gqr_rquota; /* valid if status == Q_OK */
case Q_NOQUOTA:
void;
case Q_EPERM:
void;
};
union setquota_rslt switch (qr_status status) {
case Q_OK:
rquota sqr_rquota; /* valid if status == Q_OK */
case Q_NOQUOTA:
void;
case Q_EPERM:
void;
};
program RQUOTAPROG {
version RQUOTAVERS {
/*
* Get all quotas
*/
getquota_rslt
RQUOTAPROC_GETQUOTA(getquota_args) = 1;
/*
* Get active quotas only
*/
getquota_rslt
RQUOTAPROC_GETACTIVEQUOTA(getquota_args) = 2;
/*
* Set all quotas
*/
setquota_rslt
RQUOTAPROC_SETQUOTA(setquota_args) = 3;
/*
* Get active quotas only
*/
setquota_rslt
RQUOTAPROC_SETACTIVEQUOTA(setquota_args) = 4;
} = 1;
version EXT_RQUOTAVERS {
/*
* Get all quotas
*/
getquota_rslt
RQUOTAPROC_GETQUOTA(ext_getquota_args) = 1;
/*
* Get active quotas only
*/
getquota_rslt
RQUOTAPROC_GETACTIVEQUOTA(ext_getquota_args) = 2;
/*
* Set all quotas
*/
setquota_rslt
RQUOTAPROC_SETQUOTA(ext_setquota_args) = 3;
/*
* Set active quotas only
*/
setquota_rslt
RQUOTAPROC_SETACTIVEQUOTA(ext_setquota_args) = 4;
} = 2;
} = 100011;

32
pam/Cargo.toml Normal file
View File

@@ -0,0 +1,32 @@
[package]
name = "pam-sandboxed"
# When releasing to crates.io:
# - Update html_root_url in src/lib.rs
# - Update CHANGELOG.md.
# - Run: cargo readme > README.md
# - Create git tag pam-sandboxed-0.x.y
version = "0.2.0"
readme = "README.md"
documentation = "https://docs.rs/pam-sandboxed"
repository = "https://github.com/miquels/webdav-server-rs"
homepage = "https://github.com/miquels/webdav-server-rs/tree/master/pam"
authors = ["Miquel van Smoorenburg <mike@langeraar.net>"]
edition = "2018"
license = "Apache-2.0"
categories = ["authentication"]
[build-dependencies]
cc = "1.0.66"
[dependencies]
bincode = "1.3.1"
env_logger = "0.8.2"
futures = "0.3.12"
libc = "0.2.82"
log = "0.4.13"
serde = "1.0.120"
serde_derive = "1.0.120"
threadpool = "1.8.1"
tokio = { version = "1.0.2", features = ["io-util", "net", "rt"] }

49
pam/README.md Normal file
View File

@@ -0,0 +1,49 @@
# pam-sandboxed
### PAM authentication with the pam library running in a separate process.
The PAM client in this crate creates a future that resolves with the
PAM authentication result.
### HOW.
When initialized, the code fork()s and sets up a pipe-based communications
channel between the parent (pam-client) and the child (pam-server). All
the Pam work is then done on a threadpool in the child process.
### WHY.
Reasons for doing this instead of just calling libpam directly:
- Debian still comes with pam 1.8, which when calling setuid helpers
will first close all filedescriptors up to the rlimit. if
If that limit is high (millions) then it takes a looong while.
`RLIMIT_NOFILE` is reset to a reasonably low number in the child process.
- You might want to run the pam modules as a different user than
the main process
- There is code in libpam that might call setreuid(), and that is an
absolute non-starter in threaded code.
- Also, if you're mucking around with per-thread uid credentials on Linux by
calling the setresuid syscall directly, the pthread library code that
handles setuid() gets confused.
### EXAMPLE.
```rust
// call this once.
let mut pam = PamAuth::new(None).expect("failed to initialized PAM");
// now use `pam` as a handle to authenticate.
let fut = pam.auth("other", "user", "pass", None)
.then(|res| {
println!("pam auth result: {:?}", res);
res
});
tokio::spawn(fut.map_err(|_| ()));
```
### Copyright and License.
* © 2018, 2019 XS4ALL Internet bv
* © 2018, 2019 Miquel van Smoorenburg
* [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0)

11
pam/README.tpl Normal file
View File

@@ -0,0 +1,11 @@
# {{crate}}
{{readme}}
### Copyright and License.
* © 2018, 2019 XS4ALL Internet bv
* © 2018, 2019 Miquel van Smoorenburg
* [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0)

11
pam/TODO.md Normal file
View File

@@ -0,0 +1,11 @@
## PAM crate TODO items
- check all panics. If the server-side panics - that's OK, the client-side
will just return errors.
- client side, when server has gone away:
- panic ?
- start returning errors ?
- try to restart the server ?

6
pam/build.rs Normal file
View File

@@ -0,0 +1,6 @@
extern crate cc;
fn main() {
println!("cargo:rustc-link-lib=pam");
cc::Build::new().file("src/pam.c").compile("rpam"); // outputs `librpam.a`
}

92
pam/src/bin/main.rs Normal file
View File

@@ -0,0 +1,92 @@
use std::io::{self, Write};
use env_logger;
use pam_sandboxed::PamAuth;
fn prompt(s: &str) -> io::Result<String> {
print!("{}", s);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(input.trim().to_string())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::init();
let name = prompt("What's your login? ")?;
let pass = prompt("What's your password? ")?;
let mut pamauth = PamAuth::new(None)?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.build()?;
rt.block_on(async move {
match pamauth.auth("other", &name, &pass, None).await {
Ok(res) => println!("pam.auth returned Ok({:?})", res),
Err(e) => println!("pam.auth returned error: {}", e),
}
Ok(())
})
}
// I've put the tests here in bin/main.rs instead of in lib.rs, because "cargo test"
// for the library links the tests without -lpam, so it fails. The price we pay
// for that is a dynamic test-mode setting in the library, instead of compile-time.
#[cfg(test)]
mod tests {
use pam_sandboxed::{test_mode, PamAuth, PamError};
use tokio;
const TEST_STR: &str = "xyzzy-test-test";
#[test]
fn test_auth() {
test_mode(true);
let mut pam = PamAuth::new(None).unwrap();
let mut rt = tokio::runtime::Runtime::new().unwrap();
let res = rt.block_on(async {
let mut pam2 = pam.clone();
if let Err(e) = pam.auth(TEST_STR, "test", "foo", Some(TEST_STR)).await {
eprintln!("auth(test) failed: {:?}", e);
return Err(e);
}
if let Ok(_) = pam2.auth(TEST_STR, "unknown", "bar", Some(TEST_STR)).await {
eprintln!("auth(unknown) succeeded, should have failed");
return Err(PamError::unknown());
}
Ok(())
});
assert!(res.is_ok());
}
#[test]
fn test_many() {
test_mode(true);
let pam = PamAuth::new(None).unwrap();
let mut rt = tokio::runtime::Runtime::new().unwrap();
let mut handles = Vec::new();
rt.block_on(async move {
for i in 1u32..=1000 {
let mut pam = pam.clone();
let handle = tokio::spawn(async move {
if let Err(e) = pam.auth(TEST_STR, "test", "bar", Some(TEST_STR)).await {
panic!("auth(test) failed at iteration {}: {:?}", i, e);
}
});
handles.push(handle);
}
for handle in handles.drain(..) {
let _ = handle.await;
}
});
}
}

64
pam/src/lib.rs Normal file
View File

@@ -0,0 +1,64 @@
#![doc(html_root_url = "https://docs.rs/pam-sandboxed/0.2.0")]
//! ## PAM authentication with the pam library running in a separate process.
//!
//! The PAM client in this crate creates a future that resolves with the
//! PAM authentication result.
//!
//! ## HOW.
//!
//! When initialized, the code fork()s and sets up a pipe-based communications
//! channel between the parent (pam-client) and the child (pam-server). All
//! the Pam work is then done on a threadpool in the child process.
//!
//! ## WHY.
//!
//! Reasons for doing this instead of just calling libpam directly:
//!
//! - Debian still comes with pam 1.8, which when calling setuid helpers
//! will first close all filedescriptors up to the rlimit. if
//! If that limit is high (millions) then it takes a looong while.
//! `RLIMIT_NOFILE` is reset to a reasonably low number in the child process.
//! - You might want to run the pam modules as a different user than
//! the main process
//! - There is code in libpam that might call setreuid(), and that is an
//! absolute non-starter in threaded code.
//! - Also, if you're mucking around with per-thread uid credentials on Linux by
//! calling the setresuid syscall directly, the pthread library code that
//! handles setuid() gets confused.
//!
//! ## EXAMPLE.
//! ```
//! use pam_sandboxed::PamAuth;
//!
//! fn main() {
//! // call this once, early.
//! let mut pam = PamAuth::new(None).expect("failed to initialized PAM");
//!
//! let mut rt = tokio::runtime::Runtime::new().expect("failed to initialize tokio runtime");
//! rt.block_on(async move {
//! let res = pam.auth("other", "user", "pass", None).await;
//! println!("pam auth result: {:?}", res);
//! });
//! }
//! ```
#[macro_use]
extern crate log;
#[macro_use]
extern crate serde_derive;
mod pam;
mod pamclient;
mod pamserver;
use std::sync::atomic::Ordering;
pub use crate::pam::PamError;
pub use crate::pamclient::PamAuth;
// See bin/main.rs, mod tests.
#[doc(hidden)]
pub fn test_mode(enabled: bool) {
use crate::pam::TEST_MODE;
let getal = if enabled { 1 } else { 0 };
TEST_MODE.store(getal, Ordering::SeqCst);
}

82
pam/src/pam.c Normal file
View File

@@ -0,0 +1,82 @@
#include <security/pam_appl.h>
#include <sys/resource.h>
#include <string.h>
#include <stdlib.h>
struct creds {
char *user;
char *password;
};
static void add_reply(struct pam_response **reply, int count, char *txt)
{
*reply = realloc(*reply, (count + 1) * sizeof(struct pam_response));
(*reply)[count].resp_retcode = 0;
(*reply)[count].resp = strdup(txt ? txt: "");
}
static int c_pam_conv(int num_msg, const struct pam_message **msg,
struct pam_response **resp, void *appdata)
{
struct pam_response *reply = NULL;
struct creds *creds = (struct creds *)appdata;
int replies = 0;
int count;
for (count = 0; count < num_msg; count++) {
switch (msg[count]->msg_style) {
case PAM_PROMPT_ECHO_ON:
add_reply(&reply, replies++, creds->user);
break;
case PAM_PROMPT_ECHO_OFF:
add_reply(&reply, replies++, creds->password);
break;
case PAM_TEXT_INFO:
break;
case PAM_ERROR_MSG:
default:
if (reply != NULL)
free(reply);
return PAM_CONV_ERR;
}
}
*resp = reply;
return PAM_SUCCESS;
}
int c_pam_auth(char *service, char *user, char *pass, char *remip)
{
struct creds creds = {
user,
pass,
};
struct pam_conv conv = {
c_pam_conv,
&creds,
};
pam_handle_t *pamh = NULL;
int ret = pam_start(service, user, &conv, &pamh);
if (ret != PAM_SUCCESS)
return ret;
if (ret == PAM_SUCCESS && remip && remip[0])
ret = pam_set_item(pamh, PAM_RHOST, remip);
if (ret == PAM_SUCCESS)
ret = pam_authenticate(pamh, 0);
pam_end(pamh, 0);
return ret;
}
void c_pam_lower_rlimits()
{
struct rlimit rlim;
if (getrlimit(RLIMIT_NOFILE, &rlim) == 0) {
rlim_t l = rlim.rlim_cur;
if (l > 256)
l = 256;
rlim.rlim_cur = l;
rlim.rlim_max = l;
setrlimit(RLIMIT_NOFILE, &rlim);
}
}

97
pam/src/pam.rs Normal file
View File

@@ -0,0 +1,97 @@
use std::error::Error;
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int, c_void};
use std::sync::atomic::{AtomicUsize, Ordering};
extern "C" {
fn c_pam_auth(
service: *const c_char,
user: *const c_char,
pass: *const c_char,
remip: *const c_char,
) -> c_int;
fn _c_pam_return_value(index: c_int) -> c_int;
fn pam_strerror(pamh: *const c_void, errnum: c_int) -> *const c_char;
fn c_pam_lower_rlimits();
}
pub(crate) const ERR_NUL_BYTE: i32 = 414243;
pub(crate) const ERR_SEND_TO_SERVER: i32 = 414244;
pub(crate) const ERR_RECV_FROM_SERVER: i32 = 414245;
pub(crate) static TEST_MODE: AtomicUsize = AtomicUsize::new(0);
/// Error returned if authentication fails.
///
/// It's best not to try to interpret this, and handle all errors
/// as "authentication failed".
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PamError(pub(crate) i32);
impl PamError {
#[doc(hidden)]
pub fn unknown() -> PamError {
PamError(13)
}
}
impl std::fmt::Display for PamError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self.0 {
ERR_NUL_BYTE => write!(f, "embedded 0 byte in string"),
ERR_SEND_TO_SERVER => write!(f, "error sending request to server"),
ERR_RECV_FROM_SERVER => write!(f, "error receiving response from server"),
_ => {
let errnum = self.0 as c_int;
let nullptr: *const c_void = std::ptr::null();
let errstr = unsafe { CStr::from_ptr(pam_strerror(nullptr, errnum)).to_string_lossy() };
f.write_str(&format!("PAM error: {}", errstr))
},
}
}
}
impl Error for PamError {
fn description(&self) -> &str {
"PAM authentication error"
}
fn source(&self) -> Option<&(dyn Error + 'static)> {
None
}
}
impl From<std::ffi::NulError> for PamError {
fn from(_e: std::ffi::NulError) -> Self {
PamError(ERR_NUL_BYTE)
}
}
pub(crate) fn pam_auth(service: &str, user: &str, pass: &str, remip: &str) -> Result<(), PamError> {
if TEST_MODE.load(Ordering::SeqCst) > 0 {
return if user == "test" { Ok(()) } else { Err(PamError(1)) };
}
let c_service = CString::new(service)?;
let c_user = CString::new(user)?;
let c_pass = CString::new(pass)?;
let c_remip = CString::new(remip)?;
let ret = unsafe {
c_pam_auth(
c_service.as_ptr(),
c_user.as_ptr(),
c_pass.as_ptr(),
c_remip.as_ptr(),
)
};
match ret {
0 => Ok(()),
errnum => Err(PamError(errnum)),
}
}
pub(crate) fn pam_lower_rlimits() {
unsafe {
c_pam_lower_rlimits();
}
}

265
pam/src/pamclient.rs Normal file
View File

@@ -0,0 +1,265 @@
// Client part, that is, the part that runs in the local process.
//
// All the futures based code lives here.
//
use std::cell::RefCell;
use std::collections::HashMap;
use std::io;
use std::os::unix::net::UnixStream as StdUnixStream;
use std::sync::{Arc, Mutex, Once};
use futures::channel::{mpsc, oneshot};
use futures::join;
use futures::{sink::SinkExt, stream::StreamExt};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::unix::ReadHalf as UnixReadHalf;
use tokio::net::unix::WriteHalf as UnixWriteHalf;
use tokio::net::UnixStream;
use crate::pam::{PamError, ERR_RECV_FROM_SERVER, ERR_SEND_TO_SERVER};
use crate::pamserver::{PamResponse, PamServer};
// Request to be sent to the server process.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PamRequest {
pub id: u64,
pub user: String,
pub pass: String,
pub service: String,
pub remip: Option<String>,
}
// sent over request channel to PamAuthTask.
struct PamRequest1 {
req: PamRequest,
resp_chan: oneshot::Sender<Result<(), PamError>>,
}
/// Pam authenticator.
#[derive(Clone)]
pub struct PamAuth {
inner: Arc<PamAuthInner>,
}
struct PamAuthInner {
once: Once,
serversock: RefCell<Option<StdUnixStream>>,
req_chan: RefCell<Option<mpsc::Sender<PamRequest1>>>,
}
// Mutation of PamAuthInner only happens once,
// protected by atomic Once, so this is safe.
unsafe impl Sync for PamAuthInner {}
unsafe impl Send for PamAuthInner {}
impl PamAuth {
/// Create a new PAM authenticator. This will start a new PAM server process
/// in the background, and it will contain a new PAM coordination task that
/// will be lazily spawned the first time auth() is called.
///
/// Note that it is important to call this very early in main(), before any
/// threads or runtimes have started.
///
/// ```no_run
/// use pam_sandboxed::PamAuth;
///
/// fn main() -> Result<(), Box<std::error::Error>> {
/// // get pam authentication handle.
/// let mut pam = PamAuth::new(None)?;
///
/// // now start tokio runtime and use handle.
/// let mut rt = tokio::runtime::Runtime::new()?;
/// rt.block_on(async move {
/// let res = pam.auth("other", "user", "pass", None).await;
/// println!("pam auth result: {:?}", res);
/// });
/// Ok(())
/// }
/// ```
///
pub fn new(num_threads: Option<usize>) -> Result<PamAuth, io::Error> {
// spawn the server process.
let serversock = PamServer::start(num_threads)?;
let inner = PamAuthInner {
once: Once::new(),
req_chan: RefCell::new(None),
serversock: RefCell::new(Some(serversock)),
};
Ok(PamAuth {
inner: Arc::new(inner),
})
}
/// Authenticate via pam and return the result.
///
/// - `service`: PAM service to use - usually "other".
/// - `username`: account username
/// - `password`: account password
/// - `remoteip`: if this is a networking service, the remote IP address of the client.
pub async fn auth(
&mut self,
service: &str,
username: &str,
password: &str,
remoteip: Option<&str>,
) -> Result<(), PamError>
{
// If we haven't started the background task yet, do it now.
// That also initializes req_chan.
let inner = &self.inner;
inner.once.call_once(|| {
// These should not ever panic on unwrap().
let serversock = inner.serversock.borrow_mut().take().unwrap();
inner
.req_chan
.replace(Some(PamAuthTask::start(serversock).unwrap()));
});
// create request to be sent to the server.
let req = PamRequest {
id: 0,
user: username.to_string(),
pass: password.to_string(),
service: service.to_string(),
remip: remoteip.map(|s| s.to_string()),
};
// add a one-shot channel for the response.
let (tx, rx) = oneshot::channel::<Result<(), PamError>>();
// put it all together and send it.
let req1 = PamRequest1 {
req: req,
resp_chan: tx,
};
let mut authtask_chan = inner.req_chan.borrow().as_ref().unwrap().clone();
authtask_chan
.send(req1)
.await
.map_err(|_| PamError(ERR_SEND_TO_SERVER))?;
// wait for the response.
match rx.await {
Ok(res) => res,
Err(_) => Err(PamError(ERR_RECV_FROM_SERVER)),
}
}
}
// Shared data for the PamAuthTask tasks.
struct PamAuthTask {
// clients waiting for a response.
waiters: Mutex<HashMap<u64, oneshot::Sender<Result<(), PamError>>>>,
}
impl PamAuthTask {
// Start the server process. Then return a handle to send requests on.
fn start(serversock: StdUnixStream) -> io::Result<mpsc::Sender<PamRequest1>> {
let mut serversock = UnixStream::from_std(serversock)?;
// create a request channel.
let (req_tx, req_rx) = mpsc::channel::<PamRequest1>(0);
// shared state between request and response task.
let this = PamAuthTask {
waiters: Mutex::new(HashMap::new()),
};
debug!("PamAuthTask: spawning task on runtime");
tokio::spawn(async move {
// split serversock into send/receive halves.
let (srx, stx) = serversock.split();
join!(this.handle_request(req_rx, stx), this.handle_response(srx));
});
Ok(req_tx)
}
async fn handle_request(&self, mut req_rx: mpsc::Receiver<PamRequest1>, mut stx: UnixWriteHalf<'_>) {
let mut id: u64 = 0;
loop {
// receive next request.
let PamRequest1 { mut req, resp_chan } = match req_rx.next().await {
Some(r1) => r1,
None => {
// PamAuth handle was dropped. Ask server to exit.
let data = [0u8; 2];
let _ = stx.write_all(&data).await;
return;
},
};
// store the response channel.
req.id = id;
id += 1;
{
let mut waiters = self.waiters.lock().unwrap();
waiters.insert(req.id, resp_chan);
}
// serialize data and send.
let mut data: Vec<u8> = match bincode::serialize(&req) {
Ok(data) => data,
Err(e) => {
// this panic can never happen at runtime.
panic!("PamClient: serializing data: {:?}", e);
},
};
if data.len() > 65533 {
// this panic can never happen at runtime.
panic!("PamClient: serialized data > 65533 bytes");
}
let l1 = ((data.len() >> 8) & 0xff) as u8;
let l2 = (data.len() & 0xff) as u8;
data.insert(0, l1);
data.insert(1, l2);
if let Err(e) = stx.write_all(&data).await {
// this can happen if the server has gone away.
// in which case, handle_response() will exit as well.
error!("PamClient: FATAL: writing data to server: {:?}", e);
return;
}
}
}
async fn handle_response(&self, mut srx: UnixReadHalf<'_>) {
loop {
// read size header.
let mut buf = [0u8; 2];
if let Err(_) = srx.read_exact(&mut buf).await {
error!("PamClient: FATAL: short read, server gone away?!");
return;
}
let sz = ((buf[0] as usize) << 8) + (buf[1] as usize);
// read response data.
let mut data = Vec::with_capacity(sz);
data.resize(sz, 0u8);
if let Err(_) = srx.read_exact(&mut data[..]).await {
error!("PamClient: FATAL: short read, server gone away?!");
return;
}
// deserialize.
let resp: PamResponse = match bincode::deserialize(&data[..]) {
Ok(req) => req,
Err(_) => {
// this panic can never happen at runtime.
panic!("PamCLient: error deserializing response");
},
};
// and send response to waiting requester.
let resp_chan = {
let mut waiters = self.waiters.lock().unwrap();
waiters.remove(&resp.id)
};
if let Some(resp_chan) = resp_chan {
let _ = resp_chan.send(resp.result);
}
}
}
}

169
pam/src/pamserver.rs Normal file
View File

@@ -0,0 +1,169 @@
// Server part - the code here is fork()ed off and lives in its own
// process. We communicate with it through a unix stream socket.
//
// This is all old-fashioned blocking and thread-based code.
//
use std::io::{self, Read, Write};
use std::os::unix::io::AsRawFd;
use std::os::unix::net::UnixStream as StdUnixStream;
use std::sync::{Arc, Mutex};
use bincode::{deserialize, serialize};
use libc;
use crate::pam::{pam_auth, pam_lower_rlimits, PamError};
use crate::pamclient::PamRequest;
// Response back from the server process.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PamResponse {
pub id: u64,
pub result: Result<(), PamError>,
}
// server side.
pub(crate) struct PamServer {
rx_socket: StdUnixStream,
tx_socket: Arc<Mutex<StdUnixStream>>,
}
impl PamServer {
// fork and start the server, return the stream socket for communication.
pub(crate) fn start(num_threads: Option<usize>) -> Result<StdUnixStream, io::Error> {
// Create a unix socketpair for communication.
let (sock1, sock2) = StdUnixStream::pair()?;
let sock3 = sock2.try_clone()?;
let handle = std::thread::spawn(move || {
// fork server.
let pid = unsafe { libc::fork() };
if pid < 0 {
return Err(io::Error::last_os_error());
}
if pid == 0 {
// first, close all filedescriptors (well, all..)
for fdno in 3..8192 {
if fdno != sock2.as_raw_fd() && fdno != sock3.as_raw_fd() {
unsafe {
libc::close(fdno);
}
}
}
let mut server = PamServer {
rx_socket: sock2,
tx_socket: Arc::new(Mutex::new(sock3)),
};
pam_lower_rlimits();
trace!("PamServer: child: starting server");
server.serve(num_threads.unwrap_or(8));
drop(server);
std::process::exit(0);
}
Ok(())
});
handle.join().unwrap()?;
trace!("PamServer: parent: started server");
Ok(sock1)
}
// serve requests.
fn serve(&mut self, num_threads: usize) {
// create a threadpool, then serve connections via the threadpool.
let pool = threadpool::ThreadPool::new(num_threads);
// process incoming connections.
loop {
// read length.
let mut buf = [0u8; 2];
let res = self.rx_socket.read_exact(&mut buf);
if let Err(e) = res {
if e.kind() == std::io::ErrorKind::UnexpectedEof {
// parent probably exited - not an error.
trace!("PamServer::serve: EOF reached on input");
break;
}
panic!("PamServer::serve: read socket: {}", e);
}
let sz = ((buf[0] as usize) << 8) + (buf[1] as usize);
if sz == 0 {
// size 0 packet indicates client wants to shut us down.
trace!("PamServer::serve: EOF packet on input");
break;
}
// read request data.
let mut data = Vec::with_capacity(sz);
data.resize(sz, 0u8);
let res = self.rx_socket.read_exact(&mut data);
if let Err(e) = res {
panic!("PamServer::serve: read socket: {}", e);
}
let req: PamRequest = match deserialize(&data[..]) {
Ok(req) => req,
Err(_) => panic!("PamServer::serve: error deserializing request"),
};
trace!(
"PamServer::serve: read request {:?} active threads: {} queued {}",
req,
pool.active_count(),
pool.queued_count()
);
// run request on pool.
let sock = self.tx_socket.clone();
pool.execute(move || {
if let Err(e) = pam_process(req, sock) {
panic!("PamServer::pam_process: error: {}", e);
}
});
let mut i = 0;
while pool.queued_count() > 2 * pool.max_count() {
if i == 399 {
debug!(
"PamServer::serve: pool busy! active {}, max {}, queued: {}",
pool.active_count(),
pool.max_count(),
pool.queued_count()
);
}
i += 1;
i = i % 400;
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
pool.join();
trace!("PamServer::serve: exit.");
std::process::exit(0);
}
}
// Process one request. This is run on the threadpool.
fn pam_process(req: PamRequest, sock: Arc<Mutex<StdUnixStream>>) -> Result<(), io::Error> {
trace!("PamServer::pam_process: starting with request {:?}", req);
// authenticate.
let remip = req.remip.as_ref().map(|s| s.as_str()).unwrap_or("");
let res = PamResponse {
id: req.id,
result: pam_auth(&req.service, &req.user, &req.pass, remip),
};
// and send back result.
trace!("PamServer::pam_process: returning response {:?}", res);
let mut response: Vec<u8> = serialize(&res)
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("error serializing response: {}", e)))?;
let l1 = ((response.len() >> 8) & 0xff) as u8;
let l2 = (response.len() & 0xff) as u8;
response.insert(0, l1);
response.insert(1, l2);
match sock.lock().unwrap().write_all(&response) {
Err(e) => {
debug!("PamServer::pam_process: writing to response socket: {}", e);
Err(e)
},
Ok(..) => Ok(()),
}
}

12
rustfmt.toml Normal file
View File

@@ -0,0 +1,12 @@
unstable_features = true
edition = "2018"
binop_separator = "Back"
blank_lines_upper_bound = 3
enum_discrim_align_threshold = 20
force_multiline_blocks = true
match_block_trailing_comma = true
max_width = 110
struct_field_align_threshold = 20
where_single_line = true
wrap_comments = false

156
src/auth.rs Normal file
View File

@@ -0,0 +1,156 @@
use std::io;
use std::net::SocketAddr;
use std::sync::Arc;
use crate::config::{AuthType, Config, Location};
use headers::{authorization::Basic, Authorization, HeaderMapExt};
use http::status::StatusCode;
type HttpRequest = http::Request<hyper::Body>;
#[derive(Clone)]
pub struct Auth {
config: Arc<Config>,
#[cfg(feature = "pam")]
pam_auth: pam_sandboxed::PamAuth,
}
impl Auth {
pub fn new(config: Arc<Config>) -> io::Result<Auth> {
// initialize pam.
#[cfg(feature = "pam")]
let pam_auth = {
// set cache timeouts.
if let Some(timeout) = config.pam.cache_timeout {
crate::cache::cached::set_pamcache_timeout(timeout);
}
pam_sandboxed::PamAuth::new(config.pam.threads.clone())?
};
Ok(Auth {
#[cfg(feature = "pam")]
pam_auth,
config,
})
}
// authenticate user.
pub async fn auth<'a>(
&'a self,
req: &'a HttpRequest,
location: &Location,
_remote_ip: SocketAddr,
) -> Result<String, StatusCode>
{
// we must have a login/pass
let basic = match req.headers().typed_get::<Authorization<Basic>>() {
Some(Authorization(basic)) => basic,
_ => return Err(StatusCode::UNAUTHORIZED),
};
let user = basic.username();
let pass = basic.password();
// match the auth type.
let auth_type = location
.accounts
.auth_type
.as_ref()
.or(self.config.accounts.auth_type.as_ref());
match auth_type {
#[cfg(feature = "pam")]
Some(&AuthType::Pam) => self.auth_pam(req, user, pass, _remote_ip).await,
Some(&AuthType::HtPasswd(ref ht)) => self.auth_htpasswd(user, pass, ht.as_str()).await,
None => {
debug!("need authentication, but auth-type is not set");
Err(StatusCode::UNAUTHORIZED)
},
}
}
// authenticate user using PAM.
#[cfg(feature = "pam")]
async fn auth_pam<'a>(
&'a self,
req: &'a HttpRequest,
user: &'a str,
pass: &'a str,
remote_ip: SocketAddr,
) -> Result<String, StatusCode>
{
// stringify the remote IP address.
let ip = remote_ip.ip();
let ip_string = if ip.is_loopback() {
// if it's loopback, take the value from the x-forwarded-for
// header, if present.
req.headers()
.get("x-forwarded-for")
.and_then(|s| s.to_str().ok())
.and_then(|s| s.split(',').next())
.map(|s| s.trim().to_owned())
} else {
Some(match ip {
std::net::IpAddr::V4(ip) => ip.to_string(),
std::net::IpAddr::V6(ip) => ip.to_string(),
})
};
let ip_ref = ip_string.as_ref().map(|s| s.as_str());
// authenticate.
let service = self.config.pam.service.as_str();
let pam_auth = self.pam_auth.clone();
match crate::cache::cached::pam_auth(pam_auth, service, user, pass, ip_ref).await {
Ok(_) => Ok(user.to_string()),
Err(_) => {
debug!(
"auth_pam({}): authentication for {} ({:?}) failed",
service, user, ip_ref
);
Err(StatusCode::UNAUTHORIZED)
},
}
}
// authenticate user using htpasswd.
async fn auth_htpasswd<'a>(
&'a self,
user: &'a str,
pass: &'a str,
section: &'a str,
) -> Result<String, StatusCode>
{
// Get the htpasswd.WHATEVER section from the config file.
let file = match self.config.htpasswd.get(section) {
Some(section) => section.htpasswd.as_str(),
None => return Err(StatusCode::UNAUTHORIZED),
};
// Read the file and split it into a bunch of lines.
tokio::task::block_in_place(move || {
let data = match std::fs::read_to_string(file) {
Ok(data) => data,
Err(e) => {
debug!("{}: {}", file, e);
return Err(StatusCode::UNAUTHORIZED);
},
};
let lines = data
.split('\n')
.map(|s| s.trim())
.filter(|s| !s.starts_with("#") && !s.is_empty());
// Check each line for a match.
for line in lines {
let mut fields = line.split(':');
if let (Some(htuser), Some(htpass)) = (fields.next(), fields.next()) {
if htuser == user && pwhash::unix::verify(pass, htpass) {
return Ok(user.to_string());
}
}
}
debug!("auth_htpasswd: authentication for {} failed", user);
Err(StatusCode::UNAUTHORIZED)
})
}
}

185
src/cache.rs Normal file
View File

@@ -0,0 +1,185 @@
use std::borrow::Borrow;
use std::cmp::Eq;
use std::collections::vec_deque::VecDeque;
use std::collections::HashMap;
use std::hash::Hash;
use std::option::Option;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[allow(dead_code)]
pub struct Cache<K, V> {
intern: Mutex<Intern<K, V>>,
}
struct Intern<K, V> {
maxsize: usize,
maxage: Duration,
map: HashMap<K, Arc<V>>,
fifo: VecDeque<(Instant, K)>,
}
impl<K: Hash + Eq + Clone, V> Cache<K, V> {
pub fn new() -> Cache<K, V> {
let i = Intern {
maxsize: 0,
maxage: Duration::new(0, 0),
map: HashMap::new(),
fifo: VecDeque::new(),
};
Cache {
intern: Mutex::new(i),
}
}
#[allow(dead_code)]
pub fn maxsize(self, maxsize: usize) -> Self {
self.intern.lock().unwrap().maxsize = maxsize;
self
}
#[allow(dead_code)]
pub fn maxage(self, maxage: Duration) -> Self {
self.intern.lock().unwrap().maxage = maxage;
self
}
fn expire(&self, m: &mut Intern<K, V>) {
let mut n = m.fifo.len();
if m.maxsize > 0 && n >= m.maxsize {
n = m.maxsize;
}
if m.maxage.as_secs() > 0 || m.maxage.subsec_nanos() > 0 {
let now = Instant::now();
while n > 0 {
let &(t, _) = m.fifo.get(n - 1).unwrap();
if now.duration_since(t) <= m.maxage {
break;
}
n -= 1;
}
}
for x in n..m.fifo.len() {
let &(_, ref key) = m.fifo.get(x).unwrap();
m.map.remove(&key);
}
m.fifo.truncate(n);
}
pub fn insert(&self, key: K, val: V) -> Arc<V> {
let mut m = self.intern.lock().unwrap();
self.expire(&mut *m);
let av = Arc::new(val);
let ac = av.clone();
m.map.insert(key.clone(), av);
m.fifo.push_front((Instant::now(), key));
ac
}
// see https://doc.rust-lang.org/book/first-edition/borrow-and-asref.html
pub fn get<Q: ?Sized>(&self, key: &Q) -> Option<Arc<V>>
where
K: Borrow<Q>,
Q: Hash + Eq,
{
let mut m = self.intern.lock().unwrap();
self.expire(&mut *m);
if let Some(v) = m.map.get(key) {
return Some(v.clone());
}
None
}
}
pub(crate) mod cached {
//
// Cached versions of Unix account lookup and Pam auth.
//
use std::io;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::cache;
use crate::unixuser::{self, User};
use lazy_static::lazy_static;
struct Timeouts {
pwcache: Duration,
pamcache: Duration,
}
lazy_static! {
static ref TIMEOUTS: Mutex<Timeouts> = Mutex::new(Timeouts {
pwcache: Duration::new(120, 0),
pamcache: Duration::new(120, 0),
});
static ref PWCACHE: cache::Cache<String, unixuser::User> = new_pwcache();
static ref PAMCACHE: cache::Cache<u64, String> = new_pamcache();
}
fn new_pwcache() -> cache::Cache<String, unixuser::User> {
let timeouts = TIMEOUTS.lock().unwrap();
cache::Cache::new().maxage(timeouts.pwcache)
}
fn new_pamcache() -> cache::Cache<u64, String> {
let timeouts = TIMEOUTS.lock().unwrap();
cache::Cache::new().maxage(timeouts.pamcache)
}
pub(crate) fn set_pwcache_timeout(secs: usize) {
let mut timeouts = TIMEOUTS.lock().unwrap();
timeouts.pwcache = Duration::new(secs as u64, 0);
}
#[cfg(feature = "pam")]
pub(crate) fn set_pamcache_timeout(secs: usize) {
let mut timeouts = TIMEOUTS.lock().unwrap();
timeouts.pamcache = Duration::new(secs as u64, 0);
}
#[cfg(feature = "pam")]
pub async fn pam_auth<'a>(
pam_auth: pam_sandboxed::PamAuth,
service: &'a str,
user: &'a str,
pass: &'a str,
remip: Option<&'a str>,
) -> Result<(), pam_sandboxed::PamError>
{
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut s = DefaultHasher::new();
service.hash(&mut s);
user.hash(&mut s);
pass.hash(&mut s);
remip.as_ref().hash(&mut s);
let key = s.finish();
if let Some(cache_user) = PAMCACHE.get(&key) {
if user == cache_user.as_str() {
return Ok(());
}
}
let mut pam_auth = pam_auth;
match pam_auth.auth(&service, &user, &pass, remip).await {
Err(e) => Err(e),
Ok(()) => {
PAMCACHE.insert(key, user.to_owned());
Ok(())
},
}
}
pub async fn unixuser(username: &str, with_groups: bool) -> Result<Arc<User>, io::Error> {
if let Some(pwd) = PWCACHE.get(username) {
return Ok(pwd);
}
match User::by_name_async(username, with_groups).await {
Err(e) => Err(e),
Ok(pwd) => Ok(PWCACHE.insert(username.to_owned(), pwd)),
}
}
}

339
src/config.rs Normal file
View File

@@ -0,0 +1,339 @@
use std::collections::HashMap;
use std::net::{SocketAddr, ToSocketAddrs};
use std::path::Path;
use std::process::exit;
use std::{fs, io};
use enum_from_str::ParseEnumVariantError;
use enum_from_str_derive::FromStr;
use serde::{Deserialize, Deserializer};
use toml;
use webdav_handler::DavMethodSet;
use crate::router::Router;
#[derive(Deserialize, Debug)]
pub struct Config {
pub server: Server,
#[serde(default)]
pub accounts: Accounts,
#[serde(default)]
pub pam: Pam,
#[serde(default)]
pub htpasswd: HashMap<String, HtPasswd>,
#[serde(default)]
pub unix: Unix,
#[serde(default)]
pub location: Vec<Location>,
#[serde(skip)]
pub router: Router<usize>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Server {
#[serde(default)]
pub listen: OneOrManyAddr,
#[serde(default)]
pub tls_listen: OneOrManyAddr,
#[serde(default)]
pub tls_key: Option<String>,
#[serde(default)]
pub tls_cert: Option<String>,
//#[serde(deserialize_with = "deserialize_user", default)]
pub uid: Option<u32>,
//#[serde(deserialize_with = "deserialize_group", default)]
pub gid: Option<u32>,
#[serde(default)]
pub identification: Option<String>,
#[serde(default)]
pub cors: bool,
}
#[derive(Deserialize, Debug, Clone, Default)]
pub struct Accounts {
#[serde(rename = "auth-type", deserialize_with = "deserialize_authtype", default)]
pub auth_type: Option<AuthType>,
#[serde(rename = "acct-type", deserialize_with = "deserialize_opt_enum", default)]
pub acct_type: Option<AcctType>,
#[serde(default)]
pub realm: Option<String>,
}
#[derive(Deserialize, Debug, Clone, Default)]
pub struct Pam {
pub service: String,
#[serde(rename = "cache-timeout")]
pub cache_timeout: Option<usize>,
pub threads: Option<usize>,
}
#[derive(Deserialize, Debug, Clone, Default)]
pub struct HtPasswd {
pub htpasswd: String,
}
#[derive(Deserialize, Debug, Clone, Default)]
pub struct Unix {
#[serde(rename = "cache-timeout")]
pub cache_timeout: Option<usize>,
#[serde(rename = "min-uid", default)]
pub min_uid: Option<u32>,
#[serde(rename = "supplementary-groups", default)]
pub aux_groups: bool,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Location {
#[serde(default)]
pub route: Vec<String>,
#[serde(deserialize_with = "deserialize_methodset", default)]
pub methods: Option<DavMethodSet>,
#[serde(deserialize_with = "deserialize_opt_enum", default)]
pub auth: Option<Auth>,
#[serde(default, flatten)]
pub accounts: Accounts,
#[serde(deserialize_with = "deserialize_enum")]
pub handler: Handler,
#[serde(default)]
pub setuid: bool,
pub directory: String,
#[serde(default, alias = "hide-symlinks")]
pub hide_symlinks: Option<bool>,
#[serde(default)]
pub indexfile: Option<String>,
#[serde(default)]
pub autoindex: bool,
#[serde(
rename = "case-insensitive",
deserialize_with = "deserialize_opt_enum",
default
)]
pub case_insensitive: Option<CaseInsensitive>,
#[serde(deserialize_with = "deserialize_opt_enum", default)]
pub on_notfound: Option<OnNotfound>,
}
#[derive(FromStr, Debug, Clone, Copy)]
pub enum Handler {
#[from_str = "virtroot"]
Virtroot,
#[from_str = "filesystem"]
Filesystem,
}
#[derive(FromStr, Debug, Clone, Copy)]
pub enum Auth {
#[from_str = "false"]
False,
#[from_str = "true"]
True,
#[from_str = "opportunistic"]
Opportunistic,
#[from_str = "write"]
Write,
}
#[derive(Debug, Clone)]
pub enum AuthType {
#[cfg(feature = "pam")]
Pam,
HtPasswd(String),
}
#[derive(FromStr, Debug, Clone, Copy)]
pub enum AcctType {
#[from_str = "unix"]
Unix,
}
#[derive(FromStr, Debug, Clone, Copy)]
pub enum CaseInsensitive {
#[from_str = "true"]
True,
#[from_str = "ms"]
Ms,
#[from_str = "false"]
False,
}
#[derive(FromStr, Debug, Clone, Copy)]
pub enum OnNotfound {
#[from_str = "continue"]
Continue,
#[from_str = "return"]
Return,
}
#[derive(Deserialize, Debug, Clone)]
#[serde(untagged)]
pub enum OneOrManyAddr {
One(SocketAddr),
Many(Vec<SocketAddr>),
}
impl OneOrManyAddr {
pub fn is_empty(&self) -> bool {
match self {
OneOrManyAddr::One(_) => false,
OneOrManyAddr::Many(v) => v.is_empty(),
}
}
}
impl Default for OneOrManyAddr {
fn default() -> Self {
OneOrManyAddr::Many(Vec::new())
}
}
impl ToSocketAddrs for OneOrManyAddr {
type Iter = std::vec::IntoIter<SocketAddr>;
fn to_socket_addrs(&self) -> io::Result<std::vec::IntoIter<SocketAddr>> {
let i = match self {
OneOrManyAddr::Many(ref v) => v.to_owned(),
OneOrManyAddr::One(ref s) => vec![*s],
};
Ok(i.into_iter())
}
}
// keep this here for now, we might implement a enum{(u32, String} later for
// usernames and groupnames.
#[allow(unused)]
pub fn deserialize_user<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
where D: Deserializer<'de> {
let s = String::deserialize(deserializer)?;
s.parse::<u32>()
.map(|v| Some(v))
.map_err(serde::de::Error::custom)
}
#[allow(unused)]
pub fn deserialize_group<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
where D: Deserializer<'de> {
let s = String::deserialize(deserializer)?;
s.parse::<u32>()
.map(|v| Some(v))
.map_err(serde::de::Error::custom)
}
pub fn deserialize_methodset<'de, D>(deserializer: D) -> Result<Option<DavMethodSet>, D::Error>
where D: Deserializer<'de> {
let m = Vec::<String>::deserialize(deserializer)?;
DavMethodSet::from_vec(m)
.map(|v| Some(v))
.map_err(serde::de::Error::custom)
}
pub fn deserialize_authtype<'de, D>(deserializer: D) -> Result<Option<AuthType>, D::Error>
where D: Deserializer<'de> {
let s = String::deserialize(deserializer)?;
if s.starts_with("htpasswd.") {
return Ok(Some(AuthType::HtPasswd(s[9..].to_string())));
}
#[cfg(feature = "pam")]
if &s == "pam" {
return Ok(Some(AuthType::Pam));
}
if s == "" {
return Ok(None);
}
Err(serde::de::Error::custom("unknown auth-type"))
}
pub fn deserialize_opt_enum<'de, D, E>(deserializer: D) -> Result<Option<E>, D::Error>
where
D: Deserializer<'de>,
E: std::str::FromStr,
E::Err: std::fmt::Display,
{
String::deserialize(deserializer)?
.as_str()
.parse::<E>()
.map(|e| Some(e))
.map_err(serde::de::Error::custom)
}
pub fn deserialize_enum<'de, D, E>(deserializer: D) -> Result<E, D::Error>
where
D: Deserializer<'de>,
E: std::str::FromStr,
E::Err: std::fmt::Display,
{
String::deserialize(deserializer)?
.as_str()
.parse::<E>()
.map_err(serde::de::Error::custom)
}
// Read the TOML config into a config::Config struct.
pub fn read(toml_file: impl AsRef<Path>) -> io::Result<Config> {
let buffer = fs::read_to_string(&toml_file)?;
// initial parse.
let config: Config = match toml::from_str(&buffer) {
Ok(v) => Ok(v),
Err(e) => Err(io::Error::new(io::ErrorKind::InvalidData, e.to_string())),
}?;
Ok(config)
}
pub fn build_routes(cfg: &str, config: &mut Config) -> io::Result<()> {
let mut builder = Router::builder();
for (idx, location) in config.location.iter().enumerate() {
for r in &location.route {
if let Err(e) = builder.add(r, location.methods.clone(), idx) {
let msg = format!("{}: [[location]][{}]: route {}: {}", cfg, idx, r, e);
return Err(io::Error::new(io::ErrorKind::InvalidData, msg));
}
}
}
config.router = builder.build();
Ok(())
}
pub fn check(cfg: &str, config: &Config) {
#[cfg(feature = "pam")]
if let Some(AuthType::Pam) = config.accounts.auth_type {
if config.pam.service == "" {
eprintln!("{}: missing section [pam]", cfg);
exit(1);
}
}
if config.server.listen.is_empty() && config.server.tls_listen.is_empty() {
eprintln!("{}: [server]: at least one of listen or tls_listen must be set", cfg);
exit(1);
}
if !config.server.tls_listen.is_empty() {
if config.server.tls_cert.is_none() {
eprintln!("{}: [server]: tls_cert not set", cfg);
exit(1);
}
if config.server.tls_key.is_none() {
eprintln!("{}: [server]: tls_key not set", cfg);
exit(1);
}
}
for (idx, location) in config.location.iter().enumerate() {
if location.setuid {
if !crate::suid::has_thread_switch_ugid() {
eprintln!(
"{}: [[location]][{}]: setuid: uid switching not supported on this OS",
cfg, idx
);
exit(1);
}
if config.server.uid.is_none() || config.server.gid.is_none() {
eprintln!("{}: [server]: missing uid and/or gid", cfg);
exit(1);
}
if config.accounts.acct_type.is_none() && location.accounts.acct_type.is_none() {
eprintln!("{}: [[location]][{}]: setuid: no acct-type set", cfg, idx);
exit(1);
}
}
}
}

635
src/main.rs Normal file
View File

@@ -0,0 +1,635 @@
#![doc(html_root_url = "https://docs.rs/webdav-server/0.4.0")]
//! # `webdav-server` is a webdav server that handles user-accounts.
//!
//! This is a webdav server that allows access to a users home directory,
//! just like an ancient FTP server would (remember those?).
//!
//! This is an application. There is no API documentation here.
//! If you want to build your _own_ webdav server, use the `webdav-handler` crate.
//!
//! See the [GitHub repository](https://github.com/miquels/webdav-server-rs/)
//! for documentation on how to run the server.
//!
#[macro_use]
extern crate log;
mod auth;
mod cache;
mod config;
mod rootfs;
#[doc(hidden)]
pub mod router;
mod suid;
mod tls;
mod unixuser;
mod userfs;
use std::convert::TryFrom;
use std::io;
use std::net::{SocketAddr, ToSocketAddrs};
use std::os::unix::io::{FromRawFd, AsRawFd};
use std::process::exit;
use std::sync::Arc;
use clap::clap_app;
use headers::{authorization::Basic, Authorization, HeaderMapExt};
use http::status::StatusCode;
use hyper::{
self,
server::conn::{AddrIncoming, AddrStream},
service::{make_service_fn, service_fn},
};
use tls_listener::TlsListener;
use tokio_rustls::server::TlsStream;
use webdav_handler::{davpath::DavPath, DavConfig, DavHandler, DavMethod, DavMethodSet};
use webdav_handler::{fakels::FakeLs, fs::DavFileSystem, ls::DavLockSystem};
use crate::config::{AcctType, Auth, CaseInsensitive, Handler, Location, OnNotfound};
use crate::rootfs::RootFs;
use crate::router::MatchedRoute;
use crate::suid::proc_switch_ugid;
use crate::tls::tls_acceptor;
use crate::userfs::UserFs;
static PROGNAME: &'static str = "webdav-server";
// Contains "state" and a handle to the config.
#[derive(Clone)]
struct Server {
dh: DavHandler,
auth: auth::Auth,
config: Arc<config::Config>,
}
type HttpResult = Result<hyper::Response<webdav_handler::body::Body>, io::Error>;
type HttpRequest = http::Request<hyper::Body>;
// Server implementation.
impl Server {
// Constructor.
pub fn new(config: Arc<config::Config>, auth: auth::Auth) -> Self {
// mostly empty handler.
let ls = FakeLs::new() as Box<dyn DavLockSystem>;
let dh = DavHandler::builder().locksystem(ls).build_handler();
Server { dh, auth, config }
}
// check user account.
async fn acct<'a>(
&'a self,
location: &Location,
auth_user: Option<&'a String>,
user_param: Option<&'a str>,
) -> Result<Option<Arc<unixuser::User>>, StatusCode>
{
// Get username - if any.
let user = match auth_user.map(|u| u.as_str()).or(user_param) {
Some(u) => u,
None => return Ok(None),
};
// If account is not set, fine.
let acct_type = location
.accounts
.acct_type
.as_ref()
.or(self.config.accounts.acct_type.as_ref());
match acct_type {
Some(&AcctType::Unix) => {},
None => return Ok(None),
};
// check if user exists.
let pwd = match cache::cached::unixuser(user, self.config.unix.aux_groups).await {
Ok(pwd) => pwd,
Err(_) => {
debug!("acct: unix: user {} not found", user);
return Err(StatusCode::UNAUTHORIZED);
},
};
// check minimum uid
if let Some(min_uid) = self.config.unix.min_uid {
if pwd.uid < min_uid {
debug!("acct: {}: uid {} too low (<{})", pwd.name, pwd.uid, min_uid);
return Err(StatusCode::FORBIDDEN);
}
}
Ok(Some(pwd))
}
// return a new response::Builder with the Server and CORS header set.
fn response_builder(&self) -> http::response::Builder {
let mut builder = hyper::Response::builder();
self.set_headers(builder.headers_mut().unwrap());
builder
}
// Set Server: webdav-server-rs header, and CORS.
fn set_headers(&self, headers: &mut http::HeaderMap<http::header::HeaderValue>) {
let id = self
.config
.server
.identification
.as_ref()
.map(|s| s.as_str())
.unwrap_or("webdav-server-rs");
if id != "" {
headers.insert("server", id.parse().unwrap());
}
if self.config.server.cors {
headers.insert("Access-Control-Allow-Origin", "*".parse().unwrap());
headers.insert("Access-Control-Allow-Methods", "GET,HEAD,OPTIONS,PROPFIND".parse().unwrap());
headers.insert("Access-Control-Allow-Headers", "DNT,Depth,Range".parse().unwrap());
}
}
// handle a request.
async fn route(&self, req: HttpRequest, remote_ip: SocketAddr) -> HttpResult {
// Get the URI path.
let davpath = match DavPath::from_uri(req.uri()) {
Ok(p) => p,
Err(_) => return self.error(StatusCode::BAD_REQUEST).await,
};
let path = davpath.as_bytes();
// Get the method.
let method = match DavMethod::try_from(req.method()) {
Ok(m) => m,
Err(_) => return self.error(http::StatusCode::METHOD_NOT_ALLOWED).await,
};
// Request is stored here.
let mut reqdata = Some(req);
let mut got_match = false;
// Match routes to one or more locations.
for route in self
.config
.router
.matches(path, method, &["user", "path"])
.drain(..)
{
got_match = true;
// Take the request from the option.
let req = reqdata.take().unwrap();
// if we might continue, store a clone of the request for the next round.
let location = &self.config.location[*route.data];
if let Some(OnNotfound::Continue) = location.on_notfound {
reqdata.get_or_insert(clone_httpreq(&req));
}
// handle request.
let res = self
.handle(req, method, path, route, location, remote_ip.clone())
.await?;
// no on_notfound? then this is final.
if reqdata.is_none() || res.status() != StatusCode::NOT_FOUND {
return Ok(res);
}
}
if !got_match {
debug!("route: no matching route for {:?}", davpath);
}
self.error(StatusCode::NOT_FOUND).await
}
// handle a request.
async fn handle<'a, 't: 'a, 'p: 'a>(
&'a self,
req: HttpRequest,
method: DavMethod,
path: &'a [u8],
route: MatchedRoute<'t, 'p, usize>,
location: &'a Location,
remote_ip: SocketAddr,
) -> HttpResult
{
// See if we matched a :user parameter
// If so, it must be valid UTF-8, or we return NOT_FOUND.
let user_param = match route.params[0].as_ref() {
Some(p) => {
match p.as_str() {
Some(p) => Some(p),
None => {
debug!("handle: invalid utf-8 in :user part of path");
return self.error(StatusCode::NOT_FOUND).await;
},
}
},
None => None,
};
// Do authentication if needed.
let auth_hdr = req.headers().typed_get::<Authorization<Basic>>();
let do_auth = match location.auth {
Some(Auth::True) => true,
Some(Auth::Write) => !DavMethodSet::WEBDAV_RO.contains(method) || auth_hdr.is_some(),
Some(Auth::False) => false,
Some(Auth::Opportunistic) | None => auth_hdr.is_some(),
};
let auth_user = if do_auth {
let user = match self.auth.auth(&req, location, remote_ip).await {
Ok(user) => user,
Err(status) => return self.auth_error(status, location).await,
};
// if there was a :user in the route, return error if it does not match.
if user_param.map(|u| u != &user).unwrap_or(false) {
debug!("handle: auth user and :user mismatch");
return self.auth_error(StatusCode::UNAUTHORIZED, location).await;
}
Some(user)
} else {
None
};
// Now see if we want to do a account lookup, for uid/gid/homedir.
let pwd = match self.acct(location, auth_user.as_ref(), user_param).await {
Ok(pwd) => pwd,
Err(status) => return self.auth_error(status, location).await,
};
// Expand "~" in the directory.
let dir = match expand_directory(location.directory.as_str(), pwd.as_ref()) {
Ok(d) => d,
Err(_) => return self.error(StatusCode::NOT_FOUND).await,
};
// If :path matched, we can calculate the prefix.
// If it didn't, the entire path _is_ the prefix.
let prefix = match route.params[1].as_ref() {
Some(p) => {
let mut start = p.start();
if start > 0 {
start -= 1;
}
&path[..start]
},
None => path,
};
let prefix = match std::str::from_utf8(prefix) {
Ok(p) => p.to_string(),
Err(_) => {
debug!("handle: prefix is non-UTF8");
return self.error(StatusCode::NOT_FOUND).await;
},
};
// Get User-Agent for user-agent specific modes.
let user_agent = req
.headers()
.get("user-agent")
.and_then(|s| s.to_str().ok())
.unwrap_or("");
// Case insensitivity wanted?
let case_insensitive = match location.case_insensitive {
Some(CaseInsensitive::True) => true,
Some(CaseInsensitive::Ms) => user_agent.contains("Microsoft"),
Some(CaseInsensitive::False) | None => false,
};
// macOS optimizations?
let macos = user_agent.contains("WebDAVFS/") && user_agent.contains("Darwin");
// Get the filesystem.
let auth_ugid = if location.setuid {
pwd.as_ref().map(|p| (p.uid, p.gid, p.groups.as_slice()))
} else {
None
};
let fs = match location.handler {
Handler::Virtroot => {
let auth_user = auth_user.as_ref().map(String::to_owned);
RootFs::new(dir, auth_user, auth_ugid) as Box<dyn DavFileSystem>
},
Handler::Filesystem => {
UserFs::new(dir, auth_ugid, true, case_insensitive, macos) as Box<dyn DavFileSystem>
},
};
// Build a handler.
let methods = location
.methods
.unwrap_or(DavMethodSet::from_vec(vec!["GET", "HEAD"]).unwrap());
let hide_symlinks = location.hide_symlinks.clone().unwrap_or(true);
let mut config = DavConfig::new()
.filesystem(fs)
.strip_prefix(prefix)
.methods(methods)
.hide_symlinks(hide_symlinks)
.autoindex(location.autoindex);
if let Some(auth_user) = auth_user {
config = config.principal(auth_user);
}
if let Some(indexfile) = location.indexfile.clone() {
config = config.indexfile(indexfile);
}
// All set.
self.run_davhandler(config, req).await
}
async fn build_error(&self, code: StatusCode, location: Option<&Location>) -> HttpResult {
let msg = format!(
"<error>{} {}</error>\n",
code.as_u16(),
code.canonical_reason().unwrap_or("")
);
let mut response = self
.response_builder()
.status(code)
.header("Content-Type", "text/xml");
if code == StatusCode::UNAUTHORIZED {
let realm = location.and_then(|location| location.accounts.realm.as_ref());
let realm = realm.or(self.config.accounts.realm.as_ref());
let realm = realm.map(|s| s.as_str()).unwrap_or("Webdav Server");
response = response.header("WWW-Authenticate", format!("Basic realm=\"{}\"", realm).as_str());
}
Ok(response.body(msg.into()).unwrap())
}
async fn auth_error(&self, code: StatusCode, location: &Location) -> HttpResult {
self.build_error(code, Some(location)).await
}
async fn error(&self, code: StatusCode) -> HttpResult {
self.build_error(code, None).await
}
// Call the davhandler, then add headers to the response.
async fn run_davhandler(&self, config: DavConfig, req: HttpRequest) -> HttpResult {
let resp = self.dh.handle_with(config, req).await;
let (mut parts, body) = resp.into_parts();
self.set_headers(&mut parts.headers);
Ok(http::Response::from_parts(parts, body))
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// command line option processing.
let matches = clap_app!(webdav_server =>
(version: "0.3")
(@arg CFG: -c --config +takes_value "configuration file (/etc/webdav-server.toml)")
(@arg PORT: -p --port +takes_value "listen to this port on localhost only")
(@arg DBG: -D --debug "enable debug level logging")
)
.get_matches();
if matches.is_present("DBG") {
use env_logger::Env;
let level = "webdav_server=debug,webdav_handler=debug";
env_logger::Builder::from_env(Env::default().default_filter_or(level)).init();
} else {
env_logger::init();
}
let port = matches.value_of("PORT");
let cfg = matches.value_of("CFG").unwrap_or("/etc/webdav-server.toml");
// read config.
let mut config = match config::read(cfg.clone()) {
Err(e) => {
eprintln!("{}: {}: {}", PROGNAME, cfg, e);
exit(1);
},
Ok(c) => c,
};
config::check(cfg.clone(), &config);
// build routes.
if let Err(e) = config::build_routes(cfg.clone(), &mut config) {
eprintln!("{}: {}: {}", PROGNAME, cfg, e);
exit(1);
}
if let Some(port) = port {
let localhosts = vec![
("127.0.0.1:".to_string() + port).parse::<SocketAddr>().unwrap(),
("[::]:".to_string() + port).parse::<SocketAddr>().unwrap(),
];
config.server.listen = config::OneOrManyAddr::Many(localhosts);
}
let config = Arc::new(config);
// set cache timeouts.
if let Some(timeout) = config.unix.cache_timeout {
cache::cached::set_pwcache_timeout(timeout);
}
// resolve addresses.
let addrs = config.server.listen.clone().to_socket_addrs().unwrap_or_else(|e| {
eprintln!("{}: {}: [server] listen: {:?}", PROGNAME, cfg, e);
exit(1);
});
let tls_addrs = config.server.tls_listen.clone().to_socket_addrs().unwrap_or_else(|e| {
eprintln!("{}: {}: [server] listen: {:?}", PROGNAME, cfg, e);
exit(1);
});
// initialize auth early.
let auth = auth::Auth::new(config.clone())?;
// start tokio runtime and initialize the rest from within the runtime.
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_io()
.enable_time()
.build()?;
rt.block_on(async move {
// build servers (one for each listen address).
let dav_server = Server::new(config.clone(), auth);
let mut servers = Vec::new();
let mut tls_servers = Vec::new();
// Plaintext servers.
for sockaddr in addrs {
let listener = match make_listener(sockaddr) {
Ok(l) => l,
Err(e) => {
eprintln!("{}: listener on {:?}: {}", PROGNAME, &sockaddr, e);
exit(1);
},
};
let dav_server = dav_server.clone();
let make_service = make_service_fn(move |socket: &AddrStream| {
let dav_server = dav_server.clone();
let remote_addr = socket.remote_addr();
async move {
let func = move |req| {
let dav_server = dav_server.clone();
async move { dav_server.route(req, remote_addr).await }
};
Ok::<_, hyper::Error>(service_fn(func))
}
});
let incoming = AddrIncoming::from_listener(listener)?;
let server = hyper::Server::builder(incoming);
println!("Listening on http://{:?}", sockaddr);
servers.push(async move {
if let Err(e) = server.serve(make_service).await {
eprintln!("{}: server error: {}", PROGNAME, e);
exit(1);
}
});
}
// TLS servers.
if tls_addrs.len() > 0 {
let tls_acceptor = tls_acceptor(&config.server)?;
for sockaddr in tls_addrs {
let tls_acceptor = tls_acceptor.clone();
let listener = make_listener(sockaddr).unwrap_or_else(|e| {
eprintln!("{}: listener on {:?}: {}", PROGNAME, &sockaddr, e);
exit(1);
});
let dav_server = dav_server.clone();
let make_service = make_service_fn(move |stream: &TlsStream<AddrStream>| {
let dav_server = dav_server.clone();
let remote_addr = stream.get_ref().0.remote_addr();
async move {
let func = move |req| {
let dav_server = dav_server.clone();
async move { dav_server.route(req, remote_addr).await }
};
Ok::<_, hyper::Error>(service_fn(func))
}
});
// Since the server can exit when there's an error on the TlsStream,
// we run it in a loop. Every time the loop is entered we dup() the
// listening fd and create a new TcpListener. This way, we should
// not lose any pending connections during a restart.
let master_listen_fd = listener.as_raw_fd();
std::mem::forget(listener);
println!("Listening on https://{:?}", sockaddr);
tls_servers.push(async move {
loop {
// reuse the incoming socket after the server exits.
let listen_fd = match nix::unistd::dup(master_listen_fd) {
Ok(fd) => fd,
Err(e) => {
eprintln!("{}: server error: dup: {}", PROGNAME, e);
break;
}
};
// SAFETY: listen_fd is unique (we just dup'ed it).
let std_listen = unsafe { std::net::TcpListener::from_raw_fd(listen_fd) };
let listener = match tokio::net::TcpListener::from_std(std_listen) {
Ok(l) => l,
Err(e) => {
eprintln!("{}: server error: new TcpListener: {}", PROGNAME, e);
break;
}
};
let a_incoming = match AddrIncoming::from_listener(listener) {
Ok(a) => a,
Err(e) => {
eprintln!("{}: server error: new AddrIncoming: {}", PROGNAME, e);
break;
}
};
let incoming = TlsListener::new(tls_acceptor.clone(), a_incoming);
let server = hyper::Server::builder(incoming);
if let Err(e) = server.serve(make_service.clone()).await {
eprintln!("{}: server error: {} (retrying)", PROGNAME, e);
}
}
});
}
}
// drop privs.
match (&config.server.uid, &config.server.gid) {
(&Some(uid), &Some(gid)) => {
if !suid::have_suid_privs() {
eprintln!(
"{}: insufficent priviliges to switch uid/gid (not root).",
PROGNAME
);
exit(1);
}
let keep_privs = config.location.iter().any(|l| l.setuid);
proc_switch_ugid(uid, gid, keep_privs);
},
_ => {},
}
// spawn all servers, and wait for them to finish.
let mut tasks = Vec::new();
for server in servers.drain(..) {
tasks.push(tokio::spawn(server));
}
for server in tls_servers.drain(..) {
tasks.push(tokio::spawn(server));
}
for task in tasks.drain(..) {
let _ = task.await;
}
Ok::<_, Box<dyn std::error::Error>>(())
})
}
// Clones a http request with an empty body.
fn clone_httpreq(req: &HttpRequest) -> HttpRequest {
let mut builder = http::Request::builder()
.method(req.method().clone())
.uri(req.uri().clone())
.version(req.version().clone());
for (name, value) in req.headers().iter() {
builder = builder.header(name, value);
}
builder.body(hyper::Body::empty()).unwrap()
}
fn expand_directory(dir: &str, pwd: Option<&Arc<unixuser::User>>) -> Result<String, StatusCode> {
// If it doesn't start with "~", skip.
if !dir.starts_with("~") {
return Ok(dir.to_string());
}
// ~whatever doesn't work.
if dir.len() > 1 && !dir.starts_with("~/") {
debug!("expand_directory: rejecting {}", dir);
return Err(StatusCode::NOT_FOUND);
}
// must have a directory, and that dir must be UTF-8.
let pwd = match pwd {
Some(pwd) => pwd,
None => {
debug!("expand_directory: cannot expand {}: no account", dir);
return Err(StatusCode::NOT_FOUND);
},
};
let homedir = pwd.dir.to_str().ok_or(StatusCode::NOT_FOUND)?;
Ok(format!("{}/{}", homedir, &dir[1..]))
}
// Make a new TcpListener, and if it's a V6 listener, set the
// V6_V6ONLY socket option on it.
fn make_listener(addr: SocketAddr) -> io::Result<tokio::net::TcpListener> {
use socket2::{Domain, SockAddr, Socket, Type, Protocol};
let s = Socket::new(Domain::for_address(addr), Type::STREAM, Some(Protocol::TCP))?;
if addr.is_ipv6() {
s.set_only_v6(true)?;
}
s.set_nonblocking(true)?;
s.set_nodelay(true)?;
s.set_reuse_address(true)?;
let addr: SockAddr = addr.into();
s.bind(&addr)?;
s.listen(128)?;
let listener: std::net::TcpListener = s.into();
tokio::net::TcpListener::from_std(listener)
}

112
src/rootfs.rs Normal file
View File

@@ -0,0 +1,112 @@
//
// Virtual Root filesystem for PROPFIND.
//
// Shows "/" and "/user".
//
use std;
use std::path::Path;
use futures::future::{self, FutureExt};
use webdav_handler::davpath::DavPath;
use webdav_handler::fs::*;
use crate::userfs::UserFs;
#[derive(Clone)]
pub struct RootFs {
user: String,
fs: UserFs,
}
impl RootFs {
pub fn new<P>(dir: P, user: Option<String>, creds: Option<(u32, u32, &[u32])>) -> Box<RootFs>
where P: AsRef<Path> + Clone {
Box::new(RootFs {
user: user.unwrap_or("".to_string()),
fs: *UserFs::new(dir, creds, false, false, true),
})
}
}
impl DavFileSystem for RootFs {
// Only allow "/" or "/user", for both return the metadata of the UserFs root.
fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<Box<dyn DavMetaData>> {
async move {
let b = path.as_bytes();
if b != b"/" && &b[1..] != self.user.as_bytes() {
return Err(FsError::NotFound);
}
let path = DavPath::new("/").unwrap();
self.fs.metadata(&path).await
}
.boxed()
}
// Only return one entry: "user".
fn read_dir<'a>(
&'a self,
path: &'a DavPath,
_meta: ReadDirMeta,
) -> FsFuture<FsStream<Box<dyn DavDirEntry>>>
{
Box::pin(async move {
let mut v = Vec::new();
if self.user != "" {
v.push(RootFsDirEntry {
name: self.user.clone(),
meta: self.fs.metadata(path).await,
});
}
let strm = futures::stream::iter(RootFsReadDir {
iterator: v.into_iter(),
});
Ok(Box::pin(strm) as FsStream<Box<dyn DavDirEntry>>)
})
}
// cannot open any files.
fn open(&self, _path: &DavPath, _options: OpenOptions) -> FsFuture<Box<dyn DavFile>> {
Box::pin(future::ready(Err(FsError::NotImplemented)))
}
// forward quota.
fn get_quota(&self) -> FsFuture<(u64, Option<u64>)> {
self.fs.get_quota()
}
}
#[derive(Debug)]
struct RootFsReadDir {
iterator: std::vec::IntoIter<RootFsDirEntry>,
}
impl Iterator for RootFsReadDir {
type Item = Box<dyn DavDirEntry>;
fn next(&mut self) -> Option<Box<dyn DavDirEntry>> {
match self.iterator.next() {
None => return None,
Some(entry) => Some(Box::new(entry)),
}
}
}
#[derive(Debug)]
struct RootFsDirEntry {
meta: FsResult<Box<dyn DavMetaData>>,
name: String,
}
impl DavDirEntry for RootFsDirEntry {
fn metadata(&self) -> FsFuture<Box<dyn DavMetaData>> {
Box::pin(future::ready(self.meta.clone()))
}
fn name(&self) -> Vec<u8> {
self.name.as_bytes().to_vec()
}
fn is_dir(&self) -> FsFuture<bool> {
Box::pin(future::ready(Ok(true)))
}
}

262
src/router.rs Normal file
View File

@@ -0,0 +1,262 @@
//!
//! Simple and stupid HTTP router.
//!
use std::default::Default;
use std::fmt::Debug;
use lazy_static::lazy_static;
use regex::bytes::{Match, Regex, RegexSet};
use webdav_handler::{DavMethod, DavMethodSet};
// internal representation of a route.
#[derive(Debug)]
struct Route<T: Debug> {
regex: Regex,
methods: Option<DavMethodSet>,
data: T,
}
/// A matched route.
#[derive(Debug)]
pub struct MatchedRoute<'t, 'p, T: Debug> {
pub methods: Option<DavMethodSet>,
pub params: Vec<Option<Param<'p>>>,
pub data: &'t T,
}
/// A parameter on a matched route.
pub struct Param<'p>(Match<'p>);
impl Debug for Param<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.debug_struct("Param")
.field("start", &self.0.start())
.field("end", &self.0.end())
.field("as_str", &std::str::from_utf8(self.0.as_bytes()).ok())
.finish()
}
}
impl<'p> Param<'p> {
/// Returns the starting byte offset of the match in the path.
#[inline]
pub fn start(&self) -> usize {
self.0.start()
}
/// Returns the ending byte offset of the match in the path.
#[inline]
pub fn end(&self) -> usize {
self.0.end()
}
/// Returns the matched part of the path.
#[inline]
pub fn as_bytes(&self) -> &'p [u8] {
self.0.as_bytes()
}
/// Returns the matched part of the path as a &str, if it is valid utf-8.
#[inline]
pub fn as_str(&self) -> Option<&'p str> {
std::str::from_utf8(self.0.as_bytes()).ok()
}
}
pub struct Builder<T: Debug> {
routes: Vec<Route<T>>,
}
impl<T: Debug> Builder<T> {
/// Add a route.
///
/// Routes are matched in the order they were added.
///
/// If a route starts with '^', it's assumed that it is a regular
/// expression. Parameters are included as "named capture groups".
///
/// Otherwise, it's a route-expression, with just the normal :params
/// and *splat param, and parts between parentheses are optional.
///
/// Example:
///
/// - /api/get/:id
/// - /files/*path
/// - /users(/)
/// - /users(/*path)
///
pub fn add(
&mut self,
route: impl AsRef<str>,
methods: Option<DavMethodSet>,
data: T,
) -> Result<&mut Self, regex::Error>
{
let route = route.as_ref();
// Might be a regexp
if route.starts_with("^") {
return self.add_re(route, methods, data);
}
// Ignore it if it does not start with /
if !route.starts_with("/") {
return Ok(self);
}
// First, replace special characters "()*" with unicode chars
// from the private-use area, so that we can then regex-escape
// the entire string.
let re_route = route
.chars()
.map(|c| {
match c {
'*' => '\u{e001}',
'(' => '\u{e002}',
')' => '\u{e003}',
'\u{e001}' => ' ',
'\u{e002}' => ' ',
'\u{e003}' => ' ',
c => c,
}
})
.collect::<String>();
let re_route = regex::escape(&re_route);
// Translate route expression into regexp.
// We do a simple transformation:
// :ident -> (?P<ident>[^/]*)
// *ident -> (?P<ident>.*)
// (text) -> (?:text|)
lazy_static! {
static ref COLON: Regex = Regex::new(":([a-zA-Z0-9]+)").unwrap();
static ref SPLAT: Regex = Regex::new("\u{e001}([a-zA-Z0-9]+)").unwrap();
static ref MAYBE: Regex = Regex::new("\u{e002}([^\u{e002}]*)\u{e003}").unwrap();
};
let mut re_route = re_route.into_bytes();
re_route = COLON.replace_all(&re_route, &b"(?P<$1>[^/]*)"[..]).to_vec();
re_route = SPLAT.replace_all(&re_route, &b"(?P<$1>.*)"[..]).to_vec();
re_route = MAYBE.replace_all(&re_route, &b"($1)?"[..]).to_vec();
// finalize regex.
let re_route = "^".to_string() + &String::from_utf8(re_route).unwrap() + "$";
self.add_re(&re_route, methods, data)
}
// add route as regular expression.
fn add_re(&mut self, s: &str, methods: Option<DavMethodSet>, data: T) -> Result<&mut Self, regex::Error> {
// Set flags: enable ". matches everything", disable strict unicode.
// We known 's' starts with "^", add it after that.
let s2 = format!("^(?s){}", &s[1..]);
let regex = Regex::new(&s2)?;
self.routes.push(Route { regex, methods, data });
Ok(self)
}
/// Combine all the routes and compile them into an internal RegexSet.
pub fn build(&mut self) -> Router<T> {
let set = RegexSet::new(self.routes.iter().map(|r| r.regex.as_str())).unwrap();
Router {
routes: std::mem::replace(&mut self.routes, Vec::new()),
set,
}
}
}
/// Dead simple HTTP router.
#[derive(Debug)]
pub struct Router<T: Debug> {
set: RegexSet,
routes: Vec<Route<T>>,
}
impl<T: Debug> Default for Router<T> {
fn default() -> Router<T> {
Router {
set: RegexSet::new(&[] as &[&str]).unwrap(),
routes: Vec::new(),
}
}
}
impl<T: Debug> Router<T> {
/// Return a builder.
pub fn builder() -> Builder<T> {
Builder { routes: Vec::new() }
}
/// See if the path matches a route in the set.
///
/// The names of the parameters you want to be returned need to be passed in as an array.
pub fn matches<'a>(
&self,
path: &'a [u8],
method: DavMethod,
param_names: &[&str],
) -> Vec<MatchedRoute<'_, 'a, T>>
{
let mut matched = Vec::new();
for idx in self.set.matches(path) {
let route = &self.routes[idx];
if route.methods.map(|m| m.contains(method)).unwrap_or(true) {
let mut params = Vec::new();
if let Some(caps) = route.regex.captures(path) {
for name in param_names {
params.push(caps.name(name).map(|p| Param(p)));
}
} else {
for _ in param_names {
params.push(None);
}
}
matched.push(MatchedRoute {
methods: route.methods,
params,
data: &route.data,
});
}
}
matched
}
}
#[cfg(test)]
mod tests {
use super::*;
use webdav_handler::DavMethod;
fn test_match(rtr: &Router<usize>, p: &[u8], user: &str, path: &str) {
let x = rtr.matches(p, DavMethod::Get, &["user", "path"]);
assert!(x.len() > 0);
let x = &x[0];
if user != "" {
assert!(x.params[0]
.as_ref()
.map(|b| b.as_bytes() == user.as_bytes())
.unwrap_or(false));
}
if path != "" {
assert!(x.params[1]
.as_ref()
.map(|b| b.as_bytes() == path.as_bytes())
.unwrap_or(false));
}
}
#[test]
fn test_router() -> Result<(), Box<dyn std::error::Error>> {
let rtr = Router::<usize>::builder()
.add("/", None, 1)?
.add("/users(/:user)", None, 2)?
.add("/files/*path", None, 3)?
.add("/files(/*path)", None, 4)?
.build();
test_match(&rtr, b"/", "", "");
test_match(&rtr, b"/users", "", "");
test_match(&rtr, b"/users/", "", "");
test_match(&rtr, b"/users/mike", "mike", "");
test_match(&rtr, b"/files/foo/bar", "", "foo/bar");
test_match(&rtr, b"/files", "", "");
Ok(())
}
}

304
src/suid.rs Normal file
View File

@@ -0,0 +1,304 @@
use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
static THREAD_SWITCH_UGID_USED: AtomicBool = AtomicBool::new(false);
#[cfg(all(target_os = "linux"))]
mod setuid {
// On x86, the default SYS_setresuid is 16 bits. We need to
// import the 32-bit variant.
#[cfg(target_arch = "x86")]
mod uid32 {
pub use libc::SYS_getgroups32 as SYS_getgroups;
pub use libc::SYS_setgroups32 as SYS_setgroups;
pub use libc::SYS_setresgid32 as SYS_setresgid;
pub use libc::SYS_setresuid32 as SYS_setresuid;
}
#[cfg(not(target_arch = "x86"))]
mod uid32 {
pub use libc::{SYS_getgroups, SYS_setgroups, SYS_setresgid, SYS_setresuid};
}
use self::uid32::*;
use std::cell::RefCell;
use std::convert::TryInto;
use std::io;
use std::sync::atomic::Ordering;
const ID_NONE: libc::uid_t = 0xffffffff;
// current credentials of this thread.
struct UgidState {
ruid: u32,
euid: u32,
rgid: u32,
egid: u32,
groups: Vec<u32>,
}
impl UgidState {
fn new() -> UgidState {
super::THREAD_SWITCH_UGID_USED.store(true, Ordering::Release);
UgidState {
ruid: unsafe { libc::getuid() } as u32,
euid: unsafe { libc::geteuid() } as u32,
rgid: unsafe { libc::getgid() } as u32,
egid: unsafe { libc::getegid() } as u32,
groups: getgroups().expect("UgidState::new"),
}
}
}
fn getgroups() -> io::Result<Vec<u32>> {
// get number of groups.
let size = unsafe {
libc::syscall(
SYS_getgroups,
0 as libc::c_int,
std::ptr::null_mut::<libc::gid_t>(),
)
};
if size < 0 {
return Err(oserr(size, "getgroups(0, NULL)"));
}
// get groups.
let mut groups = Vec::<u32>::with_capacity(size as usize);
groups.resize(size as usize, 0);
let res = unsafe { libc::syscall(SYS_getgroups, size as libc::c_int, groups.as_mut_ptr() as *mut _) };
// sanity check.
if res != size {
if res < 0 {
return Err(oserr(res, format!("getgroups({}, buffer)", size)));
}
return Err(io::Error::new(
io::ErrorKind::Other,
format!("getgroups({}, buffer): returned {}", size, res),
));
}
Ok(groups)
}
fn oserr(code: libc::c_long, msg: impl AsRef<str>) -> io::Error {
let msg = msg.as_ref();
let err = io::Error::from_raw_os_error(code.try_into().unwrap());
io::Error::new(err.kind(), format!("{}: {}", msg, err))
}
// thread-local seteuid.
fn seteuid(uid: u32) -> io::Result<()> {
let res = unsafe { libc::syscall(SYS_setresuid, ID_NONE, uid, ID_NONE) };
if res < 0 {
return Err(oserr(res, format!("seteuid({})", uid)));
}
Ok(())
}
// thread-local setegid.
fn setegid(gid: u32) -> io::Result<()> {
let res = unsafe { libc::syscall(SYS_setresgid, ID_NONE, gid, ID_NONE) };
if res < 0 {
return Err(oserr(res, format!("setegid({})", gid)));
}
Ok(())
}
// thread-local setgroups.
fn setgroups(gids: &[u32]) -> io::Result<()> {
let size = gids.len() as libc::c_int;
let res = unsafe { libc::syscall(SYS_setgroups, size, gids.as_ptr() as *const libc::gid_t) };
if res < 0 {
return Err(oserr(res, format!("setgroups({}, {:?}", size, gids)));
}
Ok(())
}
// credential state is thread-local.
thread_local!(static CURRENT_UGID: RefCell<UgidState> = RefCell::new(UgidState::new()));
/// Switch thread credentials.
pub(super) fn thread_switch_ugid(newuid: u32, newgid: u32, newgroups: &[u32]) -> (u32, u32, Vec<u32>) {
CURRENT_UGID.with(|current_ugid| {
let mut cur = current_ugid.borrow_mut();
let (olduid, oldgid, oldgroups) = (cur.euid, cur.egid, cur.groups.clone());
let groups_changed = newgroups != cur.groups.as_slice();
// Check if anything changed.
if newuid != cur.euid || newgid != cur.egid || groups_changed {
// See if we have to switch to root privs first.
if cur.euid != 0 && (newuid != cur.ruid || newgid != cur.rgid || groups_changed) {
// Must first switch to root.
if let Err(e) = seteuid(0) {
panic!("{}", e);
}
cur.euid = 0;
}
if newgid != cur.egid {
// Change gid.
if let Err(e) = setegid(newgid) {
panic!("{}", e);
}
cur.egid = newgid;
}
if groups_changed {
// Change groups.
if let Err(e) = setgroups(newgroups) {
panic!("{}", e);
}
cur.groups.truncate(0);
cur.groups.extend_from_slice(newgroups);
}
if newuid != cur.euid {
// Change uid.
if let Err(e) = seteuid(newuid) {
panic!("{}", e);
}
cur.euid = newuid;
}
}
(olduid, oldgid, oldgroups)
})
}
// Yep..
pub fn has_thread_switch_ugid() -> bool {
true
}
}
#[cfg(not(target_os = "linux"))]
mod setuid {
// Not implemented, as it looks like only Linux has support for
// per-thread uid/gid switching.
//
// DO NOT implement this through libc::setuid, as that will
// switch the uids of all threads.
//
/// Switch thread credentials. Not implemented!
pub(super) fn thread_switch_ugid(_newuid: u32, _newgid: u32, _newgroups: &[u32]) -> (u32, u32, Vec<u32>) {
unimplemented!();
}
// Nope.
pub fn has_thread_switch_ugid() -> bool {
false
}
}
pub use self::setuid::has_thread_switch_ugid;
use self::setuid::thread_switch_ugid;
#[derive(Clone, Debug)]
struct UgidCreds {
pub uid: u32,
pub gid: u32,
pub groups: Vec<u32>,
}
pub struct UgidSwitch {
target_creds: Option<UgidCreds>,
}
pub struct UgidSwitchGuard {
base_creds: Option<UgidCreds>,
}
impl UgidSwitch {
pub fn new(creds: Option<(u32, u32, &[u32])>) -> UgidSwitch {
let target_creds = match creds {
Some((uid, gid, groups)) => {
Some(UgidCreds {
uid,
gid,
groups: groups.into(),
})
},
None => None,
};
UgidSwitch { target_creds }
}
#[allow(dead_code)]
pub fn run<F, R>(&self, func: F) -> R
where F: FnOnce() -> R {
let _guard = self.guard();
func()
}
pub fn guard(&self) -> UgidSwitchGuard {
match &self.target_creds {
&None => UgidSwitchGuard { base_creds: None },
&Some(ref creds) => {
let (uid, gid, groups) = thread_switch_ugid(creds.uid, creds.gid, &creds.groups);
UgidSwitchGuard {
base_creds: Some(UgidCreds { uid, gid, groups }),
}
},
}
}
}
impl Drop for UgidSwitchGuard {
fn drop(&mut self) {
if let Some(ref creds) = self.base_creds {
thread_switch_ugid(creds.uid, creds.gid, &creds.groups);
}
}
}
/// Switch process credentials. Keeps the saved-uid as root, so that
/// we can switch to other ids later on.
pub fn proc_switch_ugid(uid: u32, gid: u32, keep_privs: bool) {
if THREAD_SWITCH_UGID_USED.load(Ordering::Acquire) {
panic!("proc_switch_ugid: called after thread_switch_ugid() has been used");
}
fn last_os_error() -> io::Error {
io::Error::last_os_error()
}
unsafe {
// first get full root privs (real, effective, and saved uids)
if libc::setuid(0) != 0 {
panic!("libc::setuid(0): {:?}", last_os_error());
}
// set real uid, and keep effective uid at 0.
#[cfg(not(any(target_os = "openbsd", target_os = "freebsd")))]
if libc::setreuid(uid, 0) != 0 {
panic!("libc::setreuid({}, 0): {:?}", uid, last_os_error());
}
#[cfg(any(target_os = "openbsd", target_os = "freebsd"))]
if libc::setresuid(uid, 0, 0) != 0 {
panic!("libc::setreuid({}, 0): {:?}", uid, last_os_error());
}
// set group id.
if libc::setgid(gid) != 0 {
panic!("libc::setgid({}): {:?}", gid, last_os_error());
}
// remove _all_ auxilary groups.
if libc::setgroups(0, std::ptr::null::<libc::gid_t>()) != 0 {
panic!("setgroups[]: {:?}", last_os_error());
}
if keep_privs {
// finally set effective uid. saved uid is still 0.
if libc::seteuid(uid) != 0 {
panic!("libc::seteuid({}): {:?}", uid, last_os_error());
}
} else {
// drop all privs.
if libc::setuid(uid) != 0 {
panic!("libc::setuid({}): {:?}", uid, last_os_error());
}
}
}
}
/// Do we have sufficient privs to switch uids?
pub fn have_suid_privs() -> bool {
unsafe { libc::geteuid() == 0 }
}

58
src/tls.rs Normal file
View File

@@ -0,0 +1,58 @@
use std::fs::File;
use std::io::{self, ErrorKind};
use std::sync::Arc;
use tokio_rustls::rustls::{Certificate, PrivateKey, ServerConfig};
use tokio_rustls::TlsAcceptor;
use rustls_pemfile as pemfile;
use crate::config::Server;
pub fn tls_acceptor(cfg: &Server) -> io::Result<TlsAcceptor> {
// Private key.
let pkey_fn = cfg.tls_key.as_ref().ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "config: server: tls_key not set")
})?;
let pkey_file = File::open(pkey_fn).map_err(|e| {
io::Error::new(e.kind(), format!("{}: {}", pkey_fn, e))
})?;
let mut pkey_file = io::BufReader::new(pkey_file);
let pkey = match pemfile::read_one(&mut pkey_file) {
Ok(Some(pemfile::Item::RSAKey(pkey))) => PrivateKey(pkey),
Ok(Some(pemfile::Item::PKCS8Key(pkey))) => PrivateKey(pkey),
Ok(Some(pemfile::Item::ECKey(pkey))) => PrivateKey(pkey),
Ok(Some(_)) => return Err(io::Error::new(io::ErrorKind::InvalidData, format!("{}: unknown private key format", pkey_fn))),
Ok(None) => return Err(io::Error::new(io::ErrorKind::InvalidData, format!("{}: expected one private key", pkey_fn))),
Err(_) => return Err(io::Error::new(io::ErrorKind::InvalidData, format!("{}: invalid data", pkey_fn))),
};
// Certificate.
let cert_fn = cfg.tls_cert.as_ref().ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "config: server: tls_cert not set")
})?;
let cert_file = File::open(cert_fn).map_err(|e| {
io::Error::new(e.kind(), format!("{}: {}", cert_fn, e))
})?;
let mut cert_file = io::BufReader::new(cert_file);
let certs = pemfile::certs(&mut cert_file).map_err(|_| {
io::Error::new(io::ErrorKind::InvalidData, format!("{}: invalid data", cert_fn))
})?;
let certs = certs
.into_iter()
.map(|cert| Certificate(cert.into()))
.collect();
let config = Arc::new(
ServerConfig::builder()
.with_safe_defaults()
.with_no_client_auth()
.with_single_cert(certs, pkey)
.map_err(|e| {
io::Error::new(ErrorKind::InvalidData, format!("{}/{}: {}", pkey_fn, cert_fn, e))
})?
).into();
Ok(config)
}

135
src/unixuser.rs Normal file
View File

@@ -0,0 +1,135 @@
use std;
use std::ffi::{CStr, OsStr};
use std::io;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use tokio::task::block_in_place;
#[derive(Debug)]
pub struct User {
pub name: String,
pub passwd: String,
pub gecos: String,
pub uid: u32,
pub gid: u32,
pub groups: Vec<u32>,
pub dir: PathBuf,
pub shell: PathBuf,
}
unsafe fn cptr_to_osstr<'a>(c: *const libc::c_char) -> &'a OsStr {
let bytes = CStr::from_ptr(c).to_bytes();
OsStr::from_bytes(&bytes)
}
unsafe fn cptr_to_path<'a>(c: *const libc::c_char) -> &'a Path {
Path::new(cptr_to_osstr(c))
}
unsafe fn to_user(pwd: &libc::passwd) -> User {
// turn into (unsafe!) rust slices
let cs_name = CStr::from_ptr(pwd.pw_name);
let cs_passwd = CStr::from_ptr(pwd.pw_passwd);
let cs_gecos = CStr::from_ptr(pwd.pw_gecos);
let cs_dir = cptr_to_path(pwd.pw_dir);
let cs_shell = cptr_to_path(pwd.pw_shell);
// then turn the slices into safe owned values.
User {
name: cs_name.to_string_lossy().into_owned(),
passwd: cs_passwd.to_string_lossy().into_owned(),
gecos: cs_gecos.to_string_lossy().into_owned(),
dir: cs_dir.to_path_buf(),
shell: cs_shell.to_path_buf(),
uid: pwd.pw_uid,
gid: pwd.pw_gid,
groups: Vec::new(),
}
}
impl User {
pub fn by_name(name: &str, with_groups: bool) -> Result<User, io::Error> {
let mut buf = [0u8; 1024];
let mut pwd: libc::passwd = unsafe { std::mem::zeroed() };
let mut result: *mut libc::passwd = std::ptr::null_mut();
let cname = match std::ffi::CString::new(name) {
Ok(un) => un,
Err(_) => return Err(io::Error::from_raw_os_error(libc::ENOENT)),
};
let ret = unsafe {
libc::getpwnam_r(
cname.as_ptr(),
&mut pwd as *mut _,
buf.as_mut_ptr() as *mut _,
buf.len() as libc::size_t,
&mut result as *mut _,
)
};
if ret != 0 {
return Err(io::Error::from_raw_os_error(ret));
}
if result.is_null() {
return Err(io::Error::from_raw_os_error(libc::ENOENT));
}
let mut user = unsafe { to_user(&pwd) };
if with_groups {
let mut ngroups = (buf.len() / std::mem::size_of::<libc::gid_t>()) as libc::c_int;
let ret = unsafe {
libc::getgrouplist(
cname.as_ptr(),
user.gid as libc::gid_t,
buf.as_mut_ptr() as *mut _,
&mut ngroups as *mut _,
)
};
if ret >= 0 && ngroups > 0 {
let mut groups_vec = Vec::with_capacity(ngroups as usize);
let groups = unsafe {
std::slice::from_raw_parts(buf.as_ptr() as *const libc::gid_t, ngroups as usize)
};
//
// Only supplementary or auxilary groups, filter out primary.
//
groups_vec.extend(groups.iter().map(|&g| g as u32).filter(|&g| g != user.gid));
user.groups = groups_vec;
}
}
Ok(user)
}
/*
pub fn by_uid(uid: u32) -> Result<User, io::Error> {
let mut buf = [0; 1024];
let mut pwd: libc::passwd = unsafe { std::mem::zeroed() };
let mut result: *mut libc::passwd = std::ptr::null_mut();
let ret = unsafe {
getpwuid_r(
uid,
&mut pwd as *mut _,
buf.as_mut_ptr(),
buf.len() as libc::size_t,
&mut result as *mut _,
)
};
if ret == 0 {
if result.is_null() {
return Err(io::Error::from_raw_os_error(libc::ENOENT));
}
let p = unsafe { to_user(&pwd) };
Ok(p)
} else {
Err(io::Error::from_raw_os_error(ret))
}
}
*/
pub async fn by_name_async(name: &str, with_groups: bool) -> Result<User, io::Error> {
block_in_place(move || User::by_name(name, with_groups))
}
}

125
src/userfs.rs Normal file
View File

@@ -0,0 +1,125 @@
use std::any::Any;
use std::path::{Path, PathBuf};
use webdav_handler::davpath::DavPath;
use webdav_handler::fs::*;
use webdav_handler::localfs::LocalFs;
use crate::suid::UgidSwitch;
#[derive(Clone)]
pub struct UserFs {
pub fs: LocalFs,
basedir: PathBuf,
uid: u32,
}
impl UserFs {
pub fn new(
dir: impl AsRef<Path>,
target_creds: Option<(u32, u32, &[u32])>,
public: bool,
case_insensitive: bool,
macos: bool,
) -> Box<UserFs>
{
// uid is used for quota() calls.
let uid = target_creds.as_ref().map(|ugid| ugid.0).unwrap_or(0);
// set up the LocalFs hooks for uid switching.
let switch = UgidSwitch::new(target_creds.clone());
let blocking_guard = Box::new(move || Box::new(switch.guard()) as Box<dyn Any>);
Box::new(UserFs {
basedir: dir.as_ref().to_path_buf(),
fs: *LocalFs::new_with_fs_access_guard(
dir,
public,
case_insensitive,
macos,
Some(blocking_guard),
),
uid: uid,
})
}
}
impl DavFileSystem for UserFs {
fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<Box<dyn DavMetaData>> {
self.fs.metadata(path)
}
fn symlink_metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<Box<dyn DavMetaData>> {
self.fs.symlink_metadata(path)
}
fn read_dir<'a>(
&'a self,
path: &'a DavPath,
meta: ReadDirMeta,
) -> FsFuture<FsStream<Box<dyn DavDirEntry>>>
{
self.fs.read_dir(path, meta)
}
fn open<'a>(&'a self, path: &'a DavPath, options: OpenOptions) -> FsFuture<Box<dyn DavFile>> {
self.fs.open(path, options)
}
fn create_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<()> {
self.fs.create_dir(path)
}
fn remove_dir<'a>(&'a self, path: &'a DavPath) -> FsFuture<()> {
self.fs.remove_dir(path)
}
fn remove_file<'a>(&'a self, path: &'a DavPath) -> FsFuture<()> {
self.fs.remove_file(path)
}
fn rename<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<()> {
self.fs.rename(from, to)
}
fn copy<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<()> {
self.fs.copy(from, to)
}
#[cfg(feature = "quota")]
fn get_quota<'a>(&'a self) -> FsFuture<(u64, Option<u64>)> {
use crate::cache;
use fs_quota::*;
use futures::future::FutureExt;
use std::time::Duration;
lazy_static::lazy_static! {
static ref QCACHE: cache::Cache<PathBuf, FsQuota> = cache::Cache::new().maxage(Duration::new(30, 0));
}
async move {
let mut key = self.basedir.clone();
key.push(&self.uid.to_string());
let r = match QCACHE.get(&key) {
Some(r) => {
debug!("get_quota for {:?}: from cache", key);
r
},
None => {
let path = self.basedir.clone();
let uid = self.uid;
let r = self
.fs
.blocking(move || {
FsQuota::check(&path, Some(uid)).map_err(|_| FsError::GeneralFailure)
})
.await?;
debug!("get_quota for {:?}: insert to cache", key);
QCACHE.insert(key, r)
},
};
Ok((r.bytes_used, r.bytes_limit))
}
.boxed()
}
}

154
webdav-server.toml Normal file
View File

@@ -0,0 +1,154 @@
#
# Webdav server settings.
#
[server]
# Port(s) to listen on.
listen = [ "0.0.0.0:4918", "[::]:4918" ]
# Tls config.
# tls_listen = [ "0.0.0.0:443", "[::]:443" ]
# tls_cert = "/etc/ssl/certs/example.com-chained.crt"
# tls_key = "/etc/ssl/private/example.com.key"
# Unix uid/gid to run under (when not running setuid as user).
# Optional - if not set, will not change uid.
uid = 33
gid = 33
# Server: header to send (default: "webdav-server-rs")
identification = "webdav-server-rs"
#
# User settings.
#
# These are defaults. The same settings can be applied
# on the [[location]] level.
#
[accounts]
# how to authenticate: pam, htaccess.NAME (default: unset).
auth-type = "pam"
# what account "database" to use (default: unset).
acct-type = "unix"
# realm to use with basic authentication (default: "Webdav Server").
realm = "Webdav Server"
#
# PAM authentication settings.
#
[pam]
# PAM service to use.
service = "other"
# Cache timeout (secs). 0 disables the cache (default: 120).
cache-timeout = 120
# Number of thread to use for the PAM service threadpool (default: 8).
threads = 8
#
# Htpasswd authentication settings.
#
[htpasswd.example]
# htpasswd file.
htpasswd = "/etc/htpasswd.example"
# Unix account settings.
#
[unix]
# Cache timeout (secs). 0 disables the cache (default: 120).
cache-timeout = 120
# Accounts with a user-id lower than this value cannot login (default: 0).
min-uid = 1000
#
# Below follow a number of locations. Each location definition starts with
# [[location]] (literally). For every request, the "path" and "methods"
# settings of each location are checked in the same order # as they appear
# in this file. The first one that matches is used.
#
##
## Example location. Lists all settings.
##
[[location]]
# Matching route(s) to the resource.
#
# As is the convention with many http routers, you can use :PARAMETER
# (path element match) or *PARAMETER (path wildcard match) in the
# path definition. Everything between parentheses is optional.
#
# Currently 2 parameters can be used:
#
# - "path" is the part of the path to map to the filesystem
# - "user" matches the currently authenticated user.
#
# A couple of examples:
#
# - For authenticated webdav sessions: [ "/:user/*path" ] or [ "/:user(/*path)" ]
# - For serving / but no dirs below it: [ "/(:path)" ]
# - For serving / and everything below it: [ "/*path" ]
#
# Note that only "path" is what is applied to the "directory" setting to
# form a path on the filesystem. So route = [ "/loca/tion/*path" ] and
# directory = "/var/www/html" will serve the content of /var/www/html
# at the http path /loca/tion/.
#
# If there is no route defined, the location is ignored.
route = [ "/*path" ]
# Allowed methods (default: all).
#
# List of individual methods, or one of:
#
# http-ro: GET, HEAD
# http-rw: GET, HEAD, PUT
# webdav-ro: GET, HEAD, OPTIONS, PROPFIND
# webdav-rw: GET, HEAD, OPTIONS, PROPFIND, PUT, PATCH, PROPPATCH,
# MKCOL, COPY, MOVE, DELETE, LOCK, UNLOCK
methods = [ "webdav-ro" ]
# Authenticate? true, false, opportunistic, write (default: opportunistic).
#
# "opportunistic": means "if you send an Authorization: header, we'll check it".
# "write": means "for methods in webdav-rw that are not in webdav-ro".
auth = "false"
# Type of handler: filesystem, virtroot. Mandatory.
#
# The filesystem handler is what you would expect.
#
# The virtroot handler is a special handler for PROPFIND requests on
# authenticated sessions, i.e. where we have a username. It is useful
# when you have your webdav clients all under, say, /:user/*path.
# In that case, normally a PROPFIND of "/" would return either NOT_FOUND
# or the contents of the directory of some [[location]]. If the handler
# is set to "virtroot", a PROPFIND will list exactly one subdirectory,
# with the name of the authenticated user.
#
handler = "filesystem"
# what to do on 404 Not Found: continue, return (default: return).
on_notfound = "return"
# Change UID/GID to that of the authenticated user: true, false (default: false).
setuid = false
# Directory to serve. Mandatory.
#
# You can use "~" to indicate "homedirectory of authenticated user".
#
directory = "/var/www/html"
# Index file to serve when you GET a directory (if it exists) (default: none).
#indexfile = "index.html"
# Serve HTML directory indexes: true, false (default: false).
autoindex = false
# webdav PROPFIND: hide symbolic links: true, false (default: true).
hide-symlinks = true
# case insensitive lookups: true, false, ms (default: false).
# "ms" means "for Microsoft clients".
case-insensitive = "false"
# Another location definition could follow.
#[[location]]