diff options
-rw-r--r-- | makefile | 6 | ||||
-rw-r--r-- | src/main.cpp | 327 | ||||
-rw-r--r-- | src/wg2nd.cpp (renamed from src/wg2sd.cpp) | 114 | ||||
-rw-r--r-- | src/wg2nd.hpp (renamed from src/wg2sd.hpp) | 12 | ||||
-rw-r--r-- | test/wg2nd_test.cpp (renamed from test/wg2sd_test.cpp) | 10 |
5 files changed, 367 insertions, 102 deletions
@@ -24,7 +24,7 @@ DEBUGFLAGS = -ggdb -O0 LDFLAGS = -largon2 # Object files -OBJECTS := wg2sd.o +OBJECTS := wg2nd.o OBJECTS += main.o # Source directory @@ -41,7 +41,7 @@ OBJ_DIR = obj DEBUG_OBJ_DIR = obj/debug # Target executable -CMD = wg2sd +CMD = wg2nd # Build rules all: CXXFLAGS += $(RELEASE_CXXFLAGS) @@ -62,7 +62,7 @@ $(CMD): $(addprefix $(OBJ_DIR)/, $(OBJECTS)) $(OBJ_DIR)/%.o: $(SRC_DIR)/%.cpp | $(OBJ_DIR) $(CXX) $(CXXFLAGS) -c $< -o $@ -$(TEST_DIR)/%: $(TEST_DIR)/%.cpp $(addprefix $(OBJ_DIR)/, wg2sd.o) | $(OBJ_DIR) +$(TEST_DIR)/%: $(TEST_DIR)/%.cpp $(addprefix $(OBJ_DIR)/, wg2nd.o) | $(OBJ_DIR) $(CXX) $(CXXFLAGS) $^ $(LDFLAGS) -o $@ $(OBJ_DIR) $(DEBUG_OBJ_DIR): diff --git a/src/main.cpp b/src/main.cpp index 19b5ced..b8c8ec6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,5 +1,14 @@ +#include <iostream> +#include <string> +#include <vector> +#include <filesystem> +#include <cstdlib> +#include <cstring> +#include <cstdio> +#include <getopt.h> + // ===================================== -// ERROR HANDING - FROM FCUTILS +// ERROR HANDING - FROM FCUTILS // ===================================== #include <stdio.h> @@ -69,35 +78,122 @@ void err(char const * format, ...) { } // ============================================= -// COMMAND LINE UTILITY +// COMMAND LINE UTILITY // ============================================= +constexpr char const * DEFAULT_OUTPUT_PATH = "/etc/systemd/network/"; -#include "wg2sd.hpp" +/* + * HELP AND USAGE + */ -#include <filesystem> -#include <fstream> -#include <iostream> -#include <unistd.h> -#include <sys/types.h> -#include <sys/stat.h> -#include <grp.h> +void die_usage(const char *prog) { + err("Usage: %s { install, generate } [ OPTIONS ] { -h, CONFIG_FILE }", prog); + die("Use -h for help"); +} + +void print_help(const char *prog) { + err("Usage: %s { install, generate } [ OPTIONS ] { -h, CONFIG_FILE }\n", prog); + err(" CONFIG_FILE is the complete path to a WireGuard configuration file, used by"); + err(" `wg-quick`. `wg2nd` will convert the WireGuard configuration to networkd"); + err(" files.\n"); + err(" The generated configurations are functionally equivalent to `wg-quick(8)`"); + err(" with the following exceptions:\n"); + err(" 1. When unspecified, `wg-quick` determines whether `FwMark` and `Table` are available dynamically,"); + err(" ensuring that the routing table and `fwmark` are not already in use. `wg2nd` sets"); + err(" the `fwmark` to a random number (deterministically generated from the interface"); + err(" name). If more than 500 `fwmarks` are in use, there is a non-negligible chance of a"); + err(" collision. This would occur when there are more than 500 active WireGuard interfaces.\n"); + err(" 2. The PreUp, PostUp, PreDown, and PostDown script snippets are ignored.\n"); + err(" 3. `wg-quick(8)` installs a firewall when a default route is specified (i.e., when `0.0.0.0/0`"); + err(" or `::/0` are specified in `AllowedIPs`). This is not installed by"); + err(" default with `wg2nd install`. The equivalent firewall can be generated with"); + err(" `wg2nd generate -t nft CONFIG_FILE`. Refer to `nft(8)` for details.\n"); + err(" Actions:"); + err(" install Generate and install the configuration with restricted permissions"); + err(" generate Generate specific configuration files and write the results to stdout\n"); + err(" Options:"); + err(" -h Print this help"); + exit(EXIT_SUCCESS); +} -[[noreturn]] void die_usage(char const * prog) { - err("Usage: %s [ -o OUTPUT_PATH ] CONFIG_FILE", prog); +void die_usage_generate(const char *prog) { + err("Usage: %s generate [ -h ] [ -k KEYPATH ] [ -t { network, netdev, keyfile, nft } ] CONFIG_FILE", prog); die("Use -h for help"); } -void print_help(char const * prog) { - err("Usage: %s [ -h | -f | -o OUTPUT_PATH ] CONFIG_FILE", prog); +void print_help_generate(const char *prog) { + err("Usage: %s generate [ -h ] [ -k KEYPATH ] [ -t { network, netdev, keyfile, nft } ] CONFIG_FILE\n", prog); err("Options:"); - err("-o OUTPUT_PATH\tSet the output path (default is /etc/systemd/network)"); - err("-f \tOutput firewall rules"); - err("-h \tDisplay this help message"); + err(" -t FILE_TYPE"); + err(" network Generate a Network Configuration File (see systemd.network(8))"); + err(" netdev Generate a Virtual Device File (see systemd.netdev(8))"); + err(" keyfile Print the interface's private key"); + err(" nft Print the netfilter table `nft(8)` installed by `wg-quick(8)`\n"); + err(" -k KEYPATH Full path to the keyfile (a path relative to /etc/systemd/network is generated"); + err(" if unspecified)\n"); + err(" -h Print this help"); exit(EXIT_SUCCESS); } -using namespace wg2sd; +void die_usage_install(const char *prog) { + err("Usage: %s install [ -h ] [ -f FILE_NAME ] [ -k KEYFILE ] [ -o OUTPUT_PATH ] CONFIG_FILE", prog); + die("Use -h for help"); +} + +void print_help_install(const char *prog) { + err("Usage: %s install [ -h ] [ -f FILE_NAME ] [ -o OUTPUT_PATH ] CONFIG_FILE\n", prog); + err(" `wg2nd install` translates `wg-quick(8)` configuration into corresponding"); + err(" `networkd` configuration and installs the resulting files in `OUTPUT_PATH`.\n"); + err(" `wg2nd install` generates a `netdev`, `network`, and `keyfile` for each"); + err(" CONFIG_FILE. Links will be installed with a `manual` `ActivationPolicy`."); + err(" The interface can be brought up with `networkctl up INTERFACE` and down"); + err(" with `networkctl down INTERFACE`.\n"); + err(" `wg-quick(8)` installs a firewall when a default route (i.e., when `0.0.0.0/0`"); + err(" or `::/0` is specified in `AllowedIPs`). This is not installed by default"); + err(" with `wg2nd install`. The equivalent firewall can be generated with"); + err(" `wg2nd generate -t nft CONFIG_FILE`.\n"); + err("Options:"); + err(" -o OUTPUT_PATH The installation path (default is /etc/systemd/network)\n"); + err(" -f FILE_NAME The base name for the installed configuration files. The"); + err(" networkd-specific configuration suffix will be added"); + err(" (FILE_NAME.netdev for systemd-netdev(8) files,"); + err(" FILE_NAME.network for systemd-network(8) files,"); + err(" and FILE_NAME.keyfile for keyfiles)\n"); + err(" -k KEYFILE The name of the private keyfile\n"); + err(" -h Print this help"); + exit(EXIT_SUCCESS); +} + + +/* + * PARSING + */ + +enum class FileType { + NONE = 0, + NETWORK, + NETDEV, + KEYFILE, + NFT +}; + + +/* + * INTERNAL LOGIC + */ + +#include "wg2nd.hpp" + +#include <iostream> +#include <fstream> +#include <optional> +#include <unistd.h> +#include <sys/types.h> +#include <sys/stat.h> +#include <grp.h> + +using namespace wg2nd; void write_systemd_file(SystemdFilespec const & filespec, std::string output_path, bool secure) { std::string full_path = output_path + "/" + filespec.name; @@ -142,44 +238,23 @@ void write_systemd_file(SystemdFilespec const & filespec, std::string output_pat } } -int main(int argc, char ** argv) { - int opt; - std::filesystem::path output_path = "/etc/systemd/network"; - bool print_firewall_rules = false; - - while ((opt = getopt(argc, argv, "o:fh")) != -1) { - switch (opt) { - case 'o': - output_path = optarg; - break; - case 'f': - print_firewall_rules = true; - break; - case 'h': - print_help(argv[0]); - break; - default: /* '?' */ - die_usage(argv[0]); - } - } - - if (optind >= argc) { - die_usage(argv[0]); - } - - std::filesystem::path config_path = argv[optind]; +SystemdConfig generate_cfg_or_die( + std::filesystem::path && config_path, + std::filesystem::path const & keyfile_or_output_path, + std::optional<std::string> const & filename + ) { std::fstream cfg_stream { config_path }; if(!cfg_stream.is_open()) { die("Failed to open config file %s", config_path.string().c_str()); } - std::string interface_name = interface_name_from_filename(config_path); - SystemdConfig cfg; + std::string interface_name = interface_name_from_filename(config_path); + try { - cfg = wg2sd::wg2sd(interface_name, cfg_stream, output_path); + cfg = wg2nd::wg2nd(interface_name, cfg_stream, keyfile_or_output_path, filename); } catch(ConfigurationException const & cex) { const ParsingException * pex = dynamic_cast<const ParsingException *>(&cex); @@ -191,16 +266,25 @@ int main(int argc, char ** argv) { } - if(print_firewall_rules) { - fprintf(stdout, "%s", cfg.firewall.c_str()); + return cfg; +} - return 0; - } + +void wg2nd_install_internal(std::optional<std::string> && filename, std::string && keyfile_name, + std::filesystem::path && output_path, std::filesystem::path && config_path) { if(!std::filesystem::path(output_path).is_absolute()) { output_path = std::filesystem::absolute(output_path); } + std::filesystem::path keyfile_or_output_path = output_path; + + if(!keyfile_name.empty()) { + keyfile_or_output_path /= keyfile_name; + } + + SystemdConfig cfg = generate_cfg_or_die(std::move(config_path), keyfile_or_output_path, std::move(filename)); + for(std::string const & warning : cfg.warnings) { err("warning: %s", warning.c_str()); } @@ -212,6 +296,147 @@ int main(int argc, char ** argv) { for(SystemdFilespec const & spec : cfg.symmetric_keyfiles) { write_systemd_file(spec, output_path, true); } +} + +void wg2nd_generate_internal(FileType type, std::string && config_file, + std::optional<std::filesystem::path> && keyfile_path) { + + SystemdConfig cfg = generate_cfg_or_die( + std::move(config_file), std::move(keyfile_path.value_or(DEFAULT_OUTPUT_PATH)), {} + ); + + switch(type) { + case FileType::NFT: + printf("%s", cfg.firewall.c_str()); + break; + case FileType::NETWORK: + printf("%s", cfg.network.contents.c_str()); + break; + case FileType::NETDEV: + printf("%s", cfg.netdev.contents.c_str()); + break; + case FileType::KEYFILE: + printf("%s", cfg.private_keyfile.contents.c_str()); + break; + default: + break; + } +} + + +static int wg2nd_generate(char const * prog, int argc, char **argv) { + std::filesystem::path config_path = ""; + + FileType type = FileType::NONE; + std::optional<std::filesystem::path> keyfile_path = {}; + + int opt; + while ((opt = getopt(argc, argv, "ht:k:")) != -1) { + switch (opt) { + case 't': + if (strcmp(optarg, "network") == 0) { + type = FileType::NETWORK; + } else if (strcmp(optarg, "netdev") == 0) { + type = FileType::NETDEV; + } else if (strcmp(optarg, "keyfile") == 0) { + type = FileType::KEYFILE; + } else if (strcmp(optarg, "nft") == 0) { + type = FileType::NFT; + } else { + die("Unknown file type: %s", optarg); + } + break; + case 'k': + keyfile_path = optarg; + break; + case 'h': + print_help_generate(prog); + break; + default: + die_usage_generate(prog); + } + } + + if (optind >= argc) { + die_usage_generate(prog); + } + + config_path = argv[optind]; + + wg2nd_generate_internal(type, std::move(config_path), std::move(keyfile_path)); + + return 0; +} + +static int wg2nd_install(char const * prog, int argc, char **argv) { + std::filesystem::path config_path = ""; + + std::optional<std::string> filename = {}; + std::filesystem::path output_path = DEFAULT_OUTPUT_PATH; + std::string keyfile_name = ""; + + int opt; + while ((opt = getopt(argc, argv, "o:f:k:h")) != -1) { + switch (opt) { + case 'o': { + std::string path = optarg; + if(path[path.size() - 1] != '/') { + path.push_back('/'); + } + output_path = std::move(path); + break; + } + case 'f': + filename = optarg; + break; + case 'h': + print_help_install(prog); + break; + case 'k': + keyfile_name = optarg; + break; + default: + die_usage_install(prog); + } + } + + if (optind >= argc) { + die_usage_install(prog); + } + + config_path = argv[optind]; + + wg2nd_install_internal(std::move(filename), std::move(keyfile_name), std::move(output_path), std::move(config_path)); + + return 0; +} + +// The main function remains the same as before + + +int main(int argc, char **argv) { + char const * prog = "wg2nd"; + + if(argc > 0) { + prog = argv[0]; + } + + if (argc < 2) { + die_usage(prog); + } + + std::string action = argv[1]; + + if (action == "generate") { + return wg2nd_generate(prog, argc - 1, argv + 1); + } else if (action == "install") { + return wg2nd_install(prog, argc - 1, argv + 1); + } else if (action == "-h" || action == "--help") { + print_help(prog); + } else { + err("Unknown action: %s", action.c_str()); + die_usage(prog); + } return 0; } diff --git a/src/wg2sd.cpp b/src/wg2nd.cpp index 4c39a03..c53df04 100644 --- a/src/wg2sd.cpp +++ b/src/wg2nd.cpp @@ -1,4 +1,4 @@ -#include "wg2sd.hpp" +#include "wg2nd.hpp" #include <exception> #include <sstream> @@ -27,7 +27,7 @@ std::string hashed_keyfile_name(std::string const & priv_key) { uint8_t hash[HASHLEN]; - argon2i_hash_raw(t_cost, m_cost, parallelism, key, keylen, SALT, sizeof(SALT), hash, HASHLEN); + argon2id_hash_raw(t_cost, m_cost, parallelism, key, keylen, SALT, sizeof(SALT), hash, HASHLEN); constexpr char KEYFILE_EXT[] = ".keyfile"; @@ -47,8 +47,29 @@ std::string hashed_keyfile_name(std::string const & priv_key) { return std::string { filename } ; } +uint32_t deterministic_fwmark(std::string const & interface_name) { + constexpr uint8_t const SALT[] = { + 0x90, 0x08, 0x82, 0xd7, 0x75, 0x68, 0xf4, 0x8e, + 0x90, 0x74, 0x0c, 0x74, 0x0d, 0xf4, 0xfb, 0x91, + 0xe5, 0x44, 0x87, 0x7e, 0xce, 0x48, 0xcf, 0x01, + }; + + uint8_t const * key = reinterpret_cast<uint8_t const *>(interface_name.c_str()); + uint32_t keylen = interface_name.size(); + + uint32_t mark; + + uint8_t t_cost = 2; + uint32_t m_cost = 1 << 10; + uint32_t parallelism = 1; + + argon2id_hash_raw(t_cost, m_cost, parallelism, key, keylen, SALT, sizeof(SALT), &mark, sizeof(mark)); + + return mark; +} + -namespace wg2sd { +namespace wg2nd { std::string interface_name_from_filename(std::filesystem::path config_path) { std::string interface_name = config_path.filename().string(); @@ -219,6 +240,8 @@ namespace wg2sd { } cfg.intf.listen_port = port; + } else if (key == "MTU") { + cfg.intf.mtu = value; } else if (key == "PreUp") { cfg.intf.preup = value; } else if (key == "PostUp") { @@ -303,7 +326,7 @@ namespace wg2sd { return cfg; } - static void _write_table(std::stringstream & firewall, Config const & cfg, std::vector<std::string_view> addrs, bool ipv4) { + static void _write_table(std::stringstream & firewall, Config const & cfg, std::vector<std::string_view> addrs, bool ipv4, uint32_t fwd_table) { char const * ip = ipv4 ? "ip" : "ip6"; firewall << "table " << ip << " " << cfg.intf.name << " {\n" @@ -323,13 +346,13 @@ namespace wg2sd { << "\n" << " chain postmangle {\n" << " type filter hook postrouting priority mangle; policy accept;\n" - << " meta l4proto udp meta mark " << std::hex << cfg.intf.table << std::dec << "ct mark set meta mark;\n" + << " meta l4proto udp meta mark 0x" << std::hex << fwd_table << std::dec << " ct mark set meta mark;\n" << " }\n" << "}\n"; } - std::string _gen_nftables_firewall(Config const & cfg) { + std::string _gen_nftables_firewall(Config const & cfg, uint32_t fwd_table) { std::stringstream firewall; std::vector<std::string_view> ipv4_addrs; @@ -343,39 +366,31 @@ namespace wg2sd { } } - _write_table(firewall, cfg, ipv4_addrs, true); - - firewall << "\n"; + if(ipv4_addrs.size() > 0) { + _write_table(firewall, cfg, ipv4_addrs, true, fwd_table); + firewall << "\n"; + } - _write_table(firewall, cfg, ipv6_addrs, false); + if(ipv6_addrs.size() > 0) { + _write_table(firewall, cfg, ipv6_addrs, false, fwd_table); + } return firewall.str(); } - static std::string _gen_netdev_cfg(Config const & cfg, uint32_t fwd_table, std::string const & private_keyfile, - std::vector<SystemdFilespec> & symmetric_keyfiles, std::string const & output_path) { + static std::string _gen_netdev_cfg(Config const & cfg, uint32_t fwd_table, std::filesystem::path const & private_keyfile, + std::vector<SystemdFilespec> & symmetric_keyfiles) { std::stringstream netdev; - netdev << "# Autogenerated by wg2sd\n"; + netdev << "# Autogenerated by wg2nd\n"; netdev << "[NetDev]\n"; netdev << "Name = " << cfg.intf.name << "\n"; netdev << "Kind = wireguard\n"; netdev << "Description = " << cfg.intf.name << " - wireguard tunnel\n"; - - if(!cfg.intf.mtu.empty()) { - netdev << "MTUBytes = " << cfg.intf.mtu << "\n"; - } - netdev << "\n"; netdev << "[WireGuard]\n"; - netdev << "PrivateKeyFile = " << output_path; - - if(output_path.back() != '/') { - netdev << "/"; - } - - netdev << private_keyfile << "\n"; + netdev << "PrivateKeyFile = " << private_keyfile.string() << "\n"; if(cfg.intf.listen_port.has_value()) { netdev << "ListenPort = " << cfg.intf.listen_port.value() << "\n"; @@ -441,11 +456,18 @@ namespace wg2sd { static std::string _gen_network_cfg(Config const & cfg, uint32_t fwd_table) { std::stringstream network; - network << "# Autogenerated by wg2sd\n"; + network << "# Autogenerated by wg2nd\n"; network << "[Match]\n"; network << "Name = " << cfg.intf.name << "\n"; network << "\n"; + network << "[Link]" << "\n"; + network << "ActivationPolicy = manual\n"; + if(!cfg.intf.mtu.empty()) { + network << "MTUBytes = " << cfg.intf.mtu << "\n"; + } + network << "\n"; + network << "[Network]\n"; for(std::string const & addr : cfg.intf.addresses) { network << "Address = " << addr << "\n"; @@ -523,19 +545,21 @@ namespace wg2sd { return network.str(); } - static uint32_t _random_table() { - std::random_device rd; - std::mt19937 rng { rd() }; + static uint32_t _deterministic_random_table(std::string const & interface_name) { uint32_t table = 0; while(table == 0 or table == MAIN_TABLE or table == LOCAL_TABLE) { - table = rng() % UINT32_MAX; + table = deterministic_fwmark(interface_name); } return table; } - SystemdConfig gen_systemd_config(Config const & cfg, std::string const & output_path) { + SystemdConfig gen_systemd_config( + Config const & cfg, + std::filesystem::path const & keyfile_or_output_path, + std::optional<std::string> const & filename + ) { // If the table is explicitly specified with Table=<number>, // all routes are added to this table. @@ -549,11 +573,18 @@ namespace wg2sd { // table. // // If Table=off, no routes are added. - uint32_t fwd_table = _random_table(); + uint32_t fwd_table = _deterministic_random_table(cfg.intf.name); std::vector<SystemdFilespec> symmetric_keyfiles; - std::string private_keyfile = hashed_keyfile_name(cfg.intf.private_key); + std::filesystem::path keyfile_path; + + if(keyfile_or_output_path.has_filename()) { + keyfile_path = keyfile_or_output_path; + } else { + std::string private_keyfile = hashed_keyfile_name(cfg.intf.private_key); + keyfile_path = keyfile_or_output_path / private_keyfile; + } std::vector<std::string> warnings; @@ -572,27 +603,30 @@ if(!cfg.intf.field_.empty()) { \ warnings.push_back("[Interface] section contains a field \"PreUp\" which does not have a systemd-networkd analog"); } + std::string const & basename = filename.value_or(cfg.intf.name); + return SystemdConfig { .netdev = { - .name = cfg.intf.name + ".netdev", - .contents = _gen_netdev_cfg(cfg, fwd_table, private_keyfile, symmetric_keyfiles, output_path), + .name = basename + ".netdev", + .contents = _gen_netdev_cfg(cfg, fwd_table, keyfile_path, symmetric_keyfiles), }, .network = { - .name = cfg.intf.name + ".network", + .name = basename + ".network", .contents = _gen_network_cfg(cfg, fwd_table) }, .private_keyfile = { - .name = private_keyfile, + .name = keyfile_path.filename(), .contents = cfg.intf.private_key + "\n", }, .symmetric_keyfiles = std::move(symmetric_keyfiles), .warnings = std::move(warnings), - .firewall = _gen_nftables_firewall(cfg), + .firewall = _gen_nftables_firewall(cfg, fwd_table), }; } - SystemdConfig wg2sd(std::string const & interface_name, std::istream & stream, std::string const & output_path) { - return gen_systemd_config(parse_config(interface_name, stream), output_path); + SystemdConfig wg2nd(std::string const & interface_name, std::istream & stream, + std::filesystem::path const & keyfile_or_output_path, std::optional<std::string> const & filename) { + return gen_systemd_config(parse_config(interface_name, stream), keyfile_or_output_path, filename); } } diff --git a/src/wg2sd.hpp b/src/wg2nd.hpp index e80a284..7bf7f54 100644 --- a/src/wg2sd.hpp +++ b/src/wg2nd.hpp @@ -9,7 +9,7 @@ #include <cstdint> -namespace wg2sd { +namespace wg2nd { struct Interface { // File name, or defaults to "wg" @@ -134,8 +134,14 @@ namespace wg2sd { Config parse_config(std::string const & interface_name, std::istream & stream); - SystemdConfig gen_systemd_config(Config const & cfg, std::string const & output_path); + SystemdConfig gen_systemd_config( + Config const & cfg, + std::filesystem::path const & keyfile_or_output_path, + std::optional<std::string> const & filename + ); - SystemdConfig wg2sd(std::string const & interface_name, std::istream & stream, std::string const & output_path); + SystemdConfig wg2nd(std::string const & interface_name, std::istream & stream, + std::filesystem::path const & keyfile_or_output_path, + std::optional<std::string> const & filename); }; diff --git a/test/wg2sd_test.cpp b/test/wg2nd_test.cpp index 5d57ac9..39b0766 100644 --- a/test/wg2sd_test.cpp +++ b/test/wg2nd_test.cpp @@ -1,17 +1,17 @@ #include "utest.h" -#include "wg2sd.hpp" +#include "wg2nd.hpp" #include <sstream> #include <array> -namespace wg2sd { +namespace wg2nd { extern bool _is_default_route(std::string const & cidr); extern bool _is_ipv4_route(std::string const & cidr); }; -using namespace wg2sd; +using namespace wg2nd; -UTEST(wg2sd, ip_helpers) { +UTEST(wg2nd, ip_helpers) { std::array<std::string, 8> default_routes = { "0/0", @@ -121,7 +121,7 @@ const char * INVALID_CONFIG = ( ); -UTEST(wg2sd, parses_config) { +UTEST(wg2nd, parses_config) { // CONFIG1 std::istringstream ss { CONFIG1 }; |