# Motivation
I’ve manually deploy a filehost to a digital ocean VPS in the past. There were several pain points.
Services: setting up nginx and a systemd service for my filehost was a pain. I had to edit a bunch of configuration files every time I wanted to make a change. Testing was hard since as I had to restart nginx and systemd manually at every step.
Authentication: I wanted to authenticate my filehost. I ended up just using a plaintext “key” that I committed to my github. Anyone could have hacked me.
Deploying every time I made a change was painful. I would ssh into the vps where I didn’t have any of my dev tools, make changes in nano, then see if things worked. I was missing my shell and editor. I could have set those things up, but really I ought to have been developing locally, but I didn’t have a way to easily test without deploying.
Certificates: I had to ssh in every 3 months and figure out how to use letsencrypt to generate certs. This was annoying.
Updating/System Maintenance: I had to ssh in and update. Sometimes this broke libraries or services and that I would have to manually fix.
# Prerequisites
I’m assuming some rudimentary knowledge of nix flakes and rust. This is intended as an example of how far flakes can go.
# Rust Filehost
The goal is to deploy a clone of ix.io
. The functionality looks like this:
|❯ echo "hello world" | curl -F 'f:1=<-' -F 'read:1=2' ix.io http://ix.io/M3M
This will make a text file containing “hello world” available at ix.io/M3M
.
This is convenient for sharing small snippets of code.
We would like something similar:
|❯ echo "hello world" | post_code
$SOME_UNIQUE_URL
Rocket.rs is as rust framework that can be used to spin up a set of restful endpoints. For a filehost, there should be two endpoints: a POST endpoint and a GET endpoint. The POST will allow us to post code, and the GET will allow us to retrieve code.
The full repository is here, though I followed this tutorial pretty closely. There’s nothing too novel going on. I’ve got a GET endpoint:
#[get("/code/view/<id>", format = "text/html")]
fn get_code(id: String) -> NamedFile {
NamedFile::open(format!("{}{}{}", get_storage_path(), CODE_PATH, id)).unwrap()
}
This just opens up a preexisting file at some CODE_PATH. Similarly, POST checks auth, then generates a unique filename, then writes to a file.
Nothing too fancy going on so far.
# Flakifying the Rust Filehost
The build should be completely reproducible. That is to say,
regardless of the machine the filehost is build,
the output binary should build exactly the same. Rust’s lock files
provide this for all Rust packages. What remains is to ensure that all builds use
the same version of the compiler, cargo, and any other dependencies required by every build.
These extras come through using Nix Flakes. Naersk
may be used to convert
Cargo lock file into something that Nix can understand and build. For the rust toolchain,
instead of relying on rustup, which is not declarative (it’s a binary host),
we use rust-overlay.
Let’s start by building up the Rust flake. As with any flake,
first we define our inputs
:
{
inputs = utils.url = "github:numtide/flake-utils";
naersk.url = "github:nmattia/naersk";
rust-overlay.url = "github:oxalica/rust-overlay";
};
These define Naersk and rust-overlay’s hosts. Flake-utils contains a bunch of helper functions to build on multiple system architectures.
Next, define flake outputs:
{ self, nixpkgs, utils, naersk, rust-overlay }:
outputs = (system:
utils.lib.eachDefaultSystem # TODO fill in
)
This is a function with the named flake inputs as arguments (attributes of inputs
).
The utils.lib.eachDefaultSystem
can be thought of as a function with signature System -> Outputs
.
eachDefaultSystem
produces a bunch of standard outputs.
Rocket.rs requires nightly rust and cargo. So, the config must tell Nix to use nightly rust and cargo. These obtain these from the rust overlay input:
let pkgs = import nixpkgs {
inherit system;
overlays = [
rust-overlay.overlay(self: super: {
rustc = self.latest.rustChannels.nightly.rust;
cargo = self.latest.rustChannels.nightly.rust;
})
];
};
This imports nixpkgs, and replaces rustc and cargo with their nightly counterpart. Note that we don’t care very much about versions, since nix flakes will pick a version and place it in its lock file.
Next, we need to tell Naersk
to use these nightly versions of packages, as
this is a requirement of rocket.rs
:
-lib = naersk.lib."${system}".override {
naerskcargo = pkgs.cargo;
rustc = pkgs.rustc;
};
in
This overrides the cargo
and rustc
attributes of naersk-lib
tool
use their nightly counterparts.
Finally, we define several outputs:
-lib.buildPackage {
packages.filehost = naerskpname = "filehost";
root = ./.;
/*buildInputs = with pkgs; [];*/
};
defaultPackage = packages.filehost;
{
apps.filehost = utils.lib.mkApp drv = packages.filehost;
};
defaultApp = apps.filehost;
{
devShell = pkgs.mkShell nativeBuildInputs = with pkgs; [ rustc cargo ];
};
});
packages.filehost
defines a filehost package to be our current repo.
defaultPackage
and defaultApp
are fairly self explanatory. devShell
defines a shell accessible by nix develop
that gives access to nightly
rustc and cargo so that the filehost may be develop without having to fight
with nix (really we only want to use nix for deployment).
Now, recall that function eachDefaultSystem
earlier. It now has enough information to generate
these described outputs for four different os/architecture combos. We can observe them
by running nix flake show
in the top level directory of our repo. It gets us this nice colored tree:
git+file:///home/jrestivo/fun/filehost?ref=master&rev=3f43864845d8106275548d24c1b19204447674f2
├───apps
│ ├───aarch64-linux
│ │ └───filehost: app
│ ├───i686-linux
│ │ └───filehost: app
│ ├───x86_64-darwin
│ │ └───filehost: app
│ └───x86_64-linux
│ └───filehost: app
├───defaultApp
│ ├───aarch64-linux: app
│ ├───i686-linux: app
│ ├───x86_64-darwin: app
│ └───x86_64-linux: app
├───defaultPackage
│ ├───aarch64-linux: package 'filehost-0.1.0'
│ ├───i686-linux: package 'filehost-0.1.0'
│ ├───x86_64-darwin: package 'filehost-0.1.0'
│ └───x86_64-linux: package 'filehost-0.1.0'
├───devShell
│ ├───aarch64-linux: development environment 'nix-shell'
│ ├───i686-linux: development environment 'nix-shell'
│ ├───x86_64-darwin: development environment 'nix-shell'
│ └───x86_64-linux: development environment 'nix-shell'
└───packages
├───aarch64-linux
│ └───filehost: package 'filehost-0.1.0'
├───i686-linux
│ └───filehost: package 'filehost-0.1.0'
├───x86_64-darwin
│ └───filehost: package 'filehost-0.1.0'
└───x86_64-linux
└───filehost: package 'filehost-0.1.0'
Before building the package, cargo must build the package to pin cargo package dependencies.
nix develop && cargo build --release
This results in a Cargo.lock
. To tell nix about this lock file, it must be git add
-ed.
Now running nix build .
, will produce the x86_64-linux
system’s version of the pacakge built.
And, we get a flake.lock
. This, similar to Cargo’s lockfile, will tie down
every single dependency used by the flake to the commit.
It’s about as reproducible as possible sans compiler nondeterminism.
In fact, CI may be added to build and cache build binaries and artifacts to be pulled locally later by any matching system. This is particularly easy with Github Actions. This style of automation will be discussed further in another blog post.
# Authentication and Completing the filehost
Before we deploy, we want to make sure there is at least a bit of authentication for our app. A simple way to do this is by passing in an environment variable containing a secret when the app is started. Then, upon post request, check that the key passed in with the path parameters of the post request match that key. Note that if this parameter passing is done over https (which we will force later on) then the secret key shall be encrypted.
I won’t go through the rust code to do this, but in order to test out the file host,
(it should be fairly self explanatory), but clone here
and nix build .
. Then running:
STORAGE_PATH=$SOME_PATH SECRET_PASSWORD=$SOME_SECRET_PASSWORD ./result/bin/filehost
will run the filehost locally, using SECRET_PASSWORD
as the method of authentication
and SOME_PATH
as the path to store files at. Using tmp
seems easy as a start.
To test the auth, one can define the following zsh functions:
function post_code {
SECRET_PASSWORD="secret_password"
INPUT_UNESCAPED=$(cat)
INPUT=''${INPUT_UNESCAPED//\\/\\\\}
echo -n "https://filehost.restivo.me/code/view/"$(echo -n '{"key":"'$SECRET_PASSWORD'","src":"'$(echo $INPUT | base64)'"}' | curl -X POST https://filehost.restivo.me/code/post -H 'Content-Type: application/json' --data @- | jq -r '.link') | xclip -selection clipboard
}
Example usage could be echo "hello world" | post_code
.
Unpacking what’s going on:
- The secret password used for auth is defined in plaintext. This is not good practice, and will be fixed later on with sops.
INPUT_UNESCAPED
is stdin. In the examplehello world
.INPUT
escapes\
inINPUT_UNESCAPED
so the slashes will be preserved during the post request.- The last line creates creates the post request. The post endpoint url is
filehost.restivo.me/code/post/
. Parameters are sent in with json in an encrypted html header in a json format. Thekey
is provided in plaintext, and the$INPUT
is base64encoded for easier transmission.--data @-
specifies to provide post data from stdin. The result of the post is piped tojq
which parses the resulting json and retrieves thelink
attribute. This is then copied onto the clipboard (assuming the use of X server).
The data is encrypted using https, so this is “somewhat” secure. It fits the goal of basic authentication and DDOS prevention without being fancy.
At this point, a working filehost
flake has been written, and includes authentication.
Now we may deploy.
# Secret Management
The first question here is “how can secrets such as a passphrase be stored on nix?”
A popular solution, independent of nix, is to use mozilla’s sops
tool. Nix-sops wraps this super easily.
I’m not going to reiterate the README, but will summarize in a few sentences.
Starting with a basic flakes system configuration file, sops-nix
may be added
an input. Then, sops
can be put into the module list in the server (host’s)
module list. Finally, the server’s ssh keys can be converted to
pgp keys. Add a devshell
and set SOPS_PGP_FP
to a list of private PGP keys,
nix develop
, then run sops secrets.yaml
. This sops
command allows decrypted editing
of a a yaml list of secrets that is encrypted with the listed of private pgp keys on write.
So, secrets may be added, such as the filehost secret key, then committed to the
configuration git repo and pushed to a git host (in this case github).
TODO finish the rest of this post
# Systemd Service
, our filehost service ought to run automatically, out of the box. To do this, we write a nixos module that will automatically run our filehost.