From 4eded56d5fdc90179e1f3318b6d77cd1f496fb04 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Fri, 12 May 2023 18:34:59 +0200 Subject: [PATCH] add support for log events Signed-off-by: Nicola Murino --- README.md | 2 +- docs/full-configuration.md | 1 + docs/logs.md | 2 +- docs/post-login-hook.md | 2 +- go.mod | 28 ++-- go.sum | 55 ++++--- internal/common/common.go | 13 +- internal/common/dataretention.go | 2 +- internal/config/config.go | 15 ++ internal/config/config_test.go | 5 + internal/dataprovider/dataprovider.go | 4 +- internal/dataprovider/user.go | 2 +- internal/ftpd/server.go | 5 + internal/httpd/api_events.go | 143 +++++++++++++++++- internal/httpd/api_utils.go | 6 + internal/httpd/httpd.go | 4 + internal/httpd/httpd_test.go | 54 +++++++ internal/httpd/internal_test.go | 14 ++ internal/httpd/server.go | 4 + internal/httpd/webadmin.go | 2 + internal/metric/metric.go | 10 +- internal/metric/metric_disabled.go | 4 +- internal/plugin/notifier.go | 55 ++++++- internal/plugin/plugin.go | 43 +++++- internal/sftpd/server.go | 19 ++- internal/webdavd/file.go | 10 +- internal/webdavd/server.go | 5 + openapi/openapi.yaml | 183 +++++++++++++++++++++-- templates/webadmin/events.html | 207 ++++++++++++++++++++++++-- tests/eventsearcher/go.mod | 8 +- tests/eventsearcher/go.sum | 16 +- tests/eventsearcher/main.go | 54 ++++++- tests/ipfilter/go.mod | 6 +- tests/ipfilter/go.sum | 12 +- 34 files changed, 856 insertions(+), 139 deletions(-) diff --git a/README.md b/README.md index 2fcd4c48..f8b26bb2 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ You can also purchase support plans from the [SFTPGo website](https://sftpgo.com SFTPGo is an Open Source project and you can of course use it for free but please don't ask for free support as well. -We will check the reported issues to see if you are experiencing a bug and if so we'll will fix it, but will only provide support to project [sponsors/donors](#sponsors). +We will check the reported issues to see if you are experiencing a bug and if so, it may or may not be fixed, we only provide support to project [sponsors/donors](#sponsors). If you report an invalid issue or ask for step-by-step support, your issue will remain open with no answer or will be closed as invalid without further explanation. Thanks for understanding. diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 14c9cc48..0af74fc3 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -467,6 +467,7 @@ The configuration file contains the following sections: - `fs_events`, list of strings. Defines the filesystem events that will be notified to this plugin. - `provider_events`, list of strings. Defines the provider events that will be notified to this plugin. - `provider_objects`, list if strings. Defines the provider objects that will be notified to this plugin. + - `log_events`, list of integers. Defines the log events that will be notified to this plugin. `1` means "Login failed", `2` means "Login with non-existent user", `3` means "No login tried", `4` means "Algorithm negotiation failed". - `retry_max_time`, integer. Defines the maximum number of seconds an event can be late. SFTPGo adds a timestamp to each event and add to an internal queue any events that a the plugin fails to handle (the plugin returns an error or it is not running). If a plugin fails to handle an event that is too late, based on this configuration, it will be discarded. SFTPGo will try to resend queued events every 30 seconds. 0 means no retry. - `retry_queue_max_size`, integer. Defines the maximum number of events that the internal queue can hold. Once the queue is full, the events that cannot be sent to the plugin will be discarded. 0 means no limit. - `kms_options`, struct. Defines the options for kms plugins. diff --git a/docs/logs.md b/docs/logs.md index a1b82080..235a16d7 100644 --- a/docs/logs.md +++ b/docs/logs.md @@ -63,5 +63,5 @@ The logs can be divided into the following categories: - `username`, string. Can be empty if the connection is closed before an authentication attempt - `client_ip` string. - `protocol` string. Possible values are `SSH`, `FTP`, `DAV` - - `login_type` string. Can be `publickey`, `password`, `keyboard-interactive`, `publickey+password`, `publickey+keyboard-interactive` or `no_auth_tryed` + - `login_type` string. Can be `publickey`, `password`, `keyboard-interactive`, `publickey+password`, `publickey+keyboard-interactive` or `no_auth_tried` - `error` string. Optional error description diff --git a/docs/post-login-hook.md b/docs/post-login-hook.md index 4ff04f9e..f1a8360b 100644 --- a/docs/post-login-hook.md +++ b/docs/post-login-hook.md @@ -8,7 +8,7 @@ If the hook defines an external program it can reads the following environment v - `SFTPGO_LOGIND_USER`, it contains the user serialized as JSON. The username is empty if the connection is closed for authentication timeout - `SFTPGO_LOGIND_IP` -- `SFTPGO_LOGIND_METHOD`, possible values are `publickey`, `password`, `keyboard-interactive`, `publickey+password`, `publickey+keyboard-interactive`, `TLSCertificate`, `TLSCertificate+password` or `no_auth_tryed`, `IDP` (external identity provider) +- `SFTPGO_LOGIND_METHOD`, possible values are `publickey`, `password`, `keyboard-interactive`, `publickey+password`, `publickey+keyboard-interactive`, `TLSCertificate`, `TLSCertificate+password` or `no_auth_tried`, `IDP` (external identity provider) - `SFTPGO_LOGIND_STATUS`, 1 means login OK, 0 login KO - `SFTPGO_LOGIND_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`, `HTTP`, `OIDC` (OpenID Connect) diff --git a/go.mod b/go.mod index 054e9997..299414a6 100644 --- a/go.mod +++ b/go.mod @@ -10,14 +10,14 @@ require ( github.com/alexedwards/argon2id v0.0.0-20230305115115-4b3c3280a736 github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964 github.com/aws/aws-sdk-go-v2 v1.18.0 - github.com/aws/aws-sdk-go-v2/config v1.18.23 - github.com/aws/aws-sdk-go-v2/credentials v1.13.22 + github.com/aws/aws-sdk-go-v2/config v1.18.25 + github.com/aws/aws-sdk-go-v2/credentials v1.13.24 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.3 - github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.65 + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.67 github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.14.11 github.com/aws/aws-sdk-go-v2/service/s3 v1.33.1 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.19.7 - github.com/aws/aws-sdk-go-v2/service/sts v1.18.11 + github.com/aws/aws-sdk-go-v2/service/sts v1.19.0 github.com/bmatcuk/doublestar/v4 v4.6.0 github.com/cockroachdb/cockroach-go/v2 v2.3.3 github.com/coreos/go-oidc/v3 v3.5.0 @@ -53,7 +53,7 @@ require ( github.com/rs/cors v1.9.0 github.com/rs/xid v1.5.0 github.com/rs/zerolog v1.29.1 - github.com/sftpgo/sdk v0.1.3 + github.com/sftpgo/sdk v0.1.4-0.20230512160325-38e59551f700 github.com/shirou/gopsutil/v3 v3.23.4 github.com/spf13/afero v1.9.5 github.com/spf13/cobra v1.7.0 @@ -68,21 +68,21 @@ require ( go.etcd.io/bbolt v1.3.7 go.uber.org/automaxprocs v1.5.2 gocloud.dev v0.29.0 - golang.org/x/crypto v0.8.0 - golang.org/x/net v0.9.0 - golang.org/x/oauth2 v0.7.0 + golang.org/x/crypto v0.9.0 + golang.org/x/net v0.10.0 + golang.org/x/oauth2 v0.8.0 golang.org/x/sys v0.8.0 golang.org/x/term v0.8.0 golang.org/x/time v0.3.0 - google.golang.org/api v0.121.0 + google.golang.org/api v0.122.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) require ( - cloud.google.com/go v0.110.1 // indirect - cloud.google.com/go/compute v1.19.1 // indirect + cloud.google.com/go v0.110.2 // indirect + cloud.google.com/go/compute v1.19.2 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect - cloud.google.com/go/iam v1.0.0 // indirect + cloud.google.com/go/iam v1.0.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 // indirect github.com/ajg/form v1.5.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect @@ -157,7 +157,7 @@ require ( go.opencensus.io v0.24.0 // indirect golang.org/x/mod v0.10.0 // indirect golang.org/x/text v0.9.0 // indirect - golang.org/x/tools v0.8.0 // indirect + golang.org/x/tools v0.9.1 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect @@ -170,5 +170,5 @@ require ( replace ( github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 github.com/robfig/cron/v3 => github.com/drakkan/cron/v3 v3.0.0-20230222140221-217a1e4d96c0 - golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20230408075646-704a7f627371 + golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20230512104844-219592fc3028 ) diff --git a/go.sum b/go.sum index 1bf2f4f8..7281f60a 100644 --- a/go.sum +++ b/go.sum @@ -39,8 +39,8 @@ cloud.google.com/go v0.104.0/go.mod h1:OO6xxXdJyvuJPcEPBLN9BJPD+jep5G1+2U5B5gkRY cloud.google.com/go v0.105.0/go.mod h1:PrLgOJNe5nfE9UMxKxgXj4mD3voiP+YQ6gdt6KMFOKM= cloud.google.com/go v0.107.0/go.mod h1:wpc2eNrD7hXUTy8EKS10jkxpZBjASrORK7goS+3YX2I= cloud.google.com/go v0.109.0/go.mod h1:2sYycXt75t/CSB5R9M2wPU1tJmire7AQZTPtITcGBVE= -cloud.google.com/go v0.110.1 h1:oDJ19Fu9TX9Xs06iyCw4yifSqZ7JQ8BeuVHcTmWQlOA= -cloud.google.com/go v0.110.1/go.mod h1:uc+V/WjzxQ7vpkxfJhgW4Q4axWXyfAerpQOuSNDZyFw= +cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA= +cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw= cloud.google.com/go/accessapproval v1.4.0/go.mod h1:zybIuC3KpDOvotz59lFe5qxRZx6C75OtwbisN56xYB4= cloud.google.com/go/accessapproval v1.5.0/go.mod h1:HFy3tuiGvMdcd/u+Cu5b9NkO1pEICJ46IR82PoUdplw= cloud.google.com/go/accesscontextmanager v1.3.0/go.mod h1:TgCBehyr5gNMz7ZaH9xubp+CE8dkrszb4oK9CWyvD4o= @@ -124,8 +124,8 @@ cloud.google.com/go/compute v1.13.0/go.mod h1:5aPTS0cUNMIc1CE546K+Th6weJUNQErARy cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo= cloud.google.com/go/compute v1.15.1/go.mod h1:bjjoF/NtFUrkD/urWfdHaKuOPDR5nWIs63rR+SXhcpA= cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs= -cloud.google.com/go/compute v1.19.1 h1:am86mquDUgjGNWxiGn+5PGLbmgiWXlE/yNWpIpNvuXY= -cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE= +cloud.google.com/go/compute v1.19.2 h1:GbJtPo8OKVHbVep8jvM57KidbYHxeE68LOVqouNLrDY= +cloud.google.com/go/compute v1.19.2/go.mod h1:5f5a+iC1IriXYauaQ0EyQmEAEq9CGRnV5xJSQSlTV08= cloud.google.com/go/compute/metadata v0.1.0/go.mod h1:Z1VN+bulIf6bt4P/C37K4DyZYZEXYonfTBHHFPO/4UU= cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= cloud.google.com/go/compute/metadata v0.2.1/go.mod h1:jgHgmJd2RKBGzXqF5LR2EZMGxBkeanZ9wwa75XHJgOM= @@ -218,8 +218,8 @@ cloud.google.com/go/iam v0.6.0/go.mod h1:+1AH33ueBne5MzYccyMHtEKqLE4/kJOibtffMHD cloud.google.com/go/iam v0.7.0/go.mod h1:H5Br8wRaDGNc8XP3keLc4unfUUZeyH3Sfl9XpQEYOeg= cloud.google.com/go/iam v0.8.0/go.mod h1:lga0/y3iH6CX7sYqypWJ33hf7kkfXJag67naqGESjkE= cloud.google.com/go/iam v0.10.0/go.mod h1:nXAECrMt2qHpF6RZUZseteD6QyanL68reN4OXPw0UWM= -cloud.google.com/go/iam v1.0.0 h1:hlQJMovyJJwYjZcTohUH4o1L8Z8kYz+E+W/zktiLCBc= -cloud.google.com/go/iam v1.0.0/go.mod h1:ikbQ4f1r91wTmBmmOtBCOtuEOei6taatNXytzB7Cxew= +cloud.google.com/go/iam v1.0.1 h1:lyeCAU6jpnVNrE9zGQkTl3WgNgK/X+uWwaw0kynZJMU= +cloud.google.com/go/iam v1.0.1/go.mod h1:yR3tmSL8BcZB4bxByRv2jkSIahVmCtfKZwLYGBalRE8= cloud.google.com/go/iap v1.4.0/go.mod h1:RGFwRJdihTINIe4wZ2iCP0zF/qu18ZwyKxrhMhygBEc= cloud.google.com/go/iap v1.5.0/go.mod h1:UH/CGgKd4KyohZL5Pt0jSKE4m3FR51qg6FKQ/z/Ix9A= cloud.google.com/go/ids v1.1.0/go.mod h1:WIuwCaYVOzHIj2OhN9HAwvW+DBdmUAdcWlFxRl+KubM= @@ -565,17 +565,17 @@ github.com/aws/aws-sdk-go-v2 v1.18.0/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3eP github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 h1:dK82zF6kkPeCo8J1e+tGx4JdvDIQzj7ygIoLg8WMuGs= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10/go.mod h1:VeTZetY5KRJLuD/7fkQXMU6Mw7H5m/KP2J5Iy9osMno= github.com/aws/aws-sdk-go-v2/config v1.18.12/go.mod h1:J36fOhj1LQBr+O4hJCiT8FwVvieeoSGOtPuvhKlsNu8= -github.com/aws/aws-sdk-go-v2/config v1.18.23 h1:gc3lPsAnZpwfi2exupmgHfva0JiAY2BWDg5JWYlmA28= -github.com/aws/aws-sdk-go-v2/config v1.18.23/go.mod h1:rx0ruaQ+gk3OrLFHRRx56lA//XxP8K8uPzeNiKNuWVY= +github.com/aws/aws-sdk-go-v2/config v1.18.25 h1:JuYyZcnMPBiFqn87L2cRppo+rNwgah6YwD3VuyvaW6Q= +github.com/aws/aws-sdk-go-v2/config v1.18.25/go.mod h1:dZnYpD5wTW/dQF0rRNLVypB396zWCcPiBIvdvSWHEg4= github.com/aws/aws-sdk-go-v2/credentials v1.13.12/go.mod h1:37HG2MBroXK3jXfxVGtbM2J48ra2+Ltu+tmwr/jO0KA= -github.com/aws/aws-sdk-go-v2/credentials v1.13.22 h1:Hp9rwJS4giQ48xqonRV/s7QcDf/wxF6UY7osRmBabvI= -github.com/aws/aws-sdk-go-v2/credentials v1.13.22/go.mod h1:BfNcm6A9nSd+bzejDcMJ5RE+k6WbkCwWkQil7q4heRk= +github.com/aws/aws-sdk-go-v2/credentials v1.13.24 h1:PjiYyls3QdCrzqUN35jMWtUK1vqVZ+zLfdOa/UPFDp0= +github.com/aws/aws-sdk-go-v2/credentials v1.13.24/go.mod h1:jYPYi99wUOPIFi0rhiOvXeSEReVOzBqFNOX5bXYoG2o= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.22/go.mod h1:YGSIJyQ6D6FjKMQh16hVFSIUD54L4F7zTGePqYMYYJU= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.3 h1:jJPgroehGvjrde3XufFIJUZVK5A2L9a3KwSFgKy9n8w= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.3/go.mod h1:4Q0UFP0YJf0NrsEuEYHpM9fTSEVnD16Z3uyEF7J9JGM= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.51/go.mod h1:7Grl2gV+dx9SWrUIgwwlUvU40t7+lOSbx34XwfmsTkY= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.65 h1:4irvSxFf0u7pQdtpmUoDSjvMNpOG/8yDUq3orwd9qdg= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.65/go.mod h1:BAWKiL53LT19UMewYr9YhZ8xPO69u6NwmGUjSjRwUdM= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.67 h1:fI9/5BDEaAv/pv1VO1X1n3jfP9it+IGqWsCuuBQI8wM= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.67/go.mod h1:zQClPRIwQZfJlZq6WZve+s4Tb4JW+3V6eS+4+KrYeP8= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.28/go.mod h1:3lwChorpIM/BhImY/hy+Z6jekmN92cXGPI1QJasVPYY= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33 h1:kG5eQilShqmJbv11XL1VpyDbaEJzWxd4zRiCG30GSn4= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33/go.mod h1:7i0PF1ME/2eUPFcjkVIwq+DOygHEoK92t5cDqNgYbIw= @@ -619,8 +619,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.1/go.mod h1:O1YSOg3aekZibh2Sn github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.10 h1:PkHIIJs8qvq0e5QybnZoG1K/9QTrLr9OsqCIo59jOBA= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.10/go.mod h1:AFvkxc8xfBe8XA+5St5XIHHrQQtkxqrRincx4hmMHOk= github.com/aws/aws-sdk-go-v2/service/sts v1.18.3/go.mod h1:b+psTJn33Q4qGoDaM7ZiOVVG8uVjGI6HaZ8WBHdgDgU= -github.com/aws/aws-sdk-go-v2/service/sts v1.18.11 h1:uBE+Zj478pfxV98L6SEpvxYiADNjTlMNY714PJLE7uo= -github.com/aws/aws-sdk-go-v2/service/sts v1.18.11/go.mod h1:BgQOMsg8av8jset59jelyPW7NoZcZXLVpDsXunGDrk8= +github.com/aws/aws-sdk-go-v2/service/sts v1.19.0 h1:2DQLAKDteoEDI8zpCzqBMaZlJuoE9iTYD0gFmXVax9E= +github.com/aws/aws-sdk-go-v2/service/sts v1.19.0/go.mod h1:BgQOMsg8av8jset59jelyPW7NoZcZXLVpDsXunGDrk8= github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8= github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= @@ -877,8 +877,8 @@ github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZ github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= 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/crypto v0.0.0-20230408075646-704a7f627371 h1:e2fWtTFAkFfNOeqww6HsEhtxETjGUBKnmIbMNB7V8mg= -github.com/drakkan/crypto v0.0.0-20230408075646-704a7f627371/go.mod h1:svd5Kbdx1UEmxh6mV0H38ASBeI90vEuujcyP74bw210= +github.com/drakkan/crypto v0.0.0-20230512104844-219592fc3028 h1:qUrs/afB0gubJUY5kOmxLx1euFlXn9yUMUhli7Njob8= +github.com/drakkan/crypto v0.0.0-20230512104844-219592fc3028/go.mod h1:FPowDKc1rEQhN3Xf48AhpBr8eSNzpEYaAQczEYcuAVU= github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 h1:LPH1dEblAOO/LoG7yHPMtBLXhQmjaga91/DDjWk9jWA= github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU= github.com/drakkan/webdav v0.0.0-20230227175313-32996838bcd8 h1:tdkLkSKtYd3WSDsZXGJDKsakiNstLQJPN5HjnqCkf2c= @@ -1842,8 +1842,8 @@ github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo= github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg= github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo= -github.com/sftpgo/sdk v0.1.3 h1:o/9herRbrDH6sQwfpKlV3AV0R7qJgOe/x4yQnEIWIHk= -github.com/sftpgo/sdk v0.1.3/go.mod h1:gDxDaU3rhp9Y92ddsE7SbQ8jdBNNWK1DKlp5eHXrsb8= +github.com/sftpgo/sdk v0.1.4-0.20230512160325-38e59551f700 h1:jL6mfKAaFv862AnBUxIfTH9wmnuPjbWyjHQUGDo+Xt0= +github.com/sftpgo/sdk v0.1.4-0.20230512160325-38e59551f700/go.mod h1:gDxDaU3rhp9Y92ddsE7SbQ8jdBNNWK1DKlp5eHXrsb8= github.com/shirou/gopsutil/v3 v3.23.4 h1:hZwmDxZs7Ewt75DV81r4pFMqbq+di2cbt9FsQBqLD2o= github.com/shirou/gopsutil/v3 v3.23.4/go.mod h1:ZcGxyfzAMRevhUR2+cfhXDH6gQdFYE/t8j1nsU4mPI8= github.com/shoenig/go-m1cpu v0.1.5/go.mod h1:Wwvst4LR89UxjeFtLRMrpgRiyY4xPsejnVZym39dbAQ= @@ -2250,8 +2250,8 @@ golang.org/x/net v0.3.1-0.20221206200815-1e63c2f08a10/go.mod h1:MBQ8lrhLObU/6UmL golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= -golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -2283,8 +2283,8 @@ golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri golang.org/x/oauth2 v0.3.0/go.mod h1:rQrIauxkUhJ6CuwEXwymO2/eh4xz2ZWF1nBkcxS+tGk= golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec= golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I= -golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g= -golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= +golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8= +golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= 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= @@ -2301,8 +2301,8 @@ golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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= @@ -2468,7 +2468,6 @@ golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -2590,8 +2589,8 @@ golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= golang.org/x/tools v0.5.0/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y= -golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= +golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo= +golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -2668,8 +2667,8 @@ google.golang.org/api v0.106.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/ google.golang.org/api v0.107.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY= google.golang.org/api v0.108.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY= google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI= -google.golang.org/api v0.121.0 h1:8Oopoo8Vavxx6gt+sgs8s8/X60WBAtKQq6JqnkF+xow= -google.golang.org/api v0.121.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms= +google.golang.org/api v0.122.0 h1:zDobeejm3E7pEG1mNHvdxvjs5XJoCMzyNH+CmwL94Es= +google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= diff --git a/internal/common/common.go b/internal/common/common.go index 296ba3ba..b9c9794a 100644 --- a/internal/common/common.go +++ b/internal/common/common.go @@ -32,6 +32,7 @@ import ( "time" "github.com/pires/go-proxyproto" + "github.com/sftpgo/sdk/plugin/notifier" "github.com/drakkan/sftpgo/v2/internal/command" "github.com/drakkan/sftpgo/v2/internal/dataprovider" @@ -965,12 +966,14 @@ func (conns *ActiveConnections) Remove(connectionID string) { conn.GetLocalAddress(), conn.GetRemoteAddress(), err, lastIdx) if conn.GetProtocol() == ProtocolFTP && conn.GetUsername() == "" && !util.Contains(ftpLoginCommands, conn.GetCommand()) { ip := util.GetIPFromRemoteAddress(conn.GetRemoteAddress()) - logger.ConnectionFailedLog("", ip, dataprovider.LoginMethodNoAuthTryed, conn.GetProtocol(), - dataprovider.ErrNoAuthTryed.Error()) - metric.AddNoAuthTryed() + logger.ConnectionFailedLog("", ip, dataprovider.LoginMethodNoAuthTried, ProtocolFTP, + dataprovider.ErrNoAuthTried.Error()) + metric.AddNoAuthTried() AddDefenderEvent(ip, ProtocolFTP, HostEventNoLoginTried) - dataprovider.ExecutePostLoginHook(&dataprovider.User{}, dataprovider.LoginMethodNoAuthTryed, ip, - conn.GetProtocol(), dataprovider.ErrNoAuthTryed) + dataprovider.ExecutePostLoginHook(&dataprovider.User{}, dataprovider.LoginMethodNoAuthTried, ip, + ProtocolFTP, dataprovider.ErrNoAuthTried) + plugin.Handler.NotifyLogEvent(notifier.LogEventTypeNoLoginTried, ProtocolFTP, "", ip, "", + dataprovider.ErrNoAuthTried) } Config.checkPostDisconnectHook(conn.GetRemoteAddress(), conn.GetProtocol(), conn.GetUsername(), conn.GetID(), conn.GetConnectionTime()) diff --git a/internal/common/dataretention.go b/internal/common/dataretention.go index 28787047..d3faf890 100644 --- a/internal/common/dataretention.go +++ b/internal/common/dataretention.go @@ -325,7 +325,7 @@ func (c *RetentionCheck) checkEmptyDirRemoval(folderPath string) { files, err := c.conn.ListDir(folderPath) if err == nil && len(files) == 0 { err = c.conn.RemoveDir(folderPath) - c.conn.Log(logger.LevelDebug, "tryed to remove empty dir %q, error: %v", folderPath, err) + c.conn.Log(logger.LevelDebug, "tried to remove empty dir %q, error: %v", folderPath, err) } } } diff --git a/internal/config/config.go b/internal/config/config.go index 54db833f..6d3a61e0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -964,6 +964,21 @@ func getNotifierPluginFromEnv(idx int, pluginConfig *plugin.Config) bool { isSet = true } + notifierLogEventsString, ok := lookupStringListFromEnv(fmt.Sprintf("SFTPGO_PLUGINS__%v__NOTIFIER_OPTIONS__LOG_EVENTS", idx)) + if ok { + var notifierLogEvents []int + for _, e := range notifierLogEventsString { + ev, err := strconv.Atoi(e) + if err == nil { + notifierLogEvents = append(notifierLogEvents, ev) + } + } + if len(notifierLogEvents) > 0 { + pluginConfig.NotifierOptions.LogEvents = notifierLogEvents + isSet = true + } + } + notifierRetryMaxTime, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_PLUGINS__%v__NOTIFIER_OPTIONS__RETRY_MAX_TIME", idx), 0) if ok { pluginConfig.NotifierOptions.RetryMaxTime = int(notifierRetryMaxTime) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 939934b3..2a706c55 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -698,6 +698,7 @@ func TestPluginsFromEnv(t *testing.T) { os.Setenv("SFTPGO_PLUGINS__0__NOTIFIER_OPTIONS__FS_EVENTS", "upload,download") os.Setenv("SFTPGO_PLUGINS__0__NOTIFIER_OPTIONS__PROVIDER_EVENTS", "add,update") os.Setenv("SFTPGO_PLUGINS__0__NOTIFIER_OPTIONS__PROVIDER_OBJECTS", "user,admin") + os.Setenv("SFTPGO_PLUGINS__0__NOTIFIER_OPTIONS__LOG_EVENTS", "a,1,2") os.Setenv("SFTPGO_PLUGINS__0__NOTIFIER_OPTIONS__RETRY_MAX_TIME", "2") os.Setenv("SFTPGO_PLUGINS__0__NOTIFIER_OPTIONS__RETRY_QUEUE_MAX_SIZE", "1000") os.Setenv("SFTPGO_PLUGINS__0__CMD", "plugin_start_cmd") @@ -712,6 +713,7 @@ func TestPluginsFromEnv(t *testing.T) { os.Unsetenv("SFTPGO_PLUGINS__0__NOTIFIER_OPTIONS__FS_EVENTS") os.Unsetenv("SFTPGO_PLUGINS__0__NOTIFIER_OPTIONS__PROVIDER_EVENTS") os.Unsetenv("SFTPGO_PLUGINS__0__NOTIFIER_OPTIONS__PROVIDER_OBJECTS") + os.Unsetenv("SFTPGO_PLUGINS__0__NOTIFIER_OPTIONS__LOG_EVENTS") os.Unsetenv("SFTPGO_PLUGINS__0__NOTIFIER_OPTIONS__RETRY_MAX_TIME") os.Unsetenv("SFTPGO_PLUGINS__0__NOTIFIER_OPTIONS__RETRY_QUEUE_MAX_SIZE") os.Unsetenv("SFTPGO_PLUGINS__0__CMD") @@ -738,6 +740,9 @@ func TestPluginsFromEnv(t *testing.T) { require.Len(t, pluginConf.NotifierOptions.ProviderObjects, 2) require.Equal(t, "user", pluginConf.NotifierOptions.ProviderObjects[0]) require.Equal(t, "admin", pluginConf.NotifierOptions.ProviderObjects[1]) + require.Len(t, pluginConf.NotifierOptions.LogEvents, 2) + require.Equal(t, 1, pluginConf.NotifierOptions.LogEvents[0]) + require.Equal(t, 2, pluginConf.NotifierOptions.LogEvents[1]) require.Equal(t, 2, pluginConf.NotifierOptions.RetryMaxTime) require.Equal(t, 1000, pluginConf.NotifierOptions.RetryQueueMaxSize) require.Equal(t, "plugin_start_cmd", pluginConf.Cmd) diff --git a/internal/dataprovider/dataprovider.go b/internal/dataprovider/dataprovider.go index 4da37155..6edd43b9 100644 --- a/internal/dataprovider/dataprovider.go +++ b/internal/dataprovider/dataprovider.go @@ -159,8 +159,8 @@ var ( LoginMethodTLSCertificate, LoginMethodTLSCertificateAndPwd} // SSHMultiStepsLoginMethods defines the supported Multi-Step Authentications SSHMultiStepsLoginMethods = []string{SSHLoginMethodKeyAndPassword, SSHLoginMethodKeyAndKeyboardInt} - // ErrNoAuthTryed defines the error for connection closed before authentication - ErrNoAuthTryed = errors.New("no auth tryed") + // ErrNoAuthTried defines the error for connection closed before authentication + ErrNoAuthTried = errors.New("no auth tried") // ErrNotImplemented defines the error for features not supported for a particular data provider ErrNotImplemented = errors.New("feature not supported with the configured data provider") // ValidProtocols defines all the valid protcols diff --git a/internal/dataprovider/user.go b/internal/dataprovider/user.go index 1793ae92..9cb020f3 100644 --- a/internal/dataprovider/user.go +++ b/internal/dataprovider/user.go @@ -76,7 +76,7 @@ const ( // Available login methods const ( - LoginMethodNoAuthTryed = "no_auth_tryed" + LoginMethodNoAuthTried = "no_auth_tried" LoginMethodPassword = "password" SSHLoginMethodPassword = "password-over-SSH" SSHLoginMethodPublicKey = "publickey" diff --git a/internal/ftpd/server.go b/internal/ftpd/server.go index 460d4dc3..c9715d0c 100644 --- a/internal/ftpd/server.go +++ b/internal/ftpd/server.go @@ -25,11 +25,13 @@ import ( "sync" ftpserver "github.com/fclairamb/ftpserverlib" + "github.com/sftpgo/sdk/plugin/notifier" "github.com/drakkan/sftpgo/v2/internal/common" "github.com/drakkan/sftpgo/v2/internal/dataprovider" "github.com/drakkan/sftpgo/v2/internal/logger" "github.com/drakkan/sftpgo/v2/internal/metric" + "github.com/drakkan/sftpgo/v2/internal/plugin" "github.com/drakkan/sftpgo/v2/internal/util" "github.com/drakkan/sftpgo/v2/internal/version" ) @@ -426,10 +428,13 @@ func updateLoginMetrics(user *dataprovider.User, ip, loginMethod string, err err logger.ConnectionFailedLog(user.Username, ip, loginMethod, common.ProtocolFTP, err.Error()) event := common.HostEventLoginFailed + logEv := notifier.LogEventTypeLoginFailed if errors.Is(err, util.ErrNotFound) { event = common.HostEventUserNotFound + logEv = notifier.LogEventTypeLoginNoUser } common.AddDefenderEvent(ip, common.ProtocolFTP, event) + plugin.Handler.NotifyLogEvent(logEv, common.ProtocolFTP, user.Username, ip, "", err) } metric.AddLoginResult(loginMethod, err) dataprovider.ExecutePostLoginHook(user, loginMethod, ip, common.ProtocolFTP, err) diff --git a/internal/httpd/api_events.go b/internal/httpd/api_events.go index 408dfb8f..f9fcc445 100644 --- a/internal/httpd/api_events.go +++ b/internal/httpd/api_events.go @@ -24,6 +24,7 @@ import ( "time" "github.com/sftpgo/sdk/plugin/eventsearcher" + "github.com/sftpgo/sdk/plugin/notifier" "github.com/drakkan/sftpgo/v2/internal/dataprovider" "github.com/drakkan/sftpgo/v2/internal/plugin" @@ -67,11 +68,10 @@ func getCommonSearchParamsFromRequest(r *http.Request) (eventsearcher.CommonSear } c.EndTimestamp = ts } - c.Actions = getCommaSeparatedQueryParam(r, "actions") c.Username = r.URL.Query().Get("username") c.IP = r.URL.Query().Get("ip") c.InstanceIDs = getCommaSeparatedQueryParam(r, "instance_ids") - c.ExcludeIDs = getCommaSeparatedQueryParam(r, "exclude_ids") + c.FromID = r.URL.Query().Get("from_id") return c, nil } @@ -92,6 +92,7 @@ func getFsSearchParamsFromRequest(r *http.Request) (eventsearcher.FsEventSearch, } s.FsProvider = val } + s.Actions = getCommaSeparatedQueryParam(r, "actions") s.SSHCmd = r.URL.Query().Get("ssh_cmd") s.Bucket = r.URL.Query().Get("bucket") s.Endpoint = r.URL.Query().Get("endpoint") @@ -115,11 +116,31 @@ func getProviderSearchParamsFromRequest(r *http.Request) (eventsearcher.Provider if err != nil { return s, err } + s.Actions = getCommaSeparatedQueryParam(r, "actions") s.ObjectName = r.URL.Query().Get("object_name") s.ObjectTypes = getCommaSeparatedQueryParam(r, "object_types") return s, nil } +func getLogSearchParamsFromRequest(r *http.Request) (eventsearcher.LogEventSearch, error) { + var err error + s := eventsearcher.LogEventSearch{} + s.CommonSearchParams, err = getCommonSearchParamsFromRequest(r) + if err != nil { + return s, err + } + s.Protocols = getCommaSeparatedQueryParam(r, "protocols") + events := getCommaSeparatedQueryParam(r, "events") + for _, ev := range events { + evType, err := strconv.ParseUint(ev, 10, 32) + if err == nil { + s.Events = append(s.Events, int32(evType)) + } + } + + return s, nil +} + func searchFsEvents(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) claims, err := getTokenClaims(r) @@ -143,7 +164,7 @@ func searchFsEvents(w http.ResponseWriter, r *http.Request) { return } - data, _, _, err := plugin.Handler.SearchFsEvents(&filters) + data, err := plugin.Handler.SearchFsEvents(&filters) if err != nil { sendAPIResponse(w, r, err, "", getRespStatus(err)) return @@ -178,7 +199,40 @@ func searchProviderEvents(w http.ResponseWriter, r *http.Request) { return } - data, _, _, err := plugin.Handler.SearchProviderEvents(&filters) + data, err := plugin.Handler.SearchProviderEvents(&filters) + if err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Write(data) //nolint:errcheck +} + +func searchLogEvents(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest) + return + } + + var filters eventsearcher.LogEventSearch + if filters, err = getLogSearchParamsFromRequest(r); err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } + filters.Role = getRoleFilterForEventSearch(r, claims.Role) + + if getBoolQueryParam(r, "csv_export") { + filters.Limit = 100 + if err := exportLogEvents(w, &filters); err != nil { + panic(http.ErrAbortHandler) + } + return + } + + data, err := plugin.Handler.SearchLogEvents(&filters) if err != nil { sendAPIResponse(w, r, err, "", getRespStatus(err)) return @@ -202,7 +256,7 @@ func exportFsEvents(w http.ResponseWriter, filters *eventsearcher.FsEventSearch) } results := make([]fsEvent, 0, filters.Limit) for { - data, _, _, err := plugin.Handler.SearchFsEvents(filters) + data, err := plugin.Handler.SearchFsEvents(filters) if err != nil { return err } @@ -218,7 +272,7 @@ func exportFsEvents(w http.ResponseWriter, filters *eventsearcher.FsEventSearch) break } filters.StartTimestamp = results[len(results)-1].Timestamp - filters.ExcludeIDs = []string{results[len(results)-1].ID} + filters.FromID = results[len(results)-1].ID results = nil } csvWriter.Flush() @@ -239,7 +293,44 @@ func exportProviderEvents(w http.ResponseWriter, filters *eventsearcher.Provider } results := make([]providerEvent, 0, filters.Limit) for { - data, _, _, err := plugin.Handler.SearchProviderEvents(filters) + data, err := plugin.Handler.SearchProviderEvents(filters) + if err != nil { + return err + } + if err := json.Unmarshal(data, &results); err != nil { + return err + } + for _, event := range results { + if err := csvWriter.Write(event.getCSVData()); err != nil { + return err + } + } + if len(results) < filters.Limit || len(results) == 0 { + break + } + filters.FromID = results[len(results)-1].ID + filters.StartTimestamp = results[len(results)-1].Timestamp + results = nil + } + csvWriter.Flush() + return csvWriter.Error() +} + +func exportLogEvents(w http.ResponseWriter, filters *eventsearcher.LogEventSearch) error { + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=logs-%s.csv", time.Now().Format("2006-01-02T15-04-05"))) + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Accept-Ranges", "none") + w.WriteHeader(http.StatusOK) + + ev := logEvent{} + csvWriter := csv.NewWriter(w) + err := csvWriter.Write(ev.getCSVHeader()) + if err != nil { + return err + } + results := make([]logEvent, 0, filters.Limit) + for { + data, err := plugin.Handler.SearchLogEvents(filters) if err != nil { return err } @@ -255,7 +346,7 @@ func exportProviderEvents(w http.ResponseWriter, filters *eventsearcher.Provider break } filters.StartTimestamp = results[len(results)-1].Timestamp - filters.ExcludeIDs = []string{results[len(results)-1].ID} + filters.FromID = results[len(results)-1].ID results = nil } csvWriter.Flush() @@ -349,3 +440,39 @@ func (e *providerEvent) getCSVData() []string { return []string{timestamp.Format(time.RFC3339Nano), e.Action, e.ObjectType, e.ObjectName, e.Username, e.IP} } + +type logEvent struct { + ID string `json:"id"` + Timestamp int64 `json:"timestamp"` + Event int `json:"event"` + Protocol string `json:"protocol"` + Username string `json:"username,omitempty"` + IP string `json:"ip,omitempty"` + Message string `json:"message,omitempty"` + Role string `json:"role,omitempty"` +} + +func (e *logEvent) getCSVHeader() []string { + return []string{"Time", "Event", "Protocol", "User", "IP", "Message"} +} + +func (e *logEvent) getCSVData() []string { + timestamp := time.Unix(0, e.Timestamp).UTC() + return []string{timestamp.Format(time.RFC3339Nano), getLogEventString(notifier.LogEventType(e.Event)), + e.Protocol, e.Username, e.IP, e.Message} +} + +func getLogEventString(event notifier.LogEventType) string { + switch event { + case notifier.LogEventTypeLoginFailed: + return "Login failed" + case notifier.LogEventTypeLoginNoUser: + return "Login with non-existent user" + case notifier.LogEventTypeNoLoginTried: + return "No login tried" + case notifier.LogEventTypeNotNegotiated: + return "Algorithm negotiation failed" + default: + return "" + } +} diff --git a/internal/httpd/api_utils.go b/internal/httpd/api_utils.go index 349c86d4..289ac77b 100644 --- a/internal/httpd/api_utils.go +++ b/internal/httpd/api_utils.go @@ -35,6 +35,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/render" "github.com/klauspost/compress/zip" + "github.com/sftpgo/sdk/plugin/notifier" "github.com/drakkan/sftpgo/v2/internal/common" "github.com/drakkan/sftpgo/v2/internal/dataprovider" @@ -614,6 +615,11 @@ func updateLoginMetrics(user *dataprovider.User, loginMethod, ip string, err err if err != nil && err != common.ErrInternalFailure && err != common.ErrNoCredentials { logger.ConnectionFailedLog(user.Username, ip, loginMethod, protocol, err.Error()) err = handleDefenderEventLoginFailed(ip, err) + logEv := notifier.LogEventTypeLoginFailed + if errors.Is(err, util.ErrNotFound) { + logEv = notifier.LogEventTypeLoginNoUser + } + plugin.Handler.NotifyLogEvent(logEv, protocol, user.Username, ip, "", err) } metric.AddLoginResult(loginMethod, err) dataprovider.ExecutePostLoginHook(user, loginMethod, ip, protocol, err) diff --git a/internal/httpd/httpd.go b/internal/httpd/httpd.go index f5442558..69dac543 100644 --- a/internal/httpd/httpd.go +++ b/internal/httpd/httpd.go @@ -91,6 +91,7 @@ const ( metadataChecksPath = "/api/v2/metadata/users/checks" fsEventsPath = "/api/v2/events/fs" providerEventsPath = "/api/v2/events/provider" + logEventsPath = "/api/v2/events/logs" sharesPath = "/api/v2/shares" eventActionsPath = "/api/v2/eventactions" eventRulesPath = "/api/v2/eventrules" @@ -148,6 +149,7 @@ const ( webEventsPathDefault = "/web/admin/events" webEventsFsSearchPathDefault = "/web/admin/events/fs" webEventsProviderSearchPathDefault = "/web/admin/events/provider" + webEventsLogSearchPathDefault = "/web/admin/events/logs" webConfigsPathDefault = "/web/admin/configs" webClientLoginPathDefault = "/web/client/login" webClientOIDCLoginPathDefault = "/web/client/oidclogin" @@ -243,6 +245,7 @@ var ( webEventsPath string webEventsFsSearchPath string webEventsProviderSearchPath string + webEventsLogSearchPath string webConfigsPath string webDefenderHostsPath string webClientLoginPath string @@ -1142,6 +1145,7 @@ func updateWebAdminURLs(baseURL string) { webEventsPath = path.Join(baseURL, webEventsPathDefault) webEventsFsSearchPath = path.Join(baseURL, webEventsFsSearchPathDefault) webEventsProviderSearchPath = path.Join(baseURL, webEventsProviderSearchPathDefault) + webEventsLogSearchPath = path.Join(baseURL, webEventsLogSearchPathDefault) webConfigsPath = path.Join(baseURL, webConfigsPathDefault) webStaticFilesPath = path.Join(baseURL, webStaticFilesPathDefault) webOpenAPIPath = path.Join(baseURL, webOpenAPIPathDefault) diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index df1e140c..606091d6 100644 --- a/internal/httpd/httpd_test.go +++ b/internal/httpd/httpd_test.go @@ -125,6 +125,7 @@ const ( metadataBasePath = "/api/v2/metadata/users" fsEventsPath = "/api/v2/events/fs" providerEventsPath = "/api/v2/events/provider" + logEventsPath = "/api/v2/events/logs" sharesPath = "/api/v2/shares" eventActionsPath = "/api/v2/eventactions" eventRulesPath = "/api/v2/eventrules" @@ -9869,12 +9870,65 @@ func TestSearchEvents(t *testing.T) { } exportFunc() + req, err = http.NewRequest(http.MethodGet, logEventsPath, nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + events = make([]map[string]any, 0) + err = json.Unmarshal(rr.Body.Bytes(), &events) + assert.NoError(t, err) + if assert.Len(t, events, 1) { + ev := events[0] + for _, field := range []string{"id", "timestamp", "event", "ip", "message", "role", "instance_id"} { + _, ok := ev[field] + assert.True(t, ok, field) + } + } + req, err = http.NewRequest(http.MethodGet, logEventsPath+"?events=a,1", nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // CSV export + req, err = http.NewRequest(http.MethodGet, logEventsPath+"?csv_export=true", nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Equal(t, "text/csv", rr.Header().Get("Content-Type")) + // the test eventsearcher plugin returns error if start_timestamp < 0 + req, err = http.NewRequest(http.MethodGet, logEventsPath+"?start_timestamp=-1", nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + // CSV export with error + exportFunc = func() { + defer func() { + rcv := recover() + assert.Equal(t, http.ErrAbortHandler, rcv) + }() + + req, err = http.NewRequest(http.MethodGet, logEventsPath+"?start_timestamp=-1&csv_export=true", nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + } + exportFunc() + req, err = http.NewRequest(http.MethodGet, providerEventsPath+"?limit=2000", nil) assert.NoError(t, err) setBearerForReq(req, token) rr = executeRequest(req) checkResponseCode(t, http.StatusBadRequest, rr) + req, err = http.NewRequest(http.MethodGet, logEventsPath+"?limit=2000", nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + req, err = http.NewRequest(http.MethodGet, fsEventsPath+"?start_timestamp=a", nil) assert.NoError(t, err) setBearerForReq(req, token) diff --git a/internal/httpd/internal_test.go b/internal/httpd/internal_test.go index 72f2c57e..ed2034c6 100644 --- a/internal/httpd/internal_test.go +++ b/internal/httpd/internal_test.go @@ -44,6 +44,7 @@ import ( "github.com/lestrrat-go/jwx/v2/jwt" "github.com/rs/xid" "github.com/sftpgo/sdk" + "github.com/sftpgo/sdk/plugin/notifier" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -785,6 +786,11 @@ func TestInvalidToken(t *testing.T) { assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Contains(t, rr.Body.String(), "Invalid token claims") + rr = httptest.NewRecorder() + searchLogEvents(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid token claims") + rr = httptest.NewRecorder() addIPListEntry(rr, req) assert.Equal(t, http.StatusBadRequest, rr.Code) @@ -3224,6 +3230,14 @@ func TestHTTPSRedirect(t *testing.T) { assert.NoError(t, err) } +func TestGetLogEventString(t *testing.T) { + assert.Equal(t, "Login failed", getLogEventString(notifier.LogEventTypeLoginFailed)) + assert.Equal(t, "Login with non-existent user", getLogEventString(notifier.LogEventTypeLoginNoUser)) + assert.Equal(t, "No login tried", getLogEventString(notifier.LogEventTypeNoLoginTried)) + assert.Equal(t, "Algorithm negotiation failed", getLogEventString(notifier.LogEventTypeNotNegotiated)) + assert.Empty(t, getLogEventString(0)) +} + func isSharedProviderSupported() bool { // SQLite shares the implementation with other SQL-based provider but it makes no sense // to use it outside test cases diff --git a/internal/httpd/server.go b/internal/httpd/server.go index cd0d1a34..c1e4f3d7 100644 --- a/internal/httpd/server.go +++ b/internal/httpd/server.go @@ -1322,6 +1322,8 @@ func (s *httpdServer) initializeRouter() { Get(fsEventsPath, searchFsEvents) router.With(s.checkPerm(dataprovider.PermAdminViewEvents), compressor.Handler). Get(providerEventsPath, searchProviderEvents) + router.With(s.checkPerm(dataprovider.PermAdminViewEvents), compressor.Handler). + Get(logEventsPath, searchLogEvents) router.With(forbidAPIKeyAuthentication, s.checkPerm(dataprovider.PermAdminManageAPIKeys)). Get(apiKeysPath, getAPIKeys) router.With(forbidAPIKeyAuthentication, s.checkPerm(dataprovider.PermAdminManageAPIKeys)). @@ -1724,6 +1726,8 @@ func (s *httpdServer) setupWebAdminRoutes() { Get(webEventsFsSearchPath, searchFsEvents) router.With(s.checkPerm(dataprovider.PermAdminViewEvents), compressor.Handler, s.refreshCookie). Get(webEventsProviderSearchPath, searchProviderEvents) + router.With(s.checkPerm(dataprovider.PermAdminViewEvents), compressor.Handler, s.refreshCookie). + Get(webEventsLogSearchPath, searchLogEvents) router.With(s.checkPerm(dataprovider.PermAdminManageIPLists)).Get(webIPListsPath, s.handleWebIPListsPage) router.With(s.checkPerm(dataprovider.PermAdminManageIPLists), compressor.Handler, s.refreshCookie). Get(webIPListsPath+"/{type}", getIPListEntries) diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go index 92438c90..022ab8b9 100644 --- a/internal/httpd/webadmin.go +++ b/internal/httpd/webadmin.go @@ -388,6 +388,7 @@ type eventsPage struct { basePage FsEventsSearchURL string ProviderEventsSearchURL string + LogEventsSearchURL string } type configsPage struct { @@ -3944,6 +3945,7 @@ func (s *httpdServer) handleWebGetEvents(w http.ResponseWriter, r *http.Request) basePage: s.getBasePageData(pageEventsTitle, webEventsPath, r), FsEventsSearchURL: webEventsFsSearchPath, ProviderEventsSearchURL: webEventsProviderSearchPath, + LogEventsSearchURL: webEventsLogSearchPath, } renderAdminTemplate(w, templateEvents, data) } diff --git a/internal/metric/metric.go b/internal/metric/metric.go index 534e9840..f1f2e9af 100644 --- a/internal/metric/metric.go +++ b/internal/metric/metric.go @@ -108,9 +108,9 @@ var ( Help: "The total number of login attempts", }) - // totalNoAuthTryed is te metric that reports the total number of clients disconnected + // totalNoAuthTried is te metric that reports the total number of clients disconnected // for inactivity before trying to login - totalNoAuthTryed = promauto.NewCounter(prometheus.CounterOpts{ + totalNoAuthTried = promauto.NewCounter(prometheus.CounterOpts{ Name: "sftpgo_no_auth_total", Help: "The total number of clients disconnected for inactivity before trying to login", }) @@ -984,10 +984,10 @@ func AddLoginResult(authMethod string, err error) { } } -// AddNoAuthTryed increments the metric for clients disconnected +// AddNoAuthTried increments the metric for clients disconnected // for inactivity before trying to login -func AddNoAuthTryed() { - totalNoAuthTryed.Inc() +func AddNoAuthTried() { + totalNoAuthTried.Inc() } // HTTPRequestServed increments the metrics for HTTP requests diff --git a/internal/metric/metric_disabled.go b/internal/metric/metric_disabled.go index 507897be..63369703 100644 --- a/internal/metric/metric_disabled.go +++ b/internal/metric/metric_disabled.go @@ -64,9 +64,9 @@ func AddLoginAttempt(_ string) {} // AddLoginResult increments the metrics for login results func AddLoginResult(_ string, _ error) {} -// AddNoAuthTryed increments the metric for clients disconnected +// AddNoAuthTried increments the metric for clients disconnected // for inactivity before trying to login -func AddNoAuthTryed() {} +func AddNoAuthTried() {} // HTTPRequestServed increments the metrics for HTTP requests func HTTPRequestServed(_ int) {} diff --git a/internal/plugin/notifier.go b/internal/plugin/notifier.go index 9f0fb6c9..92722d63 100644 --- a/internal/plugin/notifier.go +++ b/internal/plugin/notifier.go @@ -33,6 +33,7 @@ type NotifierConfig struct { FsEvents []string `json:"fs_events" mapstructure:"fs_events"` ProviderEvents []string `json:"provider_events" mapstructure:"provider_events"` ProviderObjects []string `json:"provider_objects" mapstructure:"provider_objects"` + LogEvents []int `json:"log_events" mapstructure:"log_events"` RetryMaxTime int `json:"retry_max_time" mapstructure:"retry_max_time"` RetryQueueMaxSize int `json:"retry_queue_max_size" mapstructure:"retry_queue_max_size"` } @@ -51,6 +52,7 @@ type eventsQueue struct { sync.RWMutex fsEvents []*notifier.FsEvent providerEvents []*notifier.ProviderEvent + logEvents []*notifier.LogEvent } func (q *eventsQueue) addFsEvent(event *notifier.FsEvent) { @@ -67,6 +69,13 @@ func (q *eventsQueue) addProviderEvent(event *notifier.ProviderEvent) { q.providerEvents = append(q.providerEvents, event) } +func (q *eventsQueue) addLogEvent(event *notifier.LogEvent) { + q.Lock() + defer q.Unlock() + + q.logEvents = append(q.logEvents, event) +} + func (q *eventsQueue) popFsEvent() *notifier.FsEvent { q.Lock() defer q.Unlock() @@ -97,11 +106,26 @@ func (q *eventsQueue) popProviderEvent() *notifier.ProviderEvent { return ev } +func (q *eventsQueue) popLogEvent() *notifier.LogEvent { + q.Lock() + defer q.Unlock() + + if len(q.logEvents) == 0 { + return nil + } + truncLen := len(q.logEvents) - 1 + ev := q.logEvents[truncLen] + q.logEvents[truncLen] = nil + q.logEvents = q.logEvents[:truncLen] + + return ev +} + func (q *eventsQueue) getSize() int { q.RLock() defer q.RUnlock() - return len(q.providerEvents) + len(q.fsEvents) + return len(q.providerEvents) + len(q.fsEvents) + len(q.logEvents) } type notifierPlugin struct { @@ -225,6 +249,19 @@ func (p *notifierPlugin) notifyProviderAction(event *notifier.ProviderEvent, obj }() } +func (p *notifierPlugin) notifyLogEvent(event *notifier.LogEvent) { + if !util.Contains(p.config.NotifierOptions.LogEvents, int(event.Event)) { + return + } + + go func() { + Handler.addTask() + defer Handler.removeTask() + + p.sendLogEvent(event) + }() +} + func (p *notifierPlugin) sendFsEvent(event *notifier.FsEvent) { if err := p.notifier.NotifyFsEvent(event); err != nil { logger.Warn(logSender, "", "unable to send fs action notification to plugin %v: %v", p.config.Cmd, err) @@ -243,6 +280,15 @@ func (p *notifierPlugin) sendProviderEvent(event *notifier.ProviderEvent) { } } +func (p *notifierPlugin) sendLogEvent(event *notifier.LogEvent) { + if err := p.notifier.NotifyLogEvent(event); err != nil { + logger.Warn(logSender, "", "unable to send log event to plugin %v: %v", p.config.Cmd, err) + if p.canQueueEvent(event.Timestamp) { + p.queue.addLogEvent(event) + } + } +} + func (p *notifierPlugin) sendQueuedEvents() { queueSize := p.queue.getSize() if queueSize == 0 { @@ -264,5 +310,12 @@ func (p *notifierPlugin) sendQueuedEvents() { }(providerEv) providerEv = p.queue.popProviderEvent() } + logEv := p.queue.popLogEvent() + for logEv != nil { + go func(ev *notifier.LogEvent) { + p.sendLogEvent(ev) + }(logEv) + logEv = p.queue.popLogEvent() + } logger.Debug(logSender, "", "queued events sent for notifier %q, new events size: %v", p.config.Cmd, p.queue.getSize()) } diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 970062d3..de6f27c7 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -291,15 +291,38 @@ func (m *Manager) NotifyProviderEvent(event *notifier.ProviderEvent, object Rend } } +// NotifyLogEvent sends the log event notifications using any defined notifier plugins +func (m *Manager) NotifyLogEvent(event notifier.LogEventType, protocol, username, ip, role string, err error) { + if !m.hasNotifiers { + return + } + m.notifLock.RLock() + defer m.notifLock.RUnlock() + + e := ¬ifier.LogEvent{ + Timestamp: time.Now().UnixNano(), + Event: event, + Protocol: protocol, + Username: username, + IP: ip, + Message: err.Error(), + Role: role, + } + + for _, n := range m.notifiers { + n.notifyLogEvent(e) + } +} + // HasSearcher returns true if an event searcher plugin is defined func (m *Manager) HasSearcher() bool { return m.hasSearcher } // SearchFsEvents returns the filesystem events matching the specified filters -func (m *Manager) SearchFsEvents(searchFilters *eventsearcher.FsEventSearch) ([]byte, []string, []string, error) { +func (m *Manager) SearchFsEvents(searchFilters *eventsearcher.FsEventSearch) ([]byte, error) { if !m.hasSearcher { - return nil, nil, nil, ErrNoSearcher + return nil, ErrNoSearcher } m.searcherLock.RLock() plugin := m.searcher @@ -309,9 +332,9 @@ func (m *Manager) SearchFsEvents(searchFilters *eventsearcher.FsEventSearch) ([] } // SearchProviderEvents returns the provider events matching the specified filters -func (m *Manager) SearchProviderEvents(searchFilters *eventsearcher.ProviderEventSearch) ([]byte, []string, []string, error) { +func (m *Manager) SearchProviderEvents(searchFilters *eventsearcher.ProviderEventSearch) ([]byte, error) { if !m.hasSearcher { - return nil, nil, nil, ErrNoSearcher + return nil, ErrNoSearcher } m.searcherLock.RLock() plugin := m.searcher @@ -320,6 +343,18 @@ func (m *Manager) SearchProviderEvents(searchFilters *eventsearcher.ProviderEven return plugin.searchear.SearchProviderEvents(searchFilters) } +// SearchLogEvents returns the log events matching the specified filters +func (m *Manager) SearchLogEvents(searchFilters *eventsearcher.LogEventSearch) ([]byte, error) { + if !m.hasSearcher { + return nil, ErrNoSearcher + } + m.searcherLock.RLock() + plugin := m.searcher + m.searcherLock.RUnlock() + + return plugin.searchear.SearchLogEvents(searchFilters) +} + // HasMetadater returns true if a metadata plugin is defined func (m *Manager) HasMetadater() bool { return m.hasMetadater diff --git a/internal/sftpd/server.go b/internal/sftpd/server.go index 72f357dd..eb6ae298 100644 --- a/internal/sftpd/server.go +++ b/internal/sftpd/server.go @@ -32,12 +32,14 @@ import ( "time" "github.com/pkg/sftp" + "github.com/sftpgo/sdk/plugin/notifier" "golang.org/x/crypto/ssh" "github.com/drakkan/sftpgo/v2/internal/common" "github.com/drakkan/sftpgo/v2/internal/dataprovider" "github.com/drakkan/sftpgo/v2/internal/logger" "github.com/drakkan/sftpgo/v2/internal/metric" + "github.com/drakkan/sftpgo/v2/internal/plugin" "github.com/drakkan/sftpgo/v2/internal/util" "github.com/drakkan/sftpgo/v2/internal/vfs" ) @@ -762,19 +764,27 @@ func checkAuthError(ip string, err error) { if errors.As(err, &sftpAuthErr) { if sftpAuthErr.getLoginMethod() == dataprovider.SSHLoginMethodPublicKey { event := common.HostEventLoginFailed + logEv := notifier.LogEventTypeLoginFailed if errors.Is(err, util.ErrNotFound) { event = common.HostEventUserNotFound + logEv = notifier.LogEventTypeLoginNoUser } common.AddDefenderEvent(ip, common.ProtocolSSH, event) + plugin.Handler.NotifyLogEvent(logEv, common.ProtocolSSH, "", ip, "", err) return } } } } else { - logger.ConnectionFailedLog("", ip, dataprovider.LoginMethodNoAuthTryed, common.ProtocolSSH, err.Error()) - metric.AddNoAuthTryed() + logger.ConnectionFailedLog("", ip, dataprovider.LoginMethodNoAuthTried, common.ProtocolSSH, err.Error()) + metric.AddNoAuthTried() common.AddDefenderEvent(ip, common.ProtocolSSH, common.HostEventNoLoginTried) - dataprovider.ExecutePostLoginHook(&dataprovider.User{}, dataprovider.LoginMethodNoAuthTryed, ip, common.ProtocolSSH, err) + dataprovider.ExecutePostLoginHook(&dataprovider.User{}, dataprovider.LoginMethodNoAuthTried, ip, common.ProtocolSSH, err) + logEv := notifier.LogEventTypeNoLoginTried + if errors.Is(err, ssh.ErrNoCommonAlgo) { + logEv = notifier.LogEventTypeNotNegotiated + } + plugin.Handler.NotifyLogEvent(logEv, common.ProtocolSSH, "", ip, "", err) } } @@ -1230,10 +1240,13 @@ func updateLoginMetrics(user *dataprovider.User, ip, method string, err error) { // record failed login key auth only once for session if the // authentication fails in checkAuthError event := common.HostEventLoginFailed + logEv := notifier.LogEventTypeLoginFailed if errors.Is(err, util.ErrNotFound) { event = common.HostEventUserNotFound + logEv = notifier.LogEventTypeLoginNoUser } common.AddDefenderEvent(ip, common.ProtocolSSH, event) + plugin.Handler.NotifyLogEvent(logEv, common.ProtocolSSH, user.Username, ip, "", err) } } metric.AddLoginResult(method, err) diff --git a/internal/webdavd/file.go b/internal/webdavd/file.go index a82a9c56..7f1cca9e 100644 --- a/internal/webdavd/file.go +++ b/internal/webdavd/file.go @@ -48,7 +48,7 @@ type webDavFile struct { info os.FileInfo startOffset int64 isFinished bool - readTryed atomic.Bool + readTried atomic.Bool } func newWebDavFile(baseTransfer *common.BaseTransfer, pipeWriter *vfs.PipeWriter, pipeReader *pipeat.PipeReaderAt) *webDavFile { @@ -70,7 +70,7 @@ func newWebDavFile(baseTransfer *common.BaseTransfer, pipeWriter *vfs.PipeWriter startOffset: 0, info: nil, } - f.readTryed.Store(false) + f.readTried.Store(false) return f } @@ -177,7 +177,7 @@ func (f *webDavFile) checkFirstRead() error { f.Connection.Log(logger.LevelDebug, "download for file %q denied by pre action: %v", f.GetVirtualPath(), err) return f.Connection.GetPermissionDeniedError() } - f.readTryed.Store(true) + f.readTried.Store(true) return nil } @@ -186,7 +186,7 @@ func (f *webDavFile) Read(p []byte) (n int, err error) { if f.AbortTransfer.Load() { return 0, errTransferAborted } - if !f.readTryed.Load() { + if !f.readTried.Load() { if err := f.checkFirstRead(); err != nil { return 0, err } @@ -417,7 +417,7 @@ func (f *webDavFile) setFinished() error { func (f *webDavFile) isTransfer() bool { if f.GetType() == common.TransferDownload { - return f.readTryed.Load() + return f.readTried.Load() } return true } diff --git a/internal/webdavd/server.go b/internal/webdavd/server.go index 43290d7b..ed424474 100644 --- a/internal/webdavd/server.go +++ b/internal/webdavd/server.go @@ -33,11 +33,13 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/rs/cors" "github.com/rs/xid" + "github.com/sftpgo/sdk/plugin/notifier" "github.com/drakkan/sftpgo/v2/internal/common" "github.com/drakkan/sftpgo/v2/internal/dataprovider" "github.com/drakkan/sftpgo/v2/internal/logger" "github.com/drakkan/sftpgo/v2/internal/metric" + "github.com/drakkan/sftpgo/v2/internal/plugin" "github.com/drakkan/sftpgo/v2/internal/util" ) @@ -414,10 +416,13 @@ func updateLoginMetrics(user *dataprovider.User, ip, loginMethod string, err err if err != nil && err != common.ErrInternalFailure && err != common.ErrNoCredentials { logger.ConnectionFailedLog(user.Username, ip, loginMethod, common.ProtocolWebDAV, err.Error()) event := common.HostEventLoginFailed + logEv := notifier.LogEventTypeLoginFailed if errors.Is(err, util.ErrNotFound) { event = common.HostEventUserNotFound + logEv = notifier.LogEventTypeLoginNoUser } common.AddDefenderEvent(ip, common.ProtocolWebDAV, event) + plugin.Handler.NotifyLogEvent(logEv, common.ProtocolWebDAV, user.Username, ip, "", err) } metric.AddLoginResult(loginMethod, err) dataprovider.ExecutePostLoginHook(user, loginMethod, ip, common.ProtocolWebDAV, err) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 1ebe8c49..70c5b6f5 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -2594,13 +2594,10 @@ paths: explode: false required: false - in: query - name: exclude_ids + name: from_id schema: - type: array - items: - type: string - description: 'the event id must not be included among those specified. This is useful for cursor based pagination. Empty or missing means omit this filter. Values must be specified comma separated' - explode: false + type: string + description: 'the event id to start from. This is useful for cursor based pagination. Empty or missing means omit this filter.' required: false - in: query name: role @@ -2728,13 +2725,10 @@ paths: explode: false required: false - in: query - name: exclude_ids + name: from_id schema: - type: array - items: - type: string - description: 'the event id must not be included among those specified. This is useful for cursor based pagination. Empty or missing means omit this filter. Values must be specified comma separated' - explode: false + type: string + description: 'the event id to start from. This is useful for cursor based pagination. Empty or missing means omit this filter.' required: false - in: query name: role @@ -2797,6 +2791,131 @@ paths: $ref: '#/components/responses/InternalServerError' default: $ref: '#/components/responses/DefaultResponse' + /events/log: + get: + tags: + - events + summary: Get log events + description: 'Returns an array with one or more log events applying the specified filters. This API is only available if you configure an "eventsearcher" plugin' + operationId: get_log_events + parameters: + - in: query + name: start_timestamp + schema: + type: integer + format: int64 + minimum: 0 + default: 0 + required: false + description: 'the event timestamp, unix timestamp in nanoseconds, must be greater than or equal to the specified one. 0 or missing means omit this filter' + - in: query + name: end_timestamp + schema: + type: integer + format: int64 + minimum: 0 + default: 0 + required: false + description: 'the event timestamp, unix timestamp in nanoseconds, must be less than or equal to the specified one. 0 or missing means omit this filter' + - in: query + name: events + schema: + type: array + items: + $ref: '#/components/schemas/LogEventType' + description: 'the log events must be included among those specified. Empty or missing means omit this filter. Events must be specified comma separated' + explode: false + required: false + - in: query + name: username + schema: + type: string + description: 'the event username must be the same as the one specified. Empty or missing means omit this filter' + required: false + - in: query + name: ip + schema: + type: string + description: 'the event IP must be the same as the one specified. Empty or missing means omit this filter' + required: false + - in: query + name: protocols + schema: + type: array + items: + $ref: '#/components/schemas/EventProtocols' + description: 'the event protocol must be included among those specified. Empty or missing means omit this filter. Values must be specified comma separated' + explode: false + required: false + - in: query + name: instance_ids + schema: + type: array + items: + type: string + description: 'the event instance id must be included among those specified. Empty or missing means omit this filter. Values must be specified comma separated' + explode: false + required: false + - in: query + name: from_id + schema: + type: string + description: 'the event id to start from. This is useful for cursor based pagination. Empty or missing means omit this filter.' + required: false + - in: query + name: role + schema: + type: string + description: 'User role. Empty or missing means omit this filter. Ignored if the admin has a role' + required: false + - in: query + name: csv_export + schema: + type: boolean + default: false + required: false + description: 'If enabled, events are exported as a CSV file' + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 100 + required: false + description: 'The maximum number of items to return. Max value is 1000, default is 100' + - in: query + name: order + required: false + description: Ordering events by timestamp. Default DESC + schema: + type: string + enum: + - ASC + - DESC + example: DESC + responses: + '200': + description: successful operation + content: + application/json; charset=utf-8: + schema: + type: array + items: + $ref: '#/components/schemas/LogEvent' + text/csv: + schema: + type: string + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + default: + $ref: '#/components/responses/DefaultResponse' /apikeys: get: security: @@ -5143,6 +5262,19 @@ components: - roles - ip_lists - configs + LogEventType: + type: integer + enum: + - 1 + - 2 + - 3 + - 4 + description: > + Event status: + * `1` - Login failed + * `2` - Login failed non-existent user + * `3` - No login tried + * `4` - Algorithm negotiation failed FsEventStatus: type: integer enum: @@ -6787,6 +6919,8 @@ components: type: string open_flags: type: string + role: + type: string instance_id: type: string ProviderEvent: @@ -6812,6 +6946,31 @@ components: type: string format: byte description: 'base64 of the JSON serialized object with sensitive fields removed' + role: + type: string + instance_id: + type: string + LogEvent: + type: object + properties: + id: + type: string + timestamp: + type: integer + format: int64 + description: 'unix timestamp in nanoseconds' + event: + $ref: '#/components/schemas/LogEventType' + protocol: + $ref: '#/components/schemas/EventProtocols' + username: + type: string + ip: + type: string + message: + type: string + role: + type: string instance_id: type: string KeyValue: diff --git a/templates/webadmin/events.html b/templates/webadmin/events.html index dd0fc1b8..edcaf139 100644 --- a/templates/webadmin/events.html +++ b/templates/webadmin/events.html @@ -44,6 +44,7 @@ along with this program. If not, see .
@@ -59,7 +60,14 @@ along with this program. If not, see .
- + + + + +
+
+
-
- -
@@ -129,6 +130,22 @@ along with this program. If not, see .
+
+ + + + + + + + + + + + +
IDTimeActionUserProtoIPMessage
+
+