diff options
| -rw-r--r-- | CHANGES | 14 | ||||
| -rw-r--r-- | Cargo.toml | 6 | ||||
| -rw-r--r-- | README | 8 | ||||
| -rw-r--r-- | src/lib.rs | 75 | ||||
| -rw-r--r-- | src/tftpc.rs | 108 | ||||
| -rw-r--r-- | src/tftpd.rs | 134 | ||||
| -rwxr-xr-x | test.sh | 2 |
7 files changed, 163 insertions, 184 deletions
@@ -0,0 +1,14 @@ +1.1.0 (2019-10-05) + * Server: + - chroot to destination directory if permissions are sufficient + - drop -d parameter, but allow directory at the end of the command line + * Support for some non-standard options: + - blksize2 + - utimeout + +1.0.0 (2019-03-12) + * Initial release + * RFC 1350 (TFTP revision 2) + * RFC 2347 (Option Extension) + * RFC 2348 (Blocksize Option) + * RFC 2349 (Timeout Interval and Transfer Size Options) @@ -1,6 +1,6 @@ [package] name = "rtftp" -version = "1.0.0" +version = "1.1.0" authors = ["Reiner Herrmann <reiner@reiner-h.de>"] edition = "2018" license = "GPL-3.0-or-later" @@ -10,8 +10,8 @@ lto = true panic = 'abort' [dependencies] -nix = "0.13.0" -getopts = "0.2.18" +nix = "0.15.0" +getopts = "0.2.19" threadpool = "1.7.1" [[bin]] @@ -10,6 +10,10 @@ Currently supported: - RFC 2348 (Blocksize Option) - RFC 2349 (Timeout Interval and Transfer Size Options) +Non-standard options: +- blksize2: block size as a power of 2 +- utimeout: timeout in microseconds + Use cargo to build the binaries (output dir is target/release/): $ cargo build --release @@ -35,12 +39,10 @@ Server: $ ./rtftpd --help RusTFTP - ./rtftpd [options] + ./rtftpd [options] [directory] Options: -h, --help display usage information - -d, --directory DIRECTORY - directory to serve (default: current directory) -p, --port PORT port to listen on (default: 69) -u, --uid UID user id to run as (default: 65534) -g, --gid GID group id to run as (default: 65534) @@ -39,7 +39,7 @@ pub enum Mode { #[derive(Clone, Copy)] pub struct TftpOptions { blksize: usize, - timeout: u8, + timeout: Duration, tsize: u64, } @@ -53,7 +53,7 @@ pub struct Tftp { fn default_options() -> TftpOptions { TftpOptions { blksize: 512, - timeout: 3, + timeout: Duration::from_secs(3), tsize: 0, } } @@ -93,6 +93,11 @@ fn octet_to_netascii(buf: &[u8]) -> Vec<u8> { out } +fn blksize2(size: usize) -> usize { + (size + 1).next_power_of_two() >> 1 +} + + impl Default for Tftp { fn default() -> Tftp { Tftp { @@ -140,18 +145,14 @@ impl Tftp { } fn get_tftp_str(&self, buf: &[u8]) -> Option<String> { - let mut iter = buf.iter(); - - let len = match iter.position(|&x| x == 0) { - Some(l) => l, - None => return None, - }; - let val = match String::from_utf8(buf[0..len].to_vec()) { - Ok(v) => v, - Err(_) => return None, - }; - - Some(val) + /* make sure the null-terminator exists */ + buf.iter().find(|&x| *x == 0)?; + + /* build string from buffer */ + String::from_utf8(buf.iter() + .take_while(|&x| *x != 0) + .cloned() + .collect()).ok() } /// Read::read can possibly return less bytes than the requested buffer size, @@ -162,7 +163,7 @@ impl Tftp { /// This function will always fill the buffer completely (like expected from /// read_exact), but also works with EOF, by filling the buffer partially and /// returning the amount of bytes read. - fn read_exact(&self, reader: &mut Read, buf: &mut [u8]) -> Result<usize, io::Error> { + fn read_exact(&self, reader: &mut dyn Read, buf: &mut [u8]) -> Result<usize, io::Error> { let maxlen = buf.len(); let mut outbuf = Vec::with_capacity(maxlen); let mut len = 0; @@ -309,9 +310,24 @@ impl Tftp { } _ => false, }, + "blksize2" => match val.parse() { + Ok(b) if b >= 8 && b <= 32768 => { + /* select 2^x lower or equal the requested size */ + self.options.blksize = blksize2(b); + true + } + _ => false, + }, "timeout" => match val.parse() { Ok(t) if t >= 1 => { - self.options.timeout = t; + self.options.timeout = Duration::from_secs(t); + true + } + _ => false, + }, + "utimeout" => match val.parse() { + Ok(t) if t >= 1 => { + self.options.timeout = Duration::from_micros(t); true } _ => false, @@ -327,7 +343,7 @@ impl Tftp { } }); - sock.set_read_timeout(Some(Duration::from_secs(u64::from(self.options.timeout))))?; + sock.set_read_timeout(Some(self.options.timeout))?; Ok(()) } @@ -356,26 +372,18 @@ impl Tftp { } pub fn parse_file_mode_options(&self, buf: &[u8]) -> Result<(PathBuf, String, HashMap<String, String>), io::Error> { - let dataerr = io::Error::new(io::ErrorKind::InvalidData, "invalid data received"); + let dataerr = || io::Error::new(io::ErrorKind::InvalidData, "invalid data received"); let mut pos = 0; - let filename = match self.get_tftp_str(&buf[pos..]) { - Some(f) => f, - None => return Err(dataerr), - }; + let filename = self.get_tftp_str(&buf[pos..]).ok_or_else(dataerr)?; pos += filename.len() + 1; - let filename = Path::new(&filename); - - let mode = match self.get_tftp_str(&buf[pos..]) { - Some(m) => m.to_lowercase(), - None => return Err(dataerr), - }; + let mode = self.get_tftp_str(&buf[pos..]).ok_or_else(dataerr)?.to_lowercase(); pos += mode.len() + 1; let options = self.parse_options(&buf[pos..]); - Ok((filename.to_path_buf(), mode, options)) + Ok((Path::new(&filename).to_path_buf(), mode, options)) } pub fn send_error(&self, socket: &UdpSocket, code: u16, msg: &str) -> Result<(), io::Error> { @@ -663,4 +671,13 @@ mod tests { assert_eq!(octet_to_netascii(b"\r\0\r\n"), b"\r\0\0\r\0\r\n"); assert_eq!(octet_to_netascii(b""), b""); } + + #[test] + fn test_blksize2() { + assert_eq!(blksize2(16), 16); + assert_eq!(blksize2(17), 16); + assert_eq!(blksize2(15), 8); + assert_eq!(blksize2(1), 1); + assert_eq!(blksize2(0), 0); + } } diff --git a/src/tftpc.rs b/src/tftpc.rs index 24073d2..a134293 100644 --- a/src/tftpc.rs +++ b/src/tftpc.rs @@ -65,35 +65,23 @@ impl Tftpc { fn wait_for_option_ack(&mut self, sock: &UdpSocket) -> Option<SocketAddr> { let mut buf = [0; 512]; - match sock.peek_from(&mut buf) { - Ok(_) => (), - Err(_) => return None, - }; + sock.peek_from(&mut buf).ok()?; let opcode = u16::from_be_bytes([buf[0], buf[1]]); if opcode != rtftp::Opcode::OACK as u16 { return None; } - let (len, remote) = match sock.recv_from(&mut buf) { - Ok(args) => args, - Err(_) => return None, - }; + let (len, remote) = sock.recv_from(&mut buf).ok()?; let mut options = self.tftp.parse_options(&buf[2..len]); - match self.tftp.init_tftp_options(&sock, &mut options) { - Ok(_) => {} - Err(_) => return None, - } + self.tftp.init_tftp_options(&sock, &mut options).ok()?; Some(remote) } fn wait_for_response(&self, sock: &UdpSocket, expected_opcode: rtftp::Opcode, expected_block: u16, expected_remote: Option<SocketAddr>) -> Result<Option<SocketAddr>, std::io::Error> { let mut buf = [0; 4]; - let (len, remote) = match sock.peek_from(&mut buf) { - Ok(args) => args, - Err(err) => return Err(err), - }; + let (len, remote) = sock.peek_from(&mut buf)?; if let Some(rem) = expected_remote { /* verify we got a response from the same client that sent @@ -140,24 +128,14 @@ impl Tftpc { } fn handle_wrq(&mut self, sock: &UdpSocket) -> Result<String, io::Error> { - let mut file = match File::open(self.conf.filename.as_path()) { - Ok(f) => f, - Err(err) => return Err(err), - }; - let err_invalidpath = io::Error::new(io::ErrorKind::InvalidInput, "Invalid path/filename"); - let filename = match self.conf.filename.file_name() { - Some(f) => match f.to_str() { - Some(s) => s, - None => return Err(err_invalidpath), - }, - None => return Err(err_invalidpath), - }; - let metadata = match file.metadata() { - Ok(m) => m, - Err(_) => return Err(err_invalidpath), - }; + let mut file = File::open(self.conf.filename.as_path())?; + let err_invalidpath = || io::Error::new(io::ErrorKind::InvalidInput, "Invalid path/filename"); + + let filename = self.conf.filename.file_name().ok_or_else(err_invalidpath)? + .to_str().ok_or_else(err_invalidpath)?; + let metadata = file.metadata().map_err(|_| err_invalidpath())?; if !metadata.is_file() { - return Err(err_invalidpath); + return Err(err_invalidpath()); } let tsize = self.tftp.transfersize(&mut file)?; @@ -192,19 +170,11 @@ impl Tftpc { } fn handle_rrq(&mut self, sock: &UdpSocket) -> Result<String, io::Error> { - let filename = match self.conf.filename.file_name() { - Some(f) => f, - None => return Err(io::Error::new(io::ErrorKind::InvalidInput, "Invalid path/filename")), - }; + let err_invalidpath = || io::Error::new(io::ErrorKind::InvalidInput, "Invalid path/filename"); + let filename = self.conf.filename.file_name().ok_or_else(err_invalidpath)?; let outpath = env::current_dir().expect("Can't get current directory").join(filename); - let mut file = match File::create(outpath) { - Ok(f) => f, - Err(err) => return Err(err), - }; - let filename = match self.conf.filename.to_str() { - Some(f) => f, - None => return Err(io::Error::new(io::ErrorKind::InvalidInput, "Invalid path/filename")), - }; + let mut file = File::create(outpath)?; + let filename = self.conf.filename.to_str().ok_or_else(err_invalidpath)?; let buf = self.init_req(rtftp::Opcode::RRQ, filename, 0); @@ -256,7 +226,7 @@ impl Tftpc { } } -fn usage(opts: Options, program: String, error: Option<String>) { +fn usage(opts: &Options, program: &str, error: Option<String>) { if let Some(err) = error { println!("{}\n", err); } @@ -264,7 +234,7 @@ fn usage(opts: Options, program: String, error: Option<String>) { println!("{}", opts.usage(format!("RusTFTP {}\n\n{} [options] <remote>[:port]", version, program).as_str())); } -fn parse_commandline(args: &[String]) -> Result<Configuration, &str> { +fn parse_commandline(args: &[String]) -> Option<Configuration> { let program = args[0].clone(); let mut operation = None; let mut mode = rtftp::Mode::OCTET; @@ -277,16 +247,14 @@ fn parse_commandline(args: &[String]) -> Result<Configuration, &str> { opts.optopt("p", "put", "upload file to remote server", "FILE"); opts.optopt("b", "blksize", format!("negotiate a different block size (default: {})", blksize).as_ref(), "SIZE"); opts.optflag("n", "netascii","use netascii mode (instead of octet)"); - let matches = match opts.parse(&args[1..]) { - Ok(m) => m, - Err(err) => { - usage(opts, program, Some(err.to_string())); - return Err("Parsing error"); - } - }; + + let getopts_fail = |err: getopts::Fail| { usage(&opts, &program, Some(err.to_string())) }; + let conv_error = |err: std::num::ParseIntError| { usage(&opts, &program, Some(err.to_string())) }; + + let matches = opts.parse(&args[1..]).map_err(getopts_fail).ok()?; if matches.opt_present("h") || matches.free.len() != 1 { - usage(opts, program, None); - return Err("usage"); + usage(&opts, &program, None); + return None; } if let Some(f) = matches.opt_str("g") { @@ -299,8 +267,8 @@ fn parse_commandline(args: &[String]) -> Result<Configuration, &str> { } if operation.is_none() || (matches.opt_present("g") && matches.opt_present("p")) { - usage(opts, program, Some("Exactly one of g (get) and p (put) required".to_string())); - return Err("get put"); + usage(&opts, &program, Some("Exactly one of g (get) and p (put) required".to_string())); + return None; } if matches.opt_present("n") { @@ -309,25 +277,19 @@ fn parse_commandline(args: &[String]) -> Result<Configuration, &str> { let remote_in = matches.free[0].as_str(); let remote = match remote_in.to_socket_addrs() { - Ok(mut i) => i.next(), + Ok(i) => i, Err(_) => match (remote_in, 69).to_socket_addrs() { - Ok(mut j) => j.next(), + Ok(j) => j, Err(_) => { - usage(opts, program, Some("Failed to parse and lookup specified remote".to_string())); - return Err("lookup"); + usage(&opts, &program, Some("Failed to parse and lookup specified remote".to_string())); + return None; } }, - }; + }.next(); - blksize = match matches.opt_get_default::<usize>("b", blksize) { - Ok(b) => b, - Err(err) => { - usage(opts, program, Some(err.to_string())); - return Err("blksize"); - } - }; + blksize = matches.opt_get_default::<usize>("b", blksize).map_err(conv_error).ok()?; - Ok(Configuration { + Some(Configuration { operation: operation.unwrap(), mode, filename: filename.unwrap(), @@ -339,8 +301,8 @@ fn parse_commandline(args: &[String]) -> Result<Configuration, &str> { fn main() { let args: Vec<String> = env::args().collect(); let conf = match parse_commandline(&args) { - Ok(c) => c, - Err(_) => return, + Some(c) => c, + None => return, }; Tftpc::new(conf).start(); diff --git a/src/tftpd.rs b/src/tftpd.rs index c45f6e1..6513367 100644 --- a/src/tftpd.rs +++ b/src/tftpd.rs @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; extern crate nix; -use nix::unistd::{setresgid, setresuid, Gid, Uid}; +use nix::unistd::{chroot, setresgid, setresuid, Gid, Uid, ROOT}; extern crate getopts; use getopts::Options; @@ -64,24 +64,13 @@ impl Tftpd { fn file_allowed(&self, filename: &Path) -> Option<PathBuf> { /* get parent to check dir where file should be read/written */ - let path = self.conf.dir.join(filename); - let path = match path.parent() { - Some(p) => p, - None => return None, - }; - let path = match path.canonicalize() { - Ok(p) => p, - Err(_) => return None, - }; - - /* get last component to append to canonicalized path */ - let filename = match filename.file_name() { - Some(f) => f, - None => return None, - }; - let path = path.join(filename); + let path = self.conf.dir.join(filename) + .parent()? + .canonicalize() + .ok()?; - match path.strip_prefix(&self.conf.dir) { + /* check last component of given filename appended to canonicalized path */ + match path.join(filename.file_name()?).strip_prefix(&self.conf.dir) { Ok(p) if p != PathBuf::new() => Some(p.to_path_buf()), _ => None, } @@ -192,6 +181,11 @@ impl Tftpd { socket.set_read_timeout(Some(Duration::from_secs(5)))?; socket.connect(cl)?; + if buf.len() < 2 { + self.tftp.send_error(&socket, 0, "Invalid request length")?; + return Err(io::Error::new(io::ErrorKind::Other, "invalid request length")); + } + match u16::from_be_bytes([buf[0], buf[1]]) { // opcode o if o == rtftp::Opcode::RRQ as u16 => { if self.conf.wo { @@ -217,8 +211,8 @@ impl Tftpd { } } - fn drop_privs(&self, uid: u32, gid: u32) -> Result<(), Box<Error>> { - let root_uid = Uid::from_raw(0); + fn drop_privs(&self, uid: u32, gid: u32) -> Result<(), Box<dyn Error>> { + let root_uid = ROOT; let root_gid = Gid::from_raw(0); let unpriv_uid = Uid::from_raw(uid); let unpriv_gid = Gid::from_raw(gid); @@ -243,6 +237,22 @@ impl Tftpd { Ok(()) } + fn chroot_destdir(&mut self) -> Result<(), nix::Error> { + /* chroot will only succeed if we have required permissions; + either running as root or having CAP_SYS_CHROOT. + propagate error only if chroot should have succeeded. */ + match chroot(&self.conf.dir) { + Ok(_) => { + /* configured dir is now new root directory */ + self.conf.dir = PathBuf::from("/"); + Ok(()) + }, + Err(err) if err == nix::Error::from_errno(nix::errno::Errno::EPERM) => Ok(()), + Err(err) if Uid::effective() == ROOT => Err(err), + Err(_) => Ok(()), + } + } + pub fn start(&mut self) { let socket = match UdpSocket::bind(format!("[::]:{}", self.conf.port)) { Ok(s) => s, @@ -251,6 +261,13 @@ impl Tftpd { return; } }; + match self.chroot_destdir() { + Ok(_) => {}, + Err(err) => { + eprintln!("Changing root directory failed ({}).", err); + return; + } + } match self.drop_privs(self.conf.uid, self.conf.gid) { Ok(_) => (), Err(err) => { @@ -262,7 +279,7 @@ impl Tftpd { match env::set_current_dir(&self.conf.dir) { Ok(_) => (), Err(err) => { - eprintln!("Changing directory to {} failed ({}).", &self.conf.dir.display(), err); + eprintln!("Changing directory failed ({}).", err); return; } } @@ -289,90 +306,57 @@ impl Tftpd { } } -fn usage(opts: Options, program: String, error: Option<String>) { +fn usage(opts: &Options, program: &str, error: Option<String>) { if let Some(err) = error { println!("{}\n", err); } let version = rtftp::VERSION.unwrap_or(""); - println!("{}", opts.usage(format!("RusTFTP {}\n\n{} [options]", version, program).as_str())); + println!("{}", opts.usage(format!("RusTFTP {}\n\n{} [options] [directory]", version, program).as_str())); } -fn parse_commandline(args: &[String]) -> Result<Configuration, &str> { +fn parse_commandline(args: &[String]) -> Option<Configuration> { let program = args[0].clone(); let mut conf: Configuration = Default::default(); let mut opts = Options::new(); opts.optflag("h", "help", "display usage information"); - opts.optopt("d", "directory", "directory to serve (default: current directory)", "DIRECTORY"); opts.optopt("p", "port", format!("port to listen on (default: {})", conf.port).as_ref(), "PORT"); opts.optopt("u", "uid", format!("user id to run as (default: {})", conf.uid).as_ref(), "UID"); opts.optopt("g", "gid", format!("group id to run as (default: {})", conf.gid).as_ref(), "GID"); opts.optflag("r", "read-only", "allow only reading/downloading of files (RRQ)"); opts.optflag("w", "write-only", "allow only writing/uploading of files (WRQ)"); opts.optopt("t", "threads", format!("number of worker threads (default: {})", conf.threads).as_ref(), "N"); - let matches = match opts.parse(&args[1..]) { - Ok(m) => m, - Err(err) => { - usage(opts, program, Some(err.to_string())); - return Err("Parsing error"); - } - }; + + let getopts_fail = |err: getopts::Fail| { usage(&opts, &program, Some(err.to_string())) }; + let conv_error = |err: std::num::ParseIntError| { usage(&opts, &program, Some(err.to_string())) }; + + let matches = opts.parse(&args[1..]).map_err(getopts_fail).ok()?; if matches.opt_present("h") { - usage(opts, program, None); - return Err("usage"); + usage(&opts, &program, None); + return None; } - conf.port = match matches.opt_get_default("p", conf.port) { - Ok(p) => p, - Err(err) => { - usage(opts, program, Some(err.to_string())); - return Err("port"); - } - }; - conf.uid = match matches.opt_get_default("u", conf.uid) { - Ok(u) => u, - Err(err) => { - usage(opts, program, Some(err.to_string())); - return Err("uid"); - } - }; - conf.gid = match matches.opt_get_default("g", conf.gid) { - Ok(g) => g, - Err(err) => { - usage(opts, program, Some(err.to_string())); - return Err("gid"); - } - }; - conf.threads = match matches.opt_get_default("t", conf.threads) { - Ok(t) => t, - Err(err) => { - usage(opts, program, Some(err.to_string())); - return Err("threads"); - } - }; + conf.port = matches.opt_get_default("p", conf.port).map_err(conv_error).ok()?; + conf.uid = matches.opt_get_default("u", conf.uid).map_err(conv_error).ok()?; + conf.gid = matches.opt_get_default("g", conf.gid).map_err(conv_error).ok()?; + conf.threads = matches.opt_get_default("t", conf.threads).map_err(conv_error).ok()?; conf.ro = matches.opt_present("r"); conf.wo = matches.opt_present("w"); if conf.ro && conf.wo { - usage(opts, program, Some(String::from("Only one of r (read-only) and w (write-only) allowed"))); - return Err("ro and wo"); + usage(&opts, &program, Some(String::from("Only one of r (read-only) and w (write-only) allowed"))); + return None; } - if matches.opt_present("d") { - conf.dir = match matches.opt_str("d") { - Some(d) => Path::new(&d).to_path_buf(), - None => { - usage(opts, program, None); - return Err("directory"); - } - }; + if matches.free.len() > 0 { + conf.dir = Path::new(&matches.free[0]).to_path_buf(); } - Ok(conf) + Some(conf) } fn main() { let args: Vec<String> = env::args().collect(); let conf = match parse_commandline(&args) { - Ok(c) => c, - Err(_) => return, + Some(c) => c, + None => return, }; Tftpd::new(conf).start(); @@ -52,7 +52,7 @@ tftpc() { } rtftpd() { - $SSD --background --exec "$RTFTPD" --start -- -p $PORT -d "$SERVERDIR" 1>/dev/null + $SSD --background --exec "$RTFTPD" --start -- -p $PORT "$SERVERDIR" 1>/dev/null } rtftpc() { |
