Retrospective of my first useful Rust project
2020-09-05This post is a retrospective of my first "useful" Rust project. vopono is a Linux program to launch applications in temporary network namespaces (managed by vopono), in order to run specific applications through VPN connections, without affecting the rest of your system.
vopono is available on Github (and in the AUR on Arch Linux) and licensed under the GPLv3 license (see reasoning here).
We'll consider the motivation and background to creating vopono, the upsides and downsides of writing it in Rust (and existing issues), and some points about starting new side projects in general. I hope this helps new Rust programmers starting their own first projects, or for other programmers to consider using Rust.
Opinions expressed are solely my own and do not express the views or opinions of my employer.
Background
I've used VPN services for many years, previously as a customer of PrivateInternetAccess, and now with Mullvad (since PrivateInternetAccess was purchased by a less scrupulous parent company), as it is very useful for working around network traffic restrictions (e.g. SSH access restrictions or blocked websites) whilst travelling.
However, I often wanted to be able to quickly connect to the VPN without disrupting other ongoing connections (i.e. video calls, etc.). In 2015, I learnt how network namespaces could do this (on Linux), and pieced together some bash scripts for OpenVPN from this StackExchange post.
I used this for a few years, but it was a bit inconvenient having to manually launch the network namespace. Especially if you wanted to connect to different servers in order to test geolocation for example.
In April 2020, Wireguard was merged in to the Linux kernel 5.6, and became much more readily available with VPN providers. This, combined with the switch to Mullvad, inspired me to add Wireguard support to the scripts I was using. But I thought it would be best to also address the issues of manually managing the network namespaces and create a comprehensive application to handle OpenVPN and Wireguard connections for various VPN providers, and create and destroy the network namespaces on demand.
This was the start of vopono, my first useful Rust project (I suppose s3rename was also useful, but a much smaller scope).
Benefits of Rust
I chose to write vopono in Rust as I am still learning the language, and greatly appreciate the ease of debugging with tools like rust-analyzer and clippy. There are many other benefits too:
Enums
Rust's native enum support makes reasoning and debugging much easier when dealing with enumerated values (like the choice between the TCP and UDP protocols for OpenVPN connections). The Rust compiler forces us to handle every possible value helping to prevent bugs from ever being written.
StructOpt
StructOpt is a great crate for handling command-line options and arguments via derived trait implementations over your structs defining commands and subcommands. This allows you to abstract away dealing with command-line arguments directly, and for the relevant code to be somewhat self-documenting (as doc comments are used to provide the user-facing help output).
Note that some developers prefer to use clap directly.
Result and anyhow
Rust's Result enum
and ?
operator (the try operator)
make it simple and ergonomic to handle operations which may fail
(which are almost all operations when dealing with disk IO and
launching processes).
It is also very convenient when working with fallible operations over a collection, where we may want to return to the user a list of operations which failed. In Rust, we can filter and map over a collection of Results to a collection of Errors and then return that to the user - this feels very natural compared to other languages.
Combined with the anyhow crate, it is easy to provide useful error messages to the end-user whilst also keeping the code very concise.
Serde
The Serde crate provides traits you can derive on your structs, allowing for easy serialization and deserialization.
In vopono this is used to serialize and deserialize lockfiles, so that if you launch a new application in an existing network namespace (via vopono), the namespace will not be destroyed until both applications have terminated.
Drop
The Drop trait allows us to run a destructor when a struct is
dropped (i.e. goes out of scope). This is used in vopono to
automatically destroy the network namespaces when the application is
closed. I initially got the idea for using Drop
this way from the
ALMA source code.
Note this causes some issues (discussed below) when we want to skip
destructors in some cases. Also if vopono is instantly terminated (i.e.
kill -9
) these will likely not run, so vopono is written to clean up
any orphaned resources when it is executed - i.e. namespaces or
lockfiles with no running applications.
Cargo
The Cargo package manager itself is a great benefit of using Rust. For example, when writing vopono it made it trivial to add the compound_duration crate, used only for reporting the uptime of running network namespaces.
The specification of the software license in the Cargo.toml
file is
also a great feature, making it easy to verify that your dependencies
have compatible licenses.
include_str macro
The include_str macro can be used to include a file on disk as a static string in the binary at compile time. This is used in vopono for providers where we cannot download certain files by other means e.g. with TigerVPN because the configuration details are behind a login with a captcha and there is no API.
Rustls
Rustls is a TLS library which can be
used in place of OpenSSL. This is used in the vopono sync
command,
which gets provider configuration files.
This subcommand relies on the reqwest crate to make HTTPS requests, but we want to avoid depending on OpenSSL to make it easier to build a statically linked binary that will be independent of the runtime environment. Fortunately we only need to set the "rustls" feature flag in the reqwest dependency.
musl and static linking
The x86_64-unknown-linux-musl
target can be used to (cross-)compile,
statically linking with musl instead of dynamically linking to glibc
(the default target). This means we can deploy the resulting binary
without worrying about glibc version mismatches (if we deploy to a
platform with an earlier version of glibc).
Difficulties
Small standard library
If you come from scripting languages, you may find that Rust has a
smaller standard library compared to those languages. For example, there
is no recursive copy (cp -r
equivalent) in the standard library
directly, and I had to do this using the
walkdir crate and copying each item.
Compile times
Rust has longer compile times than most other languages (except perhaps C++) this is particularly true when using crates which include procedural macros.
There a few options to reduce compile times (also see this more recent post), such as using sccache to cache build artifacts.
Binary size and feature creep
As more dependencies are added, the final binary size can grow
considerably. To control this, it's recommended to use feature flags
in your Cargo.toml
file (and disabling default features) to
include only what you need from large dependencies.
You can also use cargo-udeps to detect unused dependencies.
Minimum Rust version and dependencies
The Rust features and parts of the standard library that you use will result in an effective minimum Rust version for your project. I had one issue result from the compound_duration crate mentioned above which raised the minimum Rust version to 1.43.
As far as I know there is no way to automatically determine the minimum Rust version, although this discussion on Reddit has scripts for compiling with many minor versions until you build successfully.
Ongoing issues
dialoguer validation and passing references to closures
vopono uses the dialoguer crate for user input for the vopono sync
command. I also validate the input using the validate_with()
method so
that the user gets feedback immediately and can correct any errors.
However, the validate_with()
method requires that the closure used has
a static lifetime.
This is problematic for checking whether the user-entered Wireguard private key
matches the chosen public key, since we need to include the previously-chosen
public key in the closure - but this doesn't have a static lifetime.
For now I worked around this with extra clones (see devices_clone) but hopefully a better solution is possible. Perhaps the static lifetime restriction in dialoguer could also be relaxed (since we know the closure will terminate before we receive the input and continue).
This is tracked in this issue. If you have any suggestions please add a comment there!
Skipping destructors
As mentioned previously, vopono uses the Drop trait to automatically clean up resources when the relevant structs go out of scope. However, sometimes we don't want to trigger these destructors but still have the structs go out of scope - for example, if we have multiple vopono processes running applications in the same network namespace, then we don't want to destroy the network namespace until the final application has terminated. So if other lockfiles still exist, we need to prevent the clean-up destructors from firing.
For now this is done by putting the relevant structs in a Box, and then
calling Box::leak()
(docs here).
This works but feels a bit clunky when dealing with multiple structs/fields (e.g. here preventing the destructors when another vopono instance is using the same namespace):
debug!("Skipping destructors since other vopono instance using this namespace!");
let openvpn = self.openvpn.take();
let openvpn = Box::new(openvpn);
Box::leak(openvpn);
One possible alternative might be to use std::mem::ManuallyDrop
, however then the drop()
method is unsafe, so this might end up being
even less ergonomic.
This is tracked in this issue.
VPN Providers - enum or structs with traits?
The most difficult design decision was regarding how to handle VPN
provider specific code (i.e. the code that generates the OpenVPN and
Wireguard configuration files when using vopono sync
), such that it
would be easy for contributors to add support for new VPN providers.
Ideally we would get as close as possible to the following:
- Contributors only need to add new source code files to add support for a new VPN provider.
- The VPN provider specific code is independent of other VPN provider code.
- The interfaces that the code must provide are clear to the contributor, so they know what they need to implement.
- They do not need to edit the shared/core parts of vopono.
Initially I had planned to use an enum for providers (since we need one to handle command line arguments anyway) and implement the code directly on that enum. However, then all the implementation code would be together in the enum Impl, since we cannot do separate Impl blocks for different enum variants (note that even the enum variant type RFC would prohibit this). This breaks the first 2 points above.
Therefore I chose to use traits, creating a base VpnProvider trait, and then OpenVpnProvider and WireguardProvider traits which have that as a supertrait. This way the interfaces are clear to any contributors and each VPN provider can be a separate struct implementing these traits (it also gives contributors a lot of freedom in how they implement them - as they can store state in the struct, etc.).
The idea was that we could then pass trait objects to the functions that
use the VPN provider objects with dynamic dispatch, i.e. as Box<dyn VpnProvider>
, and then check if the actual struct implements the
necessary WireguardProvider
or OpenVpnProvider
traits depending on
the command line arguments, and return an error if not.
The problem is that there is no way to try to downcast from a supertrait trait object to a subtrait. We want something like:
fn myfn(obj: Box<dyn VpnProvider>) -> anyhow::Result<()> {
let as_wireguard: Box<dyn WireguardProvider> = try_downcast<WireguardProvider>(obj)?;
// ...
}
But no such function exists and, as far as I can tell, the downcast-rs
crate is for downcasting to concrete types, not subtrait trait objects.
This forum post offers
some possibilities using std::mem::transmute
but it is unsafe, and not
guaranteed to be stable (note the need for #[repr(C)]
for example).
For now I worked around this by using the fact that we have the enum from the command line arguments, so we can use that to try to generate the trait object we want:
// Do this since we can't downcast from Provider to other trait objects
impl VpnProvider {
// ...
pub fn get_dyn_wireguard_provider(&self) -> anyhow::Result<Box<dyn WireguardProvider>> {
match self {
Self::Mullvad => Ok(Box::new(mullvad::Mullvad {})),
Self::MozillaVpn => Ok(Box::new(mozilla::MozillaVPN {})),
Self::Custom => Err(anyhow!("Custom provider uses separate logic")),
_ => Err(anyhow!("Wireguard not implemented")),
}
}
But this is a painful workaround as we have a fair amount of boilerplate code and some extra allocation of trait objects.
This also slightly violates the 4th point in the above requirements,
that contributes now have to also add the enum variant entry to
providers/mod.rs
, but at least this is just a small boilerplate change
and the rest of the implementation code can be in its own files with a
lot of freedom regarding implementation.
This issue is tracked on Github here, please add a comment if you have any suggestions as this is quite a generic problem.
Releases
Releases of vopono are made on Github, and published to crates.io and the AUR.
Github Actions
The ebbflow project released Github action workflow files, which I adapted for use in vopono to generate .deb packages with cargo-deb, .rpm packages with cargo-rpm and the stand-alone binaries for release.
Arch User Repository
Releasing a package on the AUR is very simple. Review this wiki page for submitting packages, and this wiki page for the PKGBUILD syntax.
Summary
Overall it has been a longer project than I originally envisioned, but I'm glad I chose to use Rust and add the extra features like automatic killswitches (so traffic outside the VPN in the network namespace is blocked) and Wireguard support.
If you are considering starting a new project in Rust, I hope the issues covered above are useful. Finally, when considering new projects my main advice would be to build something that you can use yourself, and that will be useful to you. There will be times when you hit difficult problems and have little time to spend on the project, and it's very helpful to be able to use the result day-to-day to maintain motivation.
Furthermore, not all feedback will be positive. To quote one comment on Reddit from a post for the first vopono release:
Looking at the monstrosity of the code, this looks like a bash script that someone decided to write in Rust for no apparent reason.
Fortunately, I was later able to get some useful advice on parts of the code to improve (mainly where the design had been copied from the initial shell scripts), despite the commenter's hostility to Rust. If I'd written something that I wasn't using myself, I think this would be much harder to handle. But since I use vopono myself every day, I know that it is useful, and can be sure that it will be useful for others too (even if not for that specific commenter).
I chose to call this post a retrospective instead of a post-mortem, as vopono isn't dead or finished. Hopefully I'll find better solutions to the issues mentioned above, and there are always more VPN providers to support!