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

This commit is contained in:
Lorenz Leutgeb 2023-10-23 19:29:30 +02:00 committed by GitHub
parent 924c682627
commit cc6c2d32f2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 507 additions and 52 deletions

View file

@ -121,6 +121,8 @@
- [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`.

View file

@ -1047,6 +1047,7 @@
./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

View file

@ -0,0 +1,233 @@
{ 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\"";
};
};
}

View file

@ -703,6 +703,7 @@ in {
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 Normal file
View file

@ -0,0 +1,217 @@
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;
};
})

View file

@ -22,5 +22,6 @@ buildGoModule rec {
homepage = "https://github.com/a8m/envsubst";
license = licenses.mit;
maintainers = with maintainers; [ nicknovitski ];
mainProgram = "envsubst";
};
}

View file

@ -1,85 +1,53 @@
{ 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
installShellFiles
];
buildInputs = [
bash # for patchShebangs to find it
libsodium
];
# 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";
};
}

View file

@ -0,0 +1,30 @@
{ 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";
};
}

View file

@ -12827,6 +12827,8 @@ with pkgs;
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 { };