ソースを参照

add support for log events

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 2 年 前
コミット
4eded56d5f

+ 1 - 1
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.
 

+ 1 - 0
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.

+ 1 - 1
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

+ 1 - 1
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)
 

+ 14 - 14
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
 )

+ 27 - 28
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=

+ 8 - 5
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())

+ 1 - 1
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)
 		}
 	}
 }

+ 15 - 0
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)

+ 5 - 0
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)

+ 2 - 2
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

+ 1 - 1
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"

+ 5 - 0
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)

+ 135 - 8
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 ""
+	}
+}

+ 6 - 0
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)

+ 4 - 0
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)

+ 54 - 0
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)

+ 14 - 0
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

+ 4 - 0
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)

+ 2 - 0
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)
 }

+ 5 - 5
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

+ 2 - 2
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) {}

+ 54 - 1
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())
 }

+ 39 - 4
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 := &notifier.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

+ 16 - 3
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)

+ 5 - 5
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
 }

+ 5 - 0
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)

+ 171 - 12
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:

+ 191 - 16
templates/webadmin/events.html

@@ -44,6 +44,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
                 <select class="form-control selectpicker" id="idEventType" name="events_type" onchange="onEventChanged(this.value)">
                     <option value="1" selected>Fs events</option>
                     <option value="2">Provider events</option>
+                    <option value="3">Other events</option>
                 </select>
             </div>
             <div class="form-group col-md-3">
@@ -59,7 +60,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
         </div>
         <div class="form-row">
             <div class="form-group col-md-4">
-                <select class="form-control selectpicker fs-events" id="idProtocols" name="protocols" title="Protocols" multiple>
+                <select class="form-control selectpicker fs-events" id="idStatuses" name="statuses" title="Statuses" multiple>
+                    <option value="1">OK</option>
+                    <option value="2">KO</option>
+                    <option value="3">Quota exceeded</option>
+                </select>
+            </div>
+            <div class="form-group col-md-4">
+                <select class="form-control selectpicker fs-events log-events" id="idProtocols" name="protocols" title="Protocols" multiple>
                     <option value="SFTP">SFTP</option>
                     <option value="SCP">SCP</option>
                     <option value="SSH">SSH</option>
@@ -72,13 +80,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
                     <option value="EventAction">EventAction</option>
                 </select>
             </div>
-            <div class="form-group col-md-4">
-                <select class="form-control selectpicker fs-events" id="idStatuses" name="statuses" title="Statuses" multiple>
-                    <option value="1">OK</option>
-                    <option value="2">KO</option>
-                    <option value="3">Quota exceeded</option>
-                </select>
-            </div>
             <div class="form-group col-md-4">
                 <div class="input-group">
                     <input type="text" id="dateTimeRange" class="form-control bg-light border-0" aria-describedby="search-button">
@@ -129,6 +130,22 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
             </table>
         </div>
 
+        <div class="table-responsive log-events">
+            <table class="table table-hover nowrap" id="dataTableLog" width="100%" cellspacing="0">
+                <thead>
+                    <tr>
+                        <th>ID</th>
+                        <th>Time</th>
+                        <th>Action</th>
+                        <th>User</th>
+                        <th>Proto</th>
+                        <th>IP</th>
+                        <th>Message</th>
+                    </tr>
+                </thead>
+            </table>
+        </div>
+
         <div id="paginationContainer" class="m-4 d-none">
             <nav aria-label="Pagination">
                 <ul class="pagination justify-content-end">
@@ -160,6 +177,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
     let dateFn = $.fn.dataTable.render.datetime();
     let isFsDataTableInitialized = false;
     let isProviderDataTableInitialized = false;
+    let isLogDataTableInitialized = false;
     const pageSize = 20;
     const paginationData = new Map();
 
@@ -278,12 +296,18 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
                 return;
             }
             table = $('#dataTableFs').DataTable();
-        } else {
+        } else if (eventType == 2) {
             if (!isProviderDataTableInitialized){
                 initProviderDatatable();
                 return;
             }
             table = $('#dataTableProvider').DataTable();
+        } else {
+            if (!isLogDataTableInitialized){
+                initLogDatatable();
+                return;
+            }
+            table = $('#dataTableLog').DataTable();
         }
         table.clear().draw();
         table.ajax.url(getSearchURL(false)).load();
@@ -313,15 +337,28 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
             if (statuses.length > 0){
                 url+="&statuses="+encodeURIComponent(String(statuses));
             }
-        } else {
+        } else if (eventType == 2) {
             url = "{{.ProviderEventsSearchURL}}?omit_object_data=true&limit="+limit;
+        } else {
+            url = "{{.LogEventsSearchURL}}?limit="+limit;
+            let protocols = [];
+            $('#idProtocols').find('option:selected').each(function(){
+                protocols.push($(this).val());
+            });
+            if (protocols.length > 0){
+                url+="&protocols="+encodeURIComponent(String(protocols));
+            }
         }
         let actions = [];
         $('#idActions').find('option:selected').each(function(){
             actions.push($(this).val());
         });
         if (actions.length > 0){
-            url+="&actions="+encodeURIComponent(String(actions));
+            if (eventType == 3){
+                url+="&events="+encodeURIComponent(String(actions));
+            } else {
+                url+="&actions="+encodeURIComponent(String(actions));
+            }
         }
         let username = $('#idUsername').val();
         if (username){
@@ -332,26 +369,26 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
             url+="&ip="+encodeURIComponent(ip);
         }
         let drp = $('#dateTimeRange').data('daterangepicker');
-        let excludeIds = [];
+        let fromID = "";
         let start_ts;
         if (!csvExport && paginationData.get("prevClicked") && paginationData.has("lastId") && paginationData.has("lastTs")){
             order = "ASC";
             start_ts = paginationData.get("lastTs");
-            excludeIds.push(paginationData.get("lastId"));
+            fromID = paginationData.get("lastId");
         } else {
             start_ts = drp.startDate.valueOf()*1000000;
         }
         let end_ts;
         if (!csvExport && paginationData.get("nextClicked") && paginationData.has("firstId") && paginationData.has("firstTs")){
             end_ts = paginationData.get("firstTs");
-            excludeIds.push(paginationData.get("firstId"));
+            fromID = paginationData.get("firstId");
         } else {
             end_ts = drp.endDate.valueOf()*1000000;
         }
         url+="&start_timestamp="+encodeURIComponent(start_ts);
         url+="&end_timestamp="+encodeURIComponent(end_ts);
-        if (excludeIds.length > 0){
-            url+="&exclude_ids="+encodeURIComponent(String(excludeIds));
+        if (fromID){
+            url+="&from_id="+encodeURIComponent(fromID);
         }
         url+="&order="+order;
         if (csvExport){
@@ -360,6 +397,120 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
         return url;
     }
 
+    function initLogDatatable(){
+        $('#errorMsg').hide();
+        let tableLog = $('#dataTableLog').DataTable({
+            "ajax": {
+                "url": getSearchURL(false),
+                "dataSrc": handleResponseData,
+                "error": function ($xhr, textStatus, errorThrown) {
+                    $(".dataTables_processing").hide();
+                    let txt = "Failed to get log events";
+                    if ($xhr) {
+                        let json = $xhr.responseJSON;
+                        if (json) {
+                            if (json.message){
+                                txt += ": " + json.message;
+                            } else {
+                                txt += ": " + json.error;
+                            }
+                        }
+                    }
+                    $('#errorTxt').text(txt);
+                    $('#errorMsg').show();
+                }
+            },
+            "deferRender": true,
+            "processing": true,
+            "columns": [
+                { "data": "id" },
+                {
+                    "data": "timestamp",
+                    "render": function (data, type, row) {
+                        if (type === 'display') {
+                            return dateFn(data/1000000,type);
+                        }
+                        return data;
+                    }
+                },
+                {
+                    "data": "event",
+                    "render": function (data, type, row) {
+                        if (type === 'display') {
+                            switch (data){
+                                case 1:
+                                    return "Login failed";
+                                case 2:
+                                    return "Login with non-existent user";
+                                case 3:
+                                    return "No login tried";
+                                case 4:
+                                    return "Algorithm negotiation failed";
+                            }
+                        }
+                        return data;
+                    }
+                },
+                {
+                    "data": "username",
+                    "defaultContent": "",
+                    "render": function (data, type, row) {
+                        if (type === 'display') {
+                            if (!data){
+                                return "";
+                            }
+                            return escapeHTML(data);
+                        }
+                        return data;
+                    }
+                },
+                {
+                    "data": "protocol",
+                    "defaultContent": ""
+                },
+                {
+                    "data": "ip",
+                    "defaultContent": ""
+                },
+                {
+                    "data": "message",
+                    "defaultContent": "",
+                    "render": function (data, type, row) {
+                        if (type === 'display') {
+                            if (!data){
+                                return "";
+                            }
+                            return '<span style="white-space:normal">' + escapeHTML(data) + "</span>"
+                        }
+                        return data;
+                    }
+                }
+            ],
+            "buttons": [],
+            "lengthChange": false,
+            "columnDefs": [
+                {
+                    "targets": [0],
+                    "visible": false,
+                    "searchable": false
+                },
+            ],
+            "responsive": true,
+            "searching": false,
+            "paging": false,
+            "info": false,
+            "ordering": false,
+            "language": {
+                "loadingRecords": "",
+                "emptyTable": "No logs found"
+            }
+        });
+
+        new $.fn.dataTable.FixedHeader(tableLog);
+
+        isLogDataTableInitialized = true;
+    }
+
     function initProviderDatatable(){
         $('#errorMsg').hide();
         let tableProvider = $('#dataTableProvider').DataTable({
@@ -597,10 +748,30 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
         $('#idUsername').val("");
         $('#idIp').val("");
         $('.provider-events').hide();
+        $('.log-events').hide();
         $('.fs-events').show();
         onSearchClicked();
     }
 
+    function selectLogEvents(){
+        let idActions = $('#idActions');
+        idActions.selectpicker('deselectAll');
+        idActions.find('option').remove();
+        idActions.find('li').remove();
+        idActions.append($('<option>').val('1').text('Login failed'));
+        idActions.append($('<option>').val('2').text('Login with non-existent user'));
+        idActions.append($('<option>').val('3').text('No login tried'));
+        idActions.append($('<option>').val('4').text('Algorithm negotiation failed'));
+        idActions.selectpicker('refresh');
+
+        $('#idUsername').val("");
+        $('#idIp').val("");
+        $('.provider-events').hide();
+        $('.fs-events').hide();
+        $('.log-events').show();
+        onSearchClicked();
+    }
+
     function selectProviderEvents(){
         let idActions = $('#idActions');
         idActions.selectpicker('deselectAll');
@@ -614,6 +785,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
         $('#idUsername').val("");
         $('#idIp').val("");
         $('.fs-events').hide();
+        $('.log-events').hide();
         $('.provider-events').show();
         onSearchClicked();
     }
@@ -626,6 +798,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
             case '2':
                 selectProviderEvents();
                 break;
+            case '3':
+                selectLogEvents();
+                break;
             default:
                 console.log(`unsupported event type: ${val}`);
         }

+ 4 - 4
tests/eventsearcher/go.mod

@@ -4,7 +4,7 @@ go 1.20
 
 require (
 	github.com/hashicorp/go-plugin v1.4.10-0.20230403150917-e889c1ba1044
-	github.com/sftpgo/sdk v0.1.3
+	github.com/sftpgo/sdk v0.1.4-0.20230512160325-38e59551f700
 )
 
 require (
@@ -16,10 +16,10 @@ require (
 	github.com/mattn/go-isatty v0.0.18 // indirect
 	github.com/mitchellh/go-testing-interface v1.14.1 // indirect
 	github.com/oklog/run v1.1.0 // indirect
-	golang.org/x/net v0.9.0 // indirect
-	golang.org/x/sys v0.7.0 // indirect
+	golang.org/x/net v0.10.0 // indirect
+	golang.org/x/sys v0.8.0 // indirect
 	golang.org/x/text v0.9.0 // indirect
 	google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
-	google.golang.org/grpc v1.54.0 // indirect
+	google.golang.org/grpc v1.55.0 // indirect
 	google.golang.org/protobuf v1.30.0 // indirect
 )

+ 8 - 8
tests/eventsearcher/go.sum

@@ -31,13 +31,13 @@ github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
 github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/sftpgo/sdk v0.1.3 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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
 github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
-golang.org/x/net v0.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/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -45,15 +45,15 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
-golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A=
 google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
-google.golang.org/grpc v1.54.0 h1:EhTqbhiYeixwWQtAEZAxmV9MGqcjEU2mFx52xCzNyag=
-google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g=
+google.golang.org/grpc v1.55.0 h1:3Oj82/tFSCeUrRTg/5E/7d/W5A1tj6Ky1ABAuZuv5ag=
+google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=

+ 46 - 8
tests/eventsearcher/main.go

@@ -49,11 +49,23 @@ type providerEvent struct {
 	InstanceID string `json:"instance_id,omitempty"`
 }
 
+type logEvent struct {
+	ID         string `json:"id" gorm:"primaryKey"`
+	Timestamp  int64  `json:"timestamp"`
+	Event      int    `json:"event"`
+	Protocol   string `json:"protocol,omitempty"`
+	Username   string `json:"username,omitempty"`
+	IP         string `json:"ip,omitempty"`
+	Message    string `json:"message,omitempty"`
+	Role       string `json:"role,omitempty"`
+	InstanceID string `json:"instance_id,omitempty"`
+}
+
 type Searcher struct{}
 
-func (s *Searcher) SearchFsEvents(filters *eventsearcher.FsEventSearch) ([]byte, []string, []string, error) {
+func (s *Searcher) SearchFsEvents(filters *eventsearcher.FsEventSearch) ([]byte, error) {
 	if filters.StartTimestamp < 0 {
-		return nil, nil, nil, errNotSupported
+		return nil, errNotSupported
 	}
 
 	results := []fsEvent{
@@ -84,15 +96,15 @@ func (s *Searcher) SearchFsEvents(filters *eventsearcher.FsEventSearch) ([]byte,
 
 	data, err := json.Marshal(results)
 	if err != nil {
-		return nil, nil, nil, err
+		return nil, err
 	}
 
-	return data, nil, nil, nil
+	return data, nil
 }
 
-func (s *Searcher) SearchProviderEvents(filters *eventsearcher.ProviderEventSearch) ([]byte, []string, []string, error) {
+func (s *Searcher) SearchProviderEvents(filters *eventsearcher.ProviderEventSearch) ([]byte, error) {
 	if filters.StartTimestamp < 0 {
-		return nil, nil, nil, errNotSupported
+		return nil, errNotSupported
 	}
 
 	var objectData []byte
@@ -117,10 +129,36 @@ func (s *Searcher) SearchProviderEvents(filters *eventsearcher.ProviderEventSear
 
 	data, err := json.Marshal(results)
 	if err != nil {
-		return nil, nil, nil, err
+		return nil, err
+	}
+
+	return data, nil
+}
+
+func (s *Searcher) SearchLogEvents(filters *eventsearcher.LogEventSearch) ([]byte, error) {
+	if filters.StartTimestamp < 0 {
+		return nil, errNotSupported
+	}
+
+	results := []logEvent{
+		{
+			ID:         "1",
+			Timestamp:  100,
+			Event:      1,
+			Protocol:   "SSH",
+			IP:         "127.0.1.1",
+			Message:    "Invalid credentials",
+			Role:       "role3",
+			InstanceID: "instance2",
+		},
+	}
+
+	data, err := json.Marshal(results)
+	if err != nil {
+		return nil, err
 	}
 
-	return data, nil, nil, nil
+	return data, nil
 }
 
 func main() {

+ 3 - 3
tests/ipfilter/go.mod

@@ -16,10 +16,10 @@ require (
 	github.com/mattn/go-isatty v0.0.18 // indirect
 	github.com/mitchellh/go-testing-interface v1.14.1 // indirect
 	github.com/oklog/run v1.1.0 // indirect
-	golang.org/x/net v0.9.0 // indirect
-	golang.org/x/sys v0.7.0 // indirect
+	golang.org/x/net v0.10.0 // indirect
+	golang.org/x/sys v0.8.0 // indirect
 	golang.org/x/text v0.9.0 // indirect
 	google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
-	google.golang.org/grpc v1.54.0 // indirect
+	google.golang.org/grpc v1.55.0 // indirect
 	google.golang.org/protobuf v1.30.0 // indirect
 )

+ 6 - 6
tests/ipfilter/go.sum

@@ -36,8 +36,8 @@ github.com/sftpgo/sdk v0.1.3/go.mod h1:gDxDaU3rhp9Y92ddsE7SbQ8jdBNNWK1DKlp5eHXrs
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
 github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
-golang.org/x/net v0.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/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -45,15 +45,15 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
-golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A=
 google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
-google.golang.org/grpc v1.54.0 h1:EhTqbhiYeixwWQtAEZAxmV9MGqcjEU2mFx52xCzNyag=
-google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g=
+google.golang.org/grpc v1.55.0 h1:3Oj82/tFSCeUrRTg/5E/7d/W5A1tj6Ky1ABAuZuv5ag=
+google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=