](https://www.aledade.com/)
+
+[
](https://www.jumptrading.com/)
+
+[
](https://wpengine.com/)
+
+#### Silver sponsors
+
+[
](https://idcs.ip-paris.fr/)
+
+#### Bronze sponsors
+
+[
](https://www.7digital.com/)
+
+[
](https://servinga.com/)
+
+[
](https://www.reui.io/)
+
+## Documentation
+
+You can explore all supported features and configuration options at [docs.sftpgo.com](https://docs.sftpgo.com/latest/).
+
+**Note:** The link above refers to the **Community Edition**.
+For details on **Enterprise Edition**, please refer to the [Enterprise Documentation](https://docs.sftpgo.com/enterprise/).
+
+## Support
+
+- **Community Support**: use [GitHub Discussions](https://github.com/drakkan/sftpgo/discussions) to ask questions, share feedback, and engage with other users.
+- **Commercial Support**: If you require guaranteed SLAs, expert guidance, or the advanced features listed above, check out [SFTPGo Enterprise](https://sftpgo.com).
+
+SFTPGo Enterprise is available as:
+
+- On-premises: Full control on your infrastructure. More details: [sftpgo.com/on-premises](https://sftpgo.com/on-premises)
+- Fully managed SaaS: We handle the infrastructure. More details: [sftpgo.com/saas](https://sftpgo.com/saas)
+
+## Internationalization
+
+The translations are available via [Crowdin](https://crowdin.com/project/sftpgo), who have granted us an open source license.
+
+Before translating please take a look at our contribution [guidelines](https://docs.sftpgo.com/latest/web-interfaces/#internationalization).
+
+## Release Cadence
+
+SFTPGo follows a feature-driven release cycle.
+
+- Enterprise Edition: Receives major new features first and follows a faster [release cadence](https://docs.sftpgo.com/enterprise/changelog/).
+- Community Edition: Remains maintained, receiving bug fixes, security updates, and updates to core features.
## Acknowledgements
-- [pkg/sftp](https://github.com/pkg/sftp)
-- [go-chi](https://github.com/go-chi/chi)
-- [zerolog](https://github.com/rs/zerolog)
-- [lumberjack](https://gopkg.in/natefinch/lumberjack.v2)
-- [argon2id](https://github.com/alexedwards/argon2id)
-- [go-sqlite3](https://github.com/mattn/go-sqlite3)
-- [go-sql-driver/mysql](https://github.com/go-sql-driver/mysql)
-- [bbolt](https://github.com/etcd-io/bbolt)
-- [lib/pq](https://github.com/lib/pq)
-- [viper](https://github.com/spf13/viper)
-- [cobra](https://github.com/spf13/cobra)
-- [xid](https://github.com/rs/xid)
+SFTPGo makes use of the third party libraries listed inside [go.mod](./go.mod).
-Some code was initially taken from [Pterodactyl sftp server](https://github.com/pterodactyl/sftp-server)
+We are very grateful to all the people who contributed with ideas and/or pull requests.
+
+Thank you to [ysura](https://www.ysura.com/) for granting us stable access to a test AWS S3 account.
+
+Thank you to [KeenThemes](https://keenthemes.com/) for granting us a custom license to use their amazing [themes](https://keenthemes.com/bootstrap-templates) for the SFTPGo WebAdmin and WebClient user interfaces, across both the Open Source and Open Core versions.
+
+Thank you to [Crowdin](https://crowdin.com/) for granting us an Open Source License.
+
+Thank you to [Incode](https://www.incode.it/) for helping us to improve the UI/UX.
## License
-GNU GPLv3
+SFTPGo source code is licensed under the GNU AGPL-3.0-only with [additional terms](./NOTICE).
+
+The [theme](https://keenthemes.com/bootstrap-templates) used in WebAdmin and WebClient user interfaces is proprietary, this means:
+
+- KeenThemes HTML/CSS/JS components are allowed for use only within the SFTPGo product and restricted to be used in a resealable HTML template that can compete with KeenThemes products anyhow.
+- The SFTPGo WebAdmin and WebClient user interfaces (HTML, CSS and JS components) based on this theme are allowed for use only within the SFTPGo product and therefore cannot be used in derivative works/products without an explicit grant from the [SFTPGo Team](mailto:support@sftpgo.com).
+
+More information about [compliance](https://sftpgo.com/compliance.html).
+
+**Note:** We do not provide legal advice. If you have questions about license compliance or whether your use case is permitted under the license terms, please consult your legal team.
+
+## Copyright
+
+Copyright (C) 2019 - 2026 Nicola Murino
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..01650ca6
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,20 @@
+# Security Policy
+
+## Supported Versions
+
+We actively maintain the latest stable release of SFTPGo. While we strive to keep the Open Source version secure and up-to-date, maintenance is performed on a best-effort basis by the community and contributors.
+
+## Scope and Dependency Policy
+
+Our security advisories focus on vulnerabilities found within the **SFTPGo codebase itself**.
+
+To ensure the long-term sustainability of the project, we handle upstream dependencies (like the Go standard library, external packages, or Docker base images) as follows:
+
+- Community Updates: For the Open Source version, vulnerabilities in upstream components (such as the Go standard library or third-party packages) are addressed during our **regular release cycles**. We generally do not provide immediate, out-of-band or ad-hoc releases to address dependency-only CVEs.
+- Empowering Users: One of the strengths of SFTPGo being open-source is that you have full control. If your security scanners require an immediate fix, you can always rebuild the project using the latest patched Go toolchain or updated dependencies.
+- Compatibility: We are committed to keeping SFTPGo compatible with the latest stable Go compiler. If an upstream fix breaks SFTPGo, fixing that becomes a priority for us.
+- Professional Needs: We understand that some organizations have strict compliance requirements or internal SLAs that require guaranteed, immediate response times and out-of-band patches. For these cases, we offer [SFTPGo Enterprise](https://sftpgo.com/on-premises) to cover the additional maintenance and support overhead.
+
+## Reporting a Vulnerability
+
+To report (possible) security issues in SFTPGo, please either send a mail to the [SFTPGo Team](mailto:support@sftpgo.com) or use Github's [private reporting feature](https://github.com/drakkan/sftpgo/security/advisories/new).
diff --git a/api/api.go b/api/api.go
deleted file mode 100644
index 2b280edc..00000000
--- a/api/api.go
+++ /dev/null
@@ -1,77 +0,0 @@
-// Package api implements REST API for sftpgo.
-// REST API allows to manage users and quota and to get real time reports for the active connections
-// with possibility of forcibly closing a connection.
-// The OpenAPI 3 schema for the exposed API can be found inside the source tree:
-// https://github.com/drakkan/sftpgo/tree/master/api/schema/openapi.yaml
-package api
-
-import (
- "net/http"
-
- "github.com/drakkan/sftpgo/dataprovider"
- "github.com/go-chi/chi"
- "github.com/go-chi/render"
-)
-
-const (
- logSender = "api"
- activeConnectionsPath = "/api/v1/connection"
- quotaScanPath = "/api/v1/quota_scan"
- userPath = "/api/v1/user"
- versionPath = "/api/v1/version"
-)
-
-var (
- router *chi.Mux
- dataProvider dataprovider.Provider
-)
-
-// HTTPDConf httpd daemon configuration
-type HTTPDConf struct {
- // The port used for serving HTTP requests. 0 disable the HTTP server. Default: 8080
- BindPort int `json:"bind_port" mapstructure:"bind_port"`
- // The address to listen on. A blank value means listen on all available network interfaces. Default: "127.0.0.1"
- BindAddress string `json:"bind_address" mapstructure:"bind_address"`
-}
-
-type apiResponse struct {
- Error string `json:"error"`
- Message string `json:"message"`
- HTTPStatus int `json:"status"`
-}
-
-func init() {
- initializeRouter()
-}
-
-// SetDataProvider sets the data provider to use to fetch the data about users
-func SetDataProvider(provider dataprovider.Provider) {
- dataProvider = provider
-}
-
-func sendAPIResponse(w http.ResponseWriter, r *http.Request, err error, message string, code int) {
- var errorString string
- if err != nil {
- errorString = err.Error()
- }
- resp := apiResponse{
- Error: errorString,
- Message: message,
- HTTPStatus: code,
- }
- if code != http.StatusOK {
- w.Header().Set("Content-Type", "application/json; charset=utf-8")
- w.WriteHeader(code)
- }
- render.JSON(w, r, resp)
-}
-
-func getRespStatus(err error) int {
- if _, ok := err.(*dataprovider.ValidationError); ok {
- return http.StatusBadRequest
- }
- if _, ok := err.(*dataprovider.MethodDisabledError); ok {
- return http.StatusForbidden
- }
- return http.StatusInternalServerError
-}
diff --git a/api/api_test.go b/api/api_test.go
deleted file mode 100644
index b26138b0..00000000
--- a/api/api_test.go
+++ /dev/null
@@ -1,755 +0,0 @@
-package api_test
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "net"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "runtime"
- "strconv"
- "testing"
- "time"
-
- "github.com/go-chi/render"
- _ "github.com/go-sql-driver/mysql"
- _ "github.com/lib/pq"
- _ "github.com/mattn/go-sqlite3"
- "github.com/rs/zerolog"
-
- "github.com/drakkan/sftpgo/api"
- "github.com/drakkan/sftpgo/config"
- "github.com/drakkan/sftpgo/dataprovider"
- "github.com/drakkan/sftpgo/logger"
- "github.com/drakkan/sftpgo/sftpd"
-)
-
-const (
- defaultUsername = "test_user"
- defaultPassword = "test_password"
- testPubKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC03jj0D+djk7pxIf/0OhrxrchJTRZklofJ1NoIu4752Sq02mdXmarMVsqJ1cAjV5LBVy3D1F5U6XW4rppkXeVtd04Pxb09ehtH0pRRPaoHHlALiJt8CoMpbKYMA8b3KXPPriGxgGomvtU2T2RMURSwOZbMtpsugfjYSWenyYX+VORYhylWnSXL961LTyC21ehd6d6QnW9G7E5hYMITMY9TuQZz3bROYzXiTsgN0+g6Hn7exFQp50p45StUMfV/SftCMdCxlxuyGny2CrN/vfjO7xxOo2uv7q1qm10Q46KPWJQv+pgZ/OfL+EDjy07n5QVSKHlbx+2nT4Q0EgOSQaCTYwn3YjtABfIxWwgAFdyj6YlPulCL22qU4MYhDcA6PSBwDdf8hvxBfvsiHdM+JcSHvv8/VeJhk6CmnZxGY0fxBupov27z3yEO8nAg8k+6PaUiW1MSUfuGMF/ktB8LOstXsEPXSszuyXiOv4DaryOXUiSn7bmRqKcEFlJusO6aZP0= nicola@p1"
- logSender = "APITesting"
- userPath = "/api/v1/user"
- activeConnectionsPath = "/api/v1/connection"
- quotaScanPath = "/api/v1/quota_scan"
- versionPath = "/api/v1/version"
-)
-
-var (
- defaultPerms = []string{dataprovider.PermAny}
- homeBasePath string
- testServer *httptest.Server
-)
-
-func TestMain(m *testing.M) {
- if runtime.GOOS == "windows" {
- homeBasePath = "C:\\"
- } else {
- homeBasePath = "/tmp"
- }
- configDir := ".."
- logfilePath := filepath.Join(configDir, "sftpgo_api_test.log")
- logger.InitLogger(logfilePath, 5, 1, 28, false, zerolog.DebugLevel)
- config.LoadConfig(configDir, "")
- providerConf := config.GetProviderConf()
-
- err := dataprovider.Initialize(providerConf, configDir)
- if err != nil {
- logger.Warn(logSender, "error initializing data provider: %v", err)
- os.Exit(1)
- }
- dataProvider := dataprovider.GetProvider()
- httpdConf := config.GetHTTPDConfig()
- router := api.GetHTTPRouter()
-
- httpdConf.BindPort = 8081
- api.SetBaseURL("http://127.0.0.1:8081")
-
- sftpd.SetDataProvider(dataProvider)
- api.SetDataProvider(dataProvider)
-
- go func() {
- logger.Debug(logSender, "initializing HTTP server with config %+v", httpdConf)
- s := &http.Server{
- Addr: fmt.Sprintf("%s:%d", httpdConf.BindAddress, httpdConf.BindPort),
- Handler: router,
- ReadTimeout: 300 * time.Second,
- WriteTimeout: 300 * time.Second,
- MaxHeaderBytes: 1 << 20, // 1MB
- }
- if err := s.ListenAndServe(); err != nil {
- logger.Error(logSender, "could not start HTTP server: %v", err)
- }
- }()
-
- testServer = httptest.NewServer(api.GetHTTPRouter())
- defer testServer.Close()
-
- waitTCPListening(fmt.Sprintf("%s:%d", httpdConf.BindAddress, httpdConf.BindPort))
-
- exitCode := m.Run()
- os.Remove(logfilePath)
- os.Exit(exitCode)
-}
-
-func TestBasicUserHandling(t *testing.T) {
- user, _, err := api.AddUser(getTestUser(), http.StatusOK)
- if err != nil {
- t.Errorf("unable to add user: %v", err)
- }
- user.MaxSessions = 10
- user.QuotaSize = 4096
- user.QuotaFiles = 2
- user.UploadBandwidth = 128
- user.DownloadBandwidth = 64
- user, _, err = api.UpdateUser(user, http.StatusOK)
- if err != nil {
- t.Errorf("unable to update user: %v", err)
- }
- users, _, err := api.GetUsers(0, 0, defaultUsername, http.StatusOK)
- if err != nil {
- t.Errorf("unable to get users: %v", err)
- }
- if len(users) != 1 {
- t.Errorf("number of users mismatch, expected: 1, actual: %v", len(users))
- }
- _, err = api.RemoveUser(user, http.StatusOK)
- if err != nil {
- t.Errorf("unable to remove: %v", err)
- }
-}
-
-func TestAddUserNoCredentials(t *testing.T) {
- u := getTestUser()
- u.Password = ""
- u.PublicKeys = []string{}
- _, _, err := api.AddUser(u, http.StatusBadRequest)
- if err != nil {
- t.Errorf("unexpected error adding user with no credentials: %v", err)
- }
-}
-
-func TestAddUserNoUsername(t *testing.T) {
- u := getTestUser()
- u.Username = ""
- _, _, err := api.AddUser(u, http.StatusBadRequest)
- if err != nil {
- t.Errorf("unexpected error adding user with no home dir: %v", err)
- }
-}
-
-func TestAddUserNoHomeDir(t *testing.T) {
- u := getTestUser()
- u.HomeDir = ""
- _, _, err := api.AddUser(u, http.StatusBadRequest)
- if err != nil {
- t.Errorf("unexpected error adding user with no home dir: %v", err)
- }
-}
-
-func TestAddUserInvalidHomeDir(t *testing.T) {
- u := getTestUser()
- u.HomeDir = "relative_path"
- _, _, err := api.AddUser(u, http.StatusBadRequest)
- if err != nil {
- t.Errorf("unexpected error adding user with invalid home dir: %v", err)
- }
-}
-
-func TestAddUserNoPerms(t *testing.T) {
- u := getTestUser()
- u.Permissions = []string{}
- _, _, err := api.AddUser(u, http.StatusBadRequest)
- if err != nil {
- t.Errorf("unexpected error adding user with no perms: %v", err)
- }
-}
-
-func TestAddUserInvalidPerms(t *testing.T) {
- u := getTestUser()
- u.Permissions = []string{"invalidPerm"}
- _, _, err := api.AddUser(u, http.StatusBadRequest)
- if err != nil {
- t.Errorf("unexpected error adding user with no perms: %v", err)
- }
-}
-
-func TestUserPublicKey(t *testing.T) {
- u := getTestUser()
- invalidPubKey := "invalid"
- validPubKey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC03jj0D+djk7pxIf/0OhrxrchJTRZklofJ1NoIu4752Sq02mdXmarMVsqJ1cAjV5LBVy3D1F5U6XW4rppkXeVtd04Pxb09ehtH0pRRPaoHHlALiJt8CoMpbKYMA8b3KXPPriGxgGomvtU2T2RMURSwOZbMtpsugfjYSWenyYX+VORYhylWnSXL961LTyC21ehd6d6QnW9G7E5hYMITMY9TuQZz3bROYzXiTsgN0+g6Hn7exFQp50p45StUMfV/SftCMdCxlxuyGny2CrN/vfjO7xxOo2uv7q1qm10Q46KPWJQv+pgZ/OfL+EDjy07n5QVSKHlbx+2nT4Q0EgOSQaCTYwn3YjtABfIxWwgAFdyj6YlPulCL22qU4MYhDcA6PSBwDdf8hvxBfvsiHdM+JcSHvv8/VeJhk6CmnZxGY0fxBupov27z3yEO8nAg8k+6PaUiW1MSUfuGMF/ktB8LOstXsEPXSszuyXiOv4DaryOXUiSn7bmRqKcEFlJusO6aZP0= nicola@p1"
- u.PublicKeys = []string{invalidPubKey}
- _, _, err := api.AddUser(u, http.StatusBadRequest)
- if err != nil {
- t.Errorf("unexpected error adding user with invalid pub key: %v", err)
- }
- u.PublicKeys = []string{validPubKey}
- user, _, err := api.AddUser(u, http.StatusOK)
- if err != nil {
- t.Errorf("unable to add user: %v", err)
- }
- user.PublicKeys = []string{validPubKey, invalidPubKey}
- _, _, err = api.UpdateUser(user, http.StatusBadRequest)
- if err != nil {
- t.Errorf("update user with invalid public key must fail: %v", err)
- }
- user.PublicKeys = []string{validPubKey, validPubKey, validPubKey}
- _, _, err = api.UpdateUser(user, http.StatusOK)
- if err != nil {
- t.Errorf("unable to update user: %v", err)
- }
- _, err = api.RemoveUser(user, http.StatusOK)
- if err != nil {
- t.Errorf("unable to remove: %v", err)
- }
-}
-
-func TestUpdateUser(t *testing.T) {
- user, _, err := api.AddUser(getTestUser(), http.StatusOK)
- if err != nil {
- t.Errorf("unable to add user: %v", err)
- }
- user.HomeDir = filepath.Join(homeBasePath, "testmod")
- user.UID = 33
- user.GID = 101
- user.MaxSessions = 10
- user.QuotaSize = 4096
- user.QuotaFiles = 2
- user.Permissions = []string{dataprovider.PermCreateDirs, dataprovider.PermDelete, dataprovider.PermDownload}
- user.UploadBandwidth = 1024
- user.DownloadBandwidth = 512
- user, _, err = api.UpdateUser(user, http.StatusOK)
- if err != nil {
- t.Errorf("unable to update user: %v", err)
- }
- _, err = api.RemoveUser(user, http.StatusOK)
- if err != nil {
- t.Errorf("unable to remove: %v", err)
- }
-}
-
-func TestUpdateUserNoCredentials(t *testing.T) {
- user, _, err := api.AddUser(getTestUser(), http.StatusOK)
- if err != nil {
- t.Errorf("unable to add user: %v", err)
- }
- user.Password = ""
- user.PublicKeys = []string{}
- // password and public key will be omitted from json serialization if empty and so they will remain unchanged
- // and no validation error will be raised
- _, _, err = api.UpdateUser(user, http.StatusOK)
- if err != nil {
- t.Errorf("unexpected error updating user with no credentials: %v", err)
- }
- _, err = api.RemoveUser(user, http.StatusOK)
- if err != nil {
- t.Errorf("unable to remove: %v", err)
- }
-}
-
-func TestUpdateUserEmptyHomeDir(t *testing.T) {
- user, _, err := api.AddUser(getTestUser(), http.StatusOK)
- if err != nil {
- t.Errorf("unable to add user: %v", err)
- }
- user.HomeDir = ""
- _, _, err = api.UpdateUser(user, http.StatusBadRequest)
- if err != nil {
- t.Errorf("unexpected error updating user with empty home dir: %v", err)
- }
- _, err = api.RemoveUser(user, http.StatusOK)
- if err != nil {
- t.Errorf("unable to remove: %v", err)
- }
-}
-
-func TestUpdateUserInvalidHomeDir(t *testing.T) {
- user, _, err := api.AddUser(getTestUser(), http.StatusOK)
- if err != nil {
- t.Errorf("unable to add user: %v", err)
- }
- user.HomeDir = "relative_path"
- _, _, err = api.UpdateUser(user, http.StatusBadRequest)
- if err != nil {
- t.Errorf("unexpected error updating user with empty home dir: %v", err)
- }
- _, err = api.RemoveUser(user, http.StatusOK)
- if err != nil {
- t.Errorf("unable to remove: %v", err)
- }
-}
-
-func TestUpdateNonExistentUser(t *testing.T) {
- _, _, err := api.UpdateUser(getTestUser(), http.StatusNotFound)
- if err != nil {
- t.Errorf("unable to update user: %v", err)
- }
-}
-
-func TestGetNonExistentUser(t *testing.T) {
- _, _, err := api.GetUserByID(0, http.StatusNotFound)
- if err != nil {
- t.Errorf("unable to get user: %v", err)
- }
-}
-
-func TestDeleteNonExistentUser(t *testing.T) {
- _, err := api.RemoveUser(getTestUser(), http.StatusNotFound)
- if err != nil {
- t.Errorf("unable to remove user: %v", err)
- }
-}
-
-func TestAddDuplicateUser(t *testing.T) {
- user, _, err := api.AddUser(getTestUser(), http.StatusOK)
- if err != nil {
- t.Errorf("unable to add user: %v", err)
- }
- _, _, err = api.AddUser(getTestUser(), http.StatusInternalServerError)
- if err != nil {
- t.Errorf("unable to add second user: %v", err)
- }
- _, _, err = api.AddUser(getTestUser(), http.StatusOK)
- if err == nil {
- t.Errorf("adding a duplicate user must fail")
- }
- _, err = api.RemoveUser(user, http.StatusOK)
- if err != nil {
- t.Errorf("unable to remove user: %v", err)
- }
-}
-
-func TestGetUsers(t *testing.T) {
- user1, _, err := api.AddUser(getTestUser(), http.StatusOK)
- if err != nil {
- t.Errorf("unable to add user: %v", err)
- }
- u := getTestUser()
- u.Username = defaultUsername + "1"
- user2, _, err := api.AddUser(u, http.StatusOK)
- if err != nil {
- t.Errorf("unable to add second user: %v", err)
- }
- users, _, err := api.GetUsers(0, 0, "", http.StatusOK)
- if err != nil {
- t.Errorf("unable to get users: %v", err)
- }
- if len(users) < 2 {
- t.Errorf("at least 2 users are expected")
- }
- users, _, err = api.GetUsers(1, 0, "", http.StatusOK)
- if err != nil {
- t.Errorf("unable to get users: %v", err)
- }
- if len(users) != 1 {
- t.Errorf("1 user is expected")
- }
- users, _, err = api.GetUsers(1, 1, "", http.StatusOK)
- if err != nil {
- t.Errorf("unable to get users: %v", err)
- }
- if len(users) != 1 {
- t.Errorf("1 user is expected")
- }
- _, _, err = api.GetUsers(1, 1, "", http.StatusInternalServerError)
- if err == nil {
- t.Errorf("get users must succeed, we requested a fail for a good request")
- }
- _, err = api.RemoveUser(user1, http.StatusOK)
- if err != nil {
- t.Errorf("unable to remove user: %v", err)
- }
- _, err = api.RemoveUser(user2, http.StatusOK)
- if err != nil {
- t.Errorf("unable to remove user: %v", err)
- }
-}
-
-func TestGetQuotaScans(t *testing.T) {
- _, _, err := api.GetQuotaScans(http.StatusOK)
- if err != nil {
- t.Errorf("unable to get quota scans: %v", err)
- }
- _, _, err = api.GetQuotaScans(http.StatusInternalServerError)
- if err == nil {
- t.Errorf("quota scan request must succeed, we requested to check a wrong status code")
- }
-}
-
-func TestStartQuotaScan(t *testing.T) {
- user, _, err := api.AddUser(getTestUser(), http.StatusOK)
- if err != nil {
- t.Errorf("unable to add user: %v", err)
- }
- _, err = api.StartQuotaScan(user, http.StatusCreated)
- if err != nil {
- t.Errorf("unable to start quota scan: %v", err)
- }
- _, err = api.RemoveUser(user, http.StatusOK)
- if err != nil {
- t.Errorf("unable to remove user: %v", err)
- }
-}
-
-func TestGetVersion(t *testing.T) {
- _, _, err := api.GetVersion(http.StatusOK)
- if err != nil {
- t.Errorf("unable to get sftp version: %v", err)
- }
- _, _, err = api.GetVersion(http.StatusInternalServerError)
- if err == nil {
- t.Errorf("get version request must succeed, we requested to check a wrong status code")
- }
-}
-
-func TestGetConnections(t *testing.T) {
- _, _, err := api.GetConnections(http.StatusOK)
- if err != nil {
- t.Errorf("unable to get sftp connections: %v", err)
- }
- _, _, err = api.GetConnections(http.StatusInternalServerError)
- if err == nil {
- t.Errorf("get sftp connections request must succeed, we requested to check a wrong status code")
- }
-}
-
-func TestCloseActiveConnection(t *testing.T) {
- _, err := api.CloseConnection("non_existent_id", http.StatusNotFound)
- if err != nil {
- t.Errorf("unexpected error closing non existent sftp connection: %v", err)
- }
-}
-
-// test using mock http server
-
-func TestBasicUserHandlingMock(t *testing.T) {
- user := getTestUser()
- userAsJSON := getUserAsJSON(t, user)
- req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON))
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr.Code)
- err := render.DecodeJSON(rr.Body, &user)
- if err != nil {
- t.Errorf("Error get user: %v", err)
- }
- req, _ = http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON))
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusInternalServerError, rr.Code)
- user.MaxSessions = 10
- user.UploadBandwidth = 128
- userAsJSON = getUserAsJSON(t, user)
- req, _ = http.NewRequest(http.MethodPut, userPath+"/"+strconv.FormatInt(user.ID, 10), bytes.NewBuffer(userAsJSON))
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr.Code)
-
- req, _ = http.NewRequest(http.MethodGet, userPath+"/"+strconv.FormatInt(user.ID, 10), nil)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr.Code)
-
- var updatedUser dataprovider.User
- err = render.DecodeJSON(rr.Body, &updatedUser)
- if err != nil {
- t.Errorf("Error decoding updated user: %v", err)
- }
- if user.MaxSessions != updatedUser.MaxSessions || user.UploadBandwidth != updatedUser.UploadBandwidth {
- t.Errorf("Error modifying user actual: %v, %v", updatedUser.MaxSessions, updatedUser.UploadBandwidth)
- }
- req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr.Code)
-}
-
-func TestGetUserByIdInvalidParamsMock(t *testing.T) {
- req, _ := http.NewRequest(http.MethodGet, userPath+"/0", nil)
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusNotFound, rr.Code)
- req, _ = http.NewRequest(http.MethodGet, userPath+"/a", nil)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusBadRequest, rr.Code)
-}
-
-func TestAddUserNoUsernameMock(t *testing.T) {
- user := getTestUser()
- user.Username = ""
- userAsJSON := getUserAsJSON(t, user)
- req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON))
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusBadRequest, rr.Code)
-}
-
-func TestAddUserInvalidHomeDirMock(t *testing.T) {
- user := getTestUser()
- user.HomeDir = "relative_path"
- userAsJSON := getUserAsJSON(t, user)
- req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON))
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusBadRequest, rr.Code)
-}
-
-func TestAddUserInvalidPermsMock(t *testing.T) {
- user := getTestUser()
- user.Permissions = []string{}
- userAsJSON := getUserAsJSON(t, user)
- req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON))
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusBadRequest, rr.Code)
-}
-
-func TestAddUserInvalidJsonMock(t *testing.T) {
- req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer([]byte("invalid json")))
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusBadRequest, rr.Code)
-}
-
-func TestUpdateUserInvalidJsonMock(t *testing.T) {
- user := getTestUser()
- userAsJSON := getUserAsJSON(t, user)
- req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON))
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr.Code)
- err := render.DecodeJSON(rr.Body, &user)
- if err != nil {
- t.Errorf("Error get user: %v", err)
- }
- req, _ = http.NewRequest(http.MethodPut, userPath+"/"+strconv.FormatInt(user.ID, 10), bytes.NewBuffer([]byte("Invalid json")))
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusBadRequest, rr.Code)
- req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr.Code)
-}
-
-func TestUpdateUserInvalidParamsMock(t *testing.T) {
- user := getTestUser()
- userAsJSON := getUserAsJSON(t, user)
- req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON))
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr.Code)
- err := render.DecodeJSON(rr.Body, &user)
- if err != nil {
- t.Errorf("Error get user: %v", err)
- }
- user.HomeDir = ""
- userAsJSON = getUserAsJSON(t, user)
- req, _ = http.NewRequest(http.MethodPut, userPath+"/"+strconv.FormatInt(user.ID, 10), bytes.NewBuffer(userAsJSON))
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusBadRequest, rr.Code)
- userID := user.ID
- user.ID = 0
- userAsJSON = getUserAsJSON(t, user)
- req, _ = http.NewRequest(http.MethodPut, userPath+"/"+strconv.FormatInt(userID, 10), bytes.NewBuffer(userAsJSON))
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusBadRequest, rr.Code)
- user.ID = userID
- req, _ = http.NewRequest(http.MethodPut, userPath+"/0", bytes.NewBuffer(userAsJSON))
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusNotFound, rr.Code)
- req, _ = http.NewRequest(http.MethodPut, userPath+"/a", bytes.NewBuffer(userAsJSON))
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusBadRequest, rr.Code)
- req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr.Code)
-}
-
-func TestGetUsersMock(t *testing.T) {
- user := getTestUser()
- userAsJSON := getUserAsJSON(t, user)
- req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON))
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr.Code)
- err := render.DecodeJSON(rr.Body, &user)
- if err != nil {
- t.Errorf("Error get user: %v", err)
- }
- req, _ = http.NewRequest(http.MethodGet, userPath+"?limit=510&offset=0&order=ASC&username="+defaultUsername, nil)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr.Code)
- var users []dataprovider.User
- err = render.DecodeJSON(rr.Body, &users)
- if err != nil {
- t.Errorf("Error decoding users: %v", err)
- }
- if len(users) != 1 {
- t.Errorf("1 user is expected")
- }
- req, _ = http.NewRequest(http.MethodGet, userPath+"?limit=a&offset=0&order=ASC", nil)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusBadRequest, rr.Code)
- req, _ = http.NewRequest(http.MethodGet, userPath+"?limit=1&offset=a&order=ASC", nil)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusBadRequest, rr.Code)
- req, _ = http.NewRequest(http.MethodGet, userPath+"?limit=1&offset=0&order=ASCa", nil)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusBadRequest, rr.Code)
-
- req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr.Code)
-}
-
-func TestDeleteUserInvalidParamsMock(t *testing.T) {
- req, _ := http.NewRequest(http.MethodDelete, userPath+"/0", nil)
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusNotFound, rr.Code)
- req, _ = http.NewRequest(http.MethodDelete, userPath+"/a", nil)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusBadRequest, rr.Code)
-}
-
-func TestGetQuotaScansMock(t *testing.T) {
- req, err := http.NewRequest("GET", quotaScanPath, nil)
- if err != nil {
- t.Errorf("error get quota scan: %v", err)
- }
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr.Code)
-}
-
-func TestStartQuotaScanMock(t *testing.T) {
- user := getTestUser()
- userAsJSON := getUserAsJSON(t, user)
- req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON))
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr.Code)
- err := render.DecodeJSON(rr.Body, &user)
- if err != nil {
- t.Errorf("Error get user: %v", err)
- }
- _, err = os.Stat(user.HomeDir)
- if err == nil {
- os.Remove(user.HomeDir)
- }
- // simulate a duplicate quota scan
- userAsJSON = getUserAsJSON(t, user)
- sftpd.AddQuotaScan(user.Username)
- req, _ = http.NewRequest(http.MethodPost, quotaScanPath, bytes.NewBuffer(userAsJSON))
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusConflict, rr.Code)
- sftpd.RemoveQuotaScan(user.Username)
-
- userAsJSON = getUserAsJSON(t, user)
- req, _ = http.NewRequest(http.MethodPost, quotaScanPath, bytes.NewBuffer(userAsJSON))
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusCreated, rr.Code)
-
- req, _ = http.NewRequest(http.MethodGet, quotaScanPath, nil)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr.Code)
- var scans []sftpd.ActiveQuotaScan
- err = render.DecodeJSON(rr.Body, &scans)
- if err != nil {
- t.Errorf("Error get active scans: %v", err)
- }
- for len(scans) > 0 {
- req, _ = http.NewRequest(http.MethodGet, quotaScanPath, nil)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr.Code)
- err = render.DecodeJSON(rr.Body, &scans)
- if err != nil {
- t.Errorf("Error get active scans: %v", err)
- break
- }
- }
- _, err = os.Stat(user.HomeDir)
- if err != nil && os.IsNotExist(err) {
- os.MkdirAll(user.HomeDir, 0777)
- }
- req, _ = http.NewRequest(http.MethodPost, quotaScanPath, bytes.NewBuffer(userAsJSON))
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusCreated, rr.Code)
- req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr.Code)
-}
-
-func TestStartQuotaScanBadUserMock(t *testing.T) {
- user := getTestUser()
- userAsJSON := getUserAsJSON(t, user)
- req, _ := http.NewRequest(http.MethodPost, quotaScanPath, bytes.NewBuffer(userAsJSON))
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusNotFound, rr.Code)
-}
-
-func TestStartQuotaScanNonExistentUserMock(t *testing.T) {
- req, _ := http.NewRequest(http.MethodPost, quotaScanPath, bytes.NewBuffer([]byte("invalid json")))
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusBadRequest, rr.Code)
-}
-
-func TestGetVersionMock(t *testing.T) {
- req, _ := http.NewRequest(http.MethodGet, versionPath, nil)
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr.Code)
-}
-
-func TestGetConnectionsMock(t *testing.T) {
- req, _ := http.NewRequest(http.MethodGet, activeConnectionsPath, nil)
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr.Code)
-}
-
-func TestDeleteActiveConnectionMock(t *testing.T) {
- req, _ := http.NewRequest(http.MethodDelete, activeConnectionsPath+"/connectionID", nil)
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusNotFound, rr.Code)
-}
-
-func TestNotFoundMock(t *testing.T) {
- req, _ := http.NewRequest(http.MethodGet, "/non/existing/path", nil)
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusNotFound, rr.Code)
-}
-
-func TestMethodNotAllowedMock(t *testing.T) {
- req, _ := http.NewRequest(http.MethodPost, activeConnectionsPath, nil)
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusMethodNotAllowed, rr.Code)
-}
-
-func waitTCPListening(address string) {
- for {
- conn, err := net.Dial("tcp", address)
- if err != nil {
- logger.WarnToConsole("tcp server %v not listening: %v\n", address, err)
- time.Sleep(100 * time.Millisecond)
- continue
- }
- logger.InfoToConsole("tcp server %v now listening\n", address)
- defer conn.Close()
- break
- }
-}
-
-func getTestUser() dataprovider.User {
- return dataprovider.User{
- Username: defaultUsername,
- Password: defaultPassword,
- HomeDir: filepath.Join(homeBasePath, defaultUsername),
- Permissions: defaultPerms,
- }
-}
-
-func getUserAsJSON(t *testing.T, user dataprovider.User) []byte {
- json, err := json.Marshal(user)
- if err != nil {
- t.Errorf("error get user as json: %v", err)
- return []byte("{}")
- }
- return json
-}
-
-func executeRequest(req *http.Request) *httptest.ResponseRecorder {
- rr := httptest.NewRecorder()
- testServer.Config.Handler.ServeHTTP(rr, req)
- return rr
-}
-
-func checkResponseCode(t *testing.T, expected, actual int) {
- if expected != actual {
- t.Errorf("Expected response code %d. Got %d", expected, actual)
- }
-}
diff --git a/api/api_utils.go b/api/api_utils.go
deleted file mode 100644
index 14bba528..00000000
--- a/api/api_utils.go
+++ /dev/null
@@ -1,330 +0,0 @@
-package api
-
-import (
- "bytes"
- "encoding/json"
- "errors"
- "fmt"
- "io/ioutil"
- "net/http"
- "net/url"
- "path"
- "strconv"
- "strings"
- "time"
-
- "github.com/drakkan/sftpgo/dataprovider"
- "github.com/drakkan/sftpgo/sftpd"
- "github.com/drakkan/sftpgo/utils"
- "github.com/go-chi/render"
-)
-
-var (
- httpBaseURL = "http://127.0.0.1:8080"
-)
-
-// SetBaseURL sets the base url to use for HTTP requests, default is "http://127.0.0.1:8080"
-func SetBaseURL(url string) {
- httpBaseURL = url
-}
-
-// gets an HTTP Client with a timeout
-func getHTTPClient() *http.Client {
- return &http.Client{
- Timeout: 15 * time.Second,
- }
-}
-
-func buildURLRelativeToBase(paths ...string) string {
- // we need to use path.Join and not filepath.Join
- // since filepath.Join will use backslash separator on Windows
- p := path.Join(paths...)
- return fmt.Sprintf("%s/%s", strings.TrimRight(httpBaseURL, "/"), strings.TrimLeft(p, "/"))
-}
-
-// AddUser adds a new user and checks the received HTTP Status code against expectedStatusCode.
-func AddUser(user dataprovider.User, expectedStatusCode int) (dataprovider.User, []byte, error) {
- var newUser dataprovider.User
- var body []byte
- userAsJSON, err := json.Marshal(user)
- if err != nil {
- return newUser, body, err
- }
- resp, err := getHTTPClient().Post(buildURLRelativeToBase(userPath), "application/json", bytes.NewBuffer(userAsJSON))
- if err != nil {
- return newUser, body, err
- }
- defer resp.Body.Close()
- err = checkResponse(resp.StatusCode, expectedStatusCode)
- if expectedStatusCode != http.StatusOK {
- body, _ = getResponseBody(resp)
- return newUser, body, err
- }
- if err == nil {
- err = render.DecodeJSON(resp.Body, &newUser)
- } else {
- body, _ = getResponseBody(resp)
- }
- if err == nil {
- err = checkUser(user, newUser)
- }
- return newUser, body, err
-}
-
-// UpdateUser updates an existing user and checks the received HTTP Status code against expectedStatusCode.
-func UpdateUser(user dataprovider.User, expectedStatusCode int) (dataprovider.User, []byte, error) {
- var newUser dataprovider.User
- var body []byte
- userAsJSON, err := json.Marshal(user)
- if err != nil {
- return user, body, err
- }
- req, err := http.NewRequest(http.MethodPut, buildURLRelativeToBase(userPath, strconv.FormatInt(user.ID, 10)),
- bytes.NewBuffer(userAsJSON))
- if err != nil {
- return user, body, err
- }
- resp, err := getHTTPClient().Do(req)
- if err != nil {
- return user, body, err
- }
- defer resp.Body.Close()
- body, _ = getResponseBody(resp)
- err = checkResponse(resp.StatusCode, expectedStatusCode)
- if expectedStatusCode != http.StatusOK {
- return newUser, body, err
- }
- if err == nil {
- newUser, body, err = GetUserByID(user.ID, expectedStatusCode)
- }
- if err == nil {
- err = checkUser(user, newUser)
- }
- return newUser, body, err
-}
-
-// RemoveUser removes an existing user and checks the received HTTP Status code against expectedStatusCode.
-func RemoveUser(user dataprovider.User, expectedStatusCode int) ([]byte, error) {
- var body []byte
- req, err := http.NewRequest(http.MethodDelete, buildURLRelativeToBase(userPath, strconv.FormatInt(user.ID, 10)), nil)
- if err != nil {
- return body, err
- }
- resp, err := getHTTPClient().Do(req)
- if err != nil {
- return body, err
- }
- defer resp.Body.Close()
- body, _ = getResponseBody(resp)
- return body, checkResponse(resp.StatusCode, expectedStatusCode)
-}
-
-// GetUserByID gets an user by database id and checks the received HTTP Status code against expectedStatusCode.
-func GetUserByID(userID int64, expectedStatusCode int) (dataprovider.User, []byte, error) {
- var user dataprovider.User
- var body []byte
- resp, err := getHTTPClient().Get(buildURLRelativeToBase(userPath, strconv.FormatInt(userID, 10)))
- if err != nil {
- return user, body, err
- }
- defer resp.Body.Close()
- err = checkResponse(resp.StatusCode, expectedStatusCode)
- if err == nil && expectedStatusCode == http.StatusOK {
- err = render.DecodeJSON(resp.Body, &user)
- } else {
- body, _ = getResponseBody(resp)
- }
- return user, body, err
-}
-
-// GetUsers allows to get a list of users and checks the received HTTP Status code against expectedStatusCode.
-// The number of results can be limited specifying a limit.
-// Some results can be skipped specifying an offset.
-// The results can be filtered specifying an username, the username filter is an exact match
-func GetUsers(limit int64, offset int64, username string, expectedStatusCode int) ([]dataprovider.User, []byte, error) {
- var users []dataprovider.User
- var body []byte
- url, err := url.Parse(buildURLRelativeToBase(userPath))
- if err != nil {
- return users, body, err
- }
- q := url.Query()
- if limit > 0 {
- q.Add("limit", strconv.FormatInt(limit, 10))
- }
- if offset > 0 {
- q.Add("offset", strconv.FormatInt(offset, 10))
- }
- if len(username) > 0 {
- q.Add("username", username)
- }
- url.RawQuery = q.Encode()
- resp, err := getHTTPClient().Get(url.String())
- if err != nil {
- return users, body, err
- }
- defer resp.Body.Close()
- err = checkResponse(resp.StatusCode, expectedStatusCode)
- if err == nil && expectedStatusCode == http.StatusOK {
- err = render.DecodeJSON(resp.Body, &users)
- } else {
- body, _ = getResponseBody(resp)
- }
- return users, body, err
-}
-
-// GetQuotaScans gets active quota scans and checks the received HTTP Status code against expectedStatusCode.
-func GetQuotaScans(expectedStatusCode int) ([]sftpd.ActiveQuotaScan, []byte, error) {
- var quotaScans []sftpd.ActiveQuotaScan
- var body []byte
- resp, err := getHTTPClient().Get(buildURLRelativeToBase(quotaScanPath))
- if err != nil {
- return quotaScans, body, err
- }
- defer resp.Body.Close()
- err = checkResponse(resp.StatusCode, expectedStatusCode)
- if err == nil && expectedStatusCode == http.StatusOK {
- err = render.DecodeJSON(resp.Body, "aScans)
- } else {
- body, _ = getResponseBody(resp)
- }
- return quotaScans, body, err
-}
-
-// StartQuotaScan start a new quota scan for the given user and checks the received HTTP Status code against expectedStatusCode.
-func StartQuotaScan(user dataprovider.User, expectedStatusCode int) ([]byte, error) {
- var body []byte
- userAsJSON, err := json.Marshal(user)
- if err != nil {
- return body, err
- }
- resp, err := getHTTPClient().Post(buildURLRelativeToBase(quotaScanPath), "application/json", bytes.NewBuffer(userAsJSON))
- if err != nil {
- return body, err
- }
- defer resp.Body.Close()
- body, _ = getResponseBody(resp)
- return body, checkResponse(resp.StatusCode, expectedStatusCode)
-}
-
-// GetConnections returns status and stats for active SFTP/SCP connections
-func GetConnections(expectedStatusCode int) ([]sftpd.ConnectionStatus, []byte, error) {
- var connections []sftpd.ConnectionStatus
- var body []byte
- resp, err := getHTTPClient().Get(buildURLRelativeToBase(activeConnectionsPath))
- if err != nil {
- return connections, body, err
- }
- defer resp.Body.Close()
- err = checkResponse(resp.StatusCode, expectedStatusCode)
- if err == nil && expectedStatusCode == http.StatusOK {
- err = render.DecodeJSON(resp.Body, &connections)
- } else {
- body, _ = getResponseBody(resp)
- }
- return connections, body, err
-}
-
-// CloseConnection closes an active connection identified by connectionID
-func CloseConnection(connectionID string, expectedStatusCode int) ([]byte, error) {
- var body []byte
- req, err := http.NewRequest(http.MethodDelete, buildURLRelativeToBase(activeConnectionsPath, connectionID), nil)
- if err != nil {
- return body, err
- }
- resp, err := getHTTPClient().Do(req)
- if err != nil {
- return body, err
- }
- defer resp.Body.Close()
- err = checkResponse(resp.StatusCode, expectedStatusCode)
- body, _ = getResponseBody(resp)
- return body, err
-}
-
-// GetVersion returns version details
-func GetVersion(expectedStatusCode int) (utils.VersionInfo, []byte, error) {
- var version utils.VersionInfo
- var body []byte
- resp, err := getHTTPClient().Get(buildURLRelativeToBase(versionPath))
- if err != nil {
- return version, body, err
- }
- defer resp.Body.Close()
- err = checkResponse(resp.StatusCode, expectedStatusCode)
- if err == nil && expectedStatusCode == http.StatusOK {
- err = render.DecodeJSON(resp.Body, &version)
- } else {
- body, _ = getResponseBody(resp)
- }
- return version, body, err
-}
-
-func checkResponse(actual int, expected int) error {
- if expected != actual {
- return fmt.Errorf("wrong status code: got %v want %v", actual, expected)
- }
- return nil
-}
-
-func getResponseBody(resp *http.Response) ([]byte, error) {
- return ioutil.ReadAll(resp.Body)
-}
-
-func checkUser(expected dataprovider.User, actual dataprovider.User) error {
- if len(actual.Password) > 0 {
- return errors.New("User password must not be visible")
- }
- if len(actual.PublicKeys) > 0 {
- return errors.New("User public keys must not be visible")
- }
- if expected.ID <= 0 {
- if actual.ID <= 0 {
- return errors.New("actual user ID must be > 0")
- }
- } else {
- if actual.ID != expected.ID {
- return errors.New("user ID mismatch")
- }
- }
- for _, v := range expected.Permissions {
- if !utils.IsStringInSlice(v, actual.Permissions) {
- return errors.New("Permissions contents mismatch")
- }
- }
- return compareEqualsUserFields(expected, actual)
-}
-
-func compareEqualsUserFields(expected dataprovider.User, actual dataprovider.User) error {
- if expected.Username != actual.Username {
- return errors.New("Username mismatch")
- }
- if expected.HomeDir != actual.HomeDir {
- return errors.New("HomeDir mismatch")
- }
- if expected.UID != actual.UID {
- return errors.New("UID mismatch")
- }
- if expected.GID != actual.GID {
- return errors.New("GID mismatch")
- }
- if expected.MaxSessions != actual.MaxSessions {
- return errors.New("MaxSessions mismatch")
- }
- if expected.QuotaSize != actual.QuotaSize {
- return errors.New("QuotaSize mismatch")
- }
- if expected.QuotaFiles != actual.QuotaFiles {
- return errors.New("QuotaFiles mismatch")
- }
- if len(expected.Permissions) != len(actual.Permissions) {
- return errors.New("Permissions mismatch")
- }
- if expected.UploadBandwidth != actual.UploadBandwidth {
- return errors.New("UploadBandwidth mismatch")
- }
- if expected.DownloadBandwidth != actual.DownloadBandwidth {
- return errors.New("DownloadBandwidth mismatch")
- }
- return nil
-}
diff --git a/api/internal_test.go b/api/internal_test.go
deleted file mode 100644
index 02c3d70d..00000000
--- a/api/internal_test.go
+++ /dev/null
@@ -1,228 +0,0 @@
-package api
-
-import (
- "context"
- "fmt"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/drakkan/sftpgo/dataprovider"
- "github.com/go-chi/chi"
-)
-
-const (
- invalidURL = "http://foo\x7f.com/"
- inactiveURL = "http://127.0.0.1:12345"
-)
-
-func TestGetRespStatus(t *testing.T) {
- var err error
- err = &dataprovider.MethodDisabledError{}
- respStatus := getRespStatus(err)
- if respStatus != http.StatusForbidden {
- t.Errorf("wrong resp status extected: %d got: %d", http.StatusForbidden, respStatus)
- }
- err = fmt.Errorf("generic error")
- respStatus = getRespStatus(err)
- if respStatus != http.StatusInternalServerError {
- t.Errorf("wrong resp status extected: %d got: %d", http.StatusInternalServerError, respStatus)
- }
-}
-
-func TestCheckResponse(t *testing.T) {
- err := checkResponse(http.StatusOK, http.StatusCreated)
- if err == nil {
- t.Errorf("check must fail")
- }
- err = checkResponse(http.StatusBadRequest, http.StatusBadRequest)
- if err != nil {
- t.Errorf("test must succeed, error: %v", err)
- }
-}
-
-func TestCheckUser(t *testing.T) {
- expected := dataprovider.User{}
- actual := dataprovider.User{}
- actual.Password = "password"
- err := checkUser(expected, actual)
- if err == nil {
- t.Errorf("actual password must be nil")
- }
- actual.Password = ""
- actual.PublicKeys = []string{"pub key"}
- err = checkUser(expected, actual)
- if err == nil {
- t.Errorf("actual public key must be nil")
- }
- actual.PublicKeys = []string{}
- err = checkUser(expected, actual)
- if err == nil {
- t.Errorf("actual ID must be > 0")
- }
- expected.ID = 1
- actual.ID = 2
- err = checkUser(expected, actual)
- if err == nil {
- t.Errorf("actual ID must be equal to expected ID")
- }
- expected.ID = 2
- actual.ID = 2
- expected.Permissions = []string{dataprovider.PermCreateDirs, dataprovider.PermDelete, dataprovider.PermDownload}
- actual.Permissions = []string{dataprovider.PermCreateDirs, dataprovider.PermCreateSymlinks}
- err = checkUser(expected, actual)
- if err == nil {
- t.Errorf("Permissions are not equal")
- }
- expected.Permissions = append(expected.Permissions, dataprovider.PermRename)
- err = checkUser(expected, actual)
- if err == nil {
- t.Errorf("Permissions are not equal")
- }
-}
-
-func TestCompareUserFields(t *testing.T) {
- expected := dataprovider.User{}
- actual := dataprovider.User{}
- expected.Username = "test"
- err := compareEqualsUserFields(expected, actual)
- if err == nil {
- t.Errorf("Username does not match")
- }
- expected.Username = ""
- expected.HomeDir = "homedir"
- err = compareEqualsUserFields(expected, actual)
- if err == nil {
- t.Errorf("HomeDir does not match")
- }
- expected.HomeDir = ""
- expected.UID = 1
- err = compareEqualsUserFields(expected, actual)
- if err == nil {
- t.Errorf("UID does not match")
- }
- expected.UID = 0
- expected.GID = 1
- err = compareEqualsUserFields(expected, actual)
- if err == nil {
- t.Errorf("GID does not match")
- }
- expected.GID = 0
- expected.MaxSessions = 2
- err = compareEqualsUserFields(expected, actual)
- if err == nil {
- t.Errorf("MaxSessions do not match")
- }
- expected.MaxSessions = 0
- expected.QuotaSize = 4096
- err = compareEqualsUserFields(expected, actual)
- if err == nil {
- t.Errorf("QuotaSize does not match")
- }
- expected.QuotaSize = 0
- expected.QuotaFiles = 2
- err = compareEqualsUserFields(expected, actual)
- if err == nil {
- t.Errorf("QuotaFiles do not match")
- }
- expected.QuotaFiles = 0
- expected.Permissions = []string{dataprovider.PermCreateDirs}
- err = compareEqualsUserFields(expected, actual)
- if err == nil {
- t.Errorf("Permissions are not equal")
- }
- expected.Permissions = nil
- expected.UploadBandwidth = 64
- err = compareEqualsUserFields(expected, actual)
- if err == nil {
- t.Errorf("UploadBandwidth does not match")
- }
- expected.UploadBandwidth = 0
- expected.DownloadBandwidth = 128
- err = compareEqualsUserFields(expected, actual)
- if err == nil {
- t.Errorf("DownloadBandwidth does not match")
- }
-}
-
-func TestApiCallsWithBadURL(t *testing.T) {
- oldBaseURL := httpBaseURL
- SetBaseURL(invalidURL)
- u := dataprovider.User{}
- _, _, err := UpdateUser(u, http.StatusBadRequest)
- if err == nil {
- t.Errorf("request with invalid URL must fail")
- }
- _, err = RemoveUser(u, http.StatusNotFound)
- if err == nil {
- t.Errorf("request with invalid URL must fail")
- }
- _, _, err = GetUsers(1, 0, "", http.StatusBadRequest)
- if err == nil {
- t.Errorf("request with invalid URL must fail")
- }
- _, err = CloseConnection("non_existent_id", http.StatusNotFound)
- if err == nil {
- t.Errorf("request with invalid URL must fail")
- }
- SetBaseURL(oldBaseURL)
-}
-
-func TestApiCallToNotListeningServer(t *testing.T) {
- oldBaseURL := httpBaseURL
- SetBaseURL(inactiveURL)
- u := dataprovider.User{}
- _, _, err := AddUser(u, http.StatusBadRequest)
- if err == nil {
- t.Errorf("request to an inactive URL must fail")
- }
- _, _, err = UpdateUser(u, http.StatusNotFound)
- if err == nil {
- t.Errorf("request to an inactive URL must fail")
- }
- _, err = RemoveUser(u, http.StatusNotFound)
- if err == nil {
- t.Errorf("request to an inactive URL must fail")
- }
- _, _, err = GetUserByID(-1, http.StatusNotFound)
- if err == nil {
- t.Errorf("request to an inactive URL must fail")
- }
- _, _, err = GetUsers(100, 0, "", http.StatusOK)
- if err == nil {
- t.Errorf("request to an inactive URL must fail")
- }
- _, _, err = GetQuotaScans(http.StatusOK)
- if err == nil {
- t.Errorf("request to an inactive URL must fail")
- }
- _, err = StartQuotaScan(u, http.StatusNotFound)
- if err == nil {
- t.Errorf("request to an inactive URL must fail")
- }
- _, _, err = GetConnections(http.StatusOK)
- if err == nil {
- t.Errorf("request to an inactive URL must fail")
- }
- _, err = CloseConnection("non_existent_id", http.StatusNotFound)
- if err == nil {
- t.Errorf("request to an inactive URL must fail")
- }
- _, _, err = GetVersion(http.StatusOK)
- if err == nil {
- t.Errorf("request to an inactive URL must fail")
- }
- SetBaseURL(oldBaseURL)
-}
-
-func TestCloseConnectionHandler(t *testing.T) {
- req, _ := http.NewRequest(http.MethodDelete, activeConnectionsPath+"/connectionID", nil)
- rctx := chi.NewRouteContext()
- rctx.URLParams.Add("connectionID", "")
- req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
- rr := httptest.NewRecorder()
- handleCloseConnection(rr, req)
- if rr.Code != http.StatusBadRequest {
- t.Errorf("Expected response code 400. Got %d", rr.Code)
- }
-}
diff --git a/api/quota.go b/api/quota.go
deleted file mode 100644
index c930f643..00000000
--- a/api/quota.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package api
-
-import (
- "net/http"
-
- "github.com/drakkan/sftpgo/dataprovider"
- "github.com/drakkan/sftpgo/logger"
- "github.com/drakkan/sftpgo/sftpd"
- "github.com/drakkan/sftpgo/utils"
- "github.com/go-chi/render"
-)
-
-func getQuotaScans(w http.ResponseWriter, r *http.Request) {
- render.JSON(w, r, sftpd.GetQuotaScans())
-}
-
-func startQuotaScan(w http.ResponseWriter, r *http.Request) {
- var u dataprovider.User
- err := render.DecodeJSON(r.Body, &u)
- if err != nil {
- sendAPIResponse(w, r, err, "", http.StatusBadRequest)
- return
- }
- user, err := dataprovider.UserExists(dataProvider, u.Username)
- if err != nil {
- sendAPIResponse(w, r, err, "", http.StatusNotFound)
- return
- }
- if sftpd.AddQuotaScan(user.Username) {
- sendAPIResponse(w, r, err, "Scan started", http.StatusCreated)
- go func() {
- numFiles, size, _, err := utils.ScanDirContents(user.HomeDir)
- if err != nil {
- logger.Warn(logSender, "error scanning user home dir %v: %v", user.HomeDir, err)
- } else {
- err := dataprovider.UpdateUserQuota(dataProvider, user, numFiles, size, true)
- logger.Debug(logSender, "user dir scanned, user: %v, dir: %v, error: %v", user.Username, user.HomeDir, err)
- }
- sftpd.RemoveQuotaScan(user.Username)
- }()
- } else {
- sendAPIResponse(w, r, err, "Another scan is already in progress", http.StatusConflict)
- }
-}
diff --git a/api/router.go b/api/router.go
deleted file mode 100644
index bd58668f..00000000
--- a/api/router.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package api
-
-import (
- "net/http"
-
- "github.com/drakkan/sftpgo/logger"
- "github.com/drakkan/sftpgo/sftpd"
- "github.com/drakkan/sftpgo/utils"
- "github.com/go-chi/chi"
- "github.com/go-chi/chi/middleware"
- "github.com/go-chi/render"
-)
-
-// GetHTTPRouter returns the configured HTTP handler
-func GetHTTPRouter() http.Handler {
- return router
-}
-
-func initializeRouter() {
- router = chi.NewRouter()
- router.Use(middleware.RequestID)
- router.Use(middleware.RealIP)
- router.Use(logger.NewStructuredLogger(logger.GetLogger()))
- router.Use(middleware.Recoverer)
-
- router.NotFound(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- sendAPIResponse(w, r, nil, "Not Found", http.StatusNotFound)
- }))
-
- router.MethodNotAllowed(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- sendAPIResponse(w, r, nil, "Method not allowed", http.StatusMethodNotAllowed)
- }))
-
- router.Get(versionPath, func(w http.ResponseWriter, r *http.Request) {
- render.JSON(w, r, utils.GetAppVersion())
- })
-
- router.Get(activeConnectionsPath, func(w http.ResponseWriter, r *http.Request) {
- render.JSON(w, r, sftpd.GetConnectionsStats())
- })
-
- router.Delete(activeConnectionsPath+"/{connectionID}", func(w http.ResponseWriter, r *http.Request) {
- handleCloseConnection(w, r)
- })
-
- router.Get(quotaScanPath, func(w http.ResponseWriter, r *http.Request) {
- getQuotaScans(w, r)
- })
-
- router.Post(quotaScanPath, func(w http.ResponseWriter, r *http.Request) {
- startQuotaScan(w, r)
- })
-
- router.Get(userPath, func(w http.ResponseWriter, r *http.Request) {
- getUsers(w, r)
- })
-
- router.Post(userPath, func(w http.ResponseWriter, r *http.Request) {
- addUser(w, r)
- })
-
- router.Get(userPath+"/{userID}", func(w http.ResponseWriter, r *http.Request) {
- getUserByID(w, r)
- })
-
- router.Put(userPath+"/{userID}", func(w http.ResponseWriter, r *http.Request) {
- updateUser(w, r)
- })
-
- router.Delete(userPath+"/{userID}", func(w http.ResponseWriter, r *http.Request) {
- deleteUser(w, r)
- })
-}
-
-func handleCloseConnection(w http.ResponseWriter, r *http.Request) {
- connectionID := chi.URLParam(r, "connectionID")
- if connectionID == "" {
- sendAPIResponse(w, r, nil, "connectionID is mandatory", http.StatusBadRequest)
- return
- }
- if sftpd.CloseActiveConnection(connectionID) {
- sendAPIResponse(w, r, nil, "Connection closed", http.StatusOK)
- } else {
- sendAPIResponse(w, r, nil, "Not Found", http.StatusNotFound)
- }
-}
diff --git a/api/schema/openapi.yaml b/api/schema/openapi.yaml
deleted file mode 100644
index b79b9c31..00000000
--- a/api/schema/openapi.yaml
+++ /dev/null
@@ -1,689 +0,0 @@
-openapi: 3.0.1
-info:
- title: SFTPGo
- description: 'SFTPGo REST API'
- version: 1.0.0
-
-servers:
-- url: /api/v1
-paths:
- /version:
- get:
- tags:
- - version
- summary: Get version details
- operationId: get_version
- responses:
- 200:
- description: successful operation
- content:
- application/json:
- schema:
- type: array
- items:
- $ref : '#/components/schemas/VersionInfo'
- /connection:
- get:
- tags:
- - connections
- summary: Get the active users and info about their uploads/downloads
- operationId: get_connections
- responses:
- 200:
- description: successful operation
- content:
- application/json:
- schema:
- type: array
- items:
- $ref : '#/components/schemas/ConnectionStatus'
- /connection/{connectionID}:
- delete:
- tags:
- - connections
- summary: Terminate an active connection
- operationId: close_connection
- parameters:
- - name: connectionID
- in: path
- description: ID of the connection to close
- required: true
- schema:
- type: string
- responses:
- 200:
- description: successful operation
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 200
- message: "Connection closed"
- error: ""
- 400:
- description: Bad request
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 400
- message: ""
- error: "Error description if any"
- 404:
- description: Not Found
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 404
- message: ""
- error: "Error description if any"
- 500:
- description: Internal Server Error
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 500
- message: ""
- error: "Error description if any"
- /quota_scan:
- get:
- tags:
- - quota
- summary: Get the active quota scans
- operationId: get_quota_scans
- responses:
- 200:
- description: successful operation
- content:
- application/json:
- schema:
- type: array
- items:
- $ref : '#/components/schemas/QuotaScan'
- post:
- tags:
- - quota
- summary: start a new quota scan
- description: A quota scan update the number of files and their total size for the given user
- operationId: start_quota_scan
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref : '#/components/schemas/User'
- responses:
- 201:
- description: successful operation
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 201
- message: "Scan started"
- error: ""
- 400:
- description: Bad request
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 400
- message: ""
- error: "Error description if any"
- 403:
- description: Forbidden
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 403
- message: ""
- error: "Error description if any"
- 404:
- description: Not Found
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 404
- message: ""
- error: "Error description if any"
- 409:
- description: Another scan is already in progress for this user
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 409
- message: "Another scan is already in progress"
- error: "Error description if any"
- 500:
- description: Internal Server Error
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 500
- message: ""
- error: "Error description if any"
- /user:
- get:
- tags:
- - users
- summary: Returns an array with one or more users
- description: For security reasons password and public key are empty in the response
- operationId: get_users
- parameters:
- - in: query
- name: offset
- schema:
- type: integer
- minimum: 0
- default: 0
- required: false
- - in: query
- name: limit
- schema:
- type: integer
- minimum: 1
- maximum: 500
- default: 100
- required: false
- description: The maximum number of items to return. Max value is 500, default is 100
- - in: query
- name: order
- required: false
- description: Ordering users by username
- schema:
- type: string
- enum:
- - ASC
- - DESC
- example: ASC
- - in: query
- name: username
- required: false
- description: Filter by username, extact match case sensitive
- schema:
- type: string
- responses:
- 200:
- description: successful operation
- content:
- application/json:
- schema:
- type: array
- items:
- $ref : '#/components/schemas/User'
- 400:
- description: Bad request
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 400
- message: ""
- error: "Error description if any"
- 403:
- description: Forbidden
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 403
- message: ""
- error: "Error description if any"
- 500:
- description: Internal Server Error
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 500
- message: ""
- error: "Error description if any"
- post:
- tags:
- - users
- summary: Adds a new SFTP/SCP user
- operationId: add_user
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref : '#/components/schemas/User'
- responses:
- 200:
- description: successful operation
- content:
- application/json:
- schema:
- $ref : '#/components/schemas/User'
- 400:
- description: Bad request
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 400
- message: ""
- error: "Error description if any"
- 403:
- description: Forbidden
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 403
- message: ""
- error: "Error description if any"
- 500:
- description: Internal Server Error
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 500
- message: ""
- error: "Error description if any"
- /user/{userID}:
- get:
- tags:
- - users
- summary: Find user by ID
- description: For security reasons password and public key are empty in the response
- operationId: get_user_by_id
- parameters:
- - name: userID
- in: path
- description: ID of the user to retrieve
- required: true
- schema:
- type: integer
- format: int32
- responses:
- 200:
- description: successful operation
- content:
- application/json:
- schema:
- $ref : '#/components/schemas/User'
- 400:
- description: Bad request
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 400
- message: ""
- error: "Error description if any"
- 403:
- description: Forbidden
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 403
- message: ""
- error: "Error description if any"
- 404:
- description: Not Found
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 404
- message: ""
- error: "Error description if any"
- 500:
- description: Internal Server Error
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 500
- message: ""
- error: "Error description if any"
- put:
- tags:
- - users
- summary: Update an existing user
- operationId: update_user
- parameters:
- - name: userID
- in: path
- description: ID of the user to update
- required: true
- schema:
- type: integer
- format: int32
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref : '#/components/schemas/User'
- responses:
- 200:
- description: successful operation
- content:
- application/json:
- schema:
- $ref : '#/components/schemas/ApiResponse'
- example:
- status: 200
- message: "User updated"
- error: ""
- 400:
- description: Bad request
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 400
- message: ""
- error: "Error description if any"
- 403:
- description: Forbidden
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 403
- message: ""
- error: "Error description if any"
- 404:
- description: Not Found
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 404
- message: ""
- error: "Error description if any"
- 500:
- description: Internal Server Error
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 500
- message: ""
- error: "Error description if any"
- delete:
- tags:
- - users
- summary: Delete an existing user
- operationId: delete_user
- parameters:
- - name: userID
- in: path
- description: ID of the user to delete
- required: true
- schema:
- type: integer
- format: int32
- responses:
- 200:
- description: successful operation
- content:
- application/json:
- schema:
- $ref : '#/components/schemas/ApiResponse'
- example:
- status: 200
- message: "User deleted"
- error: ""
- 400:
- description: Bad request
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 400
- message: ""
- error: "Error description if any"
- 403:
- description: Forbidden
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 403
- message: ""
- error: "Error description if any"
- 404:
- description: Not Found
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 404
- message: ""
- error: "Error description if any"
- 500:
- description: Internal Server Error
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- status: 500
- message: ""
- error: "Error description if any"
-components:
- schemas:
- Permission:
- type: string
- enum:
- - '*'
- - list
- - download
- - upload
- - delete
- - rename
- - create_dirs
- - create_symlinks
- description: >
- Permissions:
- * `*` - all permission are granted
- * `list` - list items is allowed
- * `download` - download files is allowed
- * `upload` - upload files is allowed
- * `delete` - delete files or directories is allowed
- * `rename` - rename files or directories is allowed
- * `create_dirs` - create directories is allowed
- * `create_symlinks` - create links is allowed
- User:
- type: object
- properties:
- id:
- type: integer
- format: int32
- minimum: 1
- username:
- type: string
- password:
- type: string
- nullable: true
- description: password or public key are mandatory. If the password has no known hashing algo prefix it will be stored using argon2id. You can send a password hashed as bcrypt or pbkdf2 and it will be stored as is. For security reasons this field is omitted when you search/get users
- public_keys:
- type: array
- items:
- type: string
- nullable: true
- description: a password or at least one public key are mandatory. For security reasons this field is omitted when you search/get users.
- home_dir:
- type: string
- description: path to the user home directory. The user cannot upload or download files outside this directory. SFTPGo tries to automatically create this folder if missing. Must be an absolute path
- uid:
- type: integer
- format: int32
- minimum: 0
- maximum: 65535
- description: if you run sftpgo as root user the created files and directories will be assigned to this uid. 0 means no change, the owner will be the user that runs sftpgo. Ignored on windows
- gid:
- type: integer
- format: int32
- minimum: 0
- maximum: 65535
- description: if you run sftpgo as root user the created files and directories will be assigned to this gid. 0 means no change, the group will be the one of the user that runs sftpgo. Ignored on windows
- max_sessions:
- type: integer
- format: int32
- description: limit the sessions that an user can open. 0 means unlimited
- quota_size:
- type: integer
- format: int64
- description: quota as size. 0 menas unlimited. Please note that quota is updated if files are added/removed via SFTP/SCP otherwise a quota scan is needed
- quota_files:
- type: integer
- format: int32
- description: quota as number of files. 0 menas unlimited. Please note that quota is updated if files are added/removed via SFTP/SCP otherwise a quota scan is needed
- permissions:
- type: array
- items:
- $ref: '#/components/schemas/Permission'
- minItems: 1
- used_quota_size:
- type: integer
- format: int64
- used_quota_file:
- type: integer
- format: int32
- last_quota_update:
- type: integer
- format: int64
- description: last quota update as unix timestamp in milliseconds
- upload_bandwidth:
- type: integer
- format: int32
- description: Maximum upload bandwidth as KB/s, 0 means unlimited
- download_bandwidth:
- type: integer
- format: int32
- description: Maximum download bandwidth as KB/s, 0 means unlimited
- Transfer:
- type: object
- properties:
- operation_type:
- type: string
- enum:
- - upload
- - download
- path:
- type: string
- description: SFTP/SCP file path for the upload/download
- start_time:
- type: integer
- format: int64
- description: start time as unix timestamp in milliseconds
- size:
- type: integer
- format: int64
- description: bytes transferred
- last_activity:
- type: integer
- format: int64
- description: last transfer activity as unix timestamp in milliseconds
- ConnectionStatus:
- type: object
- properties:
- username:
- type: string
- description: connected username
- connection_id:
- type: string
- description: unique connection identifier
- client_version:
- type: string
- description: SFTP/SCP client version
- remote_address:
- type: string
- description: Remote address for the connected SFTP/SCP client
- connection_time:
- type: integer
- format: int64
- description: connection time as unix timestamp in milliseconds
- last_activity:
- type: integer
- format: int64
- description: last client activity as unix timestamp in milliseconds
- protocol:
- type: string
- enum:
- - SFTP
- - SCP
- active_transfers:
- type: array
- items:
- $ref : '#/components/schemas/Transfer'
- QuotaScan:
- type: object
- properties:
- username:
- type: string
- description: username with an active scan
- start_time:
- type: integer
- format: int64
- description: scan start time as unix timestamp in milliseconds
- ApiResponse:
- type: object
- properties:
- status:
- type: integer
- format: int32
- minimum: 200
- maximum: 500
- example: 200
- description: HTTP Status code, for example 200 OK, 400 Bad request and so on
- message:
- type: string
- nullable: true
- description: additional message if any
- error:
- type: string
- nullable: true
- description: error description if any
- VersionInfo:
- type: object
- properties:
- version:
- type: string
- build_date:
- type: string
- commit_hash:
- type: string
-
\ No newline at end of file
diff --git a/api/user.go b/api/user.go
deleted file mode 100644
index 719aa63d..00000000
--- a/api/user.go
+++ /dev/null
@@ -1,151 +0,0 @@
-package api
-
-import (
- "errors"
- "net/http"
- "strconv"
-
- "github.com/drakkan/sftpgo/dataprovider"
- "github.com/go-chi/chi"
- "github.com/go-chi/render"
-)
-
-func getUsers(w http.ResponseWriter, r *http.Request) {
- limit := 100
- offset := 0
- order := "ASC"
- username := ""
- var err error
- if _, ok := r.URL.Query()["limit"]; ok {
- limit, err = strconv.Atoi(r.URL.Query().Get("limit"))
- if err != nil {
- err = errors.New("Invalid limit")
- sendAPIResponse(w, r, err, "", http.StatusBadRequest)
- return
- }
- if limit > 500 {
- limit = 500
- }
- }
- if _, ok := r.URL.Query()["offset"]; ok {
- offset, err = strconv.Atoi(r.URL.Query().Get("offset"))
- if err != nil {
- err = errors.New("Invalid offset")
- sendAPIResponse(w, r, err, "", http.StatusBadRequest)
- return
- }
- }
- if _, ok := r.URL.Query()["order"]; ok {
- order = r.URL.Query().Get("order")
- if order != "ASC" && order != "DESC" {
- err = errors.New("Invalid order")
- sendAPIResponse(w, r, err, "", http.StatusBadRequest)
- return
- }
- }
- if _, ok := r.URL.Query()["username"]; ok {
- username = r.URL.Query().Get("username")
- }
- users, err := dataprovider.GetUsers(dataProvider, limit, offset, order, username)
- if err == nil {
- render.JSON(w, r, users)
- } else {
- sendAPIResponse(w, r, err, "", http.StatusInternalServerError)
- }
-}
-
-func getUserByID(w http.ResponseWriter, r *http.Request) {
- userID, err := strconv.ParseInt(chi.URLParam(r, "userID"), 10, 64)
- if err != nil {
- err = errors.New("Invalid userID")
- sendAPIResponse(w, r, err, "", http.StatusBadRequest)
- return
- }
- user, err := dataprovider.GetUserByID(dataProvider, userID)
- if err == nil {
- user.Password = ""
- user.PublicKeys = []string{}
- render.JSON(w, r, user)
- } else if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
- sendAPIResponse(w, r, err, "", http.StatusNotFound)
- } else {
- sendAPIResponse(w, r, err, "", http.StatusInternalServerError)
- }
-}
-
-func addUser(w http.ResponseWriter, r *http.Request) {
- var user dataprovider.User
- err := render.DecodeJSON(r.Body, &user)
- if err != nil {
- sendAPIResponse(w, r, err, "", http.StatusBadRequest)
- return
- }
- err = dataprovider.AddUser(dataProvider, user)
- if err == nil {
- user, err = dataprovider.UserExists(dataProvider, user.Username)
- if err == nil {
- user.Password = ""
- user.PublicKeys = []string{}
- render.JSON(w, r, user)
- } else {
- sendAPIResponse(w, r, err, "", http.StatusInternalServerError)
- }
- } else {
- sendAPIResponse(w, r, err, "", getRespStatus(err))
- }
-}
-
-func updateUser(w http.ResponseWriter, r *http.Request) {
- userID, err := strconv.ParseInt(chi.URLParam(r, "userID"), 10, 64)
- if err != nil {
- err = errors.New("Invalid userID")
- sendAPIResponse(w, r, err, "", http.StatusBadRequest)
- return
- }
- user, err := dataprovider.GetUserByID(dataProvider, userID)
- if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
- sendAPIResponse(w, r, err, "", http.StatusNotFound)
- return
- } else if err != nil {
- sendAPIResponse(w, r, err, "", http.StatusInternalServerError)
- return
- }
- err = render.DecodeJSON(r.Body, &user)
- if err != nil {
- sendAPIResponse(w, r, err, "", http.StatusBadRequest)
- return
- }
- if user.ID != userID {
- sendAPIResponse(w, r, err, "user ID in request body does not match user ID in path parameter", http.StatusBadRequest)
- return
- }
- err = dataprovider.UpdateUser(dataProvider, user)
- if err != nil {
- sendAPIResponse(w, r, err, "", getRespStatus(err))
- } else {
- sendAPIResponse(w, r, err, "User updated", http.StatusOK)
- }
-}
-
-func deleteUser(w http.ResponseWriter, r *http.Request) {
- userID, err := strconv.ParseInt(chi.URLParam(r, "userID"), 10, 64)
- if err != nil {
- err = errors.New("Invalid userID")
- sendAPIResponse(w, r, err, "", http.StatusBadRequest)
- return
- }
- user, err := dataprovider.GetUserByID(dataProvider, userID)
- if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
- sendAPIResponse(w, r, err, "", http.StatusNotFound)
- return
- } else if err != nil {
- sendAPIResponse(w, r, err, "", http.StatusInternalServerError)
- return
- }
- err = dataprovider.DeleteUser(dataProvider, user)
- if err != nil {
- sendAPIResponse(w, r, err, "", http.StatusInternalServerError)
- } else {
- sendAPIResponse(w, r, err, "User deleted", http.StatusOK)
- }
-}
diff --git a/cmd/root.go b/cmd/root.go
deleted file mode 100644
index 92414f05..00000000
--- a/cmd/root.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package cmd
-
-import (
- "fmt"
- "os"
-
- "github.com/drakkan/sftpgo/utils"
- "github.com/spf13/cobra"
-)
-
-const (
- logSender = "cmd"
-)
-
-var (
- rootCmd = &cobra.Command{
- Use: "sftpgo",
- Short: "Full featured and highly configurable SFTP server",
- }
-)
-
-func init() {
- version := utils.GetAppVersion()
- rootCmd.Flags().BoolP("version", "v", false, "")
- rootCmd.Version = version.GetVersionAsString()
- rootCmd.SetVersionTemplate(`{{printf "SFTPGo version: "}}{{printf "%s" .Version}}
-`)
-}
-
-// Execute adds all child commands to the root command and sets flags appropriately.
-// This is called by main.main(). It only needs to happen once to the rootCmd.
-func Execute() {
- if err := rootCmd.Execute(); err != nil {
- fmt.Println(err)
- os.Exit(1)
- }
-}
diff --git a/cmd/serve.go b/cmd/serve.go
deleted file mode 100644
index e3ed0d42..00000000
--- a/cmd/serve.go
+++ /dev/null
@@ -1,181 +0,0 @@
-package cmd
-
-import (
- "fmt"
- "net/http"
- "os"
- "time"
-
- "github.com/drakkan/sftpgo/api"
- "github.com/drakkan/sftpgo/config"
- "github.com/drakkan/sftpgo/dataprovider"
- "github.com/drakkan/sftpgo/logger"
- "github.com/drakkan/sftpgo/sftpd"
- "github.com/rs/zerolog"
- "github.com/spf13/cobra"
- "github.com/spf13/viper"
-)
-
-const (
- configDirFlag = "config-dir"
- configDirKey = "config_dir"
- configFileFlag = "config-file"
- configFileKey = "config_file"
- logFilePathFlag = "log-file-path"
- logFilePathKey = "log_file_path"
- logMaxSizeFlag = "log-max-size"
- logMaxSizeKey = "log_max_size"
- logMaxBackupFlag = "log-max-backups"
- logMaxBackupKey = "log_max_backups"
- logMaxAgeFlag = "log-max-age"
- logMaxAgeKey = "log_max_age"
- logCompressFlag = "log-compress"
- logCompressKey = "log_compress"
- logVerboseFlag = "log-verbose"
- logVerboseKey = "log_verbose"
-)
-
-var (
- configDir string
- configFile string
- logFilePath string
- logMaxSize int
- logMaxBackups int
- logMaxAge int
- logCompress bool
- logVerbose bool
- testVar string
- serveCmd = &cobra.Command{
- Use: "serve",
- Short: "Start the SFTP Server",
- Long: `To start the SFTP Server with the default values for the command line flags simply use:
-
-sftpgo serve
-
-Please take a look at the usage below to customize the startup options`,
- Run: func(cmd *cobra.Command, args []string) {
- startServe()
- },
- }
-)
-
-func init() {
- rootCmd.AddCommand(serveCmd)
-
- viper.SetDefault(configDirKey, ".")
- viper.BindEnv(configDirKey, "SFTPGO_CONFIG_DIR")
- serveCmd.Flags().StringVarP(&configDir, configDirFlag, "c", viper.GetString(configDirKey),
- "Location for SFTPGo config dir. This directory should contain the \"sftpgo\" configuration file or the configured "+
- "config-file and it is used as the base for files with a relative path (eg. the private keys for the SFTP server, "+
- "the SQLite database if you use SQLite as data provider). This flag can be set using SFTPGO_CONFIG_DIR env var too.")
- viper.BindPFlag(configDirKey, serveCmd.Flags().Lookup(configDirFlag))
-
- viper.SetDefault(configFileKey, config.DefaultConfigName)
- viper.BindEnv(configFileKey, "SFTPGO_CONFIG_FILE")
- serveCmd.Flags().StringVarP(&configFile, configFileFlag, "f", viper.GetString(configFileKey),
- "Name for SFTPGo configuration file. It must be the name of a file stored in config-dir not the absolute path to the "+
- "configuration file. The specified file name must have no extension we automatically load JSON, YAML, TOML, HCL and "+
- "Java properties. Therefore if you set \"sftpgo\" then \"sftpgo.json\", \"sftpgo.yaml\" and so on are searched. "+
- "This flag can be set using SFTPGO_CONFIG_FILE env var too.")
- viper.BindPFlag(configFileKey, serveCmd.Flags().Lookup(configFileFlag))
-
- viper.SetDefault(logFilePathKey, "sftpgo.log")
- viper.BindEnv(logFilePathKey, "SFTPGO_LOG_FILE_PATH")
- serveCmd.Flags().StringVarP(&logFilePath, logFilePathFlag, "l", viper.GetString(logFilePathKey),
- "Location for the log file. This flag can be set using SFTPGO_LOG_FILE_PATH env var too.")
- viper.BindPFlag(logFilePathKey, serveCmd.Flags().Lookup(logFilePathFlag))
-
- viper.SetDefault(logMaxSizeKey, 10)
- viper.BindEnv(logMaxSizeKey, "SFTPGO_LOG_MAX_SIZE")
- serveCmd.Flags().IntVarP(&logMaxSize, logMaxSizeFlag, "s", viper.GetInt(logMaxSizeKey),
- "Maximum size in megabytes of the log file before it gets rotated. This flag can be set using SFTPGO_LOG_MAX_SIZE "+
- "env var too.")
- viper.BindPFlag(logMaxSizeKey, serveCmd.Flags().Lookup(logMaxSizeFlag))
-
- viper.SetDefault(logMaxBackupKey, 5)
- viper.BindEnv(logMaxBackupKey, "SFTPGO_LOG_MAX_BACKUPS")
- serveCmd.Flags().IntVarP(&logMaxBackups, "log-max-backups", "b", viper.GetInt(logMaxBackupKey),
- "Maximum number of old log files to retain. This flag can be set using SFTPGO_LOG_MAX_BACKUPS env var too.")
- viper.BindPFlag(logMaxBackupKey, serveCmd.Flags().Lookup(logMaxBackupFlag))
-
- viper.SetDefault(logMaxAgeKey, 28)
- viper.BindEnv(logMaxAgeKey, "SFTPGO_LOG_MAX_AGE")
- serveCmd.Flags().IntVarP(&logMaxAge, "log-max-age", "a", viper.GetInt(logMaxAgeKey),
- "Maximum number of days to retain old log files. This flag can be set using SFTPGO_LOG_MAX_AGE env var too.")
- viper.BindPFlag(logMaxAgeKey, serveCmd.Flags().Lookup(logMaxAgeFlag))
-
- viper.SetDefault(logCompressKey, false)
- viper.BindEnv(logCompressKey, "SFTPGO_LOG_COMPRESS")
- serveCmd.Flags().BoolVarP(&logCompress, logCompressFlag, "z", viper.GetBool(logCompressKey), "Determine if the rotated "+
- "log files should be compressed using gzip. This flag can be set using SFTPGO_LOG_COMPRESS env var too.")
- viper.BindPFlag(logCompressKey, serveCmd.Flags().Lookup(logCompressFlag))
-
- viper.SetDefault(logVerboseKey, true)
- viper.BindEnv(logVerboseKey, "SFTPGO_LOG_VERBOSE")
- serveCmd.Flags().BoolVarP(&logVerbose, logVerboseFlag, "v", viper.GetBool(logVerboseKey), "Enable verbose logs. "+
- "This flag can be set using SFTPGO_LOG_VERBOSE env var too.")
- viper.BindPFlag(logVerboseKey, serveCmd.Flags().Lookup(logVerboseFlag))
-}
-
-func startServe() {
- logLevel := zerolog.DebugLevel
- if !logVerbose {
- logLevel = zerolog.InfoLevel
- }
- logger.InitLogger(logFilePath, logMaxSize, logMaxBackups, logMaxAge, logCompress, logLevel)
- logger.Info(logSender, "starting SFTPGo, config dir: %v, config file: %v, log max size: %v log max backups: %v "+
- "log max age: %v log verbose: %v, log compress: %v", configDir, configFile, logMaxSize, logMaxBackups, logMaxAge,
- logVerbose, logCompress)
- config.LoadConfig(configDir, configFile)
- providerConf := config.GetProviderConf()
-
- err := dataprovider.Initialize(providerConf, configDir)
- if err != nil {
- logger.Error(logSender, "error initializing data provider: %v", err)
- logger.ErrorToConsole("error initializing data provider: %v", err)
- os.Exit(1)
- }
-
- dataProvider := dataprovider.GetProvider()
- sftpdConf := config.GetSFTPDConfig()
- httpdConf := config.GetHTTPDConfig()
-
- sftpd.SetDataProvider(dataProvider)
-
- shutdown := make(chan bool)
-
- go func() {
- logger.Debug(logSender, "initializing SFTP server with config %+v", sftpdConf)
- if err := sftpdConf.Initialize(configDir); err != nil {
- logger.Error(logSender, "could not start SFTP server: %v", err)
- logger.ErrorToConsole("could not start SFTP server: %v", err)
- }
- shutdown <- true
- }()
-
- if httpdConf.BindPort > 0 {
- router := api.GetHTTPRouter()
- api.SetDataProvider(dataProvider)
-
- go func() {
- logger.Debug(logSender, "initializing HTTP server with config %+v", httpdConf)
- s := &http.Server{
- Addr: fmt.Sprintf("%s:%d", httpdConf.BindAddress, httpdConf.BindPort),
- Handler: router,
- ReadTimeout: 300 * time.Second,
- WriteTimeout: 300 * time.Second,
- MaxHeaderBytes: 1 << 20, // 1MB
- }
- if err := s.ListenAndServe(); err != nil {
- logger.Error(logSender, "could not start HTTP server: %v", err)
- logger.ErrorToConsole("could not start HTTP server: %v", err)
- }
- shutdown <- true
- }()
- } else {
- logger.Debug(logSender, "HTTP server not started, disabled in config file")
- logger.DebugToConsole("HTTP server not started, disabled in config file")
- }
-
- <-shutdown
-}
diff --git a/config/config.go b/config/config.go
deleted file mode 100644
index 86fba320..00000000
--- a/config/config.go
+++ /dev/null
@@ -1,134 +0,0 @@
-// Package config manages the configuration.
-// Configuration is loaded from sftpgo.conf file.
-// If sftpgo.conf is not found or cannot be readed or decoded as json the default configuration is used.
-// The default configuration an be found inside the source tree:
-// https://github.com/drakkan/sftpgo/blob/master/sftpgo.conf
-package config
-
-import (
- "fmt"
- "strings"
-
- "github.com/drakkan/sftpgo/api"
- "github.com/drakkan/sftpgo/dataprovider"
- "github.com/drakkan/sftpgo/logger"
- "github.com/drakkan/sftpgo/sftpd"
- "github.com/spf13/viper"
-)
-
-const (
- logSender = "config"
- defaultBanner = "SFTPGo"
- // DefaultConfigName defines the name for the default config file.
- // This is the file name without extension, we use viper and so we
- // support all the config files format supported by viper
- DefaultConfigName = "sftpgo"
- // ConfigEnvPrefix defines a prefix that ENVIRONMENT variables will use
- configEnvPrefix = "sftpgo"
-)
-
-var (
- globalConf globalConfig
-)
-
-type globalConfig struct {
- SFTPD sftpd.Configuration `json:"sftpd" mapstructure:"sftpd"`
- ProviderConf dataprovider.Config `json:"data_provider" mapstructure:"data_provider"`
- HTTPDConfig api.HTTPDConf `json:"httpd" mapstructure:"httpd"`
-}
-
-func init() {
- // create a default configuration to use if no config file is provided
- globalConf = globalConfig{
- SFTPD: sftpd.Configuration{
- Banner: defaultBanner,
- BindPort: 2022,
- BindAddress: "",
- IdleTimeout: 15,
- MaxAuthTries: 0,
- Umask: "0022",
- UploadMode: 0,
- Actions: sftpd.Actions{
- ExecuteOn: []string{},
- Command: "",
- HTTPNotificationURL: "",
- },
- Keys: []sftpd.Key{},
- IsSCPEnabled: false,
- },
- ProviderConf: dataprovider.Config{
- Driver: "sqlite",
- Name: "sftpgo.db",
- Host: "",
- Port: 5432,
- Username: "",
- Password: "",
- ConnectionString: "",
- UsersTable: "users",
- ManageUsers: 1,
- SSLMode: 0,
- TrackQuota: 1,
- },
- HTTPDConfig: api.HTTPDConf{
- BindPort: 8080,
- BindAddress: "127.0.0.1",
- },
- }
-
- viper.SetEnvPrefix(configEnvPrefix)
- replacer := strings.NewReplacer(".", "__")
- viper.SetEnvKeyReplacer(replacer)
- viper.SetConfigName(DefaultConfigName)
- setViperAdditionalConfigPaths()
- viper.AddConfigPath(".")
- viper.AutomaticEnv()
-}
-
-// GetSFTPDConfig returns the configuration for the SFTP server
-func GetSFTPDConfig() sftpd.Configuration {
- return globalConf.SFTPD
-}
-
-// GetHTTPDConfig returns the configuration for the HTTP server
-func GetHTTPDConfig() api.HTTPDConf {
- return globalConf.HTTPDConfig
-}
-
-//GetProviderConf returns the configuration for the data provider
-func GetProviderConf() dataprovider.Config {
- return globalConf.ProviderConf
-}
-
-// LoadConfig loads the configuration
-// configDir will be added to the configuration search paths.
-// The search path contains by default the current directory and on linux it contains
-// $HOME/.config/sftpgo and /etc/sftpgo too.
-// configName is the name of the configuration to search without extension
-func LoadConfig(configDir, configName string) error {
- var err error
- viper.AddConfigPath(configDir)
- viper.SetConfigName(configName)
- if err = viper.ReadInConfig(); err != nil {
- logger.Warn(logSender, "error loading configuration file: %v. Default configuration will be used: %+v", err, globalConf)
- logger.WarnToConsole("error loading configuration file: %v. Default configuration will be used.", err)
- return err
- }
- err = viper.Unmarshal(&globalConf)
- if err != nil {
- logger.Warn(logSender, "error parsing configuration file: %v. Default configuration will be used: %+v", err, globalConf)
- logger.WarnToConsole("error parsing configuration file: %v. Default configuration will be used.", err)
- return err
- }
- if strings.TrimSpace(globalConf.SFTPD.Banner) == "" {
- globalConf.SFTPD.Banner = defaultBanner
- }
- if globalConf.SFTPD.UploadMode < 0 || globalConf.SFTPD.UploadMode > 1 {
- err = fmt.Errorf("Invalid upload_mode 0 and 1 are supported, configured: %v reset upload_mode to 0",
- globalConf.SFTPD.UploadMode)
- globalConf.SFTPD.UploadMode = 0
- logger.Warn(logSender, "Configuration error: %v", err)
- logger.WarnToConsole("Configuration error: %v", err)
- }
- logger.Debug(logSender, "config file used: '%v', config loaded: %+v", viper.ConfigFileUsed(), globalConf)
- return err
-}
diff --git a/config/config_linux.go b/config/config_linux.go
deleted file mode 100644
index 967c2122..00000000
--- a/config/config_linux.go
+++ /dev/null
@@ -1,11 +0,0 @@
-// +build linux
-
-package config
-
-import "github.com/spf13/viper"
-
-// linux specific config search path
-func setViperAdditionalConfigPaths() {
- viper.AddConfigPath("$HOME/.config/sftpgo")
- viper.AddConfigPath("/etc/sftpgo")
-}
diff --git a/config/config_nolinux.go b/config/config_nolinux.go
deleted file mode 100644
index fe5d6aeb..00000000
--- a/config/config_nolinux.go
+++ /dev/null
@@ -1,7 +0,0 @@
-// +build !linux
-
-package config
-
-func setViperAdditionalConfigPaths() {
-
-}
diff --git a/config/config_test.go b/config/config_test.go
deleted file mode 100644
index d35f8186..00000000
--- a/config/config_test.go
+++ /dev/null
@@ -1,99 +0,0 @@
-package config_test
-
-import (
- "encoding/json"
- "io/ioutil"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/drakkan/sftpgo/api"
- "github.com/drakkan/sftpgo/config"
- "github.com/drakkan/sftpgo/dataprovider"
- "github.com/drakkan/sftpgo/sftpd"
-)
-
-const (
- tempConfigName = "temp"
-)
-
-func TestLoadConfigTest(t *testing.T) {
- configDir := ".."
- err := config.LoadConfig(configDir, "")
- if err != nil {
- t.Errorf("error loading config")
- }
- emptyHTTPDConf := api.HTTPDConf{}
- if config.GetHTTPDConfig() == emptyHTTPDConf {
- t.Errorf("error loading httpd conf")
- }
- emptyProviderConf := dataprovider.Config{}
- if config.GetProviderConf() == emptyProviderConf {
- t.Errorf("error loading provider conf")
- }
- emptySFTPDConf := sftpd.Configuration{}
- if config.GetSFTPDConfig().BindPort == emptySFTPDConf.BindPort {
- t.Errorf("error loading SFTPD conf")
- }
- confName := tempConfigName + ".json"
- configFilePath := filepath.Join(configDir, confName)
- err = config.LoadConfig(configDir, tempConfigName)
- if err == nil {
- t.Errorf("loading a non existent config file must fail")
- }
- ioutil.WriteFile(configFilePath, []byte("{invalid json}"), 0666)
- err = config.LoadConfig(configDir, tempConfigName)
- if err == nil {
- t.Errorf("loading an invalid config file must fail")
- }
- ioutil.WriteFile(configFilePath, []byte("{\"sftpd\": {\"bind_port\": \"a\"}}"), 0666)
- err = config.LoadConfig(configDir, tempConfigName)
- if err == nil {
- t.Errorf("loading a config with an invalid bond_port must fail")
- }
- os.Remove(configFilePath)
-}
-
-func TestEmptyBanner(t *testing.T) {
- configDir := ".."
- confName := tempConfigName + ".json"
- configFilePath := filepath.Join(configDir, confName)
- config.LoadConfig(configDir, "")
- sftpdConf := config.GetSFTPDConfig()
- sftpdConf.Banner = " "
- c := make(map[string]sftpd.Configuration)
- c["sftpd"] = sftpdConf
- jsonConf, _ := json.Marshal(c)
- err := ioutil.WriteFile(configFilePath, jsonConf, 0666)
- if err != nil {
- t.Errorf("error saving temporary configuration")
- }
- config.LoadConfig(configDir, tempConfigName)
- sftpdConf = config.GetSFTPDConfig()
- if strings.TrimSpace(sftpdConf.Banner) == "" {
- t.Errorf("SFTPD banner cannot be empty")
- }
- os.Remove(configFilePath)
-}
-
-func TestInvalidUploadMode(t *testing.T) {
- configDir := ".."
- confName := tempConfigName + ".json"
- configFilePath := filepath.Join(configDir, confName)
- config.LoadConfig(configDir, "")
- sftpdConf := config.GetSFTPDConfig()
- sftpdConf.UploadMode = 10
- c := make(map[string]sftpd.Configuration)
- c["sftpd"] = sftpdConf
- jsonConf, _ := json.Marshal(c)
- err := ioutil.WriteFile(configFilePath, jsonConf, 0666)
- if err != nil {
- t.Errorf("error saving temporary configuration")
- }
- err = config.LoadConfig(configDir, tempConfigName)
- if err == nil {
- t.Errorf("Loading configuration with invalid upload_mode must fail")
- }
- os.Remove(configFilePath)
-}
diff --git a/crowdin.yml b/crowdin.yml
new file mode 100644
index 00000000..d8e884c3
--- /dev/null
+++ b/crowdin.yml
@@ -0,0 +1,6 @@
+project_id_env: CROWDIN_PROJECT_ID
+api_token_env: CROWDIN_PERSONAL_TOKEN
+files:
+ - source: /static/locales/en/translation.json
+ translation: /static/locales/%two_letters_code%/%original_file_name%
+ type: i18next_json
diff --git a/dataprovider/bolt.go b/dataprovider/bolt.go
deleted file mode 100644
index b2ba61be..00000000
--- a/dataprovider/bolt.go
+++ /dev/null
@@ -1,315 +0,0 @@
-package dataprovider
-
-import (
- "encoding/binary"
- "encoding/json"
- "errors"
- "fmt"
- "path/filepath"
- "time"
-
- "github.com/drakkan/sftpgo/logger"
- "github.com/drakkan/sftpgo/utils"
- bolt "go.etcd.io/bbolt"
-)
-
-var (
- usersBucket = []byte("users")
- usersIDIdxBucket = []byte("users_id_idx")
-)
-
-// BoltProvider auth provider for bolt key/value store
-type BoltProvider struct {
- dbHandle *bolt.DB
-}
-
-func initializeBoltProvider(basePath string) error {
- var err error
- dbPath := config.Name
- if !filepath.IsAbs(dbPath) {
- dbPath = filepath.Join(basePath, dbPath)
- }
- dbHandle, err := bolt.Open(dbPath, 0600, &bolt.Options{
- NoGrowSync: false,
- FreelistType: bolt.FreelistArrayType,
- Timeout: 5 * time.Second})
- if err == nil {
- logger.Debug(logSender, "bolt key store handle created")
- err = dbHandle.Update(func(tx *bolt.Tx) error {
- _, e := tx.CreateBucketIfNotExists(usersBucket)
- return e
- })
- if err != nil {
- logger.Warn(logSender, "error creating users bucket: %v", err)
- return err
- }
- err = dbHandle.Update(func(tx *bolt.Tx) error {
- _, e := tx.CreateBucketIfNotExists(usersIDIdxBucket)
- return e
- })
- if err != nil {
- logger.Warn(logSender, "error creating username idx bucket: %v", err)
- return err
- }
- provider = BoltProvider{dbHandle: dbHandle}
- } else {
- logger.Warn(logSender, "error creating bolt key/value store handler: %v", err)
- }
- return err
-}
-
-func (p BoltProvider) validateUserAndPass(username string, password string) (User, error) {
- var user User
- if len(password) == 0 {
- return user, errors.New("Credentials cannot be null or empty")
- }
- user, err := p.userExists(username)
- if err != nil {
- logger.Warn(logSender, "error authenticating user: %v, error: %v", username, err)
- return user, err
- }
- return checkUserAndPass(user, password)
-}
-
-func (p BoltProvider) validateUserAndPubKey(username string, pubKey string) (User, error) {
- var user User
- if len(pubKey) == 0 {
- return user, errors.New("Credentials cannot be null or empty")
- }
- user, err := p.userExists(username)
- if err != nil {
- logger.Warn(logSender, "error authenticating user: %v, error: %v", username, err)
- return user, err
- }
- return checkUserAndPubKey(user, pubKey)
-}
-
-func (p BoltProvider) getUserByID(ID int64) (User, error) {
- var user User
- err := p.dbHandle.View(func(tx *bolt.Tx) error {
- bucket, idxBucket, err := getBuckets(tx)
- if err != nil {
- return err
- }
- userIDAsBytes := itob(ID)
- username := idxBucket.Get(userIDAsBytes)
- if username == nil {
- return &RecordNotFoundError{err: fmt.Sprintf("user with ID %v does not exist", ID)}
- }
- u := bucket.Get(username)
- if u == nil {
- return &RecordNotFoundError{err: fmt.Sprintf("username %v and ID: %v does not exist", string(username), ID)}
- }
- return json.Unmarshal(u, &user)
- })
-
- return user, err
-}
-
-func (p BoltProvider) updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error {
- return p.dbHandle.Update(func(tx *bolt.Tx) error {
- bucket, _, err := getBuckets(tx)
- if err != nil {
- return err
- }
- var u []byte
- if u = bucket.Get([]byte(username)); u == nil {
- return &RecordNotFoundError{err: fmt.Sprintf("username %v does not exist, unable to update quota", username)}
- }
- var user User
- err = json.Unmarshal(u, &user)
- if err != nil {
- return err
- }
- if reset {
- user.UsedQuotaSize = sizeAdd
- user.UsedQuotaFiles = filesAdd
- } else {
- user.UsedQuotaSize += sizeAdd
- user.UsedQuotaFiles += filesAdd
- }
- user.LastQuotaUpdate = utils.GetTimeAsMsSinceEpoch(time.Now())
- buf, err := json.Marshal(user)
- if err != nil {
- return err
- }
- return bucket.Put([]byte(username), buf)
- })
-}
-
-func (p BoltProvider) getUsedQuota(username string) (int, int64, error) {
- user, err := p.userExists(username)
- if err != nil {
- logger.Warn(logSender, "unable to get quota for user '%v' error: %v", username, err)
- return 0, 0, err
- }
- return user.UsedQuotaFiles, user.UsedQuotaSize, err
-}
-
-func (p BoltProvider) userExists(username string) (User, error) {
- var user User
- err := p.dbHandle.View(func(tx *bolt.Tx) error {
- bucket, _, err := getBuckets(tx)
- if err != nil {
- return err
- }
- u := bucket.Get([]byte(username))
- if u == nil {
- return &RecordNotFoundError{err: fmt.Sprintf("username %v does not exist", user.Username)}
- }
- return json.Unmarshal(u, &user)
- })
- return user, err
-}
-
-func (p BoltProvider) addUser(user User) error {
- err := validateUser(&user)
- if err != nil {
- return err
- }
- return p.dbHandle.Update(func(tx *bolt.Tx) error {
- bucket, idxBucket, err := getBuckets(tx)
- if err != nil {
- return err
- }
- if u := bucket.Get([]byte(user.Username)); u != nil {
- return fmt.Errorf("username '%v' already exists", user.Username)
- }
- id, err := bucket.NextSequence()
- if err != nil {
- return err
- }
- user.ID = int64(id)
- buf, err := json.Marshal(user)
- if err != nil {
- return err
- }
- userIDAsBytes := itob(user.ID)
- err = bucket.Put([]byte(user.Username), buf)
- if err != nil {
- return err
- }
- return idxBucket.Put(userIDAsBytes, []byte(user.Username))
- })
-}
-
-func (p BoltProvider) updateUser(user User) error {
- err := validateUser(&user)
- if err != nil {
- return err
- }
- return p.dbHandle.Update(func(tx *bolt.Tx) error {
- bucket, _, err := getBuckets(tx)
- if err != nil {
- return err
- }
- if u := bucket.Get([]byte(user.Username)); u == nil {
- return &RecordNotFoundError{err: fmt.Sprintf("username '%v' does not exist", user.Username)}
- }
- buf, err := json.Marshal(user)
- if err != nil {
- return err
- }
- return bucket.Put([]byte(user.Username), buf)
- })
-}
-
-func (p BoltProvider) deleteUser(user User) error {
- return p.dbHandle.Update(func(tx *bolt.Tx) error {
- bucket, idxBucket, err := getBuckets(tx)
- if err != nil {
- return err
- }
- userIDAsBytes := itob(user.ID)
- userName := idxBucket.Get(userIDAsBytes)
- if userName == nil {
- return &RecordNotFoundError{err: fmt.Sprintf("user with id %v does not exist", user.ID)}
- }
- err = bucket.Delete(userName)
- if err != nil {
- return err
- }
- return idxBucket.Delete(userIDAsBytes)
- })
-}
-
-func (p BoltProvider) getUsers(limit int, offset int, order string, username string) ([]User, error) {
- users := []User{}
- var err error
- if len(username) > 0 {
- if offset == 0 {
- user, err := p.userExists(username)
- if err == nil {
- users = append(users, getUserNoCredentials(&user))
- }
- }
- return users, err
- }
- err = p.dbHandle.View(func(tx *bolt.Tx) error {
- if limit <= 0 {
- return nil
- }
- bucket, _, err := getBuckets(tx)
- if err != nil {
- return err
- }
- cursor := bucket.Cursor()
- itNum := 0
- if order == "ASC" {
- for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
- itNum++
- if itNum <= offset {
- continue
- }
- var user User
- err = json.Unmarshal(v, &user)
- if err == nil {
- users = append(users, getUserNoCredentials(&user))
- }
- if len(users) >= limit {
- break
- }
- }
- } else {
- for k, v := cursor.Last(); k != nil; k, v = cursor.Prev() {
- itNum++
- if itNum <= offset {
- continue
- }
- var user User
- err = json.Unmarshal(v, &user)
- if err == nil {
- users = append(users, getUserNoCredentials(&user))
- }
- if len(users) >= limit {
- break
- }
- }
- }
- return err
- })
- return users, err
-}
-
-func getUserNoCredentials(user *User) User {
- user.Password = ""
- user.PublicKeys = []string{}
- return *user
-}
-
-// itob returns an 8-byte big endian representation of v.
-func itob(v int64) []byte {
- b := make([]byte, 8)
- binary.BigEndian.PutUint64(b, uint64(v))
- return b
-}
-
-func getBuckets(tx *bolt.Tx) (*bolt.Bucket, *bolt.Bucket, error) {
- var err error
- bucket := tx.Bucket(usersBucket)
- idxBucket := tx.Bucket(usersIDIdxBucket)
- if bucket == nil || idxBucket == nil {
- err = fmt.Errorf("Unable to find required buckets, bolt database structure not correcly defined")
- }
- return bucket, idxBucket, err
-}
diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go
deleted file mode 100644
index 17f9695d..00000000
--- a/dataprovider/dataprovider.go
+++ /dev/null
@@ -1,371 +0,0 @@
-// Package dataprovider provides data access.
-// It abstract different data providers and exposes a common API.
-// Currently the supported data providers are: PostreSQL (9+), MySQL (4.1+) and SQLite 3.x
-package dataprovider
-
-import (
- "crypto/sha1"
- "crypto/sha256"
- "crypto/sha512"
- "crypto/subtle"
- "encoding/base64"
- "errors"
- "fmt"
- "hash"
- "path/filepath"
- "strconv"
- "strings"
-
- "github.com/alexedwards/argon2id"
- "golang.org/x/crypto/bcrypt"
- "golang.org/x/crypto/pbkdf2"
- "golang.org/x/crypto/ssh"
-
- "github.com/drakkan/sftpgo/logger"
- "github.com/drakkan/sftpgo/utils"
-)
-
-const (
- // SQLiteDataProviderName name for SQLite database provider
- SQLiteDataProviderName = "sqlite"
- // PGSSQLDataProviderName name for PostgreSQL database provider
- PGSSQLDataProviderName = "postgresql"
- // MySQLDataProviderName name for MySQL database provider
- MySQLDataProviderName = "mysql"
- // BoltDataProviderName name for bbolt key/value store provider
- BoltDataProviderName = "bolt"
-
- logSender = "dataProvider"
- argonPwdPrefix = "$argon2id$"
- bcryptPwdPrefix = "$2a$"
- pbkdf2SHA1Prefix = "$pbkdf2-sha1$"
- pbkdf2SHA256Prefix = "$pbkdf2-sha256$"
- pbkdf2SHA512Prefix = "$pbkdf2-sha512$"
- manageUsersDisabledError = "please set manage_users to 1 in sftpgo.conf to enable this method"
- trackQuotaDisabledError = "please enable track_quota in sftpgo.conf to use this method"
-)
-
-var (
- // SupportedProviders data provider configured in the sftpgo.conf file must match of these strings
- SupportedProviders = []string{SQLiteDataProviderName, PGSSQLDataProviderName, MySQLDataProviderName, BoltDataProviderName}
- config Config
- provider Provider
- sqlPlaceholders []string
- validPerms = []string{PermAny, PermListItems, PermDownload, PermUpload, PermDelete, PermRename,
- PermCreateDirs, PermCreateSymlinks}
- hashPwdPrefixes = []string{argonPwdPrefix, bcryptPwdPrefix, pbkdf2SHA1Prefix, pbkdf2SHA256Prefix, pbkdf2SHA512Prefix}
- pbkdfPwdPrefixes = []string{pbkdf2SHA1Prefix, pbkdf2SHA256Prefix, pbkdf2SHA512Prefix}
-)
-
-// Config provider configuration
-type Config struct {
- // Driver name, must be one of the SupportedProviders
- Driver string `json:"driver" mapstructure:"driver"`
- // Database name
- Name string `json:"name" mapstructure:"name"`
- // Database host
- Host string `json:"host" mapstructure:"host"`
- // Database port
- Port int `json:"port" mapstructure:"port"`
- // Database username
- Username string `json:"username" mapstructure:"username"`
- // Database password
- Password string `json:"password" mapstructure:"password"`
- // Used for drivers mysql and postgresql.
- // 0 disable SSL/TLS connections.
- // 1 require ssl.
- // 2 set ssl mode to verify-ca for driver postgresql and skip-verify for driver mysql.
- // 3 set ssl mode to verify-full for driver postgresql and preferred for driver mysql.
- SSLMode int `json:"sslmode" mapstructure:"sslmode"`
- // Custom database connection string.
- // If not empty this connection string will be used instead of build one using the previous parameters
- ConnectionString string `json:"connection_string" mapstructure:"connection_string"`
- // Database table for SFTP users
- UsersTable string `json:"users_table" mapstructure:"users_table"`
- // Set to 0 to disable users management, 1 to enable
- ManageUsers int `json:"manage_users" mapstructure:"manage_users"`
- // Set the preferred way to track users quota between the following choices:
- // 0, disable quota tracking. REST API to scan user dir and update quota will do nothing
- // 1, quota is updated each time a user upload or delete a file even if the user has no quota restrictions
- // 2, quota is updated each time a user upload or delete a file but only for users with quota restrictions.
- // With this configuration the "quota scan" REST API can still be used to periodically update space usage
- // for users without quota restrictions
- TrackQuota int `json:"track_quota" mapstructure:"track_quota"`
-}
-
-// ValidationError raised if input data is not valid
-type ValidationError struct {
- err string
-}
-
-// Validation error details
-func (e *ValidationError) Error() string {
- return fmt.Sprintf("Validation error: %s", e.err)
-}
-
-// MethodDisabledError raised if a method is disabled in config file.
-// For example, if user management is disabled, this error is raised
-// every time an user operation is done using the REST API
-type MethodDisabledError struct {
- err string
-}
-
-// Method disabled error details
-func (e *MethodDisabledError) Error() string {
- return fmt.Sprintf("Method disabled error: %s", e.err)
-}
-
-// RecordNotFoundError raised if a requested user is not found
-type RecordNotFoundError struct {
- err string
-}
-
-func (e *RecordNotFoundError) Error() string {
- return fmt.Sprintf("Not found: %s", e.err)
-}
-
-// GetProvider returns the configured provider
-func GetProvider() Provider {
- return provider
-}
-
-// Provider interface that data providers must implement.
-type Provider interface {
- validateUserAndPass(username string, password string) (User, error)
- validateUserAndPubKey(username string, pubKey string) (User, error)
- updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error
- getUsedQuota(username string) (int, int64, error)
- userExists(username string) (User, error)
- addUser(user User) error
- updateUser(user User) error
- deleteUser(user User) error
- getUsers(limit int, offset int, order string, username string) ([]User, error)
- getUserByID(ID int64) (User, error)
-}
-
-// Initialize the data provider.
-// An error is returned if the configured driver is invalid or if the data provider cannot be initialized
-func Initialize(cnf Config, basePath string) error {
- config = cnf
- sqlPlaceholders = getSQLPlaceholders()
- if config.Driver == SQLiteDataProviderName {
- return initializeSQLiteProvider(basePath)
- } else if config.Driver == PGSSQLDataProviderName {
- return initializePGSQLProvider()
- } else if config.Driver == MySQLDataProviderName {
- return initializeMySQLProvider()
- } else if config.Driver == BoltDataProviderName {
- return initializeBoltProvider(basePath)
- }
- return fmt.Errorf("Unsupported data provider: %v", config.Driver)
-}
-
-// CheckUserAndPass retrieves the SFTP user with the given username and password if a match is found or an error
-func CheckUserAndPass(p Provider, username string, password string) (User, error) {
- return p.validateUserAndPass(username, password)
-}
-
-// CheckUserAndPubKey retrieves the SFTP user with the given username and public key if a match is found or an error
-func CheckUserAndPubKey(p Provider, username string, pubKey string) (User, error) {
- return p.validateUserAndPubKey(username, pubKey)
-}
-
-// UpdateUserQuota updates the quota for the given SFTP user adding filesAdd and sizeAdd.
-// If reset is true filesAdd and sizeAdd indicates the total files and the total size instead of the difference.
-func UpdateUserQuota(p Provider, user User, filesAdd int, sizeAdd int64, reset bool) error {
- if config.TrackQuota == 0 {
- return &MethodDisabledError{err: trackQuotaDisabledError}
- } else if config.TrackQuota == 2 && !reset && !user.HasQuotaRestrictions() {
- return nil
- }
- return p.updateQuota(user.Username, filesAdd, sizeAdd, reset)
-}
-
-// GetUsedQuota returns the used quota for the given SFTP user.
-// TrackQuota must be >=1 to enable this method
-func GetUsedQuota(p Provider, username string) (int, int64, error) {
- if config.TrackQuota == 0 {
- return 0, 0, &MethodDisabledError{err: trackQuotaDisabledError}
- }
- return p.getUsedQuota(username)
-}
-
-// UserExists checks if the given SFTP username exists, returns an error if no match is found
-func UserExists(p Provider, username string) (User, error) {
- return p.userExists(username)
-}
-
-// AddUser adds a new SFTP user.
-// ManageUsers configuration must be set to 1 to enable this method
-func AddUser(p Provider, user User) error {
- if config.ManageUsers == 0 {
- return &MethodDisabledError{err: manageUsersDisabledError}
- }
- return p.addUser(user)
-}
-
-// UpdateUser updates an existing SFTP user.
-// ManageUsers configuration must be set to 1 to enable this method
-func UpdateUser(p Provider, user User) error {
- if config.ManageUsers == 0 {
- return &MethodDisabledError{err: manageUsersDisabledError}
- }
- return p.updateUser(user)
-}
-
-// DeleteUser deletes an existing SFTP user.
-// ManageUsers configuration must be set to 1 to enable this method
-func DeleteUser(p Provider, user User) error {
- if config.ManageUsers == 0 {
- return &MethodDisabledError{err: manageUsersDisabledError}
- }
- return p.deleteUser(user)
-}
-
-// GetUsers returns an array of users respecting limit and offset and filtered by username exact match if not empty
-func GetUsers(p Provider, limit int, offset int, order string, username string) ([]User, error) {
- return p.getUsers(limit, offset, order, username)
-}
-
-// GetUserByID returns the user with the given database ID if a match is found or an error
-func GetUserByID(p Provider, ID int64) (User, error) {
- return p.getUserByID(ID)
-}
-
-func validateUser(user *User) error {
- if len(user.Username) == 0 || len(user.HomeDir) == 0 {
- return &ValidationError{err: "Mandatory parameters missing"}
- }
- if len(user.Password) == 0 && len(user.PublicKeys) == 0 {
- return &ValidationError{err: "Please set password or at least a public_key"}
- }
- if len(user.Permissions) == 0 {
- return &ValidationError{err: "Please grant some permissions to this user"}
- }
- if !filepath.IsAbs(user.HomeDir) {
- return &ValidationError{err: fmt.Sprintf("home_dir must be an absolute path, actual value: %v", user.HomeDir)}
- }
- for _, p := range user.Permissions {
- if !utils.IsStringInSlice(p, validPerms) {
- return &ValidationError{err: fmt.Sprintf("Invalid permission: %v", p)}
- }
- }
- if len(user.Password) > 0 && !utils.IsStringPrefixInSlice(user.Password, hashPwdPrefixes) {
- pwd, err := argon2id.CreateHash(user.Password, argon2id.DefaultParams)
- if err != nil {
- return err
- }
- user.Password = pwd
- }
- for i, k := range user.PublicKeys {
- _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k))
- if err != nil {
- return &ValidationError{err: fmt.Sprintf("Could not parse key nr. %d: %s", i, err)}
- }
- }
- return nil
-}
-
-func checkUserAndPass(user User, password string) (User, error) {
- var err error
- if len(user.Password) == 0 {
- return user, errors.New("Credentials cannot be null or empty")
- }
- var match bool
- if strings.HasPrefix(user.Password, argonPwdPrefix) {
- match, err = argon2id.ComparePasswordAndHash(password, user.Password)
- if err != nil {
- logger.Warn(logSender, "error comparing password with argon hash: %v", err)
- return user, err
- }
- } else if strings.HasPrefix(user.Password, bcryptPwdPrefix) {
- if err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
- logger.Warn(logSender, "error comparing password with bcrypt hash: %v", err)
- return user, err
- }
- match = true
- } else if utils.IsStringPrefixInSlice(user.Password, pbkdfPwdPrefixes) {
- match, err = comparePbkdf2PasswordAndHash(password, user.Password)
- if err != nil {
- logger.Warn(logSender, "error comparing password with pbkdf2 sha256 hash: %v", err)
- return user, err
- }
- }
- if !match {
- err = errors.New("Invalid credentials")
- }
- return user, err
-}
-
-func checkUserAndPubKey(user User, pubKey string) (User, error) {
- if len(user.PublicKeys) == 0 {
- return user, errors.New("Invalid credentials")
- }
- for i, k := range user.PublicKeys {
- storedPubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k))
- if err != nil {
- logger.Warn(logSender, "error parsing stored public key %d for user %v: %v", i, user.Username, err)
- return user, err
- }
- if string(storedPubKey.Marshal()) == pubKey {
- return user, nil
- }
- }
- return user, errors.New("Invalid credentials")
-}
-
-func comparePbkdf2PasswordAndHash(password, hashedPassword string) (bool, error) {
- vals := strings.Split(hashedPassword, "$")
- if len(vals) != 5 {
- return false, fmt.Errorf("pbkdf2: hash is not in the correct format")
- }
- var hashFunc func() hash.Hash
- var hashSize int
- if strings.HasPrefix(hashedPassword, pbkdf2SHA256Prefix) {
- hashSize = sha256.Size
- hashFunc = sha256.New
- } else if strings.HasPrefix(hashedPassword, pbkdf2SHA512Prefix) {
- hashSize = sha512.Size
- hashFunc = sha512.New
- } else if strings.HasPrefix(hashedPassword, pbkdf2SHA1Prefix) {
- hashSize = sha1.Size
- hashFunc = sha1.New
- } else {
- return false, fmt.Errorf("pbkdf2: invalid or unsupported hash format %v", vals[1])
- }
- iterations, err := strconv.Atoi(vals[2])
- if err != nil {
- return false, err
- }
- salt := vals[3]
- expected := vals[4]
- df := pbkdf2.Key([]byte(password), []byte(salt), iterations, hashSize, hashFunc)
- buf := make([]byte, base64.StdEncoding.EncodedLen(len(df)))
- base64.StdEncoding.Encode(buf, df)
- return subtle.ConstantTimeCompare(buf, []byte(expected)) == 1, nil
-}
-
-func getSSLMode() string {
- if config.Driver == PGSSQLDataProviderName {
- if config.SSLMode == 0 {
- return "disable"
- } else if config.SSLMode == 1 {
- return "require"
- } else if config.SSLMode == 2 {
- return "verify-ca"
- } else if config.SSLMode == 3 {
- return "verify-full"
- }
- } else if config.Driver == MySQLDataProviderName {
- if config.SSLMode == 0 {
- return "false"
- } else if config.SSLMode == 1 {
- return "true"
- } else if config.SSLMode == 2 {
- return "skip-verify"
- } else if config.SSLMode == 3 {
- return "preferred"
- }
- }
- return ""
-}
diff --git a/dataprovider/mysql.go b/dataprovider/mysql.go
deleted file mode 100644
index 92ba0992..00000000
--- a/dataprovider/mysql.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package dataprovider
-
-import (
- "database/sql"
- "fmt"
- "runtime"
- "time"
-
- "github.com/drakkan/sftpgo/logger"
-)
-
-// MySQLProvider auth provider for MySQL/MariaDB database
-type MySQLProvider struct {
- dbHandle *sql.DB
-}
-
-func initializeMySQLProvider() error {
- var err error
- var connectionString string
- if len(config.ConnectionString) == 0 {
- connectionString = fmt.Sprintf("%v:%v@tcp([%v]:%v)/%v?charset=utf8&interpolateParams=true&timeout=10s&tls=%v",
- config.Username, config.Password, config.Host, config.Port, config.Name, getSSLMode())
- } else {
- connectionString = config.ConnectionString
- }
- dbHandle, err := sql.Open("mysql", connectionString)
- if err == nil {
- numCPU := runtime.NumCPU()
- logger.Debug(logSender, "mysql database handle created, connection string: '%v', pool size: %v", connectionString, numCPU)
- dbHandle.SetMaxIdleConns(numCPU)
- dbHandle.SetMaxOpenConns(numCPU)
- dbHandle.SetConnMaxLifetime(1800 * time.Second)
- provider = MySQLProvider{dbHandle: dbHandle}
- } else {
- logger.Warn(logSender, "error creating mysql database handler, connection string: '%v', error: %v", connectionString, err)
- }
- return err
-}
-
-func (p MySQLProvider) validateUserAndPass(username string, password string) (User, error) {
- return sqlCommonValidateUserAndPass(username, password, p.dbHandle)
-}
-
-func (p MySQLProvider) validateUserAndPubKey(username string, publicKey string) (User, error) {
- return sqlCommonValidateUserAndPubKey(username, publicKey, p.dbHandle)
-}
-
-func (p MySQLProvider) getUserByID(ID int64) (User, error) {
- return sqlCommonGetUserByID(ID, p.dbHandle)
-}
-
-func (p MySQLProvider) updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error {
- tx, err := p.dbHandle.Begin()
- if err != nil {
- logger.Warn(logSender, "error starting transaction to update quota for user %v: %v", username, err)
- return err
- }
- err = sqlCommonUpdateQuota(username, filesAdd, sizeAdd, reset, p.dbHandle)
- if err == nil {
- err = tx.Commit()
- } else {
- err = tx.Rollback()
- }
- if err != nil {
- logger.Warn(logSender, "error closing transaction to update quota for user %v: %v", username, err)
- }
- return err
-}
-
-func (p MySQLProvider) getUsedQuota(username string) (int, int64, error) {
- return sqlCommonGetUsedQuota(username, p.dbHandle)
-}
-
-func (p MySQLProvider) userExists(username string) (User, error) {
- return sqlCommonCheckUserExists(username, p.dbHandle)
-}
-
-func (p MySQLProvider) addUser(user User) error {
- return sqlCommonAddUser(user, p.dbHandle)
-}
-
-func (p MySQLProvider) updateUser(user User) error {
- return sqlCommonUpdateUser(user, p.dbHandle)
-}
-
-func (p MySQLProvider) deleteUser(user User) error {
- return sqlCommonDeleteUser(user, p.dbHandle)
-}
-
-func (p MySQLProvider) getUsers(limit int, offset int, order string, username string) ([]User, error) {
- return sqlCommonGetUsers(limit, offset, order, username, p.dbHandle)
-}
diff --git a/dataprovider/pgsql.go b/dataprovider/pgsql.go
deleted file mode 100644
index 20a01125..00000000
--- a/dataprovider/pgsql.go
+++ /dev/null
@@ -1,90 +0,0 @@
-package dataprovider
-
-import (
- "database/sql"
- "fmt"
- "runtime"
-
- "github.com/drakkan/sftpgo/logger"
-)
-
-// PGSQLProvider auth provider for PostgreSQL database
-type PGSQLProvider struct {
- dbHandle *sql.DB
-}
-
-func initializePGSQLProvider() error {
- var err error
- var connectionString string
- if len(config.ConnectionString) == 0 {
- connectionString = fmt.Sprintf("host='%v' port=%v dbname='%v' user='%v' password='%v' sslmode=%v connect_timeout=10",
- config.Host, config.Port, config.Name, config.Username, config.Password, getSSLMode())
- } else {
- connectionString = config.ConnectionString
- }
- dbHandle, err := sql.Open("postgres", connectionString)
- if err == nil {
- numCPU := runtime.NumCPU()
- logger.Debug(logSender, "postgres database handle created, connection string: '%v', pool size: %v", connectionString, numCPU)
- dbHandle.SetMaxIdleConns(numCPU)
- dbHandle.SetMaxOpenConns(numCPU)
- provider = PGSQLProvider{dbHandle: dbHandle}
- } else {
- logger.Warn(logSender, "error creating postgres database handler, connection string: '%v', error: %v", connectionString, err)
- }
- return err
-}
-
-func (p PGSQLProvider) validateUserAndPass(username string, password string) (User, error) {
- return sqlCommonValidateUserAndPass(username, password, p.dbHandle)
-}
-
-func (p PGSQLProvider) validateUserAndPubKey(username string, publicKey string) (User, error) {
- return sqlCommonValidateUserAndPubKey(username, publicKey, p.dbHandle)
-}
-
-func (p PGSQLProvider) getUserByID(ID int64) (User, error) {
- return sqlCommonGetUserByID(ID, p.dbHandle)
-}
-
-func (p PGSQLProvider) updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error {
- tx, err := p.dbHandle.Begin()
- if err != nil {
- logger.Warn(logSender, "error starting transaction to update quota for user %v: %v", username, err)
- return err
- }
- err = sqlCommonUpdateQuota(username, filesAdd, sizeAdd, reset, p.dbHandle)
- if err == nil {
- err = tx.Commit()
- } else {
- err = tx.Rollback()
- }
- if err != nil {
- logger.Warn(logSender, "error closing transaction to update quota for user %v: %v", username, err)
- }
- return err
-}
-
-func (p PGSQLProvider) getUsedQuota(username string) (int, int64, error) {
- return sqlCommonGetUsedQuota(username, p.dbHandle)
-}
-
-func (p PGSQLProvider) userExists(username string) (User, error) {
- return sqlCommonCheckUserExists(username, p.dbHandle)
-}
-
-func (p PGSQLProvider) addUser(user User) error {
- return sqlCommonAddUser(user, p.dbHandle)
-}
-
-func (p PGSQLProvider) updateUser(user User) error {
- return sqlCommonUpdateUser(user, p.dbHandle)
-}
-
-func (p PGSQLProvider) deleteUser(user User) error {
- return sqlCommonDeleteUser(user, p.dbHandle)
-}
-
-func (p PGSQLProvider) getUsers(limit int, offset int, order string, username string) ([]User, error) {
- return sqlCommonGetUsers(limit, offset, order, username, p.dbHandle)
-}
diff --git a/dataprovider/sqlcommon.go b/dataprovider/sqlcommon.go
deleted file mode 100644
index 51f7e19b..00000000
--- a/dataprovider/sqlcommon.go
+++ /dev/null
@@ -1,252 +0,0 @@
-package dataprovider
-
-import (
- "database/sql"
- "encoding/json"
- "errors"
- "time"
-
- "github.com/drakkan/sftpgo/logger"
- "github.com/drakkan/sftpgo/utils"
-)
-
-func getUserByUsername(username string, dbHandle *sql.DB) (User, error) {
- var user User
- q := getUserByUsernameQuery()
- stmt, err := dbHandle.Prepare(q)
- if err != nil {
- logger.Debug(logSender, "error preparing database query %v: %v", q, err)
- return user, err
- }
- defer stmt.Close()
-
- row := stmt.QueryRow(username)
- return getUserFromDbRow(row, nil)
-}
-
-func sqlCommonValidateUserAndPass(username string, password string, dbHandle *sql.DB) (User, error) {
- var user User
- if len(password) == 0 {
- return user, errors.New("Credentials cannot be null or empty")
- }
- user, err := getUserByUsername(username, dbHandle)
- if err != nil {
- logger.Warn(logSender, "error authenticating user: %v, error: %v", username, err)
- return user, err
- }
- return checkUserAndPass(user, password)
-}
-
-func sqlCommonValidateUserAndPubKey(username string, pubKey string, dbHandle *sql.DB) (User, error) {
- var user User
- if len(pubKey) == 0 {
- return user, errors.New("Credentials cannot be null or empty")
- }
- user, err := getUserByUsername(username, dbHandle)
- if err != nil {
- logger.Warn(logSender, "error authenticating user: %v, error: %v", username, err)
- return user, err
- }
- return checkUserAndPubKey(user, pubKey)
-}
-
-func sqlCommonGetUserByID(ID int64, dbHandle *sql.DB) (User, error) {
- var user User
- q := getUserByIDQuery()
- stmt, err := dbHandle.Prepare(q)
- if err != nil {
- logger.Debug(logSender, "error preparing database query %v: %v", q, err)
- return user, err
- }
- defer stmt.Close()
-
- row := stmt.QueryRow(ID)
- return getUserFromDbRow(row, nil)
-}
-
-func sqlCommonUpdateQuota(username string, filesAdd int, sizeAdd int64, reset bool, dbHandle *sql.DB) error {
- q := getUpdateQuotaQuery(reset)
- stmt, err := dbHandle.Prepare(q)
- if err != nil {
- logger.Debug(logSender, "error preparing database query %v: %v", q, err)
- return err
- }
- defer stmt.Close()
- _, err = stmt.Exec(sizeAdd, filesAdd, utils.GetTimeAsMsSinceEpoch(time.Now()), username)
- if err == nil {
- logger.Debug(logSender, "quota updated for user %v, files increment: %v size increment: %v is reset? %v",
- username, filesAdd, sizeAdd, reset)
- } else {
- logger.Warn(logSender, "error updating quota for username %v: %v", username, err)
- }
- return err
-}
-
-func sqlCommonGetUsedQuota(username string, dbHandle *sql.DB) (int, int64, error) {
- q := getQuotaQuery()
- stmt, err := dbHandle.Prepare(q)
- if err != nil {
- logger.Warn(logSender, "error preparing database query %v: %v", q, err)
- return 0, 0, err
- }
- defer stmt.Close()
-
- var usedFiles int
- var usedSize int64
- err = stmt.QueryRow(username).Scan(&usedSize, &usedFiles)
- if err != nil {
- logger.Warn(logSender, "error getting user quota: %v, error: %v", username, err)
- return 0, 0, err
- }
- return usedFiles, usedSize, err
-}
-
-func sqlCommonCheckUserExists(username string, dbHandle *sql.DB) (User, error) {
- var user User
- q := getUserByUsernameQuery()
- stmt, err := dbHandle.Prepare(q)
- if err != nil {
- logger.Warn(logSender, "error preparing database query %v: %v", q, err)
- return user, err
- }
- defer stmt.Close()
- row := stmt.QueryRow(username)
- return getUserFromDbRow(row, nil)
-}
-
-func sqlCommonAddUser(user User, dbHandle *sql.DB) error {
- err := validateUser(&user)
- if err != nil {
- return err
- }
- q := getAddUserQuery()
- stmt, err := dbHandle.Prepare(q)
- if err != nil {
- logger.Warn(logSender, "error preparing database query %v: %v", q, err)
- return err
- }
- defer stmt.Close()
- permissions, err := user.GetPermissionsAsJSON()
- if err != nil {
- return err
- }
- publicKeys, err := user.GetPublicKeysAsJSON()
- if err != nil {
- return err
- }
- _, err = stmt.Exec(user.Username, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize,
- user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth)
- return err
-}
-
-func sqlCommonUpdateUser(user User, dbHandle *sql.DB) error {
- err := validateUser(&user)
- if err != nil {
- return err
- }
- q := getUpdateUserQuery()
- stmt, err := dbHandle.Prepare(q)
- if err != nil {
- logger.Warn(logSender, "error preparing database query %v: %v", q, err)
- return err
- }
- defer stmt.Close()
- permissions, err := user.GetPermissionsAsJSON()
- if err != nil {
- return err
- }
- publicKeys, err := user.GetPublicKeysAsJSON()
- if err != nil {
- return err
- }
- _, err = stmt.Exec(user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize,
- user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.ID)
- return err
-}
-
-func sqlCommonDeleteUser(user User, dbHandle *sql.DB) error {
- q := getDeleteUserQuery()
- stmt, err := dbHandle.Prepare(q)
- if err != nil {
- logger.Warn(logSender, "error preparing database query %v: %v", q, err)
- return err
- }
- defer stmt.Close()
- _, err = stmt.Exec(user.ID)
- return err
-}
-
-func sqlCommonGetUsers(limit int, offset int, order string, username string, dbHandle *sql.DB) ([]User, error) {
- users := []User{}
- q := getUsersQuery(order, username)
- stmt, err := dbHandle.Prepare(q)
- if err != nil {
- logger.Warn(logSender, "error preparing database query %v: %v", q, err)
- return nil, err
- }
- defer stmt.Close()
- var rows *sql.Rows
- if len(username) > 0 {
- rows, err = stmt.Query(username, limit, offset)
- } else {
- rows, err = stmt.Query(limit, offset)
- }
- if err == nil {
- defer rows.Close()
- for rows.Next() {
- u, err := getUserFromDbRow(nil, rows)
- // hide password and public key
- if err == nil {
- u.Password = ""
- u.PublicKeys = []string{}
- users = append(users, u)
- } else {
- break
- }
- }
- }
-
- return users, err
-}
-
-func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
- var user User
- var permissions sql.NullString
- var password sql.NullString
- var publicKey sql.NullString
- var err error
- if row != nil {
- err = row.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions,
- &user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
- &user.UploadBandwidth, &user.DownloadBandwidth)
-
- } else {
- err = rows.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions,
- &user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
- &user.UploadBandwidth, &user.DownloadBandwidth)
- }
- if err != nil {
- if err == sql.ErrNoRows {
- return user, &RecordNotFoundError{err: err.Error()}
- }
- return user, err
- }
- if password.Valid {
- user.Password = password.String
- }
- if publicKey.Valid {
- var list []string
- err = json.Unmarshal([]byte(publicKey.String), &list)
- if err == nil {
- user.PublicKeys = list
- }
- }
- if permissions.Valid {
- var list []string
- err = json.Unmarshal([]byte(permissions.String), &list)
- if err == nil {
- user.Permissions = list
- }
- }
- return user, err
-}
diff --git a/dataprovider/sqlite.go b/dataprovider/sqlite.go
deleted file mode 100644
index b032b59d..00000000
--- a/dataprovider/sqlite.go
+++ /dev/null
@@ -1,91 +0,0 @@
-package dataprovider
-
-import (
- "database/sql"
- "errors"
- "fmt"
- "os"
- "path/filepath"
-
- "github.com/drakkan/sftpgo/logger"
-)
-
-// SQLiteProvider auth provider for SQLite database
-type SQLiteProvider struct {
- dbHandle *sql.DB
-}
-
-func initializeSQLiteProvider(basePath string) error {
- var err error
- var connectionString string
- if len(config.ConnectionString) == 0 {
- dbPath := config.Name
- if !filepath.IsAbs(dbPath) {
- dbPath = filepath.Join(basePath, dbPath)
- }
- fi, err := os.Stat(dbPath)
- if err != nil {
- logger.Warn(logSender, "sqlite database file does not exists, please be sure to create and initialize"+
- " a database before starting sftpgo")
- return err
- }
- if fi.Size() == 0 {
- return errors.New("sqlite database file is invalid, please be sure to create and initialize" +
- " a database before starting sftpgo")
- }
- connectionString = fmt.Sprintf("file:%v?cache=shared", dbPath)
- } else {
- connectionString = config.ConnectionString
- }
- dbHandle, err := sql.Open("sqlite3", connectionString)
- if err == nil {
- logger.Debug(logSender, "sqlite database handle created, connection string: '%v'", connectionString)
- dbHandle.SetMaxOpenConns(1)
- provider = SQLiteProvider{dbHandle: dbHandle}
- } else {
- logger.Warn(logSender, "error creating sqlite database handler, connection string: '%v', error: %v", connectionString, err)
- }
- return err
-}
-
-func (p SQLiteProvider) validateUserAndPass(username string, password string) (User, error) {
- return sqlCommonValidateUserAndPass(username, password, p.dbHandle)
-}
-
-func (p SQLiteProvider) validateUserAndPubKey(username string, publicKey string) (User, error) {
- return sqlCommonValidateUserAndPubKey(username, publicKey, p.dbHandle)
-}
-
-func (p SQLiteProvider) getUserByID(ID int64) (User, error) {
- return sqlCommonGetUserByID(ID, p.dbHandle)
-}
-
-func (p SQLiteProvider) updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error {
- // we keep only 1 open connection (SetMaxOpenConns(1)) so a transaction is not needed and it could block
- // the database access since it will try to open a new connection
- return sqlCommonUpdateQuota(username, filesAdd, sizeAdd, reset, p.dbHandle)
-}
-
-func (p SQLiteProvider) getUsedQuota(username string) (int, int64, error) {
- return sqlCommonGetUsedQuota(username, p.dbHandle)
-}
-
-func (p SQLiteProvider) userExists(username string) (User, error) {
- return sqlCommonCheckUserExists(username, p.dbHandle)
-}
-
-func (p SQLiteProvider) addUser(user User) error {
- return sqlCommonAddUser(user, p.dbHandle)
-}
-
-func (p SQLiteProvider) updateUser(user User) error {
- return sqlCommonUpdateUser(user, p.dbHandle)
-}
-
-func (p SQLiteProvider) deleteUser(user User) error {
- return sqlCommonDeleteUser(user, p.dbHandle)
-}
-
-func (p SQLiteProvider) getUsers(limit int, offset int, order string, username string) ([]User, error) {
- return sqlCommonGetUsers(limit, offset, order, username, p.dbHandle)
-}
diff --git a/dataprovider/sqlqueries.go b/dataprovider/sqlqueries.go
deleted file mode 100644
index aceaf018..00000000
--- a/dataprovider/sqlqueries.go
+++ /dev/null
@@ -1,70 +0,0 @@
-package dataprovider
-
-import "fmt"
-
-const (
- selectUserFields = "id,username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions," +
- "used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth"
-)
-
-func getSQLPlaceholders() []string {
- var placeholders []string
- for i := 1; i <= 20; i++ {
- if config.Driver == PGSSQLDataProviderName {
- placeholders = append(placeholders, fmt.Sprintf("$%v", i))
- } else {
- placeholders = append(placeholders, "?")
- }
- }
- return placeholders
-}
-
-func getUserByUsernameQuery() string {
- return fmt.Sprintf(`SELECT %v FROM %v WHERE username = %v`, selectUserFields, config.UsersTable, sqlPlaceholders[0])
-}
-
-func getUserByIDQuery() string {
- return fmt.Sprintf(`SELECT %v FROM %v WHERE id = %v`, selectUserFields, config.UsersTable, sqlPlaceholders[0])
-}
-
-func getUsersQuery(order string, username string) string {
- if len(username) > 0 {
- return fmt.Sprintf(`SELECT %v FROM %v WHERE username = %v ORDER BY username %v LIMIT %v OFFSET %v`,
- selectUserFields, config.UsersTable, sqlPlaceholders[0], order, sqlPlaceholders[1], sqlPlaceholders[2])
- }
- return fmt.Sprintf(`SELECT %v FROM %v ORDER BY username %v LIMIT %v OFFSET %v`, selectUserFields, config.UsersTable,
- order, sqlPlaceholders[0], sqlPlaceholders[1])
-}
-
-func getUpdateQuotaQuery(reset bool) string {
- if reset {
- return fmt.Sprintf(`UPDATE %v SET used_quota_size = %v,used_quota_files = %v,last_quota_update = %v
- WHERE username = %v`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3])
- }
- return fmt.Sprintf(`UPDATE %v SET used_quota_size = used_quota_size + %v,used_quota_files = used_quota_files + %v,last_quota_update = %v
- WHERE username = %v`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3])
-}
-
-func getQuotaQuery() string {
- return fmt.Sprintf(`SELECT used_quota_size,used_quota_files FROM %v WHERE username = %v`, config.UsersTable,
- sqlPlaceholders[0])
-}
-
-func getAddUserQuery() string {
- return fmt.Sprintf(`INSERT INTO %v (username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,
- used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth)
- VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v)`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1],
- sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7],
- sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11])
-}
-
-func getUpdateUserQuery() string {
- return fmt.Sprintf(`UPDATE %v SET password=%v,public_keys=%v,home_dir=%v,uid=%v,gid=%v,max_sessions=%v,quota_size=%v,
- quota_files=%v,permissions=%v,upload_bandwidth=%v,download_bandwidth=%v WHERE id = %v`, config.UsersTable,
- sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5],
- sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11])
-}
-
-func getDeleteUserQuery() string {
- return fmt.Sprintf(`DELETE FROM %v WHERE id = %v`, config.UsersTable, sqlPlaceholders[0])
-}
diff --git a/dataprovider/user.go b/dataprovider/user.go
deleted file mode 100644
index 4722abe2..00000000
--- a/dataprovider/user.go
+++ /dev/null
@@ -1,122 +0,0 @@
-package dataprovider
-
-import (
- "encoding/json"
- "path/filepath"
-
- "github.com/drakkan/sftpgo/utils"
-)
-
-// Available permissions for SFTP users
-const (
- // All permissions are granted
- PermAny = "*"
- // List items such as files and directories is allowed
- PermListItems = "list"
- // download files is allowed
- PermDownload = "download"
- // upload files is allowed
- PermUpload = "upload"
- // delete files or directories is allowed
- PermDelete = "delete"
- // rename files or directories is allowed
- PermRename = "rename"
- // create directories is allowed
- PermCreateDirs = "create_dirs"
- // create symbolic links is allowed
- PermCreateSymlinks = "create_symlinks"
-)
-
-// User defines an SFTP user
-type User struct {
- // Database unique identifier
- ID int64 `json:"id"`
- // Username
- Username string `json:"username"`
- // Password used for password authentication.
- // For users created using SFTPGo REST API the password is be stored using argon2id hashing algo.
- // Checking passwords stored with bcrypt is supported too.
- // Currently, as fallback, there is a clear text password checking but you should not store passwords
- // as clear text and this support could be removed at any time, so please don't depend on it.
- Password string `json:"password,omitempty"`
- // PublicKeys used for public key authentication. At least one between password and a public key is mandatory
- PublicKeys []string `json:"public_keys,omitempty"`
- // The user cannot upload or download files outside this directory. Must be an absolute path
- HomeDir string `json:"home_dir"`
- // If sftpgo runs as root system user then the created files and directories will be assigned to this system UID
- UID int `json:"uid"`
- // If sftpgo runs as root system user then the created files and directories will be assigned to this system GID
- GID int `json:"gid"`
- // Maximum concurrent sessions. 0 means unlimited
- MaxSessions int `json:"max_sessions"`
- // Maximum size allowed as bytes. 0 means unlimited
- QuotaSize int64 `json:"quota_size"`
- // Maximum number of files allowed. 0 means unlimited
- QuotaFiles int `json:"quota_files"`
- // List of the granted permissions
- Permissions []string `json:"permissions"`
- // Used quota as bytes
- UsedQuotaSize int64 `json:"used_quota_size"`
- // Used quota as number of files
- UsedQuotaFiles int `json:"used_quota_files"`
- // Last quota update as unix timestamp in milliseconds
- LastQuotaUpdate int64 `json:"last_quota_update"`
- // Maximum upload bandwidth as KB/s, 0 means unlimited
- UploadBandwidth int64 `json:"upload_bandwidth"`
- // Maximum download bandwidth as KB/s, 0 means unlimited
- DownloadBandwidth int64 `json:"download_bandwidth"`
-}
-
-// HasPerm returns true if the user has the given permission or any permission
-func (u *User) HasPerm(permission string) bool {
- if utils.IsStringInSlice(PermAny, u.Permissions) {
- return true
- }
- return utils.IsStringInSlice(permission, u.Permissions)
-}
-
-// GetPermissionsAsJSON returns the permissions as json byte array
-func (u *User) GetPermissionsAsJSON() ([]byte, error) {
- return json.Marshal(u.Permissions)
-}
-
-// GetPublicKeysAsJSON returns the public keys as json byte array
-func (u *User) GetPublicKeysAsJSON() ([]byte, error) {
- return json.Marshal(u.PublicKeys)
-}
-
-// GetUID returns a validate uid, suitable for use with os.Chown
-func (u *User) GetUID() int {
- if u.UID <= 0 || u.UID > 65535 {
- return -1
- }
- return u.UID
-}
-
-// GetGID returns a validate gid, suitable for use with os.Chown
-func (u *User) GetGID() int {
- if u.GID <= 0 || u.GID > 65535 {
- return -1
- }
- return u.GID
-}
-
-// GetHomeDir returns the shortest path name equivalent to the user's home directory
-func (u *User) GetHomeDir() string {
- return filepath.Clean(u.HomeDir)
-}
-
-// HasQuotaRestrictions returns true if there is a quota restriction on number of files or size or both
-func (u *User) HasQuotaRestrictions() bool {
- return u.QuotaFiles > 0 || u.QuotaSize > 0
-}
-
-// GetRelativePath returns the path for a file relative to the user's home dir.
-// This is the path as seen by SFTP users
-func (u *User) GetRelativePath(path string) string {
- rel, err := filepath.Rel(u.GetHomeDir(), path)
- if err != nil {
- return ""
- }
- return "/" + filepath.ToSlash(rel)
-}
diff --git a/docker/scripts/download-plugins.sh b/docker/scripts/download-plugins.sh
new file mode 100755
index 00000000..7638cd56
--- /dev/null
+++ b/docker/scripts/download-plugins.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ARCH=$(uname -m)
+
+case ${ARCH} in
+ x86_64)
+ SUFFIX=amd64
+ ;;
+ aarch64)
+ SUFFIX=arm64
+ ;;
+ *)
+ SUFFIX=ppc64le
+ ;;
+esac
+
+echo "Downloading plugins for arch ${SUFFIX}"
+
+PLUGINS=(geoipfilter kms pubsub eventstore eventsearch auth)
+
+for PLUGIN in "${PLUGINS[@]}"; do
+ URL="https://github.com/sftpgo/sftpgo-plugin-${PLUGIN}/releases/latest/download/sftpgo-plugin-${PLUGIN}-linux-${SUFFIX}"
+ DEST="/usr/local/bin/sftpgo-plugin-${PLUGIN}"
+
+ echo "Downloading ${PLUGIN}..."
+ if curl --fail --silent --show-error -L "${URL}" --output "${DEST}"; then
+ chmod 755 "${DEST}"
+ else
+ echo "Error: Failed to download ${PLUGIN}" >&2
+ exit 1
+ fi
+done
+
+echo "All plugins downloaded successfully"
diff --git a/examples/OTP/authy/README.md b/examples/OTP/authy/README.md
new file mode 100644
index 00000000..af582956
--- /dev/null
+++ b/examples/OTP/authy/README.md
@@ -0,0 +1,60 @@
+# Authy
+
+These example show how-to integrate [Twilio Authy API](https://www.twilio.com/docs/authy/api) for One-Time-Password logins.
+
+The examples assume that the user has the free [Authy app](https://authy.com/) installed and uses it to generate offline [TOTP](https://en.wikipedia.org/wiki/Time-based_One-time_Password_algorithm) codes (soft tokens).
+
+You first need to [create an Authy Application in the Twilio Console](https://twilio.com/console/authy/applications?_ga=2.205553366.451688189.1597667213-1526360003.1597667213), then you can create a new Authy user and store a reference to the matching SFTPGo account.
+
+Verify that your Authy application is successfully registered:
+
+```bash
+export AUTHY_API_KEY={http.error.status_code} {http.error.status_text}"
+ }
+}
+
+(add_hsts_headers) {
+ header {
+ # Enable HTTP Strict Transport Security (HSTS) to force clients to always
+
+ # connect via HTTPS (do not use if only testing)
+ Strict-Transport-Security "max-age=31536000; includeSubDomains"
+
+ # Enable cross-site filter (XSS) and tell browser to block detected attacks
+ X-XSS-Protection "1; mode=block"
+
+ # Prevent some browsers from MIME-sniffing a response away from the declared Content-Type
+ X-Content-Type-Options "nosniff"
+
+ # Disallow the site to be rendered within a frame (clickjacking protection)
+ X-Frame-Options "DENY"
+
+ # keep referrer data off of HTTP connections
+ Referrer-Policy no-referrer-when-downgrade
+ }
+}
+
+(add_logging_with_path) {
+ log {
+ output file "{args.0}" {
+ roll_size 100mb
+ roll_keep 5
+ roll_keep_for 720h
+ }
+
+ format json
+ #format console
+ #format single_field common_log
+ }
+}
+
+### Site Definitions:
+
+public.example.com {
+
+ # Site Root:
+ root * F:\files\public
+
+ import add_logging_with_path "F:\caddy\logs\public_example_com_access.log"
+ import add_static_file_serving_features
+ import add_hsts_headers
+}
+
+
+### Reverse Proxy Definitions:
+
+webdav.example.com {
+ reverse_proxy localhost:9000
+
+ import add_logging_with_path "F:\caddy\logs\webdav_example_com_access.log"
+}
+```
diff --git a/examples/quotascan/README.md b/examples/quotascan/README.md
new file mode 100644
index 00000000..0830a308
--- /dev/null
+++ b/examples/quotascan/README.md
@@ -0,0 +1,23 @@
+# Update user quota
+
+:warning: Since v2.4.0 you can use the [EventManager](https://docs.sftpgo.com/latest/eventmanager/) to schedule quota scans.
+
+The `scanuserquota` example script shows how to use the SFTPGo REST API to update the users' quota.
+
+The stored quota may be incorrect for several reasons, such as an unexpected shutdown while uploading files, temporary provider failures, files copied outside of SFTPGo, and so on.
+
+A quota scan updates the number of files and their total size for the specified user and the virtual folders, if any, included in his quota.
+
+If you want to track quotas, a scheduled quota scan is recommended. You can use this example as a starting point.
+
+The script is written in Python and has the following requirements:
+
+- python3 or python2
+- python [Requests](https://requests.readthedocs.io/en/master/) module
+
+The provided example tries to connect to an SFTPGo instance running on `127.0.0.1:8080` using the following credentials:
+
+- username: `admin`
+- password: `password`
+
+Please edit the script according to your needs.
diff --git a/examples/quotascan/scanuserquota b/examples/quotascan/scanuserquota
new file mode 100755
index 00000000..7648bdd5
--- /dev/null
+++ b/examples/quotascan/scanuserquota
@@ -0,0 +1,119 @@
+#!/usr/bin/env python
+
+from datetime import datetime
+import sys
+import time
+
+import pytz
+import requests
+
+try:
+ import urllib.parse as urlparse
+except ImportError:
+ import urlparse
+
+# change base_url to point to your SFTPGo installation
+base_url = "http://127.0.0.1:8080"
+# set to False if you want to skip TLS certificate validation
+verify_tls_cert = True
+# set the credentials for a valid admin here
+admin_user = "admin"
+admin_password = "password"
+
+
+# set your update conditions here
+def needQuotaUpdate(user):
+ if user["status"] == 0: # inactive user
+ return False
+ if user["quota_size"] == 0 and user["quota_files"] == 0: # no quota restrictions
+ return False
+ return True
+
+
+class UpdateQuota:
+
+ def __init__(self):
+ self.limit = 100
+ self.offset = 0
+ self.access_token = ""
+ self.access_token_expiration = None
+
+ def printLog(self, message):
+ print("{} - {}".format(datetime.now(), message))
+
+ def checkAccessToken(self):
+ if self.access_token != "" and self.access_token_expiration:
+ expire_diff = self.access_token_expiration - datetime.now(tz=pytz.UTC)
+ # we don't use total_seconds to be python 2 compatible
+ seconds_to_expire = expire_diff.days * 86400 + expire_diff.seconds
+ if seconds_to_expire > 180:
+ return
+
+ auth = requests.auth.HTTPBasicAuth(admin_user, admin_password)
+ r = requests.get(urlparse.urljoin(base_url, "api/v2/token"), auth=auth, verify=verify_tls_cert, timeout=10)
+ if r.status_code != 200:
+ self.printLog("error getting access token: {}".format(r.text))
+ sys.exit(1)
+ self.access_token = r.json()["access_token"]
+ self.access_token_expiration = pytz.timezone("UTC").localize(datetime.strptime(r.json()["expires_at"],
+ "%Y-%m-%dT%H:%M:%SZ"))
+
+ def getAuthHeader(self):
+ self.checkAccessToken()
+ return {"Authorization": "Bearer " + self.access_token}
+
+ def waitForQuotaUpdate(self, username):
+ while True:
+ auth_header = self.getAuthHeader()
+ r = requests.get(urlparse.urljoin(base_url, "api/v2/quotas/users/scans"), headers=auth_header, verify=verify_tls_cert,
+ timeout=10)
+ if r.status_code != 200:
+ self.printLog("error getting quota scans while waiting for {}: {}".format(username, r.text))
+ sys.exit(1)
+
+ scanning = False
+ for scan in r.json():
+ if scan["username"] == username:
+ scanning = True
+ if not scanning:
+ break
+ self.printLog("waiting for the quota scan to complete for user {}".format(username))
+ time.sleep(2)
+
+ self.printLog("quota update for user {} finished".format(username))
+
+ def updateUserQuota(self, username):
+ self.printLog("starting quota update for user {}".format(username))
+ auth_header = self.getAuthHeader()
+ r = requests.post(urlparse.urljoin(base_url, "api/v2/quotas/users/" + username + "/scan"), headers=auth_header,
+ verify=verify_tls_cert, timeout=10)
+ if r.status_code != 202:
+ self.printLog("error starting quota scan for user {}: {}".format(username, r.text))
+ sys.exit(1)
+ self.waitForQuotaUpdate(username)
+
+ def updateUsersQuota(self):
+ while True:
+ self.printLog("get users, limit {} offset {}".format(self.limit, self.offset))
+ auth_header = self.getAuthHeader()
+ payload = {"limit":self.limit, "offset":self.offset}
+ r = requests.get(urlparse.urljoin(base_url, "api/v2/users"), headers=auth_header, params=payload,
+ verify=verify_tls_cert, timeout=10)
+ if r.status_code != 200:
+ self.printLog("error getting users: {}".format(r.text))
+ sys.exit(1)
+ users = r.json()
+ for user in users:
+ if needQuotaUpdate(user):
+ self.updateUserQuota(user["username"])
+ else:
+ self.printLog("user {} does not need a quota update".format(user["username"]))
+
+ self.offset += len(users)
+ if len(users) < self.limit:
+ break
+
+
+if __name__ == '__main__':
+ q = UpdateQuota()
+ q.updateUsersQuota()
diff --git a/go.mod b/go.mod
index f3438371..d2fafb83 100644
--- a/go.mod
+++ b/go.mod
@@ -1,27 +1,185 @@
-module github.com/drakkan/sftpgo
+module github.com/drakkan/sftpgo/v2
-go 1.12
+go 1.25.0
require (
- github.com/alexedwards/argon2id v0.0.0-20190612080829-01a59b2b8802
- github.com/go-chi/chi v4.0.2+incompatible
- github.com/go-chi/render v1.0.1
- github.com/go-sql-driver/mysql v1.4.1
- github.com/lib/pq v1.2.0
- github.com/magiconair/properties v1.8.1 // indirect
- github.com/mattn/go-sqlite3 v1.11.0
- github.com/pelletier/go-toml v1.4.0 // indirect
- github.com/pkg/sftp v1.10.1
- github.com/rs/xid v1.2.1
- github.com/rs/zerolog v1.15.0
- github.com/spf13/afero v1.2.2 // indirect
- github.com/spf13/cobra v0.0.5
- github.com/spf13/jwalterweatherman v1.1.0 // indirect
- github.com/spf13/viper v1.4.0
- go.etcd.io/bbolt v1.3.3
- golang.org/x/crypto v0.0.0-20190829043050-9756ffdc2472
- golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297 // indirect
- golang.org/x/sys v0.0.0-20190830142957-1e83adbbebd0 // indirect
- google.golang.org/appengine v1.6.2 // indirect
- gopkg.in/natefinch/lumberjack.v2 v2.0.0
+ cloud.google.com/go/storage v1.60.0
+ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0
+ github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1
+ github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4
+ github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5
+ github.com/alexedwards/argon2id v1.0.0
+ github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964
+ github.com/aws/aws-sdk-go-v2 v1.41.3
+ github.com/aws/aws-sdk-go-v2/config v1.32.11
+ github.com/aws/aws-sdk-go-v2/credentials v1.19.11
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4
+ github.com/aws/aws-sdk-go-v2/service/sts v1.41.8
+ github.com/bmatcuk/doublestar/v4 v4.10.0
+ github.com/cockroachdb/cockroach-go/v2 v2.4.3
+ github.com/coreos/go-oidc/v3 v3.17.0
+ github.com/drakkan/webdav v0.0.0-20241026165615-b8b8f74ae71b
+ github.com/eikenb/pipeat v0.0.0-20251030185646-385cd3c3e07b
+ github.com/fclairamb/ftpserverlib v0.30.0
+ github.com/go-acme/lego/v4 v4.32.0
+ github.com/go-chi/chi/v5 v5.2.5
+ github.com/go-chi/render v1.0.3
+ github.com/go-jose/go-jose/v4 v4.1.3
+ github.com/go-sql-driver/mysql v1.9.3
+ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
+ github.com/google/uuid v1.6.0
+ github.com/hashicorp/go-hclog v1.6.3
+ github.com/hashicorp/go-plugin v1.7.0
+ github.com/hashicorp/go-retryablehttp v0.7.8
+ github.com/jackc/pgx/v5 v5.8.0
+ github.com/jlaffaye/ftp v0.2.0
+ github.com/klauspost/compress v1.18.4
+ github.com/lithammer/shortuuid/v4 v4.2.0
+ github.com/mattn/go-sqlite3 v1.14.34
+ github.com/mhale/smtpd v0.8.3
+ github.com/minio/sio v0.4.3
+ github.com/otiai10/copy v1.14.1
+ github.com/pires/go-proxyproto v0.11.0
+ github.com/pkg/sftp v1.13.10
+ github.com/pquerna/otp v1.5.0
+ github.com/prometheus/client_golang v1.23.2
+ github.com/robfig/cron/v3 v3.0.1
+ github.com/rs/cors v1.11.1
+ github.com/rs/xid v1.6.0
+ github.com/rs/zerolog v1.34.0
+ github.com/sftpgo/sdk v0.1.9
+ github.com/shirou/gopsutil/v3 v3.24.5
+ github.com/spf13/afero v1.15.0
+ github.com/spf13/cobra v1.10.2
+ github.com/spf13/viper v1.21.0
+ github.com/stretchr/testify v1.11.1
+ github.com/studio-b12/gowebdav v0.12.0
+ github.com/subosito/gotenv v1.6.0
+ github.com/unrolled/secure v1.17.0
+ github.com/wagslane/go-password-validator v0.3.0
+ github.com/wneessen/go-mail v0.7.2
+ github.com/yl2chen/cidranger v1.0.3-0.20210928021809-d1cb2c52f37a
+ go.etcd.io/bbolt v1.4.3
+ gocloud.dev v0.45.0
+ golang.org/x/crypto v0.49.0
+ golang.org/x/net v0.52.0
+ golang.org/x/oauth2 v0.36.0
+ golang.org/x/sys v0.42.0
+ golang.org/x/term v0.41.0
+ golang.org/x/time v0.15.0
+ google.golang.org/api v0.271.0
+ gopkg.in/natefinch/lumberjack.v2 v2.2.1
+)
+
+require (
+ cel.dev/expr v0.25.1 // indirect
+ cloud.google.com/go v0.123.0 // indirect
+ cloud.google.com/go/auth v0.18.2 // indirect
+ cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
+ cloud.google.com/go/compute/metadata v0.9.0 // indirect
+ cloud.google.com/go/iam v1.5.3 // indirect
+ cloud.google.com/go/monitoring v1.24.3 // indirect
+ filippo.io/edwards25519 v1.2.0 // indirect
+ github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
+ github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 // indirect
+ github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
+ github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
+ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
+ github.com/ajg/form v1.7.1 // indirect
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 // indirect
+ github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
+ github.com/aws/smithy-go v1.24.2 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/boombuler/barcode v1.1.0 // indirect
+ github.com/cenkalti/backoff/v5 v5.0.3 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
+ github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
+ github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
+ github.com/fatih/color v1.18.0 // indirect
+ github.com/felixge/httpsnoop v1.0.4 // indirect
+ github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
+ github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
+ github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
+ github.com/golang/protobuf v1.5.4 // indirect
+ github.com/google/s2a-go v0.1.9 // indirect
+ github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
+ github.com/googleapis/gax-go/v2 v2.18.0 // indirect
+ github.com/hashicorp/errwrap v1.1.0 // indirect
+ github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+ github.com/hashicorp/go-multierror v1.1.1 // indirect
+ github.com/hashicorp/yamux v0.1.2 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/kr/fs v0.1.0 // indirect
+ github.com/kylelemons/godebug v1.1.0 // indirect
+ github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/miekg/dns v1.1.72 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/oklog/run v1.2.0 // indirect
+ github.com/otiai10/mint v1.6.3 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
+ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.67.5 // indirect
+ github.com/prometheus/procfs v0.20.1 // indirect
+ github.com/russross/blackfriday/v2 v2.1.0 // indirect
+ github.com/sagikazarmark/locafero v0.12.0 // indirect
+ github.com/shoenig/go-m1cpu v0.2.0 // indirect
+ github.com/spf13/cast v1.10.0 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
+ github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
+ github.com/tklauser/go-sysconf v0.3.16 // indirect
+ github.com/tklauser/numcpus v0.11.0 // indirect
+ github.com/yusufpapurcu/wmi v1.2.4 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
+ go.opentelemetry.io/otel v1.42.0 // indirect
+ go.opentelemetry.io/otel/metric v1.42.0 // indirect
+ go.opentelemetry.io/otel/sdk v1.42.0 // indirect
+ go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect
+ go.opentelemetry.io/otel/trace v1.42.0 // indirect
+ go.yaml.in/yaml/v2 v2.4.4 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/mod v0.34.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/text v0.35.0 // indirect
+ golang.org/x/tools v0.43.0 // indirect
+ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
+ google.golang.org/genproto v0.0.0-20260311181403-84a4fc48630c // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
+ google.golang.org/grpc v1.79.2 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
+
+replace (
+ github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20240430173938-7ba8270c8e7f
+ github.com/robfig/cron/v3 => github.com/drakkan/cron/v3 v3.0.0-20230222140221-217a1e4d96c0
)
diff --git a/go.sum b/go.sum
index 512b8d6c..d81c977d 100644
--- a/go.sum
+++ b/go.sum
@@ -1,211 +1,477 @@
-cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
-github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
-github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/alexedwards/argon2id v0.0.0-20190612080829-01a59b2b8802 h1:RwMM1q/QSKYIGbHfOkf843hE8sSUJtf1dMwFPtEDmm0=
-github.com/alexedwards/argon2id v0.0.0-20190612080829-01a59b2b8802/go.mod h1:4dsm7ufQm1Gwl8S2ss57u+2J7KlxIL2QUmFGlGtWogY=
-github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
-github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
-github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
-github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
-github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
-github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
-github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
-github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
-github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
-github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
-github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
+cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
+cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
+cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
+cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
+cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
+cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
+cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
+cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
+cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
+cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
+cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
+cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
+cloud.google.com/go/kms v1.26.0 h1:cK9mN2cf+9V63D3H1f6koxTatWy39aTI/hCjz1I+adU=
+cloud.google.com/go/kms v1.26.0/go.mod h1:pHKOdFJm63hxBsiPkYtowZPltu9dW0MWvBa6IA4HM58=
+cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA=
+cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak=
+cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
+cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
+cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
+cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
+cloud.google.com/go/storage v1.60.0 h1:oBfZrSOCimggVNz9Y/bXY35uUcts7OViubeddTTVzQ8=
+cloud.google.com/go/storage v1.60.0/go.mod h1:q+5196hXfejkctrnx+VYU8RKQr/L3c0cBIlrjmiAKE0=
+cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
+cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
+filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
+filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
+github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew=
+github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
+github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUXdP0ICgAabvq2FYLXrJWKx1fJQ+GxSo3Y=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
+github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
+github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
+github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
+github.com/ajg/form v1.7.1 h1:OsnBDzTkrWdrxvEnO68I72ZVGJGNaMwPhoAm0V+llgc=
+github.com/ajg/form v1.7.1/go.mod h1:HL757PzLyNkj5AIfptT6L+iGNeXTlnrr/oDePGc/y7Q=
+github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
+github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
+github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964 h1:I9YN9WMo3SUh7p/4wKeNvD/IQla3U3SUa61U7ul+xM4=
+github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964/go.mod h1:eFiR01PwTcpbzXtdMces7zxg6utvFM5puiWHpWB8D/k=
+github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
+github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 h1:N4lRUXZpZ1KVEUn6hxtco/1d2lgYhNn1fHkkl8WhlyQ=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
+github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
+github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 h1:qi3e/dmpdONhj1RyIZdi6DKKpDXS5Lb8ftr3p7cyHJc=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20/go.mod h1:V1K+TeJVD5JOk3D9e5tsX2KUdL7BlB+FV6cBhdobN8c=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 h1:BYf7XNsJMzl4mObARUBUib+j2tf0U//JAAtTnYqvqCw=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11/go.mod h1:aEUS4WrNk/+FxkBZZa7tVgp4pGH+kFGW40Y8rCPqt5g=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 h1:JnQeStZvPHFHeyky/7LbMlyQjUa+jIBj36OlWm0pzIk=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19/go.mod h1:HGyasyHvYdFQeJhvDHfH7HXkHh57htcJGKDZ+7z+I24=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4 h1:4ExZyubQ6LQQVuF2Qp9OsfEvsTdAWh5Gfwf6PgIdLdk=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4/go.mod h1:NF3JcMGOiARAss1ld3WGORCw71+4ExDD2cbbdKS5PpA=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
+github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
+github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
+github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
+github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
+github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
+github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
+github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
+github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
+github.com/cockroachdb/cockroach-go/v2 v2.4.3 h1:LJO3K3jC5WXvMePRQSJE1NsIGoFGcEx1LW83W6RAlhw=
+github.com/cockroachdb/cockroach-go/v2 v2.4.3/go.mod h1:9U179XbCx4qFWtNhc7BiWLPfuyMVQ7qdAhfrwLz1vH0=
+github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
+github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
+github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
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/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
-github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
-github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
-github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
-github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
-github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAUcHlgXVRs=
-github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
-github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8=
-github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns=
-github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
-github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
-github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
-github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
-github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
-github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
-github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
-github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
-github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
-github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
-github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
-github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
-github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
-github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
-github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
-github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
-github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
-github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
-github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
-github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
-github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/drakkan/cron/v3 v3.0.0-20230222140221-217a1e4d96c0 h1:EW9gIJRmt9lzk66Fhh4S8VEtURA6QHZqGeSRE9Nb2/U=
+github.com/drakkan/cron/v3 v3.0.0-20230222140221-217a1e4d96c0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
+github.com/drakkan/ftp v0.0.0-20240430173938-7ba8270c8e7f h1:S9JUlrOzjK58UKoLqqb40YLyVlt0bcIFtYrvnanV3zc=
+github.com/drakkan/ftp v0.0.0-20240430173938-7ba8270c8e7f/go.mod h1:4p8lUl4vQ80L598CygL+3IFtm+3nggvvW/palOlViwE=
+github.com/drakkan/webdav v0.0.0-20241026165615-b8b8f74ae71b h1:Y1tLiQ8fnxM5f3wiBjAXsHzHNwiY9BR+mXZA75nZwrs=
+github.com/drakkan/webdav v0.0.0-20241026165615-b8b8f74ae71b/go.mod h1:zOVb1QDhwwqWn2L2qZ0U3swMSO4GTSNyIwXCGO/UGWE=
+github.com/eikenb/pipeat v0.0.0-20251030185646-385cd3c3e07b h1:G2Mm3YhlyjkFrNnvu5E6LtNcPJtggXL1i5ekDV4hDD4=
+github.com/eikenb/pipeat v0.0.0-20251030185646-385cd3c3e07b/go.mod h1:XccPiThW83W5pzeOCsJAylEUtWeH+3zQVwiO402FXXc=
+github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
+github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
+github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
+github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
+github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
+github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
+github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
+github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
+github.com/fclairamb/ftpserverlib v0.30.0 h1:caB9sDn1Au//q0j2ev/icPn388qPuk4k1ajSvglDcMQ=
+github.com/fclairamb/ftpserverlib v0.30.0/go.mod h1:QmogtltTOgkihyKza0GNo37Mu4AEzbJ+sH6W9Y0MBIQ=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/go-acme/lego/v4 v4.32.0 h1:z7Ss7aa1noabhKj+DBzhNCO2SM96xhE3b0ucVW3x8Tc=
+github.com/go-acme/lego/v4 v4.32.0/go.mod h1:lI2fZNdgeM/ymf9xQ9YKbgZm6MeDuf91UrohMQE4DhI=
+github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
+github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
+github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
+github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
+github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
+github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
+github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
+github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
+github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
+github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
+github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
+github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
+github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
+github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
+github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
+github.com/googleapis/gax-go/v2 v2.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8QA86LI=
+github.com/googleapis/gax-go/v2 v2.18.0/go.mod h1:uSzZN4a356eRG985CzJ3WfbFSpqkLTjsnhWGJR6EwrE=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
+github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
+github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
+github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA=
+github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
+github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
+github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
+github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
+github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
+github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
+github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
+github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
+github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
+github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
+github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
-github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
-github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
-github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
-github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
-github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
-github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4=
-github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
-github.com/mattn/go-sqlite3 v1.11.0 h1:LDdKkqtYlom37fkvqs8rMPFKAMe8+SgjbwZ6ex1/A/Q=
-github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
-github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
-github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
-github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
-github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
-github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
-github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
-github.com/pelletier/go-toml v1.4.0 h1:u3Z1r+oOXJIkxqw34zVhyPgjBsm6X2wn21NWs/HfSeg=
-github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo=
-github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
-github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/sftp v1.10.1 h1:VasscCm72135zRysgrJDKsntdmPN+OuU3+nnHYA9wyc=
-github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
+github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
+github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
+github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
+github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+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/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
+github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/mhale/smtpd v0.8.3 h1:8j8YNXajksoSLZja3HdwvYVZPuJSqAxFsib3adzRRt8=
+github.com/mhale/smtpd v0.8.3/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
+github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
+github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
+github.com/minio/sio v0.4.3 h1:JqyID1XM86KwBZox5RAdLD4MLPIDoCY2cke2CXCJCkg=
+github.com/minio/sio v0.4.3/go.mod h1:4ANoe4CCXqnt1FCiLM0+vlBUhhWZzVOhYCz0069KtFc=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E=
+github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk=
+github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8=
+github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I=
+github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs=
+github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
+github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
+github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
+github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
+github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
-github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
-github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
-github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
-github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
-github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
-github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
-github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
-github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
-github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc=
-github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
-github.com/rs/zerolog v1.15.0 h1:uPRuwkWF4J6fGsJ2R0Gn2jB1EQiav9k3S6CSdygQJXY=
-github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
-github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
-github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
-github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
-github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
-github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI=
-github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
-github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc=
-github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
-github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8=
-github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s=
-github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
-github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
-github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
-github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
-github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
-github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
-github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
-github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
-github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU=
-github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
+github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
+github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
+github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
+github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
+github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
+github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
+github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
+github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
+github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
+github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
+github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
+github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
+github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
+github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
+github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
+github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
+github.com/sftpgo/sdk v0.1.9 h1:onBWfibCt34xHeKC2KFYPZ1DBqXGl9um/cAw+AVdgzY=
+github.com/sftpgo/sdk v0.1.9/go.mod h1:ehimvlTP+XTEiE3t1CPwWx9n7+6A6OGvMGlZ7ouvKFk=
+github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
+github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
+github.com/shoenig/go-m1cpu v0.2.0 h1:t4GNqvPZ84Vjtpboo/kT3pIkbaK3vc+JIlD/Wz1zSFY=
+github.com/shoenig/go-m1cpu v0.2.0/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w=
+github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
+github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
+github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
+github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
+github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
-github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
-github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
-github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
-github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
-github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
-github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
-go.etcd.io/bbolt v1.3.2 h1:Z/90sZLPOeCy2PwprqkFa25PdkusRzaj9P8zm/KNyvk=
-go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
-go.etcd.io/bbolt v1.3.3 h1:MUGmc65QhB3pIlaQ5bB4LwqSj6GIonVJXpZiaKNyaKk=
-go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
-go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
-go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
-go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
-golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/studio-b12/gowebdav v0.12.0 h1:kFRtQECt8jmVAvA6RHBz3geXUGJHUZA6/IKpOVUs5kM=
+github.com/studio-b12/gowebdav v0.12.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
+github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
+github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
+github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
+github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
+github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
+github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I=
+github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ=
+github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
+github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
+github.com/yl2chen/cidranger v1.0.3-0.20210928021809-d1cb2c52f37a h1:XfF01GyP+0eWCaVp0y6rNN+kFp7pt9Da4UUYrJ5XPWA=
+github.com/yl2chen/cidranger v1.0.3-0.20210928021809-d1cb2c52f37a/go.mod h1:aXb8yZQEWo1XHGMf1qQfnb83GR/EJ2EBlwtUgAaNBoE=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
+go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
+go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
+go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
+go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 h1:5gn2urDL/FBnK8OkCfD1j3/ER79rUuTYmCvlXBKeYL8=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0/go.mod h1:0fBG6ZJxhqByfFZDwSwpZGzJU671HkwpWaNe2t4VUPI=
+go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
+go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
+go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
+go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
+go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
+go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
+go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
+go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+gocloud.dev v0.45.0 h1:WknIK8IbRdmynDvara3Q7G6wQhmEiOGwpgJufbM39sY=
+gocloud.dev v0.45.0/go.mod h1:0kXKmkCLG6d31N7NyLZWzt7jDSQura9zD/mWgiB6THI=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586 h1:7KByu05hhLed2MO29w7p1XfZvZ13m8mub3shuVftRs0=
-golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190829043050-9756ffdc2472 h1:Gv7RPwsi3eZ2Fgewe3CBsuOebPwO27PoXzRpJPsvSSM=
-golang.org/x/crypto v0.0.0-20190829043050-9756ffdc2472/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297 h1:k7pJ2yAPLPgbskkFdhRCsA77k2fySZ1zf2zCjvQCiIM=
-golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
+golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
+golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
+golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
+golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190830142957-1e83adbbebd0 h1:7z820YPX9pxWR59qM7BE5+fglp4D/mKqAwCvGt11b+8=
-golang.org/x/sys v0.0.0-20190830142957-1e83adbbebd0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
+golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
+golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
+golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
-golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
-golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
-google.golang.org/appengine v1.6.2 h1:j8RI1yW0SkI+paT6uGwMlrMI/6zwYA6/CFil8rxOzGI=
-google.golang.org/appengine v1.6.2/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
-google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
-gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
+golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
+golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
+gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
+gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
+google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY=
+google.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q=
+google.golang.org/genproto v0.0.0-20260311181403-84a4fc48630c h1:ZhFDeBMmFc/4g8/GwxnJ4rzB3O4GwQVNr+8Mh7Y5z4g=
+google.golang.org/genproto v0.0.0-20260311181403-84a4fc48630c/go.mod h1:hf4r/rBuzaTkLUWRO03771Xvcs6P5hwdQK3UUEJjqo0=
+google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c h1:OyQPd6I3pN/9gDxz6L13kYGJgqkpdrAohJRBeXyxlgI=
+google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c/go.mod h1:X2gu9Qwng7Nn009s/r3RUxqkzQNqOrAy79bluY7ojIg=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
+google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8=
-gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
-gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
-gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
-gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/img/7digital.png b/img/7digital.png
new file mode 100644
index 00000000..c6c1c540
Binary files /dev/null and b/img/7digital.png differ
diff --git a/img/Aledade_logo.png b/img/Aledade_logo.png
new file mode 100644
index 00000000..1639a321
Binary files /dev/null and b/img/Aledade_logo.png differ
diff --git a/img/IDCS.png b/img/IDCS.png
new file mode 100644
index 00000000..0a890e89
Binary files /dev/null and b/img/IDCS.png differ
diff --git a/img/jumptrading.png b/img/jumptrading.png
new file mode 100644
index 00000000..88161521
Binary files /dev/null and b/img/jumptrading.png differ
diff --git a/img/logo.png b/img/logo.png
new file mode 100644
index 00000000..81b1f94b
Binary files /dev/null and b/img/logo.png differ
diff --git a/img/reui.png b/img/reui.png
new file mode 100644
index 00000000..20973803
Binary files /dev/null and b/img/reui.png differ
diff --git a/img/servinga.png b/img/servinga.png
new file mode 100644
index 00000000..c5ccb638
Binary files /dev/null and b/img/servinga.png differ
diff --git a/img/wpengine.png b/img/wpengine.png
new file mode 100644
index 00000000..0ca2381f
Binary files /dev/null and b/img/wpengine.png differ
diff --git a/init/com.github.drakkan.sftpgo.plist b/init/com.github.drakkan.sftpgo.plist
new file mode 100644
index 00000000..58b83f8d
--- /dev/null
+++ b/init/com.github.drakkan.sftpgo.plist
@@ -0,0 +1,36 @@
+
+
+Fs path {{.FsPath}}, Name: {{.Name}}, Target path "{{.VirtualTargetDirPath}}/{{.TargetName}}", size: {{.FileSize}}
`, + }, + }, + } + action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated) + assert.NoError(t, err) + r1 := dataprovider.EventRule{ + Name: "test rename rule", + Status: 1, + Trigger: dataprovider.EventTriggerFsEvent, + Conditions: dataprovider.EventConditions{ + FsEvents: []string{"rename"}, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + Order: 1, + }, + }, + } + rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated) + assert.NoError(t, err) + + u := getTestUser() + u.Username = "test & chars" + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + testFileSize := int64(32768) + lastReceivedEmail.reset() + err = writeSFTPFileNoCheck(testFileName, testFileSize, client) + assert.NoError(t, err) + err = client.Mkdir("subdir") + assert.NoError(t, err) + err = client.Rename(testFileName, path.Join("/subdir", testFileName)) + assert.NoError(t, err) + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 1500*time.Millisecond, 100*time.Millisecond) + email := lastReceivedEmail.get() + assert.Len(t, email.To, 1) + assert.True(t, slices.Contains(email.To, "test@example.com")) + assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "rename" from "%s"`, user.Username)) + assert.Contains(t, email.Data, "Content-Type: text/html") + assert.Contains(t, email.Data, fmt.Sprintf("Target path %q", path.Join("/subdir", testFileName))) + assert.Contains(t, email.Data, "Name: test & chars,") + } + + _, err = httpdtest.RemoveEventRule(rule1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + smtpCfg = smtp.Config{} + err = smtpCfg.Initialize(configDir, true) + require.NoError(t, err) +} + +func TestEventRuleIDPLogin(t *testing.T) { + smtpCfg := smtp.Config{ + Host: "127.0.0.1", + Port: 2525, + From: "notify@example.com", + TemplatesPath: "templates", + } + err := smtpCfg.Initialize(configDir, true) + require.NoError(t, err) + lastReceivedEmail.reset() + + username := `test_'idp_'login` + custom1 := `cust"oa"1` + u := map[string]any{ + "username": "{{.Name}}", + "status": 1, + "home_dir": filepath.Join(os.TempDir(), "{{.IDPFieldcustom1}}"), + "permissions": map[string][]string{ + "/": {dataprovider.PermAny}, + }, + } + userTmpl, err := json.Marshal(u) + require.NoError(t, err) + a := map[string]any{ + "username": "{{.Name}}", + "status": 1, + "permissions": []string{dataprovider.PermAdminAny}, + } + adminTmpl, err := json.Marshal(a) + require.NoError(t, err) + + a1 := dataprovider.BaseEventAction{ + Name: "a1", + Type: dataprovider.ActionTypeIDPAccountCheck, + Options: dataprovider.BaseEventActionOptions{ + IDPConfig: dataprovider.EventActionIDPAccountCheck{ + Mode: 1, // create if not exists + TemplateUser: string(userTmpl), + TemplateAdmin: string(adminTmpl), + }, + }, + } + action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated) + assert.NoError(t, err) + a2 := dataprovider.BaseEventAction{ + Name: "a2", + Type: dataprovider.ActionTypeEmail, + Options: dataprovider.BaseEventActionOptions{ + EmailConfig: dataprovider.EventActionEmailConfig{ + Recipients: []string{"test@example.com"}, + Subject: `"{{.Event}} {{.StatusString}}"`, + Body: "{{.Name}} Custom field: {{.IDPFieldcustom1}}", + }, + }, + } + action2, _, err := httpdtest.AddEventAction(a2, http.StatusCreated) + assert.NoError(t, err) + r1 := dataprovider.EventRule{ + Name: "test rule IDP login", + Status: 1, + Trigger: dataprovider.EventTriggerIDPLogin, + Conditions: dataprovider.EventConditions{ + IDPLoginEvent: dataprovider.IDPLoginUser, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, // the rule is not sync and will be skipped + }, + Order: 1, + }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action2.Name, + }, + Order: 2, + }, + }, + } + rule1, resp, err := httpdtest.AddEventRule(r1, http.StatusCreated) + assert.NoError(t, err, string(resp)) + + customFields := map[string]any{ + "custom1": custom1, + } + user, admin, err := common.HandleIDPLoginEvent(common.EventParams{ + Name: username, + Event: common.IDPLoginUser, + Status: 1, + }, &customFields) + assert.Nil(t, user) + assert.Nil(t, admin) + assert.NoError(t, err) + + rule1.Actions[0].Options.ExecuteSync = true + rule1, resp, err = httpdtest.UpdateEventRule(rule1, http.StatusOK) + assert.NoError(t, err, string(resp)) + user, admin, err = common.HandleIDPLoginEvent(common.EventParams{ + Name: username, + Event: common.IDPLoginUser, + Status: 1, + }, &customFields) + if assert.NotNil(t, user) { + assert.Equal(t, filepath.Join(os.TempDir(), custom1), user.GetHomeDir()) + _, err = httpdtest.RemoveUser(*user, http.StatusOK) + assert.NoError(t, err) + } + assert.Nil(t, admin) + assert.NoError(t, err) + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 3000*time.Millisecond, 100*time.Millisecond) + email := lastReceivedEmail.get() + assert.Len(t, email.To, 1) + assert.True(t, slices.Contains(email.To, "test@example.com")) + assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "%s OK"`, common.IDPLoginUser)) + assert.Contains(t, email.Data, username) + assert.Contains(t, email.Data, custom1) + + user, admin, err = common.HandleIDPLoginEvent(common.EventParams{ + Name: username, + Event: common.IDPLoginAdmin, + Status: 1, + }, &customFields) + assert.Nil(t, user) + assert.Nil(t, admin) + assert.NoError(t, err) + + rule1.Conditions.IDPLoginEvent = dataprovider.IDPLoginAny + rule1.Actions = []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + Options: dataprovider.EventActionOptions{ + ExecuteSync: true, + }, + Order: 1, + }, + } + rule1, _, err = httpdtest.UpdateEventRule(rule1, http.StatusOK) + assert.NoError(t, err) + + r2 := dataprovider.EventRule{ + Name: "test email on IDP login", + Status: 1, + Trigger: dataprovider.EventTriggerIDPLogin, + Conditions: dataprovider.EventConditions{ + IDPLoginEvent: dataprovider.IDPLoginAdmin, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action2.Name, + }, + Order: 1, + }, + }, + } + rule2, resp, err := httpdtest.AddEventRule(r2, http.StatusCreated) + assert.NoError(t, err, string(resp)) + + lastReceivedEmail.reset() + user, admin, err = common.HandleIDPLoginEvent(common.EventParams{ + Name: username, + Event: common.IDPLoginAdmin, + Status: 1, + }, &customFields) + assert.Nil(t, user) + if assert.NotNil(t, admin) { + assert.Equal(t, 1, admin.Status) + } + assert.NoError(t, err) + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 3000*time.Millisecond, 100*time.Millisecond) + email = lastReceivedEmail.get() + assert.Len(t, email.To, 1) + assert.True(t, slices.Contains(email.To, "test@example.com")) + assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "%s OK"`, common.IDPLoginAdmin)) + assert.Contains(t, email.Data, username) + assert.Contains(t, email.Data, custom1) + admin.Status = 0 + _, _, err = httpdtest.UpdateAdmin(*admin, http.StatusOK) + assert.NoError(t, err) + user, admin, err = common.HandleIDPLoginEvent(common.EventParams{ + Name: username, + Event: common.IDPLoginAdmin, + Status: 1, + }, &customFields) + assert.Nil(t, user) + if assert.NotNil(t, admin) { + assert.Equal(t, 0, admin.Status) + } + assert.NoError(t, err) + action1.Options.IDPConfig.Mode = 0 + action1, _, err = httpdtest.UpdateEventAction(action1, http.StatusOK) + assert.NoError(t, err) + user, admin, err = common.HandleIDPLoginEvent(common.EventParams{ + Name: username, + Event: common.IDPLoginAdmin, + Status: 1, + }, &customFields) + assert.Nil(t, user) + if assert.NotNil(t, admin) { + assert.Equal(t, 1, admin.Status) + } + assert.NoError(t, err) + _, err = httpdtest.RemoveAdmin(*admin, http.StatusOK) + assert.NoError(t, err) + + r3 := dataprovider.EventRule{ + Name: "test rule2 IDP login", + Status: 1, + Trigger: dataprovider.EventTriggerIDPLogin, + Conditions: dataprovider.EventConditions{ + IDPLoginEvent: dataprovider.IDPLoginAny, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + Order: 1, + Options: dataprovider.EventActionOptions{ + ExecuteSync: true, + }, + }, + }, + } + rule3, resp, err := httpdtest.AddEventRule(r3, http.StatusCreated) + assert.NoError(t, err, string(resp)) + user, admin, err = common.HandleIDPLoginEvent(common.EventParams{ + Name: username, + Event: common.IDPLoginAdmin, + Status: 1, + }, &customFields) + assert.Nil(t, user) + assert.Nil(t, admin) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "more than one account check action rules matches") + } + + _, err = httpdtest.RemoveEventRule(rule3, http.StatusOK) + assert.NoError(t, err) + + action1.Options.IDPConfig.TemplateAdmin = `{}` + action1, _, err = httpdtest.UpdateEventAction(action1, http.StatusOK) + assert.NoError(t, err) + _, _, err = common.HandleIDPLoginEvent(common.EventParams{ + Name: username, + Event: common.IDPLoginAdmin, + Status: 1, + }, &customFields) + assert.ErrorIs(t, err, util.ErrValidation) + + _, err = httpdtest.RemoveEventRule(rule1, http.StatusOK) + assert.NoError(t, err) + + user, admin, err = common.HandleIDPLoginEvent(common.EventParams{ + Name: username, + Event: common.IDPLoginAdmin, + Status: 1, + }, &customFields) + assert.Nil(t, user) + assert.Nil(t, admin) + assert.NoError(t, err) + + _, err = httpdtest.RemoveEventRule(rule2, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action2, http.StatusOK) + assert.NoError(t, err) + + smtpCfg = smtp.Config{} + err = smtpCfg.Initialize(configDir, true) + require.NoError(t, err) +} + +func TestEventRuleEmailField(t *testing.T) { + smtpCfg := smtp.Config{ + Host: "127.0.0.1", + Port: 2525, + From: "notify@example.com", + TemplatesPath: "templates", + } + err := smtpCfg.Initialize(configDir, true) + require.NoError(t, err) + lastReceivedEmail.reset() + + a1 := dataprovider.BaseEventAction{ + Name: "action1", + Type: dataprovider.ActionTypeEmail, + Options: dataprovider.BaseEventActionOptions{ + EmailConfig: dataprovider.EventActionEmailConfig{ + Recipients: []string{"{{.Email}}"}, + Subject: `"{{.Event}}" from "{{.Name}}"`, + Body: "Sample email body", + }, + }, + } + action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated) + assert.NoError(t, err) + a2 := dataprovider.BaseEventAction{ + Name: "action2", + Type: dataprovider.ActionTypeEmail, + Options: dataprovider.BaseEventActionOptions{ + EmailConfig: dataprovider.EventActionEmailConfig{ + Recipients: []string{"failure@example.com"}, + Subject: `"Failure`, + Body: "{{.ErrorString}}", + }, + }, + } + action2, _, err := httpdtest.AddEventAction(a2, http.StatusCreated) + assert.NoError(t, err) + r1 := dataprovider.EventRule{ + Name: "r1", + Status: 1, + Trigger: dataprovider.EventTriggerFsEvent, + Conditions: dataprovider.EventConditions{ + FsEvents: []string{"mkdir"}, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + }, + }, + } + r2 := dataprovider.EventRule{ + Name: "test rule2", + Status: 1, + Trigger: dataprovider.EventTriggerProviderEvent, + Conditions: dataprovider.EventConditions{ + ProviderEvents: []string{"add"}, + Options: dataprovider.ConditionOptions{ + ProviderObjects: []string{"user"}, + }, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + Order: 1, + }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action2.Name, + }, + Options: dataprovider.EventActionOptions{ + IsFailureAction: true, + }, + }, + }, + } + rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated) + assert.NoError(t, err) + rule2, _, err := httpdtest.AddEventRule(r2, http.StatusCreated) + assert.NoError(t, err) + u := getTestUser() + u.Email = "user@example.com" + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 3000*time.Millisecond, 100*time.Millisecond) + email := lastReceivedEmail.get() + assert.Len(t, email.To, 1) + assert.True(t, slices.Contains(email.To, user.Email)) + assert.Contains(t, email.Data, `Subject: "add" from "admin"`) + + // if we add a user without email the notification will fail + lastReceivedEmail.reset() + u1 := getTestUser() + u1.Username += "_1" + user1, _, err := httpdtest.AddUser(u1, http.StatusCreated) + assert.NoError(t, err) + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 3000*time.Millisecond, 100*time.Millisecond) + email = lastReceivedEmail.get() + assert.Len(t, email.To, 1) + assert.True(t, slices.Contains(email.To, "failure@example.com")) + assert.Contains(t, email.Data, `no recipient addresses set`) + + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + lastReceivedEmail.reset() + err = client.Mkdir(testFileName) + assert.NoError(t, err) + + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 3000*time.Millisecond, 100*time.Millisecond) + email := lastReceivedEmail.get() + assert.Len(t, email.To, 1) + assert.True(t, slices.Contains(email.To, user.Email)) + assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "mkdir" from "%s"`, user.Username)) + } + + _, err = httpdtest.RemoveEventRule(rule1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventRule(rule2, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action2, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(user1, http.StatusOK) + assert.NoError(t, err) + + smtpCfg = smtp.Config{} + err = smtpCfg.Initialize(configDir, true) + require.NoError(t, err) +} + +func TestEventRuleCertificate(t *testing.T) { + smtpCfg := smtp.Config{ + Host: "127.0.0.1", + Port: 2525, + From: "notify@example.com", + TemplatesPath: "templates", + } + err := smtpCfg.Initialize(configDir, true) + require.NoError(t, err) + lastReceivedEmail.reset() + + a1 := dataprovider.BaseEventAction{ + Name: "action1", + Type: dataprovider.ActionTypeEmail, + Options: dataprovider.BaseEventActionOptions{ + EmailConfig: dataprovider.EventActionEmailConfig{ + Recipients: []string{"test@example.com"}, + Subject: `"{{.Event}} {{.StatusString}}"`, + ContentType: 0, + Body: "Domain: {{.Name}} Timestamp: {{.Timestamp}} {{.ErrorString}} Date time: {{.DateTime}}", + }, + }, + } + action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated) + assert.NoError(t, err) + + a2 := dataprovider.BaseEventAction{ + Name: "action2", + Type: dataprovider.ActionTypeFolderQuotaReset, + } + action2, _, err := httpdtest.AddEventAction(a2, http.StatusCreated) + assert.NoError(t, err) + + r1 := dataprovider.EventRule{ + Name: "test rule certificate", + Status: 1, + Trigger: dataprovider.EventTriggerCertificate, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + Order: 1, + }, + }, + } + rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated) + assert.NoError(t, err) + r2 := dataprovider.EventRule{ + Name: "test rule 2", + Status: 1, + Trigger: dataprovider.EventTriggerCertificate, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + Order: 1, + }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action2.Name, + }, + Order: 2, + }, + }, + } + rule2, _, err := httpdtest.AddEventRule(r2, http.StatusCreated) + assert.NoError(t, err) + + renewalEvent := "Certificate renewal" + + common.HandleCertificateEvent(common.EventParams{ + Name: "example.com", + Timestamp: time.Now(), + Status: 1, + Event: renewalEvent, + }) + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 3000*time.Millisecond, 100*time.Millisecond) + email := lastReceivedEmail.get() + assert.Len(t, email.To, 1) + assert.True(t, slices.Contains(email.To, "test@example.com")) + assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "%s OK"`, renewalEvent)) + assert.Contains(t, email.Data, "Content-Type: text/plain") + assert.Contains(t, email.Data, `Domain: example.com Timestamp`) + + lastReceivedEmail.reset() + dateTime := time.Now() + params := common.EventParams{ + Name: "example.com", + Timestamp: dateTime, + Status: 2, + Event: renewalEvent, + } + errRenew := errors.New("generic renew error") + params.AddError(errRenew) + common.HandleCertificateEvent(params) + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 3000*time.Millisecond, 100*time.Millisecond) + email = lastReceivedEmail.get() + assert.Len(t, email.To, 1) + assert.True(t, slices.Contains(email.To, "test@example.com")) + assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "%s KO"`, renewalEvent)) + assert.Contains(t, email.Data, `Domain: example.com Timestamp`) + assert.Contains(t, email.Data, dateTime.UTC().Format("2006-01-02T15:04:05.000")) + assert.Contains(t, email.Data, errRenew.Error()) + + _, err = httpdtest.RemoveEventRule(rule1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventRule(rule2, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action2, http.StatusOK) + assert.NoError(t, err) + // ignored no more certificate rules + common.HandleCertificateEvent(common.EventParams{ + Name: "example.com", + Timestamp: time.Now(), + Status: 1, + Event: renewalEvent, + }) + + smtpCfg = smtp.Config{} + err = smtpCfg.Initialize(configDir, true) + require.NoError(t, err) +} + +func TestEventRuleIPBlocked(t *testing.T) { + oldConfig := config.GetCommonConfig() + + cfg := config.GetCommonConfig() + cfg.DefenderConfig.Enabled = true + cfg.DefenderConfig.Threshold = 3 + cfg.DefenderConfig.ScoreLimitExceeded = 2 + + err := common.Initialize(cfg, 0) + assert.NoError(t, err) + + smtpCfg := smtp.Config{ + Host: "127.0.0.1", + Port: 2525, + From: "notification@example.com", + TemplatesPath: "templates", + } + err = smtpCfg.Initialize(configDir, true) + require.NoError(t, err) + + a1 := dataprovider.BaseEventAction{ + Name: "action1", + Type: dataprovider.ActionTypeEmail, + Options: dataprovider.BaseEventActionOptions{ + EmailConfig: dataprovider.EventActionEmailConfig{ + Recipients: []string{"test3@example.com", "test4@example.com"}, + Subject: `New "{{.Event}}"`, + Body: "IP: {{.IP}} Timestamp: {{.Timestamp}}", + }, + }, + } + action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated) + assert.NoError(t, err) + + a2 := dataprovider.BaseEventAction{ + Name: "action2", + Type: dataprovider.ActionTypeFolderQuotaReset, + } + action2, _, err := httpdtest.AddEventAction(a2, http.StatusCreated) + assert.NoError(t, err) + + r1 := dataprovider.EventRule{ + Name: "test rule ip blocked", + Status: 1, + Trigger: dataprovider.EventTriggerIPBlocked, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + Order: 1, + }, + }, + } + rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated) + assert.NoError(t, err) + r2 := dataprovider.EventRule{ + Name: "test rule 2", + Status: 1, + Trigger: dataprovider.EventTriggerIPBlocked, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + Order: 1, + }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action2.Name, + }, + Order: 2, + }, + }, + } + rule2, _, err := httpdtest.AddEventRule(r2, http.StatusCreated) + assert.NoError(t, err) + + u := getTestUser() + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + lastReceivedEmail.reset() + time.Sleep(300 * time.Millisecond) + assert.Empty(t, lastReceivedEmail.get().From, lastReceivedEmail.get().Data) + + for i := 0; i < 3; i++ { + user.Password = "wrong_pwd" + _, _, err = getSftpClient(user) + assert.Error(t, err) + } + // the client is now banned + user.Password = defaultPassword + _, _, err = getSftpClient(user) + assert.Error(t, err) + // check the email notification + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 3000*time.Millisecond, 100*time.Millisecond) + email := lastReceivedEmail.get() + assert.Len(t, email.To, 2) + assert.True(t, slices.Contains(email.To, "test3@example.com")) + assert.True(t, slices.Contains(email.To, "test4@example.com")) + assert.Contains(t, email.Data, `Subject: New "IP Blocked"`) + + err = dataprovider.DeleteEventRule(rule1.Name, "", "", "") + assert.NoError(t, err) + err = dataprovider.DeleteEventRule(rule2.Name, "", "", "") + assert.NoError(t, err) + err = dataprovider.DeleteEventAction(action1.Name, "", "", "") + assert.NoError(t, err) + err = dataprovider.DeleteEventAction(action2.Name, "", "", "") + assert.NoError(t, err) + err = dataprovider.DeleteUser(user.Username, "", "", "") + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + smtpCfg = smtp.Config{} + err = smtpCfg.Initialize(configDir, true) + require.NoError(t, err) + + err = common.Initialize(oldConfig, 0) + assert.NoError(t, err) +} + +func TestEventRuleRotateLog(t *testing.T) { + smtpCfg := smtp.Config{ + Host: "127.0.0.1", + Port: 2525, + From: "notification@example.com", + TemplatesPath: "templates", + } + err := smtpCfg.Initialize(configDir, true) + require.NoError(t, err) + + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + + a1 := dataprovider.BaseEventAction{ + Name: "a1", + Type: dataprovider.ActionTypeRotateLogs, + } + action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated) + assert.NoError(t, err) + a2 := dataprovider.BaseEventAction{ + Name: "a2", + Type: dataprovider.ActionTypeEmail, + Options: dataprovider.BaseEventActionOptions{ + EmailConfig: dataprovider.EventActionEmailConfig{ + Recipients: []string{"success@example.net"}, + Subject: `OK`, + Body: "OK action", + }, + }, + } + action2, _, err := httpdtest.AddEventAction(a2, http.StatusCreated) + assert.NoError(t, err) + + r1 := dataprovider.EventRule{ + Name: "rule1", + Status: 1, + Trigger: dataprovider.EventTriggerFsEvent, + Conditions: dataprovider.EventConditions{ + FsEvents: []string{"mkdir"}, + Options: dataprovider.ConditionOptions{ + Names: []dataprovider.ConditionPattern{ + { + Pattern: user.Username, + }, + }, + }, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + Order: 1, + }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action2.Name, + }, + Order: 2, + }, + }, + } + rule1, resp, err := httpdtest.AddEventRule(r1, http.StatusCreated) + assert.NoError(t, err, string(resp)) + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + lastReceivedEmail.reset() + err := client.Mkdir("just a test dir") + assert.NoError(t, err) + // just check that the action is executed + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 1500*time.Millisecond, 100*time.Millisecond) + email := lastReceivedEmail.get() + assert.Len(t, email.To, 1) + assert.Contains(t, email.To, "success@example.net") + } + + _, err = httpdtest.RemoveEventRule(rule1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action2, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + smtpCfg = smtp.Config{} + err = smtpCfg.Initialize(configDir, true) + require.NoError(t, err) +} + +func TestEventRuleInactivityCheck(t *testing.T) { + smtpCfg := smtp.Config{ + Host: "127.0.0.1", + Port: 2525, + From: "notification@example.com", + TemplatesPath: "templates", + } + err := smtpCfg.Initialize(configDir, true) + require.NoError(t, err) + + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + + a1 := dataprovider.BaseEventAction{ + Name: "a1", + Type: dataprovider.ActionTypeUserInactivityCheck, + Options: dataprovider.BaseEventActionOptions{ + UserInactivityConfig: dataprovider.EventActionUserInactivity{ + DisableThreshold: 10, + DeleteThreshold: 20, + }, + }, + } + action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated) + assert.NoError(t, err) + a2 := dataprovider.BaseEventAction{ + Name: "a2", + Type: dataprovider.ActionTypeEmail, + Options: dataprovider.BaseEventActionOptions{ + EmailConfig: dataprovider.EventActionEmailConfig{ + Recipients: []string{"success@example.net"}, + Subject: `OK`, + Body: "OK action", + }, + }, + } + action2, _, err := httpdtest.AddEventAction(a2, http.StatusCreated) + assert.NoError(t, err) + + r1 := dataprovider.EventRule{ + Name: "rule1", + Status: 1, + Trigger: dataprovider.EventTriggerFsEvent, + Conditions: dataprovider.EventConditions{ + FsEvents: []string{"mkdir"}, + Options: dataprovider.ConditionOptions{ + Names: []dataprovider.ConditionPattern{ + { + Pattern: user.Username, + }, + }, + }, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + Order: 1, + }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action2.Name, + }, + Order: 2, + }, + }, + } + rule1, resp, err := httpdtest.AddEventRule(r1, http.StatusCreated) + assert.NoError(t, err, string(resp)) + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + lastReceivedEmail.reset() + err := client.Mkdir("just a test dir") + assert.NoError(t, err) + // just check that the action is executed + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 1500*time.Millisecond, 100*time.Millisecond) + email := lastReceivedEmail.get() + assert.Len(t, email.To, 1) + assert.Contains(t, email.To, "success@example.net") + } + + _, err = httpdtest.RemoveEventRule(rule1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action2, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + smtpCfg = smtp.Config{} + err = smtpCfg.Initialize(configDir, true) + require.NoError(t, err) +} + +func TestEventRulePasswordExpiration(t *testing.T) { + smtpCfg := smtp.Config{ + Host: "127.0.0.1", + Port: 2525, + From: "notification@example.com", + TemplatesPath: "templates", + } + err := smtpCfg.Initialize(configDir, true) + require.NoError(t, err) + + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + a1 := dataprovider.BaseEventAction{ + Name: "a1", + Type: dataprovider.ActionTypeEmail, + Options: dataprovider.BaseEventActionOptions{ + EmailConfig: dataprovider.EventActionEmailConfig{ + Recipients: []string{"failure@example.net"}, + Subject: `Failure`, + Body: "Failure action", + }, + }, + } + action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated) + assert.NoError(t, err) + a2 := dataprovider.BaseEventAction{ + Name: "a2", + Type: dataprovider.ActionTypePasswordExpirationCheck, + Options: dataprovider.BaseEventActionOptions{ + PwdExpirationConfig: dataprovider.EventActionPasswordExpiration{ + Threshold: 10, + }, + }, + } + action2, _, err := httpdtest.AddEventAction(a2, http.StatusCreated) + assert.NoError(t, err) + a3 := dataprovider.BaseEventAction{ + Name: "a3", + Type: dataprovider.ActionTypeEmail, + Options: dataprovider.BaseEventActionOptions{ + EmailConfig: dataprovider.EventActionEmailConfig{ + Recipients: []string{"success@example.net"}, + Subject: `OK`, + Body: "OK action", + }, + }, + } + action3, _, err := httpdtest.AddEventAction(a3, http.StatusCreated) + assert.NoError(t, err) + + r1 := dataprovider.EventRule{ + Name: "rule1", + Status: 1, + Trigger: dataprovider.EventTriggerFsEvent, + Conditions: dataprovider.EventConditions{ + FsEvents: []string{"mkdir"}, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action2.Name, + }, + Order: 1, + }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action3.Name, + }, + Order: 2, + }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + Options: dataprovider.EventActionOptions{ + IsFailureAction: true, + }, + }, + }, + } + rule1, resp, err := httpdtest.AddEventRule(r1, http.StatusCreated) + assert.NoError(t, err, string(resp)) + dirName := "aTestDir" + + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + lastReceivedEmail.reset() + err := client.Mkdir(dirName) + assert.NoError(t, err) + // the user has no password expiration, the check will be skipped and the ok action executed + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 1500*time.Millisecond, 100*time.Millisecond) + email := lastReceivedEmail.get() + assert.Len(t, email.To, 1) + assert.Contains(t, email.To, "success@example.net") + err = client.RemoveDirectory(dirName) + assert.NoError(t, err) + } + user.Filters.PasswordExpiration = 20 + _, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + conn, client, err = getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + lastReceivedEmail.reset() + err := client.Mkdir(dirName) + assert.NoError(t, err) + // the passowrd is not about to expire, the check will be skipped and the ok action executed + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 1500*time.Millisecond, 100*time.Millisecond) + email := lastReceivedEmail.get() + assert.Len(t, email.To, 1) + assert.Contains(t, email.To, "success@example.net") + err = client.RemoveDirectory(dirName) + assert.NoError(t, err) + } + user.Filters.PasswordExpiration = 5 + _, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + conn, client, err = getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + lastReceivedEmail.reset() + err := client.Mkdir(dirName) + assert.NoError(t, err) + // the passowrd is about to expire, the user has no email, the failure action will be executed + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 1500*time.Millisecond, 100*time.Millisecond) + email := lastReceivedEmail.get() + assert.Len(t, email.To, 1) + assert.Contains(t, email.To, "failure@example.net") + err = client.RemoveDirectory(dirName) + assert.NoError(t, err) + } + // remove the success action + rule1.Actions = []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action2.Name, + }, + Order: 1, + }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + Options: dataprovider.EventActionOptions{ + IsFailureAction: true, + }, + }, + } + _, _, err = httpdtest.UpdateEventRule(rule1, http.StatusOK) + assert.NoError(t, err) + user.Email = "user@example.net" + user.Filters.AdditionalEmails = []string{"additional@example.net"} + _, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + conn, client, err = getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + lastReceivedEmail.reset() + err := client.Mkdir(dirName) + assert.NoError(t, err) + // the passowrd expiration will be notified + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 1500*time.Millisecond, 100*time.Millisecond) + email := lastReceivedEmail.get() + assert.Len(t, email.To, 2) + assert.Contains(t, email.To, user.Email) + assert.Contains(t, email.To, user.Filters.AdditionalEmails[0]) + assert.Contains(t, email.Data, "your SFTPGo password expires in 5 days") + err = client.RemoveDirectory(dirName) + assert.NoError(t, err) + } + + _, err = httpdtest.RemoveEventRule(rule1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action2, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action3, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + smtpCfg = smtp.Config{} + err = smtpCfg.Initialize(configDir, true) + require.NoError(t, err) +} + +func TestSyncUploadAction(t *testing.T) { + if runtime.GOOS == osWindows { + t.Skip("this test is not available on Windows") + } + uploadScriptPath := filepath.Join(os.TempDir(), "upload.sh") + common.Config.Actions.ExecuteOn = []string{"upload"} + common.Config.Actions.ExecuteSync = []string{"upload"} + common.Config.Actions.Hook = uploadScriptPath + + u := getTestUser() + u.QuotaFiles = 1000 + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + movedFileName := "moved.dat" + movedPath := filepath.Join(user.HomeDir, movedFileName) + err = os.WriteFile(uploadScriptPath, getUploadScriptContent(movedPath, "", 0), 0755) + assert.NoError(t, err) + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + size := int64(32768) + err = writeSFTPFileNoCheck(testFileName, size, client) + assert.NoError(t, err) + _, err = client.Stat(testFileName) + assert.Error(t, err) + info, err := client.Stat(movedFileName) + if assert.NoError(t, err) { + assert.Equal(t, size, info.Size()) + } + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 1, user.UsedQuotaFiles) + assert.Equal(t, size, user.UsedQuotaSize) + // test some hook failure + // the uploaded file is moved and the hook fails, it will be not removed from the quota + err = os.WriteFile(uploadScriptPath, getUploadScriptContent(movedPath, "", 1), 0755) + assert.NoError(t, err) + err = writeSFTPFileNoCheck(testFileName+"_1", size, client) + assert.Error(t, err) + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 2, user.UsedQuotaFiles) + assert.Equal(t, size*2, user.UsedQuotaSize) + + // the uploaded file is not moved and the hook fails, the uploaded file will be deleted + // and removed from the quota + movedPath = filepath.Join(user.HomeDir, "missing dir", movedFileName) + err = os.WriteFile(uploadScriptPath, getUploadScriptContent(movedPath, "", 1), 0755) + assert.NoError(t, err) + err = writeSFTPFileNoCheck(testFileName+"_2", size, client) + assert.Error(t, err) + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 2, user.UsedQuotaFiles) + assert.Equal(t, size*2, user.UsedQuotaSize) + // overwrite an existing file + _, err = client.Stat(movedFileName) + assert.NoError(t, err) + err = writeSFTPFileNoCheck(movedFileName, size, client) + assert.Error(t, err) + _, err = client.Stat(movedFileName) + assert.Error(t, err) + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 1, user.UsedQuotaFiles) + assert.Equal(t, size, user.UsedQuotaSize) + } + + err = os.Remove(uploadScriptPath) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + common.Config.Actions.ExecuteOn = nil + common.Config.Actions.ExecuteSync = nil + common.Config.Actions.Hook = uploadScriptPath +} + +func TestQuotaTrackDisabled(t *testing.T) { + err := dataprovider.Close() + assert.NoError(t, err) + err = config.LoadConfig(configDir, "") + assert.NoError(t, err) + providerConf := config.GetProviderConf() + providerConf.TrackQuota = 0 + err = dataprovider.Initialize(providerConf, configDir, true) + assert.NoError(t, err) + + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + err = writeSFTPFile(testFileName, 32, client) + assert.NoError(t, err) + err = client.Rename(testFileName, testFileName+"1") + assert.NoError(t, err) + } + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + err = dataprovider.Close() + assert.NoError(t, err) + err = config.LoadConfig(configDir, "") + assert.NoError(t, err) + providerConf = config.GetProviderConf() + err = dataprovider.Initialize(providerConf, configDir, true) + assert.NoError(t, err) +} + +func TestGetQuotaError(t *testing.T) { + if dataprovider.GetProviderStatus().Driver == "memory" { + t.Skip("this test is not available with the memory provider") + } + u := getTestUser() + u.TotalDataTransfer = 2000 + mappedPath := filepath.Join(os.TempDir(), "vdir") + folderName := filepath.Base(mappedPath) + vdirPath := "/vpath" + f := vfs.BaseVirtualFolder{ + Name: folderName, + MappedPath: mappedPath, + } + _, _, err := httpdtest.AddFolder(f, http.StatusCreated) + assert.NoError(t, err) + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: folderName, + }, + VirtualPath: vdirPath, + QuotaSize: 0, + QuotaFiles: 10, + }) + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + err = writeSFTPFile(testFileName, 32, client) + assert.NoError(t, err) + + err = dataprovider.Close() + assert.NoError(t, err) + + err = client.Rename(testFileName, path.Join(vdirPath, testFileName)) + assert.Error(t, err) + + err = config.LoadConfig(configDir, "") + assert.NoError(t, err) + providerConf := config.GetProviderConf() + err = dataprovider.Initialize(providerConf, configDir, true) + assert.NoError(t, err) + } + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveFolder(vfs.BaseVirtualFolder{Name: folderName}, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath) + assert.NoError(t, err) +} + +func TestRetentionAPI(t *testing.T) { + u := getTestUser() + u.Permissions["/"] = []string{dataprovider.PermListItems, dataprovider.PermUpload, + dataprovider.PermOverwrite, dataprovider.PermDownload, dataprovider.PermCreateDirs, + dataprovider.PermChtimes} + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + uploadPath := path.Join(testDir, testFileName) + + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + err = client.Mkdir(testDir) + assert.NoError(t, err) + err = writeSFTPFile(uploadPath, 32, client) + assert.NoError(t, err) + + folderRetention := []dataprovider.FolderRetention{ + { + Path: "/", + Retention: 24, + DeleteEmptyDirs: true, + }, + } + check := common.RetentionCheck{ + Folders: folderRetention, + } + c := common.RetentionChecks.Add(check, &user) + assert.NotNil(t, c) + err = c.Start() + assert.NoError(t, err) + + assert.Eventually(t, func() bool { + return len(common.RetentionChecks.Get("")) == 0 + }, 1000*time.Millisecond, 50*time.Millisecond) + + _, err = client.Stat(uploadPath) + assert.NoError(t, err) + + err = client.Chtimes(uploadPath, time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour)) + assert.NoError(t, err) + + err = c.Start() + assert.NoError(t, err) + + assert.Eventually(t, func() bool { + return len(common.RetentionChecks.Get("")) == 0 + }, 1000*time.Millisecond, 50*time.Millisecond) + + _, err = client.Stat(uploadPath) + assert.ErrorIs(t, err, os.ErrNotExist) + + _, err = client.Stat(testDir) + assert.ErrorIs(t, err, os.ErrNotExist) + + err = client.Mkdir(testDir) + assert.NoError(t, err) + err = writeSFTPFile(uploadPath, 32, client) + assert.NoError(t, err) + + check.Folders[0].DeleteEmptyDirs = false + err = client.Chtimes(uploadPath, time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour)) + assert.NoError(t, err) + + c = common.RetentionChecks.Add(check, &user) + assert.NotNil(t, c) + err = c.Start() + assert.NoError(t, err) + + assert.Eventually(t, func() bool { + return len(common.RetentionChecks.Get("")) == 0 + }, 1000*time.Millisecond, 50*time.Millisecond) + + _, err = client.Stat(uploadPath) + assert.ErrorIs(t, err, os.ErrNotExist) + + _, err = client.Stat(testDir) + assert.NoError(t, err) + + err = writeSFTPFile(uploadPath, 32, client) + assert.NoError(t, err) + err = client.Chtimes(uploadPath, time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour)) + assert.NoError(t, err) + conn.Close() + client.Close() + } + + // remove delete permissions to the user, it will be automatically granted + user.Permissions["/"+testDir] = []string{dataprovider.PermListItems, dataprovider.PermUpload, + dataprovider.PermCreateDirs, dataprovider.PermChtimes} + user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + + conn, client, err = getSftpClient(user) + if assert.NoError(t, err) { + innerUploadFilePath := path.Join("/"+testDir, testDir, testFileName) + err = client.Mkdir(path.Join(testDir, testDir)) + assert.NoError(t, err) + + err = writeSFTPFile(innerUploadFilePath, 32, client) + assert.NoError(t, err) + err = client.Chtimes(innerUploadFilePath, time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour)) + assert.NoError(t, err) + + folderRetention := []dataprovider.FolderRetention{ + { + Path: "/missing", + Retention: 24, + }, + { + Path: "/" + testDir, + Retention: 24, + DeleteEmptyDirs: true, + }, + { + Path: path.Dir(innerUploadFilePath), + Retention: 0, + }, + } + check := common.RetentionCheck{ + Folders: folderRetention, + } + c := common.RetentionChecks.Add(check, &user) + assert.NotNil(t, c) + err = c.Start() + assert.NoError(t, err) + + assert.Eventually(t, func() bool { + return len(common.RetentionChecks.Get("")) == 0 + }, 1000*time.Millisecond, 50*time.Millisecond) + + _, err = client.Stat(uploadPath) + assert.ErrorIs(t, err, os.ErrNotExist) + _, err = client.Stat(innerUploadFilePath) + assert.NoError(t, err) + + folderRetention = []dataprovider.FolderRetention{ + + { + Path: "/" + testDir, + Retention: 24, + DeleteEmptyDirs: true, + }, + } + + check = common.RetentionCheck{ + Folders: folderRetention, + } + c = common.RetentionChecks.Add(check, &user) + assert.NotNil(t, c) + err = c.Start() + assert.NoError(t, err) + + assert.Eventually(t, func() bool { + return len(common.RetentionChecks.Get("")) == 0 + }, 1000*time.Millisecond, 50*time.Millisecond) + + _, err = client.Stat(innerUploadFilePath) + assert.ErrorIs(t, err, os.ErrNotExist) + conn.Close() + client.Close() + } + // finally test some errors removing files or folders + if runtime.GOOS != osWindows { + dirPath := filepath.Join(user.HomeDir, "adir", "sub") + err := os.MkdirAll(dirPath, os.ModePerm) + assert.NoError(t, err) + filePath := filepath.Join(dirPath, "f.dat") + err = os.WriteFile(filePath, nil, os.ModePerm) + assert.NoError(t, err) + + err = os.Chtimes(filePath, time.Now().Add(-72*time.Hour), time.Now().Add(-72*time.Hour)) + assert.NoError(t, err) + + err = os.Chmod(dirPath, 0001) + assert.NoError(t, err) + + folderRetention := []dataprovider.FolderRetention{ + + { + Path: "/adir", + Retention: 24, + DeleteEmptyDirs: true, + }, + } + + check := common.RetentionCheck{ + Folders: folderRetention, + } + c := common.RetentionChecks.Add(check, &user) + assert.NotNil(t, c) + err = c.Start() + assert.ErrorIs(t, err, os.ErrPermission) + + assert.Eventually(t, func() bool { + return len(common.RetentionChecks.Get("")) == 0 + }, 1000*time.Millisecond, 50*time.Millisecond) + + err = os.Chmod(dirPath, 0555) + assert.NoError(t, err) + + c = common.RetentionChecks.Add(check, &user) + assert.NotNil(t, c) + err = c.Start() + assert.ErrorIs(t, err, os.ErrPermission) + + assert.Eventually(t, func() bool { + return len(common.RetentionChecks.Get("")) == 0 + }, 1000*time.Millisecond, 50*time.Millisecond) + + err = os.Chmod(dirPath, os.ModePerm) + assert.NoError(t, err) + + check = common.RetentionCheck{ + Folders: folderRetention, + } + c = common.RetentionChecks.Add(check, &user) + assert.NotNil(t, c) + err = c.Start() + assert.NoError(t, err) + + assert.Eventually(t, func() bool { + return len(common.RetentionChecks.Get("")) == 0 + }, 1000*time.Millisecond, 50*time.Millisecond) + + assert.NoDirExists(t, dirPath) + } + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + assert.Eventually(t, func() bool { + return common.Connections.GetClientConnections() == 0 + }, 1*time.Second, 50*time.Millisecond) +} + +func TestPerUserTransferLimits(t *testing.T) { + oldMaxPerHostConns := common.Config.MaxPerHostConnections + + common.Config.MaxPerHostConnections = 2 + + u := getTestUser() + u.UploadBandwidth = 32 + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + if !assert.NoError(t, err) { + printLatestLogs(20) + } + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + var wg sync.WaitGroup + numErrors := 0 + for i := 0; i <= 2; i++ { + wg.Add(1) + go func(counter int) { + defer wg.Done() + + time.Sleep(20 * time.Millisecond) + err := writeSFTPFile(fmt.Sprintf("%s_%d", testFileName, counter), 64*1024, client) + if err != nil { + numErrors++ + } + }(i) + } + wg.Wait() + + assert.Equal(t, 1, numErrors) + } + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + common.Config.MaxPerHostConnections = oldMaxPerHostConns +} + +func TestMaxSessionsSameConnection(t *testing.T) { + u := getTestUser() + u.UploadBandwidth = 32 + u.MaxSessions = 2 + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + var wg sync.WaitGroup + numErrors := 0 + for i := 0; i <= 2; i++ { + wg.Add(1) + go func(counter int) { + defer wg.Done() + + var err error + if counter < 2 { + err = writeSFTPFile(fmt.Sprintf("%s_%d", testFileName, counter), 64*1024, client) + } else { + // wait for the transfers to start + time.Sleep(50 * time.Millisecond) + _, _, err = getSftpClient(user) + } + if err != nil { + numErrors++ + } + }(i) + } + + wg.Wait() + assert.Equal(t, 1, numErrors) + } + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +func TestRenameDir(t *testing.T) { + u := getTestUser() + testDir := "/dir-to-rename" + u.Permissions[testDir] = []string{dataprovider.PermListItems, dataprovider.PermUpload} + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + err = client.Mkdir(testDir) + assert.NoError(t, err) + err = writeSFTPFile(path.Join(testDir, testFileName), 32, client) + assert.NoError(t, err) + err = client.Rename(testDir, testDir+"_rename") + assert.ErrorIs(t, err, os.ErrPermission) + } + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +func TestBuiltinKeyboardInteractiveAuthentication(t *testing.T) { + u := getTestUser() + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + authMethods := []ssh.AuthMethod{ + ssh.KeyboardInteractive(func(_, _ string, _ []string, _ []bool) ([]string, error) { + return []string{defaultPassword}, nil + }), + } + conn, client, err := getCustomAuthSftpClient(user, authMethods) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + assert.NoError(t, checkBasicSFTP(client)) + err = writeSFTPFile(testFileName, 4096, client) + assert.NoError(t, err) + } + // add multi-factor authentication + configName, key, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + assert.NoError(t, err) + user.Password = defaultPassword + user.Filters.TOTPConfig = dataprovider.UserTOTPConfig{ + Enabled: true, + ConfigName: configName, + Secret: kms.NewPlainSecret(key.Secret()), + Protocols: []string{common.ProtocolSSH}, + } + err = dataprovider.UpdateUser(&user, "", "", "") + assert.NoError(t, err) + passcode, err := generateTOTPPasscode(key.Secret(), otp.AlgorithmSHA1) + assert.NoError(t, err) + passwordAsked := false + passcodeAsked := false + authMethods = []ssh.AuthMethod{ + ssh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) { + var answers []string + if strings.HasPrefix(questions[0], "Password") { + answers = append(answers, defaultPassword) + passwordAsked = true + } else { + answers = append(answers, passcode) + passcodeAsked = true + } + return answers, nil + }), + } + conn, client, err = getCustomAuthSftpClient(user, authMethods) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + assert.NoError(t, checkBasicSFTP(client)) + err = writeSFTPFile(testFileName, 4096, client) + assert.NoError(t, err) + } + assert.True(t, passwordAsked) + assert.True(t, passcodeAsked) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +func TestMultiStepBuiltinKeyboardAuth(t *testing.T) { + u := getTestUser() + u.PublicKeys = []string{testPubKey} + u.Filters.DeniedLoginMethods = []string{ + dataprovider.SSHLoginMethodPublicKey, + dataprovider.LoginMethodPassword, + dataprovider.SSHLoginMethodKeyboardInteractive, + } + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + signer, err := ssh.ParsePrivateKey([]byte(testPrivateKey)) + assert.NoError(t, err) + // public key + password + authMethods := []ssh.AuthMethod{ + ssh.PublicKeys(signer), + ssh.KeyboardInteractive(func(_, _ string, _ []string, _ []bool) ([]string, error) { + return []string{defaultPassword}, nil + }), + } + conn, client, err := getCustomAuthSftpClient(user, authMethods) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + assert.NoError(t, checkBasicSFTP(client)) + err = writeSFTPFile(testFileName, 4096, client) + assert.NoError(t, err) + } + // add multi-factor authentication + configName, key, _, err := mfa.GenerateTOTPSecret(mfa.GetAvailableTOTPConfigNames()[0], user.Username) + assert.NoError(t, err) + user.Password = defaultPassword + user.Filters.TOTPConfig = dataprovider.UserTOTPConfig{ + Enabled: true, + ConfigName: configName, + Secret: kms.NewPlainSecret(key.Secret()), + Protocols: []string{common.ProtocolSSH}, + } + err = dataprovider.UpdateUser(&user, "", "", "") + assert.NoError(t, err) + passcode, err := generateTOTPPasscode(key.Secret(), otp.AlgorithmSHA1) + assert.NoError(t, err) + // public key + passcode + authMethods = []ssh.AuthMethod{ + ssh.PublicKeys(signer), + ssh.KeyboardInteractive(func(_, _ string, _ []string, _ []bool) ([]string, error) { + return []string{passcode}, nil + }), + } + conn, client, err = getCustomAuthSftpClient(user, authMethods) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + assert.NoError(t, checkBasicSFTP(client)) + err = writeSFTPFile(testFileName, 4096, client) + assert.NoError(t, err) + } + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +func TestRenameSymlink(t *testing.T) { + u := getTestUser() + testDir := "/dir-no-create-links" + otherDir := "otherdir" + u.Permissions[testDir] = []string{dataprovider.PermListItems, dataprovider.PermUpload, dataprovider.PermDelete, + dataprovider.PermCreateDirs} + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + err = client.Mkdir(otherDir) + assert.NoError(t, err) + err = client.Symlink(otherDir, otherDir+".link") + assert.NoError(t, err) + err = client.Rename(otherDir+".link", path.Join(testDir, "symlink")) + assert.ErrorIs(t, err, os.ErrPermission) + err = client.Rename(otherDir+".link", "allowed_link") + assert.NoError(t, err) + } + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +func TestSplittedDeletePerms(t *testing.T) { + u := getTestUser() + u.Permissions["/"] = []string{dataprovider.PermListItems, dataprovider.PermUpload, dataprovider.PermDeleteDirs, + dataprovider.PermCreateDirs} + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + err = writeSFTPFile(testFileName, 4096, client) + assert.NoError(t, err) + err = client.Remove(testFileName) + assert.Error(t, err) + err = client.Mkdir(testDir) + assert.NoError(t, err) + err = client.RemoveDirectory(testDir) + assert.NoError(t, err) + } + u.Permissions["/"] = []string{dataprovider.PermListItems, dataprovider.PermUpload, dataprovider.PermDeleteFiles, + dataprovider.PermCreateDirs, dataprovider.PermOverwrite} + _, _, err = httpdtest.UpdateUser(u, http.StatusOK, "") + assert.NoError(t, err) + + conn, client, err = getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + err = writeSFTPFile(testFileName, 4096, client) + assert.NoError(t, err) + err = client.Remove(testFileName) + assert.NoError(t, err) + err = client.Mkdir(testDir) + assert.NoError(t, err) + err = client.RemoveDirectory(testDir) + assert.Error(t, err) + } + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +func TestSplittedRenamePerms(t *testing.T) { + u := getTestUser() + u.Permissions["/"] = []string{dataprovider.PermListItems, dataprovider.PermUpload, dataprovider.PermRenameDirs, + dataprovider.PermCreateDirs} + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + err = writeSFTPFile(testFileName, 4096, client) + assert.NoError(t, err) + err = client.Mkdir(testDir) + assert.NoError(t, err) + err = client.Rename(testFileName, testFileName+"_renamed") + assert.Error(t, err) + err = client.Rename(testDir, testDir+"_renamed") + assert.NoError(t, err) + } + u.Permissions["/"] = []string{dataprovider.PermListItems, dataprovider.PermUpload, dataprovider.PermRenameFiles, + dataprovider.PermCreateDirs, dataprovider.PermOverwrite} + _, _, err = httpdtest.UpdateUser(u, http.StatusOK, "") + assert.NoError(t, err) + + conn, client, err = getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + err = writeSFTPFile(testFileName, 4096, client) + assert.NoError(t, err) + err = client.Mkdir(testDir) + assert.NoError(t, err) + err = client.Rename(testFileName, testFileName+"_renamed") + assert.NoError(t, err) + err = client.Rename(testDir, testDir+"_renamed") + assert.Error(t, err) + } + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +func TestSFTPLoopError(t *testing.T) { + smtpCfg := smtp.Config{ + Host: "127.0.0.1", + Port: 2525, + From: "notification@example.com", + TemplatesPath: "templates", + } + err := smtpCfg.Initialize(configDir, true) + require.NoError(t, err) + user1 := getTestUser() + user2 := getTestUser() + user1.Username += "1" + user2.Username += "2" + // user1 is a local account with a virtual SFTP folder to user2 + // user2 has user1 as SFTP fs + f := vfs.BaseVirtualFolder{ + Name: "sftp", + FsConfig: vfs.Filesystem{ + Provider: sdk.SFTPFilesystemProvider, + SFTPConfig: vfs.SFTPFsConfig{ + BaseSFTPFsConfig: sdk.BaseSFTPFsConfig{ + Endpoint: sftpServerAddr, + Username: user2.Username, + }, + Password: kms.NewPlainSecret(defaultPassword), + }, + }, + } + folder, _, err := httpdtest.AddFolder(f, http.StatusCreated) + assert.NoError(t, err) + user1.VirtualFolders = append(user1.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: folder.Name, + }, + VirtualPath: "/vdir", + }) + + user2.FsConfig.Provider = sdk.SFTPFilesystemProvider + user2.FsConfig.SFTPConfig = vfs.SFTPFsConfig{ + BaseSFTPFsConfig: sdk.BaseSFTPFsConfig{ + Endpoint: sftpServerAddr, + Username: user1.Username, + }, + Password: kms.NewPlainSecret(defaultPassword), + } + + user1, resp, err := httpdtest.AddUser(user1, http.StatusCreated) + assert.NoError(t, err, string(resp)) + user2, resp, err = httpdtest.AddUser(user2, http.StatusCreated) + assert.NoError(t, err, string(resp)) + a1 := dataprovider.BaseEventAction{ + Name: "a1", + Type: dataprovider.ActionTypeUserQuotaReset, + } + action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated) + assert.NoError(t, err) + a2 := dataprovider.BaseEventAction{ + Name: "a2", + Type: dataprovider.ActionTypeEmail, + Options: dataprovider.BaseEventActionOptions{ + EmailConfig: dataprovider.EventActionEmailConfig{ + Recipients: []string{"failure@example.com"}, + Subject: `Failed action"`, + Body: "Test body", + }, + }, + } + action2, _, err := httpdtest.AddEventAction(a2, http.StatusCreated) + assert.NoError(t, err) + r1 := dataprovider.EventRule{ + Name: "rule1", + Status: 1, + Trigger: dataprovider.EventTriggerProviderEvent, + Conditions: dataprovider.EventConditions{ + ProviderEvents: []string{"update"}, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + Order: 1, + }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action2.Name, + }, + Order: 2, + Options: dataprovider.EventActionOptions{ + IsFailureAction: true, + }, + }, + }, + } + rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated) + assert.NoError(t, err) + + lastReceivedEmail.reset() + _, _, err = httpdtest.UpdateUser(user2, http.StatusOK, "") + assert.NoError(t, err) + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 3000*time.Millisecond, 100*time.Millisecond) + email := lastReceivedEmail.get() + assert.Len(t, email.To, 1) + assert.True(t, slices.Contains(email.To, "failure@example.com")) + assert.Contains(t, email.Data, `Subject: Failed action`) + + user1.VirtualFolders[0].FsConfig.SFTPConfig.Password = kms.NewPlainSecret(defaultPassword) + user2.FsConfig.SFTPConfig.Password = kms.NewPlainSecret(defaultPassword) + + conn := common.NewBaseConnection("", common.ProtocolWebDAV, "", "", user1) + _, _, err = conn.GetFsAndResolvedPath(user1.VirtualFolders[0].VirtualPath) + assert.ErrorIs(t, err, os.ErrPermission) + + conn = common.NewBaseConnection("", common.ProtocolSFTP, "", "", user1) + _, _, err = conn.GetFsAndResolvedPath(user1.VirtualFolders[0].VirtualPath) + assert.Error(t, err) + conn = common.NewBaseConnection("", common.ProtocolFTP, "", "", user1) + _, _, err = conn.GetFsAndResolvedPath(user1.VirtualFolders[0].VirtualPath) + assert.Error(t, err) + + _, err = httpdtest.RemoveEventRule(rule1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action2, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(user1, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user1.GetHomeDir()) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(user2, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user2.GetHomeDir()) + assert.NoError(t, err) + _, err = httpdtest.RemoveFolder(folder, http.StatusOK) + assert.NoError(t, err) + + smtpCfg = smtp.Config{} + err = smtpCfg.Initialize(configDir, true) + require.NoError(t, err) +} + +func TestNonLocalCrossRename(t *testing.T) { + baseUser, resp, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err, string(resp)) + u := getTestUser() + u.HomeDir += "_folders" + u.Username += "_folders" + mappedPathSFTP := filepath.Join(os.TempDir(), "sftp") + folderNameSFTP := filepath.Base(mappedPathSFTP) + vdirSFTPPath := "/vdir/sftp" + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: folderNameSFTP, + }, + VirtualPath: vdirSFTPPath, + }) + mappedPathCrypt := filepath.Join(os.TempDir(), "crypt") + folderNameCrypt := filepath.Base(mappedPathCrypt) + vdirCryptPath := "/vdir/crypt" + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: folderNameCrypt, + }, + VirtualPath: vdirCryptPath, + }) + f1 := vfs.BaseVirtualFolder{ + Name: folderNameSFTP, + FsConfig: vfs.Filesystem{ + Provider: sdk.SFTPFilesystemProvider, + SFTPConfig: vfs.SFTPFsConfig{ + BaseSFTPFsConfig: sdk.BaseSFTPFsConfig{ + Endpoint: sftpServerAddr, + Username: baseUser.Username, + }, + Password: kms.NewPlainSecret(defaultPassword), + }, + }, + } + _, _, err = httpdtest.AddFolder(f1, http.StatusCreated) + assert.NoError(t, err) + f2 := vfs.BaseVirtualFolder{ + Name: folderNameCrypt, + FsConfig: vfs.Filesystem{ + Provider: sdk.CryptedFilesystemProvider, + CryptConfig: vfs.CryptFsConfig{ + Passphrase: kms.NewPlainSecret(defaultPassword), + }, + }, + MappedPath: mappedPathCrypt, + } + _, _, err = httpdtest.AddFolder(f2, http.StatusCreated) + assert.NoError(t, err) + + user, resp, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err, string(resp)) + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + assert.NoError(t, checkBasicSFTP(client)) + err = writeSFTPFile(testFileName, 4096, client) + assert.NoError(t, err) + err = writeSFTPFile(path.Join(vdirSFTPPath, testFileName), 8192, client) + assert.NoError(t, err) + err = writeSFTPFile(path.Join(vdirCryptPath, testFileName), 16384, client) + assert.NoError(t, err) + err = client.Rename(path.Join(vdirSFTPPath, testFileName), path.Join(vdirCryptPath, testFileName+".rename")) + assert.ErrorIs(t, err, os.ErrPermission) + err = client.Rename(path.Join(vdirCryptPath, testFileName), path.Join(vdirSFTPPath, testFileName+".rename")) + assert.ErrorIs(t, err, os.ErrPermission) + err = client.Rename(testFileName, path.Join(vdirCryptPath, testFileName+".rename")) + assert.ErrorIs(t, err, os.ErrPermission) + err = client.Rename(testFileName, path.Join(vdirSFTPPath, testFileName+".rename")) + assert.ErrorIs(t, err, os.ErrPermission) + err = client.Rename(path.Join(vdirSFTPPath, testFileName), testFileName+".rename") + assert.ErrorIs(t, err, os.ErrPermission) + err = client.Rename(path.Join(vdirCryptPath, testFileName), testFileName+".rename") + assert.ErrorIs(t, err, os.ErrPermission) + // rename on local fs or on the same folder must work + err = client.Rename(testFileName, testFileName+".rename") + assert.NoError(t, err) + err = client.Rename(path.Join(vdirSFTPPath, testFileName), path.Join(vdirSFTPPath, testFileName+"_rename")) + assert.NoError(t, err) + err = client.Rename(path.Join(vdirCryptPath, testFileName), path.Join(vdirCryptPath, testFileName+"_rename")) + assert.NoError(t, err) + // renaming a virtual folder is not allowed + err = client.Rename(vdirSFTPPath, vdirSFTPPath+"_rename") + assert.ErrorIs(t, err, os.ErrPermission) + err = client.Rename(vdirCryptPath, vdirCryptPath+"_rename") + assert.ErrorIs(t, err, os.ErrPermission) + err = client.Rename(vdirCryptPath, path.Join(vdirCryptPath, "rename")) + assert.ErrorIs(t, err, os.ErrPermission) + err = client.Mkdir(path.Join(vdirCryptPath, "subcryptdir")) + assert.NoError(t, err) + err = client.Rename(path.Join(vdirCryptPath, "subcryptdir"), vdirCryptPath) + assert.ErrorIs(t, err, os.ErrPermission) + // renaming root folder is not allowed + err = client.Rename("/", "new_name") + assert.ErrorIs(t, err, os.ErrPermission) + // renaming a path to a virtual folder is not allowed + err = client.Rename("/vdir", "new_vdir") + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED") + } + } + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveFolder(vfs.BaseVirtualFolder{Name: folderNameCrypt}, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveFolder(vfs.BaseVirtualFolder{Name: folderNameSFTP}, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(baseUser, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + err = os.RemoveAll(baseUser.GetHomeDir()) + assert.NoError(t, err) + err = os.RemoveAll(mappedPathCrypt) + assert.NoError(t, err) + err = os.RemoveAll(mappedPathSFTP) + assert.NoError(t, err) +} + +func TestNonLocalCrossRenameNonLocalBaseUser(t *testing.T) { + baseUser, resp, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err, string(resp)) + u := getTestSFTPUser() + mappedPathLocal := filepath.Join(os.TempDir(), "local") + folderNameLocal := filepath.Base(mappedPathLocal) + vdirLocalPath := "/vdir/local" + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: folderNameLocal, + }, + VirtualPath: vdirLocalPath, + }) + mappedPathCrypt := filepath.Join(os.TempDir(), "crypt") + folderNameCrypt := filepath.Base(mappedPathCrypt) + vdirCryptPath := "/vdir/crypt" + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: folderNameCrypt, + }, + VirtualPath: vdirCryptPath, + }) + f1 := vfs.BaseVirtualFolder{ + Name: folderNameLocal, + MappedPath: mappedPathLocal, + } + _, _, err = httpdtest.AddFolder(f1, http.StatusCreated) + assert.NoError(t, err) + f2 := vfs.BaseVirtualFolder{ + Name: folderNameCrypt, + FsConfig: vfs.Filesystem{ + Provider: sdk.CryptedFilesystemProvider, + CryptConfig: vfs.CryptFsConfig{ + Passphrase: kms.NewPlainSecret(defaultPassword), + }, + }, + MappedPath: mappedPathCrypt, + } + _, _, err = httpdtest.AddFolder(f2, http.StatusCreated) + assert.NoError(t, err) + + user, resp, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err, string(resp)) + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + assert.NoError(t, checkBasicSFTP(client)) + err = writeSFTPFile(testFileName, 4096, client) + assert.NoError(t, err) + err = writeSFTPFile(path.Join(vdirLocalPath, testFileName), 8192, client) + assert.NoError(t, err) + err = writeSFTPFile(path.Join(vdirCryptPath, testFileName), 16384, client) + assert.NoError(t, err) + err = client.Rename(path.Join(vdirLocalPath, testFileName), path.Join(vdirCryptPath, testFileName+".rename")) + assert.ErrorIs(t, err, os.ErrPermission) + err = client.Rename(path.Join(vdirCryptPath, testFileName), path.Join(vdirLocalPath, testFileName+".rename")) + assert.ErrorIs(t, err, os.ErrPermission) + err = client.Rename(testFileName, path.Join(vdirCryptPath, testFileName+".rename")) + assert.ErrorIs(t, err, os.ErrPermission) + err = client.Rename(testFileName, path.Join(vdirLocalPath, testFileName+".rename")) + assert.ErrorIs(t, err, os.ErrPermission) + err = client.Rename(path.Join(vdirLocalPath, testFileName), testFileName+".rename") + assert.ErrorIs(t, err, os.ErrPermission) + err = client.Rename(path.Join(vdirCryptPath, testFileName), testFileName+".rename") + assert.ErrorIs(t, err, os.ErrPermission) + // rename on local fs or on the same folder must work + err = client.Rename(testFileName, testFileName+".rename") + assert.NoError(t, err) + err = client.Rename(path.Join(vdirLocalPath, testFileName), path.Join(vdirLocalPath, testFileName+"_rename")) + assert.NoError(t, err) + err = client.Rename(path.Join(vdirCryptPath, testFileName), path.Join(vdirCryptPath, testFileName+"_rename")) + assert.NoError(t, err) + // renaming a virtual folder is not allowed + err = client.Rename(vdirLocalPath, vdirLocalPath+"_rename") + assert.ErrorIs(t, err, os.ErrPermission) + err = client.Rename(vdirCryptPath, vdirCryptPath+"_rename") + assert.ErrorIs(t, err, os.ErrPermission) + // renaming root folder is not allowed + err = client.Rename("/", "new_name") + assert.ErrorIs(t, err, os.ErrPermission) + // renaming a path to a virtual folder is not allowed + err = client.Rename("/vdir", "new_vdir") + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED") + } + } + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveFolder(vfs.BaseVirtualFolder{Name: folderNameCrypt}, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveFolder(vfs.BaseVirtualFolder{Name: folderNameLocal}, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(baseUser, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + err = os.RemoveAll(baseUser.GetHomeDir()) + assert.NoError(t, err) + err = os.RemoveAll(mappedPathCrypt) + assert.NoError(t, err) + err = os.RemoveAll(mappedPathLocal) + assert.NoError(t, err) +} + +func TestCopyAndRemoveSSHCommands(t *testing.T) { + u := getTestUser() + u.QuotaFiles = 1000 + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + fileSize := int64(32) + err = writeSFTPFile(testFileName, fileSize, client) + assert.NoError(t, err) + + testFileNameCopy := testFileName + "_copy" + out, err := runSSHCommand(fmt.Sprintf("sftpgo-copy %s %s", testFileName, testFileNameCopy), user) + assert.NoError(t, err, string(out)) + // the resolved destination path match the source path + out, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %s %s", testFileName, path.Dir(testFileName)), user) + assert.Error(t, err, string(out)) + + info, err := client.Stat(testFileNameCopy) + if assert.NoError(t, err) { + assert.Equal(t, fileSize, info.Size()) + } + + testDir := "test dir" + err = client.Mkdir(testDir) + assert.NoError(t, err) + out, err = runSSHCommand(fmt.Sprintf(`sftpgo-copy %s '%s'`, testFileName, testDir), user) + assert.NoError(t, err, string(out)) + info, err = client.Stat(path.Join(testDir, testFileName)) + if assert.NoError(t, err) { + assert.Equal(t, fileSize, info.Size()) + } + + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 3*fileSize, user.UsedQuotaSize) + assert.Equal(t, 3, user.UsedQuotaFiles) + + out, err = runSSHCommand(fmt.Sprintf("sftpgo-remove %s", testFileNameCopy), user) + assert.NoError(t, err, string(out)) + out, err = runSSHCommand(fmt.Sprintf(`sftpgo-remove '%s'`, testDir), user) + assert.NoError(t, err, string(out)) + + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, fileSize, user.UsedQuotaSize) + assert.Equal(t, 1, user.UsedQuotaFiles) + + _, err = client.Stat(testFileNameCopy) + assert.ErrorIs(t, err, os.ErrNotExist) + // create a dir tree + dir1 := "dir1" + dir2 := "dir 2" + err = client.MkdirAll(path.Join(dir1, dir2)) + assert.NoError(t, err) + toCreate := []string{ + path.Join(dir1, testFileName), + path.Join(dir1, dir2, testFileName), + } + for _, p := range toCreate { + err = writeSFTPFile(p, fileSize, client) + assert.NoError(t, err) + } + // create a symlink, copying a symlink is not supported + err = client.Symlink(path.Join("/", dir1, testFileName), path.Join("/", dir1, testFileName+"_link")) + assert.NoError(t, err) + out, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %s %s", path.Join("/", dir1, testFileName+"_link"), + path.Join("/", testFileName+"_link")), user) + assert.Error(t, err, string(out)) + // copying a dir inside itself should fail + out, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %s %s", path.Join("/", dir1), + path.Join("/", dir1, "sub")), user) + assert.Error(t, err, string(out)) + // copy source and dest must differ + out, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %s %s", path.Join("/", dir1), + path.Join("/", dir1)), user) + assert.Error(t, err, string(out)) + // copy a missing file/dir should fail + out, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %s %s", path.Join("/", "missing_entry"), + path.Join("/", dir1)), user) + assert.Error(t, err, string(out)) + // try to overwrite a file with a dir + out, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %s %s", path.Join("/", dir1), testFileName), user) + assert.Error(t, err, string(out)) + + out, err = runSSHCommand(fmt.Sprintf(`sftpgo-copy %s "%s"`, dir1, dir2), user) + assert.NoError(t, err, string(out)) + + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 5*fileSize, user.UsedQuotaSize) + assert.Equal(t, 5, user.UsedQuotaFiles) + + // copy again, quota must remain unchanged + out, err = runSSHCommand(fmt.Sprintf(`sftpgo-copy %s/ "%s"`, dir1, dir2), user) + assert.NoError(t, err, string(out)) + _, err = client.Stat(dir2) + assert.NoError(t, err) + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 5*fileSize, user.UsedQuotaSize) + assert.Equal(t, 5, user.UsedQuotaFiles) + // now copy inside target + out, err = runSSHCommand(fmt.Sprintf(`sftpgo-copy %s "%s"`, dir1, dir2), user) + assert.NoError(t, err, string(out)) + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 7*fileSize, user.UsedQuotaSize) + assert.Equal(t, 7, user.UsedQuotaFiles) + + for _, p := range []string{dir1, dir2} { + out, err = runSSHCommand(fmt.Sprintf(`sftpgo-remove "%s"`, p), user) + assert.NoError(t, err, string(out)) + _, err = client.Stat(p) + assert.ErrorIs(t, err, os.ErrNotExist) + } + user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, fileSize, user.UsedQuotaSize) + assert.Equal(t, 1, user.UsedQuotaFiles) + // test quota errors + user.QuotaFiles = 1 + _, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + // quota files exceeded + out, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %s %s", testFileName, testFileNameCopy), user) + assert.Error(t, err, string(out)) + user.QuotaFiles = 1000 + user.QuotaSize = fileSize + 1 + _, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + // quota size exceeded after the copy + out, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %s %s", testFileName, testFileNameCopy), user) + assert.Error(t, err, string(out)) + user.QuotaSize = fileSize - 1 + _, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + // quota size exceeded + out, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %s %s", testFileName, testFileNameCopy), user) + assert.Error(t, err, string(out)) + } + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +func TestCopyAndRemovePermissions(t *testing.T) { + u := getTestUser() + restrictedPath := "/dir/path" + patternFilterPath := "/patterns" + u.Filters.FilePatterns = []sdk.PatternsFilter{ + { + Path: patternFilterPath, + DeniedPatterns: []string{"*.dat"}, + }, + } + u.Permissions[restrictedPath] = []string{} + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + err = client.MkdirAll(restrictedPath) + assert.NoError(t, err) + err = client.MkdirAll(patternFilterPath) + assert.NoError(t, err) + err = writeSFTPFile(testFileName, 100, client) + assert.NoError(t, err) + // getting file writer will fail + out, err := runSSHCommand(fmt.Sprintf(`sftpgo-copy %s %s`, testFileName, restrictedPath), user) + assert.Error(t, err, string(out)) + // file pattern not allowed + out, err = runSSHCommand(fmt.Sprintf(`sftpgo-copy %s %s`, testFileName, patternFilterPath), user) + assert.Error(t, err, string(out)) + + testDir := path.Join("/", path.Base(restrictedPath)) + err = client.Mkdir(testDir) + assert.NoError(t, err) + err = writeSFTPFile(path.Join(testDir, testFileName), 100, client) + assert.NoError(t, err) + // creating target dir will fail + out, err = runSSHCommand(fmt.Sprintf(`sftpgo-copy %s %s/`, testDir, restrictedPath), user) + assert.Error(t, err, string(out)) + // get dir contents will fail + out, err = runSSHCommand(fmt.Sprintf(`sftpgo-copy %s /`, restrictedPath), user) + assert.Error(t, err, string(out)) + // get dir contents will fail + out, err = runSSHCommand(fmt.Sprintf(`sftpgo-remove %s`, restrictedPath), user) + assert.Error(t, err, string(out)) + // give list dir permissions and retry, now delete will fail + user.Permissions[restrictedPath] = []string{dataprovider.PermListItems, dataprovider.PermUpload} + user.Permissions[testDir] = []string{dataprovider.PermListItems} + _, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + // no copy permission + out, err = runSSHCommand(fmt.Sprintf(`sftpgo-copy %s %s`, testFileName, restrictedPath), user) + assert.Error(t, err, string(out)) + user.Permissions[restrictedPath] = []string{dataprovider.PermListItems, dataprovider.PermUpload, dataprovider.PermCopy} + user.Permissions[testDir] = []string{dataprovider.PermListItems, dataprovider.PermCopy} + _, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + out, err = runSSHCommand(fmt.Sprintf(`sftpgo-copy %s %s`, testFileName, restrictedPath), user) + assert.NoError(t, err, string(out)) + // overwrite will fail, no permission + out, err = runSSHCommand(fmt.Sprintf(`sftpgo-copy %s %s`, testFileName, restrictedPath), user) + assert.Error(t, err, string(out)) + out, err = runSSHCommand(fmt.Sprintf(`sftpgo-remove %s`, restrictedPath), user) + assert.Error(t, err, string(out)) + // try to copy a file from testDir, we have only list permissions so getFileReader will fail + out, err = runSSHCommand(fmt.Sprintf(`sftpgo-copy %s %s`, path.Join(testDir, testFileName), testFileName+".copy"), user) + assert.Error(t, err, string(out)) + } + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +func TestCrossFoldersCopy(t *testing.T) { + baseUser, resp, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err, string(resp)) + + u := getTestUser() + u.Username += "_1" + u.HomeDir = filepath.Join(os.TempDir(), u.Username) + u.QuotaFiles = 1000 + mappedPath1 := filepath.Join(os.TempDir(), "mapped1") + folderName1 := filepath.Base(mappedPath1) + vpath1 := "/vdirs/vdir1" + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: folderName1, + }, + VirtualPath: vpath1, + QuotaSize: -1, + QuotaFiles: -1, + }) + mappedPath2 := filepath.Join(os.TempDir(), "mapped1", "dir", "mapped2") + folderName2 := filepath.Base(mappedPath2) + vpath2 := "/vdirs/vdir2" + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: folderName2, + }, + VirtualPath: vpath2, + QuotaSize: -1, + QuotaFiles: -1, + }) + mappedPath3 := filepath.Join(os.TempDir(), "mapped3") + folderName3 := filepath.Base(mappedPath3) + vpath3 := "/vdirs/vdir3" + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: folderName3, + }, + VirtualPath: vpath3, + QuotaSize: -1, + QuotaFiles: -1, + }) + mappedPath4 := filepath.Join(os.TempDir(), "mapped4") + folderName4 := filepath.Base(mappedPath4) + vpath4 := "/vdirs/vdir4" + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + Name: folderName4, + }, + VirtualPath: vpath4, + QuotaSize: -1, + QuotaFiles: -1, + }) + f1 := vfs.BaseVirtualFolder{ + Name: folderName1, + MappedPath: mappedPath1, + } + _, _, err = httpdtest.AddFolder(f1, http.StatusCreated) + assert.NoError(t, err) + f2 := vfs.BaseVirtualFolder{ + Name: folderName2, + MappedPath: mappedPath2, + } + _, _, err = httpdtest.AddFolder(f2, http.StatusCreated) + assert.NoError(t, err) + f3 := vfs.BaseVirtualFolder{ + Name: folderName3, + MappedPath: mappedPath3, + FsConfig: vfs.Filesystem{ + Provider: sdk.CryptedFilesystemProvider, + CryptConfig: vfs.CryptFsConfig{ + Passphrase: kms.NewPlainSecret(defaultPassword), + }, + }, + } + _, _, err = httpdtest.AddFolder(f3, http.StatusCreated) + assert.NoError(t, err) + f4 := vfs.BaseVirtualFolder{ + Name: folderName4, + MappedPath: mappedPath4, + FsConfig: vfs.Filesystem{ + Provider: sdk.SFTPFilesystemProvider, + SFTPConfig: vfs.SFTPFsConfig{ + BaseSFTPFsConfig: sdk.BaseSFTPFsConfig{ + Endpoint: sftpServerAddr, + Username: baseUser.Username, + }, + Password: kms.NewPlainSecret(defaultPassword), + }, + }, + } + _, _, err = httpdtest.AddFolder(f4, http.StatusCreated) + assert.NoError(t, err) + + user, resp, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err, string(resp)) + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + baseFileSize := int64(100) + err = writeSFTPFile(path.Join(vpath1, testFileName), baseFileSize+1, client) + assert.NoError(t, err) + err = writeSFTPFile(path.Join(vpath2, testFileName), baseFileSize+2, client) + assert.NoError(t, err) + err = writeSFTPFile(path.Join(vpath3, testFileName), baseFileSize+3, client) + assert.NoError(t, err) + err = writeSFTPFile(path.Join(vpath4, testFileName), baseFileSize+4, client) + assert.NoError(t, err) + // cannot remove a directory with virtual folders inside + out, err := runSSHCommand(fmt.Sprintf(`sftpgo-remove %s`, path.Dir(vpath1)), user) + assert.Error(t, err, string(out)) + // copy across virtual folders + copyDir := "/copy" + out, err = runSSHCommand(fmt.Sprintf(`sftpgo-copy %s %s/`, path.Dir(vpath1), copyDir), user) + assert.NoError(t, err, string(out)) + // check the copy + info, err := client.Stat(path.Join(copyDir, vpath1, testFileName)) + if assert.NoError(t, err) { + assert.Equal(t, baseFileSize+1, info.Size()) + } + info, err = client.Stat(path.Join(copyDir, vpath2, testFileName)) + if assert.NoError(t, err) { + assert.Equal(t, baseFileSize+2, info.Size()) + } + info, err = client.Stat(path.Join(copyDir, vpath3, testFileName)) + if assert.NoError(t, err) { + assert.Equal(t, baseFileSize+3, info.Size()) + } + info, err = client.Stat(path.Join(copyDir, vpath4, testFileName)) + if assert.NoError(t, err) { + assert.Equal(t, baseFileSize+4, info.Size()) + } + // nested fs paths + out, err = runSSHCommand(fmt.Sprintf(`sftpgo-copy %s %s`, vpath1, vpath2), user) + assert.Error(t, err, string(out)) + } + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(baseUser, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(baseUser.GetHomeDir()) + assert.NoError(t, err) + for _, folderName := range []string{folderName1, folderName2, folderName3, folderName4} { + _, err = httpdtest.RemoveFolder(vfs.BaseVirtualFolder{Name: folderName}, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(filepath.Join(os.TempDir(), folderName)) + assert.NoError(t, err) + } +} + +func TestHTTPFs(t *testing.T) { + u := getTestUserWithHTTPFs() + user, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + + err = os.MkdirAll(user.GetHomeDir(), os.ModePerm) + assert.NoError(t, err) + + conn := common.NewBaseConnection(xid.New().String(), common.ProtocolFTP, "", "", user) + err = conn.CreateDir(httpFsWellKnowDir, false) + assert.NoError(t, err) + + err = os.WriteFile(filepath.Join(os.TempDir(), "httpfs", defaultHTTPFsUsername, httpFsWellKnowDir, "file.txt"), []byte("data"), 0666) + assert.NoError(t, err) + + err = conn.Copy(httpFsWellKnowDir, httpFsWellKnowDir+"_copy") + assert.NoError(t, err) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +func TestProxyProtocol(t *testing.T) { + resp, err := httpclient.Get(fmt.Sprintf("http://%v", httpProxyAddr)) + if !assert.Error(t, err) { + resp.Body.Close() + } +} + +func TestSetProtocol(t *testing.T) { + conn := common.NewBaseConnection("id", "sshd_exec", "", "", dataprovider.User{BaseUser: sdk.BaseUser{HomeDir: os.TempDir()}}) + conn.SetProtocol(common.ProtocolSCP) + require.Equal(t, "SCP_id", conn.GetID()) +} + +func TestGetFsError(t *testing.T) { + u := getTestUser() + u.FsConfig.Provider = sdk.GCSFilesystemProvider + u.FsConfig.GCSConfig.Bucket = "test" + u.FsConfig.GCSConfig.Credentials = kms.NewPlainSecret("invalid JSON for credentials") + conn := common.NewBaseConnection("", common.ProtocolFTP, "", "", u) + _, _, err := conn.GetFsAndResolvedPath("/vpath") + assert.Error(t, err) +} + +func waitTCPListening(address string) { + for { + conn, err := net.Dial("tcp", address) + if err != nil { + logger.WarnToConsole("tcp server %v not listening: %v", address, err) + time.Sleep(100 * time.Millisecond) + continue + } + logger.InfoToConsole("tcp server %v now listening", address) + conn.Close() + break + } +} + +func checkBasicSFTP(client *sftp.Client) error { + _, err := client.Getwd() + if err != nil { + return err + } + _, err = client.ReadDir(".") + return err +} + +func getCustomAuthSftpClient(user dataprovider.User, authMethods []ssh.AuthMethod) (*ssh.Client, *sftp.Client, error) { + var sftpClient *sftp.Client + config := &ssh.ClientConfig{ + User: user.Username, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Auth: authMethods, + Timeout: 5 * time.Second, + } + conn, err := ssh.Dial("tcp", sftpServerAddr, config) + if err != nil { + return conn, sftpClient, err + } + sftpClient, err = sftp.NewClient(conn) + if err != nil { + conn.Close() + } + return conn, sftpClient, err +} + +func getSftpClient(user dataprovider.User) (*ssh.Client, *sftp.Client, error) { + var sftpClient *sftp.Client + config := &ssh.ClientConfig{ + User: user.Username, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 5 * time.Second, + } + if user.Password != "" { + config.Auth = []ssh.AuthMethod{ssh.Password(user.Password)} + } else { + config.Auth = []ssh.AuthMethod{ssh.Password(defaultPassword)} + } + + conn, err := ssh.Dial("tcp", sftpServerAddr, config) + if err != nil { + return conn, sftpClient, err + } + sftpClient, err = sftp.NewClient(conn) + if err != nil { + conn.Close() + } + return conn, sftpClient, err +} + +func runSSHCommand(command string, user dataprovider.User) ([]byte, error) { + var sshSession *ssh.Session + var output []byte + config := &ssh.ClientConfig{ + User: user.Username, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 5 * time.Second, + } + if user.Password != "" { + config.Auth = []ssh.AuthMethod{ssh.Password(user.Password)} + } else { + config.Auth = []ssh.AuthMethod{ssh.Password(defaultPassword)} + } + + conn, err := ssh.Dial("tcp", sftpServerAddr, config) + if err != nil { + return output, err + } + defer conn.Close() + sshSession, err = conn.NewSession() + if err != nil { + return output, err + } + var stdout, stderr bytes.Buffer + sshSession.Stdout = &stdout + sshSession.Stderr = &stderr + err = sshSession.Run(command) + if err != nil { + return nil, fmt.Errorf("failed to run command %v: %v", command, stderr.Bytes()) + } + return stdout.Bytes(), err +} + +func getWebDavClient(user dataprovider.User) *gowebdav.Client { + rootPath := fmt.Sprintf("http://localhost:%d/", webDavServerPort) + pwd := defaultPassword + if user.Password != "" { + pwd = user.Password + } + client := gowebdav.NewClient(rootPath, user.Username, pwd) + client.SetTimeout(10 * time.Second) + return client +} + +func getTestUser() dataprovider.User { + user := dataprovider.User{ + BaseUser: sdk.BaseUser{ + Username: defaultUsername, + Password: defaultPassword, + HomeDir: filepath.Join(homeBasePath, defaultUsername), + Status: 1, + ExpirationDate: 0, + }, + } + user.Permissions = make(map[string][]string) + user.Permissions["/"] = allPerms + return user +} + +func getTestSFTPUser() dataprovider.User { + u := getTestUser() + u.Username = defaultSFTPUsername + u.FsConfig.Provider = sdk.SFTPFilesystemProvider + u.FsConfig.SFTPConfig.Endpoint = sftpServerAddr + u.FsConfig.SFTPConfig.Username = defaultUsername + u.FsConfig.SFTPConfig.Password = kms.NewPlainSecret(defaultPassword) + return u +} + +func getCryptFsUser() dataprovider.User { + u := getTestUser() + u.Username += "_crypt" + u.FsConfig.Provider = sdk.CryptedFilesystemProvider + u.FsConfig.CryptConfig.Passphrase = kms.NewPlainSecret(defaultPassword) + return u +} + +func getTestUserWithHTTPFs() dataprovider.User { + u := getTestUser() + u.FsConfig.Provider = sdk.HTTPFilesystemProvider + u.FsConfig.HTTPConfig = vfs.HTTPFsConfig{ + BaseHTTPFsConfig: sdk.BaseHTTPFsConfig{ + Endpoint: fmt.Sprintf("http://127.0.0.1:%d/api/v1", httpFsPort), + Username: defaultHTTPFsUsername, + }, + } + return u +} + +func writeSFTPFile(name string, size int64, client *sftp.Client) error { + err := writeSFTPFileNoCheck(name, size, client) + if err != nil { + return err + } + info, err := client.Stat(name) + if err != nil { + return err + } + if info.Size() != size { + return fmt.Errorf("file size mismatch, wanted %v, actual %v", size, info.Size()) + } + return nil +} + +func writeSFTPFileNoCheck(name string, size int64, client *sftp.Client) error { + content := make([]byte, size) + _, err := rand.Read(content) + if err != nil { + return err + } + f, err := client.Create(name) + if err != nil { + return err + } + _, err = io.Copy(f, bytes.NewBuffer(content)) + if err != nil { + f.Close() + return err + } + return f.Close() +} + +func getUploadScriptEnvContent(envVar string) []byte { + content := []byte("#!/bin/sh\n\n") + content = append(content, []byte(fmt.Sprintf("if [ -z \"$%s\" ]\n", envVar))...) + content = append(content, []byte("then\n")...) + content = append(content, []byte(" exit 1\n")...) + content = append(content, []byte("else\n")...) + content = append(content, []byte(" exit 0\n")...) + content = append(content, []byte("fi\n")...) + return content +} + +func getUploadScriptContent(movedPath, logFilePath string, exitStatus int) []byte { + content := []byte("#!/bin/sh\n\n") + content = append(content, []byte("sleep 1\n")...) + if logFilePath != "" { + content = append(content, []byte(fmt.Sprintf("echo $@ > %v\n", logFilePath))...) + } + content = append(content, []byte(fmt.Sprintf("mv ${SFTPGO_ACTION_PATH} %v\n", movedPath))...) + content = append(content, []byte(fmt.Sprintf("exit %d", exitStatus))...) + return content +} + +func getSaveProviderObjectScriptContent(outFilePath string, exitStatus int) []byte { + content := []byte("#!/bin/sh\n\n") + content = append(content, []byte(fmt.Sprintf("echo ${SFTPGO_OBJECT_DATA} > %v\n", outFilePath))...) + content = append(content, []byte(fmt.Sprintf("exit %d", exitStatus))...) + return content +} + +func generateTOTPPasscode(secret string, algo otp.Algorithm) (string, error) { + return totp.GenerateCodeCustom(secret, time.Now(), totp.ValidateOpts{ + Period: 30, + Skew: 1, + Digits: otp.DigitsSix, + Algorithm: algo, + }) +} + +func isDbDefenderSupported() bool { + // SQLite shares the implementation with other SQL-based provider but it makes no sense + // to use it outside test cases + switch dataprovider.GetProviderStatus().Driver { + case dataprovider.MySQLDataProviderName, dataprovider.PGSQLDataProviderName, + dataprovider.CockroachDataProviderName, dataprovider.SQLiteDataProviderName: + return true + default: + return false + } +} + +func getEncryptedFileSize(size int64) (int64, error) { + encSize, err := sio.EncryptedSize(uint64(size)) + return int64(encSize) + 33, err +} + +func printLatestLogs(maxNumberOfLines int) { + var lines []string + f, err := os.Open(logFilePath) + if err != nil { + return + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lines = append(lines, scanner.Text()+"\r\n") + for len(lines) > maxNumberOfLines { + lines = lines[1:] + } + } + if scanner.Err() != nil { + logger.WarnToConsole("Unable to print latest logs: %v", scanner.Err()) + return + } + for _, line := range lines { + logger.DebugToConsole("%s", line) + } +} + +type receivedEmail struct { + sync.RWMutex + From string + To []string + Data string +} + +func (e *receivedEmail) set(from string, to []string, data []byte) { + e.Lock() + defer e.Unlock() + + e.From = from + e.To = to + e.Data = strings.ReplaceAll(string(data), "=\r\n", "") +} + +func (e *receivedEmail) reset() { + e.Lock() + defer e.Unlock() + + e.From = "" + e.To = nil + e.Data = "" +} + +func (e *receivedEmail) get() receivedEmail { + e.RLock() + defer e.RUnlock() + + return receivedEmail{ + From: e.From, + To: e.To, + Data: e.Data, + } +} + +func startHTTPFs() { + go func() { + readdirCallback := func(name string) []os.FileInfo { + if name == httpFsWellKnowDir { + return []os.FileInfo{vfs.NewFileInfo("ghost.txt", false, 0, time.Unix(0, 0), false)} + } + return nil + } + callbacks := &httpdtest.HTTPFsCallbacks{ + Readdir: readdirCallback, + } + if err := httpdtest.StartTestHTTPFs(httpFsPort, callbacks); err != nil { + logger.ErrorToConsole("could not start HTTPfs test server: %v", err) + os.Exit(1) + } + }() + waitTCPListening(fmt.Sprintf(":%d", httpFsPort)) +} diff --git a/internal/common/ratelimiter.go b/internal/common/ratelimiter.go new file mode 100644 index 00000000..85d88092 --- /dev/null +++ b/internal/common/ratelimiter.go @@ -0,0 +1,245 @@ +// Copyright (C) 2019 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, seeHaving problems?
+ +your SFTPGo password {{if le .Days 0}}has expired{{else}}expires in {{.Days}} {{if eq .Days 1}}day{{else}}days{{end}}{{end}}.
+Please login to the WebClient and set a new password.
\ No newline at end of file diff --git a/templates/email/reset-password.html b/templates/email/reset-password.html new file mode 100644 index 00000000..79a458fc --- /dev/null +++ b/templates/email/reset-password.html @@ -0,0 +1,4 @@ +Hello there! +Your SFTPGo email verification code is "{{.Code}}", this code is valid for 10 minutes.
+Please enter this code in SFTPGo to confirm your email address.
diff --git a/templates/webadmin/admin.html b/templates/webadmin/admin.html new file mode 100644 index 00000000..a96f2e68 --- /dev/null +++ b/templates/webadmin/admin.html @@ -0,0 +1,313 @@ + +{{template "base" .}} + +{{- define "page_body"}} +| Username | +Status | +Last login | +Role | +2FA | +Description | ++ |
|---|
| ID | +Node | +Username | +Started | +Remote address | +Protocol | +Last activity | +Info | ++ |
|---|
| IP | +Blocked until | +Score | ++ |
|---|
| Name | +Type | +Rules | ++ |
|---|
| Name | +Status | +Trigger | +Actions | ++ |
|---|
| Date and time | +Action | +Path | +Username | +Protocol | +IP | +Info | +
|---|
| Date and time | +Action | +Object | +Username | +IP | +
|---|
| Date and time | +Event | +Username | +Protocol | +IP | +Info | +
|---|
The following placeholders are supported
+The generated folders can be saved or exported. Exported folders can be imported from the "Maintenance" section of this SFTPGo instance or another.
+| Name | +Storage | +Disk quota | +Associations | +Description | ++ |
|---|
| Name | +Members | +Description | ++ |
|---|
| IP/Network | +Protocols | +Mode | +Description | ++ |
|---|
Recovery codes are a set of one time use codes that can be used in place of the authentication code to login to the web UI. You can use them if you lose access to your phone to login to your account and disable or regenerate two-factor configuration.
+To keep your account secure, don't share or distribute your recovery codes. We recommend saving them with a secure password manager.
+If you generate new recovery codes, you automatically invalidate old ones.
+| Name | +Members | +Description | ++ |
|---|
+ "{{.GetAddress}}" +
+ {{- if .HasProxy}} ++ +
+ {{- end}} + {{- end}} ++ "{{.Path}}" +
++ "{{.Fingerprint}}" +
++ "{{.GetAlgosAsString}}" +
++ "{{.Status.SSH.GetSSHCommandsAsString}}" +
++ "{{.Status.SSH.GetSupportedAuthsAsString}}" +
++ "{{.Status.SSH.GetPublicKeysAlgosAsString}}" +
++ "{{.Status.SSH.GetMACsAsString}}" +
++ "{{.Status.SSH.GetKEXsAsString}}" +
++ "{{.Status.SSH.GetCiphersAsString}}" +
++ "{{.GetAddress}}" +
+ {{- if .HasProxy}} ++ +
+ {{- end}} ++ +
+ {{- if .ForcePassiveIP}} ++ "{{.ForcePassiveIP}}" +
+ {{- end}} + {{- range .PassiveIPOverrides}} ++ "{{.IP}} ({{.GetNetworksAsString}})" +
+ {{- end}} + {{- end}} ++ "{{.Status.FTP.PassivePortRange.Start}}-{{.Status.FTP.PassivePortRange.End}}" +
++ "{{.GetAddress}}" +
++ {{if .EnableHTTPS}} HTTPS {{else}} HTTP {{end}} +
+ {{- end}} ++ + {{if .Status.DataProvider.Error}} "{{.Status.DataProvider.Error}}"{{end}} +
++ "{{.Status.DataProvider.Driver}}" +
++ "{{.Status.RateLimiters.GetProtocolsAsString}}" +
++ "{{.Name}}" +
++ "{{.Issuer}}" +
++ "{{.Algo}}" +
+The following placeholders are supported
+Placeholders will be replaced in paths and credentials of the configured storage backend.
+The generated users can be saved or exported. Exported users can be imported from the "Maintenance" section of this SFTPGo instance or another.
+| Username | +Status | +Last login | +Storage | +Role | +2FA | +Disk quota | +Transfer quota | +Groups | ++ |
|---|
|
+
+
+
+ |
+ + | Name | +Size | +Last Modified | ++ |
|---|
Recovery codes are a set of one time use codes that can be used in place of the authentication code to login to the web UI. You can use them if you lose access to your phone to login to your account and disable or regenerate two-factor configuration.
+To keep your account secure, don't share or distribute your recovery codes. We recommend saving them with a secure password manager.
+If you generate new recovery codes, you automatically invalidate old ones.
+| Name | +Scope | +Info | ++ |
|---|