rosenpass: refactor, add module and test (#254813)

Changed files
+507 -52
nixos
doc
manual
release-notes
modules
services
networking
tests
pkgs
tools
misc
envsubst
networking
top-level
+2
nixos/doc/manual/release-notes/rl-2311.section.md
···
- [Soft Serve](https://github.com/charmbracelet/soft-serve), a tasty, self-hostable Git server for the command line. Available as [services.soft-serve](#opt-services.soft-serve.enable).
+
- [Rosenpass](https://rosenpass.eu/), a service for post-quantum-secure VPNs with WireGuard. Available as [services.rosenpass](#opt-services.rosenpass.enable).
+
## Backward Incompatibilities {#sec-release-23.11-incompatibilities}
- `network-online.target` has been fixed to no longer time out for systems with `networking.useDHCP = true` and `networking.useNetworkd = true`.
+1
nixos/modules/module-list.nix
···
./services/networking/redsocks.nix
./services/networking/resilio.nix
./services/networking/robustirc-bridge.nix
+
./services/networking/rosenpass.nix
./services/networking/routedns.nix
./services/networking/rpcbind.nix
./services/networking/rxe.nix
+233
nixos/modules/services/networking/rosenpass.nix
···
+
{ config
+
, lib
+
, options
+
, pkgs
+
, ...
+
}:
+
let
+
inherit (lib)
+
attrValues
+
concatLines
+
concatMap
+
filter
+
filterAttrsRecursive
+
flatten
+
getExe
+
mdDoc
+
mkIf
+
optional
+
;
+
+
cfg = config.services.rosenpass;
+
opt = options.services.rosenpass;
+
settingsFormat = pkgs.formats.toml { };
+
in
+
{
+
options.services.rosenpass =
+
let
+
inherit (lib)
+
literalExpression
+
mdDoc
+
mkOption
+
;
+
inherit (lib.types)
+
enum
+
listOf
+
nullOr
+
path
+
str
+
submodule
+
;
+
in
+
{
+
enable = lib.mkEnableOption (mdDoc "Rosenpass");
+
+
package = lib.mkPackageOption pkgs "rosenpass" { };
+
+
defaultDevice = mkOption {
+
type = nullOr str;
+
description = mdDoc "Name of the network interface to use for all peers by default.";
+
example = "wg0";
+
};
+
+
settings = mkOption {
+
type = submodule {
+
freeformType = settingsFormat.type;
+
+
options = {
+
public_key = mkOption {
+
type = path;
+
description = mdDoc "Path to a file containing the public key of the local Rosenpass peer. Generate this by running {command}`rosenpass gen-keys`.";
+
};
+
+
secret_key = mkOption {
+
type = path;
+
description = mdDoc "Path to a file containing the secret key of the local Rosenpass peer. Generate this by running {command}`rosenpass gen-keys`.";
+
};
+
+
listen = mkOption {
+
type = listOf str;
+
description = mdDoc "List of local endpoints to listen for connections.";
+
default = [ ];
+
example = literalExpression "[ \"0.0.0.0:10000\" ]";
+
};
+
+
verbosity = mkOption {
+
type = enum [ "Verbose" "Quiet" ];
+
default = "Quiet";
+
description = mdDoc "Verbosity of output produced by the service.";
+
};
+
+
peers =
+
let
+
peer = submodule {
+
freeformType = settingsFormat.type;
+
+
options = {
+
public_key = mkOption {
+
type = path;
+
description = mdDoc "Path to a file containing the public key of the remote Rosenpass peer.";
+
};
+
+
endpoint = mkOption {
+
type = nullOr str;
+
default = null;
+
description = mdDoc "Endpoint of the remote Rosenpass peer.";
+
};
+
+
device = mkOption {
+
type = str;
+
default = cfg.defaultDevice;
+
defaultText = literalExpression "config.${opt.defaultDevice}";
+
description = mdDoc "Name of the local WireGuard interface to use for this peer.";
+
};
+
+
peer = mkOption {
+
type = str;
+
description = mdDoc "WireGuard public key corresponding to the remote Rosenpass peer.";
+
};
+
};
+
};
+
in
+
mkOption {
+
type = listOf peer;
+
description = mdDoc "List of peers to exchange keys with.";
+
default = [ ];
+
};
+
};
+
};
+
default = { };
+
description = mdDoc "Configuration for Rosenpass, see <https://rosenpass.eu/> for further information.";
+
};
+
};
+
+
config = mkIf cfg.enable {
+
warnings =
+
let
+
# NOTE: In the descriptions below, we tried to refer to e.g.
+
# options.systemd.network.netdevs."<name>".wireguardPeers.*.PublicKey
+
# directly, but don't know how to traverse "<name>" and * in this path.
+
extractions = [
+
{
+
relevant = config.systemd.network.enable;
+
root = config.systemd.network.netdevs;
+
peer = (x: x.wireguardPeers);
+
key = (x: if x.wireguardPeerConfig ? PublicKey then x.wireguardPeerConfig.PublicKey else null);
+
description = mdDoc "${options.systemd.network.netdevs}.\"<name>\".wireguardPeers.*.wireguardPeerConfig.PublicKey";
+
}
+
{
+
relevant = config.networking.wireguard.enable;
+
root = config.networking.wireguard.interfaces;
+
peer = (x: x.peers);
+
key = (x: x.publicKey);
+
description = mdDoc "${options.networking.wireguard.interfaces}.\"<name>\".peers.*.publicKey";
+
}
+
rec {
+
relevant = root != { };
+
root = config.networking.wg-quick.interfaces;
+
peer = (x: x.peers);
+
key = (x: x.publicKey);
+
description = mdDoc "${options.networking.wg-quick.interfaces}.\"<name>\".peers.*.publicKey";
+
}
+
];
+
relevantExtractions = filter (x: x.relevant) extractions;
+
extract = { root, peer, key, ... }:
+
filter (x: x != null) (flatten (concatMap (x: (map key (peer x))) (attrValues root)));
+
configuredKeys = flatten (map extract relevantExtractions);
+
itemize = xs: concatLines (map (x: " - ${x}") xs);
+
descriptions = map (x: "`${x.description}`");
+
missingKeys = filter (key: !builtins.elem key configuredKeys) (map (x: x.peer) cfg.settings.peers);
+
unusual = ''
+
While this may work as expected, e.g. you want to manually configure WireGuard,
+
such a scenario is unusual. Please double-check your configuration.
+
'';
+
in
+
(optional (relevantExtractions != [ ] && missingKeys != [ ]) ''
+
You have configured Rosenpass peers with the WireGuard public keys:
+
${itemize missingKeys}
+
But there is no corresponding active Wireguard peer configuration in any of:
+
${itemize (descriptions relevantExtractions)}
+
${unusual}
+
'')
+
++
+
optional (relevantExtractions == [ ]) ''
+
You have configured Rosenpass, but you have not configured Wireguard via any of:
+
${itemize (descriptions extractions)}
+
${unusual}
+
'';
+
+
environment.systemPackages = [ cfg.package pkgs.wireguard-tools ];
+
+
systemd.services.rosenpass =
+
let
+
filterNonNull = filterAttrsRecursive (_: v: v != null);
+
config = settingsFormat.generate "config.toml" (
+
filterNonNull (cfg.settings
+
//
+
(
+
let
+
credentialPath = id: "$CREDENTIALS_DIRECTORY/${id}";
+
# NOTE: We would like to remove all `null` values inside `cfg.settings`
+
# recursively, since `settingsFormat.generate` cannot handle `null`.
+
# This would require to traverse both attribute sets and lists recursively.
+
# `filterAttrsRecursive` only recurses into attribute sets, but not
+
# into values that might contain other attribute sets (such as lists,
+
# e.g. `cfg.settings.peers`). Here, we just specialize on `cfg.settings.peers`,
+
# and this may break unexpectedly whenever a `null` value is contained
+
# in a list in `cfg.settings`, other than `cfg.settings.peers`.
+
peersWithoutNulls = map filterNonNull cfg.settings.peers;
+
in
+
{
+
secret_key = credentialPath "pqsk";
+
public_key = credentialPath "pqpk";
+
peers = peersWithoutNulls;
+
}
+
)
+
)
+
);
+
in
+
rec {
+
wantedBy = [ "multi-user.target" ];
+
after = [ "network-online.target" ];
+
path = [ cfg.package pkgs.wireguard-tools ];
+
+
serviceConfig = {
+
User = "rosenpass";
+
Group = "rosenpass";
+
RuntimeDirectory = "rosenpass";
+
DynamicUser = true;
+
AmbientCapabilities = [ "CAP_NET_ADMIN" ];
+
LoadCredential = [
+
"pqsk:${cfg.settings.secret_key}"
+
"pqpk:${cfg.settings.public_key}"
+
];
+
};
+
+
# See <https://www.freedesktop.org/software/systemd/man/systemd.unit.html#Specifiers>
+
environment.CONFIG = "%t/${serviceConfig.RuntimeDirectory}/config.toml";
+
+
preStart = "${getExe pkgs.envsubst} -i ${config} -o \"$CONFIG\"";
+
script = "rosenpass exchange-config \"$CONFIG\"";
+
};
+
};
+
}
+1
nixos/tests/all-tests.nix
···
rkvm = handleTest ./rkvm {};
robustirc-bridge = handleTest ./robustirc-bridge.nix {};
roundcube = handleTest ./roundcube.nix {};
+
rosenpass = handleTest ./rosenpass.nix {};
rshim = handleTest ./rshim.nix {};
rspamd = handleTest ./rspamd.nix {};
rss2email = handleTest ./rss2email.nix {};
+217
nixos/tests/rosenpass.nix
···
+
import ./make-test-python.nix ({ pkgs, ... }:
+
let
+
deviceName = "rp0";
+
+
server = {
+
ip = "fe80::1";
+
wg = {
+
public = "mQufmDFeQQuU/fIaB2hHgluhjjm1ypK4hJr1cW3WqAw=";
+
secret = "4N5Y1dldqrpsbaEiY8O0XBUGUFf8vkvtBtm8AoOX7Eo=";
+
listen = 10000;
+
};
+
};
+
client = {
+
ip = "fe80::2";
+
wg = {
+
public = "Mb3GOlT7oS+F3JntVKiaD7SpHxLxNdtEmWz/9FMnRFU=";
+
secret = "uC5dfGMv7Oxf5UDfdPkj6rZiRZT2dRWp5x8IQxrNcUE=";
+
};
+
};
+
in
+
{
+
name = "rosenpass";
+
+
nodes =
+
let
+
shared = peer: { config, modulesPath, ... }: {
+
imports = [ "${modulesPath}/services/networking/rosenpass.nix" ];
+
+
boot.kernelModules = [ "wireguard" ];
+
+
services.rosenpass = {
+
enable = true;
+
defaultDevice = deviceName;
+
settings = {
+
verbosity = "Verbose";
+
public_key = "/etc/rosenpass/pqpk";
+
secret_key = "/etc/rosenpass/pqsk";
+
};
+
};
+
+
networking.firewall.allowedUDPPorts = [ 9999 ];
+
+
systemd.network = {
+
enable = true;
+
networks."rosenpass" = {
+
matchConfig.Name = deviceName;
+
networkConfig.IPForward = true;
+
address = [ "${peer.ip}/64" ];
+
};
+
+
netdevs."10-rp0" = {
+
netdevConfig = {
+
Kind = "wireguard";
+
Name = deviceName;
+
};
+
wireguardConfig.PrivateKeyFile = "/etc/wireguard/wgsk";
+
};
+
};
+
+
environment.etc."wireguard/wgsk" = {
+
text = peer.wg.secret;
+
user = "systemd-network";
+
group = "systemd-network";
+
};
+
};
+
in
+
{
+
server = {
+
imports = [ (shared server) ];
+
+
networking.firewall.allowedUDPPorts = [ server.wg.listen ];
+
+
systemd.network.netdevs."10-${deviceName}" = {
+
wireguardConfig.ListenPort = server.wg.listen;
+
wireguardPeers = [
+
{
+
wireguardPeerConfig = {
+
AllowedIPs = [ "::/0" ];
+
PublicKey = client.wg.public;
+
};
+
}
+
];
+
};
+
+
services.rosenpass.settings = {
+
listen = [ "0.0.0.0:9999" ];
+
peers = [
+
{
+
public_key = "/etc/rosenpass/peers/client/pqpk";
+
peer = client.wg.public;
+
}
+
];
+
};
+
};
+
client = {
+
imports = [ (shared client) ];
+
+
systemd.network.netdevs."10-${deviceName}".wireguardPeers = [
+
{
+
wireguardPeerConfig = {
+
AllowedIPs = [ "::/0" ];
+
PublicKey = server.wg.public;
+
Endpoint = "server:${builtins.toString server.wg.listen}";
+
};
+
}
+
];
+
+
services.rosenpass.settings.peers = [
+
{
+
public_key = "/etc/rosenpass/peers/server/pqpk";
+
endpoint = "server:9999";
+
peer = server.wg.public;
+
}
+
];
+
};
+
};
+
+
testScript = { ... }: ''
+
from os import system
+
+
# Full path to rosenpass in the store, to avoid fiddling with `$PATH`.
+
rosenpass = "${pkgs.rosenpass}/bin/rosenpass"
+
+
# Path in `/etc` where keys will be placed.
+
etc = "/etc/rosenpass"
+
+
start_all()
+
+
for machine in [server, client]:
+
machine.wait_for_unit("multi-user.target")
+
+
# Gently stop Rosenpass to avoid crashes during key generation/distribution.
+
for machine in [server, client]:
+
machine.execute("systemctl stop rosenpass.service")
+
+
for (name, machine, remote) in [("server", server, client), ("client", client, server)]:
+
pk, sk = f"{name}.pqpk", f"{name}.pqsk"
+
system(f"{rosenpass} gen-keys --force --secret-key {sk} --public-key {pk}")
+
machine.copy_from_host(sk, f"{etc}/pqsk")
+
machine.copy_from_host(pk, f"{etc}/pqpk")
+
remote.copy_from_host(pk, f"{etc}/peers/{name}/pqpk")
+
+
for machine in [server, client]:
+
machine.execute("systemctl start rosenpass.service")
+
+
for machine in [server, client]:
+
machine.wait_for_unit("rosenpass.service")
+
+
with subtest("ping"):
+
client.succeed("ping -c 2 -i 0.5 ${server.ip}%${deviceName}")
+
+
with subtest("preshared-keys"):
+
# Rosenpass works by setting the WireGuard preshared key at regular intervals.
+
# Thus, if it is not active, then no key will be set, and the output of `wg show` will contain "none".
+
# Otherwise, if it is active, then the key will be set and "none" will not be found in the output of `wg show`.
+
for machine in [server, client]:
+
machine.wait_until_succeeds("wg show all preshared-keys | grep --invert-match none", timeout=5)
+
'';
+
+
# NOTE: Below configuration is for "interactive" (=developing/debugging) only.
+
interactive.nodes =
+
let
+
inherit (import ./ssh-keys.nix pkgs) snakeOilPublicKey snakeOilPrivateKey;
+
+
sshAndKeyGeneration = {
+
services.openssh.enable = true;
+
users.users.root.openssh.authorizedKeys.keys = [ snakeOilPublicKey ];
+
environment.systemPackages = [
+
(pkgs.writeShellApplication {
+
name = "gen-keys";
+
runtimeInputs = [ pkgs.rosenpass ];
+
text = ''
+
HOST="$(hostname)"
+
if [ "$HOST" == "server" ]
+
then
+
PEER="client"
+
else
+
PEER="server"
+
fi
+
+
# Generate keypair.
+
mkdir -vp /etc/rosenpass/peers/$PEER
+
rosenpass gen-keys --force --secret-key /etc/rosenpass/pqsk --public-key /etc/rosenpass/pqpk
+
+
# Set up SSH key.
+
mkdir -p /root/.ssh
+
cp ${snakeOilPrivateKey} /root/.ssh/id_ecdsa
+
chmod 0400 /root/.ssh/id_ecdsa
+
+
# Copy public key to other peer.
+
# shellcheck disable=SC2029
+
ssh -o StrictHostKeyChecking=no $PEER "mkdir -pv /etc/rosenpass/peers/$HOST"
+
scp /etc/rosenpass/pqpk "$PEER:/etc/rosenpass/peers/$HOST/pqpk"
+
'';
+
})
+
];
+
};
+
+
# Use kmscon <https://www.freedesktop.org/wiki/Software/kmscon/>
+
# to provide a slightly nicer console, and while we're at it,
+
# also use a nice font.
+
# With kmscon, we can for example zoom in/out using [Ctrl] + [+]
+
# and [Ctrl] + [-]
+
niceConsoleAndAutologin.services.kmscon = {
+
enable = true;
+
autologinUser = "root";
+
fonts = [{
+
name = "Fira Code";
+
package = pkgs.fira-code;
+
}];
+
};
+
in
+
{
+
server = sshAndKeyGeneration // niceConsoleAndAutologin;
+
client = sshAndKeyGeneration // niceConsoleAndAutologin;
+
};
+
})
+1
pkgs/tools/misc/envsubst/default.nix
···
homepage = "https://github.com/a8m/envsubst";
license = licenses.mit;
maintainers = with maintainers; [ nicknovitski ];
+
mainProgram = "envsubst";
};
}
+20 -52
pkgs/tools/networking/rosenpass/default.nix
···
{ lib
-
, targetPlatform
, fetchFromGitHub
+
, nixosTests
, rustPlatform
+
, targetPlatform
+
, installShellFiles
, cmake
-
, makeWrapper
-
, pkg-config
-
, removeReferencesTo
-
, coreutils
-
, findutils
-
, gawk
-
, wireguard-tools
-
, bash
, libsodium
+
, pkg-config
}:
-
-
let
-
rpBinPath = lib.makeBinPath [
-
coreutils
-
findutils
-
gawk
-
wireguard-tools
-
];
-
in
rustPlatform.buildRustPackage rec {
pname = "rosenpass";
-
version = "0.2.0";
+
version = "unstable-2023-09-28";
+
src = fetchFromGitHub {
owner = pname;
repo = pname;
-
rev = "v${version}";
-
sha256 = "sha256-r7/3C5DzXP+9w4rp9XwbP+/NK1axIP6s3Iiio1xRMbk=";
+
rev = "b15f17133f8b5c3c5175b4cfd4fc10039a4e203f";
+
hash = "sha256-UXAkmt4VY0irLK2k4t6SW+SEodFE3CbX5cFbsPG0ZCo=";
};
-
cargoHash = "sha256-g2w3lZXQ3Kg3ydKdFs8P2lOPfIkfTbAF0MhxsJoX/E4=";
+
cargoHash = "sha256-N1DQHkgKgkDQ6DbgQJlpZkZ7AMTqX3P8R/cWr14jK2I=";
nativeBuildInputs = [
cmake # for oqs build in the oqs-sys crate
-
makeWrapper # for the rp shellscript
-
pkg-config # let libsodium-sys-stable find libsodium
-
removeReferencesTo
+
pkg-config
rustPlatform.bindgenHook # for C-bindings in the crypto libs
-
];
-
-
buildInputs = [
-
bash # for patchShebangs to find it
-
libsodium
+
installShellFiles
];
-
# otherwise pkg-config tries to link non-existent dynamic libs during the build of liboqs
-
PKG_CONFIG_ALL_STATIC = true;
-
-
# liboqs requires quite a lot of stack memory, thus we adjust the default stack size picked for
-
# new threads (which is used by `cargo test`) to be _big enough_
-
RUST_MIN_STACK = 8 * 1024 * 1024; # 8 MiB
+
buildInputs = [ libsodium ];
# nix defaults to building for aarch64 _without_ the armv8-a
# crypto extensions, but liboqs depends on these
-
preBuild = lib.optionalString targetPlatform.isAarch
-
''NIX_CFLAGS_COMPILE="$NIX_CFLAGS_COMPILE -march=armv8-a+crypto"'';
-
-
preInstall = ''
-
install -D rp $out/bin/rp
-
wrapProgram $out/bin/rp --prefix PATH : "${ rpBinPath }"
-
for file in doc/*.1
-
do
-
install -D $file $out/share/man/man1/''${file##*/}
-
done
+
preBuild = lib.optionalString targetPlatform.isAarch64 ''
+
NIX_CFLAGS_COMPILE="$NIX_CFLAGS_COMPILE -march=armv8-a+crypto"
'';
-
# nix propagates the *.dev outputs of buildInputs for static builds, but that is non-sense for an
-
# executables only package
-
postFixup = ''
-
find -type f -exec remove-references-to -t ${bash.dev} \
-
-t ${libsodium.dev} {} \;
+
postInstall = ''
+
installManPage doc/rosenpass.1
'';
+
passthru.tests.rosenpass = nixosTests.rosenpass;
+
meta = with lib; {
description = "Build post-quantum-secure VPNs with WireGuard!";
homepage = "https://rosenpass.eu/";
license = with licenses; [ mit /* or */ asl20 ];
maintainers = with maintainers; [ wucke13 ];
-
platforms = platforms.all;
+
platforms = [ "aarch64-darwin" "aarch64-linux" "x86_64-darwin" "x86_64-linux" ];
+
mainProgram = "rosenpass";
};
}
+30
pkgs/tools/networking/rosenpass/tools.nix
···
+
{ lib
+
, stdenv
+
, makeWrapper
+
, installShellFiles
+
, coreutils
+
, findutils
+
, gawk
+
, rosenpass
+
, wireguard-tools
+
}:
+
stdenv.mkDerivation {
+
inherit (rosenpass) version src;
+
pname = "rosenpass-tools";
+
+
nativeBuildInputs = [ makeWrapper installShellFiles ];
+
+
postInstall = ''
+
install -D $src/rp $out/bin/rp
+
installManPage $src/doc/rp.1
+
wrapProgram $out/bin/rp \
+
--prefix PATH : ${lib.makeBinPath [
+
coreutils findutils gawk rosenpass wireguard-tools
+
]}
+
'';
+
+
meta = rosenpass.meta // {
+
description = "This package contains the Rosenpass tool `rp`, which is a script that wraps the `rosenpass` binary.";
+
mainProgram = "rp";
+
};
+
}
+2
pkgs/top-level/all-packages.nix
···
rosenpass = callPackage ../tools/networking/rosenpass { };
+
rosenpass-tools = callPackage ../tools/networking/rosenpass/tools.nix { };
+
rot8 = callPackage ../tools/misc/rot8 { };
rowhammer-test = callPackage ../tools/system/rowhammer-test { };