Compare commits

...
Author SHA1 Message Date
Josh PattersonandGitHub ce0095154b Merge pull request #16082 from Security-Onion-Solutions/saltthangs
add date and time to output
2026-07-17 16:10:47 -04:00
Josh Patterson 4886034fef add date and time to output 2026-07-17 15:25:55 -04:00
Josh PattersonandGitHub 7b58c67a02 Merge pull request #16080 from Security-Onion-Solutions/saltthangs
Saltthangs
2026-07-17 11:55:09 -04:00
Josh Patterson 6876b25280 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-07-17 11:54:15 -04:00
Jason ErtelandGitHub 30f3bddb8b Merge pull request #16078 from Security-Onion-Solutions/jertel/wip
ignore file already closed
2026-07-17 10:58:55 -04:00
Jason Ertel 811b799b0b ignore file already closed 2026-07-17 10:57:30 -04:00
Josh PattersonandGitHub 28b3a54a27 Merge pull request #16077 from Security-Onion-Solutions/saltthangs
so-salt-minion-wait: report ready immediately when already ready
2026-07-17 10:14:55 -04:00
Josh Patterson aaea6dbd58 so-salt-minion-wait: report ready immediately when already ready
Running the wait on a healthy, steady-state minion always reported readiness
after a 3s floor:

  salt-minion (pid 4114640) ready after 3s

That floor came entirely from the unconditional sleep "$INITIAL_SLEEP" (3s)
before the poll loop. The sleep is vestigial: it predates restart_pending and
never even covered the restart race (see 89e6a746c -- "INITIAL_SLEEP=3 expired
inside that window"). Salt restarts the unit with --no-block and the restart
job is enqueued before service.restart returns, so an in-flight restart is
visible to restart_pending on the first loop iteration; the sleep protects
nothing now.

Drop the INITIAL_SLEEP constant and the pre-loop sleep and start elapsed at 0.
The loop already sleeps at the bottom of each iteration, so the first readiness
check now runs immediately: an already-ready minion returns "ready after 0s",
while the restart path (guarded by restart_pending) and the mid-startup log
gate are unchanged.
2026-07-17 09:18:03 -04:00
Josh PattersonandGitHub 403d846a89 Merge pull request #16076 from Security-Onion-Solutions/saltthangs
Saltthangs
2026-07-16 18:10:50 -04:00
Josh Patterson 8095b82841 soup use so-salt-minion-wait to ensure salt-minion is ready 2026-07-16 16:58:21 -04:00
Josh Patterson 141116f550 Make so-salt-minion-wait work without requiring a restart
The wait required both a socket gate and a log gate to pass. The log gate
greps the minion log for salt's one-time startup line "Minion is ready to
receive requests!", which scrolls out of the log tail on a minion that has
not restarted recently. On such a minion the log gate could never pass, so
the script ran to its full 120s timeout and exited 1 even though the minion
was healthy and connected. This also false-timed-out when salt_minion_service
reported a non-restart change (e.g. an enable toggle).

The log gate's only remaining purpose is closing the ~2.8s post-connect window
where the master sockets are up but _post_master_init() is still loading. Gate
it on the current pid's uptime: enforce the ready line only within
READY_LINE_WINDOW (90s) of (re)start, and let the already restart-independent
socket gate be the steady-state authority past that. The fresh-restart path is
unchanged, and if uptime can't be read the strict behavior is kept.
2026-07-16 16:52:06 -04:00
Josh Patterson f6d3cbe08d Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-07-16 15:29:51 -04:00
Josh Patterson 9e7e6edae0 Add unit tests for _beacons and wire into CI
Add 100%-coverage unit tests for the three custom salt beacons
(postgres_pillar_beacon, rules_beacon, zeek) and add salt/_beacons to
the python-test workflow's paths trigger and matrix.

To pass the workflow's flake8 lint over the whole directory:
- zeek.py: reindent to 4 spaces, drop trailing blank line, noqa the
  Salt-injected __salt__ references (F821); no logic change.
- postgres_pillar_beacon.py: noqa C901 on beacon() (complexity 13 > 12).
2026-07-16 15:28:22 -04:00
Doug BurksandGitHub 6f61e7c901 Merge pull request #16075 from Security-Onion-Solutions/dougburks-patch-1
Fix typos in CPU affinity descriptions
2026-07-16 15:15:38 -04:00
Doug BurksandGitHub cc2bfc26e2 Fix typos in CPU affinity descriptions 2026-07-16 15:13:51 -04:00
Jorge ReyesandGitHub 073e32520b Merge pull request #16074 from Security-Onion-Solutions/reyesj2-patch-5
include so-yaml.py in get_soup_script_hashes() so we ensure its at la…
2026-07-16 13:00:16 -05:00
reyesj2 5867b50720 include so-yaml.py in get_soup_script_hashes() so we ensure its at latest version before using it later on in soup 2026-07-16 12:28:23 -05:00
Josh Patterson 8a16ead33d Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-07-16 08:26:21 -04:00
Josh Patterson f9b154ccef remove comments 2026-07-15 16:32:29 -04:00
Mike ReevesandGitHub 3503d0c33d Merge pull request #16054 from Security-Onion-Solutions/kernel
Install UEK8 in so-kernel-upgrade when no UEK kernel is present
2026-07-15 16:29:51 -04:00
Josh Patterson 517538a9a7 remove comments 2026-07-15 16:28:35 -04:00
Josh PattersonandGitHub 33ed6359bb Merge pull request #16072 from Security-Onion-Solutions/saltthangs
Saltthangs
2026-07-15 15:31:08 -04:00
Josh Patterson 23c74f1727 remove installation of pyinotify 2026-07-15 15:24:33 -04:00
Josh Patterson b76f9d022e Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-07-15 15:14:20 -04:00
Mike ReevesandGitHub 02318f065c Merge pull request #16069 from Security-Onion-Solutions/mreeves/soup-resumable-upgrade
soup: make failed upgrades and hotfixes resumable
2026-07-15 15:13:57 -04:00
Josh Patterson f958212bea Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-07-15 15:07:09 -04:00
Josh Patterson 376607d292 so-status: show container status while system is starting
Containers now start on boot via restart_policy unless-stopped, so a
highstate is no longer required to bring them up. Gather and display the
container table even when no highstate has completed since reboot, while
still warning the user. The exit code / JSON status_code stays 2 in that
state so SOC's Grid continues to show the restarting message unchanged.
2026-07-15 14:52:27 -04:00
Mike Reeves 186bf86e99 soup: require green Elasticsearch cluster before upgrading
Change the pre-flight cluster-health gate to wait_for_status=green instead of
yellow, so soup only proceeds when the cluster is fully green.
2026-07-15 12:14:18 -04:00
Mike Reeves bd70dd53fb soup: add cluster-health and Fleet Server pre-flight checks
Before making any changes, verify the grid is in a good state:
 - check_cluster_health: waits for Elasticsearch to reach at least 'yellow'
   (blocks only on red/unreachable, since yellow is normal), modeled on the
   wait in so-elasticsearch-roles-load.
 - check_fleet_server: confirms the Fleet Server status API returns HTTP 200,
   modeled on the wait_for_so-elastic-fleet state in elasticfleet/enabled.sls.

Both run alongside the existing check_pillar_items (manager pillar render) and
verify_es_version_compatibility, before soup modifies anything, so a failure
exits cleanly with an actionable message and no partial changes. Valid on all
manager roles soup runs on (eval/standalone/manager/managerhype/managersearch/
import), which all run Elasticsearch and the Fleet Server.
2026-07-15 12:00:11 -04:00
Mike Reeves be7d8a2aa7 soup: make partial-upgrade state clear and avoid re-running completed upgrades
After a partial upgrade, /etc/soversion already reads the target version, so
soup's startup line "Found that Security Onion X is currently installed" made
it look finished even as soup resumed. When a resume marker is present and
differs from the installed version, print an explicit NOTE that the grid is only
partially upgraded and this run will resume and complete it.

Also clear any stale resume marker in the already-latest path so a successfully
completed upgrade is never mistaken for a partial one and re-run on a later
invocation (the marker is normally removed at the end of postupgrade_changes;
this is a belt-and-suspenders guard).
2026-07-15 11:35:04 -04:00
Josh Patterson 5178d5fd0e ensure restart_policy is below image 2026-07-15 11:04:51 -04:00
Josh Patterson fee62ab976 change restart_policy to unless-stopped 2026-07-15 10:49:27 -04:00
Mike Reeves 618712469e soup: clearly report incomplete upgrades on trap exit
When soup fails via the EXIT trap after it has begun modifying the system, print
a prominent UPGRADE INCOMPLETE banner instructing the user to run soup again to
resume and complete the update. Gated on a new SOUP_UPGRADE_STARTED flag set at
the start of the hotfix and upgrade branches, so pre-flight gate failures (ES
compatibility, disk, network) that abort before any changes are made do not show
it.
2026-07-15 09:42:59 -04:00
Mike Reeves 8b488f9226 soup: make failed upgrades and hotfixes resumable
A failed highstate mid-upgrade left /etc/soversion already advanced to the
target version (the highstate stamps it from the pillar via the soversionfile
state), so a re-run of soup saw INSTALLEDVERSION == NEWVERSION and reported
"already running the latest version", stranding the box with post-upgrade
steps never run.

Introduce /etc/sopostversion, a soup-owned marker (no salt state manages it)
that records post-upgrade walk progress. It is seeded from the pre-upgrade
version before the highstate, advanced after each post_to_* step, and removed
on successful completion. upgrade_check treats a leftover marker as "upgrade
not finished" and resumes the remaining post steps instead of bailing.

Also fix the hotfix path: /etc/sohotfix was written before the hotfix
highstate, so a failed hotfix highstate looked already-applied on re-run.
Since no salt state manages /etc/sohotfix, defer its write (update_version)
until after the highstate succeeds so it is an honest completion marker.
2026-07-14 17:30:58 -04:00
Jason ErtelandGitHub 1657480d31 Merge pull request #16064 from Security-Onion-Solutions/jertel/wip
prevent login redirect to any API url
2026-07-14 13:55:44 -04:00
Jason Ertel 63d4061500 prevent login redirect to any API url 2026-07-14 13:52:32 -04:00
Josh Patterson 405dc52587 move up restart policy for so-kratos 2026-07-14 13:34:19 -04:00
Josh Patterson e42f7cd6fc add restart policy to so-postgres 2026-07-14 13:33:57 -04:00
Jorge ReyesandGitHub 8167ae3282 Merge pull request #16062 from Security-Onion-Solutions/reyesj2/es937
ES 9.3.7
2026-07-13 14:35:35 -05:00
reyesj2 2cd889782d soup es check for 9.3.7 2026-07-13 13:16:12 -05:00
reyesj2 87a5639643 pipeline updates 2026-07-13 13:05:25 -05:00
Josh Patterson ed533efb7b so-salt-minion-check: tag and clarify log lines per check
With two independent checks now writing to the same log, messages like
"system uptime only N seconds does not meet 1800 second requirement" were
ambiguous about which check they came from. Prefix every line with a
[minion-restart-check] or [boot-highstate-check] tag and reword the uptime,
threshold, and healthy messages to say what was evaluated and why it was
skipped.

Restructure the boot-highstate check from a nested if into an if/elif chain so
each outcome (restart already queued, uptime too low, healthy, already running,
forcing) logs its own reason instead of silently doing nothing.
2026-07-10 15:53:49 -04:00
Josh Patterson 5af6c56996 so-salt-minion-check: force highstate if none has completed since boot
Add a second, independent trigger to the every-5-minute health check: if the
host has been up >= 15 minutes (HIGHSTATE_UPTIME_REQ) and no highstate has
completed since this boot (lasthighstate mtime older than boot time), run
salt-call state.highstate. This recovers a host whose boot highstate
(so-boot-highstate.service) failed or was skipped, even while the minion is
otherwise healthy and touching state-apply-test.

The new path deliberately does not enable highstate, so a soup-disabled
highstate is respected and never forced mid-upgrade. A saltutil.running guard
plus queue=True prevents stacking across successive cron runs, and a RESTARTED
flag suppresses the new block when the existing minion-restart path already
queued a highstate.
2026-07-10 14:54:20 -04:00
reyesj2 99e9fc1c3b ES 9.3.7 2026-07-10 13:50:55 -05:00
Josh Patterson 89e6a746c8 so-salt-minion-wait: wait for the restart job before reading MainPID
Live testing on a standalone node found the previous commit still reported
ready on the OUTGOING daemon. Reproduced by running the production sequence:

  systemctl restart --no-block salt-minion   # what service.restart issues
  /usr/sbin/so-salt-minion-wait              # what cmd.run then runs

  so-salt-minion-wait: gating on pid-tagged ready line ... plus master sockets
  salt-minion (pid 2750297) ready after 3s   # 2750297 is the OLD child

salt restarts this unit with --no-block -- _no_block_default() in
salt/modules/systemd_service.py returns True when the unit is the salt-minion
service -- so service.restart returns as soon as the job is enqueued. Measured
on the host, systemd does not swap MainPID until ~7.3s later. Throughout that
window the old daemon is still running, still holds its master sockets, and
its own ready line is already in the log, so every gate passes on the instance
that is about to be torn down. INITIAL_SLEEP=3 expired inside that window.

Wait for systemd's job queue for the unit to drain before resolving MainPID.
That is deterministic rather than a timing guess: the job exists from the
moment --no-block returns until the new instance signals READY, and MainPID is
new by the time it clears. Measured transition:

  t=0.0s   job pending, child=OLD, sockets up, ready line present
  t=7.9s   job drained, child=NEW, sockets up, ready line ABSENT
  t=10.7s  ready line for NEW child appears   <- script returns here

The same run also confirms empirically why the log line is required in
addition to the sockets: for 2.8s the new child has both master connections
while _post_master_init() is still loading modules and compiling pillar, so a
socket-only gate would return that much too early.

Correct the comment claim from the previous commit. The --no-block restart is
real; it lives in salt's systemd_service module, not in this repo, which is
why searching the repo for it turned up nothing.
2026-07-10 09:35:21 -04:00
Mike Reeves 52885e28c5 Name the reposync-side kernel repo securityonionkernelsync
The reposync section in repodownload.conf and the client repo assigned in
repo/client/oracle.sls both used the bare name securityonionkernel, colliding
across the two roles. Rename the reposync-side section (and its --repoid, the
so-repo-sync guard, and the so-kernel-upgrade presence check) to
securityonionkernelsync, mirroring the existing securityonion/securityonionsync
split for the main repo. The client repo stays securityonionkernel. Also give
the section its own name=Security Onion Kernel Repo repo.
2026-07-09 17:02:47 -04:00
Josh Patterson fbeac25ee9 so-salt-minion-check: highstate after minion restart
When the minion is deemed hung and restarted, wait for it to become
ready via so-salt-minion-wait, then kick off salt-call state.highstate
(queued, backgrounded) so the box re-applies its states and recovers on
its own rather than waiting for the next scheduled highstate.
2026-07-09 16:57:19 -04:00
Josh Patterson 8a3f5d0f81 Gate so-salt-minion-wait on real minion readiness
The previous gate did not detect whether the restarted minion was back:

  systemctl is-active --quiet salt-minion \
    && salt-call --local --timeout=5 --out=quiet test.ping

Both halves are near-vacuous. `--local` sets file_client=local, so test.ping
runs in a throwaway minion that never contacts the master and never inspects
the running daemon; it only proves python and the module loader work. And the
shipped unit is Type=notify with notify_systemd() called before the daemon
imports salt.cli.daemons, so is-active goes true at process launch, not at
connection. The script could return ready while the minion was still
authenticating, which is the race it exists to prevent.

Gate instead on the condition salt itself uses to log "Minion is ready to
receive requests!", requiring both signals of the current daemon instance:

  1. the pid-tagged ready line in the minion log. tune_in() emits it only
     after sync_connect_master() returns, i.e. the pub channel authenticated,
     the req channel connected, and _post_master_init() finished loading
     modules and compiling pillar.
  2. that same pid holding an ESTABLISHED req connection to a master on 4506
     plus a second (publish) connection to the same master IP. The publish
     port is absent from minion config -- the minion learns it from the
     master's auth reply -- so it is derived from the connection.

Resolve the daemon pid from systemd (MainPID -> pgrep -P), never from
/var/run/salt-minion.pid. salt_minion() runs the minion in a multiprocessing
child; that child writes the pidfile, owns the sockets and logs the ready
line, while MainPID is the parent. During a restart the pidfile still names
the old child, whose own ready line is already in the log, so keying off it
reports ready instantly. Children of the current MainPID exclude the old
instance structurally, with no timing assumptions.

Degrade deterministically rather than spinning to the timeout: if
log_level_logfile does not emit INFO records the ready line can never appear,
so detect that up front from the merged config and fall back to the socket
check. log_level_logfile defaults to None (inherit log_level), so resolve the
inheritance before deciding. If ss is unavailable, fall back to the log gate.
If neither signal is usable, fail immediately with a clear message.

Requiring master connectivity adds no new dependency: every path that applies
salt.minion or a highstate does so without --local, so file_client=remote
already required a reachable master to fetch salt:// files. No salt-call
master round-trip is added; the daemon's own successful auth already proves
the key is accepted.

Also fix the comment above wait_for_salt_minion_ready, which attributed the
script to common_sbin/common/tools/sbin (it is deployed by salt_sbin from
salt/tools/sbin) and asserted a --no-block restart that appears nowhere in
the repo. No state logic changed.
2026-07-09 16:55:30 -04:00
Mike Reeves 9a71f64a35 Branch so-kernel-upgrade on the running kernel flavor
Only the RHCK->UEK flavor cross needs grubby --set-default; a UEK7->UEK8
update stays in the kernel-uek lineage and auto-promotes on its own. Detect
the running kernel and act accordingly:

- UEK8: already on target, no-op.
- UEK7: populate the repo and install UEK8, then verify it auto-promoted
  (warn with the manual grubby command if it did not) -- no grubby change.
- RHCK: install UEK8 and set the boot default explicitly, as before.

Also make an already-installed UEK8 skip the repo entirely so a disabled or
empty kernel repo can't block flipping the default, and correct the header
comment that claimed every transition needs grubby.
2026-07-09 15:10:13 -04:00
Mike Reeves 40c02b3149 Make so-kernel-upgrade populate the kernel repo and fail loudly
Three stages of the UEK8 path fail silently, and the script only handled
the last one:

1. Populate. so-repo-sync runs before the highstate deploys the
   [securityonionkernel] section into repodownload.conf, so the first
   kernel-aware soup skips the kernel sync. kernelrepo_init_empty then
   seeds valid-but-empty repodata, leaving an enabled repo with zero
   packages. dnf resolves it happily and installs nothing, no error.

2. Install. `dnf install kernel-uek` on a UEK7 node sees kernel-uek 5.15
   already installed, prints "Nothing to do" and exits 0 -- so the script
   sailed past the install and died later with a misleading grubby error.

3. Boot. Already handled: grubby only auto-promotes within the running
   kernel's flavor lineage, so 5.x -> 6.x UEK never promotes on its own.

Add ensure_kernel_repo(), which verifies the repo is enabled (necessary
because skip_if_unavailable=1 hides a broken repo) and that it can serve a
6.x kernel-uek. When it cannot, a manager runs so-repo-sync to populate
/nsm/kernelrepo and re-checks; a minion cannot fix it and exits non-zero
pointing the admin at the manager. Airgap managers bail, since their repo
comes from the ISO rather than a sync.

Install the explicit UEK8 NEVRA instead of the bare package name so the
"Nothing to do" exit-0 case cannot mask a no-op, and pin the repoquery to
securityonionkernel so a UEK7 kernel-uek in the main repo is never picked.

Still idempotent and still never reboots.
2026-07-09 14:21:08 -04:00
Mike Reeves 5fd5df54b4 Install UEK8 in so-kernel-upgrade when no UEK kernel is present
The script assumed the UEK8 kernel was already installed and only switched
the boot default to it. On a node running the EL9 stock kernel (RHCK 5.14)
there is no kernel-uek* package at all, so `dnf update` has nothing to
upgrade and UEK8 never lands -- the script just logged "nothing to do" and
exited 0.

When no 6.x UEK boot entry exists, install the kernel-uek metapackage (it
pulls kernel-uek-core plus the module subpackages, including
kernel-uek-modules-extra-netfilter) and then proceed with the grubby
switch. Fail loudly if securityonionkernel is not an enabled repo, since
that assignment is gated on the NIC-pin marker and the salt version match
and a silent no-op there is hard to diagnose.

Also point DEFAULTKERNEL at kernel-uek-core so later kernel updates stay on
the UEK line rather than falling back to RHCK.

Still idempotent and still never reboots.
2026-07-09 13:47:50 -04:00
Josh Patterson 66a1141b84 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-07-07 09:02:15 -04:00
Josh Patterson 3310e19ee4 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-07-02 10:27:54 -04:00
Josh Patterson f441d98e71 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-07-01 10:34:56 -04:00
Josh Patterson a330bea25e Rename push-detection beacons to clearer names
Rename the two custom push-detection beacons for clarity:
- pillar_db -> postgres_pillar_beacon
- rules_db  -> rules_beacon

Salt resolves a beacon by its config-key name to a _beacons/ module of the
same filename and tags its events salt/beacon/<minion>/<name>/<tag>, so each
rename touches the module file, the beacon config key in
beacons_pushstate.conf.jinja, and the reactor tag patterns in
reactor_pushstate.conf together. Watermark filenames and log prefixes are
updated to match; reactor run() logic is unchanged.
2026-06-29 14:29:07 -04:00
Josh Patterson 33c24cd136 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-06-26 15:42:56 -04:00
Josh Patterson 12f4447875 Replace inotify rule-watch beacon with poll-based rules_db beacon
Salt's stock inotify beacon leaks one kernel inotify instance every time
the minion rebuilds the beacon loader's __context__ (the orphaned
pyinotify.Notifier is never stopped), accumulating against
fs.inotify.max_user_instances=128 until inotify_init() fails with EMFILE
and rule-change push detection silently stops. This is independent of
disable_during_state_run.

Add a custom poll-based beacon (salt/_beacons/rules_db.py) modeled on
pillar_db.py: it fingerprints the suricata/strelka rule dirs each interval
(relpath + mtime_ns + size, temp files excluded) against a per-dir
watermark, emitting an event only on change. It holds zero inotify
instances, so the leak is impossible, and it keeps firing during state
runs. Swap the inotify beacon config and reactor tag mappings accordingly;
the push_suricata/push_strelka reactors are unchanged (they read only
data['path']).
2026-06-26 15:40:32 -04:00
Josh Patterson da94788255 Move highstate_interval_hours to salt.schedule and split schedule.sls
highstate_interval_hours describes the per-minion highstate schedule, not the
active-push pipeline, so relocate it from salt.auto_apply to a new salt.schedule
settings subtree. Repoint so-salt-minion-check at the new pillar path (it had
been left on the stale global:push path) so its restart grace period tracks the
schedule again.

- Add salt.schedule.highstate_interval_hours to defaults.yaml/soc_salt.yaml and a
  side-effect-free salt/salt/schedule.map.jinja (SCHEDULEMERGED), matching the
  *MERGED map convention. Consumers read SCHEDULEMERGED.highstate_interval_hours.
- Split salt/schedule.sls into salt/salt/highstate_schedule.sls (every minion) and
  salt/salt/push_drain_schedule.sls (managers); update top.sls to apply the
  highstate schedule via '*' and the drainer schedule via the configured-manager
  block. Remove the now-empty schedule.sls aggregator.
- pillar_push_map.yaml and so-push-drainer: comment/doc updates only.
2026-06-26 10:51:57 -04:00
Josh Patterson fa2ae1b87f Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-06-25 11:45:03 -04:00
Josh Patterson 5bf9751adf do not disable during state run 2026-06-25 11:44:38 -04:00
Josh Patterson 3effdbc91e do not disable during state run 2026-06-25 11:36:52 -04:00
Josh Patterson 8836529496 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-06-25 08:13:32 -04:00
Josh Patterson b09c3776b7 Point pillar_db beacon at securityonion database
The SOC postgres database was renamed so_soc -> securityonion (see
POSTGRES_DB in salt/postgres/enabled.sls and the SOC postgres config in
salt/soc/defaults.yaml). The pillar_db beacon still hardcoded so_soc, so
every poll failed with 'database "so_soc" does not exist' (rc=2),
silently disabling active-push detection of audit_settings changes.

Update DATABASE to 'securityonion' and refresh the now-stale so_soc
references in the beacon and push_pillar reactor comments.
2026-06-24 16:51:32 -04:00
Josh Patterson dfdb1fbaeb Move global.push config to salt.auto_apply
The active-push tunables (enabled, highstate_interval_hours, debounce_seconds,
drain_interval, batch, batch_wait) described how Salt auto-applies changes, not
general grid config, so relocate them from the global namespace to a new
salt.auto_apply settings module.

- Add salt/salt/{defaults.yaml,auto_apply.map.jinja,soc_salt.yaml,adv_salt.yaml}.
  auto_apply.map.jinja is a dedicated, side-effect-free merge map (the existing
  salt/salt/map.jinja dereferences pillar.host.mainint at import time).
- Remove the push blocks from salt/global/{defaults,soc_global}.yaml.
- Register salt.soc_salt/salt.adv_salt in pillar/top.sls; seed the local pillar
  stubs for fresh installs (make_some_dirs) and upgrades (ensure_salt_local_pillar
  in soup, wired into up_to_3.2.0).
- Repoint all consumers: GLOBALMERGED.push.* -> AUTOAPPLY.* (schedule, salt
  master, manager beacons, beacons_pushstate, orch.push_batch) and
  pillar.get('global:push...') -> 'salt:auto_apply...' (push reactors,
  so-push-drainer).
- Add a salt: fleetwide-highstate entry to pillar_push_map.yaml so edits keep
  applying immediately, matching the prior global-namespace behavior.
2026-06-24 15:17:48 -04:00
Josh Patterson 61aa963a2d Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-06-24 08:10:27 -04:00
Josh Patterson d71e80cf66 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-06-23 10:32:32 -04:00
Josh Patterson 33a116357d Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-06-10 08:56:17 -04:00
Josh Patterson 8c17ae0f66 move so-salt-minion-wait 2026-06-01 14:48:54 -04:00
Josh Patterson f54939b444 Replace inotify pillar watch with postgres audit_settings beacon
The active-push feature detected pillar/settings changes via an inotify
beacon on the manager watching /opt/so/saltstack/local/pillar. Replace
that pillar watch with a custom salt beacon (pillar_db) that polls the
SOC so_soc.audit_settings table on a monotonic id watermark, so changes
made through SOC drive immediate pushes from the database instead of the
files. The suricata/strelka rule inotify watches (and pyinotify) are kept
unchanged, since rule-file edits are not recorded in audit_settings.

- salt/_beacons/pillar_db.py: new beacon. Polls audit_settings via
  `docker exec so-postgres psql` (unix-socket trust auth), tracks the last
  processed id in /opt/so/state/pillar_db_watch.id, seeds to MAX(id) on
  first run (no history replay), and emits one event per new row.
- salt/reactor/push_pillar.sls: consume setting_id/node_id from the beacon
  event instead of a file path. App = first dotted segment of setting_id,
  looked up in pillar_push_map.yaml. Empty node_id -> grid-wide actions as
  is; populated node_id -> the app's state(s) retargeted to that one node.
- salt/manager/files/beacons_pushstate.conf.jinja: drop the pillar inotify
  block, add the pillar_db beacon (interval = push.drain_interval); keep
  the suricata/strelka inotify watches.
- salt/salt/files/reactor_pushstate.conf: map salt/beacon/*/pillar_db/
  audit_settings to push_pillar.sls; remove the pillar inotify reactor
  lines; keep suricata/strelka.

The intent -> so-push-drainer -> orch.push_batch pipeline is unchanged.
Verified end-to-end on a standalone: a grid-wide telegraf.output change
re-applied telegraf fleetwide (container replaced), and a per-host
ntp.config.servers change applied ntp to only that node.
2026-05-29 14:55:13 -04:00
Josh PattersonandGitHub d48a22e37e Merge pull request #15944 from Security-Onion-Solutions/jertel/wip
Jertel/wip
2026-05-28 14:01:42 -04:00
Josh Patterson 6393d08e86 merge 2026-05-27 08:59:28 -04:00
Josh Patterson 730c828bec Merge remote-tracking branch 'origin/jertel/wip' into saltthangs 2026-05-19 10:23:45 -04:00
Josh Patterson b4e5171415 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-05-14 08:03:45 -04:00
Josh Patterson 84decc1db6 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-05-13 14:09:15 -04:00
Josh Patterson 7d4d6a0756 prune images if so-docker-prune exists 2026-05-08 10:13:15 -04:00
Josh Patterson 66c0a662fc convert wait to script 2026-05-08 09:26:42 -04:00
Josh Patterson 778cc055ea wait for salt-minion service to be ready before finishing state run 2026-05-07 17:01:20 -04:00
Josh Patterson 932deab751 update the push map 2026-05-07 10:51:53 -04:00
Josh Patterson 1281f0ee37 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-05-06 09:46:12 -04:00
Josh Patterson f774334b6c Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-05-06 08:16:41 -04:00
Josh Patterson 7fcace34c4 add sensoroni to push map 2026-04-30 16:09:08 -04:00
Josh Patterson 9541024eb7 fix broken things 2026-04-30 15:35:24 -04:00
Josh Patterson 0d166ef732 remove trailing slashes 2026-04-30 09:53:00 -04:00
Josh Patterson f7d2994f8b filter temp files 2026-04-30 09:16:22 -04:00
Josh Patterson 8f0757606d include salt..minion 2026-04-29 16:42:19 -04:00
Josh Patterson 0a8f2e01a0 install pyinotify 2026-04-29 16:41:56 -04:00
Josh Patterson 4546d7bc52 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-04-29 14:28:19 -04:00
Josh Patterson 17849d8758 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-04-28 15:49:22 -04:00
Josh Patterson d3d30a587c Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-04-28 15:30:31 -04:00
Josh Patterson 034711d148 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-04-28 10:47:29 -04:00
Mike Reeves a0cf0489d6 reduce highstate frequency with active push for rules and pillars
- schedule highstate every 2 hours (was 15 minutes); interval lives in
  global:push:highstate_interval_hours so the SOC admin UI can tune it and
  so-salt-minion-check derives its threshold as (interval + 1) * 3600
- add inotify beacon on the manager + master reactor + orch.push_batch that
  writes per-app intent files, with a so-push-drainer schedule on the manager
  that debounces, dedupes, and dispatches a single orchestration
- pillar_push_map.yaml allowlists the apps whose pillar changes trigger an
  immediate targeted state.apply (targets verified against salt/top.sls);
  edits under pillar/minions/ trigger a state.highstate on that one minion
- host-batch every push orchestration (batch: 25%, batch_wait: 15) so rule
  changes don't thundering-herd large fleets
- new global:push:enabled kill-switch tears down the beacon, reactor config,
  and drainer schedule on the next highstate for operators who want to keep
  highstate-only behavior
- set restart_policy: unless-stopped on 23 container states so docker
  recovers crashes without waiting for the next highstate; leave registry
  (always), strelka/backend (on-failure), kratos, and hydra alone with
  inline comments explaining why
2026-04-10 15:43:16 -04:00
Jason Ertel 613d31c8a6 merge 2026-03-05 11:52:09 -05:00
81 changed files with 2511 additions and 180 deletions
+2 -1
View File
@@ -5,6 +5,7 @@ on:
paths:
- "salt/sensoroni/files/analyzers/**"
- "salt/manager/tools/sbin/**"
- "salt/_beacons/**"
jobs:
build:
@@ -14,7 +15,7 @@ jobs:
fail-fast: false
matrix:
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:
- uses: actions/checkout@v3
+2
View File
@@ -3,6 +3,8 @@ base:
- ca
- global.soc_global
- global.adv_global
- salt.soc_salt
- salt.adv_salt
- docker.soc_docker
- docker.adv_docker
- influxdb.token
+142
View File
@@ -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()
+139
View File
@@ -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
+172
View File
@@ -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()
+18 -19
View File
@@ -3,31 +3,30 @@ import logging
def status():
cmd = "runuser -l zeek -c '/opt/zeek/bin/zeekctl status'"
retval = __salt__['docker.run']('so-zeek', cmd)
logging.info('zeekctl_module: zeekctl.status retval: %s' % retval)
cmd = "runuser -l zeek -c '/opt/zeek/bin/zeekctl status'"
retval = __salt__['docker.run']('so-zeek', cmd) # noqa: F821
logging.info('zeekctl_module: zeekctl.status retval: %s' % retval)
return retval
return retval
def beacon(config):
retval = []
retval = []
is_enabled = __salt__['healthcheck.is_enabled']()
logging.info('zeek_beacon: healthcheck_is_enabled: %s' % is_enabled)
is_enabled = __salt__['healthcheck.is_enabled']() # noqa: F821
logging.info('zeek_beacon: healthcheck_is_enabled: %s' % is_enabled)
if is_enabled:
zeekstatus = status().lower().split(' ')
logging.info('zeek_beacon: zeekctl.status: %s' % str(zeekstatus))
if 'stopped' in zeekstatus or 'crashed' in zeekstatus or 'error' in zeekstatus or 'error:' in zeekstatus:
zeek_restart = True
else:
zeek_restart = False
if is_enabled:
zeekstatus = status().lower().split(' ')
logging.info('zeek_beacon: zeekctl.status: %s' % str(zeekstatus))
if 'stopped' in zeekstatus or 'crashed' in zeekstatus or 'error' in zeekstatus or 'error:' in zeekstatus:
zeek_restart = True
else:
zeek_restart = False
__salt__['telegraf.send']('healthcheck zeek_restart=%s' % str(zeek_restart))
retval.append({'zeek_restart': zeek_restart})
logging.info('zeek_beacon: retval: %s' % str(retval))
return retval
__salt__['telegraf.send']('healthcheck zeek_restart=%s' % str(zeek_restart)) # noqa: F821
retval.append({'zeek_restart': zeek_restart})
logging.info('zeek_beacon: retval: %s' % str(retval))
return retval
+59
View File
@@ -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()
+1 -37
View File
@@ -602,42 +602,6 @@ run_check_net_err() {
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() {
local MINIONDIR="/opt/so/saltstack/local/pillar/minions"
MINIONCOUNT=$(ls -la $MINIONDIR/*.sls | grep -v adv_ | wc -l)
@@ -702,7 +666,7 @@ systemctl_func() {
echo ""
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 ""
}
+223 -37
View File
@@ -5,53 +5,239 @@
# https://securityonion.net/license; you may not use this file except in compliance with the
# 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).
# Installing the kernel-uek-core package adds a UEK8 boot entry but does NOT make it the
# 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.
# Security Onion is moving off the EL9 stock kernel (RHCK, 5.14) and UEK7 (5.15) onto UEK8
# (6.x). Three things have to happen, and the tool has to drive each one:
#
# Idempotent: if the UEK8 kernel is already the default it does nothing. It only sets the
# boot default; it does NOT reboot — the admin reboots the node on their own schedule.
# 1. Populate. The manager mirrors the UEK8 packages into /nsm/kernelrepo via so-repo-sync,
# 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] $*"; }
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 || { log "grubby not found"; exit 1; }
command -v grubby >/dev/null 2>&1 || die "grubby not found"
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
# /boot/vmlinuz-6.12.0-203.76.7.5.el9uek.x86_64; the 5.x UEK7 and 5.14 RHCK won't match.
target="$(grubby --info=ALL 2>/dev/null \
| sed -n 's/^kernel="\(.*\)"$/\1/p' \
| grep -E '/vmlinuz-6\.[0-9]+.*uek' \
| sort -V | tail -1)"
# /boot/vmlinuz-6.12.0-204.92.4.2.el9uek.x86_64; UEK7 (5.15) and RHCK (5.14) won't match.
find_uek8() {
grubby --info=ALL 2>/dev/null \
| sed -n 's/^kernel="\(.*\)"$/\1/p' \
| grep -E '/vmlinuz-6\.[0-9]+.*uek' \
| sort -V | tail -1
}
if [ -z "$target" ]; then
log "no installed 6.x UEK (UEK8) kernel found — confirm the kernel repo is assigned and"
log "'dnf update' has installed kernel-uek-core. Nothing to do."
# Classify the RUNNING kernel (uname -r) -- this, not what's installed, is what decides whether
# a UEK8 install auto-promotes to the boot default:
# 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
fi
;;
current="$(grubby --default-kernel 2>/dev/null)"
if [ "$current" = "$target" ]; then
log "UEK8 kernel is already the boot default: $target"
exit 0
fi
uek7)
# 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
log "current default kernel: ${current:-unknown}"
log "switching boot default to UEK8 kernel: $target"
grubby --set-default="$target" || { log "ERROR: grubby --set-default failed for $target"; exit 1; }
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"
;;
# Verify the change actually took before claiming success.
now="$(grubby --default-kernel 2>/dev/null)"
if [ "$now" != "$target" ]; then
log "ERROR: default kernel is still '${now:-unknown}' after set-default"
exit 1
fi
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"
log "boot default is now $target"
log "REBOOT REQUIRED to start using the UEK8 kernel (currently running $(uname -r))."
current="$(grubby --default-kernel 2>/dev/null)"
if [ "$current" = "$target" ]; then
log "UEK8 kernel is already the boot default: $target"
reboot_notice "$target"
exit 0
fi
log "current default kernel: ${current:-unknown}"
log "switching boot default to UEK8 kernel: $target"
grubby --set-default="$target" || die "grubby --set-default failed for $target"
# Verify the change actually took before claiming success.
now="$(grubby --default-kernel 2>/dev/null)"
[ "$now" = "$target" ] || die "default kernel is still '${now:-unknown}' after set-default"
log "boot default is now $target"
reboot_notice "$target"
;;
esac
+1
View File
@@ -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|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|file already closed" # Go logging race condition during container restart
fi
if [[ $EXCLUDE_FALSE_POSITIVE_ERRORS == 'Y' ]]; then
+11 -6
View File
@@ -74,13 +74,13 @@ def output(options, console, code, data):
summary = { "status_code": code, "containers": data }
print(json.dumps(summary))
elif "-q" not in options:
if code == 2:
console.print(" [bold yellow]:hourglass: [bold white]System appears to be starting. No highstate has completed since the system was restarted.")
elif code == 99:
if code == 99:
console.print(" [bold red]:exclamation: [bold white]Installation does not appear to be complete. A highstate has not fully completed.")
elif code == 100:
console.print(" [bold red]:exclamation: [bold white]Installation encountered errors.")
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.add_column("Container", justify="right", style="white", 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)
if code == 0:
code = check_system_status(options, console)
if code == 0:
code, container_list = check_container_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:
code = container_code
output(options, console, code, container_list)
return code
@@ -180,4 +186,3 @@ def main():
if __name__ == "__main__":
main()
@@ -1,5 +1,3 @@
{% import_yaml 'salt/minion.defaults.yaml' as SALT_MINION_DEFAULTS -%}
#!/bin/bash
#
# 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
# 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
# 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
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)
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_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
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))
logCmd() {
@@ -77,24 +77,50 @@ done
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 [ $THRESHOLD_DATE -le $CURRENT_TIME ]; then
log "salt-minion is unable to apply states" 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 "last highstate completed at `date -d @$LAST_HIGHSTATE_END`" I
log "checking if any jobs are running" I
log "[minion-restart-check] salt-minion is unable to apply states; restarting salt-minion" E
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 "[minion-restart-check] last highstate completed at `date -d @$LAST_HIGHSTATE_END`" I
log "[minion-restart-check] checking if any jobs are 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
log "ensure highstate is enabled" I
log "[minion-restart-check] ensure highstate is enabled" 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
log "starting salt-minion service" I
log "[minion-restart-check] starting salt-minion service" 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
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
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
+2 -1
View File
@@ -9,7 +9,8 @@
prune_images:
cmd.run:
- name: so-docker-prune
- order: last
- onlyif: command -v /usr/sbin/so-docker-prune >/dev/null 2>&1
- order: 9000
{% else %}
+1
View File
@@ -19,6 +19,7 @@ wait_for_elasticsearch:
so-elastalert:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elastalert:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- hostname: elastalert
- name: so-elastalert
- user: so-elastalert
@@ -15,6 +15,7 @@ include:
so-elastic-fleet-package-registry:
docker_container.running:
- 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
- hostname: Fleet-package-reg-{{ GLOBALS.hostname }}
- detach: True
+1
View File
@@ -16,6 +16,7 @@ include:
so-elastic-agent:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elastic-agent:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- name: so-elastic-agent
- hostname: {{ GLOBALS.hostname }}
- detach: True
-1
View File
@@ -1,6 +1,5 @@
elasticfleet:
enabled: False
patch_version: 9.3.3+build202604082258 # Elastic Agent specific patch release.
enable_manager_output: True
config:
server:
+1
View File
@@ -46,6 +46,7 @@ elasticagent_syncartifacts:
so-elastic-fleet:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elastic-agent:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- name: so-elastic-fleet
- hostname: FleetServer-{{ GLOBALS.hostname }}
- detach: True
@@ -5,7 +5,7 @@
"package": {
"name": "endpoint",
"title": "Elastic Defend",
"version": "9.3.0",
"version": "9.3.1",
"requires_root": true
},
"enabled": true,
@@ -29,7 +29,7 @@
"\\.gz$"
],
"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": [
"import"
],
+1 -1
View File
@@ -1,6 +1,6 @@
elasticsearch:
enabled: false
version: 9.3.3
version: 9.3.7
index_clean: true
data_retention_method: DLM
vm:
+1
View File
@@ -24,6 +24,7 @@ include:
so-elasticsearch:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elasticsearch:{{ ELASTICSEARCHMERGED.version }}
- restart_policy: unless-stopped
- hostname: elasticsearch
- name: so-elasticsearch
- user: elasticsearch
@@ -118,70 +118,70 @@
{
"pipeline": {
"tag": "pipeline_e16851a7",
"name": "logs-pfsense.log-1.25.2-firewall",
"name": "logs-pfsense.log-1.25.4-firewall",
"if": "ctx.event.provider == 'filterlog'"
}
},
{
"pipeline": {
"tag": "pipeline_828590b5",
"name": "logs-pfsense.log-1.25.2-openvpn",
"name": "logs-pfsense.log-1.25.4-openvpn",
"if": "ctx.event.provider == 'openvpn'"
}
},
{
"pipeline": {
"tag": "pipeline_9d37039c",
"name": "logs-pfsense.log-1.25.2-ipsec",
"name": "logs-pfsense.log-1.25.4-ipsec",
"if": "ctx.event.provider == 'charon'"
}
},
{
"pipeline": {
"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)"
}
},
{
"pipeline": {
"tag": "pipeline_dd85553d",
"name": "logs-pfsense.log-1.25.2-unbound",
"name": "logs-pfsense.log-1.25.4-unbound",
"if": "ctx.event.provider == 'unbound'"
}
},
{
"pipeline": {
"tag": "pipeline_720ed255",
"name": "logs-pfsense.log-1.25.2-haproxy",
"name": "logs-pfsense.log-1.25.4-haproxy",
"if": "ctx.event.provider == 'haproxy'"
}
},
{
"pipeline": {
"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'"
}
},
{
"pipeline": {
"tag": "pipeline_a0d89375",
"name": "logs-pfsense.log-1.25.2-squid",
"name": "logs-pfsense.log-1.25.4-squid",
"if": "ctx.event.provider == 'squid'"
}
},
{
"pipeline": {
"tag": "pipeline_c2f1ed55",
"name": "logs-pfsense.log-1.25.2-snort",
"name": "logs-pfsense.log-1.25.4-snort",
"if": "ctx.event.provider == 'snort'"
}
},
{
"pipeline": {
"tag":"pipeline_33db1c9e",
"name": "logs-pfsense.log-1.25.2-suricata",
"name": "logs-pfsense.log-1.25.4-suricata",
"if": "ctx.event.provider == 'suricata'"
}
},
+1 -1
View File
@@ -22,6 +22,7 @@ include:
so-hydra:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-hydra:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- hostname: hydra
- name: so-hydra
- networks:
@@ -58,7 +59,6 @@ so-hydra:
- {{ ULIMIT.name }}={{ ULIMIT.soft }}:{{ ULIMIT.hard }}
{% endfor %}
{% endif %}
- restart_policy: unless-stopped
- watch:
- file: hydraconfig
- require:
+1
View File
@@ -15,6 +15,7 @@ include:
so-idh:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-idh:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- name: so-idh
- detach: True
- network_mode: host
+1
View File
@@ -18,6 +18,7 @@ include:
so-influxdb:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-influxdb:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- hostname: influxdb
- networks:
- sobridge:
+1
View File
@@ -27,6 +27,7 @@ include:
so-kafka:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-kafka:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- hostname: so-kafka
- name: so-kafka
- networks:
+1 -1
View File
@@ -22,7 +22,7 @@ kibana:
- default
- file
migrations:
discardCorruptObjects: "9.3.3"
discardCorruptObjects: "9.3.7"
telemetry:
enabled: False
xpack:
+1
View File
@@ -17,6 +17,7 @@ include:
so-kibana:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-kibana:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- hostname: kibana
- user: "932:0"
- networks:
+1 -1
View File
@@ -15,6 +15,7 @@ include:
so-kratos:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-kratos:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- hostname: kratos
- name: so-kratos
- networks:
@@ -51,7 +52,6 @@ so-kratos:
- {{ ULIMIT.name }}={{ ULIMIT.soft }}:{{ ULIMIT.hard }}
{% endfor %}
{% endif %}
- restart_policy: unless-stopped
- watch:
- file: kratosschema
- file: kratosconfig
+67
View File
@@ -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
+1
View File
@@ -28,6 +28,7 @@ include:
so-logstash:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-logstash:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- hostname: so-logstash
- name: so-logstash
- networks:
+21
View File
@@ -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
+2
View File
@@ -15,6 +15,7 @@ include:
- manager.elasticsearch
- manager.kibana
- manager.managed_soc_annotations
- manager.beacons
repo_log_dir:
file.directory:
@@ -260,6 +261,7 @@ surifiltersrules:
- user: 939
- group: 939
{% else %}
{{sls}}_state_not_allowed:
+231
View File
@@ -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
View File
@@ -12,7 +12,17 @@
UPDATE_DIR=/tmp/sogh/securityonion
DEFAULT_SALT_DIR=/opt/so/saltstack/default
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}')
BATCHSIZE=5
SOUP_LOG=/root/soup.log
@@ -23,6 +33,10 @@ NOTIFYCUSTOMELASTICCONFIG=false
TOPFILE=/opt/so/saltstack/default/salt/top.sls
BACKUPTOPFILE=/opt/so/saltstack/default/salt/top.sls.backup
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_CONFIGURED=false
# 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"
# 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
fi
@@ -291,6 +327,30 @@ check_pillar_items() {
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() {
set +e
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}')
CURRENTSOFIREWALL=$(md5sum /usr/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() {
@@ -414,6 +476,13 @@ preupgrade_changes() {
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() {
# This function is to add any new pillar items if needed.
echo "Running post upgrade processes."
@@ -421,6 +490,8 @@ postupgrade_changes() {
[[ "$POSTVERSION" =~ ^2\.4\.21[0-9]+$ ]] && post_to_3.0.0
[[ "$POSTVERSION" == "3.0.0" ]] && post_to_3.1.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
}
@@ -513,7 +584,7 @@ post_to_3.0.0() {
# convert yes/no in suricata pillars to true/false
convert_suricata_yes_no
POSTVERSION=3.0.0
set_postversion 3.0.0
}
### 3.0.0 End ###
@@ -691,6 +762,21 @@ ensure_postgres_local_pillar() {
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() {
# On a fresh install, generate_passwords + secrets_pillar seed
# 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() {
ensure_postgres_local_pillar
ensure_postgres_secret
determine_elastic_agent_upgrade
elasticsearch_backup_index_templates
# Clear existing component template state file.
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_transform_health_and_reauthorize || true
POSTVERSION=3.1.0
set_postversion 3.1.0
}
### 3.1.0 End ###
@@ -888,6 +973,11 @@ update_kafka_metadata() {
}
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
pin_elasticsearch_data_retention_method
@@ -901,7 +991,7 @@ post_to_3.2.0() {
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"
/sbin/so-elastic-agent-gen-installers
@@ -909,7 +999,7 @@ post_to_3.2.0() {
update_kafka_metadata "4.3"
POSTVERSION=3.2.0
set_postversion 3.2.0
}
### 3.2.0 End ###
@@ -1061,8 +1151,20 @@ upgrade_check() {
fi
[[ -f /etc/sohotfix ]] && CURRENTHOTFIX=$(cat /etc/sohotfix)
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"
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."
exit 0
else
@@ -1141,7 +1243,7 @@ upgrade_salt() {
verify_latest_update_script() {
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."
else
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
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."
else
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.6"]="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
@@ -1460,18 +1563,13 @@ verify_es_version_compatibility() {
}
wait_for_salt_minion_with_restart() {
local minion="$1"
local max_wait="${2:-60}"
local interval="${3:-3}"
local logfile="$4"
wait_for_salt_minion "$minion" "$max_wait" "$interval" "$logfile"
/usr/sbin/so-salt-minion-wait
local result=$?
if [[ $result -ne 0 ]]; then
echo "$(date '+%a %d %b %Y %H:%M:%S.%6N') - salt-minion not ready, attempting restart..."
systemctl_func "restart" "salt-minion"
wait_for_salt_minion "$minion" "$max_wait" "$interval" "$logfile"
/usr/sbin/so-salt-minion-wait
result=$?
fi
@@ -1757,6 +1855,15 @@ main() {
set_minionid
MINION_ROLE=$(lookup_role)
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 ""
check_minimum_version
@@ -1785,6 +1892,12 @@ main() {
echo "Verifying Elasticsearch version compatibility across the grid before upgrading."
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."
upgrade_check_salt
set -e
@@ -1801,6 +1914,7 @@ main() {
fi
if [ "$is_hotfix" == "true" ]; then
SOUP_UPGRADE_STARTED=true
echo "Applying $HOTFIXVERSION hotfix"
# 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
@@ -1811,10 +1925,16 @@ main() {
create_local_directories "/opt/so/saltstack/default"
apply_hotfix
echo "Hotfix applied"
update_version
enable_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
SOUP_UPGRADE_STARTED=true
echo ""
echo "Performing upgrade from Security Onion $INSTALLEDVERSION to Security Onion $NEWVERSION."
echo ""
@@ -1870,6 +1990,10 @@ main() {
copy_new_files
echo ""
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
echo ""
@@ -1899,9 +2023,9 @@ main() {
enable_highstate
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
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
set -e
@@ -1913,8 +2037,8 @@ main() {
check_saltmaster_status
echo "Running a highstate 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."
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 || fail "Salt minion was not running or ready."
# 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
+1
View File
@@ -34,6 +34,7 @@ make-rule-dir-nginx:
so-nginx:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-nginx:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- hostname: so-nginx
- networks:
- sobridge:
+1 -1
View File
@@ -399,7 +399,7 @@ http {
error_page 429 = @error429;
location @error401 {
if ($request_uri ~* (^/api/.*|^/connect/.*|^/oauth2/.*|^/.*\.map$)) {
if ($request_uri ~* (^.*/api/.*|^/connect/.*|^/oauth2/.*|^/.*\.map$)) {
return 401;
}
+37
View File
@@ -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 %}
+1
View File
@@ -19,6 +19,7 @@ include:
so-postgres:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-postgres:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- hostname: so-postgres
- networks:
- sobridge:
+251
View File
@@ -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'
+176
View File
@@ -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 {}
+96
View File
@@ -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 {}
+95
View File
@@ -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 {}
+1
View File
@@ -17,6 +17,7 @@ include:
so-redis:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-redis:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- hostname: so-redis
- user: socore
- networks:
+3 -1
View File
@@ -17,11 +17,13 @@ include:
so-dockerregistry:
docker_container.running:
- 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
- networks:
- sobridge:
- ipv4_address: {{ DOCKERMERGED.containers['so-dockerregistry'].ip }}
- restart_policy: always
- port_bindings:
{% for BINDING in DOCKERMERGED.containers['so-dockerregistry'].port_bindings %}
- {{ BINDING }}
View File
+2
View File
@@ -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) %}
+2 -1
View File
@@ -3,7 +3,7 @@
{% set SCHEDULE = salt['pillar.get']('healthcheck:schedule', 30) %}
include:
- salt
- salt.minion
{% if CHECKS and ENABLED %}
salt_beacons:
@@ -23,3 +23,4 @@ salt_beacons:
- watch_in:
- service: salt_minion_service
{% endif %}
+9
View File
@@ -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
+7
View File
@@ -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
+11
View File
@@ -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 %}
+8
View File
@@ -5,3 +5,11 @@ salt_bootstrap:
- source: salt://salt/scripts/bootstrap-salt.sh
- mode: 755
- show_changes: False
salt_sbin:
file.recurse:
- name: /usr/sbin
- source: salt://salt/tools/sbin
- user: 939
- group: 939
- file_mode: 755
+1 -1
View File
@@ -1,4 +1,4 @@
lasthighstate:
file.touch:
- name: /opt/so/log/salt/lasthighstate
- order: last
- order: 9001
+17 -1
View File
@@ -10,6 +10,7 @@
# software that is protected by the license key."
{% from 'allowed_states.map.jinja' import allowed_states %}
{% from 'salt/auto_apply.map.jinja' import AUTOAPPLY %}
{% if sls in allowed_states %}
include:
@@ -63,6 +64,21 @@ engines_config:
- name: /etc/salt/master.d/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
salt_bootstrap_cloud:
file.managed:
@@ -78,7 +94,7 @@ salt_master_service:
- file: checkmine_engine
- file: pillarWatch_engine
- file: engines_config
- order: last
- order: 9002
{% else %}
-1
View File
@@ -2,4 +2,3 @@
salt:
minion:
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
+27 -2
View File
@@ -111,13 +111,17 @@ mark_setup_complete_for_upgrades:
{% 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:
service.running:
- name: salt-minion
- enable: True
- onlyif: test "{{INSTALLEDSALTVERSION}}" == "{{SALTVERSION}}"
- listen:
- watch:
- file: mine_functions
{% if INSTALLEDSALTVERSION|string == SALTVERSION|string %}
- file: set_log_levels
@@ -126,3 +130,24 @@ salt_minion_service:
- file: signing_policy
{% endif %}
- 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
+17
View File
@@ -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 %}
+2
View File
@@ -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) %}
+39
View File
@@ -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
+207
View File
@@ -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
-10
View File
@@ -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 %}
+1
View File
@@ -14,6 +14,7 @@ include:
so-sensoroni:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-soc:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- network_mode: host
- binds:
- /nsm/import:/nsm/import:rw
+1
View File
@@ -18,6 +18,7 @@ include:
so-soc:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-soc:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- hostname: soc
- name: so-soc
- networks:
+1 -1
View File
@@ -15,6 +15,7 @@ include:
strelka_backend:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-strelka-backend:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- binds:
- /opt/so/conf/strelka/backend/:/etc/strelka/:ro
- /opt/so/conf/strelka/rules/compiled/:/etc/yara/:ro
@@ -47,7 +48,6 @@ strelka_backend:
- {{ ULIMIT.name }}={{ ULIMIT.soft }}:{{ ULIMIT.hard }}
{% endfor %}
{% endif %}
- restart_policy: on-failure
- watch:
- file: strelkasensorcompiledrules
- file: backend_backend_config
+1
View File
@@ -15,6 +15,7 @@ include:
strelka_coordinator:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-redis:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- name: so-strelka-coordinator
- networks:
- sobridge:
+1
View File
@@ -15,6 +15,7 @@ include:
strelka_filestream:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-strelka-manager:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- binds:
- /opt/so/conf/strelka/filestream/:/etc/strelka/:ro
- /nsm/strelka:/nsm/strelka
+1
View File
@@ -15,6 +15,7 @@ include:
strelka_frontend:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-strelka-manager:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- binds:
- /opt/so/conf/strelka/frontend/:/etc/strelka/:ro
- /nsm/strelka/log/:/var/log/strelka/:rw
+1
View File
@@ -15,6 +15,7 @@ include:
strelka_gatekeeper:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-redis:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- name: so-strelka-gatekeeper
- networks:
- sobridge:
+1
View File
@@ -15,6 +15,7 @@ include:
strelka_manager:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-strelka-manager:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- binds:
- /opt/so/conf/strelka/manager/:/etc/strelka/:ro
{% if DOCKERMERGED.containers['so-strelka-manager'].custom_bind_mounts %}
+1
View File
@@ -17,6 +17,7 @@ include:
so-suricata:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-suricata:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- privileged: True
- environment:
- INTERFACE={{ GLOBALS.sensor.interface }}
+2 -2
View File
@@ -153,12 +153,12 @@ suricata:
cpu-affinity:
management-cpu-set:
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"
helpLink: suricata
worker-cpu-set:
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"
helpLink: suricata
vars:
+1
View File
@@ -7,6 +7,7 @@ so-tcpreplay:
docker_container.running:
- network_mode: "host"
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-tcpreplay:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- name: so-tcpreplay
- user: root
- interactive: True
+1
View File
@@ -18,6 +18,7 @@ include:
so-telegraf:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-telegraf:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- user: 939
- group_add: 939,920
- environment:
+2 -2
View File
@@ -19,7 +19,7 @@ base:
- repo.client
- versionlock
- ntp
- schedule
- salt.highstate_schedule
- logrotate
# manager node on proper salt version with empty node_data pillar
@@ -55,6 +55,7 @@ base:
- motd
- salt.minion-check
- salt.lasthighstate
- salt.push_drain_schedule
- common
- docker
- docker_clean
@@ -294,7 +295,6 @@ base:
- nginx
- elasticfleet
- elasticfleet.install_agent_grid
- schedule
- stig
'*_hypervisor and I@features:vrt and G@saltversion:{{saltversion}}':
+1
View File
@@ -16,6 +16,7 @@ include:
so-zeek:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-zeek:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- start: True
- privileged: True
{% if DOCKERMERGED.containers['so-zeek'].ulimits %}
+1 -1
View File
@@ -1435,7 +1435,7 @@ make_some_dirs() {
mkdir -p $local_salt_dir/salt/firewall/portgroups
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
touch $local_salt_dir/pillar/$THEDIR/adv_$THEDIR.sls
touch $local_salt_dir/pillar/$THEDIR/soc_$THEDIR.sls