From 613d31c8a64a90ee45635a7ceb7a4022d58c9174 Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Thu, 5 Mar 2026 11:52:09 -0500 Subject: [PATCH 01/39] merge --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 4a36342fc..03e153fda 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0 +3.0.0-kilo From a0cf0489d6b1720ecb5b7c9eb0c5e1ab517bfb9e Mon Sep 17 00:00:00 2001 From: Mike Reeves Date: Fri, 10 Apr 2026 15:43:16 -0400 Subject: [PATCH 02/39] 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 --- .../tools/sbin_jinja/so-salt-minion-check | 5 +- salt/elastalert/enabled.sls | 1 + .../enabled.sls | 1 + salt/elasticagent/enabled.sls | 1 + salt/elasticfleet/enabled.sls | 1 + salt/elasticsearch/enabled.sls | 1 + salt/global/defaults.yaml | 9 +- salt/global/soc_global.yaml | 37 +++ salt/hydra/enabled.sls | 1 + salt/idh/enabled.sls | 1 + salt/influxdb/enabled.sls | 1 + salt/kafka/enabled.sls | 1 + salt/kibana/enabled.sls | 1 + salt/kratos/enabled.sls | 1 + salt/logstash/enabled.sls | 1 + salt/manager/tools/sbin/so-push-drainer | 233 ++++++++++++++++++ salt/nginx/enabled.sls | 1 + salt/orch/push_batch.sls | 37 +++ salt/reactor/pillar_push_map.yaml | 128 ++++++++++ salt/reactor/push_pillar.sls | 170 +++++++++++++ salt/reactor/push_strelka.sls | 96 ++++++++ salt/reactor/push_suricata.sls | 95 +++++++ salt/redis/enabled.sls | 1 + salt/registry/enabled.sls | 3 + salt/salt/beacons.sls | 22 +- salt/salt/files/beacons_pushstate.conf.jinja | 26 ++ salt/salt/files/reactor_pushstate.conf | 7 + salt/salt/master.sls | 17 ++ salt/salt/minion.defaults.yaml | 1 - salt/schedule.sls | 22 +- salt/sensoroni/enabled.sls | 1 + salt/soc/enabled.sls | 1 + salt/strelka/backend/enabled.sls | 4 + salt/strelka/coordinator/enabled.sls | 1 + salt/strelka/filestream/enabled.sls | 1 + salt/strelka/frontend/enabled.sls | 1 + salt/strelka/gatekeeper/enabled.sls | 1 + salt/strelka/manager/enabled.sls | 1 + salt/suricata/enabled.sls | 1 + salt/tcpreplay/init.sls | 1 + salt/telegraf/enabled.sls | 1 + salt/zeek/enabled.sls | 1 + 42 files changed, 927 insertions(+), 10 deletions(-) create mode 100644 salt/manager/tools/sbin/so-push-drainer create mode 100644 salt/orch/push_batch.sls create mode 100644 salt/reactor/pillar_push_map.yaml create mode 100644 salt/reactor/push_pillar.sls create mode 100644 salt/reactor/push_strelka.sls create mode 100644 salt/reactor/push_suricata.sls create mode 100644 salt/salt/files/beacons_pushstate.conf.jinja create mode 100644 salt/salt/files/reactor_pushstate.conf diff --git a/salt/common/tools/sbin_jinja/so-salt-minion-check b/salt/common/tools/sbin_jinja/so-salt-minion-check index 47d3bb7e1..3b2b32afe 100755 --- a/salt/common/tools/sbin_jinja/so-salt-minion-check +++ b/salt/common/tools/sbin_jinja/so-salt-minion-check @@ -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 @@ -25,7 +23,8 @@ SYSTEM_START_TIME=$(date -d "$('.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 global:push 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()) diff --git a/salt/nginx/enabled.sls b/salt/nginx/enabled.sls index 2e4c9631c..c50ad8f8f 100644 --- a/salt/nginx/enabled.sls +++ b/salt/nginx/enabled.sls @@ -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: diff --git a/salt/orch/push_batch.sls b/salt/orch/push_batch.sls new file mode 100644 index 000000000..9eb435cce --- /dev/null +++ b/salt/orch/push_batch.sls @@ -0,0 +1,37 @@ +{% from 'global/map.jinja' import GLOBALMERGED %} +{% set actions = salt['pillar.get']('actions', []) %} +{% set BATCH = GLOBALMERGED.push.batch %} +{% set BATCH_WAIT = GLOBALMERGED.push.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 %} diff --git a/salt/reactor/pillar_push_map.yaml b/salt/reactor/pillar_push_map.yaml new file mode 100644 index 000000000..95fb0544e --- /dev/null +++ b/salt/reactor/pillar_push_map.yaml @@ -0,0 +1,128 @@ +# 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). +# +# 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). +# - `data` and `node_data` pillar dirs are intentionally omitted -- +# they're pillar-only data consumed by many states; trying to handle +# them generically would amount to a highstate. +# +# 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: '*' + +# 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: '*' + +# 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' + +# 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' + +# healthcheck: eval, sensor, standalone only. +healthcheck: + - state: healthcheck + tgt: 'G@role:so-eval or G@role:so-sensor or G@role:so-standalone' + +# 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' + +# 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' + +# 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' + +# 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' + +# 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' + +# 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: '*' + +# 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' diff --git a/salt/reactor/push_pillar.sls b/salt/reactor/push_pillar.sls new file mode 100644 index 000000000..a31fe9be4 --- /dev/null +++ b/salt/reactor/push_pillar.sls @@ -0,0 +1,170 @@ +#!py + +# Reactor invoked by the inotify beacon on pillar file changes under +# /opt/so/saltstack/local/pillar/. +# +# Two branches: +# A) per-minion override under pillar/minions/.sls or adv_.sls +# -> write an intent that runs state.highstate on just that minion. +# B) shared app pillar (pillar//...) -> look up in +# pillar_push_map.yaml and write an intent with the entry's actions. +# +# Reactors never dispatch directly. The so-push-drainer schedule picks up +# ready intents, dedupes across pending files, and dispatches orch.push_batch. +# See plan /home/mreeves/.claude/plans/goofy-marinating-hummingbird.md. + +import fcntl +import json +import logging +import os +import time + +import salt.client +import yaml + +LOG = logging.getLogger(__name__) + +PENDING_DIR = '/opt/so/state/push_pending' +LOCK_FILE = os.path.join(PENDING_DIR, '.lock') +MAX_PATHS = 20 + +PILLAR_ROOT = '/opt/so/saltstack/local/pillar/' +MINIONS_PREFIX = PILLAR_ROOT + 'minions/' + +# 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 = salt.client.Caller() + return bool(caller.cmd('pillar.get', 'global:push:enabled', True)) + except Exception: + LOG.exception('push_pillar: pillar.get global:push: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 _minion_id_from_path(path): + # path is e.g. /opt/so/saltstack/local/pillar/minions/sensor1.sls + # or /opt/so/saltstack/local/pillar/minions/adv_sensor1.sls + filename = os.path.basename(path) + if not filename.endswith('.sls'): + return None + stem = filename[:-4] + if stem.startswith('adv_'): + stem = stem[4:] + return stem or None + + +def _app_from_path(path): + # path is e.g. /opt/so/saltstack/local/pillar/zeek/soc_zeek.sls -> 'zeek' + remainder = path[len(PILLAR_ROOT):] + if '/' not in remainder: + return None + return remainder.split('/', 1)[0] or None + + +def run(): + if not _push_enabled(): + LOG.info('push_pillar: push disabled, skipping') + return {} + + path = data.get('data', {}).get('path', '') # noqa: F821 -- data provided by reactor + if not path or not path.startswith(PILLAR_ROOT): + LOG.debug('push_pillar: ignoring path outside pillar root: %s', path) + return {} + + # Branch A: per-minion override + if path.startswith(MINIONS_PREFIX): + minion_id = _minion_id_from_path(path) + if not minion_id: + LOG.debug('push_pillar: ignoring non-sls path under minions/: %s', path) + return {} + actions = [{'highstate': True, 'tgt': minion_id, 'tgt_type': 'glob'}] + _write_intent('minion_{}'.format(minion_id), actions, path) + LOG.info('push_pillar: per-minion intent updated for %s (path=%s)', minion_id, path) + return {} + + # Branch B: shared app pillar -> allowlist lookup + app = _app_from_path(path) + if not app: + LOG.debug('push_pillar: ignoring path with no app segment: %s', path) + return {} + + push_map = _load_push_map() + entry = push_map.get(app) + if not entry: + LOG.warning( + 'push_pillar: pillar dir "%s" is not in pillar_push_map.yaml; ' + 'change will be picked up at the next scheduled highstate (path=%s)', + app, path, + ) + return {} + + actions = list(entry) # copy to avoid mutating the cache + _write_intent('pillar_{}'.format(app), actions, path) + LOG.info('push_pillar: app intent updated for %s (path=%s)', app, path) + return {} diff --git a/salt/reactor/push_strelka.sls b/salt/reactor/push_strelka.sls new file mode 100644 index 000000000..b3ed30ed7 --- /dev/null +++ b/salt/reactor/push_strelka.sls @@ -0,0 +1,96 @@ +#!py + +# Reactor invoked by the inotify beacon 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 -- see plan /home/mreeves/.claude/plans/goofy-marinating-hummingbird.md. + +import fcntl +import json +import logging +import os +import time + +import salt.client + +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 = salt.client.Caller() + return bool(caller.cmd('pillar.get', 'global:push:enabled', True)) + except Exception: + LOG.exception('push_strelka: pillar.get global:push: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('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 {} diff --git a/salt/reactor/push_suricata.sls b/salt/reactor/push_suricata.sls new file mode 100644 index 000000000..c9c6eee92 --- /dev/null +++ b/salt/reactor/push_suricata.sls @@ -0,0 +1,95 @@ +#!py + +# Reactor invoked by the inotify beacon 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 -- see plan /home/mreeves/.claude/plans/goofy-marinating-hummingbird.md. + +import fcntl +import json +import logging +import os +import time + +import salt.client + +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 = salt.client.Caller() + return bool(caller.cmd('pillar.get', 'global:push:enabled', True)) + except Exception: + LOG.exception('push_suricata: pillar.get global:push: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('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 {} diff --git a/salt/redis/enabled.sls b/salt/redis/enabled.sls index 4cea8d028..1c9d1afa0 100644 --- a/salt/redis/enabled.sls +++ b/salt/redis/enabled.sls @@ -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: diff --git a/salt/registry/enabled.sls b/salt/registry/enabled.sls index fc5021910..ab09d1f99 100644 --- a/salt/registry/enabled.sls +++ b/salt/registry/enabled.sls @@ -21,6 +21,9 @@ so-dockerregistry: - networks: - sobridge: - ipv4_address: {{ DOCKERMERGED.containers['so-dockerregistry'].ip }} + # Intentionally `always` (not unless-stopped) -- registry is critical infra + # and must come back up even if it was manually stopped. Do not homogenize + # to unless-stopped; see the container auto-restart section of the plan. - restart_policy: always - port_bindings: {% for BINDING in DOCKERMERGED.containers['so-dockerregistry'].port_bindings %} diff --git a/salt/salt/beacons.sls b/salt/salt/beacons.sls index df6198d01..f3e519edd 100644 --- a/salt/salt/beacons.sls +++ b/salt/salt/beacons.sls @@ -1,3 +1,5 @@ +{% from 'vars/globals.map.jinja' import GLOBALS %} +{% from 'global/map.jinja' import GLOBALMERGED %} {% set CHECKS = salt['pillar.get']('healthcheck:checks', {}) %} {% set ENABLED = salt['pillar.get']('healthcheck:enabled', False) %} {% set SCHEDULE = salt['pillar.get']('healthcheck:schedule', 30) %} @@ -14,12 +16,28 @@ salt_beacons: - defaults: CHECKS: {{ CHECKS }} SCHEDULE: {{ SCHEDULE }} - - watch_in: + - watch_in: - service: salt_minion_service {% else %} salt_beacons: file.absent: - name: /etc/salt/minion.d/beacons.conf - - watch_in: + - watch_in: + - service: salt_minion_service +{% endif %} + +{% if GLOBALS.is_manager and GLOBALMERGED.push.enabled %} +salt_beacons_pushstate: + file.managed: + - name: /etc/salt/minion.d/beacons_pushstate.conf + - source: salt://salt/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 %} diff --git a/salt/salt/files/beacons_pushstate.conf.jinja b/salt/salt/files/beacons_pushstate.conf.jinja new file mode 100644 index 000000000..8d3f05864 --- /dev/null +++ b/salt/salt/files/beacons_pushstate.conf.jinja @@ -0,0 +1,26 @@ +beacons: + inotify: + - disable_during_state_run: True + - coalesce: True + - files: + /opt/so/saltstack/local/salt/suricata/rules/: + mask: + - close_write + - moved_to + - delete + recurse: True + auto_add: True + /opt/so/saltstack/local/salt/strelka/rules/compiled/: + mask: + - close_write + - moved_to + - delete + recurse: True + auto_add: True + /opt/so/saltstack/local/pillar/: + mask: + - close_write + - moved_to + - delete + recurse: True + auto_add: True diff --git a/salt/salt/files/reactor_pushstate.conf b/salt/salt/files/reactor_pushstate.conf new file mode 100644 index 000000000..7d3a5a0d7 --- /dev/null +++ b/salt/salt/files/reactor_pushstate.conf @@ -0,0 +1,7 @@ +reactor: + - 'salt/beacon/*/inotify//opt/so/saltstack/local/salt/suricata/rules/': + - salt://reactor/push_suricata.sls + - 'salt/beacon/*/inotify//opt/so/saltstack/local/salt/strelka/rules/compiled/': + - salt://reactor/push_strelka.sls + - 'salt/beacon/*/inotify//opt/so/saltstack/local/pillar/': + - salt://reactor/push_pillar.sls diff --git a/salt/salt/master.sls b/salt/salt/master.sls index 895150cd7..23af779dd 100644 --- a/salt/salt/master.sls +++ b/salt/salt/master.sls @@ -10,6 +10,7 @@ # software that is protected by the license key." {% from 'allowed_states.map.jinja' import allowed_states %} +{% from 'global/map.jinja' import GLOBALMERGED %} {% if sls in allowed_states %} include: @@ -62,6 +63,22 @@ engines_config: - name: /etc/salt/master.d/engines.conf - source: salt://salt/files/engines.conf +{% if GLOBALMERGED.push.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 + - order: last +{% 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: diff --git a/salt/salt/minion.defaults.yaml b/salt/salt/minion.defaults.yaml index 11f3dab41..b258e1c1e 100644 --- a/salt/salt/minion.defaults.yaml +++ b/salt/salt/minion.defaults.yaml @@ -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 diff --git a/salt/schedule.sls b/salt/schedule.sls index c3b5d85ae..014d24460 100644 --- a/salt/schedule.sls +++ b/salt/schedule.sls @@ -1,10 +1,26 @@ -{% from 'vars/globals.map.jinja' import GLOBALS %} +{% from 'vars/globals.map.jinja' import GLOBALS %} +{% from 'global/map.jinja' import GLOBALMERGED %} highstate_schedule: schedule.present: - function: state.highstate - - minutes: 15 + - hours: {{ GLOBALMERGED.push.highstate_interval_hours }} - maxrunning: 1 {% if not GLOBALS.is_manager %} - - splay: 120 + - splay: 1800 +{% endif %} + +{% if GLOBALS.is_manager and GLOBALMERGED.push.enabled %} +push_drain_schedule: + schedule.present: + - function: cmd.run + - job_args: + - /usr/sbin/so-push-drainer + - seconds: {{ GLOBALMERGED.push.drain_interval }} + - maxrunning: 1 + - return_job: False +{% elif GLOBALS.is_manager %} +push_drain_schedule: + schedule.absent: + - name: push_drain_schedule {% endif %} diff --git a/salt/sensoroni/enabled.sls b/salt/sensoroni/enabled.sls index 7790574f6..db4b91dd0 100644 --- a/salt/sensoroni/enabled.sls +++ b/salt/sensoroni/enabled.sls @@ -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 diff --git a/salt/soc/enabled.sls b/salt/soc/enabled.sls index 1805bacaf..1c736eddd 100644 --- a/salt/soc/enabled.sls +++ b/salt/soc/enabled.sls @@ -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: diff --git a/salt/strelka/backend/enabled.sls b/salt/strelka/backend/enabled.sls index ca3f0e6dc..8c71bdf68 100644 --- a/salt/strelka/backend/enabled.sls +++ b/salt/strelka/backend/enabled.sls @@ -47,6 +47,10 @@ strelka_backend: - {{ ULIMIT.name }}={{ ULIMIT.soft }}:{{ ULIMIT.hard }} {% endfor %} {% endif %} + # Intentionally `on-failure` (not unless-stopped) -- strelka backend shuts + # down cleanly during rule reloads and we do not want those clean exits to + # trigger an auto-restart. Do not homogenize; see the container + # auto-restart section of the plan. - restart_policy: on-failure - watch: - file: strelkasensorcompiledrules diff --git a/salt/strelka/coordinator/enabled.sls b/salt/strelka/coordinator/enabled.sls index 6756a324c..8393d7e68 100644 --- a/salt/strelka/coordinator/enabled.sls +++ b/salt/strelka/coordinator/enabled.sls @@ -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: diff --git a/salt/strelka/filestream/enabled.sls b/salt/strelka/filestream/enabled.sls index b03faf4b1..c01171565 100644 --- a/salt/strelka/filestream/enabled.sls +++ b/salt/strelka/filestream/enabled.sls @@ -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 diff --git a/salt/strelka/frontend/enabled.sls b/salt/strelka/frontend/enabled.sls index 58e703898..f2d0eecd1 100644 --- a/salt/strelka/frontend/enabled.sls +++ b/salt/strelka/frontend/enabled.sls @@ -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 diff --git a/salt/strelka/gatekeeper/enabled.sls b/salt/strelka/gatekeeper/enabled.sls index 45b6e467e..19d74a6d8 100644 --- a/salt/strelka/gatekeeper/enabled.sls +++ b/salt/strelka/gatekeeper/enabled.sls @@ -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: diff --git a/salt/strelka/manager/enabled.sls b/salt/strelka/manager/enabled.sls index 7c73452d8..272767928 100644 --- a/salt/strelka/manager/enabled.sls +++ b/salt/strelka/manager/enabled.sls @@ -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 %} diff --git a/salt/suricata/enabled.sls b/salt/suricata/enabled.sls index d9d7f32ae..10c04e5b9 100644 --- a/salt/suricata/enabled.sls +++ b/salt/suricata/enabled.sls @@ -18,6 +18,7 @@ so-suricata: docker_container.running: - image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-suricata:{{ GLOBALS.so_version }} - privileged: True + - restart_policy: unless-stopped - environment: - INTERFACE={{ GLOBALS.sensor.interface }} {% if DOCKERMERGED.containers['so-suricata'].extra_env %} diff --git a/salt/tcpreplay/init.sls b/salt/tcpreplay/init.sls index 7d739d00c..f5b1b05d9 100644 --- a/salt/tcpreplay/init.sls +++ b/salt/tcpreplay/init.sls @@ -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 diff --git a/salt/telegraf/enabled.sls b/salt/telegraf/enabled.sls index fc9946149..6a063e08b 100644 --- a/salt/telegraf/enabled.sls +++ b/salt/telegraf/enabled.sls @@ -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: diff --git a/salt/zeek/enabled.sls b/salt/zeek/enabled.sls index ee78714c8..355e555b3 100644 --- a/salt/zeek/enabled.sls +++ b/salt/zeek/enabled.sls @@ -18,6 +18,7 @@ so-zeek: - image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-zeek:{{ GLOBALS.so_version }} - start: True - privileged: True + - restart_policy: unless-stopped {% if DOCKERMERGED.containers['so-zeek'].ulimits %} - ulimits: {% for ULIMIT in DOCKERMERGED.containers['so-zeek'].ulimits %} From 0a8f2e01a0435eb03ba31504fab1bf73a3d5e088 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Wed, 29 Apr 2026 16:41:56 -0400 Subject: [PATCH 03/39] install pyinotify --- salt/salt/master.sls | 1 + salt/salt/master/pyinotify.sls | 20 ++++++++++++++++++ .../pyinotify/pyinotify-0.9.6.tar.gz | Bin 0 -> 60998 bytes 3 files changed, 21 insertions(+) create mode 100644 salt/salt/master/pyinotify.sls create mode 100644 salt/salt/module_packages/pyinotify/pyinotify-0.9.6.tar.gz diff --git a/salt/salt/master.sls b/salt/salt/master.sls index 23af779dd..b33d3631d 100644 --- a/salt/salt/master.sls +++ b/salt/salt/master.sls @@ -15,6 +15,7 @@ include: - salt.minion + - salt.master.pyinotify {% if 'vrt' in salt['pillar.get']('features', []) %} - salt.cloud - salt.cloud.reactor_config_hypervisor diff --git a/salt/salt/master/pyinotify.sls b/salt/salt/master/pyinotify.sls new file mode 100644 index 000000000..8aa2f1d53 --- /dev/null +++ b/salt/salt/master/pyinotify.sls @@ -0,0 +1,20 @@ +# 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. + +pyinotify_module_package: + file.recurse: + - name: /opt/so/conf/salt/module_packages/pyinotify + - source: salt://salt/module_packages/pyinotify + - clean: True + - makedirs: True + +pyinotify_python_module_install: + cmd.run: + - name: /opt/saltstack/salt/bin/python3.10 -m pip install pyinotify --no-index --find-links=/opt/so/conf/salt/module_packages/pyinotify/ --upgrade + - onchanges: + - file: pyinotify_module_package + - failhard: True + - watch_in: + - service: salt_minion_service diff --git a/salt/salt/module_packages/pyinotify/pyinotify-0.9.6.tar.gz b/salt/salt/module_packages/pyinotify/pyinotify-0.9.6.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..3150ad360fcd785b11c1d67e734fe906e973a782 GIT binary patch literal 60998 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

y>w-0k;31nP{WrocQke%$Sx(n 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 bM>38 literal 0 HcmV?d00001 From 8f0757606d2cfbe4db6a6d0eaaa283cf27bd265b Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Wed, 29 Apr 2026 16:42:19 -0400 Subject: [PATCH 04/39] include salt..minion --- salt/salt/beacons.sls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/salt/salt/beacons.sls b/salt/salt/beacons.sls index f3e519edd..bce3635f6 100644 --- a/salt/salt/beacons.sls +++ b/salt/salt/beacons.sls @@ -5,7 +5,7 @@ {% set SCHEDULE = salt['pillar.get']('healthcheck:schedule', 30) %} include: - - salt + - salt.minion {% if CHECKS and ENABLED %} salt_beacons: From f7d2994f8b66adc5def2aa72ce475ad0bbed31e6 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Thu, 30 Apr 2026 09:16:22 -0400 Subject: [PATCH 05/39] filter temp files --- salt/manager/beacons.sls | 21 ++++++++ .../files/beacons_pushstate.conf.jinja | 53 +++++++++++++++++++ salt/manager/init.sls | 2 + salt/salt/beacons.sls | 17 ------ salt/salt/files/beacons_pushstate.conf.jinja | 26 --------- 5 files changed, 76 insertions(+), 43 deletions(-) create mode 100644 salt/manager/beacons.sls create mode 100644 salt/manager/files/beacons_pushstate.conf.jinja delete mode 100644 salt/salt/files/beacons_pushstate.conf.jinja diff --git a/salt/manager/beacons.sls b/salt/manager/beacons.sls new file mode 100644 index 000000000..11574f9e2 --- /dev/null +++ b/salt/manager/beacons.sls @@ -0,0 +1,21 @@ +{% from 'vars/globals.map.jinja' import GLOBALS %} +{% from 'global/map.jinja' import GLOBALMERGED %} + +include: + - salt.minion + +{% if GLOBALS.is_manager and GLOBALMERGED.push.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 %} diff --git a/salt/manager/files/beacons_pushstate.conf.jinja b/salt/manager/files/beacons_pushstate.conf.jinja new file mode 100644 index 000000000..31a12159b --- /dev/null +++ b/salt/manager/files/beacons_pushstate.conf.jinja @@ -0,0 +1,53 @@ +beacons: + inotify: + - disable_during_state_run: True + - coalesce: True + - files: + /opt/so/saltstack/local/salt/suricata/rules/: + mask: + - close_write + - moved_to + - delete + recurse: True + auto_add: True + exclude: + - '\.sw[a-z]$': + regex: True + - '~$': + regex: True + - '/4913$': + regex: True + - '/\.#': + regex: True + /opt/so/saltstack/local/salt/strelka/rules/compiled/: + mask: + - close_write + - moved_to + - delete + recurse: True + auto_add: True + exclude: + - '\.sw[a-z]$': + regex: True + - '~$': + regex: True + - '/4913$': + regex: True + - '/\.#': + regex: True + /opt/so/saltstack/local/pillar/: + mask: + - close_write + - moved_to + - delete + recurse: True + auto_add: True + exclude: + - '\.sw[a-z]$': + regex: True + - '~$': + regex: True + - '/4913$': + regex: True + - '/\.#': + regex: True diff --git a/salt/manager/init.sls b/salt/manager/init.sls index 2353bb64b..ef2428492 100644 --- a/salt/manager/init.sls +++ b/salt/manager/init.sls @@ -15,6 +15,7 @@ include: - manager.elasticsearch - manager.kibana - manager.managed_soc_annotations + - manager.beacons repo_log_dir: file.directory: @@ -231,6 +232,7 @@ surifiltersrules: - user: 939 - group: 939 + {% else %} {{sls}}_state_not_allowed: diff --git a/salt/salt/beacons.sls b/salt/salt/beacons.sls index bce3635f6..2df927d75 100644 --- a/salt/salt/beacons.sls +++ b/salt/salt/beacons.sls @@ -1,5 +1,3 @@ -{% from 'vars/globals.map.jinja' import GLOBALS %} -{% from 'global/map.jinja' import GLOBALMERGED %} {% set CHECKS = salt['pillar.get']('healthcheck:checks', {}) %} {% set ENABLED = salt['pillar.get']('healthcheck:enabled', False) %} {% set SCHEDULE = salt['pillar.get']('healthcheck:schedule', 30) %} @@ -26,18 +24,3 @@ salt_beacons: - service: salt_minion_service {% endif %} -{% if GLOBALS.is_manager and GLOBALMERGED.push.enabled %} -salt_beacons_pushstate: - file.managed: - - name: /etc/salt/minion.d/beacons_pushstate.conf - - source: salt://salt/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 %} diff --git a/salt/salt/files/beacons_pushstate.conf.jinja b/salt/salt/files/beacons_pushstate.conf.jinja deleted file mode 100644 index 8d3f05864..000000000 --- a/salt/salt/files/beacons_pushstate.conf.jinja +++ /dev/null @@ -1,26 +0,0 @@ -beacons: - inotify: - - disable_during_state_run: True - - coalesce: True - - files: - /opt/so/saltstack/local/salt/suricata/rules/: - mask: - - close_write - - moved_to - - delete - recurse: True - auto_add: True - /opt/so/saltstack/local/salt/strelka/rules/compiled/: - mask: - - close_write - - moved_to - - delete - recurse: True - auto_add: True - /opt/so/saltstack/local/pillar/: - mask: - - close_write - - moved_to - - delete - recurse: True - auto_add: True From 0d166ef732ebe483eb6f1d58840ea7da19817588 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Thu, 30 Apr 2026 09:53:00 -0400 Subject: [PATCH 06/39] remove trailing slashes --- salt/manager/files/beacons_pushstate.conf.jinja | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/salt/manager/files/beacons_pushstate.conf.jinja b/salt/manager/files/beacons_pushstate.conf.jinja index 31a12159b..7eca37969 100644 --- a/salt/manager/files/beacons_pushstate.conf.jinja +++ b/salt/manager/files/beacons_pushstate.conf.jinja @@ -3,7 +3,7 @@ beacons: - disable_during_state_run: True - coalesce: True - files: - /opt/so/saltstack/local/salt/suricata/rules/: + /opt/so/saltstack/local/salt/suricata/rules: mask: - close_write - moved_to @@ -19,7 +19,7 @@ beacons: regex: True - '/\.#': regex: True - /opt/so/saltstack/local/salt/strelka/rules/compiled/: + /opt/so/saltstack/local/salt/strelka/rules/compiled: mask: - close_write - moved_to @@ -35,7 +35,7 @@ beacons: regex: True - '/\.#': regex: True - /opt/so/saltstack/local/pillar/: + /opt/so/saltstack/local/pillar: mask: - close_write - moved_to From 9541024eb77cd3af5f0582c03c0f419f7b0703d6 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Thu, 30 Apr 2026 15:35:24 -0400 Subject: [PATCH 07/39] fix broken things --- salt/manager/tools/sbin/so-push-drainer | 3 +-- salt/reactor/push_pillar.sls | 6 +++--- salt/reactor/push_strelka.sls | 6 +++--- salt/reactor/push_suricata.sls | 6 +++--- salt/salt/files/reactor_pushstate.conf | 12 +++++++++--- 5 files changed, 19 insertions(+), 14 deletions(-) diff --git a/salt/manager/tools/sbin/so-push-drainer b/salt/manager/tools/sbin/so-push-drainer index 3dc7bc8b3..4ce198fd0 100644 --- a/salt/manager/tools/sbin/so-push-drainer +++ b/salt/manager/tools/sbin/so-push-drainer @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/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 @@ -35,7 +35,6 @@ import subprocess import sys import time -sys.path.append('/opt/saltstack/salt/lib/python3.10/site-packages/') import salt.client PENDING_DIR = '/opt/so/state/push_pending' diff --git a/salt/reactor/push_pillar.sls b/salt/reactor/push_pillar.sls index a31fe9be4..0e7fd40eb 100644 --- a/salt/reactor/push_pillar.sls +++ b/salt/reactor/push_pillar.sls @@ -19,7 +19,7 @@ import logging import os import time -import salt.client +from salt.client import Caller import yaml LOG = logging.getLogger(__name__) @@ -57,7 +57,7 @@ def _load_push_map(): def _push_enabled(): try: - caller = salt.client.Caller() + caller = Caller() return bool(caller.cmd('pillar.get', 'global:push:enabled', True)) except Exception: LOG.exception('push_pillar: pillar.get global:push:enabled failed, assuming enabled') @@ -132,7 +132,7 @@ def run(): LOG.info('push_pillar: push disabled, skipping') return {} - path = data.get('data', {}).get('path', '') # noqa: F821 -- data provided by reactor + path = data.get('path', '') # noqa: F821 -- data provided by reactor if not path or not path.startswith(PILLAR_ROOT): LOG.debug('push_pillar: ignoring path outside pillar root: %s', path) return {} diff --git a/salt/reactor/push_strelka.sls b/salt/reactor/push_strelka.sls index b3ed30ed7..1d6a2b044 100644 --- a/salt/reactor/push_strelka.sls +++ b/salt/reactor/push_strelka.sls @@ -14,7 +14,7 @@ import logging import os import time -import salt.client +from salt.client import Caller LOG = logging.getLogger(__name__) @@ -34,7 +34,7 @@ def _sensor_compound(): def _push_enabled(): try: - caller = salt.client.Caller() + caller = Caller() return bool(caller.cmd('pillar.get', 'global:push:enabled', True)) except Exception: LOG.exception('push_strelka: pillar.get global:push:enabled failed, assuming enabled') @@ -89,7 +89,7 @@ def run(): LOG.info('push_strelka: push disabled, skipping') return {} - path = data.get('data', {}).get('path', '') # noqa: F821 -- data provided by reactor + 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) diff --git a/salt/reactor/push_suricata.sls b/salt/reactor/push_suricata.sls index c9c6eee92..468249296 100644 --- a/salt/reactor/push_suricata.sls +++ b/salt/reactor/push_suricata.sls @@ -14,7 +14,7 @@ import logging import os import time -import salt.client +from salt.client import Caller LOG = logging.getLogger(__name__) @@ -33,7 +33,7 @@ def _sensor_compound_plus_import(): def _push_enabled(): try: - caller = salt.client.Caller() + caller = Caller() return bool(caller.cmd('pillar.get', 'global:push:enabled', True)) except Exception: LOG.exception('push_suricata: pillar.get global:push:enabled failed, assuming enabled') @@ -88,7 +88,7 @@ def run(): LOG.info('push_suricata: push disabled, skipping') return {} - path = data.get('data', {}).get('path', '') # noqa: F821 -- data provided by reactor + 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) diff --git a/salt/salt/files/reactor_pushstate.conf b/salt/salt/files/reactor_pushstate.conf index 7d3a5a0d7..ceab284e2 100644 --- a/salt/salt/files/reactor_pushstate.conf +++ b/salt/salt/files/reactor_pushstate.conf @@ -1,7 +1,13 @@ reactor: - - 'salt/beacon/*/inotify//opt/so/saltstack/local/salt/suricata/rules/': + - 'salt/beacon/*/inotify//opt/so/saltstack/local/salt/suricata/rules': - salt://reactor/push_suricata.sls - - 'salt/beacon/*/inotify//opt/so/saltstack/local/salt/strelka/rules/compiled/': + - 'salt/beacon/*/inotify//opt/so/saltstack/local/salt/suricata/rules/*': + - salt://reactor/push_suricata.sls + - 'salt/beacon/*/inotify//opt/so/saltstack/local/salt/strelka/rules/compiled': - salt://reactor/push_strelka.sls - - 'salt/beacon/*/inotify//opt/so/saltstack/local/pillar/': + - 'salt/beacon/*/inotify//opt/so/saltstack/local/salt/strelka/rules/compiled/*': + - salt://reactor/push_strelka.sls + - 'salt/beacon/*/inotify//opt/so/saltstack/local/pillar': + - salt://reactor/push_pillar.sls + - 'salt/beacon/*/inotify//opt/so/saltstack/local/pillar/*': - salt://reactor/push_pillar.sls From 7fcace34c4b6fb35fa6219b0e0b26d1d020105e9 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Thu, 30 Apr 2026 16:09:08 -0400 Subject: [PATCH 08/39] add sensoroni to push map --- salt/reactor/pillar_push_map.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/salt/reactor/pillar_push_map.yaml b/salt/reactor/pillar_push_map.yaml index 95fb0544e..7a1917eb2 100644 --- a/salt/reactor/pillar_push_map.yaml +++ b/salt/reactor/pillar_push_map.yaml @@ -98,6 +98,11 @@ 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' +# sensoroni: universal. +sensoroni: + - state: sensoroni + tgt: '*' + # soc: manager_roles exactly. soc: - state: soc From 932deab751b07aceacb500c304da575b68f5cd37 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Thu, 7 May 2026 10:51:53 -0400 Subject: [PATCH 09/39] update the push map --- salt/reactor/pillar_push_map.yaml | 113 +++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 3 deletions(-) diff --git a/salt/reactor/pillar_push_map.yaml b/salt/reactor/pillar_push_map.yaml index 7a1917eb2..36699d3de 100644 --- a/salt/reactor/pillar_push_map.yaml +++ b/salt/reactor/pillar_push_map.yaml @@ -1,6 +1,8 @@ # 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 @@ -8,9 +10,19 @@ # - 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). -# - `data` and `node_data` pillar dirs are intentionally omitted -- -# they're pillar-only data consumed by many states; trying to handle -# them generically would amount to a highstate. +# +# 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/.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 @@ -28,6 +40,13 @@ 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: @@ -41,11 +60,22 @@ 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 @@ -62,11 +92,29 @@ 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 @@ -82,22 +130,61 @@ 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' + # sensoroni: universal. sensoroni: - state: sensoroni @@ -108,6 +195,14 @@ 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 @@ -127,6 +222,18 @@ 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@::). +# 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 From 778cc055eaa7a5948d51b0e440f959ebb0228672 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Thu, 7 May 2026 17:01:20 -0400 Subject: [PATCH 10/39] wait for salt-minion service to be ready before finishing state run --- salt/docker_clean/init.sls | 2 +- salt/salt/lasthighstate.sls | 2 +- salt/salt/master.sls | 3 +-- salt/salt/minion/init.sls | 37 +++++++++++++++++++++++++++++++++++-- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/salt/docker_clean/init.sls b/salt/docker_clean/init.sls index ee60f5591..86dc19a8f 100644 --- a/salt/docker_clean/init.sls +++ b/salt/docker_clean/init.sls @@ -9,7 +9,7 @@ prune_images: cmd.run: - name: so-docker-prune - - order: last + - order: 9000 {% else %} diff --git a/salt/salt/lasthighstate.sls b/salt/salt/lasthighstate.sls index 606bd1082..88d060483 100644 --- a/salt/salt/lasthighstate.sls +++ b/salt/salt/lasthighstate.sls @@ -1,4 +1,4 @@ lasthighstate: file.touch: - name: /opt/so/log/salt/lasthighstate - - order: last \ No newline at end of file + - order: 9001 diff --git a/salt/salt/master.sls b/salt/salt/master.sls index b33d3631d..3833232fd 100644 --- a/salt/salt/master.sls +++ b/salt/salt/master.sls @@ -71,7 +71,6 @@ reactor_pushstate_config: - source: salt://salt/files/reactor_pushstate.conf - watch_in: - service: salt_master_service - - order: last {% else %} reactor_pushstate_config: file.absent: @@ -95,7 +94,7 @@ salt_master_service: - file: checkmine_engine - file: pillarWatch_engine - file: engines_config - - order: last + - order: 9002 {% else %} diff --git a/salt/salt/minion/init.sls b/salt/salt/minion/init.sls index eb7018aed..42f98de2a 100644 --- a/salt/salt/minion/init.sls +++ b/salt/salt/minion/init.sls @@ -88,13 +88,17 @@ enable_startup_states: {% endif %} -# this has to be outside the if statement above since there are _in calls to this state +# this has to be outside the if statement above since there are _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 @@ -103,3 +107,32 @@ salt_minion_service: - file: signing_policy {% endif %} - order: last + +# block until the just-restarted salt-minion is back and can execute modules locally, 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. initial sleep gives the +# systemctl restart (--no-block by default for salt-minion on >=3006.15) time to begin +# tearing down the old process before we probe for readiness. +wait_for_salt_minion_ready: + cmd.run: + - name: | + sleep 3 + timeout=120 + elapsed=3 + while [ $elapsed -lt $timeout ]; do + if systemctl is-active --quiet salt-minion \ + && salt-call --local --timeout=5 --out=quiet test.ping >/dev/null 2>&1; then + echo "salt-minion ready after ${elapsed}s" + exit 0 + fi + sleep 1 + elapsed=$((elapsed+1)) + done + echo "salt-minion did not become ready within ${timeout}s" >&2 + exit 1 + - onchanges: + - service: salt_minion_service + - require: + - service: salt_minion_service + - order: last From 66c0a662fc71a9738d16ce584c263b5d05daa427 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Fri, 8 May 2026 09:26:42 -0400 Subject: [PATCH 11/39] convert wait to script --- salt/common/tools/sbin/so-salt-minion-wait | 35 ++++++++++++++++++++++ salt/salt/minion/init.sls | 21 ++----------- 2 files changed, 38 insertions(+), 18 deletions(-) create mode 100644 salt/common/tools/sbin/so-salt-minion-wait diff --git a/salt/common/tools/sbin/so-salt-minion-wait b/salt/common/tools/sbin/so-salt-minion-wait new file mode 100644 index 000000000..a30c67e80 --- /dev/null +++ b/salt/common/tools/sbin/so-salt-minion-wait @@ -0,0 +1,35 @@ +#!/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 local salt-minion service is back up and can execute modules locally. +# Invoked from the wait_for_salt_minion_ready state in salt/minion/init.sls after +# salt_minion_service fires its watch-driven mod_watch (a non-blocking systemctl restart), +# so follow-on jobs and the next highstate iteration do not race the in-flight restart. + +. /usr/sbin/so-common + +# Initial sleep gives the systemctl restart (--no-block by default for salt-minion on +# >=3006.15) time to begin tearing down the old process before we probe for readiness. +INITIAL_SLEEP=3 +TIMEOUT=120 +PING_TIMEOUT=5 + +sleep "$INITIAL_SLEEP" + +elapsed="$INITIAL_SLEEP" +while [ "$elapsed" -lt "$TIMEOUT" ]; do + if systemctl is-active --quiet salt-minion \ + && salt-call --local --timeout="$PING_TIMEOUT" --out=quiet test.ping >/dev/null 2>&1; then + echo "salt-minion ready after ${elapsed}s" + exit 0 + fi + sleep 1 + elapsed=$((elapsed + 1)) +done + +echo "salt-minion did not become ready within ${TIMEOUT}s" >&2 +exit 1 diff --git a/salt/salt/minion/init.sls b/salt/salt/minion/init.sls index 42f98de2a..01c24e698 100644 --- a/salt/salt/minion/init.sls +++ b/salt/salt/minion/init.sls @@ -111,26 +111,11 @@ salt_minion_service: # block until the just-restarted salt-minion is back and can execute modules locally, 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. initial sleep gives the -# systemctl restart (--no-block by default for salt-minion on >=3006.15) time to begin -# tearing down the old process before we probe for readiness. +# mod_watch results replace the service state's running entry. wait logic lives in +# /usr/sbin/so-salt-minion-wait (deployed by common_sbin from common/tools/sbin/). wait_for_salt_minion_ready: cmd.run: - - name: | - sleep 3 - timeout=120 - elapsed=3 - while [ $elapsed -lt $timeout ]; do - if systemctl is-active --quiet salt-minion \ - && salt-call --local --timeout=5 --out=quiet test.ping >/dev/null 2>&1; then - echo "salt-minion ready after ${elapsed}s" - exit 0 - fi - sleep 1 - elapsed=$((elapsed+1)) - done - echo "salt-minion did not become ready within ${timeout}s" >&2 - exit 1 + - name: /usr/sbin/so-salt-minion-wait - onchanges: - service: salt_minion_service - require: From 7d4d6a075619a699833cb6693d13f0f9732d0018 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Fri, 8 May 2026 10:13:15 -0400 Subject: [PATCH 12/39] prune images if so-docker-prune exists --- salt/docker_clean/init.sls | 1 + 1 file changed, 1 insertion(+) diff --git a/salt/docker_clean/init.sls b/salt/docker_clean/init.sls index 86dc19a8f..65f43b1ef 100644 --- a/salt/docker_clean/init.sls +++ b/salt/docker_clean/init.sls @@ -9,6 +9,7 @@ prune_images: cmd.run: - name: so-docker-prune + - onlyif: command -v /usr/sbin/so-docker-prune >/dev/null 2>&1 - order: 9000 {% else %} From f54939b444313ab9a68839e010bfa78c5602562f Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Fri, 29 May 2026 14:38:59 -0400 Subject: [PATCH 13/39] 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. --- salt/_beacons/pillar_db.py | 142 ++++++++++++++++++ .../files/beacons_pushstate.conf.jinja | 20 +-- salt/reactor/push_pillar.sls | 104 +++++++------ salt/salt/files/reactor_pushstate.conf | 4 +- 4 files changed, 202 insertions(+), 68 deletions(-) create mode 100644 salt/_beacons/pillar_db.py diff --git a/salt/_beacons/pillar_db.py b/salt/_beacons/pillar_db.py new file mode 100644 index 000000000..9022d9b87 --- /dev/null +++ b/salt/_beacons/pillar_db.py @@ -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 so_soc.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/pillar_db_watch.id' +CONTAINER = 'so-postgres' +DATABASE = 'so_soc' + +# 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('pillar_db beacon: failed to persist watermark to %s', WATERMARK_FILE) + + +def _query(sql): + # Run a query against so_soc 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('pillar_db beacon: psql timed out') + return None + except Exception: + log.exception('pillar_db beacon: failed to exec psql') + return None + if result.returncode != 0: + log.warning('pillar_db beacon: psql failed (rc=%s): %s', + result.returncode, (result.stderr or '').strip()) + return None + return result.stdout + + +def beacon(config): + 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('pillar_db 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('pillar_db beacon: skipping malformed row: %r', line) + continue + try: + row_id = int(parts[0]) + except ValueError: + log.warning('pillar_db 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('pillar_db beacon: emitted %d change(s), watermark %d -> %d', + len(retval), watermark, max_id) + + return retval diff --git a/salt/manager/files/beacons_pushstate.conf.jinja b/salt/manager/files/beacons_pushstate.conf.jinja index 7eca37969..ce9259359 100644 --- a/salt/manager/files/beacons_pushstate.conf.jinja +++ b/salt/manager/files/beacons_pushstate.conf.jinja @@ -1,4 +1,8 @@ +{% from 'global/map.jinja' import GLOBALMERGED %} beacons: + pillar_db: + - interval: {{ GLOBALMERGED.push.drain_interval }} + - disable_during_state_run: True inotify: - disable_during_state_run: True - coalesce: True @@ -35,19 +39,3 @@ beacons: regex: True - '/\.#': regex: True - /opt/so/saltstack/local/pillar: - mask: - - close_write - - moved_to - - delete - recurse: True - auto_add: True - exclude: - - '\.sw[a-z]$': - regex: True - - '~$': - regex: True - - '/4913$': - regex: True - - '/\.#': - regex: True diff --git a/salt/reactor/push_pillar.sls b/salt/reactor/push_pillar.sls index 0e7fd40eb..06ee474df 100644 --- a/salt/reactor/push_pillar.sls +++ b/salt/reactor/push_pillar.sls @@ -1,17 +1,21 @@ #!py -# Reactor invoked by the inotify beacon on pillar file changes under -# /opt/so/saltstack/local/pillar/. +# Reactor invoked by the pillar_db beacon when SOC records settings changes in +# the so_soc.audit_settings table (see salt/_beacons/pillar_db.py). The beacon +# emits one event per new row carrying setting_id and node_id. # -# Two branches: -# A) per-minion override under pillar/minions/.sls or adv_.sls -# -> write an intent that runs state.highstate on just that minion. -# B) shared app pillar (pillar//...) -> look up in -# pillar_push_map.yaml and write an intent with the entry's actions. +# 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. -# See plan /home/mreeves/.claude/plans/goofy-marinating-hummingbird.md. import fcntl import json @@ -28,9 +32,6 @@ PENDING_DIR = '/opt/so/state/push_pending' LOCK_FILE = os.path.join(PENDING_DIR, '.lock') MAX_PATHS = 20 -PILLAR_ROOT = '/opt/so/saltstack/local/pillar/' -MINIONS_PREFIX = PILLAR_ROOT + 'minions/' - # 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' @@ -107,24 +108,25 @@ def _write_intent(key, actions, path): os.close(lock_fd) -def _minion_id_from_path(path): - # path is e.g. /opt/so/saltstack/local/pillar/minions/sensor1.sls - # or /opt/so/saltstack/local/pillar/minions/adv_sensor1.sls - filename = os.path.basename(path) - if not filename.endswith('.sls'): +def _app_from_setting(setting_id): + # setting_id is e.g. 'telegraf.output' -> 'telegraf', 'ntp.config.servers' -> 'ntp' + if not setting_id: return None - stem = filename[:-4] - if stem.startswith('adv_'): - stem = stem[4:] - return stem or None + return setting_id.split('.', 1)[0] or None -def _app_from_path(path): - # path is e.g. /opt/so/saltstack/local/pillar/zeek/soc_zeek.sls -> 'zeek' - remainder = path[len(PILLAR_ROOT):] - if '/' not in remainder: - return None - return remainder.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(): @@ -132,39 +134,43 @@ def run(): LOG.info('push_pillar: push disabled, skipping') return {} - path = data.get('path', '') # noqa: F821 -- data provided by reactor - if not path or not path.startswith(PILLAR_ROOT): - LOG.debug('push_pillar: ignoring path outside pillar root: %s', path) - return {} + # The pillar_db 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() - # Branch A: per-minion override - if path.startswith(MINIONS_PREFIX): - minion_id = _minion_id_from_path(path) - if not minion_id: - LOG.debug('push_pillar: ignoring non-sls path under minions/: %s', path) - return {} - actions = [{'highstate': True, 'tgt': minion_id, 'tgt_type': 'glob'}] - _write_intent('minion_{}'.format(minion_id), actions, path) - LOG.info('push_pillar: per-minion intent updated for %s (path=%s)', minion_id, path) - return {} - - # Branch B: shared app pillar -> allowlist lookup - app = _app_from_path(path) + app = _app_from_setting(setting_id) if not app: - LOG.debug('push_pillar: ignoring path with no app segment: %s', path) + 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: pillar dir "%s" is not in pillar_push_map.yaml; ' - 'change will be picked up at the next scheduled highstate (path=%s)', - app, path, + '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, path) - LOG.info('push_pillar: app intent updated for %s (path=%s)', app, path) + _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 {} diff --git a/salt/salt/files/reactor_pushstate.conf b/salt/salt/files/reactor_pushstate.conf index ceab284e2..991c4d516 100644 --- a/salt/salt/files/reactor_pushstate.conf +++ b/salt/salt/files/reactor_pushstate.conf @@ -7,7 +7,5 @@ reactor: - salt://reactor/push_strelka.sls - 'salt/beacon/*/inotify//opt/so/saltstack/local/salt/strelka/rules/compiled/*': - salt://reactor/push_strelka.sls - - 'salt/beacon/*/inotify//opt/so/saltstack/local/pillar': - - salt://reactor/push_pillar.sls - - 'salt/beacon/*/inotify//opt/so/saltstack/local/pillar/*': + - 'salt/beacon/*/pillar_db/audit_settings': - salt://reactor/push_pillar.sls From 8c17ae0f66d74f3fce326602edcd85bf74562897 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Mon, 1 Jun 2026 14:48:54 -0400 Subject: [PATCH 14/39] move so-salt-minion-wait --- salt/salt/init.sls | 8 ++++++++ salt/{common => salt}/tools/sbin/so-salt-minion-wait | 0 2 files changed, 8 insertions(+) rename salt/{common => salt}/tools/sbin/so-salt-minion-wait (100%) diff --git a/salt/salt/init.sls b/salt/salt/init.sls index cea67f46a..d39c984eb 100644 --- a/salt/salt/init.sls +++ b/salt/salt/init.sls @@ -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 diff --git a/salt/common/tools/sbin/so-salt-minion-wait b/salt/salt/tools/sbin/so-salt-minion-wait similarity index 100% rename from salt/common/tools/sbin/so-salt-minion-wait rename to salt/salt/tools/sbin/so-salt-minion-wait From dfdb1fbaebe7689fdcaf8dab54e51080e55b69ea Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Wed, 24 Jun 2026 15:17:48 -0400 Subject: [PATCH 15/39] 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. --- pillar/top.sls | 2 + salt/global/defaults.yaml | 7 ---- salt/global/soc_global.yaml | 37 ------------------ salt/manager/beacons.sls | 4 +- .../files/beacons_pushstate.conf.jinja | 4 +- salt/manager/tools/sbin/so-push-drainer | 6 +-- salt/manager/tools/sbin/soup | 17 +++++++++ salt/orch/push_batch.sls | 6 +-- salt/reactor/pillar_push_map.yaml | 11 ++++++ salt/reactor/push_pillar.sls | 4 +- salt/reactor/push_strelka.sls | 4 +- salt/reactor/push_suricata.sls | 4 +- salt/salt/adv_salt.yaml | 0 salt/salt/auto_apply.map.jinja | 2 + salt/salt/defaults.yaml | 8 ++++ salt/salt/master.sls | 4 +- salt/salt/soc_salt.yaml | 38 +++++++++++++++++++ salt/schedule.sls | 8 ++-- setup/so-functions | 2 +- 19 files changed, 101 insertions(+), 67 deletions(-) create mode 100644 salt/salt/adv_salt.yaml create mode 100644 salt/salt/auto_apply.map.jinja create mode 100644 salt/salt/defaults.yaml create mode 100644 salt/salt/soc_salt.yaml diff --git a/pillar/top.sls b/pillar/top.sls index 712629dbf..aba3ef4c5 100644 --- a/pillar/top.sls +++ b/pillar/top.sls @@ -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 diff --git a/salt/global/defaults.yaml b/salt/global/defaults.yaml index 23cfb1454..60f7885ac 100644 --- a/salt/global/defaults.yaml +++ b/salt/global/defaults.yaml @@ -1,10 +1,3 @@ global: pcapengine: SURICATA pipeline: REDIS - push: - enabled: true - highstate_interval_hours: 2 - debounce_seconds: 30 - drain_interval: 15 - batch: '25%' - batch_wait: 15 diff --git a/salt/global/soc_global.yaml b/salt/global/soc_global.yaml index c46102fe7..c15f3eb98 100644 --- a/salt/global/soc_global.yaml +++ b/salt/global/soc_global.yaml @@ -59,41 +59,4 @@ global: description: Allows use of Endgame with Security Onion. This feature requires a license from Endgame. global: True advanced: True - push: - 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 - 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 - 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 diff --git a/salt/manager/beacons.sls b/salt/manager/beacons.sls index 11574f9e2..b04017140 100644 --- a/salt/manager/beacons.sls +++ b/salt/manager/beacons.sls @@ -1,10 +1,10 @@ {% from 'vars/globals.map.jinja' import GLOBALS %} -{% from 'global/map.jinja' import GLOBALMERGED %} +{% from 'salt/auto_apply.map.jinja' import AUTOAPPLY %} include: - salt.minion -{% if GLOBALS.is_manager and GLOBALMERGED.push.enabled %} +{% if GLOBALS.is_manager and AUTOAPPLY.enabled %} salt_beacons_pushstate: file.managed: - name: /etc/salt/minion.d/beacons_pushstate.conf diff --git a/salt/manager/files/beacons_pushstate.conf.jinja b/salt/manager/files/beacons_pushstate.conf.jinja index ce9259359..9f45d5d85 100644 --- a/salt/manager/files/beacons_pushstate.conf.jinja +++ b/salt/manager/files/beacons_pushstate.conf.jinja @@ -1,7 +1,7 @@ -{% from 'global/map.jinja' import GLOBALMERGED %} +{% from 'salt/auto_apply.map.jinja' import AUTOAPPLY %} beacons: pillar_db: - - interval: {{ GLOBALMERGED.push.drain_interval }} + - interval: {{ AUTOAPPLY.drain_interval }} - disable_during_state_run: True inotify: - disable_during_state_run: True diff --git a/salt/manager/tools/sbin/so-push-drainer b/salt/manager/tools/sbin/so-push-drainer index 4ce198fd0..fe25d31fc 100644 --- a/salt/manager/tools/sbin/so-push-drainer +++ b/salt/manager/tools/sbin/so-push-drainer @@ -60,9 +60,9 @@ def _make_logger(): def _load_push_cfg(): - """Read the global:push pillar subtree via salt-call. Returns a dict.""" + """Read the salt:auto_apply pillar subtree via salt-call. Returns a dict.""" caller = salt.client.Caller() - cfg = caller.cmd('pillar.get', 'global:push', {}) + cfg = caller.cmd('pillar.get', 'salt:auto_apply', {}) return cfg if isinstance(cfg, dict) else {} @@ -135,7 +135,7 @@ def main(): try: push = _load_push_cfg() except Exception: - log.exception('failed to read global:push pillar; aborting drain pass') + log.exception('failed to read salt:auto_apply pillar; aborting drain pass') return 1 if not push.get('enabled', True): diff --git a/salt/manager/tools/sbin/soup b/salt/manager/tools/sbin/soup index 2b8680191..e667e6448 100755 --- a/salt/manager/tools/sbin/soup +++ b/salt/manager/tools/sbin/soup @@ -690,6 +690,21 @@ ensure_postgres_local_pillar() { chown -R socore:socore "$dir" } +ensure_salt_local_pillar() { + # The salt.auto_apply settings (moved from global.push) 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 @@ -851,6 +866,8 @@ kibana_backport_streams_index_template() { } up_to_3.2.0() { + ensure_salt_local_pillar + fix_logstash_0013_lumberjack_pipeline_name pin_elasticsearch_data_retention_method diff --git a/salt/orch/push_batch.sls b/salt/orch/push_batch.sls index 9eb435cce..ba8676961 100644 --- a/salt/orch/push_batch.sls +++ b/salt/orch/push_batch.sls @@ -1,7 +1,7 @@ -{% from 'global/map.jinja' import GLOBALMERGED %} +{% from 'salt/auto_apply.map.jinja' import AUTOAPPLY %} {% set actions = salt['pillar.get']('actions', []) %} -{% set BATCH = GLOBALMERGED.push.batch %} -{% set BATCH_WAIT = GLOBALMERGED.push.batch_wait %} +{% set BATCH = AUTOAPPLY.batch %} +{% set BATCH_WAIT = AUTOAPPLY.batch_wait %} {% for action in actions %} {% if action.get('highstate') %} diff --git a/salt/reactor/pillar_push_map.yaml b/salt/reactor/pillar_push_map.yaml index 36699d3de..51ad1db81 100644 --- a/salt/reactor/pillar_push_map.yaml +++ b/salt/reactor/pillar_push_map.yaml @@ -185,6 +185,17 @@ 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 the +# per-minion highstate schedule; 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 edits -- salt version bumps go through soup, not +# SOC, so they never reach this map. +salt: + - highstate: True + tgt: '*' + # sensoroni: universal. sensoroni: - state: sensoroni diff --git a/salt/reactor/push_pillar.sls b/salt/reactor/push_pillar.sls index 06ee474df..47e586788 100644 --- a/salt/reactor/push_pillar.sls +++ b/salt/reactor/push_pillar.sls @@ -59,9 +59,9 @@ def _load_push_map(): def _push_enabled(): try: caller = Caller() - return bool(caller.cmd('pillar.get', 'global:push:enabled', True)) + return bool(caller.cmd('pillar.get', 'salt:auto_apply:enabled', True)) except Exception: - LOG.exception('push_pillar: pillar.get global:push:enabled failed, assuming enabled') + LOG.exception('push_pillar: pillar.get salt:auto_apply:enabled failed, assuming enabled') return True diff --git a/salt/reactor/push_strelka.sls b/salt/reactor/push_strelka.sls index 1d6a2b044..21727bc91 100644 --- a/salt/reactor/push_strelka.sls +++ b/salt/reactor/push_strelka.sls @@ -35,9 +35,9 @@ def _sensor_compound(): def _push_enabled(): try: caller = Caller() - return bool(caller.cmd('pillar.get', 'global:push:enabled', True)) + return bool(caller.cmd('pillar.get', 'salt:auto_apply:enabled', True)) except Exception: - LOG.exception('push_strelka: pillar.get global:push:enabled failed, assuming enabled') + LOG.exception('push_strelka: pillar.get salt:auto_apply:enabled failed, assuming enabled') return True diff --git a/salt/reactor/push_suricata.sls b/salt/reactor/push_suricata.sls index 468249296..53900e469 100644 --- a/salt/reactor/push_suricata.sls +++ b/salt/reactor/push_suricata.sls @@ -34,9 +34,9 @@ def _sensor_compound_plus_import(): def _push_enabled(): try: caller = Caller() - return bool(caller.cmd('pillar.get', 'global:push:enabled', True)) + return bool(caller.cmd('pillar.get', 'salt:auto_apply:enabled', True)) except Exception: - LOG.exception('push_suricata: pillar.get global:push:enabled failed, assuming enabled') + LOG.exception('push_suricata: pillar.get salt:auto_apply:enabled failed, assuming enabled') return True diff --git a/salt/salt/adv_salt.yaml b/salt/salt/adv_salt.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/salt/salt/auto_apply.map.jinja b/salt/salt/auto_apply.map.jinja new file mode 100644 index 000000000..6a353f4f8 --- /dev/null +++ b/salt/salt/auto_apply.map.jinja @@ -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) %} diff --git a/salt/salt/defaults.yaml b/salt/salt/defaults.yaml new file mode 100644 index 000000000..7dcbf0ff8 --- /dev/null +++ b/salt/salt/defaults.yaml @@ -0,0 +1,8 @@ +salt: + auto_apply: + enabled: true + highstate_interval_hours: 2 + debounce_seconds: 30 + drain_interval: 15 + batch: '25%' + batch_wait: 15 diff --git a/salt/salt/master.sls b/salt/salt/master.sls index 273e97e84..bb39f1395 100644 --- a/salt/salt/master.sls +++ b/salt/salt/master.sls @@ -10,7 +10,7 @@ # software that is protected by the license key." {% from 'allowed_states.map.jinja' import allowed_states %} -{% from 'global/map.jinja' import GLOBALMERGED %} +{% from 'salt/auto_apply.map.jinja' import AUTOAPPLY %} {% if sls in allowed_states %} include: @@ -65,7 +65,7 @@ engines_config: - name: /etc/salt/master.d/engines.conf - source: salt://salt/files/engines.conf -{% if GLOBALMERGED.push.enabled %} +{% if AUTOAPPLY.enabled %} reactor_pushstate_config: file.managed: - name: /etc/salt/master.d/reactor_pushstate.conf diff --git a/salt/salt/soc_salt.yaml b/salt/salt/soc_salt.yaml new file mode 100644 index 000000000..530c5ab79 --- /dev/null +++ b/salt/salt/soc_salt.yaml @@ -0,0 +1,38 @@ +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 + 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 + 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 diff --git a/salt/schedule.sls b/salt/schedule.sls index 014d24460..0b71dc65f 100644 --- a/salt/schedule.sls +++ b/salt/schedule.sls @@ -1,22 +1,22 @@ {% from 'vars/globals.map.jinja' import GLOBALS %} -{% from 'global/map.jinja' import GLOBALMERGED %} +{% from 'salt/auto_apply.map.jinja' import AUTOAPPLY %} highstate_schedule: schedule.present: - function: state.highstate - - hours: {{ GLOBALMERGED.push.highstate_interval_hours }} + - hours: {{ AUTOAPPLY.highstate_interval_hours }} - maxrunning: 1 {% if not GLOBALS.is_manager %} - splay: 1800 {% endif %} -{% if GLOBALS.is_manager and GLOBALMERGED.push.enabled %} +{% if GLOBALS.is_manager and AUTOAPPLY.enabled %} push_drain_schedule: schedule.present: - function: cmd.run - job_args: - /usr/sbin/so-push-drainer - - seconds: {{ GLOBALMERGED.push.drain_interval }} + - seconds: {{ AUTOAPPLY.drain_interval }} - maxrunning: 1 - return_job: False {% elif GLOBALS.is_manager %} diff --git a/setup/so-functions b/setup/so-functions index 2d5181dc1..8859aef97 100755 --- a/setup/so-functions +++ b/setup/so-functions @@ -1432,7 +1432,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 From b09c3776b7c2a780ea544e50377644cfca41abec Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Wed, 24 Jun 2026 16:51:32 -0400 Subject: [PATCH 16/39] 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. --- salt/_beacons/pillar_db.py | 6 +++--- salt/reactor/push_pillar.sls | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/salt/_beacons/pillar_db.py b/salt/_beacons/pillar_db.py index 9022d9b87..8712cce7f 100644 --- a/salt/_beacons/pillar_db.py +++ b/salt/_beacons/pillar_db.py @@ -6,7 +6,7 @@ # 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 so_soc.audit_settings table that SOC writes to. +# 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, @@ -24,7 +24,7 @@ log = logging.getLogger(__name__) WATERMARK_FILE = '/opt/so/state/pillar_db_watch.id' CONTAINER = 'so-postgres' -DATABASE = 'so_soc' +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. @@ -60,7 +60,7 @@ def _write_watermark(value): def _query(sql): - # Run a query against so_soc inside the so-postgres container over the unix + # 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 = [ diff --git a/salt/reactor/push_pillar.sls b/salt/reactor/push_pillar.sls index 47e586788..c4b82959b 100644 --- a/salt/reactor/push_pillar.sls +++ b/salt/reactor/push_pillar.sls @@ -1,7 +1,7 @@ #!py # Reactor invoked by the pillar_db beacon when SOC records settings changes in -# the so_soc.audit_settings table (see salt/_beacons/pillar_db.py). The beacon +# the securityonion.audit_settings table (see salt/_beacons/pillar_db.py). The beacon # emits one event per new row carrying setting_id and node_id. # # Two branches, keyed on node_id: From 3effdbc91ed33ef95136834e0af7cad146ac30ed Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Thu, 25 Jun 2026 11:36:52 -0400 Subject: [PATCH 17/39] do not disable during state run --- salt/manager/files/beacons_pushstate.conf.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/salt/manager/files/beacons_pushstate.conf.jinja b/salt/manager/files/beacons_pushstate.conf.jinja index 9f45d5d85..05d739dd4 100644 --- a/salt/manager/files/beacons_pushstate.conf.jinja +++ b/salt/manager/files/beacons_pushstate.conf.jinja @@ -4,7 +4,7 @@ beacons: - interval: {{ AUTOAPPLY.drain_interval }} - disable_during_state_run: True inotify: - - disable_during_state_run: True + - disable_during_state_run: False - coalesce: True - files: /opt/so/saltstack/local/salt/suricata/rules: From 5bf9751adf5e1aacdee0a11a0f99402805c4acba Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Thu, 25 Jun 2026 11:44:38 -0400 Subject: [PATCH 18/39] do not disable during state run --- salt/manager/files/beacons_pushstate.conf.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/salt/manager/files/beacons_pushstate.conf.jinja b/salt/manager/files/beacons_pushstate.conf.jinja index 05d739dd4..4de5ef5e0 100644 --- a/salt/manager/files/beacons_pushstate.conf.jinja +++ b/salt/manager/files/beacons_pushstate.conf.jinja @@ -2,7 +2,7 @@ beacons: pillar_db: - interval: {{ AUTOAPPLY.drain_interval }} - - disable_during_state_run: True + - disable_during_state_run: False inotify: - disable_during_state_run: False - coalesce: True From da94788255407268b2edbfcae0894e20d185b7bd Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Fri, 26 Jun 2026 10:51:41 -0400 Subject: [PATCH 19/39] 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. --- salt/common/tools/sbin_jinja/so-salt-minion-check | 6 +++--- salt/manager/tools/sbin/so-push-drainer | 2 +- salt/reactor/pillar_push_map.yaml | 12 ++++++------ salt/salt/defaults.yaml | 3 ++- salt/salt/highstate_schedule.sls | 11 +++++++++++ salt/{schedule.sls => salt/push_drain_schedule.sls} | 9 --------- salt/salt/schedule.map.jinja | 2 ++ salt/salt/soc_salt.yaml | 13 +++++++------ salt/top.sls | 4 ++-- 9 files changed, 34 insertions(+), 28 deletions(-) create mode 100644 salt/salt/highstate_schedule.sls rename salt/{schedule.sls => salt/push_drain_schedule.sls} (69%) create mode 100644 salt/salt/schedule.map.jinja diff --git a/salt/common/tools/sbin_jinja/so-salt-minion-check b/salt/common/tools/sbin_jinja/so-salt-minion-check index 3b2b32afe..1376193cc 100755 --- a/salt/common/tools/sbin_jinja/so-salt-minion-check +++ b/salt/common/tools/sbin_jinja/so-salt-minion-check @@ -5,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. @@ -23,8 +23,8 @@ SYSTEM_START_TIME=$(date -d "$( Date: Fri, 26 Jun 2026 15:40:32 -0400 Subject: [PATCH 20/39] 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']). --- salt/_beacons/rules_db.py | 139 ++++++++++++++++++ .../files/beacons_pushstate.conf.jinja | 40 +---- salt/reactor/push_strelka.sls | 4 +- salt/reactor/push_suricata.sls | 4 +- salt/salt/files/reactor_pushstate.conf | 8 +- 5 files changed, 150 insertions(+), 45 deletions(-) create mode 100644 salt/_beacons/rules_db.py diff --git a/salt/_beacons/rules_db.py b/salt/_beacons/rules_db.py new file mode 100644 index 000000000..ab63da431 --- /dev/null +++ b/salt/_beacons/rules_db.py @@ -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//rules_db/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_db_%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_db 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_db beacon: change detected in %s, emitting %s', directory, tag) + + return retval diff --git a/salt/manager/files/beacons_pushstate.conf.jinja b/salt/manager/files/beacons_pushstate.conf.jinja index 4de5ef5e0..2c00163e3 100644 --- a/salt/manager/files/beacons_pushstate.conf.jinja +++ b/salt/manager/files/beacons_pushstate.conf.jinja @@ -3,39 +3,9 @@ beacons: pillar_db: - interval: {{ AUTOAPPLY.drain_interval }} - disable_during_state_run: False - inotify: + rules_db: + - interval: {{ AUTOAPPLY.drain_interval }} - disable_during_state_run: False - - coalesce: True - - files: - /opt/so/saltstack/local/salt/suricata/rules: - mask: - - close_write - - moved_to - - delete - recurse: True - auto_add: True - exclude: - - '\.sw[a-z]$': - regex: True - - '~$': - regex: True - - '/4913$': - regex: True - - '/\.#': - regex: True - /opt/so/saltstack/local/salt/strelka/rules/compiled: - mask: - - close_write - - moved_to - - delete - recurse: True - auto_add: True - exclude: - - '\.sw[a-z]$': - regex: True - - '~$': - regex: True - - '/4913$': - regex: True - - '/\.#': - regex: True + - paths: + /opt/so/saltstack/local/salt/suricata/rules: suricata + /opt/so/saltstack/local/salt/strelka/rules/compiled: strelka diff --git a/salt/reactor/push_strelka.sls b/salt/reactor/push_strelka.sls index 21727bc91..d1d0207eb 100644 --- a/salt/reactor/push_strelka.sls +++ b/salt/reactor/push_strelka.sls @@ -1,7 +1,7 @@ #!py -# Reactor invoked by the inotify beacon on rule file changes under -# /opt/so/saltstack/local/salt/strelka/rules/compiled/. +# Reactor invoked by the rules_db poll beacon (salt/_beacons/rules_db.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 diff --git a/salt/reactor/push_suricata.sls b/salt/reactor/push_suricata.sls index 53900e469..f50a92527 100644 --- a/salt/reactor/push_suricata.sls +++ b/salt/reactor/push_suricata.sls @@ -1,7 +1,7 @@ #!py -# Reactor invoked by the inotify beacon on rule file changes under -# /opt/so/saltstack/local/salt/suricata/rules/. +# Reactor invoked by the rules_db poll beacon (salt/_beacons/rules_db.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 diff --git a/salt/salt/files/reactor_pushstate.conf b/salt/salt/files/reactor_pushstate.conf index 991c4d516..b4543b1a7 100644 --- a/salt/salt/files/reactor_pushstate.conf +++ b/salt/salt/files/reactor_pushstate.conf @@ -1,11 +1,7 @@ reactor: - - 'salt/beacon/*/inotify//opt/so/saltstack/local/salt/suricata/rules': + - 'salt/beacon/*/rules_db/suricata': - salt://reactor/push_suricata.sls - - 'salt/beacon/*/inotify//opt/so/saltstack/local/salt/suricata/rules/*': - - salt://reactor/push_suricata.sls - - 'salt/beacon/*/inotify//opt/so/saltstack/local/salt/strelka/rules/compiled': - - salt://reactor/push_strelka.sls - - 'salt/beacon/*/inotify//opt/so/saltstack/local/salt/strelka/rules/compiled/*': + - 'salt/beacon/*/rules_db/strelka': - salt://reactor/push_strelka.sls - 'salt/beacon/*/pillar_db/audit_settings': - salt://reactor/push_pillar.sls From a330bea25e1b4920589fd009d644d921ed6d98be Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Mon, 29 Jun 2026 14:29:07 -0400 Subject: [PATCH 21/39] 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///, 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. --- ...{pillar_db.py => postgres_pillar_beacon.py} | 18 +++++++++--------- salt/_beacons/{rules_db.py => rules_beacon.py} | 8 ++++---- .../manager/files/beacons_pushstate.conf.jinja | 4 ++-- salt/reactor/push_pillar.sls | 6 +++--- salt/reactor/push_strelka.sls | 2 +- salt/reactor/push_suricata.sls | 2 +- salt/salt/files/reactor_pushstate.conf | 6 +++--- 7 files changed, 23 insertions(+), 23 deletions(-) rename salt/_beacons/{pillar_db.py => postgres_pillar_beacon.py} (86%) rename salt/_beacons/{rules_db.py => rules_beacon.py} (93%) diff --git a/salt/_beacons/pillar_db.py b/salt/_beacons/postgres_pillar_beacon.py similarity index 86% rename from salt/_beacons/pillar_db.py rename to salt/_beacons/postgres_pillar_beacon.py index 8712cce7f..d22eef0ea 100644 --- a/salt/_beacons/pillar_db.py +++ b/salt/_beacons/postgres_pillar_beacon.py @@ -22,7 +22,7 @@ import subprocess log = logging.getLogger(__name__) -WATERMARK_FILE = '/opt/so/state/pillar_db_watch.id' +WATERMARK_FILE = '/opt/so/state/postgres_pillar_beacon_watch.id' CONTAINER = 'so-postgres' DATABASE = 'securityonion' @@ -56,7 +56,7 @@ def _write_watermark(value): f.write(str(int(value))) os.rename(tmp, WATERMARK_FILE) except OSError: - log.exception('pillar_db beacon: failed to persist watermark to %s', WATERMARK_FILE) + log.exception('postgres_pillar_beacon: failed to persist watermark to %s', WATERMARK_FILE) def _query(sql): @@ -71,13 +71,13 @@ def _query(sql): try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) except subprocess.TimeoutExpired: - log.warning('pillar_db beacon: psql timed out') + log.warning('postgres_pillar_beacon: psql timed out') return None except Exception: - log.exception('pillar_db beacon: failed to exec psql') + log.exception('postgres_pillar_beacon: failed to exec psql') return None if result.returncode != 0: - log.warning('pillar_db beacon: psql failed (rc=%s): %s', + log.warning('postgres_pillar_beacon: psql failed (rc=%s): %s', result.returncode, (result.stderr or '').strip()) return None return result.stdout @@ -97,7 +97,7 @@ def beacon(config): try: _write_watermark(int((seed or '0').strip() or 0)) except ValueError: - log.warning('pillar_db beacon: could not parse MAX(id) seed: %r', seed) + log.warning('postgres_pillar_beacon: could not parse MAX(id) seed: %r', seed) return retval rows = _query( @@ -116,12 +116,12 @@ def beacon(config): continue parts = line.split(FIELD_SEP) if len(parts) < 3: - log.warning('pillar_db beacon: skipping malformed row: %r', line) + log.warning('postgres_pillar_beacon: skipping malformed row: %r', line) continue try: row_id = int(parts[0]) except ValueError: - log.warning('pillar_db beacon: skipping row with non-int id: %r', line) + log.warning('postgres_pillar_beacon: skipping row with non-int id: %r', line) continue setting_id = parts[1] node_id = parts[2] @@ -136,7 +136,7 @@ def beacon(config): if max_id > watermark: _write_watermark(max_id) - log.info('pillar_db beacon: emitted %d change(s), watermark %d -> %d', + log.info('postgres_pillar_beacon: emitted %d change(s), watermark %d -> %d', len(retval), watermark, max_id) return retval diff --git a/salt/_beacons/rules_db.py b/salt/_beacons/rules_beacon.py similarity index 93% rename from salt/_beacons/rules_db.py rename to salt/_beacons/rules_beacon.py index ab63da431..3bde11c30 100644 --- a/salt/_beacons/rules_db.py +++ b/salt/_beacons/rules_beacon.py @@ -19,7 +19,7 @@ # up on the next one). # # Each emitted event carries the watched directory path under the configured tag -# (e.g. salt/beacon//rules_db/suricata); the push_suricata / push_strelka +# (e.g. salt/beacon//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. @@ -95,7 +95,7 @@ def _fingerprint(directory): def _watermark_file(tag): - return os.path.join(WATERMARK_DIR, 'rules_db_%s.hash' % tag) + return os.path.join(WATERMARK_DIR, 'rules_beacon_%s.hash' % tag) def _read_watermark(tag): @@ -115,7 +115,7 @@ def _write_watermark(tag, digest): f.write(digest) os.rename(tmp, path) except OSError: - log.exception('rules_db beacon: failed to persist watermark to %s', path) + log.exception('rules_beacon: failed to persist watermark to %s', path) def beacon(config): @@ -134,6 +134,6 @@ def beacon(config): if digest != previous: _write_watermark(tag, digest) retval.append({'tag': tag, 'path': directory}) - log.info('rules_db beacon: change detected in %s, emitting %s', directory, tag) + log.info('rules_beacon: change detected in %s, emitting %s', directory, tag) return retval diff --git a/salt/manager/files/beacons_pushstate.conf.jinja b/salt/manager/files/beacons_pushstate.conf.jinja index 2c00163e3..fba53b759 100644 --- a/salt/manager/files/beacons_pushstate.conf.jinja +++ b/salt/manager/files/beacons_pushstate.conf.jinja @@ -1,9 +1,9 @@ {% from 'salt/auto_apply.map.jinja' import AUTOAPPLY %} beacons: - pillar_db: + postgres_pillar_beacon: - interval: {{ AUTOAPPLY.drain_interval }} - disable_during_state_run: False - rules_db: + rules_beacon: - interval: {{ AUTOAPPLY.drain_interval }} - disable_during_state_run: False - paths: diff --git a/salt/reactor/push_pillar.sls b/salt/reactor/push_pillar.sls index c4b82959b..8d28ef5cf 100644 --- a/salt/reactor/push_pillar.sls +++ b/salt/reactor/push_pillar.sls @@ -1,7 +1,7 @@ #!py -# Reactor invoked by the pillar_db beacon when SOC records settings changes in -# the securityonion.audit_settings table (see salt/_beacons/pillar_db.py). The beacon +# 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: @@ -134,7 +134,7 @@ def run(): LOG.info('push_pillar: push disabled, skipping') return {} - # The pillar_db beacon nests its payload under data['data']; fall back to the + # 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', '') diff --git a/salt/reactor/push_strelka.sls b/salt/reactor/push_strelka.sls index d1d0207eb..52e3fd3ef 100644 --- a/salt/reactor/push_strelka.sls +++ b/salt/reactor/push_strelka.sls @@ -1,6 +1,6 @@ #!py -# Reactor invoked by the rules_db poll beacon (salt/_beacons/rules_db.py) on rule +# 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 diff --git a/salt/reactor/push_suricata.sls b/salt/reactor/push_suricata.sls index f50a92527..cce95fdb7 100644 --- a/salt/reactor/push_suricata.sls +++ b/salt/reactor/push_suricata.sls @@ -1,6 +1,6 @@ #!py -# Reactor invoked by the rules_db poll beacon (salt/_beacons/rules_db.py) on rule +# 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 diff --git a/salt/salt/files/reactor_pushstate.conf b/salt/salt/files/reactor_pushstate.conf index b4543b1a7..dc23e68e4 100644 --- a/salt/salt/files/reactor_pushstate.conf +++ b/salt/salt/files/reactor_pushstate.conf @@ -1,7 +1,7 @@ reactor: - - 'salt/beacon/*/rules_db/suricata': + - 'salt/beacon/*/rules_beacon/suricata': - salt://reactor/push_suricata.sls - - 'salt/beacon/*/rules_db/strelka': + - 'salt/beacon/*/rules_beacon/strelka': - salt://reactor/push_strelka.sls - - 'salt/beacon/*/pillar_db/audit_settings': + - 'salt/beacon/*/postgres_pillar_beacon/audit_settings': - salt://reactor/push_pillar.sls From 8a3f5d0f819ebd2b2059aa71a446e0af9335a31a Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Thu, 9 Jul 2026 16:55:30 -0400 Subject: [PATCH 22/39] 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. --- salt/salt/minion/init.sls | 13 ++- salt/salt/tools/sbin/so-salt-minion-wait | 142 +++++++++++++++++++++-- 2 files changed, 138 insertions(+), 17 deletions(-) diff --git a/salt/salt/minion/init.sls b/salt/salt/minion/init.sls index a251aa633..035d9e7f8 100644 --- a/salt/salt/minion/init.sls +++ b/salt/salt/minion/init.sls @@ -131,11 +131,14 @@ salt_minion_service: {% endif %} - order: last -# block until the just-restarted salt-minion is back and can execute modules locally, 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 common_sbin from common/tools/sbin/). +# block until the just-restarted salt-minion daemon logs "Minion is ready to receive requests!" +# 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/); it keys the ready +# line to the current daemon pid (resolved via systemd, not the pidfile) and corroborates with the +# master req/publish sockets. set_log_levels above enforces the log_level_logfile: info that the +# ready line depends on. wait_for_salt_minion_ready: cmd.run: - name: /usr/sbin/so-salt-minion-wait diff --git a/salt/salt/tools/sbin/so-salt-minion-wait b/salt/salt/tools/sbin/so-salt-minion-wait index a30c67e80..9fb2a41c7 100644 --- a/salt/salt/tools/sbin/so-salt-minion-wait +++ b/salt/salt/tools/sbin/so-salt-minion-wait @@ -5,31 +5,149 @@ # https://securityonion.net/license; you may not use this file except in compliance with the # Elastic License 2.0. -# Block until the local salt-minion service is back up and can execute modules locally. -# Invoked from the wait_for_salt_minion_ready state in salt/minion/init.sls after -# salt_minion_service fires its watch-driven mod_watch (a non-blocking systemctl restart), -# so follow-on jobs and the next highstate iteration do not race the in-flight restart. +# Block until the just-restarted salt-minion daemon reaches the point where salt itself logs +# "Minion 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. +# +# Salt logs that line 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. Primary 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. +# 2. Corroborating that same 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. +# +# 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. . /usr/sbin/so-common -# Initial sleep gives the systemctl restart (--no-block by default for salt-minion on -# >=3006.15) time to begin tearing down the old process before we probe for readiness. +set -u + INITIAL_SLEEP=3 TIMEOUT=120 -PING_TIMEOUT=5 +MASTER_PORT=4506 +LOG_TAIL_LINES=10000 +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 + +# 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}" +} + +# 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 + if [ "$USE_LOG_GATE" -eq 1 ] && ! ready_logged "$pid"; then + return 1 + fi + if [ "$USE_SOCKET_GATE" -eq 1 ] && ! socket_ready "$pid"; then + return 1 + fi + return 0 +} sleep "$INITIAL_SLEEP" elapsed="$INITIAL_SLEEP" +pids="" while [ "$elapsed" -lt "$TIMEOUT" ]; do - if systemctl is-active --quiet salt-minion \ - && salt-call --local --timeout="$PING_TIMEOUT" --out=quiet test.ping >/dev/null 2>&1; then - echo "salt-minion ready after ${elapsed}s" - exit 0 + if 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 -echo "salt-minion did not become ready within ${TIMEOUT}s" >&2 +mainpid=$(systemctl show -p MainPID --value salt-minion 2>/dev/null) +echo "salt-minion did not become ready within ${TIMEOUT}s (MainPID=${mainpid:-unknown}, candidate pids='${pids:-none}', log_gate=${USE_LOG_GATE}, socket_gate=${USE_SOCKET_GATE}, log_file=${LOG_FILE})" >&2 exit 1 From fbeac25ee9ca07341335e1551fe035303c958120 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Thu, 9 Jul 2026 16:57:19 -0400 Subject: [PATCH 23/39] 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. --- salt/common/tools/sbin_jinja/so-salt-minion-check | 2 ++ 1 file changed, 2 insertions(+) diff --git a/salt/common/tools/sbin_jinja/so-salt-minion-check b/salt/common/tools/sbin_jinja/so-salt-minion-check index 1376193cc..281efbcb7 100755 --- a/salt/common/tools/sbin_jinja/so-salt-minion-check +++ b/salt/common/tools/sbin_jinja/so-salt-minion-check @@ -91,6 +91,8 @@ if [ $CURRENT_TIME -ge $((SYSTEM_START_TIME+$UPTIME_REQ)) ]; then logCmd "pkill -9 -ef /usr/bin/salt-minion" I log "starting salt-minion service" I logCmd "systemctl start salt-minion" I + log "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 & 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 fi From 89e6a746c8e730b4b5a51bf4c16365ca486657a4 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Fri, 10 Jul 2026 09:35:21 -0400 Subject: [PATCH 24/39] 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. --- salt/salt/minion/init.sls | 3 ++- salt/salt/tools/sbin/so-salt-minion-wait | 28 ++++++++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/salt/salt/minion/init.sls b/salt/salt/minion/init.sls index 035d9e7f8..1f1ec8305 100644 --- a/salt/salt/minion/init.sls +++ b/salt/salt/minion/init.sls @@ -138,7 +138,8 @@ salt_minion_service: # /usr/sbin/so-salt-minion-wait (deployed by salt_sbin from salt/tools/sbin/); it keys the ready # line to the current daemon pid (resolved via systemd, not the pidfile) and corroborates with the # master req/publish sockets. set_log_levels above enforces the log_level_logfile: info that the -# ready line depends on. +# 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 diff --git a/salt/salt/tools/sbin/so-salt-minion-wait b/salt/salt/tools/sbin/so-salt-minion-wait index 9fb2a41c7..984f144f3 100644 --- a/salt/salt/tools/sbin/so-salt-minion-wait +++ b/salt/salt/tools/sbin/so-salt-minion-wait @@ -26,6 +26,14 @@ # 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 @@ -76,6 +84,13 @@ 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() { @@ -134,8 +149,16 @@ sleep "$INITIAL_SLEEP" elapsed="$INITIAL_SLEEP" pids="" +announced_pending=0 while [ "$elapsed" -lt "$TIMEOUT" ]; do - if pids=$(resolve_daemon_pids); then + 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 @@ -149,5 +172,6 @@ while [ "$elapsed" -lt "$TIMEOUT" ]; do done mainpid=$(systemctl show -p MainPID --value salt-minion 2>/dev/null) -echo "salt-minion did not become ready within ${TIMEOUT}s (MainPID=${mainpid:-unknown}, candidate pids='${pids:-none}', log_gate=${USE_LOG_GATE}, socket_gate=${USE_SOCKET_GATE}, log_file=${LOG_FILE})" >&2 +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 From 5af6c56996ac5287e892ac9780dd972ee9558355 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Fri, 10 Jul 2026 14:54:20 -0400 Subject: [PATCH 25/39] 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. --- .../tools/sbin_jinja/so-salt-minion-check | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/salt/common/tools/sbin_jinja/so-salt-minion-check b/salt/common/tools/sbin_jinja/so-salt-minion-check index 281efbcb7..936ecda9a 100755 --- a/salt/common/tools/sbin_jinja/so-salt-minion-check +++ b/salt/common/tools/sbin_jinja/so-salt-minion-check @@ -18,6 +18,7 @@ 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 "$(> "/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 fi else log "system uptime only $((CURRENT_TIME-SYSTEM_START_TIME)) seconds does not meet $UPTIME_REQ second requirement." I fi + +# 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 && [ $CURRENT_TIME -ge $((SYSTEM_START_TIME+HIGHSTATE_UPTIME_REQ)) ] && [ $LAST_HIGHSTATE_END -lt $SYSTEM_START_TIME ]; then + if salt-call --local saltutil.running 2>/dev/null | grep -q 'state.highstate'; then + log "no highstate has completed since boot, but one is already running; skipping" I + else + log "no highstate has completed since boot after $((CURRENT_TIME-SYSTEM_START_TIME))s uptime; applying highstate" E + nohup bash -c 'salt-call state.highstate -l info queue=True' >> "/opt/so/log/salt/so-salt-minion-check" 2>&1 & + fi +fi From ed533efb7b52bd3b6fadf38277124f3c811896eb Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Fri, 10 Jul 2026 15:53:49 -0400 Subject: [PATCH 26/39] 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. --- .../tools/sbin_jinja/so-salt-minion-check | 53 +++++++++++-------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/salt/common/tools/sbin_jinja/so-salt-minion-check b/salt/common/tools/sbin_jinja/so-salt-minion-check index 936ecda9a..c78846a5f 100755 --- a/salt/common/tools/sbin_jinja/so-salt-minion-check +++ b/salt/common/tools/sbin_jinja/so-salt-minion-check @@ -79,41 +79,48 @@ 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 "waiting for salt-minion to become ready, then applying highstate in the background (queued)" 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 -# 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 && [ $CURRENT_TIME -ge $((SYSTEM_START_TIME+HIGHSTATE_UPTIME_REQ)) ] && [ $LAST_HIGHSTATE_END -lt $SYSTEM_START_TIME ]; then - if salt-call --local saltutil.running 2>/dev/null | grep -q 'state.highstate'; then - log "no highstate has completed since boot, but one is already running; skipping" I - else - log "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 +# 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 From e42f7cd6fca72c23a46fe2940622c36d5e176ebb Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Tue, 14 Jul 2026 13:33:57 -0400 Subject: [PATCH 27/39] add restart policy to so-postgres --- salt/postgres/enabled.sls | 1 + 1 file changed, 1 insertion(+) diff --git a/salt/postgres/enabled.sls b/salt/postgres/enabled.sls index 20d256ae8..b0eadd205 100644 --- a/salt/postgres/enabled.sls +++ b/salt/postgres/enabled.sls @@ -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: From 405dc52587fe417c767e7005cfc177a444cf8296 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Tue, 14 Jul 2026 13:34:19 -0400 Subject: [PATCH 28/39] move up restart policy for so-kratos --- salt/kratos/enabled.sls | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/salt/kratos/enabled.sls b/salt/kratos/enabled.sls index 7dd13732e..ab13d759f 100644 --- a/salt/kratos/enabled.sls +++ b/salt/kratos/enabled.sls @@ -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,8 +52,6 @@ so-kratos: - {{ ULIMIT.name }}={{ ULIMIT.soft }}:{{ ULIMIT.hard }} {% endfor %} {% endif %} - # Intentionally unless-stopped -- matches the fleet default. - - restart_policy: unless-stopped - watch: - file: kratosschema - file: kratosconfig From fee62ab976cfabe96390d752bcf4b42dd6aed337 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Wed, 15 Jul 2026 10:49:27 -0400 Subject: [PATCH 29/39] change restart_policy to unless-stopped --- salt/strelka/backend/enabled.sls | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/salt/strelka/backend/enabled.sls b/salt/strelka/backend/enabled.sls index 8c71bdf68..e62349f70 100644 --- a/salt/strelka/backend/enabled.sls +++ b/salt/strelka/backend/enabled.sls @@ -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,11 +48,6 @@ strelka_backend: - {{ ULIMIT.name }}={{ ULIMIT.soft }}:{{ ULIMIT.hard }} {% endfor %} {% endif %} - # Intentionally `on-failure` (not unless-stopped) -- strelka backend shuts - # down cleanly during rule reloads and we do not want those clean exits to - # trigger an auto-restart. Do not homogenize; see the container - # auto-restart section of the plan. - - restart_policy: on-failure - watch: - file: strelkasensorcompiledrules - file: backend_backend_config From 5178d5fd0e91ec9a0cd48a4361313fb4c03fa9f8 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Wed, 15 Jul 2026 11:04:51 -0400 Subject: [PATCH 30/39] ensure restart_policy is below image --- salt/hydra/enabled.sls | 3 +-- salt/registry/enabled.sls | 7 +++---- salt/suricata/enabled.sls | 2 +- salt/zeek/enabled.sls | 2 +- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/salt/hydra/enabled.sls b/salt/hydra/enabled.sls index b19f9a80d..74106f550 100644 --- a/salt/hydra/enabled.sls +++ b/salt/hydra/enabled.sls @@ -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,8 +59,6 @@ so-hydra: - {{ ULIMIT.name }}={{ ULIMIT.soft }}:{{ ULIMIT.hard }} {% endfor %} {% endif %} - # Intentionally unless-stopped -- matches the fleet default. - - restart_policy: unless-stopped - watch: - file: hydraconfig - require: diff --git a/salt/registry/enabled.sls b/salt/registry/enabled.sls index 7b039b313..b2bfbc56d 100644 --- a/salt/registry/enabled.sls +++ b/salt/registry/enabled.sls @@ -17,14 +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 }} - # Intentionally `always` (not unless-stopped) -- registry is critical infra - # and must come back up even if it was manually stopped. Do not homogenize - # to unless-stopped; see the container auto-restart section of the plan. - - restart_policy: always - port_bindings: {% for BINDING in DOCKERMERGED.containers['so-dockerregistry'].port_bindings %} - {{ BINDING }} diff --git a/salt/suricata/enabled.sls b/salt/suricata/enabled.sls index 53f367971..d9206798e 100644 --- a/salt/suricata/enabled.sls +++ b/salt/suricata/enabled.sls @@ -17,8 +17,8 @@ include: so-suricata: docker_container.running: - image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-suricata:{{ GLOBALS.so_version }} - - privileged: True - restart_policy: unless-stopped + - privileged: True - environment: - INTERFACE={{ GLOBALS.sensor.interface }} {% if DOCKERMERGED.containers['so-suricata'].extra_env %} diff --git a/salt/zeek/enabled.sls b/salt/zeek/enabled.sls index 355e555b3..ec01693d7 100644 --- a/salt/zeek/enabled.sls +++ b/salt/zeek/enabled.sls @@ -16,9 +16,9 @@ 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 - - restart_policy: unless-stopped {% if DOCKERMERGED.containers['so-zeek'].ulimits %} - ulimits: {% for ULIMIT in DOCKERMERGED.containers['so-zeek'].ulimits %} From 376607d292852ab238a2a6fee1c4dffa6bae571c Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Wed, 15 Jul 2026 14:52:27 -0400 Subject: [PATCH 31/39] 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. --- salt/common/tools/sbin/so-status | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/salt/common/tools/sbin/so-status b/salt/common/tools/sbin/so-status index f4abd8aa3..b50cdff14 100755 --- a/salt/common/tools/sbin/so-status +++ b/salt/common/tools/sbin/so-status @@ -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() - From 23c74f172764758bb2478e50565d0dd9328250fb Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Wed, 15 Jul 2026 15:24:33 -0400 Subject: [PATCH 32/39] remove installation of pyinotify --- salt/salt/master.sls | 1 - salt/salt/master/pyinotify.sls | 20 ------------------ .../pyinotify/pyinotify-0.9.6.tar.gz | Bin 60998 -> 0 bytes 3 files changed, 21 deletions(-) delete mode 100644 salt/salt/master/pyinotify.sls delete mode 100644 salt/salt/module_packages/pyinotify/pyinotify-0.9.6.tar.gz diff --git a/salt/salt/master.sls b/salt/salt/master.sls index bb39f1395..2c47a95c6 100644 --- a/salt/salt/master.sls +++ b/salt/salt/master.sls @@ -15,7 +15,6 @@ include: - salt.minion - - salt.master.pyinotify - salt.master.boot_mine_update {% if 'vrt' in salt['pillar.get']('features', []) %} - salt.cloud diff --git a/salt/salt/master/pyinotify.sls b/salt/salt/master/pyinotify.sls deleted file mode 100644 index 8aa2f1d53..000000000 --- a/salt/salt/master/pyinotify.sls +++ /dev/null @@ -1,20 +0,0 @@ -# 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. - -pyinotify_module_package: - file.recurse: - - name: /opt/so/conf/salt/module_packages/pyinotify - - source: salt://salt/module_packages/pyinotify - - clean: True - - makedirs: True - -pyinotify_python_module_install: - cmd.run: - - name: /opt/saltstack/salt/bin/python3.10 -m pip install pyinotify --no-index --find-links=/opt/so/conf/salt/module_packages/pyinotify/ --upgrade - - onchanges: - - file: pyinotify_module_package - - failhard: True - - watch_in: - - service: salt_minion_service diff --git a/salt/salt/module_packages/pyinotify/pyinotify-0.9.6.tar.gz b/salt/salt/module_packages/pyinotify/pyinotify-0.9.6.tar.gz deleted file mode 100644 index 3150ad360fcd785b11c1d67e734fe906e973a782..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 60998 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

y>w-0k;31nP{WrocQke%$Sx(n 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 bM>38 From 517538a9a73ad07ee9b9cf4a6bc887c6976ebc57 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Wed, 15 Jul 2026 16:28:35 -0400 Subject: [PATCH 33/39] remove comments --- salt/libvirt/dev/saltDev.sls | 67 ++++++++++++++++++++++++++++++++++ salt/reactor/push_strelka.sls | 2 +- salt/reactor/push_suricata.sls | 2 +- 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 salt/libvirt/dev/saltDev.sls diff --git a/salt/libvirt/dev/saltDev.sls b/salt/libvirt/dev/saltDev.sls new file mode 100644 index 000000000..016407635 --- /dev/null +++ b/salt/libvirt/dev/saltDev.sls @@ -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 diff --git a/salt/reactor/push_strelka.sls b/salt/reactor/push_strelka.sls index 52e3fd3ef..31f66e0a6 100644 --- a/salt/reactor/push_strelka.sls +++ b/salt/reactor/push_strelka.sls @@ -6,7 +6,7 @@ # 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 -- see plan /home/mreeves/.claude/plans/goofy-marinating-hummingbird.md. +# directly import fcntl import json diff --git a/salt/reactor/push_suricata.sls b/salt/reactor/push_suricata.sls index cce95fdb7..11fbd1286 100644 --- a/salt/reactor/push_suricata.sls +++ b/salt/reactor/push_suricata.sls @@ -6,7 +6,7 @@ # 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 -- see plan /home/mreeves/.claude/plans/goofy-marinating-hummingbird.md. +# directly import fcntl import json From f9b154ccef3ade676439f16824fc348aac7b1ace Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Wed, 15 Jul 2026 16:32:29 -0400 Subject: [PATCH 34/39] remove comments --- salt/manager/tools/sbin/so-push-drainer | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/salt/manager/tools/sbin/so-push-drainer b/salt/manager/tools/sbin/so-push-drainer index fbbd952a4..8cc97a8d5 100644 --- a/salt/manager/tools/sbin/so-push-drainer +++ b/salt/manager/tools/sbin/so-push-drainer @@ -21,8 +21,7 @@ is older than debounce_seconds, this script: * deletes the contributed intent files on successful dispatch Reactor sls files (push_suricata, push_strelka, push_pillar) write intents -but never dispatch directly -- see plan -/home/mreeves/.claude/plans/goofy-marinating-hummingbird.md for the full design. +but never dispatch directly """ import fcntl From 9e7e6edae070d622a533fca34eba5cdd48849844 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Thu, 16 Jul 2026 15:28:22 -0400 Subject: [PATCH 35/39] 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). --- .github/workflows/pythontest.yml | 3 +- salt/_beacons/postgres_pillar_beacon.py | 2 +- salt/_beacons/postgres_pillar_beacon_test.py | 165 ++++++++++++++++++ salt/_beacons/rules_beacon_test.py | 172 +++++++++++++++++++ salt/_beacons/zeek.py | 37 ++-- salt/_beacons/zeek_test.py | 59 +++++++ 6 files changed, 417 insertions(+), 21 deletions(-) create mode 100644 salt/_beacons/postgres_pillar_beacon_test.py create mode 100644 salt/_beacons/rules_beacon_test.py create mode 100644 salt/_beacons/zeek_test.py diff --git a/.github/workflows/pythontest.yml b/.github/workflows/pythontest.yml index a4cc92d8d..dc95b01c3 100644 --- a/.github/workflows/pythontest.yml +++ b/.github/workflows/pythontest.yml @@ -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 diff --git a/salt/_beacons/postgres_pillar_beacon.py b/salt/_beacons/postgres_pillar_beacon.py index d22eef0ea..074e9e8af 100644 --- a/salt/_beacons/postgres_pillar_beacon.py +++ b/salt/_beacons/postgres_pillar_beacon.py @@ -83,7 +83,7 @@ def _query(sql): return result.stdout -def beacon(config): +def beacon(config): # noqa: C901 retval = [] watermark = _read_watermark() diff --git a/salt/_beacons/postgres_pillar_beacon_test.py b/salt/_beacons/postgres_pillar_beacon_test.py new file mode 100644 index 000000000..377f0ff04 --- /dev/null +++ b/salt/_beacons/postgres_pillar_beacon_test.py @@ -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() diff --git a/salt/_beacons/rules_beacon_test.py b/salt/_beacons/rules_beacon_test.py new file mode 100644 index 000000000..adcd19181 --- /dev/null +++ b/salt/_beacons/rules_beacon_test.py @@ -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() diff --git a/salt/_beacons/zeek.py b/salt/_beacons/zeek.py index 117c2b401..6c4d01b11 100644 --- a/salt/_beacons/zeek.py +++ b/salt/_beacons/zeek.py @@ -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 diff --git a/salt/_beacons/zeek_test.py b/salt/_beacons/zeek_test.py new file mode 100644 index 000000000..db3b4236f --- /dev/null +++ b/salt/_beacons/zeek_test.py @@ -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() From 141116f5500b21d900a7db3ffcb30249e9adccd1 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Thu, 16 Jul 2026 16:52:06 -0400 Subject: [PATCH 36/39] 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. --- salt/salt/minion/init.sls | 17 ++++--- salt/salt/tools/sbin/so-salt-minion-wait | 63 ++++++++++++++++++------ 2 files changed, 58 insertions(+), 22 deletions(-) diff --git a/salt/salt/minion/init.sls b/salt/salt/minion/init.sls index 1f1ec8305..fa94ec7be 100644 --- a/salt/salt/minion/init.sls +++ b/salt/salt/minion/init.sls @@ -131,13 +131,16 @@ salt_minion_service: {% endif %} - order: last -# block until the just-restarted salt-minion daemon logs "Minion is ready to receive requests!" -# 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/); it keys the ready -# line to the current daemon pid (resolved via systemd, not the pidfile) and corroborates with the -# master req/publish sockets. set_log_levels above enforces the log_level_logfile: info that the +# 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: diff --git a/salt/salt/tools/sbin/so-salt-minion-wait b/salt/salt/tools/sbin/so-salt-minion-wait index 984f144f3..cf16ab0d1 100644 --- a/salt/salt/tools/sbin/so-salt-minion-wait +++ b/salt/salt/tools/sbin/so-salt-minion-wait @@ -5,21 +5,29 @@ # https://securityonion.net/license; you may not use this file except in compliance with the # Elastic License 2.0. -# Block until the just-restarted salt-minion daemon reaches the point where salt itself logs -# "Minion 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. +# 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 that line 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: +# 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. Primary the pid-tagged ready line in the minion log. Salt's log_fmt_logfile embeds +# 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. -# 2. Corroborating that same 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. +# 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 @@ -43,6 +51,12 @@ INITIAL_SLEEP=3 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" @@ -103,6 +117,15 @@ resolve_daemon_pids() { 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 @@ -135,9 +158,19 @@ socket_ready() { } instance_ready() { - local pid=$1 - if [ "$USE_LOG_GATE" -eq 1 ] && ! ready_logged "$pid"; then - return 1 + 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 From 8095b828411d230bf964b336ca352c7d892ec8a9 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Thu, 16 Jul 2026 16:58:21 -0400 Subject: [PATCH 37/39] soup use so-salt-minion-wait to ensure salt-minion is ready --- salt/common/tools/sbin/so-common | 36 -------------------------------- salt/manager/tools/sbin/soup | 13 ++++-------- 2 files changed, 4 insertions(+), 45 deletions(-) diff --git a/salt/common/tools/sbin/so-common b/salt/common/tools/sbin/so-common index 4e6580ae1..b5d1ab286 100755 --- a/salt/common/tools/sbin/so-common +++ b/salt/common/tools/sbin/so-common @@ -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) diff --git a/salt/manager/tools/sbin/soup b/salt/manager/tools/sbin/soup index 52f310e81..751621647 100755 --- a/salt/manager/tools/sbin/soup +++ b/salt/manager/tools/sbin/soup @@ -1563,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 @@ -2030,7 +2025,7 @@ main() { echo "" echo "Running a highstate. 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 @@ -2043,7 +2038,7 @@ 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." + 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 From aaea6dbd58fbe251e0de189ceb2203f838584533 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Fri, 17 Jul 2026 09:18:03 -0400 Subject: [PATCH 38/39] 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. --- salt/salt/tools/sbin/so-salt-minion-wait | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/salt/salt/tools/sbin/so-salt-minion-wait b/salt/salt/tools/sbin/so-salt-minion-wait index cf16ab0d1..d54e39677 100644 --- a/salt/salt/tools/sbin/so-salt-minion-wait +++ b/salt/salt/tools/sbin/so-salt-minion-wait @@ -47,7 +47,6 @@ set -u -INITIAL_SLEEP=3 TIMEOUT=120 MASTER_PORT=4506 LOG_TAIL_LINES=10000 @@ -178,9 +177,7 @@ instance_ready() { return 0 } -sleep "$INITIAL_SLEEP" - -elapsed="$INITIAL_SLEEP" +elapsed=0 pids="" announced_pending=0 while [ "$elapsed" -lt "$TIMEOUT" ]; do From 4886034fefb603f584c90a2701144eea96cdf179 Mon Sep 17 00:00:00 2001 From: Josh Patterson Date: Fri, 17 Jul 2026 15:25:55 -0400 Subject: [PATCH 39/39] add date and time to output --- salt/common/tools/sbin/so-common | 2 +- salt/manager/tools/sbin/soup | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/salt/common/tools/sbin/so-common b/salt/common/tools/sbin/so-common index b5d1ab286..402d84363 100755 --- a/salt/common/tools/sbin/so-common +++ b/salt/common/tools/sbin/so-common @@ -666,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 "" } diff --git a/salt/manager/tools/sbin/soup b/salt/manager/tools/sbin/soup index 751621647..f5b40310e 100755 --- a/salt/manager/tools/sbin/soup +++ b/salt/manager/tools/sbin/soup @@ -2023,7 +2023,7 @@ 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 || fail "Salt minion was not running or ready." highstate @@ -2037,7 +2037,7 @@ main() { check_saltmaster_status - echo "Running a highstate to complete the Security Onion upgrade on this manager. This could take several minutes." + echo "Running a highstate at $(date +"%T.%6N") to complete the Security Onion upgrade on this manager. This could take several minutes." wait_for_salt_minion_with_restart || fail "Salt minion was not running or ready." # Stop long-running scripts to allow potentially updated scripts to load on the next execution.