Bläddra i källkod

WebAdmin: add configs section

Setting configurations is an experimental feature and is not currently
supported in the REST API

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 2 år sedan
förälder
incheckning
a3fff56da5

+ 1 - 1
docs/full-configuration.md

@@ -136,7 +136,7 @@ The configuration file contains the following sections:
   - `host_keys`, list of strings. It contains the daemon's private host keys. Each host key can be defined as a path relative to the configuration directory or an absolute one. If empty, the daemon will search or try to generate `id_rsa`, `id_ecdsa` and `id_ed25519` keys inside the configuration directory. If you configure absolute paths to files named `id_rsa`, `id_ecdsa` and/or `id_ed25519` then SFTPGo will try to generate these keys using the default settings.
   - `host_certificates`, list of strings. Public host certificates. Each certificate can be defined as a path relative to the configuration directory or an absolute one. Certificate's public key must match a private host key otherwise it will be silently ignored. Default: empty.
   - `host_key_algorithms`, list of strings. Public key algorithms that the server will accept for host key authentication. The supported values are: `rsa-sha2-512-cert-v01@openssh.com`, `rsa-sha2-256-cert-v01@openssh.com`, `ssh-rsa-cert-v01@openssh.com`, `ssh-dss-cert-v01@openssh.com`, `ecdsa-sha2-nistp256-cert-v01@openssh.com`, `ecdsa-sha2-nistp384-cert-v01@openssh.com`, `ecdsa-sha2-nistp521-cert-v01@openssh.com`, `ssh-ed25519-cert-v01@openssh.com`, `ecdsa-sha2-nistp256`, `ecdsa-sha2-nistp384`, `ecdsa-sha2-nistp521`, `rsa-sha2-512`, `rsa-sha2-256`, `ssh-rsa`, `ssh-dss`, `ssh-ed25519`. Default values: `rsa-sha2-512-cert-v01@openssh.com`, `rsa-sha2-256-cert-v01@openssh.com`, `ecdsa-sha2-nistp256-cert-v01@openssh.com`, `ecdsa-sha2-nistp384-cert-v01@openssh.com`, `ecdsa-sha2-nistp521-cert-v01@openssh.com`, `ssh-ed25519-cert-v01@openssh.com`, `ecdsa-sha2-nistp256`, `ecdsa-sha2-nistp384`, `ecdsa-sha2-nistp521`, `rsa-sha2-512`, `rsa-sha2-256`, `ssh-ed25519`.
-  - `moduli`, list of strings. Diffie-Hellman moduli files. Each moduli file can be defined as a path relative to the configuration directory or an absolute one. If set, `diffie-hellman-group-exchange-sha256` and `diffie-hellman-group-exchange-sha1` KEX algorithms will be available, `diffie-hellman-group-exchange-sha256` will be enabled by default if you don't explicitly set KEXs. Default: empty.
+  - `moduli`, list of strings. Diffie-Hellman moduli files. Each moduli file can be defined as a path relative to the configuration directory or an absolute one. If set and valid, `diffie-hellman-group-exchange-sha256` and `diffie-hellman-group-exchange-sha1` KEX algorithms will be available, `diffie-hellman-group-exchange-sha256` will be enabled by default if you don't explicitly set KEXs. Invalid moduli file will be silently ignored. Default: empty.
   - `kex_algorithms`, list of strings. Available KEX (Key Exchange) algorithms in preference order. Leave empty to use default values. The supported values are: `curve25519-sha256`, `curve25519-sha256@libssh.org`, `ecdh-sha2-nistp256`, `ecdh-sha2-nistp384`, `ecdh-sha2-nistp521`, `diffie-hellman-group14-sha256`, `diffie-hellman-group16-sha512`, `diffie-hellman-group18-sha512`, `diffie-hellman-group14-sha1`, `diffie-hellman-group1-sha1`. Default values: `curve25519-sha256`, `curve25519-sha256@libssh.org`, `ecdh-sha2-nistp256`, `ecdh-sha2-nistp384`, `ecdh-sha2-nistp521`, `diffie-hellman-group14-sha256`. SHA512 based KEXs are disabled by default because they are slow. If you set one or more moduli files,  `diffie-hellman-group-exchange-sha256` and `diffie-hellman-group-exchange-sha1` will be available.
   - `ciphers`, list of strings. Allowed ciphers in preference order. Leave empty to use default values. The supported values are: `aes128-gcm@openssh.com`, `aes256-gcm@openssh.com`, `chacha20-poly1305@openssh.com`, `aes128-ctr`, `aes192-ctr`, `aes256-ctr`, `aes128-cbc`, `aes192-cbc`, `aes256-cbc`, `3des-cbc`, `arcfour256`, `arcfour128`, `arcfour`. Default values: `aes128-gcm@openssh.com`, `aes256-gcm@openssh.com`, `chacha20-poly1305@openssh.com`, `aes128-ctr`, `aes192-ctr`, `aes256-ctr`. Please note that the ciphers disabled by default are insecure, you should expect that an active attacker can recover plaintext if you enable them.
   - `macs`, list of strings. Available MAC (message authentication code) algorithms in preference order. Leave empty to use default values. The supported values are: `hmac-sha2-256-etm@openssh.com`, `hmac-sha2-256`, `hmac-sha2-512-etm@openssh.com`, `hmac-sha2-512`, `hmac-sha1`, `hmac-sha1-96`. Default values: `hmac-sha2-256-etm@openssh.com`, `hmac-sha2-256`.

+ 8 - 6
docs/howto/lets-encrypt-certificate.md

@@ -19,12 +19,12 @@ In this tutorial we'll focus on `HTTP-01` challenge type and make the following
 
 ## Overview
 
-- [Obtaining a certificate using the Lego CLI tool](#Obtaining-a-certificate-using-the-Lego-CLI-tool)
-  - [Automatic certificate renewal using the Lego CLI tool](#Automatic-certificate-renewal-using-the-Lego-CLI-tool)
-- [Obtaining a certificate using the ACME protocol built into SFTPGo](#Obtaining-a-certificate-using-the-ACME-protocol-built-into-SFTPGo)
-- [Enable HTTPS for SFTPGo Web UI and REST API](#Enable-HTTPS-for-SFTPGo-Web-UI-and-REST-API)
-- [Enable HTTPS for WebDAV service](#Enable-HTTPS-for-WebDAV-service)
-- [Enable explicit FTP over TLS](#Enable-explicit-FTP-over-TLS)
+- [Obtaining a certificate using the Lego CLI tool](#obtaining-a-certificate-using-the-lego-cli-tool)
+  - [Automatic certificate renewal using the Lego CLI tool](#automatic-certificate-renewal-using-the-lego-cli-tool)
+- [Obtaining a certificate using the ACME protocol built into SFTPGo](#obtaining-a-certificate-using-the-acme-protocol-built-into-sftpgo)
+- [Enable HTTPS for SFTPGo Web UI and REST API](#enable-https-for-sftpgo-web-ui-and-rest-api)
+- [Enable HTTPS for WebDAV service](#enable-https-for-webdav-service)
+- [Enable explicit FTP over TLS](#enable-explicit-ftp-over-tls)
 
 ## Obtaining a certificate using the Lego CLI tool
 
@@ -149,6 +149,8 @@ SFTPGO_ACME__HTTP01_CHALLENGE__WEBROOT="/var/www/sftpgo.com"
 Make sure that the `sftpgo` user can write to the `/var/www/sftpgo.com` directory or pre-create the `/var/www/sftpgo.com/.well-known/acme-challenge` directory with the appropriate permissions.
 This directory must be publicly served by your web server.
 
+:warning: in this example we assume you have an existing HTTP server. If not, you can leave the web root blank and SFTPGo will resolve the HTTP01 challenge by itself.
+
 Register your account and obtain certificates by running the following command.
 
 ```bash

+ 15 - 15
go.mod

@@ -9,13 +9,13 @@ require (
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
 	github.com/aws/aws-sdk-go-v2 v1.17.4
-	github.com/aws/aws-sdk-go-v2/config v1.18.12
-	github.com/aws/aws-sdk-go-v2/credentials v1.13.12
+	github.com/aws/aws-sdk-go-v2/config v1.18.13
+	github.com/aws/aws-sdk-go-v2/credentials v1.13.13
 	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.22
-	github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.51
-	github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.14.2
-	github.com/aws/aws-sdk-go-v2/service/s3 v1.30.2
-	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.18.3
+	github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.53
+	github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.14.3
+	github.com/aws/aws-sdk-go-v2/service/s3 v1.30.3
+	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.18.4
 	github.com/aws/aws-sdk-go-v2/service/sts v1.18.3
 	github.com/bmatcuk/doublestar/v4 v4.6.0
 	github.com/cockroachdb/cockroach-go/v2 v2.2.20
@@ -68,33 +68,33 @@ require (
 	go.uber.org/automaxprocs v1.5.1
 	gocloud.dev v0.28.0
 	golang.org/x/crypto v0.6.0
-	golang.org/x/net v0.6.0
+	golang.org/x/net v0.7.0
 	golang.org/x/oauth2 v0.5.0
 	golang.org/x/sys v0.5.0
 	golang.org/x/term v0.5.0
 	golang.org/x/time v0.3.0
-	google.golang.org/api v0.109.0
+	google.golang.org/api v0.110.0
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 )
 
 require (
-	cloud.google.com/go v0.109.0 // indirect
+	cloud.google.com/go v0.110.0 // indirect
 	cloud.google.com/go/compute v1.18.0 // indirect
 	cloud.google.com/go/compute/metadata v0.2.3 // indirect
-	cloud.google.com/go/iam v0.10.0 // indirect
+	cloud.google.com/go/iam v0.12.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2 // indirect
 	github.com/ajg/form v1.5.1 // indirect
 	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect
 	github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.28 // indirect
 	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.22 // indirect
 	github.com/aws/aws-sdk-go-v2/internal/ini v1.3.29 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.19 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.20 // indirect
 	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect
 	github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.23 // indirect
 	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.22 // indirect
 	github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.22 // indirect
-	github.com/aws/aws-sdk-go-v2/service/sso v1.12.1 // indirect
-	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.1 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sso v1.12.2 // indirect
+	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.2 // indirect
 	github.com/aws/smithy-go v1.13.5 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/boombuler/barcode v1.0.1 // indirect
@@ -112,7 +112,7 @@ require (
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/google/go-cmp v0.5.9 // indirect
-	github.com/googleapis/enterprise-certificate-proxy v0.2.2 // indirect
+	github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
 	github.com/googleapis/gax-go/v2 v2.7.0 // indirect
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
@@ -157,7 +157,7 @@ require (
 	golang.org/x/tools v0.6.0 // indirect
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc // indirect
+	google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44 // indirect
 	google.golang.org/grpc v1.53.0 // indirect
 	google.golang.org/protobuf v1.28.1 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect

+ 30 - 29
go.sum

@@ -37,8 +37,8 @@ cloud.google.com/go v0.102.1/go.mod h1:XZ77E9qnTEnrgEOvr4xzfdX5TRo7fB4T2F4O6+34h
 cloud.google.com/go v0.104.0/go.mod h1:OO6xxXdJyvuJPcEPBLN9BJPD+jep5G1+2U5B5gkRYtA=
 cloud.google.com/go v0.105.0/go.mod h1:PrLgOJNe5nfE9UMxKxgXj4mD3voiP+YQ6gdt6KMFOKM=
 cloud.google.com/go v0.107.0/go.mod h1:wpc2eNrD7hXUTy8EKS10jkxpZBjASrORK7goS+3YX2I=
-cloud.google.com/go v0.109.0 h1:38CZoKGlCnPZjGdyj0ZfpoGae0/wgNfy5F0byyxg0Gk=
-cloud.google.com/go v0.109.0/go.mod h1:2sYycXt75t/CSB5R9M2wPU1tJmire7AQZTPtITcGBVE=
+cloud.google.com/go v0.110.0 h1:Zc8gqp3+a9/Eyph2KDmcGaPtbKRIoqq4YTlL4NMD0Ys=
+cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY=
 cloud.google.com/go/accessapproval v1.4.0/go.mod h1:zybIuC3KpDOvotz59lFe5qxRZx6C75OtwbisN56xYB4=
 cloud.google.com/go/accessapproval v1.5.0/go.mod h1:HFy3tuiGvMdcd/u+Cu5b9NkO1pEICJ46IR82PoUdplw=
 cloud.google.com/go/accesscontextmanager v1.3.0/go.mod h1:TgCBehyr5gNMz7ZaH9xubp+CE8dkrszb4oK9CWyvD4o=
@@ -205,8 +205,8 @@ cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp
 cloud.google.com/go/iam v0.5.0/go.mod h1:wPU9Vt0P4UmCux7mqtRu6jcpPAb74cP1fh50J3QpkUc=
 cloud.google.com/go/iam v0.6.0/go.mod h1:+1AH33ueBne5MzYccyMHtEKqLE4/kJOibtffMHDMFMc=
 cloud.google.com/go/iam v0.7.0/go.mod h1:H5Br8wRaDGNc8XP3keLc4unfUUZeyH3Sfl9XpQEYOeg=
-cloud.google.com/go/iam v0.10.0 h1:fpP/gByFs6US1ma53v7VxhvbJpO2Aapng6wabJ99MuI=
-cloud.google.com/go/iam v0.10.0/go.mod h1:nXAECrMt2qHpF6RZUZseteD6QyanL68reN4OXPw0UWM=
+cloud.google.com/go/iam v0.12.0 h1:DRtTY29b75ciH6Ov1PHb4/iat2CLCvrOm40Q0a6DFpE=
+cloud.google.com/go/iam v0.12.0/go.mod h1:knyHGviacl11zrtZUoDuYpDgLjvr28sLQaG0YB2GYAY=
 cloud.google.com/go/iap v1.4.0/go.mod h1:RGFwRJdihTINIe4wZ2iCP0zF/qu18ZwyKxrhMhygBEc=
 cloud.google.com/go/iap v1.5.0/go.mod h1:UH/CGgKd4KyohZL5Pt0jSKE4m3FR51qg6FKQ/z/Ix9A=
 cloud.google.com/go/ids v1.1.0/go.mod h1:WIuwCaYVOzHIj2OhN9HAwvW+DBdmUAdcWlFxRl+KubM=
@@ -539,17 +539,17 @@ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.9/go.mod h1:vCmV1q1VK
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 h1:dK82zF6kkPeCo8J1e+tGx4JdvDIQzj7ygIoLg8WMuGs=
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10/go.mod h1:VeTZetY5KRJLuD/7fkQXMU6Mw7H5m/KP2J5Iy9osMno=
 github.com/aws/aws-sdk-go-v2/config v1.18.3/go.mod h1:BYdrbeCse3ZnOD5+2/VE/nATOK8fEUpBtmPMdKSyhMU=
-github.com/aws/aws-sdk-go-v2/config v1.18.12 h1:fKs/I4wccmfrNRO9rdrbMO1NgLxct6H9rNMiPdBxHWw=
-github.com/aws/aws-sdk-go-v2/config v1.18.12/go.mod h1:J36fOhj1LQBr+O4hJCiT8FwVvieeoSGOtPuvhKlsNu8=
+github.com/aws/aws-sdk-go-v2/config v1.18.13 h1:v0xlYqbO6/EVlM8tUn2QEOA7btQxcgidEq2JRDBPTho=
+github.com/aws/aws-sdk-go-v2/config v1.18.13/go.mod h1:r39wGSZB7wPDW1i54JyQXUpc5KsWjh5z/3S5D9eCqDg=
 github.com/aws/aws-sdk-go-v2/credentials v1.13.3/go.mod h1:/rOMmqYBcFfNbRPU0iN9IgGqD5+V2yp3iWNmIlz0wI4=
-github.com/aws/aws-sdk-go-v2/credentials v1.13.12 h1:Cb+HhuEnV19zHRaYYVglwvdHGMJWbdsyP4oHhw04xws=
-github.com/aws/aws-sdk-go-v2/credentials v1.13.12/go.mod h1:37HG2MBroXK3jXfxVGtbM2J48ra2+Ltu+tmwr/jO0KA=
+github.com/aws/aws-sdk-go-v2/credentials v1.13.13 h1:zw1KAc1kl00NYd3ofVmFrb09qnYlSQMeh+fmlQRAihI=
+github.com/aws/aws-sdk-go-v2/credentials v1.13.13/go.mod h1:DW9nbIIF9MrIja0cBQrUpeWYQMSlNmP8fevLUyF9W38=
 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.19/go.mod h1:VihW95zQpeKQWVPGkwT+2+WJNQV8UXFfMTWdU6VErL8=
 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.22 h1:3aMfcTmoXtTZnaT86QlVaYh+BRMbvrrmZwIQ5jWqCZQ=
 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.22/go.mod h1:YGSIJyQ6D6FjKMQh16hVFSIUD54L4F7zTGePqYMYYJU=
 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.42/go.mod h1:LHOsygMiW/14CkFxdXxvzKyMh3jbk/QfZVaDtCbLkl8=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.51 h1:iTFYCAdKzSAjGnVIUe88Hxvix0uaBqr0Rv7qJEOX5hE=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.51/go.mod h1:7Grl2gV+dx9SWrUIgwwlUvU40t7+lOSbx34XwfmsTkY=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.53 h1:h1MmqGtYgkf49DhG2BSjGukpm8c+BJ9CL+bBbdFGzlk=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.53/go.mod h1:mlWLxwKZNeEwE+3Pko07lSr1NvHZwUtdzmo9AiGn7QU=
 github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.25/go.mod h1:Zb29PYkf42vVYQY6pvSyJCJcFHlPIiY+YKdPtwnvMkY=
 github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.28 h1:r+XwaCLpIvCKjBIYy/HVZujQS9tsz5ohHG3ZIe0wKoE=
 github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.28/go.mod h1:3lwChorpIM/BhImY/hy+Z6jekmN92cXGPI1QJasVPYY=
@@ -560,8 +560,8 @@ github.com/aws/aws-sdk-go-v2/internal/ini v1.3.26/go.mod h1:Y2OJ+P+MC1u1VKnavT+P
 github.com/aws/aws-sdk-go-v2/internal/ini v1.3.29 h1:J4xhFd6zHhdF9jPP0FQJ6WknzBboGMBNjKOv4iTuw4A=
 github.com/aws/aws-sdk-go-v2/internal/ini v1.3.29/go.mod h1:TwuqRBGzxjQJIwH16/fOZodwXt2Zxa9/cwJC5ke4j7s=
 github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.16/go.mod h1:XH+3h395e3WVdd6T2Z3mPxuI+x/HVtdqVOREkTiyubs=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.19 h1:FGvpyTg2LKEmMrLlpjOgkoNp9XF5CGeyAyo33LdqZW8=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.19/go.mod h1:8W88sW3PjamQpKFUQvHWWKay6ARsNvZnzU7+a4apubw=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.20 h1:YIvKIfPXQVp0EhXUV644kmQo6cQPPSRmC44A1HSoJeg=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.20/go.mod h1:8W88sW3PjamQpKFUQvHWWKay6ARsNvZnzU7+a4apubw=
 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.10/go.mod h1:9cBNUHI2aW4ho0A5T87O294iPDuuUOSIEDjnd1Lq/z0=
 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 h1:y2+VQzC6Zh2ojtV2LoC0MNwHWc6qXv/j2vrQtlftkdA=
 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11/go.mod h1:iV4q2hsqtNECrfmlXyord9u4zyuFEJX9eLgLpSPzWA8=
@@ -575,23 +575,23 @@ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.19/go.mod h1:BmQWRV
 github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.22 h1:ISLJ2BKXe4zzyZ7mp5ewKECiw0U7KpLgS3S6OxY9Cm0=
 github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.22/go.mod h1:QFVbqK54XArazLvn2wvWMRBi/jGrWii46qbr5DyPGjc=
 github.com/aws/aws-sdk-go-v2/service/kms v1.19.0/go.mod h1:kZodDPTQjSH/qM6/OvyTfM5mms5JHB/EKYp5dhn/vI4=
-github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.14.2 h1:7vuSkPqVqwBwSV0OJD71qqWOEFr3Hh1K0e2yOQ/JWwQ=
-github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.14.2/go.mod h1:vrZVsmrC7QRNBK/W8nplI0tfJDvMl6DZAUT/pkFJiws=
+github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.14.3 h1:7SguEzgmyCr6bgJ4+GLk1QWGJ+tpN8q26oNpWcQg1jw=
+github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.14.3/go.mod h1:vrZVsmrC7QRNBK/W8nplI0tfJDvMl6DZAUT/pkFJiws=
 github.com/aws/aws-sdk-go-v2/service/s3 v1.29.4/go.mod h1:/NHbqPRiwxSPVOB2Xr+StDEH+GWV/64WwnUjv4KYzV0=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.30.2 h1:5EQWIFO+Hc8E2hFcXQJ1vm6ufl/PMt/6RVRDZRju2vM=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.30.2/go.mod h1:SXDHd6fI2RhqB7vmAzyYQCTQnpZrIprVJvYxpzW3JAM=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.30.3 h1:PVieHTwugdlHedlxLpYLQsOZAq736RScuEb/m4zhzc4=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.30.3/go.mod h1:XN3YcdmnWYZ3Hrnojvo5p2mc/wfF973nkq3ClXPDMHk=
 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.16.8/go.mod h1:k6CPuxyzO247nYEM1baEwHH1kRtosRCvgahAepaaShw=
-github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.18.3 h1:Zod/h9QcDvbrrG3jjTUp4lctRb6Qg2nj7ARC/xMsUc4=
-github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.18.3/go.mod h1:hqPcyOuLU6yWIbLy3qMnQnmidgKuIEwqIlW6+chYnog=
+github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.18.4 h1:0P9VF9miVGT40WSZSuMzHwkwTVIltpDrTrvswMLjbx0=
+github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.18.4/go.mod h1:hqPcyOuLU6yWIbLy3qMnQnmidgKuIEwqIlW6+chYnog=
 github.com/aws/aws-sdk-go-v2/service/sns v1.18.6/go.mod h1:2cPUjR63iE9MPMPJtSyzYmsTFCNrN/Xi9j0v9BL5OU0=
 github.com/aws/aws-sdk-go-v2/service/sqs v1.19.15/go.mod h1:DKX/7/ZiAzHO6p6AhArnGdrV4r+d461weby8KeVtvC4=
 github.com/aws/aws-sdk-go-v2/service/ssm v1.33.1/go.mod h1:rEsqsZrOp9YvSGPOrcL3pR9+i/QJaWRkAYbuxMa7yCU=
 github.com/aws/aws-sdk-go-v2/service/sso v1.11.25/go.mod h1:IARHuzTXmj1C0KS35vboR0FeJ89OkEy1M9mWbK2ifCI=
-github.com/aws/aws-sdk-go-v2/service/sso v1.12.1 h1:lQKN/LNa3qqu2cDOQZybP7oL4nMGGiFqob0jZJaR8/4=
-github.com/aws/aws-sdk-go-v2/service/sso v1.12.1/go.mod h1:IgV8l3sj22nQDd5qcAGY0WenwCzCphqdbFOpfktZPrI=
+github.com/aws/aws-sdk-go-v2/service/sso v1.12.2 h1:EN102fWY7hI5u/2FPheTrwwMHkSXfl49RYkeEnJsrCU=
+github.com/aws/aws-sdk-go-v2/service/sso v1.12.2/go.mod h1:IgV8l3sj22nQDd5qcAGY0WenwCzCphqdbFOpfktZPrI=
 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.8/go.mod h1:er2JHN+kBY6FcMfcBBKNGCT3CarImmdFzishsqBmSRI=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.1 h1:0bLhH6DRAqox+g0LatcjGKjjhU6Eudyys6HB6DJVPj8=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.1/go.mod h1:O1YSOg3aekZibh2SngvCRRG+cRHKKlYgxf/JBF/Kr/k=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.2 h1:f1lmlce7r13CX1BPyPqt9oh/H+uqOWc9367lDoGGwNQ=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.2/go.mod h1:O1YSOg3aekZibh2SngvCRRG+cRHKKlYgxf/JBF/Kr/k=
 github.com/aws/aws-sdk-go-v2/service/sts v1.17.5/go.mod h1:bXcN3koeVYiJcdDU89n3kCYILob7Y34AeLopUbZgLT4=
 github.com/aws/aws-sdk-go-v2/service/sts v1.18.3 h1:s49mSnsBZEXjfGBkRfmK+nPqzT7Lt3+t2SmAKNyHblw=
 github.com/aws/aws-sdk-go-v2/service/sts v1.18.3/go.mod h1:b+psTJn33Q4qGoDaM7ZiOVVG8uVjGI6HaZ8WBHdgDgU=
@@ -1172,8 +1172,8 @@ github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1
 github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
 github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
 github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg=
-github.com/googleapis/enterprise-certificate-proxy v0.2.2 h1:jUqbmxlR+gGPQq/uvQviKpS1bSQecfs2t7o6F14sk9s=
-github.com/googleapis/enterprise-certificate-proxy v0.2.2/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
+github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k=
+github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
@@ -2180,8 +2180,9 @@ golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
 golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
 golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
-golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
+golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -2578,8 +2579,8 @@ google.golang.org/api v0.99.0/go.mod h1:1YOf74vkVndF7pG6hIHuINsM7eWwpVTAfNMNiL91
 google.golang.org/api v0.100.0/go.mod h1:ZE3Z2+ZOr87Rx7dqFsdRQkRBk36kDtp/h+QpHbB7a70=
 google.golang.org/api v0.102.0/go.mod h1:3VFl6/fzoA+qNuS1N1/VfXY4LjoXN/wzeIp7TweWwGo=
 google.golang.org/api v0.103.0/go.mod h1:hGtW6nK1AC+d9si/UBhw8Xli+QMOf6xyNAyJw4qU9w0=
-google.golang.org/api v0.109.0 h1:sW9hgHyX497PP5//NUM7nqfV8D0iDfBApqq7sOh1XR8=
-google.golang.org/api v0.109.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY=
+google.golang.org/api v0.110.0 h1:l+rh0KYUooe9JGbGVx71tbFo4SMbMTXK3I3ia2QSEeU=
+google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -2713,8 +2714,8 @@ google.golang.org/genproto v0.0.0-20221109142239-94d6d90a7d66/go.mod h1:rZS5c/ZV
 google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg=
 google.golang.org/genproto v0.0.0-20221201164419-0e50fba7f41c/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg=
 google.golang.org/genproto v0.0.0-20221201204527-e3fa12d562f3/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg=
-google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc h1:ijGwO+0vL2hJt5gaygqP2j6PfflOBrRot0IczKbmtio=
-google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
+google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44 h1:EfLuoKW5WfkgVdDy7dTK8qSbH37AX5mj/MFh+bGPz14=
+google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44/go.mod h1:8B0gmkoRebU8ukX6HP+4wrVQUY1+6PkQ44BSyIlflHA=
 google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=

+ 9 - 2
internal/cmd/smtptest.go

@@ -21,6 +21,7 @@ import (
 	"github.com/spf13/cobra"
 
 	"github.com/drakkan/sftpgo/v2/internal/config"
+	"github.com/drakkan/sftpgo/v2/internal/dataprovider"
 	"github.com/drakkan/sftpgo/v2/internal/logger"
 	"github.com/drakkan/sftpgo/v2/internal/smtp"
 	"github.com/drakkan/sftpgo/v2/internal/util"
@@ -39,7 +40,13 @@ If the SMTP configuration is correct you should receive this email.`,
 			configDir = util.CleanDirInput(configDir)
 			err := config.LoadConfig(configDir, configFile)
 			if err != nil {
-				logger.WarnToConsole("Unable to load configuration: %v", err)
+				logger.ErrorToConsole("Unable to load configuration: %v", err)
+				os.Exit(1)
+			}
+			providerConf := config.GetProviderConf()
+			err = dataprovider.Initialize(providerConf, configDir, false)
+			if err != nil {
+				logger.ErrorToConsole("error initializing data provider: %v", err)
 				os.Exit(1)
 			}
 			smtpConfig := config.GetSMTPConfig()
@@ -54,7 +61,7 @@ If the SMTP configuration is correct you should receive this email.`,
 				logger.WarnToConsole("Error sending email: %v", err)
 				os.Exit(1)
 			}
-			logger.InfoToConsole("No errors were reported while sending an email. Please check your inbox to make sure.")
+			logger.InfoToConsole("No errors were reported while sending the test email. Please check your inbox to make sure.")
 		},
 	}
 )

+ 10 - 10
internal/cmd/startsubsys.go

@@ -96,16 +96,6 @@ Command-line flags should be specified in the Subsystem declaration.
 				logger.Error(logSender, "", "unable to initialize MFA: %v", err)
 				os.Exit(1)
 			}
-			if err := plugin.Initialize(config.GetPluginsConfig(), logLevel); err != nil {
-				logger.Error(logSender, connectionID, "unable to initialize plugin system: %v", err)
-				os.Exit(1)
-			}
-			smtpConfig := config.GetSMTPConfig()
-			err = smtpConfig.Initialize(configDir)
-			if err != nil {
-				logger.Error(logSender, connectionID, "unable to initialize SMTP configuration: %v", err)
-				os.Exit(1)
-			}
 			dataProviderConf := config.GetProviderConf()
 			if dataProviderConf.Driver == dataprovider.SQLiteDataProviderName || dataProviderConf.Driver == dataprovider.BoltDataProviderName {
 				logger.Debug(logSender, connectionID, "data provider %q not supported in subsystem mode, using %q provider",
@@ -119,6 +109,16 @@ Command-line flags should be specified in the Subsystem declaration.
 				logger.Error(logSender, connectionID, "unable to initialize the data provider: %v", err)
 				os.Exit(1)
 			}
+			if err := plugin.Initialize(config.GetPluginsConfig(), logLevel); err != nil {
+				logger.Error(logSender, connectionID, "unable to initialize plugin system: %v", err)
+				os.Exit(1)
+			}
+			smtpConfig := config.GetSMTPConfig()
+			err = smtpConfig.Initialize(configDir)
+			if err != nil {
+				logger.Error(logSender, connectionID, "unable to initialize SMTP configuration: %v", err)
+				os.Exit(1)
+			}
 			commonConfig := config.GetCommonConfig()
 			// idle connection are managed externally
 			commonConfig.IdleTimeout = 0

+ 8 - 2
internal/common/common.go

@@ -39,6 +39,7 @@ import (
 	"github.com/drakkan/sftpgo/v2/internal/logger"
 	"github.com/drakkan/sftpgo/v2/internal/metric"
 	"github.com/drakkan/sftpgo/v2/internal/plugin"
+	"github.com/drakkan/sftpgo/v2/internal/smtp"
 	"github.com/drakkan/sftpgo/v2/internal/util"
 	"github.com/drakkan/sftpgo/v2/internal/vfs"
 )
@@ -170,7 +171,7 @@ func Initialize(c Configuration, isShared int) error {
 	Config.ProxyAllowed = util.RemoveDuplicates(Config.ProxyAllowed, true)
 	Config.idleLoginTimeout = 2 * time.Minute
 	Config.idleTimeoutAsDuration = time.Duration(Config.IdleTimeout) * time.Minute
-	startPeriodicChecks(periodicTimeoutCheckInterval)
+	startPeriodicChecks(periodicTimeoutCheckInterval, isShared)
 	Config.defender = nil
 	Config.allowList = nil
 	Config.rateLimitersList = nil
@@ -382,12 +383,17 @@ func AddDefenderEvent(ip, protocol string, event HostEvent) {
 	Config.defender.AddEvent(ip, protocol, event)
 }
 
-func startPeriodicChecks(duration time.Duration) {
+func startPeriodicChecks(duration time.Duration, isShared int) {
 	startEventScheduler()
 	spec := fmt.Sprintf("@every %s", duration)
 	_, err := eventScheduler.AddFunc(spec, Connections.checkTransfers)
 	util.PanicOnError(err)
 	logger.Info(logSender, "", "scheduled overquota transfers check, schedule %q", spec)
+	if isShared == 1 {
+		logger.Info(logSender, "", "add reload configs task")
+		_, err := eventScheduler.AddFunc("@every 10m", smtp.ReloadProviderConf)
+		util.PanicOnError(err)
+	}
 	if Config.IdleTimeout > 0 {
 		ratio := idleTimeoutCheckInterval / periodicTimeoutCheckInterval
 		spec = fmt.Sprintf("@every %s", duration*ratio)

+ 2 - 2
internal/common/common_test.go

@@ -722,7 +722,7 @@ func TestIdleConnections(t *testing.T) {
 	assert.Len(t, Connections.sshConnections, 2)
 	Connections.RUnlock()
 
-	startPeriodicChecks(100 * time.Millisecond)
+	startPeriodicChecks(100*time.Millisecond, 0)
 	assert.Eventually(t, func() bool { return Connections.GetActiveSessions(username) == 1 }, 2*time.Second, 200*time.Millisecond)
 	assert.Eventually(t, func() bool {
 		Connections.RLock()
@@ -734,7 +734,7 @@ func TestIdleConnections(t *testing.T) {
 	c.lastActivity.Store(time.Now().Add(-24 * time.Hour).UnixNano())
 	cFTP.lastActivity.Store(time.Now().Add(-24 * time.Hour).UnixNano())
 	sshConn2.lastActivity.Store(c.lastActivity.Load())
-	startPeriodicChecks(100 * time.Millisecond)
+	startPeriodicChecks(100*time.Millisecond, 1)
 	assert.Eventually(t, func() bool { return len(Connections.GetStats("")) == 0 }, 2*time.Second, 200*time.Millisecond)
 	assert.Eventually(t, func() bool {
 		Connections.RLock()

+ 4 - 3
internal/dataprovider/actions.go

@@ -52,6 +52,7 @@ const (
 	actionObjectEventRule   = "event_rule"
 	actionObjectRole        = "role"
 	actionObjectIPListEntry = "ip_list_entry"
+	actionObjectConfigs     = "configs"
 )
 
 var (
@@ -90,14 +91,14 @@ func executeAction(operation, executor, ip, objectType, objectName, role string,
 
 		dataAsJSON, err := object.RenderAsJSON(operation != operationDelete)
 		if err != nil {
-			providerLog(logger.LevelError, "unable to serialize user as JSON for operation %#v: %v", operation, err)
+			providerLog(logger.LevelError, "unable to serialize user as JSON for operation %q: %v", operation, err)
 			return
 		}
 		if strings.HasPrefix(config.Actions.Hook, "http") {
 			var url *url.URL
 			url, err := url.Parse(config.Actions.Hook)
 			if err != nil {
-				providerLog(logger.LevelError, "Invalid http_notification_url %#v for operation %#v: %v",
+				providerLog(logger.LevelError, "Invalid http_notification_url %q for operation %q: %v",
 					config.Actions.Hook, operation, err)
 				return
 			}
@@ -129,7 +130,7 @@ func executeAction(operation, executor, ip, objectType, objectName, role string,
 
 func executeNotificationCommand(operation, executor, ip, objectType, objectName, role string, objectAsJSON []byte) error {
 	if !filepath.IsAbs(config.Actions.Hook) {
-		err := fmt.Errorf("invalid notification command %#v", config.Actions.Hook)
+		err := fmt.Errorf("invalid notification command %q", config.Actions.Hook)
 		logger.Warn(logSender, "", "unable to execute notification command: %v", err)
 		return err
 	}

+ 48 - 11
internal/dataprovider/bolt.go

@@ -37,7 +37,7 @@ import (
 )
 
 const (
-	boltDatabaseVersion = 27
+	boltDatabaseVersion = 28
 )
 
 var (
@@ -51,10 +51,12 @@ var (
 	rulesBucket     = []byte("events_rules")
 	rolesBucket     = []byte("roles")
 	ipListsBucket   = []byte("ip_lists")
+	configsBucket   = []byte("configs")
 	dbVersionBucket = []byte("db_version")
 	dbVersionKey    = []byte("version")
+	configsKey      = []byte("configs")
 	boltBuckets     = [][]byte{usersBucket, groupsBucket, foldersBucket, adminsBucket, apiKeysBucket,
-		sharesBucket, actionsBucket, rulesBucket, rolesBucket, ipListsBucket, dbVersionBucket}
+		sharesBucket, actionsBucket, rulesBucket, rolesBucket, ipListsBucket, configsBucket, dbVersionBucket}
 )
 
 // BoltProvider defines the auth provider for bolt key/value store
@@ -2977,6 +2979,39 @@ func (p *BoltProvider) getListEntriesForIP(ip string, listType IPListType) ([]IP
 	return entries, err
 }
 
+func (p *BoltProvider) getConfigs() (Configs, error) {
+	var configs Configs
+	err := p.dbHandle.View(func(tx *bolt.Tx) error {
+		bucket := tx.Bucket(configsBucket)
+		if bucket == nil {
+			return fmt.Errorf("unable to find configs bucket")
+		}
+		data := bucket.Get(configsKey)
+		if data != nil {
+			return json.Unmarshal(data, &configs)
+		}
+		return nil
+	})
+	return configs, err
+}
+
+func (p *BoltProvider) setConfigs(configs *Configs) error {
+	if err := configs.validate(); err != nil {
+		return err
+	}
+	return p.dbHandle.Update(func(tx *bolt.Tx) error {
+		bucket := tx.Bucket(configsBucket)
+		if bucket == nil {
+			return fmt.Errorf("unable to find configs bucket")
+		}
+		buf, err := json.Marshal(configs)
+		if err != nil {
+			return err
+		}
+		return bucket.Put(configsKey, buf)
+	})
+}
+
 func (p *BoltProvider) setFirstDownloadTimestamp(username string) error {
 	return p.dbHandle.Update(func(tx *bolt.Tx) error {
 		bucket, err := p.getUsersBucket(tx)
@@ -3061,9 +3096,9 @@ func (p *BoltProvider) migrateDatabase() error {
 		providerLog(logger.LevelError, "%v", err)
 		logger.ErrorToConsole("%v", err)
 		return err
-	case version == 23, version == 24, version == 25, version == 26:
-		logger.InfoToConsole("updating database schema version: %d -> 27", version)
-		providerLog(logger.LevelInfo, "updating database schema version: %d -> 27", version)
+	case version == 23, version == 24, version == 25, version == 26, version == 27:
+		logger.InfoToConsole("updating database schema version: %d -> 28", version)
+		providerLog(logger.LevelInfo, "updating database schema version: %d -> 28", version)
 		err := p.dbHandle.Update(func(tx *bolt.Tx) error {
 			rules, err := p.dumpEventRules()
 			if err != nil {
@@ -3095,7 +3130,7 @@ func (p *BoltProvider) migrateDatabase() error {
 		if err != nil {
 			return err
 		}
-		return updateBoltDatabaseVersion(p.dbHandle, 27)
+		return updateBoltDatabaseVersion(p.dbHandle, 28)
 	default:
 		if version > boltDatabaseVersion {
 			providerLog(logger.LevelError, "database schema version %d is newer than the supported one: %d", version,
@@ -3108,7 +3143,7 @@ func (p *BoltProvider) migrateDatabase() error {
 	}
 }
 
-func (p *BoltProvider) revertDatabase(targetVersion int) error {
+func (p *BoltProvider) revertDatabase(targetVersion int) error { //nolint:gocyclo
 	dbVersion, err := getBoltDatabaseVersion(p.dbHandle)
 	if err != nil {
 		return err
@@ -3117,7 +3152,7 @@ func (p *BoltProvider) revertDatabase(targetVersion int) error {
 		return errors.New("current version match target version, nothing to do")
 	}
 	switch dbVersion.Version {
-	case 24, 25, 26:
+	case 24, 25, 26, 27, 28:
 		logger.InfoToConsole("downgrading database schema version: %d -> 23", dbVersion.Version)
 		providerLog(logger.LevelInfo, "downgrading database schema version: %d -> 23", dbVersion.Version)
 		err := p.dbHandle.Update(func(tx *bolt.Tx) error {
@@ -3145,9 +3180,11 @@ func (p *BoltProvider) revertDatabase(targetVersion int) error {
 					}
 				}
 			}
-			err = tx.DeleteBucket(rolesBucket)
-			if err != nil && !errors.Is(err, bolt.ErrBucketNotFound) {
-				return err
+			for _, b := range [][]byte{rolesBucket, configsBucket} {
+				err = tx.DeleteBucket(b)
+				if err != nil && !errors.Is(err, bolt.ErrBucketNotFound) {
+					return err
+				}
 			}
 			return nil
 		})

+ 299 - 0
internal/dataprovider/configs.go

@@ -0,0 +1,299 @@
+// Copyright (C) 2019-2023 Nicola Murino
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, version 3.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+package dataprovider
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"golang.org/x/crypto/ssh"
+
+	"github.com/drakkan/sftpgo/v2/internal/kms"
+	"github.com/drakkan/sftpgo/v2/internal/logger"
+	"github.com/drakkan/sftpgo/v2/internal/util"
+)
+
+// Supported values for host keys, KEXs, ciphers, MACs
+var (
+	supportedHostKeyAlgos = []string{ssh.KeyAlgoRSA, ssh.CertAlgoRSAv01}
+	supportedKexAlgos     = []string{
+		"diffie-hellman-group16-sha512", "diffie-hellman-group18-sha512",
+		"diffie-hellman-group14-sha1", "diffie-hellman-group1-sha1",
+		"diffie-hellman-group-exchange-sha256", "diffie-hellman-group-exchange-sha1",
+	}
+	supportedCiphers = []string{
+		"aes128-cbc", "aes192-cbc", "aes256-cbc",
+		"3des-cbc",
+	}
+	supportedMACs = []string{
+		"hmac-sha2-512-etm@openssh.com", "hmac-sha2-512",
+		"hmac-sha1", "hmac-sha1-96",
+	}
+)
+
+// SFTPDConfigs defines configurations for SFTPD
+type SFTPDConfigs struct {
+	HostKeyAlgos  []string `json:"host_key_algos,omitempty"`
+	Moduli        []string `json:"moduli,omitempty"`
+	KexAlgorithms []string `json:"kex_algorithms,omitempty"`
+	Ciphers       []string `json:"ciphers,omitempty"`
+	MACs          []string `json:"macs,omitempty"`
+}
+
+func (c *SFTPDConfigs) isEmpty() bool {
+	if len(c.HostKeyAlgos) > 0 {
+		return false
+	}
+	if len(c.Moduli) > 0 {
+		return false
+	}
+	if len(c.KexAlgorithms) > 0 {
+		return false
+	}
+	if len(c.Ciphers) > 0 {
+		return false
+	}
+	if len(c.MACs) > 0 {
+		return false
+	}
+	return true
+}
+
+// GetSupportedHostKeyAlgos returns the supported legacy host key algos
+func (*SFTPDConfigs) GetSupportedHostKeyAlgos() []string {
+	return supportedHostKeyAlgos
+}
+
+// GetSupportedKEXAlgos returns the supported KEX algos
+func (*SFTPDConfigs) GetSupportedKEXAlgos() []string {
+	return supportedKexAlgos
+}
+
+// GetSupportedCiphers returns the supported ciphers
+func (*SFTPDConfigs) GetSupportedCiphers() []string {
+	return supportedCiphers
+}
+
+// GetSupportedMACs returns the supported MACs algos
+func (*SFTPDConfigs) GetSupportedMACs() []string {
+	return supportedMACs
+}
+
+// GetModuliAsString returns moduli files as comma separated string
+func (c *SFTPDConfigs) GetModuliAsString() string {
+	return strings.Join(c.Moduli, ",")
+}
+
+func (c *SFTPDConfigs) validate() error {
+	for _, algo := range c.HostKeyAlgos {
+		if !util.Contains(supportedHostKeyAlgos, algo) {
+			return util.NewValidationError(fmt.Sprintf("unsupported host key algorithm %q", algo))
+		}
+	}
+	for _, algo := range c.KexAlgorithms {
+		if !util.Contains(supportedKexAlgos, algo) {
+			return util.NewValidationError(fmt.Sprintf("unsupported KEX algorithm %q", algo))
+		}
+	}
+	for _, cipher := range c.Ciphers {
+		if !util.Contains(supportedCiphers, cipher) {
+			return util.NewValidationError(fmt.Sprintf("unsupported cipher %q", cipher))
+		}
+	}
+	for _, mac := range c.MACs {
+		if !util.Contains(supportedMACs, mac) {
+			return util.NewValidationError(fmt.Sprintf("unsupported MAC algorithm %q", mac))
+		}
+	}
+	return nil
+}
+
+func (c *SFTPDConfigs) getACopy() *SFTPDConfigs {
+	hostKeys := make([]string, len(c.HostKeyAlgos))
+	copy(hostKeys, c.HostKeyAlgos)
+	moduli := make([]string, len(c.Moduli))
+	copy(moduli, c.Moduli)
+	kexs := make([]string, len(c.KexAlgorithms))
+	copy(kexs, c.KexAlgorithms)
+	ciphers := make([]string, len(c.Ciphers))
+	copy(ciphers, c.Ciphers)
+	macs := make([]string, len(c.MACs))
+	copy(macs, c.MACs)
+
+	return &SFTPDConfigs{
+		HostKeyAlgos:  hostKeys,
+		Moduli:        moduli,
+		KexAlgorithms: kexs,
+		Ciphers:       ciphers,
+		MACs:          macs,
+	}
+}
+
+// SMTPConfigs defines configuration for SMTP
+type SMTPConfigs struct {
+	Host       string      `json:"host,omitempty"`
+	Port       int         `json:"port,omitempty"`
+	From       string      `json:"from,omitempty"`
+	User       string      `json:"user,omitempty"`
+	Password   *kms.Secret `json:"password,omitempty"`
+	AuthType   int         `json:"auth_type,omitempty"`
+	Encryption int         `json:"encryption,omitempty"`
+	Domain     string      `json:"domain,omitempty"`
+}
+
+func (c *SMTPConfigs) isEmpty() bool {
+	return c.Host == ""
+}
+
+func (c *SMTPConfigs) validatePassword() error {
+	if c.Password != nil {
+		if c.Password.IsRedacted() {
+			return util.NewValidationError("cannot save a redacted smtp password")
+		}
+		if c.Password.IsEncrypted() && !c.Password.IsValid() {
+			return util.NewValidationError("invalid encrypted smtp password")
+		}
+		if !c.Password.IsEmpty() && !c.Password.IsValidInput() {
+			return util.NewValidationError("invalid smtp password")
+		}
+		if c.Password.IsPlain() {
+			c.Password.SetAdditionalData("smtp")
+			if err := c.Password.Encrypt(); err != nil {
+				return util.NewValidationError(fmt.Sprintf("could not encrypt smtp password: %v", err))
+			}
+		}
+	}
+	return nil
+}
+
+func (c *SMTPConfigs) validate() error {
+	if c.isEmpty() {
+		return nil
+	}
+	if c.Port <= 0 || c.Port > 65535 {
+		return util.NewValidationError(fmt.Sprintf("smtp: invalid port %d", c.Port))
+	}
+	if err := c.validatePassword(); err != nil {
+		return err
+	}
+	if c.User == "" && c.From == "" {
+		return util.NewValidationError("smtp: from address and user cannot both be empty")
+	}
+	if c.AuthType < 0 || c.AuthType > 2 {
+		return util.NewValidationError(fmt.Sprintf("smtp: invalid auth type %d", c.AuthType))
+	}
+	if c.Encryption < 0 || c.Encryption > 2 {
+		return util.NewValidationError(fmt.Sprintf("smtp: invalid encryption %d", c.Encryption))
+	}
+	return nil
+}
+
+func (c *SMTPConfigs) getACopy() *SMTPConfigs {
+	var password *kms.Secret
+	if c.Password != nil {
+		password = c.Password.Clone()
+	}
+	return &SMTPConfigs{
+		Host:       c.Host,
+		Port:       c.Port,
+		From:       c.From,
+		User:       c.User,
+		Password:   password,
+		AuthType:   c.AuthType,
+		Encryption: c.Encryption,
+		Domain:     c.Domain,
+	}
+}
+
+// Configs allows to set configuration keys disabled by default without
+// modifying the config file or setting env vars
+type Configs struct {
+	SFTPD     *SFTPDConfigs `json:"sftpd,omitempty"`
+	SMTP      *SMTPConfigs  `json:"smtp,omitempty"`
+	UpdatedAt int64         `json:"updated_at,omitempty"`
+}
+
+func (c *Configs) validate() error {
+	if c.SFTPD != nil {
+		if err := c.SFTPD.validate(); err != nil {
+			return err
+		}
+	}
+	if c.SMTP != nil {
+		if err := c.SMTP.validate(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// PrepareForRendering prepares configs for rendering.
+// It hides confidential data and set to nil the empty structs/secrets
+// so they are not serialized
+func (c *Configs) PrepareForRendering() {
+	if c.SFTPD != nil && c.SFTPD.isEmpty() {
+		c.SFTPD = nil
+	}
+	if c.SMTP != nil && c.SMTP.isEmpty() {
+		c.SMTP = nil
+	}
+	if c.SMTP != nil && c.SMTP.Password != nil {
+		c.SMTP.Password.Hide()
+		if c.SMTP.Password.IsEmpty() {
+			c.SMTP.Password = nil
+		}
+	}
+}
+
+// SetNilsToEmpty sets nil fields to empty
+func (c *Configs) SetNilsToEmpty() {
+	if c.SFTPD == nil {
+		c.SFTPD = &SFTPDConfigs{}
+	}
+	if c.SMTP == nil {
+		c.SMTP = &SMTPConfigs{}
+	}
+	if c.SMTP.Password == nil {
+		c.SMTP.Password = kms.NewEmptySecret()
+	}
+}
+
+// RenderAsJSON implements the renderer interface used within plugins
+func (c *Configs) RenderAsJSON(reload bool) ([]byte, error) {
+	if reload {
+		config, err := provider.getConfigs()
+		if err != nil {
+			providerLog(logger.LevelError, "unable to reload config overrides before rendering as json: %v", err)
+			return nil, err
+		}
+		config.PrepareForRendering()
+		return json.Marshal(config)
+	}
+	c.PrepareForRendering()
+	return json.Marshal(c)
+}
+
+func (c *Configs) getACopy() Configs {
+	var result Configs
+	if c.SFTPD != nil {
+		result.SFTPD = c.SFTPD.getACopy()
+	}
+	if c.SMTP != nil {
+		result.SMTP = c.SMTP.getACopy()
+	}
+	result.UpdatedAt = c.UpdatedAt
+	return result
+}

+ 32 - 2
internal/dataprovider/dataprovider.go

@@ -192,6 +192,7 @@ var (
 	sqlTableNodes                string
 	sqlTableRoles                string
 	sqlTableIPLists              string
+	sqlTableConfigs              string
 	sqlTableSchemaVersion        string
 	argon2Params                 *argon2id.Params
 	lastLoginMinDelay            = 10 * time.Minute
@@ -225,6 +226,7 @@ func initSQLTables() {
 	sqlTableNodes = "nodes"
 	sqlTableRoles = "roles"
 	sqlTableIPLists = "ip_lists"
+	sqlTableConfigs = "configurations"
 	sqlTableSchemaVersion = "schema_version"
 }
 
@@ -666,6 +668,7 @@ type BackupData struct {
 	EventRules   []EventRule             `json:"event_rules"`
 	Roles        []Role                  `json:"roles"`
 	IPLists      []IPListEntry           `json:"ip_lists"`
+	Configs      *Configs                `json:"configs"`
 	Version      int                     `json:"version"`
 }
 
@@ -817,6 +820,8 @@ type Provider interface {
 	dumpIPListEntries() ([]IPListEntry, error)
 	countIPListEntries(listType IPListType) (int64, error)
 	getListEntriesForIP(ip string, listType IPListType) ([]IPListEntry, error)
+	getConfigs() (Configs, error)
+	setConfigs(configs *Configs) error
 	checkAvailability() error
 	close() error
 	reloadConfig() error
@@ -997,17 +1002,18 @@ func validateSQLTablesPrefix() error {
 		sqlTableNodes = config.SQLTablesPrefix + sqlTableNodes
 		sqlTableRoles = config.SQLTablesPrefix + sqlTableRoles
 		sqlTableIPLists = config.SQLTablesPrefix + sqlTableIPLists
+		sqlTableConfigs = config.SQLTablesPrefix + sqlTableConfigs
 		sqlTableSchemaVersion = config.SQLTablesPrefix + sqlTableSchemaVersion
 		providerLog(logger.LevelDebug, "sql table for users %q, folders %q users folders mapping %q admins %q "+
 			"api keys %q shares %q defender hosts %q defender events %q transfers %q  groups %q "+
 			"users groups mapping %q admins groups mapping %q groups folders mapping %q shared sessions %q "+
 			"schema version %q events actions %q events rules %q rules actions mapping %q tasks %q nodes %q roles %q"+
-			"ip lists %q",
+			"ip lists %q configs %q",
 			sqlTableUsers, sqlTableFolders, sqlTableUsersFoldersMapping, sqlTableAdmins, sqlTableAPIKeys,
 			sqlTableShares, sqlTableDefenderHosts, sqlTableDefenderEvents, sqlTableActiveTransfers, sqlTableGroups,
 			sqlTableUsersGroupsMapping, sqlTableAdminsGroupsMapping, sqlTableGroupsFoldersMapping, sqlTableSharedSessions,
 			sqlTableSchemaVersion, sqlTableEventsActions, sqlTableEventsRules, sqlTableRulesActionsMapping,
-			sqlTableTasks, sqlTableNodes, sqlTableRoles, sqlTableIPLists)
+			sqlTableTasks, sqlTableNodes, sqlTableRoles, sqlTableIPLists, sqlTableConfigs)
 	}
 	return nil
 }
@@ -1518,6 +1524,25 @@ func GetUsedVirtualFolderQuota(name string) (int, int64, error) {
 	return files + delayedFiles, size + delayedSize, err
 }
 
+// GetConfigs returns the configurations
+func GetConfigs() (Configs, error) {
+	return provider.getConfigs()
+}
+
+// UpdateConfigs updates configurations
+func UpdateConfigs(configs *Configs, executor, ipAddress, role string) error {
+	if configs == nil {
+		configs = &Configs{}
+	} else {
+		configs.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+	}
+	err := provider.setConfigs(configs)
+	if err == nil {
+		executeAction(operationUpdate, executor, ipAddress, actionObjectConfigs, "configs", role, configs)
+	}
+	return err
+}
+
 // AddShare adds a new share
 func AddShare(share *Share, executor, ipAddress, role string) error {
 	err := provider.addShare(share)
@@ -2306,6 +2331,10 @@ func DumpData() (BackupData, error) {
 	if err != nil {
 		return data, err
 	}
+	configs, err := provider.getConfigs()
+	if err != nil {
+		return data, err
+	}
 	data.Users = users
 	data.Groups = groups
 	data.Folders = folders
@@ -2316,6 +2345,7 @@ func DumpData() (BackupData, error) {
 	data.EventRules = rules
 	data.Roles = roles
 	data.IPLists = ipLists
+	data.Configs = &configs
 	data.Version = DumpVersion
 	return data, err
 }

+ 39 - 2
internal/dataprovider/memory.go

@@ -80,6 +80,8 @@ type memoryProviderHandle struct {
 	ipListEntries map[string]IPListEntry
 	// slice with ordered IP list entries
 	ipListEntriesKeys []string
+	// configurations
+	configs Configs
 }
 
 // MemoryProvider defines the auth provider for a memory store
@@ -118,6 +120,7 @@ func initializeMemoryProvider(basePath string) {
 			roleNames:         []string{},
 			ipListEntries:     map[string]IPListEntry{},
 			ipListEntriesKeys: []string{},
+			configs:           Configs{},
 			configFile:        configFile,
 		},
 	}
@@ -2797,6 +2800,28 @@ func (p *MemoryProvider) getListEntriesForIP(ip string, listType IPListType) ([]
 	return entries, nil
 }
 
+func (p *MemoryProvider) getConfigs() (Configs, error) {
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+	if p.dbHandle.isClosed {
+		return Configs{}, errMemoryProviderClosed
+	}
+	return p.dbHandle.configs.getACopy(), nil
+}
+
+func (p *MemoryProvider) setConfigs(configs *Configs) error {
+	if err := configs.validate(); err != nil {
+		return err
+	}
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+	if p.dbHandle.isClosed {
+		return errMemoryProviderClosed
+	}
+	p.dbHandle.configs = configs.getACopy()
+	return nil
+}
+
 func (p *MemoryProvider) setFirstDownloadTimestamp(username string) error {
 	p.dbHandle.Lock()
 	defer p.dbHandle.Unlock()
@@ -2929,6 +2954,7 @@ func (p *MemoryProvider) clear() {
 	p.dbHandle.roleNames = []string{}
 	p.dbHandle.ipListEntries = map[string]IPListEntry{}
 	p.dbHandle.ipListEntriesKeys = []string{}
+	p.dbHandle.configs = Configs{}
 }
 
 func (p *MemoryProvider) reloadConfig() error {
@@ -2968,7 +2994,11 @@ func (p *MemoryProvider) reloadConfig() error {
 func (p *MemoryProvider) restoreDump(dump *BackupData) error {
 	p.clear()
 
-	if err := p.restoreIPListEntries(*dump); err != nil {
+	if err := p.restoreConfigs(dump); err != nil {
+		return err
+	}
+
+	if err := p.restoreIPListEntries(dump); err != nil {
 		return err
 	}
 
@@ -3130,7 +3160,14 @@ func (p *MemoryProvider) restoreAdmins(dump *BackupData) error {
 	return nil
 }
 
-func (p *MemoryProvider) restoreIPListEntries(dump BackupData) error {
+func (p *MemoryProvider) restoreConfigs(dump *BackupData) error {
+	if dump.Configs != nil && dump.Configs.UpdatedAt > 0 {
+		return UpdateConfigs(dump.Configs, ActionExecutorSystem, "", "")
+	}
+	return nil
+}
+
+func (p *MemoryProvider) restoreIPListEntries(dump *BackupData) error {
 	for idx := range dump.IPLists {
 		entry := dump.IPLists[idx]
 		_, err := p.ipListEntryExists(entry.IPOrNet, entry.Type)

+ 45 - 1
internal/dataprovider/mysql.go

@@ -59,6 +59,7 @@ const (
 		"DROP TABLE IF EXISTS `{{nodes}}` CASCADE;" +
 		"DROP TABLE IF EXISTS `{{roles}}` CASCADE;" +
 		"DROP TABLE IF EXISTS `{{ip_lists}}` CASCADE;" +
+		"DROP TABLE IF EXISTS `{{configs}}` CASCADE;" +
 		"DROP TABLE IF EXISTS `{{schema_version}}` CASCADE;"
 	mysqlInitialSQL = "CREATE TABLE `{{schema_version}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, `version` integer NOT NULL);" +
 		"CREATE TABLE `{{admins}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, `username` varchar(255) NOT NULL UNIQUE, " +
@@ -202,6 +203,9 @@ const (
 		"CREATE INDEX `{{prefix}}ip_lists_deleted_at_idx` ON `{{ip_lists}}` (`deleted_at`);" +
 		"CREATE INDEX `{{prefix}}ip_lists_first_last_idx` ON `{{ip_lists}}` (`first`, `last`);"
 	mysqlV27DownSQL = "DROP TABLE `{{ip_lists}}` CASCADE;"
+	mysqlV28SQL     = "CREATE TABLE `{{configs}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, `configs` longtext NOT NULL);" +
+		"INSERT INTO {{configs}} (configs) VALUES ('{}');"
+	mysqlV28DownSQL = "DROP TABLE `{{configs}}` CASCADE;"
 )
 
 // MySQLProvider defines the auth provider for MySQL/MariaDB database
@@ -745,6 +749,14 @@ func (p *MySQLProvider) getListEntriesForIP(ip string, listType IPListType) ([]I
 	return sqlCommonGetListEntriesForIP(ip, listType, p.dbHandle)
 }
 
+func (p *MySQLProvider) getConfigs() (Configs, error) {
+	return sqlCommonGetConfigs(p.dbHandle)
+}
+
+func (p *MySQLProvider) setConfigs(configs *Configs) error {
+	return sqlCommonSetConfigs(configs, p.dbHandle)
+}
+
 func (p *MySQLProvider) setFirstDownloadTimestamp(username string) error {
 	return sqlCommonSetFirstDownloadTimestamp(username, p.dbHandle)
 }
@@ -800,6 +812,8 @@ func (p *MySQLProvider) migrateDatabase() error { //nolint:dupl
 		return updateMySQLDatabaseFromV25(p.dbHandle)
 	case version == 26:
 		return updateMySQLDatabaseFromV26(p.dbHandle)
+	case version == 27:
+		return updateMySQLDatabaseFromV27(p.dbHandle)
 	default:
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelError, "database schema version %d is newer than the supported one: %d", version,
@@ -830,6 +844,8 @@ func (p *MySQLProvider) revertDatabase(targetVersion int) error {
 		return downgradeMySQLDatabaseFromV26(p.dbHandle)
 	case 27:
 		return downgradeMySQLDatabaseFromV27(p.dbHandle)
+	case 28:
+		return downgradeMySQLDatabaseFromV28(p.dbHandle)
 	default:
 		return fmt.Errorf("database schema version not handled: %d", dbVersion.Version)
 	}
@@ -862,7 +878,14 @@ func updateMySQLDatabaseFromV25(dbHandle *sql.DB) error {
 }
 
 func updateMySQLDatabaseFromV26(dbHandle *sql.DB) error {
-	return updateMySQLDatabaseFrom26To27(dbHandle)
+	if err := updateMySQLDatabaseFrom26To27(dbHandle); err != nil {
+		return err
+	}
+	return updateMySQLDatabaseFromV27(dbHandle)
+}
+
+func updateMySQLDatabaseFromV27(dbHandle *sql.DB) error {
+	return updateMySQLDatabaseFrom27To28(dbHandle)
 }
 
 func downgradeMySQLDatabaseFromV24(dbHandle *sql.DB) error {
@@ -890,6 +913,13 @@ func downgradeMySQLDatabaseFromV27(dbHandle *sql.DB) error {
 	return downgradeMySQLDatabaseFromV26(dbHandle)
 }
 
+func downgradeMySQLDatabaseFromV28(dbHandle *sql.DB) error {
+	if err := downgradeMySQLDatabaseFrom28To27(dbHandle); err != nil {
+		return err
+	}
+	return downgradeMySQLDatabaseFromV27(dbHandle)
+}
+
 func updateMySQLDatabaseFrom23To24(dbHandle *sql.DB) error {
 	logger.InfoToConsole("updating database schema version: 23 -> 24")
 	providerLog(logger.LevelInfo, "updating database schema version: 23 -> 24")
@@ -922,6 +952,13 @@ func updateMySQLDatabaseFrom26To27(dbHandle *sql.DB) error {
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 27, true)
 }
 
+func updateMySQLDatabaseFrom27To28(dbHandle *sql.DB) error {
+	logger.InfoToConsole("updating database schema version: 27 -> 28")
+	providerLog(logger.LevelInfo, "updating database schema version: 27 -> 28")
+	sql := strings.ReplaceAll(mysqlV28SQL, "{{configs}}", sqlTableConfigs)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 28, true)
+}
+
 func downgradeMySQLDatabaseFrom24To23(dbHandle *sql.DB) error {
 	logger.InfoToConsole("downgrading database schema version: 24 -> 23")
 	providerLog(logger.LevelInfo, "downgrading database schema version: 24 -> 23")
@@ -952,3 +989,10 @@ func downgradeMySQLDatabaseFrom27To26(dbHandle *sql.DB) error {
 	sql := strings.ReplaceAll(mysqlV27DownSQL, "{{ip_lists}}", sqlTableIPLists)
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 26, false)
 }
+
+func downgradeMySQLDatabaseFrom28To27(dbHandle *sql.DB) error {
+	logger.InfoToConsole("downgrading database schema version: 28 -> 27")
+	providerLog(logger.LevelInfo, "downgrading database schema version: 28 -> 27")
+	sql := strings.ReplaceAll(mysqlV28DownSQL, "{{configs}}", sqlTableConfigs)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 27, false)
+}

+ 46 - 1
internal/dataprovider/pgsql.go

@@ -57,6 +57,7 @@ DROP TABLE IF EXISTS "{{tasks}}" CASCADE;
 DROP TABLE IF EXISTS "{{nodes}}" CASCADE;
 DROP TABLE IF EXISTS "{{roles}}" CASCADE;
 DROP TABLE IF EXISTS "{{ip_lists}}" CASCADE;
+DROP TABLE IF EXISTS "{{configs}}" CASCADE;
 DROP TABLE IF EXISTS "{{schema_version}}" CASCADE;
 `
 	pgsqlInitial = `CREATE TABLE "{{schema_version}}" ("id" serial NOT NULL PRIMARY KEY, "version" integer NOT NULL);
@@ -216,6 +217,10 @@ CREATE INDEX "{{prefix}}ip_lists_deleted_at_idx" ON "{{ip_lists}}" ("deleted_at"
 CREATE INDEX "{{prefix}}ip_lists_first_last_idx" ON "{{ip_lists}}" ("first", "last");
 `
 	pgsqlV27DownSQL = `DROP TABLE "{{ip_lists}}" CASCADE;`
+	pgsqlV28SQL     = `CREATE TABLE "{{configs}}" ("id" serial NOT NULL PRIMARY KEY, "configs" text NOT NULL);
+INSERT INTO {{configs}} (configs) VALUES ('{}');
+`
+	pgsqlV28DownSQL = `DROP TABLE "{{configs}}" CASCADE;`
 )
 
 // PGSQLProvider defines the auth provider for PostgreSQL database
@@ -718,6 +723,14 @@ func (p *PGSQLProvider) getListEntriesForIP(ip string, listType IPListType) ([]I
 	return sqlCommonGetListEntriesForIP(ip, listType, p.dbHandle)
 }
 
+func (p *PGSQLProvider) getConfigs() (Configs, error) {
+	return sqlCommonGetConfigs(p.dbHandle)
+}
+
+func (p *PGSQLProvider) setConfigs(configs *Configs) error {
+	return sqlCommonSetConfigs(configs, p.dbHandle)
+}
+
 func (p *PGSQLProvider) setFirstDownloadTimestamp(username string) error {
 	return sqlCommonSetFirstDownloadTimestamp(username, p.dbHandle)
 }
@@ -773,6 +786,8 @@ func (p *PGSQLProvider) migrateDatabase() error { //nolint:dupl
 		return updatePgSQLDatabaseFromV25(p.dbHandle)
 	case version == 26:
 		return updatePgSQLDatabaseFromV26(p.dbHandle)
+	case version == 27:
+		return updatePgSQLDatabaseFromV27(p.dbHandle)
 	default:
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelError, "database schema version %d is newer than the supported one: %d", version,
@@ -803,6 +818,8 @@ func (p *PGSQLProvider) revertDatabase(targetVersion int) error {
 		return downgradePgSQLDatabaseFromV26(p.dbHandle)
 	case 27:
 		return downgradePgSQLDatabaseFromV27(p.dbHandle)
+	case 28:
+		return downgradePgSQLDatabaseFromV28(p.dbHandle)
 	default:
 		return fmt.Errorf("database schema version not handled: %d", dbVersion.Version)
 	}
@@ -835,7 +852,14 @@ func updatePgSQLDatabaseFromV25(dbHandle *sql.DB) error {
 }
 
 func updatePgSQLDatabaseFromV26(dbHandle *sql.DB) error {
-	return updatePgSQLDatabaseFrom26To27(dbHandle)
+	if err := updatePgSQLDatabaseFrom26To27(dbHandle); err != nil {
+		return err
+	}
+	return updatePgSQLDatabaseFromV27(dbHandle)
+}
+
+func updatePgSQLDatabaseFromV27(dbHandle *sql.DB) error {
+	return updatePgSQLDatabaseFrom27To28(dbHandle)
 }
 
 func downgradePgSQLDatabaseFromV24(dbHandle *sql.DB) error {
@@ -863,6 +887,13 @@ func downgradePgSQLDatabaseFromV27(dbHandle *sql.DB) error {
 	return downgradePgSQLDatabaseFromV26(dbHandle)
 }
 
+func downgradePgSQLDatabaseFromV28(dbHandle *sql.DB) error {
+	if err := downgradePgSQLDatabaseFrom28To27(dbHandle); err != nil {
+		return err
+	}
+	return downgradePgSQLDatabaseFromV27(dbHandle)
+}
+
 func updatePgSQLDatabaseFrom23To24(dbHandle *sql.DB) error {
 	logger.InfoToConsole("updating database schema version: 23 -> 24")
 	providerLog(logger.LevelInfo, "updating database schema version: 23 -> 24")
@@ -907,6 +938,13 @@ func updatePgSQLDatabaseFrom26To27(dbHandle *sql.DB) error {
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 27, true)
 }
 
+func updatePgSQLDatabaseFrom27To28(dbHandle *sql.DB) error {
+	logger.InfoToConsole("updating database schema version: 27 -> 28")
+	providerLog(logger.LevelInfo, "updating database schema version: 27 -> 28")
+	sql := strings.ReplaceAll(pgsqlV28SQL, "{{configs}}", sqlTableConfigs)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 28, true)
+}
+
 func downgradePgSQLDatabaseFrom24To23(dbHandle *sql.DB) error {
 	logger.InfoToConsole("downgrading database schema version: 24 -> 23")
 	providerLog(logger.LevelInfo, "downgrading database schema version: 24 -> 23")
@@ -937,3 +975,10 @@ func downgradePgSQLDatabaseFrom27To26(dbHandle *sql.DB) error {
 	sql := strings.ReplaceAll(pgsqlV27DownSQL, "{{ip_lists}}", sqlTableIPLists)
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 26, false)
 }
+
+func downgradePgSQLDatabaseFrom28To27(dbHandle *sql.DB) error {
+	logger.InfoToConsole("downgrading database schema version: 28 -> 27")
+	providerLog(logger.LevelInfo, "downgrading database schema version: 28 -> 27")
+	sql := strings.ReplaceAll(pgsqlV28DownSQL, "{{configs}}", sqlTableConfigs)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 27, false)
+}

+ 39 - 1
internal/dataprovider/sqlcommon.go

@@ -35,7 +35,7 @@ import (
 )
 
 const (
-	sqlDatabaseVersion     = 27
+	sqlDatabaseVersion     = 28
 	defaultSQLQueryTimeout = 10 * time.Second
 	longSQLQueryTimeout    = 60 * time.Second
 )
@@ -81,6 +81,7 @@ func sqlReplaceAll(sql string) string {
 	sql = strings.ReplaceAll(sql, "{{nodes}}", sqlTableNodes)
 	sql = strings.ReplaceAll(sql, "{{roles}}", sqlTableRoles)
 	sql = strings.ReplaceAll(sql, "{{ip_lists}}", sqlTableIPLists)
+	sql = strings.ReplaceAll(sql, "{{configs}}", sqlTableConfigs)
 	sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix)
 	return sql
 }
@@ -3825,6 +3826,43 @@ func sqlCommonCleanupNodes(dbHandle *sql.DB) error {
 	return err
 }
 
+func sqlCommonGetConfigs(dbHandle sqlQuerier) (Configs, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
+	defer cancel()
+
+	var result Configs
+	var configs []byte
+	q := getConfigsQuery()
+	err := dbHandle.QueryRowContext(ctx, q).Scan(&configs)
+	if err != nil {
+		return result, err
+	}
+	err = json.Unmarshal(configs, &result)
+	return result, err
+}
+
+func sqlCommonSetConfigs(configs *Configs, dbHandle *sql.DB) error {
+	if err := configs.validate(); err != nil {
+		return err
+	}
+	asJSON, err := json.Marshal(configs)
+	if err != nil {
+		return err
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
+	defer cancel()
+
+	q := getUpdateConfigsQuery()
+	res, err := dbHandle.ExecContext(ctx, q, asJSON)
+	if err != nil {
+		return err
+	}
+	if config.Driver == MySQLDataProviderName {
+		return nil
+	}
+	return sqlCommonRequireRowAffected(res)
+}
+
 func sqlCommonGetDatabaseVersion(dbHandle sqlQuerier, showInitWarn bool) (schemaVersion, error) {
 	var result schemaVersion
 	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)

+ 46 - 1
internal/dataprovider/sqlite.go

@@ -58,6 +58,7 @@ DROP TABLE IF EXISTS "{{events_actions}}";
 DROP TABLE IF EXISTS "{{tasks}}";
 DROP TABLE IF EXISTS "{{roles}}";
 DROP TABLE IF EXISTS "{{ip_lists}}";
+DROP TABLE IF EXISTS "{{configs}}";
 DROP TABLE IF EXISTS "{{schema_version}}";
 `
 	sqliteInitialSQL = `CREATE TABLE "{{schema_version}}" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "version" integer NOT NULL);
@@ -193,6 +194,10 @@ CREATE INDEX "{{prefix}}ip_lists_ip_deleted_at_idx" ON "{{ip_lists}}" ("deleted_
 CREATE INDEX "{{prefix}}ip_lists_first_last_idx" ON "{{ip_lists}}" ("first", "last");
 `
 	sqliteV27DownSQL = `DROP TABLE "{{ip_lists}}";`
+	sqliteV28SQL     = `CREATE TABLE "{{configs}}" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "configs" text NOT NULL);
+INSERT INTO {{configs}} (configs) VALUES ('{}');
+`
+	sqliteV28DownSQL = `DROP TABLE "{{configs}}";`
 )
 
 // SQLiteProvider defines the auth provider for SQLite database
@@ -674,6 +679,14 @@ func (p *SQLiteProvider) getListEntriesForIP(ip string, listType IPListType) ([]
 	return sqlCommonGetListEntriesForIP(ip, listType, p.dbHandle)
 }
 
+func (p *SQLiteProvider) getConfigs() (Configs, error) {
+	return sqlCommonGetConfigs(p.dbHandle)
+}
+
+func (p *SQLiteProvider) setConfigs(configs *Configs) error {
+	return sqlCommonSetConfigs(configs, p.dbHandle)
+}
+
 func (p *SQLiteProvider) setFirstDownloadTimestamp(username string) error {
 	return sqlCommonSetFirstDownloadTimestamp(username, p.dbHandle)
 }
@@ -728,6 +741,8 @@ func (p *SQLiteProvider) migrateDatabase() error { //nolint:dupl
 		return updateSQLiteDatabaseFromV25(p.dbHandle)
 	case version == 26:
 		return updateSQLiteDatabaseFromV26(p.dbHandle)
+	case version == 27:
+		return updateSQLiteDatabaseFromV27(p.dbHandle)
 	default:
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelError, "database schema version %d is newer than the supported one: %d", version,
@@ -758,6 +773,8 @@ func (p *SQLiteProvider) revertDatabase(targetVersion int) error {
 		return downgradeSQLiteDatabaseFromV26(p.dbHandle)
 	case 27:
 		return downgradeSQLiteDatabaseFromV27(p.dbHandle)
+	case 28:
+		return downgradeSQLiteDatabaseFromV28(p.dbHandle)
 	default:
 		return fmt.Errorf("database schema version not handled: %d", dbVersion.Version)
 	}
@@ -790,7 +807,14 @@ func updateSQLiteDatabaseFromV25(dbHandle *sql.DB) error {
 }
 
 func updateSQLiteDatabaseFromV26(dbHandle *sql.DB) error {
-	return updateSQLiteDatabaseFrom26To27(dbHandle)
+	if err := updateSQLiteDatabaseFrom26To27(dbHandle); err != nil {
+		return err
+	}
+	return updateSQLiteDatabaseFromV27(dbHandle)
+}
+
+func updateSQLiteDatabaseFromV27(dbHandle *sql.DB) error {
+	return updateSQLiteDatabaseFrom27To28(dbHandle)
 }
 
 func downgradeSQLiteDatabaseFromV24(dbHandle *sql.DB) error {
@@ -818,6 +842,13 @@ func downgradeSQLiteDatabaseFromV27(dbHandle *sql.DB) error {
 	return downgradeSQLiteDatabaseFromV26(dbHandle)
 }
 
+func downgradeSQLiteDatabaseFromV28(dbHandle *sql.DB) error {
+	if err := downgradeSQLiteDatabaseFrom28To27(dbHandle); err != nil {
+		return err
+	}
+	return downgradeSQLiteDatabaseFromV27(dbHandle)
+}
+
 func updateSQLiteDatabaseFrom23To24(dbHandle *sql.DB) error {
 	logger.InfoToConsole("updating database schema version: 23 -> 24")
 	providerLog(logger.LevelInfo, "updating database schema version: 23 -> 24")
@@ -850,6 +881,13 @@ func updateSQLiteDatabaseFrom26To27(dbHandle *sql.DB) error {
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 27, true)
 }
 
+func updateSQLiteDatabaseFrom27To28(dbHandle *sql.DB) error {
+	logger.InfoToConsole("updating database schema version: 27 -> 28")
+	providerLog(logger.LevelInfo, "updating database schema version: 27 -> 28")
+	sql := strings.ReplaceAll(sqliteV28SQL, "{{configs}}", sqlTableConfigs)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 28, true)
+}
+
 func downgradeSQLiteDatabaseFrom24To23(dbHandle *sql.DB) error {
 	logger.InfoToConsole("downgrading database schema version: 24 -> 23")
 	providerLog(logger.LevelInfo, "downgrading database schema version: 24 -> 23")
@@ -881,6 +919,13 @@ func downgradeSQLiteDatabaseFrom27To26(dbHandle *sql.DB) error {
 	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 26, false)
 }
 
+func downgradeSQLiteDatabaseFrom28To27(dbHandle *sql.DB) error {
+	logger.InfoToConsole("downgrading database schema version: 28 -> 27")
+	providerLog(logger.LevelInfo, "downgrading database schema version: 28 -> 27")
+	sql := strings.ReplaceAll(sqliteV28DownSQL, "{{configs}}", sqlTableConfigs)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 27, false)
+}
+
 /*func setPragmaFK(dbHandle *sql.DB, value string) error {
 	ctx, cancel := context.WithTimeout(context.Background(), longSQLQueryTimeout)
 	defer cancel()

+ 8 - 0
internal/dataprovider/sqlqueries.go

@@ -274,6 +274,14 @@ func getRemoveSoftDeletedIPListEntryQuery() string {
 		sqlTableIPLists, sqlPlaceholders[0], sqlPlaceholders[1])
 }
 
+func getConfigsQuery() string {
+	return fmt.Sprintf(`SELECT configs FROM %s LIMIT 1`, sqlTableConfigs)
+}
+
+func getUpdateConfigsQuery() string {
+	return fmt.Sprintf(`UPDATE %s SET configs = %s`, sqlTableConfigs, sqlPlaceholders[0])
+}
+
 func getRoleByNameQuery() string {
 	return fmt.Sprintf(`SELECT %s FROM %s WHERE name = %s`, selectRoleFields, sqlTableRoles,
 		sqlPlaceholders[0])

+ 57 - 0
internal/httpd/api_configs.go

@@ -0,0 +1,57 @@
+// Copyright (C) 2019-2023 Nicola Murino
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, version 3.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+package httpd
+
+import (
+	"net/http"
+
+	"github.com/go-chi/render"
+
+	"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+	"github.com/drakkan/sftpgo/v2/internal/smtp"
+)
+
+type smtpTestRequest struct {
+	smtp.Config
+	Recipient string `json:"recipient"`
+}
+
+func testSMTPConfig(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+
+	var req smtpTestRequest
+	err := render.DecodeJSON(r.Body, &req)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
+		return
+	}
+	if req.Password == redactedSecret {
+		configs, err := dataprovider.GetConfigs()
+		if err != nil {
+			sendAPIResponse(w, r, err, "", http.StatusInternalServerError)
+			return
+		}
+		configs.SetNilsToEmpty()
+		if err := configs.SMTP.Password.TryDecrypt(); err == nil {
+			req.Password = configs.SMTP.Password.GetPayload()
+		}
+	}
+	if err := req.SendEmail([]string{req.Recipient}, "SFTPGo - Testing Email Settings",
+		"It appears your SFTPGo email is setup correctly!", smtp.EmailContentTypeTextPlain); err != nil {
+		sendAPIResponse(w, r, err, "", http.StatusInternalServerError)
+		return
+	}
+	sendAPIResponse(w, r, nil, "SMTP connection OK", http.StatusOK)
+}

+ 25 - 3
internal/httpd/api_maintenance.go

@@ -183,6 +183,10 @@ func restoreBackup(content []byte, inputFile string, scanQuota, mode int, execut
 		return util.NewValidationError(fmt.Sprintf("unable to parse backup content: %v", err))
 	}
 
+	if err = RestoreConfigs(dump.Configs, inputFile, mode, executor, ipAddress, role); err != nil {
+		return err
+	}
+
 	if err = RestoreIPListEntries(dump.IPLists, inputFile, mode, executor, ipAddress, role); err != nil {
 		return err
 	}
@@ -222,9 +226,7 @@ func restoreBackup(content []byte, inputFile string, scanQuota, mode int, execut
 	if err = RestoreEventRules(dump.EventRules, inputFile, mode, executor, ipAddress, role, dump.Version); err != nil {
 		return err
 	}
-
-	logger.Debug(logSender, "", "backup restored, users: %d, folders: %d, admins: %d",
-		len(dump.Users), len(dump.Folders), len(dump.Admins))
+	logger.Debug(logSender, "", "backup restored")
 
 	return nil
 }
@@ -420,6 +422,26 @@ func RestoreAdmins(admins []dataprovider.Admin, inputFile string, mode int, exec
 	return nil
 }
 
+// RestoreConfigs restores the specified provider configs
+func RestoreConfigs(configs *dataprovider.Configs, inputFile string, mode int, executor, ipAddress,
+	executorRole string,
+) error {
+	if configs == nil {
+		return nil
+	}
+	c, err := dataprovider.GetConfigs()
+	if err != nil {
+		return fmt.Errorf("unable to restore configs, error loading existing from db: %w", err)
+	}
+	if c.UpdatedAt > 0 {
+		if mode == 1 {
+			logger.Debug(logSender, "", "loaddata mode 1, existing configs not updated")
+			return nil
+		}
+	}
+	return dataprovider.UpdateConfigs(configs, executor, ipAddress, executorRole)
+}
+
 // RestoreIPListEntries restores the specified IP list entries
 func RestoreIPListEntries(entries []dataprovider.IPListEntry, inputFile string, mode int, executor, ipAddress,
 	executorRole string,

+ 3 - 0
internal/httpd/httpd.go

@@ -146,6 +146,7 @@ const (
 	webEventsPathDefault                  = "/web/admin/events"
 	webEventsFsSearchPathDefault          = "/web/admin/events/fs"
 	webEventsProviderSearchPathDefault    = "/web/admin/events/provider"
+	webConfigsPathDefault                 = "/web/admin/configs"
 	webClientLoginPathDefault             = "/web/client/login"
 	webClientOIDCLoginPathDefault         = "/web/client/oidclogin"
 	webClientTwoFactorPathDefault         = "/web/client/twofactor"
@@ -239,6 +240,7 @@ var (
 	webEventsPath                  string
 	webEventsFsSearchPath          string
 	webEventsProviderSearchPath    string
+	webConfigsPath                 string
 	webDefenderHostsPath           string
 	webClientLoginPath             string
 	webClientOIDCLoginPath         string
@@ -1069,6 +1071,7 @@ func updateWebAdminURLs(baseURL string) {
 	webEventsPath = path.Join(baseURL, webEventsPathDefault)
 	webEventsFsSearchPath = path.Join(baseURL, webEventsFsSearchPathDefault)
 	webEventsProviderSearchPath = path.Join(baseURL, webEventsProviderSearchPathDefault)
+	webConfigsPath = path.Join(baseURL, webConfigsPathDefault)
 	webStaticFilesPath = path.Join(baseURL, webStaticFilesPathDefault)
 	webOpenAPIPath = path.Join(baseURL, webOpenAPIPathDefault)
 }

+ 345 - 2
internal/httpd/httpd_test.go

@@ -54,6 +54,7 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"golang.org/x/crypto/bcrypt"
+	"golang.org/x/crypto/ssh"
 	"golang.org/x/net/html"
 
 	"github.com/drakkan/sftpgo/v2/internal/common"
@@ -167,6 +168,7 @@ const (
 	webAdminRolesPath              = "/web/admin/roles"
 	webAdminRolePath               = "/web/admin/role"
 	webEventsPath                  = "/web/admin/events"
+	webConfigsPath                 = "/web/admin/configs"
 	webBasePathClient              = "/web/client"
 	webClientLoginPath             = "/web/client/login"
 	webClientFilesPath             = "/web/client/files"
@@ -1288,6 +1290,46 @@ func TestGroupSettingsOverride(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestConfigs(t *testing.T) {
+	err := dataprovider.UpdateConfigs(nil, "", "", "")
+	assert.NoError(t, err)
+	configs, err := dataprovider.GetConfigs()
+	assert.NoError(t, err)
+	assert.Equal(t, int64(0), configs.UpdatedAt)
+	assert.Nil(t, configs.SFTPD)
+	assert.Nil(t, configs.SMTP)
+	configs = dataprovider.Configs{
+		SFTPD: &dataprovider.SFTPDConfigs{},
+		SMTP:  &dataprovider.SMTPConfigs{},
+	}
+	err = dataprovider.UpdateConfigs(&configs, "", "", "")
+	assert.NoError(t, err)
+	configs, err = dataprovider.GetConfigs()
+	assert.NoError(t, err)
+	assert.Greater(t, configs.UpdatedAt, int64(0))
+
+	configs = dataprovider.Configs{
+		SFTPD: &dataprovider.SFTPDConfigs{
+			Ciphers: []string{"unknown"},
+		},
+		SMTP: &dataprovider.SMTPConfigs{},
+	}
+	err = dataprovider.UpdateConfigs(&configs, "", "", "")
+	assert.ErrorIs(t, err, util.ErrValidation)
+	configs = dataprovider.Configs{
+		SFTPD: &dataprovider.SFTPDConfigs{},
+		SMTP: &dataprovider.SMTPConfigs{
+			Host: "smtp.example.com",
+			Port: -1,
+		},
+	}
+	err = dataprovider.UpdateConfigs(&configs, "", "", "")
+	assert.ErrorIs(t, err, util.ErrValidation)
+
+	err = dataprovider.UpdateConfigs(nil, "", "", "")
+	assert.NoError(t, err)
+}
+
 func TestBasicIPListEntriesHandling(t *testing.T) {
 	entry := dataprovider.IPListEntry{
 		IPOrNet:     "::ffff:12.34.56.78",
@@ -6782,6 +6824,7 @@ func TestProviderErrors(t *testing.T) {
 	backupData := dataprovider.BackupData{
 		Version: dataprovider.DumpVersion,
 	}
+	backupData.Configs = &dataprovider.Configs{}
 	backupData.Users = append(backupData.Users, user)
 	backupContent, err := json.Marshal(backupData)
 	assert.NoError(t, err)
@@ -6790,6 +6833,13 @@ func TestProviderErrors(t *testing.T) {
 	assert.NoError(t, err)
 	_, _, err = httpdtest.Loaddata(backupFilePath, "", "", http.StatusInternalServerError)
 	assert.NoError(t, err)
+	backupData.Configs = nil
+	backupContent, err = json.Marshal(backupData)
+	assert.NoError(t, err)
+	err = os.WriteFile(backupFilePath, backupContent, os.ModePerm)
+	assert.NoError(t, err)
+	_, _, err = httpdtest.Loaddata(backupFilePath, "", "", http.StatusInternalServerError)
+	assert.NoError(t, err)
 	backupData.Folders = append(backupData.Folders, vfs.BaseVirtualFolder{Name: "testFolder", MappedPath: filepath.Clean(os.TempDir())})
 	backupContent, err = json.Marshal(backupData)
 	assert.NoError(t, err)
@@ -7478,6 +7528,8 @@ func TestLoaddataFromPostBody(t *testing.T) {
 }
 
 func TestLoaddata(t *testing.T) {
+	err := dataprovider.UpdateConfigs(nil, "", "", "")
+	assert.NoError(t, err)
 	mappedPath := filepath.Join(os.TempDir(), "restored_folder")
 	folderName := filepath.Base(mappedPath)
 	folderDesc := "restored folder desc"
@@ -7567,9 +7619,20 @@ func TestLoaddata(t *testing.T) {
 			},
 		},
 	}
+	configs := dataprovider.Configs{
+		SFTPD: &dataprovider.SFTPDConfigs{
+			HostKeyAlgos: []string{ssh.KeyAlgoRSA, ssh.CertAlgoRSAv01},
+		},
+		SMTP: &dataprovider.SMTPConfigs{
+			Host: "mail.example.com",
+			Port: 587,
+			From: "from@example.net",
+		},
+	}
 	backupData := dataprovider.BackupData{
 		Version: 14,
 	}
+	backupData.Configs = &configs
 	backupData.Users = append(backupData.Users, user)
 	backupData.Roles = append(backupData.Roles, role)
 	backupData.Groups = append(backupData.Groups, group)
@@ -7621,6 +7684,15 @@ func TestLoaddata(t *testing.T) {
 	// update from backup
 	_, _, err = httpdtest.Loaddata(backupFilePath, "2", "", http.StatusOK)
 	assert.NoError(t, err)
+	configsGet, err := dataprovider.GetConfigs()
+	assert.NoError(t, err)
+	assert.Equal(t, configs.SMTP, configsGet.SMTP)
+	assert.Equal(t, configs.SFTPD.HostKeyAlgos, configsGet.SFTPD.HostKeyAlgos)
+	assert.Len(t, configsGet.SFTPD.Moduli, 0)
+	assert.Len(t, configsGet.SFTPD.KexAlgorithms, 0)
+	assert.Len(t, configsGet.SFTPD.Ciphers, 0)
+	assert.Len(t, configsGet.SFTPD.MACs, 0)
+	assert.Greater(t, configsGet.UpdatedAt, int64(0))
 	user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
 	assert.NoError(t, err)
 	assert.Len(t, user.VirtualFolders, 1)
@@ -7751,11 +7823,20 @@ func TestLoaddata(t *testing.T) {
 	assert.NoError(t, err)
 	err = os.Remove(backupFilePath)
 	assert.NoError(t, err)
+	err = dataprovider.UpdateConfigs(nil, "", "", "")
+	assert.NoError(t, err)
 }
 
 func TestLoaddataMode(t *testing.T) {
+	err := dataprovider.UpdateConfigs(nil, "", "", "")
+	assert.NoError(t, err)
 	mappedPath := filepath.Join(os.TempDir(), "restored_fold")
 	folderName := filepath.Base(mappedPath)
+	configs := dataprovider.Configs{
+		SFTPD: &dataprovider.SFTPDConfigs{
+			Moduli: []string{"/moduli"},
+		},
+	}
 	role := getTestRole()
 	role.ID = 1
 	role.Name = "test_role_load"
@@ -7834,6 +7915,7 @@ func TestLoaddataMode(t *testing.T) {
 	backupData := dataprovider.BackupData{
 		Version: dataprovider.DumpVersion,
 	}
+	backupData.Configs = &configs
 	backupData.Users = append(backupData.Users, user)
 	backupData.Groups = append(backupData.Groups, group)
 	backupData.Admins = append(backupData.Admins, admin)
@@ -7859,10 +7941,13 @@ func TestLoaddataMode(t *testing.T) {
 	backupData.IPLists = append(backupData.IPLists, ipListEntry)
 	backupContent, _ := json.Marshal(backupData)
 	backupFilePath := filepath.Join(backupsPath, "backup.json")
-	err := os.WriteFile(backupFilePath, backupContent, os.ModePerm)
+	err = os.WriteFile(backupFilePath, backupContent, os.ModePerm)
 	assert.NoError(t, err)
 	_, _, err = httpdtest.Loaddata(backupFilePath, "0", "0", http.StatusOK)
 	assert.NoError(t, err)
+	configs, err = dataprovider.GetConfigs()
+	assert.NoError(t, err)
+	assert.Len(t, configs.SFTPD.Moduli, 1)
 	folder, _, err := httpdtest.GetFolderByName(folderName, http.StatusOK)
 	assert.NoError(t, err)
 	assert.Equal(t, mappedPath+"1", folder.MappedPath)
@@ -7934,6 +8019,10 @@ func TestLoaddataMode(t *testing.T) {
 	entry, _, err = httpdtest.UpdateIPListEntry(entry, http.StatusOK)
 	assert.NoError(t, err)
 
+	configs.SFTPD.Moduli = append(configs.SFTPD.Moduli, "/moduli_new")
+	err = dataprovider.UpdateConfigs(&configs, "", "", "")
+	assert.NoError(t, err)
+	backupData.Configs = &configs
 	backupData.Folders = []vfs.BaseVirtualFolder{
 		{
 			MappedPath: mappedPath,
@@ -7942,6 +8031,9 @@ func TestLoaddataMode(t *testing.T) {
 	}
 	_, _, err = httpdtest.Loaddata(backupFilePath, "0", "1", http.StatusOK)
 	assert.NoError(t, err)
+	configs, err = dataprovider.GetConfigs()
+	assert.NoError(t, err)
+	assert.Len(t, configs.SFTPD.Moduli, 2)
 	group, _, err = httpdtest.GetGroupByName(group.Name, http.StatusOK)
 	assert.NoError(t, err)
 	assert.NotEqual(t, oldGroupDesc, group.Description)
@@ -7999,6 +8091,9 @@ func TestLoaddataMode(t *testing.T) {
 	user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
 	assert.NoError(t, err)
 	assert.Equal(t, oldUploadBandwidth, user.UploadBandwidth)
+	configs, err = dataprovider.GetConfigs()
+	assert.NoError(t, err)
+	assert.Len(t, configs.SFTPD.Moduli, 1)
 	// the group is referenced
 	_, err = httpdtest.RemoveGroup(group, http.StatusBadRequest)
 	assert.NoError(t, err)
@@ -8022,6 +8117,8 @@ func TestLoaddataMode(t *testing.T) {
 	assert.NoError(t, err)
 	err = os.Remove(backupFilePath)
 	assert.NoError(t, err)
+	err = dataprovider.UpdateConfigs(nil, "", "", "")
+	assert.NoError(t, err)
 }
 
 func TestRateLimiter(t *testing.T) {
@@ -8992,6 +9089,84 @@ func TestChangeAdminPwdInvalidJsonMock(t *testing.T) {
 	checkResponseCode(t, http.StatusBadRequest, rr)
 }
 
+func TestSMTPConfig(t *testing.T) {
+	smtpCfg := smtp.Config{
+		Host:          "127.0.0.1",
+		Port:          3525,
+		From:          "notification@example.com",
+		TemplatesPath: "templates",
+	}
+	err := smtpCfg.Initialize(configDir)
+	require.NoError(t, err)
+
+	smtpTestURL := path.Join(webConfigsPath, "smtp", "test")
+	tokenHeader := "X-CSRF-TOKEN"
+	webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
+	assert.NoError(t, err)
+	csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
+	assert.NoError(t, err)
+	req, err := http.NewRequest(http.MethodPost, smtpTestURL, bytes.NewBuffer([]byte("{")))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+
+	req.Header.Set(tokenHeader, csrfToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+
+	testReq := make(map[string]any)
+	testReq["host"] = smtpCfg.Host
+	testReq["port"] = 3525
+	testReq["from"] = "from@example.com"
+	asJSON, err := json.Marshal(testReq)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, smtpTestURL, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	req.Header.Set(tokenHeader, csrfToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusInternalServerError, rr)
+
+	testReq["recipient"] = "example@example.com"
+	asJSON, err = json.Marshal(testReq)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, smtpTestURL, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	req.Header.Set(tokenHeader, csrfToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	configs := dataprovider.Configs{
+		SMTP: &dataprovider.SMTPConfigs{
+			Host:     "127.0.0.1",
+			Port:     3535,
+			User:     "user@example.com",
+			Password: kms.NewPlainSecret(defaultPassword),
+		},
+	}
+	err = dataprovider.UpdateConfigs(&configs, "", "", "")
+	assert.NoError(t, err)
+
+	testReq["password"] = redactedSecret
+	asJSON, err = json.Marshal(testReq)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, smtpTestURL, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	req.Header.Set(tokenHeader, csrfToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusInternalServerError, rr)
+	assert.Contains(t, rr.Body.String(), "server does not support SMTP AUTH")
+
+	err = dataprovider.UpdateConfigs(nil, "", "", "")
+	assert.NoError(t, err)
+	smtpCfg = smtp.Config{}
+	err = smtpCfg.Initialize(configDir)
+	require.NoError(t, err)
+}
+
 func TestMFAPermission(t *testing.T) {
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	assert.NoError(t, err)
@@ -12072,6 +12247,149 @@ func TestMaxSessions(t *testing.T) {
 	assert.Len(t, common.Connections.GetStats(""), 0)
 }
 
+func TestWebConfigsMock(t *testing.T) {
+	err := dataprovider.UpdateConfigs(nil, "", "", "")
+	assert.NoError(t, err)
+	webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
+	assert.NoError(t, err)
+	csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
+	assert.NoError(t, err)
+
+	req, err := http.NewRequest(http.MethodGet, webConfigsPath, nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	form := make(url.Values)
+	req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+	// parse form error
+	form.Set(csrfFormToken, csrfToken)
+	req, err = http.NewRequest(http.MethodPost, webConfigsPath+"?p=p%C3%AO%GH", bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	// save SFTP configs
+	form.Set("sftp_host_key_algos", ssh.KeyAlgoRSA)
+	form.Add("sftp_host_key_algos", ssh.CertAlgoDSAv01)
+	form.Set("sftp_moduli", "path 1 , path 2")
+	form.Set("form_action", "sftp_submit")
+	req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Contains(t, rr.Body.String(), ssh.CertAlgoDSAv01) // invalid algo
+	form.Set("sftp_host_key_algos", ssh.KeyAlgoRSA)
+	form.Add("sftp_host_key_algos", ssh.CertAlgoRSAv01)
+	req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Contains(t, rr.Body.String(), "Configurations updated")
+	// check SFTP configs
+	configs, err := dataprovider.GetConfigs()
+	assert.NoError(t, err)
+	assert.Len(t, configs.SFTPD.HostKeyAlgos, 2)
+	assert.Contains(t, configs.SFTPD.HostKeyAlgos, ssh.KeyAlgoRSA)
+	assert.Contains(t, configs.SFTPD.HostKeyAlgos, ssh.CertAlgoRSAv01)
+	assert.Len(t, configs.SFTPD.Moduli, 2)
+	assert.Contains(t, configs.SFTPD.Moduli, "path 1")
+	assert.Contains(t, configs.SFTPD.Moduli, "path 2")
+	// invalid form action
+	form.Set("form_action", "")
+	req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "unsupported form action")
+	// test SMTP configs
+	form.Set("form_action", "smtp_submit")
+	form.Set("smtp_host", "mail.example.net")
+	form.Set("smtp_from", "Example <info@example.net>")
+	form.Set("smtp_username", defaultUsername)
+	form.Set("smtp_password", defaultPassword)
+	form.Set("smtp_domain", "localdomain")
+	req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Contains(t, rr.Body.String(), "Validation error") // port is not passed so 0
+	// set valid parameters
+	form.Set("smtp_port", "465")
+	form.Set("smtp_auth", "1")
+	form.Set("smtp_encryption", "2")
+	req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Contains(t, rr.Body.String(), "Configurations updated")
+	// check
+	configs, err = dataprovider.GetConfigs()
+	assert.NoError(t, err)
+	assert.Len(t, configs.SFTPD.HostKeyAlgos, 2)
+	assert.Contains(t, configs.SFTPD.HostKeyAlgos, ssh.KeyAlgoRSA)
+	assert.Contains(t, configs.SFTPD.HostKeyAlgos, ssh.CertAlgoRSAv01)
+	assert.Len(t, configs.SFTPD.Moduli, 2)
+	assert.Equal(t, "mail.example.net", configs.SMTP.Host)
+	assert.Equal(t, 465, configs.SMTP.Port)
+	assert.Equal(t, "Example <info@example.net>", configs.SMTP.From)
+	assert.Equal(t, defaultUsername, configs.SMTP.User)
+	err = configs.SMTP.Password.Decrypt()
+	assert.NoError(t, err)
+	assert.Equal(t, defaultPassword, configs.SMTP.Password.GetPayload())
+	assert.Equal(t, 1, configs.SMTP.AuthType)
+	assert.Equal(t, 2, configs.SMTP.Encryption)
+	assert.Equal(t, "localdomain", configs.SMTP.Domain)
+	// set a redacted password, the current password must be preserved
+	form.Set("smtp_password", redactedSecret)
+	req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Contains(t, rr.Body.String(), "Configurations updated")
+	updatedConfigs, err := dataprovider.GetConfigs()
+	assert.NoError(t, err)
+	encryptedPayload := updatedConfigs.SMTP.Password.GetPayload()
+	secretKey := updatedConfigs.SMTP.Password.GetKey()
+	err = updatedConfigs.SMTP.Password.Decrypt()
+	assert.NoError(t, err)
+	assert.Equal(t, configs.SFTPD, updatedConfigs.SFTPD)
+	assert.Equal(t, configs.SMTP, updatedConfigs.SMTP)
+	// now set an undecryptable password
+	updatedConfigs.SMTP.Password = kms.NewSecret(sdkkms.SecretStatusSecretBox, encryptedPayload, secretKey, "")
+	err = dataprovider.UpdateConfigs(&updatedConfigs, "", "", "")
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Contains(t, rr.Body.String(), "Configurations updated")
+
+	err = dataprovider.UpdateConfigs(nil, "", "", "")
+	assert.NoError(t, err)
+}
+
 func TestSFTPLoopError(t *testing.T) {
 	user1 := getTestUser()
 	user2 := getTestUser()
@@ -22949,10 +23267,35 @@ func TestProviderClosedMock(t *testing.T) {
 	assert.NoError(t, err)
 
 	dataprovider.Close()
-	req, _ := http.NewRequest(http.MethodGet, webFoldersPath, nil)
+
+	testReq := make(map[string]any)
+	testReq["password"] = redactedSecret
+	asJSON, err := json.Marshal(testReq)
+	assert.NoError(t, err)
+	req, err := http.NewRequest(http.MethodPost, path.Join(webConfigsPath, "smtp", "test"), bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
 	setJWTCookieForReq(req, token)
+	req.Header.Set("X-CSRF-TOKEN", csrfToken)
 	rr := executeRequest(req)
 	checkResponseCode(t, http.StatusInternalServerError, rr)
+
+	req, err = http.NewRequest(http.MethodGet, webConfigsPath, nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusInternalServerError, rr)
+
+	req, err = http.NewRequest(http.MethodPost, webConfigsPath, nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, token)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusInternalServerError, rr)
+
+	req, _ = http.NewRequest(http.MethodGet, webFoldersPath, nil)
+	setJWTCookieForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusInternalServerError, rr)
 	req, _ = http.NewRequest(http.MethodGet, webUsersPath, nil)
 	setJWTCookieForReq(req, token)
 	rr = executeRequest(req)

+ 5 - 0
internal/httpd/internal_test.go

@@ -616,6 +616,11 @@ func TestInvalidToken(t *testing.T) {
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Contains(t, rr.Body.String(), "invalid token claims")
 
+	rr = httptest.NewRecorder()
+	server.handleWebConfigsPost(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "invalid token claims")
+
 	rr = httptest.NewRecorder()
 	addAdmin(rr, req)
 	assert.Equal(t, http.StatusBadRequest, rr.Code)

+ 6 - 2
internal/httpd/server.go

@@ -1687,16 +1687,20 @@ func (s *httpdServer) setupWebAdminRoutes() {
 			router.With(s.checkPerm(dataprovider.PermAdminManageIPLists)).Get(webIPListsPath, s.handleWebIPListsPage)
 			router.With(s.checkPerm(dataprovider.PermAdminManageIPLists), compressor.Handler, s.refreshCookie).
 				Get(webIPListsPath+"/{type}", getIPListEntries)
-			router.With(s.checkPerm(dataprovider.PermAdminManageIPLists)).Get(webIPListPath+"/{type}",
+			router.With(s.checkPerm(dataprovider.PermAdminManageIPLists), s.refreshCookie).Get(webIPListPath+"/{type}",
 				s.handleWebAddIPListEntryGet)
 			router.With(s.checkPerm(dataprovider.PermAdminManageIPLists)).Post(webIPListPath+"/{type}",
 				s.handleWebAddIPListEntryPost)
-			router.With(s.checkPerm(dataprovider.PermAdminManageIPLists)).Get(webIPListPath+"/{type}/{ipornet}",
+			router.With(s.checkPerm(dataprovider.PermAdminManageIPLists), s.refreshCookie).Get(webIPListPath+"/{type}/{ipornet}",
 				s.handleWebUpdateIPListEntryGet)
 			router.With(s.checkPerm(dataprovider.PermAdminManageIPLists)).Post(webIPListPath+"/{type}/{ipornet}",
 				s.handleWebUpdateIPListEntryPost)
 			router.With(s.checkPerm(dataprovider.PermAdminManageIPLists), verifyCSRFHeader).
 				Delete(webIPListPath+"/{type}/{ipornet}", deleteIPListEntry)
+			router.With(s.checkPerm(dataprovider.PermAdminManageSystem), s.refreshCookie).Get(webConfigsPath, s.handleWebConfigs)
+			router.With(s.checkPerm(dataprovider.PermAdminManageSystem)).Post(webConfigsPath, s.handleWebConfigsPost)
+			router.With(s.checkPerm(dataprovider.PermAdminManageSystem), verifyCSRFHeader, s.refreshCookie).
+				Post(webConfigsPath+"/smtp/test", testSMTPConfig)
 		})
 	}
 }

+ 264 - 115
internal/httpd/webadmin.go

@@ -35,6 +35,7 @@ import (
 	"github.com/drakkan/sftpgo/v2/internal/common"
 	"github.com/drakkan/sftpgo/v2/internal/dataprovider"
 	"github.com/drakkan/sftpgo/v2/internal/kms"
+	"github.com/drakkan/sftpgo/v2/internal/logger"
 	"github.com/drakkan/sftpgo/v2/internal/mfa"
 	"github.com/drakkan/sftpgo/v2/internal/plugin"
 	"github.com/drakkan/sftpgo/v2/internal/smtp"
@@ -94,6 +95,7 @@ const (
 	templateDefender         = "defender.html"
 	templateIPLists          = "iplists.html"
 	templateIPList           = "iplist.html"
+	templateConfigs          = "configs.html"
 	templateProfile          = "profile.html"
 	templateChangePwd        = "changepassword.html"
 	templateMaintenance      = "maintenance.html"
@@ -114,6 +116,7 @@ const (
 	pageDefenderTitle        = "Auto Blocklist"
 	pageIPListsTitle         = "IP Lists"
 	pageEventsTitle          = "Logs"
+	pageConfigsTitle         = "Configurations"
 	pageForgotPwdTitle       = "SFTPGo Admin - Forgot password"
 	pageResetPwdTitle        = "SFTPGo Admin - Reset password"
 	pageSetupTitle           = "Create first admin user"
@@ -126,60 +129,63 @@ var (
 )
 
 type basePage struct {
-	Title              string
-	CurrentURL         string
-	UsersURL           string
-	UserURL            string
-	UserTemplateURL    string
-	AdminsURL          string
-	AdminURL           string
-	QuotaScanURL       string
-	ConnectionsURL     string
-	GroupsURL          string
-	GroupURL           string
-	FoldersURL         string
-	FolderURL          string
-	FolderTemplateURL  string
-	DefenderURL        string
-	IPListsURL         string
-	IPListURL          string
-	EventsURL          string
-	LogoutURL          string
-	ProfileURL         string
-	ChangePwdURL       string
-	MFAURL             string
-	EventRulesURL      string
-	EventRuleURL       string
-	EventActionsURL    string
-	EventActionURL     string
-	RolesURL           string
-	RoleURL            string
-	FolderQuotaScanURL string
-	StatusURL          string
-	MaintenanceURL     string
-	StaticURL          string
-	UsersTitle         string
-	AdminsTitle        string
-	ConnectionsTitle   string
-	FoldersTitle       string
-	GroupsTitle        string
-	EventRulesTitle    string
-	EventActionsTitle  string
-	RolesTitle         string
-	StatusTitle        string
-	MaintenanceTitle   string
-	DefenderTitle      string
-	IPListsTitle       string
-	EventsTitle        string
-	Version            string
-	CSRFToken          string
-	IsEventManagerPage bool
-	IsIPManagerPage    bool
-	HasDefender        bool
-	HasSearcher        bool
-	HasExternalLogin   bool
-	LoggedAdmin        *dataprovider.Admin
-	Branding           UIBranding
+	Title               string
+	CurrentURL          string
+	UsersURL            string
+	UserURL             string
+	UserTemplateURL     string
+	AdminsURL           string
+	AdminURL            string
+	QuotaScanURL        string
+	ConnectionsURL      string
+	GroupsURL           string
+	GroupURL            string
+	FoldersURL          string
+	FolderURL           string
+	FolderTemplateURL   string
+	DefenderURL         string
+	IPListsURL          string
+	IPListURL           string
+	EventsURL           string
+	ConfigsURL          string
+	LogoutURL           string
+	ProfileURL          string
+	ChangePwdURL        string
+	MFAURL              string
+	EventRulesURL       string
+	EventRuleURL        string
+	EventActionsURL     string
+	EventActionURL      string
+	RolesURL            string
+	RoleURL             string
+	FolderQuotaScanURL  string
+	StatusURL           string
+	MaintenanceURL      string
+	StaticURL           string
+	UsersTitle          string
+	AdminsTitle         string
+	ConnectionsTitle    string
+	FoldersTitle        string
+	GroupsTitle         string
+	EventRulesTitle     string
+	EventActionsTitle   string
+	RolesTitle          string
+	StatusTitle         string
+	MaintenanceTitle    string
+	DefenderTitle       string
+	IPListsTitle        string
+	EventsTitle         string
+	ConfigsTitle        string
+	Version             string
+	CSRFToken           string
+	IsEventManagerPage  bool
+	IsIPManagerPage     bool
+	IsServerManagerPage bool
+	HasDefender         bool
+	HasSearcher         bool
+	HasExternalLogin    bool
+	LoggedAdmin         *dataprovider.Admin
+	Branding            UIBranding
 }
 
 type usersPage struct {
@@ -383,6 +389,14 @@ type eventsPage struct {
 	ProviderEventsSearchURL string
 }
 
+type configsPage struct {
+	basePage
+	Configs        dataprovider.Configs
+	ConfigSection  int
+	RedactedSecret string
+	Error          string
+}
+
 type messagePage struct {
 	basePage
 	Error   string
@@ -554,6 +568,11 @@ func loadAdminTemplates(templatesPath string) {
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateEvents),
 	}
+	configsPaths := []string{
+		filepath.Join(templatesPath, templateCommonDir, templateCommonCSS),
+		filepath.Join(templatesPath, templateAdminDir, templateBase),
+		filepath.Join(templatesPath, templateAdminDir, templateConfigs),
+	}
 
 	fsBaseTpl := template.New("fsBaseTemplate").Funcs(template.FuncMap{
 		"ListFSProviders": func() []sdk.FilesystemProvider {
@@ -595,6 +614,7 @@ func loadAdminTemplates(templatesPath string) {
 	rolesTmpl := util.LoadTemplate(nil, rolesPaths...)
 	roleTmpl := util.LoadTemplate(nil, rolePaths...)
 	eventsTmpl := util.LoadTemplate(nil, eventsPaths...)
+	configsTmpl := util.LoadTemplate(nil, configsPaths...)
 
 	adminTemplates[templateUsers] = usersTmpl
 	adminTemplates[templateUser] = userTmpl
@@ -627,6 +647,7 @@ func loadAdminTemplates(templatesPath string) {
 	adminTemplates[templateRoles] = rolesTmpl
 	adminTemplates[templateRole] = roleTmpl
 	adminTemplates[templateEvents] = eventsTmpl
+	adminTemplates[templateConfigs] = configsTmpl
 }
 
 func isEventManagerResource(currentURL string) bool {
@@ -658,66 +679,74 @@ func isIPListsResource(currentURL string) bool {
 	return false
 }
 
+func isServerManagerResource(currentURL string) bool {
+	return currentURL == webEventsPath || currentURL == webStatusPath || currentURL == webMaintenancePath ||
+		currentURL == webConfigsPath
+}
+
 func (s *httpdServer) getBasePageData(title, currentURL string, r *http.Request) basePage {
 	var csrfToken string
 	if currentURL != "" {
 		csrfToken = createCSRFToken(util.GetIPFromRemoteAddress(r.RemoteAddr))
 	}
 	return basePage{
-		Title:              title,
-		CurrentURL:         currentURL,
-		UsersURL:           webUsersPath,
-		UserURL:            webUserPath,
-		UserTemplateURL:    webTemplateUser,
-		AdminsURL:          webAdminsPath,
-		AdminURL:           webAdminPath,
-		GroupsURL:          webGroupsPath,
-		GroupURL:           webGroupPath,
-		FoldersURL:         webFoldersPath,
-		FolderURL:          webFolderPath,
-		FolderTemplateURL:  webTemplateFolder,
-		DefenderURL:        webDefenderPath,
-		IPListsURL:         webIPListsPath,
-		IPListURL:          webIPListPath,
-		EventsURL:          webEventsPath,
-		LogoutURL:          webLogoutPath,
-		ProfileURL:         webAdminProfilePath,
-		ChangePwdURL:       webChangeAdminPwdPath,
-		MFAURL:             webAdminMFAPath,
-		EventRulesURL:      webAdminEventRulesPath,
-		EventRuleURL:       webAdminEventRulePath,
-		EventActionsURL:    webAdminEventActionsPath,
-		EventActionURL:     webAdminEventActionPath,
-		RolesURL:           webAdminRolesPath,
-		RoleURL:            webAdminRolePath,
-		QuotaScanURL:       webQuotaScanPath,
-		ConnectionsURL:     webConnectionsPath,
-		StatusURL:          webStatusPath,
-		FolderQuotaScanURL: webScanVFolderPath,
-		MaintenanceURL:     webMaintenancePath,
-		StaticURL:          webStaticFilesPath,
-		UsersTitle:         pageUsersTitle,
-		AdminsTitle:        pageAdminsTitle,
-		ConnectionsTitle:   pageConnectionsTitle,
-		FoldersTitle:       pageFoldersTitle,
-		GroupsTitle:        pageGroupsTitle,
-		EventRulesTitle:    pageEventRulesTitle,
-		EventActionsTitle:  pageEventActionsTitle,
-		RolesTitle:         pageRolesTitle,
-		StatusTitle:        pageStatusTitle,
-		MaintenanceTitle:   pageMaintenanceTitle,
-		DefenderTitle:      pageDefenderTitle,
-		IPListsTitle:       pageIPListsTitle,
-		EventsTitle:        pageEventsTitle,
-		Version:            version.GetAsString(),
-		LoggedAdmin:        getAdminFromToken(r),
-		IsEventManagerPage: isEventManagerResource(currentURL),
-		IsIPManagerPage:    isIPListsResource(currentURL),
-		HasDefender:        common.Config.DefenderConfig.Enabled,
-		HasSearcher:        plugin.Handler.HasSearcher(),
-		HasExternalLogin:   isLoggedInWithOIDC(r),
-		CSRFToken:          csrfToken,
-		Branding:           s.binding.Branding.WebAdmin,
+		Title:               title,
+		CurrentURL:          currentURL,
+		UsersURL:            webUsersPath,
+		UserURL:             webUserPath,
+		UserTemplateURL:     webTemplateUser,
+		AdminsURL:           webAdminsPath,
+		AdminURL:            webAdminPath,
+		GroupsURL:           webGroupsPath,
+		GroupURL:            webGroupPath,
+		FoldersURL:          webFoldersPath,
+		FolderURL:           webFolderPath,
+		FolderTemplateURL:   webTemplateFolder,
+		DefenderURL:         webDefenderPath,
+		IPListsURL:          webIPListsPath,
+		IPListURL:           webIPListPath,
+		EventsURL:           webEventsPath,
+		ConfigsURL:          webConfigsPath,
+		LogoutURL:           webLogoutPath,
+		ProfileURL:          webAdminProfilePath,
+		ChangePwdURL:        webChangeAdminPwdPath,
+		MFAURL:              webAdminMFAPath,
+		EventRulesURL:       webAdminEventRulesPath,
+		EventRuleURL:        webAdminEventRulePath,
+		EventActionsURL:     webAdminEventActionsPath,
+		EventActionURL:      webAdminEventActionPath,
+		RolesURL:            webAdminRolesPath,
+		RoleURL:             webAdminRolePath,
+		QuotaScanURL:        webQuotaScanPath,
+		ConnectionsURL:      webConnectionsPath,
+		StatusURL:           webStatusPath,
+		FolderQuotaScanURL:  webScanVFolderPath,
+		MaintenanceURL:      webMaintenancePath,
+		StaticURL:           webStaticFilesPath,
+		UsersTitle:          pageUsersTitle,
+		AdminsTitle:         pageAdminsTitle,
+		ConnectionsTitle:    pageConnectionsTitle,
+		FoldersTitle:        pageFoldersTitle,
+		GroupsTitle:         pageGroupsTitle,
+		EventRulesTitle:     pageEventRulesTitle,
+		EventActionsTitle:   pageEventActionsTitle,
+		RolesTitle:          pageRolesTitle,
+		StatusTitle:         pageStatusTitle,
+		MaintenanceTitle:    pageMaintenanceTitle,
+		DefenderTitle:       pageDefenderTitle,
+		IPListsTitle:        pageIPListsTitle,
+		EventsTitle:         pageEventsTitle,
+		ConfigsTitle:        pageConfigsTitle,
+		Version:             version.GetAsString(),
+		LoggedAdmin:         getAdminFromToken(r),
+		IsEventManagerPage:  isEventManagerResource(currentURL),
+		IsIPManagerPage:     isIPListsResource(currentURL),
+		IsServerManagerPage: isServerManagerResource(currentURL),
+		HasDefender:         common.Config.DefenderConfig.Enabled,
+		HasSearcher:         plugin.Handler.HasSearcher(),
+		HasExternalLogin:    isLoggedInWithOIDC(r),
+		CSRFToken:           csrfToken,
+		Branding:            s.binding.Branding.WebAdmin,
 	}
 }
 
@@ -867,6 +896,24 @@ func (s *httpdServer) renderMaintenancePage(w http.ResponseWriter, r *http.Reque
 	renderAdminTemplate(w, templateMaintenance, data)
 }
 
+func (s *httpdServer) renderConfigsPage(w http.ResponseWriter, r *http.Request, configs dataprovider.Configs,
+	error string, section int,
+) {
+	configs.SetNilsToEmpty()
+	if configs.SMTP.Port == 0 {
+		configs.SMTP.Port = 587
+	}
+	data := configsPage{
+		basePage:       s.getBasePageData(pageConfigsTitle, webConfigsPath, r),
+		Configs:        configs,
+		ConfigSection:  section,
+		RedactedSecret: redactedSecret,
+		Error:          error,
+	}
+
+	renderAdminTemplate(w, templateConfigs, data)
+}
+
 func (s *httpdServer) renderAdminSetupPage(w http.ResponseWriter, r *http.Request, username, error string) {
 	data := setupPage{
 		basePage:             s.getBasePageData(pageSetupTitle, webAdminSetupPath, r),
@@ -2218,11 +2265,11 @@ func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEven
 	}
 	var emailAttachments []string
 	if r.Form.Get("email_attachments") != "" {
-		emailAttachments = strings.Split(strings.ReplaceAll(r.Form.Get("email_attachments"), " ", ""), ",")
+		emailAttachments = getSliceFromDelimitedValues(r.Form.Get("email_attachments"), ",")
 	}
 	var cmdArgs []string
 	if r.Form.Get("cmd_arguments") != "" {
-		cmdArgs = strings.Split(strings.ReplaceAll(r.Form.Get("cmd_arguments"), " ", ""), ",")
+		cmdArgs = getSliceFromDelimitedValues(r.Form.Get("cmd_arguments"), ",")
 	}
 	options := dataprovider.BaseEventActionOptions{
 		HTTPConfig: dataprovider.EventActionHTTPConfig{
@@ -2244,7 +2291,7 @@ func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEven
 			EnvVars: getKeyValsFromPostFields(r, "cmd_env_key", "cmd_env_val"),
 		},
 		EmailConfig: dataprovider.EventActionEmailConfig{
-			Recipients:  strings.Split(strings.ReplaceAll(r.Form.Get("email_recipients"), " ", ""), ","),
+			Recipients:  getSliceFromDelimitedValues(r.Form.Get("email_recipients"), ","),
 			Subject:     r.Form.Get("email_subject"),
 			Body:        r.Form.Get("email_body"),
 			Attachments: emailAttachments,
@@ -2255,13 +2302,13 @@ func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEven
 		FsConfig: dataprovider.EventActionFilesystemConfig{
 			Type:    fsActionType,
 			Renames: getKeyValsFromPostFields(r, "fs_rename_source", "fs_rename_target"),
-			Deletes: strings.Split(strings.ReplaceAll(r.Form.Get("fs_delete_paths"), " ", ""), ","),
-			MkDirs:  strings.Split(strings.ReplaceAll(r.Form.Get("fs_mkdir_paths"), " ", ""), ","),
-			Exist:   strings.Split(strings.ReplaceAll(r.Form.Get("fs_exist_paths"), " ", ""), ","),
+			Deletes: getSliceFromDelimitedValues(r.Form.Get("fs_delete_paths"), ","),
+			MkDirs:  getSliceFromDelimitedValues(r.Form.Get("fs_mkdir_paths"), ","),
+			Exist:   getSliceFromDelimitedValues(r.Form.Get("fs_exist_paths"), ","),
 			Copy:    getKeyValsFromPostFields(r, "fs_copy_source", "fs_copy_target"),
 			Compress: dataprovider.EventActionFsCompress{
 				Name:  r.Form.Get("fs_compress_name"),
-				Paths: strings.Split(strings.ReplaceAll(r.Form.Get("fs_compress_paths"), " ", ""), ","),
+				Paths: getSliceFromDelimitedValues(r.Form.Get("fs_compress_paths"), ","),
 			},
 		},
 		PwdExpirationConfig: dataprovider.EventActionPasswordExpiration{
@@ -2487,6 +2534,41 @@ func getIPListEntryFromPostFields(r *http.Request, listType dataprovider.IPListT
 	}, nil
 }
 
+func getSFTPConfigsFromPostFields(r *http.Request) *dataprovider.SFTPDConfigs {
+	return &dataprovider.SFTPDConfigs{
+		HostKeyAlgos:  r.Form["sftp_host_key_algos"],
+		Moduli:        getSliceFromDelimitedValues(r.Form.Get("sftp_moduli"), ","),
+		KexAlgorithms: r.Form["sftp_kex_algos"],
+		Ciphers:       r.Form["sftp_ciphers"],
+		MACs:          r.Form["sftp_macs"],
+	}
+}
+
+func getSMTPConfigsFromPostFields(r *http.Request) *dataprovider.SMTPConfigs {
+	port, err := strconv.Atoi(r.Form.Get("smtp_port"))
+	if err != nil {
+		port = 0
+	}
+	authType, err := strconv.Atoi(r.Form.Get("smtp_auth"))
+	if err != nil {
+		authType = 0
+	}
+	encryption, err := strconv.Atoi(r.Form.Get("smtp_encryption"))
+	if err != nil {
+		encryption = 0
+	}
+	return &dataprovider.SMTPConfigs{
+		Host:       r.Form.Get("smtp_host"),
+		Port:       port,
+		From:       r.Form.Get("smtp_from"),
+		User:       r.Form.Get("smtp_username"),
+		Password:   getSecretFromFormField(r, "smtp_password"),
+		AuthType:   authType,
+		Encryption: encryption,
+		Domain:     r.Form.Get("smtp_domain"),
+	}
+}
+
 func (s *httpdServer) handleWebAdminForgotPwd(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	if !smtp.IsEnabled() {
@@ -3921,3 +4003,70 @@ func (s *httpdServer) handleWebUpdateIPListEntryPost(w http.ResponseWriter, r *h
 	}
 	http.Redirect(w, r, webIPListsPath, http.StatusSeeOther)
 }
+
+func (s *httpdServer) handleWebConfigs(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	configs, err := dataprovider.GetConfigs()
+	if err != nil {
+		s.renderInternalServerErrorPage(w, r, err)
+		return
+	}
+	s.renderConfigsPage(w, r, configs, "", 0)
+}
+
+func (s *httpdServer) handleWebConfigsPost(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		s.renderBadRequestPage(w, r, errors.New("invalid token claims"))
+		return
+	}
+	configs, err := dataprovider.GetConfigs()
+	if err != nil {
+		s.renderInternalServerErrorPage(w, r, err)
+		return
+	}
+	err = r.ParseForm()
+	if err != nil {
+		s.renderBadRequestPage(w, r, err)
+		return
+	}
+	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
+	if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+		s.renderForbiddenPage(w, r, err.Error())
+		return
+	}
+	var configSection int
+	switch r.Form.Get("form_action") {
+	case "sftp_submit":
+		configSection = 1
+		sftpConfigs := getSFTPConfigsFromPostFields(r)
+		configs.SFTPD = sftpConfigs
+	case "smtp_submit":
+		configSection = 2
+		smtpConfigs := getSMTPConfigsFromPostFields(r)
+		if smtpConfigs.Password.IsNotPlainAndNotEmpty() {
+			smtpConfigs.Password = configs.SMTP.Password
+		}
+		configs.SMTP = smtpConfigs
+	default:
+		s.renderBadRequestPage(w, r, errors.New("unsupported form action"))
+		return
+	}
+
+	err = dataprovider.UpdateConfigs(&configs, claims.Username, ipAddr, claims.Role)
+	if err != nil {
+		s.renderConfigsPage(w, r, configs, err.Error(), configSection)
+		return
+	}
+	if configSection == 2 {
+		err := configs.SMTP.Password.TryDecrypt()
+		if err == nil {
+			smtp.Activate(configs.SMTP)
+		} else {
+			logger.Error(logSender, "", "unable to decrypt SMTP password, cannot activate configuration")
+		}
+	}
+	s.renderMessagePage(w, r, "Configurations updated", "", http.StatusOK, nil,
+		"Configurations has been successfully updated")
+}

+ 11 - 7
internal/service/service.go

@@ -135,6 +135,12 @@ func (s *Service) initializeServices(disableAWSInstallationCode bool) error {
 		logger.ErrorToConsole("unable to initialize MFA: %v", err)
 		return err
 	}
+	err = dataprovider.Initialize(providerConf, s.ConfigDir, s.PortableMode == 0)
+	if err != nil {
+		logger.Error(logSender, "", "error initializing data provider: %v", err)
+		logger.ErrorToConsole("error initializing data provider: %v", err)
+		return err
+	}
 	if err := plugin.Initialize(config.GetPluginsConfig(), s.LogLevel); err != nil {
 		logger.Error(logSender, "", "unable to initialize plugin system: %v", err)
 		logger.ErrorToConsole("unable to initialize plugin system: %v", err)
@@ -147,12 +153,6 @@ func (s *Service) initializeServices(disableAWSInstallationCode bool) error {
 		logger.ErrorToConsole("unable to initialize SMTP configuration: %v", err)
 		return err
 	}
-	err = dataprovider.Initialize(providerConf, s.ConfigDir, s.PortableMode == 0)
-	if err != nil {
-		logger.Error(logSender, "", "error initializing data provider: %v", err)
-		logger.ErrorToConsole("error initializing data provider: %v", err)
-		return err
-	}
 	err = common.Initialize(config.GetCommonConfig(), providerConf.GetShared())
 	if err != nil {
 		logger.Error(logSender, "", "%v", err)
@@ -350,7 +350,11 @@ func (s *Service) LoadInitialData() error {
 }
 
 func (s *Service) restoreDump(dump *dataprovider.BackupData) error {
-	err := httpd.RestoreIPListEntries(dump.IPLists, s.LoadDataFrom, s.LoadDataMode, dataprovider.ActionExecutorSystem, "", "")
+	err := httpd.RestoreConfigs(dump.Configs, s.LoadDataFrom, s.LoadDataMode, dataprovider.ActionExecutorSystem, "", "")
+	if err != nil {
+		return fmt.Errorf("unable to restore configs from file %q: %v", s.LoadDataFrom, err)
+	}
+	err = httpd.RestoreIPListEntries(dump.IPLists, s.LoadDataFrom, s.LoadDataMode, dataprovider.ActionExecutorSystem, "", "")
 	if err != nil {
 		return fmt.Errorf("unable to restore IP list entries from file %q: %v", s.LoadDataFrom, err)
 	}

+ 51 - 5
internal/sftpd/internal_test.go

@@ -1862,6 +1862,44 @@ func TestConnectionStatusStruct(t *testing.T) {
 	assert.NotEqual(t, 0, len(connInfo))
 }
 
+func TestConfigsFromProvider(t *testing.T) {
+	err := dataprovider.UpdateConfigs(nil, "", "", "")
+	assert.NoError(t, err)
+	c := Configuration{}
+	err = c.loadFromProvider()
+	assert.NoError(t, err)
+	assert.Len(t, c.HostKeyAlgorithms, 0)
+	assert.Len(t, c.Moduli, 0)
+	assert.Len(t, c.KexAlgorithms, 0)
+	assert.Len(t, c.Ciphers, 0)
+	assert.Len(t, c.MACs, 0)
+	configs := dataprovider.Configs{
+		SFTPD: &dataprovider.SFTPDConfigs{
+			HostKeyAlgos:  []string{ssh.KeyAlgoRSA},
+			Moduli:        []string{"/etc/ssh/moduli"},
+			KexAlgorithms: []string{kexDHGroupExchangeSHA256},
+			Ciphers:       []string{"aes128-cbc", "aes192-cbc", "aes256-cbc"},
+			MACs:          []string{"hmac-sha2-512-etm@openssh.com"},
+		},
+	}
+	err = dataprovider.UpdateConfigs(&configs, "", "", "")
+	assert.NoError(t, err)
+	err = c.loadFromProvider()
+	assert.NoError(t, err)
+	expectedHostKeyAlgos := append(preferredHostKeyAlgos, configs.SFTPD.HostKeyAlgos...)
+	expectedKEXs := append(preferredKexAlgos, configs.SFTPD.KexAlgorithms...)
+	expectedCiphers := append(preferredCiphers, configs.SFTPD.Ciphers...)
+	expectedMACs := append(preferredMACs, configs.SFTPD.MACs...)
+	assert.Equal(t, expectedHostKeyAlgos, c.HostKeyAlgorithms)
+	assert.Equal(t, expectedKEXs, c.KexAlgorithms)
+	assert.Equal(t, expectedCiphers, c.Ciphers)
+	assert.Equal(t, expectedMACs, c.MACs)
+	assert.Equal(t, configs.SFTPD.Moduli, c.Moduli)
+
+	err = dataprovider.UpdateConfigs(nil, "", "", "")
+	assert.NoError(t, err)
+}
+
 func TestSupportedSecurityOptions(t *testing.T) {
 	c := Configuration{
 		KexAlgorithms: supportedKexAlgos,
@@ -1888,6 +1926,12 @@ func TestSupportedSecurityOptions(t *testing.T) {
 	assert.Equal(t, supportedCiphers, serverConfig.Ciphers)
 	assert.Equal(t, supportedMACs, serverConfig.MACs)
 	assert.Equal(t, supportedKexAlgos, serverConfig.KeyExchanges)
+	c.KexAlgorithms = append(preferredKexAlgos, kexDHGroupExchangeSHA256) // removed because no moduli is provided
+	err = c.configureSecurityOptions(serverConfig)
+	assert.NoError(t, err)
+	assert.Equal(t, supportedCiphers, serverConfig.Ciphers)
+	assert.Equal(t, supportedMACs, serverConfig.MACs)
+	assert.Equal(t, preferredKexAlgos, serverConfig.KeyExchanges)
 }
 
 func TestLoadModuli(t *testing.T) {
@@ -1895,20 +1939,22 @@ func TestLoadModuli(t *testing.T) {
 	dhGEXSha256 := "diffie-hellman-group-exchange-sha256"
 	c := Configuration{}
 	c.Moduli = []string{".", "missing file"}
-	err := c.loadModuli(configDir)
-	assert.Error(t, err)
+	c.loadModuli(configDir)
 	assert.NotContains(t, supportedKexAlgos, dhGEXSha1)
 	assert.NotContains(t, supportedKexAlgos, dhGEXSha256)
+	assert.NotContains(t, preferredKexAlgos, dhGEXSha1)
+	assert.NotContains(t, preferredKexAlgos, dhGEXSha256)
 	assert.Len(t, supportedKexAlgos, 10)
 	moduli := []byte("20220414072358 2 6 100 2047 5 F19C2D09AD49978F8A0C1B84168A4011A26F9CD516815934764A319FDC5975FA514AAF11B747D8CA6B3919532BEFB68FA118079473895674F3770F71FBB742F176883841EB3DE679BEF53C6AFE437A662F228B03C1E34B5A0D3909F608CEAA16C1F8131DE11E67878EFD918A89205E5E4DE323054010CA4711F25D466BB7727A016DD3F9F53BDBCE093055A4F2497ADEFB5A2500F9C5C3B0BCD88C6489F4C1CBC7CFB67BA6EABA0195794E4188CE9060F431041AD52FB9BAC4DF7FA536F585FBE67746CD57BFAD67567E9706C24D95C49BE95B759657C6BB5151E2AEA32F4CD557C40298A5C402101520EE8AAB8DFEED6FFC11AAF8036D6345923CFB5D1B922F")
 	moduliFile := filepath.Join(os.TempDir(), "moduli")
-	err = os.WriteFile(moduliFile, moduli, 0600)
+	err := os.WriteFile(moduliFile, moduli, 0600)
 	assert.NoError(t, err)
 	c.Moduli = []string{moduliFile}
-	err = c.loadModuli(configDir)
-	assert.NoError(t, err)
+	c.loadModuli(configDir)
 	assert.Contains(t, supportedKexAlgos, dhGEXSha1)
 	assert.Contains(t, supportedKexAlgos, dhGEXSha256)
+	assert.NotContains(t, preferredKexAlgos, dhGEXSha1)
+	assert.Contains(t, preferredKexAlgos, dhGEXSha256)
 	assert.Len(t, supportedKexAlgos, 12)
 	err = os.Remove(moduliFile)
 	assert.NoError(t, err)

+ 94 - 20
internal/sftpd/server.go

@@ -47,6 +47,8 @@ const (
 	defaultPrivateECDSAKeyName   = "id_ecdsa"
 	defaultPrivateEd25519KeyName = "id_ed25519"
 	sourceAddressCriticalOption  = "source-address"
+	kexDHGroupExchangeSHA1       = "diffie-hellman-group-exchange-sha1"
+	kexDHGroupExchangeSHA256     = "diffie-hellman-group-exchange-sha256"
 )
 
 var (
@@ -75,6 +77,11 @@ var (
 		"diffie-hellman-group18-sha512", "diffie-hellman-group14-sha1",
 		"diffie-hellman-group1-sha1",
 	}
+	preferredKexAlgos = []string{
+		"curve25519-sha256", "curve25519-sha256@libssh.org",
+		"ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521",
+		"diffie-hellman-group14-sha256",
+	}
 	supportedCiphers = []string{
 		"aes128-gcm@openssh.com", "aes256-gcm@openssh.com",
 		"chacha20-poly1305@openssh.com",
@@ -83,11 +90,19 @@ var (
 		"3des-cbc",
 		"arcfour", "arcfour128", "arcfour256",
 	}
+	preferredCiphers = []string{
+		"aes128-gcm@openssh.com", "aes256-gcm@openssh.com",
+		"chacha20-poly1305@openssh.com",
+		"aes128-ctr", "aes192-ctr", "aes256-ctr",
+	}
 	supportedMACs = []string{
 		"hmac-sha2-256-etm@openssh.com", "hmac-sha2-256",
 		"hmac-sha2-512-etm@openssh.com", "hmac-sha2-512",
 		"hmac-sha1", "hmac-sha1-96",
 	}
+	preferredMACs = []string{
+		"hmac-sha2-256-etm@openssh.com", "hmac-sha2-256",
+	}
 
 	revokedCertManager = revokedCertificates{
 		certs: map[string]bool{},
@@ -145,7 +160,7 @@ type Configuration struct {
 	HostKeyAlgorithms []string `json:"host_key_algorithms" mapstructure:"host_key_algorithms"`
 	// Diffie-Hellman moduli files.
 	// Each moduli file can be defined as a path relative to the configuration directory or an absolute one.
-	// If set, "diffie-hellman-group-exchange-sha256" and "diffie-hellman-group-exchange-sha1" KEX algorithms
+	// If set and valid, "diffie-hellman-group-exchange-sha256" and "diffie-hellman-group-exchange-sha1" KEX algorithms
 	// will be available, `diffie-hellman-group-exchange-sha256` will be enabled by default if you
 	// don't explicitly set KEXs
 	Moduli []string `json:"moduli" mapstructure:"moduli"`
@@ -272,7 +287,7 @@ func (c *Configuration) getServerConfig() *ssh.ServerConfig {
 			}
 			return nextMethods
 		},
-		ServerVersion: fmt.Sprintf("SSH-2.0-%v", c.Banner),
+		ServerVersion: fmt.Sprintf("SSH-2.0-%s", c.Banner),
 	}
 
 	if c.PasswordAuthentication {
@@ -306,9 +321,46 @@ func (c *Configuration) updateSupportedAuthentications() {
 	}
 }
 
+func (c *Configuration) loadFromProvider() error {
+	configs, err := dataprovider.GetConfigs()
+	if err != nil {
+		return fmt.Errorf("unable to load config from provider: %w", err)
+	}
+	configs.SetNilsToEmpty()
+	if len(configs.SFTPD.HostKeyAlgos) > 0 {
+		if len(c.HostKeyAlgorithms) == 0 {
+			c.HostKeyAlgorithms = preferredHostKeyAlgos
+		}
+		c.HostKeyAlgorithms = append(c.HostKeyAlgorithms, configs.SFTPD.HostKeyAlgos...)
+	}
+	c.Moduli = append(c.Moduli, configs.SFTPD.Moduli...)
+	if len(configs.SFTPD.KexAlgorithms) > 0 {
+		if len(c.KexAlgorithms) == 0 {
+			c.KexAlgorithms = preferredKexAlgos
+		}
+		c.KexAlgorithms = append(c.KexAlgorithms, configs.SFTPD.KexAlgorithms...)
+	}
+	if len(configs.SFTPD.Ciphers) > 0 {
+		if len(c.Ciphers) == 0 {
+			c.Ciphers = preferredCiphers
+		}
+		c.Ciphers = append(c.Ciphers, configs.SFTPD.Ciphers...)
+	}
+	if len(configs.SFTPD.MACs) > 0 {
+		if len(c.MACs) == 0 {
+			c.MACs = preferredMACs
+		}
+		c.MACs = append(c.MACs, configs.SFTPD.MACs...)
+	}
+	return nil
+}
+
 // Initialize the SFTP server and add a persistent listener to handle inbound SFTP connections.
 func (c *Configuration) Initialize(configDir string) error {
-	serviceStatus.Authentications = nil
+	if err := c.loadFromProvider(); err != nil {
+		return fmt.Errorf("unable to load configs from provider: %w", err)
+	}
+	serviceStatus = ServiceStatus{}
 	serverConfig := c.getServerConfig()
 
 	if !c.ShouldBind() {
@@ -324,9 +376,7 @@ func (c *Configuration) Initialize(configDir string) error {
 		return err
 	}
 
-	if err := c.loadModuli(configDir); err != nil {
-		return err
-	}
+	c.loadModuli(configDir)
 
 	sftp.SetSFTPExtensions(sftpExtensions...) //nolint:errcheck // we configure valid SFTP Extensions so we cannot get an error
 
@@ -379,7 +429,7 @@ func (c *Configuration) Initialize(configDir string) error {
 }
 
 func (c *Configuration) serve(listener net.Listener, serverConfig *ssh.ServerConfig) error {
-	logger.Info(logSender, "", "server listener registered, address: %v", listener.Addr().String())
+	logger.Info(logSender, "", "server listener registered, address: %s", listener.Addr().String())
 	var tempDelay time.Duration // how long to sleep on accept failure
 
 	for {
@@ -416,37 +466,52 @@ func (c *Configuration) configureSecurityOptions(serverConfig *ssh.ServerConfig)
 	}
 	for _, hostKeyAlgo := range c.HostKeyAlgorithms {
 		if !util.Contains(supportedHostKeyAlgos, hostKeyAlgo) {
-			return fmt.Errorf("unsupported host key algorithm %#v", hostKeyAlgo)
+			return fmt.Errorf("unsupported host key algorithm %q", hostKeyAlgo)
 		}
 	}
 	serverConfig.HostKeyAlgorithms = c.HostKeyAlgorithms
+	serviceStatus.HostKeyAlgos = c.HostKeyAlgorithms
 
 	if len(c.KexAlgorithms) > 0 {
+		hasDHGroupKEX := util.Contains(supportedKexAlgos, kexDHGroupExchangeSHA256)
+		if !hasDHGroupKEX {
+			c.KexAlgorithms = util.Remove(c.KexAlgorithms, kexDHGroupExchangeSHA1)
+			c.KexAlgorithms = util.Remove(c.KexAlgorithms, kexDHGroupExchangeSHA256)
+		}
 		c.KexAlgorithms = util.RemoveDuplicates(c.KexAlgorithms, true)
 		for _, kex := range c.KexAlgorithms {
 			if !util.Contains(supportedKexAlgos, kex) {
-				return fmt.Errorf("unsupported key-exchange algorithm %#v", kex)
+				return fmt.Errorf("unsupported key-exchange algorithm %q", kex)
 			}
 		}
 		serverConfig.KeyExchanges = c.KexAlgorithms
+		serviceStatus.KexAlgorithms = c.KexAlgorithms
+	} else {
+		serviceStatus.KexAlgorithms = preferredKexAlgos
 	}
 	if len(c.Ciphers) > 0 {
 		c.Ciphers = util.RemoveDuplicates(c.Ciphers, true)
 		for _, cipher := range c.Ciphers {
 			if !util.Contains(supportedCiphers, cipher) {
-				return fmt.Errorf("unsupported cipher %#v", cipher)
+				return fmt.Errorf("unsupported cipher %q", cipher)
 			}
 		}
 		serverConfig.Ciphers = c.Ciphers
+		serviceStatus.Ciphers = c.Ciphers
+	} else {
+		serviceStatus.Ciphers = preferredCiphers
 	}
 	if len(c.MACs) > 0 {
 		c.MACs = util.RemoveDuplicates(c.MACs, true)
 		for _, mac := range c.MACs {
 			if !util.Contains(supportedMACs, mac) {
-				return fmt.Errorf("unsupported MAC algorithm %#v", mac)
+				return fmt.Errorf("unsupported MAC algorithm %q", mac)
 			}
 		}
 		serverConfig.MACs = c.MACs
+		serviceStatus.MACs = c.MACs
+	} else {
+		serviceStatus.MACs = preferredMACs
 	}
 	return nil
 }
@@ -875,9 +940,11 @@ func (c *Configuration) checkHostKeyAutoGeneration(configDir string) error {
 	return nil
 }
 
-func (c *Configuration) loadModuli(configDir string) error {
-	supportedKexAlgos = util.Remove(supportedKexAlgos, "diffie-hellman-group-exchange-sha1")
-	supportedKexAlgos = util.Remove(supportedKexAlgos, "diffie-hellman-group-exchange-sha256")
+func (c *Configuration) loadModuli(configDir string) {
+	supportedKexAlgos = util.Remove(supportedKexAlgos, kexDHGroupExchangeSHA1)
+	supportedKexAlgos = util.Remove(supportedKexAlgos, kexDHGroupExchangeSHA256)
+	preferredKexAlgos = util.Remove(preferredKexAlgos, kexDHGroupExchangeSHA256)
+	c.Moduli = util.RemoveDuplicates(c.Moduli, false)
 	for _, m := range c.Moduli {
 		m = strings.TrimSpace(m)
 		if !util.IsFileInputValid(m) {
@@ -890,12 +957,19 @@ func (c *Configuration) loadModuli(configDir string) error {
 		}
 		logger.Info(logSender, "", "loading moduli file %q", m)
 		if err := ssh.ParseModuli(m); err != nil {
-			return err
+			logger.Warn(logSender, "", "ignoring moduli file %q, error: %v", m, err)
+			continue
+		}
+		if !util.Contains(supportedKexAlgos, kexDHGroupExchangeSHA1) {
+			supportedKexAlgos = append(supportedKexAlgos, kexDHGroupExchangeSHA1)
+		}
+		if !util.Contains(supportedKexAlgos, kexDHGroupExchangeSHA256) {
+			supportedKexAlgos = append(supportedKexAlgos, kexDHGroupExchangeSHA256)
+		}
+		if !util.Contains(preferredKexAlgos, kexDHGroupExchangeSHA256) {
+			preferredKexAlgos = append(preferredKexAlgos, kexDHGroupExchangeSHA256)
 		}
-		supportedKexAlgos = append(supportedKexAlgos, "diffie-hellman-group-exchange-sha1",
-			"diffie-hellman-group-exchange-sha256")
 	}
-	return nil
 }
 
 // If no host keys are defined we try to use or generate the default ones.
@@ -934,7 +1008,7 @@ func (c *Configuration) checkAndLoadHostKeys(configDir string, serverConfig *ssh
 			Fingerprint: ssh.FingerprintSHA256(private.PublicKey()),
 		}
 		serviceStatus.HostKeys = append(serviceStatus.HostKeys, k)
-		logger.Info(logSender, "", "Host key %#v loaded, type %#v, fingerprint %#v", hostKey,
+		logger.Info(logSender, "", "Host key %q loaded, type %q, fingerprint %q", hostKey,
 			private.PublicKey().Type(), k.Fingerprint)
 
 		// Add private key to the server configuration.
@@ -943,7 +1017,7 @@ func (c *Configuration) checkAndLoadHostKeys(configDir string, serverConfig *ssh
 			signer, err := ssh.NewCertSigner(cert, private)
 			if err == nil {
 				serverConfig.AddHostKey(signer)
-				logger.Info(logSender, "", "Host certificate loaded for host key %#v, fingerprint %#v",
+				logger.Info(logSender, "", "Host certificate loaded for host key %q, fingerprint %q",
 					hostKey, ssh.FingerprintSHA256(signer.PublicKey()))
 			}
 		}

+ 24 - 0
internal/sftpd/sftpd.go

@@ -57,6 +57,10 @@ type ServiceStatus struct {
 	SSHCommands     []string  `json:"ssh_commands"`
 	HostKeys        []HostKey `json:"host_keys"`
 	Authentications []string  `json:"authentications"`
+	HostKeyAlgos    []string  `json:"host_key_algos"`
+	MACs            []string  `json:"macs"`
+	KexAlgorithms   []string  `json:"kex_algorithms"`
+	Ciphers         []string  `json:"ciphers"`
 }
 
 // GetSSHCommandsAsString returns enabled SSH commands as comma separated string
@@ -69,6 +73,26 @@ func (s *ServiceStatus) GetSupportedAuthsAsString() string {
 	return strings.Join(s.Authentications, ", ")
 }
 
+// GetHostKeyAlgosAsString returns the enabled host keys algorithms as comma separated string
+func (s *ServiceStatus) GetHostKeyAlgosAsString() string {
+	return strings.Join(s.HostKeyAlgos, ", ")
+}
+
+// GetMACsAsString returns the enabled MAC algorithms as comma separated string
+func (s *ServiceStatus) GetMACsAsString() string {
+	return strings.Join(s.MACs, ", ")
+}
+
+// GetKEXsAsString returns the enabled KEX algorithms as comma separated string
+func (s *ServiceStatus) GetKEXsAsString() string {
+	return strings.Join(s.KexAlgorithms, ", ")
+}
+
+// GetCiphersAsString returns the enabled ciphers as comma separated string
+func (s *ServiceStatus) GetCiphersAsString() string {
+	return strings.Join(s.Ciphers, ", ")
+}
+
 // GetStatus returns the server status
 func GetStatus() ServiceStatus {
 	return serviceStatus

+ 24 - 7
internal/sftpd/sftpd_test.go

@@ -219,6 +219,12 @@ func TestMain(m *testing.M) {
 		os.Exit(1)
 	}
 
+	err = dataprovider.UpdateConfigs(nil, "", "", "")
+	if err != nil {
+		logger.ErrorToConsole("error resetting configs: %v", err)
+		os.Exit(1)
+	}
+
 	err = common.Initialize(commonConf, 0)
 	if err != nil {
 		logger.WarnToConsole("error initializing common: %v", err)
@@ -401,12 +407,6 @@ func TestInitialization(t *testing.T) {
 	assert.True(t, sftpdConf.Bindings[0].HasProxy())
 	err = sftpdConf.Initialize(configDir)
 	assert.Error(t, err)
-	sftpdConf.Moduli = []string{"missing moduli file"}
-	err = sftpdConf.Initialize(configDir)
-	if assert.Error(t, err) {
-		assert.Contains(t, err.Error(), "unable to open moduli file")
-	}
-	sftpdConf.Moduli = nil
 	sftpdConf.HostKeys = []string{"missing key"}
 	err = sftpdConf.Initialize(configDir)
 	assert.Error(t, err)
@@ -429,11 +429,13 @@ func TestInitialization(t *testing.T) {
 	if assert.Error(t, err) {
 		assert.Contains(t, err.Error(), "unsupported MAC algorithm")
 	}
-	sftpdConf.KexAlgorithms = []string{"not a KEX"}
+	sftpdConf.MACs = nil
+	sftpdConf.KexAlgorithms = []string{"diffie-hellman-group-exchange-sha1", "not a KEX"}
 	err = sftpdConf.Initialize(configDir)
 	if assert.Error(t, err) {
 		assert.Contains(t, err.Error(), "unsupported key-exchange algorithm")
 	}
+	sftpdConf.KexAlgorithms = nil
 	sftpdConf.HostKeyAlgorithms = []string{"not a host key algo"}
 	err = sftpdConf.Initialize(configDir)
 	if assert.Error(t, err) {
@@ -495,6 +497,17 @@ func TestInitialization(t *testing.T) {
 	assert.NoError(t, err)
 	err = sftpdConf.Initialize(configDir)
 	assert.Error(t, err)
+	err = dataprovider.Close()
+	assert.NoError(t, err)
+	err = sftpdConf.Initialize(configDir)
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "unable to load configs from provider")
+	}
+	err = config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	providerConf := config.GetProviderConf()
+	err = dataprovider.Initialize(providerConf, configDir, true)
+	assert.NoError(t, err)
 }
 
 func TestBasicSFTPHandling(t *testing.T) {
@@ -562,6 +575,10 @@ func TestBasicSFTPHandling(t *testing.T) {
 	assert.NotEmpty(t, sshCommands)
 	sshAuths := status.GetSupportedAuthsAsString()
 	assert.NotEmpty(t, sshAuths)
+	assert.NotEmpty(t, status.GetHostKeyAlgosAsString())
+	assert.NotEmpty(t, status.GetMACsAsString())
+	assert.NotEmpty(t, status.GetKEXsAsString())
+	assert.NotEmpty(t, status.GetCiphersAsString())
 }
 
 func TestBasicSFTPFsHandling(t *testing.T) {

+ 202 - 60
internal/smtp/smtp.go

@@ -22,10 +22,12 @@ import (
 	"fmt"
 	"html/template"
 	"path/filepath"
+	"sync"
 	"time"
 
 	"github.com/wneessen/go-mail"
 
+	"github.com/drakkan/sftpgo/v2/internal/dataprovider"
 	"github.com/drakkan/sftpgo/v2/internal/logger"
 	"github.com/drakkan/sftpgo/v2/internal/util"
 )
@@ -47,16 +49,93 @@ const (
 	templateEmailDir           = "email"
 	templatePasswordReset      = "reset-password.html"
 	templatePasswordExpiration = "password-expiration.html"
+	dialTimeout                = 10 * time.Second
 )
 
 var (
-	config         *Config
+	config         = &activeConfig{}
+	initialConfig  *Config
 	emailTemplates = make(map[string]*template.Template)
 )
 
+type activeConfig struct {
+	sync.RWMutex
+	config *Config
+}
+
+func (c *activeConfig) isEnabled() bool {
+	c.RLock()
+	defer c.RUnlock()
+
+	return c.config != nil && c.config.Host != ""
+}
+
+func (c *activeConfig) Set(cfg *dataprovider.SMTPConfigs) {
+	var config *Config
+	if cfg != nil {
+		config = &Config{
+			Host:       cfg.Host,
+			Port:       cfg.Port,
+			From:       cfg.From,
+			User:       cfg.User,
+			Password:   cfg.Password.GetPayload(),
+			AuthType:   cfg.AuthType,
+			Encryption: cfg.Encryption,
+			Domain:     cfg.Domain,
+		}
+	}
+
+	c.Lock()
+	defer c.Unlock()
+
+	if config != nil && config.Host != "" {
+		if c.config != nil && c.config.isEqual(config) {
+			return
+		}
+		c.config = config
+		logger.Info(logSender, "", "activated new config, server %s:%d", c.config.Host, c.config.Port)
+	} else {
+		logger.Debug(logSender, "", "activating initial config")
+		c.config = initialConfig
+		if c.config == nil || c.config.Host == "" {
+			logger.Debug(logSender, "", "configuration disabled, email capabilities will not be available")
+		}
+	}
+}
+
+func (c *activeConfig) getSMTPClientAndMsg(to []string, subject, body string, contentType EmailContentType,
+	attachments ...*mail.File,
+) (*mail.Client, *mail.Msg, error) {
+	c.RLock()
+	defer c.RUnlock()
+
+	if c.config == nil || c.config.Host == "" {
+		return nil, nil, errors.New("smtp: not configured")
+	}
+
+	return c.config.getSMTPClientAndMsg(to, subject, body, contentType, attachments...)
+}
+
+func (c *activeConfig) sendEmail(to []string, subject, body string, contentType EmailContentType, attachments ...*mail.File) error {
+	client, msg, err := c.getSMTPClientAndMsg(to, subject, body, contentType, attachments...)
+	if err != nil {
+		return err
+	}
+
+	ctx, cancelFn := context.WithTimeout(context.Background(), dialTimeout)
+	defer cancelFn()
+
+	return client.DialAndSendWithContext(ctx, msg)
+}
+
 // IsEnabled returns true if an SMTP server is configured
 func IsEnabled() bool {
-	return config != nil
+	return config.isEnabled()
+}
+
+// Activate sets the specified config as active
+func Activate(c *dataprovider.SMTPConfigs) {
+	config.Set(c)
 }
 
 // Config defines the SMTP configuration to use to send emails
@@ -89,34 +168,65 @@ type Config struct {
 	TemplatesPath string `json:"templates_path" mapstructure:"templates_path"`
 }
 
+func (c *Config) isEqual(other *Config) bool {
+	if c.Host != other.Host {
+		return false
+	}
+	if c.Port != other.Port {
+		return false
+	}
+	if c.From != other.From {
+		return false
+	}
+	if c.User != other.User {
+		return false
+	}
+	if c.Password != other.Password {
+		return false
+	}
+	if c.AuthType != other.AuthType {
+		return false
+	}
+	if c.Encryption != other.Encryption {
+		return false
+	}
+	if c.Domain != other.Domain {
+		return false
+	}
+	return true
+}
+
 // Initialize initialized and validates the SMTP configuration
 func (c *Config) Initialize(configDir string) error {
-	config = nil
+	if c.TemplatesPath == "" {
+		logger.Debug(logSender, "", "templates path empty, using default")
+		c.TemplatesPath = "templates"
+	}
+	templatesPath := util.FindSharedDataPath(c.TemplatesPath, configDir)
+	if templatesPath == "" {
+		return fmt.Errorf("smtp: invalid templates path %q", templatesPath)
+	}
+	loadTemplates(filepath.Join(templatesPath, templateEmailDir))
 	if c.Host == "" {
-		logger.Debug(logSender, "", "configuration disabled, email capabilities will not be available")
-		return nil
+		return loadConfigFromProvider()
 	}
 	if c.Port <= 0 || c.Port > 65535 {
-		return fmt.Errorf("smtp: invalid port %v", c.Port)
+		return fmt.Errorf("smtp: invalid port %d", c.Port)
 	}
 	if c.AuthType < 0 || c.AuthType > 2 {
-		return fmt.Errorf("smtp: invalid auth type %v", c.AuthType)
+		return fmt.Errorf("smtp: invalid auth type %d", c.AuthType)
 	}
 	if c.Encryption < 0 || c.Encryption > 2 {
-		return fmt.Errorf("smtp: invalid encryption %v", c.Encryption)
+		return fmt.Errorf("smtp: invalid encryption %d", c.Encryption)
 	}
 	if c.From == "" && c.User == "" {
 		return fmt.Errorf(`smtp: from address and user cannot both be empty`)
 	}
-	templatesPath := util.FindSharedDataPath(c.TemplatesPath, configDir)
-	if templatesPath == "" {
-		return fmt.Errorf("smtp: invalid templates path %#v", templatesPath)
-	}
-	loadTemplates(filepath.Join(templatesPath, templateEmailDir))
-	config = c
+	initialConfig = c
+	config.Set(nil)
 	logger.Debug(logSender, "", "configuration successfully initialized, host: %q, port: %d, username: %q, auth: %d, encryption: %d, helo: %q",
-		config.Host, config.Port, config.User, config.AuthType, config.Encryption, config.Domain)
-	return nil
+		c.Host, c.Port, c.User, c.AuthType, c.Encryption, c.Domain)
+	return loadConfigFromProvider()
 }
 
 func (c *Config) getMailClientOptions() []mail.Option {
@@ -130,14 +240,14 @@ func (c *Config) getMailClientOptions() []mail.Option {
 	default:
 		options = append(options, mail.WithTLSPolicy(mail.NoTLS))
 	}
-	if config.User != "" {
-		options = append(options, mail.WithUsername(config.User))
+	if c.User != "" {
+		options = append(options, mail.WithUsername(c.User))
 	}
-	if config.Password != "" {
-		options = append(options, mail.WithPassword(config.Password))
+	if c.Password != "" {
+		options = append(options, mail.WithPassword(c.Password))
 	}
-	if config.User != "" || config.Password != "" {
-		switch config.AuthType {
+	if c.User != "" || c.Password != "" {
+		switch c.AuthType {
 		case 1:
 			options = append(options, mail.WithSMTPAuth(mail.SMTPAuthLogin))
 		case 2:
@@ -146,14 +256,63 @@ func (c *Config) getMailClientOptions() []mail.Option {
 			options = append(options, mail.WithSMTPAuth(mail.SMTPAuthPlain))
 		}
 	}
-	if config.Domain != "" {
-		options = append(options, mail.WithHELO(config.Domain))
+	if c.Domain != "" {
+		options = append(options, mail.WithHELO(c.Domain))
 	}
 	return options
 }
 
+func (c *Config) getSMTPClientAndMsg(to []string, subject, body string, contentType EmailContentType,
+	attachments ...*mail.File) (*mail.Client, *mail.Msg, error) {
+	msg := mail.NewMsg()
+
+	var from string
+	if c.From != "" {
+		from = c.From
+	} else {
+		from = c.User
+	}
+	if err := msg.From(from); err != nil {
+		return nil, nil, fmt.Errorf("invalid from address: %w", err)
+	}
+	if err := msg.To(to...); err != nil {
+		return nil, nil, err
+	}
+	msg.Subject(subject)
+	msg.SetDate()
+	msg.SetMessageID()
+	msg.SetAttachements(attachments)
+
+	switch contentType {
+	case EmailContentTypeTextPlain:
+		msg.SetBodyString(mail.TypeTextPlain, body)
+	case EmailContentTypeTextHTML:
+		msg.SetBodyString(mail.TypeTextHTML, body)
+	default:
+		return nil, nil, fmt.Errorf("smtp: unsupported body content type %v", contentType)
+	}
+
+	client, err := mail.NewClient(c.Host, c.getMailClientOptions()...)
+	if err != nil {
+		return nil, nil, fmt.Errorf("unable to create mail client: %w", err)
+	}
+	return client, msg, nil
+}
+
+// SendEmail tries to send an email using the specified parameters
+func (c *Config) SendEmail(to []string, subject, body string, contentType EmailContentType, attachments ...*mail.File) error {
+	client, msg, err := c.getSMTPClientAndMsg(to, subject, body, contentType, attachments...)
+	if err != nil {
+		return err
+	}
+	ctx, cancelFn := context.WithTimeout(context.Background(), dialTimeout)
+	defer cancelFn()
+
+	return client.DialAndSendWithContext(ctx, msg)
+}
+
 func loadTemplates(templatesPath string) {
-	logger.Debug(logSender, "", "loading templates from %#v", templatesPath)
+	logger.Debug(logSender, "", "loading templates from %q", templatesPath)
 
 	passwordResetPath := filepath.Join(templatesPath, templatePasswordReset)
 	pwdResetTmpl := util.LoadTemplate(nil, passwordResetPath)
@@ -182,43 +341,26 @@ func RenderPasswordExpirationTemplate(buf *bytes.Buffer, data any) error {
 
 // SendEmail tries to send an email using the specified parameters.
 func SendEmail(to []string, subject, body string, contentType EmailContentType, attachments ...*mail.File) error {
-	if !IsEnabled() {
-		return errors.New("smtp: not configured")
-	}
-	m := mail.NewMsg()
-
-	var from string
-	if config.From != "" {
-		from = config.From
-	} else {
-		from = config.User
-	}
-	if err := m.From(from); err != nil {
-		return fmt.Errorf("invalid from address: %w", err)
-	}
-	if err := m.To(to...); err != nil {
-		return err
-	}
-	m.Subject(subject)
-	m.SetDate()
-	m.SetMessageID()
-	m.SetAttachements(attachments)
+	return config.sendEmail(to, subject, body, contentType, attachments...)
+}
 
-	switch contentType {
-	case EmailContentTypeTextPlain:
-		m.SetBodyString(mail.TypeTextPlain, body)
-	case EmailContentTypeTextHTML:
-		m.SetBodyString(mail.TypeTextHTML, body)
-	default:
-		return fmt.Errorf("smtp: unsupported body content type %v", contentType)
-	}
+// ReloadProviderConf reloads the configuration from the provider
+// and apply it if different from the active one
+func ReloadProviderConf() {
+	loadConfigFromProvider() //nolint:errcheck
+}
 
-	c, err := mail.NewClient(config.Host, config.getMailClientOptions()...)
+func loadConfigFromProvider() error {
+	configs, err := dataprovider.GetConfigs()
 	if err != nil {
-		return fmt.Errorf("unable to create mail client: %w", err)
+		logger.Error(logSender, "", "unable to load config from provider: %v", err)
+		return fmt.Errorf("smtp: unable to load config from provider: %w", err)
 	}
-	ctx, cancelFn := context.WithTimeout(context.Background(), 10*time.Second)
-	defer cancelFn()
-
-	return c.DialAndSendWithContext(ctx, m)
+	configs.SetNilsToEmpty()
+	if err := configs.SMTP.Password.TryDecrypt(); err != nil {
+		logger.Error(logSender, "", "unable to decrypt password: %v", err)
+		return fmt.Errorf("smtp: unable to decrypt password: %w", err)
+	}
+	config.Set(configs.SMTP)
+	return nil
 }

+ 16 - 0
openapi/openapi.yaml

@@ -6263,6 +6263,22 @@ components:
           type: array
           items:
             $ref: '#/components/schemas/SSHAuthentications'
+        host_key_algos:
+          type: array
+          items:
+            type: string
+        macs:
+          type: array
+          items:
+            type: string
+        kex_algorithms:
+          type: array
+          items:
+            type: string
+        ciphers:
+          type: array
+          items:
+            type: string
     FTPPassivePortRange:
       type: object
       properties:

+ 24 - 22
templates/webadmin/base.html

@@ -125,7 +125,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
                 <div id="collapseIPManager" class="collapse {{if .IsIPManagerPage}}show{{end}}" aria-labelledby="headingIPManager" data-parent="#accordionSidebar">
                     <div class="bg-white py-2 collapse-inner rounded">
                         {{ if .LoggedAdmin.HasPermission "manage_ip_lists"}}
-                        <a class="collapse-item {{if eq .CurrentURL .IPListsURL}}active{{end}}"  href="{{.IPListsURL}}">{{.IPListsTitle}}</a>
+                        <a class="collapse-item {{if eq .CurrentURL .IPListsURL}}active{{end}}" href="{{.IPListsURL}}">{{.IPListsTitle}}</a>
                         {{end}}
                         {{ if and .HasDefender (.LoggedAdmin.HasPermission "view_defender")}}
                         <a class="collapse-item {{if eq .CurrentURL .DefenderURL}}active{{end}}" href="{{.DefenderURL}}">{{.DefenderTitle}}</a>
@@ -151,27 +151,29 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
             </li>
             {{end}}
 
-            {{ if and .HasSearcher (.LoggedAdmin.HasPermission "view_events")}}
-            <li class="nav-item {{if eq .CurrentURL .EventsURL}}active{{end}}">
-                <a class="nav-link" href="{{.EventsURL}}">
-                    <i class="fas fa-clipboard-list"></i>
-                    <span>{{.EventsTitle}}</span></a>
-            </li>
-            {{end}}
-
-            {{ if .LoggedAdmin.HasPermission "manage_system"}}
-            <li class="nav-item {{if eq .CurrentURL .MaintenanceURL}}active{{end}}">
-                <a class="nav-link" href="{{.MaintenanceURL}}">
-                    <i class="fas fa-wrench"></i>
-                    <span>{{.MaintenanceTitle}}</span></a>
-            </li>
-            {{end}}
-
-            {{ if .LoggedAdmin.HasPermission "view_status"}}
-            <li class="nav-item {{if eq .CurrentURL .StatusURL}}active{{end}}">
-                <a class="nav-link" href="{{.StatusURL}}">
-                    <i class="fas fa-info-circle"></i>
-                    <span>{{.StatusTitle}}</span></a>
+            {{ if or (.LoggedAdmin.HasPermission "manage_system") (.LoggedAdmin.HasPermission "view_status") (and .HasSearcher (.LoggedAdmin.HasPermission "view_events"))}}
+            <li class="nav-item {{if .IsServerManagerPage}}active{{end}}">
+                <a class="nav-link {{if not .IsServerManagerPage}}collapsed{{end}}" href="#" data-toggle="collapse" data-target="#collapseServerManager"
+                    aria-expanded="true" aria-controls="collapseServerManager">
+                    <i class="fas fa-tools"></i>
+                    <span>Server Manager</span>
+                </a>
+                <div id="collapseServerManager" class="collapse {{if .IsServerManagerPage}}show{{end}}" aria-labelledby="headingServerManager" data-parent="#accordionSidebar">
+                    <div class="bg-white py-2 collapse-inner rounded">
+                        {{ if .LoggedAdmin.HasPermission "manage_system"}}
+                        <a class="collapse-item {{if eq .CurrentURL .ConfigsURL}}active{{end}}" href="{{.ConfigsURL}}">{{.ConfigsTitle}}</a>
+                        {{end}}
+                        {{ if and .HasSearcher (.LoggedAdmin.HasPermission "view_events")}}
+                        <a class="collapse-item {{if eq .CurrentURL .EventsURL}}active{{end}}" href="{{.EventsURL}}">{{.EventsTitle}}</a>
+                        {{end}}
+                        {{ if .LoggedAdmin.HasPermission "manage_system"}}
+                        <a class="collapse-item {{if eq .CurrentURL .MaintenanceURL}}active{{end}}" href="{{.MaintenanceURL}}">{{.MaintenanceTitle}}</a>
+                        {{end}}
+                        {{ if .LoggedAdmin.HasPermission "view_status"}}
+                        <a class="collapse-item {{if eq .CurrentURL .StatusURL}}active{{end}}" href="{{.StatusURL}}">{{.StatusTitle}}</a>
+                        {{end}}
+                    </div>
+                </div>
             </li>
             {{end}}
 

+ 327 - 0
templates/webadmin/configs.html

@@ -0,0 +1,327 @@
+<!--
+Copyright (C) 2019-2023 Nicola Murino
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published
+by the Free Software Foundation, version 3.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+-->
+{{template "base" .}}
+
+{{define "title"}}{{.Title}}{{end}}
+
+{{define "extra_css"}}
+<link href="{{.StaticURL}}/vendor/bootstrap-select/css/bootstrap-select.min.css" rel="stylesheet">
+{{end}}
+
+{{define "page_body"}}
+<div class="card shadow mb-4">
+    <div class="card-header py-3">
+        <h6 class="m-0 font-weight-bold text-primary">{{.Title}}</h6>
+    </div>
+    <div class="card-body">
+        {{if .Error}}
+        <div class="alert alert-warning alert-dismissible fade show" role="alert">
+            {{.Error}}
+            <button type="button" class="close" data-dismiss="alert" aria-label="Close">
+                <span aria-hidden="true">&times;</span>
+            </button>
+        </div>
+        {{end}}
+        <form id="configs_form" action="{{.CurrentURL}}" method="POST" autocomplete="off">
+            <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
+            <div class="accordion" id="accordionConfigs">
+                <div class="card">
+                    <div class="card-header" id="headingSFTPD">
+                        <h2 class="mb-0">
+                            <button class="btn btn-link btn-block text-left" type="button" data-toggle="collapse"
+                                data-target="#collapseSFTPD" aria-expanded="true" aria-controls="collapseSFTPD">
+                                <h6 class="m-0 font-weight-bold text-primary">SFTP</h6>
+                            </button>
+                        </h2>
+                    </div>
+
+                    <div id="collapseSFTPD" class="collapse {{if eq .ConfigSection 1}}show{{end}}" aria-labelledby="headingSFTPD" data-parent="#accordionConfigs">
+                        <div class="card-body">
+                            <div id="configs-sftp-info" class="card mb-3 border-left-info">
+                                <div class="card-body">Here you can enable algorithms disabled by default. You don't need to set values already defined using env vars or config file. A service restart is required to apply changes.</div>
+                            </div>
+
+                            <div class="form-group row">
+                                <label for="idHostKeyAlgos" class="col-sm-2 col-form-label">Host Key Algos</label>
+                                <div class="col-sm-10">
+                                    <select class="form-control selectpicker" id="idHostKeyAlgos" name="sftp_host_key_algos" multiple>
+                                        {{range $val := .Configs.SFTPD.GetSupportedHostKeyAlgos}}
+                                        <option value="{{$val}}" {{range $algo :=$.Configs.SFTPD.HostKeyAlgos }}{{if eq $algo $val}}selected{{end}}{{end}}>{{$val}}</option>
+                                        {{end}}
+                                    </select>
+                                </div>
+                            </div>
+
+                            <div class="form-group row">
+                                <label for="idModuli" class="col-sm-2 col-form-label">Moduli</label>
+                                <div class="col-sm-10">
+                                    <textarea class="form-control" id="idModuli" name="sftp_moduli" rows="2" placeholder=""
+                                        aria-describedby="moduliHelpBlock">{{.Configs.SFTPD.GetModuliAsString}}</textarea>
+                                    <small id="moduliHelpBlock" class="form-text text-muted">
+                                        Comma separated moduli file paths. Invalid/missing paths are silently ignored. Moduli are required to enable Diffie-Helmann Group Exchange KEX algos
+                                    </small>
+                                </div>
+                            </div>
+
+                            <div class="form-group row">
+                                <label for="idKEXAlgos" class="col-sm-2 col-form-label">KEX Algos</label>
+                                <div class="col-sm-10">
+                                    <select class="form-control selectpicker" id="idKEXAlgos" name="sftp_kex_algos" multiple>
+                                        {{range $val := .Configs.SFTPD.GetSupportedKEXAlgos}}
+                                        <option value="{{$val}}" {{range $algo :=$.Configs.SFTPD.KexAlgorithms }}{{if eq $algo $val}}selected{{end}}{{end}}>{{$val}}</option>
+                                        {{end}}
+                                    </select>
+                                </div>
+                            </div>
+
+                            <div class="form-group row">
+                                <label for="idCiphers" class="col-sm-2 col-form-label">Ciphers</label>
+                                <div class="col-sm-10">
+                                    <select class="form-control selectpicker" id="idCiphers" name="sftp_ciphers" multiple>
+                                        {{range $val := .Configs.SFTPD.GetSupportedCiphers}}
+                                        <option value="{{$val}}" {{range $algo :=$.Configs.SFTPD.Ciphers }}{{if eq $algo $val}}selected{{end}}{{end}}>{{$val}}</option>
+                                        {{end}}
+                                    </select>
+                                </div>
+                            </div>
+
+                            <div class="form-group row">
+                                <label for="idMACAlgos" class="col-sm-2 col-form-label">MAC Algos</label>
+                                <div class="col-sm-10">
+                                    <select class="form-control selectpicker" id="idMACAlgos" name="sftp_macs" multiple>
+                                        {{range $val := .Configs.SFTPD.GetSupportedMACs}}
+                                        <option value="{{$val}}" {{range $algo :=$.Configs.SFTPD.MACs }}{{if eq $algo $val}}selected{{end}}{{end}}>{{$val}}</option>
+                                        {{end}}
+                                    </select>
+                                </div>
+                            </div>
+
+                            <div class="col-sm-12 text-right px-0">
+                                <button type="submit" class="btn btn-primary mt-3 ml-3 px-5" name="form_action" value="sftp_submit">Submit</button>
+                            </div>
+
+                        </div>
+                    </div>
+                </div>
+                <div class="card">
+                    <div class="card-header" id="headingSMTP">
+                        <h2 class="mb-0">
+                            <button class="btn btn-link btn-block text-left" type="button" data-toggle="collapse"
+                                data-target="#collapseSMTP" aria-expanded="true" aria-controls="collapseSMTP">
+                                <h6 class="m-0 font-weight-bold text-primary">SMTP</h6>
+                            </button>
+                        </h2>
+                    </div>
+
+                    <div id="collapseSMTP" class="collapse {{if eq .ConfigSection 2}}show{{end}}" aria-labelledby="headingSMTP" data-parent="#accordionConfigs">
+                        <div class="card-body">
+                            <div id="configs-smtp-info" class="card mb-3 border-left-info">
+                                <div class="card-body">Set the SMTP configuration replacing the one defined using env vars or config file if any.</div>
+                            </div>
+
+                            <div class="form-group row">
+                                <label for="idSMTPHost" class="col-sm-2 col-form-label">Server name</label>
+                                <div class="col-sm-5">
+                                    <input type="text" class="form-control" id="idSMTPHost" name="smtp_host" placeholder=""
+                                        value="{{.Configs.SMTP.Host}}" maxlength="512" spellcheck="false" aria-describedby="smtpHostHelpBlock">
+                                    <small id="smtpHostHelpBlock" class="form-text text-muted">
+                                        If empty the configuration is disabled
+                                    </small>
+                                </div>
+                                <div class="col-sm-1"></div>
+                                <label for="idSMTPPort" class="col-sm-2 col-form-label">Port</label>
+                                <div class="col-sm-2">
+                                    <input type="number" min="0" max="65535" class="form-control" id="idSMTPPort" name="smtp_port" placeholder=""
+                                        value="{{.Configs.SMTP.Port}}">
+                                </div>
+                            </div>
+
+                            <div class="form-group row">
+                                <label for="idSMTPUsername" class="col-sm-2 col-form-label">Username</label>
+                                <div class="col-sm-10">
+                                    <input type="text" class="form-control" id="idSMTPUsername" name="smtp_username" placeholder=""
+                                    value="{{.Configs.SMTP.User}}" maxlength="255" spellcheck="false">
+                                </div>
+                            </div>
+
+                            <div class="form-group row">
+                                <label for="idSMTPPassword" class="col-sm-2 col-form-label">Password</label>
+                                <div class="col-sm-10">
+                                    <input type="password" class="form-control" id="idSMTPPassword" name="smtp_password" placeholder="" autocomplete="new-password" spellcheck="false"
+                                        value="{{if .Configs.SMTP.Password.IsEncrypted}}{{.RedactedSecret}}{{else}}{{.Configs.SMTP.Password.GetPayload}}{{end}}">
+                                </div>
+                            </div>
+
+                            <div class="form-group row">
+                                <label for="idSMTPAuth" class="col-sm-2 col-form-label">Auth</label>
+                                <div class="col-sm-3">
+                                    <select class="form-control selectpicker" id="idSMTPAuth" name="smtp_auth">
+                                        <option value="0" {{if eq .Configs.SMTP.AuthType 0}}selected{{end}}>Plain</option>
+                                        <option value="1" {{if eq .Configs.SMTP.AuthType 1}}selected{{end}}>Login</option>
+                                        <option value="2" {{if eq .Configs.SMTP.AuthType 2}}selected{{end}}>CRAM-MD5</option>
+                                    </select>
+                                </div>
+                                <div class="col-sm-2"></div>
+                                <label for="idSMTPEncryption" class="col-sm-2 col-form-label">Encryption</label>
+                                <div class="col-sm-3">
+                                    <select class="form-control selectpicker" id="idSMTPEncryption" name="smtp_encryption">
+                                        <option value="0" {{if eq .Configs.SMTP.Encryption 0}}selected{{end}}>None</option>
+                                        <option value="1" {{if eq .Configs.SMTP.Encryption 1}}selected{{end}}>TLS</option>
+                                        <option value="2" {{if eq .Configs.SMTP.Encryption 2}}selected{{end}}>STARTTLS</option>
+                                    </select>
+                                </div>
+                            </div>
+
+                            <div class="form-group row">
+                                <label for="idSMTPFrom" class="col-sm-2 col-form-label">From</label>
+                                <div class="col-sm-10">
+                                    <input type="text" class="form-control" id="idSMTPFrom" name="smtp_from" placeholder=""
+                                        value="{{.Configs.SMTP.From}}" maxlength="512" spellcheck="false">
+                                </div>
+                            </div>
+
+                            <div class="form-group row">
+                                <label for="idSMTPDomain" class="col-sm-2 col-form-label">Domain</label>
+                                <div class="col-sm-10">
+                                    <input type="text" class="form-control" id="idSMTPDomain" name="smtp_domain" placeholder=""
+                                        value="{{.Configs.SMTP.Domain}}" aria-describedby="smtpDomainHelpBlock">
+                                    <small id="smtpDomainHelpBlock" class="form-text text-muted">
+                                        HELO domain. Leave blank to use the server hostname
+                                    </small>
+                                </div>
+                            </div>
+
+                            <div class="form-group row">
+                                <div class="col-sm-12">
+                                    <div class="input-group">
+                                        <input type="email" class="form-control float-right" id="idSMTPRecipient" placeholder="Test email recipient" aria-label="Test email recipient">
+                                        <div class="input-group-append">
+                                          <button class="btn btn-secondary px-5" onclick="testSMTP(event);">Test</button>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+
+                            <div class="col-sm-12 text-right px-0">
+                                <button type="submit" class="btn btn-primary mt-3 px-5" name="form_action" value="smtp_submit">Submit</button>
+                            </div>
+
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </form>
+    </div>
+</div>
+{{end}}
+{{define "dialog"}}
+<div class="modal fade" id="spinnerModal" tabindex="-1" role="dialog" data-keyboard="false" data-backdrop="static">
+    <div class="modal-dialog modal-dialog-centered justify-content-center" role="document">
+        <span style="color: #333333;" class="fa fa-spinner fa-spin fa-3x"></span>
+    </div>
+</div>
+
+<div class="modal fade" id="smtpTestResultModal" tabindex="-1" role="dialog" aria-labelledby="smtpTestResultModalLabel"
+    aria-hidden="true">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="smtpTestResultModal">
+                    SMTP test result
+                </h5>
+                <button class="close" type="button" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <div class="modal-body">
+                <div id="smtpSuccessMsg" class="card mb-4 border-left-success" style="display: none;">
+                    <div id="successTxt" class="card-body">No errors were reported while sending the test email. Please check your inbox to make sure.</div>
+                </div>
+                <div id="smtpErrorMsg" class="card mb-4 border-left-warning" style="display: none;">
+                    <div id="smtpErrorTxt" class="card-body text-form-error"></div>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <button class="btn btn-primary" type="button" data-dismiss="modal">
+                    OK
+                </button>
+            </div>
+        </div>
+    </div>
+</div>
+{{end}}
+{{define "extra_js"}}
+<script src="{{.StaticURL}}/vendor/bootstrap-select/js/bootstrap-select.min.js"></script>
+<script type="text/javascript">
+    var spinnerDone = false;
+
+    function testSMTP(event){
+        event.preventDefault();
+        let recipient = $('#idSMTPRecipient').val();
+        if (!recipient){
+            $('#smtpErrorTxt').text('Set a recipient to send the test mail to.');
+            $('#smtpErrorMsg').show();
+            $('#smtpTestResultModal').modal('show');
+            return;
+        }
+        $('#smtpSuccessMsg').hide();
+        $('#smtpErrorMsg').hide();
+        $('#spinnerModal').modal('show');
+
+        $.ajax({
+            url: "{{.ConfigsURL}}/smtp/test",
+            type: 'POST',
+            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
+            data: JSON.stringify({"host": $('#idSMTPHost').val(),"port": parseInt($('#idSMTPPort').val()),"from": $('#idSMTPFrom').val(),"user": $('#idSMTPUsername').val(),"password": $('#idSMTPPassword').val(),"auth_type": parseInt($('#idSMTPAuth').val()),"encryption": parseInt($('#idSMTPEncryption').val()), "domain": $('#idSMTPDomain').val(),"recipient": recipient}),
+            dataType: 'json',
+            contentType: 'application/json; charset=utf-8',
+            timeout: 15000,
+            success: function (result) {
+                $('#spinnerModal').modal('hide');
+                spinnerDone = true;
+                $('#smtpSuccessMsg').show();
+                $('#smtpTestResultModal').modal('show');
+            },
+            error: function ($xhr, textStatus, errorThrown) {
+                $('#spinnerModal').modal('hide');
+                spinnerDone = true;
+                let txt = "SMTP connection failed";
+                if ($xhr) {
+                    let json = $xhr.responseJSON;
+                    if (json) {
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
+                    }
+                }
+                $('#smtpErrorTxt').text(txt);
+                $('#smtpErrorMsg').show();
+                $('#smtpTestResultModal').modal('show');
+            }
+        });
+    }
+
+    $(document).ready(function () {
+        $('#spinnerModal').on('shown.bs.modal', function () {
+            if (spinnerDone){
+                $('#spinnerModal').modal('hide');
+            }
+        });
+    });
+</script>
+{{end}}

+ 9 - 0
templates/webadmin/status.html

@@ -47,6 +47,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
                     Fingerprint: "{{.Fingerprint}}"
                     <br>
                     {{end}}
+                    <br>
+                    Host Key algorithms: "{{.Status.SSH.GetHostKeyAlgosAsString}}"
+                    <br><br>
+                    MAC algorithms: "{{.Status.SSH.GetMACsAsString}}"
+                    <br><br>
+                    KEX algorithms: "{{.Status.SSH.GetKEXsAsString}}"
+                    <br><br>
+                    Ciphers: "{{.Status.SSH.GetCiphersAsString}}"
+                    <br>
                     {{end}}
                 </p>
             </div>

+ 2 - 3
tests/eventsearcher/go.mod

@@ -10,17 +10,16 @@ require (
 require (
 	github.com/fatih/color v1.14.1 // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
-	github.com/google/go-cmp v0.5.9 // indirect
 	github.com/hashicorp/go-hclog v1.4.0 // indirect
 	github.com/hashicorp/yamux v0.1.1 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
 	github.com/mattn/go-isatty v0.0.17 // indirect
 	github.com/mitchellh/go-testing-interface v1.14.1 // indirect
 	github.com/oklog/run v1.1.0 // indirect
-	golang.org/x/net v0.6.0 // indirect
+	golang.org/x/net v0.7.0 // indirect
 	golang.org/x/sys v0.5.0 // indirect
 	golang.org/x/text v0.7.0 // indirect
-	google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc // indirect
+	google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44 // indirect
 	google.golang.org/grpc v1.53.0 // indirect
 	google.golang.org/protobuf v1.28.1 // indirect
 )

+ 4 - 19
tests/eventsearcher/go.sum

@@ -1,7 +1,6 @@
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
 github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
 github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
 github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
@@ -10,7 +9,6 @@ github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw
 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
-github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/hashicorp/go-hclog v1.4.0 h1:ctuWFGrhFha8BnnzxqeRGidlEcQkDyL5u8J8t5eA11I=
 github.com/hashicorp/go-hclog v1.4.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
 github.com/hashicorp/go-plugin v1.4.8 h1:CHGwpxYDOttQOY7HOWgETU9dyVjOXzniXDqJcYJE1zM=
@@ -24,7 +22,6 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec
 github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
 github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
-github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
 github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
 github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -34,38 +31,26 @@ github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
 github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/sftpgo/sdk v0.1.3-0.20221208080405-e682ae869318 h1:oDr2it5L9nh13+P3BzyIqx89gN9Kfrsdk4cw42HaIuQ=
-github.com/sftpgo/sdk v0.1.3-0.20221208080405-e682ae869318/go.mod h1:3GpW3Qy8IHH6kex0ny+Y6ayeYb9OJxz8Pxh3IZgAs2E=
 github.com/sftpgo/sdk v0.1.3-0.20230212154322-556375985d8c h1:SiWQZe99SZ/O4QSIsxzL91NgwFJNoo4IJ31cazUrYh4=
 github.com/sftpgo/sdk v0.1.3-0.20230212154322-556375985d8c/go.mod h1:B1lPGb05WtvvrX5IuhHrSjWdRT867qBaoxlS2Q9+1bA=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
 github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
-golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
-golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
-golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q=
-golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
+golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
-golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
-golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/genproto v0.0.0-20221207170731-23e4bf6bdc37 h1:jmIfw8+gSvXcZSgaFAGyInDXeWzUhvYH57G/5GKMn70=
-google.golang.org/genproto v0.0.0-20221207170731-23e4bf6bdc37/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
-google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc h1:ijGwO+0vL2hJt5gaygqP2j6PfflOBrRot0IczKbmtio=
-google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
-google.golang.org/grpc v1.51.0 h1:E1eGv1FTqoLIdnBCZufiSHgKjlqG6fKFf6pPWtMTh8U=
-google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww=
+google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44 h1:EfLuoKW5WfkgVdDy7dTK8qSbH37AX5mj/MFh+bGPz14=
+google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44/go.mod h1:8B0gmkoRebU8ukX6HP+4wrVQUY1+6PkQ44BSyIlflHA=
 google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc=
 google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=

+ 2 - 2
tests/ipfilter/go.mod

@@ -16,10 +16,10 @@ require (
 	github.com/mattn/go-isatty v0.0.17 // indirect
 	github.com/mitchellh/go-testing-interface v1.14.1 // indirect
 	github.com/oklog/run v1.1.0 // indirect
-	golang.org/x/net v0.6.0 // indirect
+	golang.org/x/net v0.7.0 // indirect
 	golang.org/x/sys v0.5.0 // indirect
 	golang.org/x/text v0.7.0 // indirect
-	google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc // indirect
+	google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44 // indirect
 	google.golang.org/grpc v1.53.0 // indirect
 	google.golang.org/protobuf v1.28.1 // indirect
 )

+ 5 - 22
tests/ipfilter/go.sum

@@ -1,7 +1,6 @@
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
 github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
 github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
 github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
@@ -9,7 +8,7 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
 github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/hashicorp/go-hclog v1.4.0 h1:ctuWFGrhFha8BnnzxqeRGidlEcQkDyL5u8J8t5eA11I=
 github.com/hashicorp/go-hclog v1.4.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
 github.com/hashicorp/go-plugin v1.4.8 h1:CHGwpxYDOttQOY7HOWgETU9dyVjOXzniXDqJcYJE1zM=
@@ -23,7 +22,6 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec
 github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
 github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
-github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
 github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
 github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -33,41 +31,26 @@ github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
 github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/sftpgo/sdk v0.1.3-0.20221208080405-e682ae869318 h1:oDr2it5L9nh13+P3BzyIqx89gN9Kfrsdk4cw42HaIuQ=
-github.com/sftpgo/sdk v0.1.3-0.20221208080405-e682ae869318/go.mod h1:3GpW3Qy8IHH6kex0ny+Y6ayeYb9OJxz8Pxh3IZgAs2E=
-github.com/sftpgo/sdk v0.1.3-0.20230212154322-556375985d8c h1:SiWQZe99SZ/O4QSIsxzL91NgwFJNoo4IJ31cazUrYh4=
-github.com/sftpgo/sdk v0.1.3-0.20230212154322-556375985d8c/go.mod h1:B1lPGb05WtvvrX5IuhHrSjWdRT867qBaoxlS2Q9+1bA=
 github.com/sftpgo/sdk v0.1.3-0.20230213120720-de3129520736 h1:QFzoqYPIxuqDOe2NJfYI7J71bZrsfC0Aejc0ChblkcA=
 github.com/sftpgo/sdk v0.1.3-0.20230213120720-de3129520736/go.mod h1:B1lPGb05WtvvrX5IuhHrSjWdRT867qBaoxlS2Q9+1bA=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
 github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
-golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
-golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
-golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q=
-golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
+golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
-golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
-golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
-google.golang.org/genproto v0.0.0-20221207170731-23e4bf6bdc37 h1:jmIfw8+gSvXcZSgaFAGyInDXeWzUhvYH57G/5GKMn70=
-google.golang.org/genproto v0.0.0-20221207170731-23e4bf6bdc37/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
-google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc h1:ijGwO+0vL2hJt5gaygqP2j6PfflOBrRot0IczKbmtio=
-google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
-google.golang.org/grpc v1.51.0 h1:E1eGv1FTqoLIdnBCZufiSHgKjlqG6fKFf6pPWtMTh8U=
-google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww=
+google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44 h1:EfLuoKW5WfkgVdDy7dTK8qSbH37AX5mj/MFh+bGPz14=
+google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44/go.mod h1:8B0gmkoRebU8ukX6HP+4wrVQUY1+6PkQ44BSyIlflHA=
 google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc=
 google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=