From 112ab8fbf18e3bea312da5ac997b95e46def160d Mon Sep 17 00:00:00 2001 From: TQ Hirsch Date: Sat, 18 Nov 2023 17:06:10 +0100 Subject: [PATCH] Much progress --- Cargo.toml | 11 +++ devenv.lock | 39 ++++++++ devenv.nix | 15 +-- devenv.yaml | 5 + doc/lua_api.md | 102 ++++++++++++++++++++ src/handler.rs | 215 +++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 114 ++++++++++++++++++++++- vendor/async-tftp-rs | 2 +- 8 files changed, 493 insertions(+), 10 deletions(-) create mode 100644 doc/lua_api.md create mode 100644 src/handler.rs diff --git a/Cargo.toml b/Cargo.toml index 879fd81..f33c91e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,3 +6,14 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +async-lock = "3.1.0" +async-tftp = { path = "vendor/async-tftp-rs" } +futures = "0.3.29" +anyhow = "1" +fern = "0.6.2" +structopt = "0.3" +tokio = { version = "1.32.0", features = ["rt-multi-thread", "macros", "rt"] } +mlua = { version = "0.9.1", features = ["luau-jit", "vendored", "async", "send"] } +reqwest = { version = "0.11.22", features = ["stream"] } +listenfd = "1.0.1" +libc = "0.2.150" \ No newline at end of file diff --git a/devenv.lock b/devenv.lock index 31052e4..94903f2 100644 --- a/devenv.lock +++ b/devenv.lock @@ -17,6 +17,27 @@ "type": "github" } }, + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1700115779, + "narHash": "sha256-oajhxEBg+16/KH74CaygAQ6b5KUHS7DwBoL9ecD9qeI=", + "owner": "nix-community", + "repo": "fenix", + "rev": "4378e7e5f5bdef438eee5ce967f37593b9b5cd16", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, "flake-compat": { "flake": false, "locked": { @@ -131,10 +152,28 @@ "root": { "inputs": { "devenv": "devenv", + "fenix": "fenix", "nixpkgs": "nixpkgs", "pre-commit-hooks": "pre-commit-hooks" } }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1700077026, + "narHash": "sha256-Vf7ykubXsriSjBbeYAm8bzBIvSOYVUmRiCQ3iLL/E+U=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "58de0b130a763f3a2d373f508ac0c18a8e7d0acd", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, "systems": { "locked": { "lastModified": 1681028828, diff --git a/devenv.nix b/devenv.nix index 858da90..b750cf6 100644 --- a/devenv.nix +++ b/devenv.nix @@ -2,24 +2,25 @@ { # https://devenv.sh/basics/ - env.GREET = "devenv"; + #env.GREET = "devenv"; # https://devenv.sh/packages/ - packages = [ pkgs.git ]; + packages = [ pkgs.git pkgs.openssl ]; # https://devenv.sh/scripts/ - scripts.hello.exec = "echo hello from $GREET"; + #scripts.hello.exec = "echo hello from $GREET"; - enterShell = '' - hello - git --version - ''; + #enterShell = '' + # hello + # git --version + #''; # https://devenv.sh/languages/ # languages.nix.enable = true; languages.c.enable = true; languages.rust.enable = true; + languages.rust.channel = "stable"; # https://devenv.sh/pre-commit-hooks/ # pre-commit.hooks.shellcheck.enable = true; diff --git a/devenv.yaml b/devenv.yaml index c7cb5ce..7784d37 100644 --- a/devenv.yaml +++ b/devenv.yaml @@ -1,3 +1,8 @@ inputs: nixpkgs: url: github:NixOS/nixpkgs/nixpkgs-unstable + fenix: + url: github:nix-community/fenix + inputs: + nixpkgs: + follows: nixpkgs diff --git a/doc/lua_api.md b/doc/lua_api.md new file mode 100644 index 0000000..f8d94c6 --- /dev/null +++ b/doc/lua_api.md @@ -0,0 +1,102 @@ +# Overview + +The lua script must have the following form: + +```lua +-- Do whatever setup is necessary for the handler. + +local function handle(path, client, size) + -- Decide what to do with the request. The following + -- behaves like a traditional TFTP server. + return resource.FILE(path) +end + +return handle() +``` + +For simple scripts, an alternative form is also possible: + +```lua +path, client, size = table.unpack(arg) +-- For a script this short, the following line is optional. A path +-- consisting of a single null byte is used to determine whether +-- the script is the "simple" form, and thus you can exit early. +if path == "\0" then return resource.ERR.Unknown end +return resource.FILE(path) +``` + +The latter form is shorter, but prevents any pre-calculation or initialization steps, +so generally the function approach is preferred. + +# Arguments + +Whether a function or a bare script is used, the following arguments are given: + +* `path`: The path that is requested. +* `client`: Information about the client connection. This is a table containing the following fields: + * `address`: The client address, as an IpAddress object. + * `for_write`: true if this is a write request +* `size`: nil for reads, the declared object size (if any) for writes + +# Available functions + +## Resource returns + +* `resource.FILE(path)`: Reads or writes the given path in the filesystem. This + is interpreted relative to the configured root directory, and does not allow + access outside of that directory +* `resource.HTTP(url)`: Fetch a given HTTP URL. Reads act as an HTTP GET request, + whereas writes POST the body to the server. While this may seem to be a security + risk, generally the ability to speak TFTP implies the ability to send arbitrary + network traffic on a nearby network segment. +* `resource.DATA(content)`: Simply sends the given string as the file content. For + write requests, equivalent to `resource.ERROR.FileAlreadyExists` +* `resource.ERROR(message)`: Returns a free-form error message +* `resource.ERROR.`: Returns one of the protocol-specified error messages. + `` may be one of the following: + * `Unknown` + * `FileNotFound` + * `PermissionDenied` + * `DiskFull` + * `IllegalOperation` + * `FileAlreadyExists` + * `NoSuchUser` + +## Network requests + +* `http.GET(url)`: Fetches the content of the given URL as a string. This will + throw an error if more than 1MiB is returned; if possible, use `resource.HTTP`. +* `redis.CMD(command, args...)`: Performs a redis request. Returns the datastructure + returned by Redis, without interpretation. On error, returns `nil, err`. If no Redis + server is configured, this will always throw an error. + +# Data types + +## `stftpd.Cidr` + +Contains a CIDR network (i.e., a network address and a netmask). + +Can be constructed using either `stftpd.Cidr(address, prefix)` or `stftpd.Cidr("address/prefix")`. + +Available fields are: +* `.addr`: The network address as a `stftpd.IpAddr` +* `.prefix`: The network prefix, as a number of bits. + +Available methods include: +* `:contains(address)`: Returns true if the address is contained in this network. + Note that IPv4 address will be matched against an IPv6 network as if they were mapped. (i.e., `::ffff:0/96` is equivalent to `0.0.0.0/0`) + A similar process is used to match an IPv4 network against an IPv6 address. + +## `stftpd.IpAddr` +Contains an IP address (whether v4 or v6). This can be constructed using, e.g. +`stftpd.IpAddr("1.2.3.4")`. Further, a string containing an IP address can be used +anywhere an IpAddr is expected. + +It can be converted to text via `tostring`, or to bytes using `.bytes`. Further, +individual bytes of the address can be accessed or modified using indexing notation. + +You can test the version using either `.version`, which returns either 4 or 6, or +`.is_v4` and `.is_v6`. If you need the IPv6-mapped version of a v4 address, (i.e., +`::ffff:0.0.0.0`), this is available through `.to_v6` + +This is probably most useful in conjunction with `stftpd.Cidr` \ No newline at end of file diff --git a/src/handler.rs b/src/handler.rs new file mode 100644 index 0000000..f56592c --- /dev/null +++ b/src/handler.rs @@ -0,0 +1,215 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use async_lock::Mutex; +use async_tftp::async_trait; +use async_tftp::packet::Error; +use async_tftp::server::handlers::{DirHandler, DirHandlerMode}; +use futures::{AsyncRead, AsyncWrite, TryStreamExt}; +use mlua::{FromLua, Lua, UserData, UserDataFields, Value}; + +#[derive(Clone)] +pub struct Handler { + lua: Arc>, + call_key: Arc, + dir_handler: Arc, + http: reqwest::Client, +} + +#[derive(Debug)] +pub enum Resource { + Http(String), // Parameter is URL + File(String), // Parameter is content + Data(Vec), + Error(Error) +} + +impl Resource { +} + +impl UserData for Resource { + +} + +impl<'lua> FromLua<'lua> for Resource { + fn from_lua(value: Value<'lua>, _lua: &'lua Lua) -> mlua::Result { + value.as_userdata().ok_or(mlua::Error::UserDataTypeMismatch).and_then(|value| value.take()) + } +} + +#[derive(Clone, Debug)] +struct Client { + address: SocketAddr, + for_write: bool, +} + +impl UserData for Client { + fn add_fields<'lua, F: UserDataFields<'lua, Self>>(fields: &mut F) { + fields.add_field_method_get("mode", |_lua, client| Ok(if client.for_write { "w" } else { "r" })); + fields.add_field_method_get("address", |lua, client| lua.create_any_userdata(client.address.ip())); + } +} + +impl Handler { + pub fn new(srv_path: impl AsRef) -> Result { + let lua = mlua::Lua::new(); + + let http = reqwest::Client::builder() + .user_agent(concat!("sftpd/", env!("CARGO_PKG_VERSION"))) + .build()?; + + lua.register_userdata_type::(|registry| { + registry.add_field_method_get("version", |_, ip| Ok(if ip.is_ipv4() { 4} else {6})); + })?; + + { + // prepare resource types... + let resources = lua.create_table()?; + resources.set("HTTP", lua.create_function(|_lua, url: String| Ok(Resource::Http(url)))?)?; + resources.set("FILE", lua.create_function(|_lua, path: String| Ok(Resource::File(path)))?)?; + resources.set("DATA", lua.create_function(|_lua, url: mlua::String| Ok(Resource::Data(url.as_bytes().to_vec())))?)?; + + let err_tbl = lua.create_table()?; + let err_mtbl = lua.create_table()?; + err_tbl.set_metatable(Some(err_mtbl.clone())); + + let err_fn = lua.create_function(|_, msg: String| Ok(Resource::Error(Error::Msg(msg))))?; + err_mtbl.set("__call", err_fn.clone())?; + err_tbl.set("FileNotFound", Resource::Error(Error::FileNotFound))?; + err_tbl.set("Unknown", Resource::Error(Error::UnknownError))?; + err_tbl.set("PermissionDenied", Resource::Error(Error::PermissionDenied))?; + err_tbl.set("DiskFull", Resource::Error(Error::DiskFull))?; + err_tbl.set("IllegalOperation", Resource::Error(Error::IllegalOperation))?; + err_tbl.set("FileAlreadyExists", Resource::Error(Error::FileAlreadyExists))?; + err_tbl.set("NoSuchUser", Resource::Error(Error::NoSuchUser))?; + err_tbl.set("Message", err_fn)?; + + resources.set("ERROR", err_tbl)?; + lua.globals().set("resource", resources)?; + lua.globals().set("state", lua.create_table()?)?; + + // + + } + let handler_fn = lua.create_registry_value(0)?; + + Ok(Self { + lua: Arc::new(Mutex::new(lua)), + call_key: Arc::new(handler_fn), + http, + dir_handler: Arc::new(DirHandler::new(srv_path, DirHandlerMode::ReadWrite)?), + }) + } + + pub fn load_script(&mut self, data: PathBuf) -> impl Future> { + let lua = self.lua.clone(); + let key = self.call_key.clone(); + async move { + let lua = lua.lock_arc().await; + let chunk = lua.load(data); + let script_fn = chunk.into_function()?; + + // Prepare a fake client to determine whether the script should just be run in full for each request + // or if it returns a function + let client = Client { + address: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), + for_write: false, + }; + + let result: mlua::Value = script_fn.call_async(("", client, None as Option)).await?; + if result.is_function() { + lua.replace_registry_value(&*key, result)? + } else if Resource::from_lua(result, &*lua).is_ok() { + // We must run the script, it seems + lua.replace_registry_value(&*key, script_fn)? + } + + Ok(()) + } + } + + async fn call_handler_int(lua: Arc>, key: Arc, path: String, client: Client, size: Option) -> Result { + lua.lock().await + .registry_value::(&*key).map_err(|_| Error::UnknownError)? + .call_async((path, client, size)).await + .map_err(|_| Error::UnknownError) + + } + + async fn call_handler(&mut self, path: String, client: Client, size: Option) -> Result { + + let handle = tokio::task::spawn_local(Self::call_handler_int(self.lua.clone(), self.call_key.clone(), path, client, size)); + handle.await.map_err(|err| Error::Msg(err.to_string())) + .and_then(|x| x) + } +} + +#[async_trait] +impl async_tftp::server::Handler for Handler { + type Reader = Box; + type Writer = Box; + + async fn read_req_open(&mut self, client: &SocketAddr, path: &Path) -> Result<(Self::Reader, Option), Error> { + let lua_client = Client { + address: client.clone(), + for_write: false, + }; + let resource: Resource = self.call_handler(path.to_str().ok_or(Error::FileNotFound)?.to_owned(), lua_client, None).await?; + + match resource { + Resource::Http(url) => { + // TODO: Add headers describing client + let req = self.http.get(url).send().await.map_err(|err| Error::Msg(err.to_string()))?; + let size = req.content_length(); + let stream = req.bytes_stream() + .map_err(|e| futures::io::Error::new(futures::io::ErrorKind::Other, e)) + .into_async_read(); + + Ok((Box::new(stream), size)) + } + Resource::File(path) => { + let (rdr, size) = (*self.dir_handler).clone().read_req_open(client, Path::new(path.as_str())).await?; + Ok((Box::new(rdr), size)) + } + Resource::Data(data) => { + let len = data.len() as u64; + Ok(( + Box::new(futures::io::Cursor::new(data)), + Some(len), + )) + } + Resource::Error(err) => { return Err(err) } + } + } + + async fn write_req_open(&mut self, _client: &SocketAddr, _path: &Path, _size: Option) -> Result { + todo!(); + + #[cfg(ignore)] + { + let lua_client = Client { + address: client.clone(), + for_write: true, + }; + + let resource: Resource = { + let mut lua = self.lua.lock_arc().await; + let lc = lua_client.clone().into_lua(&*lua).map_err(|_| Error::UnknownError)?; + let handle_fn: mlua::Function = lua.registry_value(&self.call_key).map_err(|_| Error::UnknownError)?; + + let result = handle_fn.call_async((path.to_str().ok_or(Error::FileNotFound)?, lc, size)).await.map_err(|_| Error::UnknownError)?; + + // let lc = lc.as_userdata().and_then(|ud| ud.take().ok()).unwrap_or(lua_client); + result + }; + + match resource { + Resource::Http(_) => { todo!() } + Resource::File(_) => { todo!() } + Resource::Data(_) => { todo!() } + Resource::Error(_) => { todo!() } + } + } + } +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..3354ac6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,113 @@ -fn main() { - println!("Hello, world!"); +use std::ffi::c_void; +use std::net; +use std::net::{Ipv4Addr, Ipv6Addr}; +use std::path::{Path, PathBuf}; +use anyhow::anyhow; +use structopt::StructOpt; + +mod handler; + +#[derive(StructOpt, Debug)] +struct Options { + #[structopt(short="s", long="script", env="STFTPD_SCRIPT")] + /// The lua script to determine how to handle requests + script: PathBuf, + /// Systemd socket activated mode. Can also be used for inetd activation + #[structopt(long)] + systemd: bool, + /// The address and port to listen on + #[structopt(short="l", env="STFTPD_LISTEN", default_value=":69")] + listen: String, + #[structopt(short="u")] + /// User to drop privileges to + user: Option, + #[structopt(short="g")] + /// User to drop privileges to + group: Option, + #[structopt(short="d")] + /// Directory to serve files from + serve: Option, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let opts = Options::from_args(); + + + let mut handler = handler::Handler::new( + opts.serve.as_ref() + .map(PathBuf::as_path) + .unwrap_or_else(|| Path::new("")) + )?; + let load_fut = tokio::task::spawn_local(handler.load_script(opts.script.clone())); + load_fut.await??; + + + let sock = if opts.systemd { + let mut lfds = listenfd::ListenFd::from_env(); + if lfds.len() > 0 { + lfds.take_udp_socket(0)?.ok_or(anyhow!("Failed to receive socket from systemd"))? + } else { + // inetd activation + let sock_fd = 0; + let sock = unsafe { + // validate the socket + let mut sockaddr : libc::sockaddr = std::mem::zeroed(); + let mut sockaddr_sz = std::mem::size_of_val(&sockaddr) as libc::socklen_t; + let mut ty : libc::c_int = 0; + let mut ty_sz = std::mem::size_of_val(&ty) as libc::socklen_t; + let ret = libc::getsockname(sock_fd, &mut sockaddr, &mut sockaddr_sz); + if ret != 0 { + return Err(std::io::Error::last_os_error().into()) + } + let ret = libc::getsockopt( + sock_fd, + libc::SOL_SOCKET, + libc::SO_TYPE, + &mut ty as *mut libc::c_int as *mut c_void, + &mut ty_sz + ); + if ret != 0 { + return Err(std::io::Error::last_os_error().into()) + } + if sockaddr.sa_family as libc::c_int != libc::AF_INET && sockaddr.sa_family as libc::c_int != libc::AF_INET6 || ty != libc::SOCK_DGRAM { + return Err(anyhow!("Can only listen on inet or inet6 UDP sockets")) + } + let owned = std::os::fd::BorrowedFd::borrow_raw(sock_fd).try_clone_to_owned()?; + // Putz around with stdin and stdout so that we don't accidentally write to the socket. + let mut pipe_fds = [0 as libc::c_int; 2]; + if libc::pipe(&mut pipe_fds[0]) < 0 { + return Err(std::io::Error::last_os_error().into()) + } + if libc::close(pipe_fds[1]) < 0 { + return Err(std::io::Error::last_os_error().into()) + } + if libc::dup2(pipe_fds[0], 0) < 0 || libc::dup2(2, 1) < 0 { + return Err(std::io::Error::last_os_error().into()) + } + net::UdpSocket::from(owned) + }; + sock + } + } else { + let (host, port) = opts.listen.split_once(':').ok_or(anyhow!("Invalid listen address"))?; + let port = u16::from_str_radix(port, 10).map_err(|_| anyhow!("Invalid listen address"))?; + if host.is_empty() { + net::UdpSocket::bind((Ipv6Addr::UNSPECIFIED, port)).or_else( + |_| net::UdpSocket::bind((Ipv4Addr::UNSPECIFIED, port)) + )? + } else { + net::UdpSocket::bind((host, port))? + } + + }; + + let server = async_tftp::server::TftpServerBuilder::with_handler(handler.clone()) + .std_socket(sock)? + .build().await?; + + + server.serve().await?; + println!("{opts:#?}"); + Ok(()) } diff --git a/vendor/async-tftp-rs b/vendor/async-tftp-rs index 128f482..f023d62 160000 --- a/vendor/async-tftp-rs +++ b/vendor/async-tftp-rs @@ -1 +1 @@ -Subproject commit 128f48226673c39cf7484bb20d0daf71b777c55e +Subproject commit f023d6246298be11cf7987bc798b92f39469a163