From 7988d6adcb8e1532ec6fe8f9024083d3446e90e5 Mon Sep 17 00:00:00 2001 From: Aram Peres Date: Tue, 17 Nov 2020 23:03:10 -0500 Subject: [PATCH] Initial commit --- .github/workflows/ci.yml | 104 +++++++++++++++++++++++++++ .gitignore | 4 ++ Cargo.toml | 17 +++++ LICENSE | 21 ++++++ README.md | 64 +++++++++++++++++ examples/blocking.rs | 34 +++++++++ src/blocking/mod.rs | 134 +++++++++++++++++++++++++++++++++++ src/cmd.rs | 147 +++++++++++++++++++++++++++++++++++++++ src/config.rs | 102 +++++++++++++++++++++++++++ src/error.rs | 57 +++++++++++++++ src/lib.rs | 8 +++ 11 files changed, 692 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 examples/blocking.rs create mode 100644 src/blocking/mod.rs create mode 100644 src/cmd.rs create mode 100644 src/config.rs create mode 100644 src/error.rs create mode 100644 src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2ba3a20 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,104 @@ +on: + pull_request: + push: + branches: master + +name: CI + +jobs: + check: + name: Check + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v2 + + - name: Install stable toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + + - name: Run cargo check + uses: actions-rs/cargo@v1 + with: + command: check + args: --all-targets + + cross-compile: + name: Cross Compile + runs-on: ubuntu-latest + strategy: + matrix: + target: + - aarch64-unknown-linux-gnu + - arm-unknown-linux-gnueabihf + - armv7-unknown-linux-gnueabihf + steps: + - name: Checkout sources + uses: actions/checkout@v2 + + - name: Install gcc for armhf + run: sudo apt-get update && sudo apt-get install gcc-arm-linux-gnueabihf gcc-aarch64-linux-gnu + + - name: Install stable toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + target: ${{ matrix.target }} + + - name: Run cargo build + uses: actions-rs/cargo@v1 + with: + command: build + args: --target ${{ matrix.target }} --no-default-features --features "rustls-tls" + + test: + name: Test Suite + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v2 + + - name: Install stable toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + + - name: Run cargo test + uses: actions-rs/cargo@v1 + with: + command: test + args: --features env-file + + lints: + name: Lints + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v2 + + - name: Install stable toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + components: rustfmt, clippy + + - name: Run cargo fmt + uses: actions-rs/cargo@v1 + with: + command: fmt + args: --all -- --check + + - name: Run cargo clippy + uses: actions-rs/cargo@v1 + with: + command: clippy + args: -- -D warnings diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4d8139 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +Cargo.lock +/.idea + diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f1d24ed --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "nut-client" +version = "0.0.1" +authors = ["Aram Peres "] +edition = "2018" +description = "Network UPS Tools (NUT) client library" +categories = ["network-programming"] +keywords = ["ups", "nut"] +repository = "https://github.com/aramperes/nut-client-rs" +documentation = "https://docs.rs/nut-client" +readme = "README.md" +license = "MIT" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +shell-words = "1.0.0" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5ee4a0c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Aram Peres + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..65320be --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# nut-client + +[![crates.io](https://img.shields.io/crates/v/nut-client.svg)](https://crates.io/crates/nut-client) +[![Documentation](https://docs.rs/nut-client/badge.svg)](https://docs.rs/nut-client) +[![MIT licensed](https://img.shields.io/crates/l/nut-client.svg)](./LICENSE) +[![CI](https://github.com/aramperes/nut-client-rs/workflows/CI/badge.svg)](https://github.com/aramperes/nut-client-rs/actions?query=workflow%3ACI) + +A [Network UPS Tools](https://github.com/networkupstools/nut) (NUT) client library for Rust. + +- Connect to `upsd`/`nut-server` using TCP +- Login with with username and password +- List UPS devices + +## ⚠️ Safety Goggles Required ⚠️ + +Do not use this library with critical UPS devices. This library is in early development and I cannot +guarantee that it won't mess up your UPS configurations, and potentially cause catastrophic failure to your hardware. + +Be careful and stay safe! + +## Example + +Check out the `examples` directory for more advanced examples. + +```rust +use std::env; +use std::net::ToSocketAddrs; + +use nut_client::{Auth, ConfigBuilder, Host}; +use nut_client::blocking::Connection; + +fn main() -> nut_client::Result<()> { + // The TCP host:port for upsd/nut-server + let addr = env::var("NUT_ADDR") + .unwrap_or_else(|_| "localhost:3493".into()) + .to_socket_addrs() + .unwrap() + .next() + .unwrap(); + + // Username and password (optional) + let username = env::var("NUT_USER").ok(); + let password = env::var("NUT_PASSWORD").ok(); + let auth = username.map(|username| Auth::new(username, password)); + + // Build the config + let config = ConfigBuilder::new() + .with_host(Host::Tcp(addr)) + .with_auth(auth) + .build(); + + // Open a connection and login + let mut conn = Connection::new(config)?; + + // Print a list of all UPS devices + println!("Connected UPS devices:"); + for (id, description) in conn.list_ups()? { + println!("\t- ID: {}", id); + println!("\t Description: {}", description); + } + + Ok(()) +} +``` diff --git a/examples/blocking.rs b/examples/blocking.rs new file mode 100644 index 0000000..a96b944 --- /dev/null +++ b/examples/blocking.rs @@ -0,0 +1,34 @@ +use std::env; +use std::net::ToSocketAddrs; + +use nut_client::{Auth, ConfigBuilder, Host}; +use nut_client::blocking::Connection; + +fn main() -> nut_client::Result<()> { + let addr = env::var("NUT_ADDR") + .unwrap_or_else(|_| "localhost:3493".into()) + .to_socket_addrs() + .unwrap() + .next() + .unwrap(); + + let username = env::var("NUT_USER").ok(); + let password = env::var("NUT_PASSWORD").ok(); + let auth = username.map(|username| Auth::new(username, password)); + + let config = ConfigBuilder::new() + .with_host(Host::Tcp(addr)) + .with_auth(auth) + .build(); + + let mut conn = Connection::new(config)?; + + // Print a list of all UPS devices + println!("Connected UPS devices:"); + for (id, description) in conn.list_ups()? { + println!("\t- ID: {}", id); + println!("\t Description: {}", description); + } + + Ok(()) +} diff --git a/src/blocking/mod.rs b/src/blocking/mod.rs new file mode 100644 index 0000000..882df8c --- /dev/null +++ b/src/blocking/mod.rs @@ -0,0 +1,134 @@ +use std::io; +use std::io::{BufRead, BufReader, Write}; +use std::net::{SocketAddr, TcpStream}; + +use crate::cmd::{Command, Response}; +use crate::{ClientError, Config, Host, NutError}; + +/// A blocking NUT client connection. +pub enum Connection { + Tcp(TcpConnection), +} + +impl Connection { + pub fn new(config: Config) -> crate::Result { + match &config.host { + Host::Tcp(socket_addr) => { + Ok(Self::Tcp(TcpConnection::new(config.clone(), socket_addr)?)) + } + } + } + + pub fn list_ups(&mut self) -> crate::Result> { + match self { + Self::Tcp(conn) => conn.list_ups(), + } + } +} + +/// A blocking TCP NUT client connection. +#[derive(Debug)] +pub struct TcpConnection { + config: Config, + tcp_stream: TcpStream, +} + +impl TcpConnection { + fn new(config: Config, socket_addr: &SocketAddr) -> crate::Result { + // Create the TCP connection + let tcp_stream = TcpStream::connect_timeout(socket_addr, config.timeout)?; + let mut connection = Self { config, tcp_stream }; + + // Attempt login using `config.auth` + connection.login()?; + + Ok(connection) + } + + fn login(&mut self) -> crate::Result<()> { + if let Some(auth) = &self.config.auth { + // Pass username and check for 'OK' + Self::write_cmd(&mut self.tcp_stream, Command::SetUsername(&auth.username))?; + Self::read_response(&mut self.tcp_stream)?.expect_ok()?; + + // Pass password and check for 'OK' + if let Some(password) = &auth.password { + Self::write_cmd(&mut self.tcp_stream, Command::SetPassword(&password))?; + Self::read_response(&mut self.tcp_stream)?.expect_ok()?; + } + } + Ok(()) + } + + fn list_ups(&mut self) -> crate::Result> { + Self::write_cmd(&mut self.tcp_stream, Command::List(&["UPS"]))?; + let list = Self::read_list(&mut self.tcp_stream, &["UPS"])?; + + Ok(list + .into_iter() + .map(|mut row| (row.remove(0), row.remove(0))) + .collect()) + } + + fn write_cmd(stream: &mut TcpStream, line: Command) -> crate::Result<()> { + let line = format!("{}\n", line); + stream.write_all(line.as_bytes())?; + stream.flush()?; + Ok(()) + } + + fn parse_line(reader: &mut BufReader<&mut TcpStream>) -> crate::Result> { + let mut raw = String::new(); + reader.read_line(&mut raw)?; + raw = raw[..raw.len() - 1].to_string(); // Strip off \n + + // Parse args by splitting whitespace, minding quotes for args with multiple words + let args = shell_words::split(&raw) + .map_err(|e| NutError::Generic(format!("Parsing server response failed: {}", e)))?; + + Ok(args) + } + + fn read_response(stream: &mut TcpStream) -> crate::Result { + let mut reader = io::BufReader::new(stream); + let args = Self::parse_line(&mut reader)?; + Response::from_args(args) + } + + fn read_list(stream: &mut TcpStream, query: &[&str]) -> crate::Result>> { + let mut reader = io::BufReader::new(stream); + let args = Self::parse_line(&mut reader)?; + + Response::from_args(args)?.expect_begin_list(query)?; + let mut lines: Vec> = Vec::new(); + + loop { + let mut args = Self::parse_line(&mut reader)?; + let resp = Response::from_args(args.clone()); + + if let Ok(resp) = resp { + resp.expect_end_list(query)?; + break; + } else { + let err = resp.unwrap_err(); + if let ClientError::Nut(err) = err { + if let NutError::UnknownResponseType(_) = err { + // Likely an item entry, let's check... + if args.len() < query.len() || &args[0..query.len()] != query { + return Err(ClientError::Nut(err)); + } else { + let args = args.drain(query.len()..).collect(); + lines.push(args); + continue; + } + } else { + return Err(ClientError::Nut(err)); + } + } else { + return Err(err); + } + } + } + Ok(lines) + } +} diff --git a/src/cmd.rs b/src/cmd.rs new file mode 100644 index 0000000..4329d24 --- /dev/null +++ b/src/cmd.rs @@ -0,0 +1,147 @@ +use core::fmt; + +use crate::NutError; + +#[derive(Debug, Clone)] +pub enum Command<'a> { + /// Passes the login username. + SetUsername(&'a str), + /// Passes the login password. + SetPassword(&'a str), + /// Queries for a list. Allows for any number of arguments, which forms a single query. + List(&'a [&'a str]), +} + +impl<'a> Command<'a> { + /// The network identifier of the command. + pub fn name(&self) -> &'static str { + match self { + Self::SetUsername(_) => "USERNAME", + Self::SetPassword(_) => "PASSWORD", + Self::List(_) => "LIST", + } + } + + /// The arguments of the command to serialize. + pub fn args(&self) -> Vec<&str> { + match self { + Self::SetUsername(username) => vec![username], + Self::SetPassword(password) => vec![password], + Self::List(query) => query.to_vec(), + } + } +} + +impl<'a> fmt::Display for Command<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut args = self.args(); + args.insert(0, self.name()); + write!(f, "{}", shell_words::join(args)) + } +} + +#[derive(Debug, Clone)] +pub enum Response { + /// A successful response. + Ok, + /// Marks the beginning of a list response. + BeginList(String), + /// Marks the end of a list response. + EndList(String), +} + +impl Response { + pub fn from_args(mut args: Vec) -> crate::Result { + if args.is_empty() { + return Err( + NutError::Generic("Parsing server response failed: empty line".into()).into(), + ); + } + let cmd_name = args.remove(0); + match cmd_name.as_str() { + "OK" => Ok(Self::Ok), + "ERR" => { + if args.is_empty() { + Err(NutError::Generic("Unspecified server error".into()).into()) + } else { + let err_type = args.remove(0); + match err_type.as_str() { + "ACCESS-DENIED" => Err(NutError::AccessDenied.into()), + _ => Err(NutError::Generic(format!( + "Server error: {} {}", + err_type, + args.join(" ") + )) + .into()), + } + } + } + "BEGIN" => { + if args.is_empty() { + Err(NutError::Generic("Unspecified BEGIN type".into()).into()) + } else { + let begin_type = args.remove(0); + if &begin_type != "LIST" { + Err( + NutError::Generic(format!("Unexpected BEGIN type: {}", begin_type)) + .into(), + ) + } else { + let args = shell_words::join(args); + Ok(Response::BeginList(args)) + } + } + } + "END" => { + if args.is_empty() { + Err(NutError::Generic("Unspecified END type".into()).into()) + } else { + let begin_type = args.remove(0); + if &begin_type != "LIST" { + Err( + NutError::Generic(format!("Unexpected END type: {}", begin_type)) + .into(), + ) + } else { + let args = shell_words::join(args); + Ok(Response::EndList(args)) + } + } + } + _ => Err(NutError::UnknownResponseType(cmd_name).into()), + } + } + + pub fn expect_ok(&self) -> crate::Result<&Response> { + match self { + Self::Ok => Ok(self), + _ => Err(NutError::UnexpectedResponse.into()), + } + } + + pub fn expect_begin_list(self, expected_args: &[&str]) -> crate::Result { + let expected_args = shell_words::join(expected_args); + if let Self::BeginList(args) = &self { + if &expected_args == args { + Ok(self) + } else { + Err(NutError::UnexpectedResponse.into()) + } + } else { + Err(NutError::UnexpectedResponse.into()) + } + } + + pub fn expect_end_list(self, expected_args: &[&str]) -> crate::Result { + let expected_args = shell_words::join(expected_args); + if let Self::EndList(args) = &self { + if &expected_args == args { + Ok(self) + } else { + Err(NutError::UnexpectedResponse.into()) + } + } else { + Err(NutError::UnexpectedResponse.into()) + } + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..e18fc7b --- /dev/null +++ b/src/config.rs @@ -0,0 +1,102 @@ +use core::fmt; +use std::net::{SocketAddr, ToSocketAddrs}; +use std::time::Duration; + +/// A host specification. +#[derive(Clone, Debug)] +pub enum Host { + /// A TCP hostname and port. + Tcp(SocketAddr), + // TODO: Support Unix socket streams. +} + +impl Default for Host { + fn default() -> Self { + let addr = (String::from("localhost"), 3493) + .to_socket_addrs() + .expect("Failed to create local UPS socket address. This is a bug.") + .next() + .expect("Failed to create local UPS socket address. This is a bug."); + Self::Tcp(addr) + } +} + +/// An authentication mechanism. +#[derive(Clone)] +pub struct Auth { + /// The username of the user to login as. + pub(crate) username: String, + /// Optional password assigned to the remote user. + pub(crate) password: Option, +} + +impl Auth { + pub fn new(username: String, password: Option) -> Self { + Auth { username, password } + } +} + +impl fmt::Debug for Auth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Auth") + .field("username", &self.username) + .field("password", &self.password.as_ref().map(|_| "(redacted)")) + .finish() + } +} + +/// Configuration for connecting to a remote NUT server. +#[derive(Clone, Debug)] +pub struct Config { + pub(crate) host: Host, + pub(crate) auth: Option, + pub(crate) timeout: Duration, +} + +impl Config { + pub fn new(host: Host, auth: Option, timeout: Duration) -> Self { + Config { + host, + auth, + timeout, + } + } +} + +/// A builder for [`Config`]. +#[derive(Clone, Debug, Default)] +pub struct ConfigBuilder { + host: Option, + auth: Option, + timeout: Option, +} + +impl ConfigBuilder { + /// Initializes an empty builder for [`Config`]. + pub fn new() -> Self { + ConfigBuilder::default() + } + + pub fn with_host(mut self, host: Host) -> Self { + self.host = Some(host); + self + } + + pub fn with_auth(mut self, auth: Option) -> Self { + self.auth = auth; + self + } + + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + pub fn build(self) -> Config { + Config::new( + self.host.unwrap_or_default(), + self.auth, + self.timeout.unwrap_or_else(|| Duration::from_secs(5)), + ) + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..53da74f --- /dev/null +++ b/src/error.rs @@ -0,0 +1,57 @@ +use core::fmt; +use std::io; + +/// A NUT-native error. +#[derive(Debug)] +pub enum NutError { + /// Occurs when the username/password combination is rejected. + AccessDenied, + UnexpectedResponse, + UnknownResponseType(String), + /// Generic (usually internal) client error. + Generic(String), +} + +impl fmt::Display for NutError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::AccessDenied => write!(f, "Authentication failed"), + Self::UnexpectedResponse => write!(f, "Unexpected server response"), + Self::UnknownResponseType(ty) => write!(f, "Unknown response type: {}", ty), + Self::Generic(msg) => write!(f, "Internal client error: {}", msg), + } + } +} + +impl std::error::Error for NutError {} + +#[derive(Debug)] +pub enum ClientError { + Io(io::Error), + Nut(NutError), +} + +impl fmt::Display for ClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(err) => err.fmt(f), + Self::Nut(err) => err.fmt(f), + } + } +} + +impl std::error::Error for ClientError {} + +impl From for ClientError { + fn from(err: io::Error) -> Self { + ClientError::Io(err) + } +} + +impl From for ClientError { + fn from(err: NutError) -> Self { + ClientError::Nut(err) + } +} + +pub type Result = std::result::Result; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..34ec24d --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,8 @@ +pub use config::*; +pub use error::*; + +pub mod blocking; + +mod cmd; +mod config; +mod error;