33 Commits

Author SHA1 Message Date
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
a75df70922 Update CHANGELOG.md with release date for version 0.4.0 2026-01-11 16:50:56 +05:00
a84f1ccde6 Update CHANGELOG.md to document IP blocking fix during Docker container redirection 2026-01-11 16:49:58 +05:00
0d13f851dd Fixed a bug where IP blocking for containers did not work when Docker was enabled 2026-01-11 16:44:33 +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
40 changed files with 1394 additions and 204 deletions

View File

@@ -1,4 +1,50 @@
## 0.4.0 (soon)
## 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)
***
#### Русский
* Удалён параметр options.docker_support из файла firewall.toml. Настройки от Docker перенесены в файл docker.toml.
@@ -8,6 +54,7 @@
* Исправлена ошибка:
* Настройка binaryLocations.docker не работала.
* Программа аварийно завершалась после остановки Docker'а.
* Указанные в настройках IP-адреса не блокировались во время перенаправления в контейнер Docker.
***
#### English
* Removed the options.docker_support parameter from firewall.toml. Docker settings have been moved to the docker.toml file.
@@ -17,6 +64,7 @@
* Fixed error:
* The binaryLocations.docker setting did not work.
* The program crashed after Docker was stopped.
* The IP addresses specified in the settings were not blocked during redirection to the Docker container.
***
## 0.3.0 (4.1.2026)
***

View File

@@ -710,6 +710,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 +798,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
--------------------------------------------------------------------------------

View File

@@ -12,6 +12,7 @@
* Linux 5.2+
* nftables
* Systemd
* journalctl
***

View File

@@ -12,6 +12,7 @@
* Linux 5.2+
* nftables
* Systemd
* journalctl
***

View File

@@ -45,3 +45,66 @@ 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

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

1
go.mod
View File

@@ -8,6 +8,7 @@ require (
github.com/spf13/viper v1.21.0
github.com/urfave/cli/v3 v3.4.1
github.com/wneessen/go-mail v0.7.2
go.etcd.io/bbolt v1.4.3
go.uber.org/zap v1.27.0
golang.org/x/sys v0.36.0
golang.org/x/text v0.29.0

4
go.sum
View File

@@ -42,6 +42,8 @@ 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/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=
@@ -50,6 +52,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/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/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=

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,19 @@ 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()
}()
notificationsService, err := newNotificationsService(repositories.NotificationsQueue(), logger)
if err != nil {
logger.Fatal(err.Error())
@@ -81,13 +95,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,7 @@ func NewMainApp(appVer AppVersion, defaultConfigPath string) *cli.Command {
daemon.CmdStop(),
daemon.CmdStatus(),
daemon.CmdReopenLogger(),
daemon.CmdNotifications(),
}
return app

View File

@@ -27,13 +27,27 @@ type analyzer struct {
}
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")
var matches []string
alertRuleIndex := analysisServices.NewAlertRuleIndex()
for _, source := range config.Sources {
switch source.Type {
case config2.SourceTypeJournal:
match := source.Journal.JournalctlMatch()
matches = append(matches, match)
default:
logger.Error(fmt.Sprintf("Unknown source type: %s", source.Type))
continue
}
err := alertRuleIndex.Add(source)
if err != nil {
logger.Error(fmt.Sprintf("Failed to add alert rule: %s", err))
}
}
systemdService := analyzerLog.NewSystemd(config.BinPath.Journalctl, units, logger)
analysisService := analyzerLog.NewAnalysis(&config, logger, notify)
systemdService := analyzerLog.NewSystemd(config.BinPath.Journalctl, matches, logger)
analysisService := analyzerLog.NewAnalysis(alertRuleIndex, logger, notify)
return &analyzer{
config: config,
@@ -62,16 +76,9 @@ 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.Alert(&entry)
}
}
}

View File

@@ -1,6 +1,90 @@
package config
import (
"regexp"
"sync"
"time"
)
type SourceType string
const (
SourceTypeJournal SourceType = "journalctl"
)
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 (s *SourceJournal) JournalctlMatch() string {
return string(s.Field) + "=" + s.Match
}
type Source struct {
Type SourceType
Journal *SourceJournal
AlertRule *AlertRule
}
type AlertRule struct {
Name string
Message string
IsNotification bool
Patterns []AlertRegexPattern
Group *AlertGroup
}
type AlertRegexPattern struct {
Regexp *LazyRegexp
Values []PatternValue
}
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
}
type PatternValue struct {
Name string
Value uint8
}
type RateLimit struct {
Count uint32
Period time.Duration
}
type AlertGroup struct {
Name string
Message string
RateLimits []RateLimit
RateLimitResetPeriod time.Duration
}

View File

@@ -1,12 +1,148 @@
package config
type Login struct {
Enabled bool
Notify bool
SSH LoginSSH
import "git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/i18n"
func NewLoginSSH(isNotify bool) []*Source {
var sources []*Source
source := &Source{
Type: SourceTypeJournal,
Journal: &SourceJournal{
Field: JournalFieldSystemdUnit,
Match: "ssh.service",
},
AlertRule: &AlertRule{
Name: "_login-ssh",
Message: i18n.Lang.T("alert.login.ssh.message"),
IsNotification: isNotify,
Patterns: []AlertRegexPattern{
{
Regexp: 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
}
type LoginSSH struct {
Enabled bool
Notify bool
func NewLoginLocal(isNotify bool) []*Source {
var sources []*Source
source := &Source{
Type: SourceTypeJournal,
Journal: &SourceJournal{
Field: JournalFieldSyslogIdentifier,
Match: "login",
},
AlertRule: &AlertRule{
Name: "_login-local",
Message: i18n.Lang.T("alert.login.local.message"),
IsNotification: isNotify,
Patterns: []AlertRegexPattern{
{
Regexp: 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
}
func NewLoginSu(isNotify bool) []*Source {
var sources []*Source
source := &Source{
Type: SourceTypeJournal,
Journal: &SourceJournal{
Field: JournalFieldSyslogIdentifier,
Match: "su",
},
AlertRule: &AlertRule{
Name: "_login-su",
Message: i18n.Lang.T("alert.login.su.message"),
IsNotification: isNotify,
Patterns: []AlertRegexPattern{
{
Regexp: 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
}
func NewLoginSudo(isNotify bool) []*Source {
var sources []*Source
source := &Source{
Type: SourceTypeJournal,
Journal: &SourceJournal{
Field: JournalFieldSyslogIdentifier,
Match: "sudo",
},
AlertRule: &AlertRule{
Name: "_login-sudo",
Message: i18n.Lang.T("alert.login.sudo.message"),
IsNotification: isNotify,
Patterns: []AlertRegexPattern{
{
Regexp: 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
}

View File

@@ -1,31 +1,25 @@
package log
import (
"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/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)
}
type analysis struct {
sshService analysisServices.Analysis
logger log.Logger
notify notifications.Notifications
alertService analysisServices.Alert
}
func NewAnalysis(config *config.Config, logger log.Logger, notify notifications.Notifications) Analysis {
func NewAnalysis(alertRuleIndex analysisServices.AlertRuleIndex, logger log.Logger, notify notifications.Notifications) Analysis {
return &analysis{
sshService: analysisServices.NewSSH(config, logger, notify),
logger: logger,
notify: notify,
alertService: analysisServices.NewAlert(alertRuleIndex, 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)
}

View File

@@ -0,0 +1,135 @@
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/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)
}
type alert struct {
ruleIndex AlertRuleIndex
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(ruleIndex AlertRuleIndex, logger log.Logger, notify notifications.Notifications) Alert {
return &alert{
ruleIndex: ruleIndex,
logger: logger,
notify: notify,
}
}
func (a *alert) Analyze(entry *Entry) {
rules, err := a.ruleIndex.Rules(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 {
groupName = rule.Group.Name
} 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) 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 {
a.logger.Error(fmt.Sprintf("Failed to get value start/end: %s", err))
break
}
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

@@ -1,22 +1,37 @@
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 {
Message string
Unit string
PID string
Time time.Time
Source config.SourceType
Message string
Unit string
PID string
SyslogIdentifier string
Time time.Time
}
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,70 @@
package analysis
import (
"errors"
"fmt"
config2 "git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
)
type AlertRuleIndex struct {
byKey map[string][]*config2.AlertRule
}
func (idx *AlertRuleIndex) Add(source *config2.Source) error {
key, err := generateIndexKeyBySource(source)
if err != nil {
return err
}
idx.byKey[key] = append(idx.byKey[key], source.AlertRule)
return nil
}
func (idx *AlertRuleIndex) Rules(entry *Entry) ([]*config2.AlertRule, error) {
var rules []*config2.AlertRule
keys, err := generateIndexKeysByEntry(entry)
if err != nil {
return rules, err
}
for _, key := range keys {
rules = append(rules, idx.byKey[key]...)
}
return rules, nil
}
func NewAlertRuleIndex() AlertRuleIndex {
return AlertRuleIndex{byKey: make(map[string][]*config2.AlertRule)}
}
func generateIndexKeyBySource(source *config2.Source) (string, error) {
switch source.Type {
case config2.SourceTypeJournal:
match := source.Journal.JournalctlMatch()
if source.Journal.Field == "" || source.Journal.Match == "" {
return "", errors.New("journalctl match is empty")
}
return string(source.Type) + ":" + match, nil
}
return "", errors.New(fmt.Sprintf("unknown source type: %s", source.Type))
}
func generateIndexKeysByEntry(entry *Entry) ([]string, error) {
var keys []string
switch entry.Source {
case config2.SourceTypeJournal:
source := string(entry.Source) + ":"
keys = append(keys, source+string(config2.JournalFieldSystemdUnit)+"="+entry.Unit)
keys = append(keys, source+string(config2.JournalFieldSyslogIdentifier)+"="+entry.SyslogIdentifier)
return keys, nil
}
return []string{}, 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

@@ -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...)
@@ -115,10 +120,12 @@ func (s *systemd) watch(ctx context.Context, logChan chan<- analysisServices.Ent
}
logChan <- analysisServices.Entry{
Message: raw.Message,
Unit: raw.Unit,
PID: raw.PID,
Time: entryTime,
Source: config.SourceTypeJournal,
Message: raw.Message,
Unit: raw.Unit,
PID: raw.PID,
SyslogIdentifier: raw.SyslogIdentifier,
Time: entryTime,
}
}
@@ -126,7 +133,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,10 @@ func (d *daemon) runWorker(ctx context.Context, isTesting bool, testingInterval
return
case <-stopTestingCh:
d.logger.Info("Testing interval expired, stopping service")
err := d.notifications.DBQueueClear()
if err != nil {
d.logger.Error(fmt.Sprintf("failed to clear notifications queue: %v", err))
}
d.Stop()
return
case <-d.stopCh:
@@ -126,6 +132,15 @@ 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")
default:
_ = socket.Write("unknown command")
return errors.New("unknown command")

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

@@ -0,0 +1,60 @@
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 (
app = "app.db"
)
type Repositories interface {
NotificationsQueue() repository.NotificationsQueueRepository
Close() error
}
type repositories struct {
notificationsQueue repository.NotificationsQueueRepository
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+app, 0600, &bbolt.Options{Timeout: 3 * time.Second})
return &repositories{
notificationsQueue: repository.NewNotificationsQueueRepository(appDB),
db: []*bbolt.DB{appDB},
}, nil
}
func (r *repositories) NotificationsQueue() repository.NotificationsQueueRepository {
return r.notificationsQueue
}
func (r *repositories) Close() error {
for _, db := range r.db {
_ = db.Close()
}
return nil
}

View File

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

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: notificationsQueue,
}
}
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,22 @@
package repository
import (
"encoding/binary"
"go.etcd.io/bbolt"
)
const (
notificationsQueue = "notifications_queue"
)
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
}

View File

@@ -27,6 +27,9 @@ type Chains interface {
NewLocalOutput() error
LocalOutput() LocalOutput
NewLocalForward() error
LocalForward() LocalForward
ClearRules() error
NewNoneChain(chain string) (Chain, error)
@@ -39,8 +42,9 @@ type chains struct {
forward Forward
packetFilter PacketFilter
localInput LocalInput
localOutput LocalOutput
localInput LocalInput
localOutput LocalOutput
localForward LocalForward
family nftFamily.Type
table string
@@ -147,6 +151,19 @@ func (c *chains) LocalOutput() LocalOutput {
return c.localOutput
}
func (c *chains) NewLocalForward() error {
localForward, err := newLocalForward(c.nft, c.family, c.table)
if err != nil {
return err
}
c.localForward = localForward
return nil
}
func (c *chains) LocalForward() LocalForward {
return c.localForward
}
func (c *chains) ClearRules() error {
return clearRules(c.nft, c.family, c.table)
}

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 LocalForward interface {
AddRule(expr ...string) error
AddRuleIn(AddRuleFunc func(expr ...string) error) error
}
type localForward struct {
nft nft.NFT
family family.Type
table string
chain string
}
func newLocalForward(nft nft.NFT, family family.Type, table string) (LocalForward, error) {
chain := "local-forward"
if err := nft.Chain().Add(family, table, chain, nftChain.TypeNone); err != nil {
return nil, err
}
return &localForward{
nft: nft,
family: family,
table: table,
chain: chain,
}, nil
}
func (l *localForward) AddRule(expr ...string) error {
return l.nft.Rule().Add(l.family, l.table, l.chain, expr...)
}
func (l *localForward) AddRuleIn(AddRuleFunc func(expr ...string) error) error {
return AddRuleFunc("iifname != \"lo\" counter jump " + l.chain)
}

View File

@@ -8,6 +8,10 @@ func (f *firewall) reloadForward() error {
}
chain := f.chains.Forward()
if err := f.reloadForwardAddIPs(); err != nil {
return err
}
if f.config.Options.DockerSupport {
if err := f.docker.NftChains().ForwardFilterJump(chain.AddRule); err != nil {
return err
@@ -23,3 +27,53 @@ func (f *firewall) reloadForward() error {
return nil
}
func (f *firewall) reloadForwardAddIPs() error {
if err := f.chains.NewLocalForward(); err != nil {
return err
}
chain := f.chains.LocalForward()
if err := chain.AddRuleIn(f.chains.Forward().AddRule); err != nil {
return err
}
for _, ipConfig := range f.config.IP4.InIPs {
if ipConfig.Action != ActionDrop && ipConfig.Action != ActionReject {
continue
}
if err := forwardAddIP(chain.AddRule, ipConfig, "ip"); err != nil {
return err
}
}
if !f.config.IP6.Enable {
return nil
}
for _, ipConfig := range f.config.IP6.InIPs {
if ipConfig.Action != ActionDrop && ipConfig.Action != ActionReject {
continue
}
if err := forwardAddIP(chain.AddRule, ipConfig, "ip6"); err != nil {
return err
}
}
return nil
}
func forwardAddIP(addRuleFunc func(expr ...string) error, config ConfigIP, ipMatch string) error {
rule := ipMatch + " saddr " + config.IP + " iifname != \"lo\""
// There, during routing, the port changes and then the IP blocking rule will not work.
//if !config.OnlyIP {
// rule += " " + config.Protocol.String() + " dport " + strconv.Itoa(int(config.Port))
//}
rule += " counter " + config.Action.String()
if err := addRuleFunc(rule); err != nil {
return err
}
return nil
}

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

@@ -8,6 +8,7 @@ import (
type DaemonOptions struct {
PathPidFile string
PathSocketFile string
DataDir string
PathNftables string
ConfigFirewall firewall.Config
ConfigAnalyzer config.Config

View File

@@ -15,6 +15,16 @@
"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.",
"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 +37,14 @@
"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",
"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."
}

View File

@@ -15,6 +15,16 @@
"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": "Хабарландыру кезегі тазартылды",
"Command error": "Командалық қате",
"invalid log level": "Параметрлерде көрсетілген журнал деңгейі жарамсыз. Ол қазір мына күйге орнатылған: {{.Level}}. Жарамды мәндер: {{.Levels}}",
"invalid log encoding": "Жарамсыз кодтау параметрі. Қазіргі уақытта орнатылған: {{.Encoding}}. Жарамды мәндер: {{.Encodings}}",
@@ -27,6 +37,14 @@
"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": "Пайдаланушыға кіру мүмкіндігі алынды",
"alert.subject": "Ескерту анықталды ({{.Name}}) (топ:{{.GroupName}})",
"alert.login.ssh.message": "ОС-қа ssh арқылы кірді.",
"alert.login.local.message": "ОЖ-ға TTY арқылы кірдіңіз.",
"alert.login.su.message": "su арқылы басқа пайдаланушыға кіру мүмкіндігі алынды.",
"alert.login.sudo.message": "sudo арқылы басқа пайдаланушыға кіру мүмкіндігі алынды."
}

View File

@@ -15,6 +15,16 @@
"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": "Очередь уведомлений очищена",
"Command error": "Ошибка команды",
"invalid log level": "В настройках указан не верный уровень log. Сейчас указан: {{.Level}}. Допустимые значения: {{.Levels}}",
"invalid log encoding": "Неверная настройка encoding. Сейчас указан: {{.Encoding}}. Допустимые значения: {{.Encodings}}",
@@ -27,6 +37,14 @@
"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": "Получен доступ к пользователю",
"alert.subject": "Обнаружено оповещение ({{.Name}}) (группа:{{.GroupName}})",
"alert.login.ssh.message": "Вошли в ОС через ssh.",
"alert.login.local.message": "Вошли в ОС через TTY.",
"alert.login.su.message": "Получили доступ к другому пользователю через su.",
"alert.login.sudo.message": "Получили доступ к другому пользователю через sudo."
}

View File

@@ -1,6 +1,7 @@
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"
)
@@ -36,6 +37,18 @@ func settingDefault() Setting {
}
}
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...)
return sources, nil
}
func (s Setting) Validate() error {
if err := s.Login.Validate(); err != nil {
return err

View File

@@ -1,21 +1,71 @@
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 {
sources = append(sources, config.NewLoginSSH(l.Notify && l.SSHNotify)...)
}
if l.LocalEnable {
sources = append(sources, config.NewLoginLocal(l.Notify && l.LocalNotify)...)
}
if l.SuEnable {
sources = append(sources, config.NewLoginSu(l.Notify && l.SuNotify)...)
}
if l.SudoEnable {
sources = append(sources, config.NewLoginSudo(l.Notify && l.SudoNotify)...)
}
return sources, nil
}

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,