14 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
39 changed files with 1136 additions and 518 deletions

View File

@@ -1,3 +1,25 @@
## 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)
***
#### Русский

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

@@ -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,28 +27,27 @@ type analyzer struct {
}
func New(config config2.Config, logger log.Logger, notify notifications.Notifications) Analyzer {
var units []string
var matches []string
alertRuleIndex := analysisServices.NewAlertRuleIndex()
if config.Login.Enabled {
if config.Login.SSH.Enabled {
units = append(units, "_SYSTEMD_UNIT=ssh.service")
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
}
if config.Login.Local.Enabled {
units = append(units, "SYSLOG_IDENTIFIER=login")
}
if config.Login.Su.Enabled {
units = append(units, "SYSLOG_IDENTIFIER=su")
}
if config.Login.Sudo.Enabled {
units = append(units, "SYSLOG_IDENTIFIER=sudo")
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,
@@ -77,28 +76,9 @@ func (a *analyzer) processLogs(ctx context.Context) {
// Channel closed
return
}
a.logger.Debug(fmt.Sprintf("Received log entry: %s", entry))
a.logger.Debug(fmt.Sprintf("Received log entry: %v", entry))
switch {
case entry.Unit == "ssh.service":
if err := a.analysis.SSH(&entry); err != nil {
a.logger.Error(fmt.Sprintf("Failed to analyze SSH logs: %s", err))
}
case entry.SyslogIdentifier == "login":
if err := a.analysis.Locale(&entry); err != nil {
a.logger.Error(fmt.Sprintf("Failed to analyze locale logs: %s", err))
}
case entry.SyslogIdentifier == "sudo":
if err := a.analysis.Sudo(&entry); err != nil {
a.logger.Error(fmt.Sprintf("Failed to analyze sudo logs: %s", err))
}
case entry.SyslogIdentifier == "su":
if err := a.analysis.Su(&entry); err != nil {
a.logger.Error(fmt.Sprintf("Failed to analyze su logs: %s", err))
}
default:
a.logger.Debug(fmt.Sprintf("Unknown unit or SyslogIdentifier: %s", entry.Unit))
}
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,30 +1,148 @@
package config
type Login struct {
Enabled bool
Notify bool
SSH LoginSSH
Local LoginLocal
Su LoginSu
Sudo LoginSudo
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
}
type LoginLocal struct {
Enabled bool
Notify bool
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
}
type LoginSu struct {
Enabled bool
Notify bool
}
func NewLoginSudo(isNotify bool) []*Source {
var sources []*Source
type LoginSudo struct {
Enabled bool
Notify bool
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,52 +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
Locale(entry *analysisServices.Entry) error
Su(entry *analysisServices.Entry) error
Sudo(entry *analysisServices.Entry) error
Alert(entry *analysisServices.Entry)
}
type analysis struct {
sshService analysisServices.Analysis
localeService analysisServices.Analysis
suService analysisServices.Analysis
sudoService 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),
localeService: analysisServices.NewLocale(config, logger, notify),
suService: analysisServices.NewSu(config, logger, notify),
sudoService: analysisServices.NewSudo(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) Locale(entry *analysisServices.Entry) error {
return a.localeService.Process(entry)
}
func (a *analysis) Su(entry *analysisServices.Entry) error {
return a.suService.Process(entry)
}
func (a *analysis) Sudo(entry *analysisServices.Entry) error {
return a.sudoService.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,14 +1,14 @@
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
@@ -16,14 +16,22 @@ type Entry struct {
Time time.Time
}
type processReturn struct {
found bool
subject string
body string
type regexField struct {
name string
value string
}
type EmptyAnalysis struct{}
func getValueStartEndByRegexIndex(valueId int, idx []int) (start int, end int, err error) {
id := 2 * valueId
func (empty *EmptyAnalysis) Process(_ *Entry) error {
return nil
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

@@ -1,78 +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 locale struct {
login localeLogin
logger log.Logger
notify notifications.Notifications
}
type localeLogin struct {
enabled bool
notify bool
}
func NewLocale(config *config.Config, logger log.Logger, notify notifications.Notifications) Analysis {
if !config.Login.Enabled || !config.Login.Local.Enabled {
return &EmptyAnalysis{}
}
return &locale{
login: localeLogin{
enabled: config.Login.Enabled && config.Login.SSH.Enabled,
notify: config.Login.Notify && config.Login.SSH.Notify,
},
logger: logger,
notify: notify,
}
}
func (l *locale) Process(entry *Entry) error {
if l.login.enabled {
result, err := l.login.process(entry)
if err != nil {
l.logger.Error(fmt.Sprintf("Failed to process TTY login: %s", err))
} else if result.found {
if l.login.notify {
l.notify.SendAsync(notifications.Message{Subject: result.subject, Body: result.body})
}
l.logger.Info(fmt.Sprintf("TTY login detected: %s", entry.Message))
}
}
return nil
}
func (l *localeLogin) process(entry *Entry) (processReturn, error) {
re := regexp.MustCompile(`^pam_unix\(login:session\): session opened for user (\S+)\(\S+\) by \S+`)
matches := re.FindStringSubmatch(entry.Message)
if matches != nil {
user := matches[1]
return processReturn{
found: true,
subject: i18n.Lang.T("alert.login.locale.subject", map[string]any{
"User": user,
}),
body: i18n.Lang.T("alert.login.locale.body", map[string]any{
"User": user,
"Log": entry.Message,
"Time": entry.Time,
}),
}, nil
}
return processReturn{found: false}, 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,81 +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
}
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) (processReturn, 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 processReturn{
found: true,
subject: i18n.Lang.T("alert.login.ssh.subject", map[string]any{
"User": user,
"IP": ip,
}),
body: i18n.Lang.T("alert.login.ssh.body", map[string]any{
"User": user,
"IP": ip,
"Log": entry.Message,
"Time": entry.Time,
}),
}, nil
}
return processReturn{found: false}, nil
}

View File

@@ -1,81 +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 su struct {
login suLogin
logger log.Logger
notify notifications.Notifications
}
type suLogin struct {
enabled bool
notify bool
}
func NewSu(config *config.Config, logger log.Logger, notify notifications.Notifications) Analysis {
if !config.Login.Enabled || !config.Login.Su.Enabled {
return &EmptyAnalysis{}
}
return &su{
login: suLogin{
enabled: config.Login.Enabled && config.Login.Su.Enabled,
notify: config.Login.Notify && config.Login.Su.Notify,
},
logger: logger,
notify: notify,
}
}
func (l *su) Process(entry *Entry) error {
if l.login.enabled {
result, err := l.login.process(entry)
if err != nil {
l.logger.Error(fmt.Sprintf("Failed to process Su login: %s", err))
} else if result.found {
if l.login.notify {
l.notify.SendAsync(notifications.Message{Subject: result.subject, Body: result.body})
}
l.logger.Info(fmt.Sprintf("Su login detected: %s", entry.Message))
}
}
return nil
}
func (l *suLogin) process(entry *Entry) (processReturn, error) {
re := regexp.MustCompile(`^pam_unix\(su:session\): session opened for user (\S+)\(\S+\) by (\S+)\(\S+\)`)
matches := re.FindStringSubmatch(entry.Message)
if matches != nil {
user := matches[1]
byUser := matches[2]
return processReturn{
found: true,
subject: i18n.Lang.T("alert.login.su.subject", map[string]any{
"User": user,
"ByUser": byUser,
}),
body: i18n.Lang.T("alert.login.su.body", map[string]any{
"User": user,
"ByUser": byUser,
"Log": entry.Message,
"Time": entry.Time,
}),
}, nil
}
return processReturn{found: false}, nil
}

View File

@@ -1,81 +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 sudo struct {
login sudoLogin
logger log.Logger
notify notifications.Notifications
}
type sudoLogin struct {
enabled bool
notify bool
}
func NewSudo(config *config.Config, logger log.Logger, notify notifications.Notifications) Analysis {
if !config.Login.Enabled || !config.Login.Su.Enabled {
return &EmptyAnalysis{}
}
return &sudo{
login: sudoLogin{
enabled: config.Login.Enabled && config.Login.Sudo.Enabled,
notify: config.Login.Notify && config.Login.Sudo.Notify,
},
logger: logger,
notify: notify,
}
}
func (s *sudo) 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 Sudo 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("Sudo login detected: %s", entry.Message))
}
}
return nil
}
func (s *sudoLogin) process(entry *Entry) (processReturn, error) {
re := regexp.MustCompile(`^pam_unix\(sudo:session\): session opened for user (\S+)\(\S+\) by (\S+)\(\S+\)`)
matches := re.FindStringSubmatch(entry.Message)
if matches != nil {
user := matches[1]
byUser := matches[2]
return processReturn{
found: true,
subject: i18n.Lang.T("alert.login.sudo.subject", map[string]any{
"User": user,
"ByUser": byUser,
}),
body: i18n.Lang.T("alert.login.sudo.body", map[string]any{
"User": user,
"ByUser": byUser,
"Log": entry.Message,
"Time": entry.Time,
}),
}, nil
}
return processReturn{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
@@ -37,17 +38,17 @@ type journalRawEntry struct {
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
}
@@ -75,11 +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 index, unit := range s.units {
for index, match := range s.matches {
if index > 0 {
args = append(args, "+")
}
args = append(args, unit)
args = append(args, match)
}
cmd := exec.CommandContext(ctx, s.path, args...)
@@ -119,6 +120,7 @@ func (s *systemd) watch(ctx context.Context, logChan chan<- analysisServices.Ent
}
logChan <- analysisServices.Entry{
Source: config.SourceTypeJournal,
Message: raw.Message,
Unit: raw.Unit,
PID: raw.PID,
@@ -131,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

@@ -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,15 +37,14 @@
"daemon is not running": "Daemon is not running",
"daemon is not reopening logger": "The daemon did not reopen the log",
"alert.login.ssh.subject": "SSH login alert for user {{.User}} from {{.IP}}",
"alert.login.ssh.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.login.locale.subject": "Login message for user {{.User}} (TTY)",
"alert.login.locale.body": "Logged into the OS via TTY:\n Time: {{.Time}}\n User: {{.User}}\n Log: {{.Log}}",
"alert.login.su.subject": "User {{.ByUser}} has accessed user {{.User}} via su",
"alert.login.su.body": "User {{.ByUser}} accessed user {{.User}} via su.\nTime: {{.Time}}\nLog: {{.Log}}",
"alert.login.sudo.subject": "User {{.ByUser}} has accessed user {{.User}} via sudo",
"alert.login.sudo.body": "User {{.ByUser}} accessed user {{.User}} via sudo.\nTime: {{.Time}}\nLog: {{.Log}}"
"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,15 +37,14 @@
"daemon is not running": "Демон жұмыс істемейді",
"daemon is not reopening logger": "Жын журналды қайта ашпады",
"alert.login.ssh.subject": "{{.IP}} IP мекенжайынан {{.User}} пайдаланушысына арналған SSH кіру хабарламасы",
"alert.login.ssh.body": "ОС-қа ssh арқылы кірді:\n Уақыт: {{.Time}}\n IP: {{.IP}}\n Пайдаланушы: {{.User}}\n Лог: {{.Log}}",
"time": "Уақыт: {{.Time}}",
"log": "Лог: ",
"user": "Пайдаланушы",
"access to user has been gained": "Пайдаланушыға кіру мүмкіндігі алынды",
"alert.login.locale.subject": "{{.User}} пайдаланушысына арналған кіру хабарламасы (TTY)",
"alert.login.locale.body": "ОЖ-ға TTY арқылы кірдіңіз:\n Уақыт: {{.Time}}\n Пайдаланушы: {{.User}}\n Лог: {{.Log}}",
"alert.login.su.subject": "{{.ByUser}} пайдаланушысы {{.User}} пайдаланушысына su арқылы кіру мүмкіндігін алды",
"alert.login.su.body": "{{.ByUser}} пайдаланушысы {{.User}} пайдаланушысына su арқылы кірді.\nУақыты: {{.Time}}\nЛог: {{.Log}}",
"alert.login.sudo.subject": "{{.ByUser}} пайдаланушысы {{.User}} пайдаланушысына sudo арқылы кіру мүмкіндігін алды",
"alert.login.sudo.body": "{{.ByUser}} пайдаланушысы {{.User}} пайдаланушысына sudo арқылы кірді.\nУақыты: {{.Time}}\nЛог: {{.Log}}"
"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,15 +37,14 @@
"daemon is not running": "Демон не запущен",
"daemon is not reopening logger": "Демон не открыл журнал повторно",
"alert.login.ssh.subject": "SSH-сообщение о входе пользователя {{.User}} с IP-адреса {{.IP}}",
"alert.login.ssh.body": "Вошли в ОС через ssh:\n Время: {{.Time}}\n IP: {{.IP}}\n Пользователь: {{.User}}\n Лог: {{.Log}}",
"time": "Время: {{.Time}}",
"log": "Лог: ",
"user": "Пользователь",
"access to user has been gained": "Получен доступ к пользователю",
"alert.login.locale.subject": "Сообщение о входе пользователя {{.User}} (TTY)",
"alert.login.locale.body": "Вошли в ОС через TTY:\n Время: {{.Time}}\n Пользователь: {{.User}}\n Лог: {{.Log}}",
"alert.login.su.subject": "Пользователь {{.ByUser}} получил доступ к пользователю {{.User}} через su",
"alert.login.su.body": "Пользователь {{.ByUser}} получил доступ к пользователю {{.User}} через su.\nВремя: {{.Time}}\nЛог: {{.Log}}",
"alert.login.sudo.subject": "Пользователь {{.ByUser}} получил доступ к пользователю {{.User}} через sudo",
"alert.login.sudo.body": "Пользователь {{.ByUser}} получил доступ к пользователю {{.User}} через sudo.\nВремя: {{.Time}}\nЛог: {{.Log}}"
"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,5 +1,9 @@
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"`
@@ -39,3 +43,29 @@ func defaultLogin() Login {
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,30 +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,
},
Local: config.LoginLocal{
Enabled: setting.Login.LocalEnable,
Notify: setting.Login.LocalNotify,
},
Su: config.LoginSu{
Enabled: setting.Login.SuEnable,
Notify: setting.Login.SuNotify,
},
Sudo: config.LoginSudo{
Enabled: setting.Login.SudoEnable,
Notify: setting.Login.SudoNotify,
},
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,