2022-06-03 09:05:41 +02:00
package headscale
import (
"crypto/tls"
2022-06-03 09:26:36 +02:00
"errors"
"fmt"
2022-06-03 09:05:41 +02:00
"io/fs"
2022-09-04 11:32:29 +02:00
"net/netip"
2022-06-03 09:05:41 +02:00
"net/url"
2022-06-03 09:26:36 +02:00
"strings"
2022-06-03 09:05:41 +02:00
"time"
2022-06-03 09:26:36 +02:00
"github.com/coreos/go-oidc/v3/oidc"
2022-06-03 10:37:45 +02:00
"github.com/rs/zerolog"
2022-06-03 09:26:36 +02:00
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
2022-09-02 00:04:04 +02:00
"go4.org/netipx"
2022-06-03 09:05:41 +02:00
"tailscale.com/tailcfg"
2022-06-03 09:26:36 +02:00
"tailscale.com/types/dnstype"
2022-06-03 09:05:41 +02:00
)
2022-07-11 20:33:24 +02:00
const (
tlsALPN01ChallengeType = "TLS-ALPN-01"
http01ChallengeType = "HTTP-01"
2022-09-11 21:37:23 +02:00
JSONLogFormat = "json"
TextLogFormat = "text"
2022-07-11 20:33:24 +02:00
)
2022-06-03 09:05:41 +02:00
// Config contains the initial Headscale configuration.
type Config struct {
ServerURL string
Addr string
MetricsAddr string
GRPCAddr string
GRPCAllowInsecure bool
EphemeralNodeInactivityTimeout time . Duration
2022-07-12 12:52:03 +02:00
NodeUpdateCheckInterval time . Duration
2022-09-02 00:04:04 +02:00
IPPrefixes [ ] netip . Prefix
2022-06-03 09:05:41 +02:00
PrivateKeyPath string
2022-08-13 11:14:38 +02:00
NoisePrivateKeyPath string
2022-06-03 09:05:41 +02:00
BaseDomain string
2022-09-11 21:37:23 +02:00
Log LogConfig
2022-06-03 10:37:45 +02:00
DisableUpdateCheck bool
2022-06-03 09:05:41 +02:00
DERP DERPConfig
DBtype string
DBpath string
DBhost string
DBport int
DBname string
DBuser string
DBpass string
2022-08-16 09:50:30 +02:00
DBssl bool
2022-06-03 09:05:41 +02:00
2022-06-03 10:14:14 +02:00
TLS TLSConfig
2022-06-03 09:05:41 +02:00
ACMEURL string
ACMEEmail string
DNSConfig * tailcfg . DNSConfig
UnixSocket string
UnixSocketPermission fs . FileMode
OIDC OIDCConfig
2022-06-09 21:20:11 +02:00
LogTail LogTailConfig
RandomizeClientPort bool
2022-06-03 09:05:41 +02:00
CLI CLIConfig
ACL ACLConfig
}
2022-06-03 10:14:14 +02:00
type TLSConfig struct {
CertPath string
KeyPath string
ClientAuthMode tls . ClientAuthType
LetsEncrypt LetsEncryptConfig
}
type LetsEncryptConfig struct {
Listen string
Hostname string
CacheDir string
ChallengeType string
}
2022-06-03 09:05:41 +02:00
type OIDCConfig struct {
2022-09-26 09:57:28 +02:00
OnlyStartIfOIDCIsAvailable bool
Issuer string
ClientID string
ClientSecret string
Scope [ ] string
ExtraParams map [ string ] string
AllowedDomains [ ] string
AllowedUsers [ ] string
StripEmaildomain bool
2022-06-03 09:05:41 +02:00
}
type DERPConfig struct {
ServerEnabled bool
ServerRegionID int
ServerRegionCode string
ServerRegionName string
STUNAddr string
URLs [ ] url . URL
Paths [ ] string
AutoUpdate bool
UpdateFrequency time . Duration
}
type LogTailConfig struct {
Enabled bool
}
type CLIConfig struct {
Address string
APIKey string
Timeout time . Duration
Insecure bool
}
type ACLConfig struct {
PolicyPath string
}
2022-06-03 09:26:36 +02:00
2022-09-11 21:37:23 +02:00
type LogConfig struct {
Format string
Level zerolog . Level
}
2022-06-07 16:24:35 +02:00
func LoadConfig ( path string , isFile bool ) error {
if isFile {
viper . SetConfigFile ( path )
2022-06-03 09:26:36 +02:00
} else {
2022-06-07 16:24:35 +02:00
viper . SetConfigName ( "config" )
if path == "" {
viper . AddConfigPath ( "/etc/headscale/" )
viper . AddConfigPath ( "$HOME/.headscale" )
viper . AddConfigPath ( "." )
} else {
// For testing
viper . AddConfigPath ( path )
}
2022-06-03 09:26:36 +02:00
}
viper . SetEnvPrefix ( "headscale" )
viper . SetEnvKeyReplacer ( strings . NewReplacer ( "." , "_" ) )
viper . AutomaticEnv ( )
viper . SetDefault ( "tls_letsencrypt_cache_dir" , "/var/www/.cache" )
2022-07-11 20:33:24 +02:00
viper . SetDefault ( "tls_letsencrypt_challenge_type" , http01ChallengeType )
2022-06-03 09:26:36 +02:00
viper . SetDefault ( "tls_client_auth_mode" , "relaxed" )
2022-09-11 21:37:23 +02:00
viper . SetDefault ( "log.level" , "info" )
viper . SetDefault ( "log.format" , TextLogFormat )
2022-06-03 09:26:36 +02:00
viper . SetDefault ( "dns_config" , nil )
viper . SetDefault ( "derp.server.enabled" , false )
viper . SetDefault ( "derp.server.stun.enabled" , true )
viper . SetDefault ( "unix_socket" , "/var/run/headscale.sock" )
viper . SetDefault ( "unix_socket_permission" , "0o770" )
viper . SetDefault ( "grpc_listen_addr" , ":50443" )
viper . SetDefault ( "grpc_allow_insecure" , false )
viper . SetDefault ( "cli.timeout" , "5s" )
viper . SetDefault ( "cli.insecure" , false )
viper . SetDefault ( "oidc.scope" , [ ] string { oidc . ScopeOpenID , "profile" , "email" } )
viper . SetDefault ( "oidc.strip_email_domain" , true )
2022-09-26 09:57:28 +02:00
viper . SetDefault ( "oidc.only_start_if_oidc_is_available" , true )
2022-06-03 09:26:36 +02:00
viper . SetDefault ( "logtail.enabled" , false )
2022-06-09 21:20:11 +02:00
viper . SetDefault ( "randomize_client_port" , false )
2022-06-03 09:26:36 +02:00
2022-06-12 15:12:53 +02:00
viper . SetDefault ( "ephemeral_node_inactivity_timeout" , "120s" )
2022-07-12 12:52:03 +02:00
viper . SetDefault ( "node_update_check_interval" , "10s" )
2022-07-12 12:27:28 +02:00
2022-06-03 09:26:36 +02:00
if err := viper . ReadInConfig ( ) ; err != nil {
2022-06-12 15:32:16 +02:00
log . Warn ( ) . Err ( err ) . Msg ( "Failed to read configuration from disk" )
2022-06-03 09:26:36 +02:00
return fmt . Errorf ( "fatal error reading config file: %w" , err )
}
// Collect any validation errors and return them all at once
var errorText string
if ( viper . GetString ( "tls_letsencrypt_hostname" ) != "" ) &&
( ( viper . GetString ( "tls_cert_path" ) != "" ) || ( viper . GetString ( "tls_key_path" ) != "" ) ) {
errorText += "Fatal config error: set either tls_letsencrypt_hostname or tls_cert_path/tls_key_path, not both\n"
}
2022-08-21 10:42:23 +02:00
if ! viper . IsSet ( "noise" ) || viper . GetString ( "noise.private_key_path" ) == "" {
errorText += "Fatal config error: headscale now requires a new `noise.private_key_path` field in the config file for the Tailscale v2 protocol\n"
2022-08-14 12:35:14 +02:00
}
2022-06-03 09:26:36 +02:00
if ( viper . GetString ( "tls_letsencrypt_hostname" ) != "" ) &&
2022-07-11 20:33:24 +02:00
( viper . GetString ( "tls_letsencrypt_challenge_type" ) == tlsALPN01ChallengeType ) &&
2022-06-03 09:26:36 +02:00
( ! strings . HasSuffix ( viper . GetString ( "listen_addr" ) , ":443" ) ) {
// this is only a warning because there could be something sitting in front of headscale that redirects the traffic (e.g. an iptables rule)
log . Warn ( ) .
Msg ( "Warning: when using tls_letsencrypt_hostname with TLS-ALPN-01 as challenge type, headscale must be reachable on port 443, i.e. listen_addr should probably end in :443" )
}
2022-07-11 20:33:24 +02:00
if ( viper . GetString ( "tls_letsencrypt_challenge_type" ) != http01ChallengeType ) &&
( viper . GetString ( "tls_letsencrypt_challenge_type" ) != tlsALPN01ChallengeType ) {
2022-06-03 09:26:36 +02:00
errorText += "Fatal config error: the only supported values for tls_letsencrypt_challenge_type are HTTP-01 and TLS-ALPN-01\n"
}
if ! strings . HasPrefix ( viper . GetString ( "server_url" ) , "http://" ) &&
! strings . HasPrefix ( viper . GetString ( "server_url" ) , "https://" ) {
errorText += "Fatal config error: server_url must start with https:// or http://\n"
}
_ , authModeValid := LookupTLSClientAuthMode (
viper . GetString ( "tls_client_auth_mode" ) ,
)
if ! authModeValid {
errorText += fmt . Sprintf (
"Invalid tls_client_auth_mode supplied: %s. Accepted values: %s, %s, %s." ,
viper . GetString ( "tls_client_auth_mode" ) ,
DisabledClientAuth ,
RelaxedClientAuth ,
EnforcedClientAuth )
}
2022-06-12 15:12:43 +02:00
// Minimum inactivity time out is keepalive timeout (60s) plus a few seconds
// to avoid races
minInactivityTimeout , _ := time . ParseDuration ( "65s" )
if viper . GetDuration ( "ephemeral_node_inactivity_timeout" ) <= minInactivityTimeout {
errorText += fmt . Sprintf (
"Fatal config error: ephemeral_node_inactivity_timeout (%s) is set too low, must be more than %s" ,
viper . GetString ( "ephemeral_node_inactivity_timeout" ) ,
minInactivityTimeout ,
)
}
2022-07-12 12:52:03 +02:00
maxNodeUpdateCheckInterval , _ := time . ParseDuration ( "60s" )
if viper . GetDuration ( "node_update_check_interval" ) > maxNodeUpdateCheckInterval {
2022-07-12 12:27:28 +02:00
errorText += fmt . Sprintf (
2022-07-12 12:52:03 +02:00
"Fatal config error: node_update_check_interval (%s) is set too high, must be less than %s" ,
viper . GetString ( "node_update_check_interval" ) ,
maxNodeUpdateCheckInterval ,
2022-07-12 12:27:28 +02:00
)
}
2022-06-03 09:26:36 +02:00
if errorText != "" {
//nolint
return errors . New ( strings . TrimSuffix ( errorText , "\n" ) )
} else {
return nil
}
}
2022-06-03 10:14:14 +02:00
func GetTLSConfig ( ) TLSConfig {
tlsClientAuthMode , _ := LookupTLSClientAuthMode (
viper . GetString ( "tls_client_auth_mode" ) ,
)
return TLSConfig {
LetsEncrypt : LetsEncryptConfig {
Hostname : viper . GetString ( "tls_letsencrypt_hostname" ) ,
Listen : viper . GetString ( "tls_letsencrypt_listen" ) ,
CacheDir : AbsolutePathFromConfigPath (
viper . GetString ( "tls_letsencrypt_cache_dir" ) ,
) ,
ChallengeType : viper . GetString ( "tls_letsencrypt_challenge_type" ) ,
} ,
CertPath : AbsolutePathFromConfigPath (
viper . GetString ( "tls_cert_path" ) ,
) ,
KeyPath : AbsolutePathFromConfigPath (
viper . GetString ( "tls_key_path" ) ,
) ,
ClientAuthMode : tlsClientAuthMode ,
}
}
2022-06-03 09:26:36 +02:00
func GetDERPConfig ( ) DERPConfig {
serverEnabled := viper . GetBool ( "derp.server.enabled" )
serverRegionID := viper . GetInt ( "derp.server.region_id" )
serverRegionCode := viper . GetString ( "derp.server.region_code" )
serverRegionName := viper . GetString ( "derp.server.region_name" )
stunAddr := viper . GetString ( "derp.server.stun_listen_addr" )
if serverEnabled && stunAddr == "" {
log . Fatal ( ) .
Msg ( "derp.server.stun_listen_addr must be set if derp.server.enabled is true" )
}
urlStrs := viper . GetStringSlice ( "derp.urls" )
urls := make ( [ ] url . URL , len ( urlStrs ) )
for index , urlStr := range urlStrs {
urlAddr , err := url . Parse ( urlStr )
if err != nil {
log . Error ( ) .
Str ( "url" , urlStr ) .
Err ( err ) .
Msg ( "Failed to parse url, ignoring..." )
}
urls [ index ] = * urlAddr
}
paths := viper . GetStringSlice ( "derp.paths" )
autoUpdate := viper . GetBool ( "derp.auto_update_enabled" )
updateFrequency := viper . GetDuration ( "derp.update_frequency" )
return DERPConfig {
ServerEnabled : serverEnabled ,
ServerRegionID : serverRegionID ,
ServerRegionCode : serverRegionCode ,
ServerRegionName : serverRegionName ,
STUNAddr : stunAddr ,
URLs : urls ,
Paths : paths ,
AutoUpdate : autoUpdate ,
UpdateFrequency : updateFrequency ,
}
}
func GetLogTailConfig ( ) LogTailConfig {
enabled := viper . GetBool ( "logtail.enabled" )
return LogTailConfig {
Enabled : enabled ,
}
}
func GetACLConfig ( ) ACLConfig {
policyPath := viper . GetString ( "acl_policy_path" )
return ACLConfig {
PolicyPath : policyPath ,
}
}
2022-09-11 21:37:23 +02:00
func GetLogConfig ( ) LogConfig {
logLevelStr := viper . GetString ( "log.level" )
logLevel , err := zerolog . ParseLevel ( logLevelStr )
if err != nil {
logLevel = zerolog . DebugLevel
}
logFormatOpt := viper . GetString ( "log.format" )
var logFormat string
switch logFormatOpt {
case "json" :
logFormat = JSONLogFormat
case "text" :
logFormat = TextLogFormat
case "" :
logFormat = TextLogFormat
default :
log . Error ( ) .
Str ( "func" , "GetLogConfig" ) .
Msgf ( "Could not parse log format: %s. Valid choices are 'json' or 'text'" , logFormatOpt )
}
return LogConfig {
Format : logFormat ,
Level : logLevel ,
}
}
2022-06-03 09:26:36 +02:00
func GetDNSConfig ( ) ( * tailcfg . DNSConfig , string ) {
if viper . IsSet ( "dns_config" ) {
dnsConfig := & tailcfg . DNSConfig { }
if viper . IsSet ( "dns_config.nameservers" ) {
nameserversStr := viper . GetStringSlice ( "dns_config.nameservers" )
2022-09-02 00:04:04 +02:00
nameservers := make ( [ ] netip . Addr , len ( nameserversStr ) )
2022-06-11 17:33:48 +02:00
resolvers := make ( [ ] * dnstype . Resolver , len ( nameserversStr ) )
2022-06-03 09:26:36 +02:00
for index , nameserverStr := range nameserversStr {
2022-09-02 00:04:04 +02:00
nameserver , err := netip . ParseAddr ( nameserverStr )
2022-06-03 09:26:36 +02:00
if err != nil {
log . Error ( ) .
Str ( "func" , "getDNSConfig" ) .
Err ( err ) .
Msgf ( "Could not parse nameserver IP: %s" , nameserverStr )
}
nameservers [ index ] = nameserver
2022-06-11 17:33:48 +02:00
resolvers [ index ] = & dnstype . Resolver {
2022-06-03 09:26:36 +02:00
Addr : nameserver . String ( ) ,
}
}
dnsConfig . Nameservers = nameservers
dnsConfig . Resolvers = resolvers
}
if viper . IsSet ( "dns_config.restricted_nameservers" ) {
if len ( dnsConfig . Nameservers ) > 0 {
2022-06-11 17:33:48 +02:00
dnsConfig . Routes = make ( map [ string ] [ ] * dnstype . Resolver )
2022-06-03 09:26:36 +02:00
restrictedDNS := viper . GetStringMapStringSlice (
"dns_config.restricted_nameservers" ,
)
for domain , restrictedNameservers := range restrictedDNS {
restrictedResolvers := make (
2022-06-11 17:33:48 +02:00
[ ] * dnstype . Resolver ,
2022-06-03 09:26:36 +02:00
len ( restrictedNameservers ) ,
)
for index , nameserverStr := range restrictedNameservers {
2022-09-02 00:04:04 +02:00
nameserver , err := netip . ParseAddr ( nameserverStr )
2022-06-03 09:26:36 +02:00
if err != nil {
log . Error ( ) .
Str ( "func" , "getDNSConfig" ) .
Err ( err ) .
Msgf ( "Could not parse restricted nameserver IP: %s" , nameserverStr )
}
2022-06-11 17:33:48 +02:00
restrictedResolvers [ index ] = & dnstype . Resolver {
2022-06-03 09:26:36 +02:00
Addr : nameserver . String ( ) ,
}
}
dnsConfig . Routes [ domain ] = restrictedResolvers
}
} else {
log . Warn ( ) .
Msg ( "Warning: dns_config.restricted_nameservers is set, but no nameservers are configured. Ignoring restricted_nameservers." )
}
}
if viper . IsSet ( "dns_config.domains" ) {
dnsConfig . Domains = viper . GetStringSlice ( "dns_config.domains" )
}
if viper . IsSet ( "dns_config.magic_dns" ) {
magicDNS := viper . GetBool ( "dns_config.magic_dns" )
if len ( dnsConfig . Nameservers ) > 0 {
dnsConfig . Proxied = magicDNS
} else if magicDNS {
log . Warn ( ) .
Msg ( "Warning: dns_config.magic_dns is set, but no nameservers are configured. Ignoring magic_dns." )
}
}
var baseDomain string
if viper . IsSet ( "dns_config.base_domain" ) {
baseDomain = viper . GetString ( "dns_config.base_domain" )
} else {
baseDomain = "headscale.net" // does not really matter when MagicDNS is not enabled
}
return dnsConfig , baseDomain
}
return nil , ""
}
2022-06-05 17:47:12 +02:00
func GetHeadscaleConfig ( ) ( * Config , error ) {
2022-06-03 09:26:36 +02:00
dnsConfig , baseDomain := GetDNSConfig ( )
derpConfig := GetDERPConfig ( )
logConfig := GetLogTailConfig ( )
2022-06-09 21:20:11 +02:00
randomizeClientPort := viper . GetBool ( "randomize_client_port" )
2022-06-03 09:26:36 +02:00
configuredPrefixes := viper . GetStringSlice ( "ip_prefixes" )
2022-09-02 00:04:04 +02:00
parsedPrefixes := make ( [ ] netip . Prefix , 0 , len ( configuredPrefixes ) + 1 )
2022-06-03 09:26:36 +02:00
for i , prefixInConfig := range configuredPrefixes {
2022-09-02 00:04:04 +02:00
prefix , err := netip . ParsePrefix ( prefixInConfig )
2022-06-03 09:26:36 +02:00
if err != nil {
panic ( fmt . Errorf ( "failed to parse ip_prefixes[%d]: %w" , i , err ) )
}
parsedPrefixes = append ( parsedPrefixes , prefix )
}
2022-09-02 00:04:04 +02:00
prefixes := make ( [ ] netip . Prefix , 0 , len ( parsedPrefixes ) )
2022-06-03 09:26:36 +02:00
{
// dedup
normalizedPrefixes := make ( map [ string ] int , len ( parsedPrefixes ) )
for i , p := range parsedPrefixes {
2022-09-02 00:04:04 +02:00
normalized , _ := netipx . RangeOfPrefix ( p ) . Prefix ( )
2022-06-03 09:26:36 +02:00
normalizedPrefixes [ normalized . String ( ) ] = i
}
// convert back to list
for _ , i := range normalizedPrefixes {
prefixes = append ( prefixes , parsedPrefixes [ i ] )
}
}
if len ( prefixes ) < 1 {
2022-09-02 00:04:04 +02:00
prefixes = append ( prefixes , netip . MustParsePrefix ( "100.64.0.0/10" ) )
2022-06-03 09:26:36 +02:00
log . Warn ( ) .
Msgf ( "'ip_prefixes' not configured, falling back to default: %v" , prefixes )
}
2022-06-05 17:47:12 +02:00
return & Config {
2022-06-03 10:37:45 +02:00
ServerURL : viper . GetString ( "server_url" ) ,
Addr : viper . GetString ( "listen_addr" ) ,
MetricsAddr : viper . GetString ( "metrics_listen_addr" ) ,
GRPCAddr : viper . GetString ( "grpc_listen_addr" ) ,
GRPCAllowInsecure : viper . GetBool ( "grpc_allow_insecure" ) ,
DisableUpdateCheck : viper . GetBool ( "disable_check_updates" ) ,
2022-06-03 09:26:36 +02:00
IPPrefixes : prefixes ,
PrivateKeyPath : AbsolutePathFromConfigPath (
viper . GetString ( "private_key_path" ) ,
) ,
2022-08-13 11:14:38 +02:00
NoisePrivateKeyPath : AbsolutePathFromConfigPath (
2022-08-21 10:42:23 +02:00
viper . GetString ( "noise.private_key_path" ) ,
2022-08-13 11:14:38 +02:00
) ,
2022-06-03 09:26:36 +02:00
BaseDomain : baseDomain ,
DERP : derpConfig ,
EphemeralNodeInactivityTimeout : viper . GetDuration (
"ephemeral_node_inactivity_timeout" ,
) ,
2022-07-12 12:52:03 +02:00
NodeUpdateCheckInterval : viper . GetDuration (
"node_update_check_interval" ,
2022-07-12 12:27:28 +02:00
) ,
2022-06-03 09:26:36 +02:00
DBtype : viper . GetString ( "db_type" ) ,
DBpath : AbsolutePathFromConfigPath ( viper . GetString ( "db_path" ) ) ,
DBhost : viper . GetString ( "db_host" ) ,
DBport : viper . GetInt ( "db_port" ) ,
DBname : viper . GetString ( "db_name" ) ,
DBuser : viper . GetString ( "db_user" ) ,
DBpass : viper . GetString ( "db_pass" ) ,
2022-08-16 09:50:30 +02:00
DBssl : viper . GetBool ( "db_ssl" ) ,
2022-06-03 09:26:36 +02:00
2022-06-03 10:14:14 +02:00
TLS : GetTLSConfig ( ) ,
2022-06-03 09:26:36 +02:00
DNSConfig : dnsConfig ,
ACMEEmail : viper . GetString ( "acme_email" ) ,
ACMEURL : viper . GetString ( "acme_url" ) ,
UnixSocket : viper . GetString ( "unix_socket" ) ,
UnixSocketPermission : GetFileMode ( "unix_socket_permission" ) ,
OIDC : OIDCConfig {
2022-09-26 09:57:28 +02:00
OnlyStartIfOIDCIsAvailable : viper . GetBool (
"oidc.only_start_if_oidc_is_available" ,
) ,
2022-06-03 09:26:36 +02:00
Issuer : viper . GetString ( "oidc.issuer" ) ,
ClientID : viper . GetString ( "oidc.client_id" ) ,
ClientSecret : viper . GetString ( "oidc.client_secret" ) ,
Scope : viper . GetStringSlice ( "oidc.scope" ) ,
ExtraParams : viper . GetStringMapString ( "oidc.extra_params" ) ,
AllowedDomains : viper . GetStringSlice ( "oidc.allowed_domains" ) ,
AllowedUsers : viper . GetStringSlice ( "oidc.allowed_users" ) ,
StripEmaildomain : viper . GetBool ( "oidc.strip_email_domain" ) ,
} ,
2022-06-09 21:20:11 +02:00
LogTail : logConfig ,
RandomizeClientPort : randomizeClientPort ,
2022-06-03 09:26:36 +02:00
CLI : CLIConfig {
Address : viper . GetString ( "cli.address" ) ,
APIKey : viper . GetString ( "cli.api_key" ) ,
Timeout : viper . GetDuration ( "cli.timeout" ) ,
Insecure : viper . GetBool ( "cli.insecure" ) ,
} ,
ACL : GetACLConfig ( ) ,
2022-09-11 21:37:23 +02:00
Log : GetLogConfig ( ) ,
2022-06-05 17:47:12 +02:00
} , nil
2022-06-03 09:26:36 +02:00
}