Merge pull request #27958 from LumiGuide/strongswan-swanctl
nixos: add the strongswan-swanctl service
This commit is contained in:
commit
e4717c902f
|
@ -560,6 +560,7 @@
|
||||||
./services/networking/ssh/lshd.nix
|
./services/networking/ssh/lshd.nix
|
||||||
./services/networking/ssh/sshd.nix
|
./services/networking/ssh/sshd.nix
|
||||||
./services/networking/strongswan.nix
|
./services/networking/strongswan.nix
|
||||||
|
./services/networking/strongswan-swanctl/module.nix
|
||||||
./services/networking/stunnel.nix
|
./services/networking/stunnel.nix
|
||||||
./services/networking/supplicant.nix
|
./services/networking/supplicant.nix
|
||||||
./services/networking/supybot.nix
|
./services/networking/supybot.nix
|
||||||
|
|
|
@ -0,0 +1,82 @@
|
||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
with (import ./param-lib.nix lib);
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.strongswan-swanctl;
|
||||||
|
swanctlParams = import ./swanctl-params.nix lib;
|
||||||
|
in {
|
||||||
|
options.services.strongswan-swanctl = {
|
||||||
|
enable = mkEnableOption "strongswan-swanctl service";
|
||||||
|
|
||||||
|
package = mkOption {
|
||||||
|
type = types.package;
|
||||||
|
default = pkgs.strongswan;
|
||||||
|
defaultText = "pkgs.strongswan";
|
||||||
|
description = ''
|
||||||
|
The strongswan derivation to use.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
strongswan.extraConfig = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "";
|
||||||
|
description = ''
|
||||||
|
Contents of the <literal>strongswan.conf</literal> file.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
swanctl = paramsToOptions swanctlParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
config = mkIf cfg.enable {
|
||||||
|
|
||||||
|
assertions = [
|
||||||
|
{ assertion = !config.services.strongswan.enable;
|
||||||
|
message = "cannot enable both services.strongswan and services.strongswan-swanctl. Choose either one.";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
environment.etc."swanctl/swanctl.conf".text =
|
||||||
|
paramsToConf cfg.swanctl swanctlParams;
|
||||||
|
|
||||||
|
# The swanctl command complains when the following directories don't exist:
|
||||||
|
# See: https://wiki.strongswan.org/projects/strongswan/wiki/Swanctldirectory
|
||||||
|
system.activationScripts.strongswan-swanctl-etc = stringAfter ["etc"] ''
|
||||||
|
mkdir -p '/etc/swanctl/x509' # Trusted X.509 end entity certificates
|
||||||
|
mkdir -p '/etc/swanctl/x509ca' # Trusted X.509 Certificate Authority certificates
|
||||||
|
mkdir -p '/etc/swanctl/x509ocsp'
|
||||||
|
mkdir -p '/etc/swanctl/x509aa' # Trusted X.509 Attribute Authority certificates
|
||||||
|
mkdir -p '/etc/swanctl/x509ac' # Attribute Certificates
|
||||||
|
mkdir -p '/etc/swanctl/x509crl' # Certificate Revocation Lists
|
||||||
|
mkdir -p '/etc/swanctl/pubkey' # Raw public keys
|
||||||
|
mkdir -p '/etc/swanctl/private' # Private keys in any format
|
||||||
|
mkdir -p '/etc/swanctl/rsa' # PKCS#1 encoded RSA private keys
|
||||||
|
mkdir -p '/etc/swanctl/ecdsa' # Plain ECDSA private keys
|
||||||
|
mkdir -p '/etc/swanctl/bliss'
|
||||||
|
mkdir -p '/etc/swanctl/pkcs8' # PKCS#8 encoded private keys of any type
|
||||||
|
mkdir -p '/etc/swanctl/pkcs12' # PKCS#12 containers
|
||||||
|
'';
|
||||||
|
|
||||||
|
systemd.services.strongswan-swanctl = {
|
||||||
|
description = "strongSwan IPsec IKEv1/IKEv2 daemon using swanctl";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "network-online.target" "keys.target" ];
|
||||||
|
wants = [ "keys.target" ];
|
||||||
|
path = with pkgs; [ kmod iproute iptables utillinux ];
|
||||||
|
environment.STRONGSWAN_CONF = pkgs.writeTextFile {
|
||||||
|
name = "strongswan.conf";
|
||||||
|
text = cfg.strongswan.extraConfig;
|
||||||
|
};
|
||||||
|
restartTriggers = [ config.environment.etc."swanctl/swanctl.conf".source ];
|
||||||
|
serviceConfig = {
|
||||||
|
ExecStart = "${cfg.package}/sbin/charon-systemd";
|
||||||
|
Type = "notify";
|
||||||
|
ExecStartPost = "${cfg.package}/sbin/swanctl --load-all --noprompt";
|
||||||
|
ExecReload = "${cfg.package}/sbin/swanctl --reload";
|
||||||
|
Restart = "on-abnormal";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,162 @@
|
||||||
|
# In the following context a parameter is an attribute set that
|
||||||
|
# contains a NixOS option and a render function. It also contains the
|
||||||
|
# attribute: '_type = "param"' so we can distinguish it from other
|
||||||
|
# sets.
|
||||||
|
#
|
||||||
|
# The render function is used to convert the value of the option to a
|
||||||
|
# snippet of strongswan.conf. Most parameters simply render their
|
||||||
|
# value to a string. For example, take the following parameter:
|
||||||
|
#
|
||||||
|
# threads = mkIntParam 10 "Threads to use for request handling.";
|
||||||
|
#
|
||||||
|
# When a users defines the corresponding option as for example:
|
||||||
|
#
|
||||||
|
# services.strongswan-swanctl.strongswan.threads = 32;
|
||||||
|
#
|
||||||
|
# It will get rendered to the following snippet in strongswan.conf:
|
||||||
|
#
|
||||||
|
# threads = 32
|
||||||
|
#
|
||||||
|
# Some parameters however need to be able to change the attribute
|
||||||
|
# name. For example, take the following parameter:
|
||||||
|
#
|
||||||
|
# id = mkPrefixedAttrsOfParam (mkOptionalStrParam "") "...";
|
||||||
|
#
|
||||||
|
# A user can define the corresponding option as for example:
|
||||||
|
#
|
||||||
|
# id = {
|
||||||
|
# "foo" = "bar";
|
||||||
|
# "baz" = "qux";
|
||||||
|
# };
|
||||||
|
#
|
||||||
|
# This will get rendered to the following snippet:
|
||||||
|
#
|
||||||
|
# foo-id = bar
|
||||||
|
# baz-id = qux
|
||||||
|
#
|
||||||
|
# For this reason the render function is not simply a function from
|
||||||
|
# value -> string but a function from a value to an attribute set:
|
||||||
|
# { "${name}" = string }. This allows parameters to change the attribute
|
||||||
|
# name like in the previous example.
|
||||||
|
|
||||||
|
lib :
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
with (import ./param-lib.nix lib);
|
||||||
|
|
||||||
|
rec {
|
||||||
|
mkParamOfType = type : strongswanDefault : description : {
|
||||||
|
_type = "param";
|
||||||
|
option = mkOption {
|
||||||
|
type = types.nullOr type;
|
||||||
|
default = null;
|
||||||
|
description = documentDefault description strongswanDefault;
|
||||||
|
};
|
||||||
|
render = single toString;
|
||||||
|
};
|
||||||
|
|
||||||
|
documentDefault = description : strongswanDefault :
|
||||||
|
if isNull strongswanDefault
|
||||||
|
then description
|
||||||
|
else description + ''
|
||||||
|
</para><para>
|
||||||
|
StrongSwan default: <literal><![CDATA[${builtins.toJSON strongswanDefault}]]></literal>
|
||||||
|
'';
|
||||||
|
|
||||||
|
single = f: name: value: { "${name}" = f value; };
|
||||||
|
|
||||||
|
mkStrParam = mkParamOfType types.str;
|
||||||
|
mkOptionalStrParam = mkStrParam null;
|
||||||
|
|
||||||
|
mkEnumParam = values : mkParamOfType (types.enum values);
|
||||||
|
|
||||||
|
mkIntParam = mkParamOfType types.int;
|
||||||
|
mkOptionalIntParam = mkIntParam null;
|
||||||
|
|
||||||
|
# We should have floats in Nix...
|
||||||
|
mkFloatParam = mkStrParam;
|
||||||
|
|
||||||
|
# TODO: Check for hex format:
|
||||||
|
mkHexParam = mkStrParam;
|
||||||
|
mkOptionalHexParam = mkOptionalStrParam;
|
||||||
|
|
||||||
|
# TODO: Check for duration format:
|
||||||
|
mkDurationParam = mkStrParam;
|
||||||
|
mkOptionalDurationParam = mkOptionalStrParam;
|
||||||
|
|
||||||
|
mkYesNoParam = strongswanDefault : description : {
|
||||||
|
_type = "param";
|
||||||
|
option = mkOption {
|
||||||
|
type = types.nullOr types.bool;
|
||||||
|
default = null;
|
||||||
|
description = documentDefault description strongswanDefault;
|
||||||
|
};
|
||||||
|
render = single (b: if b then "yes" else "no");
|
||||||
|
};
|
||||||
|
yes = true;
|
||||||
|
no = false;
|
||||||
|
|
||||||
|
mkSpaceSepListParam = mkSepListParam " ";
|
||||||
|
mkCommaSepListParam = mkSepListParam ",";
|
||||||
|
|
||||||
|
mkSepListParam = sep : strongswanDefault : description : {
|
||||||
|
_type = "param";
|
||||||
|
option = mkOption {
|
||||||
|
type = types.nullOr (types.listOf types.str);
|
||||||
|
default = null;
|
||||||
|
description = documentDefault description strongswanDefault;
|
||||||
|
};
|
||||||
|
render = single (value: concatStringsSep sep value);
|
||||||
|
};
|
||||||
|
|
||||||
|
mkAttrsOfParams = params :
|
||||||
|
mkAttrsOf params (types.submodule {options = paramsToOptions params;});
|
||||||
|
|
||||||
|
mkAttrsOfParam = param :
|
||||||
|
mkAttrsOf param param.option.type;
|
||||||
|
|
||||||
|
mkAttrsOf = param : option : description : {
|
||||||
|
_type = "param";
|
||||||
|
option = mkOption {
|
||||||
|
type = types.attrsOf option;
|
||||||
|
default = {};
|
||||||
|
inherit description;
|
||||||
|
};
|
||||||
|
render = single (attrs:
|
||||||
|
(paramsToRenderedStrings attrs
|
||||||
|
(mapAttrs (_n: _v: param) attrs)));
|
||||||
|
};
|
||||||
|
|
||||||
|
mkPrefixedAttrsOfParams = params :
|
||||||
|
mkPrefixedAttrsOf params (types.submodule {options = paramsToOptions params;});
|
||||||
|
|
||||||
|
mkPrefixedAttrsOfParam = param :
|
||||||
|
mkPrefixedAttrsOf param param.option.type;
|
||||||
|
|
||||||
|
mkPrefixedAttrsOf = p : option : description : {
|
||||||
|
_type = "param";
|
||||||
|
option = mkOption {
|
||||||
|
type = types.attrsOf option;
|
||||||
|
default = {};
|
||||||
|
inherit description;
|
||||||
|
};
|
||||||
|
render = prefix: attrs:
|
||||||
|
let prefixedAttrs = mapAttrs' (name: nameValuePair "${prefix}-${name}") attrs;
|
||||||
|
in paramsToRenderedStrings prefixedAttrs
|
||||||
|
(mapAttrs (_n: _v: p) prefixedAttrs);
|
||||||
|
};
|
||||||
|
|
||||||
|
mkPostfixedAttrsOfParams = params : description : {
|
||||||
|
_type = "param";
|
||||||
|
option = mkOption {
|
||||||
|
type = types.attrsOf (types.submodule {options = paramsToOptions params;});
|
||||||
|
default = {};
|
||||||
|
inherit description;
|
||||||
|
};
|
||||||
|
render = postfix: attrs:
|
||||||
|
let postfixedAttrs = mapAttrs' (name: nameValuePair "${name}-${postfix}") attrs;
|
||||||
|
in paramsToRenderedStrings postfixedAttrs
|
||||||
|
(mapAttrs (_n: _v: params) postfixedAttrs);
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,82 @@
|
||||||
|
lib :
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
|
||||||
|
rec {
|
||||||
|
paramsToConf = cfg : ps : mkConf 0 (paramsToRenderedStrings cfg ps);
|
||||||
|
|
||||||
|
# mkConf takes an indentation level (which usually starts at 0) and a nested
|
||||||
|
# attribute set of strings and will render that set to a strongswan.conf style
|
||||||
|
# configuration format. For example:
|
||||||
|
#
|
||||||
|
# mkConf 0 {a = "1"; b = { c = { "foo" = "2"; "bar" = "3"; }; d = "4";};} => ''
|
||||||
|
# a = 1
|
||||||
|
# b {
|
||||||
|
# c {
|
||||||
|
# foo = 2
|
||||||
|
# bar = 3
|
||||||
|
# }
|
||||||
|
# d = 4
|
||||||
|
# }''
|
||||||
|
mkConf = indent : ps :
|
||||||
|
concatMapStringsSep "\n"
|
||||||
|
(name:
|
||||||
|
let value = ps."${name}";
|
||||||
|
indentation = replicate indent " ";
|
||||||
|
in
|
||||||
|
indentation + (
|
||||||
|
if isAttrs value
|
||||||
|
then "${name} {\n" +
|
||||||
|
mkConf (indent + 2) value + "\n" +
|
||||||
|
indentation + "}"
|
||||||
|
else "${name} = ${value}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(attrNames ps);
|
||||||
|
|
||||||
|
replicate = n : c : concatStrings (builtins.genList (_x : c) n);
|
||||||
|
|
||||||
|
# `paramsToRenderedStrings cfg ps` converts the NixOS configuration `cfg`
|
||||||
|
# (typically the "config" argument of a NixOS module) and the set of
|
||||||
|
# parameters `ps` (an attribute set where the values are constructed using the
|
||||||
|
# parameter constructors in ./param-constructors.nix) to a nested attribute
|
||||||
|
# set of strings (rendered parameters).
|
||||||
|
paramsToRenderedStrings = cfg : ps :
|
||||||
|
filterEmptySets (
|
||||||
|
(mapParamsRecursive (path: name: param:
|
||||||
|
let value = attrByPath path null cfg;
|
||||||
|
in optionalAttrs (!isNull value) (param.render name value)
|
||||||
|
) ps));
|
||||||
|
|
||||||
|
filterEmptySets = set : filterAttrs (n: v: !(isNull v)) (mapAttrs (name: value:
|
||||||
|
if isAttrs value
|
||||||
|
then let value' = filterEmptySets value;
|
||||||
|
in if value' == {}
|
||||||
|
then null
|
||||||
|
else value'
|
||||||
|
else value
|
||||||
|
) set);
|
||||||
|
|
||||||
|
# Recursively map over every parameter in the given attribute set.
|
||||||
|
mapParamsRecursive = mapAttrsRecursiveCond' (as: (!(as ? "_type" && as._type == "param")));
|
||||||
|
|
||||||
|
mapAttrsRecursiveCond' = cond: f: set:
|
||||||
|
let
|
||||||
|
recurse = path: set:
|
||||||
|
let
|
||||||
|
g =
|
||||||
|
name: value:
|
||||||
|
if isAttrs value && cond value
|
||||||
|
then { "${name}" = recurse (path ++ [name]) value; }
|
||||||
|
else f (path ++ [name]) name value;
|
||||||
|
in mapAttrs'' g set;
|
||||||
|
in recurse [] set;
|
||||||
|
|
||||||
|
mapAttrs'' = f: set:
|
||||||
|
foldl' (a: b: a // b) {} (map (attr: f attr set.${attr}) (attrNames set));
|
||||||
|
|
||||||
|
# Extract the options from the given set of parameters.
|
||||||
|
paramsToOptions = ps :
|
||||||
|
mapParamsRecursive (_path: name: param: { "${name}" = param.option; }) ps;
|
||||||
|
|
||||||
|
}
|
File diff suppressed because it is too large
Load diff
|
@ -384,6 +384,7 @@ in rec {
|
||||||
tests.smokeping = callTest tests/smokeping.nix {};
|
tests.smokeping = callTest tests/smokeping.nix {};
|
||||||
tests.snapper = callTest tests/snapper.nix {};
|
tests.snapper = callTest tests/snapper.nix {};
|
||||||
tests.statsd = callTest tests/statsd.nix {};
|
tests.statsd = callTest tests/statsd.nix {};
|
||||||
|
tests.strongswan-swanctl = callTest tests/strongswan-swanctl.nix {};
|
||||||
tests.sudo = callTest tests/sudo.nix {};
|
tests.sudo = callTest tests/sudo.nix {};
|
||||||
tests.systemd = callTest tests/systemd.nix {};
|
tests.systemd = callTest tests/systemd.nix {};
|
||||||
tests.switchTest = callTest tests/switch-test.nix {};
|
tests.switchTest = callTest tests/switch-test.nix {};
|
||||||
|
|
148
nixos/tests/strongswan-swanctl.nix
Normal file
148
nixos/tests/strongswan-swanctl.nix
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
# This strongswan-swanctl test is based on:
|
||||||
|
# https://www.strongswan.org/testing/testresults/swanctl/rw-psk-ipv4/index.html
|
||||||
|
# https://github.com/strongswan/strongswan/tree/master/testing/tests/swanctl/rw-psk-ipv4
|
||||||
|
#
|
||||||
|
# The roadwarrior carol sets up a connection to gateway moon. The authentication
|
||||||
|
# is based on pre-shared keys and IPv4 addresses. Upon the successful
|
||||||
|
# establishment of the IPsec tunnels, the specified updown script automatically
|
||||||
|
# inserts iptables-based firewall rules that let pass the tunneled traffic. In
|
||||||
|
# order to test both tunnel and firewall, carol pings the client alice behind
|
||||||
|
# the gateway moon.
|
||||||
|
#
|
||||||
|
# alice moon carol
|
||||||
|
# eth1------vlan_0------eth1 eth2------vlan_1------eth1
|
||||||
|
# 192.168.0.1 192.168.0.3 192.168.1.3 192.168.1.2
|
||||||
|
#
|
||||||
|
# See the NixOS manual for how to run this test:
|
||||||
|
# https://nixos.org/nixos/manual/index.html#sec-running-nixos-tests-interactively
|
||||||
|
|
||||||
|
import ./make-test.nix ({ pkgs, ...} :
|
||||||
|
|
||||||
|
let
|
||||||
|
allowESP = "iptables --insert INPUT --protocol ESP --jump ACCEPT";
|
||||||
|
|
||||||
|
# Shared VPN settings:
|
||||||
|
vlan0 = "192.168.0.0/24";
|
||||||
|
carolIp = "192.168.1.2";
|
||||||
|
moonIp = "192.168.1.3";
|
||||||
|
version = 2;
|
||||||
|
secret = "0sFpZAZqEN6Ti9sqt4ZP5EWcqx";
|
||||||
|
esp_proposals = [ "aes128gcm128-x25519" ];
|
||||||
|
proposals = [ "aes128-sha256-x25519" ];
|
||||||
|
in {
|
||||||
|
name = "strongswan-swanctl";
|
||||||
|
meta.maintainers = with pkgs.stdenv.lib.maintainers; [ basvandijk ];
|
||||||
|
nodes = {
|
||||||
|
|
||||||
|
alice = { nodes, ... } : {
|
||||||
|
virtualisation.vlans = [ 0 ];
|
||||||
|
networking = {
|
||||||
|
dhcpcd.enable = false;
|
||||||
|
defaultGateway = "192.168.0.3";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
moon = {pkgs, config, nodes, ...} :
|
||||||
|
let strongswan = config.services.strongswan-swanctl.package;
|
||||||
|
in {
|
||||||
|
virtualisation.vlans = [ 0 1 ];
|
||||||
|
networking = {
|
||||||
|
dhcpcd.enable = false;
|
||||||
|
firewall = {
|
||||||
|
allowedUDPPorts = [ 4500 500 ];
|
||||||
|
extraCommands = allowESP;
|
||||||
|
};
|
||||||
|
nat = {
|
||||||
|
enable = true;
|
||||||
|
internalIPs = [ vlan0 ];
|
||||||
|
internalInterfaces = [ "eth1" ];
|
||||||
|
externalIP = moonIp;
|
||||||
|
externalInterface = "eth2";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
environment.systemPackages = [ strongswan ];
|
||||||
|
services.strongswan-swanctl = {
|
||||||
|
enable = true;
|
||||||
|
swanctl = {
|
||||||
|
connections = {
|
||||||
|
"rw" = {
|
||||||
|
local_addrs = [ moonIp ];
|
||||||
|
local."main" = {
|
||||||
|
auth = "psk";
|
||||||
|
};
|
||||||
|
remote."main" = {
|
||||||
|
auth = "psk";
|
||||||
|
};
|
||||||
|
children = {
|
||||||
|
"net" = {
|
||||||
|
local_ts = [ vlan0 ];
|
||||||
|
updown = "${strongswan}/libexec/ipsec/_updown iptables";
|
||||||
|
inherit esp_proposals;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
inherit version;
|
||||||
|
inherit proposals;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
secrets = {
|
||||||
|
ike."carol" = {
|
||||||
|
id."main" = carolIp;
|
||||||
|
inherit secret;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
carol = {pkgs, config, nodes, ...} :
|
||||||
|
let strongswan = config.services.strongswan-swanctl.package;
|
||||||
|
in {
|
||||||
|
virtualisation.vlans = [ 1 ];
|
||||||
|
networking = {
|
||||||
|
dhcpcd.enable = false;
|
||||||
|
firewall.extraCommands = allowESP;
|
||||||
|
};
|
||||||
|
environment.systemPackages = [ strongswan ];
|
||||||
|
services.strongswan-swanctl = {
|
||||||
|
enable = true;
|
||||||
|
swanctl = {
|
||||||
|
connections = {
|
||||||
|
"home" = {
|
||||||
|
local_addrs = [ carolIp ];
|
||||||
|
remote_addrs = [ moonIp ];
|
||||||
|
local."main" = {
|
||||||
|
auth = "psk";
|
||||||
|
id = carolIp;
|
||||||
|
};
|
||||||
|
remote."main" = {
|
||||||
|
auth = "psk";
|
||||||
|
id = moonIp;
|
||||||
|
};
|
||||||
|
children = {
|
||||||
|
"home" = {
|
||||||
|
remote_ts = [ vlan0 ];
|
||||||
|
start_action = "trap";
|
||||||
|
updown = "${strongswan}/libexec/ipsec/_updown iptables";
|
||||||
|
inherit esp_proposals;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
inherit version;
|
||||||
|
inherit proposals;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
secrets = {
|
||||||
|
ike."moon" = {
|
||||||
|
id."main" = moonIp;
|
||||||
|
inherit secret;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
};
|
||||||
|
testScript = ''
|
||||||
|
startAll();
|
||||||
|
$carol->waitUntilSucceeds("ping -c 1 alice");
|
||||||
|
'';
|
||||||
|
})
|
Loading…
Reference in a new issue