90 Commits

Author SHA1 Message Date
c4852c3540 Merge pull request 'v0.7.0' (#7) from develop into main
Reviewed-on: #7
2026-02-28 21:45:11 +05:00
b884494250 Update CHANGELOG.md with 0.7.0 release date 2026-02-28 21:36:43 +05:00
598d83d6da Update CHANGELOG.md to include ban clear command information 2026-02-28 21:06:03 +05:00
f737edc3ce Add ban_clear command to unban all blocked IPs via the daemon
- Introduced `ban_clear` CLI command for clearing all banned IPs.
- Implemented `UnblockAllIPs` in the firewall for IP unblocking.
- Added internationalization messages for `ban_clear` actions.
2026-02-28 21:02:43 +05:00
dc85bc759a Add DeleteIP method to remove IPs from firewall lists
- Implemented `DeleteIP` to remove IPv4 and IPv6 addresses.
2026-02-28 21:02:12 +05:00
93b2927da7 Add DeleteElement method to manage firewall list elements
- Implemented `DeleteElement` to remove elements from the list.
- Updated `AddElement` to improve command construction with family inclusion.
2026-02-28 21:01:53 +05:00
afb0773dfd Update CHANGELOG.md with password brute force protection details and link to full settings configuration 2026-02-28 20:23:45 +05:00
187c447301 Reorganize "Plans" section in README files for clarity 2026-02-28 20:19:57 +05:00
3ec6b4c72d Expand brute force protection configuration with groups and rules
- Added support for defining groups with rate limits (`bruteForceProtection.groups`).
- Introduced rule configuration for log monitoring and grouping (`bruteForceProtection.rules`).
- Enhanced `analyzer.toml` with detailed examples and documentation for new settings.
2026-02-28 20:14:44 +05:00
b63e3adbd3 Add support for brute force protection rules and groups
- Introduced `BruteForceProtectionGroup` for managing rate limits and validation.
- Added `BruteForceProtectionRule` for defining protection rules with patterns.
- Updated `BruteForceProtection` to include groups and rules.
- Enhanced `ToSources` to process rules and associate them with defined groups.
2026-02-28 20:14:11 +05:00
aa519c8b44 Remove debug print statement from IP blocking logic 2026-02-28 18:47:16 +05:00
8329da32e3 Add reloadBlockList method to handle block list reloading in the firewall
- Implements `reloadBlockList` to reload IP block lists via `NftReload`.
- Links block lists to chains using `AddRuleToChain`.
2026-02-28 17:50:17 +05:00
833bc394b3 Pass logger to blocking.New in server initialization for improved logging and debugging. 2026-02-28 17:50:01 +05:00
e422bc4206 Add blocking package for IP blocking management
- Introduced `blocking` implementation with methods for managing blocked IPs.
- Added `NftReload` for reloading block lists into the firewall.
- Created `BlockIP` to block specific IPs with expiration and logging support.
- Implemented `ClearDBData` for clearing database blocking entries.
2026-02-28 17:49:39 +05:00
47aa0a9d6c Update BlockIP to return banning status (bool, error) for improved tracking and logging 2026-02-28 17:49:13 +05:00
58dbee450a Refactor BruteForceProtection to improve error handling and notification clarity
- Updated `BlockIPFunc` to return `(bool, error)` for better banning status tracking.
- Enhanced notification messages to include errors and blocked time (`blockSec`).
- Simplified `sendNotifyError` logic by embedding error context in `bruteForceProtectionNotify`.
2026-02-28 17:48:51 +05:00
68034fd6f9 Add before-local-input and after-local-input chains with block list API
- Introduced `NewBeforeLocalInput` and `NewAfterLocalInput` chain methods.
- Added `NewBlockListIP` for creating IP block lists linked to chains.
2026-02-28 17:48:12 +05:00
7b77b8730e Add ListIP interface and implementation for IP block list management in the firewall
- Implemented `NewListIP` for creating IPv4 and IPv6 block lists with timeout support.
- Added methods `AddIP` to add IPs to the lists and `AddRuleToChain` to link block lists to firewall chains.
2026-02-28 17:47:31 +05:00
187e874c29 Add List interface and implementation for managing block lists in the firewall 2026-02-28 17:47:05 +05:00
ee5a6a2d3d Add Reason field to Blocking entity 2026-02-28 17:46:35 +05:00
38283247e9 Simplify List method in BlockingRepository by removing unnecessary bucket creation logic 2026-02-28 17:46:24 +05:00
79c7ef1f91 Add before-local-input and after-local-input chains to enhance firewall rule management
- Implemented `BeforeLocalInput` and `AfterLocalInput` chain structures with rule management methods.
- Integrated chains into `reloadInput` logic for improved rule execution ordering.
2026-02-28 17:43:32 +05:00
e29d0de632 Add blockSec localization and integrate blocked time into notifications
- Added `blockSec` message to English, Kazakh, and Russian locale files.
- Extended `BruteForceProtection` logic to include blocked time (`blockSec`) in notifications.
2026-02-28 12:45:51 +05:00
be082a1841 Fix IP address handling in BruteForceProtectionGroup to prioritize IPv4 over IPv6 2026-02-28 12:13:03 +05:00
4b364cbdf0 Extend daemon stop logic to clear firewall data during testing interval expiration 2026-02-28 11:45:51 +05:00
dfa23bc7a6 Add ClearDBData method to firewall and integrate block list reload logic 2026-02-28 11:45:32 +05:00
3a34569e78 Add Clear method to BlockingRepository and implement bucket reset logic
- Introduced `Clear` to reset the database bucket for the `BlockingRepository`.
- Handled `ErrBucketNotFound` error to allow safe bucket recreation during cleanup.
2026-02-28 11:44:58 +05:00
b1f5ce4e9b Add ClearDBData method to Group interface and implement it in group 2026-02-28 11:44:30 +05:00
f2d851baa7 Add ClearDBData method to Group interface and implement it in group 2026-02-28 11:44:27 +05:00
2a617b5c17 Invoke ClearDBData in daemon stop logic to clean up analyzer data 2026-02-28 11:37:55 +05:00
a648647e4a Add ClearDBData functionality for analysis and repository components
- Introduced `ClearDBData` methods in `Analysis`, `Alert`, and `BruteForceProtection` components.
- Implemented `Clear` operations for `AlertGroupRepository` and `BruteForceProtectionGroupRepository` to reset database buckets.
- Updated `Analyzer` to invoke `ClearDBData` for cleanup logic.
2026-02-28 11:37:25 +05:00
6b482a350b Simplify error handling logic in DBQueueClear during testing interval expiration. 2026-02-28 11:05:56 +05:00
097cf362e3 Add brute force protection core logic and SSH-specific rules
- Integrated brute force protection mechanisms into the analyzer.
- Added `BruteForceProtection` and `BruteForceProtectionGroup` structures with rate-limiting and group-based blocking logic.
- Implemented IP blocking via the firewall service.
- Introduced SSH brute force detection rules and notifications for detected attempts.
- Updated analyzer and firewall services to handle brute force protection rules.
- Localized new brute force protection alert messages.
2026-02-26 00:01:06 +05:00
bf7d463930 Expand analyzer.toml with brute force protection settings
- Added configuration options for password brute-force protection, including rate limits, blocking duration, and SSH-specific settings.
- Included detailed parameter descriptions and default values to enhance setup clarity.
2026-02-25 23:58:12 +05:00
b49889ef58 Add brute force protection to analyzer settings
- Introduced `BruteForceProtection` structure with validation and default settings.
- Integrated brute force protection logic into `Setting` methods for initialization, validation, and source generation.
- Added group-based brute force rate-limiting functionality with `_default` group included.
2026-02-25 23:57:33 +05:00
fd899087d4 Introduce Blocking and BruteForceProtectionGroup entities and repositories
- Added `Blocking` and `BruteForceProtectionGroup` entities with associated logic.
- Implemented `BlockingRepository` for IP blocking management with add, list, and delete-expired functionalities.
- Introduced `BruteForceProtectionGroupRepository` for managing brute force protection groups.
- Updated `Repositories` to include new repositories with `app.db` and `security.db`.
2026-02-25 23:54:38 +05:00
8f254d11c1 Fix incorrect condition for empty rate limits in AlertGroup 2026-02-24 23:05:40 +05:00
2e08bf6b6a Improve error message for empty rate limits in alert_group configuration 2026-02-24 23:01:39 +05:00
036f037a30 Extract LazyRegexp into a reusable package and update references 2026-02-23 22:45:29 +05:00
c7f25b4ba8 Rename addRule to addAlertRule for improved clarity and consistency 2026-02-23 22:42:38 +05:00
623d626878 Add event tracking customization and new parameters to analyzer settings
- Enabled customization of log event tracking with examples.
- Added new `logAlert.groups` and `logAlert.rules` configurations in `analyzer.toml`.
2026-02-16 23:13:08 +05:00
e1bace602c Expand analyzer configuration with group-based alerting options
- Added detailed examples for group configuration and rate-limiting in `analyzer.toml`.
- Introduced optional `group` field in alert rule definitions for group associations.
2026-02-16 22:47:33 +05:00
e85fd785cd Integrate advanced alert grouping functionality
- Introduced `AlertGroup` structure for advanced rate-limiting and reset logic.
- Added support for nested rate-limit configuration with `RateLimit` structure.
- Implemented `alert_group.Group` service to facilitate alert group analysis and persistence.
- Integrated alert group logic into the analyzer configuration and runtime processing pipeline.
- Updated `LogAlertRule` to support group associations and validations.
- Enhanced repository structure with `AlertGroupRepository` for persistent alert group management.
2026-02-16 22:26:33 +05:00
c6841d14f3 Introduce AlertGroup entity and repository
- Added `AlertGroup` structure with reset functionality.
- Implemented `AlertGroupRepository` to manage alert group persistence using BoltDB.
- Integrated `AlertGroupRepository` into the `Repositories` interface and factory setup.
2026-02-16 22:24:51 +05:00
57b80da767 Rename notificationsQueue constant to notificationsQueueBucket for clarity 2026-02-14 23:51:12 +05:00
696961f7c0 Update third-party license file with entries for github.com/nxadm/tail and gopkg.in/tomb.v1 2026-02-14 02:11:47 +05:00
af082f36da Fix log formatting issue in file_monitoring.Logger.Fatalf 2026-02-14 02:06:13 +05:00
a889e5c81a Bump dependencies to latest versions for improved stability and performance 2026-02-14 02:05:10 +05:00
99e155fe10 Introduce file-based log monitoring support in analyzer
- Added `FileMonitoring` implementation for tailing log files.
- Integrated file monitoring service into `Analyzer`.
- Introduced `file_monitoring.Logger` for consistent log handling.
- Updated `Analyzer` to handle both `systemd` and file-based logs.
2026-02-14 01:56:11 +05:00
2fffe45a89 Add dependencies for file-based log monitoring (github.com/nxadm/tail, gopkg.in/tomb.v1) 2026-02-14 01:54:23 +05:00
ff0317ed0b Handle context cancellation in systemd log analyzer to prevent log channel blocking 2026-02-14 01:43:10 +05:00
0b627a283d Refactor and replace AlertRuleIndex with RulesIndex for improved rule handling
- Replaced `AlertRuleIndex` with the more robust `RulesIndex` structure.
- Introduced `RulesBucket` for efficient rule grouping and management.
- Added support for `file` source type in `RulesIndex`.
- Updated `Analyzer` and associated services to utilize `RulesIndex` for rule processing.
2026-02-11 23:48:56 +05:00
2b8a3e0d98 Add file source support in analyzer configuration
- Extended `Source` structure with a `Path` field for file sources.
- Added support for `file` source type in `ToSource` method.
- Implemented logic to handle `file` paths and integrate with `SourceFile`.
2026-02-11 23:47:30 +05:00
c09bf01de1 Add SourceFile support for file-based log monitoring
- Introduced `SourceFile` structure for file sources in analyzer configuration.
- Added `NewSourceFile` constructor with path validation logic.
- Updated `Source` to support both `SourceJournal` and `SourceFile` types.
2026-02-11 23:46:42 +05:00
627b70e024 Ensure unique journalctl match entries in analyzer configuration 2026-02-11 21:36:15 +05:00
660e1fcebd Add detailed log monitoring settings for LogAlert in analyzer.toml configuration file
- Introduced a dedicated section for configuring `LogAlert` rules and patterns.
- Added examples and documentation to guide users on setting up log analysis rules and notifications.
2026-02-10 22:54:10 +05:00
c9093f8244 Add LogAlert support for log analysis with configurable rules and patterns
- Introduced `LogAlert` feature to `Setting` for flexible log monitoring.
- Implemented `LogAlertRule`, `LogAlertPattern`, `PatternValue`, and `Source` structures for robust rule and pattern configurations.
- Enhanced validation and transformation logic for sources, patterns, and rules.
- Integrated `LogAlert` into log source generation and default settings.
2026-02-10 22:53:43 +05:00
8985ff884d Handle missing regex match values in log analysis by defaulting to "unknown" in supported locales 2026-02-10 22:52:22 +05:00
c7dadb3684 Refactor login analyzers to improve error handling and validation logic for journal sources 2026-02-09 23:38:45 +05:00
d5e92b70ef Ensure alert rules are only added when AlertRule is not nil 2026-02-09 22:46:02 +05:00
3bbedc5088 Merge pull request 'v0.6.0' (#6) from develop into main
Reviewed-on: #6
2026-02-08 15:06:31 +05:00
960494eec0 Add journalctl as a prerequisite in README files 2026-02-08 15:05:07 +05:00
98a62b4551 Update CHANGELOG.md with 0.6.0 release date 2026-02-08 14:57:18 +05:00
0fa8d88479 Update third-party license file to add go.etcd.io/bbolt and fix minor formatting inconsistencies 2026-02-08 14:55:27 +05:00
9eef81d1a5 Clarify test period description to include data clearing steps at end 2026-02-08 14:50:17 +05:00
6821924c8e Added clearing of queues from the database at the end of the test period 2026-02-08 14:48:05 +05:00
f0958a340f Refactor log analysis to support dynamic alert rules through a centralized rule index, replacing hardcoded login-specific logic. 2026-02-08 14:40:36 +05:00
d9a40c620c Update CHANGELOG.md with notification queue clear command details 2026-01-28 22:11:21 +05:00
fd764fb5c5 Add support for clearing the notification queue via new daemon command and DB layer 2026-01-28 22:09:29 +05:00
d6af8a7ea5 Update CHANGELOG.md with notification queue count command details 2026-01-28 21:44:51 +05:00
f0d5b597cb Add support for retrieving notification queue size via new daemon command and DB layer 2026-01-28 21:40:04 +05:00
81a28bf485 Update CHANGELOG.md with 0.6.0 changes: add notification retry support and new configuration options 2026-01-28 21:23:41 +05:00
0fb8c0b42d Add notifications retry mechanism with configurable interval and queue handling 2026-01-28 21:22:45 +05:00
6b79928b3a Add DB layer for managing notifications queue 2026-01-28 21:20:19 +05:00
9a0cf7bd8a Merge pull request 'v0.5.0' (#5) from develop into main
Reviewed-on: #5
2026-01-17 20:25:14 +05:00
b938b73cfd Update CHANGELOG.md with 0.5.0 release date 2026-01-17 20:15:43 +05:00
ce031be060 Update CHANGELOG.md with sudo login tracking and notification details 2026-01-15 00:31:44 +05:00
5e50bc179f Add sudo command login tracking and notification support 2026-01-15 00:28:11 +05:00
279f58b644 Update CHANGELOG.md with su login tracking and notification details 2026-01-14 23:27:52 +05:00
26365a519b Add su command login tracking and notification support 2026-01-14 23:25:16 +05:00
d1f307d2ad Update CHANGELOG.md with 0.5.0 changes: add local login tracking and notifications 2026-01-14 21:51:55 +05:00
ccf228242d Add TTY login tracking with notification support 2026-01-14 21:51:20 +05:00
5e12b1f6ab Refactor: Rename SSH alert keys for clarity and update relevant usages 2026-01-13 22:09:42 +05:00
67abcc0ef2 Refactor: Rename processLogin to process in SSH analyzer for consistency 2026-01-13 00:27:11 +05:00
5ad40cdf9b Refactor: Rename process to processLogin in SSH analyzer for clarity 2026-01-13 00:24:07 +05:00
374abcea80 Refactor: Consolidate sshProcessReturn into generic processReturn for improved reusability 2026-01-13 00:18:55 +05:00
4748630b04 Merge pull request 'v0.4.0' (#4) from develop into main
Reviewed-on: #4
2026-01-11 17:01:42 +05:00
bbaf0304c3 Merge pull request 'v0.3.0' (#3) from develop into main
Reviewed-on: #3
2026-01-04 17:09:39 +05:00
69157c90cb Merge pull request 'v0.2.0' (#2) from develop into main
Reviewed-on: #2
2025-11-29 16:12:03 +05:00
e76d2ae398 Merge pull request 'v0.1.0' (#1) from develop into main
Reviewed-on: #1
2025-11-08 17:34:06 +05:00
78 changed files with 4511 additions and 228 deletions

View File

@@ -1,3 +1,63 @@
## 0.7.0 (8.2.2026)
***
#### Русский
* Добавлена возможность настройки отслеживания событий в журналах.
* Добавлены настройки для защиты от перебора паролей.
* В файл настроек analyzer.toml добавлены новые параметры. Смотрите полный список по ссылке https://git.kor-elf.net/kor-elf-shield/kor-elf-shield/src/commit/187c447301b9c0bfa41ec2b2c9435ab0ce44bed6/assets/configs/analyzer.toml
* Добавлена команда `kor-elf-shield ban clear`, которая разблокирует все IP адреса. Которые были забанены.
***
#### English
* Added the ability to customize event tracking in logs.
* Added settings to protect against password guessing.
* New parameters have been added to the analyzer.toml settings file. See the full list at https://git.kor-elf.net/kor-elf-shield/kor-elf-shield/src/commit/187c447301b9c0bfa41ec2b2c9435ab0ce44bed6/assets/configs/analyzer.toml
* Added the `kor-elf-shield ban clear` command, which unbans all banned IP addresses.
***
## 0.6.0 (8.2.2026)
***
#### Русский
* Добавлена возможность повторной отправки уведомления, если в прошлый раз произошла ошибка.
* Добавлена команда `kor-elf-shield notifications queue count`, которая возвращает количество уведомлений в очереди в базе данных.
* Добавлена команда `kor-elf-shield notifications queue clear`, которая удаляет все уведомления из очереди в базе данных.
* В файл настроек kor-elf-shield.toml добавлены новые параметры:
* data_dir = Каталог для постоянных данных приложения (state): локальная база данных, кэш/индексы, файлы состояния и другие служебные файлы. Должен быть доступен на запись пользователю, от имени которого запущен демон. Если каталог не существует — будет создан. По умолчанию: "/var/lib/kor-elf-shield/"
* В файл настроек notifications.toml добавлены новые параметры:
* enable_retries = Включает повторные попытки отправить уведомление, если сразу не получилось. По умолчанию: true
* retry_interval = Интервал времени в секундах между попытками. По умолчанию: 600
***
#### English
* Added the ability to retry sending a notification if an error occurred the previous time.
* Added the `kor-elf-shield notifications queue count` command, which returns the number of notifications in the queue in the database.
* Added the `kor-elf-shield notifications queue clear` command, which removes all notifications from the queue in the database.
* New parameters have been added to the kor-elf-shield.toml settings file:
* data_dir = Directory for persistent application data (state): local database, cache/indexes, state files, and other internal data. Must be writable by the daemon user. If the directory does not exist, it will be created. Default: "/var/lib/kor-elf-shield/"
* New parameters have been added to the notifications.toml settings file:
* enable_retries = Enables repeated attempts to send a notification if the first attempt fails. Default: true
* retry_interval = The time interval in seconds between attempts. Default: 600
***
## 0.5.0 (17.1.2026)
***
#### Русский
* В настройках analyzer.toml добавил параметры local_enable и local_notify.
* local_enable = Включает отслеживание локальных авторизаций (TTY, физический доступ). По умолчанию включён.
* local_notify = Включает уведомления о локальных авторизациях. По умолчанию включён.
* В настройках analyzer.toml добавил параметры su_enable и su_notify.
* su_enable = Включает отслеживание авторизаций через su. По умолчанию включён.
* su_notify = Включает уведомления об авторизациях через su. По умолчанию включён.
* В настройках analyzer.toml добавил параметры sudo_enable и sudo_notify.
* sudo_enable = Включает отслеживание авторизаций через sudo. По умолчанию выключен.
* sudo_notify = Включает уведомления об авторизациях через sudo. По умолчанию включён.
***
#### English
* Added local_enable and local_notify parameters to analyzer.toml settings.
* local_enable = Enables tracking of local logins (TTY, physical access). Enabled by default.
* local_notify = Enables notifications about local logins. Enabled by default.
* Added su_enable and su_notify parameters to analyzer.toml settings.
* su_enable = Enables tracking of logins via su. Enabled by default.
* su_notify = Enables notifications about logins via su. Enabled by default.
* Added sudo_enable and sudo_notify parameters to analyzer.toml settings.
* sudo_enable = Enables tracking of logins via sudo. Off by default.
* sudo_notify = Enables notifications about logins via sudo. Enabled by default.
***
## 0.4.0 (11.1.2026)
***
#### Русский

View File

@@ -92,6 +92,32 @@ THE SOFTWARE.
--------------------------------------------------------------------------------
github.com/nxadm/tail
# The MIT License (MIT)
# © Copyright 2015 Hewlett Packard Enterprise Development LP
Copyright (c) 2014 ActiveState
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
--------------------------------------------------------------------------------
github.com/pelletier/go-toml/v2
The bulk of github.com/pelletier/go-toml is distributed under the MIT license
@@ -710,6 +736,31 @@ SOFTWARE.
--------------------------------------------------------------------------------
go.etcd.io/bbolt
The MIT License (MIT)
Copyright (c) 2013 Ben Johnson
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
go.uber.org/multierr
Copyright (c) 2017-2021 Uber Technologies, Inc.
@@ -773,13 +824,13 @@ starting in 2011 when the project was ported over:
- internal/libyaml/yaml.go
- internal/libyaml/yamlprivate.go
Copyright 2006-2011 - Kirill Simonov
Copyright 2006-2010 Kirill Simonov
https://opensource.org/license/mit
All the remaining project files are covered by the Apache license:
Copyright 2011-2019 - Canonical Ltd
Copyright 2025 - The go-yaml Project Contributors
Copyright 2011-2019 Canonical Ltd
Copyright 2025 The go-yaml Project Contributors
http://www.apache.org/licenses/LICENSE-2.0
--------------------------------------------------------------------------------
@@ -848,6 +899,40 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
gopkg.in/tomb.v1
tomb - support for clean goroutine termination in Go.
Copyright (c) 2010-2011 - Gustavo Niemeyer <gustavo@niemeyer.net>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
github.com/golang/go
Copyright 2009 The Go Authors.
@@ -879,4 +964,3 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------

View File

@@ -12,6 +12,7 @@
* Linux 5.2+
* nftables
* Systemd
* journalctl
***
@@ -26,9 +27,9 @@
* Подружить с docker (частично).
* Внедрить настройку уведомлений (пока только e-mail).
* Отправлять уведомления при авторизации ssh.
* Защита от перебора паролей (brute-force).
### В планах:
* Защита от перебора паролей (brute-force).
* Уведомлять, если появится новый пользователь в системе.
* Уведомлять, если изменились системные файлы.
***

View File

@@ -12,6 +12,7 @@
* Linux 5.2+
* nftables
* Systemd
* journalctl
***
@@ -26,9 +27,9 @@
* Make friends with docker (partially).
* Implement notification settings (for now only by e-mail).
* Send notifications during ssh authorization.
* Password brute-force protection.
### The plans include:
* Password brute-force protection.
* Notify if a new user appears in the system.
* Notify if system files have changed.
***

View File

@@ -1,3 +1,196 @@
###############################################################################
# РАЗДЕЛ:Защита от перебора пароля
# ***
# SECTION:Protection against password brute-force attacks
###############################################################################
[bruteForceProtection]
###
# Включает группу отслеживания перебора пароля.
# Если отключено, отслеживание перебора пароля работать не будет.
# По умолчанию: true
# ***
# Enables the password attack monitoring group.
# If disabled, password attack monitoring will not work.
# Default: true
###
enabled = true
###
# Включает уведомления об блокировок.
# Если отключено, они будут отображаться в логах только на уровне = "info".
# По умолчанию: true
# ***
# Enables notifications about blocks.
# If disabled, they will only appear in the logs under level = "info".
# Default: true
###
notify = true
###
# Максимальное количество ошибок, после которого произойдёт блокировка.
# По умолчанию: 5
# ***
# The maximum number of errors after which a blocking will occur.
# Default: 5
###
rate_limit_count = 5
###
# Насколько времени в секундах блокировать IP адрес.
# Если указать 0, то будет на всегда заблокирован.
# По умолчанию: 3600
# ***
# How long in seconds to block an IP address.
# If you specify 0, it will be blocked forever.
# Default: 3600
###
blocking_time = 3600
###
# Установите временной интервал для отслеживания сбоев входа в систему в течение секунд.
# По умолчанию: 3600
# ***
# Set the time interval to monitor login failures in seconds.
# Default: 3600
###
rate_limit_period = 3600
###
# Указываем в секундах, через какое время сбрасывать данные IP в групе _default если не было событий.
# Если указать 0, то не будет сбрасывать.
# По умолчанию: 86400
# ***
# Specify the number of seconds after which IP data in the _default group will be reset if there have been no events.
# If you specify 0, the reset will not occur.
# Default: 86400
###
rate_limit_reset_period = 86400
###
# Включает защиту от перебора пароля от ssh.
# По умолчанию: true
# ***
# Enables protection against brute-force attacks against ssh.
# Default: true
###
ssh_enable = true
###
# Включает уведомления об блокировках, когда срабатывает защита от перебора пароля.
# Если отключено, они будут отображаться в логах только на уровне = "info".
# По умолчанию: true
# ***
# Enables block notifications when password brute-force protection is triggered.
# If disabled, they will only appear in the logs under level = "info".
# Default: true
###
ssh_notify = true
###
# Можно указать свою группу, чтобы по связать с другими правилами.
# По умолчанию: ""
# ***
# You can specify your own group to link it to other rules.
# Default: ""
###
ssh_group = ""
###
# Указываем настройки группы.
# Примеры:
# [[bruteForceProtection.groups]]
# name = "my_name_group" # Имя группы. Разрешены символы "a-z, A-Z, -, _". Первый символ обязательно буква (обязательное поле)
# message = "Любой текст группы" # Текст уведомления (обязательное поле)
# rate_limit_reset_period = 86400 # Указываем в секундах, через какое время сбрасывать данные в групе если не было событий. Если указать 0, то не будет сбрасывать.
# [[bruteForceProtection.groups.rate_limits]]
## Через сколько будет срабатывать блокировка. В данном случае в течение часа, если было 5 обнаружений, то сработает блокировка.
## И заблокирует на 10 минут.
# count = 5
# period = 3600
# blocking_time = 600
# [[bruteForceProtection.groups.rate_limits]]
## После срабатывания блокировки, переходим на второй уровень, тепер если в течение часа было 3 обнаружений, то сработает блокировка.
## И теперь заблокирует на час.
# count = 3
# period = 3600
# blocking_time = 3600
# [[bruteForceProtection.groups.rate_limits]]
## И таких уровней можно указыват сколько захотите.
# count = 2
# period = 600
# blocking_time = 3600
#
# ***
# Specify group settings.
# Examples:
# [[bruteForceProtection.groups]]
# name = "my_name_group" # Group name. Allowed characters are "a-z, A-Z, -, _". The first character must be a letter (required)
# message = "Any group text" # Notification text (required)
# rate_limit_reset_period = 86400 # Specify, in seconds, how long to reset group data if there have been no events. Specifying 0 means no reset.
# [[bruteForceProtection.groups.rate_limits]]
## How long will it take for the block to be triggered? In this case, if there were 5 detections within an hour, the block will be triggered.
## And it will block for 10 minutes.
# count = 5
# period = 3600
# blocking_time = 600
# [[bruteForceProtection.groups.rate_limits]]
## After the blocking is triggered, we move to the second level. Now, if there are three detections within an hour, the blocking will be triggered.
## And now it will block for an hour.
# count = 3
# period = 3600
# blocking_time = 3600
# [[bruteForceProtection.groups.rate_limits]]
## You can specify as many of these levels as you like.
# count = 2
# period = 600
# blocking_time = 3600
###
###
# Указываем настройки логов, которые надо отслеживать для защиты от перебора пароля.
# Примеры:
# [[bruteForceProtection.rules]]
# enabled = true # Включает или выключает отслеживания (обязательное поле)
# notify = true # Включает или выключает уведомления (обязательное поле)
# name = "my_name_rule" # Имя уведомления. Разрешены символы "a-z, A-Z, -, _". Первый символ обязательно буква (обязательное поле)
# message = "Ваш любой текст для уведомления" # Текст уведомления (обязательное поле)
# group = "my_name_group" # Можно указать имя группы (не обязательное поле)
# [bruteForceProtection.rules.source]
# type = "journalctl" # journalctl или file (обязательное поле)
# field = "systemd_unit" # systemd_unit или syslog_identifier (обязательное поле если type = "journalctl")
# match = "ssh.service" # Значение (обязательное поле если type = "journalctl")
# если field = "systemd_unit", то match должен заканичваться: .service, .socket, .target, .mount, .timer, .path, .scope, .slice, .device
# [[bruteForceProtection.rules.patterns]]
# regexp = '^Failed password for (\S+) from (\S+) port \S+'
# ip = 2 # Указываем номер value, который укажет IP (обязательное поле)
# [[bruteForceProtection.rules.patterns.values]]
# name = "Пользователь"
# value = 1
#
# ***
# Specify the log settings that need to be monitored to protect against password brute-force attacks.
# Examples:
# [[bruteForceProtection.rules]]
# enabled = true # Enables or disables tracking (required)
# notify = true # Enables or disables notifications (required)
# name = "my_name_rule" # Notification name. Allowed characters are "a-z, A-Z, -, _". The first character must be a letter (required field)
# message = "Your any text for notification" # Notification text (required field)
# group = "my_name_group" # You can specify the group name (optional field)
# [bruteForceProtection.rules.source]
# type = "journalctl" # journalctl or file (required)
# field = "systemd_unit" # systemd_unit or syslog_identifier (required if type = "journalctl")
# match = "ssh.service" # Value (required if type = "journalctl")
# If field = "systemd_unit", then match must end with: .service, .socket, .target, .mount, .timer, .path, .scope, .slice, .device
# [[bruteForceProtection.rules.patterns]]
# regexp = '^Accepted (\S+) for (\S+) from (\S+) port \S+'
# [[bruteForceProtection.rules.patterns]]
# regexp = '^Failed password for (\S+) from (\S+) port \S+'
# ip = 2 # We indicate the value number that will indicate the IP (required field)
# [[bruteForceProtection.rules.patterns.values]]
# name = "User"
# value = 1
###
###############################################################################
# РАЗДЕЛ:Отслеживать авторизаций
# ***
@@ -45,3 +238,181 @@ ssh_enable = true
# Default: true
###
ssh_notify = true
###
# Включает отслеживание локальных авторизаций (TTY, физический доступ).
# По умолчанию: true
# ***
# Enables tracking of local authorizations (TTY, physical access).
# Default: true
###
local_enable = true
###
# Включает уведомления о локальных авторизациях.
# По умолчанию: true
# ***
# Enables local authorization notifications.
# Default: true
###
local_notify = true
###
# Включает отслеживание, если кто-либо использует команду `su` для доступа к другой учетной записи.
# По умолчанию: true
# ***
# Enables tracking if someone uses the `su` command to access another account.
# Default: true
###
su_enable = true
###
# Включает уведомления, если кто-либо использует команду `su` для доступа к другой учетной записи.
# По умолчанию: true
# ***
# Enables notifications if someone uses the `su` command to access another account.
# Default: true
###
su_notify = true
###
# Включает отслеживание, если кто-либо использует команду `sudo` для доступа к другой учетной записи.
#
# ПРИМЕЧАНИЕ: Эта опция может стать обременительной, если команда sudo широко используется
# для получения root-доступа администраторами или панелями управления.
#
# По умолчанию: false
# ***
# Enables tracking if someone uses the `sudo` command to access another account.
#
# NOTE: This option could become onerous if sudo is used extensively for root
# access by administrators or control panels.
#
# Default: false
###
sudo_enable = false
###
# Включает уведомления, если кто-либо использует команду `sudo` для доступа к другой учетной записи.
# По умолчанию: true
# ***
# Enables notifications if someone uses the `sudo` command to access another account.
# Default: true
###
sudo_notify = true
###############################################################################
# РАЗДЕЛ:Настройки анализа логов для уведомления
# ***
# SECTION:Log analysis settings for notifications
###############################################################################
[logAlert]
###
# Включает группу отслеживания логов для оповещения.
# Если отключено, отслеживание логов для оповещения работать не будет.
# По умолчанию: true
# ***
# Enables the log monitoring group for alerts.
# If disabled, log monitoring for alerts will not work.
# Default: true
###
enabled = true
###
# Включает уведомления.
# Если отключено, они будут отображаться в логах только на уровне = "info".
# По умолчанию: true
# ***
# Enables notifications.
# If disabled, they will only appear in the logs under level = "info".
# Default: true
###
notify = true
###
# Указываем настройки группы.
# Примеры:
# [[logAlert.groups]]
# name = "my_name_group" # Имя группы. Разрешены символы "a-z, A-Z, -, _". Первый символ обязательно буква (обязательное поле)
# message = "Любой текст группы" # Текст уведомления (обязательное поле)
# rate_limit_reset_period = 86400 # Указываем в секундах, через какое время сбрасывать данные в групе если не было событий. Если указать 0, то не будет сбрасывать.
# [[logAlert.groups.rate_limits]]
## Через сколько будет срабатывать оповещение. В данном случае в течение часа, если было 5 обнаружений, то сработает оповещение.
# count = 5
# period = 3600
# [[logAlert.groups.rate_limits]]
## После срабатывания оповещения, переходим на второй уровень, тепер если в течение часа было 3 обнаружений, то сработает оповещение.
# count = 3
# period = 3600
# [[logAlert.groups.rate_limits]]
## И таких уровней можно указыват сколько захотите.
# count = 2
# period = 600
#
# ***
# Specify group settings.
# Examples:
# [[logAlert.groups]]
# name = "my_name_group" # Group name. Allowed characters are "a-z, A-Z, -, _". The first character must be a letter (required)
# message = "Any group text" # Notification text (required)
# rate_limit_reset_period = 86400 # Specify, in seconds, how long to reset group data if there have been no events. Specifying 0 means no reset.
# [[logAlert.groups.rate_limits]]
## How long to wait before an alert is triggered. In this case, if there were 5 detections within an hour, the alert will be triggered. # count = 5
# count = 5
# period = 3600
# [[logAlert.groups.rate_limits]]
## After the alert is triggered, we move to the second level. Now, if there are 3 detections within an hour, the alert will be triggered.
# count = 3
# period = 3600
# [[logAlert.groups.rate_limits]]
## You can specify as many of these levels as you like.
# count = 2
# period = 600
###
###
# Указываем настройки логов, которые надо отслеживать для оповещения.
# Примеры:
# [[logAlert.rules]]
# enabled = true # Включает или выключает отслеживания (обязательное поле)
# notify = true # Включает или выключает уведомления (обязательное поле)
# name = "my_name_rule" # Имя уведомления. Разрешены символы "a-z, A-Z, -, _". Первый символ обязательно буква (обязательное поле)
# message = "Ваш любой текст для уведомления" # Текст уведомления (обязательное поле)
# group = "my_name_group" # Можно указать имя группы (не обязательное поле)
# [logAlert.rules.source]
# type = "journalctl" # journalctl или file (обязательное поле)
# field = "systemd_unit" # systemd_unit или syslog_identifier (обязательное поле если type = "journalctl")
# match = "ssh.service" # Значение (обязательное поле если type = "journalctl")
# если field = "systemd_unit", то match должен заканичваться: .service, .socket, .target, .mount, .timer, .path, .scope, .slice, .device
# [[logAlert.rules.patterns]]
# regexp = '^Accepted (\S+) for (\S+) from (\S+) port \S+'
# [[logAlert.rules.patterns.values]]
# name = "Пользователь"
# value = 2
# [[logAlert.rules.patterns.values]]
# name = "IP"
# value = 3
#
# ***
# Specify the log settings to monitor for notifications.
# Examples:
# [[logAlert.rules]]
# enabled = true # Enables or disables tracking (required)
# notify = true # Enables or disables notifications (required)
# name = "my_name_rule" # Notification name. Allowed characters are "a-z, A-Z, -, _". The first character must be a letter (required field)
# message = "Your any text for notification" # Notification text (required field)
# group = "my_name_group" # You can specify the group name (optional field)
# [logAlert.rules.source]
# type = "journalctl" # journalctl or file (required)
# field = "systemd_unit" # systemd_unit or syslog_identifier (required if type = "journalctl")
# match = "ssh.service" # Value (required if type = "journalctl")
# If field = "systemd_unit", then match must end with: .service, .socket, .target, .mount, .timer, .path, .scope, .slice, .device
# [[logAlert.rules.patterns]]
# regexp = '^Accepted (\S+) for (\S+) from (\S+) port \S+'
# [[logAlert.rules.patterns.values]]
# name = "User"
# value = 2
# [[logAlert.rules.patterns.values]]
# name = "IP"
# value = 3
###

View File

@@ -22,13 +22,13 @@
testing = true
###
# Тестовый период, по истечении которого брандмауэр удалит правила и демон завершит работу.
# Тестовый период, по истечении которого брандмауэр удалит правила, очистит другие данные и демон завершит работу.
# Период указывается в минутах.
# Мин: 1
# Макс: 30000
# По умолчанию: 5
# ***
# The test period after which the firewall will clear the rules and the daemon will shut down.
# A test period after which the firewall will remove rules, clear other data, and the daemon will exit.
# The period is specified in minutes.
# Min: 1
# Max: 30000
@@ -76,6 +76,18 @@ pid_file = "/var/run/kor-elf-shield/kor-elf-shield.pid"
###
socket_file = "/var/run/kor-elf-shield/kor-elf-shield.sock"
###
# Каталог для постоянных данных приложения (state): локальная база данных, кэш/индексы, файлы состояния
# и другие служебные файлы. Должен быть доступен на запись пользователю, от имени которого запущен демон.
# Если каталог не существует — будет создан.
# По умолчанию: "/var/lib/kor-elf-shield/"
# ***
# Directory for persistent application data (state): local database, cache/indexes, state files, and other
# internal data. Must be writable by the daemon user. If the directory does not exist, it will be created.
# Default: "/var/lib/kor-elf-shield/"
###
data_dir = "/var/lib/kor-elf-shield/"
###############################################################################
# РАЗДЕЛ:Log
# ***

View File

@@ -21,6 +21,35 @@
###
enabled = false
###
# Включает повторные попытки отправить уведомление, если сразу не получилось.
# false = Выключает.
# true = Включает.
#
# По умолчанию: true
# ***
# Enables repeated attempts to send a notification if the first attempt fails.
# false = Disables.
# true = Enables.
#
# Default: true
###
enable_retries = true
###
# Интервал времени в секундах между попытками.
#
# По умолчанию: 600
# ***
# The time interval in seconds between attempts.
#
# Default: 600
###
retry_interval = 600
###
# Название сервера в уведомлениях
# По умолчанию: server

17
go.mod
View File

@@ -4,25 +4,28 @@ go 1.25
require (
git.kor-elf.net/kor-elf-shield/go-nftables-client v0.1.1
github.com/nicksnyder/go-i18n/v2 v2.6.0
github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/nxadm/tail v1.4.11
github.com/spf13/viper v1.21.0
github.com/urfave/cli/v3 v3.4.1
github.com/urfave/cli/v3 v3.6.2
github.com/wneessen/go-mail v0.7.2
go.uber.org/zap v1.27.0
golang.org/x/sys v0.36.0
golang.org/x/text v0.29.0
go.etcd.io/bbolt v1.4.3
go.uber.org/zap v1.27.1
golang.org/x/sys v0.41.0
golang.org/x/text v0.34.0
)
require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
)

26
go.sum
View File

@@ -2,14 +2,18 @@ git.kor-elf.net/kor-elf-shield/go-nftables-client v0.1.1 h1:3oGtZ/r1YAdlvI16OkZS
git.kor-elf.net/kor-elf-shield/go-nftables-client v0.1.1/go.mod h1:a7F+XdL1pK5P3ucQRR2EK/fABAP37LLBENiA4hX7L6A=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -18,6 +22,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ=
github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -40,22 +48,40 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM=
github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8=
github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,53 @@
package daemon
import (
"context"
"errors"
"fmt"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/i18n"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/setting"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/socket"
"github.com/urfave/cli/v3"
)
func CmdBan() *cli.Command {
return &cli.Command{
Name: "ban",
Usage: i18n.Lang.T("cmd.daemon.ban.Usage"),
Commands: []*cli.Command{
{
Name: "clear",
Usage: i18n.Lang.T("cmd.daemon.ban.clear.Usage"),
Description: i18n.Lang.T("cmd.daemon.ban.clear.Description"),
Action: CmdBanClear,
},
},
}
}
func CmdBanClear(_ context.Context, _ *cli.Command) error {
if setting.Config.SocketFile == "" {
return errors.New(i18n.Lang.T("socket file is not specified"))
}
sock, err := socket.NewSocketClient(setting.Config.SocketFile)
if err != nil {
return errors.New(i18n.Lang.T("daemon is not running"))
}
defer func() {
_ = sock.Close()
}()
result, err := sock.Send("ban_clear")
if err != nil {
return err
}
if result != "ok" {
return errors.New(i18n.Lang.T("ban_clear_error"))
}
fmt.Println(i18n.Lang.T("ban_clear_success"))
return nil
}

View File

@@ -0,0 +1,89 @@
package daemon
import (
"context"
"errors"
"fmt"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/i18n"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/setting"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/socket"
"github.com/urfave/cli/v3"
)
func CmdNotifications() *cli.Command {
return &cli.Command{
Name: "notifications",
Usage: i18n.Lang.T("cmd.daemon.notifications.Usage"),
Commands: []*cli.Command{
{
Name: "queue",
Usage: i18n.Lang.T("cmd.daemon.notifications.queue.Usage"),
Commands: []*cli.Command{
{
Name: "count",
Usage: i18n.Lang.T("cmd.daemon.notifications.queue.count.Usage"),
Description: i18n.Lang.T("cmd.daemon.notifications.queue.count.Description"),
Action: cmdNotificationsQueueCount,
},
{
Name: "clear",
Usage: i18n.Lang.T("cmd.daemon.notifications.queue.clear.Usage"),
Description: i18n.Lang.T("cmd.daemon.notifications.queue.clear.Description"),
Action: cmdNotificationsQueueClear,
},
},
},
},
}
}
func cmdNotificationsQueueCount(_ context.Context, _ *cli.Command) error {
if setting.Config.SocketFile == "" {
return errors.New(i18n.Lang.T("socket file is not specified"))
}
sock, err := socket.NewSocketClient(setting.Config.SocketFile)
if err != nil {
return errors.New(i18n.Lang.T("daemon is not running"))
}
defer func() {
_ = sock.Close()
}()
result, err := sock.Send("notifications_queue_count")
if err != nil {
return err
}
fmt.Println(i18n.Lang.T("cmd.daemon.notifications.queue.count.result", map[string]interface{}{
"Count": result,
}))
return nil
}
func cmdNotificationsQueueClear(_ context.Context, _ *cli.Command) error {
if setting.Config.SocketFile == "" {
return errors.New(i18n.Lang.T("socket file is not specified"))
}
sock, err := socket.NewSocketClient(setting.Config.SocketFile)
if err != nil {
return errors.New(i18n.Lang.T("daemon is not running"))
}
defer func() {
_ = sock.Close()
}()
result, err := sock.Send("notifications_queue_clear")
if err != nil {
return err
}
if result != "ok" {
return errors.New(i18n.Lang.T("notifications_queue_clear_error"))
}
fmt.Println(i18n.Lang.T("notifications_queue_clear_success"))
return nil
}

View File

@@ -5,6 +5,8 @@ import (
"fmt"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/db"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/db/repository"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/docker_monitor"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/notifications"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/i18n"
@@ -51,7 +53,20 @@ func runDaemon(ctx context.Context, _ *cli.Command) error {
return err
}
notificationsService, err := newNotificationsService(logger)
repositories, err := db.New(config.DataDir)
if err != nil {
logger.Fatal(err.Error())
// Fatal should call os.Exit(1), but there's a chance that might not happen,
// so we return err just in case.return err
return err
}
defer func() {
_ = repositories.Close()
}()
config.Repositories = repositories
notificationsService, err := newNotificationsService(repositories.NotificationsQueue(), logger)
if err != nil {
logger.Fatal(err.Error())
@@ -81,13 +96,13 @@ func runDaemon(ctx context.Context, _ *cli.Command) error {
return nil
}
func newNotificationsService(logger log.Logger) (notifications.Notifications, error) {
func newNotificationsService(queueRepository repository.NotificationsQueueRepository, logger log.Logger) (notifications.Notifications, error) {
config, err := setting.Config.OtherSettingsPath.ToNotificationsConfig()
if err != nil {
return nil, err
}
return notifications.New(config, logger), nil
return notifications.New(config, queueRepository, logger), nil
}
func newDockerService(ctx context.Context, logger log.Logger) (dockerService docker_monitor.Docker, dockerSupport bool, err error) {

View File

@@ -38,6 +38,8 @@ func NewMainApp(appVer AppVersion, defaultConfigPath string) *cli.Command {
daemon.CmdStop(),
daemon.CmdStatus(),
daemon.CmdReopenLogger(),
daemon.CmdNotifications(),
daemon.CmdBan(),
}
return app

View File

@@ -7,12 +7,14 @@ import (
config2 "git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
analyzerLog "git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/log"
analysisServices "git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/log/analysis"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/db"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/notifications"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/log"
)
type Analyzer interface {
Run(ctx context.Context)
ClearDBData() error
Close() error
}
@@ -21,25 +23,56 @@ type analyzer struct {
logger log.Logger
notify notifications.Notifications
systemd analyzerLog.Systemd
files analyzerLog.FileMonitoring
analysis analyzerLog.Analysis
logChan chan analysisServices.Entry
}
func New(config config2.Config, logger log.Logger, notify notifications.Notifications) Analyzer {
var units []string
if config.Login.Enabled && config.Login.SSH.Enabled {
units = append(units, "ssh")
func New(config config2.Config, blockIPFunc analysisServices.BlockIPFunc, repositories db.Repositories, logger log.Logger, notify notifications.Notifications) Analyzer {
var journalMatches []string
journalMatchesUniq := map[string]struct{}{}
var files []string
filesUniq := map[string]struct{}{}
rulesIndex := analysisServices.NewRulesIndex()
for _, source := range config.Sources {
switch source.Type {
case config2.SourceTypeJournal:
match := source.Journal.JournalctlMatch()
if _, ok := journalMatchesUniq[match]; !ok {
journalMatchesUniq[match] = struct{}{}
journalMatches = append(journalMatches, match)
}
case config2.SourceTypeFile:
file := source.File.Path
if _, ok := filesUniq[file]; !ok {
filesUniq[file] = struct{}{}
files = append(files, file)
}
default:
logger.Error(fmt.Sprintf("Unknown source type: %s", source.Type))
continue
}
err := rulesIndex.Add(source)
if err != nil {
logger.Error(fmt.Sprintf("Failed to add rule: %s", err))
}
}
systemdService := analyzerLog.NewSystemd(config.BinPath.Journalctl, units, logger)
analysisService := analyzerLog.NewAnalysis(&config, logger, notify)
systemdService := analyzerLog.NewSystemd(config.BinPath.Journalctl, journalMatches, logger)
filesService := analyzerLog.NewFileMonitoring(files, logger)
analysisService := analyzerLog.NewAnalysis(rulesIndex, blockIPFunc, repositories, logger, notify)
return &analyzer{
config: config,
logger: logger,
notify: notify,
systemd: systemdService,
files: filesService,
analysis: analysisService,
logChan: make(chan analysisServices.Entry, 1000),
@@ -47,11 +80,28 @@ func New(config config2.Config, logger log.Logger, notify notifications.Notifica
}
func (a *analyzer) Run(ctx context.Context) {
go a.systemd.Run(ctx, a.logChan)
go a.processLogs(ctx)
go a.systemd.Run(ctx, a.logChan)
go a.files.Run(ctx, a.logChan)
a.logger.Debug("Analyzer is start")
}
func (a *analyzer) ClearDBData() error {
a.logger.Debug("Clear data")
clearDBErrors, err := a.analysis.ClearDBData()
if err != nil {
for _, err := range clearDBErrors {
a.logger.Error(err.Error())
}
return err
}
return nil
}
func (a *analyzer) processLogs(ctx context.Context) {
for {
select {
@@ -62,23 +112,20 @@ func (a *analyzer) processLogs(ctx context.Context) {
// Channel closed
return
}
a.logger.Debug(fmt.Sprintf("Received log entry: %s", entry))
switch entry.Unit {
case "ssh.service":
if err := a.analysis.SSH(&entry); err != nil {
a.logger.Error(fmt.Sprintf("Failed to analyze SSH logs: %s", err))
}
break
default:
a.logger.Warn(fmt.Sprintf("Unknown unit: %s", entry.Unit))
}
a.logger.Debug(fmt.Sprintf("Received log entry: %v", entry))
a.analysis.BruteForceProtection(&entry)
a.analysis.Alert(&entry)
}
}
}
func (a *analyzer) Close() error {
if err := a.systemd.Close(); err != nil {
return err
a.logger.Error(err.Error())
}
if err := a.files.Close(); err != nil {
a.logger.Error(err.Error())
}
close(a.logChan)

View File

@@ -0,0 +1,31 @@
package config
import "fmt"
type RateLimit struct {
Count uint32
Period uint32
}
type AlertGroup struct {
Name string
Message string
RateLimits []RateLimit
RateLimitResetPeriod uint32
}
func (g *AlertGroup) RateLimit(level uint64) (rateLimit RateLimit, err error) {
lenRateLimits := len(g.RateLimits) - 1
if lenRateLimits < 0 {
return RateLimit{}, fmt.Errorf("rate limits is empty")
}
if level <= uint64(lenRateLimits) {
rateLimit = g.RateLimits[level]
} else {
rateLimit = g.RateLimits[lenRateLimits]
}
return rateLimit, nil
}

View File

@@ -0,0 +1,45 @@
package config
import (
"fmt"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config/brute_force_protection"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/i18n"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/pkg/regular_expression"
)
func NewBruteForceProtectionSSH(isNotify bool, group *brute_force_protection.Group) ([]*Source, error) {
var sources []*Source
journal, err := NewSourceJournal(JournalFieldSystemdUnit, "ssh.service")
if err != nil {
return nil, fmt.Errorf("failed to create journal source for SSH: %w", err)
}
source := &Source{
Type: SourceTypeJournal,
Journal: journal,
BruteForceProtectionRule: &brute_force_protection.Rule{
Name: "_ssh",
Message: i18n.Lang.T("alert.bruteForceProtection.ssh.message"),
IsNotification: isNotify,
Patterns: []brute_force_protection.RegexPattern{
{
Regexp: regular_expression.NewLazyRegexp(`^Failed password for (\S+) from (\S+) port \S+`),
Values: []brute_force_protection.PatternValue{
{
Name: i18n.Lang.T("user"),
Value: 1,
},
},
IP: 2,
},
},
Group: group,
},
}
sources = append(sources, source)
return sources, nil
}

View File

@@ -0,0 +1,28 @@
package brute_force_protection
import "git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/pkg/regular_expression"
type Rule struct {
Name string
Message string
IsNotification bool
Patterns []RegexPattern
Group *Group
}
type RegexPattern struct {
Regexp *regular_expression.LazyRegexp
Values []PatternValue
IP uint8
}
type RateLimit struct {
Count uint32
Period uint32
BlockingTimeSeconds uint32
}
type PatternValue struct {
Name string
Value uint8
}

View File

@@ -0,0 +1,26 @@
package brute_force_protection
import "fmt"
type Group struct {
Name string
Message string
RateLimits []RateLimit
RateLimitResetPeriod uint32
}
func (g *Group) RateLimit(level uint64) (rateLimit RateLimit, err error) {
lenRateLimits := len(g.RateLimits) - 1
if lenRateLimits < 0 {
return RateLimit{}, fmt.Errorf("rate limits is empty")
}
if level <= uint64(lenRateLimits) {
rateLimit = g.RateLimits[level]
} else {
rateLimit = g.RateLimits[lenRateLimits]
}
return rateLimit, nil
}

View File

@@ -1,6 +1,122 @@
package config
import (
"fmt"
"regexp"
"strings"
"unicode"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config/brute_force_protection"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/pkg/regular_expression"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/setting/validate"
)
var (
reSystemdUnitValue = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@-]{0,255}\.(service|socket|target|mount|timer|path|scope|slice|device)$`)
reSyslogIDValue = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@-]{0,127}$`)
)
type SourceType string
const (
SourceTypeJournal SourceType = "journalctl"
SourceTypeFile SourceType = "file"
)
type JournalField string
const (
JournalFieldSystemdUnit JournalField = "_SYSTEMD_UNIT"
JournalFieldSyslogIdentifier JournalField = "SYSLOG_IDENTIFIER"
)
type Config struct {
BinPath BinPath
Login Login
Sources []*Source
}
type SourceJournal struct {
Field JournalField
Match string
}
func NewSourceJournal(field JournalField, match string) (*SourceJournal, error) {
v := strings.TrimSpace(match)
if v == "" {
return nil, fmt.Errorf("journal match must not be empty")
}
if len(v) > 512 {
return nil, fmt.Errorf("journal match is too long: %d", len(v))
}
for _, r := range v {
if r == 0 || r == '\n' || r == '\r' || unicode.IsControl(r) {
return nil, fmt.Errorf("journal match contains control characters")
}
}
// to avoid breaking the FIELD=VALUE format and concatenation with '+'
if strings.ContainsAny(v, "=+") {
return nil, fmt.Errorf("journal match must not contain '=' or '+'")
}
if strings.ContainsAny(v, " \t") {
return nil, fmt.Errorf("journal match must not contain spaces or tabs")
}
switch field {
case JournalFieldSystemdUnit:
if !reSystemdUnitValue.MatchString(v) {
return nil, fmt.Errorf("invalid _SYSTEMD_UNIT value: %q", v)
}
case JournalFieldSyslogIdentifier:
if !reSyslogIDValue.MatchString(v) {
return nil, fmt.Errorf("invalid SYSLOG_IDENTIFIER value: %q", v)
}
default:
return nil, fmt.Errorf("invalid journal field: %q", field)
}
return &SourceJournal{Field: field, Match: v}, nil
}
type SourceFile struct {
Path string
}
func NewSourceFile(path string) (*SourceFile, error) {
if err := validate.PathFile(path, "logAlert.rules.source.path"); err != nil {
return nil, err
}
return &SourceFile{Path: path}, nil
}
func (s *SourceJournal) JournalctlMatch() string {
return string(s.Field) + "=" + s.Match
}
type Source struct {
Type SourceType
Journal *SourceJournal
File *SourceFile
AlertRule *AlertRule
BruteForceProtectionRule *brute_force_protection.Rule
}
type AlertRule struct {
Name string
Message string
IsNotification bool
Patterns []AlertRegexPattern
Group *AlertGroup
}
type AlertRegexPattern struct {
Regexp *regular_expression.LazyRegexp
Values []PatternValue
}
type PatternValue struct {
Name string
Value uint8
}

View File

@@ -1,12 +1,160 @@
package config
type Login struct {
Enabled bool
Notify bool
SSH LoginSSH
import (
"fmt"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/i18n"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/pkg/regular_expression"
)
func NewLoginSSH(isNotify bool) ([]*Source, error) {
var sources []*Source
journal, err := NewSourceJournal(JournalFieldSystemdUnit, "ssh.service")
if err != nil {
return nil, fmt.Errorf("failed to create journal source for SSH login: %w", err)
}
source := &Source{
Type: SourceTypeJournal,
Journal: journal,
AlertRule: &AlertRule{
Name: "_login-ssh",
Message: i18n.Lang.T("alert.login.ssh.message"),
IsNotification: isNotify,
Patterns: []AlertRegexPattern{
{
Regexp: regular_expression.NewLazyRegexp(`^Accepted (\S+) for (\S+) from (\S+) port \S+`),
Values: []PatternValue{
{
Name: i18n.Lang.T("user"),
Value: 2,
},
{
Name: "IP",
Value: 3,
},
},
},
},
Group: nil,
},
}
sources = append(sources, source)
return sources, nil
}
type LoginSSH struct {
Enabled bool
Notify bool
func NewLoginLocal(isNotify bool) ([]*Source, error) {
var sources []*Source
journal, err := NewSourceJournal(JournalFieldSyslogIdentifier, "login")
if err != nil {
return nil, fmt.Errorf("failed to create journal source for local login: %w", err)
}
source := &Source{
Type: SourceTypeJournal,
Journal: journal,
AlertRule: &AlertRule{
Name: "_login-local",
Message: i18n.Lang.T("alert.login.local.message"),
IsNotification: isNotify,
Patterns: []AlertRegexPattern{
{
Regexp: regular_expression.NewLazyRegexp(`^pam_unix\(login:session\): session opened for user (\S+)\(\S+\) by \S+`),
Values: []PatternValue{
{
Name: i18n.Lang.T("user"),
Value: 1,
},
},
},
},
Group: nil,
},
}
sources = append(sources, source)
return sources, nil
}
func NewLoginSu(isNotify bool) ([]*Source, error) {
var sources []*Source
journal, err := NewSourceJournal(JournalFieldSyslogIdentifier, "su")
if err != nil {
return nil, fmt.Errorf("failed to create journal source for su login: %w", err)
}
source := &Source{
Type: SourceTypeJournal,
Journal: journal,
AlertRule: &AlertRule{
Name: "_login-su",
Message: i18n.Lang.T("alert.login.su.message"),
IsNotification: isNotify,
Patterns: []AlertRegexPattern{
{
Regexp: regular_expression.NewLazyRegexp(`^pam_unix\(su:session\): session opened for user (\S+)\(\S+\) by (\S+)\(\S+\)`),
Values: []PatternValue{
{
Name: i18n.Lang.T("user"),
Value: 2,
},
{
Name: i18n.Lang.T("access to user has been gained"),
Value: 1,
},
},
},
},
Group: nil,
},
}
sources = append(sources, source)
return sources, nil
}
func NewLoginSudo(isNotify bool) ([]*Source, error) {
var sources []*Source
journal, err := NewSourceJournal(JournalFieldSyslogIdentifier, "sudo")
if err != nil {
return nil, fmt.Errorf("failed to create journal source for sudo login: %w", err)
}
source := &Source{
Type: SourceTypeJournal,
Journal: journal,
AlertRule: &AlertRule{
Name: "_login-sudo",
Message: i18n.Lang.T("alert.login.sudo.message"),
IsNotification: isNotify,
Patterns: []AlertRegexPattern{
{
Regexp: regular_expression.NewLazyRegexp(`^pam_unix\(sudo:session\): session opened for user (\S+)\(\S+\) by (\S+)\(\S+\)`),
Values: []PatternValue{
{
Name: i18n.Lang.T("user"),
Value: 2,
},
{
Name: i18n.Lang.T("access to user has been gained"),
Value: 1,
},
},
},
},
Group: nil,
},
}
sources = append(sources, source)
return sources, nil
}

View File

@@ -1,31 +1,57 @@
package log
import (
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
"fmt"
analysisServices "git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/log/analysis"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/log/analysis/alert_group"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/log/analysis/brute_force_protection_group"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/db"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/notifications"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/log"
)
type Analysis interface {
SSH(entry *analysisServices.Entry) error
Alert(entry *analysisServices.Entry)
BruteForceProtection(entry *analysisServices.Entry)
ClearDBData() ([]error, error)
}
type analysis struct {
sshService analysisServices.Analysis
logger log.Logger
notify notifications.Notifications
alertService analysisServices.Alert
bruteForceProtectionService analysisServices.BruteForceProtection
}
func NewAnalysis(config *config.Config, logger log.Logger, notify notifications.Notifications) Analysis {
func NewAnalysis(rulesIndex *analysisServices.RulesIndex, blockIPFunc analysisServices.BlockIPFunc, repositories db.Repositories, logger log.Logger, notify notifications.Notifications) Analysis {
alertGroupService := alert_group.NewGroup(repositories.AlertGroup(), logger)
bruteForceProtectionGroupService := brute_force_protection_group.NewGroup(repositories.BruteForceProtectionGroup(), logger)
return &analysis{
sshService: analysisServices.NewSSH(config, logger, notify),
logger: logger,
notify: notify,
alertService: analysisServices.NewAlert(rulesIndex, alertGroupService, logger, notify),
bruteForceProtectionService: analysisServices.NewBruteForceProtection(rulesIndex, bruteForceProtectionGroupService, blockIPFunc, logger, notify),
}
}
func (a *analysis) SSH(entry *analysisServices.Entry) error {
return a.sshService.Process(entry)
func (a *analysis) Alert(entry *analysisServices.Entry) {
a.alertService.Analyze(entry)
}
func (a *analysis) BruteForceProtection(entry *analysisServices.Entry) {
a.bruteForceProtectionService.Analyze(entry)
}
func (a *analysis) ClearDBData() ([]error, error) {
var errClearDB []error
if err := a.alertService.ClearDBData(); err != nil {
errClearDB = append(errClearDB, err)
}
if err := a.bruteForceProtectionService.ClearDBData(); err != nil {
errClearDB = append(errClearDB, err)
}
if len(errClearDB) > 0 {
return nil, fmt.Errorf("failed to clear database data: %v", errClearDB)
}
return errClearDB, nil
}

View File

@@ -0,0 +1,155 @@
package analysis
import (
"fmt"
"time"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/log/analysis/alert_group"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/notifications"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/i18n"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/log"
)
type Alert interface {
Analyze(entry *Entry)
ClearDBData() error
}
type alert struct {
rulesIndex *RulesIndex
alertGroupService alert_group.Group
logger log.Logger
notify notifications.Notifications
}
type alertAnalyzeRuleReturn struct {
found bool
fields []*regexField
}
type alertNotify struct {
rule *config.AlertRule
messages []string
time time.Time
fields []*regexField
}
func NewAlert(rulesIndex *RulesIndex, alertGroupService alert_group.Group, logger log.Logger, notify notifications.Notifications) Alert {
return &alert{
rulesIndex: rulesIndex,
alertGroupService: alertGroupService,
logger: logger,
notify: notify,
}
}
func (a *alert) Analyze(entry *Entry) {
rules, err := a.rulesIndex.Alerts(entry)
if err != nil {
a.logger.Error(fmt.Sprintf("Failed to get alert rules: %s", err))
}
for _, rule := range rules {
result := a.analyzeRule(rule, entry.Message)
if !result.found {
continue
}
groupName := ""
messages := []string{}
if rule.Group != nil {
alertGroup, err := a.alertGroupService.Analyze(rule.Group, entry.Time, entry.Message)
if err != nil {
a.logger.Error(fmt.Sprintf("Failed to analyze alert group: %s", err))
continue
}
if !alertGroup.Alerted {
continue
}
groupName = rule.Group.Name
for _, lastLog := range alertGroup.LastLogs {
messages = append(messages, lastLog)
}
} else {
messages = append(messages, entry.Message)
}
a.logger.Info(fmt.Sprintf("Alert detected (%s) (group:%s): %s", rule.Name, groupName, entry.Message))
a.sendNotify(&alertNotify{
rule: rule,
messages: messages,
time: entry.Time,
fields: result.fields,
})
}
}
func (a *alert) ClearDBData() error {
return a.alertGroupService.ClearDBData()
}
func (a *alert) analyzeRule(rule *config.AlertRule, message string) alertAnalyzeRuleReturn {
result := alertAnalyzeRuleReturn{
found: false,
fields: []*regexField{},
}
for _, pattern := range rule.Patterns {
re, err := pattern.Regexp.Get()
if err != nil {
a.logger.Error(fmt.Sprintf("Failed to compile regexp: %s", err))
continue
}
idx := re.FindStringSubmatchIndex(message)
if idx != nil {
for _, value := range pattern.Values {
start, end, err := getValueStartEndByRegexIndex(int(value.Value), idx)
if err != nil {
result.fields = append(result.fields, &regexField{name: value.Name, value: i18n.Lang.T("unknown")})
continue
}
result.fields = append(result.fields, &regexField{name: value.Name, value: message[start:end]})
}
if len(pattern.Values) != len(result.fields) {
continue
}
result.found = true
return result
}
}
return result
}
func (a *alert) sendNotify(notify *alertNotify) {
if !notify.rule.IsNotification {
return
}
groupName := ""
groupMessage := ""
if notify.rule.Group != nil {
groupName = notify.rule.Group.Name
groupMessage = notify.rule.Group.Message + "\n\n"
}
subject := i18n.Lang.T("alert.subject", map[string]any{
"Name": notify.rule.Name,
"GroupName": groupName,
})
text := subject + "\n\n" + groupMessage + notify.rule.Message + "\n\n"
text += i18n.Lang.T("time", map[string]any{
"Time": notify.time,
}) + "\n"
for _, field := range notify.fields {
text += fmt.Sprintf("%s: %s\n", field.name, field.value)
}
text += "\n" + i18n.Lang.T("log") + "\n"
for _, message := range notify.messages {
text += message + "\n"
}
a.notify.SendAsync(notifications.Message{Subject: subject, Body: text})
}

View File

@@ -0,0 +1,108 @@
package alert_group
import (
"fmt"
"time"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/db/entity"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/db/repository"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/log"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/pkg/time_operation"
)
type Group interface {
Analyze(alertGroup *config.AlertGroup, eventTime time.Time, message string) (AnalysisResult, error)
ClearDBData() error
}
type group struct {
alertGroupRepository repository.AlertGroupRepository
logger log.Logger
}
type AnalysisResult struct {
Alerted bool
LastLogs []string
}
func NewGroup(alertGroupRepository repository.AlertGroupRepository, logger log.Logger) Group {
return &group{
alertGroupRepository: alertGroupRepository,
logger: logger,
}
}
func (g *group) Analyze(alertGroup *config.AlertGroup, eventTime time.Time, message string) (AnalysisResult, error) {
analysisResult := AnalysisResult{
Alerted: false,
}
g.logger.Debug(fmt.Sprintf("Analyzing alert group %s", alertGroup.Name))
err := g.alertGroupRepository.Update(alertGroup.Name, func(entityAlertGroup *entity.AlertGroup) (*entity.AlertGroup, error) {
rateLimit, err := alertGroup.RateLimit(entityAlertGroup.CurrentLevelTriggerCount)
if err != nil {
return entityAlertGroup, err
}
if time_operation.IsRateLimited(entityAlertGroup.LastTriggeredAtUnix, eventTime, int64(rateLimit.Period)) {
g.logger.Debug(fmt.Sprintf("Alert group %s is rate limited", alertGroup.Name))
analysisResult, entityAlertGroup = g.analysisResult(rateLimit, eventTime, message, entityAlertGroup)
return entityAlertGroup, nil
}
entityAlertGroup.TriggerCount = 0
if time_operation.IsReset(entityAlertGroup.LastTriggeredAtUnix, eventTime, int64(alertGroup.RateLimitResetPeriod)) {
g.logger.Debug(fmt.Sprintf("Alert group %s is reset", alertGroup.Name))
entityAlertGroup.Reset()
rateLimit, err = alertGroup.RateLimit(0)
if err != nil {
return entityAlertGroup, err
}
}
g.logger.Debug(fmt.Sprintf("Alert not rate limited"))
analysisResult, entityAlertGroup = g.analysisResult(rateLimit, eventTime, message, entityAlertGroup)
return entityAlertGroup, nil
})
if err != nil {
return AnalysisResult{
Alerted: false,
}, err
}
return analysisResult, nil
}
func (g *group) ClearDBData() error {
return g.alertGroupRepository.Clear()
}
func (g *group) analysisResult(rateLimit config.RateLimit, eventTime time.Time, message string, entityAlertGroup *entity.AlertGroup) (AnalysisResult, *entity.AlertGroup) {
analysisResult := AnalysisResult{
Alerted: false,
}
entityAlertGroup.LastTriggeredAtUnix = eventTime.Unix()
entityAlertGroup.TriggerCount++
entityAlertGroup.LastLogs = append(entityAlertGroup.LastLogs, fmt.Sprintf("event time: %s, message: %s", eventTime.Format(time.RFC3339), message))
g.logger.Debug(fmt.Sprintf("Alert triggered. Count: %d", entityAlertGroup.TriggerCount))
if entityAlertGroup.TriggerCount >= uint64(rateLimit.Count) {
g.logger.Debug(fmt.Sprintf("Alert reached rate limit"))
analysisResult.LastLogs = entityAlertGroup.LastLogs
analysisResult.Alerted = true
entityAlertGroup.CurrentLevelTriggerCount++
entityAlertGroup.TriggerCount = 0
entityAlertGroup.LastLogs = []string{}
} else {
g.logger.Debug(fmt.Sprintf("Alert not reached rate limit"))
}
return analysisResult, entityAlertGroup
}

View File

@@ -1,22 +1,40 @@
package analysis
import (
"errors"
"time"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
)
type Analysis interface {
Process(entry *Entry) error
}
type Entry struct {
Source config.SourceType
Message string
Unit string
PID string
Time time.Time
Unit string // for systemd source
PID string // for systemd source
SyslogIdentifier string // for systemd source
File string // for file source
}
type EmptyAnalysis struct{}
func (empty *EmptyAnalysis) Process(_ *Entry) error {
return nil
type regexField struct {
name string
value string
}
func getValueStartEndByRegexIndex(valueId int, idx []int) (start int, end int, err error) {
id := 2 * valueId
if idx == nil || len(idx) <= id+1 {
return 0, 0, errors.New("invalid index")
}
start, end = idx[id], idx[id+1]
if start < 0 || end < 0 {
return 0, 0, errors.New("invalid index")
}
return start, end, nil
}

View File

@@ -0,0 +1,241 @@
package analysis
import (
"fmt"
"net"
"time"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config/brute_force_protection"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/log/analysis/brute_force_protection_group"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/firewall/blocking"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/notifications"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/i18n"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/log"
)
type BruteForceProtection interface {
Analyze(entry *Entry)
ClearDBData() error
}
type BlockIPFunc func(blockIP blocking.BlockIP) (bool, error)
type bruteForceProtection struct {
rulesIndex *RulesIndex
groupService brute_force_protection_group.Group
blockIP BlockIPFunc
logger log.Logger
notify notifications.Notifications
}
type bruteForceProtectionAnalyzeRuleReturn struct {
found bool
fields []*regexField
ip net.IP
}
type bruteForceProtectionNotify struct {
rule *brute_force_protection.Rule
messages []string
ip net.IP
time time.Time
fields []*regexField
blockSec uint32
err error
}
func NewBruteForceProtection(rulesIndex *RulesIndex, groupService brute_force_protection_group.Group, blockIP BlockIPFunc, logger log.Logger, notify notifications.Notifications) BruteForceProtection {
return &bruteForceProtection{
rulesIndex: rulesIndex,
groupService: groupService,
blockIP: blockIP,
logger: logger,
notify: notify,
}
}
func (p *bruteForceProtection) Analyze(entry *Entry) {
rules, err := p.rulesIndex.BruteForceProtections(entry)
if err != nil {
p.logger.Error(fmt.Sprintf("Failed to get brute force protection rules for entry: %v", err))
return
}
for _, rule := range rules {
if rule.Group == nil {
p.logger.Error("Brute force protection rule without group")
continue
}
result := p.analyzeRule(rule, entry.Message)
if !result.found {
continue
}
groupResult, err := p.groupService.Analyze(rule.Group, entry.Time, result.ip, entry.Message)
if err != nil {
p.logger.Error(fmt.Sprintf("Failed to analyze brute force protection group: %s", err))
continue
}
if !groupResult.Block {
continue
}
blockIP := blocking.BlockIP{
IP: result.ip,
TimeSeconds: groupResult.BlockSec,
Reason: rule.Message,
}
isBanned, err := p.blockIP(blockIP)
if isBanned == false {
p.logger.Info(fmt.Sprintf("IP %s are not blocked (%s) (group:%s): %s. Err: %s", result.ip, rule.Name, rule.Group.Name, entry.Message, err.Error()))
p.sendNotifyError(&bruteForceProtectionNotify{
rule: rule,
ip: result.ip,
messages: groupResult.LastLogs,
time: entry.Time,
fields: result.fields,
blockSec: groupResult.BlockSec,
err: err,
})
continue
}
p.logger.Info(fmt.Sprintf("Block IP %s detected (%s) (group:%s): %s", result.ip, rule.Name, rule.Group.Name, entry.Message))
p.sendNotify(&bruteForceProtectionNotify{
rule: rule,
ip: result.ip,
messages: groupResult.LastLogs,
time: entry.Time,
fields: result.fields,
blockSec: groupResult.BlockSec,
err: err,
})
}
}
func (p *bruteForceProtection) ClearDBData() error {
return p.groupService.ClearDBData()
}
func (p *bruteForceProtection) analyzeRule(rule *brute_force_protection.Rule, message string) bruteForceProtectionAnalyzeRuleReturn {
result := bruteForceProtectionAnalyzeRuleReturn{
found: false,
fields: []*regexField{},
ip: nil,
}
for _, pattern := range rule.Patterns {
re, err := pattern.Regexp.Get()
if err != nil {
p.logger.Error(fmt.Sprintf("Failed to compile regexp: %s", err))
continue
}
idx := re.FindStringSubmatchIndex(message)
if idx != nil {
start, end, err := getValueStartEndByRegexIndex(int(pattern.IP), idx)
if err != nil {
p.logger.Error(fmt.Sprintf("Failed to get ip value: %s", err))
return result
}
ipText := message[start:end]
result.ip = net.ParseIP(ipText)
if result.ip == nil {
p.logger.Error(fmt.Sprintf("Failed to parse ip: %s", ipText))
return bruteForceProtectionAnalyzeRuleReturn{
found: false,
}
}
for _, value := range pattern.Values {
start, end, err := getValueStartEndByRegexIndex(int(value.Value), idx)
if err != nil {
result.fields = append(result.fields, &regexField{name: value.Name, value: i18n.Lang.T("unknown")})
continue
}
result.fields = append(result.fields, &regexField{name: value.Name, value: message[start:end]})
}
if len(pattern.Values) != len(result.fields) {
continue
}
result.found = true
return result
}
}
return result
}
func (p *bruteForceProtection) sendNotify(notify *bruteForceProtectionNotify) {
if !notify.rule.IsNotification {
return
}
groupName := notify.rule.Group.Name
groupMessage := notify.rule.Group.Message + "\n\n"
subject := i18n.Lang.T("alert.bruteForceProtection.subject", map[string]any{
"Name": notify.rule.Name,
"GroupName": groupName,
"IP": notify.ip,
})
text := subject + "\n\n" + groupMessage + notify.rule.Message + "\n\n"
if notify.err != nil {
text += i18n.Lang.T("alert.bruteForceProtection.error", map[string]any{
"Error": notify.err.Error(),
}) + "\n"
}
text += "IP: " + notify.ip.String() + "\n"
text += i18n.Lang.T("blockSec", map[string]any{
"BlockSec": notify.blockSec,
}) + "\n"
text += i18n.Lang.T("time", map[string]any{
"Time": notify.time,
}) + "\n"
for _, field := range notify.fields {
text += fmt.Sprintf("%s: %s\n", field.name, field.value)
}
text += "\n" + i18n.Lang.T("log") + "\n"
for _, message := range notify.messages {
text += message + "\n"
}
p.notify.SendAsync(notifications.Message{Subject: subject, Body: text})
}
func (p *bruteForceProtection) sendNotifyError(notify *bruteForceProtectionNotify) {
if !notify.rule.IsNotification {
return
}
groupName := notify.rule.Group.Name
groupMessage := notify.rule.Group.Message + "\n\n"
subject := i18n.Lang.T("alert.bruteForceProtection.subject-error", map[string]any{
"Name": notify.rule.Name,
"GroupName": groupName,
"IP": notify.ip,
})
text := subject + "\n\n" + groupMessage + notify.rule.Message + "\n\n"
if notify.err != nil {
text += i18n.Lang.T("alert.bruteForceProtection.error", map[string]any{
"Error": notify.err.Error(),
}) + "\n"
}
text += "IP: " + notify.ip.String() + "\n"
text += i18n.Lang.T("time", map[string]any{
"Time": notify.time,
}) + "\n"
for _, field := range notify.fields {
text += fmt.Sprintf("%s: %s\n", field.name, field.value)
}
text += "\n" + i18n.Lang.T("log") + "\n"
for _, message := range notify.messages {
text += message + "\n"
}
p.notify.SendAsync(notifications.Message{Subject: subject, Body: text})
}

View File

@@ -0,0 +1,111 @@
package brute_force_protection_group
import (
"fmt"
"net"
"time"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config/brute_force_protection"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/db/entity"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/db/repository"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/log"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/pkg/time_operation"
)
type Group interface {
Analyze(group *brute_force_protection.Group, eventTime time.Time, ip net.IP, message string) (AnalysisResult, error)
ClearDBData() error
}
type group struct {
groupRepository repository.BruteForceProtectionGroupRepository
logger log.Logger
}
type AnalysisResult struct {
Block bool
BlockSec uint32
LastLogs []string
}
func NewGroup(groupRepository repository.BruteForceProtectionGroupRepository, logger log.Logger) Group {
return &group{
groupRepository: groupRepository,
logger: logger,
}
}
func (g *group) Analyze(group *brute_force_protection.Group, eventTime time.Time, ip net.IP, message string) (AnalysisResult, error) {
analysisResult := AnalysisResult{
Block: false,
}
g.logger.Debug(fmt.Sprintf("Analyzing brute force protection group %s IP %s", group.Name, ip.String()))
err := g.groupRepository.Update(group.Name, ip, func(entityGroup *entity.BruteForceProtectionGroup) (*entity.BruteForceProtectionGroup, error) {
rateLimit, err := group.RateLimit(entityGroup.CurrentLevelTriggerCount)
if err != nil {
return entityGroup, err
}
if time_operation.IsRateLimited(entityGroup.LastTriggeredAtUnix, eventTime, int64(rateLimit.Period)) {
g.logger.Debug(fmt.Sprintf("Brute force protection group %s is rate limited", group.Name))
analysisResult, entityGroup = g.analysisResult(rateLimit, eventTime, message, entityGroup)
return entityGroup, nil
}
entityGroup.TriggerCount = 0
if time_operation.IsReset(entityGroup.LastTriggeredAtUnix, eventTime, int64(group.RateLimitResetPeriod)) {
g.logger.Debug(fmt.Sprintf("Brute force protection group %s is reset", group.Name))
entityGroup.Reset()
rateLimit, err = group.RateLimit(0)
if err != nil {
return entityGroup, err
}
}
g.logger.Debug(fmt.Sprintf("Brute force protection not rate limited"))
analysisResult, entityGroup = g.analysisResult(rateLimit, eventTime, message, entityGroup)
return entityGroup, nil
})
if err != nil {
return AnalysisResult{
Block: false,
}, err
}
return analysisResult, nil
}
func (g *group) ClearDBData() error {
return g.groupRepository.Clear()
}
func (g *group) analysisResult(rateLimit brute_force_protection.RateLimit, eventTime time.Time, message string, entityGroup *entity.BruteForceProtectionGroup) (AnalysisResult, *entity.BruteForceProtectionGroup) {
analysisResult := AnalysisResult{
Block: false,
}
entityGroup.LastTriggeredAtUnix = eventTime.Unix()
entityGroup.TriggerCount++
entityGroup.LastLogs = append(entityGroup.LastLogs, fmt.Sprintf("event time: %s, message: %s", eventTime.Format(time.RFC3339), message))
g.logger.Debug(fmt.Sprintf("Brute force protection triggered. Count: %d", entityGroup.TriggerCount))
if entityGroup.TriggerCount >= uint64(rateLimit.Count) {
g.logger.Debug(fmt.Sprintf("Brute force protection reached rate limit"))
analysisResult.LastLogs = entityGroup.LastLogs
analysisResult.Block = true
analysisResult.BlockSec = rateLimit.BlockingTimeSeconds
entityGroup.CurrentLevelTriggerCount++
entityGroup.TriggerCount = 0
entityGroup.LastLogs = []string{}
} else {
g.logger.Debug(fmt.Sprintf("Brute force protection not reached rate limit"))
}
return analysisResult, entityGroup
}

View File

@@ -0,0 +1,41 @@
package analysis
import (
config2 "git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config/brute_force_protection"
)
type RulesBucket interface {
Alerts() []*config2.AlertRule
BruteForceProtectionRules() []*brute_force_protection.Rule
addAlertRule(rule *config2.AlertRule)
addBruteForceProtectionRule(rule *brute_force_protection.Rule)
}
type rulesBucket struct {
alerts []*config2.AlertRule
bruteForceProtectionRules []*brute_force_protection.Rule
}
func (rb *rulesBucket) Alerts() []*config2.AlertRule {
return rb.alerts
}
func (rb *rulesBucket) BruteForceProtectionRules() []*brute_force_protection.Rule {
return rb.bruteForceProtectionRules
}
func (rb *rulesBucket) addAlertRule(rule *config2.AlertRule) {
rb.alerts = append(rb.alerts, rule)
}
func (rb *rulesBucket) addBruteForceProtectionRule(rule *brute_force_protection.Rule) {
rb.bruteForceProtectionRules = append(rb.bruteForceProtectionRules, rule)
}
func newRulesBucket() RulesBucket {
return &rulesBucket{
alerts: make([]*config2.AlertRule, 0),
}
}

View File

@@ -0,0 +1,118 @@
package analysis
import (
"errors"
"fmt"
config2 "git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config/brute_force_protection"
)
type RulesIndex struct {
byKey map[indexKey]RulesBucket
}
type indexKey struct {
source config2.SourceType
val string
}
func (idx *RulesIndex) Add(source *config2.Source) error {
if source.AlertRule == nil && source.BruteForceProtectionRule == nil {
return fmt.Errorf("no alert rule or brute force protection rule")
}
key, err := generateIndexKeyBySource(source)
if err != nil {
return err
}
if _, ok := idx.byKey[key]; !ok {
idx.byKey[key] = newRulesBucket()
}
if source.AlertRule != nil {
idx.byKey[key].addAlertRule(source.AlertRule)
}
if source.BruteForceProtectionRule != nil {
idx.byKey[key].addBruteForceProtectionRule(source.BruteForceProtectionRule)
}
return nil
}
func (idx *RulesIndex) Alerts(entry *Entry) ([]*config2.AlertRule, error) {
rules := make([]*config2.AlertRule, 0)
keys, err := generateIndexKeysByEntry(entry)
if err != nil {
return rules, err
}
for _, key := range keys {
b, ok := idx.byKey[key]
if !ok {
continue
}
rules = append(rules, b.Alerts()...)
}
return rules, nil
}
func (idx *RulesIndex) BruteForceProtections(entry *Entry) ([]*brute_force_protection.Rule, error) {
rules := make([]*brute_force_protection.Rule, 0)
keys, err := generateIndexKeysByEntry(entry)
if err != nil {
return rules, err
}
for _, key := range keys {
b, ok := idx.byKey[key]
if !ok {
continue
}
rules = append(rules, b.BruteForceProtectionRules()...)
}
return rules, nil
}
func NewRulesIndex() *RulesIndex {
return &RulesIndex{byKey: make(map[indexKey]RulesBucket)}
}
func generateIndexKeyBySource(source *config2.Source) (indexKey, error) {
switch source.Type {
case config2.SourceTypeJournal:
match := source.Journal.JournalctlMatch()
if source.Journal.Field == "" || source.Journal.Match == "" {
return indexKey{}, errors.New("journalctl match is empty")
}
return indexKey{source: source.Type, val: match}, nil
case config2.SourceTypeFile:
return indexKey{source: source.Type, val: source.File.Path}, nil
}
return indexKey{}, errors.New(fmt.Sprintf("unknown source type: %s", source.Type))
}
func generateIndexKeysByEntry(entry *Entry) ([]indexKey, error) {
var keys []indexKey
switch entry.Source {
case config2.SourceTypeJournal:
keys = append(keys, indexKey{source: entry.Source, val: string(config2.JournalFieldSystemdUnit) + "=" + entry.Unit})
keys = append(keys, indexKey{source: entry.Source, val: string(config2.JournalFieldSyslogIdentifier) + "=" + entry.SyslogIdentifier})
return keys, nil
case config2.SourceTypeFile:
keys = append(keys, indexKey{source: entry.Source, val: entry.File})
return keys, nil
}
return []indexKey{}, errors.New(fmt.Sprintf("unknown source type: %s", entry.Source))
}

View File

@@ -1,87 +0,0 @@
package analysis
import (
"fmt"
"regexp"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/notifications"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/i18n"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/log"
)
type ssh struct {
login sshLogin
logger log.Logger
notify notifications.Notifications
}
type sshLogin struct {
enabled bool
notify bool
}
type sshProcessReturn struct {
found bool
subject string
body string
}
func NewSSH(config *config.Config, logger log.Logger, notify notifications.Notifications) Analysis {
if !config.Login.Enabled || !config.Login.SSH.Enabled {
return &EmptyAnalysis{}
}
return &ssh{
login: sshLogin{
enabled: config.Login.Enabled && config.Login.SSH.Enabled,
notify: config.Login.Notify && config.Login.SSH.Notify,
},
logger: logger,
notify: notify,
}
}
func (s *ssh) Process(entry *Entry) error {
if s.login.enabled {
result, err := s.login.process(entry)
if err != nil {
s.logger.Error(fmt.Sprintf("Failed to process ssh login: %s", err))
} else if result.found {
if s.login.notify {
s.notify.SendAsync(notifications.Message{Subject: result.subject, Body: result.body})
}
s.logger.Info(fmt.Sprintf("SSH login detected: %s", entry.Message))
}
}
return nil
}
func (l *sshLogin) process(entry *Entry) (sshProcessReturn, error) {
re := regexp.MustCompile(`^Accepted (\S+) for (\S+) from (\S+) port \S+`)
matches := re.FindStringSubmatch(entry.Message)
if matches != nil {
user := matches[2]
ip := matches[3]
return sshProcessReturn{
found: true,
subject: i18n.Lang.T("alert.login.subject", map[string]any{
"User": user,
"IP": ip,
}),
body: i18n.Lang.T("alert.login.body", map[string]any{
"User": user,
"IP": ip,
"Log": entry.Message,
"Time": entry.Time,
}),
}, nil
}
return sshProcessReturn{found: false}, nil
}

View File

@@ -0,0 +1,111 @@
package log
import (
"context"
"fmt"
"io"
"sync"
"time"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
analysisServices "git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/log/analysis"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/log/file_monitoring"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/log"
"github.com/nxadm/tail"
)
type FileMonitoring interface {
Run(ctx context.Context, logChan chan<- analysisServices.Entry)
Close() error
}
type fileMonitoring struct {
paths []string
logger log.Logger
tailers []*tail.Tail
mu sync.Mutex
}
func NewFileMonitoring(paths []string, logger log.Logger) FileMonitoring {
return &fileMonitoring{
paths: paths,
logger: logger,
tailers: []*tail.Tail{},
}
}
func (fm *fileMonitoring) Run(ctx context.Context, logChan chan<- analysisServices.Entry) {
pathsCount := len(fm.paths)
if pathsCount == 0 {
fm.logger.Debug("No paths specified for file monitoring")
return
}
fm.logger.Debug("Starting file monitoring")
tailLogger := file_monitoring.NewLogger(fm.logger)
for _, path := range fm.paths {
path := path
go func() {
fm.monitorFile(path, ctx, logChan, tailLogger)
}()
}
}
func (fm *fileMonitoring) Close() error {
for _, t := range fm.tailers {
_ = t.Stop()
fm.logger.Debug(fmt.Sprintf("Stopped monitoring file %s", t.Filename))
}
return nil
}
func (fm *fileMonitoring) monitorFile(path string, ctx context.Context, logChan chan<- analysisServices.Entry, tailLogger file_monitoring.Logger) {
fm.logger.Debug(fmt.Sprintf("Monitoring file %s", path))
t, err := tail.TailFile(path, tail.Config{
Follow: true,
ReOpen: true,
Poll: true,
Location: &tail.SeekInfo{Offset: 0, Whence: io.SeekEnd},
Logger: tailLogger,
})
fm.mu.Lock()
fm.tailers = append(fm.tailers, t)
fm.mu.Unlock()
if err != nil {
fm.logger.Error(fmt.Sprintf("Failed to tail file %s: %s", path, err))
return
}
for {
select {
case <-ctx.Done():
return
case line, ok := <-t.Lines:
if !ok {
return
}
if line == nil {
continue
}
entry := analysisServices.Entry{
Source: config.SourceTypeFile,
File: path,
Message: line.Text,
Time: time.Now(),
}
select {
case <-ctx.Done():
return
case logChan <- entry:
}
}
}
}

View File

@@ -0,0 +1,63 @@
package file_monitoring
import (
"fmt"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/log"
)
type Logger interface {
Fatal(v ...interface{})
Fatalf(format string, v ...interface{})
Fatalln(v ...interface{})
Panic(v ...interface{})
Panicf(format string, v ...interface{})
Panicln(v ...interface{})
Print(v ...interface{})
Printf(format string, v ...interface{})
Println(v ...interface{})
}
type logger struct {
logger log.Logger
}
func NewLogger(log log.Logger) Logger {
return &logger{logger: log}
}
func (l *logger) Fatal(v ...interface{}) {
l.logger.Error(fmt.Sprintf("File Monitoring: %v", v...))
}
func (l *logger) Fatalf(format string, v ...interface{}) {
l.logger.Error(fmt.Sprintf("File Monitoring: "+format, v...))
}
func (l *logger) Fatalln(v ...interface{}) {
l.logger.Error(fmt.Sprintf("File Monitoring: %v", v...))
}
func (l *logger) Panic(v ...interface{}) {
l.logger.Error(fmt.Sprintf("File Monitoring: %v", v...))
}
func (l *logger) Panicf(format string, v ...interface{}) {
l.logger.Warn(fmt.Sprintf("File Monitoring: "+format, v...))
}
func (l *logger) Panicln(v ...interface{}) {
l.logger.Error(fmt.Sprintf("File Monitoring: %v", v...))
}
func (l *logger) Print(v ...interface{}) {
l.logger.Warn(fmt.Sprintf("File Monitoring: %v", v...))
}
func (l *logger) Printf(format string, v ...interface{}) {
l.logger.Warn(fmt.Sprintf("File Monitoring: "+format, v...))
}
func (l *logger) Println(v ...interface{}) {
l.logger.Warn(fmt.Sprintf("File Monitoring: %v", v...))
}

View File

@@ -10,6 +10,7 @@ import (
"sync"
"time"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
analysisServices "git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/log/analysis"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/log"
)
@@ -20,9 +21,9 @@ type Systemd interface {
}
type systemd struct {
path string
units []string
logger log.Logger
path string
matches []string
logger log.Logger
cmd *exec.Cmd
mu sync.Mutex
@@ -32,21 +33,22 @@ type journalRawEntry struct {
Message string `json:"MESSAGE"`
Unit string `json:"_SYSTEMD_UNIT"`
PID string `json:"_PID"`
SyslogIdentifier string `json:"SYSLOG_IDENTIFIER"`
SourceTimestamp string `json:"_SOURCE_REALTIME_TIMESTAMP"`
RealtimeTimestamp string `json:"__REALTIME_TIMESTAMP"`
}
func NewSystemd(path string, units []string, logger log.Logger) Systemd {
func NewSystemd(path string, matches []string, logger log.Logger) Systemd {
return &systemd{
path: path,
units: units,
logger: logger,
path: path,
matches: matches,
logger: logger,
}
}
func (s *systemd) Run(ctx context.Context, logChan chan<- analysisServices.Entry) {
if len(s.units) == 0 {
s.logger.Debug("No units specified for journalctl")
if len(s.matches) == 0 {
s.logger.Debug("No matches specified for journalctl")
return
}
@@ -74,8 +76,11 @@ func (s *systemd) Run(ctx context.Context, logChan chan<- analysisServices.Entry
func (s *systemd) watch(ctx context.Context, logChan chan<- analysisServices.Entry) error {
args := []string{"-f", "-n", "0", "-o", "json"}
for _, unit := range s.units {
args = append(args, "-u", unit)
for index, match := range s.matches {
if index > 0 {
args = append(args, "+")
}
args = append(args, match)
}
cmd := exec.CommandContext(ctx, s.path, args...)
@@ -114,11 +119,19 @@ func (s *systemd) watch(ctx context.Context, logChan chan<- analysisServices.Ent
entryTime = time.Now()
}
logChan <- analysisServices.Entry{
Message: raw.Message,
Unit: raw.Unit,
PID: raw.PID,
Time: entryTime,
entry := analysisServices.Entry{
Source: config.SourceTypeJournal,
Message: raw.Message,
Unit: raw.Unit,
PID: raw.PID,
SyslogIdentifier: raw.SyslogIdentifier,
Time: entryTime,
}
select {
case <-ctx.Done():
break
case logChan <- entry:
}
}
@@ -126,7 +139,7 @@ func (s *systemd) watch(ctx context.Context, logChan chan<- analysisServices.Ent
}
func (s *systemd) Close() error {
if s.units == nil {
if s.matches == nil {
return nil
}

View File

@@ -3,6 +3,8 @@ package daemon
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer"
@@ -104,6 +106,19 @@ func (d *daemon) runWorker(ctx context.Context, isTesting bool, testingInterval
return
case <-stopTestingCh:
d.logger.Info("Testing interval expired, stopping service")
if err := d.notifications.DBQueueClear(); err != nil {
d.logger.Error(fmt.Sprintf("failed to clear notifications queue: %v", err))
}
if err := d.analyzer.ClearDBData(); err != nil {
d.logger.Error(fmt.Sprintf("failed to clear analyzer data: %v", err))
}
if err := d.firewall.ClearDBData(); err != nil {
d.logger.Error(fmt.Sprintf("failed to clear firewall data: %v", err))
}
d.Stop()
return
case <-d.stopCh:
@@ -126,6 +141,21 @@ func (d *daemon) socketCommand(command string, socket socket.Connect) error {
return err
}
return socket.Write("ok")
case "notifications_queue_count":
count := d.notifications.DBQueueSize()
return socket.Write(strconv.Itoa(count))
case "notifications_queue_clear":
if err := d.notifications.DBQueueClear(); err != nil {
_ = socket.Write("notifications queue clear failed: " + err.Error())
return err
}
return socket.Write("ok")
case "ban_clear":
if err := d.firewall.UnblockAllIPs(); err != nil {
_ = socket.Write("ban clear failed: " + err.Error())
return err
}
return socket.Write("ok")
default:
_ = socket.Write("unknown command")
return errors.New("unknown command")

87
internal/daemon/db/db.go Normal file
View File

@@ -0,0 +1,87 @@
package db
import (
"errors"
"time"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/db/repository"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/pkg/filesystem"
"go.etcd.io/bbolt"
)
const (
appDB = "app.db"
securityDB = "security.db"
)
type Repositories interface {
NotificationsQueue() repository.NotificationsQueueRepository
AlertGroup() repository.AlertGroupRepository
BruteForceProtectionGroup() repository.BruteForceProtectionGroupRepository
Blocking() repository.BlockingRepository
Close() error
}
type repositories struct {
notificationsQueue repository.NotificationsQueueRepository
alertGroup repository.AlertGroupRepository
bruteForceProtectionGroup repository.BruteForceProtectionGroupRepository
blocking repository.BlockingRepository
db []*bbolt.DB
}
func New(dataDir string) (Repositories, error) {
if dataDir == "" {
return &repositories{}, errors.New("data directory is empty")
}
if dataDir[len(dataDir)-1:] != "/" {
dataDir += "/"
}
err := filesystem.EnsureDir(dataDir)
if err != nil {
return &repositories{}, err
}
appDB, err := bbolt.Open(dataDir+appDB, 0600, &bbolt.Options{Timeout: 3 * time.Second})
if err != nil {
return &repositories{}, err
}
securityDB, err := bbolt.Open(dataDir+securityDB, 0600, &bbolt.Options{Timeout: 3 * time.Second})
return &repositories{
notificationsQueue: repository.NewNotificationsQueueRepository(appDB),
alertGroup: repository.NewAlertGroupRepository(appDB),
bruteForceProtectionGroup: repository.NewBruteForceProtectionGroupRepository(securityDB),
blocking: repository.NewBlockingRepository(securityDB),
db: []*bbolt.DB{appDB, securityDB},
}, nil
}
func (r *repositories) NotificationsQueue() repository.NotificationsQueueRepository {
return r.notificationsQueue
}
func (r *repositories) AlertGroup() repository.AlertGroupRepository {
return r.alertGroup
}
func (r *repositories) BruteForceProtectionGroup() repository.BruteForceProtectionGroupRepository {
return r.bruteForceProtectionGroup
}
func (r *repositories) Blocking() repository.BlockingRepository {
return r.blocking
}
func (r *repositories) Close() error {
for _, db := range r.db {
_ = db.Close()
}
return nil
}

View File

@@ -0,0 +1,15 @@
package entity
type AlertGroup struct {
LastTriggeredAtUnix int64 `json:"LastTriggeredAtUnix"`
TriggerCount uint64 `json:"TriggerCount"`
CurrentLevelTriggerCount uint64 `json:"CurrentLevelTriggerCount"`
LastLogs []string `json:"LastLogs"`
}
func (g *AlertGroup) Reset() {
g.LastTriggeredAtUnix = 0
g.TriggerCount = 0
g.CurrentLevelTriggerCount = 0
g.LastLogs = []string{}
}

View File

@@ -0,0 +1,7 @@
package entity
type Blocking struct {
IP string `json:"IP"`
ExpireAtUnix int64 `json:"ExpireAtUnix"`
Reason string `json:"Reason"`
}

View File

@@ -0,0 +1,15 @@
package entity
type BruteForceProtectionGroup struct {
LastTriggeredAtUnix int64 `json:"LastTriggeredAtUnix"`
TriggerCount uint64 `json:"TriggerCount"`
CurrentLevelTriggerCount uint64 `json:"CurrentLevelTriggerCount"`
LastLogs []string `json:"LastLogs"`
}
func (g *BruteForceProtectionGroup) Reset() {
g.LastTriggeredAtUnix = 0
g.TriggerCount = 0
g.CurrentLevelTriggerCount = 0
g.LastLogs = []string{}
}

View File

@@ -0,0 +1,6 @@
package entity
type NotificationsQueue struct {
Subject string `json:"Subject"`
Body string `json:"Body"`
}

View File

@@ -0,0 +1,72 @@
package repository
import (
"encoding/json"
"errors"
"fmt"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/db/entity"
"go.etcd.io/bbolt"
bboltErrors "go.etcd.io/bbolt/errors"
)
type AlertGroupRepository interface {
Update(name string, f func(*entity.AlertGroup) (*entity.AlertGroup, error)) error
Clear() error
}
type alertGroupRepository struct {
db *bbolt.DB
bucket string
}
func NewAlertGroupRepository(appDB *bbolt.DB) AlertGroupRepository {
return &alertGroupRepository{
db: appDB,
bucket: alertGroupBucket,
}
}
func (r *alertGroupRepository) Update(name string, f func(*entity.AlertGroup) (*entity.AlertGroup, error)) error {
entityAlertGroup := &entity.AlertGroup{}
entityAlertGroup.Reset()
return r.db.Update(func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte(r.bucket))
if err != nil {
return err
}
key := []byte(name)
group := b.Get(key)
if group != nil {
err = json.Unmarshal(group, entityAlertGroup)
if err != nil {
return fmt.Errorf("failed to unmarshal alert group: %w", err)
}
}
entityAlertGroup, err = f(entityAlertGroup)
if err != nil {
return err
}
data, err := json.Marshal(entityAlertGroup)
if err != nil {
return err
}
return b.Put(key, data)
})
}
func (r *alertGroupRepository) Clear() error {
return r.db.Update(func(tx *bbolt.Tx) error {
err := tx.DeleteBucket([]byte(r.bucket))
if errors.Is(err, bboltErrors.ErrBucketNotFound) {
// If the bucket may not exist, ignore ErrBucketNotFound
return nil
}
_, err = tx.CreateBucketIfNotExists([]byte(r.bucket))
return err
})
}

View File

@@ -0,0 +1,133 @@
package repository
import (
"encoding/json"
"errors"
"time"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/db/entity"
"go.etcd.io/bbolt"
bboltErrors "go.etcd.io/bbolt/errors"
)
type BlockingRepository interface {
Add(blockedIP entity.Blocking) error
List(callback func(entity.Blocking) error) error
DeleteExpired(limit int) (int, error)
Clear() error
}
type blocking struct {
db *bbolt.DB
bucket string
}
func NewBlockingRepository(appDB *bbolt.DB) BlockingRepository {
return &blocking{
db: appDB,
bucket: blockingBucket,
}
}
func (r *blocking) Add(blockedIP entity.Blocking) error {
return r.db.Update(func(tx *bbolt.Tx) error {
bucket, err := tx.CreateBucketIfNotExists([]byte(r.bucket))
if err != nil {
return err
}
data, err := json.Marshal(blockedIP)
if err != nil {
return err
}
id, err := nextKeyByExpire(bucket, uint64(blockedIP.ExpireAtUnix))
if err != nil {
return err
}
return bucket.Put(id, data)
})
}
func (r *blocking) List(callback func(entity.Blocking) error) error {
return r.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket([]byte(r.bucket))
if bucket == nil {
return nil
}
return bucket.ForEach(func(_, v []byte) error {
blockedIP := entity.Blocking{}
err := json.Unmarshal(v, &blockedIP)
if err != nil {
return err
}
if err := callback(blockedIP); err != nil {
return err
}
return nil
})
})
}
func (r *blocking) DeleteExpired(limit int) (int, error) {
if limit <= 0 {
return 0, nil
}
var deleted int
err := r.db.Update(func(tx *bbolt.Tx) error {
bucket, err := tx.CreateBucketIfNotExists([]byte(r.bucket))
if err != nil {
return err
}
now := time.Now().Unix()
c := bucket.Cursor()
deleted = 0
for k, v := c.First(); k != nil && deleted < limit; {
blockedIP := entity.Blocking{}
if err := json.Unmarshal(v, &blockedIP); err != nil {
return err
}
if blockedIP.ExpireAtUnix <= 0 {
k, v = c.Next()
continue
}
if blockedIP.ExpireAtUnix > now {
// Not expired yet
break
}
nextK, nextV := c.Next()
if err := bucket.Delete(k); err != nil {
return err
}
deleted++
k = nextK
v = nextV
}
return nil
})
return deleted, err
}
func (r *blocking) Clear() error {
return r.db.Update(func(tx *bbolt.Tx) error {
err := tx.DeleteBucket([]byte(r.bucket))
if errors.Is(err, bboltErrors.ErrBucketNotFound) {
// If the bucket may not exist, ignore ErrBucketNotFound
return nil
}
_, err = tx.CreateBucketIfNotExists([]byte(r.bucket))
return err
})
}

View File

@@ -0,0 +1,103 @@
package repository
import (
"encoding/json"
"errors"
"fmt"
"net"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/db/entity"
"go.etcd.io/bbolt"
bboltErrors "go.etcd.io/bbolt/errors"
)
type BruteForceProtectionGroupRepository interface {
Update(name string, ip net.IP, f func(*entity.BruteForceProtectionGroup) (*entity.BruteForceProtectionGroup, error)) error
Clear() error
}
type bruteForceProtectionGroupRepository struct {
db *bbolt.DB
bucket string
}
func NewBruteForceProtectionGroupRepository(appDB *bbolt.DB) BruteForceProtectionGroupRepository {
return &bruteForceProtectionGroupRepository{
db: appDB,
bucket: bruteForceProtectionGroupBucket,
}
}
func (r *bruteForceProtectionGroupRepository) Update(name string, ip net.IP, f func(*entity.BruteForceProtectionGroup) (*entity.BruteForceProtectionGroup, error)) error {
entityGroup := &entity.BruteForceProtectionGroup{}
entityGroup.Reset()
return r.db.Update(func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte(r.bucket))
if err != nil {
return err
}
key, err := keyGroupIP(name, ip)
if err != nil {
return err
}
group := b.Get(key)
if group != nil {
err = json.Unmarshal(group, entityGroup)
if err != nil {
return fmt.Errorf("failed to unmarshal brute force protection group: %w", err)
}
}
entityGroup, err = f(entityGroup)
if err != nil {
return err
}
data, err := json.Marshal(entityGroup)
if err != nil {
return err
}
return b.Put(key, data)
})
}
func (r *bruteForceProtectionGroupRepository) Clear() error {
return r.db.Update(func(tx *bbolt.Tx) error {
err := tx.DeleteBucket([]byte(r.bucket))
if errors.Is(err, bboltErrors.ErrBucketNotFound) {
// If the bucket may not exist, ignore ErrBucketNotFound
return nil
}
_, err = tx.CreateBucketIfNotExists([]byte(r.bucket))
return err
})
}
func keyGroupIP(groupID string, ip net.IP) ([]byte, error) {
if ip == nil {
return nil, fmt.Errorf("ip cannot be nil")
}
if len(groupID) == 0 {
return nil, fmt.Errorf("group id cannot be empty")
}
if ip.To4() == nil && ip.To16() == nil {
return nil, fmt.Errorf("ip is neither IPv4 nor IPv6")
}
var ipAddr net.IP
if ip.To4() != nil {
ipAddr = ip.To4()
} else {
ipAddr = ip.To16()
}
k := make([]byte, 0, len(groupID)+1+len(ipAddr))
k = append(k, groupID...)
k = append(k, 0x00)
k = append(k, ipAddr...)
return k, nil
}

View File

@@ -0,0 +1,121 @@
package repository
import (
"encoding/json"
"errors"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/db/entity"
"go.etcd.io/bbolt"
bboltErrors "go.etcd.io/bbolt/errors"
)
type NotificationsQueueRepository interface {
Add(q entity.NotificationsQueue) error
Get(limit int) (map[string]entity.NotificationsQueue, error)
Delete(id string) error
// Count - return size of notifications queue in db
Count() (int, error)
Clear() error
}
type notificationsQueueRepository struct {
db *bbolt.DB
bucket string
}
func NewNotificationsQueueRepository(appDB *bbolt.DB) NotificationsQueueRepository {
return &notificationsQueueRepository{
db: appDB,
bucket: notificationsQueueBucket,
}
}
func (r *notificationsQueueRepository) Add(q entity.NotificationsQueue) error {
return r.db.Update(func(tx *bbolt.Tx) error {
bucket, err := tx.CreateBucketIfNotExists([]byte(r.bucket))
if err != nil {
return err
}
data, err := json.Marshal(q)
if err != nil {
return err
}
id, err := nextID(bucket)
if err != nil {
return err
}
return bucket.Put(id, data)
})
}
func (r *notificationsQueueRepository) Get(limit int) (map[string]entity.NotificationsQueue, error) {
notifications := make(map[string]entity.NotificationsQueue)
if limit <= 0 {
return notifications, nil
}
err := r.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket([]byte(r.bucket))
if bucket == nil {
return nil
}
c := bucket.Cursor()
for k, v := c.First(); k != nil && len(notifications) < limit; k, v = c.Next() {
var q entity.NotificationsQueue
if err := json.Unmarshal(v, &q); err != nil {
return err
}
notifications[string(k)] = q
}
return nil
})
return notifications, err
}
func (r *notificationsQueueRepository) Delete(id string) error {
return r.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket([]byte(r.bucket))
if bucket == nil {
return nil
}
return bucket.Delete([]byte(id))
})
}
func (r *notificationsQueueRepository) Count() (int, error) {
count := 0
err := r.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket([]byte(r.bucket))
if bucket == nil {
return nil
}
count = bucket.Stats().KeyN
return nil
})
return count, err
}
func (r *notificationsQueueRepository) Clear() error {
return r.db.Update(func(tx *bbolt.Tx) error {
err := tx.DeleteBucket([]byte(r.bucket))
if errors.Is(err, bboltErrors.ErrBucketNotFound) {
// If the bucket may not exist, ignore ErrBucketNotFound
return nil
}
_, err = tx.CreateBucketIfNotExists([]byte(r.bucket))
return err
})
}

View File

@@ -0,0 +1,47 @@
package repository
import (
"encoding/binary"
"math"
"go.etcd.io/bbolt"
)
const (
notificationsQueueBucket = "notifications_queue"
alertGroupBucket = "alert_group"
bruteForceProtectionGroupBucket = "brute_force_protection_group"
blockingBucket = "blocking"
)
func nextID(b *bbolt.Bucket) ([]byte, error) {
seq, err := b.NextSequence()
if err != nil {
return nil, err
}
key := make([]byte, 8)
binary.BigEndian.PutUint64(key, seq)
return key, nil
}
func nextKeyByExpire(b *bbolt.Bucket, expireUnixAt uint64) ([]byte, error) {
seq, err := b.NextSequence()
if err != nil {
return nil, err
}
// 0 = "forever" -> sort after any finite timestamp
if expireUnixAt == 0 {
expireUnixAt = math.MaxUint64
}
// 8 bytes expire + 8 bytes seq
key := make([]byte, 16)
// Important: BigEndian, so that sorting by bytes matches sorting by number.
binary.BigEndian.PutUint64(key[0:8], expireUnixAt)
binary.BigEndian.PutUint64(key[8:16], seq)
return key, nil
}

View File

@@ -0,0 +1,140 @@
package blocking
import (
"fmt"
"net"
"strings"
"sync"
"time"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/db/entity"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/db/repository"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/firewall/chain/block"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/log"
)
type API interface {
NftReload(blockListIP block.ListIP) error
BlockIP(blockIP BlockIP) (bool, error)
UnblockAllIPs() error
ClearDBData() error
}
type blocking struct {
blockingRepository repository.BlockingRepository
blockListIP block.ListIP
logger log.Logger
mu sync.Mutex
}
type BlockIP struct {
IP net.IP
TimeSeconds uint32
Reason string
}
func New(blockingRepository repository.BlockingRepository, logger log.Logger) API {
return &blocking{
blockingRepository: blockingRepository,
logger: logger,
mu: sync.Mutex{},
}
}
func (b *blocking) NftReload(blockListIP block.ListIP) error {
b.mu.Lock()
b.blockListIP = blockListIP
b.mu.Unlock()
isExpiredEntries := false
nowUnix := time.Now().Unix()
err := b.blockingRepository.List(func(e entity.Blocking) error {
ip := net.ParseIP(e.IP)
if ip == nil {
b.logger.Error(fmt.Sprintf("Failed to parse IP address: %s", e.IP))
return nil
}
banSeconds := uint32(0)
if e.ExpireAtUnix > 0 {
if e.ExpireAtUnix < nowUnix {
isExpiredEntries = true
return nil
}
banSeconds = uint32(e.ExpireAtUnix - nowUnix)
}
if err := b.blockListIP.AddIP(ip, banSeconds); err != nil {
b.logger.Error(fmt.Sprintf("Failed to add IP %s to block list: %s", ip.String(), err))
return nil
}
return nil
})
if isExpiredEntries {
go func() {
deleteCount, err := b.blockingRepository.DeleteExpired(100)
if err != nil {
b.logger.Error(fmt.Sprintf("Failed to delete expired entries from database: %s", err))
}
b.logger.Debug(fmt.Sprintf("Deleted %d expired entries from database", deleteCount))
}()
}
return err
}
func (b *blocking) BlockIP(blockIP BlockIP) (bool, error) {
if blockIP.IP.IsLoopback() {
return false, fmt.Errorf("loopback IP address %s cannot be blocked", blockIP.IP.String())
}
if err := b.blockListIP.AddIP(blockIP.IP, blockIP.TimeSeconds); err != nil {
return false, err
}
expireAtUnix := int64(0)
if blockIP.TimeSeconds > 0 {
expire := time.Now().Add(time.Duration(int64(blockIP.TimeSeconds)) * time.Second)
expireAtUnix = expire.Unix()
}
data := entity.Blocking{
IP: blockIP.IP.String(),
ExpireAtUnix: expireAtUnix,
Reason: blockIP.Reason,
}
if err := b.blockingRepository.Add(data); err != nil {
return true, fmt.Errorf("the IP is blocked, but not recorded in the database. Failed to add IP %s to database: %w", blockIP.IP.String(), err)
}
return true, nil
}
func (b *blocking) UnblockAllIPs() error {
err := b.blockingRepository.List(func(e entity.Blocking) error {
ip := net.ParseIP(e.IP)
if ip == nil {
return fmt.Errorf("failed to parse IP address: %s", e.IP)
}
if err := b.blockListIP.DeleteIP(ip); err != nil {
if strings.Contains(err.Error(), "element does not exist") {
return nil
}
return err
}
return nil
})
if err != nil {
_ = b.blockingRepository.Clear()
return err
}
return b.blockingRepository.Clear()
}
func (b *blocking) ClearDBData() error {
return b.blockingRepository.Clear()
}

View File

@@ -0,0 +1,41 @@
package chain
import (
nft "git.kor-elf.net/kor-elf-shield/go-nftables-client"
nftChain "git.kor-elf.net/kor-elf-shield/go-nftables-client/chain"
"git.kor-elf.net/kor-elf-shield/go-nftables-client/family"
)
type AfterLocalInput interface {
AddRule(expr ...string) error
AddRuleIn(AddRuleFunc func(expr ...string) error) error
}
type afterLocalInput struct {
nft nft.NFT
family family.Type
table string
chain string
}
func newAfterLocalInput(nft nft.NFT, family family.Type, table string) (LocalInput, error) {
chain := "after-local-input"
if err := nft.Chain().Add(family, table, chain, nftChain.TypeNone); err != nil {
return nil, err
}
return &afterLocalInput{
nft: nft,
family: family,
table: table,
chain: chain,
}, nil
}
func (l *afterLocalInput) AddRule(expr ...string) error {
return l.nft.Rule().Add(l.family, l.table, l.chain, expr...)
}
func (l *afterLocalInput) AddRuleIn(AddRuleFunc func(expr ...string) error) error {
return AddRuleFunc("iifname != \"lo\" counter jump " + l.chain)
}

View File

@@ -0,0 +1,41 @@
package chain
import (
nft "git.kor-elf.net/kor-elf-shield/go-nftables-client"
nftChain "git.kor-elf.net/kor-elf-shield/go-nftables-client/chain"
"git.kor-elf.net/kor-elf-shield/go-nftables-client/family"
)
type BeforeLocalInput interface {
AddRule(expr ...string) error
AddRuleIn(AddRuleFunc func(expr ...string) error) error
}
type beforeLocalInput struct {
nft nft.NFT
family family.Type
table string
chain string
}
func newBeforeLocalInput(nft nft.NFT, family family.Type, table string) (LocalInput, error) {
chain := "before-local-input"
if err := nft.Chain().Add(family, table, chain, nftChain.TypeNone); err != nil {
return nil, err
}
return &beforeLocalInput{
nft: nft,
family: family,
table: table,
chain: chain,
}, nil
}
func (l *beforeLocalInput) AddRule(expr ...string) error {
return l.nft.Rule().Add(l.family, l.table, l.chain, expr...)
}
func (l *beforeLocalInput) AddRuleIn(AddRuleFunc func(expr ...string) error) error {
return AddRuleFunc("iifname != \"lo\" counter jump " + l.chain)
}

View File

@@ -0,0 +1,59 @@
package block
import (
"fmt"
nft "git.kor-elf.net/kor-elf-shield/go-nftables-client"
"git.kor-elf.net/kor-elf-shield/go-nftables-client/family"
)
type List interface {
Name() string
AddElement(element string) error
DeleteElement(element string) error
}
type list struct {
nft nft.NFT
family family.Type
table string
name string
}
func newList(nft nft.NFT, family family.Type, table string, name string, params string) (List, error) {
command := []string{
"add set", family.String(), table, name, "{ " + params + " }",
}
if err := nft.Command().Run(command...); err != nil {
return nil, err
}
return &list{
nft: nft,
family: family,
table: table,
name: name,
}, nil
}
func (l *list) Name() string {
return l.name
}
func (l *list) AddElement(element string) error {
command := []string{
"add element",
l.family.String(), l.table, l.name,
fmt.Sprintf("{ %s }", element),
}
return l.nft.Command().Run(command...)
}
func (l *list) DeleteElement(element string) error {
command := []string{
"delete element",
l.family.String(), l.table, l.name,
fmt.Sprintf("{ %s }", element),
}
return l.nft.Command().Run(command...)
}

View File

@@ -0,0 +1,87 @@
package block
import (
"fmt"
"net"
"strings"
nft "git.kor-elf.net/kor-elf-shield/go-nftables-client"
"git.kor-elf.net/kor-elf-shield/go-nftables-client/family"
)
type ListIP interface {
// AddIP Add an IP address to the list.
AddIP(addr net.IP, banSeconds uint32) error
// DeleteIP Delete an IP address from the list.
DeleteIP(addr net.IP) error
// AddRuleToChain Add a rule to the parent chain.
AddRuleToChain(chainAddRuleFunc func(expr ...string) error, action string) error
}
type listIP struct {
listIPv4 List
listIPv6 List
}
func NewListIP(nft nft.NFT, family family.Type, table string, name string) (ListIP, error) {
params := "type ipv4_addr; flags interval, timeout;"
listName := name + "_ip4"
listIPv4, err := newList(nft, family, table, listName, params)
if err != nil {
return nil, err
}
params = "type ipv6_addr; flags interval, timeout;"
listName = name + "_ip6"
listIPv6, err := newList(nft, family, table, listName, params)
if err != nil {
return nil, err
}
return &listIP{
listIPv4: listIPv4,
listIPv6: listIPv6,
}, nil
}
func (l *listIP) AddIP(addr net.IP, banSeconds uint32) error {
el := []string{addr.String()}
if banSeconds > 0 {
el = append(el, "timeout", fmt.Sprintf("%ds", banSeconds))
}
element := strings.Join(el, " ")
if addr.To4() != nil {
return l.listIPv4.AddElement(fmt.Sprintf("%s", element))
}
return l.listIPv6.AddElement(fmt.Sprintf("%s", element))
}
func (l *listIP) DeleteIP(addr net.IP) error {
if addr == nil {
return fmt.Errorf("IP address cannot be nil")
}
if addr.To4() != nil {
return l.listIPv4.DeleteElement(addr.String())
}
return l.listIPv6.DeleteElement(addr.String())
}
func (l *listIP) AddRuleToChain(chainAddRuleFunc func(expr ...string) error, action string) error {
rule := "ip saddr @" + l.listIPv4.Name() + " " + action
if err := chainAddRuleFunc(rule); err != nil {
return err
}
rule = "ip6 saddr @" + l.listIPv6.Name() + " " + action
if err := chainAddRuleFunc(rule); err != nil {
return err
}
return nil
}

View File

@@ -6,6 +6,7 @@ import (
nft "git.kor-elf.net/kor-elf-shield/go-nftables-client"
nftChain "git.kor-elf.net/kor-elf-shield/go-nftables-client/chain"
nftFamily "git.kor-elf.net/kor-elf-shield/go-nftables-client/family"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/firewall/chain/block"
)
type Chains interface {
@@ -21,9 +22,15 @@ type Chains interface {
NewForward(chain string, defaultAllow bool, priority int) error
Forward() Forward
NewBeforeLocalInput() error
BeforeLocalInput() BeforeLocalInput
NewLocalInput() error
LocalInput() LocalInput
NewAfterLocalInput() error
AfterLocalInput() AfterLocalInput
NewLocalOutput() error
LocalOutput() LocalOutput
@@ -34,6 +41,7 @@ type Chains interface {
NewNoneChain(chain string) (Chain, error)
NewChain(chain string, baseChain nftChain.ChainOptions) (Chain, error)
NewBlockListIP(name string) (block.ListIP, error)
}
type chains struct {
@@ -42,7 +50,10 @@ type chains struct {
forward Forward
packetFilter PacketFilter
localInput LocalInput
beforeLocalInput BeforeLocalInput
localInput LocalInput
afterLocalInput AfterLocalInput
localOutput LocalOutput
localForward LocalForward
@@ -125,6 +136,19 @@ func (c *chains) Forward() Forward {
return c.forward
}
func (c *chains) NewBeforeLocalInput() error {
newChain, err := newBeforeLocalInput(c.nft, c.family, c.table)
if err != nil {
return err
}
c.beforeLocalInput = newChain
return nil
}
func (c *chains) BeforeLocalInput() BeforeLocalInput {
return c.beforeLocalInput
}
func (c *chains) NewLocalInput() error {
localInput, err := newLocalInput(c.nft, c.family, c.table)
if err != nil {
@@ -138,6 +162,19 @@ func (c *chains) LocalInput() LocalInput {
return c.localInput
}
func (c *chains) NewAfterLocalInput() error {
newChain, err := newAfterLocalInput(c.nft, c.family, c.table)
if err != nil {
return err
}
c.afterLocalInput = newChain
return nil
}
func (c *chains) AfterLocalInput() AfterLocalInput {
return c.afterLocalInput
}
func (c *chains) NewLocalOutput() error {
localOutput, err := newLocalOutput(c.nft, c.family, c.table)
if err != nil {
@@ -185,6 +222,15 @@ func (c *chains) NewChain(chainName string, baseChain nftChain.ChainOptions) (Ch
}, nil
}
func (c *chains) NewBlockListIP(name string) (block.ListIP, error) {
blockList, err := block.NewListIP(c.nft, c.family, c.table, name)
if err != nil {
return nil, err
}
return blockList, nil
}
func clearRules(nft nft.NFT, family nftFamily.Type, table string) error {
if err := nft.Table().Delete(family, table); err != nil {
if !strings.Contains(string(err.Error()), "delete table "+family.String()+" "+table) {

View File

@@ -5,6 +5,7 @@ import (
"os"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/docker_monitor"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/firewall/blocking"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/firewall/chain"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/log"
@@ -21,28 +22,40 @@ type API interface {
// ClearRules Clear all rules.
ClearRules()
// BlockIP Block IP address.
BlockIP(blockIP blocking.BlockIP) (bool, error)
// UnblockAllIPs Unblock all IP addresses.
UnblockAllIPs() error
// ClearDBData Clear all data from DB
ClearDBData() error
// DockerSupport Return true if docker support
DockerSupport() bool
}
type firewall struct {
nft nftables.NFT
logger log.Logger
config *Config
chains chain.Chains
docker docker_monitor.Docker
nft nftables.NFT
logger log.Logger
config *Config
blockingService blocking.API
chains chain.Chains
docker docker_monitor.Docker
}
func New(pathNFT string, logger log.Logger, config Config, docker docker_monitor.Docker) (API, error) {
func New(pathNFT string, blockingService blocking.API, logger log.Logger, config Config, docker docker_monitor.Docker) (API, error) {
nft, err := nftables.NewWithPath(pathNFT)
if err != nil {
return nil, fmt.Errorf("failed to create nft client: %w %s", err, pathNFT)
}
return &firewall{
nft: nft,
logger: logger,
config: &config,
docker: docker,
nft: nft,
logger: logger,
config: &config,
blockingService: blockingService,
docker: docker,
}, nil
}
@@ -82,6 +95,10 @@ func (f *firewall) Reload() error {
}
}
if err := f.reloadBlockList(); err != nil {
return err
}
f.logger.Debug("Reload nftables rules done")
return nil
}
@@ -105,6 +122,14 @@ func (f *firewall) ClearRules() {
f.logger.Debug("Clear nftables rules done")
}
func (f *firewall) UnblockAllIPs() error {
return f.blockingService.UnblockAllIPs()
}
func (f *firewall) ClearDBData() error {
return f.blockingService.ClearDBData()
}
func (f *firewall) SavesRules() {
if !f.config.Options.SavesRules {
f.logger.Debug("SavesRules is false, skip")
@@ -133,6 +158,15 @@ func (f *firewall) SavesRules() {
f.logger.Info("Save nftables rules")
}
func (f *firewall) BlockIP(blockIP blocking.BlockIP) (bool, error) {
isBanned, err := f.blockingService.BlockIP(blockIP)
if err != nil {
f.logger.Warn(fmt.Sprintf("Failed to block ip %s: %s", blockIP.IP.String(), err))
}
return isBanned, err
}
func (f *firewall) DockerSupport() bool {
return f.config.Options.DockerSupport
}

View File

@@ -0,0 +1,17 @@
package firewall
func (f *firewall) reloadBlockList() error {
listBan, err := f.chains.NewBlockListIP("ban")
if err != nil {
return err
}
if err := listBan.AddRuleToChain(f.chains.BeforeLocalInput().AddRule, "drop"); err != nil {
return err
}
if err := f.blockingService.NftReload(listBan); err != nil {
return err
}
return nil
}

View File

@@ -24,10 +24,24 @@ func (f *firewall) reloadInput() error {
return err
}
if err := f.chains.NewBeforeLocalInput(); err != nil {
return err
}
if err := f.chains.BeforeLocalInput().AddRuleIn(chain.AddRule); err != nil {
return err
}
if err := f.reloadInputAddIPs(); err != nil {
return err
}
if err := f.chains.NewAfterLocalInput(); err != nil {
return err
}
if err := f.chains.AfterLocalInput().AddRuleIn(chain.AddRule); err != nil {
return err
}
if err := f.chains.PacketFilter().AddRuleIn(chain.AddRule); err != nil {
return err
}

View File

@@ -5,9 +5,11 @@ import (
)
type Config struct {
Enabled bool
ServerName string
Email Email
Enabled bool
EnableRetries bool
RetryInterval uint16
ServerName string
Email Email
}
type Email struct {

View File

@@ -7,6 +7,8 @@ import (
"sync"
"time"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/db/entity"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/db/repository"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/log"
"github.com/wneessen/go-mail"
)
@@ -19,21 +21,26 @@ type Message struct {
type Notifications interface {
Run()
SendAsync(message Message)
// DBQueueSize - return size of notifications queue in db
DBQueueSize() int
DBQueueClear() error
Close() error
}
type notifications struct {
config Config
logger log.Logger
msgQueue chan Message
wg sync.WaitGroup
config Config
queueRepository repository.NotificationsQueueRepository
logger log.Logger
msgQueue chan Message
wg sync.WaitGroup
}
func New(config Config, logger log.Logger) Notifications {
func New(config Config, queueRepository repository.NotificationsQueueRepository, logger log.Logger) Notifications {
return &notifications{
config: config,
logger: logger,
msgQueue: make(chan Message, 100),
config: config,
queueRepository: queueRepository,
logger: logger,
msgQueue: make(chan Message, 100),
}
}
@@ -45,12 +52,46 @@ func (n *notifications) Run() {
n.wg.Add(1)
go func() {
defer n.wg.Done()
for msg := range n.msgQueue {
err := n.sendEmail(msg)
if err != nil {
n.logger.Error(fmt.Sprintf("failed to send email: %v", err))
} else if n.config.Enabled {
n.logger.Debug(fmt.Sprintf("email sent: Subject %s, Body %s", msg.Subject, msg.Body))
ticker := time.NewTicker(time.Duration(n.config.RetryInterval) * time.Second)
defer ticker.Stop()
for {
select {
case msg, ok := <-n.msgQueue:
if !ok {
return
}
err := n.sendEmail(msg)
if err != nil {
n.logger.Error(fmt.Sprintf("failed to send email: %v", err))
n.addNotificationsQueue(msg)
} else if n.config.Enabled {
n.logger.Debug(fmt.Sprintf("email sent: Subject %s, Body %s", msg.Subject, msg.Body))
}
case <-ticker.C:
if n.config.Enabled == false || n.config.EnableRetries == false {
continue
}
items, err := n.queueRepository.Get(10)
if err != nil {
n.logger.Error(fmt.Sprintf("failed to get notifications from the queue: %v", err))
continue
}
for id, item := range items {
err = n.sendEmail(Message{Subject: item.Subject, Body: item.Body})
if err != nil {
n.logger.Error(fmt.Sprintf("failed to send queued email: %v", err))
break
}
err = n.queueRepository.Delete(id)
if err != nil {
n.logger.Error(fmt.Sprintf("failed to delete queued email from the queue: %v", err))
}
}
}
}
}()
@@ -66,9 +107,27 @@ func (n *notifications) SendAsync(message Message) {
}
default:
n.logger.Error(fmt.Sprintf("failed to send email: queue is full"))
n.addNotificationsQueue(message)
}
}
func (n *notifications) DBQueueSize() int {
count, err := n.queueRepository.Count()
if err != nil {
n.logger.Error(fmt.Sprintf("failed to get notifications queue size: %v", err))
return 0
}
return count
}
func (n *notifications) DBQueueClear() error {
err := n.queueRepository.Clear()
if err != nil {
n.logger.Error(fmt.Sprintf("failed to clear notifications queue: %v", err))
}
return err
}
func (n *notifications) Close() error {
close(n.msgQueue)
n.logger.Debug("We are waiting for all notifications to be sent")
@@ -104,6 +163,17 @@ func (n *notifications) sendEmail(message Message) error {
return client.DialAndSendWithContext(ctx, m)
}
func (n *notifications) addNotificationsQueue(message Message) {
if n.config.Enabled == false || n.config.EnableRetries == false {
return
}
err := n.queueRepository.Add(entity.NotificationsQueue{Body: message.Body, Subject: message.Subject})
if err != nil {
n.logger.Error(fmt.Sprintf("failed to save email to the queue: %v", err))
}
}
func newClient(config Email) (*mail.Client, error) {
options := []mail.Option{
mail.WithPort(int(config.Port)),

View File

@@ -2,13 +2,16 @@ package daemon
import (
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/db"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/firewall"
)
type DaemonOptions struct {
PathPidFile string
PathSocketFile string
DataDir string
PathNftables string
ConfigFirewall firewall.Config
ConfigAnalyzer config.Config
Repositories db.Repositories
}

View File

@@ -6,6 +6,7 @@ import (
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/docker_monitor"
firewall2 "git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/firewall"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/firewall/blocking"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/notifications"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/pidfile"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/socket"
@@ -27,9 +28,10 @@ func NewDaemon(opts DaemonOptions, logger log.Logger, notifications notification
return nil, err
}
firewall, err := firewall2.New(opts.PathNftables, logger, opts.ConfigFirewall, docker)
blockingService := blocking.New(opts.Repositories.Blocking(), logger)
firewall, err := firewall2.New(opts.PathNftables, blockingService, logger, opts.ConfigFirewall, docker)
analyzerService := analyzer.New(opts.ConfigAnalyzer, logger, notifications)
analyzerService := analyzer.New(opts.ConfigAnalyzer, firewall.BlockIP, opts.Repositories, logger, notifications)
return &daemon{
pidFile: pidFile,

View File

@@ -15,6 +15,22 @@
"cmd.daemon.reopen_logger.Usage": "Reopen the file for logging",
"cmd.daemon.reopen_logger.Description": "Reopen the file where the daemon's logs are written",
"cmd.daemon.notifications.Usage": "Notifications",
"cmd.daemon.notifications.queue.Usage": "Notification queue",
"cmd.daemon.notifications.queue.count.Usage": "Number of notifications in the pending queue",
"cmd.daemon.notifications.queue.count.Description": "The number of notifications waiting to be sent after an error.",
"cmd.daemon.notifications.queue.count.result": "Number in backlog queue: {{.Count}}",
"cmd.daemon.notifications.queue.clear.Usage": "Clear the notification queue",
"cmd.daemon.notifications.queue.clear.Description": "Clear the queue of notifications waiting to be sent after an error.",
"notifications_queue_clear_error": "Failed to clear notification queue",
"notifications_queue_clear_success": "The notification queue has been cleared.",
"cmd.daemon.ban.Usage": "Ban",
"cmd.daemon.ban.clear.Usage": "Unban all banned IP addresses",
"cmd.daemon.ban.clear.Description": "Unban all banned IP addresses.",
"ban_clear_error": "Unable to unban all IP addresses",
"ban_clear_success": "The request was successfully completed",
"Command error": "Command error",
"invalid log level": "The log level specified in the settings is invalid. It is currently set to: {{.Level}}. Valid values: {{.Levels}}",
"invalid log encoding": "Invalid encoding setting. Currently set to: {{.Encoding}}. Valid values: {{.Encodings}}",
@@ -27,6 +43,22 @@
"daemon is not running": "Daemon is not running",
"daemon is not reopening logger": "The daemon did not reopen the log",
"alert.login.subject": "SSH login alert for user {{.User}} from {{.IP}}",
"alert.login.body": "Logged into the OS via ssh:\n Time: {{.Time}}\n IP: {{.IP}}\n User: {{.User}}\n Log: {{.Log}}"
"time": "Time: {{.Time}}",
"log": "Log: ",
"user": "User",
"access to user has been gained": "Access to user has been gained",
"unknown": "unknown",
"blockSec": "Blocked for {{.BlockSec}} seconds",
"alert.subject": "Alert detected ({{.Name}}) (group:{{.GroupName}})",
"alert.login.ssh.message": "Logged into the OS via ssh.",
"alert.login.local.message": "Logged into the OS via TTY.",
"alert.login.su.message": "Gained access to another user via su.",
"alert.login.sudo.message": "Gained access to another user via sudo.",
"alert.bruteForceProtection.subject": "A hacking attempt was detected and IP {{.IP}} was blocked. Alert ({{.Name}}) (Group:{{.GroupName}})",
"alert.bruteForceProtection.subject-error": "A hacking attempt was detected, but the IP {{.IP}} is not blocked. Alert ({{.Name}}) (group:{{.GroupName}})",
"alert.bruteForceProtection.error": "Error: {{.Error}}",
"alert.bruteForceProtection.ssh.message": "An attempt to brute-force SSH was detected.",
"alert.bruteForceProtection.group._default.message": "Default group."
}

View File

@@ -15,6 +15,22 @@
"cmd.daemon.reopen_logger.Usage": "Файлды тіркеу үшін қайта ашыңыз",
"cmd.daemon.reopen_logger.Description": "Демонның журналдары жазылған файлды қайта ашыңыз.",
"cmd.daemon.notifications.Usage": "Хабарландырулар",
"cmd.daemon.notifications.queue.Usage": "Хабарландыру кезегі",
"cmd.daemon.notifications.queue.count.Usage": "Күтудегі кезектегі хабарландырулар саны",
"cmd.daemon.notifications.queue.count.Description": "Қатеден кейін жіберуді күтіп тұрған хабарландырулар саны.",
"cmd.daemon.notifications.queue.count.result": "Кезекте тұрған нөмір: {{.Count}}",
"cmd.daemon.notifications.queue.clear.Usage": "Хабарландыру кезегін тазалау",
"cmd.daemon.notifications.queue.clear.Description": "Қатеден кейін жіберуді күтіп тұрған хабарландырулар кезегін тазалаңыз.",
"notifications_queue_clear_error": "Хабарландыру кезегі тазаланбады",
"notifications_queue_clear_success": "Хабарландыру кезегі тазартылды",
"cmd.daemon.ban.Usage": "Тыйым салу",
"cmd.daemon.ban.clear.Usage": "Барлық тыйым салынған IP мекенжайларын алып тастау",
"cmd.daemon.ban.clear.Description": "Барлық тыйым салынған IP мекенжайларын алып тастау.",
"ban_clear_error": "Барлық IP мекенжайларын бұғаттаудан шығару мүмкін емес",
"ban_clear_success": "Сұраныс сәтті орындалды",
"Command error": "Командалық қате",
"invalid log level": "Параметрлерде көрсетілген журнал деңгейі жарамсыз. Ол қазір мына күйге орнатылған: {{.Level}}. Жарамды мәндер: {{.Levels}}",
"invalid log encoding": "Жарамсыз кодтау параметрі. Қазіргі уақытта орнатылған: {{.Encoding}}. Жарамды мәндер: {{.Encodings}}",
@@ -27,6 +43,22 @@
"daemon is not running": "Демон жұмыс істемейді",
"daemon is not reopening logger": "Жын журналды қайта ашпады",
"alert.login.subject": "{{.IP}} IP мекенжайынан {{.User}} пайдаланушысына арналған SSH кіру хабарламасы",
"alert.login.body": "ОС-қа ssh арқылы кірді:\n Уақыт: {{.Time}}\n IP: {{.IP}}\n Пайдаланушы: {{.User}}\n Лог: {{.Log}}"
"time": "Уақыт: {{.Time}}",
"log": "Лог: ",
"user": "Пайдаланушы",
"access to user has been gained": "Пайдаланушыға кіру мүмкіндігі алынды",
"unknown": "белгісіз",
"blockSec": "{{.BlockSec}} секундқа блокталды",
"alert.subject": "Ескерту анықталды ({{.Name}}) (топ:{{.GroupName}})",
"alert.login.ssh.message": "ОС-қа ssh арқылы кірді.",
"alert.login.local.message": "ОЖ-ға TTY арқылы кірдіңіз.",
"alert.login.su.message": "su арқылы басқа пайдаланушыға кіру мүмкіндігі алынды.",
"alert.login.sudo.message": "sudo арқылы басқа пайдаланушыға кіру мүмкіндігі алынды.",
"alert.bruteForceProtection.subject": "Хакерлік әрекет анықталды және IP мекенжайы {{.IP}} бұғатталды. Ескерту ({{.Name}}) (Топ:{{.GroupName}})",
"alert.bruteForceProtection.subject-error": "Хакерлік әрекет анықталды, бірақ IP мекенжайы {{.IP}} бұғатталмаған. Ескерту ({{.Name}}) (Топ:{{.GroupName}})",
"alert.bruteForceProtection.error": "Қате: {{.Error}}",
"alert.bruteForceProtection.ssh.message": "SSH-ті күштеп қолдану әрекеті анықталды.",
"alert.bruteForceProtection.group._default.message": "Әдепкі топ."
}

View File

@@ -15,6 +15,22 @@
"cmd.daemon.reopen_logger.Usage": "Переоткрыть файл для логирования",
"cmd.daemon.reopen_logger.Description": "Переоткроет файл, куда пишутся логи от демона",
"cmd.daemon.notifications.Usage": "Уведомления",
"cmd.daemon.notifications.queue.Usage": "Очередь уведомлений",
"cmd.daemon.notifications.queue.count.Usage": "Количество уведомлений в отложенной очереди",
"cmd.daemon.notifications.queue.count.Description": "Количество уведомлений, ожидающих отправки после ошибки.",
"cmd.daemon.notifications.queue.count.result": "Количество в отложенной очереди: {{.Count}}",
"cmd.daemon.notifications.queue.clear.Usage": "Очистить очередь уведомлений",
"cmd.daemon.notifications.queue.clear.Description": "Очистить очередь уведомлений, ожидающих отправки после ошибки.",
"notifications_queue_clear_error": "Не удалось очистить очередь уведомлений",
"notifications_queue_clear_success": "Очередь уведомлений очищена",
"cmd.daemon.ban.Usage": "Бан",
"cmd.daemon.ban.clear.Usage": "Разбанить все забаненные IP адреса",
"cmd.daemon.ban.clear.Description": "Разбанить все забаненные IP адреса.",
"ban_clear_error": "Не смогли разбанить все IP адреса",
"ban_clear_success": "Запрос успешно выполнен",
"Command error": "Ошибка команды",
"invalid log level": "В настройках указан не верный уровень log. Сейчас указан: {{.Level}}. Допустимые значения: {{.Levels}}",
"invalid log encoding": "Неверная настройка encoding. Сейчас указан: {{.Encoding}}. Допустимые значения: {{.Encodings}}",
@@ -27,6 +43,22 @@
"daemon is not running": "Демон не запущен",
"daemon is not reopening logger": "Демон не открыл журнал повторно",
"alert.login.subject": "SSH-сообщение о входе пользователя {{.User}} с IP-адреса {{.IP}}",
"alert.login.body": "Вошли в ОС через ssh:\n Время: {{.Time}}\n IP: {{.IP}}\n Пользователь: {{.User}}\n Лог: {{.Log}}"
"time": "Время: {{.Time}}",
"log": "Лог: ",
"user": "Пользователь",
"access to user has been gained": "Получен доступ к пользователю",
"unknown": "неизвестный",
"blockSec": "Блокировка на {{.BlockSec}} секунд",
"alert.subject": "Обнаружено оповещение ({{.Name}}) (группа:{{.GroupName}})",
"alert.login.ssh.message": "Вошли в ОС через ssh.",
"alert.login.local.message": "Вошли в ОС через TTY.",
"alert.login.su.message": "Получили доступ к другому пользователю через su.",
"alert.login.sudo.message": "Получили доступ к другому пользователю через sudo.",
"alert.bruteForceProtection.subject": "Обнаружена попытка взлома, IP {{.IP}} заблокирован. Оповещение ({{.Name}}) (группа:{{.GroupName}})",
"alert.bruteForceProtection.subject-error": "Обнаружена попытка взлома, но IP {{.IP}} не заблокирован. Оповещение ({{.Name}}) (группа:{{.GroupName}})",
"alert.bruteForceProtection.error": "Ошибка: {{.Error}}",
"alert.bruteForceProtection.ssh.message": "Обнаружена попытка атаки на SSH методом перебора паролей.",
"alert.bruteForceProtection.group._default.message": "Группа по умолчанию."
}

View File

@@ -0,0 +1,25 @@
package regular_expression
import (
"regexp"
"sync"
)
type LazyRegexp struct {
pattern string
once sync.Once
re *regexp.Regexp
err error
}
func NewLazyRegexp(pattern string) *LazyRegexp {
return &LazyRegexp{pattern: pattern}
}
func (lr *LazyRegexp) Get() (*regexp.Regexp, error) {
lr.once.Do(func() {
lr.re, lr.err = regexp.Compile(lr.pattern)
})
return lr.re, lr.err
}

View File

@@ -0,0 +1,23 @@
package time_operation
import "time"
func IsRateLimited(lastTriggeredAtUnix int64, eventTime time.Time, rateLimit int64) bool {
if lastTriggeredAtUnix == 0 {
return true
}
return eventTime.Unix()-lastTriggeredAtUnix < rateLimit
}
func IsReset(lastTriggeredAtUnix int64, eventTime time.Time, resetPeriod int64) bool {
if resetPeriod == 0 || lastTriggeredAtUnix == 0 {
return false
}
if eventTime.Unix()-lastTriggeredAtUnix > resetPeriod {
return true
}
return false
}

View File

@@ -1,12 +1,15 @@
package analyzer
import (
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/setting/validate"
"github.com/spf13/viper"
)
type Setting struct {
Login Login
Login Login
LogAlert LogAlert
BruteForceProtection BruteForceProtection
}
func InitSetting(path string) (Setting, error) {
@@ -32,14 +35,46 @@ func InitSetting(path string) (Setting, error) {
func settingDefault() Setting {
return Setting{
Login: defaultLogin(),
Login: defaultLogin(),
LogAlert: defaultLogAlert(),
BruteForceProtection: defaultBruteForceProtection(),
}
}
func (s Setting) ToSources() ([]*config.Source, error) {
var sources []*config.Source
loginSources, err := s.Login.ToSources()
if err != nil {
return sources, err
}
sources = append(sources, loginSources...)
alertSources, err := s.LogAlert.ToSources()
if err != nil {
return sources, err
}
sources = append(sources, alertSources...)
bruteForceSources, err := s.BruteForceProtection.ToSources()
if err != nil {
return sources, err
}
sources = append(sources, bruteForceSources...)
return sources, nil
}
func (s Setting) Validate() error {
if err := s.Login.Validate(); err != nil {
return err
}
if err := s.LogAlert.Validate(); err != nil {
return err
}
if err := s.BruteForceProtection.Validate(); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,140 @@
package analyzer
import (
"errors"
"fmt"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config/brute_force_protection"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/i18n"
)
type BruteForceProtection struct {
Enabled bool `mapstructure:"enabled"`
Notify bool `mapstructure:"notify"`
RateLimitCount int `mapstructure:"rate_limit_count"`
RateLimitPeriod int `mapstructure:"rate_limit_period"`
RateLimitResetPeriod int `mapstructure:"rate_limit_reset_period"`
BlockingTime int `mapstructure:"blocking_time"`
SSHEnable bool `mapstructure:"ssh_enable"`
SSHNotify bool `mapstructure:"ssh_notify"`
SSHGroup string `mapstructure:"ssh_group"`
Groups []BruteForceProtectionGroup
Rules []BruteForceProtectionRule
}
func defaultBruteForceProtection() BruteForceProtection {
return BruteForceProtection{
Enabled: true,
Notify: true,
RateLimitCount: 5,
RateLimitPeriod: 3600,
RateLimitResetPeriod: 86400,
BlockingTime: 3600,
SSHEnable: true,
SSHNotify: true,
SSHGroup: "",
Groups: []BruteForceProtectionGroup{},
Rules: []BruteForceProtectionRule{},
}
}
func (p *BruteForceProtection) Validate() error {
if p.RateLimitPeriod <= 0 {
return errors.New("rate limit period must be greater than 0")
}
if p.RateLimitCount <= 0 {
return errors.New("rate limit count must be greater than 0")
}
if p.RateLimitResetPeriod < 0 {
return errors.New("rate limit reset period must be positive")
}
if p.BlockingTime < 0 {
return errors.New("blocking time must be positive")
}
return nil
}
func (p *BruteForceProtection) ToSources() ([]*config.Source, error) {
var sources []*config.Source
if !p.Enabled {
return sources, nil
}
groups, err := p.groups()
if err != nil {
return nil, err
}
if p.SSHEnable {
sshGroup := "_default"
if p.SSHGroup != "" {
if _, ok := groups[p.SSHGroup]; !ok {
return nil, errors.New("ssh group not found")
}
sshGroup = p.SSHGroup
}
sshSources, err := config.NewBruteForceProtectionSSH(p.Notify && p.SSHNotify, groups[sshGroup])
if err != nil {
return nil, err
}
sources = append(sources, sshSources...)
}
for _, rule := range p.Rules {
if !rule.Enabled {
continue
}
var group *brute_force_protection.Group
groupName := "_default"
if rule.Group != "" {
groupName = rule.Group
}
if _, ok := groups[groupName]; !ok {
return nil, fmt.Errorf("group %q not found", rule.Group)
}
group = groups[groupName]
source, err := rule.ToSource(p.Notify, group)
if err != nil {
return nil, err
}
sources = append(sources, source)
}
return sources, nil
}
func (p *BruteForceProtection) groups() (map[string]*brute_force_protection.Group, error) {
groups := make(map[string]*brute_force_protection.Group)
groups["_default"] = &brute_force_protection.Group{
Name: "_default",
Message: i18n.Lang.T("alert.bruteForceProtection.group._default.message"),
RateLimits: []brute_force_protection.RateLimit{
{
Count: uint32(p.RateLimitCount),
Period: uint32(p.RateLimitPeriod),
BlockingTimeSeconds: uint32(p.BlockingTime),
},
},
RateLimitResetPeriod: uint32(p.RateLimitResetPeriod),
}
for _, group := range p.Groups {
g, err := group.ToGroup()
if err != nil {
return nil, err
}
groups[g.Name] = g
}
return groups, nil
}

View File

@@ -0,0 +1,57 @@
package analyzer
import (
"fmt"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config/brute_force_protection"
)
type BruteForceProtectionGroup struct {
Name string `mapstructure:"name"`
Message string `mapstructure:"message"`
RateLimitResetPeriod int `mapstructure:"rate_limit_reset_period"`
RateLimits []BruteForceProtectionGroupRateLimit `mapstructure:"rate_limits"`
}
func (g *BruteForceProtectionGroup) ToGroup() (*brute_force_protection.Group, error) {
if err := g.validate(); err != nil {
return nil, err
}
var rateLimits []brute_force_protection.RateLimit
for _, rateLimit := range g.RateLimits {
rLimit, err := rateLimit.ToRateLimit()
if err != nil {
return nil, err
}
rateLimits = append(rateLimits, rLimit)
}
return &brute_force_protection.Group{
Name: g.Name,
Message: g.Message,
RateLimits: rateLimits,
RateLimitResetPeriod: uint32(g.RateLimitResetPeriod),
}, nil
}
func (g *BruteForceProtectionGroup) validate() error {
if g.Name == "" {
return fmt.Errorf("brute force protection group name is empty")
}
if !reName.MatchString(g.Name) {
return fmt.Errorf("brute force protection group invalid name: %s", g.Name)
}
if g.RateLimitResetPeriod < 0 {
return fmt.Errorf("brute force protection group rate limit reset period must be positive")
}
if len(g.RateLimits) == 0 {
return fmt.Errorf("brute force protection group rate limits is empty")
}
return nil
}

View File

@@ -0,0 +1,38 @@
package analyzer
import (
"fmt"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config/brute_force_protection"
)
type BruteForceProtectionGroupRateLimit struct {
Count int `mapstructure:"count"`
Period int `mapstructure:"period"`
BlockingTime int `mapstructure:"blocking_time"`
}
func (l *BruteForceProtectionGroupRateLimit) ToRateLimit() (brute_force_protection.RateLimit, error) {
if err := l.validate(); err != nil {
return brute_force_protection.RateLimit{}, err
}
return brute_force_protection.RateLimit{
Count: uint32(l.Count),
Period: uint32(l.Period),
BlockingTimeSeconds: uint32(l.BlockingTime),
}, nil
}
func (l *BruteForceProtectionGroupRateLimit) validate() error {
if l.Count <= 0 {
return fmt.Errorf("count must be greater than 0")
}
if l.Period <= 0 {
return fmt.Errorf("period must be greater than 0")
}
if l.BlockingTime < 0 {
return fmt.Errorf("blocking time must be non-negative")
}
return nil
}

View File

@@ -0,0 +1,48 @@
package analyzer
import (
"errors"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config/brute_force_protection"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/pkg/regular_expression"
)
type BruteForceProtectionPattern struct {
Regexp string `mapstructure:"regexp"`
IP int `mapstructure:"ip"`
Values []PatternValue
}
func (p *BruteForceProtectionPattern) ToPattern() (brute_force_protection.RegexPattern, error) {
if err := p.validate(); err != nil {
return brute_force_protection.RegexPattern{}, err
}
pattern := brute_force_protection.RegexPattern{
Regexp: regular_expression.NewLazyRegexp(p.Regexp),
IP: uint8(p.IP),
}
for _, value := range p.Values {
v, err := value.ToPatternValueForBruteForceProtection()
if err != nil {
return brute_force_protection.RegexPattern{}, err
}
pattern.Values = append(pattern.Values, v)
}
return pattern, nil
}
func (p *BruteForceProtectionPattern) validate() error {
if p.IP <= 0 || p.IP > 255 {
return errors.New("ip must be between 1 and 255")
}
if p.Regexp == "" {
return errors.New("regexp is empty")
}
return nil
}

View File

@@ -0,0 +1,69 @@
package analyzer
import (
"fmt"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config/brute_force_protection"
)
type BruteForceProtectionRule struct {
Enabled bool `mapstructure:"enabled"`
Notify bool `mapstructure:"notify"`
Name string `mapstructure:"name"`
Message string `mapstructure:"message"`
Group string `mapstructure:"group"`
Source Source
Patterns []BruteForceProtectionPattern
}
func (l *BruteForceProtectionRule) ToSource(isNotify bool, group *brute_force_protection.Group) (*config.Source, error) {
if err := l.validate(); err != nil {
return nil, err
}
if group == nil {
return nil, fmt.Errorf("brute force protection group is empty")
}
source, err := l.Source.ToSource()
if err != nil {
return nil, err
}
var patterns []brute_force_protection.RegexPattern
for _, pattern := range l.Patterns {
p, err := pattern.ToPattern()
if err != nil {
return nil, err
}
patterns = append(patterns, p)
}
if len(patterns) == 0 {
return nil, fmt.Errorf("patterns is empty")
}
source.BruteForceProtectionRule = &brute_force_protection.Rule{
Name: l.Name,
Message: l.Message,
IsNotification: isNotify && l.Notify,
Patterns: patterns,
Group: group,
}
return source, nil
}
func (l *BruteForceProtectionRule) validate() error {
if l.Name == "" {
return fmt.Errorf("brute force protection name is empty")
}
if !reName.MatchString(l.Name) {
return fmt.Errorf("brute force protection invalid name: %s", l.Name)
}
return nil
}

View File

@@ -0,0 +1,80 @@
package analyzer
import (
"fmt"
"regexp"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
)
var (
reName = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,254}$`)
)
type LogAlert struct {
Enabled bool `mapstructure:"enabled"`
Notify bool `mapstructure:"notify"`
Groups []LogAlertGroup
Rules []LogAlertRule
}
func defaultLogAlert() LogAlert {
return LogAlert{
Enabled: true,
Notify: true,
Groups: []LogAlertGroup{},
Rules: []LogAlertRule{},
}
}
func (l *LogAlert) Validate() error {
return nil
}
func (l *LogAlert) ToSources() ([]*config.Source, error) {
var sources []*config.Source
if !l.Enabled || len(l.Rules) == 0 {
return sources, nil
}
groups, err := l.groups()
if err != nil {
return nil, fmt.Errorf("groups: %w", err)
}
for _, rule := range l.Rules {
if !rule.Enabled {
continue
}
var group *config.AlertGroup
if rule.Group != "" {
if _, ok := groups[rule.Group]; !ok {
return nil, fmt.Errorf("group %q not found", rule.Group)
}
group = groups[rule.Group]
}
source, err := rule.ToSource(l.Notify, group)
if err != nil {
return nil, err
}
sources = append(sources, source)
}
return sources, nil
}
func (l *LogAlert) groups() (map[string]*config.AlertGroup, error) {
groups := make(map[string]*config.AlertGroup)
for _, group := range l.Groups {
g, err := group.ToGroup()
if err != nil {
return nil, err
}
groups[g.Name] = g
}
return groups, nil
}

View File

@@ -0,0 +1,57 @@
package analyzer
import (
"fmt"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
)
type LogAlertGroup struct {
Name string `mapstructure:"name"`
Message string `mapstructure:"message"`
RateLimitResetPeriod int `mapstructure:"rate_limit_reset_period"`
RateLimits []LogAlertGroupRateLimit `mapstructure:"rate_limits"`
}
func (g *LogAlertGroup) ToGroup() (*config.AlertGroup, error) {
if err := g.validate(); err != nil {
return nil, err
}
var rateLimits []config.RateLimit
for _, rateLimit := range g.RateLimits {
rLimit, err := rateLimit.ToRateLimit()
if err != nil {
return nil, err
}
rateLimits = append(rateLimits, rLimit)
}
return &config.AlertGroup{
Name: g.Name,
Message: g.Message,
RateLimits: rateLimits,
RateLimitResetPeriod: uint32(g.RateLimitResetPeriod),
}, nil
}
func (g *LogAlertGroup) validate() error {
if g.Name == "" {
return fmt.Errorf("alert group name is empty")
}
if !reName.MatchString(g.Name) {
return fmt.Errorf("alert group invalid name: %s", g.Name)
}
if g.RateLimitResetPeriod < 0 {
return fmt.Errorf("alert group rate limit reset period must be positive")
}
if len(g.RateLimits) == 0 {
return fmt.Errorf("alert group rate limits is empty")
}
return nil
}

View File

@@ -0,0 +1,33 @@
package analyzer
import (
"fmt"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
)
type LogAlertGroupRateLimit struct {
Count int `mapstructure:"count"`
Period int `mapstructure:"period"`
}
func (l *LogAlertGroupRateLimit) ToRateLimit() (config.RateLimit, error) {
if err := l.validate(); err != nil {
return config.RateLimit{}, err
}
return config.RateLimit{
Count: uint32(l.Count),
Period: uint32(l.Period),
}, nil
}
func (l *LogAlertGroupRateLimit) validate() error {
if l.Count <= 0 {
return fmt.Errorf("count must be greater than 0")
}
if l.Period <= 0 {
return fmt.Errorf("period must be greater than 0")
}
return nil
}

View File

@@ -0,0 +1,28 @@
package analyzer
import (
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/pkg/regular_expression"
)
type LogAlertPattern struct {
Regexp string `mapstructure:"regexp"`
Values []PatternValue
}
func (p *LogAlertPattern) ToPattern() (config.AlertRegexPattern, error) {
pattern := config.AlertRegexPattern{
Regexp: regular_expression.NewLazyRegexp(p.Regexp),
}
for _, value := range p.Values {
v, err := value.ToPatternValue()
if err != nil {
return config.AlertRegexPattern{}, err
}
pattern.Values = append(pattern.Values, v)
}
return pattern, nil
}

View File

@@ -0,0 +1,66 @@
package analyzer
import (
"fmt"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
)
type LogAlertRule struct {
Enabled bool `mapstructure:"enabled"`
Notify bool `mapstructure:"notify"`
Name string `mapstructure:"name"`
Message string `mapstructure:"message"`
Group string `mapstructure:"group"`
Source Source
Patterns []LogAlertPattern
}
func (l *LogAlertRule) ToSource(isNotify bool, group *config.AlertGroup) (*config.Source, error) {
if err := l.validate(); err != nil {
return nil, err
}
source, err := l.Source.ToSource()
if err != nil {
return nil, err
}
var patterns []config.AlertRegexPattern
for _, pattern := range l.Patterns {
p, err := pattern.ToPattern()
if err != nil {
return nil, err
}
patterns = append(patterns, p)
}
if len(patterns) == 0 {
return nil, fmt.Errorf("patterns is empty")
}
source.AlertRule = &config.AlertRule{
Name: l.Name,
Message: l.Message,
IsNotification: isNotify && l.Notify,
Patterns: patterns,
}
if group != nil {
source.AlertRule.Group = group
}
return source, nil
}
func (l *LogAlertRule) validate() error {
if l.Name == "" {
return fmt.Errorf("alert name is empty")
}
if !reName.MatchString(l.Name) {
return fmt.Errorf("alert invalid name: %s", l.Name)
}
return nil
}

View File

@@ -1,21 +1,87 @@
package analyzer
import (
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
)
type Login struct {
Enabled bool `mapstructure:"enabled"`
Notify bool `mapstructure:"notify"`
Enabled bool `mapstructure:"enabled"`
Notify bool `mapstructure:"notify"`
SSHEnable bool `mapstructure:"ssh_enable"`
SSHNotify bool `mapstructure:"ssh_notify"`
LocalEnable bool `mapstructure:"local_enable"`
LocalNotify bool `mapstructure:"local_notify"`
SuEnable bool `mapstructure:"su_enable"`
SuNotify bool `mapstructure:"su_notify"`
SudoEnable bool `mapstructure:"sudo_enable"`
SudoNotify bool `mapstructure:"sudo_notify"`
}
func defaultLogin() Login {
return Login{
Enabled: true,
Notify: true,
Enabled: true,
Notify: true,
SSHEnable: true,
SSHNotify: true,
LocalEnable: true,
LocalNotify: true,
SuEnable: true,
SuNotify: true,
SudoEnable: false,
SudoNotify: true,
}
}
func (l Login) Validate() error {
return nil
}
func (l Login) ToSources() ([]*config.Source, error) {
var sources []*config.Source
if !l.Enabled {
return sources, nil
}
if l.SSHEnable {
loginSources, err := config.NewLoginSSH(l.Notify && l.SSHNotify)
if err != nil {
return nil, err
}
sources = append(sources, loginSources...)
}
if l.LocalEnable {
loginSources, err := config.NewLoginLocal(l.Notify && l.LocalNotify)
if err != nil {
return nil, err
}
sources = append(sources, loginSources...)
}
if l.SuEnable {
loginSources, err := config.NewLoginSu(l.Notify && l.SuNotify)
if err != nil {
return nil, err
}
sources = append(sources, loginSources...)
}
if l.SudoEnable {
loginSources, err := config.NewLoginSudo(l.Notify && l.SudoNotify)
if err != nil {
return nil, err
}
sources = append(sources, loginSources...)
}
return sources, nil
}

View File

@@ -0,0 +1,51 @@
package analyzer
import (
"fmt"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config/brute_force_protection"
)
type PatternValue struct {
Name string `mapstructure:"name"`
Value int8 `mapstructure:"value"`
}
func (v *PatternValue) ToPatternValue() (config.PatternValue, error) {
if err := v.validate(); err != nil {
return config.PatternValue{}, err
}
value := config.PatternValue{
Name: v.Name,
Value: uint8(v.Value),
}
return value, nil
}
func (v *PatternValue) ToPatternValueForBruteForceProtection() (brute_force_protection.PatternValue, error) {
if err := v.validate(); err != nil {
return brute_force_protection.PatternValue{}, err
}
value := brute_force_protection.PatternValue{
Name: v.Name,
Value: uint8(v.Value),
}
return value, nil
}
func (v *PatternValue) validate() error {
if v.Value <= 0 {
return fmt.Errorf("value must be greater than 0")
}
if v.Name == "" {
return fmt.Errorf("name is required")
}
return nil
}

View File

@@ -0,0 +1,59 @@
package analyzer
import (
"errors"
"fmt"
"strings"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
)
type Source struct {
Type string `mapstructure:"type"`
Field string `mapstructure:"field"`
Match string `mapstructure:"match"`
Path string `mapstructure:"path"`
}
func (s *Source) ToSource() (*config.Source, error) {
switch s.Type {
case "journalctl":
field, err := s.journalField()
if err != nil {
return nil, err
}
journal, err := config.NewSourceJournal(field, s.Match)
if err != nil {
return nil, err
}
return &config.Source{
Type: config.SourceTypeJournal,
Journal: journal,
}, nil
case "file":
file, err := config.NewSourceFile(s.Path)
if err != nil {
return nil, err
}
return &config.Source{
Type: config.SourceTypeFile,
File: file,
}, nil
}
return nil, errors.New(fmt.Sprintf("unknown source type: %s. journalctl or file are allowed.", s.Type))
}
func (s *Source) journalField() (config.JournalField, error) {
switch strings.ToLower(s.Field) {
case "systemd_unit":
return config.JournalFieldSystemdUnit, nil
case "syslog_identifier":
return config.JournalFieldSyslogIdentifier, nil
}
return "", errors.New(fmt.Sprintf("unknown journal field: %s. systemd_unit or syslog_identifier are allowed.", s.Field))
}

View File

@@ -8,9 +8,11 @@ import (
)
type Setting struct {
Enabled bool `mapstructure:"enabled"`
ServerName string `mapstructure:"server_name"`
Email Email
Enabled bool `mapstructure:"enabled"`
EnableRetries bool `mapstructure:"enable_retries"`
RetryInterval int16 `mapstructure:"retry_interval"`
ServerName string `mapstructure:"server_name"`
Email Email
}
func InitSetting(path string) (Setting, error) {
@@ -44,9 +46,11 @@ func InitSetting(path string) (Setting, error) {
func settingDefault() Setting {
return Setting{
Enabled: false,
ServerName: "server",
Email: defaultEmail(),
Enabled: false,
EnableRetries: true,
RetryInterval: 600,
ServerName: "server",
Email: defaultEmail(),
}
}
@@ -63,5 +67,9 @@ func (s Setting) Validate() error {
return err
}
if s.RetryInterval < 1 {
return errors.New("retry_interval must be greater than 0")
}
return nil
}

View File

@@ -115,8 +115,10 @@ func (o *otherSettingsPath) ToNotificationsConfig() (notifications.Config, error
}
return notifications.Config{
Enabled: setting.Enabled,
ServerName: setting.ServerName,
Enabled: setting.Enabled,
EnableRetries: setting.EnableRetries,
RetryInterval: uint16(setting.RetryInterval),
ServerName: setting.ServerName,
Email: notifications.Email{
Host: setting.Email.Host,
Port: uint(setting.Email.Port),
@@ -150,18 +152,14 @@ func (o *otherSettingsPath) ToAnalyzerConfig(binaryLocations *binaryLocations) (
Journalctl: binaryLocations.Journalctl,
}
login := config.Login{
Enabled: setting.Login.Enabled,
Notify: setting.Login.Notify,
SSH: config.LoginSSH{
Enabled: setting.Login.SSHEnable,
Notify: setting.Login.SSHNotify,
},
sources, err := setting.ToSources()
if err != nil {
return config.Config{}, err
}
return config.Config{
BinPath: binPath,
Login: login,
Sources: sources,
}, nil
}

View File

@@ -16,6 +16,7 @@ type setting struct {
FallbackLanguage string `mapstructure:"fallback_language"`
PidFile string `mapstructure:"pid_file"`
SocketFile string `mapstructure:"socket_file"`
DataDir string `mapstructure:"data_dir"`
Log *log
BinaryLocations *binaryLocations
@@ -30,6 +31,7 @@ func settingDefault() *setting {
FallbackLanguage: "ru",
PidFile: "/var/run/kor-elf-shield/kor-elf-shield.pid",
SocketFile: "/var/run/kor-elf-shield/kor-elf-shield.sock",
DataDir: "/var/lib/kor-elf-shield/",
Log: logDefault(),
BinaryLocations: binaryLocationsDefault(),
@@ -56,6 +58,12 @@ func (s setting) ToDaemonOptions(dockerSupport bool) (daemon.DaemonOptions, erro
}))
}
if s.DataDir == "" {
return daemon.DaemonOptions{}, errors.New(i18n.Lang.T("parameter is not specified", map[string]any{
"Parameter": "data_dir",
}))
}
firewallConfig, err := s.OtherSettingsPath.ToFirewallConfig(dockerSupport)
if err != nil {
return daemon.DaemonOptions{}, err
@@ -69,6 +77,7 @@ func (s setting) ToDaemonOptions(dockerSupport bool) (daemon.DaemonOptions, erro
return daemon.DaemonOptions{
PathPidFile: s.PidFile,
PathSocketFile: s.SocketFile,
DataDir: s.DataDir,
PathNftables: s.BinaryLocations.Nftables,
ConfigFirewall: firewallConfig,
ConfigAnalyzer: analyzerConfig,