diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index a82c01b..92e000b 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -47,9 +47,10 @@ jobs: flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Build - run: cargo build --verbose + run: cargo build --verbose --examples env: RUSTFLAGS: -Awarnings + CARGO_TARGET_DIR: .pytest_cache/d/target # HiSLIP isn't supported by pyvisa-py yet - name: Test with pytest diff --git a/.gitignore b/.gitignore index 21b6580..5d6e257 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,8 @@ __pycache__/ .pytest_cache/ # Coverage -lcov.info \ No newline at end of file +lcov.info + +# Certificates +/.certificates +ssl.log diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..10efcb2 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Debug", + "program": "${workspaceFolder}/", + "args": [], + "cwd": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 34ebecd..8d3fbab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,5 @@ [workspace] -members = [ - "device", - "hislip", - "raw", - "telnet", - "vxi11" -] +members = ["device", "hislip", "raw", "telnet", "vxi11", "lxi-common"] [workspace.package] version = "0.1.0" @@ -15,12 +9,13 @@ edition = "2021" [workspace.dependencies] # Common dependencies -async-std = {version = "1.11", features = ["attributes"]} +async-std = { version = "1.11", features = ["attributes"] } async-listen = "0.2.1" -futures = {version = "0.3" } +futures = { version = "0.3" } log = { version = "0.4.17" } byteorder = { version = "1.4" } +async-rustls = { version = "0.2" } # Dev dependencies femme = "2.2" -clap = { version = "4.0", features = ["derive"] } \ No newline at end of file +clap = { version = "4.0", features = ["derive"] } diff --git a/README.md b/README.md index 5efc426..059b096 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,35 @@ # lxi-rs This crate aims to simplify implementation of the [LXI Device Specification](https://www.lxistandard.org/Specifications/Specifications.aspx). -The specifications consists of a [core specification](https://www.lxistandard.org/members/Adopted%20Specifications/Latest%20Version%20of%20Standards_/LXI%20Standard%201.5%20Specifications/LXI%20Device%20Specification%20v1_5_01.pdf) and a optional set of extended functions. +The specifications consists of a [core specification](https://www.lxistandard.org/members/Adopted%20Specifications/Latest%20Version%20of%20Standards_/LXI%20Version%201.6/LXI_Device_Specification_1.6_2022-06-09.pdf) and a optional set of extended functions. -Currently the focus is on implementing HiSLIP/VXI-11/Socket protocols for Unix-like environments. A long-term goal is to support an async no-std environment like [](https://github.com/embassy-rs/embassy) +Currently the focus is on implementing HiSLIP/VXI-11/Socket protocols for Unix-like environments. A long-term goal is to support an async no-std environment like [embassy](https://github.com/embassy-rs/embassy) or [smol-tcp](). # Relevant standards: -* [IVI-6.1 High-Speed LAN Instrument Protocol (HiSLIP) v2.0](https://www.ivifoundation.org/specifications/) -* [VXI-11 REVISION v1.0](https://www.vxibus.org/specifications.html) -* [LXI Device specification v1.5](https://www.lxistandard.org/members/Adopted%20Specifications/Latest%20Version%20of%20Standards_/LXI%20Standard%201.5%20Specifications/LXI%20Device%20Specification%20v1_5_01.pdf) +* [LXI Device specification v1.6](https://www.lxistandard.org/members/Adopted%20Specifications/Latest%20Version%20of%20Standards_/LXI%20Version%201.6/LXI_Device_Specification_1.6_2022-06-09.pdf) # Scope This crate does not handle command parsing and/or execution, look at [scpi-rs](https://github.com/Atmelfan/scpi-rs)(:crab:) or [libscpi](https://github.com/j123b567/scpi-parser)(C) for that. +# Architecture +* [device](./device/) Common abstractions for the core device. Crate is `no-std` compatible [but do require alloc (TODO)](https://github.com/Atmelfan/lxi-rs/issues/3). +* [raw](./raw/) Server for Scpi-raw and Scpi-TLS protocols (`TCPIP::hostname::port::SOCKET`). +* [telnet](./telnet/) Server for Telnet protocol, mostly useful for interactive debugging. +* [hislip](./hislip/) HiSLIP v2.0 server, more modern VXI-11 replacement. See [IVI-6.1 High-Speed LAN Instrument Protocol (HiSLIP) v2.0](https://www.ivifoundation.org/specifications/). +* [vxi-11](./vxi11/) VXI-11 server. See [VXI-11 REVISION v1.0](https://www.vxibus.org/specifications.html). + + +# Certificates +Secure extensions and https server requires a certificate and key. + +The simplest method is to use [`mkcert`](https://github.com/FiloSottile/mkcert) to generate one in `.certificates` directory: + +```mkcert -key-file .certificates/key.pem -cert-file .certificates/cert.pem localhost 127.0.0.1 ::1``` + # Examples Each protocol includes an example service, you can try them out with `cargo run --example ` where protocol is either `hislip`,`vxi11`,`raw`, or `telnet`. + Run `cargo run --example -- --help` for help and specific arguments for each protocol. # Testing @@ -30,6 +44,6 @@ This crate uses two types of tests, the cargo test framework and pytest. Cargo t 2. Run `./coverage --open` # Licensing -Lxi-rs is available under GPLv3 License, see [LICENSE-GPL](./LICENSE-GPL). +Lxi-rs is available under dual GPLv3 and commercial license, see [LICENSE-GPL](./LICENSE-GPL) and `TBD`. Core crates like [lxi-device](device) are licensed under MIT and APACHE version 2. diff --git a/conftest.py b/conftest.py index 2a7b412..3c1ad3b 100644 --- a/conftest.py +++ b/conftest.py @@ -1,3 +1,5 @@ +import os +import subprocess import pytest from pyvisa import ResourceManager import socket diff --git a/coverage.sh b/coverage.sh old mode 100755 new mode 100644 diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..b4abc8b --- /dev/null +++ b/deny.toml @@ -0,0 +1,270 @@ +# This template contains all of the possible sections and their default values + +# Note that all fields that take a lint level have these possible values: +# * deny - An error will be produced and the check will fail +# * warn - A warning will be produced, but the check will not fail +# * allow - No warning or error will be produced, though in some cases a note +# will be + +# The values provided in this template are the default values that will be used +# when any section or field is not specified in your own configuration + +# Root options + +# If 1 or more target triples (and optionally, target_features) are specified, +# only the specified targets will be checked when running `cargo deny check`. +# This means, if a particular package is only ever used as a target specific +# dependency, such as, for example, the `nix` crate only being used via the +# `target_family = "unix"` configuration, that only having windows targets in +# this list would mean the nix crate, as well as any of its exclusive +# dependencies not shared by any other crates, would be ignored, as the target +# list here is effectively saying which targets you are building for. +targets = [ + # The triple can be any string, but only the target triples built in to + # rustc (as of 1.40) can be checked against actual config expressions + #{ triple = "x86_64-unknown-linux-musl" }, + # You can also specify which target_features you promise are enabled for a + # particular target. target_features are currently not validated against + # the actual valid features supported by the target architecture. + #{ triple = "wasm32-unknown-unknown", features = ["atomics"] }, +] +# When creating the dependency graph used as the source of truth when checks are +# executed, this field can be used to prune crates from the graph, removing them +# from the view of cargo-deny. This is an extremely heavy hammer, as if a crate +# is pruned from the graph, all of its dependencies will also be pruned unless +# they are connected to another crate in the graph that hasn't been pruned, +# so it should be used with care. The identifiers are [Package ID Specifications] +# (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html) +#exclude = [] +# If true, metadata will be collected with `--all-features`. Note that this can't +# be toggled off if true, if you want to conditionally enable `--all-features` it +# is recommended to pass `--all-features` on the cmd line instead +all-features = false +# If true, metadata will be collected with `--no-default-features`. The same +# caveat with `all-features` applies +no-default-features = false +# If set, these feature will be enabled when collecting metadata. If `--features` +# is specified on the cmd line they will take precedence over this option. +#features = [] +# When outputting inclusion graphs in diagnostics that include features, this +# option can be used to specify the depth at which feature edges will be added. +# This option is included since the graphs can be quite large and the addition +# of features from the crate(s) to all of the graph roots can be far too verbose. +# This option can be overridden via `--feature-depth` on the cmd line +feature-depth = 1 + +# This section is considered when running `cargo deny check advisories` +# More documentation for the advisories section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html +[advisories] +# The path where the advisory database is cloned/fetched into +db-path = "~/.cargo/advisory-db" +# The url(s) of the advisory databases to use +db-urls = ["https://github.com/rustsec/advisory-db"] +# The lint level for security vulnerabilities +vulnerability = "deny" +# The lint level for unmaintained crates +unmaintained = "warn" +# The lint level for crates that have been yanked from their source registry +yanked = "warn" +# The lint level for crates with security notices. Note that as of +# 2019-12-17 there are no security notice advisories in +# https://github.com/rustsec/advisory-db +notice = "warn" +# A list of advisory IDs to ignore. Note that ignored advisories will still +# output a note when they are encountered. +ignore = [ + #"RUSTSEC-0000-0000", +] +# Threshold for security vulnerabilities, any vulnerability with a CVSS score +# lower than the range specified will be ignored. Note that ignored advisories +# will still output a note when they are encountered. +# * None - CVSS Score 0.0 +# * Low - CVSS Score 0.1 - 3.9 +# * Medium - CVSS Score 4.0 - 6.9 +# * High - CVSS Score 7.0 - 8.9 +# * Critical - CVSS Score 9.0 - 10.0 +#severity-threshold = + +# If this is true, then cargo deny will use the git executable to fetch advisory database. +# If this is false, then it uses a built-in git library. +# Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support. +# See Git Authentication for more information about setting up git authentication. +#git-fetch-with-cli = true + +# This section is considered when running `cargo deny check licenses` +# More documentation for the licenses section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html +[licenses] +# The lint level for crates which do not have a detectable license +unlicensed = "deny" +# List of explicitly allowed licenses +# See https://spdx.org/licenses/ for list of possible licenses +# [possible values: any SPDX 3.11 short identifier (+ optional exception)]. +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-DFS-2016", + "OpenSSL", +] +# List of explicitly disallowed licenses +# See https://spdx.org/licenses/ for list of possible licenses +# [possible values: any SPDX 3.11 short identifier (+ optional exception)]. +deny = [ + #"Nokia", +] +# Lint level for licenses considered copyleft +copyleft = "warn" +# Blanket approval or denial for OSI-approved or FSF Free/Libre licenses +# * both - The license will be approved if it is both OSI-approved *AND* FSF +# * either - The license will be approved if it is either OSI-approved *OR* FSF +# * osi-only - The license will be approved if is OSI-approved *AND NOT* FSF +# * fsf-only - The license will be approved if is FSF *AND NOT* OSI-approved +# * neither - This predicate is ignored and the default lint level is used +allow-osi-fsf-free = "neither" +# Lint level used when no other predicates are matched +# 1. License isn't in the allow or deny lists +# 2. License isn't copyleft +# 3. License isn't OSI/FSF, or allow-osi-fsf-free = "neither" +default = "deny" +# The confidence threshold for detecting a license from license text. +# The higher the value, the more closely the license text must be to the +# canonical license text of a valid SPDX license file. +# [possible values: any between 0.0 and 1.0]. +confidence-threshold = 0.8 +# Allow 1 or more licenses on a per-crate basis, so that particular licenses +# aren't accepted for every possible crate as with the normal allow list +exceptions = [ + # Each entry is the crate and version constraint, and its specific allow + # list + #{ allow = ["Zlib"], name = "adler32", version = "*" }, +] + +# Some crates don't have (easily) machine readable licensing information, +# adding a clarification entry for it allows you to manually specify the +# licensing information +[[licenses.clarify]] +name = "ring" +version = "*" +expression = "MIT AND ISC AND OpenSSL" +# One or more files in the crate's source used as the "source of truth" for +# the license expression. If the contents match, the clarification will be used +# when running the license check, otherwise the clarification will be ignored +# and the crate will be checked normally, which may produce warnings or errors +# depending on the rest of your configuration +license-files = [ + { path = "LICENSE", hash = 0xbd0eed23 } +] + +[licenses.private] +# If true, ignores workspace crates that aren't published, or are only +# published to private registries. +# To see how to mark a crate as unpublished (to the official registry), +# visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field. +ignore = false +# One or more private registries that you might publish crates to, if a crate +# is only published to private registries, and ignore is true, the crate will +# not have its license(s) checked +registries = [ + #"https://sekretz.com/registry +] + +# This section is considered when running `cargo deny check bans`. +# More documentation about the 'bans' section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html +[bans] +# Lint level for when multiple versions of the same crate are detected +multiple-versions = "warn" +# Lint level for when a crate version requirement is `*` +wildcards = "allow" +# The graph highlighting used when creating dotgraphs for crates +# with multiple versions +# * lowest-version - The path to the lowest versioned duplicate is highlighted +# * simplest-path - The path to the version with the fewest edges is highlighted +# * all - Both lowest-version and simplest-path are used +highlight = "all" +# The default lint level for `default` features for crates that are members of +# the workspace that is being checked. This can be overriden by allowing/denying +# `default` on a crate-by-crate basis if desired. +workspace-default-features = "allow" +# The default lint level for `default` features for external crates that are not +# members of the workspace. This can be overriden by allowing/denying `default` +# on a crate-by-crate basis if desired. +external-default-features = "allow" +# List of crates that are allowed. Use with care! +allow = [ + #{ name = "ansi_term", version = "=0.11.0" }, +] +# List of crates to deny +deny = [ + # Each entry the name of a crate and a version range. If version is + # not specified, all versions will be matched. + #{ name = "ansi_term", version = "=0.11.0" }, + # + # Wrapper crates can optionally be specified to allow the crate when it + # is a direct dependency of the otherwise banned crate + #{ name = "ansi_term", version = "=0.11.0", wrappers = [] }, +] + +# List of features to allow/deny +# Each entry the name of a crate and a version range. If version is +# not specified, all versions will be matched. +#[[bans.features]] +#name = "reqwest" +# Features to not allow +#deny = ["json"] +# Features to allow +#allow = [ +# "rustls", +# "__rustls", +# "__tls", +# "hyper-rustls", +# "rustls", +# "rustls-pemfile", +# "rustls-tls-webpki-roots", +# "tokio-rustls", +# "webpki-roots", +#] +# If true, the allowed features must exactly match the enabled feature set. If +# this is set there is no point setting `deny` +#exact = true + +# Certain crates/versions that will be skipped when doing duplicate detection. +skip = [ + #{ name = "ansi_term", version = "=0.11.0" }, +] +# Similarly to `skip` allows you to skip certain crates during duplicate +# detection. Unlike skip, it also includes the entire tree of transitive +# dependencies starting at the specified crate, up to a certain depth, which is +# by default infinite. +skip-tree = [ + #{ name = "ansi_term", version = "=0.11.0", depth = 20 }, +] + +# This section is considered when running `cargo deny check sources`. +# More documentation about the 'sources' section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html +[sources] +# Lint level for what to happen when a crate from a crate registry that is not +# in the allow list is encountered +unknown-registry = "warn" +# Lint level for what to happen when a crate from a git repository that is not +# in the allow list is encountered +unknown-git = "warn" +# List of URLs for allowed crate registries. Defaults to the crates.io index +# if not specified. If it is specified but empty, no registries are allowed. +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +# List of URLs for allowed Git repositories +allow-git = [] + +[sources.allow-org] +# 1 or more github.com organizations to allow git sources for +#github = [""] +# 1 or more gitlab.com organizations to allow git sources for +#gitlab = [""] +# 1 or more bitbucket.org organizations to allow git sources for +#bitbucket = [""] diff --git a/device/Cargo.toml b/device/Cargo.toml index 74b713d..b970618 100644 --- a/device/Cargo.toml +++ b/device/Cargo.toml @@ -22,4 +22,5 @@ clap = { workspace = true } [features] default = [] std = [] -experimental = [] \ No newline at end of file +experimental = [] +security = ["std"] \ No newline at end of file diff --git a/hislip/Cargo.toml b/hislip/Cargo.toml index 67c7379..b0de5cf 100644 --- a/hislip/Cargo.toml +++ b/hislip/Cargo.toml @@ -15,11 +15,16 @@ futures = { workspace = true } byteorder = { workspace = true } log = { workspace = true, features = ["kv_unstable_std"] } bitfield = "0.14" +async-rustls = { workspace = true, optional = true } +cfg-if = "1.0.0" [dependencies.lxi-device] path = "../device" version = "0.1.0" [dev-dependencies] -femme = { workspace = true } -clap = { workspace = true } \ No newline at end of file +femme = { workspace = true } +clap = { workspace = true } + +[features] +secure-capability = ["dep:async-rustls"] diff --git a/hislip/README.md b/hislip/README.md index 3264a94..f1f2761 100644 --- a/hislip/README.md +++ b/hislip/README.md @@ -4,6 +4,12 @@ * Currently only supports overlapped mode * Asynchronous commands cannot be aborted +# Testing +On windows + +```TARGET=localhost CREDENTIALS=MyCred pytest -s``` + # License This crate is licensed under GPLv3 or later. See ([LICENSE-GPL](../LICENSE-GPL) or https://opensource.org/licenses/GPL-3.0) + diff --git a/hislip/examples/hislip.rs b/hislip/examples/hislip.rs index da9ef8d..60af67b 100644 --- a/hislip/examples/hislip.rs +++ b/hislip/examples/hislip.rs @@ -1,4 +1,4 @@ -use std::{sync::Arc, time::Duration}; +use std::{fs::File, io::BufReader, sync::Arc, time::Duration}; use async_std::{ io::{self, timeout}, @@ -12,7 +12,7 @@ use lxi_device::{ Device, }; use lxi_hislip::{ - server::{ServerBuilder, ServerConfig}, + server::{config::ServerConfig, ServerBuilder}, STANDARD_PORT, }; @@ -32,6 +32,20 @@ struct Args { /// Kill server after timeout (useful for coverage testing) #[clap(short, long)] timeout: Option, + + /// TLS certificate + #[cfg(feature = "secure-capability")] + #[clap(short, long, default_value = ".certificates/cert.pem")] + cert: String, + + /// TLS key + #[cfg(feature = "secure-capability")] + #[clap(short, long, default_value = ".certificates/key.pem")] + key: String, + + #[cfg(feature = "secure-capability")] + #[clap(long)] + client_cert: Vec, } struct DummySpawner; @@ -45,6 +59,20 @@ impl Spawn for DummySpawner { } } +#[cfg(feature = "secure-capability")] +fn load_certs(path: &str) -> io::Result> { + async_rustls::rustls::internal::pemfile::certs(&mut BufReader::new(File::open(path)?)) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid cert")) +} + +#[cfg(feature = "secure-capability")] +fn load_keys(path: &str) -> io::Result> { + async_rustls::rustls::internal::pemfile::pkcs8_private_keys(&mut BufReader::new(File::open( + path, + )?)) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid key")) +} + #[async_std::main] async fn main() -> Result<(), io::Error> { femme::with_level(log::LevelFilter::Debug); @@ -69,13 +97,28 @@ async fn main() -> Result<(), io::Error> { let shared_lock1 = SharedLock::new(); let device1: Arc>> = Arc::new(Mutex::new(Box::new(EchoDevice))); - let config = ServerConfig::default() - .vendor_id(0x1234) - .short_idn(b"Vendor,Model,Serial,Version"); + let config = ServerConfig::default().vendor_id(0x1234); let server = ServerBuilder::new(config) .device("hislip0".to_string(), device0, shared_lock0) - .device("hislip1".to_string(), device1, shared_lock1) - .build(); + .device("hislip1".to_string(), device1, shared_lock1); + + cfg_if::cfg_if! { + if #[cfg(feature = "secure-capability")] { + let certs = load_certs(&args.cert)?; + let mut keys = load_keys(&args.key)?; + + let mut config = + async_rustls::rustls::ServerConfig::new(async_rustls::rustls::NoClientAuth::new()); + config + .set_single_cert(certs, keys.remove(0)) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; + config.key_log = Arc::new(async_rustls::rustls::KeyLogFile::new()); + let server = server.build(Arc::new(config)); + + } else { + let server = server.build(); + } + }; log::info!("Running server on port {}:{}...", args.ip, args.port); if let Some(t) = args.timeout { diff --git a/hislip/src/common/descriptors.rs b/hislip/src/common/descriptors.rs new file mode 100644 index 0000000..0c963a5 --- /dev/null +++ b/hislip/src/common/descriptors.rs @@ -0,0 +1,74 @@ +use std::io; +use byteorder::{WriteBytesExt, ReadBytesExt}; + +pub enum Descriptor { + SupportedTlsVersions(Vec), + TlsInformation(Vec), + TlsLastError(Vec), + Reserved(u8, Vec), + VendorSpecific(u8, Vec), +} + +impl Descriptor { + pub fn read_descriptor(reader: &mut R) -> io::Result { + let len = reader.read_u16::()?; + let typ = reader.read_u8()?; + match typ { + 0 => { + let mut buf = Vec::with_capacity(len as usize); + for _ in 0..len { + buf.push(reader.read_u16::()?) + } + Ok(Self::SupportedTlsVersions(buf)) + }, + 1 => { + let mut buf = Vec::with_capacity(len as usize); + reader.read_exact(&mut buf)?; + Ok(Self::TlsInformation(buf)) + }, + 2 => { + let mut buf = Vec::with_capacity(len as usize); + reader.read_exact(&mut buf)?; + Ok(Self::TlsLastError(buf)) + }, + 3..=127 => { + let mut buf = Vec::with_capacity(len as usize); + reader.read_exact(&mut buf)?; + Ok(Self::Reserved(typ, buf)) + } + 128..=255 => { + let mut buf = Vec::with_capacity(len as usize); + reader.read_exact(&mut buf)?; + Ok(Self::VendorSpecific(typ, buf)) + } + } + } + + pub fn write_descriptor(&self, writer: &mut W) -> io::Result<()> { + match self { + Descriptor::SupportedTlsVersions(versions) => { + writer.write_u16::((versions.len()*2) as u16)?; + writer.write_u8(0)?; + for v in versions { + writer.write_u16::(*v)?; + } + } + Descriptor::TlsInformation(info) => { + writer.write_u16::(info.len() as u16)?; + writer.write_u8(1)?; + writer.write(info)?; + } + Descriptor::TlsLastError(err) => { + writer.write_u16::(err.len() as u16)?; + writer.write_u8(2)?; + writer.write(err)?; + } + Descriptor::Reserved(t, dat) | Descriptor::VendorSpecific(t, dat) => { + writer.write_u16::(dat.len() as u16)?; + writer.write_u8(t.clone())?; + writer.write(dat)?; + } + } + Ok(()) + } +} diff --git a/hislip/src/common/messages.rs b/hislip/src/common/messages.rs index 960c427..8240255 100644 --- a/hislip/src/common/messages.rs +++ b/hislip/src/common/messages.rs @@ -461,3 +461,23 @@ pub(crate) enum ReleaseLockControl { SuccessShared = 2, Error = 3, } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TlsStatus { + Busy = 0, + Success = 1, + Error = 3, +} + +impl TryFrom for TlsStatus { + type Error = NonFatalErrorCode; + + fn try_from(value: u8) -> Result>::Error> { + match value { + 0 => Ok(Self::Busy), + 1 => Ok(Self::Success), + 3 => Ok(Self::Error), + _ => Err(NonFatalErrorCode::UnrecognizedControlCode) + } + } +} \ No newline at end of file diff --git a/hislip/src/common/mod.rs b/hislip/src/common/mod.rs index a206521..491b152 100644 --- a/hislip/src/common/mod.rs +++ b/hislip/src/common/mod.rs @@ -2,6 +2,8 @@ use bitfield::bitfield; pub mod errors; pub mod messages; +pub mod descriptors; +pub(crate) mod stream; /// Protocol version 1.0 pub const PROTOCOL_1_0: Protocol = Protocol(0x0100); diff --git a/hislip/src/common/stream.rs b/hislip/src/common/stream.rs new file mode 100644 index 0000000..eebadb3 --- /dev/null +++ b/hislip/src/common/stream.rs @@ -0,0 +1,118 @@ +use std::pin::Pin; + +use futures::io::{AsyncRead, AsyncWrite}; + +pub(crate) enum HislipStream { + Insecure(IO), + #[cfg(feature = "secure-capability")] + Secure(async_rustls::server::TlsStream), +} + +impl HislipStream { + pub(crate) fn new(io: IO) -> Self { + Self::Insecure(io) + } + + pub(crate) fn is_secure(&self) -> bool { + cfg_if::cfg_if!{ + if #[cfg(feature = "secure-capability")] { + matches!(self, Self::Secure(..)) + } else { + false + } + } + } +} + +impl HislipStream +where + IO: AsyncRead + AsyncWrite + Unpin, +{ + #[cfg(feature = "secure-capability")] + pub(crate) async fn start_tls( + self, + acceptor: &mut async_rustls::TlsAcceptor, + ) -> Result { + match self { + HislipStream::Insecure(io) => { + match acceptor.accept(io).into_failable().await { + // Success + Ok(tls) => Ok(Self::Secure(tls)), + // Failed to switch to TLS + Err((err, io)) => Err((err, Self::Insecure(io))), + } + }, + HislipStream::Secure(_) => Err((std::io::ErrorKind::Other.into(), self)), + } + } + + #[cfg(feature = "secure-capability")] + pub(crate) async fn end_tls(self) -> Result { + match self { + HislipStream::Insecure(_) => Err((std::io::ErrorKind::Other.into(), self)), + HislipStream::Secure(mut _tls) => { + let (_io, _session) = _tls.get_mut(); + todo!("Implement end_tls when async-rustls is updated") + } + } + } +} + +impl AsyncRead for HislipStream +where + IO: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_read( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut [u8], + ) -> std::task::Poll> { + match self.get_mut() { + HislipStream::Insecure(io) => Pin::new(io).poll_read(cx, buf), + #[cfg(feature = "secure-capability")] + HislipStream::Secure(tls) => Pin::new(tls).poll_read(cx, buf), + } + } +} + +impl AsyncWrite for HislipStream +where + IO: AsyncRead + AsyncWrite + Unpin, +{ + #[inline] + fn poll_write( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + match self.get_mut() { + HislipStream::Insecure(io) => Pin::new(io).poll_write(cx, buf), + #[cfg(feature = "secure-capability")] + HislipStream::Secure(tls) => Pin::new(tls).poll_write(cx, buf), + } + } + + #[inline] + fn poll_flush( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match self.get_mut() { + HislipStream::Insecure(io) => Pin::new(io).poll_flush(cx), + #[cfg(feature = "secure-capability")] + HislipStream::Secure(tls) => Pin::new(tls).poll_flush(cx), + } + } + + #[inline] + fn poll_close( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match self.get_mut() { + HislipStream::Insecure(io) => Pin::new(io).poll_close(cx), + #[cfg(feature = "secure-capability")] + HislipStream::Secure(tls) => Pin::new(tls).poll_close(cx), + } + } +} diff --git a/hislip/src/server/config.rs b/hislip/src/server/config.rs new file mode 100644 index 0000000..7a17453 --- /dev/null +++ b/hislip/src/server/config.rs @@ -0,0 +1,76 @@ +#[derive(Clone)] +pub struct ServerConfig { + pub vendor_id: u16, + /// Maximum server message size + pub max_message_size: u64, + /// Prefer overlapped data + pub prefer_overlap: bool, + /// Maximum allowed number of sessions + pub max_num_sessions: usize, + /// Force use of encryption and do do not allow clients to end encryption + #[cfg(feature="secure-capability")] + pub encryption_mandatory: bool, + /// Clients must encrypt/authenticate after initializing the session + #[cfg(feature="secure-capability")] + pub initial_encryption: bool, +} + +impl ServerConfig { + pub fn vendor_id(mut self, vendor_id: u16) -> Self { + self.vendor_id = vendor_id; + self + } + + pub fn max_message_size(mut self, max_message_size: u64) -> Self { + self.max_message_size = max_message_size; + self + } + + pub fn max_num_sessions(mut self, max_num_sessions: usize) -> Self { + self.max_num_sessions = max_num_sessions; + self + } + + pub fn prefer_overlap(mut self) -> Self { + self.prefer_overlap = true; + self + } + + pub fn prefer_synchronized(mut self) -> Self { + self.prefer_overlap = false; + self + } + + #[cfg(feature="secure-capability")] + pub fn encryption_mandatory(mut self, encryption_mandatory: bool) -> Self { + self.encryption_mandatory = encryption_mandatory; + self + } + + #[cfg(feature="secure-capability")] + pub fn initial_encryption(mut self, initial_encryption: bool) -> Self { + self.initial_encryption = initial_encryption; + self + } + + #[cfg(feature="secure-capability")] + pub fn is_secure(&self) -> bool { + return self.encryption_mandatory && self.initial_encryption; + } + +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + vendor_id: 0xBEEF, + max_message_size: 1024 * 1024, + prefer_overlap: true, + max_num_sessions: 64, + #[cfg(feature="secure-capability")] + encryption_mandatory: false, + #[cfg(feature="secure-capability")] + initial_encryption: false, + } + } +} diff --git a/hislip/src/server/mod.rs b/hislip/src/server/mod.rs index e86db47..b0c29b2 100644 --- a/hislip/src/server/mod.rs +++ b/hislip/src/server/mod.rs @@ -2,7 +2,6 @@ use std::cmp::min; use std::collections::HashMap; use std::io; use std::str::from_utf8; -use std::sync::Weak; use async_std::net::{TcpListener, ToSocketAddrs}; use async_std::sync::Arc; @@ -15,69 +14,16 @@ use lxi_device::Device; use crate::common::errors::{Error, FatalErrorCode, NonFatalErrorCode}; use crate::common::messages::{prelude::*, send_fatal, send_nonfatal}; +use crate::common::stream::HislipStream; use crate::common::{Protocol, SUPPORTED_PROTOCOL}; use crate::server::session::{SessionState, SharedSession}; use crate::DEFAULT_DEVICE_SUBADRESS; -pub mod session; - -#[derive(Debug, Clone)] -pub struct ServerConfig { - pub vendor_id: u16, - /// Maximum server message size - pub max_message_size: u64, - /// Prefer overlapped data - pub prefer_overlap: bool, - /// Maximum allowed number of sessions - pub max_num_sessions: usize, - /// Short circuited "*IDN?" response. - /// This should be set identical to what a real "*IDN?" command would return. - pub short_idn: Option>, -} - -impl ServerConfig { - pub fn vendor_id(mut self, vendor_id: u16) -> Self { - self.vendor_id = vendor_id; - self - } - - pub fn max_message_size(mut self, max_message_size: u64) -> Self { - self.max_message_size = max_message_size; - self - } - - pub fn short_idn(mut self, short_idn: &[u8]) -> Self { - self.short_idn = Some(short_idn.to_vec()); - self - } - - pub fn max_num_sessions(mut self, max_num_sessions: usize) -> Self { - self.max_num_sessions = max_num_sessions; - self - } +pub use self::config::ServerConfig; +use self::session::SessionHandle; - pub fn prefer_overlap(mut self) -> Self { - self.prefer_overlap = true; - self - } - - pub fn prefer_synchronized(mut self) -> Self { - self.prefer_overlap = false; - self - } -} - -impl Default for ServerConfig { - fn default() -> Self { - Self { - vendor_id: 0xBEEF, - max_message_size: 1024 * 1024, - prefer_overlap: true, - max_num_sessions: 64, - short_idn: None, - } - } -} +pub mod config; +pub mod session; type DeviceMap = HashMap>, Arc>)>; @@ -125,12 +71,20 @@ where self } - pub fn build(self) -> Arc> { + pub fn build( + self, + #[cfg(feature = "secure-capability")] tls_config: Arc, + ) -> Arc> { assert!( !self.devices.is_empty(), "Server must have one or more devices" ); - Server::with_config(self.config, self.devices) + Server::with_config( + self.config, + self.devices, + #[cfg(feature = "secure-capability")] + tls_config, + ) } } @@ -141,22 +95,31 @@ where inner: Arc>>, devices: DeviceMap, config: ServerConfig, + #[cfg(feature = "secure-capability")] + tls_acceptor: async_rustls::TlsAcceptor, } impl Server where DEV: Device + Send + 'static, { + #[cfg(not(feature = "secure-capability"))] pub fn new(devices: DeviceMap) -> Arc { let config = ServerConfig::default(); Self::with_config(config, devices) } - pub fn with_config(config: ServerConfig, devices: DeviceMap) -> Arc { + pub fn with_config( + config: ServerConfig, + devices: DeviceMap, + #[cfg(feature = "secure-capability")] tls_config: Arc, + ) -> Arc { Arc::new(Server { inner: InnerServer::new(config.max_num_sessions), config, devices, + #[cfg(feature = "secure-capability")] + tls_acceptor: async_rustls::TlsAcceptor::from(tls_config), }) } @@ -192,13 +155,14 @@ where async fn handle_session( &self, peer: String, - mut stream: S, + stream: S, srq: SRQ, ) -> Result<(), io::Error> where S: AsyncRead + AsyncWrite + Unpin, SRQ: Stream + Unpin, { + let mut stream = HislipStream::new(stream); loop { match Message::read_from(&mut stream, self.config.max_message_size).await? { Ok(msg) => { @@ -215,27 +179,22 @@ where ) } Message { - message_type: MessageType::FatalError, + message_type: typ @ MessageType::Error | typ @ MessageType::FatalError, control_code, payload, .. } => { - log::error!(peer=format!("{}", peer); - "Client fatal error {:?}: {}", FatalErrorCode::from_error_code(control_code), - from_utf8(&payload).unwrap_or("") - ); - //break; // Let client close connection - } - Message { - message_type: MessageType::Error, - control_code, - payload, - .. - } => { - log::warn!(peer=format!("{}", peer); - "Client error {:?}: {}", NonFatalErrorCode::from_error_code(control_code), - from_utf8(&payload).unwrap_or("") - ); + if typ == MessageType::FatalError { + log::error!(peer=peer.to_string(); + "Client fatal error {:?}: {}", FatalErrorCode::from_error_code(control_code), + from_utf8(&payload).unwrap_or("") + ); + } else { + log::warn!(peer=peer.to_string(); + "Client error {:?}: {}", NonFatalErrorCode::from_error_code(control_code), + from_utf8(&payload).unwrap_or("") + ); + } } Message { message_type: MessageType::Initialize, @@ -301,8 +260,11 @@ where shared, RemoteLockHandle::new(device), receiver, + protocol, + #[cfg(feature = "secure-capability")] + self.tls_acceptor.clone(), ) - .handle_session(stream, peer.clone(), protocol) + .handle_session(stream, peer.clone()) .await; log::debug!(peer=peer.to_string(), session_id=id; "Sync session closed: {res:?}"); return res; @@ -361,7 +323,7 @@ where MessageType::AsyncInitializeResponse .message_params( - AsyncInitializeResponseControl::new(false).0, + AsyncInitializeResponseControl::new(true).0, AsyncInitializeResponseParameter::new( self.config.vendor_id, ) @@ -378,8 +340,11 @@ where shared, device, sender, + protocol, + #[cfg(feature = "secure-capability")] + self.tls_acceptor.clone(), ) - .handle_session(stream, peer.clone(), srq, protocol) + .handle_session(stream, peer.clone(), srq) .await; log::debug!(peer=peer.to_string(), session_id=id; "Async session closed: {res:?}"); return res; @@ -407,39 +372,6 @@ where } } -/// A handle to a created active season -#[derive(Clone)] -pub(crate) struct SessionHandle -where - DEV: Device, -{ - _id: u16, - shared: Weak>, - device: Weak>>, -} - -impl SessionHandle -where - DEV: Device, -{ - fn new( - id: u16, - session: Weak>, - handle: Weak>>, - ) -> Self { - Self { - _id: id, - shared: session, - device: handle, - } - } - - /// Return false if the assosciated object have been closed - fn active(&self) -> bool { - self.shared.strong_count() > 0 && self.device.strong_count() > 0 - } -} - struct InnerServer where DEV: Device, diff --git a/hislip/src/server/session/asynchronous.rs b/hislip/src/server/session/asynchronous.rs index 3b23d4e..0d16ebf 100644 --- a/hislip/src/server/session/asynchronous.rs +++ b/hislip/src/server/session/asynchronous.rs @@ -7,14 +7,16 @@ use async_std::future; use async_std::prelude::StreamExt; use async_std::sync::Arc; use byteorder::{ByteOrder, NetworkEndian}; -use futures::future::Either; +use futures::future::{select, Either}; use futures::lock::Mutex; -use futures::{pin_mut, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, FutureExt, Stream}; +use futures::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, Stream}; use lxi_device::lock::{LockHandle, SharedLockError, SharedLockMode, SpinMutex}; use lxi_device::{Device, DeviceError}; +use crate::common::descriptors::Descriptor; use crate::common::errors::{Error, FatalErrorCode, NonFatalErrorCode}; -use crate::common::messages::{prelude::*, send_fatal, send_nonfatal}; +use crate::common::messages::{prelude::*, send_fatal, send_nonfatal, TlsStatus}; +use crate::common::stream::HislipStream; use crate::common::{Protocol, PROTOCOL_2_0}; use super::{ServerConfig, SharedSession}; @@ -36,6 +38,11 @@ where handle: Arc>>, clear: Sender<()>, + + protocol: Protocol, + + #[cfg(feature = "secure-capability")] + acceptor: async_rustls::TlsAcceptor, } impl AsyncSession @@ -48,6 +55,8 @@ where shared: Arc>, handle: Arc>>, clear: Sender<()>, + protocol: Protocol, + #[cfg(feature = "secure-capability")] acceptor: async_rustls::TlsAcceptor, ) -> Self { Self { id, @@ -55,52 +64,60 @@ where shared, handle, clear, + protocol, + #[cfg(feature = "secure-capability")] + acceptor, } } pub(crate) async fn handle_session( self, - stream: S, + mut stream: HislipStream, peer: String, mut srq: SRQ, - protocol: Protocol, ) -> Result<(), io::Error> where S: AsyncRead + AsyncWrite + Unpin, SRQ: Stream + Unpin, { - let (mut rd, mut wr) = stream.split(); + //let (mut rd, mut wr) = stream.split(); let mut srq_bit = false; loop { - let read_msg = Message::read_from(&mut rd, self.config.max_message_size).fuse(); - pin_mut!(read_msg); - - let t = match futures::future::select(read_msg, srq.next()).await { - // Message was received - Either::Left((msg, _)) => msg, - // Status changed - Either::Right((stb, read_msg)) => { - // Send SRQ - match stb { - Some(val) if !srq_bit => { - srq_bit = true; - MessageType::AsyncServiceRequest - .message_params(val, 0) - .write_to(&mut wr) - .await? - } - _ => { - send_fatal!(peer=peer.to_string(), session_id=self.id; - &mut wr, FatalErrorCode::UnidentifiedError, - "Server shutdown", - ); + // Read a message + let t = { + let (mut rd, mut wr) = stream.split(); + let read_msg = Box::pin(Message::read_from(&mut rd, self.config.max_message_size)); + let msg = match select(read_msg, srq.next()).await { + Either::Left((msg, _)) => msg, + Either::Right((stb, msg)) => { + match stb { + // Statusbyte has changed + Some(stb) => { + if !srq_bit { + MessageType::AsyncServiceRequest + .message_params(stb as u8, 0) + .no_payload() + .write_to(&mut wr) + .await?; + srq_bit = true; + } + } + // Srq is closed, server is shutting down + None => { + log::info!(peer=peer.to_string(), session_id=self.id; "Server shutting down..."); + return Ok(()); + } } + // Finish receiving message + // This is important as dropping the future mid-message can corrupt the datastream + msg.await } - // Finish receiving message - read_msg.await - } - }?; + }; + stream = rd.reunite(wr).unwrap(); + + msg? + }; match t { Ok(msg) => { @@ -110,32 +127,27 @@ where .. } => { send_nonfatal!(peer=peer.to_string(), session_id=self.id; - &mut wr, NonFatalErrorCode::UnrecognizedVendorDefinedMessage, + &mut stream, NonFatalErrorCode::UnrecognizedVendorDefinedMessage, "Unrecognized Vendor Defined Message ({})", code ); } Message { - message_type: MessageType::FatalError, + message_type: typ @ MessageType::Error | typ @ MessageType::FatalError, control_code, payload, .. } => { - log::error!(peer=peer.to_string(), session_id=self.id; - "Client fatal error {:?}: {}", FatalErrorCode::from_error_code(control_code), - from_utf8(&payload).unwrap_or("") - ); - //break; // Let client close connection - } - Message { - message_type: MessageType::Error, - control_code, - payload, - .. - } => { - log::warn!(peer=peer.to_string(), session_id=self.id; - "Client error {:?}: {}", NonFatalErrorCode::from_error_code(control_code), - from_utf8(&payload).unwrap_or("") - ); + if typ == MessageType::FatalError { + log::error!(peer=peer.to_string(), session_id=self.id; + "Client fatal error {:?}: {}", FatalErrorCode::from_error_code(control_code), + from_utf8(&payload).unwrap_or("") + ); + } else { + log::warn!(peer=peer.to_string(), session_id=self.id; + "Client error {:?}: {}", NonFatalErrorCode::from_error_code(control_code), + from_utf8(&payload).unwrap_or("") + ); + } } Message { message_type: MessageType::AsyncLock, @@ -158,7 +170,7 @@ where MessageType::AsyncLockResponse .message_params(control as u8, 0) .no_payload() - .write_to(&mut wr) + .write_to(&mut stream) .await?; } else { // Lock @@ -205,7 +217,7 @@ where MessageType::AsyncLockResponse .message_params(control as u8, 0) .no_payload() - .write_to(&mut wr) + .write_to(&mut stream) .await?; } } @@ -273,17 +285,17 @@ where MessageType::AsyncRemoteLocalResponse .message_params(0, 0) .no_payload() - .write_to(&mut wr) + .write_to(&mut stream) .await? } Err(DeviceError::NotSupported) => { - send_nonfatal!(peer=peer.to_string(), session_id=self.id; &mut wr, + send_nonfatal!(peer=peer.to_string(), session_id=self.id; &mut stream, NonFatalErrorCode::UnrecognizedControlCode, "Unrecognized control code", ); } Err(_) => { - send_nonfatal!(peer=peer.to_string(), session_id=self.id; &mut wr, + send_nonfatal!(peer=peer.to_string(), session_id=self.id; &mut stream, NonFatalErrorCode::UnidentifiedError, "Internal error", ); @@ -297,7 +309,7 @@ where } => { if payload.len() != 8 { send_fatal!(peer=peer.to_string(), session_id=self.id; - &mut wr, FatalErrorCode::PoorlyFormattedMessageHeader, + &mut stream, FatalErrorCode::PoorlyFormattedMessageHeader, "Expected 8 bytes in AsyncMaximumMessageSize payload" ) } @@ -316,7 +328,7 @@ where MessageType::AsyncMaximumMessageSizeResponse .message_params(0, 0) .with_payload(buf.to_vec()) - .write_to(&mut wr) + .write_to(&mut stream) .await?; } Message { @@ -338,7 +350,7 @@ where MessageType::AsyncDeviceClearAcknowledge .message_params(features.0, 0) .no_payload() - .write_to(&mut wr) + .write_to(&mut stream) .await?; } Message { @@ -372,7 +384,7 @@ where MessageType::AsyncStatusResponse .message_params(stb, 0) .no_payload() - .write_to(&mut wr) + .write_to(&mut stream) .await?; } Message { @@ -389,18 +401,58 @@ where MessageType::AsyncLockInfoResponse .message_params(exclusive.into(), num_shared) .no_payload() - .write_to(&mut wr) + .write_to(&mut stream) .await?; } + Message { + message_type: MessageType::GetDescriptors, + control_code, + message_parameter, + .. + } if self.protocol >= PROTOCOL_2_0 => { + log::debug!(session_id=self.id, control_code=control_code, message_parameter=message_parameter; "Get descriptors (async)"); + + let mut payload = Vec::new(); + Descriptor::SupportedTlsVersions(vec![0x0303, 0x0304]) + .write_descriptor(&mut payload)?; + Descriptor::TlsInformation(b"1.2".to_vec()) + .write_descriptor(&mut payload)?; + Descriptor::TlsLastError(b"OK".to_vec()) + .write_descriptor(&mut payload)?; + + log::debug!(session_id=self.id, control_code=control_code, message_parameter=message_parameter; "Response -> {payload:?}"); + + MessageType::GetDescriptorsResponse + .message_params(0, 0) + .with_payload(payload) + .write_to(&mut stream) + .await?; + } + #[cfg(not(feature = "secure-capability"))] + Message { + message_type: + MessageType::AsyncStartTLS + | MessageType::AsyncEndTLS, + .. + } if self.protocol >= PROTOCOL_2_0 => { + send_fatal!( + &mut stream, + FatalErrorCode::SecureConnectionFailed, + "Secure capablity not supported" + ) + } + #[cfg(feature = "secure-capability")] Message { message_type: MessageType::AsyncStartTLS, control_code, message_parameter, payload, - } if protocol >= PROTOCOL_2_0 => { + } if self.protocol >= PROTOCOL_2_0 => { + let shared = self.shared.lock().await; + if payload.len() != 4 { send_fatal!(peer=peer.to_string(), session_id=self.id; - &mut wr, FatalErrorCode::PoorlyFormattedMessageHeader, + &mut stream, FatalErrorCode::PoorlyFormattedMessageHeader, "Expected 4 bytes in AsyncStartTLS payload" ) } @@ -411,20 +463,51 @@ where log::debug!(session_id=self.id, message_id_sent=message_id_sent, message_id_read=message_id_read; "Start async TLS"); - // TODO: Encryption support - send_fatal!( - &mut wr, - FatalErrorCode::SecureConnectionFailed, - "Secure connection not supported" - ) + let control = if stream.is_secure() { + TlsStatus::Error + } else if message_id_sent != shared.read_message_id + || message_id_read != shared.sent_message_id + { + TlsStatus::Busy + } else { + TlsStatus::Success + }; + log::trace!("Response = {control:?}"); + + // Drop the shared object to avoid blocking + drop(shared); + + MessageType::AsyncStartTLSResponse + .message_params(control as u8, 0) + .no_payload() + .write_to(&mut stream) + .await?; + + if control == TlsStatus::Success { + // Switch over to TLS + stream = match stream.start_tls(&mut self.acceptor.clone()).await { + Ok(stream) => stream, + Err((err, mut stream)) => { + send_fatal!( + &mut stream, + FatalErrorCode::SecureConnectionFailed, + "Failed to establish TLS connections: {err}" + ) + } + }; + log::info!(session_id=self.id; "Async channel switched to TLS") + } else { + log::error!(session_id=self.id; "Failed to switch async session to TLS: {control:?}") + } } + #[cfg(feature = "secure-capability")] Message { message_type: MessageType::AsyncEndTLS, control_code, message_parameter, payload, - } if protocol >= PROTOCOL_2_0 => { - // Only supported >= 2.0 + } if self.protocol >= PROTOCOL_2_0 => { + // Only supported >= 2.0 and supports secure-capability let _control = RmtDeliveredControl(control_code); let message_id_sent = message_parameter; @@ -434,15 +517,15 @@ where // TODO: Encryption support send_fatal!( - &mut wr, + &mut stream, FatalErrorCode::SecureConnectionFailed, "Secure connection not supported" ) } - _ => { - send_nonfatal!(peer=peer.to_string(), session_id=self.id; &mut wr, + Message { message_type, .. } => { + send_nonfatal!(peer=peer.to_string(), session_id=self.id; &mut stream, NonFatalErrorCode::UnrecognizedMessageType, - "Unexpected message type in asynchronous channel", + "Unexpected {message_type:?} in asynchronous channel", ); } } @@ -450,10 +533,10 @@ where Err(err) => { // Send error to client and close if fatal if err.is_fatal() { - Message::from(err).write_to(&mut wr).await?; + Message::from(err).write_to(&mut stream).await?; break Err(io::ErrorKind::Other.into()); } else { - Message::from(err).write_to(&mut wr).await?; + Message::from(err).write_to(&mut stream).await?; } } } diff --git a/hislip/src/server/session/mod.rs b/hislip/src/server/session/mod.rs index afba926..36dbc32 100644 --- a/hislip/src/server/session/mod.rs +++ b/hislip/src/server/session/mod.rs @@ -1,4 +1,10 @@ +use std::sync::Weak; + use async_std::channel::{self, Receiver, Sender}; +use lxi_device::{ + lock::{LockHandle, Mutex, SpinMutex}, + Device, +}; use super::ServerConfig; use crate::common::Protocol; @@ -22,7 +28,7 @@ pub(crate) struct SharedSession { /// Negotiated rpc protocol: Protocol, - /// Current tate of session + /// Current state of session state: SessionState, /// Negotiated session mode @@ -48,9 +54,9 @@ impl SharedSession { mode: SessionMode::Overlapped, max_message_size: 256, clear: channel::bounded(1), - read_message_id: 0, + read_message_id: 0xffff_fefe, enable_remote: true, - sent_message_id: 0, + sent_message_id: 0xffff_fefe, } } @@ -88,3 +94,36 @@ impl SharedSession { self.clear.0.clone() } } + +/// A handle to a created active season +#[derive(Clone)] +pub(crate) struct SessionHandle +where + DEV: Device, +{ + _id: u16, + pub shared: Weak>, + pub device: Weak>>, +} + +impl SessionHandle +where + DEV: Device, +{ + pub(crate) fn new( + id: u16, + session: Weak>, + handle: Weak>>, + ) -> Self { + Self { + _id: id, + shared: session, + device: handle, + } + } + + /// Return false if the assosciated object have been closed + pub(crate) fn active(&self) -> bool { + self.shared.strong_count() > 0 && self.device.strong_count() > 0 + } +} diff --git a/hislip/src/server/session/synchronous.rs b/hislip/src/server/session/synchronous.rs index a368cae..5677b8d 100644 --- a/hislip/src/server/session/synchronous.rs +++ b/hislip/src/server/session/synchronous.rs @@ -1,7 +1,9 @@ use std::io; use std::str::from_utf8; +use std::time::Duration; use async_std::channel::Receiver; +use async_std::future::timeout; use async_std::sync::Arc; use futures::lock::Mutex; use futures::{select, AsyncRead, AsyncWrite, AsyncWriteExt, FutureExt}; @@ -9,8 +11,10 @@ use lxi_device::lock::RemoteLockHandle; use lxi_device::trigger::Source; use lxi_device::Device; +use crate::common::descriptors::Descriptor; use crate::common::errors::{Error, FatalErrorCode, NonFatalErrorCode}; use crate::common::messages::{prelude::*, send_fatal, send_nonfatal}; +use crate::common::stream::HislipStream; use crate::common::{Protocol, PROTOCOL_2_0}; use super::{ServerConfig, SharedSession}; @@ -33,6 +37,11 @@ where shared: Arc>, clear: Receiver<()>, + + protocol: Protocol, + + #[cfg(feature = "secure-capability")] + acceptor: async_rustls::TlsAcceptor, } impl SyncSession @@ -45,6 +54,8 @@ where shared: Arc>, handle: RemoteLockHandle, clear: Receiver<()>, + protocol: Protocol, + #[cfg(feature = "secure-capability")] acceptor: async_rustls::TlsAcceptor, ) -> Self { Self { id, @@ -52,6 +63,9 @@ where shared, handle, clear, + protocol, + #[cfg(feature = "secure-capability")] + acceptor, } } @@ -89,52 +103,10 @@ where .await } - async fn clear_buffer( - &self, - mut stream: S, - peer: String, - mut msg: Result, - ) -> Result<(), io::Error> - where - S: AsyncRead + AsyncWrite + Unpin, - { - loop { - match msg { - Ok(Message { - message_type: MessageType::DeviceClearComplete, - control_code, - .. - }) => { - if self.handle.can_lock().is_ok() { - let mut dev = self.handle.inner_lock().await; - let _res = dev.clear(); - } - - break self - .acknowledge_device_clear(stream, peer, control_code) - .await; - } - // Ignore other messages - Ok(_) => {} - // Invalid message - Err(err) => { - if err.is_fatal() { - Message::from(err).write_to(&mut stream).await?; - return Err(io::ErrorKind::Other.into()); - } else { - Message::from(err).write_to(&mut stream).await?; - } - } - } - msg = Message::read_from(&mut stream, self.config.max_message_size).await?; - } - } - pub(crate) async fn handle_session( self, - mut stream: S, + mut stream: HislipStream, peer: String, - protocol: Protocol, ) -> Result<(), io::Error> where S: AsyncRead + AsyncWrite + Unpin, @@ -145,26 +117,6 @@ where loop { let msg = Message::read_from(&mut stream, self.config.max_message_size).await?; - // Check if a clear device is in progress before waiting for a lock - if let Ok(_abort) = self.clear.try_recv() { - // Clear buffer - buffer.clear(); - self.clear_buffer(&mut stream, peer.clone(), msg).await?; - continue; - } - - // Wait for device becoming available or a lock is acquired - // Abort the lock attempt if a clear device is started - let mut dev = select! { - res = self.handle.async_lock().fuse() => res.unwrap(), - _abort = self.clear.recv().fuse() => { - // Clear buffer - buffer.clear(); - self.clear_buffer(&mut stream, peer.clone(), msg).await?; - continue; - } - }; - // Do not read messages unless a loc match msg { // Valid message @@ -180,26 +132,22 @@ where ); } Message { - message_type: MessageType::FatalError, + message_type: typ @ MessageType::Error | typ @ MessageType::FatalError, control_code, payload, .. } => { - log::error!(peer=peer.to_string(), session_id=self.id; - "Client fatal error {:?}: {}", FatalErrorCode::from_error_code(control_code), - from_utf8(&payload).unwrap_or("") - ); - } - Message { - message_type: MessageType::Error, - control_code, - payload, - .. - } => { - log::warn!(peer=peer.to_string(), session_id=self.id; - "Client error {:?}: {}", NonFatalErrorCode::from_error_code(control_code), - from_utf8(&payload).unwrap_or("") - ); + if typ == MessageType::FatalError { + log::error!(peer=peer.to_string(), session_id=self.id; + "Client fatal error {:?}: {}", FatalErrorCode::from_error_code(control_code), + from_utf8(&payload).unwrap_or("") + ); + } else { + log::warn!(peer=peer.to_string(), session_id=self.id; + "Client error {:?}: {}", NonFatalErrorCode::from_error_code(control_code), + from_utf8(&payload).unwrap_or("") + ); + } } Message { message_type: typ @ MessageType::Data | typ @ MessageType::DataEnd, @@ -211,6 +159,26 @@ where let control = RmtDeliveredControl(control_code); let is_end = matches!(typ, MessageType::DataEnd); + // Wait for device becoming available or a lock is acquired + // Abort the lock attempt if a clear device is started + let mut dev = select! { + res = self.handle.async_lock().fuse() => match res{ + Ok(res) => res, + Err(_) => { + send_fatal!(peer=peer.to_string(), session_id=self.id; + &mut stream, + FatalErrorCode::UnidentifiedError, + "Internal locking error" + ); + } + }, + _abort = self.clear.recv().fuse() => { + buffer.clear(); + self.acknowledge_device_clear(&mut stream, peer.clone(), control_code).await?; + continue; + }, + }; + let mut shared = self.shared.lock().await; let state = shared.state(); @@ -218,6 +186,7 @@ where // Normal state SessionState::Normal => { shared.read_message_id = message_id; + drop(shared); // Drop shared data as to not block async session if buffer.try_reserve_exact(data.len()).is_err() { send_fatal!(peer=peer.to_string(), session_id=self.id; @@ -227,22 +196,18 @@ where ); } buffer.extend_from_slice(&data); + log::info!("Buffer={:?}", data); if is_end { log::debug!(peer=peer.to_string(), session_id=self.id, message_id=message_id; "Data END, {}", control); - let data = if buffer.eq_ignore_ascii_case(b"*idn?") - && self.config.short_idn.is_some() - { - self.config.short_idn.clone() - } else { - let data = dev.execute(&buffer); - buffer.clear(); - data - }; + let data = dev.execute(&buffer); + buffer.clear(); // Send back response + let shared = self.shared.lock().await; if let Some(data) = data { + log::info!("Sending back"); let mut chunks = data .chunks(shared.max_message_size as usize) .peekable(); @@ -251,6 +216,8 @@ where while let Some(chunk) = chunks.next() { // Stop sending if a clear has been received on async channel if self.clear.try_recv().is_ok() { + log::info!("Sending back, clear!"); + break; } @@ -291,6 +258,26 @@ where control_code, .. } => { + // Wait for device becoming available or a lock is acquired + // Abort the lock attempt if a clear device is started + let mut dev = select! { + res = self.handle.async_lock().fuse() => match res{ + Ok(res) => res, + Err(_) => { + send_fatal!(peer=peer.to_string(), session_id=self.id; + &mut stream, + FatalErrorCode::UnidentifiedError, + "Internal locking error" + ); + } + }, + _abort = self.clear.recv().fuse() => { + buffer.clear(); + self.acknowledge_device_clear(&mut stream, peer.clone(), control_code).await?; + continue; + } + }; + let mut inner = self.shared.lock().await; inner.read_message_id = message_id; let state = inner.state(); @@ -315,31 +302,123 @@ where } Message { message_type: MessageType::DeviceClearComplete, + control_code, .. - } => { - // Should've been handled above when AsyncDeviceClear was sent - send_nonfatal!(peer=peer.to_string(), session_id=self.id; - &mut stream, - NonFatalErrorCode::UnidentifiedError, - "Unexpected device clear complete in synchronous channel" - ); - } + } => match timeout(Duration::from_secs(10), self.clear.recv()).await { + Ok(Ok(())) => { + buffer.clear(); + self.acknowledge_device_clear( + &mut stream, + peer.clone(), + control_code, + ) + .await?; + } + Ok(Err(_rerr)) => { + send_fatal!(peer=peer.to_string(), session_id=self.id; + &mut stream, + FatalErrorCode::UnidentifiedError, + "Internal server error" + ); + } + Err(_terr) => { + send_fatal!(peer=peer.to_string(), session_id=self.id; + &mut stream, + FatalErrorCode::UnidentifiedError, + "Received device clear complete without a request" + ); + } + }, Message { message_type: MessageType::GetDescriptors, + control_code, + message_parameter, .. - } => {} + } if self.protocol >= PROTOCOL_2_0 => { + log::debug!(session_id=self.id, control_code=control_code, message_parameter=message_parameter; "Get descriptors (sync)"); + + let mut payload = Vec::new(); + Descriptor::SupportedTlsVersions(vec![0x0303, 0x0304]) + .write_descriptor(&mut payload)?; + Descriptor::TlsInformation(b"1.2".to_vec()) + .write_descriptor(&mut payload)?; + Descriptor::TlsLastError(b"OK".to_vec()) + .write_descriptor(&mut payload)?; + + log::debug!(session_id=self.id, control_code=control_code, message_parameter=message_parameter; "Response -> {payload:?}"); + + MessageType::GetDescriptorsResponse + .message_params(0, 0) + .with_payload(payload) + .write_to(&mut stream) + .await?; + } + #[cfg(not(feature = "secure-capability"))] Message { - message_type: MessageType::StartTLS | MessageType::EndTLS, + message_type: + MessageType::StartTLS + | MessageType::EndTLS + | MessageType::GetSaslMechanismList + | MessageType::AuthenticationStart + | MessageType::AuthenticationExchange, .. - } if protocol >= PROTOCOL_2_0 => { - log::debug!(peer=peer.to_string(), session_id=self.id; "Start/end TLS"); - + } if self.protocol >= PROTOCOL_2_0 => { send_fatal!( &mut stream, FatalErrorCode::SecureConnectionFailed, - "Secure connection not supported" + "Secure capablity not supported" ) } + #[cfg(feature = "secure-capability")] + Message { + message_type: MessageType::StartTLS, + .. + } if self.protocol >= PROTOCOL_2_0 => { + log::debug!(session_id=self.id; "Sync start TLS"); + + // Switch over to TLS + stream = match stream.start_tls(&mut self.acceptor.clone()).await { + Ok(stream) => stream, + Err((err, mut stream)) => { + send_fatal!( + &mut stream, + FatalErrorCode::SecureConnectionFailed, + "Failed to establish TLS connections: {err}" + ) + } + }; + log::info!(session_id=self.id; "Sync channel switched to TLS") + } + #[cfg(feature = "secure-capability")] + Message { + message_type: MessageType::EndTLS, + .. + } if self.protocol >= PROTOCOL_2_0 => { + log::debug!(session_id=self.id; "Sync end TLS"); + + // Disconnect client if encrption is mandatory + if self.config.encryption_mandatory { + send_fatal!( + &mut stream, + FatalErrorCode::SecureConnectionFailed, + "Authentication not supported" + ) + } + + // Switch off TLS + stream = match stream.end_tls().await { + Ok(stream) => stream, + Err((err, mut stream)) => { + send_fatal!( + &mut stream, + FatalErrorCode::SecureConnectionFailed, + "Failed to end TLS connections: {err}" + ) + } + }; + log::info!(session_id=self.id; "Sync channel switched to TLS") + } + #[cfg(feature = "secure-capability")] Message { message_type: MessageType::GetSaslMechanismList @@ -347,14 +426,10 @@ where | MessageType::AuthenticationExchange, payload: _data, .. - } if protocol >= PROTOCOL_2_0 => { + } if self.protocol >= PROTOCOL_2_0 => { log::debug!(peer=peer.to_string(), session_id=self.id; "Authentication Start/Exchange"); - send_fatal!( - &mut stream, - FatalErrorCode::SecureConnectionFailed, - "Secure connection not supported" - ) + todo!() } msg => { send_nonfatal!(peer=peer.to_string(), session_id=self.id; diff --git a/hislip/tests/conftest.py b/hislip/tests/conftest.py index 794240d..d9dba58 100644 --- a/hislip/tests/conftest.py +++ b/hislip/tests/conftest.py @@ -2,26 +2,39 @@ import pytest from xprocess import ProcessStarter + @pytest.fixture -def hislip_example(xprocess, request, free_port): +def hislip_example(xprocess, request, pytestconfig, free_port): target = os.environ.get("DEBUG_TARGET") + + # Add credentials if set + credentials = os.environ.get("HISLIP_CRED") + if credentials is not None: + prefix = f"{credentials}@" + else: + prefix = "" + if target is not None: port = os.environ.get("HISLIP_PORT") if port is not None: - yield f"TCPIP::{target}::hislip0,{port}::INSTR" + yield f"TCPIP::{prefix}{target}::hislip0,{port}::INSTR" else: - yield f"TCPIP::{target}::hislip0::INSTR" + yield f"TCPIP::{prefix}{target}::hislip0::INSTR" else: - port = free_port + port = os.environ.get("HISLIP_PORT", default=str(free_port)) class Starter(ProcessStarter): # startup pattern pattern = "Running server" # Hide warnings - env = {"RUSTFLAGS": "-Awarnings", **os.environ} + env = { + "RUSTFLAGS": "-Awarnings", + # "CARGO_TARGET_DIR": pytestconfig.cache.mkdir("target"), + **os.environ, + } # command to start process args = [ @@ -39,7 +52,7 @@ class Starter(ProcessStarter): name = request.function.__name__ xprocess.ensure(f"hislip_example-{name}", Starter) - yield f"TCPIP::localhost::hislip0,{port}::INSTR" + yield f"TCPIP::{prefix}localhost::hislip0,{port}::INSTR" # clean up whole process tree afterwards xprocess.getinfo(f"hislip_example-{name}").terminate() diff --git a/hislip/tests/test_hislip.py b/hislip/tests/test_hislip.py index f6a7fde..9ede3f9 100644 --- a/hislip/tests/test_hislip.py +++ b/hislip/tests/test_hislip.py @@ -2,11 +2,12 @@ import pytest import pyvisa +IDN_RESPONSE = "Cyberdyne systems,T800 Model 101,A9012.C,V2.4" def test_connect(hislip_example, resource_manager): if resource_manager.visalib.library_path == "py": pytest.skip("pyvisa-py does not support HiSLIP", allow_module_level=True) - inst = resource_manager.open_resource(hislip_example) + inst = resource_manager.open_resource(hislip_example, read_termination = "", write_termination = "") inst.close() @@ -14,12 +15,10 @@ def test_connect(hislip_example, resource_manager): def test_hislip_idn(hislip_example, resource_manager): if resource_manager.visalib.library_path == "py": pytest.skip("pyvisa-py does not support HiSLIP", allow_module_level=True) - inst = resource_manager.open_resource(hislip_example) - inst.read_termination = "" - inst.write_termination = "" + inst = resource_manager.open_resource(hislip_example, read_termination = "", write_termination = "") - resp = inst.query("*IDN?") - assert resp == "Cyberdyne systems,T800 Model 101,A9012.C,V2.4" + resp = inst.query("*IDN?\n") + assert resp == IDN_RESPONSE inst.close() @@ -42,9 +41,13 @@ def test_hislip_idn_short(hislip_example, resource_manager): def test_clear(hislip_example, resource_manager: pyvisa.ResourceManager): if resource_manager.visalib.library_path == "py": pytest.skip("pyvisa-py does not support HiSLIP", allow_module_level=True) - inst = resource_manager.open_resource(hislip_example) + inst: pyvisa.resources.MessageBasedResource = resource_manager.open_resource(hislip_example, read_termination = "", write_termination = "") + inst.send_end = False + inst.write("GARBAGE") + inst.send_end = True inst.clear() + assert inst.query("*IDN?") == IDN_RESPONSE inst.close() @@ -52,7 +55,7 @@ def test_clear(hislip_example, resource_manager: pyvisa.ResourceManager): def test_trigger(hislip_example, resource_manager: pyvisa.ResourceManager): if resource_manager.visalib.library_path == "py": pytest.skip("pyvisa-py does not support HiSLIP", allow_module_level=True) - inst = resource_manager.open_resource(hislip_example) + inst = resource_manager.open_resource(hislip_example, read_termination = "", write_termination = "") inst.assert_trigger() @@ -64,7 +67,7 @@ def test_hislip_exclusive_lock( ): if resource_manager.visalib.library_path == "py": pytest.skip("pyvisa-py does not support HiSLIP", allow_module_level=True) - inst = resource_manager.open_resource(hislip_example) + inst = resource_manager.open_resource(hislip_example, read_termination = "", write_termination = "") # Lock and unlock inst.lock_excl(25.0) @@ -76,9 +79,9 @@ def test_hislip_exclusive_lock( def test_hislip_shared_lock(hislip_example, resource_manager: pyvisa.ResourceManager): if resource_manager.visalib.library_path == "py": pytest.skip("pyvisa-py does not support HiSLIP", allow_module_level=True) - inst1 = resource_manager.open_resource(hislip_example) - inst2 = resource_manager.open_resource(hislip_example) - inst3 = resource_manager.open_resource(hislip_example) + inst1 = resource_manager.open_resource(hislip_example, read_termination = "", write_termination = "") + inst2 = resource_manager.open_resource(hislip_example, read_termination = "", write_termination = "") + inst3 = resource_manager.open_resource(hislip_example, read_termination = "", write_termination = "") # Lock inst1.lock(requested_key="foo", timeout=0) @@ -108,8 +111,8 @@ def test_hislip_clear_in_progress( ): if resource_manager.visalib.library_path == "py": pytest.skip("pyvisa-py does not support HiSLIP", allow_module_level=True) - inst1 = resource_manager.open_resource(hislip_example) - inst2 = resource_manager.open_resource(hislip_example) + inst1 = resource_manager.open_resource(hislip_example, read_termination = "", write_termination = "") + inst2 = resource_manager.open_resource(hislip_example, read_termination = "", write_termination = "") # Lock inst1.lock(requested_key="foo") diff --git a/hislip/tests/test_hislip_v2.py b/hislip/tests/test_hislip_v2.py new file mode 100644 index 0000000..0c9be1b --- /dev/null +++ b/hislip/tests/test_hislip_v2.py @@ -0,0 +1,27 @@ +# Only works with Keysight IO Libraries and Secure communications expert +# See README on how to setup +# + +from pyvisa import highlevel + + +for backend in highlevel.list_backends(): + if backend.startswith("pyvisa-"): + backend = backend[7:] + + try: + cls = highlevel.get_wrapper_class(backend) + except Exception as e: + backend_details[backend] = [ + "Could not instantiate backend", + "-> %s" % str(e), + ] + continue + + try: + backend_details[backend] = cls.get_debug_info() + except Exception as e: + backend_details[backend] = [ + "Could not obtain debug info", + "-> %s" % str(e), + ] \ No newline at end of file diff --git a/lxi-common/Cargo.toml b/lxi-common/Cargo.toml new file mode 100644 index 0000000..7103d64 --- /dev/null +++ b/lxi-common/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "lxi-common" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +digest = { version = "0.10", optional = true } +sha1 = { version = "0.10", optional = true } +sha2 = { version = "0.10", optional = true } + +[features] +default = ["security"] +security = ["dep:digest", "dep:sha1", "dep:sha2"] diff --git a/lxi-common/src/lib.rs b/lxi-common/src/lib.rs new file mode 100644 index 0000000..c118405 --- /dev/null +++ b/lxi-common/src/lib.rs @@ -0,0 +1,18 @@ + + +pub mod security; + +pub fn add(left: usize, right: usize) -> usize { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} diff --git a/lxi-common/src/security.rs b/lxi-common/src/security.rs new file mode 100644 index 0000000..d8db9e4 --- /dev/null +++ b/lxi-common/src/security.rs @@ -0,0 +1,63 @@ +use digest::Digest; + +struct ClientAuthentication {} + + +/// Algorithm used to calculate thumbprint +#[non_exhaustive] +#[derive(Debug, Clone, Copy)] +pub enum ThumbprintHash { + Sha1, + Sha224, + Sha256, + Sha384, + Sha512, +} + +impl ThumbprintHash { + pub fn from_str(hash: &str) -> Option { + match hash { + "sha-1" | "SHA-1" => Some(Self::Sha1), + "sha-224" | "SHA-224" => Some(Self::Sha224), + "sha-256" | "SHA-256" => Some(Self::Sha256), + "sha-384" | "SHA-384" => Some(Self::Sha384), + "sha-512" | "SHA-512" => Some(Self::Sha512), + _ => None, + } + } + + pub fn digest(&self, data: &[u8]) -> Vec { + match self { + ThumbprintHash::Sha1 => sha1::Sha1::digest(data).to_vec(), + ThumbprintHash::Sha224 => sha2::Sha224::digest(data).to_vec(), + ThumbprintHash::Sha256 => sha2::Sha256::digest(data).to_vec(), + ThumbprintHash::Sha384 => sha2::Sha384::digest(data).to_vec(), + ThumbprintHash::Sha512 => sha2::Sha512::digest(data).to_vec(), + } + } +} + +pub struct CertificateThumbprint { + hash: ThumbprintHash, + thumbprint: Vec, +} + +impl CertificateThumbprint { + pub fn new(hash: ThumbprintHash, thumbprint: Vec) -> Self { + Self { hash, thumbprint } + } + + pub fn new_from_hash_name(hash_name: &str, thumbprint: Vec) -> Option { + Some(Self { + hash: ThumbprintHash::from_str(hash_name)?, + thumbprint, + }) + } + + pub fn eq_certificate(&self, cert: &[u8]) -> bool { + let cert_hash = self.hash.digest(cert); + + // Compare the calculated hash to our thumbprint + cert_hash.as_slice() == self.thumbprint.as_slice() + } +} diff --git a/raw/Cargo.toml b/raw/Cargo.toml index ce18d68..c40a522 100644 --- a/raw/Cargo.toml +++ b/raw/Cargo.toml @@ -14,13 +14,18 @@ async-std = { workspace = true } async-listen = { workspace = true } futures = { workspace = true } log = { workspace = true, features = ["kv_unstable_std"] } +async-rustls = { workspace = true, features = ["dangerous_configuration"], optional = true } [dependencies.lxi-device] path = "../device" version = "0.1.0" [dev-dependencies] -femme = { workspace = true } +femme = { workspace = true } clap = { workspace = true } mio-serial = "5.0" -async-io = "1.9.0" \ No newline at end of file +async-io = "1.9.0" + +[features] +default = ["tls"] +tls = ["dep:async-rustls"] diff --git a/raw/examples/raw.rs b/raw/examples/scpi-raw.rs similarity index 100% rename from raw/examples/raw.rs rename to raw/examples/scpi-raw.rs diff --git a/raw/examples/scpi-tls.rs b/raw/examples/scpi-tls.rs new file mode 100644 index 0000000..04de135 --- /dev/null +++ b/raw/examples/scpi-tls.rs @@ -0,0 +1,178 @@ +use std::{ + fs::File, + io::{self, BufReader}, + sync::Arc, + time::Duration, +}; + +use async_std::io::timeout; +use lxi_device::{lock::SharedLock, util::SimpleDevice}; +use lxi_socket::{server::ServerConfig, SOCKET_STANDARD_PORT}; + +use clap::Parser; + +use async_rustls::{ + rustls::{ + internal::pemfile::{certs, pkcs8_private_keys, rsa_private_keys}, + AllowAnyAnonymousOrAuthenticatedClient, AllowAnyAuthenticatedClient, Certificate, + NoClientAuth, PrivateKey, RootCertStore, ServerConfig as TlsConfig, + }, + TlsAcceptor, +}; + +/// Simple program to greet a person +#[derive(Parser, Debug)] +#[clap(author, version, about, long_about = None)] +struct Args { + #[clap(default_value = "0.0.0.0")] + ip: String, + + /// Number of times to greet + #[clap(short, long, default_value_t = SOCKET_STANDARD_PORT)] + port: u16, + + /// Kill server after timeout (useful for coverage testing) + #[clap(short, long)] + timeout: Option, + + /// TLS certificate + #[clap(short, long, default_value = ".certificates/cert.pem")] + cert: String, + + /// TLS key + #[clap(short, long, default_value = ".certificates/key.pem")] + key: String, + + #[clap(long)] + client_cert: Vec, + + #[clap(long)] + require_authentication: bool, +} + +/// Load the passed certificates file +fn load_certs(path: &str) -> io::Result> { + certs(&mut BufReader::new(File::open(path)?)) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid cert")) +} + +/// Load the passed keys file +fn load_keys(path: &str) -> io::Result> { + // Try to load RSA key + match rsa_private_keys(&mut BufReader::new(File::open(path)?)) { + Ok(keys) => Ok(keys), + // Try PKCS#8 if not RSA + Err(_) => match pkcs8_private_keys(&mut BufReader::new(File::open(path)?)) { + Ok(keys) => Ok(keys), + Err(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Invalid key, expected RSA or PKCS#8 in PEM format", + )) + } + }, + } +} + +struct LxiClientCertVerifier { + inner: V, + thumbprints: Vec<()> +} + +impl LxiClientCertVerifier { + fn get_certificate_thumbprint() { + + } +} + +impl async_rustls::rustls::ClientCertVerifier for LxiClientCertVerifier +where + V: async_rustls::rustls::ClientCertVerifier, +{ + fn offer_client_auth(&self) -> bool { + self.inner.offer_client_auth() + } + + fn client_auth_mandatory(&self, sni: Option<&async_rustls::webpki::DNSName>) -> Option { + self.inner.client_auth_mandatory(sni) + } + + fn client_auth_root_subjects( + &self, + sni: Option<&async_rustls::webpki::DNSName>, + ) -> Option { + self.inner.client_auth_root_subjects(sni) + } + + fn verify_client_cert( + &self, + presented_certs: &[Certificate], + sni: Option<&async_rustls::webpki::DNSName>, + ) -> Result { + if let Some(end_cert) = presented_certs.first() { + //end_cert + } + self.inner.verify_client_cert(presented_certs, sni) + } +} + +/// Configure the server using rusttls +/// See https://docs.rs/rustls/0.16.0/rustls/struct.ServerConfig.html for details +/// +/// A TLS server needs a certificate and a fitting private key +fn load_config(options: &Args) -> io::Result { + let certs = load_certs(&options.cert)?; + let mut keys = load_keys(&options.key)?; + + let mut config = if !options.client_cert.is_empty() { + let mut store = RootCertStore::empty(); + for path in &options.client_cert { + let mut reader = BufReader::new(File::open(path)?); + store + .add_pem_file(&mut reader) + .expect("Failed to load client certificate"); + } + if options.require_authentication { + TlsConfig::new(AllowAnyAuthenticatedClient::new(store)) + } else { + TlsConfig::new(AllowAnyAnonymousOrAuthenticatedClient::new(store)) + } + } else { + if options.require_authentication { + log::error!("Client authentication required but no certificates were provided") + } + TlsConfig::new(NoClientAuth::new()) + }; + + config + // set this server to use one cert together with the loaded private key + .set_single_cert(certs, keys.remove(0)) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; + + Ok(config) +} + +#[async_std::main] +async fn main() -> std::io::Result<()> { + femme::with_level(log::LevelFilter::Debug); + let args = Args::parse(); + + let device = SimpleDevice::new_arc(); + let shared_lock = SharedLock::new(); + + // TLS + let config = load_config(&args)?; + let acceptor = TlsAcceptor::from(Arc::new(config)); + + let ipv4_server = ServerConfig::default() + .read_buffer(16 * 1024) + .build() + .accept_tls((&args.ip[..], args.port), shared_lock, device, acceptor); + + log::info!("Running server on port {}:{}...", args.ip, args.port); + if let Some(t) = args.timeout { + timeout(Duration::from_millis(t), ipv4_server).await + } else { + ipv4_server.await + } +} diff --git a/raw/src/lib.rs b/raw/src/lib.rs index 1e805d4..f609c82 100644 --- a/raw/src/lib.rs +++ b/raw/src/lib.rs @@ -2,4 +2,9 @@ pub mod server; pub mod common {} +/// Standard port for raw SCPI socket communication pub const SOCKET_STANDARD_PORT: u16 = 5025; +/// Our standard port for secure raw communication. +/// **This is not a LXI standard port, just ours!** +pub const TLS_PORT: u16 = 6025; + diff --git a/raw/src/server/mod.rs b/raw/src/server/mod.rs index da13cc6..4c15ed6 100644 --- a/raw/src/server/mod.rs +++ b/raw/src/server/mod.rs @@ -21,6 +21,9 @@ use lxi_device::{ #[cfg(unix)] use async_std::os::unix::net::UnixListener; +#[cfg(feature = "tls")] +pub mod tls; + pub struct Server(ServerConfig); impl Server { @@ -65,6 +68,57 @@ impl Server { Ok(()) } + /// Listen to a socket for clients with a TLS acceptor + #[cfg(feature = "tls")] + pub async fn accept_tls( + self: Arc, + addr: impl ToSocketAddrs, + shared_lock: Arc>, + device: Arc>, + acceptor: async_rustls::TlsAcceptor, + ) -> io::Result<()> + where + DEV: Device + Send + 'static, + { + let listener = TcpListener::bind(addr).await?; + let mut incoming = listener + .incoming() + .log_warnings(|warn| log::warn!("Listening error: {}", warn)) + .handle_errors(Duration::from_millis(100)) + .backpressure(self.0.limit); + + while let Some((token, stream)) = incoming.next().await { + let s = self.clone(); + let peer = stream.peer_addr()?; + log::error!("Accepted from: {}", peer); + + let shared_lock = shared_lock.clone(); + let device = device.clone(); + let acceptor = acceptor.clone(); + + stream.set_nodelay(true)?; + + task::spawn(async move { + match acceptor.accept(stream).await { + Ok(stream) => { + let (reader, writer) = stream.split(); + if let Err(err) = s + .process_client(reader, writer, shared_lock, device, peer) + .await + { + log::warn!("Error processing client: {}", err) + } + } + Err(err) => { + log::warn!("TLS handshake failed: {err}") + }, + } + drop(token); + }); + } + Ok(()) + } + /// Listen to a unix socket for client #[cfg(unix)] pub async fn accept_unix( diff --git a/raw/src/server/tls.rs b/raw/src/server/tls.rs new file mode 100644 index 0000000..482f914 --- /dev/null +++ b/raw/src/server/tls.rs @@ -0,0 +1,13 @@ + + + +struct TlsServerConfig { + +} + + + + + + + diff --git a/raw/tests/conftest.py b/raw/tests/conftest.py index 3dac4b2..25d790a 100644 --- a/raw/tests/conftest.py +++ b/raw/tests/conftest.py @@ -2,22 +2,27 @@ import pytest from xprocess import ProcessStarter + @pytest.fixture -def socket_example(xprocess, request, free_port): +def socket_example(xprocess, request, pytestconfig, free_port): target = os.environ.get("DEBUG_TARGET") if target is not None: port = os.environ.get("SOCKET_PORT", default="5025") yield f"TCPIP::{target}::{port}::SOCKET" else: - port = free_port + port = os.environ.get("SOCKET_PORT", default=str(free_port)) class Starter(ProcessStarter): # startup pattern pattern = "Running server" # Hide warnings - env = {"RUSTFLAGS": "-Awarnings", **os.environ} + env = { + "RUSTFLAGS": "-Awarnings", + # "CARGO_TARGET_DIR": pytestconfig.cache.mkdir("target"), + **os.environ, + } # command to start process args = [ diff --git a/requirements.txt b/requirements.txt index bf75ee0..a493b40 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,10 @@ # Python requirements # Testing -pytest >= 7.1 -pytest-xprocess >= 0.18 -pytest-order >= 1.0.1 +pytest == 7.1 +pytest-xprocess >= 0.19 +pytest-order == 1.0.1 +lxml >= 4.9.1 # VISA framework pyvisa >= 1.11 diff --git a/telnet/tests/conftest.py b/telnet/tests/conftest.py index cdacef2..f1b7491 100644 --- a/telnet/tests/conftest.py +++ b/telnet/tests/conftest.py @@ -2,22 +2,27 @@ import pytest from xprocess import ProcessStarter + @pytest.fixture -def telnet_example(xprocess, request, free_port): +def telnet_example(xprocess, request, pytestconfig, free_port): target = os.environ.get("DEBUG_TARGET") if target is not None: port = os.environ.get("TELNET_PORT", default="5024") yield (target, port) else: - port = free_port + port = port = os.environ.get("TELNET_PORT", default=str(free_port)) class Starter(ProcessStarter): # startup pattern pattern = "Running server" # Hide warnings - env = {"RUSTFLAGS": "-Awarnings", **os.environ} + env = { + "RUSTFLAGS": "-Awarnings", + # "CARGO_TARGET_DIR": pytestconfig.cache.mkdir("target"), + **os.environ, + } # command to start process args = [ diff --git a/update_readme.sh b/update_readme.sh old mode 100755 new mode 100644 diff --git a/vxi11/tests/conftest.py b/vxi11/tests/conftest.py index cc854ff..c1d19ec 100644 --- a/vxi11/tests/conftest.py +++ b/vxi11/tests/conftest.py @@ -2,8 +2,9 @@ import pytest from xprocess import ProcessStarter + @pytest.fixture -def vxi11_example(xprocess, request): +def vxi11_example(xprocess, request, pytestconfig): target = os.environ.get("DEBUG_TARGET") if target is not None: yield f"TCPIP::{target}" @@ -14,7 +15,11 @@ class Starter(ProcessStarter): pattern = "Running server" # Hide warnings - env = {"RUSTFLAGS": "-Awarnings", **os.environ} + env = { + "RUSTFLAGS": "-Awarnings", + # "CARGO_TARGET_DIR": pytestconfig.cache.mkdir("target"), + **os.environ, + } # command to start process args = [