mirror of
https://github.com/Security-Onion-Solutions/securityonion.git
synced 2026-07-18 14:53:40 +02:00
Compare commits
92
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce0095154b | ||
|
|
4886034fef | ||
|
|
7b58c67a02 | ||
|
|
6876b25280 | ||
|
|
30f3bddb8b | ||
|
|
811b799b0b | ||
|
|
28b3a54a27 | ||
|
|
aaea6dbd58 | ||
|
|
403d846a89 | ||
|
|
8095b82841 | ||
|
|
141116f550 | ||
|
|
f6d3cbe08d | ||
|
|
9e7e6edae0 | ||
|
|
6f61e7c901 | ||
|
|
cc2bfc26e2 | ||
|
|
073e32520b | ||
|
|
5867b50720 | ||
|
|
8a16ead33d | ||
|
|
f9b154ccef | ||
|
|
3503d0c33d | ||
|
|
517538a9a7 | ||
|
|
33ed6359bb | ||
|
|
23c74f1727 | ||
|
|
b76f9d022e | ||
|
|
02318f065c | ||
|
|
f958212bea | ||
|
|
376607d292 | ||
|
|
186bf86e99 | ||
|
|
bd70dd53fb | ||
|
|
be7d8a2aa7 | ||
|
|
5178d5fd0e | ||
|
|
fee62ab976 | ||
|
|
618712469e | ||
|
|
8b488f9226 | ||
|
|
1657480d31 | ||
|
|
63d4061500 | ||
|
|
405dc52587 | ||
|
|
e42f7cd6fc | ||
|
|
8167ae3282 | ||
|
|
2cd889782d | ||
|
|
87a5639643 | ||
|
|
ed533efb7b | ||
|
|
5af6c56996 | ||
|
|
99e9fc1c3b | ||
|
|
89e6a746c8 | ||
|
|
52885e28c5 | ||
|
|
fbeac25ee9 | ||
|
|
8a3f5d0f81 | ||
|
|
9a71f64a35 | ||
|
|
40c02b3149 | ||
|
|
5fd5df54b4 | ||
|
|
66a1141b84 | ||
|
|
3310e19ee4 | ||
|
|
f441d98e71 | ||
|
|
a330bea25e | ||
|
|
33c24cd136 | ||
|
|
12f4447875 | ||
|
|
da94788255 | ||
|
|
fa2ae1b87f | ||
|
|
5bf9751adf | ||
|
|
3effdbc91e | ||
|
|
8836529496 | ||
|
|
b09c3776b7 | ||
|
|
dfdb1fbaeb | ||
|
|
61aa963a2d | ||
|
|
d71e80cf66 | ||
|
|
33a116357d | ||
|
|
8c17ae0f66 | ||
|
|
f54939b444 | ||
|
|
d48a22e37e | ||
|
|
6393d08e86 | ||
|
|
730c828bec | ||
|
|
b4e5171415 | ||
|
|
84decc1db6 | ||
|
|
7d4d6a0756 | ||
|
|
66c0a662fc | ||
|
|
778cc055ea | ||
|
|
932deab751 | ||
|
|
1281f0ee37 | ||
|
|
f774334b6c | ||
|
|
7fcace34c4 | ||
|
|
9541024eb7 | ||
|
|
0d166ef732 | ||
|
|
f7d2994f8b | ||
|
|
8f0757606d | ||
|
|
0a8f2e01a0 | ||
|
|
4546d7bc52 | ||
|
|
17849d8758 | ||
|
|
d3d30a587c | ||
|
|
034711d148 | ||
|
|
a0cf0489d6 | ||
|
|
613d31c8a6 |
@@ -5,6 +5,7 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- "salt/sensoroni/files/analyzers/**"
|
- "salt/sensoroni/files/analyzers/**"
|
||||||
- "salt/manager/tools/sbin/**"
|
- "salt/manager/tools/sbin/**"
|
||||||
|
- "salt/_beacons/**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -14,7 +15,7 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.14"]
|
python-version: ["3.14"]
|
||||||
python-code-path: ["salt/sensoroni/files/analyzers", "salt/manager/tools/sbin"]
|
python-code-path: ["salt/sensoroni/files/analyzers", "salt/manager/tools/sbin", "salt/_beacons"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ base:
|
|||||||
- ca
|
- ca
|
||||||
- global.soc_global
|
- global.soc_global
|
||||||
- global.adv_global
|
- global.adv_global
|
||||||
|
- salt.soc_salt
|
||||||
|
- salt.adv_salt
|
||||||
- docker.soc_docker
|
- docker.soc_docker
|
||||||
- docker.adv_docker
|
- docker.adv_docker
|
||||||
- influxdb.token
|
- influxdb.token
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one
|
||||||
|
# or more contributor license agreements. Licensed under the Elastic License 2.0 as shown at
|
||||||
|
# https://securityonion.net/license; you may not use this file except in compliance with the
|
||||||
|
# Elastic License 2.0.
|
||||||
|
|
||||||
|
# Custom salt beacon that watches the SOC audit_settings table in postgres for
|
||||||
|
# new settings changes and emits a beacon event per new row. This replaces the
|
||||||
|
# inotify watch on /opt/so/saltstack/local/pillar -- instead of monitoring pillar
|
||||||
|
# files on disk, we monitor the securityonion.audit_settings table that SOC writes to.
|
||||||
|
#
|
||||||
|
# Detection is poll-based with a monotonic `id` watermark persisted to
|
||||||
|
# WATERMARK_FILE: each pass selects rows with id greater than the last id seen,
|
||||||
|
# which makes it self-healing (a missed poll simply catches up on the next one).
|
||||||
|
#
|
||||||
|
# Each emitted event carries setting_id and node_id; the push_pillar reactor maps
|
||||||
|
# setting_id -> app via pillar_push_map.yaml and writes a push intent, after which
|
||||||
|
# the existing so-push-drainer / orch.push_batch pipeline takes over unchanged.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
WATERMARK_FILE = '/opt/so/state/postgres_pillar_beacon_watch.id'
|
||||||
|
CONTAINER = 'so-postgres'
|
||||||
|
DATABASE = 'securityonion'
|
||||||
|
|
||||||
|
# Unaligned, tuples-only psql output with a field separator that cannot appear in
|
||||||
|
# an id/setting_id/node_id, so we can split each row reliably.
|
||||||
|
FIELD_SEP = '\x1f'
|
||||||
|
|
||||||
|
|
||||||
|
def __virtual__():
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def validate(config):
|
||||||
|
return True, 'valid'
|
||||||
|
|
||||||
|
|
||||||
|
def _read_watermark():
|
||||||
|
# Returns the last processed id, or None if the watermark has not been seeded.
|
||||||
|
try:
|
||||||
|
with open(WATERMARK_FILE, 'r') as f:
|
||||||
|
return int((f.read() or '').strip())
|
||||||
|
except (IOError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_watermark(value):
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(WATERMARK_FILE), exist_ok=True)
|
||||||
|
tmp = WATERMARK_FILE + '.tmp'
|
||||||
|
with open(tmp, 'w') as f:
|
||||||
|
f.write(str(int(value)))
|
||||||
|
os.rename(tmp, WATERMARK_FILE)
|
||||||
|
except OSError:
|
||||||
|
log.exception('postgres_pillar_beacon: failed to persist watermark to %s', WATERMARK_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def _query(sql):
|
||||||
|
# Run a query against securityonion inside the so-postgres container over the unix
|
||||||
|
# socket (trust auth, no password). Returns stdout on success, or None on any
|
||||||
|
# failure so the caller can no-op and retry on the next interval.
|
||||||
|
cmd = [
|
||||||
|
'docker', 'exec', CONTAINER,
|
||||||
|
'psql', '-U', 'postgres', '-d', DATABASE,
|
||||||
|
'-tA', '-F', FIELD_SEP, '-c', sql,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
log.warning('postgres_pillar_beacon: psql timed out')
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
log.exception('postgres_pillar_beacon: failed to exec psql')
|
||||||
|
return None
|
||||||
|
if result.returncode != 0:
|
||||||
|
log.warning('postgres_pillar_beacon: psql failed (rc=%s): %s',
|
||||||
|
result.returncode, (result.stderr or '').strip())
|
||||||
|
return None
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def beacon(config): # noqa: C901
|
||||||
|
retval = []
|
||||||
|
|
||||||
|
watermark = _read_watermark()
|
||||||
|
|
||||||
|
# First run / missing watermark: seed to the current MAX(id) and emit nothing
|
||||||
|
# so we never replay the entire settings history into a fleetwide push.
|
||||||
|
if watermark is None:
|
||||||
|
seed = _query('SELECT COALESCE(MAX(id), 0) FROM audit_settings;')
|
||||||
|
if seed is None:
|
||||||
|
return retval # postgres not ready yet; retry next interval
|
||||||
|
try:
|
||||||
|
_write_watermark(int((seed or '0').strip() or 0))
|
||||||
|
except ValueError:
|
||||||
|
log.warning('postgres_pillar_beacon: could not parse MAX(id) seed: %r', seed)
|
||||||
|
return retval
|
||||||
|
|
||||||
|
rows = _query(
|
||||||
|
"SELECT id, setting_id, COALESCE(node_id, '') FROM audit_settings "
|
||||||
|
"WHERE id > %d ORDER BY id;" % watermark
|
||||||
|
)
|
||||||
|
if rows is None:
|
||||||
|
return retval
|
||||||
|
|
||||||
|
max_id = watermark
|
||||||
|
for line in rows.splitlines():
|
||||||
|
# Do NOT str.strip() the whole line: Python treats the \x1f field
|
||||||
|
# separator (and \x1c-\x1e) as whitespace, so stripping would eat an
|
||||||
|
# empty trailing node_id field and make the row look malformed.
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = line.split(FIELD_SEP)
|
||||||
|
if len(parts) < 3:
|
||||||
|
log.warning('postgres_pillar_beacon: skipping malformed row: %r', line)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
row_id = int(parts[0])
|
||||||
|
except ValueError:
|
||||||
|
log.warning('postgres_pillar_beacon: skipping row with non-int id: %r', line)
|
||||||
|
continue
|
||||||
|
setting_id = parts[1]
|
||||||
|
node_id = parts[2]
|
||||||
|
retval.append({
|
||||||
|
'tag': 'audit_settings',
|
||||||
|
'id': row_id,
|
||||||
|
'setting_id': setting_id,
|
||||||
|
'node_id': node_id,
|
||||||
|
})
|
||||||
|
if row_id > max_id:
|
||||||
|
max_id = row_id
|
||||||
|
|
||||||
|
if max_id > watermark:
|
||||||
|
_write_watermark(max_id)
|
||||||
|
log.info('postgres_pillar_beacon: emitted %d change(s), watermark %d -> %d',
|
||||||
|
len(retval), watermark, max_id)
|
||||||
|
|
||||||
|
return retval
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one
|
||||||
|
# or more contributor license agreements. Licensed under the Elastic License 2.0 as shown at
|
||||||
|
# https://securityonion.net/license; you may not use this file except in compliance with the
|
||||||
|
# Elastic License 2.0.
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import postgres_pillar_beacon
|
||||||
|
|
||||||
|
|
||||||
|
class TestPostgresPillarBeacon(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Point WATERMARK_FILE at a throwaway dir so the real read/write helpers
|
||||||
|
# (and their os.makedirs/os.rename) run against actual files, then clean
|
||||||
|
# it all up in tearDown.
|
||||||
|
self.tmpdir = tempfile.mkdtemp()
|
||||||
|
self.watermark = os.path.join(self.tmpdir, 'state', 'watch.id')
|
||||||
|
patcher = patch.object(postgres_pillar_beacon, 'WATERMARK_FILE', self.watermark)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
|
# -- trivial contract -------------------------------------------------
|
||||||
|
|
||||||
|
def test_virtual_returns_true(self):
|
||||||
|
self.assertTrue(postgres_pillar_beacon.__virtual__())
|
||||||
|
|
||||||
|
def test_validate_returns_valid(self):
|
||||||
|
self.assertEqual(postgres_pillar_beacon.validate({}), (True, 'valid'))
|
||||||
|
|
||||||
|
# -- _read_watermark --------------------------------------------------
|
||||||
|
|
||||||
|
def test_read_watermark_valid(self):
|
||||||
|
postgres_pillar_beacon._write_watermark(42)
|
||||||
|
self.assertEqual(postgres_pillar_beacon._read_watermark(), 42)
|
||||||
|
|
||||||
|
def test_read_watermark_missing_file_returns_none(self):
|
||||||
|
# tmp watermark file was never created
|
||||||
|
self.assertIsNone(postgres_pillar_beacon._read_watermark())
|
||||||
|
|
||||||
|
def test_read_watermark_garbage_returns_none(self):
|
||||||
|
os.makedirs(os.path.dirname(self.watermark), exist_ok=True)
|
||||||
|
with open(self.watermark, 'w') as f:
|
||||||
|
f.write('nope')
|
||||||
|
self.assertIsNone(postgres_pillar_beacon._read_watermark())
|
||||||
|
|
||||||
|
# -- _write_watermark -------------------------------------------------
|
||||||
|
|
||||||
|
def test_write_watermark_round_trip(self):
|
||||||
|
postgres_pillar_beacon._write_watermark(7)
|
||||||
|
with open(self.watermark) as f:
|
||||||
|
self.assertEqual(f.read(), '7')
|
||||||
|
|
||||||
|
def test_write_watermark_swallows_oserror(self):
|
||||||
|
with patch.object(postgres_pillar_beacon.os, 'makedirs', side_effect=OSError):
|
||||||
|
# Must not raise; failure is logged and the beacon retries next pass.
|
||||||
|
postgres_pillar_beacon._write_watermark(5)
|
||||||
|
self.assertFalse(os.path.exists(self.watermark))
|
||||||
|
|
||||||
|
# -- _query -----------------------------------------------------------
|
||||||
|
|
||||||
|
def test_query_success_returns_stdout_and_builds_argv(self):
|
||||||
|
completed = subprocess.CompletedProcess(args=[], returncode=0, stdout='rows', stderr='')
|
||||||
|
with patch.object(postgres_pillar_beacon.subprocess, 'run', return_value=completed) as mock_run:
|
||||||
|
result = postgres_pillar_beacon._query('SELECT 1;')
|
||||||
|
self.assertEqual(result, 'rows')
|
||||||
|
argv = mock_run.call_args[0][0]
|
||||||
|
self.assertEqual(argv[:5], ['docker', 'exec', 'so-postgres', 'psql', '-U'])
|
||||||
|
self.assertIn('SELECT 1;', argv)
|
||||||
|
self.assertFalse(mock_run.call_args[1].get('shell', False))
|
||||||
|
|
||||||
|
def test_query_timeout_returns_none(self):
|
||||||
|
with patch.object(postgres_pillar_beacon.subprocess, 'run',
|
||||||
|
side_effect=subprocess.TimeoutExpired(cmd='psql', timeout=30)):
|
||||||
|
self.assertIsNone(postgres_pillar_beacon._query('SELECT 1;'))
|
||||||
|
|
||||||
|
def test_query_generic_exception_returns_none(self):
|
||||||
|
with patch.object(postgres_pillar_beacon.subprocess, 'run', side_effect=Exception('boom')):
|
||||||
|
self.assertIsNone(postgres_pillar_beacon._query('SELECT 1;'))
|
||||||
|
|
||||||
|
def test_query_nonzero_returncode_returns_none(self):
|
||||||
|
completed = subprocess.CompletedProcess(args=[], returncode=1, stdout='', stderr='bad')
|
||||||
|
with patch.object(postgres_pillar_beacon.subprocess, 'run', return_value=completed):
|
||||||
|
self.assertIsNone(postgres_pillar_beacon._query('SELECT 1;'))
|
||||||
|
|
||||||
|
# -- beacon: first run / seeding --------------------------------------
|
||||||
|
|
||||||
|
def test_beacon_seeds_when_postgres_not_ready(self):
|
||||||
|
with patch.object(postgres_pillar_beacon, '_read_watermark', return_value=None), \
|
||||||
|
patch.object(postgres_pillar_beacon, '_query', return_value=None), \
|
||||||
|
patch.object(postgres_pillar_beacon, '_write_watermark') as mock_write:
|
||||||
|
self.assertEqual(postgres_pillar_beacon.beacon({}), [])
|
||||||
|
mock_write.assert_not_called()
|
||||||
|
|
||||||
|
def test_beacon_seeds_to_max_id_and_emits_nothing(self):
|
||||||
|
with patch.object(postgres_pillar_beacon, '_read_watermark', return_value=None), \
|
||||||
|
patch.object(postgres_pillar_beacon, '_query', return_value='7\n'), \
|
||||||
|
patch.object(postgres_pillar_beacon, '_write_watermark') as mock_write:
|
||||||
|
self.assertEqual(postgres_pillar_beacon.beacon({}), [])
|
||||||
|
mock_write.assert_called_once_with(7)
|
||||||
|
|
||||||
|
def test_beacon_seed_unparseable_is_swallowed(self):
|
||||||
|
with patch.object(postgres_pillar_beacon, '_read_watermark', return_value=None), \
|
||||||
|
patch.object(postgres_pillar_beacon, '_query', return_value='abc'), \
|
||||||
|
patch.object(postgres_pillar_beacon, '_write_watermark') as mock_write:
|
||||||
|
self.assertEqual(postgres_pillar_beacon.beacon({}), [])
|
||||||
|
mock_write.assert_not_called()
|
||||||
|
|
||||||
|
# -- beacon: steady state ---------------------------------------------
|
||||||
|
|
||||||
|
def test_beacon_query_failure_returns_empty(self):
|
||||||
|
with patch.object(postgres_pillar_beacon, '_read_watermark', return_value=10), \
|
||||||
|
patch.object(postgres_pillar_beacon, '_query', return_value=None), \
|
||||||
|
patch.object(postgres_pillar_beacon, '_write_watermark') as mock_write:
|
||||||
|
self.assertEqual(postgres_pillar_beacon.beacon({}), [])
|
||||||
|
mock_write.assert_not_called()
|
||||||
|
|
||||||
|
def test_beacon_emits_events_and_advances_watermark(self):
|
||||||
|
sep = postgres_pillar_beacon.FIELD_SEP
|
||||||
|
rows = '11%s5%snode1\n12%s6%s\n' % (sep, sep, sep, sep)
|
||||||
|
with patch.object(postgres_pillar_beacon, '_read_watermark', return_value=10), \
|
||||||
|
patch.object(postgres_pillar_beacon, '_query', return_value=rows), \
|
||||||
|
patch.object(postgres_pillar_beacon, '_write_watermark') as mock_write:
|
||||||
|
result = postgres_pillar_beacon.beacon({})
|
||||||
|
self.assertEqual(result, [
|
||||||
|
{'tag': 'audit_settings', 'id': 11, 'setting_id': '5', 'node_id': 'node1'},
|
||||||
|
{'tag': 'audit_settings', 'id': 12, 'setting_id': '6', 'node_id': ''},
|
||||||
|
])
|
||||||
|
mock_write.assert_called_once_with(12)
|
||||||
|
|
||||||
|
def test_beacon_skips_malformed_blank_and_noninteger_rows(self):
|
||||||
|
sep = postgres_pillar_beacon.FIELD_SEP
|
||||||
|
rows = (
|
||||||
|
'\n' # blank line -> skipped
|
||||||
|
'13%s7\n' # too few fields -> skipped
|
||||||
|
'abc%s8%snodeX\n' # non-integer id -> skipped
|
||||||
|
'14%s9%snodeY\n' # the one good row
|
||||||
|
) % (sep, sep, sep, sep, sep)
|
||||||
|
with patch.object(postgres_pillar_beacon, '_read_watermark', return_value=10), \
|
||||||
|
patch.object(postgres_pillar_beacon, '_query', return_value=rows), \
|
||||||
|
patch.object(postgres_pillar_beacon, '_write_watermark') as mock_write:
|
||||||
|
result = postgres_pillar_beacon.beacon({})
|
||||||
|
self.assertEqual(result, [
|
||||||
|
{'tag': 'audit_settings', 'id': 14, 'setting_id': '9', 'node_id': 'nodeY'},
|
||||||
|
])
|
||||||
|
mock_write.assert_called_once_with(14)
|
||||||
|
|
||||||
|
def test_beacon_no_new_rows_does_not_advance_watermark(self):
|
||||||
|
with patch.object(postgres_pillar_beacon, '_read_watermark', return_value=10), \
|
||||||
|
patch.object(postgres_pillar_beacon, '_query', return_value=''), \
|
||||||
|
patch.object(postgres_pillar_beacon, '_write_watermark') as mock_write:
|
||||||
|
self.assertEqual(postgres_pillar_beacon.beacon({}), [])
|
||||||
|
mock_write.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one
|
||||||
|
# or more contributor license agreements. Licensed under the Elastic License 2.0 as shown at
|
||||||
|
# https://securityonion.net/license; you may not use this file except in compliance with the
|
||||||
|
# Elastic License 2.0.
|
||||||
|
|
||||||
|
# Custom salt beacon that watches the suricata/strelka rule directories for changes
|
||||||
|
# and emits a beacon event per changed directory. This replaces the stock salt
|
||||||
|
# `inotify` beacon, which leaks a kernel inotify instance every time the minion
|
||||||
|
# rebuilds the beacon loader's __context__ (orphaning the old pyinotify.Notifier
|
||||||
|
# without closing it) until fs.inotify.max_user_instances is exhausted and the
|
||||||
|
# beacon dies with EMFILE. Polling holds zero inotify instances, so the leak is
|
||||||
|
# impossible, and it keeps firing during state runs (no blackout).
|
||||||
|
#
|
||||||
|
# Detection is poll-based with a per-directory fingerprint persisted to
|
||||||
|
# WATERMARK_DIR: each pass walks the directory and hashes every file's
|
||||||
|
# (relpath, st_mtime_ns, st_size), which catches content writes, additions,
|
||||||
|
# moves, and deletions. A change in the digest emits one event; an unchanged
|
||||||
|
# digest emits nothing. This makes it self-healing (a missed poll simply catches
|
||||||
|
# up on the next one).
|
||||||
|
#
|
||||||
|
# Each emitted event carries the watched directory path under the configured tag
|
||||||
|
# (e.g. salt/beacon/<minion>/rules_beacon/suricata); the push_suricata / push_strelka
|
||||||
|
# reactors write a push intent, after which the existing so-push-drainer /
|
||||||
|
# orch.push_batch pipeline takes over unchanged.
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
WATERMARK_DIR = '/opt/so/state'
|
||||||
|
|
||||||
|
# Temp/editor files that should not trigger a push. Mirrors the exclude regexes
|
||||||
|
# the inotify beacon used. Matched against the full pathname.
|
||||||
|
EXCLUDES = [
|
||||||
|
re.compile(r'\.sw[a-z]$'),
|
||||||
|
re.compile(r'~$'),
|
||||||
|
re.compile(r'/4913$'),
|
||||||
|
re.compile(r'/\.#'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def __virtual__():
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def validate(config):
|
||||||
|
return True, 'valid'
|
||||||
|
|
||||||
|
|
||||||
|
def _paths_from_config(config):
|
||||||
|
# The beacon config arrives as a list of single-key dicts (salt beacon style).
|
||||||
|
# Merge it and return the {dir: tag} mapping under the 'paths' key.
|
||||||
|
merged = {}
|
||||||
|
if isinstance(config, list):
|
||||||
|
for item in config:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
merged.update(item)
|
||||||
|
elif isinstance(config, dict):
|
||||||
|
merged = config
|
||||||
|
paths = merged.get('paths', {})
|
||||||
|
return paths if isinstance(paths, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _excluded(pathname):
|
||||||
|
for pattern in EXCLUDES:
|
||||||
|
if pattern.search(pathname):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _fingerprint(directory):
|
||||||
|
# Stat-only walk; hash each file's (relpath, mtime_ns, size). Returns a hex
|
||||||
|
# digest, or the digest of an empty tree if the directory does not exist.
|
||||||
|
h = hashlib.sha1()
|
||||||
|
if os.path.isdir(directory):
|
||||||
|
entries = []
|
||||||
|
for root, _dirs, files in os.walk(directory):
|
||||||
|
for name in files:
|
||||||
|
full = os.path.join(root, name)
|
||||||
|
if _excluded(full):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
st = os.stat(full)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
rel = os.path.relpath(full, directory)
|
||||||
|
entries.append('%s\0%d\0%d' % (rel, st.st_mtime_ns, st.st_size))
|
||||||
|
for line in sorted(entries):
|
||||||
|
h.update(line.encode('utf-8', 'surrogateescape'))
|
||||||
|
h.update(b'\n')
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _watermark_file(tag):
|
||||||
|
return os.path.join(WATERMARK_DIR, 'rules_beacon_%s.hash' % tag)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_watermark(tag):
|
||||||
|
try:
|
||||||
|
with open(_watermark_file(tag), 'r') as f:
|
||||||
|
return (f.read() or '').strip() or None
|
||||||
|
except IOError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_watermark(tag, digest):
|
||||||
|
path = _watermark_file(tag)
|
||||||
|
try:
|
||||||
|
os.makedirs(WATERMARK_DIR, exist_ok=True)
|
||||||
|
tmp = path + '.tmp'
|
||||||
|
with open(tmp, 'w') as f:
|
||||||
|
f.write(digest)
|
||||||
|
os.rename(tmp, path)
|
||||||
|
except OSError:
|
||||||
|
log.exception('rules_beacon: failed to persist watermark to %s', path)
|
||||||
|
|
||||||
|
|
||||||
|
def beacon(config):
|
||||||
|
retval = []
|
||||||
|
|
||||||
|
for directory, tag in _paths_from_config(config).items():
|
||||||
|
digest = _fingerprint(directory)
|
||||||
|
previous = _read_watermark(tag)
|
||||||
|
|
||||||
|
# First run / missing watermark: seed the digest and emit nothing so a
|
||||||
|
# fresh host does not fire a spurious fleetwide push.
|
||||||
|
if previous is None:
|
||||||
|
_write_watermark(tag, digest)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if digest != previous:
|
||||||
|
_write_watermark(tag, digest)
|
||||||
|
retval.append({'tag': tag, 'path': directory})
|
||||||
|
log.info('rules_beacon: change detected in %s, emitting %s', directory, tag)
|
||||||
|
|
||||||
|
return retval
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one
|
||||||
|
# or more contributor license agreements. Licensed under the Elastic License 2.0 as shown at
|
||||||
|
# https://securityonion.net/license; you may not use this file except in compliance with the
|
||||||
|
# Elastic License 2.0.
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import rules_beacon
|
||||||
|
|
||||||
|
|
||||||
|
class TestRulesBeacon(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Isolate all on-disk state (watermarks and the dirs we fingerprint) in a
|
||||||
|
# throwaway tree, and point WATERMARK_DIR at it so the real read/write
|
||||||
|
# helpers run against actual files.
|
||||||
|
self.tmpdir = tempfile.mkdtemp()
|
||||||
|
self.state = os.path.join(self.tmpdir, 'state')
|
||||||
|
patcher = patch.object(rules_beacon, 'WATERMARK_DIR', self.state)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
|
def _make_dir(self, name, files=None):
|
||||||
|
path = os.path.join(self.tmpdir, name)
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
for fname, content in (files or {}).items():
|
||||||
|
with open(os.path.join(path, fname), 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
return path
|
||||||
|
|
||||||
|
# -- trivial contract -------------------------------------------------
|
||||||
|
|
||||||
|
def test_virtual_returns_true(self):
|
||||||
|
self.assertTrue(rules_beacon.__virtual__())
|
||||||
|
|
||||||
|
def test_validate_returns_valid(self):
|
||||||
|
self.assertEqual(rules_beacon.validate({}), (True, 'valid'))
|
||||||
|
|
||||||
|
# -- _paths_from_config -----------------------------------------------
|
||||||
|
|
||||||
|
def test_paths_from_config_list_of_dicts(self):
|
||||||
|
config = [{'interval': 10}, {'paths': {'/a': 'suricata', '/b': 'strelka'}}]
|
||||||
|
self.assertEqual(
|
||||||
|
rules_beacon._paths_from_config(config),
|
||||||
|
{'/a': 'suricata', '/b': 'strelka'},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_paths_from_config_plain_dict(self):
|
||||||
|
self.assertEqual(
|
||||||
|
rules_beacon._paths_from_config({'paths': {'/a': 'suricata'}}),
|
||||||
|
{'/a': 'suricata'},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_paths_from_config_skips_non_dict_items(self):
|
||||||
|
self.assertEqual(rules_beacon._paths_from_config(['bogus', 42]), {})
|
||||||
|
|
||||||
|
def test_paths_from_config_paths_not_a_dict(self):
|
||||||
|
self.assertEqual(rules_beacon._paths_from_config({'paths': 'nope'}), {})
|
||||||
|
|
||||||
|
def test_paths_from_config_unexpected_type(self):
|
||||||
|
self.assertEqual(rules_beacon._paths_from_config('nonsense'), {})
|
||||||
|
|
||||||
|
# -- _excluded --------------------------------------------------------
|
||||||
|
|
||||||
|
def test_excluded_matches_temp_and_editor_files(self):
|
||||||
|
for pathname in ('/rules/foo.swp', '/rules/foo~', '/rules/4913', '/rules/.#foo'):
|
||||||
|
self.assertTrue(rules_beacon._excluded(pathname), pathname)
|
||||||
|
|
||||||
|
def test_excluded_allows_real_rule_files(self):
|
||||||
|
self.assertFalse(rules_beacon._excluded('/rules/suricata.rules'))
|
||||||
|
|
||||||
|
# -- _fingerprint -----------------------------------------------------
|
||||||
|
|
||||||
|
def test_fingerprint_missing_dir_is_empty_tree_digest(self):
|
||||||
|
missing = os.path.join(self.tmpdir, 'does-not-exist')
|
||||||
|
self.assertEqual(rules_beacon._fingerprint(missing), hashlib.sha1().hexdigest())
|
||||||
|
|
||||||
|
def test_fingerprint_changes_when_content_changes(self):
|
||||||
|
d = self._make_dir('rules', {'a.rules': 'alert'})
|
||||||
|
before = rules_beacon._fingerprint(d)
|
||||||
|
with open(os.path.join(d, 'a.rules'), 'w') as f:
|
||||||
|
f.write('alert tcp any any -> any any') # different size
|
||||||
|
self.assertNotEqual(rules_beacon._fingerprint(d), before)
|
||||||
|
|
||||||
|
def test_fingerprint_ignores_excluded_files(self):
|
||||||
|
d = self._make_dir('rules', {'a.rules': 'alert'})
|
||||||
|
before = rules_beacon._fingerprint(d)
|
||||||
|
with open(os.path.join(d, 'a.rules.swp'), 'w') as f:
|
||||||
|
f.write('editor swap')
|
||||||
|
self.assertEqual(rules_beacon._fingerprint(d), before)
|
||||||
|
|
||||||
|
def test_fingerprint_skips_unstatable_entries(self):
|
||||||
|
# A dangling symlink appears in os.walk's file list but os.stat raises
|
||||||
|
# OSError, exercising the except-continue path.
|
||||||
|
d = self._make_dir('rules', {'a.rules': 'alert'})
|
||||||
|
good = rules_beacon._fingerprint(d)
|
||||||
|
os.symlink(os.path.join(d, 'missing-target'), os.path.join(d, 'broken.link'))
|
||||||
|
self.assertEqual(rules_beacon._fingerprint(d), good)
|
||||||
|
|
||||||
|
# -- _read_watermark / _write_watermark -------------------------------
|
||||||
|
|
||||||
|
def test_watermark_round_trip(self):
|
||||||
|
rules_beacon._write_watermark('suricata', 'deadbeef')
|
||||||
|
self.assertEqual(rules_beacon._read_watermark('suricata'), 'deadbeef')
|
||||||
|
|
||||||
|
def test_read_watermark_missing_returns_none(self):
|
||||||
|
self.assertIsNone(rules_beacon._read_watermark('suricata'))
|
||||||
|
|
||||||
|
def test_read_watermark_empty_file_returns_none(self):
|
||||||
|
os.makedirs(self.state, exist_ok=True)
|
||||||
|
with open(rules_beacon._watermark_file('suricata'), 'w') as f:
|
||||||
|
f.write('')
|
||||||
|
self.assertIsNone(rules_beacon._read_watermark('suricata'))
|
||||||
|
|
||||||
|
def test_write_watermark_swallows_oserror(self):
|
||||||
|
with patch.object(rules_beacon.os, 'makedirs', side_effect=OSError):
|
||||||
|
rules_beacon._write_watermark('suricata', 'deadbeef')
|
||||||
|
self.assertIsNone(rules_beacon._read_watermark('suricata'))
|
||||||
|
|
||||||
|
# -- beacon -----------------------------------------------------------
|
||||||
|
|
||||||
|
def _config(self, mapping):
|
||||||
|
return [{'paths': mapping}]
|
||||||
|
|
||||||
|
def test_beacon_seeds_first_run_and_emits_nothing(self):
|
||||||
|
with patch.object(rules_beacon, '_fingerprint', return_value='hash1'), \
|
||||||
|
patch.object(rules_beacon, '_read_watermark', return_value=None), \
|
||||||
|
patch.object(rules_beacon, '_write_watermark') as mock_write:
|
||||||
|
result = rules_beacon.beacon(self._config({'/rules/suricata': 'suricata'}))
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
mock_write.assert_called_once_with('suricata', 'hash1')
|
||||||
|
|
||||||
|
def test_beacon_emits_on_change(self):
|
||||||
|
with patch.object(rules_beacon, '_fingerprint', return_value='newhash'), \
|
||||||
|
patch.object(rules_beacon, '_read_watermark', return_value='oldhash'), \
|
||||||
|
patch.object(rules_beacon, '_write_watermark') as mock_write:
|
||||||
|
result = rules_beacon.beacon(self._config({'/rules/suricata': 'suricata'}))
|
||||||
|
self.assertEqual(result, [{'tag': 'suricata', 'path': '/rules/suricata'}])
|
||||||
|
mock_write.assert_called_once_with('suricata', 'newhash')
|
||||||
|
|
||||||
|
def test_beacon_no_change_emits_nothing(self):
|
||||||
|
with patch.object(rules_beacon, '_fingerprint', return_value='samehash'), \
|
||||||
|
patch.object(rules_beacon, '_read_watermark', return_value='samehash'), \
|
||||||
|
patch.object(rules_beacon, '_write_watermark') as mock_write:
|
||||||
|
result = rules_beacon.beacon(self._config({'/rules/suricata': 'suricata'}))
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
mock_write.assert_not_called()
|
||||||
|
|
||||||
|
def test_beacon_end_to_end_with_real_files(self):
|
||||||
|
# Exercise the full stack (real fingerprint + real watermark files) across
|
||||||
|
# two poll passes: first seeds silently, second fires after a write.
|
||||||
|
d = self._make_dir('rules', {'a.rules': 'alert'})
|
||||||
|
config = self._config({d: 'suricata'})
|
||||||
|
|
||||||
|
self.assertEqual(rules_beacon.beacon(config), []) # seed pass
|
||||||
|
self.assertEqual(rules_beacon.beacon(config), []) # unchanged pass
|
||||||
|
|
||||||
|
with open(os.path.join(d, 'b.rules'), 'w') as f:
|
||||||
|
f.write('alert tcp any any -> any any')
|
||||||
|
self.assertEqual(rules_beacon.beacon(config), [{'tag': 'suricata', 'path': d}])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -4,7 +4,7 @@ import logging
|
|||||||
def status():
|
def status():
|
||||||
|
|
||||||
cmd = "runuser -l zeek -c '/opt/zeek/bin/zeekctl status'"
|
cmd = "runuser -l zeek -c '/opt/zeek/bin/zeekctl status'"
|
||||||
retval = __salt__['docker.run']('so-zeek', cmd)
|
retval = __salt__['docker.run']('so-zeek', cmd) # noqa: F821
|
||||||
logging.info('zeekctl_module: zeekctl.status retval: %s' % retval)
|
logging.info('zeekctl_module: zeekctl.status retval: %s' % retval)
|
||||||
|
|
||||||
return retval
|
return retval
|
||||||
@@ -14,7 +14,7 @@ def beacon(config):
|
|||||||
|
|
||||||
retval = []
|
retval = []
|
||||||
|
|
||||||
is_enabled = __salt__['healthcheck.is_enabled']()
|
is_enabled = __salt__['healthcheck.is_enabled']() # noqa: F821
|
||||||
logging.info('zeek_beacon: healthcheck_is_enabled: %s' % is_enabled)
|
logging.info('zeek_beacon: healthcheck_is_enabled: %s' % is_enabled)
|
||||||
|
|
||||||
if is_enabled:
|
if is_enabled:
|
||||||
@@ -25,9 +25,8 @@ def beacon(config):
|
|||||||
else:
|
else:
|
||||||
zeek_restart = False
|
zeek_restart = False
|
||||||
|
|
||||||
__salt__['telegraf.send']('healthcheck zeek_restart=%s' % str(zeek_restart))
|
__salt__['telegraf.send']('healthcheck zeek_restart=%s' % str(zeek_restart)) # noqa: F821
|
||||||
retval.append({'zeek_restart': zeek_restart})
|
retval.append({'zeek_restart': zeek_restart})
|
||||||
logging.info('zeek_beacon: retval: %s' % str(retval))
|
logging.info('zeek_beacon: retval: %s' % str(retval))
|
||||||
|
|
||||||
return retval
|
return retval
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one
|
||||||
|
# or more contributor license agreements. Licensed under the Elastic License 2.0 as shown at
|
||||||
|
# https://securityonion.net/license; you may not use this file except in compliance with the
|
||||||
|
# Elastic License 2.0.
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import zeek
|
||||||
|
|
||||||
|
ZEEKCTL_CMD = "runuser -l zeek -c '/opt/zeek/bin/zeekctl status'"
|
||||||
|
|
||||||
|
|
||||||
|
class TestZeekBeacon(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# zeek.py relies on the __salt__ dunder that Salt injects at load time.
|
||||||
|
# Nothing defines it under test, so we attach a dict of mock loader
|
||||||
|
# functions to the module and remove it again afterwards.
|
||||||
|
self.salt = {
|
||||||
|
'docker.run': MagicMock(return_value='Zeek is running'),
|
||||||
|
'healthcheck.is_enabled': MagicMock(return_value=True),
|
||||||
|
'telegraf.send': MagicMock(),
|
||||||
|
}
|
||||||
|
zeek.__salt__ = self.salt
|
||||||
|
self.addCleanup(lambda: delattr(zeek, '__salt__'))
|
||||||
|
|
||||||
|
# -- status -----------------------------------------------------------
|
||||||
|
|
||||||
|
def test_status_runs_zeekctl_and_returns_output(self):
|
||||||
|
self.salt['docker.run'].return_value = 'Zeek is running'
|
||||||
|
result = zeek.status()
|
||||||
|
self.assertEqual(result, 'Zeek is running')
|
||||||
|
self.salt['docker.run'].assert_called_once_with('so-zeek', ZEEKCTL_CMD)
|
||||||
|
|
||||||
|
# -- beacon -----------------------------------------------------------
|
||||||
|
|
||||||
|
def test_beacon_disabled_returns_empty_and_skips_telegraf(self):
|
||||||
|
self.salt['healthcheck.is_enabled'].return_value = False
|
||||||
|
self.assertEqual(zeek.beacon({}), [])
|
||||||
|
self.salt['telegraf.send'].assert_not_called()
|
||||||
|
|
||||||
|
def test_beacon_running_reports_no_restart(self):
|
||||||
|
self.salt['docker.run'].return_value = 'Zeek is running'
|
||||||
|
self.assertEqual(zeek.beacon({}), [{'zeek_restart': False}])
|
||||||
|
self.salt['telegraf.send'].assert_called_once_with('healthcheck zeek_restart=False')
|
||||||
|
|
||||||
|
def test_beacon_unhealthy_status_triggers_restart(self):
|
||||||
|
# Each of these status tokens should flag a restart (the or-chain in beacon).
|
||||||
|
for status_text in ('Zeek is stopped', 'Zeek crashed', 'Zeek error state', 'Zeek error:'):
|
||||||
|
with self.subTest(status=status_text):
|
||||||
|
self.salt['docker.run'].return_value = status_text
|
||||||
|
self.salt['telegraf.send'].reset_mock()
|
||||||
|
self.assertEqual(zeek.beacon({}), [{'zeek_restart': True}])
|
||||||
|
self.salt['telegraf.send'].assert_called_once_with('healthcheck zeek_restart=True')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -602,42 +602,6 @@ run_check_net_err() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
wait_for_salt_minion() {
|
|
||||||
local minion="$1"
|
|
||||||
local max_wait="${2:-30}"
|
|
||||||
local interval="${3:-2}"
|
|
||||||
local logfile="${4:-'/dev/stdout'}"
|
|
||||||
local elapsed=0
|
|
||||||
|
|
||||||
echo "$(date '+%a %d %b %Y %H:%M:%S.%6N') - Waiting for salt-minion '$minion' to be ready..."
|
|
||||||
|
|
||||||
while [ $elapsed -lt $max_wait ]; do
|
|
||||||
# Check if service is running
|
|
||||||
echo "$(date '+%a %d %b %Y %H:%M:%S.%6N') - Check if salt-minion service is running"
|
|
||||||
if ! systemctl is-active --quiet salt-minion; then
|
|
||||||
echo "$(date '+%a %d %b %Y %H:%M:%S.%6N') - salt-minion service not running (elapsed: ${elapsed}s)"
|
|
||||||
sleep $interval
|
|
||||||
elapsed=$((elapsed + interval))
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
echo "$(date '+%a %d %b %Y %H:%M:%S.%6N') - salt-minion service is running"
|
|
||||||
|
|
||||||
# Check if minion responds to ping
|
|
||||||
echo "$(date '+%a %d %b %Y %H:%M:%S.%6N') - Check if $minion responds to ping"
|
|
||||||
if salt "$minion" test.ping --timeout=3 --out=json 2>> "$logfile" | grep -q "true"; then
|
|
||||||
echo "$(date '+%a %d %b %Y %H:%M:%S.%6N') - salt-minion '$minion' is connected and ready!"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$(date '+%a %d %b %Y %H:%M:%S.%6N') - Waiting... (${elapsed}s / ${max_wait}s)"
|
|
||||||
sleep $interval
|
|
||||||
elapsed=$((elapsed + interval))
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "$(date '+%a %d %b %Y %H:%M:%S.%6N') - ERROR: salt-minion '$minion' not ready after $max_wait seconds"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
salt_minion_count() {
|
salt_minion_count() {
|
||||||
local MINIONDIR="/opt/so/saltstack/local/pillar/minions"
|
local MINIONDIR="/opt/so/saltstack/local/pillar/minions"
|
||||||
MINIONCOUNT=$(ls -la $MINIONDIR/*.sls | grep -v adv_ | wc -l)
|
MINIONCOUNT=$(ls -la $MINIONDIR/*.sls | grep -v adv_ | wc -l)
|
||||||
@@ -702,7 +666,7 @@ systemctl_func() {
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "${echo_action^}ing $service_name service at $(date +"%T.%6N")"
|
echo "${echo_action^}ing $service_name service at $(date +"%T.%6N")"
|
||||||
systemctl $action $service_name && echo "Successfully ${echo_action}ed $service_name." || echo "Failed to $action $service_name."
|
systemctl $action $service_name && echo "Successfully ${echo_action}ed $service_name at $(date +"%T.%6N")." || echo "Failed to $action $service_name at $(date +"%T.%6N")."
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,53 +5,239 @@
|
|||||||
# https://securityonion.net/license; you may not use this file except in compliance with the
|
# https://securityonion.net/license; you may not use this file except in compliance with the
|
||||||
# Elastic License 2.0.
|
# Elastic License 2.0.
|
||||||
#
|
#
|
||||||
# so-kernel-upgrade — switch the boot default to the installed UEK8 (6.x) kernel.
|
# so-kernel-upgrade — install the UEK8 (6.x) kernel and make it the boot default.
|
||||||
#
|
#
|
||||||
# Security Onion is moving off the EL9 stock kernel / UEK7 (5.x) onto UEK8 (6.x).
|
# Security Onion is moving off the EL9 stock kernel (RHCK, 5.14) and UEK7 (5.15) onto UEK8
|
||||||
# Installing the kernel-uek-core package adds a UEK8 boot entry but does NOT make it the
|
# (6.x). Three things have to happen, and the tool has to drive each one:
|
||||||
# default: kernel-install/grubby only auto-promote a new kernel within the running
|
|
||||||
# kernel's flavor lineage, and we're crossing from a 5.x kernel to the new 6.x UEK flavor.
|
|
||||||
# So even with UPDATEDEFAULT=yes and DEFAULTKERNEL=kernel-uek-core the box keeps booting
|
|
||||||
# the old kernel. This tool finds the newest installed 6.x UEK kernel and makes it the
|
|
||||||
# GRUB default via grubby so the next boot comes up on UEK8.
|
|
||||||
#
|
#
|
||||||
# Idempotent: if the UEK8 kernel is already the default it does nothing. It only sets the
|
# 1. Populate. The manager mirrors the UEK8 packages into /nsm/kernelrepo via so-repo-sync,
|
||||||
# boot default; it does NOT reboot — the admin reboots the node on their own schedule.
|
# and serves them to the grid over https://<manager>/kernelrepo. Until that sync runs the
|
||||||
|
# repo is valid but EMPTY -- dnf resolves it happily and installs nothing, with no error.
|
||||||
|
# 2. Install. A node on RHCK has no kernel-uek* package at all, so there is nothing for
|
||||||
|
# 'dnf update' to upgrade. A node on UEK7 does have kernel-uek installed, so
|
||||||
|
# 'dnf install kernel-uek' reports "Nothing to do" and exits 0 without installing 6.x.
|
||||||
|
# Both cases need an explicit install of the UEK8 NEVRA.
|
||||||
|
# 3. Boot it. Whether a newly installed UEK8 kernel becomes the boot default depends on the
|
||||||
|
# RUNNING kernel's flavor. kernel-install/grubby (with UPDATEDEFAULT=yes) only auto-promote
|
||||||
|
# within the running kernel's flavor lineage:
|
||||||
|
# - From UEK7 (5.x, kernel-uek) the install stays in the kernel-uek lineage and IS
|
||||||
|
# auto-promoted, so no grubby change is needed -- just make sure the repo is populated
|
||||||
|
# and install UEK8.
|
||||||
|
# - From the stock EL9 kernel (RHCK, 5.14, no UEK) it is a flavor CROSS that is NOT
|
||||||
|
# auto-promoted, so the box keeps booting RHCK until grubby is told otherwise.
|
||||||
|
# This tool inspects the running kernel and only runs 'grubby --set-default' for RHCK.
|
||||||
|
#
|
||||||
|
# Every one of those failure modes is silent by default. This tool handles each case and fails
|
||||||
|
# loudly when it cannot, rather than reporting success while changing nothing.
|
||||||
|
#
|
||||||
|
# Manager vs minion: only the manager owns /nsm/kernelrepo, so only the manager can populate
|
||||||
|
# it. If the repo is empty here, a manager runs so-repo-sync itself; a minion has no way to
|
||||||
|
# fix it and exits non-zero telling the admin to sync the manager first.
|
||||||
|
#
|
||||||
|
# Idempotent: an already-installed, already-default UEK8 kernel is left alone. It only sets
|
||||||
|
# the boot default; it does NOT reboot -- the admin reboots the node on their own schedule.
|
||||||
|
|
||||||
|
. /usr/sbin/so-common
|
||||||
|
|
||||||
|
# Client-side repo id (what dnf enables on this node, from repo/client/oracle.sls) vs the
|
||||||
|
# reposync-side section in repodownload.conf that the manager mirrors from (mirrors the
|
||||||
|
# securityonion/securityonionsync split for the main repo).
|
||||||
|
KERNEL_REPO="securityonionkernel"
|
||||||
|
KERNEL_REPO_SYNC="securityonionkernelsync"
|
||||||
|
KERNEL_PKG="kernel-uek"
|
||||||
|
KERNEL_REPO_DIR="/nsm/kernelrepo"
|
||||||
|
REPOSYNC_CONF="/opt/so/conf/reposync/repodownload.conf"
|
||||||
|
GLOBAL_PILLAR="/opt/so/saltstack/local/pillar/global/soc_global.sls"
|
||||||
|
|
||||||
log() { echo "[so-kernel-upgrade] $*"; }
|
log() { echo "[so-kernel-upgrade] $*"; }
|
||||||
|
die() { echo "[so-kernel-upgrade] ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
[ "$(id -u)" -eq 0 ] || { log "must run as root"; exit 1; }
|
command -v grubby >/dev/null 2>&1 || die "grubby not found"
|
||||||
command -v grubby >/dev/null 2>&1 || { log "grubby not found"; exit 1; }
|
command -v dnf >/dev/null 2>&1 || die "dnf not found"
|
||||||
|
|
||||||
|
ARCH="$(rpm -E '%{_arch}')"
|
||||||
|
|
||||||
|
is_airgap() {
|
||||||
|
[ -f "$GLOBAL_PILLAR" ] && grep -q 'airgap: *[Tt]rue' "$GLOBAL_PILLAR"
|
||||||
|
}
|
||||||
|
|
||||||
# Newest installed UEK8 (6.x) kernel known to the bootloader. UEK8 vmlinuz paths look like
|
# Newest installed UEK8 (6.x) kernel known to the bootloader. UEK8 vmlinuz paths look like
|
||||||
# /boot/vmlinuz-6.12.0-203.76.7.5.el9uek.x86_64; the 5.x UEK7 and 5.14 RHCK won't match.
|
# /boot/vmlinuz-6.12.0-204.92.4.2.el9uek.x86_64; UEK7 (5.15) and RHCK (5.14) won't match.
|
||||||
target="$(grubby --info=ALL 2>/dev/null \
|
find_uek8() {
|
||||||
|
grubby --info=ALL 2>/dev/null \
|
||||||
| sed -n 's/^kernel="\(.*\)"$/\1/p' \
|
| sed -n 's/^kernel="\(.*\)"$/\1/p' \
|
||||||
| grep -E '/vmlinuz-6\.[0-9]+.*uek' \
|
| grep -E '/vmlinuz-6\.[0-9]+.*uek' \
|
||||||
| sort -V | tail -1)"
|
| sort -V | tail -1
|
||||||
|
}
|
||||||
|
|
||||||
if [ -z "$target" ]; then
|
# Classify the RUNNING kernel (uname -r) -- this, not what's installed, is what decides whether
|
||||||
log "no installed 6.x UEK (UEK8) kernel found — confirm the kernel repo is assigned and"
|
# a UEK8 install auto-promotes to the boot default:
|
||||||
log "'dnf update' has installed kernel-uek-core. Nothing to do."
|
# uek8 6.x UEK already on the target line; nothing to do
|
||||||
|
# uek7 5.x UEK a UEK8 install stays in the kernel-uek lineage and auto-promotes (no grubby)
|
||||||
|
# rhck 5.14 EL9 crossing into the UEK flavor does NOT auto-promote (needs grubby --set-default)
|
||||||
|
running_flavor() {
|
||||||
|
case "$(uname -r)" in
|
||||||
|
6.*uek*) echo uek8 ;;
|
||||||
|
*uek*) echo uek7 ;;
|
||||||
|
*) echo rhck ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Newest UEK8 kernel-uek NEVRA offered by the kernel repo, empty if the repo has none.
|
||||||
|
# Restricted to the kernel repo so a UEK7 kernel-uek in the main repo can't be picked up,
|
||||||
|
# and filtered to 6.x so we never "succeed" by reinstalling the 5.15 we already have.
|
||||||
|
uek8_available() {
|
||||||
|
dnf -q repoquery --disablerepo='*' --enablerepo="$KERNEL_REPO" \
|
||||||
|
--arch="$ARCH" --latest-limit=1 \
|
||||||
|
--qf '%{name}-%{evr}.%{arch}\n' "$KERNEL_PKG" 2>/dev/null \
|
||||||
|
| grep -E "^${KERNEL_PKG}-6\." | tail -1
|
||||||
|
}
|
||||||
|
|
||||||
|
kernelrepo_rpm_count() {
|
||||||
|
find "$KERNEL_REPO_DIR" -maxdepth 1 -name '*.rpm' 2>/dev/null | wc -l
|
||||||
|
}
|
||||||
|
|
||||||
|
# The kernel repo starts life as valid-but-empty (kernelrepo_init_empty in
|
||||||
|
# salt/manager/init.sls) and is filled by so-repo-sync. During a soup, so-repo-sync runs
|
||||||
|
# BEFORE the highstate deploys the [securityonionkernelsync] section into repodownload.conf, so
|
||||||
|
# the first kernel-aware soup leaves the repo empty until the next nightly sync.
|
||||||
|
sync_kernel_repo() {
|
||||||
|
if is_airgap; then
|
||||||
|
log "airgap install: $KERNEL_REPO_DIR is populated from the airgap ISO, not by so-repo-sync."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! grep -q "^\[${KERNEL_REPO_SYNC}\]" "$REPOSYNC_CONF" 2>/dev/null; then
|
||||||
|
log "$REPOSYNC_CONF has no [${KERNEL_REPO_SYNC}] section -- run a highstate to deploy it."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "populating $KERNEL_REPO_DIR with so-repo-sync (mirrors upstream; can take several minutes)"
|
||||||
|
su socore -c '/usr/sbin/so-repo-sync' || { log "so-repo-sync failed"; return 1; }
|
||||||
|
|
||||||
|
dnf -q clean expire-cache >/dev/null 2>&1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Make the kernel repo actually able to serve a UEK8 package, or fail trying.
|
||||||
|
ensure_kernel_repo() {
|
||||||
|
# The repo is assigned by the repo.client highstate, and only once NICs are pinned by MAC
|
||||||
|
# (/opt/so/state/nic_names_pinned) so the kernel swap can't renumber interfaces SO binds
|
||||||
|
# by name. skip_if_unavailable=1 means a broken repo is silently ignored, so check first.
|
||||||
|
if ! dnf -q repolist --enabled 2>/dev/null | awk '{print $1}' | grep -qx "$KERNEL_REPO"; then
|
||||||
|
log "repo '$KERNEL_REPO' is not enabled on this node."
|
||||||
|
log "Run a highstate first; the repo is skipped until /opt/so/state/nic_names_pinned"
|
||||||
|
log "exists (run so-nic-pin) and this node's salt matches the version this release ships."
|
||||||
|
die "kernel repo unavailable"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -n "$(uek8_available)" ] && return 0
|
||||||
|
|
||||||
|
log "repo '$KERNEL_REPO' is enabled but offers no UEK8 $KERNEL_PKG package"
|
||||||
|
|
||||||
|
if ! is_manager_node; then
|
||||||
|
log "This is a minion; it consumes the kernel repo from the manager and cannot populate it."
|
||||||
|
log "On the manager, run: su socore -c /usr/sbin/so-repo-sync"
|
||||||
|
log "then re-run this script here."
|
||||||
|
die "manager's kernel repo is empty"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "this is a manager and $KERNEL_REPO_DIR holds $(kernelrepo_rpm_count) rpm(s)"
|
||||||
|
sync_kernel_repo || die "could not populate $KERNEL_REPO_DIR"
|
||||||
|
|
||||||
|
[ -n "$(uek8_available)" ] \
|
||||||
|
|| die "so-repo-sync completed but $KERNEL_REPO still offers no UEK8 $KERNEL_PKG"
|
||||||
|
}
|
||||||
|
|
||||||
|
reboot_notice() {
|
||||||
|
[ "$(uname -r)" = "$(basename "$1" | sed 's/^vmlinuz-//')" ] \
|
||||||
|
|| log "REBOOT REQUIRED to start using the UEK8 kernel (currently running $(uname -r))."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep future kernel updates on the UEK line rather than falling back to RHCK. Oracle ships
|
||||||
|
# /etc/sysconfig/kernel; only rewrite it when it's actually pointing somewhere else.
|
||||||
|
set_default_kernel_conf() {
|
||||||
|
if [ -f /etc/sysconfig/kernel ] && ! grep -q '^DEFAULTKERNEL=kernel-uek-core$' /etc/sysconfig/kernel; then
|
||||||
|
log "setting DEFAULTKERNEL=kernel-uek-core in /etc/sysconfig/kernel"
|
||||||
|
sed -i 's/^DEFAULTKERNEL=.*/DEFAULTKERNEL=kernel-uek-core/' /etc/sysconfig/kernel
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Make sure a UEK8 kernel is installed, leaving its boot entry in INSTALLED_UEK8. If one is
|
||||||
|
# already present we leave the repo alone -- it may be disabled or empty and we don't need it
|
||||||
|
# just to flip the boot default. Otherwise install the explicit NEVRA, not the bare package
|
||||||
|
# name: on a UEK7 node 'dnf install kernel-uek' sees 5.15 already present, prints "Nothing to
|
||||||
|
# do" and exits 0 without installing 6.x.
|
||||||
|
ensure_uek8_installed() {
|
||||||
|
INSTALLED_UEK8="$(find_uek8)"
|
||||||
|
if [ -n "$INSTALLED_UEK8" ]; then
|
||||||
|
log "UEK8 kernel already installed: $INSTALLED_UEK8"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ensure_kernel_repo
|
||||||
|
local nevra; nevra="$(uek8_available)"
|
||||||
|
log "installing $nevra from $KERNEL_REPO"
|
||||||
|
dnf -y install "$nevra" || die "failed to install $nevra"
|
||||||
|
|
||||||
|
INSTALLED_UEK8="$(find_uek8)"
|
||||||
|
[ -n "$INSTALLED_UEK8" ] || die "$nevra installed but no 6.x UEK boot entry appeared -- check 'grubby --info=ALL'"
|
||||||
|
log "installed UEK8 kernel: $INSTALLED_UEK8"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$(running_flavor)" in
|
||||||
|
uek8)
|
||||||
|
# Already on the 6.x UEK line. A plain 'dnf update' keeps this node current within the
|
||||||
|
# lineage and auto-promotes newer builds, so there is nothing for this tool to do.
|
||||||
|
log "already running a UEK8 kernel ($(uname -r)); nothing to do."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
;;
|
||||||
|
|
||||||
current="$(grubby --default-kernel 2>/dev/null)"
|
uek7)
|
||||||
if [ "$current" = "$target" ]; then
|
# On a 5.x UEK kernel. Installing UEK8 stays inside the kernel-uek lineage, so dnf/grubby
|
||||||
|
# (UPDATEDEFAULT=yes) auto-promote it and we do NOT touch grubby. A node still on UEK7
|
||||||
|
# usually means the kernel repo was empty when it last updated, so populate it and install.
|
||||||
|
log "running UEK7 kernel ($(uname -r)); the kernel repo was likely not yet populated when"
|
||||||
|
log "this node last updated. Populating it and installing UEK8 -- the update stays on the"
|
||||||
|
log "kernel-uek line, so it becomes the boot default automatically (no grubby change needed)."
|
||||||
|
set_default_kernel_conf
|
||||||
|
ensure_uek8_installed
|
||||||
|
|
||||||
|
now="$(grubby --default-kernel 2>/dev/null)"
|
||||||
|
if [ "$now" = "$INSTALLED_UEK8" ]; then
|
||||||
|
log "boot default auto-promoted to UEK8 kernel: $INSTALLED_UEK8"
|
||||||
|
else
|
||||||
|
log "WARNING: expected the UEK8 kernel to auto-promote but the default is still"
|
||||||
|
log "'${now:-unknown}'. Run 'grubby --set-default=$INSTALLED_UEK8' to force it."
|
||||||
|
fi
|
||||||
|
reboot_notice "$INSTALLED_UEK8"
|
||||||
|
;;
|
||||||
|
|
||||||
|
rhck)
|
||||||
|
# On the stock EL9 kernel (5.14, no UEK installed). Crossing from RHCK into the UEK flavor
|
||||||
|
# does NOT auto-promote -- kernel-install/grubby only auto-promote within the running
|
||||||
|
# kernel's flavor lineage -- so after installing we must set the boot default explicitly.
|
||||||
|
log "running stock EL9 (RHCK) kernel ($(uname -r)); installing UEK8 and setting it as the"
|
||||||
|
log "boot default explicitly (a RHCK->UEK flavor change does not auto-promote)."
|
||||||
|
set_default_kernel_conf
|
||||||
|
ensure_uek8_installed
|
||||||
|
target="$INSTALLED_UEK8"
|
||||||
|
|
||||||
|
current="$(grubby --default-kernel 2>/dev/null)"
|
||||||
|
if [ "$current" = "$target" ]; then
|
||||||
log "UEK8 kernel is already the boot default: $target"
|
log "UEK8 kernel is already the boot default: $target"
|
||||||
|
reboot_notice "$target"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "current default kernel: ${current:-unknown}"
|
log "current default kernel: ${current:-unknown}"
|
||||||
log "switching boot default to UEK8 kernel: $target"
|
log "switching boot default to UEK8 kernel: $target"
|
||||||
grubby --set-default="$target" || { log "ERROR: grubby --set-default failed for $target"; exit 1; }
|
grubby --set-default="$target" || die "grubby --set-default failed for $target"
|
||||||
|
|
||||||
# Verify the change actually took before claiming success.
|
# Verify the change actually took before claiming success.
|
||||||
now="$(grubby --default-kernel 2>/dev/null)"
|
now="$(grubby --default-kernel 2>/dev/null)"
|
||||||
if [ "$now" != "$target" ]; then
|
[ "$now" = "$target" ] || die "default kernel is still '${now:-unknown}' after set-default"
|
||||||
log "ERROR: default kernel is still '${now:-unknown}' after set-default"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "boot default is now $target"
|
log "boot default is now $target"
|
||||||
log "REBOOT REQUIRED to start using the UEK8 kernel (currently running $(uname -r))."
|
reboot_notice "$target"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ if [[ $EXCLUDE_STARTUP_ERRORS == 'Y' ]]; then
|
|||||||
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|HTTP 404: Not Found" # Salt loops until Kratos returns 200, during startup Kratos may not be ready
|
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|HTTP 404: Not Found" # Salt loops until Kratos returns 200, during startup Kratos may not be ready
|
||||||
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|Cancelling deferred write event maybeFenceReplicas because the event queue is now closed" # Kafka controller log during shutdown/restart
|
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|Cancelling deferred write event maybeFenceReplicas because the event queue is now closed" # Kafka controller log during shutdown/restart
|
||||||
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|Redis may have been restarted" # Redis likely restarted by salt
|
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|Redis may have been restarted" # Redis likely restarted by salt
|
||||||
|
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|file already closed" # Go logging race condition during container restart
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $EXCLUDE_FALSE_POSITIVE_ERRORS == 'Y' ]]; then
|
if [[ $EXCLUDE_FALSE_POSITIVE_ERRORS == 'Y' ]]; then
|
||||||
|
|||||||
@@ -74,13 +74,13 @@ def output(options, console, code, data):
|
|||||||
summary = { "status_code": code, "containers": data }
|
summary = { "status_code": code, "containers": data }
|
||||||
print(json.dumps(summary))
|
print(json.dumps(summary))
|
||||||
elif "-q" not in options:
|
elif "-q" not in options:
|
||||||
if code == 2:
|
if code == 99:
|
||||||
console.print(" [bold yellow]:hourglass: [bold white]System appears to be starting. No highstate has completed since the system was restarted.")
|
|
||||||
elif code == 99:
|
|
||||||
console.print(" [bold red]:exclamation: [bold white]Installation does not appear to be complete. A highstate has not fully completed.")
|
console.print(" [bold red]:exclamation: [bold white]Installation does not appear to be complete. A highstate has not fully completed.")
|
||||||
elif code == 100:
|
elif code == 100:
|
||||||
console.print(" [bold red]:exclamation: [bold white]Installation encountered errors.")
|
console.print(" [bold red]:exclamation: [bold white]Installation encountered errors.")
|
||||||
else:
|
else:
|
||||||
|
if code == 2:
|
||||||
|
console.print(" [bold yellow]:hourglass: [bold white]System appears to be starting. No highstate has completed since the system was restarted. Container status is shown below.")
|
||||||
table = Table(title = "Security Onion Status", show_edge = False, safe_box = True, box = box.MINIMAL)
|
table = Table(title = "Security Onion Status", show_edge = False, safe_box = True, box = box.MINIMAL)
|
||||||
table.add_column("Container", justify="right", style="white", no_wrap=True)
|
table.add_column("Container", justify="right", style="white", no_wrap=True)
|
||||||
table.add_column("Status", justify="left", style="green", no_wrap=True)
|
table.add_column("Status", justify="left", style="green", no_wrap=True)
|
||||||
@@ -154,8 +154,14 @@ def check_status(options, console):
|
|||||||
code = check_installation_status(options, console)
|
code = check_installation_status(options, console)
|
||||||
if code == 0:
|
if code == 0:
|
||||||
code = check_system_status(options, console)
|
code = check_system_status(options, console)
|
||||||
|
# Containers now start on boot without a highstate, so gather/display their
|
||||||
|
# status even when the system is still "starting" (code 2). Keep the starting
|
||||||
|
# code as the exit/status_code so SOC keeps showing the "restarting" message
|
||||||
|
# on the Grid until a highstate completes.
|
||||||
|
if code == 0 or code == 2:
|
||||||
|
container_code, container_list = check_container_status(options, console)
|
||||||
if code == 0:
|
if code == 0:
|
||||||
code, container_list = check_container_status(options, console)
|
code = container_code
|
||||||
output(options, console, code, container_list)
|
output(options, console, code, container_list)
|
||||||
return code
|
return code
|
||||||
|
|
||||||
@@ -180,4 +186,3 @@ def main():
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
{% import_yaml 'salt/minion.defaults.yaml' as SALT_MINION_DEFAULTS -%}
|
|
||||||
|
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
#
|
#
|
||||||
# Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one
|
# Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one
|
||||||
@@ -7,7 +5,7 @@
|
|||||||
# https://securityonion.net/license; you may not use this file except in compliance with the
|
# https://securityonion.net/license; you may not use this file except in compliance with the
|
||||||
# Elastic License 2.0.
|
# Elastic License 2.0.
|
||||||
|
|
||||||
|
{% from 'salt/schedule.map.jinja' import SCHEDULEMERGED %}
|
||||||
|
|
||||||
# this script checks the time the file /opt/so/log/salt/state-apply-test was last modified and restarts the salt-minion service if it is outside a threshold date/time
|
# this script checks the time the file /opt/so/log/salt/state-apply-test was last modified and restarts the salt-minion service if it is outside a threshold date/time
|
||||||
# the file is modified via file.touch using a scheduled job healthcheck.salt-minion.state-apply-test that runs a state.apply.
|
# the file is modified via file.touch using a scheduled job healthcheck.salt-minion.state-apply-test that runs a state.apply.
|
||||||
@@ -20,12 +18,14 @@
|
|||||||
|
|
||||||
QUIET=false
|
QUIET=false
|
||||||
UPTIME_REQ=1800 #in seconds, how long the box has to be up before considering restarting salt-minion due to /opt/so/log/salt/state-apply-test not being touched
|
UPTIME_REQ=1800 #in seconds, how long the box has to be up before considering restarting salt-minion due to /opt/so/log/salt/state-apply-test not being touched
|
||||||
|
HIGHSTATE_UPTIME_REQ=900 #in seconds; if the box has been up this long and no highstate has completed since boot, force one
|
||||||
CURRENT_TIME=$(date +%s)
|
CURRENT_TIME=$(date +%s)
|
||||||
SYSTEM_START_TIME=$(date -d "$(</proc/uptime awk '{print $1}') seconds ago" +%s)
|
SYSTEM_START_TIME=$(date -d "$(</proc/uptime awk '{print $1}') seconds ago" +%s)
|
||||||
LAST_HIGHSTATE_END=$([ -e "/opt/so/log/salt/lasthighstate" ] && date -r /opt/so/log/salt/lasthighstate +%s || echo 0)
|
LAST_HIGHSTATE_END=$([ -e "/opt/so/log/salt/lasthighstate" ] && date -r /opt/so/log/salt/lasthighstate +%s || echo 0)
|
||||||
LAST_HEALTHCHECK_STATE_APPLY=$([ -e "/opt/so/log/salt/state-apply-test" ] && date -r /opt/so/log/salt/state-apply-test +%s || echo 0)
|
LAST_HEALTHCHECK_STATE_APPLY=$([ -e "/opt/so/log/salt/state-apply-test" ] && date -r /opt/so/log/salt/state-apply-test +%s || echo 0)
|
||||||
# SETTING THRESHOLD TO ANYTHING UNDER 600 seconds may cause a lot of salt-minion restarts since the job to touch the file occurs every 5-8 minutes by default
|
# SETTING THRESHOLD TO ANYTHING UNDER 600 seconds may cause a lot of salt-minion restarts since the job to touch the file occurs every 5-8 minutes by default
|
||||||
THRESHOLD={{SALT_MINION_DEFAULTS.salt.minion.check_threshold}} #within how many seconds the file /opt/so/log/salt/state-apply-test must have been touched/modified before the salt minion is restarted
|
# THRESHOLD is derived from the salt schedule highstate interval + 1 hour, so the minion-check grace period tracks the schedule automatically.
|
||||||
|
THRESHOLD=$(( ({{ SCHEDULEMERGED.highstate_interval_hours }} + 1) * 3600 )) #within how many seconds the file /opt/so/log/salt/state-apply-test must have been touched/modified before the salt minion is restarted
|
||||||
THRESHOLD_DATE=$((LAST_HEALTHCHECK_STATE_APPLY+THRESHOLD))
|
THRESHOLD_DATE=$((LAST_HEALTHCHECK_STATE_APPLY+THRESHOLD))
|
||||||
|
|
||||||
logCmd() {
|
logCmd() {
|
||||||
@@ -77,24 +77,50 @@ done
|
|||||||
|
|
||||||
log "running so-salt-minion-check"
|
log "running so-salt-minion-check"
|
||||||
|
|
||||||
|
RESTARTED=false
|
||||||
|
|
||||||
|
# Check 1 (minion-restart-check): if the minion has stopped applying states (the
|
||||||
|
# state-apply-test healthcheck file has gone stale), restart the salt-minion service.
|
||||||
if [ $CURRENT_TIME -ge $((SYSTEM_START_TIME+$UPTIME_REQ)) ]; then
|
if [ $CURRENT_TIME -ge $((SYSTEM_START_TIME+$UPTIME_REQ)) ]; then
|
||||||
if [ $THRESHOLD_DATE -le $CURRENT_TIME ]; then
|
if [ $THRESHOLD_DATE -le $CURRENT_TIME ]; then
|
||||||
log "salt-minion is unable to apply states" E
|
log "[minion-restart-check] salt-minion is unable to apply states; restarting salt-minion" E
|
||||||
log "/opt/so/log/salt/healthcheck-state-apply not touched by required date: `date -d @$THRESHOLD_DATE`, last touched: `date -d @$LAST_HEALTHCHECK_STATE_APPLY`" I
|
log "[minion-restart-check] state-apply-test not touched by required date `date -d @$THRESHOLD_DATE`, last touched `date -d @$LAST_HEALTHCHECK_STATE_APPLY`" I
|
||||||
log "last highstate completed at `date -d @$LAST_HIGHSTATE_END`" I
|
log "[minion-restart-check] last highstate completed at `date -d @$LAST_HIGHSTATE_END`" I
|
||||||
log "checking if any jobs are running" I
|
log "[minion-restart-check] checking if any jobs are running" I
|
||||||
logCmd "salt-call --local saltutil.running" I
|
logCmd "salt-call --local saltutil.running" I
|
||||||
log "ensure salt.minion-state-apply-test is enabled" I
|
log "[minion-restart-check] ensure salt.minion-state-apply-test is enabled" I
|
||||||
logCmd "salt-call state.enable salt.minion-state-apply-test" I
|
logCmd "salt-call state.enable salt.minion-state-apply-test" I
|
||||||
log "ensure highstate is enabled" I
|
log "[minion-restart-check] ensure highstate is enabled" I
|
||||||
logCmd "salt-call state.enable highstate" I
|
logCmd "salt-call state.enable highstate" I
|
||||||
log "killing all salt-minion processes" I
|
log "[minion-restart-check] killing all salt-minion processes" I
|
||||||
logCmd "pkill -9 -ef /usr/bin/salt-minion" I
|
logCmd "pkill -9 -ef /usr/bin/salt-minion" I
|
||||||
log "starting salt-minion service" I
|
log "[minion-restart-check] starting salt-minion service" I
|
||||||
logCmd "systemctl start salt-minion" I
|
logCmd "systemctl start salt-minion" I
|
||||||
|
log "[minion-restart-check] waiting for salt-minion to become ready, then applying highstate in the background (queued)" I
|
||||||
|
nohup bash -c '/usr/sbin/so-salt-minion-wait; salt-call state.highstate queue=True' >> "/opt/so/log/salt/so-salt-minion-check" 2>&1 &
|
||||||
|
RESTARTED=true
|
||||||
else
|
else
|
||||||
log "/opt/so/log/salt/healthcheck-state-apply last touched: `date -d @$LAST_HEALTHCHECK_STATE_APPLY` must be touched by `date -d @$THRESHOLD_DATE` to avoid salt-minion restart" I
|
log "[minion-restart-check] healthy: state-apply-test last touched `date -d @$LAST_HEALTHCHECK_STATE_APPLY`, must go stale past `date -d @$THRESHOLD_DATE` to trigger a salt-minion restart" I
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
log "system uptime only $((CURRENT_TIME-SYSTEM_START_TIME)) seconds does not meet $UPTIME_REQ second requirement." I
|
log "[minion-restart-check] skipped: system uptime $((CURRENT_TIME-SYSTEM_START_TIME))s is below the ${UPTIME_REQ}s minimum required before a salt-minion restart" I
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check 2 (boot-highstate-check): if the host has been up long enough but no highstate
|
||||||
|
# has completed since this boot, force one. This recovers a host whose boot highstate
|
||||||
|
# (so-boot-highstate.service) failed or was skipped, even while the minion is otherwise
|
||||||
|
# healthy (touching state-apply-test). We deliberately do NOT enable highstate here: if
|
||||||
|
# soup has disabled it during an upgrade, Salt will refuse the highstate and we avoid
|
||||||
|
# forcing one mid-upgrade.
|
||||||
|
if $RESTARTED; then
|
||||||
|
log "[boot-highstate-check] skipped: minion-restart-check already queued a highstate this run" I
|
||||||
|
elif [ $CURRENT_TIME -lt $((SYSTEM_START_TIME+HIGHSTATE_UPTIME_REQ)) ]; then
|
||||||
|
log "[boot-highstate-check] skipped: system uptime $((CURRENT_TIME-SYSTEM_START_TIME))s is below the ${HIGHSTATE_UPTIME_REQ}s minimum required before forcing a highstate" I
|
||||||
|
elif [ $LAST_HIGHSTATE_END -ge $SYSTEM_START_TIME ]; then
|
||||||
|
log "[boot-highstate-check] healthy: a highstate completed at `date -d @$LAST_HIGHSTATE_END`, after this boot at `date -d @$SYSTEM_START_TIME`" I
|
||||||
|
elif salt-call --local saltutil.running 2>/dev/null | grep -q 'state.highstate'; then
|
||||||
|
log "[boot-highstate-check] no highstate has completed since boot, but one is already running; skipping" I
|
||||||
|
else
|
||||||
|
log "[boot-highstate-check] no highstate has completed since boot after $((CURRENT_TIME-SYSTEM_START_TIME))s uptime; applying highstate" E
|
||||||
|
nohup bash -c 'salt-call state.highstate -l info queue=True' >> "/opt/so/log/salt/so-salt-minion-check" 2>&1 &
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
prune_images:
|
prune_images:
|
||||||
cmd.run:
|
cmd.run:
|
||||||
- name: so-docker-prune
|
- name: so-docker-prune
|
||||||
- order: last
|
- onlyif: command -v /usr/sbin/so-docker-prune >/dev/null 2>&1
|
||||||
|
- order: 9000
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ wait_for_elasticsearch:
|
|||||||
so-elastalert:
|
so-elastalert:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elastalert:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elastalert:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- hostname: elastalert
|
- hostname: elastalert
|
||||||
- name: so-elastalert
|
- name: so-elastalert
|
||||||
- user: so-elastalert
|
- user: so-elastalert
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ include:
|
|||||||
so-elastic-fleet-package-registry:
|
so-elastic-fleet-package-registry:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elastic-fleet-package-registry:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elastic-fleet-package-registry:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- name: so-elastic-fleet-package-registry
|
- name: so-elastic-fleet-package-registry
|
||||||
- hostname: Fleet-package-reg-{{ GLOBALS.hostname }}
|
- hostname: Fleet-package-reg-{{ GLOBALS.hostname }}
|
||||||
- detach: True
|
- detach: True
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ include:
|
|||||||
so-elastic-agent:
|
so-elastic-agent:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elastic-agent:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elastic-agent:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- name: so-elastic-agent
|
- name: so-elastic-agent
|
||||||
- hostname: {{ GLOBALS.hostname }}
|
- hostname: {{ GLOBALS.hostname }}
|
||||||
- detach: True
|
- detach: True
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
elasticfleet:
|
elasticfleet:
|
||||||
enabled: False
|
enabled: False
|
||||||
patch_version: 9.3.3+build202604082258 # Elastic Agent specific patch release.
|
|
||||||
enable_manager_output: True
|
enable_manager_output: True
|
||||||
config:
|
config:
|
||||||
server:
|
server:
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ elasticagent_syncartifacts:
|
|||||||
so-elastic-fleet:
|
so-elastic-fleet:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elastic-agent:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elastic-agent:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- name: so-elastic-fleet
|
- name: so-elastic-fleet
|
||||||
- hostname: FleetServer-{{ GLOBALS.hostname }}
|
- hostname: FleetServer-{{ GLOBALS.hostname }}
|
||||||
- detach: True
|
- detach: True
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"package": {
|
"package": {
|
||||||
"name": "endpoint",
|
"name": "endpoint",
|
||||||
"title": "Elastic Defend",
|
"title": "Elastic Defend",
|
||||||
"version": "9.3.0",
|
"version": "9.3.1",
|
||||||
"requires_root": true
|
"requires_root": true
|
||||||
},
|
},
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"\\.gz$"
|
"\\.gz$"
|
||||||
],
|
],
|
||||||
"include_files": [],
|
"include_files": [],
|
||||||
"processors": "- dissect:\n tokenizer: \"/nsm/import/%{import.id}/evtx/%{import.file}\"\n field: \"log.file.path\"\n target_prefix: \"\"\n- decode_json_fields:\n fields: [\"message\"]\n target: \"\"\n- drop_fields:\n fields: [\"host\"]\n ignore_missing: true\n- add_fields:\n target: data_stream\n fields:\n type: logs\n dataset: system.security\n- add_fields:\n target: event\n fields:\n dataset: system.security\n module: system\n imported: true\n- add_fields:\n target: \"@metadata\"\n fields:\n pipeline: logs-system.security-2.15.0\n- if:\n equals:\n winlog.channel: 'Microsoft-Windows-Sysmon/Operational'\n then: \n - add_fields:\n target: data_stream\n fields:\n dataset: windows.sysmon_operational\n - add_fields:\n target: event\n fields:\n dataset: windows.sysmon_operational\n module: windows\n imported: true\n - add_fields:\n target: \"@metadata\"\n fields:\n pipeline: logs-windows.sysmon_operational-3.8.0\n- if:\n equals:\n winlog.channel: 'Application'\n then: \n - add_fields:\n target: data_stream\n fields:\n dataset: system.application\n - add_fields:\n target: event\n fields:\n dataset: system.application\n - add_fields:\n target: \"@metadata\"\n fields:\n pipeline: logs-system.application-2.15.0\n- if:\n equals:\n winlog.channel: 'System'\n then: \n - add_fields:\n target: data_stream\n fields:\n dataset: system.system\n - add_fields:\n target: event\n fields:\n dataset: system.system\n - add_fields:\n target: \"@metadata\"\n fields:\n pipeline: logs-system.system-2.15.0\n \n- if:\n equals:\n winlog.channel: 'Microsoft-Windows-PowerShell/Operational'\n then: \n - add_fields:\n target: data_stream\n fields:\n dataset: windows.powershell_operational\n - add_fields:\n target: event\n fields:\n dataset: windows.powershell_operational\n module: windows\n - add_fields:\n target: \"@metadata\"\n fields:\n pipeline: logs-windows.powershell_operational-3.8.0\n- add_fields:\n target: data_stream\n fields:\n dataset: import",
|
"processors": "- dissect:\n tokenizer: \"/nsm/import/%{import.id}/evtx/%{import.file}\"\n field: \"log.file.path\"\n target_prefix: \"\"\n- decode_json_fields:\n fields: [\"message\"]\n target: \"\"\n- drop_fields:\n fields: [\"host\"]\n ignore_missing: true\n- add_fields:\n target: data_stream\n fields:\n type: logs\n dataset: system.security\n- add_fields:\n target: event\n fields:\n dataset: system.security\n module: system\n imported: true\n- add_fields:\n target: \"@metadata\"\n fields:\n pipeline: logs-system.security-2.20.0\n- if:\n equals:\n winlog.channel: 'Microsoft-Windows-Sysmon/Operational'\n then: \n - add_fields:\n target: data_stream\n fields:\n dataset: windows.sysmon_operational\n - add_fields:\n target: event\n fields:\n dataset: windows.sysmon_operational\n module: windows\n imported: true\n - add_fields:\n target: \"@metadata\"\n fields:\n pipeline: logs-windows.sysmon_operational-3.8.3\n- if:\n equals:\n winlog.channel: 'Application'\n then: \n - add_fields:\n target: data_stream\n fields:\n dataset: system.application\n - add_fields:\n target: event\n fields:\n dataset: system.application\n - add_fields:\n target: \"@metadata\"\n fields:\n pipeline: logs-system.application-2.20.0\n- if:\n equals:\n winlog.channel: 'System'\n then: \n - add_fields:\n target: data_stream\n fields:\n dataset: system.system\n - add_fields:\n target: event\n fields:\n dataset: system.system\n - add_fields:\n target: \"@metadata\"\n fields:\n pipeline: logs-system.system-2.20.0\n \n- if:\n equals:\n winlog.channel: 'Microsoft-Windows-PowerShell/Operational'\n then: \n - add_fields:\n target: data_stream\n fields:\n dataset: windows.powershell_operational\n - add_fields:\n target: event\n fields:\n dataset: windows.powershell_operational\n module: windows\n - add_fields:\n target: \"@metadata\"\n fields:\n pipeline: logs-windows.powershell_operational-3.8.3\n- add_fields:\n target: data_stream\n fields:\n dataset: import",
|
||||||
"tags": [
|
"tags": [
|
||||||
"import"
|
"import"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
elasticsearch:
|
elasticsearch:
|
||||||
enabled: false
|
enabled: false
|
||||||
version: 9.3.3
|
version: 9.3.7
|
||||||
index_clean: true
|
index_clean: true
|
||||||
data_retention_method: DLM
|
data_retention_method: DLM
|
||||||
vm:
|
vm:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ include:
|
|||||||
so-elasticsearch:
|
so-elasticsearch:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elasticsearch:{{ ELASTICSEARCHMERGED.version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elasticsearch:{{ ELASTICSEARCHMERGED.version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- hostname: elasticsearch
|
- hostname: elasticsearch
|
||||||
- name: so-elasticsearch
|
- name: so-elasticsearch
|
||||||
- user: elasticsearch
|
- user: elasticsearch
|
||||||
|
|||||||
+10
-10
@@ -118,70 +118,70 @@
|
|||||||
{
|
{
|
||||||
"pipeline": {
|
"pipeline": {
|
||||||
"tag": "pipeline_e16851a7",
|
"tag": "pipeline_e16851a7",
|
||||||
"name": "logs-pfsense.log-1.25.2-firewall",
|
"name": "logs-pfsense.log-1.25.4-firewall",
|
||||||
"if": "ctx.event.provider == 'filterlog'"
|
"if": "ctx.event.provider == 'filterlog'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pipeline": {
|
"pipeline": {
|
||||||
"tag": "pipeline_828590b5",
|
"tag": "pipeline_828590b5",
|
||||||
"name": "logs-pfsense.log-1.25.2-openvpn",
|
"name": "logs-pfsense.log-1.25.4-openvpn",
|
||||||
"if": "ctx.event.provider == 'openvpn'"
|
"if": "ctx.event.provider == 'openvpn'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pipeline": {
|
"pipeline": {
|
||||||
"tag": "pipeline_9d37039c",
|
"tag": "pipeline_9d37039c",
|
||||||
"name": "logs-pfsense.log-1.25.2-ipsec",
|
"name": "logs-pfsense.log-1.25.4-ipsec",
|
||||||
"if": "ctx.event.provider == 'charon'"
|
"if": "ctx.event.provider == 'charon'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pipeline": {
|
"pipeline": {
|
||||||
"tag": "pipeline_ad56bbca",
|
"tag": "pipeline_ad56bbca",
|
||||||
"name": "logs-pfsense.log-1.25.2-dhcp",
|
"name": "logs-pfsense.log-1.25.4-dhcp",
|
||||||
"if": "[\"dhcpd\", \"dhclient\", \"dhcp6c\", \"dnsmasq-dhcp\"].contains(ctx.event.provider)"
|
"if": "[\"dhcpd\", \"dhclient\", \"dhcp6c\", \"dnsmasq-dhcp\"].contains(ctx.event.provider)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pipeline": {
|
"pipeline": {
|
||||||
"tag": "pipeline_dd85553d",
|
"tag": "pipeline_dd85553d",
|
||||||
"name": "logs-pfsense.log-1.25.2-unbound",
|
"name": "logs-pfsense.log-1.25.4-unbound",
|
||||||
"if": "ctx.event.provider == 'unbound'"
|
"if": "ctx.event.provider == 'unbound'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pipeline": {
|
"pipeline": {
|
||||||
"tag": "pipeline_720ed255",
|
"tag": "pipeline_720ed255",
|
||||||
"name": "logs-pfsense.log-1.25.2-haproxy",
|
"name": "logs-pfsense.log-1.25.4-haproxy",
|
||||||
"if": "ctx.event.provider == 'haproxy'"
|
"if": "ctx.event.provider == 'haproxy'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pipeline": {
|
"pipeline": {
|
||||||
"tag": "pipeline_456beba5",
|
"tag": "pipeline_456beba5",
|
||||||
"name": "logs-pfsense.log-1.25.2-php-fpm",
|
"name": "logs-pfsense.log-1.25.4-php-fpm",
|
||||||
"if": "ctx.event.provider == 'php-fpm'"
|
"if": "ctx.event.provider == 'php-fpm'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pipeline": {
|
"pipeline": {
|
||||||
"tag": "pipeline_a0d89375",
|
"tag": "pipeline_a0d89375",
|
||||||
"name": "logs-pfsense.log-1.25.2-squid",
|
"name": "logs-pfsense.log-1.25.4-squid",
|
||||||
"if": "ctx.event.provider == 'squid'"
|
"if": "ctx.event.provider == 'squid'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pipeline": {
|
"pipeline": {
|
||||||
"tag": "pipeline_c2f1ed55",
|
"tag": "pipeline_c2f1ed55",
|
||||||
"name": "logs-pfsense.log-1.25.2-snort",
|
"name": "logs-pfsense.log-1.25.4-snort",
|
||||||
"if": "ctx.event.provider == 'snort'"
|
"if": "ctx.event.provider == 'snort'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pipeline": {
|
"pipeline": {
|
||||||
"tag":"pipeline_33db1c9e",
|
"tag":"pipeline_33db1c9e",
|
||||||
"name": "logs-pfsense.log-1.25.2-suricata",
|
"name": "logs-pfsense.log-1.25.4-suricata",
|
||||||
"if": "ctx.event.provider == 'suricata'"
|
"if": "ctx.event.provider == 'suricata'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -22,6 +22,7 @@ include:
|
|||||||
so-hydra:
|
so-hydra:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-hydra:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-hydra:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- hostname: hydra
|
- hostname: hydra
|
||||||
- name: so-hydra
|
- name: so-hydra
|
||||||
- networks:
|
- networks:
|
||||||
@@ -58,7 +59,6 @@ so-hydra:
|
|||||||
- {{ ULIMIT.name }}={{ ULIMIT.soft }}:{{ ULIMIT.hard }}
|
- {{ ULIMIT.name }}={{ ULIMIT.soft }}:{{ ULIMIT.hard }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
- restart_policy: unless-stopped
|
|
||||||
- watch:
|
- watch:
|
||||||
- file: hydraconfig
|
- file: hydraconfig
|
||||||
- require:
|
- require:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ include:
|
|||||||
so-idh:
|
so-idh:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-idh:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-idh:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- name: so-idh
|
- name: so-idh
|
||||||
- detach: True
|
- detach: True
|
||||||
- network_mode: host
|
- network_mode: host
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ include:
|
|||||||
so-influxdb:
|
so-influxdb:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-influxdb:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-influxdb:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- hostname: influxdb
|
- hostname: influxdb
|
||||||
- networks:
|
- networks:
|
||||||
- sobridge:
|
- sobridge:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ include:
|
|||||||
so-kafka:
|
so-kafka:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-kafka:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-kafka:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- hostname: so-kafka
|
- hostname: so-kafka
|
||||||
- name: so-kafka
|
- name: so-kafka
|
||||||
- networks:
|
- networks:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ kibana:
|
|||||||
- default
|
- default
|
||||||
- file
|
- file
|
||||||
migrations:
|
migrations:
|
||||||
discardCorruptObjects: "9.3.3"
|
discardCorruptObjects: "9.3.7"
|
||||||
telemetry:
|
telemetry:
|
||||||
enabled: False
|
enabled: False
|
||||||
xpack:
|
xpack:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ include:
|
|||||||
so-kibana:
|
so-kibana:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-kibana:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-kibana:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- hostname: kibana
|
- hostname: kibana
|
||||||
- user: "932:0"
|
- user: "932:0"
|
||||||
- networks:
|
- networks:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ include:
|
|||||||
so-kratos:
|
so-kratos:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-kratos:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-kratos:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- hostname: kratos
|
- hostname: kratos
|
||||||
- name: so-kratos
|
- name: so-kratos
|
||||||
- networks:
|
- networks:
|
||||||
@@ -51,7 +52,6 @@ so-kratos:
|
|||||||
- {{ ULIMIT.name }}={{ ULIMIT.soft }}:{{ ULIMIT.hard }}
|
- {{ ULIMIT.name }}={{ ULIMIT.soft }}:{{ ULIMIT.hard }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
- restart_policy: unless-stopped
|
|
||||||
- watch:
|
- watch:
|
||||||
- file: kratosschema
|
- file: kratosschema
|
||||||
- file: kratosconfig
|
- file: kratosconfig
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# This state is designed to run on a development manager running in a libvirt VM. It will map the default pillar and salt directories
|
||||||
|
# from /opt/so/saltstack/default to your local development machine as the source path.
|
||||||
|
# The VM requires a filesystem to be added. Only the source path should be changed to your development codebase
|
||||||
|
# Driver: virtio-9p
|
||||||
|
# Source path: ~/project/securityonion
|
||||||
|
# Target path: saltDev
|
||||||
|
|
||||||
|
# If you want a directory to be RW, then kvm must have group privileges.
|
||||||
|
# ll /home/user/projects/securityonion/salt/hypervisor
|
||||||
|
# total 48
|
||||||
|
# drwxrwxr-x 3 user kvm 4096 Feb 13 11:18 ./
|
||||||
|
# drwxrwxr-x 64 user user 4096 Feb 13 10:32 ../
|
||||||
|
# -rw-rw-r-- 1 user kvm 2238 Feb 12 15:06 defaults.yaml
|
||||||
|
# -rw-rw-r-- 1 user kvm 1467 Feb 12 15:06 init.sls
|
||||||
|
# -rw-rw-r-- 1 user kvm 70 Feb 13 09:37 soc_hypervisor.yaml
|
||||||
|
# drwxrwxr-x 3 user kvm 4096 Feb 12 15:06 tools/
|
||||||
|
|
||||||
|
# Ensure required kernel modules are configured for loading
|
||||||
|
/etc/modules-load.d/virtio-9p.conf:
|
||||||
|
file.managed:
|
||||||
|
- contents: |
|
||||||
|
9pnet_virtio
|
||||||
|
9pnet
|
||||||
|
9p
|
||||||
|
- mode: 644
|
||||||
|
- user: root
|
||||||
|
- group: root
|
||||||
|
|
||||||
|
# Load the kernel modules immediately (in the correct order)
|
||||||
|
load_9p_modules:
|
||||||
|
cmd.run:
|
||||||
|
- names:
|
||||||
|
- modprobe 9pnet_virtio
|
||||||
|
- modprobe 9pnet
|
||||||
|
- modprobe 9p
|
||||||
|
- unless: lsmod | grep -E '9pnet_virtio|9pnet|9p'
|
||||||
|
|
||||||
|
# Ensure mount point exists
|
||||||
|
/opt/so/saltstack/default:
|
||||||
|
file.directory:
|
||||||
|
- user: root
|
||||||
|
- group: root
|
||||||
|
- mode: 755
|
||||||
|
- makedirs: True
|
||||||
|
|
||||||
|
# Configure fstab entry using mount.fstab_present
|
||||||
|
# Configure fstab entry using mount.fstab_present
|
||||||
|
saltdev_fstab:
|
||||||
|
mount.fstab_present:
|
||||||
|
- name: saltDev
|
||||||
|
- fs_file: /opt/so/saltstack/default
|
||||||
|
- fs_vfstype: 9p
|
||||||
|
- fs_mntops: _netdev,trans=virtio,version=9p2000.L
|
||||||
|
- fs_freq: 0
|
||||||
|
- fs_passno: 0
|
||||||
|
|
||||||
|
# Mount the filesystem if not already mounted
|
||||||
|
mount_saltdev:
|
||||||
|
mount.mounted:
|
||||||
|
- name: /opt/so/saltstack/default
|
||||||
|
- device: saltDev
|
||||||
|
- fstype: 9p
|
||||||
|
- opts: _netdev,trans=virtio,version=9p2000.L
|
||||||
|
- require:
|
||||||
|
- file: /opt/so/saltstack/default
|
||||||
|
- mount: saltdev_fstab
|
||||||
|
- cmd: load_9p_modules
|
||||||
@@ -28,6 +28,7 @@ include:
|
|||||||
so-logstash:
|
so-logstash:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-logstash:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-logstash:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- hostname: so-logstash
|
- hostname: so-logstash
|
||||||
- name: so-logstash
|
- name: so-logstash
|
||||||
- networks:
|
- networks:
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{% from 'vars/globals.map.jinja' import GLOBALS %}
|
||||||
|
{% from 'salt/auto_apply.map.jinja' import AUTOAPPLY %}
|
||||||
|
|
||||||
|
include:
|
||||||
|
- salt.minion
|
||||||
|
|
||||||
|
{% if GLOBALS.is_manager and AUTOAPPLY.enabled %}
|
||||||
|
salt_beacons_pushstate:
|
||||||
|
file.managed:
|
||||||
|
- name: /etc/salt/minion.d/beacons_pushstate.conf
|
||||||
|
- source: salt://manager/files/beacons_pushstate.conf.jinja
|
||||||
|
- template: jinja
|
||||||
|
- watch_in:
|
||||||
|
- service: salt_minion_service
|
||||||
|
{% else %}
|
||||||
|
salt_beacons_pushstate:
|
||||||
|
file.absent:
|
||||||
|
- name: /etc/salt/minion.d/beacons_pushstate.conf
|
||||||
|
- watch_in:
|
||||||
|
- service: salt_minion_service
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{% from 'salt/auto_apply.map.jinja' import AUTOAPPLY %}
|
||||||
|
beacons:
|
||||||
|
postgres_pillar_beacon:
|
||||||
|
- interval: {{ AUTOAPPLY.drain_interval }}
|
||||||
|
- disable_during_state_run: False
|
||||||
|
rules_beacon:
|
||||||
|
- interval: {{ AUTOAPPLY.drain_interval }}
|
||||||
|
- disable_during_state_run: False
|
||||||
|
- paths:
|
||||||
|
/opt/so/saltstack/local/salt/suricata/rules: suricata
|
||||||
|
/opt/so/saltstack/local/salt/strelka/rules/compiled: strelka
|
||||||
@@ -15,6 +15,7 @@ include:
|
|||||||
- manager.elasticsearch
|
- manager.elasticsearch
|
||||||
- manager.kibana
|
- manager.kibana
|
||||||
- manager.managed_soc_annotations
|
- manager.managed_soc_annotations
|
||||||
|
- manager.beacons
|
||||||
|
|
||||||
repo_log_dir:
|
repo_log_dir:
|
||||||
file.directory:
|
file.directory:
|
||||||
@@ -260,6 +261,7 @@ surifiltersrules:
|
|||||||
- user: 939
|
- user: 939
|
||||||
- group: 939
|
- group: 939
|
||||||
|
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
{{sls}}_state_not_allowed:
|
{{sls}}_state_not_allowed:
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
#!/opt/saltstack/salt/bin/python3
|
||||||
|
|
||||||
|
# Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one
|
||||||
|
# or more contributor license agreements. Licensed under the Elastic License 2.0 as shown at
|
||||||
|
# https://securityonion.net/license; you may not use this file except in compliance with the
|
||||||
|
# Elastic License 2.0.
|
||||||
|
|
||||||
|
"""
|
||||||
|
so-push-drainer
|
||||||
|
===============
|
||||||
|
|
||||||
|
Scheduled drainer for the active-push feature. Runs on the manager every
|
||||||
|
drain_interval seconds (default 15) via a salt schedule in salt/salt/push_drain_schedule.sls.
|
||||||
|
|
||||||
|
For each intent file under /opt/so/state/push_pending/*.json whose last_touch
|
||||||
|
is older than debounce_seconds, this script:
|
||||||
|
* concatenates the actions lists from every ready intent
|
||||||
|
* dedupes by (state or __highstate__, tgt, tgt_type)
|
||||||
|
* dispatches a single `salt-run state.orchestrate orch.push_batch --async`
|
||||||
|
with the deduped actions list passed as pillar kwargs
|
||||||
|
* deletes the contributed intent files on successful dispatch
|
||||||
|
|
||||||
|
Reactor sls files (push_suricata, push_strelka, push_pillar) write intents
|
||||||
|
but never dispatch directly
|
||||||
|
"""
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import salt.client
|
||||||
|
|
||||||
|
PENDING_DIR = '/opt/so/state/push_pending'
|
||||||
|
LOCK_FILE = os.path.join(PENDING_DIR, '.lock')
|
||||||
|
LOG_FILE = '/opt/so/log/salt/so-push-drainer.log'
|
||||||
|
|
||||||
|
HIGHSTATE_SENTINEL = '__highstate__'
|
||||||
|
|
||||||
|
|
||||||
|
def _make_logger():
|
||||||
|
logger = logging.getLogger('so-push-drainer')
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
if not logger.handlers:
|
||||||
|
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
|
||||||
|
handler = logging.handlers.RotatingFileHandler(
|
||||||
|
LOG_FILE, maxBytes=5 * 1024 * 1024, backupCount=3,
|
||||||
|
)
|
||||||
|
handler.setFormatter(logging.Formatter(
|
||||||
|
'%(asctime)s | %(levelname)s | %(message)s',
|
||||||
|
))
|
||||||
|
logger.addHandler(handler)
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def _load_push_cfg():
|
||||||
|
"""Read the salt:auto_apply pillar subtree via salt-call. Returns a dict."""
|
||||||
|
caller = salt.client.Caller()
|
||||||
|
cfg = caller.cmd('pillar.get', 'salt:auto_apply', {})
|
||||||
|
return cfg if isinstance(cfg, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_intent(path, log):
|
||||||
|
try:
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (IOError, ValueError) as exc:
|
||||||
|
log.warning('cannot read intent %s: %s', path, exc)
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
log.exception('unexpected error reading %s', path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_actions(actions):
|
||||||
|
seen = set()
|
||||||
|
deduped = []
|
||||||
|
for action in actions:
|
||||||
|
if not isinstance(action, dict):
|
||||||
|
continue
|
||||||
|
state_key = HIGHSTATE_SENTINEL if action.get('highstate') else action.get('state')
|
||||||
|
tgt = action.get('tgt')
|
||||||
|
tgt_type = action.get('tgt_type', 'compound')
|
||||||
|
if not state_key or not tgt:
|
||||||
|
continue
|
||||||
|
key = (state_key, tgt, tgt_type)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
deduped.append(action)
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch(actions, log):
|
||||||
|
pillar_arg = json.dumps({'actions': actions})
|
||||||
|
cmd = [
|
||||||
|
'salt-run',
|
||||||
|
'state.orchestrate',
|
||||||
|
'orch.push_batch',
|
||||||
|
'pillar={}'.format(pillar_arg),
|
||||||
|
'--async',
|
||||||
|
]
|
||||||
|
log.info('dispatching: %s', ' '.join(cmd[:3]) + ' pillar=<{} actions>'.format(len(actions)))
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd, check=True, capture_output=True, text=True, timeout=60,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
log.error('dispatch failed (rc=%s): stdout=%s stderr=%s',
|
||||||
|
exc.returncode, exc.stdout, exc.stderr)
|
||||||
|
return False
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
log.error('dispatch timed out after 60s')
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
log.exception('dispatch raised')
|
||||||
|
return False
|
||||||
|
log.info('dispatch accepted: %s', (result.stdout or '').strip())
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log = _make_logger()
|
||||||
|
|
||||||
|
if not os.path.isdir(PENDING_DIR):
|
||||||
|
# Nothing to do; reactors create the dir on first use.
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
push = _load_push_cfg()
|
||||||
|
except Exception:
|
||||||
|
log.exception('failed to read salt:auto_apply pillar; aborting drain pass')
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not push.get('enabled', True):
|
||||||
|
log.debug('push disabled; exiting')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
debounce_seconds = int(push.get('debounce_seconds', 30))
|
||||||
|
|
||||||
|
os.makedirs(PENDING_DIR, exist_ok=True)
|
||||||
|
lock_fd = os.open(LOCK_FILE, os.O_CREAT | os.O_RDWR, 0o644)
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||||
|
|
||||||
|
intent_files = [
|
||||||
|
p for p in sorted(glob.glob(os.path.join(PENDING_DIR, '*.json')))
|
||||||
|
if os.path.basename(p) != '.lock'
|
||||||
|
]
|
||||||
|
if not intent_files:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
ready = []
|
||||||
|
skipped = 0
|
||||||
|
broken = []
|
||||||
|
for path in intent_files:
|
||||||
|
intent = _read_intent(path, log)
|
||||||
|
if not isinstance(intent, dict):
|
||||||
|
broken.append(path)
|
||||||
|
continue
|
||||||
|
last_touch = intent.get('last_touch', 0)
|
||||||
|
if now - last_touch < debounce_seconds:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
ready.append((path, intent))
|
||||||
|
|
||||||
|
for path in broken:
|
||||||
|
try:
|
||||||
|
os.unlink(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not ready:
|
||||||
|
if skipped:
|
||||||
|
log.debug('no ready intents (%d still in debounce window)', skipped)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
combined_actions = []
|
||||||
|
oldest_first_touch = now
|
||||||
|
all_paths = []
|
||||||
|
for path, intent in ready:
|
||||||
|
combined_actions.extend(intent.get('actions', []) or [])
|
||||||
|
first = intent.get('first_touch', now)
|
||||||
|
if first < oldest_first_touch:
|
||||||
|
oldest_first_touch = first
|
||||||
|
all_paths.extend(intent.get('paths', []) or [])
|
||||||
|
|
||||||
|
deduped = _dedupe_actions(combined_actions)
|
||||||
|
if not deduped:
|
||||||
|
log.warning('%d intent(s) had no usable actions; clearing', len(ready))
|
||||||
|
for path, _ in ready:
|
||||||
|
try:
|
||||||
|
os.unlink(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
debounce_duration = now - oldest_first_touch
|
||||||
|
log.info(
|
||||||
|
'draining %d intent(s): %d action(s) after dedupe (raw=%d), '
|
||||||
|
'debounce_duration=%.1fs, paths=%s',
|
||||||
|
len(ready), len(deduped), len(combined_actions),
|
||||||
|
debounce_duration, all_paths[:20],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _dispatch(deduped, log):
|
||||||
|
log.warning('dispatch failed; leaving intent files in place for retry')
|
||||||
|
return 1
|
||||||
|
|
||||||
|
for path, _ in ready:
|
||||||
|
try:
|
||||||
|
os.unlink(path)
|
||||||
|
except OSError:
|
||||||
|
log.exception('failed to remove drained intent %s', path)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||||
|
finally:
|
||||||
|
os.close(lock_fd)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
+145
-21
@@ -12,7 +12,17 @@
|
|||||||
UPDATE_DIR=/tmp/sogh/securityonion
|
UPDATE_DIR=/tmp/sogh/securityonion
|
||||||
DEFAULT_SALT_DIR=/opt/so/saltstack/default
|
DEFAULT_SALT_DIR=/opt/so/saltstack/default
|
||||||
INSTALLEDVERSION=$(cat /etc/soversion)
|
INSTALLEDVERSION=$(cat /etc/soversion)
|
||||||
POSTVERSION=$INSTALLEDVERSION
|
# /etc/sopostversion is a soup-owned marker (no salt state manages it) tracking how
|
||||||
|
# far the post-upgrade walk has progressed. Its presence means a prior upgrade did
|
||||||
|
# not finish its post-upgrade steps; its contents are the resume point. It is read
|
||||||
|
# here before preupgrade_changes mutates INSTALLEDVERSION and before any highstate
|
||||||
|
# stamps /etc/soversion from the pillar.
|
||||||
|
POSTVERSION_FILE=/etc/sopostversion
|
||||||
|
if [ -f "$POSTVERSION_FILE" ]; then
|
||||||
|
POSTVERSION=$(cat "$POSTVERSION_FILE")
|
||||||
|
else
|
||||||
|
POSTVERSION=$INSTALLEDVERSION
|
||||||
|
fi
|
||||||
INSTALLEDSALTVERSION=$(salt --versions-report | grep Salt: | awk '{print $2}')
|
INSTALLEDSALTVERSION=$(salt --versions-report | grep Salt: | awk '{print $2}')
|
||||||
BATCHSIZE=5
|
BATCHSIZE=5
|
||||||
SOUP_LOG=/root/soup.log
|
SOUP_LOG=/root/soup.log
|
||||||
@@ -23,6 +33,10 @@ NOTIFYCUSTOMELASTICCONFIG=false
|
|||||||
TOPFILE=/opt/so/saltstack/default/salt/top.sls
|
TOPFILE=/opt/so/saltstack/default/salt/top.sls
|
||||||
BACKUPTOPFILE=/opt/so/saltstack/default/salt/top.sls.backup
|
BACKUPTOPFILE=/opt/so/saltstack/default/salt/top.sls.backup
|
||||||
SALTUPGRADED=false
|
SALTUPGRADED=false
|
||||||
|
# Set true once soup begins modifying the system (past the pre-flight checks), so the
|
||||||
|
# EXIT trap can tell the user the update did not finish and must be re-run. Only the
|
||||||
|
# pre-flight gates (ES compatibility, disk, network) fail before this is set.
|
||||||
|
SOUP_UPGRADE_STARTED=false
|
||||||
SALT_CLOUD_INSTALLED=false
|
SALT_CLOUD_INSTALLED=false
|
||||||
SALT_CLOUD_CONFIGURED=false
|
SALT_CLOUD_CONFIGURED=false
|
||||||
# Check if salt-cloud is installed
|
# Check if salt-cloud is installed
|
||||||
@@ -123,6 +137,28 @@ check_err() {
|
|||||||
|
|
||||||
echo "SOUP XTRACE debug log (if enabled) at $SOUP_DEBUG_LOG. Re-run soup with SOUP_DEBUG=1 to create $SOUP_DEBUG_LOG"
|
echo "SOUP XTRACE debug log (if enabled) at $SOUP_DEBUG_LOG. Re-run soup with SOUP_DEBUG=1 to create $SOUP_DEBUG_LOG"
|
||||||
|
|
||||||
|
# If soup had already started modifying the system, make it unmistakable that the
|
||||||
|
# update is incomplete and must be re-run. soup is resumable: a version upgrade
|
||||||
|
# picks up from the /etc/sopostversion marker, and a hotfix re-applies because
|
||||||
|
# /etc/sohotfix is only advanced after a successful highstate.
|
||||||
|
if [[ "$SOUP_UPGRADE_STARTED" == "true" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "=============================================================================="
|
||||||
|
echo " UPGRADE INCOMPLETE"
|
||||||
|
echo "=============================================================================="
|
||||||
|
echo " This soup run did NOT finish. Your Security Onion installation may be in a"
|
||||||
|
echo " partially-updated state and is not yet fully upgraded."
|
||||||
|
echo ""
|
||||||
|
echo " Review the error above and $SOUP_LOG, resolve the underlying problem, then"
|
||||||
|
echo " run soup again to resume and complete the update:"
|
||||||
|
echo ""
|
||||||
|
echo " sudo soup"
|
||||||
|
echo ""
|
||||||
|
echo " soup is resumable -- re-running it continues from where this run stopped."
|
||||||
|
echo "=============================================================================="
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
exit $exit_code
|
exit $exit_code
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -291,6 +327,30 @@ check_pillar_items() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
check_cluster_health() {
|
||||||
|
echo "Checking Elasticsearch cluster health."
|
||||||
|
# Require a 'green' cluster before upgrading; anything less (yellow, red, or
|
||||||
|
# unreachable) blocks. Modeled on the wait used in so-elasticsearch-roles-load.
|
||||||
|
if so-elasticsearch-query "_cluster/health?wait_for_status=green&timeout=120s" --fail > /dev/null 2>&1; then
|
||||||
|
printf "\nThe Elasticsearch cluster is healthy (green). We can proceed with SOUP.\n\n"
|
||||||
|
else
|
||||||
|
printf "\nThe Elasticsearch cluster is not green. Please resolve the cluster health issue so the cluster is green before running SOUP again.\n\n"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_fleet_server() {
|
||||||
|
echo "Checking that Elastic Fleet Server is responding."
|
||||||
|
# Modeled on the wait_for_so-elastic-fleet state check in elasticfleet/enabled.sls,
|
||||||
|
# which waits for HTTP 200 from the Fleet Server status API.
|
||||||
|
if curl -sk --fail --retry 3 --retry-delay 10 --max-time 30 "https://localhost:8220/api/status" > /dev/null 2>&1; then
|
||||||
|
printf "\nElastic Fleet Server is responding. We can proceed with SOUP.\n\n"
|
||||||
|
else
|
||||||
|
printf "\nElastic Fleet Server is not responding at https://localhost:8220/api/status. Please ensure Elastic Fleet is healthy before running SOUP again.\n\n"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
check_saltmaster_status() {
|
check_saltmaster_status() {
|
||||||
set +e
|
set +e
|
||||||
echo "Waiting on the Salt Master service to be ready."
|
echo "Waiting on the Salt Master service to be ready."
|
||||||
@@ -377,6 +437,8 @@ get_soup_script_hashes() {
|
|||||||
GITIMGCMN=$(md5sum $UPDATE_DIR/salt/common/tools/sbin/so-image-common | awk '{print $1}')
|
GITIMGCMN=$(md5sum $UPDATE_DIR/salt/common/tools/sbin/so-image-common | awk '{print $1}')
|
||||||
CURRENTSOFIREWALL=$(md5sum /usr/sbin/so-firewall | awk '{print $1}')
|
CURRENTSOFIREWALL=$(md5sum /usr/sbin/so-firewall | awk '{print $1}')
|
||||||
GITSOFIREWALL=$(md5sum $UPDATE_DIR/salt/manager/tools/sbin/so-firewall | awk '{print $1}')
|
GITSOFIREWALL=$(md5sum $UPDATE_DIR/salt/manager/tools/sbin/so-firewall | awk '{print $1}')
|
||||||
|
CURRENTSOYAML=$(md5sum /usr/sbin/so-yaml.py | awk '{print $1}')
|
||||||
|
GITSOYAML=$(md5sum $UPDATE_DIR/salt/manager/tools/sbin/so-yaml.py | awk '{print $1}')
|
||||||
}
|
}
|
||||||
|
|
||||||
highstate() {
|
highstate() {
|
||||||
@@ -414,6 +476,13 @@ preupgrade_changes() {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set_postversion() {
|
||||||
|
# Persist post-upgrade walk progress so an interrupted upgrade can resume the
|
||||||
|
# remaining steps on the next soup run (see /etc/sopostversion handling).
|
||||||
|
POSTVERSION="$1"
|
||||||
|
echo "$POSTVERSION" > "$POSTVERSION_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
postupgrade_changes() {
|
postupgrade_changes() {
|
||||||
# This function is to add any new pillar items if needed.
|
# This function is to add any new pillar items if needed.
|
||||||
echo "Running post upgrade processes."
|
echo "Running post upgrade processes."
|
||||||
@@ -421,6 +490,8 @@ postupgrade_changes() {
|
|||||||
[[ "$POSTVERSION" =~ ^2\.4\.21[0-9]+$ ]] && post_to_3.0.0
|
[[ "$POSTVERSION" =~ ^2\.4\.21[0-9]+$ ]] && post_to_3.0.0
|
||||||
[[ "$POSTVERSION" == "3.0.0" ]] && post_to_3.1.0
|
[[ "$POSTVERSION" == "3.0.0" ]] && post_to_3.1.0
|
||||||
[[ "$POSTVERSION" == "3.1.0" ]] && post_to_3.2.0
|
[[ "$POSTVERSION" == "3.1.0" ]] && post_to_3.2.0
|
||||||
|
# All applicable post-upgrade steps completed; clear the resume marker.
|
||||||
|
rm -f "$POSTVERSION_FILE"
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,7 +584,7 @@ post_to_3.0.0() {
|
|||||||
# convert yes/no in suricata pillars to true/false
|
# convert yes/no in suricata pillars to true/false
|
||||||
convert_suricata_yes_no
|
convert_suricata_yes_no
|
||||||
|
|
||||||
POSTVERSION=3.0.0
|
set_postversion 3.0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
### 3.0.0 End ###
|
### 3.0.0 End ###
|
||||||
@@ -691,6 +762,21 @@ ensure_postgres_local_pillar() {
|
|||||||
chown -R socore:socore "$dir"
|
chown -R socore:socore "$dir"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_salt_local_pillar() {
|
||||||
|
# The salt.auto_apply settings are a new SOC settings
|
||||||
|
# module, so the new pillar/top.sls references salt.soc_salt / salt.adv_salt
|
||||||
|
# unconditionally. Managers upgrading from before this change have no
|
||||||
|
# /opt/so/saltstack/local/pillar/salt/ (make_some_dirs only runs at install
|
||||||
|
# time), so the stubs must be created here before salt-master restarts against
|
||||||
|
# the new top.sls.
|
||||||
|
echo "Ensuring salt local pillar stubs exist."
|
||||||
|
local dir=/opt/so/saltstack/local/pillar/salt
|
||||||
|
mkdir -p "$dir"
|
||||||
|
[[ -f "$dir/soc_salt.sls" ]] || touch "$dir/soc_salt.sls"
|
||||||
|
[[ -f "$dir/adv_salt.sls" ]] || touch "$dir/adv_salt.sls"
|
||||||
|
chown -R socore:socore "$dir"
|
||||||
|
}
|
||||||
|
|
||||||
ensure_postgres_secret() {
|
ensure_postgres_secret() {
|
||||||
# On a fresh install, generate_passwords + secrets_pillar seed
|
# On a fresh install, generate_passwords + secrets_pillar seed
|
||||||
# secrets:postgres_pass in /opt/so/saltstack/local/pillar/secrets.sls. That
|
# secrets:postgres_pass in /opt/so/saltstack/local/pillar/secrets.sls. That
|
||||||
@@ -740,7 +826,6 @@ fix_logstash_0013_lumberjack_pipeline_name() {
|
|||||||
up_to_3.1.0() {
|
up_to_3.1.0() {
|
||||||
ensure_postgres_local_pillar
|
ensure_postgres_local_pillar
|
||||||
ensure_postgres_secret
|
ensure_postgres_secret
|
||||||
determine_elastic_agent_upgrade
|
|
||||||
elasticsearch_backup_index_templates
|
elasticsearch_backup_index_templates
|
||||||
# Clear existing component template state file.
|
# Clear existing component template state file.
|
||||||
rm -f /opt/so/state/esfleet_component_templates.json
|
rm -f /opt/so/state/esfleet_component_templates.json
|
||||||
@@ -777,7 +862,7 @@ post_to_3.1.0() {
|
|||||||
# Check for unhealthy / unauthorized integration transform jobs and attempt reauthorizations
|
# Check for unhealthy / unauthorized integration transform jobs and attempt reauthorizations
|
||||||
check_transform_health_and_reauthorize || true
|
check_transform_health_and_reauthorize || true
|
||||||
|
|
||||||
POSTVERSION=3.1.0
|
set_postversion 3.1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
### 3.1.0 End ###
|
### 3.1.0 End ###
|
||||||
@@ -888,6 +973,11 @@ update_kafka_metadata() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
up_to_3.2.0() {
|
up_to_3.2.0() {
|
||||||
|
ensure_salt_local_pillar
|
||||||
|
|
||||||
|
# download 9.3.7 elastic agent packages
|
||||||
|
determine_elastic_agent_upgrade
|
||||||
|
|
||||||
fix_logstash_0013_lumberjack_pipeline_name
|
fix_logstash_0013_lumberjack_pipeline_name
|
||||||
|
|
||||||
pin_elasticsearch_data_retention_method
|
pin_elasticsearch_data_retention_method
|
||||||
@@ -901,7 +991,7 @@ post_to_3.2.0() {
|
|||||||
|
|
||||||
bootstrap_so_soc_database
|
bootstrap_so_soc_database
|
||||||
|
|
||||||
# Including agent regen script here since it was missed in post_to_3.1.0
|
# Generate 9.3.7 elastic agent installers
|
||||||
echo "Regenerating Elastic Agent Installers"
|
echo "Regenerating Elastic Agent Installers"
|
||||||
/sbin/so-elastic-agent-gen-installers
|
/sbin/so-elastic-agent-gen-installers
|
||||||
|
|
||||||
@@ -909,7 +999,7 @@ post_to_3.2.0() {
|
|||||||
|
|
||||||
update_kafka_metadata "4.3"
|
update_kafka_metadata "4.3"
|
||||||
|
|
||||||
POSTVERSION=3.2.0
|
set_postversion 3.2.0
|
||||||
}
|
}
|
||||||
|
|
||||||
### 3.2.0 End ###
|
### 3.2.0 End ###
|
||||||
@@ -1061,8 +1151,20 @@ upgrade_check() {
|
|||||||
fi
|
fi
|
||||||
[[ -f /etc/sohotfix ]] && CURRENTHOTFIX=$(cat /etc/sohotfix)
|
[[ -f /etc/sohotfix ]] && CURRENTHOTFIX=$(cat /etc/sohotfix)
|
||||||
if [ "$INSTALLEDVERSION" == "$NEWVERSION" ]; then
|
if [ "$INSTALLEDVERSION" == "$NEWVERSION" ]; then
|
||||||
|
# A leftover post-version marker means a previous upgrade to this version
|
||||||
|
# advanced /etc/soversion (the highstate stamps it from the pillar) but did not
|
||||||
|
# finish its post-upgrade steps. Resume the upgrade instead of reporting "latest".
|
||||||
|
if [ -f "$POSTVERSION_FILE" ] && [ "$(cat "$POSTVERSION_FILE")" != "$NEWVERSION" ]; then
|
||||||
|
echo "A previous upgrade to $NEWVERSION did not complete its post-upgrade steps; resuming."
|
||||||
|
is_hotfix=false
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
echo "Checking to see if there are hotfixes needed"
|
echo "Checking to see if there are hotfixes needed"
|
||||||
if [ "$HOTFIXVERSION" == "$CURRENTHOTFIX" ]; then
|
if [ "$HOTFIXVERSION" == "$CURRENTHOTFIX" ]; then
|
||||||
|
# Reaching here means we are at the target version and NOT resuming (the resume
|
||||||
|
# check above returned otherwise). Clear any stale resume marker so a completed
|
||||||
|
# upgrade is never mistaken for a partial one and re-run on a later invocation.
|
||||||
|
rm -f "$POSTVERSION_FILE"
|
||||||
echo "You are already running the latest version of Security Onion."
|
echo "You are already running the latest version of Security Onion."
|
||||||
exit 0
|
exit 0
|
||||||
else
|
else
|
||||||
@@ -1141,7 +1243,7 @@ upgrade_salt() {
|
|||||||
|
|
||||||
verify_latest_update_script() {
|
verify_latest_update_script() {
|
||||||
get_soup_script_hashes
|
get_soup_script_hashes
|
||||||
if [[ "$CURRENTSOUP" == "$GITSOUP" && "$CURRENTCMN" == "$GITCMN" && "$CURRENTIMGCMN" == "$GITIMGCMN" && "$CURRENTSOFIREWALL" == "$GITSOFIREWALL" ]]; then
|
if [[ "$CURRENTSOUP" == "$GITSOUP" && "$CURRENTCMN" == "$GITCMN" && "$CURRENTIMGCMN" == "$GITIMGCMN" && "$CURRENTSOFIREWALL" == "$GITSOFIREWALL" && "$CURRENTSOYAML" == "$GITSOYAML" ]]; then
|
||||||
echo "This version of the soup script is up to date. Proceeding."
|
echo "This version of the soup script is up to date. Proceeding."
|
||||||
else
|
else
|
||||||
echo "You are not running the latest soup version. Updating soup and its components. This might take multiple runs to complete."
|
echo "You are not running the latest soup version. Updating soup and its components. This might take multiple runs to complete."
|
||||||
@@ -1150,7 +1252,7 @@ verify_latest_update_script() {
|
|||||||
|
|
||||||
# Verify that soup scripts updated as expected
|
# Verify that soup scripts updated as expected
|
||||||
get_soup_script_hashes
|
get_soup_script_hashes
|
||||||
if [[ "$CURRENTSOUP" == "$GITSOUP" && "$CURRENTCMN" == "$GITCMN" && "$CURRENTIMGCMN" == "$GITIMGCMN" && "$CURRENTSOFIREWALL" == "$GITSOFIREWALL" ]]; then
|
if [[ "$CURRENTSOUP" == "$GITSOUP" && "$CURRENTCMN" == "$GITCMN" && "$CURRENTIMGCMN" == "$GITIMGCMN" && "$CURRENTSOFIREWALL" == "$GITSOFIREWALL" && "$CURRENTSOYAML" == "$GITSOYAML" ]]; then
|
||||||
echo "Succesfully updated soup scripts."
|
echo "Succesfully updated soup scripts."
|
||||||
else
|
else
|
||||||
echo "There was a problem updating soup scripts. Trying to rerun script update."
|
echo "There was a problem updating soup scripts. Trying to rerun script update."
|
||||||
@@ -1174,7 +1276,8 @@ verify_es_version_compatibility() {
|
|||||||
["8.18.4"]="8.18.6 8.18.8 9.0.8"
|
["8.18.4"]="8.18.6 8.18.8 9.0.8"
|
||||||
["8.18.6"]="8.18.8 9.0.8"
|
["8.18.6"]="8.18.8 9.0.8"
|
||||||
["8.18.8"]="9.0.8"
|
["8.18.8"]="9.0.8"
|
||||||
["9.0.8"]="9.3.3"
|
["9.0.8"]="9.3.3 9.3.7"
|
||||||
|
["9.3.3"]="9.3.7"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Elasticsearch MUST upgrade through these versions
|
# Elasticsearch MUST upgrade through these versions
|
||||||
@@ -1460,18 +1563,13 @@ verify_es_version_compatibility() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wait_for_salt_minion_with_restart() {
|
wait_for_salt_minion_with_restart() {
|
||||||
local minion="$1"
|
/usr/sbin/so-salt-minion-wait
|
||||||
local max_wait="${2:-60}"
|
|
||||||
local interval="${3:-3}"
|
|
||||||
local logfile="$4"
|
|
||||||
|
|
||||||
wait_for_salt_minion "$minion" "$max_wait" "$interval" "$logfile"
|
|
||||||
local result=$?
|
local result=$?
|
||||||
|
|
||||||
if [[ $result -ne 0 ]]; then
|
if [[ $result -ne 0 ]]; then
|
||||||
echo "$(date '+%a %d %b %Y %H:%M:%S.%6N') - salt-minion not ready, attempting restart..."
|
echo "$(date '+%a %d %b %Y %H:%M:%S.%6N') - salt-minion not ready, attempting restart..."
|
||||||
systemctl_func "restart" "salt-minion"
|
systemctl_func "restart" "salt-minion"
|
||||||
wait_for_salt_minion "$minion" "$max_wait" "$interval" "$logfile"
|
/usr/sbin/so-salt-minion-wait
|
||||||
result=$?
|
result=$?
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1757,6 +1855,15 @@ main() {
|
|||||||
set_minionid
|
set_minionid
|
||||||
MINION_ROLE=$(lookup_role)
|
MINION_ROLE=$(lookup_role)
|
||||||
echo "Found that Security Onion $INSTALLEDVERSION is currently installed."
|
echo "Found that Security Onion $INSTALLEDVERSION is currently installed."
|
||||||
|
# /etc/soversion is stamped to the target version before the upgrade fully
|
||||||
|
# completes, so a lingering resume marker means this grid is only partially
|
||||||
|
# upgraded even though the line above shows the target version. Make that explicit
|
||||||
|
# so it is not mistaken for a finished upgrade.
|
||||||
|
if [ -f "$POSTVERSION_FILE" ] && [ "$(cat "$POSTVERSION_FILE")" != "$INSTALLEDVERSION" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "NOTE: A previous upgrade to $INSTALLEDVERSION did not finish. This grid is"
|
||||||
|
echo " partially upgraded and this soup run will resume and complete it."
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
check_minimum_version
|
check_minimum_version
|
||||||
|
|
||||||
@@ -1785,6 +1892,12 @@ main() {
|
|||||||
echo "Verifying Elasticsearch version compatibility across the grid before upgrading."
|
echo "Verifying Elasticsearch version compatibility across the grid before upgrading."
|
||||||
verify_es_version_compatibility
|
verify_es_version_compatibility
|
||||||
|
|
||||||
|
# Pre-flight health checks: confirm the grid is in a good state before we change
|
||||||
|
# anything. These run before any modifications, so a failure exits cleanly and the
|
||||||
|
# operator can fix the issue and re-run soup.
|
||||||
|
check_cluster_health
|
||||||
|
check_fleet_server
|
||||||
|
|
||||||
echo "Checking for Salt Master and Minion updates."
|
echo "Checking for Salt Master and Minion updates."
|
||||||
upgrade_check_salt
|
upgrade_check_salt
|
||||||
set -e
|
set -e
|
||||||
@@ -1801,6 +1914,7 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$is_hotfix" == "true" ]; then
|
if [ "$is_hotfix" == "true" ]; then
|
||||||
|
SOUP_UPGRADE_STARTED=true
|
||||||
echo "Applying $HOTFIXVERSION hotfix"
|
echo "Applying $HOTFIXVERSION hotfix"
|
||||||
# since we don't run the backup.config_backup state on import we wont snapshot previous version states and pillars
|
# since we don't run the backup.config_backup state on import we wont snapshot previous version states and pillars
|
||||||
if [[ ! "$MINION_ROLE" == "import" ]]; then
|
if [[ ! "$MINION_ROLE" == "import" ]]; then
|
||||||
@@ -1811,10 +1925,16 @@ main() {
|
|||||||
create_local_directories "/opt/so/saltstack/default"
|
create_local_directories "/opt/so/saltstack/default"
|
||||||
apply_hotfix
|
apply_hotfix
|
||||||
echo "Hotfix applied"
|
echo "Hotfix applied"
|
||||||
update_version
|
|
||||||
enable_highstate
|
enable_highstate
|
||||||
highstate
|
highstate
|
||||||
|
# Record the hotfix only after the highstate succeeds. /etc/sohotfix is written
|
||||||
|
# solely by soup (no salt state manages it), so deferring the write means a failed
|
||||||
|
# hotfix highstate leaves the old hotfix value and re-running soup re-applies it,
|
||||||
|
# rather than reporting "already latest". The soversion/pillar writes in
|
||||||
|
# update_version are no-ops here since the version is unchanged for a hotfix.
|
||||||
|
update_version
|
||||||
else
|
else
|
||||||
|
SOUP_UPGRADE_STARTED=true
|
||||||
echo ""
|
echo ""
|
||||||
echo "Performing upgrade from Security Onion $INSTALLEDVERSION to Security Onion $NEWVERSION."
|
echo "Performing upgrade from Security Onion $INSTALLEDVERSION to Security Onion $NEWVERSION."
|
||||||
echo ""
|
echo ""
|
||||||
@@ -1870,6 +1990,10 @@ main() {
|
|||||||
copy_new_files
|
copy_new_files
|
||||||
echo ""
|
echo ""
|
||||||
create_local_directories "/opt/so/saltstack/default"
|
create_local_directories "/opt/so/saltstack/default"
|
||||||
|
# Seed the resume marker before the highstate stamps /etc/soversion to the new
|
||||||
|
# version, so an interrupted upgrade is detectable as "not finished" on re-run.
|
||||||
|
# POSTVERSION still holds the pre-upgrade (or prior resume) version here.
|
||||||
|
[ -f "$POSTVERSION_FILE" ] || echo "$POSTVERSION" > "$POSTVERSION_FILE"
|
||||||
update_version
|
update_version
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -1899,9 +2023,9 @@ main() {
|
|||||||
enable_highstate
|
enable_highstate
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Running a highstate. This could take several minutes."
|
echo "Running a highstate at $(date +"%T.%6N"). This could take several minutes."
|
||||||
set +e
|
set +e
|
||||||
wait_for_salt_minion_with_restart "$MINIONID" "60" "3" "$SOUP_LOG" || fail "Salt minion was not running or ready."
|
wait_for_salt_minion_with_restart || fail "Salt minion was not running or ready."
|
||||||
highstate
|
highstate
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -1913,8 +2037,8 @@ main() {
|
|||||||
|
|
||||||
check_saltmaster_status
|
check_saltmaster_status
|
||||||
|
|
||||||
echo "Running a highstate to complete the Security Onion upgrade on this manager. This could take several minutes."
|
echo "Running a highstate at $(date +"%T.%6N") to complete the Security Onion upgrade on this manager. This could take several minutes."
|
||||||
wait_for_salt_minion_with_restart "$MINIONID" "60" "3" "$SOUP_LOG" || fail "Salt minion was not running or ready."
|
wait_for_salt_minion_with_restart || fail "Salt minion was not running or ready."
|
||||||
|
|
||||||
# Stop long-running scripts to allow potentially updated scripts to load on the next execution.
|
# Stop long-running scripts to allow potentially updated scripts to load on the next execution.
|
||||||
if pgrep salt-relay.sh > /dev/null 2>&1; then
|
if pgrep salt-relay.sh > /dev/null 2>&1; then
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ make-rule-dir-nginx:
|
|||||||
so-nginx:
|
so-nginx:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-nginx:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-nginx:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- hostname: so-nginx
|
- hostname: so-nginx
|
||||||
- networks:
|
- networks:
|
||||||
- sobridge:
|
- sobridge:
|
||||||
|
|||||||
@@ -399,7 +399,7 @@ http {
|
|||||||
error_page 429 = @error429;
|
error_page 429 = @error429;
|
||||||
|
|
||||||
location @error401 {
|
location @error401 {
|
||||||
if ($request_uri ~* (^/api/.*|^/connect/.*|^/oauth2/.*|^/.*\.map$)) {
|
if ($request_uri ~* (^.*/api/.*|^/connect/.*|^/oauth2/.*|^/.*\.map$)) {
|
||||||
return 401;
|
return 401;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{% from 'salt/auto_apply.map.jinja' import AUTOAPPLY %}
|
||||||
|
{% set actions = salt['pillar.get']('actions', []) %}
|
||||||
|
{% set BATCH = AUTOAPPLY.batch %}
|
||||||
|
{% set BATCH_WAIT = AUTOAPPLY.batch_wait %}
|
||||||
|
|
||||||
|
{% for action in actions %}
|
||||||
|
{% if action.get('highstate') %}
|
||||||
|
apply_highstate_{{ loop.index }}:
|
||||||
|
salt.state:
|
||||||
|
- tgt: '{{ action.tgt }}'
|
||||||
|
- tgt_type: {{ action.get('tgt_type', 'compound') }}
|
||||||
|
- highstate: True
|
||||||
|
- batch: {{ action.get('batch', BATCH) }}
|
||||||
|
- batch_wait: {{ action.get('batch_wait', BATCH_WAIT) }}
|
||||||
|
- kwarg:
|
||||||
|
queue: 2
|
||||||
|
{% else %}
|
||||||
|
refresh_pillar_{{ loop.index }}:
|
||||||
|
salt.function:
|
||||||
|
- name: saltutil.refresh_pillar
|
||||||
|
- tgt: '{{ action.tgt }}'
|
||||||
|
- tgt_type: {{ action.get('tgt_type', 'compound') }}
|
||||||
|
|
||||||
|
apply_{{ action.state | replace('.', '_') }}_{{ loop.index }}:
|
||||||
|
salt.state:
|
||||||
|
- tgt: '{{ action.tgt }}'
|
||||||
|
- tgt_type: {{ action.get('tgt_type', 'compound') }}
|
||||||
|
- sls:
|
||||||
|
- {{ action.state }}
|
||||||
|
- batch: {{ action.get('batch', BATCH) }}
|
||||||
|
- batch_wait: {{ action.get('batch_wait', BATCH_WAIT) }}
|
||||||
|
- kwarg:
|
||||||
|
queue: 2
|
||||||
|
- require:
|
||||||
|
- salt: refresh_pillar_{{ loop.index }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
@@ -19,6 +19,7 @@ include:
|
|||||||
so-postgres:
|
so-postgres:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-postgres:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-postgres:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- hostname: so-postgres
|
- hostname: so-postgres
|
||||||
- networks:
|
- networks:
|
||||||
- sobridge:
|
- sobridge:
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# One pillar directory can map to multiple (state, tgt) actions.
|
||||||
|
# tgt is a raw salt compound expression. tgt_type is always "compound".
|
||||||
|
# Per-action `batch` / `batch_wait` override the orch defaults (25% / 15s).
|
||||||
|
# An action with `highstate: True` triggers state.highstate instead of
|
||||||
|
# state.apply -- see salt/orch/push_batch.sls.
|
||||||
|
#
|
||||||
|
# Notes:
|
||||||
|
# - `bpf` is a pillar-only dir (no state of its own) consumed by both
|
||||||
|
# zeek and suricata via macros, so a bpf pillar change re-applies both.
|
||||||
|
# - suricata/strelka/zeek/elasticsearch/redis/kafka/logstash etc. have
|
||||||
|
# their own pillar dirs AND their own state, so they map 1:1 (or 1:2
|
||||||
|
# in strelka's case, because of the split init.sls / manager.sls).
|
||||||
|
#
|
||||||
|
# Intentional omissions (these will log a "not in pillar_push_map.yaml"
|
||||||
|
# warning in push_pillar.sls and wait for the next scheduled highstate):
|
||||||
|
# - `data` and `node_data`: pillar-only data consumed by many states;
|
||||||
|
# handling them generically would amount to a fleetwide highstate.
|
||||||
|
# - `host`: soc_host describes mainint/mainip; a change is a re-IP and
|
||||||
|
# needs a coordinated procedure, not an immediate state push.
|
||||||
|
# - `hypervisor`: state changes touch libvirt and are disruptive; leave
|
||||||
|
# to the next scheduled highstate.
|
||||||
|
# - `sensor`: every field in soc_sensor.yaml is `readonly: True` or
|
||||||
|
# per-minion (`node: True`). Per-minion edits are persisted under
|
||||||
|
# pillar/minions/<id>.sls and are handled by Branch A of push_pillar.sls
|
||||||
|
# (per-minion highstate intent), not by this app-pillar map.
|
||||||
|
#
|
||||||
|
# The role sets here were verified line-by-line against salt/top.sls. If
|
||||||
|
# salt/top.sls changes how an app is targeted, update the corresponding
|
||||||
|
# compound here.
|
||||||
|
|
||||||
|
# firewall: the one pillar everyone touches. Applied everywhere intentionally
|
||||||
|
# because every host's iptables needs to know about every other host in the
|
||||||
|
# grid. Salt's firewall state is idempotent (file.managed + iptables-restore
|
||||||
|
# onchanges in salt/firewall/init.sls), so hosts whose rendered firewall is
|
||||||
|
# unchanged do a file comparison and no-op without touching iptables -- actual
|
||||||
|
# reload happens only on the hosts whose rules actually changed. Fleetwide
|
||||||
|
# blast radius is intentional and matches the pre-plan behavior via highstate.
|
||||||
|
# Adding N sensors in a burst coalesces into one dispatch via the drainer.
|
||||||
|
firewall:
|
||||||
|
- state: firewall
|
||||||
|
tgt: '*'
|
||||||
|
|
||||||
|
# backup: backup.config_backup runs on eval, standalone, manager, managerhype,
|
||||||
|
# managersearch (NOT import -- the backup pillar is included on import per
|
||||||
|
# pillar/top.sls but the backup state is not run there per salt/top.sls).
|
||||||
|
backup:
|
||||||
|
- state: backup.config_backup
|
||||||
|
tgt: 'G@role:so-eval or G@role:so-manager or G@role:so-managerhype or G@role:so-managersearch or G@role:so-standalone'
|
||||||
|
|
||||||
|
# bpf is pillar-only (no state); consumed by both zeek and suricata as macros.
|
||||||
|
# Both states run on sensor_roles + so-import per salt/top.sls.
|
||||||
|
bpf:
|
||||||
|
- state: zeek
|
||||||
|
tgt: 'G@role:so-eval or G@role:so-heavynode or G@role:so-import or G@role:so-sensor or G@role:so-standalone'
|
||||||
|
- state: suricata
|
||||||
|
tgt: 'G@role:so-eval or G@role:so-heavynode or G@role:so-import or G@role:so-sensor or G@role:so-standalone'
|
||||||
|
|
||||||
|
# ca is applied universally.
|
||||||
|
ca:
|
||||||
|
- state: ca
|
||||||
|
tgt: '*'
|
||||||
|
|
||||||
|
# docker: universal. The docker state is in both the all-non-managers and
|
||||||
|
# all-managers branches of salt/top.sls.
|
||||||
|
docker:
|
||||||
|
- state: docker
|
||||||
|
tgt: '*'
|
||||||
|
|
||||||
|
# elastalert: eval, standalone, manager, managerhype, managersearch (NOT import).
|
||||||
|
elastalert:
|
||||||
|
- state: elastalert
|
||||||
|
tgt: 'G@role:so-eval or G@role:so-manager or G@role:so-managerhype or G@role:so-managersearch or G@role:so-standalone'
|
||||||
|
|
||||||
|
# elastic-fleet-package-registry: manager_roles exactly.
|
||||||
|
elastic-fleet-package-registry:
|
||||||
|
- state: elastic-fleet-package-registry
|
||||||
|
tgt: 'G@role:so-eval or G@role:so-import or G@role:so-manager or G@role:so-managerhype or G@role:so-managersearch or G@role:so-standalone'
|
||||||
|
|
||||||
|
# elasticsearch: 8 roles.
|
||||||
|
elasticsearch:
|
||||||
|
- state: elasticsearch
|
||||||
|
tgt: 'G@role:so-eval or G@role:so-heavynode or G@role:so-import or G@role:so-manager or G@role:so-managerhype or G@role:so-managersearch or G@role:so-searchnode or G@role:so-standalone'
|
||||||
|
|
||||||
|
# elasticagent: so-heavynode only.
|
||||||
|
elasticagent:
|
||||||
|
- state: elasticagent
|
||||||
|
tgt: 'G@role:so-heavynode'
|
||||||
|
|
||||||
|
# elasticfleet: base state only on pillar change. elasticfleet.install_agent_grid
|
||||||
|
# is a deploy/enrollment step, not a config reload; leave it to the next highstate.
|
||||||
|
elasticfleet:
|
||||||
|
- state: elasticfleet
|
||||||
|
tgt: 'G@role:so-eval or G@role:so-fleet or G@role:so-import or G@role:so-manager or G@role:so-managerhype or G@role:so-managersearch or G@role:so-standalone'
|
||||||
|
|
||||||
|
# global: fanout to a fleetwide highstate. The global pillar (soc_global.sls)
|
||||||
|
# carries cross-cutting settings (pipeline, url_base, imagerepo, mdengine, ...)
|
||||||
|
# that are consumed by virtually every state, so a targeted re-apply isn't
|
||||||
|
# meaningful. The drainer's batch/batch_wait throttling controls blast radius.
|
||||||
|
global:
|
||||||
|
- highstate: True
|
||||||
|
tgt: '*'
|
||||||
|
|
||||||
|
# healthcheck: eval, sensor, standalone only.
|
||||||
|
healthcheck:
|
||||||
|
- state: healthcheck
|
||||||
|
tgt: 'G@role:so-eval or G@role:so-sensor or G@role:so-standalone'
|
||||||
|
|
||||||
|
# hydra: manager_roles exactly.
|
||||||
|
hydra:
|
||||||
|
- state: hydra
|
||||||
|
tgt: 'G@role:so-eval or G@role:so-import or G@role:so-manager or G@role:so-managerhype or G@role:so-managersearch or G@role:so-standalone'
|
||||||
|
|
||||||
|
# idh: so-idh only.
|
||||||
|
idh:
|
||||||
|
- state: idh
|
||||||
|
tgt: 'G@role:so-idh'
|
||||||
|
|
||||||
|
# influxdb: manager_roles exactly.
|
||||||
|
influxdb:
|
||||||
|
- state: influxdb
|
||||||
|
tgt: 'G@role:so-eval or G@role:so-import or G@role:so-manager or G@role:so-managerhype or G@role:so-managersearch or G@role:so-standalone'
|
||||||
|
|
||||||
|
# kafka: standalone, manager, managerhype, managersearch, searchnode, receiver.
|
||||||
|
kafka:
|
||||||
|
- state: kafka
|
||||||
|
tgt: 'G@role:so-manager or G@role:so-managerhype or G@role:so-managersearch or G@role:so-receiver or G@role:so-searchnode or G@role:so-standalone'
|
||||||
|
|
||||||
|
# kibana: manager_roles exactly.
|
||||||
|
kibana:
|
||||||
|
- state: kibana
|
||||||
|
tgt: 'G@role:so-eval or G@role:so-import or G@role:so-manager or G@role:so-managerhype or G@role:so-managersearch or G@role:so-standalone'
|
||||||
|
|
||||||
|
# kratos: manager_roles exactly.
|
||||||
|
kratos:
|
||||||
|
- state: kratos
|
||||||
|
tgt: 'G@role:so-eval or G@role:so-import or G@role:so-manager or G@role:so-managerhype or G@role:so-managersearch or G@role:so-standalone'
|
||||||
|
|
||||||
|
# logrotate: universal (top-of-file '*' branch in salt/top.sls).
|
||||||
|
logrotate:
|
||||||
|
- state: logrotate
|
||||||
|
tgt: '*'
|
||||||
|
|
||||||
|
# logstash: 8 roles, no eval/import.
|
||||||
|
logstash:
|
||||||
|
- state: logstash
|
||||||
|
tgt: 'G@role:so-fleet or G@role:so-heavynode or G@role:so-manager or G@role:so-managerhype or G@role:so-managersearch or G@role:so-receiver or G@role:so-searchnode or G@role:so-standalone'
|
||||||
|
|
||||||
|
# manager: manager_roles exactly. The manager state is also referenced under
|
||||||
|
# *_sensor / *_heavynode top.sls blocks via `sensor`, but the standalone
|
||||||
|
# `manager` state itself runs only on manager_roles.
|
||||||
|
manager:
|
||||||
|
- state: manager
|
||||||
|
tgt: 'G@role:so-eval or G@role:so-import or G@role:so-manager or G@role:so-managerhype or G@role:so-managersearch or G@role:so-standalone'
|
||||||
|
|
||||||
|
# nginx: 10 specific roles. NOT receiver, idh, hypervisor, desktop.
|
||||||
|
nginx:
|
||||||
|
- state: nginx
|
||||||
|
tgt: 'G@role:so-eval or G@role:so-fleet or G@role:so-heavynode or G@role:so-import or G@role:so-manager or G@role:so-managerhype or G@role:so-managersearch or G@role:so-searchnode or G@role:so-sensor or G@role:so-standalone'
|
||||||
|
|
||||||
|
# ntp: universal (top-of-file '*' branch in salt/top.sls).
|
||||||
|
ntp:
|
||||||
|
- state: ntp
|
||||||
|
tgt: '*'
|
||||||
|
|
||||||
|
# patch: universal. soc_patch carries the OS update schedule, applied via
|
||||||
|
# patch.os.schedule on every node (it's in both the all-non-managers and
|
||||||
|
# all-managers branches of salt/top.sls).
|
||||||
|
patch:
|
||||||
|
- state: patch.os.schedule
|
||||||
|
tgt: '*'
|
||||||
|
|
||||||
|
# postgres: manager_roles exactly.
|
||||||
|
postgres:
|
||||||
|
- state: postgres
|
||||||
|
tgt: 'G@role:so-eval or G@role:so-import or G@role:so-manager or G@role:so-managerhype or G@role:so-managersearch or G@role:so-standalone'
|
||||||
|
|
||||||
|
# redis: 6 roles. standalone, manager, managerhype, managersearch, heavynode, receiver.
|
||||||
|
# (NOT eval, NOT import, NOT searchnode.)
|
||||||
|
redis:
|
||||||
|
- state: redis
|
||||||
|
tgt: 'G@role:so-heavynode or G@role:so-manager or G@role:so-managerhype or G@role:so-managersearch or G@role:so-receiver or G@role:so-standalone'
|
||||||
|
|
||||||
|
# registry: manager_roles exactly.
|
||||||
|
registry:
|
||||||
|
- state: registry
|
||||||
|
tgt: 'G@role:so-eval or G@role:so-import or G@role:so-manager or G@role:so-managerhype or G@role:so-managersearch or G@role:so-standalone'
|
||||||
|
|
||||||
|
# salt: fanout to a fleetwide highstate. The salt.auto_apply settings tune the
|
||||||
|
# push pipeline itself (enabled, debounce/drain intervals, batch sizing) and
|
||||||
|
# salt.schedule sets the per-minion highstate interval; they are consumed by the
|
||||||
|
# manager's schedule, beacons, and master reactor config as well as every
|
||||||
|
# minion's highstate schedule, so a targeted re-apply isn't meaningful. A salt
|
||||||
|
# audit row only fires for SOC-driven salt.auto_apply / salt.schedule edits --
|
||||||
|
# salt version bumps go through soup, not SOC, so they never reach this map.
|
||||||
|
salt:
|
||||||
|
- highstate: True
|
||||||
|
tgt: '*'
|
||||||
|
|
||||||
|
# sensoroni: universal.
|
||||||
|
sensoroni:
|
||||||
|
- state: sensoroni
|
||||||
|
tgt: '*'
|
||||||
|
|
||||||
|
# soc: manager_roles exactly.
|
||||||
|
soc:
|
||||||
|
- state: soc
|
||||||
|
tgt: 'G@role:so-eval or G@role:so-import or G@role:so-manager or G@role:so-managerhype or G@role:so-managersearch or G@role:so-standalone'
|
||||||
|
|
||||||
|
# stig: broad. Runs on standalone, manager, managerhype, managersearch,
|
||||||
|
# searchnode, sensor, receiver, fleet, hypervisor, desktop.
|
||||||
|
# NOT eval, NOT import, NOT heavynode, NOT idh (the *_idh block in
|
||||||
|
# salt/top.sls intentionally omits stig).
|
||||||
|
stig:
|
||||||
|
- state: stig
|
||||||
|
tgt: 'G@role:so-desktop or G@role:so-fleet or G@role:so-hypervisor or G@role:so-manager or G@role:so-managerhype or G@role:so-managersearch or G@role:so-receiver or G@role:so-searchnode or G@role:so-sensor or G@role:so-standalone'
|
||||||
|
|
||||||
|
# strelka: sensor-side only on pillar change (sensor_roles). strelka.manager is
|
||||||
|
# intentionally NOT fired on pillar changes -- YARA rule and strelka config
|
||||||
|
# pillar changes are consumed by the sensor-side strelka backend, and re-running
|
||||||
|
# strelka.manager on managers is both unnecessary and disruptive. strelka.manager
|
||||||
|
# is left to the 2-hour highstate.
|
||||||
|
strelka:
|
||||||
|
- state: strelka
|
||||||
|
tgt: 'G@role:so-eval or G@role:so-heavynode or G@role:so-sensor or G@role:so-standalone'
|
||||||
|
|
||||||
|
# suricata: sensor_roles + so-import (5 roles).
|
||||||
|
suricata:
|
||||||
|
- state: suricata
|
||||||
|
tgt: 'G@role:so-eval or G@role:so-heavynode or G@role:so-import or G@role:so-sensor or G@role:so-standalone'
|
||||||
|
|
||||||
|
# telegraf: universal.
|
||||||
|
telegraf:
|
||||||
|
- state: telegraf
|
||||||
|
tgt: '*'
|
||||||
|
|
||||||
|
# versionlock: universal (top-of-file '*' branch in salt/top.sls).
|
||||||
|
versionlock:
|
||||||
|
- state: versionlock
|
||||||
|
tgt: '*'
|
||||||
|
|
||||||
|
# vm: libvirt-driver hypervisors only. Matched by the salt-cloud:driver:libvirt
|
||||||
|
# grain (compound supports nested grain matching via G@<key>:<subkey>:<value>).
|
||||||
|
# pillar/vm/soc_vm.sls write path is referenced at salt/_runners/setup_hypervisor.py:856.
|
||||||
|
vm:
|
||||||
|
- state: vm
|
||||||
|
tgt: 'G@salt-cloud:driver:libvirt'
|
||||||
|
|
||||||
|
# zeek: sensor_roles + so-import (5 roles).
|
||||||
|
zeek:
|
||||||
|
- state: zeek
|
||||||
|
tgt: 'G@role:so-eval or G@role:so-heavynode or G@role:so-import or G@role:so-sensor or G@role:so-standalone'
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
#!py
|
||||||
|
|
||||||
|
# Reactor invoked by the postgres_pillar_beacon when SOC records settings changes in
|
||||||
|
# the securityonion.audit_settings table (see salt/_beacons/postgres_pillar_beacon.py). The beacon
|
||||||
|
# emits one event per new row carrying setting_id and node_id.
|
||||||
|
#
|
||||||
|
# Two branches, keyed on node_id:
|
||||||
|
# A) node_id populated -> the change is scoped to that one minion. Look up the
|
||||||
|
# app in pillar_push_map.yaml and write an intent that runs the app's mapped
|
||||||
|
# state(s) targeted to just that node.
|
||||||
|
# B) node_id empty -> grid-wide app change. Look up the app in
|
||||||
|
# pillar_push_map.yaml and write an intent with the entry's actions as-is.
|
||||||
|
#
|
||||||
|
# The app name is the first dotted segment of setting_id (e.g. "telegraf.output"
|
||||||
|
# -> "telegraf"), which matches the pillar_push_map.yaml keys 1:1.
|
||||||
|
#
|
||||||
|
# Reactors never dispatch directly. The so-push-drainer schedule picks up
|
||||||
|
# ready intents, dedupes across pending files, and dispatches orch.push_batch.
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from salt.client import Caller
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PENDING_DIR = '/opt/so/state/push_pending'
|
||||||
|
LOCK_FILE = os.path.join(PENDING_DIR, '.lock')
|
||||||
|
MAX_PATHS = 20
|
||||||
|
|
||||||
|
# The pillar_push_map.yaml is shipped via salt:// but the reactor runs on the
|
||||||
|
# master, which mounts the default saltstack tree at this path.
|
||||||
|
PUSH_MAP_PATH = '/opt/so/saltstack/default/salt/reactor/pillar_push_map.yaml'
|
||||||
|
|
||||||
|
_PUSH_MAP_CACHE = {'mtime': 0, 'data': None}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_push_map():
|
||||||
|
try:
|
||||||
|
st = os.stat(PUSH_MAP_PATH)
|
||||||
|
except OSError:
|
||||||
|
LOG.warning('push_pillar: %s not found', PUSH_MAP_PATH)
|
||||||
|
return {}
|
||||||
|
if _PUSH_MAP_CACHE['mtime'] != st.st_mtime:
|
||||||
|
try:
|
||||||
|
with open(PUSH_MAP_PATH, 'r') as f:
|
||||||
|
_PUSH_MAP_CACHE['data'] = yaml.safe_load(f) or {}
|
||||||
|
except Exception:
|
||||||
|
LOG.exception('push_pillar: failed to load %s', PUSH_MAP_PATH)
|
||||||
|
_PUSH_MAP_CACHE['data'] = {}
|
||||||
|
_PUSH_MAP_CACHE['mtime'] = st.st_mtime
|
||||||
|
return _PUSH_MAP_CACHE['data'] or {}
|
||||||
|
|
||||||
|
|
||||||
|
def _push_enabled():
|
||||||
|
try:
|
||||||
|
caller = Caller()
|
||||||
|
return bool(caller.cmd('pillar.get', 'salt:auto_apply:enabled', True))
|
||||||
|
except Exception:
|
||||||
|
LOG.exception('push_pillar: pillar.get salt:auto_apply:enabled failed, assuming enabled')
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _write_intent(key, actions, path):
|
||||||
|
now = time.time()
|
||||||
|
try:
|
||||||
|
os.makedirs(PENDING_DIR, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
LOG.exception('push_pillar: cannot create %s', PENDING_DIR)
|
||||||
|
return
|
||||||
|
|
||||||
|
intent_path = os.path.join(PENDING_DIR, '{}.json'.format(key))
|
||||||
|
lock_fd = os.open(LOCK_FILE, os.O_CREAT | os.O_RDWR, 0o644)
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||||
|
|
||||||
|
intent = {}
|
||||||
|
if os.path.exists(intent_path):
|
||||||
|
try:
|
||||||
|
with open(intent_path, 'r') as f:
|
||||||
|
intent = json.load(f)
|
||||||
|
except (IOError, ValueError):
|
||||||
|
intent = {}
|
||||||
|
|
||||||
|
intent.setdefault('first_touch', now)
|
||||||
|
intent['last_touch'] = now
|
||||||
|
intent['actions'] = actions
|
||||||
|
paths = intent.get('paths', [])
|
||||||
|
if path and path not in paths:
|
||||||
|
paths.append(path)
|
||||||
|
paths = paths[-MAX_PATHS:]
|
||||||
|
intent['paths'] = paths
|
||||||
|
|
||||||
|
tmp_path = intent_path + '.tmp'
|
||||||
|
with open(tmp_path, 'w') as f:
|
||||||
|
json.dump(intent, f)
|
||||||
|
os.rename(tmp_path, intent_path)
|
||||||
|
except Exception:
|
||||||
|
LOG.exception('push_pillar: failed to write intent %s', intent_path)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||||
|
finally:
|
||||||
|
os.close(lock_fd)
|
||||||
|
|
||||||
|
|
||||||
|
def _app_from_setting(setting_id):
|
||||||
|
# setting_id is e.g. 'telegraf.output' -> 'telegraf', 'ntp.config.servers' -> 'ntp'
|
||||||
|
if not setting_id:
|
||||||
|
return None
|
||||||
|
return setting_id.split('.', 1)[0] or None
|
||||||
|
|
||||||
|
|
||||||
|
def _node_actions(entry, node_id):
|
||||||
|
# Copy the app's mapped actions but retarget each one to the single node.
|
||||||
|
# Preserves the state/highstate selection and any batch/batch_wait overrides.
|
||||||
|
actions = []
|
||||||
|
for action in entry:
|
||||||
|
if not isinstance(action, dict):
|
||||||
|
continue
|
||||||
|
node_action = dict(action)
|
||||||
|
node_action['tgt'] = node_id
|
||||||
|
node_action['tgt_type'] = 'glob'
|
||||||
|
actions.append(node_action)
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
if not _push_enabled():
|
||||||
|
LOG.info('push_pillar: push disabled, skipping')
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# The postgres_pillar_beacon nests its payload under data['data']; fall back to the
|
||||||
|
# top level so the reactor is robust to either shape.
|
||||||
|
event = data.get('data', data) # noqa: F821 -- data provided by reactor
|
||||||
|
setting_id = event.get('setting_id', '')
|
||||||
|
node_id = (event.get('node_id') or '').strip()
|
||||||
|
|
||||||
|
app = _app_from_setting(setting_id)
|
||||||
|
if not app:
|
||||||
|
LOG.debug('push_pillar: ignoring event with no app segment: setting_id=%s', setting_id)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
push_map = _load_push_map()
|
||||||
|
entry = push_map.get(app)
|
||||||
|
if not entry:
|
||||||
|
LOG.warning(
|
||||||
|
'push_pillar: app "%s" is not in pillar_push_map.yaml; change will be '
|
||||||
|
'picked up at the next scheduled highstate (setting_id=%s)',
|
||||||
|
app, setting_id,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Branch A: per-node change -> retarget the app's states to just that node.
|
||||||
|
if node_id:
|
||||||
|
actions = _node_actions(entry, node_id)
|
||||||
|
if not actions:
|
||||||
|
LOG.warning('push_pillar: no usable actions for app "%s" (setting_id=%s)', app, setting_id)
|
||||||
|
return {}
|
||||||
|
_write_intent(
|
||||||
|
'node_{}_{}'.format(node_id, app), actions,
|
||||||
|
'audit:{}@{}'.format(setting_id, node_id),
|
||||||
|
)
|
||||||
|
LOG.info('push_pillar: per-node intent updated for %s on %s (setting_id=%s)',
|
||||||
|
app, node_id, setting_id)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Branch B: grid-wide app change -> use the map entry's actions as-is.
|
||||||
|
actions = list(entry) # copy to avoid mutating the cache
|
||||||
|
_write_intent('pillar_{}'.format(app), actions, 'audit:{}'.format(setting_id))
|
||||||
|
LOG.info('push_pillar: app intent updated for %s (setting_id=%s)', app, setting_id)
|
||||||
|
return {}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#!py
|
||||||
|
|
||||||
|
# Reactor invoked by the rules_beacon poll beacon (salt/_beacons/rules_beacon.py) on rule
|
||||||
|
# file changes under /opt/so/saltstack/local/salt/strelka/rules/compiled/.
|
||||||
|
#
|
||||||
|
# Writes (or updates) a push intent at /opt/so/state/push_pending/rules_strelka.json
|
||||||
|
# and returns {}. The so-push-drainer schedule picks up ready intents, dedupes
|
||||||
|
# across pending files, and dispatches orch.push_batch. Reactors never dispatch
|
||||||
|
# directly
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from salt.client import Caller
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PENDING_DIR = '/opt/so/state/push_pending'
|
||||||
|
LOCK_FILE = os.path.join(PENDING_DIR, '.lock')
|
||||||
|
MAX_PATHS = 20
|
||||||
|
|
||||||
|
# Mirrors GLOBALS.sensor_roles in salt/vars/globals.map.jinja. Sensor-side
|
||||||
|
# strelka runs on exactly these four roles; so-import gets strelka.manager
|
||||||
|
# instead, which is not fired on pillar changes.
|
||||||
|
SENSOR_ROLES = ['so-eval', 'so-heavynode', 'so-sensor', 'so-standalone']
|
||||||
|
|
||||||
|
|
||||||
|
def _sensor_compound():
|
||||||
|
return ' or '.join('G@role:{}'.format(r) for r in SENSOR_ROLES)
|
||||||
|
|
||||||
|
|
||||||
|
def _push_enabled():
|
||||||
|
try:
|
||||||
|
caller = Caller()
|
||||||
|
return bool(caller.cmd('pillar.get', 'salt:auto_apply:enabled', True))
|
||||||
|
except Exception:
|
||||||
|
LOG.exception('push_strelka: pillar.get salt:auto_apply:enabled failed, assuming enabled')
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _write_intent(key, actions, path):
|
||||||
|
now = time.time()
|
||||||
|
try:
|
||||||
|
os.makedirs(PENDING_DIR, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
LOG.exception('push_strelka: cannot create %s', PENDING_DIR)
|
||||||
|
return
|
||||||
|
|
||||||
|
intent_path = os.path.join(PENDING_DIR, '{}.json'.format(key))
|
||||||
|
lock_fd = os.open(LOCK_FILE, os.O_CREAT | os.O_RDWR, 0o644)
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||||
|
|
||||||
|
intent = {}
|
||||||
|
if os.path.exists(intent_path):
|
||||||
|
try:
|
||||||
|
with open(intent_path, 'r') as f:
|
||||||
|
intent = json.load(f)
|
||||||
|
except (IOError, ValueError):
|
||||||
|
intent = {}
|
||||||
|
|
||||||
|
intent.setdefault('first_touch', now)
|
||||||
|
intent['last_touch'] = now
|
||||||
|
intent['actions'] = actions
|
||||||
|
paths = intent.get('paths', [])
|
||||||
|
if path and path not in paths:
|
||||||
|
paths.append(path)
|
||||||
|
paths = paths[-MAX_PATHS:]
|
||||||
|
intent['paths'] = paths
|
||||||
|
|
||||||
|
tmp_path = intent_path + '.tmp'
|
||||||
|
with open(tmp_path, 'w') as f:
|
||||||
|
json.dump(intent, f)
|
||||||
|
os.rename(tmp_path, intent_path)
|
||||||
|
except Exception:
|
||||||
|
LOG.exception('push_strelka: failed to write intent %s', intent_path)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||||
|
finally:
|
||||||
|
os.close(lock_fd)
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
if not _push_enabled():
|
||||||
|
LOG.info('push_strelka: push disabled, skipping')
|
||||||
|
return {}
|
||||||
|
|
||||||
|
path = data.get('path', '') # noqa: F821 -- data provided by reactor
|
||||||
|
actions = [{'state': 'strelka', 'tgt': _sensor_compound()}]
|
||||||
|
_write_intent('rules_strelka', actions, path)
|
||||||
|
LOG.info('push_strelka: intent updated for path=%s', path)
|
||||||
|
return {}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#!py
|
||||||
|
|
||||||
|
# Reactor invoked by the rules_beacon poll beacon (salt/_beacons/rules_beacon.py) on rule
|
||||||
|
# file changes under /opt/so/saltstack/local/salt/suricata/rules/.
|
||||||
|
#
|
||||||
|
# Writes (or updates) a push intent at /opt/so/state/push_pending/rules_suricata.json
|
||||||
|
# and returns {}. The so-push-drainer schedule picks up ready intents, dedupes
|
||||||
|
# across pending files, and dispatches orch.push_batch. Reactors never dispatch
|
||||||
|
# directly
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from salt.client import Caller
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PENDING_DIR = '/opt/so/state/push_pending'
|
||||||
|
LOCK_FILE = os.path.join(PENDING_DIR, '.lock')
|
||||||
|
MAX_PATHS = 20
|
||||||
|
|
||||||
|
# Mirrors GLOBALS.sensor_roles in salt/vars/globals.map.jinja. Suricata also
|
||||||
|
# runs on so-import per salt/top.sls, so that role is appended below.
|
||||||
|
SENSOR_ROLES = ['so-eval', 'so-heavynode', 'so-sensor', 'so-standalone']
|
||||||
|
|
||||||
|
|
||||||
|
def _sensor_compound_plus_import():
|
||||||
|
return ' or '.join('G@role:{}'.format(r) for r in SENSOR_ROLES) + ' or G@role:so-import'
|
||||||
|
|
||||||
|
|
||||||
|
def _push_enabled():
|
||||||
|
try:
|
||||||
|
caller = Caller()
|
||||||
|
return bool(caller.cmd('pillar.get', 'salt:auto_apply:enabled', True))
|
||||||
|
except Exception:
|
||||||
|
LOG.exception('push_suricata: pillar.get salt:auto_apply:enabled failed, assuming enabled')
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _write_intent(key, actions, path):
|
||||||
|
now = time.time()
|
||||||
|
try:
|
||||||
|
os.makedirs(PENDING_DIR, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
LOG.exception('push_suricata: cannot create %s', PENDING_DIR)
|
||||||
|
return
|
||||||
|
|
||||||
|
intent_path = os.path.join(PENDING_DIR, '{}.json'.format(key))
|
||||||
|
lock_fd = os.open(LOCK_FILE, os.O_CREAT | os.O_RDWR, 0o644)
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||||
|
|
||||||
|
intent = {}
|
||||||
|
if os.path.exists(intent_path):
|
||||||
|
try:
|
||||||
|
with open(intent_path, 'r') as f:
|
||||||
|
intent = json.load(f)
|
||||||
|
except (IOError, ValueError):
|
||||||
|
intent = {}
|
||||||
|
|
||||||
|
intent.setdefault('first_touch', now)
|
||||||
|
intent['last_touch'] = now
|
||||||
|
intent['actions'] = actions
|
||||||
|
paths = intent.get('paths', [])
|
||||||
|
if path and path not in paths:
|
||||||
|
paths.append(path)
|
||||||
|
paths = paths[-MAX_PATHS:]
|
||||||
|
intent['paths'] = paths
|
||||||
|
|
||||||
|
tmp_path = intent_path + '.tmp'
|
||||||
|
with open(tmp_path, 'w') as f:
|
||||||
|
json.dump(intent, f)
|
||||||
|
os.rename(tmp_path, intent_path)
|
||||||
|
except Exception:
|
||||||
|
LOG.exception('push_suricata: failed to write intent %s', intent_path)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||||
|
finally:
|
||||||
|
os.close(lock_fd)
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
if not _push_enabled():
|
||||||
|
LOG.info('push_suricata: push disabled, skipping')
|
||||||
|
return {}
|
||||||
|
|
||||||
|
path = data.get('path', '') # noqa: F821 -- data provided by reactor
|
||||||
|
actions = [{'state': 'suricata', 'tgt': _sensor_compound_plus_import()}]
|
||||||
|
_write_intent('rules_suricata', actions, path)
|
||||||
|
LOG.info('push_suricata: intent updated for path=%s', path)
|
||||||
|
return {}
|
||||||
@@ -17,6 +17,7 @@ include:
|
|||||||
so-redis:
|
so-redis:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-redis:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-redis:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- hostname: so-redis
|
- hostname: so-redis
|
||||||
- user: socore
|
- user: socore
|
||||||
- networks:
|
- networks:
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ include:
|
|||||||
so-dockerregistry:
|
so-dockerregistry:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: ghcr.io/security-onion-solutions/registry:3.1.1
|
- image: ghcr.io/security-onion-solutions/registry:3.1.1
|
||||||
|
# Intentionally `always`-- registry is critical and must
|
||||||
|
# come back up even if it was manually stopped.
|
||||||
|
- restart_policy: always
|
||||||
- hostname: so-registry
|
- hostname: so-registry
|
||||||
- networks:
|
- networks:
|
||||||
- sobridge:
|
- sobridge:
|
||||||
- ipv4_address: {{ DOCKERMERGED.containers['so-dockerregistry'].ip }}
|
- ipv4_address: {{ DOCKERMERGED.containers['so-dockerregistry'].ip }}
|
||||||
- restart_policy: always
|
|
||||||
- port_bindings:
|
- port_bindings:
|
||||||
{% for BINDING in DOCKERMERGED.containers['so-dockerregistry'].port_bindings %}
|
{% for BINDING in DOCKERMERGED.containers['so-dockerregistry'].port_bindings %}
|
||||||
- {{ BINDING }}
|
- {{ BINDING }}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{% import_yaml 'salt/defaults.yaml' as SALT_DEFAULTS %}
|
||||||
|
{% set AUTOAPPLY = salt['pillar.get']('salt:auto_apply', SALT_DEFAULTS.salt.auto_apply, merge=True) %}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
{% set SCHEDULE = salt['pillar.get']('healthcheck:schedule', 30) %}
|
{% set SCHEDULE = salt['pillar.get']('healthcheck:schedule', 30) %}
|
||||||
|
|
||||||
include:
|
include:
|
||||||
- salt
|
- salt.minion
|
||||||
|
|
||||||
{% if CHECKS and ENABLED %}
|
{% if CHECKS and ENABLED %}
|
||||||
salt_beacons:
|
salt_beacons:
|
||||||
@@ -23,3 +23,4 @@ salt_beacons:
|
|||||||
- watch_in:
|
- watch_in:
|
||||||
- service: salt_minion_service
|
- service: salt_minion_service
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
salt:
|
||||||
|
auto_apply:
|
||||||
|
enabled: true
|
||||||
|
debounce_seconds: 30
|
||||||
|
drain_interval: 15
|
||||||
|
batch: '25%'
|
||||||
|
batch_wait: 15
|
||||||
|
schedule:
|
||||||
|
highstate_interval_hours: 2
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
reactor:
|
||||||
|
- 'salt/beacon/*/rules_beacon/suricata':
|
||||||
|
- salt://reactor/push_suricata.sls
|
||||||
|
- 'salt/beacon/*/rules_beacon/strelka':
|
||||||
|
- salt://reactor/push_strelka.sls
|
||||||
|
- 'salt/beacon/*/postgres_pillar_beacon/audit_settings':
|
||||||
|
- salt://reactor/push_pillar.sls
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{% from 'vars/globals.map.jinja' import GLOBALS %}
|
||||||
|
{% from 'salt/schedule.map.jinja' import SCHEDULEMERGED %}
|
||||||
|
|
||||||
|
highstate_schedule:
|
||||||
|
schedule.present:
|
||||||
|
- function: state.highstate
|
||||||
|
- hours: {{ SCHEDULEMERGED.highstate_interval_hours }}
|
||||||
|
- maxrunning: 1
|
||||||
|
{% if not GLOBALS.is_manager %}
|
||||||
|
- splay: 1800
|
||||||
|
{% endif %}
|
||||||
@@ -5,3 +5,11 @@ salt_bootstrap:
|
|||||||
- source: salt://salt/scripts/bootstrap-salt.sh
|
- source: salt://salt/scripts/bootstrap-salt.sh
|
||||||
- mode: 755
|
- mode: 755
|
||||||
- show_changes: False
|
- show_changes: False
|
||||||
|
|
||||||
|
salt_sbin:
|
||||||
|
file.recurse:
|
||||||
|
- name: /usr/sbin
|
||||||
|
- source: salt://salt/tools/sbin
|
||||||
|
- user: 939
|
||||||
|
- group: 939
|
||||||
|
- file_mode: 755
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
lasthighstate:
|
lasthighstate:
|
||||||
file.touch:
|
file.touch:
|
||||||
- name: /opt/so/log/salt/lasthighstate
|
- name: /opt/so/log/salt/lasthighstate
|
||||||
- order: last
|
- order: 9001
|
||||||
|
|||||||
+17
-1
@@ -10,6 +10,7 @@
|
|||||||
# software that is protected by the license key."
|
# software that is protected by the license key."
|
||||||
|
|
||||||
{% from 'allowed_states.map.jinja' import allowed_states %}
|
{% from 'allowed_states.map.jinja' import allowed_states %}
|
||||||
|
{% from 'salt/auto_apply.map.jinja' import AUTOAPPLY %}
|
||||||
{% if sls in allowed_states %}
|
{% if sls in allowed_states %}
|
||||||
|
|
||||||
include:
|
include:
|
||||||
@@ -63,6 +64,21 @@ engines_config:
|
|||||||
- name: /etc/salt/master.d/engines.conf
|
- name: /etc/salt/master.d/engines.conf
|
||||||
- source: salt://salt/files/engines.conf
|
- source: salt://salt/files/engines.conf
|
||||||
|
|
||||||
|
{% if AUTOAPPLY.enabled %}
|
||||||
|
reactor_pushstate_config:
|
||||||
|
file.managed:
|
||||||
|
- name: /etc/salt/master.d/reactor_pushstate.conf
|
||||||
|
- source: salt://salt/files/reactor_pushstate.conf
|
||||||
|
- watch_in:
|
||||||
|
- service: salt_master_service
|
||||||
|
{% else %}
|
||||||
|
reactor_pushstate_config:
|
||||||
|
file.absent:
|
||||||
|
- name: /etc/salt/master.d/reactor_pushstate.conf
|
||||||
|
- watch_in:
|
||||||
|
- service: salt_master_service
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
# update the bootstrap script when used for salt-cloud
|
# update the bootstrap script when used for salt-cloud
|
||||||
salt_bootstrap_cloud:
|
salt_bootstrap_cloud:
|
||||||
file.managed:
|
file.managed:
|
||||||
@@ -78,7 +94,7 @@ salt_master_service:
|
|||||||
- file: checkmine_engine
|
- file: checkmine_engine
|
||||||
- file: pillarWatch_engine
|
- file: pillarWatch_engine
|
||||||
- file: engines_config
|
- file: engines_config
|
||||||
- order: last
|
- order: 9002
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,3 @@
|
|||||||
salt:
|
salt:
|
||||||
minion:
|
minion:
|
||||||
version: '3006.19'
|
version: '3006.19'
|
||||||
check_threshold: 3600 # in seconds, threshold used for so-salt-minion-check. any value less than 600 seconds may cause a lot of salt-minion restarts since the job to touch the file occurs every 5-8 minutes by default
|
|
||||||
|
|||||||
@@ -111,13 +111,17 @@ mark_setup_complete_for_upgrades:
|
|||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
# this has to be outside the if statement above since there are <requisite>_in calls to this state
|
# this has to be outside the if statement above since there are <requisite>_in calls to this state.
|
||||||
|
# uses watch (not listen) so the restart fires in-state and its result lands on this state's
|
||||||
|
# running entry; that is what lets wait_for_salt_minion_ready below detect any restart
|
||||||
|
# uniformly via onchanges, regardless of whether the trigger came from these files or from
|
||||||
|
# external watch_in's (e.g. beacons, master/pyinotify).
|
||||||
salt_minion_service:
|
salt_minion_service:
|
||||||
service.running:
|
service.running:
|
||||||
- name: salt-minion
|
- name: salt-minion
|
||||||
- enable: True
|
- enable: True
|
||||||
- onlyif: test "{{INSTALLEDSALTVERSION}}" == "{{SALTVERSION}}"
|
- onlyif: test "{{INSTALLEDSALTVERSION}}" == "{{SALTVERSION}}"
|
||||||
- listen:
|
- watch:
|
||||||
- file: mine_functions
|
- file: mine_functions
|
||||||
{% if INSTALLEDSALTVERSION|string == SALTVERSION|string %}
|
{% if INSTALLEDSALTVERSION|string == SALTVERSION|string %}
|
||||||
- file: set_log_levels
|
- file: set_log_levels
|
||||||
@@ -126,3 +130,24 @@ salt_minion_service:
|
|||||||
- file: signing_policy
|
- file: signing_policy
|
||||||
{% endif %}
|
{% endif %}
|
||||||
- order: last
|
- order: last
|
||||||
|
|
||||||
|
# block until the salt-minion daemon is ready for the current instance, so follow-on jobs and the
|
||||||
|
# next highstate iteration do not race the restart. onchanges + require on salt_minion_service
|
||||||
|
# catches every restart trigger uniformly because watch mod_watch results replace the service
|
||||||
|
# state's running entry. wait logic lives in /usr/sbin/so-salt-minion-wait (deployed by salt_sbin
|
||||||
|
# from salt/tools/sbin/); its steady-state authority is the master req/publish sockets for the
|
||||||
|
# current daemon pid (resolved via systemd, not the pidfile), and it corroborates a just-restarted
|
||||||
|
# instance with the pid-tagged "Minion is ready to receive requests!" log line only within a short
|
||||||
|
# window of startup. Because that socket signal does not require a recent restart, the wait also
|
||||||
|
# succeeds cleanly when salt_minion_service reports a non-restart change (e.g. an enable toggle)
|
||||||
|
# rather than false-timing-out. set_log_levels above enforces the log_level_logfile: info that the
|
||||||
|
# ready line depends on. salt restarts this unit with --no-block, so mod_watch returns while the old
|
||||||
|
# daemon is still up; the script waits for systemd's restart job to drain before it reads MainPID.
|
||||||
|
wait_for_salt_minion_ready:
|
||||||
|
cmd.run:
|
||||||
|
- name: /usr/sbin/so-salt-minion-wait
|
||||||
|
- onchanges:
|
||||||
|
- service: salt_minion_service
|
||||||
|
- require:
|
||||||
|
- service: salt_minion_service
|
||||||
|
- order: last
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{% from 'vars/globals.map.jinja' import GLOBALS %}
|
||||||
|
{% from 'salt/auto_apply.map.jinja' import AUTOAPPLY %}
|
||||||
|
|
||||||
|
{% if GLOBALS.is_manager and AUTOAPPLY.enabled %}
|
||||||
|
push_drain_schedule:
|
||||||
|
schedule.present:
|
||||||
|
- function: cmd.run
|
||||||
|
- job_args:
|
||||||
|
- /usr/sbin/so-push-drainer
|
||||||
|
- seconds: {{ AUTOAPPLY.drain_interval }}
|
||||||
|
- maxrunning: 1
|
||||||
|
- return_job: False
|
||||||
|
{% elif GLOBALS.is_manager %}
|
||||||
|
push_drain_schedule:
|
||||||
|
schedule.absent:
|
||||||
|
- name: push_drain_schedule
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{% import_yaml 'salt/defaults.yaml' as SALT_DEFAULTS %}
|
||||||
|
{% set SCHEDULEMERGED = salt['pillar.get']('salt:schedule', SALT_DEFAULTS.salt.schedule, merge=True) %}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
salt:
|
||||||
|
auto_apply:
|
||||||
|
enabled:
|
||||||
|
description: Master kill-switch for the active push feature. When disabled, rule and pillar changes are picked up at the next scheduled highstate instead of being pushed immediately.
|
||||||
|
forcedType: bool
|
||||||
|
helpLink: push
|
||||||
|
global: True
|
||||||
|
debounce_seconds:
|
||||||
|
description: Trailing-edge debounce window in seconds. A push intent must be quiet for this long before the drainer dispatches. Rapid bursts of edits within this window coalesce into one dispatch.
|
||||||
|
forcedType: int
|
||||||
|
helpLink: push
|
||||||
|
global: True
|
||||||
|
advanced: True
|
||||||
|
drain_interval:
|
||||||
|
description: How often the push drainer checks for ready intents, in seconds. Small values lower dispatch latency at the cost of more background work on the manager.
|
||||||
|
forcedType: int
|
||||||
|
helpLink: push
|
||||||
|
global: True
|
||||||
|
advanced: True
|
||||||
|
batch:
|
||||||
|
description: "Host batch size for push orchestrations. A number (e.g. '10') or a percentage (e.g. '25%'). Limits how many minions run the push state at once so large fleets don't thundering-herd."
|
||||||
|
helpLink: push
|
||||||
|
global: True
|
||||||
|
advanced: True
|
||||||
|
regex: '^([0-9]+%?)$'
|
||||||
|
regexFailureMessage: Enter a whole number or a whole-number percentage (e.g. 10 or 25%).
|
||||||
|
batch_wait:
|
||||||
|
description: Seconds to wait between host batches in a push orchestration. Gives the fleet time to breathe between waves.
|
||||||
|
forcedType: int
|
||||||
|
helpLink: push
|
||||||
|
global: True
|
||||||
|
advanced: True
|
||||||
|
schedule:
|
||||||
|
highstate_interval_hours:
|
||||||
|
description: How often every minion in the grid runs a scheduled state.highstate, in hours. Lower values keep minions closer in sync at the cost of more load; higher values reduce load but increase worst-case latency for non-pushed changes. The salt-minion health check restarts a minion if its last highstate is older than this value plus one hour.
|
||||||
|
forcedType: int
|
||||||
|
helpLink: push
|
||||||
|
global: True
|
||||||
|
advanced: True
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one
|
||||||
|
# or more contributor license agreements. Licensed under the Elastic License 2.0 as shown at
|
||||||
|
# https://securityonion.net/license; you may not use this file except in compliance with the
|
||||||
|
# Elastic License 2.0.
|
||||||
|
|
||||||
|
# Block until the current salt-minion daemon is ready to receive requests. Invoked from the
|
||||||
|
# wait_for_salt_minion_ready state in salt/minion/init.sls after salt_minion_service fires its
|
||||||
|
# watch-driven restart, so follow-on jobs and the next highstate iteration do not race it. It is
|
||||||
|
# also correct on an already-running minion (no recent restart): the steady-state readiness signal
|
||||||
|
# is the live master sockets, so it does not depend on a restart having just happened.
|
||||||
|
#
|
||||||
|
# Salt logs "Minion is ready to receive requests!" from Minion.tune_in() only after
|
||||||
|
# sync_connect_master() returns, which means the pub channel authenticated, the long-running req
|
||||||
|
# channel connected, and _post_master_init() finished loading modules and compiling pillar. Two
|
||||||
|
# signals reproduce that:
|
||||||
|
#
|
||||||
|
# 1. Steady state the pid holds an ESTABLISHED req connection to a master on 4506 plus a second
|
||||||
|
# (publish) connection to that same master IP on another port. The publish port is
|
||||||
|
# learned from the master's auth reply and is absent from minion config, so it is
|
||||||
|
# derived from the connection rather than read from config. This is the always-on
|
||||||
|
# authority: it reflects whatever daemon is running now, restart or not.
|
||||||
|
# 2. Startup only the pid-tagged ready line in the minion log. Salt's log_fmt_logfile embeds
|
||||||
|
# [%(process)d] just before the message, so this is keyed to one daemon instance.
|
||||||
|
# Salt logs it exactly once per start, so it exists only to close a ~2.8s window
|
||||||
|
# after the sockets come up where they are established but _post_master_init() is
|
||||||
|
# still finishing. It is therefore required only within READY_LINE_WINDOW seconds
|
||||||
|
# of (re)start (by pid uptime); past that the line has scrolled out of the log and
|
||||||
|
# the socket gate alone decides. See instance_ready().
|
||||||
|
#
|
||||||
|
# The daemon pid is resolved from systemd, never from /var/run/salt-minion.pid. salt_minion() runs
|
||||||
|
# the real minion in a multiprocessing child; that child writes the pidfile, owns the sockets and
|
||||||
|
# logs the ready line, while systemd's MainPID is the parent. During a restart the pidfile can still
|
||||||
|
# name the OLD child, whose own ready line is already in the log -- matching it would report ready
|
||||||
|
# instantly. Children of the current MainPID structurally exclude the old instance.
|
||||||
|
#
|
||||||
|
# That is only true once systemd has actually swapped MainPID. Salt restarts this unit with
|
||||||
|
# --no-block (salt/modules/systemd_service.py:_no_block_default returns True for salt-minion), so
|
||||||
|
# service.restart returns as soon as the job is enqueued and the state proceeds to run this script
|
||||||
|
# while the OLD daemon is still up -- observed at ~7s before MainPID flips. Reading MainPID in that
|
||||||
|
# window names the outgoing instance, which is still fully connected and has its own ready line, so
|
||||||
|
# every gate below would pass on the daemon that is about to die. Wait for systemd's job queue for
|
||||||
|
# the unit to drain first; that is the deterministic "the swap has happened" signal.
|
||||||
|
|
||||||
|
. /usr/sbin/so-common
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
TIMEOUT=120
|
||||||
|
MASTER_PORT=4506
|
||||||
|
LOG_TAIL_LINES=10000
|
||||||
|
# Seconds after a (re)start during which the pid-tagged ready line is still required. Past this the
|
||||||
|
# daemon is clearly beyond the ~2.8s post-connect race and the socket gate is authoritative -- the
|
||||||
|
# one-time ready line has scrolled out of the log tail on a long-running minion. Kept under TIMEOUT
|
||||||
|
# so a fresh minion that connects but never logs the line still falls back to socket-only near the
|
||||||
|
# end instead of false-timing-out.
|
||||||
|
READY_LINE_WINDOW=90
|
||||||
|
DEFAULT_LOG_FILE="/opt/so/log/salt/minion"
|
||||||
|
LOG_FILE="$DEFAULT_LOG_FILE"
|
||||||
|
|
||||||
|
# Decide whether the ready line can ever appear. salt-call --local sets file_client=local, so this
|
||||||
|
# reads the merged config (honoring minion.d overrides) without contacting the master. salt defaults
|
||||||
|
# log_level_logfile to None, meaning it inherits log_level, so resolve that before deciding.
|
||||||
|
LOG_LEVEL_LOGFILE=$(salt-call --local --out=newline_values_only config.get log_level_logfile 2>/dev/null | head -n1)
|
||||||
|
case "${LOG_LEVEL_LOGFILE,,}" in
|
||||||
|
""|none) LOG_LEVEL_LOGFILE=$(salt-call --local --out=newline_values_only config.get log_level 2>/dev/null | head -n1) ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "${LOG_LEVEL_LOGFILE,,}" in
|
||||||
|
all|garbage|trace|debug|profile|info) USE_LOG_GATE=1 ;;
|
||||||
|
*) USE_LOG_GATE=0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$USE_LOG_GATE" -eq 1 ]; then
|
||||||
|
LOG_FILE=$(salt-call --local --out=newline_values_only config.get log_file 2>/dev/null | head -n1)
|
||||||
|
[ -z "$LOG_FILE" ] && LOG_FILE="$DEFAULT_LOG_FILE"
|
||||||
|
[ -d "$(dirname "$LOG_FILE")" ] || USE_LOG_GATE=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v ss >/dev/null 2>&1; then
|
||||||
|
USE_SOCKET_GATE=1
|
||||||
|
else
|
||||||
|
USE_SOCKET_GATE=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$USE_LOG_GATE" -eq 0 ] && [ "$USE_SOCKET_GATE" -eq 0 ]; then
|
||||||
|
echo "so-salt-minion-wait: no usable readiness signal (log_level_logfile='${LOG_LEVEL_LOGFILE:-unset}', ss not found)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$USE_LOG_GATE" -eq 1 ] && [ "$USE_SOCKET_GATE" -eq 1 ]; then
|
||||||
|
echo "so-salt-minion-wait: gating on pid-tagged ready line in ${LOG_FILE} plus master sockets"
|
||||||
|
elif [ "$USE_LOG_GATE" -eq 1 ]; then
|
||||||
|
echo "so-salt-minion-wait: ss not found; gating on pid-tagged ready line in ${LOG_FILE} only"
|
||||||
|
else
|
||||||
|
echo "so-salt-minion-wait: INFO file logging unavailable (log_level_logfile='${LOG_LEVEL_LOGFILE:-unset}'); gating on master sockets only"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# True while systemd still has a queued or running job for the unit, i.e. an in-flight --no-block
|
||||||
|
# restart. MainPID still names the outgoing daemon until this drains. The unit name is passed as a
|
||||||
|
# filter and grepped as well, so this stays correct if an older systemctl ignores the filter.
|
||||||
|
restart_pending() {
|
||||||
|
systemctl list-jobs --no-legend salt-minion.service 2>/dev/null | grep -q 'salt-minion\.service'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Emit the pid(s) of the current daemon instance. systemd's MainPID is the parent keepalive process;
|
||||||
|
# its child runs tune_in. Fall back to MainPID when there is no child (--disable-keepalive path).
|
||||||
|
resolve_daemon_pids() {
|
||||||
|
local mainpid children
|
||||||
|
mainpid=$(systemctl show -p MainPID --value salt-minion 2>/dev/null)
|
||||||
|
if [ -z "$mainpid" ] || [ "$mainpid" = "0" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
children=$(pgrep -P "$mainpid" 2>/dev/null)
|
||||||
|
printf '%s\n' "${children:-$mainpid}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Elapsed seconds since this pid started (Linux procps etimes). Empty/non-numeric -> failure, so the
|
||||||
|
# caller can fall back to the strict (log-gate-enforced) behavior when uptime cannot be read.
|
||||||
|
pid_uptime() {
|
||||||
|
local pid=$1 secs
|
||||||
|
secs=$(ps -o etimes= -p "$pid" 2>/dev/null | tr -d ' ')
|
||||||
|
case "$secs" in ''|*[!0-9]*) return 1 ;; esac
|
||||||
|
printf '%s\n' "$secs"
|
||||||
|
}
|
||||||
|
|
||||||
|
# True iff the ready line tagged with this pid is in the current or most recently rotated log.
|
||||||
|
ready_logged() {
|
||||||
|
local pid=$1 f
|
||||||
|
for f in "$LOG_FILE" "$LOG_FILE.1"; do
|
||||||
|
[ -r "$f" ] || continue
|
||||||
|
if tail -n "$LOG_TAIL_LINES" "$f" 2>/dev/null | grep -Fq "[$pid] Minion is ready to receive requests!"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# True iff this pid holds an ESTABLISHED req connection to a master on MASTER_PORT and a second
|
||||||
|
# ESTABLISHED connection to that same master IP on another port. The trailing comma in "pid=N,"
|
||||||
|
# keeps pid=123 from matching pid=1234. Grid comms are IPv4 (the unit's ExecStartPre gates on ip -4).
|
||||||
|
socket_ready() {
|
||||||
|
local pid=$1 mip master_ips
|
||||||
|
master_ips=$(ss -tnp state established "dport = :${MASTER_PORT}" 2>/dev/null \
|
||||||
|
| grep -F "pid=${pid}," \
|
||||||
|
| grep -oE "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:${MASTER_PORT}" \
|
||||||
|
| sed "s/:${MASTER_PORT}\$//" \
|
||||||
|
| sort -u)
|
||||||
|
[ -z "$master_ips" ] && return 1
|
||||||
|
for mip in $master_ips; do
|
||||||
|
if ss -tnp state established "dst ${mip} and dport != :${MASTER_PORT}" 2>/dev/null | grep -qF "pid=${pid},"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
instance_ready() {
|
||||||
|
local pid=$1 uptime
|
||||||
|
# The log gate only closes the ~2.8s window right after the master sockets come up where they are
|
||||||
|
# established but _post_master_init() is still loading modules/compiling pillar. Salt logs the
|
||||||
|
# pid-tagged ready line exactly once at startup, so on a daemon that started long ago the line has
|
||||||
|
# scrolled out of the log tail and the gate could never pass -- making the wait require a recent
|
||||||
|
# restart. Enforce it only while the daemon is young enough that the race could still be open; past
|
||||||
|
# READY_LINE_WINDOW the socket gate is authoritative. If uptime can't be read, keep the strict
|
||||||
|
# behavior (uptime=0 -> gate enforced) so the fresh-restart path never regresses.
|
||||||
|
if [ "$USE_LOG_GATE" -eq 1 ]; then
|
||||||
|
uptime=$(pid_uptime "$pid") || uptime=0
|
||||||
|
if [ "$uptime" -lt "$READY_LINE_WINDOW" ] && ! ready_logged "$pid"; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ "$USE_SOCKET_GATE" -eq 1 ] && ! socket_ready "$pid"; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed=0
|
||||||
|
pids=""
|
||||||
|
announced_pending=0
|
||||||
|
while [ "$elapsed" -lt "$TIMEOUT" ]; do
|
||||||
|
if restart_pending; then
|
||||||
|
# An in-flight --no-block restart: MainPID still names the outgoing daemon. Evaluating now
|
||||||
|
# would bless the instance that is about to be torn down.
|
||||||
|
if [ "$announced_pending" -eq 0 ]; then
|
||||||
|
echo "so-salt-minion-wait: systemd restart job in flight; waiting for it to drain"
|
||||||
|
announced_pending=1
|
||||||
|
fi
|
||||||
|
elif pids=$(resolve_daemon_pids); then
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
for pid in $pids; do
|
||||||
|
if instance_ready "$pid"; then
|
||||||
|
echo "salt-minion (pid ${pid}) ready after ${elapsed}s"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
elapsed=$((elapsed + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
mainpid=$(systemctl show -p MainPID --value salt-minion 2>/dev/null)
|
||||||
|
restart_pending && pending=yes || pending=no
|
||||||
|
echo "salt-minion did not become ready within ${TIMEOUT}s (MainPID=${mainpid:-unknown}, candidate pids='${pids:-none}', restart_job_pending=${pending}, log_gate=${USE_LOG_GATE}, socket_gate=${USE_SOCKET_GATE}, log_file=${LOG_FILE})" >&2
|
||||||
|
exit 1
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{% from 'vars/globals.map.jinja' import GLOBALS %}
|
|
||||||
|
|
||||||
highstate_schedule:
|
|
||||||
schedule.present:
|
|
||||||
- function: state.highstate
|
|
||||||
- minutes: 15
|
|
||||||
- maxrunning: 1
|
|
||||||
{% if not GLOBALS.is_manager %}
|
|
||||||
- splay: 120
|
|
||||||
{% endif %}
|
|
||||||
@@ -14,6 +14,7 @@ include:
|
|||||||
so-sensoroni:
|
so-sensoroni:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-soc:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-soc:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- network_mode: host
|
- network_mode: host
|
||||||
- binds:
|
- binds:
|
||||||
- /nsm/import:/nsm/import:rw
|
- /nsm/import:/nsm/import:rw
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ include:
|
|||||||
so-soc:
|
so-soc:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-soc:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-soc:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- hostname: soc
|
- hostname: soc
|
||||||
- name: so-soc
|
- name: so-soc
|
||||||
- networks:
|
- networks:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ include:
|
|||||||
strelka_backend:
|
strelka_backend:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-strelka-backend:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-strelka-backend:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- binds:
|
- binds:
|
||||||
- /opt/so/conf/strelka/backend/:/etc/strelka/:ro
|
- /opt/so/conf/strelka/backend/:/etc/strelka/:ro
|
||||||
- /opt/so/conf/strelka/rules/compiled/:/etc/yara/:ro
|
- /opt/so/conf/strelka/rules/compiled/:/etc/yara/:ro
|
||||||
@@ -47,7 +48,6 @@ strelka_backend:
|
|||||||
- {{ ULIMIT.name }}={{ ULIMIT.soft }}:{{ ULIMIT.hard }}
|
- {{ ULIMIT.name }}={{ ULIMIT.soft }}:{{ ULIMIT.hard }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
- restart_policy: on-failure
|
|
||||||
- watch:
|
- watch:
|
||||||
- file: strelkasensorcompiledrules
|
- file: strelkasensorcompiledrules
|
||||||
- file: backend_backend_config
|
- file: backend_backend_config
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ include:
|
|||||||
strelka_coordinator:
|
strelka_coordinator:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-redis:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-redis:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- name: so-strelka-coordinator
|
- name: so-strelka-coordinator
|
||||||
- networks:
|
- networks:
|
||||||
- sobridge:
|
- sobridge:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ include:
|
|||||||
strelka_filestream:
|
strelka_filestream:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-strelka-manager:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-strelka-manager:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- binds:
|
- binds:
|
||||||
- /opt/so/conf/strelka/filestream/:/etc/strelka/:ro
|
- /opt/so/conf/strelka/filestream/:/etc/strelka/:ro
|
||||||
- /nsm/strelka:/nsm/strelka
|
- /nsm/strelka:/nsm/strelka
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ include:
|
|||||||
strelka_frontend:
|
strelka_frontend:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-strelka-manager:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-strelka-manager:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- binds:
|
- binds:
|
||||||
- /opt/so/conf/strelka/frontend/:/etc/strelka/:ro
|
- /opt/so/conf/strelka/frontend/:/etc/strelka/:ro
|
||||||
- /nsm/strelka/log/:/var/log/strelka/:rw
|
- /nsm/strelka/log/:/var/log/strelka/:rw
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ include:
|
|||||||
strelka_gatekeeper:
|
strelka_gatekeeper:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-redis:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-redis:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- name: so-strelka-gatekeeper
|
- name: so-strelka-gatekeeper
|
||||||
- networks:
|
- networks:
|
||||||
- sobridge:
|
- sobridge:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ include:
|
|||||||
strelka_manager:
|
strelka_manager:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-strelka-manager:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-strelka-manager:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- binds:
|
- binds:
|
||||||
- /opt/so/conf/strelka/manager/:/etc/strelka/:ro
|
- /opt/so/conf/strelka/manager/:/etc/strelka/:ro
|
||||||
{% if DOCKERMERGED.containers['so-strelka-manager'].custom_bind_mounts %}
|
{% if DOCKERMERGED.containers['so-strelka-manager'].custom_bind_mounts %}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ include:
|
|||||||
so-suricata:
|
so-suricata:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-suricata:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-suricata:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- privileged: True
|
- privileged: True
|
||||||
- environment:
|
- environment:
|
||||||
- INTERFACE={{ GLOBALS.sensor.interface }}
|
- INTERFACE={{ GLOBALS.sensor.interface }}
|
||||||
|
|||||||
@@ -153,12 +153,12 @@ suricata:
|
|||||||
cpu-affinity:
|
cpu-affinity:
|
||||||
management-cpu-set:
|
management-cpu-set:
|
||||||
cpu:
|
cpu:
|
||||||
description: Bind management threads to a core or range of cores. This can be a sigle core, list of cores, or list of range of cores. set-cpu-affinity must be set to true for this to be used.
|
description: Bind management threads to a core or range of cores. This can be a single core, list of cores, or list of range of cores. set-cpu-affinity must be set to true for this to be used.
|
||||||
forcedType: "[]string"
|
forcedType: "[]string"
|
||||||
helpLink: suricata
|
helpLink: suricata
|
||||||
worker-cpu-set:
|
worker-cpu-set:
|
||||||
cpu:
|
cpu:
|
||||||
description: Bind worker threads to a core or range of cores. This can be a sigle core, list of cores, or list of range of cores. set-cpu-affinity must be set to true for this to be used.
|
description: Bind worker threads to a core or range of cores. This can be a single core, list of cores, or list of range of cores. set-cpu-affinity must be set to true for this to be used.
|
||||||
forcedType: "[]string"
|
forcedType: "[]string"
|
||||||
helpLink: suricata
|
helpLink: suricata
|
||||||
vars:
|
vars:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ so-tcpreplay:
|
|||||||
docker_container.running:
|
docker_container.running:
|
||||||
- network_mode: "host"
|
- network_mode: "host"
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-tcpreplay:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-tcpreplay:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- name: so-tcpreplay
|
- name: so-tcpreplay
|
||||||
- user: root
|
- user: root
|
||||||
- interactive: True
|
- interactive: True
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ include:
|
|||||||
so-telegraf:
|
so-telegraf:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-telegraf:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-telegraf:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- user: 939
|
- user: 939
|
||||||
- group_add: 939,920
|
- group_add: 939,920
|
||||||
- environment:
|
- environment:
|
||||||
|
|||||||
+2
-2
@@ -19,7 +19,7 @@ base:
|
|||||||
- repo.client
|
- repo.client
|
||||||
- versionlock
|
- versionlock
|
||||||
- ntp
|
- ntp
|
||||||
- schedule
|
- salt.highstate_schedule
|
||||||
- logrotate
|
- logrotate
|
||||||
|
|
||||||
# manager node on proper salt version with empty node_data pillar
|
# manager node on proper salt version with empty node_data pillar
|
||||||
@@ -55,6 +55,7 @@ base:
|
|||||||
- motd
|
- motd
|
||||||
- salt.minion-check
|
- salt.minion-check
|
||||||
- salt.lasthighstate
|
- salt.lasthighstate
|
||||||
|
- salt.push_drain_schedule
|
||||||
- common
|
- common
|
||||||
- docker
|
- docker
|
||||||
- docker_clean
|
- docker_clean
|
||||||
@@ -294,7 +295,6 @@ base:
|
|||||||
- nginx
|
- nginx
|
||||||
- elasticfleet
|
- elasticfleet
|
||||||
- elasticfleet.install_agent_grid
|
- elasticfleet.install_agent_grid
|
||||||
- schedule
|
|
||||||
- stig
|
- stig
|
||||||
|
|
||||||
'*_hypervisor and I@features:vrt and G@saltversion:{{saltversion}}':
|
'*_hypervisor and I@features:vrt and G@saltversion:{{saltversion}}':
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ include:
|
|||||||
so-zeek:
|
so-zeek:
|
||||||
docker_container.running:
|
docker_container.running:
|
||||||
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-zeek:{{ GLOBALS.so_version }}
|
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-zeek:{{ GLOBALS.so_version }}
|
||||||
|
- restart_policy: unless-stopped
|
||||||
- start: True
|
- start: True
|
||||||
- privileged: True
|
- privileged: True
|
||||||
{% if DOCKERMERGED.containers['so-zeek'].ulimits %}
|
{% if DOCKERMERGED.containers['so-zeek'].ulimits %}
|
||||||
|
|||||||
+1
-1
@@ -1435,7 +1435,7 @@ make_some_dirs() {
|
|||||||
mkdir -p $local_salt_dir/salt/firewall/portgroups
|
mkdir -p $local_salt_dir/salt/firewall/portgroups
|
||||||
mkdir -p $local_salt_dir/salt/firewall/ports
|
mkdir -p $local_salt_dir/salt/firewall/ports
|
||||||
|
|
||||||
for THEDIR in bpf elasticsearch ntp firewall redis backup influxdb postgres strelka sensoroni soc docker zeek suricata nginx telegraf logstash soc manager kratos hydra idh elastalert stig global kafka versionlock hypervisor vm; do
|
for THEDIR in bpf elasticsearch ntp firewall redis backup influxdb postgres strelka sensoroni soc docker zeek suricata nginx telegraf logstash soc manager kratos hydra idh elastalert stig global salt kafka versionlock hypervisor vm; do
|
||||||
mkdir -p $local_salt_dir/pillar/$THEDIR
|
mkdir -p $local_salt_dir/pillar/$THEDIR
|
||||||
touch $local_salt_dir/pillar/$THEDIR/adv_$THEDIR.sls
|
touch $local_salt_dir/pillar/$THEDIR/adv_$THEDIR.sls
|
||||||
touch $local_salt_dir/pillar/$THEDIR/soc_$THEDIR.sls
|
touch $local_salt_dir/pillar/$THEDIR/soc_$THEDIR.sls
|
||||||
|
|||||||
Reference in New Issue
Block a user