3 Commits

Author SHA1 Message Date
8615c79f12 Refactor log analyzer to support SSH login detection
- Moved `Entry` type to `analysis` package for better organization.
- Introduced `SSH` analysis service to detect and notify about SSH logins.
- Added notification and logging for detected SSH login events.
2025-12-31 22:52:12 +05:00
b5686a2ee6 Add systemd log integration for analyzer service
- Implemented `systemd` log monitoring using `journalctl`.
- Added `BinPath` configuration for specifying binary paths.
- Introduced `ssh` unit monitoring for authorization tracking.
- Updated analyzer lifecycle to integrate log processing.
- Enhanced validation for `journalctl` path in settings.
- Updated default configurations with `journalctl` path.
2025-12-30 20:57:35 +05:00
e78685c130 Add support for analyzer service and configuration
- Introduced `analyzer` service for log parsing and authorization tracking.
- Added dedicated analyzer configuration via `analyzer.toml`.
- Integrated analyzer setup and lifecycle management into daemon runtime.
- Enhanced `setting` package to include analyzer settings parsing and validation.
- Updated daemon options to support analyzer configuration.
- Extended default configuration files for analyzer settings.
2025-12-30 15:03:41 +05:00
22 changed files with 624 additions and 19 deletions

View File

@@ -0,0 +1,47 @@
###############################################################################
# РАЗДЕЛ:Отслеживать авторизаций
# ***
# SECTION:Track authorizations
###############################################################################
[login]
###
# Включает группу отслеживания авторизации.
# Если отключено, отслеживание авторизации работать не будет.
# По умолчанию: true
# ***
# Enables the authorization tracking group.
# If disabled, no authorization tracking will work.
# Default: true
###
enabled = true
###
# Включает уведомления об авторизации.
# Если отключено, они будут отображаться в логах только на уровне = "info".
# По умолчанию: true
# ***
# Enables authorization notifications.
# If disabled, they will only appear in the logs under level = "info".
# Default: true
###
notify = true
###
# Включает отслеживание авторизации по ssh.
# По умолчанию: true
# ***
# Enables tracking of SSH authorization.
# Default: true
###
ssh_enable = true
###
# Включает уведомления об авторизации по ssh.
# Если отключено, они будут отображаться в логах только на уровне = "info".
# По умолчанию: true
# ***
# Enables SSH authorization notifications.
# If disabled, they will only appear in the logs under level = "info".
# Default: true
###
ssh_notify = true

View File

@@ -192,6 +192,15 @@ log_error_paths = ["stderr"]
###
nftables = "/usr/sbin/nft"
###
# Укажите путь к journalctl. Возможно в вашей ОС путь может отличаться.
# По умолчанию: /bin/journalctl
# ***
# Specify the path to journalctl. The path may differ in your OS.
# Default: /bin/journalctl
###
journalctl = "/bin/journalctl"
###############################################################################
# РАЗДЕЛ:Пути к другим настройкам
# ***
@@ -221,3 +230,14 @@ firewall = "/etc/kor-elf-shield/firewall.toml"
# Default: /etc/kor-elf-shield/notifications.toml
###
notifications = "/etc/kor-elf-shield/notifications.toml"
###
# Укажите путь к настройкам парсинга логов.
# Файл должен иметь расширение .toml.
# По умолчанию: /etc/kor-elf-shield/analyzer.toml
# ***
# Specify the path to the log parsing settings.
# The file must have the .toml extension.
# Default: /etc/kor-elf-shield/analyzer.toml
###
analyzer = "/etc/kor-elf-shield/analyzer.toml"

View File

@@ -35,17 +35,6 @@ func runDaemon(ctx context.Context, _ *cli.Command) error {
_ = logger.Sync()
}()
notificationsConfig, err := setting.Config.OtherSettingsPath.ToNotificationsConfig()
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
}
notificationsClient := notifications.New(notificationsConfig, logger)
config, err := setting.Config.ToDaemonOptions()
if err != nil {
logger.Fatal(err.Error())
@@ -55,7 +44,16 @@ func runDaemon(ctx context.Context, _ *cli.Command) error {
return err
}
d, err := daemon.NewDaemon(config, logger, notificationsClient)
notificationsService, err := newNotificationsService(logger)
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
}
d, err := daemon.NewDaemon(config, logger, notificationsService)
if err != nil {
logger.Fatal(err.Error())
@@ -75,3 +73,12 @@ func runDaemon(ctx context.Context, _ *cli.Command) error {
return nil
}
func newNotificationsService(logger log.Logger) (notifications.Notifications, error) {
config, err := setting.Config.OtherSettingsPath.ToNotificationsConfig()
if err != nil {
return nil, err
}
return notifications.New(config, logger), nil
}

View File

@@ -0,0 +1,81 @@
package analyzer
import (
"context"
"fmt"
config2 "git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
analyzerLog "git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/log"
analysisServices "git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/log/analysis"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/notifications"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/log"
)
type Analyzer interface {
Run(ctx context.Context)
Close() error
}
type analyzer struct {
config config2.Config
logger log.Logger
notify notifications.Notifications
systemd analyzerLog.Systemd
analysis analyzerLog.Analysis
}
func New(config config2.Config, logger log.Logger, notify notifications.Notifications) Analyzer {
units := []string{}
if config.Login.Enabled && config.Login.SSH.Enabled {
units = append(units, "ssh")
}
systemdService := analyzerLog.NewSystemd(config.BinPath.Journalctl, units, logger)
analysisService := analyzerLog.NewAnalysis(&config, logger, notify)
return &analyzer{
config: config,
logger: logger,
notify: notify,
systemd: systemdService,
analysis: analysisService,
}
}
func (a *analyzer) Run(ctx context.Context) {
logChan := make(chan analysisServices.Entry, 1000)
go a.systemd.Run(ctx, logChan)
go a.processLogs(ctx, logChan)
a.logger.Debug("Analyzer is start")
}
func (a *analyzer) processLogs(ctx context.Context, logChan <-chan analysisServices.Entry) {
for {
select {
case <-ctx.Done():
return
case entry := <-logChan:
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))
}
}
}
}
func (a *analyzer) Close() error {
if err := a.systemd.Close(); err != nil {
return err
}
a.logger.Debug("Analyzer is stop")
return nil
}

View File

@@ -0,0 +1,5 @@
package config
type BinPath struct {
Journalctl string
}

View File

@@ -0,0 +1,6 @@
package config
type Config struct {
BinPath BinPath
Login Login
}

View File

@@ -0,0 +1,12 @@
package config
type Login struct {
Enabled bool
Notify bool
SSH LoginSSH
}
type LoginSSH struct {
Enabled bool
Notify bool
}

View File

@@ -0,0 +1,31 @@
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
}
type analysis struct {
sshService analysisServices.Analysis
logger log.Logger
notify notifications.Notifications
}
func NewAnalysis(config *config.Config, logger log.Logger, notify notifications.Notifications) Analysis {
return &analysis{
sshService: analysisServices.NewSSH(config, logger, notify),
logger: logger,
notify: notify,
}
}
func (a *analysis) SSH(entry *analysisServices.Entry) error {
return a.sshService.Process(entry)
}

View File

@@ -0,0 +1,22 @@
package analysis
import (
"time"
)
type Analysis interface {
Process(entry *Entry) error
}
type Entry struct {
Message string
Unit string
PID string
Time time.Time
}
type EmptyAnalysis struct{}
func (empty *EmptyAnalysis) Process(entry *Entry) error {
return nil
}

View File

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

View File

@@ -0,0 +1,147 @@
package log
import (
"context"
"encoding/json"
"fmt"
"io"
"os/exec"
"strconv"
"sync"
"time"
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"
)
type Systemd interface {
Run(ctx context.Context, logChan chan<- analysisServices.Entry)
Close() error
}
type systemd struct {
path string
units []string
logger log.Logger
cmd *exec.Cmd
mu sync.Mutex
}
type journalRawEntry struct {
Message string `json:"MESSAGE"`
Unit string `json:"_SYSTEMD_UNIT"`
PID string `json:"_PID"`
SourceTimestamp string `json:"_SOURCE_REALTIME_TIMESTAMP"`
RealtimeTimestamp string `json:"__REALTIME_TIMESTAMP"`
}
func NewSystemd(path string, units []string, logger log.Logger) Systemd {
return &systemd{
path: path,
units: units,
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")
return
}
args := []string{"-f", "-n", "0", "-o", "json"}
for _, unit := range s.units {
args = append(args, "-u", unit)
}
s.logger.Debug("Journalctl started")
for {
select {
case <-ctx.Done():
return
default:
if err := s.watch(ctx, args, logChan); err != nil {
s.logger.Error(fmt.Sprintf("Journalctl exited with error: %v", err))
}
// Pause before restarting to avoid CPU load during persistent errors
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
s.logger.Warn("Journalctl connection lost. Restarting in 5s...")
continue
}
}
}
}
func (s *systemd) watch(ctx context.Context, args []string, logChan chan<- analysisServices.Entry) error {
cmd := exec.CommandContext(ctx, s.path, args...)
s.mu.Lock()
s.cmd = cmd
s.mu.Unlock()
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe error: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start error: %w", err)
}
decoder := json.NewDecoder(stdout)
for {
var raw journalRawEntry
if err := decoder.Decode(&raw); err != nil {
if err == io.EOF {
break // The process terminated normally or was killed.
}
return fmt.Errorf("decode error: %w", err)
}
tsStr := raw.SourceTimestamp
if tsStr == "" {
tsStr = raw.RealtimeTimestamp
}
var entryTime time.Time
if usec, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
entryTime = time.Unix(0, usec*int64(time.Microsecond))
} else {
entryTime = time.Now()
}
logChan <- analysisServices.Entry{
Message: raw.Message,
Unit: raw.Unit,
PID: raw.PID,
Time: entryTime,
}
}
return cmd.Wait()
}
func (s *systemd) Close() error {
if s.units == nil {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if s.cmd != nil && s.cmd.Process != nil {
s.logger.Debug("Stopping journalctl")
// Force journalctl to quit on shutdown
return s.cmd.Process.Kill()
}
s.logger.Debug("Journalctl stopped")
return nil
}

View File

@@ -5,6 +5,7 @@ import (
"errors"
"time"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/firewall"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/notifications"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/pidfile"
@@ -23,6 +24,7 @@ type daemon struct {
logger log.Logger
firewall firewall.API
notifications notifications.Notifications
analyzer analyzer.Analyzer
stopCh chan struct{}
}
@@ -59,6 +61,11 @@ func (d *daemon) Run(ctx context.Context, isTesting bool, testingInterval uint16
_ = d.notifications.Close()
}()
d.analyzer.Run(ctx)
defer func() {
_ = d.analyzer.Close()
}()
go d.socket.Run(ctx, d.socketCommand)
d.runWorker(ctx, isTesting, testingInterval)

View File

@@ -1,10 +1,14 @@
package daemon
import "git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/firewall"
import (
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer/config"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/firewall"
)
type DaemonOptions struct {
PathPidFile string
PathSocketFile string
PathNftables string
ConfigFirewall firewall.Config
ConfigAnalyzer config.Config
}

View File

@@ -3,6 +3,7 @@ package daemon
import (
"errors"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/analyzer"
firewall2 "git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/firewall"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/notifications"
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/daemon/pidfile"
@@ -27,11 +28,14 @@ func NewDaemon(opts DaemonOptions, logger log.Logger, notifications notification
firewall, err := firewall2.New(opts.PathNftables, logger, opts.ConfigFirewall)
analyzerService := analyzer.New(opts.ConfigAnalyzer, logger, notifications)
return &daemon{
pidFile: pidFile,
socket: sock,
logger: logger,
firewall: firewall,
notifications: notifications,
analyzer: analyzerService,
}, nil
}

View File

@@ -25,5 +25,8 @@
"daemon stopped": "Daemon stopped",
"daemon stop failed": "Daemon stop failed",
"daemon is not running": "Daemon is not running",
"daemon is not reopening logger": "The daemon did not reopen the log"
}
"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}}"
}

View File

@@ -25,5 +25,8 @@
"daemon stopped": "Жын тоқтатылды",
"daemon stop failed": "Жынды тоқтату сәтсіз аяқталды",
"daemon is not running": "Демон жұмыс істемейді",
"daemon is not reopening logger": "Жын журналды қайта ашпады"
"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}}"
}

View File

@@ -25,5 +25,8 @@
"daemon stopped": "Демон остановлен",
"daemon stop failed": "Остановка демона не удалась",
"daemon is not running": "Демон не запущен",
"daemon is not reopening logger": "Демон не открыл журнал повторно"
"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}}"
}

View File

@@ -0,0 +1,45 @@
package analyzer
import (
"git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/setting/validate"
"github.com/spf13/viper"
)
type Setting struct {
Login Login
}
func InitSetting(path string) (Setting, error) {
if err := validate.IsTomlFile(path, "otherSettingsPath.analyzer"); err != nil {
return Setting{}, err
}
setting := settingDefault()
v := viper.New()
v.SetConfigType("toml")
v.SetConfigFile(path)
if err := v.ReadInConfig(); err != nil {
return Setting{}, err
}
if err := v.Unmarshal(&setting); err != nil {
return Setting{}, err
}
return setting, nil
}
func settingDefault() Setting {
return Setting{
Login: defaultLogin(),
}
}
func (s Setting) Validate() error {
if err := s.Login.Validate(); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,21 @@
package analyzer
type Login struct {
Enabled bool `mapstructure:"enabled"`
Notify bool `mapstructure:"notify"`
SSHEnable bool `mapstructure:"ssh_enable"`
SSHNotify bool `mapstructure:"ssh_notify"`
}
func defaultLogin() Login {
return Login{
Enabled: true,
Notify: true,
SSHEnable: true,
SSHNotify: true,
}
}
func (l Login) Validate() error {
return nil
}

View File

@@ -1,11 +1,13 @@
package setting
type binaryLocations struct {
Nftables string `mapstructure:"nftables"`
Nftables string `mapstructure:"nftables"`
Journalctl string `mapstructure:"journalctl"`
}
func binaryLocationsDefault() *binaryLocations {
return &binaryLocations{
Nftables: "/usr/sbin/nft",
Nftables: "/usr/sbin/nft",
Journalctl: "/bin/journalctl",
}
}

View File

@@ -1,8 +1,13 @@
package setting
import (
"errors"
"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/firewall"
"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"
analyzerSetting "git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/setting/analyzer"
firewallSetting "git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/setting/firewall"
notificationsSetting "git.kor-elf.net/kor-elf-shield/kor-elf-shield/internal/setting/notifications"
"github.com/wneessen/go-mail"
@@ -11,12 +16,14 @@ import (
type otherSettingsPath struct {
Firewall string `mapstructure:"firewall"`
Notifications string `mapstructure:"notifications"`
Analyzer string `mapstructure:"analyzer"`
}
func otherSettingsPathDefault() *otherSettingsPath {
return &otherSettingsPath{
Firewall: "/etc/kor-elf-shield/firewall.toml",
Notifications: "/etc/kor-elf-shield/notifications.toml",
Analyzer: "/etc/kor-elf-shield/analyzer.toml",
}
}
@@ -117,3 +124,38 @@ func (o *otherSettingsPath) ToNotificationsConfig() (notifications.Config, error
},
}, nil
}
func (o *otherSettingsPath) ToAnalyzerConfig(binaryLocations *binaryLocations) (config.Config, error) {
if binaryLocations.Journalctl == "" {
return config.Config{}, errors.New(i18n.Lang.T("parameter is not specified", map[string]any{
"Parameter": "binaryLocations.journalctl",
}))
}
setting, err := analyzerSetting.InitSetting(o.Analyzer)
if err != nil {
return config.Config{}, err
}
if err := setting.Validate(); err != nil {
return config.Config{}, err
}
binPath := config.BinPath{
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,
},
}
return config.Config{
BinPath: binPath,
Login: login,
}, nil
}

View File

@@ -61,11 +61,17 @@ func (s setting) ToDaemonOptions() (daemon.DaemonOptions, error) {
return daemon.DaemonOptions{}, err
}
analyzerConfig, err := s.OtherSettingsPath.ToAnalyzerConfig(s.BinaryLocations)
if err != nil {
return daemon.DaemonOptions{}, err
}
return daemon.DaemonOptions{
PathPidFile: s.PidFile,
PathSocketFile: s.SocketFile,
PathNftables: s.BinaryLocations.Nftables,
ConfigFirewall: firewallConfig,
ConfigAnalyzer: analyzerConfig,
}, nil
}