Migrate the mail config to the nixos module

This commit is contained in:
Daniel Frank 2021-08-12 18:32:28 +02:00
parent ad0ab2e843
commit 5dcbb4e2dc
Signed by: tokudan
GPG key ID: 063CCCAD04182D32
7 changed files with 19 additions and 564 deletions

View file

@ -10,7 +10,6 @@
./hardware-configuration.nix ./hardware-configuration.nix
./acme.nix ./acme.nix
./sshusers.nix ./sshusers.nix
./variables.nix
./mailserver.nix ./mailserver.nix
./borgbackup.nix ./borgbackup.nix
./nginx.nix ./nginx.nix

View file

@ -1,164 +0,0 @@
{ config, lib, pkgs, ... }:
let
dovecotSQL = pkgs.writeText "dovecot-sql.conf" ''
driver = sqlite
connect = ${config.variables.pfadminDataDir}/postfixadmin.db
password_query = SELECT username AS user, password FROM mailbox WHERE username = '%Lu' AND active='1'
user_query = SELECT username AS user FROM mailbox WHERE username = '%Lu' AND active='1'
'';
dovecotConfSSL = pkgs.writeText "dovecot.conf" ''
${lib.optionalString (config.variables.useSSL) ''
ssl = yes
ssl_cert = </var/lib/acme/dovecot2.${config.variables.myFQDN}/fullchain.pem
ssl_key = </var/lib/acme/dovecot2.${config.variables.myFQDN}/key.pem
''
}
'';
dovecotConf = pkgs.writeText "dovecot.conf" ''
sendmail_path = /run/wrappers/bin/sendmail
default_internal_user = dovecot2
default_internal_group = dovecot2
protocols = imap lmtp pop3 sieve
# commented out due to a dovecot but in the most recent release
#$ {lib.optionalString (config.variables.useSSL) '#'
# ssl = yes
# ssl_cert = </var/lib/acme/dovecot2.${config.variables.myFQDN}/fullchain.pem
# ssl_key = </var/lib/acme/dovecot2.${config.variables.myFQDN}/key.pem
# '#'
#}
!include_try /var/lib/dovecot/ssl-keys.conf
disable_plaintext_auth = yes
auth_mechanisms = plain login
userdb {
driver = sql
args = ${dovecotSQL}
}
passdb {
driver = sql
args = ${dovecotSQL}
}
mail_home = ${config.variables.vmailBaseDir}/%Lu/
mail_location = maildir:${config.variables.vmailBaseDir}/%Lu/Maildir
mail_uid = ${toString config.variables.vmailUID}
mail_gid = ${toString config.variables.vmailGID}
service auth {
unix_listener ${config.variables.dovecotAuthSocket} {
user = ${config.services.postfix.user}
group = ${config.services.postfix.group}
mode = 0600
}
}
service lmtp {
unix_listener ${config.variables.dovecotLmtpSocket} {
user = ${config.services.postfix.user}
group = ${config.services.postfix.group}
mode = 0600
}
}
service stats {
unix_listener stats-reader {
user = dovecot2
group = dovecot2
mode = 0660
}
unix_listener stats-writer {
user = dovecot2
group = dovecot2
mode = 0660
}
}
protocol lmtp {
mail_plugins = sieve
}
protocol imap {
mail_plugins = $mail_plugins imap_sieve
}
imap_idle_notify_interval = 29 mins
namespace inbox {
inbox = yes
location =
mailbox Drafts {
special_use = \Drafts
auto = subscribe
}
mailbox Junk {
special_use = \Junk
auto = subscribe
}
mailbox Sent {
special_use = \Sent
auto = subscribe
}
mailbox Trash {
special_use = \Trash
auto = subscribe
}
mailbox Archive {
special_use = \Archive
auto = subscribe
}
prefix =
}
plugin {
sieve_after = ${(pkgs.callPackage ./sieve-after.nix {}) }
sieve_plugins = sieve_imapsieve sieve_extprograms
# From elsewhere to Spam folder
imapsieve_mailbox1_name = Junk
imapsieve_mailbox1_causes = COPY
imapsieve_mailbox1_before = file:${(pkgs.callPackage ./sieve-report-spam-ham.nix {})}/report-spam.sieve
# From Spam folder to elsewhere
imapsieve_mailbox2_name = *
imapsieve_mailbox2_from = Junk
imapsieve_mailbox2_causes = COPY
imapsieve_mailbox2_before = file:${(pkgs.callPackage ./sieve-report-spam-ham.nix {})}/report-ham.sieve
sieve_pipe_bin_dir = ${(pkgs.callPackage ./sieve-pipe-bin-dir.nix {})}
sieve_global_extensions = +vnd.dovecot.pipe +vnd.dovecot.environment
}
'';
in
{
# Configure certificates...
security = lib.mkIf config.variables.useSSL {
acme.certs."dovecot2.${config.variables.myFQDN}" = {
domain = "${config.variables.myFQDN}";
group = "certs";
postRun = "systemctl restart dovecot2.service";
# cheat by getting the webroot from another certificate configured through nginx.
webroot = config.security.acme.certs."${config.variables.myFQDN}".webroot;
};
};
# Make sure at least the self-signed certs are available before trying to start postfix
systemd.services.dovecot2.after = [ (lib.mkIf config.variables.useSSL "acme-selfsigned-certificates.target") "vmail-setup.service" ];
# Setup dovecot
networking.firewall.allowedTCPPorts = [ 110 143 993 995 4190 ];
services.dovecot2 = {
enable = true;
configFile = "${dovecotConf}";
modules = [ pkgs.dovecot_pigeonhole ];
};
systemd.services."vmail-setup" = {
serviceConfig.Type = "oneshot";
wantedBy = [ "multi-user.target" ];
script = ''
mkdir -p ${config.variables.vmailBaseDir}
chown -c ${config.variables.vmailUser}:${config.variables.vmailGroup} ${config.variables.vmailBaseDir}
chmod -c 0700 ${config.variables.vmailBaseDir}
# SSL workaround for dovecot...
mkdir -p /var/lib/dovecot
cat ${dovecotConfSSL} > /var/lib/dovecot/ssl-keys.conf
chown root:root /var/lib/dovecot/ssl-keys.conf
chmod 400 /var/lib/dovecot/ssl-keys.conf
'';
};
}

View file

@ -1,14 +1,28 @@
{ config, pkgs, ... }: { config, pkgs, ... }:
let mymailserver = (import <nixpkgs> {}).pkgs.fetchgit {
url = "https://codeberg.org/tokudan/nixos-mailserver.git";
rev = "15c419d488d1f4148f268d62fce0975f5a88a464";
sha256 = "111xjmcvr7gq4406yxdj87wvi8psq3dhb7shkdsj5d4bdr9kr13q";
};
in
{ {
# Import some configuration as they are too long to be easily readable here # Import some configuration as they are too long to be easily readable here
imports = [ imports = [
./dovecot.nix #./dovecot.nix
./postfix.nix #./postfix.nix
./postfixadmin.nix #./postfixadmin.nix
./roundcube.nix #./roundcube.nix
./rspamd.nix #./rspamd.nix
"${mymailserver}/default.nix"
]; ];
networking.domain = "hamburg.freifunk.net";
services.mymailserver = {
enable = true;
adminAddress = "kontakt@hamburg.freifunk.net";
mailFQDN = "mail2.hamburg.freifunk.net";
};
users.groups."${config.variables.vmailGroup}" = { gid = config.variables.vmailGID; }; users.groups."${config.variables.vmailGroup}" = { gid = config.variables.vmailGID; };
users.users."${config.variables.vmailUser}" = { users.users."${config.variables.vmailUser}" = {
uid = config.variables.vmailUID; uid = config.variables.vmailUID;

View file

@ -1,89 +0,0 @@
{ config, lib, pkgs, ... }:
let
submission_header_cleanup_regex = pkgs.writeText "submission_header_cleanup_regex" ''
/^Received:.*by ${config.variables.myFQDN} \(Postfix/ IGNORE
'';
pfvirtual_mailbox_domains = pkgs.writeText "virtual_mailbox_domains.cf" ''
dbpath = ${config.variables.pfadminDataDir}/postfixadmin.db
query = SELECT domain FROM domain WHERE domain='%s' AND active = '1'
'';
pfvirtual_alias_maps = pkgs.writeText "virtual_alias_maps.cf" ''
dbpath = ${config.variables.pfadminDataDir}/postfixadmin.db
query = SELECT goto FROM alias WHERE address='%s' AND active = '1'
'';
pfvirtual_alias_domain_maps = pkgs.writeText "virtual_alias_domain_maps.cf" ''
dbpath = ${config.variables.pfadminDataDir}/postfixadmin.db
query = SELECT goto FROM alias,alias_domain WHERE alias_domain.alias_domain = '%d' and alias.address = ('%u' || '@' || alias_domain.target_domain) AND alias.active = 1 AND alias_domain.active='1'
'';
pfvirtual_alias_domain_catchall_maps = pkgs.writeText "virtual_alias_domain_catchall_maps.cf" ''
dbpath = ${config.variables.pfadminDataDir}/postfixadmin.db
query = SELECT goto FROM alias,alias_domain WHERE alias_domain.alias_domain = '%d' and alias.address = ('@' || alias_domain.target_domain) AND alias.active = 1 AND alias_domain.active='1'
'';
pfvirtual_mailbox_maps = pkgs.writeText "virtual_mailbox_maps.cf" ''
dbpath = ${config.variables.pfadminDataDir}/postfixadmin.db
query = SELECT maildir FROM mailbox WHERE username='%s' AND active = '1'
'';
pfvirtual_alias_domain_mailbox_maps = pkgs.writeText "virtual_alias_domain_mailbox_maps.cf" ''
dbpath = ${config.variables.pfadminDataDir}/postfixadmin.db
query = SELECT maildir FROM mailbox,alias_domain WHERE alias_domain.alias_domain = '%d' and mailbox.username = ('%u' || '@' || alias_domain.target_domain) AND mailbox.active = 1 AND alias_domain.active='1'
'';
in
{
# Configure Postfix to support SQLite
nixpkgs.config.packageOverrides = pkgs: { postfix = pkgs.postfix.override { withSQLite = true; }; };
# SSL/TLS specific configuration
security = lib.mkIf config.variables.useSSL {
# Configure the certificates...
acme.certs."postfix.${config.variables.myFQDN}" = {
domain = "${config.variables.myFQDN}";
group = "certs";
postRun = "systemctl restart postfix.service";
# cheat by getting some settings from another certificate configured through nginx.
webroot = config.security.acme.certs."${config.variables.myFQDN}".webroot;
};
};
systemd = lib.mkIf config.variables.useSSL {
# Make sure at least the self-signed certs are available before trying to start postfix
services.postfix.after = [ "acme-selfsigned-certificates.target" ];
};
# Setup Postfix
networking.firewall.allowedTCPPorts = [ 25 587 ];
services.postfix = {
enable = true;
enableSmtp = true;
enableSubmission = true;
config = {
mydestination = "";
myhostname = config.variables.myFQDN;
mynetworks_style = "host";
recipient_delimiter = "+";
relay_domains = "";
smtpd_milters = "unix:${config.variables.rspamdMilterSocket}";
non_smtpd_milters = "unix:${config.variables.rspamdMilterSocket}";
smtpd_sasl_path = config.variables.dovecotAuthSocket;
smtpd_sasl_type = "dovecot";
smtpd_tls_auth_only = "yes";
smtpd_tls_chain_files = lib.mkIf config.variables.useSSL "/var/lib/acme/postfix.${config.variables.myFQDN}/full.pem";
smtpd_tls_loglevel = "1";
smtpd_tls_received_header = "yes";
smtpd_tls_security_level = "may";
smtp_tls_loglevel = "1";
smtp_tls_security_level = "may";
virtual_alias_maps = "proxy:sqlite:${pfvirtual_alias_maps}, proxy:sqlite:${pfvirtual_alias_domain_maps}, proxy:sqlite:${pfvirtual_alias_domain_catchall_maps}";
virtual_mailbox_domains = "proxy:sqlite:${pfvirtual_mailbox_domains}";
virtual_mailbox_maps = "proxy:sqlite:${pfvirtual_mailbox_maps}, proxy:sqlite:${pfvirtual_alias_domain_mailbox_maps}";
virtual_transport = "lmtp:unix:${config.variables.dovecotLmtpSocket}";
};
masterConfig.submission.args = [ "-o" "cleanup_service_name=submission_cleanup" ];
masterConfig."submission_cleanup" = {
command = "cleanup";
args = [ "-o" "header_checks=regexp:${submission_header_cleanup_regex}" ];
private = false;
maxproc = 0;
};
rootAlias = config.variables.mailAdmin;
postmasterAlias = config.variables.mailAdmin;
};
}

View file

@ -1,101 +0,0 @@
{ config, lib, pkgs, ... }:
let
phppoolName = "postfixadmin_pool";
pfaGroup = config.variables.pfaGroup;
pfaUser = config.variables.pfaUser;
postfixadminpkg = config.variables.postfixadminpkg;
pfadminDataDir = config.variables.pfadminDataDir;
cacheDir = config.variables.postfixadminpkgCacheDir;
phpfpmHostPort = config.variables.pfaPhpfpmHostPort;
in
{
# Setup the user and group
users.groups."${pfaGroup}" = { };
users.users."${pfaUser}" = {
isSystemUser = true;
group = "${pfaGroup}";
extraGroups = [ "dovecot2" ];
description = "PHP User for postfixadmin";
};
# Setup nginx
networking.firewall.allowedTCPPorts = [ 80 443 ];
services.nginx.enable = true;
services.nginx.virtualHosts."${config.variables.pfaDomain}" = {
forceSSL = config.variables.useSSL;
enableACME = config.variables.useSSL;
root = "${postfixadminpkg}/public";
extraConfig = ''
charset utf-8;
etag off;
add_header etag "\"${builtins.substring 11 32 postfixadminpkg}\"";
add_header Permissions-Policy "interest-cohort=()" always;
index index.php;
location ~* \.php$ {
add_header Permissions-Policy "interest-cohort=()" always;
# Zero-day exploit defense.
# http://forum.nginx.org/read.php?2,88845,page=3
# Won't work properly (404 error) if the file is not stored on this
# server, which is entirely possible with php-fpm/php-fcgi.
# Comment the 'try_files' line out if you set up php-fpm/php-fcgi on
# another machine. And then cross your fingers that you won't get hacked.
try_files $uri =404;
# NOTE: You should have "cgi.fix_pathinfo = 0;" in php.ini
fastcgi_split_path_info ^(.+\.php)(/.+)$;
# With php5-cgi alone:
fastcgi_pass unix:${config.services.phpfpm.pools."${phppoolName}".socket};
fastcgi_index index.php;
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
fastcgi_param SERVER_SOFTWARE nginx;
fastcgi_param QUERY_STRING $query_string;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param DOCUMENT_URI $document_uri;
fastcgi_param DOCUMENT_ROOT $document_root;
fastcgi_param SERVER_PROTOCOL $server_protocol;
fastcgi_param REMOTE_ADDR $remote_addr;
fastcgi_param REMOTE_PORT $remote_port;
fastcgi_param SERVER_ADDR $server_addr;
fastcgi_param SERVER_PORT $server_port;
fastcgi_param SERVER_NAME $server_name;
fastcgi_param HTTP_PROXY "";
}
'';
};
systemd.services."postfixadmin-setup" = {
serviceConfig.Type = "oneshot";
wantedBy = [ "multi-user.target" ];
script = ''
# Setup the data directory with the database and the cache directory
mkdir -p ${pfadminDataDir}
chmod -c 751 ${pfadminDataDir}
chown -c ${pfaUser}:${pfaGroup} ${pfadminDataDir}
mkdir -p ${cacheDir}/templates_c
chown -Rc ${pfaUser}:${pfaGroup} ${cacheDir}/templates_c
chmod -Rc 751 ${cacheDir}/templates_c
'';
};
services.phpfpm.pools."${phppoolName}" = {
user = "${pfaUser}";
group = "${pfaGroup}";
settings = {
"pm" = "dynamic";
"pm.max_children" = 75;
"pm.min_spare_servers" = 5;
"pm.max_spare_servers" = 20;
"pm.max_requests" = 10;
"catch_workers_output" = 1;
"listen.owner" = "nginx";
"listen.group" = "nginx";
};
};
}

View file

@ -1,136 +0,0 @@
{ config, lib, pkgs, ... }:
let
poolName = "roundcube_pool";
roundcube = (pkgs.callPackage ./pkg-roundcube.nix {
conf = pkgs.writeText "roundcube-config.inc.php" ''
<?php
$config = array();
$config['db_dsnw'] = 'sqlite:///${config.variables.roundcubeDataDir}/roundcube.sqlite?mode=0600';
$config['default_host'] = 'tls://${config.variables.myFQDN}';
$config['smtp_server'] = 'tls://${config.variables.myFQDN}';
$config['smtp_port'] = 587;
$config['smtp_user'] = '%u';
$config['smtp_pass'] = '%p';
$config['product_name'] = 'Webmail';
$config['des_key'] = file_get_contents("${config.variables.roundcubeDataDir}/des_key");;
$config['cipher_method'] = 'AES-256-CBC';
$config['plugins'] = array(
'archive',
'managesieve',
'zipdownload',
);
$config['skin'] = 'larry';
'';
temp = "${config.variables.roundcubeDataDir}/temp";
logs = "${config.variables.roundcubeDataDir}/logs";
} );
in
{
services.nginx.virtualHosts."${config.variables.roundcubeFQDN}" = {
forceSSL = config.variables.useSSL;
enableACME = config.variables.useSSL;
root = "${roundcube}/public_html";
extraConfig = ''
access_log off;
add_header Permissions-Policy "interest-cohort=()" always;
'';
locations."~ ^/favicon.ico/.*$" = {
extraConfig = ''
try_files $uri kins/larry/images/$uri;
add_header Permissions-Policy "interest-cohort=()" always;
'';
};
locations."/" = {
extraConfig = ''
index index.php;
try_files $uri /public/$uri /index.php$is_args$args;
etag off;
add_header etag "\"${builtins.substring 11 32 roundcube}\"";
add_header Permissions-Policy "interest-cohort=()" always;
'';
};
locations."~ [^/]\.php(/|$)" = {
extraConfig = ''
etag off;
add_header etag "\"${builtins.substring 11 32 roundcube}\"";
add_header Permissions-Policy "interest-cohort=()" always;
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
if (!-f $document_root$fastcgi_script_name) {
return 404;
}
fastcgi_pass unix:${config.services.phpfpm.pools."${poolName}".socket};
fastcgi_index index.php;
fastcgi_param QUERY_STRING $query_string;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param DOCUMENT_URI $document_uri;
fastcgi_param DOCUMENT_ROOT $document_root;
fastcgi_param SERVER_PROTOCOL $server_protocol;
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
fastcgi_param REMOTE_ADDR $remote_addr;
fastcgi_param REMOTE_PORT $remote_port;
fastcgi_param SERVER_ADDR $server_addr;
fastcgi_param SERVER_PORT $server_port;
fastcgi_param SERVER_NAME $server_name;
fastcgi_param HTTPS $https;
fastcgi_param HTTP_PROXY "";
'';
};
};
systemd.services.roundcube-install = {
serviceConfig.Type = "oneshot";
wantedBy = [ "multi-user.target" ];
script = ''
mkdir -p ${config.variables.roundcubeDataDir}/temp ${config.variables.roundcubeDataDir}/logs
chown -Rc ${config.variables.roundcubeUser}:${config.variables.roundcubeGroup} ${config.variables.roundcubeDataDir}
chmod -c 700 ${config.variables.roundcubeDataDir}
# Regenerate the key every now and then. This invalidates all sessions, but during reboot should be good enough.
[ -f "${config.variables.roundcubeDataDir}/des_key" ] && ${pkgs.coreutils}/bin/shred "${config.variables.roundcubeDataDir}/des_key"
${pkgs.coreutils}/bin/dd if=/dev/urandom bs=32 count=1 2>/dev/null | ${pkgs.coreutils}/bin/base64 > "${config.variables.roundcubeDataDir}/des_key"
chown -c "${config.variables.roundcubeUser}":${config.variables.roundcubeGroup} "${config.variables.roundcubeDataDir}/des_key"
chmod -c 400 "${config.variables.roundcubeDataDir}/des_key"
if [ -s "${config.variables.roundcubeDataDir}/roundcube.sqlite" ]; then
# Just go ahead and remove the sessions, the key to decrypt them has just been destroyed anyway.
${pkgs.sqlite}/bin/sqlite3 "${config.variables.roundcubeDataDir}/roundcube.sqlite" "DELETE FROM session;"
fi
'';
};
services.phpfpm.pools."${poolName}" = {
user = "${config.variables.roundcubeUser}";
group = "${config.variables.roundcubeUser}";
settings = {
"pm" = "dynamic";
"pm.max_children" = 75;
"pm.min_spare_servers" = 5;
"pm.max_spare_servers" = 20;
"pm.max_requests" = 10;
"catch_workers_output" = 1;
"listen.owner" = "nginx";
"listen.group" = "nginx";
};
};
users.extraUsers."${config.variables.roundcubeUser}" = { };
users.extraGroups."${config.variables.roundcubeUser}" = { };
users.groups."${config.variables.roundcubeGroup}" = { };
users.users."${config.variables.roundcubeUser}" = {
isSystemUser = true;
group = "${config.variables.roundcubeGroup}";
description = "PHP User for roundcube";
};
}

View file

@ -1,68 +0,0 @@
{ config, lib, pkgs, ... }:
let
rspamdExtraConfig = pkgs.writeText "rspamd-extra.conf" ''
secure_ip = [::1]
options {
filters: "chartable,dkim,dkim_signing,spf,surbl,regexp,fuzzy_check"
}
milter_headers {
extended_spam_headers = true;
}
classifier {
bayes {
autolearn = true;
}
}
dkim_signing {
path = "/var/lib/rspamd/dkim/$domain.$selector.key";
check_pubkey = true;
}
'';
in
{
#networking.firewall.allowedTCPPorts = [ 110 143 993 995 ];
environment.systemPackages = [
(pkgs.writeShellScriptBin "dkim-generate" ''
if [ $# -ne 1 ]; then
echo Usage: dkim-generate DOMAIN >&2
exit 1
fi
rspamd=${pkgs.rspamd}/bin/rspamadm
mkdir -p /var/lib/rspamd/dkim
$rspamd dkim_keygen -b 2048 -d "$1" -s dkim | ${pkgs.gawk}/bin/awk '/^-/ {KEY= ! KEY; print; next} KEY {print} !KEY {print > "/dev/stderr"}' >/var/lib/rspamd/dkim/"$1".dkim.key 2>/var/lib/rspamd/dkim/"$1".dkim.dns
ls -l /var/lib/rspamd/dkim/"$1".dkim.key /var/lib/rspamd/dkim/"$1".dkim.dns
'') ];
services.rspamd = {
enable = true;
# Just shove our own configuration up rspamd's rear end with high prio as the default configuration structure is a mess
extraConfig = ''
.include(try=true,priority=10,duplicate=merge) "${rspamdExtraConfig}"
'';
workers = {
controller = {
enable = true;
extraConfig = ''
secure_ip = [::1]
'';
bindSockets = [
"[::1]:11334"
{ mode = "0666"; owner = config.variables.vmailUser; socket = "/run/rspamd/worker-controller.socket"; }
];
};
rspamd_proxy = {
enable = true;
type = "rspamd_proxy";
count = 5; # TODO: match with postfix limits
extraConfig = ''
upstream "local" {
self_scan = yes; # Enable self-scan
}
'';
bindSockets = [
{ socket = config.variables.rspamdMilterSocket; mode = "0600"; owner = config.services.postfix.user; group = config.services.rspamd.group; }
];
};
};
};
}