Initial commit

This commit is contained in:
Aram 🍐 2020-11-17 23:03:10 -05:00
commit 7988d6adcb
11 changed files with 692 additions and 0 deletions

104
.github/workflows/ci.yml vendored Normal file
View file

@ -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

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/target
Cargo.lock
/.idea

17
Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "nut-client"
version = "0.0.1"
authors = ["Aram Peres <aram.peres@wavy.fm>"]
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"

21
LICENSE Normal file
View file

@ -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.

64
README.md Normal file
View file

@ -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(())
}
```

34
examples/blocking.rs Normal file
View file

@ -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(())
}

134
src/blocking/mod.rs Normal file
View file

@ -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<Self> {
match &config.host {
Host::Tcp(socket_addr) => {
Ok(Self::Tcp(TcpConnection::new(config.clone(), socket_addr)?))
}
}
}
pub fn list_ups(&mut self) -> crate::Result<Vec<(String, String)>> {
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<Self> {
// 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<Vec<(String, String)>> {
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<Vec<String>> {
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<Response> {
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<Vec<Vec<String>>> {
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<String>> = 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)
}
}

147
src/cmd.rs Normal file
View file

@ -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<String>) -> crate::Result<Response> {
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<Response> {
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<Response> {
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())
}
}
}

102
src/config.rs Normal file
View file

@ -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<String>,
}
impl Auth {
pub fn new(username: String, password: Option<String>) -> 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<Auth>,
pub(crate) timeout: Duration,
}
impl Config {
pub fn new(host: Host, auth: Option<Auth>, timeout: Duration) -> Self {
Config {
host,
auth,
timeout,
}
}
}
/// A builder for [`Config`].
#[derive(Clone, Debug, Default)]
pub struct ConfigBuilder {
host: Option<Host>,
auth: Option<Auth>,
timeout: Option<Duration>,
}
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<Auth>) -> 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)),
)
}
}

57
src/error.rs Normal file
View file

@ -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<io::Error> for ClientError {
fn from(err: io::Error) -> Self {
ClientError::Io(err)
}
}
impl From<NutError> for ClientError {
fn from(err: NutError) -> Self {
ClientError::Nut(err)
}
}
pub type Result<T> = std::result::Result<T, ClientError>;

8
src/lib.rs Normal file
View file

@ -0,0 +1,8 @@
pub use config::*;
pub use error::*;
pub mod blocking;
mod cmd;
mod config;
mod error;