mirror of
https://github.com/Security-Onion-Solutions/securityonion.git
synced 2026-06-12 13:19:22 +02:00
Compare commits
175 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80c39d612c | |||
| f03f0155f4 | |||
| 0cc94980af | |||
| b8bf684077 | |||
| f083db67e4 | |||
| 4741cc92bd | |||
| 46655860e9 | |||
| 289ddda5e8 | |||
| f905afbc6f | |||
| bd5e77afc5 | |||
| 944e773759 | |||
| 3ba96da3b7 | |||
| f0712bd780 | |||
| 448668a72e | |||
| f088a27159 | |||
| 9f5a9616a5 | |||
| 27c7702325 | |||
| 8c306eb37d | |||
| e536ffa363 | |||
| eb82f9ea9d | |||
| d7aa7ab228 | |||
| fe0b68d24c | |||
| 6ad345730b | |||
| 9580976ba2 | |||
| ac907ba45f | |||
| f957954abf | |||
| cb3631da81 | |||
| f5d63f585e | |||
| 13f8be40b5 | |||
| 9ee90a5bc0 | |||
| ca85c5d900 | |||
| 2d653b6f1b | |||
| 34fee25b0c | |||
| 1d3d98f759 | |||
| a767c79641 | |||
| 61e72c89e4 | |||
| d9fb7313f9 | |||
| 7ca2313255 | |||
| 534f0e639d | |||
| 559465b407 | |||
| f9c2579261 | |||
| 33699a914b | |||
| 0c2d8f8973 | |||
| f2996fb888 | |||
| 3c533cccbc | |||
| 79da9f9f2c | |||
| 99a027589b | |||
| 68a82a425b | |||
| d86a3c5cc9 | |||
| 86edc5aaba | |||
| 9a70a06b3b | |||
| 526d739b3b | |||
| 68d783e760 | |||
| 1e9b6b0975 | |||
| 2131e7d450 | |||
| 2a2d853ac4 | |||
| 5abd6de4b5 | |||
| bb8ae91d91 | |||
| 93ffce98d7 | |||
| 5599cce22c | |||
| b2a82fec29 | |||
| 613eca52fc | |||
| 79987f3659 | |||
| bf609a112e | |||
| 0b4a4de609 | |||
| ad376d2a43 | |||
| 0834998cca | |||
| 473f93f0ee | |||
| 16055c4d88 | |||
| 7cc2e045fb | |||
| 6955ee73bf | |||
| c0272ddb81 | |||
| d72219c586 | |||
| ffd34d4e0e | |||
| aa78978740 | |||
| 75d4f5e496 | |||
| 89a28d2cfe | |||
| c1d187599b | |||
| d87313db27 | |||
| 141a61f5b5 | |||
| 901cbf03e4 | |||
| b485be4602 | |||
| 7d13007aa9 | |||
| d7a1b67095 | |||
| 6c8997b28a | |||
| 58f1d08ebe | |||
| d0aa33a255 | |||
| 74b50f6009 | |||
| e89c820b65 | |||
| 9ac05a6ad1 | |||
| 24ee3318bc | |||
| ce566ba174 | |||
| 2635a60a8c | |||
| 244a73b7a2 | |||
| e45ad45d73 | |||
| 1189621ec5 | |||
| d2524a593f | |||
| f2ab2354fd | |||
| 64731c73ba | |||
| 024fece607 | |||
| 249b126312 | |||
| 8e38bff0c3 | |||
| b9f2d56932 | |||
| 03fa01a705 | |||
| 450eacca41 | |||
| b7a13899f7 | |||
| 6f273d7d97 | |||
| fabecb8288 | |||
| 907f699721 | |||
| e7a7047f71 | |||
| b328820c01 | |||
| 936295f1c4 | |||
| 61ca60a94c | |||
| 638aca97c8 | |||
| 74a5c895e8 | |||
| d56bf01823 | |||
| d29267d9c2 | |||
| 72327285b2 | |||
| cc7a237457 | |||
| b068ad2b35 | |||
| b103f412b5 | |||
| ef79c63858 | |||
| 01fb1aa156 | |||
| f19bdd7aae | |||
| f637dc62d1 | |||
| 081f6fa1fb | |||
| d6d90d84cd | |||
| 125610ed42 | |||
| 306b0af4d0 | |||
| 492ae80da7 | |||
| 4a2177c827 | |||
| 006ac31109 | |||
| 49a643fff4 | |||
| e1d830da76 | |||
| e847c46129 | |||
| 499f7102bd | |||
| 4bc19f91ce | |||
| 4990d0ddea | |||
| 3e49322220 | |||
| ecb92d43fc | |||
| 3b714db0bf | |||
| f17da4e68b | |||
| 04cfc22e3f | |||
| dceed421ae | |||
| 652ac5d61f | |||
| f888a2ba6b | |||
| 8a1ee02335 | |||
| 192f6cfe13 | |||
| 5bca81d833 | |||
| 1c6574c694 | |||
| b701664e04 | |||
| bc64f1431d | |||
| 2203037ce7 | |||
| 77a4ad877e | |||
| 702b3585cc | |||
| 86966d2778 | |||
| ce3ad3a895 | |||
| 3a4b7b50de | |||
| 39d0947102 | |||
| 0085d9a353 | |||
| 2f01ce3b23 | |||
| 71b19c1b5f | |||
| 82e55ae87f | |||
| 3e02001544 | |||
| 82f70bb53a | |||
| 2dcded6cca | |||
| 8ca59e6f0c | |||
| 82dac82d15 | |||
| 288a823edf | |||
| f9e3d30a71 | |||
| 9cec79b299 | |||
| c86399327b | |||
| affede7f0a | |||
| 97366c0496 | |||
| d7e971a0fc |
@@ -11,6 +11,7 @@ body:
|
||||
-
|
||||
- 3.0.0
|
||||
- 3.1.0
|
||||
- 3.2.0
|
||||
- Other (please provide detail below)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
+11
-11
@@ -1,17 +1,17 @@
|
||||
### 3.0.0-20260331 ISO image released on 2026/03/31
|
||||
### 3.1.0-20260528 ISO image released on 2026/05/28
|
||||
|
||||
|
||||
### Download and Verify
|
||||
|
||||
3.0.0-20260331 ISO image:
|
||||
https://download.securityonion.net/file/securityonion/securityonion-3.0.0-20260331.iso
|
||||
3.1.0-20260528 ISO image:
|
||||
https://download.securityonion.net/file/securityonion/securityonion-3.1.0-20260528.iso
|
||||
|
||||
MD5: ECD318A1662A6FDE0EF213F5A9BD4B07
|
||||
SHA1: E55BE314440CCF3392DC0B06BC5E270B43176D9C
|
||||
SHA256: 7FC47405E335CBE5C2B6C51FE7AC60248F35CBE504907B8B5A33822B23F8F4D5
|
||||
MD5: 9D6FF58DEEE24089D722C73169765B3E
|
||||
SHA1: 2B8B816B6CEC3B7F96B3C5E040EBF502DD2C412F
|
||||
SHA256: 62FAB57E247C843D6A04F0796D8162C732B65D82FC3E4A59D087135B9FD32912
|
||||
|
||||
Signature for ISO image:
|
||||
https://github.com/Security-Onion-Solutions/securityonion/raw/3/main/sigs/securityonion-3.0.0-20260331.iso.sig
|
||||
https://github.com/Security-Onion-Solutions/securityonion/raw/3/main/sigs/securityonion-3.1.0-20260528.iso.sig
|
||||
|
||||
Signing key:
|
||||
https://raw.githubusercontent.com/Security-Onion-Solutions/securityonion/3/main/KEYS
|
||||
@@ -25,22 +25,22 @@ wget https://raw.githubusercontent.com/Security-Onion-Solutions/securityonion/3/
|
||||
|
||||
Download the signature file for the ISO:
|
||||
```
|
||||
wget https://github.com/Security-Onion-Solutions/securityonion/raw/3/main/sigs/securityonion-3.0.0-20260331.iso.sig
|
||||
wget https://github.com/Security-Onion-Solutions/securityonion/raw/3/main/sigs/securityonion-3.1.0-20260528.iso.sig
|
||||
```
|
||||
|
||||
Download the ISO image:
|
||||
```
|
||||
wget https://download.securityonion.net/file/securityonion/securityonion-3.0.0-20260331.iso
|
||||
wget https://download.securityonion.net/file/securityonion/securityonion-3.1.0-20260528.iso
|
||||
```
|
||||
|
||||
Verify the downloaded ISO image using the signature file:
|
||||
```
|
||||
gpg --verify securityonion-3.0.0-20260331.iso.sig securityonion-3.0.0-20260331.iso
|
||||
gpg --verify securityonion-3.1.0-20260528.iso.sig securityonion-3.1.0-20260528.iso
|
||||
```
|
||||
|
||||
The output should show "Good signature" and the Primary key fingerprint should match what's shown below:
|
||||
```
|
||||
gpg: Signature made Mon 30 Mar 2026 06:22:14 PM EDT using RSA key ID FE507013
|
||||
gpg: Signature made Wed 27 May 2026 03:03:59 PM EDT using RSA key ID FE507013
|
||||
gpg: Good signature from "Security Onion Solutions, LLC <info@securityonionsolutions.com>"
|
||||
gpg: WARNING: This key is not certified with a trusted signature!
|
||||
gpg: There is no indication that the signature belongs to the owner.
|
||||
|
||||
@@ -25,9 +25,11 @@ if [ ! -f $BACKUPFILE ]; then
|
||||
# Create empty backup file
|
||||
tar -cf $BACKUPFILE -T /dev/null
|
||||
|
||||
# Loop through all paths defined in global.sls, and append them to backup file
|
||||
# Loop through all paths defined in global.sls, and append them to backup file if they exist
|
||||
{%- for LOCATION in BACKUPLOCATIONS %}
|
||||
if [[ -d {{ LOCATION }} || -f {{ LOCATION }} ]]; then
|
||||
tar -rf $BACKUPFILE "${EXCLUSIONS[@]}" {{ LOCATION }}
|
||||
fi
|
||||
{%- endfor %}
|
||||
|
||||
fi
|
||||
|
||||
@@ -130,6 +130,17 @@ common_sbin:
|
||||
- so-pcap-import
|
||||
{% endif %}
|
||||
|
||||
# Pin physical NIC names by MAC (run-once) so a kernel upgrade can't renumber the
|
||||
# interfaces SO binds by name. The marker keeps it a one-time setup; an admin can
|
||||
# pre-create the marker to opt out.
|
||||
pin_nic_names:
|
||||
cmd.run:
|
||||
- name: /usr/sbin/so-nic-pin
|
||||
- unless: 'test -e /opt/so/state/nic_names_pinned'
|
||||
- require:
|
||||
- file: common_sbin
|
||||
- file: statedir
|
||||
|
||||
common_sbin_jinja:
|
||||
file.recurse:
|
||||
- name: /usr/sbin
|
||||
|
||||
@@ -192,8 +192,21 @@ update_docker_containers() {
|
||||
echo "Unable to tag $image" >> "$LOG_FILE" 2>&1
|
||||
exit 1
|
||||
}
|
||||
docker push $HOSTNAME:5000/$IMAGEREPO/$image >> "$LOG_FILE" 2>&1 || {
|
||||
echo "Unable to push $image" >> "$LOG_FILE" 2>&1
|
||||
# Push to the embedded registry via a registry-to-registry copy. Avoids
|
||||
# `docker push`, which on Docker 29.x with the containerd image store
|
||||
# represents freshly-pulled images as an index whose layer content
|
||||
# isn't reachable through the push path. The local `docker tag` above
|
||||
# is preserved so so-image-pull's `:5000` existence check still works.
|
||||
# Pin to the digest already gpg-verified above so we copy exactly the
|
||||
# bytes we approved.
|
||||
local VERIFIED_REF
|
||||
VERIFIED_REF=$(echo "$DOCKERINSPECT" | jq -r ".[0].RepoDigests[] | select(. | contains(\"$CONTAINER_REGISTRY\"))" | head -n 1)
|
||||
if [ -z "$VERIFIED_REF" ] || [ "$VERIFIED_REF" = "null" ]; then
|
||||
echo "Unable to determine verified digest for $image" >> "$LOG_FILE" 2>&1
|
||||
exit 1
|
||||
fi
|
||||
docker buildx imagetools create --tag $HOSTNAME:5000/$IMAGEREPO/$image "$VERIFIED_REF" >> "$LOG_FILE" 2>&1 || {
|
||||
echo "Unable to copy $image to embedded registry" >> "$LOG_FILE" 2>&1
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
@@ -165,6 +165,8 @@ if [[ $EXCLUDE_FALSE_POSITIVE_ERRORS == 'Y' ]]; then
|
||||
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|upgrading component template" # false positive (elasticsearch index or template names contain 'error')
|
||||
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|upgrading composable template" # false positive (elasticsearch composable template names contain 'error')
|
||||
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|Error while parsing document for index \[.ds-logs-kratos-so-.*object mapping for \[file\]" # false positive (mapping error occuring BEFORE kratos index has rolled over in 2.4.210)
|
||||
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|No such container" # false positive (telegraf trying to run stats on an old container)
|
||||
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|passwords do not match" # false positive (automated hydra test)
|
||||
fi
|
||||
|
||||
if [[ $EXCLUDE_KNOWN_ERRORS == 'Y' ]]; then
|
||||
@@ -227,7 +229,7 @@ if [[ $EXCLUDE_KNOWN_ERRORS == 'Y' ]]; then
|
||||
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|from NIC checksum offloading" # zeek reporter.log
|
||||
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|marked for removal" # docker container getting recycled
|
||||
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|tcp 127.0.0.1:6791: bind: address already in use" # so-elastic-fleet agent restarting. Seen starting w/ 8.18.8 https://github.com/elastic/kibana/issues/201459
|
||||
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|TransformTask\] \[logs-(tychon|aws_billing|microsoft_defender_endpoint|armis|o365_metrics|microsoft_sentinel|snyk).*user so_kibana lacks the required permissions \[(logs|metrics)-\1" # Known issue with integrations starting transform jobs that are explicitly not allowed to start as a system user. (installed as so_elastic / so_kibana)
|
||||
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|TransformTask\] \[logs-(tychon|aws_billing|microsoft_defender_endpoint|armis|o365_metrics|microsoft_sentinel|snyk|cyera|island_browser).*user so_kibana lacks the required permissions \[(logs|metrics)-\1" # Known issue with integrations starting transform jobs that are explicitly not allowed to start as a system user. This error should not be seen on fresh ES 9.3.3 installs or after SO 3.1.0 with soups addition of check_transform_health_and_reauthorize()
|
||||
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|manifest unknown" # appears in so-dockerregistry log for so-tcpreplay following docker upgrade to 29.2.1-1
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# so-nic-pin — pin physical NIC names by permanent MAC via classic by-MAC udev
|
||||
# rules, so a kernel upgrade can't renumber them.
|
||||
#
|
||||
# Security Onion binds its management and monitor interfaces BY NAME in pillar
|
||||
# (host:mainint, sensor:mainint, and bond0 is built on a specific physical NIC).
|
||||
# A kernel upgrade can change the kernel/systemd-udevd predictable-naming output
|
||||
# and renumber those NICs (e.g. enp1s0 -> enp2s0), which breaks the grid: the
|
||||
# pillar references a name that no longer exists and bond/bridge bring-up fails.
|
||||
#
|
||||
# This writes /etc/udev/rules.d/70-persistent-net.rules pinning each PHYSICAL NIC
|
||||
# to its CURRENT name by its PERMANENT MAC, freezing the names across future kernel
|
||||
# changes. It only writes the rules file; it does NOT live-trigger a rename (the
|
||||
# rules apply on the next boot/kernel, and a live rename would be disruptive).
|
||||
#
|
||||
# Run-once: gated by the drop file /opt/so/state/nic_names_pinned. If the marker is
|
||||
# present the script does nothing, so an admin can pre-create it to opt out. Invoked
|
||||
# from the common state on every highstate; the marker keeps it a one-time setup.
|
||||
|
||||
NET_RULES_FILE="/etc/udev/rules.d/70-persistent-net.rules"
|
||||
MARKER="/opt/so/state/nic_names_pinned"
|
||||
|
||||
log() { echo -e "[so-nic-pin] $*"; }
|
||||
|
||||
# Echo "<name> <permanent-mac>" for every PHYSICAL NIC. A physical NIC is backed by a
|
||||
# real device (has device/driver), which excludes bond0/sobridge/docker0/veth*/lo whose
|
||||
# MACs are dynamic and must never be pinned. The PERMANENT MAC is used (ethtool -P, with
|
||||
# fallbacks), not the current one: an enslaved bond member's current MAC is rewritten to
|
||||
# the bond's, so matching on it would be wrong/ambiguous.
|
||||
physical_nics() {
|
||||
local path n mac
|
||||
for path in /sys/class/net/*; do
|
||||
n="${path##*/}"
|
||||
[ "$n" = "lo" ] && continue
|
||||
[ -e "${path}/device/driver" ] || continue # real device only
|
||||
mac="$(ethtool -P "$n" 2>/dev/null | awk '/Permanent address/{print $NF}')"
|
||||
case "$mac" in ""|00:00:00:00:00:00) mac="$(cat "${path}/bonding_slave/perm_hwaddr" 2>/dev/null)" ;; esac
|
||||
case "$mac" in ""|00:00:00:00:00:00) mac="$(cat "${path}/address" 2>/dev/null)" ;; esac
|
||||
case "$mac" in ""|00:00:00:00:00:00) continue ;; esac
|
||||
echo "$n $mac"
|
||||
done
|
||||
}
|
||||
|
||||
# Turn "<name> <mac>" lines on stdin into classic by-MAC persistent-net udev rules.
|
||||
render_net_rules() {
|
||||
echo "# Generated by so-nic-pin: pin NIC names by MAC so kernel upgrades can't renumber them."
|
||||
echo "# Security Onion binds its management/monitor interfaces by name; do not hand-edit."
|
||||
local n mac
|
||||
while read -r n mac; do
|
||||
[ -n "$n" ] || continue
|
||||
printf 'SUBSYSTEM=="net", ACTION=="add", DRIVERS=="?*", ATTR{address}=="%s", NAME="%s"\n' \
|
||||
"$mac" "$n"
|
||||
done
|
||||
}
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || exit 0 # salt runs us as root; bail quietly otherwise
|
||||
[ -e "${MARKER}" ] && exit 0 # run-once guard (mirrors the state's unless)
|
||||
|
||||
nics="$(physical_nics)"
|
||||
if [ -z "${nics}" ]; then
|
||||
log "no physical NICs detected — nothing to pin (will retry on next highstate)"
|
||||
exit 0 # do NOT drop the marker; let it retry later
|
||||
fi
|
||||
|
||||
log "pinning physical NICs by permanent MAC:"
|
||||
echo "${nics}" | sed 's/^/ /'
|
||||
|
||||
[ -f "${NET_RULES_FILE}" ] && cp -f "${NET_RULES_FILE}" "${NET_RULES_FILE}.bak"
|
||||
echo "${nics}" | render_net_rules > "${NET_RULES_FILE}" || {
|
||||
log "ERROR: failed to write ${NET_RULES_FILE}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
mkdir -p "$(dirname "${MARKER}")" && touch "${MARKER}"
|
||||
log "wrote ${NET_RULES_FILE} ($(grep -c '^SUBSYSTEM' "${NET_RULES_FILE}") NIC(s) pinned); dropped ${MARKER}"
|
||||
@@ -51,6 +51,16 @@ so-elastic-fleet-package-registry:
|
||||
- {{ ULIMIT.name }}={{ ULIMIT.soft }}:{{ ULIMIT.hard }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
wait_for_so-elastic-fleet-package-registry:
|
||||
http.wait_for_successful_query:
|
||||
- name: "http://localhost:8080/health"
|
||||
- status: 200
|
||||
- wait_for: 300
|
||||
- request_interval: 15
|
||||
- require:
|
||||
- docker_container: so-elastic-fleet-package-registry
|
||||
|
||||
delete_so-elastic-fleet-package-registry_so-status.disabled:
|
||||
file.uncomment:
|
||||
- name: /opt/so/conf/so-status/so-status.conf
|
||||
|
||||
@@ -26,7 +26,9 @@ include:
|
||||
wait_for_elasticsearch_elasticfleet:
|
||||
cmd.run:
|
||||
- name: so-elasticsearch-wait
|
||||
{% endif %}
|
||||
|
||||
{% if GLOBALS.role == "so-fleet" %}
|
||||
# Sync Elastic Agent artifacts to Fleet Node
|
||||
elasticagent_syncartifacts:
|
||||
file.recurse:
|
||||
@@ -99,6 +101,17 @@ so-elastic-fleet:
|
||||
- file: trusttheca
|
||||
- x509: etc_elasticfleet_key
|
||||
- x509: etc_elasticfleet_crt
|
||||
|
||||
wait_for_so-elastic-fleet:
|
||||
http.wait_for_successful_query:
|
||||
- name: "https://localhost:8220/api/status"
|
||||
- ssl: True
|
||||
- verify_ssl: False
|
||||
- status: 200
|
||||
- wait_for: 300
|
||||
- request_interval: 15
|
||||
- require:
|
||||
- docker_container: so-elastic-fleet
|
||||
{% endif %}
|
||||
|
||||
delete_so-elastic-fleet_so-status.disabled:
|
||||
|
||||
@@ -9,26 +9,19 @@
|
||||
|
||||
include:
|
||||
- elasticfleet.config
|
||||
- kibana.enabled
|
||||
|
||||
# If enabled, automatically update Fleet Logstash Outputs
|
||||
{% if ELASTICFLEETMERGED.config.server.enable_auto_configuration and grains.role not in ['so-import', 'so-eval'] %}
|
||||
{% if ELASTICFLEETMERGED.config.server.enable_auto_configuration %}
|
||||
{% if grains.role not in ['so-import', 'so-eval']%}
|
||||
so-elastic-fleet-auto-configure-logstash-outputs:
|
||||
cmd.run:
|
||||
- name: /usr/sbin/so-elastic-fleet-outputs-update
|
||||
- retry:
|
||||
attempts: 4
|
||||
interval: 30
|
||||
|
||||
{# Separate from above in order to catch elasticfleet-logstash.crt changes and force update to fleet output policy #}
|
||||
so-elastic-fleet-auto-configure-logstash-outputs-force:
|
||||
cmd.run:
|
||||
- name: /usr/sbin/so-elastic-fleet-outputs-update --certs
|
||||
- retry:
|
||||
attempts: 4
|
||||
interval: 30
|
||||
- onchanges:
|
||||
- x509: etc_elasticfleet_logstash_crt
|
||||
- x509: elasticfleet_kafka_crt
|
||||
- require:
|
||||
- http: wait_for_so-kibana
|
||||
{% endif %}
|
||||
|
||||
# If enabled, automatically update Fleet Server URLs & ES Connection
|
||||
@@ -38,6 +31,9 @@ so-elastic-fleet-auto-configure-server-urls:
|
||||
- retry:
|
||||
attempts: 4
|
||||
interval: 30
|
||||
- require:
|
||||
- http: wait_for_so-kibana
|
||||
{% endif %}
|
||||
|
||||
# Automatically update Fleet Server Elasticsearch URLs & Agent Artifact URLs
|
||||
so-elastic-fleet-auto-configure-elasticsearch-urls:
|
||||
@@ -46,6 +42,8 @@ so-elastic-fleet-auto-configure-elasticsearch-urls:
|
||||
- retry:
|
||||
attempts: 4
|
||||
interval: 30
|
||||
- require:
|
||||
- http: wait_for_so-kibana
|
||||
|
||||
so-elastic-fleet-auto-configure-artifact-urls:
|
||||
cmd.run:
|
||||
@@ -53,6 +51,8 @@ so-elastic-fleet-auto-configure-artifact-urls:
|
||||
- retry:
|
||||
attempts: 4
|
||||
interval: 30
|
||||
- require:
|
||||
- http: wait_for_so-kibana
|
||||
|
||||
so-elastic-fleet-package-statefile:
|
||||
file.managed:
|
||||
@@ -64,7 +64,9 @@ so-elastic-fleet-package-upgrade:
|
||||
- name: /usr/sbin/so-elastic-fleet-package-upgrade
|
||||
- retry:
|
||||
attempts: 3
|
||||
interval: 10
|
||||
interval: 30
|
||||
- require:
|
||||
- http: wait_for_so-kibana
|
||||
- onchanges:
|
||||
- file: /opt/so/state/elastic_fleet_packages.txt
|
||||
|
||||
@@ -74,6 +76,8 @@ so-elastic-fleet-integrations:
|
||||
- retry:
|
||||
attempts: 3
|
||||
interval: 10
|
||||
- require:
|
||||
- http: wait_for_so-kibana
|
||||
|
||||
so-elastic-agent-grid-upgrade:
|
||||
cmd.run:
|
||||
@@ -81,6 +85,8 @@ so-elastic-agent-grid-upgrade:
|
||||
- retry:
|
||||
attempts: 12
|
||||
interval: 5
|
||||
- require:
|
||||
- http: wait_for_so-kibana
|
||||
|
||||
so-elastic-fleet-integration-upgrade:
|
||||
cmd.run:
|
||||
@@ -88,16 +94,22 @@ so-elastic-fleet-integration-upgrade:
|
||||
- retry:
|
||||
attempts: 3
|
||||
interval: 10
|
||||
- require:
|
||||
- http: wait_for_so-kibana
|
||||
|
||||
{# Optional integrations script doesn't need the retries like so-elastic-fleet-integration-upgrade which loads the default integrations #}
|
||||
so-elastic-fleet-addon-integrations:
|
||||
cmd.run:
|
||||
- name: /usr/sbin/so-elastic-fleet-optional-integrations-load
|
||||
- require:
|
||||
- http: wait_for_so-kibana
|
||||
|
||||
{% if ELASTICFLEETMERGED.config.defend_filters.enable_auto_configuration %}
|
||||
so-elastic-defend-manage-filters-file-watch:
|
||||
cmd.run:
|
||||
- name: python3 /sbin/so-elastic-defend-manage-filters.py -c /opt/so/conf/elasticsearch/curl.config -d /opt/so/conf/elastic-fleet/defend-exclusions/disabled-filters.yaml -i /nsm/securityonion-resources/event_filters/ -i /opt/so/conf/elastic-fleet/defend-exclusions/rulesets/custom-filters/ &>> /opt/so/log/elasticfleet/elastic-defend-manage-filters.log
|
||||
- require:
|
||||
- http: wait_for_so-kibana
|
||||
- onchanges:
|
||||
- file: elasticdefendcustom
|
||||
- file: elasticdefenddisabled
|
||||
|
||||
@@ -240,7 +240,7 @@ elastic_fleet_policy_create() {
|
||||
--arg DESC "$DESC" \
|
||||
--arg TIMEOUT $TIMEOUT \
|
||||
--arg FLEETSERVER "$FLEETSERVER" \
|
||||
'{"name": $NAME,"id":$NAME,"description":$DESC,"namespace":"default","monitoring_enabled":["logs"],"inactivity_timeout":$TIMEOUT,"has_fleet_server":$FLEETSERVER}'
|
||||
'{"name": $NAME,"id":$NAME,"description":$DESC,"namespace":"default","monitoring_enabled":["logs"],"inactivity_timeout":$TIMEOUT,"has_fleet_server":$FLEETSERVER,"advanced_settings":{"agent_logging_level": "warning"}}'
|
||||
)
|
||||
# Create Fleet Policy
|
||||
if ! fleet_api "agent_policies" -XPOST -H 'kbn-xsrf: true' -H 'Content-Type: application/json' -d "$JSON_STRING"; then
|
||||
|
||||
@@ -108,9 +108,12 @@ if [ ! -f /opt/so/state/eaintegrations.txt ]; then
|
||||
done
|
||||
|
||||
# Only create the state file if all policies were created/updated successfully
|
||||
if [[ "$RETURN_CODE" != "1" ]]; then
|
||||
if [[ $RETURN_CODE -eq 0 ]]; then
|
||||
touch /opt/so/state/eaintegrations.txt
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
exit $RETURN_CODE
|
||||
echo "Fleet integration policies already loaded."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -235,6 +235,16 @@ function update_kafka_outputs() {
|
||||
|
||||
{% endif %}
|
||||
|
||||
# Compare the current Elastic Fleet certificate against what is on disk
|
||||
POLICY_CERT_SHA=$(jq -r '.item.ssl.certificate' <<< $RAW_JSON | openssl x509 -noout -sha256 -fingerprint)
|
||||
DISK_CERT_SHA=$(openssl x509 -in /etc/pki/elasticfleet-logstash.crt -noout -sha256 -fingerprint)
|
||||
|
||||
if [[ "$POLICY_CERT_SHA" != "$DISK_CERT_SHA" ]]; then
|
||||
printf "Certificate on disk doesn't match certificate in policy - forcing update\n"
|
||||
UPDATE_CERTS=true
|
||||
FORCE_UPDATE=true
|
||||
fi
|
||||
|
||||
# Sort & hash the new list of Logstash Outputs
|
||||
NEW_LIST_JSON=$(jq --compact-output --null-input '$ARGS.positional' --args -- "${NEW_LIST[@]}")
|
||||
NEW_HASH=$(sha256sum <<< "$NEW_LIST_JSON" | awk '{print $1}')
|
||||
|
||||
@@ -8,18 +8,33 @@
|
||||
|
||||
. /usr/sbin/so-elastic-fleet-common
|
||||
|
||||
PKG_LOAD_FAILURES=0
|
||||
PKG_LOAD_FAILURES_NAMES=()
|
||||
|
||||
{%- for PACKAGE in SUPPORTED_PACKAGES %}
|
||||
echo "Upgrading {{ PACKAGE }} package..."
|
||||
if VERSION=$(elastic_fleet_package_latest_version_check "{{ PACKAGE }}"); then
|
||||
if ! elastic_fleet_package_install "{{ PACKAGE }}" "$VERSION"; then
|
||||
# exit 1 on failure to upgrade a default package, allow salt to handle retries
|
||||
echo -e "\nERROR: Failed to upgrade $PACKAGE to version: $VERSION"
|
||||
exit 1
|
||||
PKG_LOAD_FAILURES=$((PKG_LOAD_FAILURES + 1))
|
||||
PKG_LOAD_FAILURES_NAMES+=("{{ PACKAGE }}")
|
||||
fi
|
||||
else
|
||||
echo -e "\nERROR: Failed to get version information for integration $PACKAGE"
|
||||
PKG_LOAD_FAILURES=$((PKG_LOAD_FAILURES + 1))
|
||||
PKG_LOAD_FAILURES_NAMES+=("{{ PACKAGE }}")
|
||||
fi
|
||||
echo
|
||||
{%- endfor %}
|
||||
|
||||
if [ $PKG_LOAD_FAILURES -gt 0 ]; then
|
||||
echo "ERROR: Failed to upgrade $PKG_LOAD_FAILURES package(s):"
|
||||
for PKG in "${PKG_LOAD_FAILURES_NAMES[@]}"; do
|
||||
echo " - $PKG"
|
||||
done
|
||||
# exit 1 on failure to upgrade a default package, allow salt to handle retries
|
||||
exit 1
|
||||
else
|
||||
echo "Successfully upgraded all packages."
|
||||
fi
|
||||
|
||||
echo
|
||||
/usr/sbin/so-elasticsearch-templates-load
|
||||
|
||||
@@ -9,9 +9,12 @@
|
||||
{% from 'elasticsearch/config.map.jinja' import ELASTICSEARCHMERGED %}
|
||||
{% from 'elasticsearch/template.map.jinja' import ES_INDEX_SETTINGS, SO_MANAGED_INDICES %}
|
||||
{% if GLOBALS.role != 'so-heavynode' %}
|
||||
{% from 'elasticsearch/template.map.jinja' import ALL_ADDON_SETTINGS %}
|
||||
{% from 'elasticsearch/template.map.jinja' import ALL_ADDON_SETTINGS, ADDON_INDICES %}
|
||||
{% endif %}
|
||||
|
||||
include:
|
||||
- elasticsearch.enabled
|
||||
|
||||
escomponenttemplates:
|
||||
file.recurse:
|
||||
- name: /opt/so/conf/elasticsearch/templates/component
|
||||
@@ -35,6 +38,20 @@ so_index_template_dir:
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
|
||||
{% if GLOBALS.role != "so-heavynode" %}
|
||||
# Clean up legacy and non-SO managed templates from the elasticsearch/templates/addon-index/ directory
|
||||
addon_index_template_dir:
|
||||
file.directory:
|
||||
- name: /opt/so/conf/elasticsearch/templates/addon-index
|
||||
- clean: True
|
||||
{%- if ADDON_INDICES %}
|
||||
- require:
|
||||
{%- for index in ADDON_INDICES %}
|
||||
- file: addon_index_template_{{index}}
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{% endif %}
|
||||
|
||||
# Auto-generate index templates for SO managed indices (directly defined in elasticsearch/defaults.yaml)
|
||||
# These index templates are for the core SO datasets and are always required
|
||||
{% for index, settings in ES_INDEX_SETTINGS.items() %}
|
||||
|
||||
@@ -3958,10 +3958,13 @@ elasticsearch:
|
||||
- vulnerability-mappings
|
||||
- common-settings
|
||||
- common-dynamic-mappings
|
||||
- logs-redis.log@package
|
||||
- logs-redis.log@custom
|
||||
data_stream:
|
||||
allow_custom_routing: false
|
||||
hidden: false
|
||||
ignore_missing_component_templates: []
|
||||
ignore_missing_component_templates:
|
||||
- logs-redis.log@custom
|
||||
index_patterns:
|
||||
- logs-redis.log*
|
||||
priority: 501
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
{ "set": { "if": "ctx.event?.dataset != null && !ctx.event.dataset.contains('.')", "field": "event.dataset", "value": "{{event.module}}.{{event.dataset}}" } },
|
||||
{ "split": { "if": "ctx.event?.dataset != null && ctx.event.dataset.contains('.')", "field": "event.dataset", "separator": "\\.", "target_field": "dataset_tag_temp" } },
|
||||
{ "append": { "if": "ctx.dataset_tag_temp != null", "field": "tags", "value": "{{dataset_tag_temp.1}}" } },
|
||||
{ "grok": { "if": "ctx.http?.response?.status_code != null", "field": "http.response.status_code", "patterns": ["%{NUMBER:http.response.status_code:long} %{GREEDYDATA}"]} },
|
||||
{ "grok": { "if": "ctx.http?.response?.status_code instanceof String", "field": "http.response.status_code", "patterns": ["%{NUMBER:http.response.status_code:long}(?:\\s+%{GREEDYDATA})?"], "ignore_failure": true } },
|
||||
{ "convert": { "if": "ctx.http?.response?.status_code != null && !(ctx.http.response.status_code instanceof Number)", "field": "http.response.status_code", "type": "long", "ignore_failure": true } },
|
||||
{ "set": { "if": "ctx?.metadata?.kafka != null" , "field": "kafka.id", "value": "{{metadata.kafka.partition}}{{metadata.kafka.offset}}{{metadata.kafka.timestamp}}", "ignore_failure": true } },
|
||||
{ "remove": { "field": [ "message2", "type", "fields", "category", "module", "dataset", "dataset_tag_temp", "event.dataset_temp" ], "ignore_missing": true, "ignore_failure": true } },
|
||||
{ "pipeline": { "name": "global@custom", "ignore_missing_pipeline": true, "description": "[Fleet] Global pipeline for all data streams" } }
|
||||
|
||||
@@ -177,12 +177,84 @@
|
||||
"description": "Extract IPs from Elastic Agent events (host.ip) and adds them to related.ip"
|
||||
}
|
||||
},
|
||||
{
|
||||
"script": {
|
||||
"description": "Snapshot event.ingested into _tmp.event_ingested_pre_fleet before .fleet_final_pipeline-1 overwrites it with ES ingest time",
|
||||
"lang": "painless",
|
||||
"if": "ctx.event?.ingested != null && ctx.event?.created == null",
|
||||
"ignore_failure": true,
|
||||
"source": "ctx.putIfAbsent('_tmp', [:]); ctx._tmp.event_ingested_pre_fleet = ctx.event.ingested;"
|
||||
}
|
||||
},
|
||||
{
|
||||
"pipeline": {
|
||||
"name": ".fleet_final_pipeline-1",
|
||||
"ignore_missing_pipeline": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"script": {
|
||||
"description": "Calculate time from Elastic Agent to Logstash.",
|
||||
"lang": "painless",
|
||||
"if": "ctx._tmp?.logstash_from_agent != null",
|
||||
"ignore_failure": true,
|
||||
"source": "ZonedDateTime start = ctx._tmp.event_ingested_pre_fleet != null ? ZonedDateTime.parse(ctx._tmp.event_ingested_pre_fleet) : ZonedDateTime.parse(ctx['@timestamp']); ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_elasticagent_to_logstash = ChronoUnit.SECONDS.between(start, ZonedDateTime.parse(ctx._tmp.logstash_from_agent));"
|
||||
}
|
||||
},
|
||||
{
|
||||
"script": {
|
||||
"description": "Calculate time from Logstash to Redis",
|
||||
"lang": "painless",
|
||||
"if": "ctx._tmp?.logstash_from_agent != null && ctx._tmp?.logstash_to_redis != null",
|
||||
"ignore_failure": true,
|
||||
"source": "ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_logstash_to_redis = ChronoUnit.SECONDS.between(ZonedDateTime.parse(ctx._tmp.logstash_from_agent), ZonedDateTime.parse(ctx._tmp.logstash_to_redis));"
|
||||
}
|
||||
},
|
||||
{
|
||||
"script": {
|
||||
"description": "Calculate time message spends in redis queue (logstash delay in pulling event).",
|
||||
"lang": "painless",
|
||||
"if": "ctx._tmp?.logstash_to_redis != null && ctx._tmp?.logstash_from_redis != null",
|
||||
"ignore_failure": true,
|
||||
"source": "ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_redis_to_logstash = ChronoUnit.SECONDS.between(ZonedDateTime.parse(ctx._tmp.logstash_to_redis), ZonedDateTime.parse(ctx._tmp.logstash_from_redis));"
|
||||
}
|
||||
},
|
||||
{
|
||||
"script": {
|
||||
"description": "Calculate time from Logstash to Elasticsearch (after read from Redis).",
|
||||
"lang": "painless",
|
||||
"if": "ctx._tmp?.logstash_from_redis != null",
|
||||
"ignore_failure": true,
|
||||
"source": "ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_logstash_to_elasticsearch = ChronoUnit.SECONDS.between(ZonedDateTime.parse(ctx._tmp.logstash_from_redis), metadata().now);"
|
||||
}
|
||||
},
|
||||
{
|
||||
"script": {
|
||||
"description": "Calculate time from Elastic Agent to Kafka.",
|
||||
"lang": "painless",
|
||||
"if": "ctx._tmp?.logstash_from_kafka != null && ctx._tmp?.logstash_from_agent == null",
|
||||
"ignore_failure": true,
|
||||
"source": "ZonedDateTime start = ctx._tmp.event_ingested_pre_fleet != null ? ZonedDateTime.parse(ctx._tmp.event_ingested_pre_fleet) : ZonedDateTime.parse(ctx['@timestamp']); ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_elasticagent_to_kafka = ChronoUnit.SECONDS.between(start, ZonedDateTime.parse(ctx._tmp.logstash_from_kafka));"
|
||||
}
|
||||
},
|
||||
{
|
||||
"script": {
|
||||
"description": "Calculate time message spends in Kafka queue (logstash delay in pulling event).",
|
||||
"lang": "painless",
|
||||
"if": "ctx._tmp?.logstash_from_kafka != null && ctx.metadata?.kafka?.timestamp != null && ctx._tmp?.logstash_from_agent == null",
|
||||
"ignore_failure": true,
|
||||
"source": "ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_kafka_queue = ChronoUnit.SECONDS.between(ZonedDateTime.ofInstant(Instant.ofEpochMilli(Long.parseLong(ctx.metadata.kafka.timestamp.toString())), ZoneId.of('UTC')), ZonedDateTime.parse(ctx._tmp.logstash_from_kafka));"
|
||||
}
|
||||
},
|
||||
{
|
||||
"script": {
|
||||
"description": "Calculate time from Logstash to Elasticsearch (after read from Kafka).",
|
||||
"lang": "painless",
|
||||
"if": "ctx._tmp?.logstash_from_kafka != null && ctx._tmp?.logstash_from_agent == null",
|
||||
"ignore_failure": true,
|
||||
"source": "ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_kafka_to_elasticsearch = ChronoUnit.SECONDS.between(ZonedDateTime.parse(ctx._tmp.logstash_from_kafka), metadata().now);"
|
||||
}
|
||||
},
|
||||
{
|
||||
"remove": {
|
||||
"field": "event.agent_id_status",
|
||||
@@ -202,7 +274,8 @@
|
||||
"event.dataset_temp",
|
||||
"dataset_tag_temp",
|
||||
"module_temp",
|
||||
"datastream_dataset_temp"
|
||||
"datastream_dataset_temp",
|
||||
"_tmp"
|
||||
],
|
||||
"ignore_missing": true,
|
||||
"ignore_failure": true
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"description": "zeek.ja4d",
|
||||
"processors": [
|
||||
{
|
||||
"set": {
|
||||
"field": "event.dataset",
|
||||
"value": "ja4d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"remove": {
|
||||
"field": [
|
||||
"host"
|
||||
],
|
||||
"ignore_failure": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"field": "message",
|
||||
"target_field": "message2",
|
||||
"ignore_failure": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"rename": {
|
||||
"field": "message2.ja4d",
|
||||
"target_field": "hash.ja4d",
|
||||
"ignore_missing": true,
|
||||
"if": "ctx?.message2?.ja4d != null && ctx.message2.ja4d.length() > 0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"rename": {
|
||||
"field": "message2.client_mac",
|
||||
"target_field": "host.mac",
|
||||
"ignore_missing": true,
|
||||
"if": "ctx?.message2?.client_mac != null && ctx.message2.client_mac.length() > 0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"rename": {
|
||||
"field": "message2.hostname",
|
||||
"target_field": "host.hostname",
|
||||
"ignore_missing": true,
|
||||
"if": "ctx?.message2?.hostname != null && ctx.message2.hostname.length() > 0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"rename": {
|
||||
"field": "message2.requested_ip",
|
||||
"target_field": "dhcp.requested_address",
|
||||
"ignore_missing": true,
|
||||
"if": "ctx?.message2?.requested_ip != null && ctx.message2.requested_ip.length() > 0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"rename": {
|
||||
"field": "message2.vendor_class_id",
|
||||
"target_field": "zeek.ja4d.vendor_class_id",
|
||||
"ignore_missing": true,
|
||||
"if": "ctx?.message2?.vendor_class_id != null && ctx.message2.vendor_class_id.length() > 0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"pipeline": {
|
||||
"name": "zeek.common"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -61,15 +61,25 @@
|
||||
{% if ALL_ADDON_SETTINGS_ORIG.keys() | length > 0 %}
|
||||
{% for index in ALL_ADDON_SETTINGS_ORIG.keys() %}
|
||||
{% do ALL_ADDON_SETTINGS_GLOBAL_OVERRIDES.update({index: salt['defaults.merge'](ALL_ADDON_SETTINGS_ORIG[index], PILLAR_GLOBAL_OVERRIDES, in_place=False)}) %}
|
||||
{# Explicitly excluding addon indices from ES_INDEX_SETTINGS_ORIG
|
||||
When manager.soc_managed_annotations runs, new entries are added to the salt/elasticsearch/defaults.yaml file to support 'revert to default' functionality.
|
||||
Subsequent map renders will then incorrectly include 'integration X' in 'ES_INDEX_SETTINGS_ORIG' due to being in the defaults.yaml file. #}
|
||||
{% if index in ES_INDEX_SETTINGS_ORIG.keys() %}
|
||||
{% do ES_INDEX_SETTINGS_ORIG.pop(index) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% set ES_INDEX_SETTINGS = {} %}
|
||||
{% macro create_final_index_template(DEFINED_SETTINGS, GLOBAL_OVERRIDES, FINAL_INDEX_SETTINGS) %}
|
||||
{% macro create_final_index_template(DEFINED_SETTINGS, GLOBAL_OVERRIDES, FINAL_INDEX_SETTINGS, EXCLUDE_INDICES=[]) %}
|
||||
|
||||
{% do GLOBAL_OVERRIDES.update(salt['defaults.merge'](GLOBAL_OVERRIDES, ES_INDEX_PILLAR, in_place=False)) %}
|
||||
{% for index, settings in GLOBAL_OVERRIDES.items() %}
|
||||
|
||||
{% if index in EXCLUDE_INDICES %}
|
||||
{% continue %}
|
||||
{% endif %}
|
||||
|
||||
{# prevent this action from being performed on custom defined indices. #}
|
||||
{# the custom defined index is not present in either of the dictionaries and fails to reder. #}
|
||||
{% if index in DEFINED_SETTINGS and index in GLOBAL_OVERRIDES %}
|
||||
@@ -150,10 +160,19 @@
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
|
||||
{{ create_final_index_template(ES_INDEX_SETTINGS_ORIG, ES_INDEX_SETTINGS_GLOBAL_OVERRIDES, ES_INDEX_SETTINGS) }}
|
||||
{{ create_final_index_template(ALL_ADDON_SETTINGS_ORIG, ALL_ADDON_SETTINGS_GLOBAL_OVERRIDES, ALL_ADDON_SETTINGS) }}
|
||||
{# Exclude addon integrations from final ES_INDEX_SETTINGS #}
|
||||
{{ create_final_index_template(ES_INDEX_SETTINGS_ORIG, ES_INDEX_SETTINGS_GLOBAL_OVERRIDES, ES_INDEX_SETTINGS, ALL_ADDON_SETTINGS_ORIG.keys() | list ) }}
|
||||
|
||||
{# Exclude SO managed indices, otherwise ALL_ADDON_SETTINGS will include pillar values
|
||||
of core integrations without merging defaults, resulting in an overlapping, but bad index template being generated. #}
|
||||
{{ create_final_index_template(ALL_ADDON_SETTINGS_ORIG, ALL_ADDON_SETTINGS_GLOBAL_OVERRIDES, ALL_ADDON_SETTINGS, ES_INDEX_SETTINGS_ORIG.keys() | list ) }}
|
||||
|
||||
{% set SO_MANAGED_INDICES = [] %}
|
||||
{% for index, settings in ES_INDEX_SETTINGS.items() %}
|
||||
{% do SO_MANAGED_INDICES.append(index) %}
|
||||
{% endfor %}
|
||||
|
||||
{% set ADDON_INDICES = [] %}
|
||||
{% for index, settings in ALL_ADDON_SETTINGS.items() %}
|
||||
{% do ADDON_INDICES.append(index) %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -398,6 +398,7 @@ firewall:
|
||||
- elasticsearch_rest
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- sensoroni
|
||||
- yum
|
||||
- beats_5044
|
||||
@@ -410,6 +411,7 @@ firewall:
|
||||
portgroups:
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- sensoroni
|
||||
- yum
|
||||
- beats_5044
|
||||
@@ -427,6 +429,7 @@ firewall:
|
||||
- yum
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- sensoroni
|
||||
searchnode:
|
||||
portgroups:
|
||||
@@ -437,6 +440,7 @@ firewall:
|
||||
- yum
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- elastic_agent_control
|
||||
- elastic_agent_data
|
||||
- elastic_agent_update
|
||||
@@ -450,6 +454,7 @@ firewall:
|
||||
- yum
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- elastic_agent_control
|
||||
- elastic_agent_data
|
||||
- elastic_agent_update
|
||||
@@ -459,6 +464,7 @@ firewall:
|
||||
- yum
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- elastic_agent_control
|
||||
- elastic_agent_data
|
||||
- elastic_agent_update
|
||||
@@ -492,6 +498,7 @@ firewall:
|
||||
portgroups:
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- sensoroni
|
||||
- yum
|
||||
- elastic_agent_control
|
||||
@@ -502,6 +509,7 @@ firewall:
|
||||
- yum
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- elastic_agent_control
|
||||
- elastic_agent_data
|
||||
- elastic_agent_update
|
||||
@@ -610,6 +618,7 @@ firewall:
|
||||
- elasticsearch_rest
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- sensoroni
|
||||
- yum
|
||||
- beats_5044
|
||||
@@ -622,6 +631,7 @@ firewall:
|
||||
portgroups:
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- sensoroni
|
||||
- yum
|
||||
- beats_5044
|
||||
@@ -639,6 +649,7 @@ firewall:
|
||||
- yum
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- sensoroni
|
||||
searchnode:
|
||||
portgroups:
|
||||
@@ -649,6 +660,7 @@ firewall:
|
||||
- yum
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- elastic_agent_control
|
||||
- elastic_agent_data
|
||||
- elastic_agent_update
|
||||
@@ -662,6 +674,7 @@ firewall:
|
||||
- yum
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- elastic_agent_control
|
||||
- elastic_agent_data
|
||||
- elastic_agent_update
|
||||
@@ -671,6 +684,7 @@ firewall:
|
||||
- yum
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- elastic_agent_control
|
||||
- elastic_agent_data
|
||||
- elastic_agent_update
|
||||
@@ -702,6 +716,7 @@ firewall:
|
||||
portgroups:
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- sensoroni
|
||||
- yum
|
||||
- elastic_agent_control
|
||||
@@ -712,6 +727,7 @@ firewall:
|
||||
- yum
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- elastic_agent_control
|
||||
- elastic_agent_data
|
||||
- elastic_agent_update
|
||||
@@ -820,6 +836,7 @@ firewall:
|
||||
- elasticsearch_rest
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- sensoroni
|
||||
- yum
|
||||
- beats_5044
|
||||
@@ -832,6 +849,7 @@ firewall:
|
||||
portgroups:
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- sensoroni
|
||||
- yum
|
||||
- beats_5044
|
||||
@@ -849,6 +867,7 @@ firewall:
|
||||
- yum
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- sensoroni
|
||||
searchnode:
|
||||
portgroups:
|
||||
@@ -858,6 +877,7 @@ firewall:
|
||||
- yum
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- elastic_agent_control
|
||||
- elastic_agent_data
|
||||
- elastic_agent_update
|
||||
@@ -870,6 +890,7 @@ firewall:
|
||||
- yum
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- elastic_agent_control
|
||||
- elastic_agent_data
|
||||
- elastic_agent_update
|
||||
@@ -879,6 +900,7 @@ firewall:
|
||||
- yum
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- elastic_agent_control
|
||||
- elastic_agent_data
|
||||
- elastic_agent_update
|
||||
@@ -912,6 +934,7 @@ firewall:
|
||||
portgroups:
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- sensoroni
|
||||
- yum
|
||||
- elastic_agent_control
|
||||
@@ -922,6 +945,7 @@ firewall:
|
||||
- yum
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- elastic_agent_control
|
||||
- elastic_agent_data
|
||||
- elastic_agent_update
|
||||
@@ -1040,6 +1064,7 @@ firewall:
|
||||
- elasticsearch_rest
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- sensoroni
|
||||
- yum
|
||||
- beats_5044
|
||||
@@ -1052,6 +1077,7 @@ firewall:
|
||||
portgroups:
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- sensoroni
|
||||
- yum
|
||||
- beats_5044
|
||||
@@ -1063,6 +1089,7 @@ firewall:
|
||||
portgroups:
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- sensoroni
|
||||
- yum
|
||||
- beats_5044
|
||||
@@ -1074,6 +1101,7 @@ firewall:
|
||||
portgroups:
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- sensoroni
|
||||
- yum
|
||||
- redis
|
||||
@@ -1083,6 +1111,7 @@ firewall:
|
||||
portgroups:
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- sensoroni
|
||||
- yum
|
||||
- redis
|
||||
@@ -1093,6 +1122,7 @@ firewall:
|
||||
- yum
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- elastic_agent_control
|
||||
- elastic_agent_data
|
||||
- elastic_agent_update
|
||||
@@ -1129,6 +1159,7 @@ firewall:
|
||||
portgroups:
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- sensoroni
|
||||
- yum
|
||||
- elastic_agent_control
|
||||
@@ -1139,6 +1170,7 @@ firewall:
|
||||
- yum
|
||||
- docker_registry
|
||||
- influxdb
|
||||
- postgres
|
||||
- elastic_agent_control
|
||||
- elastic_agent_data
|
||||
- elastic_agent_update
|
||||
@@ -1482,6 +1514,7 @@ firewall:
|
||||
- kibana
|
||||
- redis
|
||||
- influxdb
|
||||
- postgres
|
||||
- elasticsearch_rest
|
||||
- elasticsearch_node
|
||||
- elastic_agent_control
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{% from 'vars/globals.map.jinja' import GLOBALS %}
|
||||
{% from 'docker/docker.map.jinja' import DOCKERMERGED %}
|
||||
{% from 'telegraf/map.jinja' import TELEGRAFMERGED %}
|
||||
{% import_yaml 'firewall/defaults.yaml' as FIREWALL_DEFAULT %}
|
||||
|
||||
{# add our ip to self #}
|
||||
@@ -56,16 +55,4 @@
|
||||
|
||||
{% endif %}
|
||||
|
||||
{# Open Postgres (5432) to minion hostgroups when Telegraf is configured to write to Postgres #}
|
||||
{% set TG_OUT = TELEGRAFMERGED.output | upper %}
|
||||
{% if TG_OUT in ['POSTGRES', 'BOTH'] %}
|
||||
{% if role.startswith('manager') or role == 'standalone' or role == 'eval' %}
|
||||
{% for r in ['sensor', 'searchnode', 'heavynode', 'receiver', 'fleet', 'idh', 'desktop', 'import'] %}
|
||||
{% if FIREWALL_DEFAULT.firewall.role[role].chain["DOCKER-USER"].hostgroups[r] is defined %}
|
||||
{% do FIREWALL_DEFAULT.firewall.role[role].chain["DOCKER-USER"].hostgroups[r].portgroups.append('postgres') %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% set FIREWALL_MERGED = salt['pillar.get']('firewall', FIREWALL_DEFAULT.firewall, merge=True) %}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
{% from 'allowed_states.map.jinja' import allowed_states %}
|
||||
{% if sls.split('.')[0] in allowed_states %}
|
||||
{% from 'docker/docker.map.jinja' import DOCKERMERGED %}
|
||||
{% from 'elasticsearch/config.map.jinja' import ELASTICSEARCHMERGED %}
|
||||
{% from 'vars/globals.map.jinja' import GLOBALS %}
|
||||
|
||||
include:
|
||||
@@ -60,6 +61,19 @@ so-kibana:
|
||||
- watch:
|
||||
- file: kibanaconfig
|
||||
|
||||
wait_for_so-kibana:
|
||||
http.wait_for_successful_query:
|
||||
- name: "http://localhost:5601/api/status"
|
||||
- username: 'so_elastic'
|
||||
- password: '{{ ELASTICSEARCHMERGED.auth.users.so_elastic_user.pass }}'
|
||||
- ssl: True
|
||||
- verify_ssl: False
|
||||
- status: 200
|
||||
- wait_for: 300
|
||||
- request_interval: 15
|
||||
- require:
|
||||
- docker_container: so-kibana
|
||||
|
||||
delete_so-kibana_so-status.disabled:
|
||||
file.uncomment:
|
||||
- name: /opt/so/conf/so-status/so-status.conf
|
||||
|
||||
@@ -103,7 +103,7 @@ kratos:
|
||||
config:
|
||||
session:
|
||||
lifespan:
|
||||
description: Defines the length of a login session.
|
||||
description: Defines the length of a login session before it will timeout, and require a new login.
|
||||
global: True
|
||||
helpLink: kratos
|
||||
whoami:
|
||||
|
||||
@@ -26,12 +26,12 @@ logstash:
|
||||
manager:
|
||||
- so/0011_input_endgame.conf
|
||||
- so/0012_input_elastic_agent.conf.jinja
|
||||
- so/0013_input_lumberjack_fleet.conf
|
||||
- so/0013_input_lumberjack_fleet.conf.jinja
|
||||
- so/9999_output_redis.conf.jinja
|
||||
receiver:
|
||||
- so/0011_input_endgame.conf
|
||||
- so/0012_input_elastic_agent.conf.jinja
|
||||
- so/0013_input_lumberjack_fleet.conf
|
||||
- so/0013_input_lumberjack_fleet.conf.jinja
|
||||
- so/9999_output_redis.conf.jinja
|
||||
search:
|
||||
- so/0900_input_redis.conf.jinja
|
||||
@@ -69,4 +69,5 @@ logstash:
|
||||
pipeline_x_batch_x_size: 125
|
||||
pipeline_x_ecs_compatibility: disabled
|
||||
dmz_nodes: []
|
||||
latency_metrics: False
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{%- from 'logstash/map.jinja' import LOGSTASH_MERGED %}
|
||||
input {
|
||||
elastic_agent {
|
||||
port => 5055
|
||||
@@ -11,10 +12,15 @@ input {
|
||||
}
|
||||
}
|
||||
filter {
|
||||
if ![metadata] {
|
||||
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
|
||||
ruby {
|
||||
code => "event.set('[_tmp][logstash_from_agent]', Time.now().utc.iso8601(3));"
|
||||
}
|
||||
{% endif %}
|
||||
if ![metadata] {
|
||||
mutate {
|
||||
rename => {"@metadata" => "metadata"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
input {
|
||||
elastic_agent {
|
||||
port => 5056
|
||||
tags => [ "elastic-agent", "fleet-lumberjack-input" ]
|
||||
ssl_enabled => true
|
||||
ssl_certificate => "/usr/share/logstash/elasticfleet-lumberjack.crt"
|
||||
ssl_key => "/usr/share/logstash/elasticfleet-lumberjack.key"
|
||||
ecs_compatibility => v8
|
||||
id => "fleet-lumberjack-in"
|
||||
codec => "json"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
filter {
|
||||
if ![metadata] {
|
||||
mutate {
|
||||
rename => {"@metadata" => "metadata"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{%- from 'logstash/map.jinja' import LOGSTASH_MERGED %}
|
||||
input {
|
||||
elastic_agent {
|
||||
port => 5056
|
||||
tags => [ "elastic-agent", "fleet-lumberjack-input" ]
|
||||
ssl_enabled => true
|
||||
ssl_certificate => "/usr/share/logstash/elasticfleet-lumberjack.crt"
|
||||
ssl_key => "/usr/share/logstash/elasticfleet-lumberjack.key"
|
||||
ecs_compatibility => v8
|
||||
id => "fleet-lumberjack-in"
|
||||
codec => "json"
|
||||
}
|
||||
}
|
||||
|
||||
filter {
|
||||
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
|
||||
ruby {
|
||||
code => "event.set('[_tmp][logstash_from_fleet]', Time.now().utc.iso8601(3));"
|
||||
}
|
||||
{% endif %}
|
||||
if ![metadata] {
|
||||
mutate {
|
||||
rename => {"@metadata" => "metadata"}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
{%- from 'logstash/map.jinja' import LOGSTASH_MERGED %}
|
||||
{%- set kafka_password = salt['pillar.get']('kafka:config:password') %}
|
||||
{%- set kafka_trustpass = salt['pillar.get']('kafka:config:trustpass') %}
|
||||
{%- set kafka_brokers = salt['pillar.get']('kafka:nodes', {}) %}
|
||||
@@ -30,6 +31,11 @@ input {
|
||||
}
|
||||
}
|
||||
filter {
|
||||
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
|
||||
ruby {
|
||||
code => "event.set('[_tmp][logstash_from_kafka]', Time.now().utc.iso8601(3));"
|
||||
}
|
||||
{% endif %}
|
||||
if ![metadata] {
|
||||
mutate {
|
||||
rename => { "@metadata" => "metadata" }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{%- from 'logstash/map.jinja' import LOGSTASH_REDIS_NODES with context %}
|
||||
{%- from 'logstash/map.jinja' import LOGSTASH_REDIS_NODES, LOGSTASH_MERGED %}
|
||||
{%- set REDIS_PASS = salt['pillar.get']('redis:config:requirepass') %}
|
||||
|
||||
{%- for index in range(LOGSTASH_REDIS_NODES|length) %}
|
||||
@@ -18,3 +18,10 @@ input {
|
||||
}
|
||||
{% endfor %}
|
||||
{% endfor -%}
|
||||
filter {
|
||||
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
|
||||
ruby {
|
||||
code => "event.set('[_tmp][logstash_from_redis]', Time.now().utc.iso8601(3));"
|
||||
}
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
{%- from 'logstash/map.jinja' import LOGSTASH_MERGED %}
|
||||
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
|
||||
filter {
|
||||
ruby {
|
||||
code => "event.set('[_tmp][logstash_to_elasticsearch]', Time.now().utc.iso8601(3));"
|
||||
}
|
||||
}
|
||||
{% endif %}
|
||||
output {
|
||||
if "elastic-agent" in [tags] and "so-ip-mappings" in [tags] {
|
||||
elasticsearch {
|
||||
|
||||
@@ -13,7 +13,14 @@ filter {
|
||||
add_tag => "fleet-lumberjack-{{ GLOBALS.hostname }}"
|
||||
}
|
||||
}
|
||||
|
||||
{%- from 'logstash/map.jinja' import LOGSTASH_MERGED %}
|
||||
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
|
||||
filter {
|
||||
ruby {
|
||||
code => "event.set('[_tmp][fleet_to_logstash]', Time.now().utc.iso8601(3));"
|
||||
}
|
||||
}
|
||||
{% endif %}
|
||||
output {
|
||||
lumberjack {
|
||||
codec => json
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
{%- from 'logstash/map.jinja' import LOGSTASH_MERGED %}
|
||||
{%- if grains.role in ['so-heavynode', 'so-receiver'] %}
|
||||
{%- set HOST = GLOBALS.hostname %}
|
||||
{%- else %}
|
||||
{%- set HOST = GLOBALS.manager %}
|
||||
{%- endif %}
|
||||
{%- set REDIS_PASS = salt['pillar.get']('redis:config:requirepass') %}
|
||||
|
||||
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
|
||||
filter {
|
||||
ruby {
|
||||
code => "event.set('[_tmp][logstash_to_redis]', Time.now().utc.iso8601(3));"
|
||||
}
|
||||
}
|
||||
{% endif %}
|
||||
output {
|
||||
redis {
|
||||
host => '{{ HOST }}'
|
||||
|
||||
@@ -86,3 +86,8 @@ logstash:
|
||||
multiline: True
|
||||
advanced: True
|
||||
forcedType: "[]string"
|
||||
latency_metrics:
|
||||
description: Enable latency metrics within events processed by logstash. Useful for pinpointing log ingest delay.
|
||||
forcedType: bool
|
||||
global: False
|
||||
advanced: True
|
||||
|
||||
@@ -31,11 +31,13 @@ sync_es_users:
|
||||
- http: wait_for_kratos
|
||||
- file: so-user.lock # require so-user.lock file to be missing
|
||||
|
||||
# we dont want this added too early in setup, so we add the onlyif to verify 'startup_states: highstate'
|
||||
# is in the minion config. That line is added before the final highstate during setup
|
||||
# we dont want this added too early in setup, so the onlyif gates on the
|
||||
# /opt/so/state/setup-complete marker. The marker is written by
|
||||
# mark_setup_complete in setup/so-functions just before the final setup
|
||||
# highstate (and by an upgrade-path state for systems set up under the old gate).
|
||||
so-user_sync:
|
||||
cron.present:
|
||||
- user: root
|
||||
- name: 'PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin:/root/bin /usr/sbin/so-user sync &>> /opt/so/log/soc/sync.log'
|
||||
- identifier: so-user_sync
|
||||
- onlyif: "grep -x 'startup_states: highstate' /etc/salt/minion"
|
||||
- onlyif: "test -e /opt/so/state/setup-complete"
|
||||
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
#!/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.
|
||||
|
||||
# Runs once per boot on managers (via so-boot-mine-update.service), before
|
||||
# so-boot-highstate.service. Waits for the responsive minion set to settle, pushes
|
||||
# mine.update, waits until every up minion has actually reported to the mine, then
|
||||
# warms the master's per-minion pillar cache so the mine-backed node pillars (node
|
||||
# IPs, ES/Redis/Logstash/hypervisor discovery -- some glob- and some pillar/grain-
|
||||
# targeted) are complete before the boot highstate renders them. Otherwise a node
|
||||
# that is up but not yet fully reported gets dropped from those pillars and torn
|
||||
# out of the configs they build (e.g. so-elasticsearch ExtraHosts -> container recreate).
|
||||
|
||||
MAX_WAIT=${MINE_UPDATE_MAX_WAIT:-180} # hard backstop only
|
||||
INTERVAL=10
|
||||
STABLE_CHECKS=3 # up-count must hold steady this many polls
|
||||
elapsed=0
|
||||
prev=-1
|
||||
stable=0
|
||||
up=0
|
||||
|
||||
# Wait for the *reachable* minion set to settle rather than for every accepted
|
||||
# key to report up: an operator may accept a minion's key and then intentionally
|
||||
# power off that host, so requiring up >= accepted would never be satisfied and
|
||||
# we'd always burn the full MAX_WAIT. Once the responsive count stops growing we
|
||||
# stop waiting and run mine.update against whoever is up.
|
||||
while [ "$elapsed" -lt "$MAX_WAIT" ]; do
|
||||
up=$(/usr/bin/salt-run manage.up --out=json 2>/dev/null \
|
||||
| python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null)
|
||||
up=${up:-0}
|
||||
if [ "$up" -gt 0 ] && [ "$up" -eq "$prev" ]; then
|
||||
stable=$((stable + 1))
|
||||
[ "$stable" -ge "$STABLE_CHECKS" ] && break
|
||||
else
|
||||
stable=0
|
||||
fi
|
||||
prev=$up
|
||||
sleep "$INTERVAL"
|
||||
elapsed=$((elapsed + INTERVAL))
|
||||
done
|
||||
|
||||
echo "so-boot-mine-update: ${up} minions up (settled after ${elapsed}s); running mine.update"
|
||||
/usr/bin/salt '*' mine.update --out=txt
|
||||
|
||||
# A node that is up but has not yet re-reported network.ip_addrs to the mine is
|
||||
# silently dropped from mine-backed pillars (elasticsearch:nodes, node_data, ...)
|
||||
# when highstate recompiles them -- which e.g. removes it from so-elasticsearch
|
||||
# ExtraHosts and forces a container recreate. After the broad mine.update above,
|
||||
# wait until every up minion actually has network.ip_addrs in the mine, re-pushing
|
||||
# mine.update to stragglers, before releasing the boot highstate. Bounded by the
|
||||
# same MAX_WAIT backstop so a slow/down node never blocks boot indefinitely.
|
||||
missing=""
|
||||
while [ "$elapsed" -lt "$MAX_WAIT" ]; do
|
||||
up_json=$(/usr/bin/salt-run manage.up --out=json 2>/dev/null)
|
||||
mine_json=$(/usr/bin/salt-run mine.get '*' network.ip_addrs tgt_type=glob --out=json 2>/dev/null)
|
||||
missing=$(printf '%s' "$up_json" | python3 -c '
|
||||
import sys, json
|
||||
up = set(json.load(sys.stdin) or [])
|
||||
mine = {k for k, v in (json.loads(sys.argv[1]) or {}).items() if v}
|
||||
print("\n".join(sorted(up - mine)))
|
||||
' "$mine_json" 2>/dev/null)
|
||||
if [ -z "$missing" ]; then
|
||||
echo "so-boot-mine-update: mine complete for all up minions after ${elapsed}s"
|
||||
break
|
||||
fi
|
||||
echo "so-boot-mine-update: mine missing up minion(s): $(echo $missing); re-running mine.update"
|
||||
for m in $missing; do /usr/bin/salt "$m" mine.update --out=txt; done
|
||||
sleep "$INTERVAL"
|
||||
elapsed=$((elapsed + INTERVAL))
|
||||
done
|
||||
[ -n "$missing" ] && echo "so-boot-mine-update: WARNING ${MAX_WAIT}s backstop hit; up minion(s) still absent from mine: $(echo $missing); highstate may drop them from configs"
|
||||
|
||||
# The pillar/compound-targeted node pillars (elasticsearch:nodes, redis:nodes,
|
||||
# logstash:nodes, hypervisor:nodes) resolve their target against the master's
|
||||
# per-minion data cache (grains+pillar in .../minions/<id>/data.p), populated only
|
||||
# when a minion's pillar is (re)compiled -- separately from the mine. A freshly
|
||||
# booted node can be in the mine (glob/node_data sees it) yet absent from that
|
||||
# cache, so it is dropped from those pillars and from the configs they build (e.g.
|
||||
# so-elasticsearch ExtraHosts). Force a synchronous pillar refresh so the master
|
||||
# caches every up node's pillar; refresh_pillar wait=True returns only once the
|
||||
# pillar is recompiled (and thus cached for matching). Retry stragglers <= MAX_WAIT.
|
||||
echo "so-boot-mine-update: warming master pillar cache for pillar/grain-targeted node pillars"
|
||||
/usr/bin/salt '*' saltutil.refresh_pillar wait=True --out=txt
|
||||
missing=""
|
||||
while [ "$elapsed" -lt "$MAX_WAIT" ]; do
|
||||
up_json=$(/usr/bin/salt-run manage.up --out=json 2>/dev/null)
|
||||
cached_json=$(/usr/bin/salt-run cache.pillar tgt='*' --out=json 2>/dev/null)
|
||||
missing=$(printf '%s' "$up_json" | python3 -c '
|
||||
import sys, json
|
||||
up = set(json.load(sys.stdin) or [])
|
||||
cached = {k for k, v in (json.loads(sys.argv[1]) or {}).items() if v}
|
||||
print("\n".join(sorted(up - cached)))
|
||||
' "$cached_json" 2>/dev/null)
|
||||
if [ -z "$missing" ]; then
|
||||
echo "so-boot-mine-update: pillar cache warm for all up minions after ${elapsed}s"
|
||||
break
|
||||
fi
|
||||
echo "so-boot-mine-update: pillar not yet cached for: $(echo $missing); refreshing"
|
||||
for m in $missing; do /usr/bin/salt "$m" saltutil.refresh_pillar wait=True --out=txt; done
|
||||
sleep "$INTERVAL"
|
||||
elapsed=$((elapsed + INTERVAL))
|
||||
done
|
||||
[ -n "$missing" ] && echo "so-boot-mine-update: WARNING ${MAX_WAIT}s backstop hit; pillar not cached for: $(echo $missing); pillar-targeted pillars may drop them"
|
||||
|
||||
# Log what the mine-backed pillars render so the boot-time state is inspectable.
|
||||
/usr/bin/salt-call saltutil.refresh_pillar >/dev/null 2>&1
|
||||
sleep 2
|
||||
for key in node_data elasticsearch:nodes; do
|
||||
rendered=$(/usr/bin/salt-call --out=json pillar.get "$key" 2>/dev/null \
|
||||
| python3 -c 'import sys,json; print(json.dumps(json.load(sys.stdin).get("local"), indent=2, sort_keys=True))' 2>/dev/null)
|
||||
echo "so-boot-mine-update: ${key} rendered as:"
|
||||
echo "${rendered:-null}"
|
||||
done
|
||||
exit 0
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one
|
||||
# or more contributor license agreements. Licensed under the Elastic License 2.0 as shown at
|
||||
# https://securityonion.net/license; you may not use this file except in compliance with the
|
||||
# Elastic License 2.0.
|
||||
|
||||
# Imports detection overrides (e.g. from so-detections-backup) into the so-detection
|
||||
# index. Reads <publicId>.<ext> files (NDJSON, one override per line) from a source
|
||||
# directory, looks up the matching detection by publicId+engine, validates each
|
||||
# override against the same rules SOC enforces, dedupes against existing overrides
|
||||
# (operational fields only), and appends new ones.
|
||||
|
||||
import argparse
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
from requests.auth import HTTPBasicAuth
|
||||
import urllib3
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
DEFAULT_INDEX = "so-detection"
|
||||
AUTH_FILE = "/opt/so/conf/elasticsearch/curl.config"
|
||||
ES_URL = "https://localhost:9200"
|
||||
|
||||
# Engines we know how to handle and the file extension the backup script writes.
|
||||
ENGINES = {
|
||||
"suricata": "txt",
|
||||
}
|
||||
|
||||
# Standard Suricata variables that ship with Security Onion. Anything else
|
||||
# referenced in an override is "custom" and the user needs to make sure it
|
||||
# exists in SOC Config before the override will function.
|
||||
BUILTIN_SURICATA_VARS = {
|
||||
"$HOME_NET", "$EXTERNAL_NET",
|
||||
"$HTTP_SERVERS", "$DNS_SERVERS", "$SQL_SERVERS", "$SMTP_SERVERS",
|
||||
"$TELNET_SERVERS", "$AIM_SERVERS", "$DC_SERVERS", "$MODBUS_SERVER",
|
||||
"$MODBUS_CLIENT", "$ENIP_CLIENT", "$ENIP_SERVER",
|
||||
"$HTTP_PORTS", "$SHELLCODE_PORTS", "$ORACLE_PORTS", "$SSH_PORTS",
|
||||
"$FTP_PORTS", "$FILE_DATA_PORTS",
|
||||
}
|
||||
|
||||
VAR_PATTERN = re.compile(r"\$[A-Z_][A-Z0-9_]*")
|
||||
|
||||
# Canonical valid values, per securityonion-soc/model/detection.go.
|
||||
SURICATA_OVERRIDE_TYPES = {"suppress", "threshold", "modify"}
|
||||
SUPPRESS_TRACKS = {"by_src", "by_dst", "by_either"}
|
||||
THRESHOLD_TRACKS = {"by_src", "by_dst", "by_both"}
|
||||
THRESHOLD_TYPES = {"limit", "threshold", "both"}
|
||||
|
||||
STALE_WARNING = """\
|
||||
WARNING: so-detections-backup does not remove backup files when overrides are
|
||||
deleted via the Security Onion web UI. As a result, files in the source
|
||||
directory may represent overrides that were intentionally deleted and should
|
||||
NOT be re-imported.
|
||||
|
||||
Before continuing, verify that the source directory reflects the overrides you
|
||||
actually want imported. Remove any files corresponding to overrides you previously deleted.
|
||||
"""
|
||||
|
||||
|
||||
def make_session(auth_file):
|
||||
with open(auth_file, "r") as f:
|
||||
for line in f:
|
||||
if line.startswith("user ="):
|
||||
creds = line.split("=", 1)[1].strip().replace('"', "")
|
||||
user, _, password = creds.partition(":")
|
||||
session = requests.Session()
|
||||
session.auth = HTTPBasicAuth(user, password)
|
||||
session.headers.update({"Content-Type": "application/json"})
|
||||
session.verify = False
|
||||
return session
|
||||
raise RuntimeError(f"Could not find 'user =' line in {auth_file}")
|
||||
|
||||
|
||||
def find_detection(session, index, public_id, engine):
|
||||
query = {
|
||||
"query": {"bool": {"must": [
|
||||
{"term": {"so_detection.publicId": public_id}},
|
||||
{"term": {"so_detection.engine": engine}},
|
||||
]}},
|
||||
"size": 2,
|
||||
}
|
||||
r = session.get(f"{ES_URL}/{index}/_search", json=query)
|
||||
r.raise_for_status()
|
||||
hits = r.json().get("hits", {}).get("hits", [])
|
||||
if not hits:
|
||||
return None, None, None
|
||||
if len(hits) > 1:
|
||||
# Shouldn't happen — publicId is unique per engine — but flag it.
|
||||
print(f" WARN: {len(hits)} detections matched publicId={public_id} engine={engine}; using first")
|
||||
hit = hits[0]
|
||||
existing = hit["_source"].get("so_detection", {}).get("overrides") or []
|
||||
return hit["_id"], hit["_index"], existing
|
||||
|
||||
|
||||
def update_overrides(session, doc_index, doc_id, overrides):
|
||||
body = {"doc": {"so_detection": {"overrides": overrides}}}
|
||||
r = session.post(f"{ES_URL}/{doc_index}/_update/{doc_id}", json=body)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def dedupe_key(override):
|
||||
"""Operational fields only, per Override.Equal() in detection.go.
|
||||
Excludes timestamps and isEnabled so re-imports don't appear unique."""
|
||||
t = override.get("type")
|
||||
if t == "suppress":
|
||||
return (t, override.get("track"), override.get("ip"))
|
||||
if t == "threshold":
|
||||
return (t, override.get("thresholdType"), override.get("track"),
|
||||
override.get("count"), override.get("seconds"))
|
||||
if t == "modify":
|
||||
return (t, override.get("regex"), override.get("value"))
|
||||
|
||||
|
||||
def _validate_suricata_ip(ip):
|
||||
if not ip:
|
||||
return "ip cannot be empty"
|
||||
if ip.startswith("$"):
|
||||
return None
|
||||
if ip.startswith("[") and ip.endswith("]"):
|
||||
for part in ip[1:-1].split(","):
|
||||
err = _validate_single_ip(part.strip())
|
||||
if err:
|
||||
return f"invalid IP in list: {err}"
|
||||
return None
|
||||
return _validate_single_ip(ip)
|
||||
|
||||
|
||||
def _validate_single_ip(ip):
|
||||
try:
|
||||
if "/" in ip:
|
||||
ipaddress.ip_network(ip, strict=False)
|
||||
else:
|
||||
ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return f"invalid IP/CIDR {ip!r}"
|
||||
return None
|
||||
|
||||
|
||||
def validate_override(override, engine):
|
||||
"""Mirror Override.Validate() from securityonion-soc/model/detection.go.
|
||||
Returns None on success, an error string otherwise."""
|
||||
t = override.get("type")
|
||||
if not t:
|
||||
return "override type is required"
|
||||
if t not in SURICATA_OVERRIDE_TYPES:
|
||||
return f"invalid type {t!r}: must be one of {sorted(SURICATA_OVERRIDE_TYPES)}"
|
||||
|
||||
has = {k: override.get(k) is not None for k in
|
||||
("regex", "value", "thresholdType", "track", "ip", "count", "seconds", "customFilter")}
|
||||
|
||||
if t == "suppress":
|
||||
if not has["ip"] or not has["track"]:
|
||||
return "suppress requires 'ip' and 'track'"
|
||||
if any(has[k] for k in ("regex", "value", "thresholdType", "count", "seconds", "customFilter")):
|
||||
return "suppress has unnecessary fields"
|
||||
if override["track"] not in SUPPRESS_TRACKS:
|
||||
return f"invalid track {override['track']!r}: must be one of {sorted(SUPPRESS_TRACKS)}"
|
||||
return _validate_suricata_ip(override["ip"])
|
||||
|
||||
if t == "threshold":
|
||||
if not all(has[k] for k in ("thresholdType", "track", "count", "seconds")):
|
||||
return "threshold requires 'thresholdType', 'track', 'count', 'seconds'"
|
||||
if any(has[k] for k in ("regex", "value", "customFilter")):
|
||||
return "threshold has unnecessary fields"
|
||||
if override["thresholdType"] not in THRESHOLD_TYPES:
|
||||
return f"invalid thresholdType {override['thresholdType']!r}: must be one of {sorted(THRESHOLD_TYPES)}"
|
||||
if override["track"] not in THRESHOLD_TRACKS:
|
||||
return f"invalid track {override['track']!r}: must be one of {sorted(THRESHOLD_TRACKS)}"
|
||||
if not isinstance(override["count"], int) or override["count"] <= 0:
|
||||
return f"count must be a positive integer, got {override['count']!r}"
|
||||
if not isinstance(override["seconds"], int) or override["seconds"] <= 0:
|
||||
return f"seconds must be a positive integer, got {override['seconds']!r}"
|
||||
return None
|
||||
|
||||
if t == "modify":
|
||||
if not has["regex"] or not has["value"]:
|
||||
return "modify requires 'regex' and 'value'"
|
||||
if any(has[k] for k in ("thresholdType", "track", "count", "seconds", "customFilter")):
|
||||
return "modify has unnecessary fields"
|
||||
try:
|
||||
re.compile(override["regex"])
|
||||
except re.error as e:
|
||||
return f"invalid regex: {e}"
|
||||
return None
|
||||
|
||||
|
||||
def parse_overrides_file(path):
|
||||
"""Parse a file written by so-detections-backup.py: NDJSON, one override
|
||||
per line. Returns a list of (override_dict, line_number)."""
|
||||
overrides = []
|
||||
with open(path, "r") as f:
|
||||
for i, line in enumerate(f, start=1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
overrides.append((json.loads(line), i))
|
||||
return overrides
|
||||
|
||||
|
||||
def describe(override):
|
||||
"""Human-readable summary of the operational fields for a given override type."""
|
||||
t = override.get("type")
|
||||
if t == "suppress":
|
||||
return f"type=suppress track={override.get('track')} ip={override.get('ip')}"
|
||||
if t == "threshold":
|
||||
return (f"type=threshold track={override.get('track')} "
|
||||
f"thresholdType={override.get('thresholdType')} "
|
||||
f"count={override.get('count')} seconds={override.get('seconds')}")
|
||||
if t == "modify":
|
||||
return f"type=modify regex={override.get('regex')!r}"
|
||||
|
||||
|
||||
def collect_custom_vars(override):
|
||||
found = set()
|
||||
for value in override.values():
|
||||
if isinstance(value, str):
|
||||
for match in VAR_PATTERN.findall(value):
|
||||
if match not in BUILTIN_SURICATA_VARS:
|
||||
found.add(match)
|
||||
return found
|
||||
|
||||
|
||||
def parse_args():
|
||||
p = argparse.ArgumentParser(
|
||||
description="Import detection overrides into the so-detection index.",
|
||||
)
|
||||
p.add_argument("--source", "-s", required=True,
|
||||
help="Source directory containing <publicId>.<ext> override files.")
|
||||
p.add_argument("--engine", "-e", default="suricata", choices=list(ENGINES.keys()),
|
||||
help="Detection engine (default: suricata).")
|
||||
p.add_argument("--dry-run", "-n", action="store_true",
|
||||
help="Print what would happen without writing to Elasticsearch.")
|
||||
p.add_argument("--no-import-note", action="store_true",
|
||||
help="Do not prepend '[Imported YYYY-MM-DD] ' to the override note.")
|
||||
p.add_argument("--index", "-i", default=DEFAULT_INDEX,
|
||||
help=f"Elasticsearch index to update (default: {DEFAULT_INDEX}).")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def confirm_proceed(args):
|
||||
"""Show the stale-backup warning. Dry-run prints it and continues. Real
|
||||
runs require the user typing 'yes' at the prompt."""
|
||||
print(STALE_WARNING)
|
||||
if args.dry_run:
|
||||
print("(dry-run: no acknowledgement required)\n")
|
||||
return True
|
||||
answer = input("Type 'yes' to acknowledge and continue: ").strip().lower()
|
||||
print()
|
||||
return answer == "yes"
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
|
||||
if not os.path.isdir(args.source):
|
||||
print(f"ERROR: source directory not found: {args.source}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
extension = ENGINES[args.engine]
|
||||
files = sorted(f for f in os.listdir(args.source) if f.endswith(f".{extension}"))
|
||||
if not files:
|
||||
print(f"No *.{extension} files found in {args.source}")
|
||||
sys.exit(0)
|
||||
|
||||
if not confirm_proceed(args):
|
||||
print("Aborted.")
|
||||
sys.exit(1)
|
||||
|
||||
session = make_session(AUTH_FILE)
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
note_prefix = "" if args.no_import_note else f"[Imported {today}] "
|
||||
|
||||
counts = {"added": 0, "skipped_dedupe": 0, "skipped_not_found": 0, "invalid": 0, "error": 0}
|
||||
custom_vars = set()
|
||||
|
||||
mode = "DRY-RUN" if args.dry_run else "IMPORT"
|
||||
print(f"[{mode}] engine={args.engine} source={args.source} index={args.index}\n")
|
||||
|
||||
for filename in files:
|
||||
public_id = os.path.splitext(filename)[0]
|
||||
path = os.path.join(args.source, filename)
|
||||
print(f"{public_id}:")
|
||||
|
||||
try:
|
||||
new_overrides = parse_overrides_file(path)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print(f" ERROR: could not parse {filename}: {e}")
|
||||
counts["error"] += 1
|
||||
continue
|
||||
|
||||
if not new_overrides:
|
||||
print(" SKIP: empty file")
|
||||
continue
|
||||
|
||||
try:
|
||||
doc_id, doc_index, existing = find_detection(session, args.index, public_id, args.engine)
|
||||
except requests.HTTPError as e:
|
||||
print(f" ERROR: search failed: {e}")
|
||||
counts["error"] += 1
|
||||
continue
|
||||
|
||||
if doc_id is None:
|
||||
print(f" WARN: no detection found for publicId={public_id} engine={args.engine}; skipping")
|
||||
counts["skipped_not_found"] += len(new_overrides)
|
||||
continue
|
||||
|
||||
existing_keys = {dedupe_key(o) for o in existing}
|
||||
merged = list(existing)
|
||||
added_this_file = 0
|
||||
|
||||
for override, line_no in new_overrides:
|
||||
err = validate_override(override, args.engine)
|
||||
if err:
|
||||
print(f" INVALID (line {line_no}): {err}")
|
||||
counts["invalid"] += 1
|
||||
continue
|
||||
|
||||
custom_vars.update(collect_custom_vars(override))
|
||||
key = dedupe_key(override)
|
||||
if key in existing_keys:
|
||||
print(f" SKIP (line {line_no}): duplicate of existing override [{describe(override)}]")
|
||||
counts["skipped_dedupe"] += 1
|
||||
continue
|
||||
|
||||
if note_prefix:
|
||||
override = dict(override)
|
||||
override["note"] = note_prefix + (override.get("note") or "")
|
||||
|
||||
merged.append(override)
|
||||
existing_keys.add(key)
|
||||
added_this_file += 1
|
||||
print(f" ADD (line {line_no}): {describe(override)}")
|
||||
|
||||
if added_this_file == 0:
|
||||
continue
|
||||
|
||||
if args.dry_run:
|
||||
print(f" DRY-RUN: would update {doc_index}/{doc_id} "
|
||||
f"({len(existing)} existing → {len(merged)} total)")
|
||||
counts["added"] += added_this_file
|
||||
continue
|
||||
|
||||
try:
|
||||
update_overrides(session, doc_index, doc_id, merged)
|
||||
print(f" UPDATED {doc_index}/{doc_id} ({len(existing)} → {len(merged)})")
|
||||
counts["added"] += added_this_file
|
||||
except requests.HTTPError as e:
|
||||
print(f" ERROR: update failed: {e}")
|
||||
counts["error"] += 1
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f"Summary ({mode}):")
|
||||
print(f" Overrides added: {counts['added']}")
|
||||
print(f" Skipped (already present): {counts['skipped_dedupe']}")
|
||||
print(f" Skipped (no detection): {counts['skipped_not_found']}")
|
||||
print(f" Invalid (failed checks): {counts['invalid']}")
|
||||
print(f" Errors: {counts['error']}")
|
||||
|
||||
if custom_vars:
|
||||
print()
|
||||
print("WARNING: detected custom Suricata variables in imported overrides:")
|
||||
for v in sorted(custom_vars):
|
||||
print(f" {v}")
|
||||
print("If any of these are not already defined in SOC Config (Suricata variables),")
|
||||
print("you must add them manually before the rules will function correctly.")
|
||||
|
||||
sys.exit(0 if counts["error"] == 0 and counts["invalid"] == 0 else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,588 @@
|
||||
# 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 importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from importlib.machinery import SourceFileLoader
|
||||
from io import StringIO
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import requests
|
||||
|
||||
# The script has no .py extension; spec_from_file_location can't auto-detect a
|
||||
# loader, so we hand it a SourceFileLoader explicitly. (load_module() is
|
||||
# deprecated in 3.14 and slated for removal in 3.15.)
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SCRIPT = os.path.join(HERE, "so-detections-overrides-import")
|
||||
_loader = SourceFileLoader("so_overrides_import", SCRIPT)
|
||||
_spec = importlib.util.spec_from_loader("so_overrides_import", _loader)
|
||||
soi = importlib.util.module_from_spec(_spec)
|
||||
_loader.exec_module(soi)
|
||||
|
||||
|
||||
class TestValidateSuppress(unittest.TestCase):
|
||||
def test_valid(self):
|
||||
self.assertIsNone(soi.validate_override(
|
||||
{"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}, "suricata"))
|
||||
|
||||
def test_valid_var(self):
|
||||
self.assertIsNone(soi.validate_override(
|
||||
{"type": "suppress", "track": "by_either", "ip": "$HOME_NET"}, "suricata"))
|
||||
|
||||
def test_valid_cidr(self):
|
||||
self.assertIsNone(soi.validate_override(
|
||||
{"type": "suppress", "track": "by_dst", "ip": "10.0.0.0/8"}, "suricata"))
|
||||
|
||||
def test_valid_bracket_list(self):
|
||||
self.assertIsNone(soi.validate_override(
|
||||
{"type": "suppress", "track": "by_src", "ip": "[1.2.3.4,10.0.0.0/8]"}, "suricata"))
|
||||
|
||||
def test_missing_ip(self):
|
||||
err = soi.validate_override({"type": "suppress", "track": "by_src"}, "suricata")
|
||||
self.assertIn("requires", err)
|
||||
|
||||
def test_missing_track(self):
|
||||
err = soi.validate_override({"type": "suppress", "ip": "1.2.3.4"}, "suricata")
|
||||
self.assertIn("requires", err)
|
||||
|
||||
def test_invalid_track(self):
|
||||
err = soi.validate_override(
|
||||
{"type": "suppress", "track": "by_both", "ip": "1.2.3.4"}, "suricata")
|
||||
self.assertIn("invalid track", err)
|
||||
|
||||
def test_invalid_ip(self):
|
||||
err = soi.validate_override(
|
||||
{"type": "suppress", "track": "by_src", "ip": "not-an-ip"}, "suricata")
|
||||
self.assertIn("invalid IP", err)
|
||||
|
||||
def test_unnecessary_field(self):
|
||||
err = soi.validate_override(
|
||||
{"type": "suppress", "track": "by_src", "ip": "1.2.3.4", "count": 5}, "suricata")
|
||||
self.assertIn("unnecessary fields", err)
|
||||
|
||||
|
||||
class TestValidateThreshold(unittest.TestCase):
|
||||
def test_valid(self):
|
||||
self.assertIsNone(soi.validate_override({
|
||||
"type": "threshold", "track": "by_src",
|
||||
"thresholdType": "limit", "count": 10, "seconds": 60,
|
||||
}, "suricata"))
|
||||
|
||||
def test_valid_by_both(self):
|
||||
self.assertIsNone(soi.validate_override({
|
||||
"type": "threshold", "track": "by_both",
|
||||
"thresholdType": "both", "count": 1, "seconds": 1,
|
||||
}, "suricata"))
|
||||
|
||||
def test_track_by_either_invalid(self):
|
||||
err = soi.validate_override({
|
||||
"type": "threshold", "track": "by_either",
|
||||
"thresholdType": "limit", "count": 10, "seconds": 60,
|
||||
}, "suricata")
|
||||
self.assertIn("invalid track", err)
|
||||
|
||||
def test_invalid_threshold_type(self):
|
||||
err = soi.validate_override({
|
||||
"type": "threshold", "track": "by_src",
|
||||
"thresholdType": "bogus", "count": 10, "seconds": 60,
|
||||
}, "suricata")
|
||||
self.assertIn("invalid thresholdType", err)
|
||||
|
||||
def test_zero_count(self):
|
||||
err = soi.validate_override({
|
||||
"type": "threshold", "track": "by_src",
|
||||
"thresholdType": "limit", "count": 0, "seconds": 60,
|
||||
}, "suricata")
|
||||
self.assertIn("count", err)
|
||||
|
||||
def test_negative_seconds(self):
|
||||
err = soi.validate_override({
|
||||
"type": "threshold", "track": "by_src",
|
||||
"thresholdType": "limit", "count": 10, "seconds": -1,
|
||||
}, "suricata")
|
||||
self.assertIn("seconds", err)
|
||||
|
||||
def test_missing_field(self):
|
||||
err = soi.validate_override({
|
||||
"type": "threshold", "track": "by_src",
|
||||
"thresholdType": "limit", "count": 10, # missing seconds
|
||||
}, "suricata")
|
||||
self.assertIn("requires", err)
|
||||
|
||||
def test_unnecessary_field(self):
|
||||
err = soi.validate_override({
|
||||
"type": "threshold", "track": "by_src",
|
||||
"thresholdType": "limit", "count": 10, "seconds": 60,
|
||||
"regex": "foo",
|
||||
}, "suricata")
|
||||
self.assertIn("unnecessary fields", err)
|
||||
|
||||
|
||||
class TestValidateModify(unittest.TestCase):
|
||||
def test_valid(self):
|
||||
self.assertIsNone(soi.validate_override(
|
||||
{"type": "modify", "regex": r"content:\"foo\"", "value": "content:bar"}, "suricata"))
|
||||
|
||||
def test_invalid_regex(self):
|
||||
err = soi.validate_override(
|
||||
{"type": "modify", "regex": "(unbalanced", "value": "x"}, "suricata")
|
||||
self.assertIn("invalid regex", err)
|
||||
|
||||
def test_missing_value(self):
|
||||
err = soi.validate_override({"type": "modify", "regex": "x"}, "suricata")
|
||||
self.assertIn("requires", err)
|
||||
|
||||
def test_unnecessary_field(self):
|
||||
err = soi.validate_override(
|
||||
{"type": "modify", "regex": "x", "value": "y", "track": "by_src"}, "suricata")
|
||||
self.assertIn("unnecessary fields", err)
|
||||
|
||||
|
||||
class TestValidateMisc(unittest.TestCase):
|
||||
def test_unknown_type(self):
|
||||
err = soi.validate_override({"type": "suppresss", "track": "by_src", "ip": "1.2.3.4"}, "suricata")
|
||||
self.assertIn("invalid type", err)
|
||||
|
||||
def test_missing_type(self):
|
||||
err = soi.validate_override({"track": "by_src"}, "suricata")
|
||||
self.assertIn("type is required", err)
|
||||
|
||||
|
||||
class TestValidateIP(unittest.TestCase):
|
||||
def test_plain_ipv4(self):
|
||||
self.assertIsNone(soi._validate_suricata_ip("1.2.3.4"))
|
||||
|
||||
def test_plain_ipv6(self):
|
||||
self.assertIsNone(soi._validate_suricata_ip("::1"))
|
||||
|
||||
def test_cidr(self):
|
||||
self.assertIsNone(soi._validate_suricata_ip("10.0.0.0/8"))
|
||||
|
||||
def test_var(self):
|
||||
self.assertIsNone(soi._validate_suricata_ip("$CONCOURSEWORKERS"))
|
||||
|
||||
def test_bracket_list(self):
|
||||
self.assertIsNone(soi._validate_suricata_ip("[1.2.3.4, 10.0.0.0/8]"))
|
||||
|
||||
def test_bracket_list_bad_member(self):
|
||||
err = soi._validate_suricata_ip("[1.2.3.4,nope]")
|
||||
self.assertIn("invalid IP in list", err)
|
||||
|
||||
def test_empty(self):
|
||||
self.assertIn("empty", soi._validate_suricata_ip(""))
|
||||
|
||||
def test_invalid(self):
|
||||
self.assertIn("invalid", soi._validate_suricata_ip("999.999.999.999"))
|
||||
|
||||
|
||||
class TestDedupeKey(unittest.TestCase):
|
||||
def test_suppress(self):
|
||||
a = {"type": "suppress", "track": "by_src", "ip": "1.2.3.4", "count": 99}
|
||||
b = {"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}
|
||||
# count is irrelevant for suppress dedupe
|
||||
self.assertEqual(soi.dedupe_key(a), soi.dedupe_key(b))
|
||||
|
||||
def test_suppress_differs_on_ip(self):
|
||||
a = {"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}
|
||||
b = {"type": "suppress", "track": "by_src", "ip": "5.6.7.8"}
|
||||
self.assertNotEqual(soi.dedupe_key(a), soi.dedupe_key(b))
|
||||
|
||||
def test_threshold(self):
|
||||
a = {"type": "threshold", "track": "by_src", "thresholdType": "limit",
|
||||
"count": 10, "seconds": 60, "ip": "ignored"}
|
||||
b = {"type": "threshold", "track": "by_src", "thresholdType": "limit",
|
||||
"count": 10, "seconds": 60}
|
||||
self.assertEqual(soi.dedupe_key(a), soi.dedupe_key(b))
|
||||
|
||||
def test_threshold_differs_on_count(self):
|
||||
a = {"type": "threshold", "track": "by_src", "thresholdType": "limit",
|
||||
"count": 10, "seconds": 60}
|
||||
b = {"type": "threshold", "track": "by_src", "thresholdType": "limit",
|
||||
"count": 20, "seconds": 60}
|
||||
self.assertNotEqual(soi.dedupe_key(a), soi.dedupe_key(b))
|
||||
|
||||
def test_modify(self):
|
||||
a = {"type": "modify", "regex": "x", "value": "y"}
|
||||
b = {"type": "modify", "regex": "x", "value": "y"}
|
||||
self.assertEqual(soi.dedupe_key(a), soi.dedupe_key(b))
|
||||
|
||||
|
||||
class TestDescribe(unittest.TestCase):
|
||||
def test_suppress(self):
|
||||
s = soi.describe({"type": "suppress", "track": "by_src", "ip": "1.2.3.4"})
|
||||
self.assertIn("suppress", s)
|
||||
self.assertIn("by_src", s)
|
||||
self.assertIn("1.2.3.4", s)
|
||||
|
||||
def test_threshold_includes_count(self):
|
||||
s = soi.describe({"type": "threshold", "track": "by_src",
|
||||
"thresholdType": "limit", "count": 10, "seconds": 60})
|
||||
self.assertIn("count=10", s)
|
||||
self.assertIn("seconds=60", s)
|
||||
|
||||
def test_modify(self):
|
||||
s = soi.describe({"type": "modify", "regex": "foo"})
|
||||
self.assertIn("modify", s)
|
||||
self.assertIn("foo", s)
|
||||
|
||||
|
||||
class TestParseOverridesFile(unittest.TestCase):
|
||||
def _write(self, content):
|
||||
fd, path = tempfile.mkstemp(suffix=".txt")
|
||||
os.close(fd)
|
||||
with open(path, "w") as f:
|
||||
f.write(content)
|
||||
self.addCleanup(os.unlink, path)
|
||||
return path
|
||||
|
||||
def test_single_line(self):
|
||||
path = self._write('{"type":"suppress","track":"by_src","ip":"1.2.3.4"}')
|
||||
result = soi.parse_overrides_file(path)
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0][0]["type"], "suppress")
|
||||
self.assertEqual(result[0][1], 1)
|
||||
|
||||
def test_ndjson(self):
|
||||
path = self._write(
|
||||
'{"type":"suppress","track":"by_src","ip":"1.2.3.4"}\n'
|
||||
'{"type":"suppress","track":"by_dst","ip":"5.6.7.8"}\n'
|
||||
)
|
||||
result = soi.parse_overrides_file(path)
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertEqual(result[1][1], 2)
|
||||
|
||||
def test_empty(self):
|
||||
path = self._write("")
|
||||
self.assertEqual(soi.parse_overrides_file(path), [])
|
||||
|
||||
def test_blank_lines_skipped(self):
|
||||
path = self._write('\n{"type":"suppress","track":"by_src","ip":"1.2.3.4"}\n\n')
|
||||
result = soi.parse_overrides_file(path)
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0][1], 2) # line number reflects original position
|
||||
|
||||
def test_invalid_raises(self):
|
||||
path = self._write("not json")
|
||||
with self.assertRaises(json.JSONDecodeError):
|
||||
soi.parse_overrides_file(path)
|
||||
|
||||
|
||||
class TestCollectCustomVars(unittest.TestCase):
|
||||
def test_finds_custom(self):
|
||||
v = soi.collect_custom_vars({"ip": "$CONCOURSEWORKERS"})
|
||||
self.assertEqual(v, {"$CONCOURSEWORKERS"})
|
||||
|
||||
def test_filters_builtins(self):
|
||||
v = soi.collect_custom_vars({"ip": "$HOME_NET"})
|
||||
self.assertEqual(v, set())
|
||||
|
||||
def test_mixed(self):
|
||||
v = soi.collect_custom_vars({"ip": "[$HOME_NET,$MYNET]"})
|
||||
self.assertEqual(v, {"$MYNET"})
|
||||
|
||||
def test_non_string_fields_ignored(self):
|
||||
v = soi.collect_custom_vars({"count": 10, "isEnabled": True})
|
||||
self.assertEqual(v, set())
|
||||
|
||||
|
||||
class TestMakeSession(unittest.TestCase):
|
||||
def _write(self, content):
|
||||
fd, path = tempfile.mkstemp()
|
||||
os.close(fd)
|
||||
with open(path, "w") as f:
|
||||
f.write(content)
|
||||
self.addCleanup(os.unlink, path)
|
||||
return path
|
||||
|
||||
def test_valid_auth_file(self):
|
||||
path = self._write('user = "admin:secret"\n')
|
||||
session = soi.make_session(path)
|
||||
self.assertEqual(session.auth.username, "admin")
|
||||
self.assertEqual(session.auth.password, "secret")
|
||||
self.assertFalse(session.verify)
|
||||
|
||||
def test_missing_user_line(self):
|
||||
path = self._write("# no user line here\n")
|
||||
with self.assertRaises(RuntimeError):
|
||||
soi.make_session(path)
|
||||
|
||||
|
||||
class TestFindDetection(unittest.TestCase):
|
||||
def _session_with_response(self, payload):
|
||||
session = MagicMock()
|
||||
response = MagicMock()
|
||||
response.json.return_value = payload
|
||||
response.raise_for_status.return_value = None
|
||||
session.get.return_value = response
|
||||
return session
|
||||
|
||||
def test_found(self):
|
||||
session = self._session_with_response({"hits": {"hits": [{
|
||||
"_id": "abc", "_index": "so-detection",
|
||||
"_source": {"so_detection": {"overrides": [{"type": "suppress"}]}},
|
||||
}]}})
|
||||
doc_id, idx, existing = soi.find_detection(session, "so-detection", "2049201", "suricata")
|
||||
self.assertEqual(doc_id, "abc")
|
||||
self.assertEqual(idx, "so-detection")
|
||||
self.assertEqual(len(existing), 1)
|
||||
|
||||
def test_not_found(self):
|
||||
session = self._session_with_response({"hits": {"hits": []}})
|
||||
doc_id, idx, existing = soi.find_detection(session, "so-detection", "x", "suricata")
|
||||
self.assertIsNone(doc_id)
|
||||
self.assertIsNone(idx)
|
||||
self.assertIsNone(existing)
|
||||
|
||||
def test_no_overrides_field(self):
|
||||
session = self._session_with_response({"hits": {"hits": [{
|
||||
"_id": "abc", "_index": "so-detection",
|
||||
"_source": {"so_detection": {}},
|
||||
}]}})
|
||||
_, _, existing = soi.find_detection(session, "so-detection", "x", "suricata")
|
||||
self.assertEqual(existing, [])
|
||||
|
||||
def test_multiple_hits_warns(self):
|
||||
session = self._session_with_response({"hits": {"hits": [
|
||||
{"_id": "a", "_index": "i", "_source": {"so_detection": {"overrides": []}}},
|
||||
{"_id": "b", "_index": "i", "_source": {"so_detection": {"overrides": []}}},
|
||||
]}})
|
||||
with patch("sys.stdout", new=StringIO()) as out:
|
||||
doc_id, _, _ = soi.find_detection(session, "i", "x", "suricata")
|
||||
self.assertEqual(doc_id, "a")
|
||||
self.assertIn("WARN", out.getvalue())
|
||||
|
||||
|
||||
class TestUpdateOverrides(unittest.TestCase):
|
||||
def test_posts_to_update_endpoint(self):
|
||||
session = MagicMock()
|
||||
response = MagicMock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {"result": "updated"}
|
||||
session.post.return_value = response
|
||||
|
||||
result = soi.update_overrides(session, "so-detection", "abc", [{"type": "suppress"}])
|
||||
|
||||
self.assertEqual(result, {"result": "updated"})
|
||||
url = session.post.call_args[0][0]
|
||||
self.assertIn("/_update/abc", url)
|
||||
body = session.post.call_args[1]["json"]
|
||||
self.assertEqual(body["doc"]["so_detection"]["overrides"], [{"type": "suppress"}])
|
||||
|
||||
|
||||
class TestConfirmProceed(unittest.TestCase):
|
||||
def test_dry_run_skips_prompt(self):
|
||||
args = MagicMock(dry_run=True)
|
||||
with patch("sys.stdout", new=StringIO()):
|
||||
self.assertTrue(soi.confirm_proceed(args))
|
||||
|
||||
def test_yes_input(self):
|
||||
args = MagicMock(dry_run=False)
|
||||
with patch("sys.stdout", new=StringIO()):
|
||||
with patch("builtins.input", return_value="yes"):
|
||||
self.assertTrue(soi.confirm_proceed(args))
|
||||
|
||||
def test_yes_input_case_insensitive(self):
|
||||
args = MagicMock(dry_run=False)
|
||||
with patch("sys.stdout", new=StringIO()):
|
||||
with patch("builtins.input", return_value="YES"):
|
||||
self.assertTrue(soi.confirm_proceed(args))
|
||||
|
||||
def test_no_input_aborts(self):
|
||||
args = MagicMock(dry_run=False)
|
||||
with patch("sys.stdout", new=StringIO()):
|
||||
with patch("builtins.input", return_value="no"):
|
||||
self.assertFalse(soi.confirm_proceed(args))
|
||||
|
||||
def test_empty_input_aborts(self):
|
||||
args = MagicMock(dry_run=False)
|
||||
with patch("sys.stdout", new=StringIO()):
|
||||
with patch("builtins.input", return_value=""):
|
||||
self.assertFalse(soi.confirm_proceed(args))
|
||||
|
||||
|
||||
class TestParseArgs(unittest.TestCase):
|
||||
def test_defaults(self):
|
||||
with patch.object(sys, "argv", ["cmd", "--source", "/some/path"]):
|
||||
args = soi.parse_args()
|
||||
self.assertEqual(args.source, "/some/path")
|
||||
self.assertEqual(args.engine, "suricata")
|
||||
self.assertFalse(args.dry_run)
|
||||
self.assertFalse(args.no_import_note)
|
||||
self.assertEqual(args.index, soi.DEFAULT_INDEX)
|
||||
|
||||
def test_all_options(self):
|
||||
argv = ["cmd", "-s", "/x", "-e", "suricata", "-n",
|
||||
"--no-import-note", "-i", "alt-index"]
|
||||
with patch.object(sys, "argv", argv):
|
||||
args = soi.parse_args()
|
||||
self.assertEqual(args.source, "/x")
|
||||
self.assertTrue(args.dry_run)
|
||||
self.assertTrue(args.no_import_note)
|
||||
self.assertEqual(args.index, "alt-index")
|
||||
|
||||
|
||||
class TestMain(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.tmpdir, ignore_errors=True)
|
||||
# Stub make_session so tests don't need /opt/so/conf/elasticsearch/curl.config.
|
||||
p = patch.object(soi, "make_session", return_value=MagicMock())
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def _write_file(self, public_id, overrides, ext="txt"):
|
||||
"""Write an NDJSON override file. Entries may be dicts or raw strings (for malformed input)."""
|
||||
path = os.path.join(self.tmpdir, f"{public_id}.{ext}")
|
||||
with open(path, "w") as f:
|
||||
for o in overrides:
|
||||
f.write(o if isinstance(o, str) else json.dumps(o))
|
||||
f.write("\n")
|
||||
return path
|
||||
|
||||
def _run_main(self, *extra_argv, input_response="yes"):
|
||||
"""Run main() with stdout/stderr captured and input mocked. Returns (stdout, stderr, exit_code)."""
|
||||
argv = ["cmd", "--source", self.tmpdir, *extra_argv]
|
||||
out, err = StringIO(), StringIO()
|
||||
with patch.object(sys, "argv", argv), \
|
||||
patch("sys.stdout", new=out), \
|
||||
patch("sys.stderr", new=err), \
|
||||
patch("builtins.input", return_value=input_response):
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
soi.main()
|
||||
return out.getvalue(), err.getvalue(), cm.exception.code
|
||||
|
||||
def test_source_dir_missing(self):
|
||||
argv = ["cmd", "--source", "/no/such/path/here"]
|
||||
err = StringIO()
|
||||
with patch.object(sys, "argv", argv), patch("sys.stderr", new=err):
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
soi.main()
|
||||
self.assertEqual(cm.exception.code, 1)
|
||||
self.assertIn("source directory not found", err.getvalue())
|
||||
|
||||
def test_no_files_found(self):
|
||||
out, _, code = self._run_main()
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("No *.txt files found", out)
|
||||
|
||||
def test_user_aborts(self):
|
||||
self._write_file("1001", [{"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}])
|
||||
out, _, code = self._run_main(input_response="no")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("Aborted", out)
|
||||
|
||||
def test_parse_error_increments_error(self):
|
||||
# Malformed JSON line — parse_overrides_file raises JSONDecodeError.
|
||||
self._write_file("1002", ["not json"])
|
||||
out, _, code = self._run_main("--dry-run")
|
||||
self.assertEqual(code, 1) # invalid+error → non-zero
|
||||
self.assertIn("could not parse", out)
|
||||
self.assertIn("Errors: 1", out)
|
||||
|
||||
def test_empty_file_skipped(self):
|
||||
# Blank lines only — parse_overrides_file returns []; main reports "empty file" and continues.
|
||||
path = os.path.join(self.tmpdir, "1003.txt")
|
||||
with open(path, "w") as f:
|
||||
f.write("\n\n")
|
||||
out, _, code = self._run_main("--dry-run")
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("empty file", out)
|
||||
|
||||
@patch.object(soi, "find_detection")
|
||||
def test_search_http_error(self, mock_find):
|
||||
mock_find.side_effect = requests.HTTPError("boom")
|
||||
self._write_file("1004", [{"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}])
|
||||
out, _, code = self._run_main("--dry-run")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("search failed", out)
|
||||
|
||||
@patch.object(soi, "find_detection")
|
||||
def test_no_detection_found(self, mock_find):
|
||||
mock_find.return_value = (None, None, None)
|
||||
self._write_file("1005", [{"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}])
|
||||
out, _, code = self._run_main("--dry-run")
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("no detection found", out)
|
||||
self.assertIn("Skipped (no detection): 1", out)
|
||||
|
||||
@patch.object(soi, "find_detection")
|
||||
def test_all_duplicates_no_update(self, mock_find):
|
||||
existing = [{"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}]
|
||||
mock_find.return_value = ("doc1", "so-detection", existing)
|
||||
self._write_file("1006", [{"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}])
|
||||
out, _, code = self._run_main("--dry-run")
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("SKIP", out)
|
||||
self.assertNotIn("DRY-RUN: would update", out) # added_this_file == 0 branch
|
||||
|
||||
@patch.object(soi, "update_overrides")
|
||||
@patch.object(soi, "find_detection")
|
||||
def test_happy_path_full(self, mock_find, mock_update):
|
||||
# Exercises: ADD, dedupe SKIP, INVALID, note prefix, UPDATE, custom-vars warning, exit=1 (invalid present)
|
||||
existing = [{"type": "suppress", "track": "by_src", "ip": "9.9.9.9"}]
|
||||
mock_find.return_value = ("doc1", "so-detection", existing)
|
||||
mock_update.return_value = {"result": "updated"}
|
||||
self._write_file("1007", [
|
||||
{"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}, # ADD
|
||||
{"type": "suppress", "track": "by_src", "ip": "9.9.9.9"}, # SKIP (dupe of existing)
|
||||
{"type": "suppress", "track": "bogus", "ip": "1.2.3.4"}, # INVALID
|
||||
{"type": "suppress", "track": "by_src", "ip": "$CONCOURSEWORKERS"}, # ADD + custom var
|
||||
])
|
||||
out, _, code = self._run_main()
|
||||
self.assertEqual(code, 1) # one invalid -> non-zero
|
||||
|
||||
mock_update.assert_called_once()
|
||||
merged = mock_update.call_args[0][3]
|
||||
self.assertEqual(len(merged), 3) # 1 existing + 2 new
|
||||
new_notes = [o.get("note", "") for o in merged if o.get("ip") in ("1.2.3.4", "$CONCOURSEWORKERS")]
|
||||
self.assertTrue(all(n.startswith("[Imported ") for n in new_notes))
|
||||
|
||||
self.assertIn("ADD", out)
|
||||
self.assertIn("SKIP", out)
|
||||
self.assertIn("INVALID", out)
|
||||
self.assertIn("UPDATED", out)
|
||||
self.assertIn("$CONCOURSEWORKERS", out)
|
||||
|
||||
@patch.object(soi, "update_overrides")
|
||||
@patch.object(soi, "find_detection")
|
||||
def test_no_import_note_preserves_note(self, mock_find, mock_update):
|
||||
mock_find.return_value = ("doc1", "so-detection", [])
|
||||
mock_update.return_value = {"result": "updated"}
|
||||
self._write_file("1008", [
|
||||
{"type": "suppress", "track": "by_src", "ip": "1.2.3.4", "note": "original"},
|
||||
])
|
||||
_, _, code = self._run_main("--no-import-note")
|
||||
self.assertEqual(code, 0)
|
||||
merged = mock_update.call_args[0][3]
|
||||
self.assertEqual(merged[0]["note"], "original") # no prefix applied
|
||||
|
||||
@patch.object(soi, "find_detection")
|
||||
def test_dry_run_skips_update(self, mock_find):
|
||||
mock_find.return_value = ("doc1", "so-detection", [])
|
||||
self._write_file("1009", [{"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}])
|
||||
with patch.object(soi, "update_overrides") as mock_update:
|
||||
out, _, code = self._run_main("--dry-run")
|
||||
self.assertEqual(code, 0)
|
||||
mock_update.assert_not_called()
|
||||
self.assertIn("DRY-RUN: would update", out)
|
||||
|
||||
@patch.object(soi, "update_overrides")
|
||||
@patch.object(soi, "find_detection")
|
||||
def test_update_http_error(self, mock_find, mock_update):
|
||||
mock_find.return_value = ("doc1", "so-detection", [])
|
||||
mock_update.side_effect = requests.HTTPError("nope")
|
||||
self._write_file("1010", [{"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}])
|
||||
out, _, code = self._run_main()
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("update failed", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+424
-18
@@ -188,13 +188,6 @@ airgap_update_dockers() {
|
||||
fi
|
||||
}
|
||||
|
||||
backup_old_states_pillars() {
|
||||
|
||||
tar czf /nsm/backup/$(echo $INSTALLEDVERSION)_$(date +%Y%m%d-%H%M%S)_soup_default_states_pillars.tar.gz /opt/so/saltstack/default/
|
||||
tar czf /nsm/backup/$(echo $INSTALLEDVERSION)_$(date +%Y%m%d-%H%M%S)_soup_local_states_pillars.tar.gz /opt/so/saltstack/local/
|
||||
|
||||
}
|
||||
|
||||
update_registry() {
|
||||
docker stop so-dockerregistry
|
||||
docker rm so-dockerregistry
|
||||
@@ -372,6 +365,7 @@ preupgrade_changes() {
|
||||
|
||||
[[ "$INSTALLEDVERSION" =~ ^2\.4\.21[0-9]+$ ]] && up_to_3.0.0
|
||||
[[ "$INSTALLEDVERSION" == "3.0.0" ]] && up_to_3.1.0
|
||||
[[ "$INSTALLEDVERSION" == "3.1.0" ]] && up_to_3.2.0
|
||||
true
|
||||
}
|
||||
|
||||
@@ -381,6 +375,7 @@ postupgrade_changes() {
|
||||
|
||||
[[ "$POSTVERSION" =~ ^2\.4\.21[0-9]+$ ]] && post_to_3.0.0
|
||||
[[ "$POSTVERSION" == "3.0.0" ]] && post_to_3.1.0
|
||||
[[ "$POSTVERSION" == "3.1.0" ]] && post_to_3.2.0
|
||||
true
|
||||
}
|
||||
|
||||
@@ -485,6 +480,158 @@ elasticsearch_backup_index_templates() {
|
||||
tar -czf /nsm/backup/3.0.0_elasticsearch_index_templates.tar.gz -C /opt/so/conf/elasticsearch/templates/index/ .
|
||||
}
|
||||
|
||||
elasticfleet_set_agent_logging_level_warn() {
|
||||
. /usr/sbin/so-elastic-fleet-common
|
||||
|
||||
local current_agent_policies
|
||||
if ! current_agent_policies=$(fleet_api "agent_policies?perPage=1000"); then
|
||||
echo "Warning: unable to retrieve Fleet agent policies"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Only updating policies that are within Security Onion defaults and do not already have any user configured advanced_settings.
|
||||
local policies_to_update
|
||||
policies_to_update=$(jq -c '
|
||||
.items[]
|
||||
| select(has("advanced_settings") | not)
|
||||
| select(
|
||||
.id == "so-grid-nodes_general"
|
||||
or .id == "so-grid-nodes_heavy"
|
||||
or .id == "endpoints-initial"
|
||||
or (.id | startswith("FleetServer_"))
|
||||
)
|
||||
' <<< "$current_agent_policies")
|
||||
|
||||
if [[ -z "$policies_to_update" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
while IFS= read -r policy; do
|
||||
[[ -z "$policy" ]] && continue
|
||||
|
||||
local policy_id policy_name policy_namespace
|
||||
policy_id=$(jq -r '.id' <<< "$policy")
|
||||
policy_name=$(jq -r '.name' <<< "$policy")
|
||||
policy_namespace=$(jq -r '.namespace' <<< "$policy")
|
||||
|
||||
local update_logging
|
||||
update_logging=$(jq -n \
|
||||
--arg name "$policy_name" \
|
||||
--arg namespace "$policy_namespace" \
|
||||
'{name: $name, namespace: $namespace, advanced_settings: {agent_logging_level: "warning"}}'
|
||||
)
|
||||
|
||||
echo "Setting elastic agent_logging_level to warning on policy '$policy_name' ($policy_id)."
|
||||
if ! fleet_api "agent_policies/$policy_id" -XPUT -H 'kbn-xsrf: true' -H 'Content-Type: application/json' -d "$update_logging" >/dev/null; then
|
||||
echo " warning: failed to update agent policy '$policy_name' ($policy_id)" >&2
|
||||
fi
|
||||
done <<< "$policies_to_update"
|
||||
}
|
||||
|
||||
update_logstash_pipeline_name() {
|
||||
local original_pipeline_name="$1"
|
||||
local new_pipeline_name="$2"
|
||||
|
||||
echo "Checking for conflicting logstash defined_pipelines pillar value."
|
||||
local LOGSTASH_FILE=/opt/so/saltstack/local/pillar/logstash/soc_logstash.sls
|
||||
local MINIONDIR=/opt/so/saltstack/local/pillar/minions
|
||||
for pillar_file in "$LOGSTASH_FILE" "$MINIONDIR"/*.sls; do
|
||||
[[ -f "$pillar_file" ]] || continue
|
||||
if grep -q "$original_pipeline_name$" "$pillar_file"; then
|
||||
echo "Found conflicting defined_pipeline pillar value in $pillar_file. Updating to use the new logstash pipeline name."
|
||||
sed -i "s#$original_pipeline_name\$#$new_pipeline_name#g" "$pillar_file"
|
||||
chown socore:socore "$pillar_file"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
check_transform_health_and_reauthorize() {
|
||||
. /usr/sbin/so-elastic-fleet-common
|
||||
|
||||
echo "Checking integration transform jobs for unhealthy / unauthorized status..."
|
||||
|
||||
local transforms_doc stats_doc installed_doc
|
||||
if ! transforms_doc=$(so-elasticsearch-query "_transform/_all?size=1000" --fail --retry 3 --retry-delay 5 2>/dev/null); then
|
||||
echo "Unable to query for transform jobs, skipping reauthorization."
|
||||
return 0
|
||||
fi
|
||||
if ! stats_doc=$(so-elasticsearch-query "_transform/_all/_stats?size=1000" --fail --retry 3 --retry-delay 5 2>/dev/null); then
|
||||
echo "Unable to query for transform job stats, skipping reauthorization."
|
||||
return 0
|
||||
fi
|
||||
if ! installed_doc=$(fleet_api "epm/packages/installed?perPage=500"); then
|
||||
echo "Unable to list installed Fleet packages, skipping reauthorization."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Get all transforms that meet the following
|
||||
# - unhealthy (any non-green health status)
|
||||
# - metadata has run_as_kibana_system: false (this fix is specific to transforms started prior to Kibana 9.3.3)
|
||||
# - are not orphaned (integration is not somehow missing/corrupt/uninstalled)
|
||||
local tmp_transforms tmp_stats tmp_installed
|
||||
tmp_transforms=$(mktemp)
|
||||
tmp_stats=$(mktemp)
|
||||
tmp_installed=$(mktemp)
|
||||
|
||||
echo "$transforms_doc" > "$tmp_transforms"
|
||||
echo "$stats_doc" > "$tmp_stats"
|
||||
echo "$installed_doc" > "$tmp_installed"
|
||||
|
||||
local unhealthy_transforms
|
||||
unhealthy_transforms=$(jq -c -n \
|
||||
--slurpfile t "$tmp_transforms" \
|
||||
--slurpfile s "$tmp_stats" \
|
||||
--slurpfile i "$tmp_installed" '
|
||||
($i[0].items | map({key: .name, value: .version}) | from_entries) as $pkg_ver
|
||||
| ($s[0].transforms | map({key: .id, value: .health.status}) | from_entries) as $health
|
||||
| [ $t[0].transforms[]
|
||||
| select(._meta.run_as_kibana_system == false)
|
||||
| select(($health[.id] // "unknown") != "green")
|
||||
| {id, pkg: ._meta.package.name, ver: ($pkg_ver[._meta.package.name])}
|
||||
]
|
||||
| if length == 0 then empty else . end
|
||||
| (map(select(.ver == null)) | map({orphan: .id})[]),
|
||||
(map(select(.ver != null))
|
||||
| group_by(.pkg)
|
||||
| map({pkg: .[0].pkg, ver: .[0].ver, transformIds: map(.id)})[])
|
||||
')
|
||||
|
||||
if [[ -z "$unhealthy_transforms" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local unhealthy_count
|
||||
unhealthy_count=$(jq -s '[.[].transformIds? // empty | .[]] | length' <<< "$unhealthy_transforms")
|
||||
echo "Found $unhealthy_count transform(s) needing reauthorization."
|
||||
|
||||
local total_failures=0
|
||||
while IFS= read -r transform; do
|
||||
[[ -z "$transform" ]] && continue
|
||||
if jq -e 'has("orphan")' <<< "$transform" >/dev/null 2>&1; then
|
||||
echo "Skipping transform not owned by any installed Fleet package: $(jq -r '.orphan' <<< "$transform")"
|
||||
continue
|
||||
fi
|
||||
|
||||
local pkg ver body resp
|
||||
pkg=$(jq -r '.pkg' <<< "$transform")
|
||||
ver=$(jq -r '.ver' <<< "$transform")
|
||||
body=$(jq -c '{transforms: (.transformIds | map({transformId: .}))}' <<< "$transform")
|
||||
|
||||
echo "Reauthorizing transform(s) for ${pkg}-${ver}..."
|
||||
resp=$(fleet_api "epm/packages/${pkg}/${ver}/transforms/authorize" \
|
||||
-XPOST -H 'kbn-xsrf: true' -H 'Content-Type: application/json' \
|
||||
-d "$body") || { echo "Could not reauthorize transform(s) for ${pkg}-${ver}"; continue; }
|
||||
|
||||
(( total_failures += $(jq 'map(select(.success != true)) | length' <<< "$resp" 2>/dev/null) ))
|
||||
done <<< "$unhealthy_transforms"
|
||||
|
||||
rm -f "$tmp_transforms" "$tmp_stats" "$tmp_installed"
|
||||
|
||||
if [[ "$total_failures" -gt 0 ]]; then
|
||||
echo "Some transform(s) failed to reauthorize."
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_postgres_local_pillar() {
|
||||
# Postgres was added as a service after 3.0.0, so the new pillar/top.sls
|
||||
# references postgres.soc_postgres / postgres.adv_postgres unconditionally.
|
||||
@@ -520,6 +667,31 @@ ensure_postgres_secret() {
|
||||
chown socore:socore "$secrets_file"
|
||||
}
|
||||
|
||||
rename_strelka_scan_lnk() {
|
||||
echo "Renaming strelka pillar ScanLNK to ScanLnk."
|
||||
local STRELKA_FILE=/opt/so/saltstack/local/pillar/strelka/soc_strelka.sls
|
||||
local MINIONDIR=/opt/so/saltstack/local/pillar/minions
|
||||
local OLD_KEY=strelka.backend.config.backend.scanners.ScanLNK
|
||||
local NEW_KEY=strelka.backend.config.backend.scanners.ScanLnk
|
||||
local TMP_VALUE_FILE
|
||||
TMP_VALUE_FILE=$(mktemp)
|
||||
|
||||
for pillar_file in "$STRELKA_FILE" "$MINIONDIR"/*.sls; do
|
||||
[[ -f "$pillar_file" ]] || continue
|
||||
# Skip if ScanLNK doesn't exist
|
||||
so-yaml.py get "$pillar_file" "$OLD_KEY" > "$TMP_VALUE_FILE" 2>/dev/null || continue
|
||||
echo "Found 'ScanLNK' key in $pillar_file. Renaming to 'ScanLnk'."
|
||||
so-yaml.py add "$pillar_file" "$NEW_KEY" "file:$TMP_VALUE_FILE"
|
||||
so-yaml.py remove "$pillar_file" "$OLD_KEY"
|
||||
done
|
||||
|
||||
rm -f "$TMP_VALUE_FILE"
|
||||
}
|
||||
|
||||
fix_logstash_0013_lumberjack_pipeline_name() {
|
||||
update_logstash_pipeline_name "so/0013_input_lumberjack_fleet.conf" "so/0013_input_lumberjack_fleet.conf.jinja"
|
||||
}
|
||||
|
||||
up_to_3.1.0() {
|
||||
ensure_postgres_local_pillar
|
||||
ensure_postgres_secret
|
||||
@@ -527,7 +699,8 @@ up_to_3.1.0() {
|
||||
elasticsearch_backup_index_templates
|
||||
# Clear existing component template state file.
|
||||
rm -f /opt/so/state/esfleet_component_templates.json
|
||||
|
||||
rename_strelka_scan_lnk
|
||||
fix_logstash_0013_lumberjack_pipeline_name
|
||||
|
||||
INSTALLEDVERSION=3.1.0
|
||||
}
|
||||
@@ -553,11 +726,59 @@ post_to_3.1.0() {
|
||||
# file_roots of its own and --local would fail with "No matching sls found".
|
||||
salt-call state.apply postgres.telegraf_users queue=True || true
|
||||
|
||||
# Update default agent policies to use logging level warn.
|
||||
elasticfleet_set_agent_logging_level_warn || true
|
||||
|
||||
# Check for unhealthy / unauthorized integration transform jobs and attempt reauthorizations
|
||||
check_transform_health_and_reauthorize || true
|
||||
|
||||
POSTVERSION=3.1.0
|
||||
}
|
||||
|
||||
### 3.1.0 End ###
|
||||
|
||||
### 3.2.0 Scripts ###
|
||||
|
||||
bootstrap_so_soc_database() {
|
||||
# init-db.sh is mounted into so-postgres at /docker-entrypoint-initdb.d/init-db.sh
|
||||
# and runs automatically only on a fresh data directory. Hosts upgrading from
|
||||
# 3.1.0 already have /nsm/postgres populated, so the so_soc bootstrap block
|
||||
# added in 3.2 never fires. Re-run the script explicitly; it's idempotent.
|
||||
echo "Bootstrapping so_soc database via init-db.sh."
|
||||
# The postgres image has no USER directive, so `docker exec` defaults to
|
||||
# root, and the container env intentionally omits POSTGRES_USER (the upstream
|
||||
# entrypoint defaults it transiently during first-init only). Recreate both
|
||||
# so psql inside init-db.sh resolves the connect user correctly.
|
||||
local exec_cmd="docker exec -u postgres -e POSTGRES_USER=postgres so-postgres bash /docker-entrypoint-initdb.d/init-db.sh"
|
||||
if ! /usr/sbin/so-postgres-wait; then
|
||||
FINAL_MESSAGE_QUEUE+=("WARNING: so-postgres was not ready during the 3.2.0 upgrade; the so_soc database may not have been bootstrapped. Re-run manually: $exec_cmd")
|
||||
return 0
|
||||
fi
|
||||
if ! $exec_cmd; then
|
||||
FINAL_MESSAGE_QUEUE+=("WARNING: init-db.sh failed inside so-postgres during the 3.2.0 upgrade; the so_soc database may not have been bootstrapped. Re-run manually: $exec_cmd")
|
||||
return 0
|
||||
fi
|
||||
echo "so_soc bootstrap complete."
|
||||
}
|
||||
|
||||
up_to_3.2.0() {
|
||||
fix_logstash_0013_lumberjack_pipeline_name
|
||||
|
||||
INSTALLEDVERSION=3.2.0
|
||||
}
|
||||
|
||||
post_to_3.2.0() {
|
||||
bootstrap_so_soc_database
|
||||
|
||||
# Including agent regen script here since it was missed in post_to_3.1.0
|
||||
echo "Regenerating Elastic Agent Installers"
|
||||
/sbin/so-elastic-agent-gen-installers
|
||||
|
||||
POSTVERSION=3.2.0
|
||||
}
|
||||
|
||||
### 3.2.0 End ###
|
||||
|
||||
|
||||
repo_sync() {
|
||||
echo "Sync the local repo."
|
||||
@@ -809,6 +1030,9 @@ verify_es_version_compatibility() {
|
||||
local is_active_intermediate_upgrade=1
|
||||
# supported upgrade paths for SO-ES versions
|
||||
declare -A es_upgrade_map=(
|
||||
["8.18.4"]="8.18.6 8.18.8 9.0.8"
|
||||
["8.18.6"]="8.18.8 9.0.8"
|
||||
["8.18.8"]="9.0.8"
|
||||
["9.0.8"]="9.3.3"
|
||||
)
|
||||
|
||||
@@ -832,6 +1056,171 @@ verify_es_version_compatibility() {
|
||||
exit 160
|
||||
fi
|
||||
|
||||
compatible_es_versions="$target_es_version"
|
||||
for current_version in "${!es_upgrade_map[@]}"; do
|
||||
# shellcheck disable=SC2076
|
||||
if [[ " ${es_upgrade_map[$current_version]} " =~ " $target_es_version " ]]; then
|
||||
compatible_es_versions+=" $current_version"
|
||||
fi
|
||||
done
|
||||
|
||||
# Check if the given ES version can directly upgrade to the target ES version. Used to assist with catching lagging nodes during the upgrade process
|
||||
es_version_can_upgrade_to_target() {
|
||||
local current_version="$1"
|
||||
# shellcheck disable=SC2076
|
||||
if [[ -n "$current_version" && " $compatible_es_versions " =~ " $current_version " ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Gather Elasticsearch cluster version info and verify that each node in the cluster is running a version compatible with the target ES version.
|
||||
verify_searchnodes_es_target_compatibility() {
|
||||
local retries=20
|
||||
local retry_count=0
|
||||
local delay=180
|
||||
local expected_es_nodes searchnode_minions attempt
|
||||
local searchnode_discovery_success=false
|
||||
SEARCHNODE_ES_VERSIONS=""
|
||||
|
||||
for attempt in {1..3}; do
|
||||
if searchnode_minions=$(set -o pipefail; salt-key --out=json --list=accepted 2> /dev/null | jq -r '.minions[]? | select(endswith("searchnode"))'); then
|
||||
searchnode_discovery_success=true
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Failed to retrieve grid searchnodes via salt-key... Retrying in 30 seconds. Attempt $attempt of 3."
|
||||
sleep 30
|
||||
done
|
||||
|
||||
if [[ "$searchnode_discovery_success" != "true" ]]; then
|
||||
echo "Failed to retrieve grid searchnodes via salt-key."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Always add node running soup to expected es nodes
|
||||
expected_es_nodes="${MINIONID%_*}"
|
||||
while IFS= read -r searchnode_minion; do
|
||||
[[ -z "$searchnode_minion" ]] && continue
|
||||
expected_es_nodes+=$'\n'"${searchnode_minion%_searchnode}"
|
||||
done <<< "$searchnode_minions"
|
||||
|
||||
while [[ $retry_count -lt $retries ]]; do
|
||||
SEARCHNODE_ES_VERSIONS=$(so-elasticsearch-query _nodes/_all/version --retry 5 --retry-delay 10 --fail 2>&1)
|
||||
local exit_status=$?
|
||||
|
||||
if [[ $exit_status -ne 0 ]]; then
|
||||
echo "Failed to retrieve Elasticsearch versions from searchnodes... Retrying in $delay seconds. Attempt $((retry_count + 1)) of $retries."
|
||||
((retry_count++))
|
||||
sleep $delay
|
||||
continue
|
||||
fi
|
||||
|
||||
local all_searchnodes_compatible=true
|
||||
while IFS=$'\t' read -r node current_version; do
|
||||
[[ -z "$node" ]] && continue
|
||||
if ! es_version_can_upgrade_to_target "$current_version"; then
|
||||
echo "Searchnode $node is running Elasticsearch $current_version, which is not directly upgradable to Elasticsearch $target_es_version."
|
||||
all_searchnodes_compatible=false
|
||||
fi
|
||||
done < <(echo "$SEARCHNODE_ES_VERSIONS" | jq -r '.nodes | to_entries[] | [.value.name, .value.version] | @tsv')
|
||||
|
||||
while IFS= read -r expected_es_node; do
|
||||
[[ -z "$expected_es_node" ]] && continue
|
||||
if ! echo "$SEARCHNODE_ES_VERSIONS" | jq -e --arg node "$expected_es_node" '.nodes | to_entries | any(.value.name == $node)' > /dev/null; then
|
||||
echo "Searchnode $expected_es_node did not report an Elasticsearch version. It may be offline or still upgrading."
|
||||
all_searchnodes_compatible=false
|
||||
fi
|
||||
done <<< "$expected_es_nodes"
|
||||
|
||||
if [[ "$all_searchnodes_compatible" == true ]]; then
|
||||
echo "All Searchnodes are upgradable to Elasticsearch $target_es_version."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "One or more Searchnodes cannot upgrade directly to Elasticsearch $target_es_version. Rechecking in $delay seconds. Attempt $((retry_count + 1)) of $retries."
|
||||
((retry_count++))
|
||||
sleep $delay
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Gather heavynode version info and verify that each node is running a version compatible with the target ES version.
|
||||
verify_heavynodes_es_target_compatibility() {
|
||||
local heavynode_minions attempt
|
||||
local retries=20
|
||||
local retry_count=0
|
||||
local delay=180
|
||||
local heavynode_discovery_success=false
|
||||
HEAVYNODE_ES_VERSIONS=""
|
||||
|
||||
for attempt in {1..3}; do
|
||||
if heavynode_minions=$(set -o pipefail; salt-key --out=json --list=accepted 2> /dev/null | jq -r '.minions[]? | select(endswith("heavynode"))'); then
|
||||
heavynode_discovery_success=true
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Failed to retrieve grid heavynodes via salt-key... Retrying in 30 seconds. Attempt $attempt of 3."
|
||||
sleep 30
|
||||
done
|
||||
|
||||
if [[ "$heavynode_discovery_success" != "true" ]]; then
|
||||
echo "Failed to retrieve grid heavynodes via salt-key."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -z "$heavynode_minions" ]]; then
|
||||
echo "No heavynodes detected. Skipping heavynode Elasticsearch version compatibility check."
|
||||
return 0
|
||||
fi
|
||||
|
||||
while [[ $retry_count -lt $retries ]]; do
|
||||
HEAVYNODE_ES_VERSIONS=$(salt -C 'G@role:so-heavynode' cmd.run 'set -o pipefail; so-elasticsearch-query / --retry 5 --retry-delay 10 | jq -er ".version.number"' shell=/bin/bash --out=json 2> /dev/null)
|
||||
local exit_status=$?
|
||||
|
||||
if [[ $exit_status -ne 0 ]]; then
|
||||
echo "Failed to retrieve Elasticsearch version from one or more heavynodes... Retrying in $delay seconds. Attempt $((retry_count + 1)) of $retries."
|
||||
((retry_count++))
|
||||
sleep $delay
|
||||
continue
|
||||
fi
|
||||
|
||||
local all_heavynodes_compatible=true
|
||||
while IFS=$'\t' read -r node current_version; do
|
||||
[[ -z "$node" ]] && continue
|
||||
if ! es_version_can_upgrade_to_target "$current_version"; then
|
||||
echo "Heavynode $node is running Elasticsearch $current_version, which is not directly upgradable to Elasticsearch $target_es_version."
|
||||
all_heavynodes_compatible=false
|
||||
fi
|
||||
done < <(echo "$HEAVYNODE_ES_VERSIONS" | jq -r 'to_entries[] | [.key, .value] | @tsv')
|
||||
|
||||
while IFS= read -r heavynode_minion; do
|
||||
[[ -z "$heavynode_minion" ]] && continue
|
||||
if ! echo "$HEAVYNODE_ES_VERSIONS" | jq -se --arg minion "$heavynode_minion" 'add | has($minion)' > /dev/null; then
|
||||
echo "Heavynode $heavynode_minion did not report an Elasticsearch version. It may be offline or still upgrading."
|
||||
all_heavynodes_compatible=false
|
||||
fi
|
||||
done <<< "$heavynode_minions"
|
||||
|
||||
if [[ "$all_heavynodes_compatible" == true ]]; then
|
||||
echo -e "\nAll heavynodes can upgrade to Elasticsearch $target_es_version."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "One or more heavynodes cannot upgrade directly to Elasticsearch $target_es_version. Rechecking in $delay seconds. Attempt $((retry_count + 1)) of $retries."
|
||||
((retry_count++))
|
||||
sleep $delay
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ ! -f "$es_verification_script" ]]; then
|
||||
create_intermediate_upgrade_verification_script "$es_verification_script"
|
||||
fi
|
||||
|
||||
for statefile in "${es_required_version_statefile_base}"-*; do
|
||||
[[ -f $statefile ]] || continue
|
||||
|
||||
@@ -850,10 +1239,6 @@ verify_es_version_compatibility() {
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ ! -f "$es_verification_script" ]]; then
|
||||
create_intermediate_upgrade_verification_script "$es_verification_script"
|
||||
fi
|
||||
|
||||
echo -e "\n##############################################################################################################################\n"
|
||||
echo "A previously required intermediate Elasticsearch upgrade was detected. Verifying that all Searchnodes/Heavynodes have successfully upgraded Elasticsearch to $es_required_version_statefile_value before proceeding with soup to avoid potential data loss! This command can take up to an hour to complete."
|
||||
if ! timeout --foreground 4000 bash "$es_verification_script" "$es_required_version_statefile_value" "$statefile"; then
|
||||
@@ -875,6 +1260,26 @@ verify_es_version_compatibility() {
|
||||
|
||||
# shellcheck disable=SC2076 # Do not want a regex here eg usage " 8.18.8 9.0.8 " =~ " 9.0.8 "
|
||||
if [[ " ${es_upgrade_map[$es_version]} " =~ " $target_es_version " || "$es_version" == "$target_es_version" ]]; then
|
||||
if ! verify_searchnodes_es_target_compatibility || ! verify_heavynodes_es_target_compatibility; then
|
||||
echo -e "\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
|
||||
|
||||
echo "One or more Searchnode(s)/Heavynode(s) cannot upgrade directly to Elasticsearch $target_es_version. This can happen with soups that include Elasticsearch upgrades being run in quick succession. Typically, this will resolve itself as the grid synchronizes. Please allow time for all Searchnodes/Heavynodes to have upgraded Elasticsearch to a compatible version with $target_es_version before running soup again to avoid potential data loss!"
|
||||
|
||||
if [[ -n "$HEAVYNODE_ES_VERSIONS" ]]; then
|
||||
echo "Current heavynode Elasticsearch versions:"
|
||||
echo "$HEAVYNODE_ES_VERSIONS" | jq '.'
|
||||
fi
|
||||
|
||||
if [[ -n "$SEARCHNODE_ES_VERSIONS" ]]; then
|
||||
echo "Current searchnode Elasticsearch versions:"
|
||||
echo "$SEARCHNODE_ES_VERSIONS" | jq '.nodes | to_entries | map({(.value.name): .value.version}) | sort | add'
|
||||
fi
|
||||
|
||||
echo -e "\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
|
||||
|
||||
exit 161
|
||||
fi
|
||||
|
||||
# supported upgrade
|
||||
return 0
|
||||
else
|
||||
@@ -1232,13 +1637,13 @@ main() {
|
||||
echo "Verifying we have the latest soup script."
|
||||
verify_latest_update_script
|
||||
|
||||
echo "Verifying Elasticsearch version compatibility before upgrading."
|
||||
verify_es_version_compatibility
|
||||
|
||||
echo "Let's see if we need to update Security Onion."
|
||||
upgrade_check
|
||||
upgrade_space
|
||||
|
||||
echo "Verifying Elasticsearch version compatibility across the grid before upgrading."
|
||||
verify_es_version_compatibility
|
||||
|
||||
echo "Checking for Salt Master and Minion updates."
|
||||
upgrade_check_salt
|
||||
set -e
|
||||
@@ -1258,7 +1663,8 @@ main() {
|
||||
echo "Applying $HOTFIXVERSION hotfix"
|
||||
# since we don't run the backup.config_backup state on import we wont snapshot previous version states and pillars
|
||||
if [[ ! "$MINION_ROLE" == "import" ]]; then
|
||||
backup_old_states_pillars
|
||||
echo "Running so-config-backup script."
|
||||
/sbin/so-config-backup
|
||||
fi
|
||||
copy_new_files
|
||||
create_local_directories "/opt/so/saltstack/default"
|
||||
@@ -1314,8 +1720,8 @@ main() {
|
||||
# since we don't run the backup.config_backup state on import we wont snapshot previous version states and pillars
|
||||
if [[ ! "$MINION_ROLE" == "import" ]]; then
|
||||
echo ""
|
||||
echo "Creating snapshots of default and local Salt states and pillars and saving to /nsm/backup/"
|
||||
backup_old_states_pillars
|
||||
echo "Running so-config-backup script."
|
||||
/sbin/so-config-backup
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -225,6 +225,7 @@ http {
|
||||
limit_req zone=auth_throttle burst={{ NGINXMERGED.config.throttle_login_burst }} nodelay;
|
||||
limit_req_status 429;
|
||||
proxy_pass http://{{ GLOBALS.manager }}:4433;
|
||||
proxy_set_header Connection "Close";
|
||||
proxy_read_timeout 90;
|
||||
proxy_connect_timeout 90;
|
||||
proxy_set_header Host $host;
|
||||
@@ -237,6 +238,7 @@ http {
|
||||
location ~ ^/auth/.*?(whoami|logout|settings|errors|webauthn.js) {
|
||||
rewrite /auth/(.*) /$1 break;
|
||||
proxy_pass http://{{ GLOBALS.manager }}:4433;
|
||||
proxy_set_header Connection "Close";
|
||||
proxy_read_timeout 90;
|
||||
proxy_connect_timeout 90;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
# https://securityonion.net/license; you may not use this file except in compliance with the
|
||||
# Elastic License 2.0.
|
||||
|
||||
{% set hypervisor = pillar.minion_id %}
|
||||
{% set hypervisor = pillar.get('minion_id', '') %}
|
||||
|
||||
{% if not hypervisor|regex_match('^([A-Za-z0-9._-]{1,253})$') %}
|
||||
{% do salt.log.error('delete_hypervisor_orch: refusing unsafe minion_id=' ~ hypervisor) %}
|
||||
delete_hypervisor_invalid_minion_id:
|
||||
test.fail_without_changes:
|
||||
- name: delete_hypervisor_invalid_minion_id
|
||||
{% else %}
|
||||
|
||||
ensure_hypervisor_mine_deleted:
|
||||
salt.function:
|
||||
@@ -20,3 +27,5 @@ update_salt_cloud_profile:
|
||||
- sls:
|
||||
- salt.cloud.config
|
||||
- concurrent: True
|
||||
|
||||
{% endif %}
|
||||
|
||||
@@ -12,7 +12,14 @@
|
||||
{% if 'vrt' in salt['pillar.get']('features', []) %}
|
||||
|
||||
{% do salt.log.debug('vm_pillar_clean_orch: Running') %}
|
||||
{% set vm_name = pillar.get('vm_name') %}
|
||||
{% set vm_name = pillar.get('vm_name', '') %}
|
||||
|
||||
{% if not vm_name|regex_match('^([A-Za-z0-9._-]{1,253})$') %}
|
||||
{% do salt.log.error('vm_pillar_clean_orch: refusing unsafe vm_name=' ~ vm_name) %}
|
||||
vm_pillar_clean_invalid_name:
|
||||
test.fail_without_changes:
|
||||
- name: vm_pillar_clean_invalid_name
|
||||
{% else %}
|
||||
|
||||
delete_adv_{{ vm_name }}_pillar:
|
||||
module.run:
|
||||
@@ -24,6 +31,8 @@ delete_{{ vm_name }}_pillar:
|
||||
- file.remove:
|
||||
- path: /opt/so/saltstack/local/pillar/minions/{{ vm_name }}.sls
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
|
||||
{% do salt.log.error(
|
||||
|
||||
@@ -46,10 +46,10 @@ postgresinitdir:
|
||||
- require:
|
||||
- file: postgresconfdir
|
||||
|
||||
postgresinitusers:
|
||||
postgresinitdb:
|
||||
file.managed:
|
||||
- name: /opt/so/conf/postgres/init/init-users.sh
|
||||
- source: salt://postgres/files/init-users.sh
|
||||
- name: /opt/so/conf/postgres/init/init-db.sh
|
||||
- source: salt://postgres/files/init-db.sh
|
||||
- user: 939
|
||||
- group: 939
|
||||
- mode: 755
|
||||
|
||||
@@ -31,7 +31,7 @@ so-postgres:
|
||||
- POSTGRES_DB=securityonion
|
||||
# Passwords are delivered via mounted 0600 secret files, not plaintext env vars.
|
||||
# The upstream postgres image resolves POSTGRES_PASSWORD_FILE; entrypoint.sh and
|
||||
# init-users.sh resolve SO_POSTGRES_PASS_FILE the same way.
|
||||
# init-db.sh resolve SO_POSTGRES_PASS_FILE the same way.
|
||||
- POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password
|
||||
- SO_POSTGRES_USER={{ SO_POSTGRES_USER }}
|
||||
- SO_POSTGRES_PASS_FILE=/run/secrets/so_postgres_pass
|
||||
@@ -46,7 +46,7 @@ so-postgres:
|
||||
- /opt/so/conf/postgres/postgresql.conf:/conf/postgresql.conf:ro
|
||||
- /opt/so/conf/postgres/pg_hba.conf:/conf/pg_hba.conf:ro
|
||||
- /opt/so/conf/postgres/secrets:/run/secrets:ro
|
||||
- /opt/so/conf/postgres/init/init-users.sh:/docker-entrypoint-initdb.d/init-users.sh:ro
|
||||
- /opt/so/conf/postgres/init/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro
|
||||
- /etc/pki/postgres.crt:/conf/postgres.crt:ro
|
||||
- /etc/pki/postgres.key:/conf/postgres.key:ro
|
||||
- /etc/pki/tls/certs/intca.crt:/conf/ca.crt:ro
|
||||
@@ -70,7 +70,7 @@ so-postgres:
|
||||
- watch:
|
||||
- file: postgresconf
|
||||
- file: postgreshba
|
||||
- file: postgresinitusers
|
||||
- file: postgresinitdb
|
||||
- file: postgres_super_secret
|
||||
- file: postgres_app_secret
|
||||
- x509: postgres_crt
|
||||
@@ -78,7 +78,7 @@ so-postgres:
|
||||
- require:
|
||||
- file: postgresconf
|
||||
- file: postgreshba
|
||||
- file: postgresinitusers
|
||||
- file: postgresinitdb
|
||||
- file: postgres_super_secret
|
||||
- file: postgres_app_secret
|
||||
- x509: postgres_crt
|
||||
|
||||
@@ -17,6 +17,7 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-E
|
||||
END IF;
|
||||
END
|
||||
\$\$;
|
||||
GRANT ALL ON SCHEMA public TO "$SO_POSTGRES_USER";
|
||||
GRANT ALL PRIVILEGES ON DATABASE "$POSTGRES_DB" TO "$SO_POSTGRES_USER";
|
||||
-- Lock the SOC database down at the connect layer; PUBLIC gets CONNECT
|
||||
-- by default, which would let per-minion telegraf roles open sessions
|
||||
@@ -18,38 +18,22 @@ include:
|
||||
{% set TG_OUT = TELEGRAFMERGED.output | upper %}
|
||||
{% if TG_OUT in ['POSTGRES', 'BOTH'] %}
|
||||
|
||||
# docker_container.running returns as soon as the container starts, but on
|
||||
# first-init docker-entrypoint.sh starts a temporary postgres with
|
||||
# `listen_addresses=''` to run /docker-entrypoint-initdb.d scripts, then
|
||||
# shuts it down before exec'ing the real CMD. A default pg_isready check
|
||||
# (Unix socket) passes during that ephemeral phase and races the shutdown
|
||||
# with "the database system is shutting down". Checking TCP readiness on
|
||||
# 127.0.0.1 only succeeds after the final postgres binds the port.
|
||||
postgres_wait_ready:
|
||||
cmd.run:
|
||||
- name: |
|
||||
for i in $(seq 1 60); do
|
||||
if docker exec so-postgres pg_isready -h 127.0.0.1 -U postgres -q 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "so-postgres did not accept TCP connections within 120s" >&2
|
||||
exit 1
|
||||
- name: /usr/sbin/so-postgres-wait
|
||||
- require:
|
||||
- docker_container: so-postgres
|
||||
- file: postgres_sbin
|
||||
|
||||
# Ensure the shared Telegraf database exists. init-users.sh only runs on a
|
||||
# Ensure the shared Telegraf database exists. init-db.sh only runs on a
|
||||
# fresh data dir, so hosts upgraded onto an existing /nsm/postgres volume
|
||||
# would otherwise never get so_telegraf.
|
||||
postgres_create_telegraf_db:
|
||||
cmd.run:
|
||||
- name: |
|
||||
if ! docker exec so-postgres psql -U postgres -tAc "SELECT 1 FROM pg_database WHERE datname='so_telegraf'" | grep -q 1; then
|
||||
docker exec so-postgres psql -v ON_ERROR_STOP=1 -U postgres -c "CREATE DATABASE so_telegraf"
|
||||
fi
|
||||
- name: /usr/sbin/so-telegraf-postgres create_db
|
||||
- require:
|
||||
- cmd: postgres_wait_ready
|
||||
- file: postgres_sbin
|
||||
|
||||
# Provision the shared group role and schema once. Every per-minion role is a
|
||||
# member of so_telegraf, and each Telegraf connection does SET ROLE so_telegraf
|
||||
@@ -57,68 +41,26 @@ postgres_create_telegraf_db:
|
||||
# on first write are owned by the group role and every member can INSERT/SELECT.
|
||||
postgres_telegraf_group_role:
|
||||
cmd.run:
|
||||
- name: |
|
||||
docker exec -i so-postgres psql -v ON_ERROR_STOP=1 -U postgres -d so_telegraf <<'EOSQL'
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'so_telegraf') THEN
|
||||
CREATE ROLE so_telegraf NOLOGIN;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
GRANT CONNECT ON DATABASE so_telegraf TO so_telegraf;
|
||||
CREATE SCHEMA IF NOT EXISTS telegraf AUTHORIZATION so_telegraf;
|
||||
GRANT USAGE, CREATE ON SCHEMA telegraf TO so_telegraf;
|
||||
CREATE SCHEMA IF NOT EXISTS partman;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_partman SCHEMA partman;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||
-- Telegraf (running as so_telegraf) calls partman.create_parent()
|
||||
-- on first write of each metric, which needs USAGE on the partman
|
||||
-- schema, EXECUTE on its functions/procedures, and write access to
|
||||
-- partman.part_config so it can register new partitioned parents.
|
||||
GRANT USAGE, CREATE ON SCHEMA partman TO so_telegraf;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA partman TO so_telegraf;
|
||||
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA partman TO so_telegraf;
|
||||
GRANT EXECUTE ON ALL PROCEDURES IN SCHEMA partman TO so_telegraf;
|
||||
-- partman creates per-parent template tables (partman.template_*) at
|
||||
-- runtime; default privileges extend DML/sequence access to them.
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA partman
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO so_telegraf;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA partman
|
||||
GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO so_telegraf;
|
||||
-- Hourly partman maintenance. cron.schedule is idempotent by jobname.
|
||||
SELECT cron.schedule(
|
||||
'telegraf-partman-maintenance',
|
||||
'17 * * * *',
|
||||
'CALL partman.run_maintenance_proc()'
|
||||
);
|
||||
EOSQL
|
||||
- name: /usr/sbin/so-telegraf-postgres group_role
|
||||
- require:
|
||||
- cmd: postgres_create_telegraf_db
|
||||
- file: postgres_sbin
|
||||
|
||||
{% set creds = salt['pillar.get']('telegraf:postgres_creds', {}) %}
|
||||
{% for mid, entry in creds.items() %}
|
||||
{% if entry.get('user') and entry.get('pass') %}
|
||||
{% set u = entry.user %}
|
||||
{% set p = entry.pass | replace("'", "''") %}
|
||||
{% set p = entry.pass %}
|
||||
|
||||
postgres_telegraf_role_{{ u }}:
|
||||
cmd.run:
|
||||
- name: |
|
||||
docker exec -i so-postgres psql -v ON_ERROR_STOP=1 -U postgres -d so_telegraf <<'EOSQL'
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '{{ u }}') THEN
|
||||
EXECUTE format('CREATE ROLE %I WITH LOGIN PASSWORD %L', '{{ u }}', '{{ p }}');
|
||||
ELSE
|
||||
EXECUTE format('ALTER ROLE %I WITH PASSWORD %L', '{{ u }}', '{{ p }}');
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
GRANT CONNECT ON DATABASE so_telegraf TO "{{ u }}";
|
||||
GRANT so_telegraf TO "{{ u }}";
|
||||
EOSQL
|
||||
- name: /usr/sbin/so-telegraf-postgres user
|
||||
- env:
|
||||
- ROLE_USER: {{ u | tojson }}
|
||||
- ROLE_PASS: {{ p | tojson }}
|
||||
- hide_output: True
|
||||
- require:
|
||||
- file: postgres_sbin
|
||||
- cmd: postgres_telegraf_group_role
|
||||
|
||||
{% endif %}
|
||||
@@ -130,21 +72,12 @@ postgres_telegraf_role_{{ u }}:
|
||||
{% set retention = salt['pillar.get']('postgres:telegraf:retention_days', 14) | int %}
|
||||
postgres_telegraf_retention_reconcile:
|
||||
cmd.run:
|
||||
- name: |
|
||||
docker exec -i so-postgres psql -v ON_ERROR_STOP=1 -U postgres -d so_telegraf <<'EOSQL'
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_partman') THEN
|
||||
UPDATE partman.part_config
|
||||
SET retention = '{{ retention }} days',
|
||||
retention_keep_table = false
|
||||
WHERE parent_table LIKE 'telegraf.%';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
EOSQL
|
||||
- name: /usr/sbin/so-telegraf-postgres retention
|
||||
- env:
|
||||
- RETENTION_DAYS: {{ retention }}
|
||||
- require:
|
||||
- cmd: postgres_telegraf_group_role
|
||||
- file: postgres_sbin
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -7,15 +7,29 @@
|
||||
|
||||
. /usr/sbin/so-common
|
||||
|
||||
# Without pipefail, a pipeline's exit status is gzip's. A failed pg_dumpall would
|
||||
# otherwise be masked by a successful gzip, silently producing a valid .gz that
|
||||
# holds a truncated dump.
|
||||
set -o pipefail
|
||||
|
||||
# Backups contain role password hashes and full chat data; keep them 0600.
|
||||
umask 0077
|
||||
|
||||
TODAY=$(date '+%Y_%m_%d')
|
||||
BACKUPDIR=/nsm/backup
|
||||
BACKUPFILE="$BACKUPDIR/so-postgres-backup-$TODAY.sql.gz"
|
||||
TMPFILE="$BACKUPFILE.tmp"
|
||||
MAXBACKUPS=7
|
||||
LOGFILE=/opt/so/log/postgres/backup.log
|
||||
|
||||
mkdir -p $BACKUPDIR
|
||||
log() {
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOGFILE"
|
||||
}
|
||||
|
||||
mkdir -p "$BACKUPDIR"
|
||||
|
||||
# Remove any temp files left behind by a previously crashed run
|
||||
rm -f "$BACKUPDIR"/so-postgres-backup-*.sql.gz.tmp
|
||||
|
||||
# Skip if already backed up today
|
||||
if [ -f "$BACKUPFILE" ]; then
|
||||
@@ -27,13 +41,33 @@ if ! docker ps --format '{{.Names}}' | grep -q '^so-postgres$'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Dump all databases and roles, compress
|
||||
docker exec so-postgres pg_dumpall -U postgres | gzip > "$BACKUPFILE"
|
||||
# Always clean up the temp file on exit; the success path clears this trap
|
||||
# after the atomic rename so the finished backup is not deleted.
|
||||
trap 'rm -f "$TMPFILE"' EXIT
|
||||
|
||||
# Retention cleanup
|
||||
NUMBACKUPS=$(find $BACKUPDIR -type f -name "so-postgres-backup*" | wc -l)
|
||||
# Dump all databases and roles, compress. Write to a temp file so the final
|
||||
# filename only ever appears for a complete, verified backup.
|
||||
if ! docker exec so-postgres pg_dumpall -U postgres | gzip > "$TMPFILE"; then
|
||||
log "ERROR: pg_dumpall/gzip failed; backup aborted"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify the compressed stream is intact before publishing it
|
||||
if ! gzip -t "$TMPFILE"; then
|
||||
log "ERROR: backup failed gzip integrity check; backup aborted"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Atomically publish the verified backup
|
||||
mv "$TMPFILE" "$BACKUPFILE"
|
||||
trap - EXIT
|
||||
log "OK: wrote $BACKUPFILE"
|
||||
|
||||
# Retention cleanup (only reached after a successful backup). The glob is
|
||||
# restricted to finished backups so an in-progress .tmp can never be counted.
|
||||
NUMBACKUPS=$(find "$BACKUPDIR" -type f -name "so-postgres-backup-*.sql.gz" | wc -l)
|
||||
while [ "$NUMBACKUPS" -gt "$MAXBACKUPS" ]; do
|
||||
OLDEST=$(find $BACKUPDIR -type f -name "so-postgres-backup*" -printf '%T+ %p\n' | sort | head -n 1 | awk -F" " '{print $2}')
|
||||
OLDEST=$(find "$BACKUPDIR" -type f -name "so-postgres-backup-*.sql.gz" -printf '%T+ %p\n' | sort | head -n 1 | awk -F" " '{print $2}')
|
||||
rm -f "$OLDEST"
|
||||
NUMBACKUPS=$(find $BACKUPDIR -type f -name "so-postgres-backup*" | wc -l)
|
||||
NUMBACKUPS=$(find "$BACKUPDIR" -type f -name "so-postgres-backup-*.sql.gz" | wc -l)
|
||||
done
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/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.
|
||||
|
||||
# Wait for the so-postgres container to accept TCP connections.
|
||||
#
|
||||
# docker_container.running returns as soon as the container starts, but on
|
||||
# first-init docker-entrypoint.sh starts a temporary postgres with
|
||||
# `listen_addresses=''` to run /docker-entrypoint-initdb.d scripts, then
|
||||
# shuts it down before exec'ing the real CMD. A default pg_isready check
|
||||
# (Unix socket) passes during that ephemeral phase and races the shutdown
|
||||
# with "the database system is shutting down". Checking TCP readiness on
|
||||
# 127.0.0.1 only succeeds after the final postgres binds the port.
|
||||
#
|
||||
# Usage: so-postgres-wait [iterations] [sleep_seconds]
|
||||
# Default: 60 iterations, 2s sleep (~120s total).
|
||||
|
||||
ITERATIONS=${1:-60}
|
||||
SLEEP_SECONDS=${2:-2}
|
||||
|
||||
for i in $(seq 1 "$ITERATIONS"); do
|
||||
if docker exec so-postgres pg_isready -h 127.0.0.1 -U postgres -q 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
sleep "$SLEEP_SECONDS"
|
||||
done
|
||||
|
||||
echo "so-postgres did not accept TCP connections within $((ITERATIONS * SLEEP_SECONDS))s" >&2
|
||||
exit 1
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Provision Telegraf state inside the so-postgres container.
|
||||
# Usage: so-telegraf-postgres <subcommand>
|
||||
# create_db Ensure the so_telegraf database exists.
|
||||
# group_role Provision the so_telegraf group role, telegraf/partman schemas,
|
||||
# pg_partman, pg_cron, and the hourly partman maintenance job.
|
||||
# user Create or update a per-minion login role granted to so_telegraf.
|
||||
# Env: ROLE_USER, ROLE_PASS.
|
||||
# retention Reconcile partman retention on telegraf parents.
|
||||
# Env: RETENTION_DAYS.
|
||||
|
||||
cmd="${1:?subcommand required}"
|
||||
|
||||
case "$cmd" in
|
||||
create_db)
|
||||
if ! docker exec so-postgres psql -U postgres -tAc \
|
||||
"SELECT 1 FROM pg_database WHERE datname='so_telegraf'" | grep -q 1; then
|
||||
docker exec so-postgres psql -v ON_ERROR_STOP=1 -U postgres \
|
||||
-c "CREATE DATABASE so_telegraf"
|
||||
fi
|
||||
;;
|
||||
|
||||
group_role)
|
||||
docker exec -i so-postgres psql -v ON_ERROR_STOP=1 -U postgres -d so_telegraf <<'EOSQL'
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'so_telegraf') THEN
|
||||
CREATE ROLE so_telegraf NOLOGIN;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
GRANT CONNECT ON DATABASE so_telegraf TO so_telegraf;
|
||||
CREATE SCHEMA IF NOT EXISTS telegraf AUTHORIZATION so_telegraf;
|
||||
GRANT USAGE, CREATE ON SCHEMA telegraf TO so_telegraf;
|
||||
CREATE SCHEMA IF NOT EXISTS partman;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_partman SCHEMA partman;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||
-- Telegraf (running as so_telegraf) calls partman.create_parent()
|
||||
-- on first write of each metric, which needs USAGE on the partman
|
||||
-- schema, EXECUTE on its functions/procedures, and write access to
|
||||
-- partman.part_config so it can register new partitioned parents.
|
||||
GRANT USAGE, CREATE ON SCHEMA partman TO so_telegraf;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA partman TO so_telegraf;
|
||||
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA partman TO so_telegraf;
|
||||
GRANT EXECUTE ON ALL PROCEDURES IN SCHEMA partman TO so_telegraf;
|
||||
-- partman creates per-parent template tables (partman.template_*) at
|
||||
-- runtime; default privileges extend DML/sequence access to them.
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA partman
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO so_telegraf;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA partman
|
||||
GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO so_telegraf;
|
||||
-- Hourly partman maintenance. cron.schedule is idempotent by jobname.
|
||||
SELECT cron.schedule(
|
||||
'telegraf-partman-maintenance',
|
||||
'17 * * * *',
|
||||
'CALL partman.run_maintenance_proc()'
|
||||
);
|
||||
EOSQL
|
||||
;;
|
||||
|
||||
user)
|
||||
: "${ROLE_USER:?ROLE_USER is required}"
|
||||
: "${ROLE_PASS:?ROLE_PASS is required}"
|
||||
# psql does not substitute :vars inside dollar-quoted strings, so the
|
||||
# conditional CREATE/ALTER is built outside any DO block and dispatched
|
||||
# with \gexec. format() handles identifier/literal quoting.
|
||||
docker exec -i so-postgres psql \
|
||||
-v ON_ERROR_STOP=1 \
|
||||
-v role_user="$ROLE_USER" \
|
||||
-v role_pass="$ROLE_PASS" \
|
||||
-U postgres -d so_telegraf <<'EOSQL'
|
||||
SELECT format(
|
||||
CASE WHEN EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = :'role_user')
|
||||
THEN 'ALTER ROLE %I WITH LOGIN PASSWORD %L'
|
||||
ELSE 'CREATE ROLE %I WITH LOGIN PASSWORD %L'
|
||||
END,
|
||||
:'role_user',
|
||||
:'role_pass'
|
||||
) \gexec
|
||||
GRANT CONNECT ON DATABASE so_telegraf TO :"role_user";
|
||||
GRANT so_telegraf TO :"role_user";
|
||||
EOSQL
|
||||
;;
|
||||
|
||||
retention)
|
||||
: "${RETENTION_DAYS:?RETENTION_DAYS is required}"
|
||||
# \gset + \if guards against a missing pg_partman without using a DO
|
||||
# block (psql :var substitution doesn't reach into dollar-quoted code).
|
||||
docker exec -i so-postgres psql \
|
||||
-v ON_ERROR_STOP=1 \
|
||||
-v retention_days="$RETENTION_DAYS" \
|
||||
-U postgres -d so_telegraf <<'EOSQL'
|
||||
SELECT CASE WHEN EXISTS (SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_partman')
|
||||
THEN 'true' ELSE 'false' END AS has_partman \gset
|
||||
\if :has_partman
|
||||
UPDATE partman.part_config
|
||||
SET retention = :'retention_days' || ' days',
|
||||
retention_keep_table = false
|
||||
WHERE parent_table LIKE 'telegraf.%';
|
||||
\endif
|
||||
EOSQL
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unknown subcommand: $cmd" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -3,12 +3,15 @@
|
||||
# https://securityonion.net/license; you may not use this file except in compliance with the
|
||||
# Elastic License 2.0.
|
||||
|
||||
{% if data['id'].endswith('_hypervisor') and data['result'] == True %}
|
||||
{% set hid = data['id'] %}
|
||||
{% if hid|regex_match('^([A-Za-z0-9._-]{1,253})$')
|
||||
and hid.endswith('_hypervisor')
|
||||
and data['result'] == True %}
|
||||
|
||||
{% if data['act'] == 'accept' %}
|
||||
check_and_trigger:
|
||||
runner.setup_hypervisor.setup_environment:
|
||||
- minion_id: {{ data['id'] }}
|
||||
- minion_id: {{ hid }}
|
||||
{% endif %}
|
||||
|
||||
{% if data['act'] == 'delete' %}
|
||||
@@ -17,8 +20,7 @@ delete_hypervisor:
|
||||
- args:
|
||||
- mods: orch.delete_hypervisor
|
||||
- pillar:
|
||||
minion_id: {{ data['id'] }}
|
||||
minion_id: {{ hid }}
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -9,30 +9,42 @@ import logging
|
||||
import os
|
||||
import pwd
|
||||
import grp
|
||||
import re
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
PILLAR_ROOT = '/opt/so/saltstack/local/pillar/minions/'
|
||||
_VMNAME_RE = re.compile(r'^[A-Za-z0-9._-]{1,253}$')
|
||||
|
||||
|
||||
def run():
|
||||
vm_name = data['kwargs']['name']
|
||||
logging.error("createEmptyPillar reactor: vm_name: %s" % vm_name)
|
||||
pillar_root = '/opt/so/saltstack/local/pillar/minions/'
|
||||
vm_name = data.get('kwargs', {}).get('name', '')
|
||||
if not _VMNAME_RE.match(str(vm_name)):
|
||||
log.error("createEmptyPillar reactor: refusing unsafe vm_name=%r", vm_name)
|
||||
return {}
|
||||
|
||||
log.info("createEmptyPillar reactor: vm_name: %s", vm_name)
|
||||
pillar_files = ['adv_' + vm_name + '.sls', vm_name + '.sls']
|
||||
|
||||
try:
|
||||
# Get socore user and group IDs
|
||||
socore_uid = pwd.getpwnam('socore').pw_uid
|
||||
socore_gid = grp.getgrnam('socore').gr_gid
|
||||
pillar_root_real = os.path.realpath(PILLAR_ROOT)
|
||||
|
||||
for f in pillar_files:
|
||||
full_path = pillar_root + f
|
||||
if not os.path.exists(full_path):
|
||||
# Create empty file
|
||||
os.mknod(full_path)
|
||||
# Set ownership to socore:socore
|
||||
os.chown(full_path, socore_uid, socore_gid)
|
||||
# Set mode to 644 (rw-r--r--)
|
||||
os.chmod(full_path, 0o640)
|
||||
logging.error("createEmptyPillar reactor: created %s with socore:socore ownership and mode 644" % f)
|
||||
full_path = os.path.join(PILLAR_ROOT, f)
|
||||
resolved = os.path.realpath(full_path)
|
||||
if os.path.dirname(resolved) != pillar_root_real:
|
||||
log.error("createEmptyPillar reactor: refusing path outside pillar root: %s", resolved)
|
||||
continue
|
||||
if os.path.exists(resolved):
|
||||
continue
|
||||
os.mknod(resolved)
|
||||
os.chown(resolved, socore_uid, socore_gid)
|
||||
os.chmod(resolved, 0o640)
|
||||
log.info("createEmptyPillar reactor: created %s with socore:socore ownership and mode 0640", f)
|
||||
|
||||
except (KeyError, OSError) as e:
|
||||
logging.error("createEmptyPillar reactor: Error setting ownership/permissions: %s" % str(e))
|
||||
log.error("createEmptyPillar reactor: Error setting ownership/permissions: %s", e)
|
||||
|
||||
return {}
|
||||
|
||||
+33
-11
@@ -1,18 +1,40 @@
|
||||
#!py
|
||||
|
||||
# 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.
|
||||
|
||||
remove_key:
|
||||
wheel.key.delete:
|
||||
- args:
|
||||
- match: {{ data['name'] }}
|
||||
import logging
|
||||
import re
|
||||
|
||||
{{ data['name'] }}_pillar_clean:
|
||||
runner.state.orchestrate:
|
||||
- args:
|
||||
- mods: orch.vm_pillar_clean
|
||||
- pillar:
|
||||
vm_name: {{ data['name'] }}
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
{% do salt.log.info('deleteKey reactor: deleted minion key: %s' % data['name']) %}
|
||||
_VMNAME_RE = re.compile(r'^[A-Za-z0-9._-]{1,253}$')
|
||||
|
||||
|
||||
def run():
|
||||
name = data.get('name', '')
|
||||
if not _VMNAME_RE.match(str(name)):
|
||||
log.error("deleteKey reactor: refusing unsafe name=%r", name)
|
||||
return {}
|
||||
|
||||
log.info("deleteKey reactor: deleted minion key: %s", name)
|
||||
|
||||
return {
|
||||
'remove_key': {
|
||||
'wheel.key.delete': [
|
||||
{'args': [
|
||||
{'match': name},
|
||||
]},
|
||||
],
|
||||
},
|
||||
'%s_pillar_clean' % name: {
|
||||
'runner.state.orchestrate': [
|
||||
{'args': [
|
||||
{'mods': 'orch.vm_pillar_clean'},
|
||||
{'pillar': {'vm_name': name}},
|
||||
]},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
include:
|
||||
- salt.minion
|
||||
- salt.master.boot_mine_update
|
||||
{% if 'vrt' in salt['pillar.get']('features', []) %}
|
||||
- salt.cloud
|
||||
- salt.cloud.reactor_config_hypervisor
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# 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.
|
||||
|
||||
# Manages /etc/systemd/system/so-boot-mine-update.service, a manager-only
|
||||
# Type=oneshot unit that pushes `salt '*' mine.update` once per boot, ordered
|
||||
# before so-boot-highstate.service so mine-backed pillars (node IPs, ES/Redis/
|
||||
# Logstash discovery) are fresh before the boot highstate renders them.
|
||||
|
||||
include:
|
||||
- systemd.reload
|
||||
|
||||
so_boot_mine_update_unit_file:
|
||||
file.managed:
|
||||
- name: /etc/systemd/system/so-boot-mine-update.service
|
||||
- source: salt://salt/service/so-boot-mine-update.service
|
||||
- onchanges_in:
|
||||
- module: systemd_reload
|
||||
|
||||
# Only enable once setup is complete. Until then the gate file is missing and
|
||||
# the unit's own ConditionPathExists would no-op it anyway.
|
||||
so_boot_mine_update_service:
|
||||
service.enabled:
|
||||
- name: so-boot-mine-update.service
|
||||
- onlyif: test -e /opt/so/state/setup-complete
|
||||
- require:
|
||||
- file: so_boot_mine_update_unit_file
|
||||
- module: systemd_reload
|
||||
@@ -0,0 +1,31 @@
|
||||
# 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.
|
||||
|
||||
# Manages /etc/systemd/system/so-boot-highstate.service, a Type=oneshot
|
||||
# RemainAfterExit=yes unit that runs `salt-call state.highstate` exactly once
|
||||
# per system boot. Replaces the legacy `startup_states: highstate` minion
|
||||
# config, which fired on every salt-minion service restart (causing a redundant
|
||||
# highstate whenever a highstate itself restarted salt-minion).
|
||||
|
||||
include:
|
||||
- systemd.reload
|
||||
|
||||
so_boot_highstate_unit_file:
|
||||
file.managed:
|
||||
- name: /etc/systemd/system/so-boot-highstate.service
|
||||
- source: salt://salt/service/so-boot-highstate.service
|
||||
- onchanges_in:
|
||||
- module: systemd_reload
|
||||
|
||||
# Only enable once setup is complete. Until then the gate file is missing and
|
||||
# the unit's own ConditionPathExists would no-op it anyway -- this just keeps
|
||||
# `systemctl is-enabled` honest for the sync_es_users gate.
|
||||
so_boot_highstate_service:
|
||||
service.enabled:
|
||||
- name: so-boot-highstate.service
|
||||
- onlyif: test -e /opt/so/state/setup-complete
|
||||
- require:
|
||||
- file: so_boot_highstate_unit_file
|
||||
- module: systemd_reload
|
||||
@@ -17,6 +17,7 @@ include:
|
||||
- repo.client
|
||||
- salt.mine_functions
|
||||
- salt.minion.service_file
|
||||
- salt.minion.boot_highstate
|
||||
{% if GLOBALS.is_manager %}
|
||||
- ca.signing_policy
|
||||
{% endif %}
|
||||
@@ -80,11 +81,33 @@ set_log_levels:
|
||||
- "log_level: info"
|
||||
- "log_level_logfile: info"
|
||||
|
||||
enable_startup_states:
|
||||
file.uncomment:
|
||||
# startup_states: highstate caused a full highstate to run on every
|
||||
# salt-minion service start, including the restart triggered when a highstate
|
||||
# itself modified the minion config (beacons, mine, unit file). Replaced by
|
||||
# so-boot-highstate.service (managed in salt.minion.boot_highstate), which
|
||||
# runs once per system boot only. Strip the line from /etc/salt/minion on
|
||||
# upgrade; both the commented and uncommented forms historically existed.
|
||||
remove_startup_states:
|
||||
file.line:
|
||||
- name: /etc/salt/minion
|
||||
- regex: '^startup_states: highstate$'
|
||||
- unless: pgrep so-setup
|
||||
- match: 'startup_states: highstate'
|
||||
- mode: delete
|
||||
|
||||
# Upgrade-path bridge: systems that already passed setup under the old gate
|
||||
# (`grep -x 'startup_states: highstate' /etc/salt/minion`) get a /opt/so/state/setup-complete
|
||||
# marker so so-boot-highstate.service can be enabled and the so-user_sync cron
|
||||
# in sync_es_users.sls keeps installing. Setup-in-progress systems instead get
|
||||
# the marker from `mark_setup_complete` in setup/so-functions at the right
|
||||
# moment. `replace: false` means we never overwrite a marker once written.
|
||||
mark_setup_complete_for_upgrades:
|
||||
file.managed:
|
||||
- name: /opt/so/state/setup-complete
|
||||
- replace: false
|
||||
- makedirs: True
|
||||
- onlyif: "grep -qx 'startup_states: highstate' /etc/salt/minion"
|
||||
- require_in:
|
||||
- file: remove_startup_states
|
||||
- service: so_boot_highstate_service
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Security Onion boot-time highstate (runs once per boot)
|
||||
After=salt-minion.service network-online.target docker.service
|
||||
Wants=network-online.target docker.service
|
||||
Requires=salt-minion.service
|
||||
ConditionPathExists=/opt/so/state/setup-complete
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/bin/salt-call state.highstate -l info queue=True
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Security Onion boot-time grid mine.update (managers, runs once per boot before highstate)
|
||||
After=salt-master.service salt-minion.service network-online.target
|
||||
Wants=network-online.target
|
||||
Requires=salt-master.service salt-minion.service
|
||||
Before=so-boot-highstate.service
|
||||
ConditionPathExists=/opt/so/state/setup-complete
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/sbin/so-boot-mine-update
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -8,11 +8,6 @@ set_role_grain:
|
||||
- name: role
|
||||
- value: so-{{ grains.id.split("_") | last }}
|
||||
|
||||
set_highstate:
|
||||
file.append:
|
||||
- name: /etc/salt/minion
|
||||
- text: 'startup_states: highstate'
|
||||
|
||||
enable_salt_minion:
|
||||
service.enabled:
|
||||
- name: salt-minion
|
||||
|
||||
@@ -24,11 +24,6 @@
|
||||
|
||||
{% do SOCDEFAULTS.soc.config.server.modules.elastic.update({'username': GLOBALS.elasticsearch.auth.users.so_elastic_user.user, 'password': GLOBALS.elasticsearch.auth.users.so_elastic_user.pass}) %}
|
||||
|
||||
{% if GLOBALS.postgres is defined and GLOBALS.postgres.auth is defined %}
|
||||
{% set PG_ADMIN_PASS = salt['pillar.get']('secrets:postgres_pass', '') %}
|
||||
{% do SOCDEFAULTS.soc.config.server.modules.update({'postgres': {'hostUrl': GLOBALS.manager_ip, 'port': 5432, 'username': GLOBALS.postgres.auth.users.so_postgres_user.user, 'password': GLOBALS.postgres.auth.users.so_postgres_user.pass, 'adminUser': 'postgres', 'adminPassword': PG_ADMIN_PASS, 'dbname': 'securityonion', 'sslMode': 'require', 'assistantEnabled': true, 'esHostUrl': 'https://' ~ GLOBALS.manager_ip ~ ':9200', 'esUsername': GLOBALS.elasticsearch.auth.users.so_elastic_user.user, 'esPassword': GLOBALS.elasticsearch.auth.users.so_elastic_user.pass, 'esVerifyCert': false}}) %}
|
||||
{% endif %}
|
||||
|
||||
{% do SOCDEFAULTS.soc.config.server.modules.influxdb.update({'hostUrl': 'https://' ~ GLOBALS.influxdb_host ~ ':8086'}) %}
|
||||
{% do SOCDEFAULTS.soc.config.server.modules.influxdb.update({'token': INFLUXDB_TOKEN}) %}
|
||||
{% for tool in SOCDEFAULTS.soc.config.server.client.tools %}
|
||||
|
||||
@@ -1519,6 +1519,16 @@ soc:
|
||||
serviceAccountJSON: ""
|
||||
serviceAccountLocation: ""
|
||||
healthTimeoutSeconds: 5
|
||||
onionconfig:
|
||||
saltstackDir: /opt/so/saltstack
|
||||
bypassEnabled: false
|
||||
postgres:
|
||||
host: ""
|
||||
port: 5432
|
||||
sslMode: "allow"
|
||||
database: securityonion
|
||||
user: ""
|
||||
password: ""
|
||||
salt:
|
||||
queueDir: /opt/sensoroni/queue
|
||||
timeoutMs: 45000
|
||||
|
||||
@@ -117,6 +117,121 @@ transformations:
|
||||
- type: logsource
|
||||
product: linux
|
||||
service: auth
|
||||
# Maps M365 audit rules to Elastic Agent O365 integration logs
|
||||
- id: m365_audit_field_mappings
|
||||
type: field_name_mapping
|
||||
mapping:
|
||||
Operation: event.action
|
||||
ResultStatus: event.outcome
|
||||
ApplicationId: o365.audit.ApplicationId
|
||||
ObjectId: o365.audit.ObjectId
|
||||
RequestType: o365.audit.RequestType
|
||||
rule_conditions:
|
||||
- type: logsource
|
||||
product: m365
|
||||
service: audit
|
||||
- id: m365_audit_add-fields
|
||||
type: add_condition
|
||||
conditions:
|
||||
event.dataset: 'o365.audit'
|
||||
event.module: 'o365'
|
||||
rule_conditions:
|
||||
- type: logsource
|
||||
product: m365
|
||||
service: audit
|
||||
# Maps M365 exchange rules to Elastic Agent O365 integration logs
|
||||
- id: m365_exchange_field_mappings
|
||||
type: field_name_mapping
|
||||
mapping:
|
||||
eventSource: event.provider
|
||||
eventName: event.action
|
||||
status: event.outcome
|
||||
rule_conditions:
|
||||
- type: logsource
|
||||
product: m365
|
||||
service: exchange
|
||||
- id: m365_exchange_add-fields
|
||||
type: add_condition
|
||||
conditions:
|
||||
event.dataset: 'o365.audit'
|
||||
event.module: 'o365'
|
||||
rule_conditions:
|
||||
- type: logsource
|
||||
product: m365
|
||||
service: exchange
|
||||
# Maps M365 threat_management rules to Elastic Agent O365 integration logs
|
||||
- id: m365_threat_management_field_mappings
|
||||
type: field_name_mapping
|
||||
mapping:
|
||||
eventSource: event.provider
|
||||
eventName: event.action
|
||||
status: event.outcome
|
||||
rule_conditions:
|
||||
- type: logsource
|
||||
product: m365
|
||||
service: threat_management
|
||||
- id: m365_threat_management_add-fields
|
||||
type: add_condition
|
||||
conditions:
|
||||
event.dataset: 'o365.audit'
|
||||
event.module: 'o365'
|
||||
rule_conditions:
|
||||
- type: logsource
|
||||
product: m365
|
||||
service: threat_management
|
||||
# Maps M365 threat_detection rules to Elastic Agent O365 integration logs
|
||||
- id: m365_threat_detection_field_mappings
|
||||
type: field_name_mapping
|
||||
mapping:
|
||||
eventSource: event.provider
|
||||
eventName: event.action
|
||||
status: event.outcome
|
||||
rule_conditions:
|
||||
- type: logsource
|
||||
product: m365
|
||||
service: threat_detection
|
||||
- id: m365_threat_detection_add-fields
|
||||
type: add_condition
|
||||
conditions:
|
||||
event.dataset: 'o365.audit'
|
||||
event.module: 'o365'
|
||||
rule_conditions:
|
||||
- type: logsource
|
||||
product: m365
|
||||
service: threat_detection
|
||||
# Maps FortiGate event rules to Elastic Agent Fortinet integration logs
|
||||
- id: fortigate_event_field_mappings
|
||||
type: field_name_mapping
|
||||
mapping:
|
||||
action: fortinet.firewall.action
|
||||
cfgpath: fortinet.firewall.cfgpath
|
||||
cfgobj: fortinet.firewall.cfgobj
|
||||
cfgattr: fortinet.firewall.cfgattr
|
||||
devname: observer.name
|
||||
devid: observer.serial_number
|
||||
logid: event.code
|
||||
type: fortinet.firewall.type
|
||||
subtype: fortinet.firewall.subtype
|
||||
level: log.level
|
||||
vd: fortinet.firewall.vd
|
||||
logdesc: fortinet.firewall.desc
|
||||
user: user.name
|
||||
ui: fortinet.firewall.ui
|
||||
cfgtid: fortinet.firewall.cfgtid
|
||||
msg: message
|
||||
rule_conditions:
|
||||
- type: logsource
|
||||
product: fortigate
|
||||
service: event
|
||||
- id: fortigate_event_add-fields
|
||||
type: add_condition
|
||||
conditions:
|
||||
event.dataset: 'fortinet_fortigate.log'
|
||||
event.module: 'fortinet_fortigate'
|
||||
rule_conditions:
|
||||
- type: logsource
|
||||
product: fortigate
|
||||
service: event
|
||||
# event.code should always be a string
|
||||
- id: convert_event_code_to_string
|
||||
type: convert_type
|
||||
@@ -126,15 +241,36 @@ transformations:
|
||||
fields:
|
||||
- event.code
|
||||
# Maps process_creation rules to endpoint process creation logs
|
||||
# This is an OS-agnostic mapping, to account for logs that don't specify source OS
|
||||
- id: endpoint_process_create_windows_add-fields
|
||||
type: add_condition
|
||||
conditions:
|
||||
event.category: 'process'
|
||||
event.type: 'start'
|
||||
host.os.type: 'windows'
|
||||
rule_conditions:
|
||||
- type: logsource
|
||||
category: process_creation
|
||||
product: windows
|
||||
- id: endpoint_process_create_macos_add-fields
|
||||
type: add_condition
|
||||
conditions:
|
||||
event.category: 'process'
|
||||
event.type: 'start'
|
||||
host.os.type: 'macos'
|
||||
rule_conditions:
|
||||
- type: logsource
|
||||
category: process_creation
|
||||
product: macos
|
||||
- id: endpoint_process_create_linux_add-fields
|
||||
type: add_condition
|
||||
conditions:
|
||||
event.category: 'process'
|
||||
event.type: 'start'
|
||||
host.os.type: 'linux'
|
||||
rule_conditions:
|
||||
- type: logsource
|
||||
category: process_creation
|
||||
product: linux
|
||||
# Maps file_event rules to endpoint file creation logs
|
||||
# This is an OS-agnostic mapping, to account for logs that don't specify source OS
|
||||
- id: endpoint_file_create_add-fields
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
{% do SOCMERGED.config.server.update({'additionalCA': MANAGERMERGED.additionalCA}) %}
|
||||
{% do SOCMERGED.config.server.update({'insecureSkipVerify': MANAGERMERGED.insecureSkipVerify}) %}
|
||||
|
||||
{% if not SOCMERGED.config.server.modules.postgres.host %}
|
||||
{% do SOCMERGED.config.server.modules.postgres.update({'host': GLOBALS.manager}) %}
|
||||
{% endif %}
|
||||
{% if not SOCMERGED.config.server.modules.postgres.password %}
|
||||
{% do SOCMERGED.config.server.modules.postgres.update({'password': salt['pillar.get']('postgres:auth:users:so_postgres_user:pass', '')}) %}
|
||||
{% do SOCMERGED.config.server.modules.postgres.update({'user': salt['pillar.get']('postgres:auth:users:so_postgres_user:user', 'so_postgres')}) %}
|
||||
{% endif %}
|
||||
|
||||
{# if SOCMERGED.config.server.modules.cases == httpcase details come from the soc pillar #}
|
||||
{% if SOCMERGED.config.server.modules.cases != 'soc' %}
|
||||
{% do SOCMERGED.config.server.modules.elastic.update({'casesEnabled': false}) %}
|
||||
|
||||
@@ -453,6 +453,42 @@ soc:
|
||||
description: Duration (in milliseconds) that must elapse after a grid node fails to check-in before the node will be marked offline (fault).
|
||||
global: True
|
||||
advanced: True
|
||||
onionconfig:
|
||||
saltstackDir:
|
||||
description: Root directory containing the SaltStack tree that SOC reads and writes configuration from. Should not be changed under normal circumstances.
|
||||
global: True
|
||||
advanced: True
|
||||
bypassEnabled:
|
||||
description: When enabled, errors encountered while reading the SaltStack pillar tree (missing files, unreadable directories, etc.) are logged but do not prevent SOC from starting or serving settings. Intended for advanced troubleshooting and recovery scenarios when the pillar tree is partially unreadable.
|
||||
global: True
|
||||
advanced: True
|
||||
forcedType: bool
|
||||
postgres:
|
||||
host:
|
||||
description: Hostname or IP address of the PostgreSQL server used by SOC. Defaults to the manager hostname.
|
||||
global: True
|
||||
advanced: True
|
||||
port:
|
||||
description: Port of the PostgreSQL server used by SOC.
|
||||
global: True
|
||||
advanced: True
|
||||
sslMode:
|
||||
description: "Use encrypted connections to the PostgreSQL server. Must be one of the following values: disable, allow, prefer, require, verify-ca, verify-full. Defaults to allow."
|
||||
global: True
|
||||
advanced: True
|
||||
database:
|
||||
description: Database used by SOC to authenticate to the PostgreSQL server.
|
||||
global: True
|
||||
advanced: True
|
||||
user:
|
||||
description: Username used by SOC to authenticate to the PostgreSQL server.
|
||||
global: True
|
||||
advanced: True
|
||||
password:
|
||||
description: Password used by SOC to authenticate to the PostgreSQL server.
|
||||
global: True
|
||||
sensitive: True
|
||||
advanced: True
|
||||
salt:
|
||||
longRelayTimeoutMs:
|
||||
description: Duration (in milliseconds) to wait for a response from the Salt API when executing tasks known for being long running before giving up and showing an error on the SOC UI.
|
||||
@@ -818,6 +854,7 @@ soc:
|
||||
description: List of available external tools visible in the SOC UI. Each tool is defined in JSON object notation, and must include the "name" key and "link" key, where the link is the tool's URL.
|
||||
global: True
|
||||
advanced: True
|
||||
multiline: True
|
||||
forcedType: "[]{}"
|
||||
exportNodeId:
|
||||
description: The node ID on which export jobs will be executed.
|
||||
|
||||
@@ -261,7 +261,7 @@ strelka:
|
||||
priority: 5
|
||||
options:
|
||||
limit: 1000
|
||||
'ScanLNK':
|
||||
'ScanLnk':
|
||||
- positive:
|
||||
flavors:
|
||||
- 'lnk_file'
|
||||
|
||||
@@ -15,7 +15,7 @@ from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
|
||||
with open("/opt/so/conf/strelka/filecheck.yaml", "r") as ymlfile:
|
||||
cfg = yaml.load(ymlfile, Loader=yaml.Loader)
|
||||
cfg = yaml.safe_load(ymlfile)
|
||||
|
||||
extract_path = cfg["filecheck"]["extract_path"]
|
||||
historypath = cfg["filecheck"]["historypath"]
|
||||
|
||||
@@ -99,7 +99,7 @@ strelka:
|
||||
'ScanJpeg': *scannerOptions
|
||||
'ScanJson': *scannerOptions
|
||||
'ScanLibarchive': *scannerOptions
|
||||
'ScanLNK': *scannerOptions
|
||||
'ScanLnk': *scannerOptions
|
||||
'ScanLsb': *scannerOptions
|
||||
'ScanLzma': *scannerOptions
|
||||
'ScanMacho': *scannerOptions
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
telegraf:
|
||||
enabled: False
|
||||
output: BOTH
|
||||
output: INFLUXDB
|
||||
config:
|
||||
interval: '30s'
|
||||
metric_batch_size: 1000
|
||||
|
||||
+27
-2
@@ -119,7 +119,7 @@ base:
|
||||
- kafka
|
||||
- pcap.cleanup
|
||||
|
||||
'*_manager or *_managerhype and G@saltversion:{{saltversion}} and not I@node_data:False':
|
||||
'*_manager and G@saltversion:{{saltversion}} and not I@node_data:False':
|
||||
- match: compound
|
||||
- salt.master
|
||||
- registry
|
||||
@@ -146,6 +146,32 @@ base:
|
||||
- stig
|
||||
- kafka
|
||||
|
||||
'*_managerhype and G@saltversion:{{saltversion}} and not I@node_data:False':
|
||||
- match: compound
|
||||
- salt.master
|
||||
- registry
|
||||
- nginx
|
||||
- influxdb
|
||||
- postgres
|
||||
- strelka.manager
|
||||
- soc
|
||||
- kratos
|
||||
- hydra
|
||||
- firewall
|
||||
- manager
|
||||
- sensoroni
|
||||
- telegraf
|
||||
- backup.config_backup
|
||||
- elasticsearch
|
||||
- logstash
|
||||
- redis
|
||||
- elastic-fleet-package-registry
|
||||
- kibana
|
||||
- elastalert
|
||||
- utility
|
||||
- elasticfleet
|
||||
- kafka
|
||||
|
||||
'*_managerhype and I@features:vrt and G@saltversion:{{saltversion}}':
|
||||
- match: compound
|
||||
- manager.hypervisor
|
||||
@@ -286,7 +312,6 @@ base:
|
||||
- libvirt
|
||||
- libvirt.images
|
||||
- elasticfleet.install_agent_grid
|
||||
- stig
|
||||
|
||||
'*_desktop and G@saltversion:{{saltversion}}':
|
||||
- sensoroni
|
||||
|
||||
+90
-8
@@ -539,16 +539,19 @@ configure_minion() {
|
||||
" x509_v2: true"\
|
||||
"log_level: info"\
|
||||
"log_level_logfile: info"\
|
||||
"log_file: /opt/so/log/salt/minion"\
|
||||
"#startup_states: highstate" >> "$minion_config"
|
||||
"log_file: /opt/so/log/salt/minion" >> "$minion_config"
|
||||
|
||||
}
|
||||
|
||||
checkin_at_boot() {
|
||||
local minion_config=/etc/salt/minion
|
||||
mark_setup_complete() {
|
||||
# Writes the setup-complete marker. Salt's so-boot-highstate.service
|
||||
# (boot-time oneshot) and the so-user_sync cron gate in
|
||||
# salt/manager/sync_es_users.sls both key off this file.
|
||||
local marker=/opt/so/state/setup-complete
|
||||
|
||||
info "Enabling checkin at boot"
|
||||
sed -i 's/#startup_states: highstate/startup_states: highstate/' "$minion_config"
|
||||
info "Marking setup as complete"
|
||||
mkdir -p "$(dirname "$marker")"
|
||||
touch "$marker"
|
||||
}
|
||||
|
||||
check_requirements() {
|
||||
@@ -745,6 +748,56 @@ configure_network_sensor() {
|
||||
return $err
|
||||
}
|
||||
|
||||
configure_management_bond() {
|
||||
local bond_name="bond1"
|
||||
local bond_mode=${MBOND_MODE:-active-backup}
|
||||
|
||||
info "Setting up $bond_name management interface with mode $bond_mode"
|
||||
|
||||
if [[ ${#MBNICS[@]} -eq 0 ]]; then
|
||||
error "[ERROR] No management bond NICs were selected."
|
||||
fail_setup
|
||||
fi
|
||||
|
||||
nmcli -t -f NAME con show | grep -Fxq "$bond_name"
|
||||
local found_int=$?
|
||||
|
||||
if [[ $found_int != 0 ]]; then
|
||||
nmcli con add type bond ifname "$bond_name" con-name "$bond_name" mode "$bond_mode" -- \
|
||||
ipv6.method ignore \
|
||||
connection.autoconnect yes >> "$setup_log" 2>&1
|
||||
else
|
||||
nmcli con mod "$bond_name" \
|
||||
bond.options "mode=$bond_mode" \
|
||||
ipv6.method ignore \
|
||||
connection.autoconnect yes >> "$setup_log" 2>&1
|
||||
fi
|
||||
|
||||
local err=0
|
||||
for MBNIC in "${MBNICS[@]}"; do
|
||||
local slave_name="$bond_name-slave-$MBNIC"
|
||||
|
||||
nmcli -t -f NAME con show | grep -Fxq "$slave_name"
|
||||
found_int=$?
|
||||
|
||||
if [[ $found_int != 0 ]]; then
|
||||
nmcli con add type ethernet ifname "$MBNIC" con-name "$slave_name" master "$bond_name" -- \
|
||||
connection.autoconnect yes >> "$setup_log" 2>&1
|
||||
else
|
||||
nmcli con mod "$slave_name" \
|
||||
connection.master "$bond_name" \
|
||||
connection.slave-type bond \
|
||||
connection.autoconnect yes >> "$setup_log" 2>&1
|
||||
fi
|
||||
|
||||
nmcli con up "$slave_name" >> "$setup_log" 2>&1
|
||||
local ret=$?
|
||||
[[ $ret -eq 0 ]] || err=$ret
|
||||
done
|
||||
|
||||
return $err
|
||||
}
|
||||
|
||||
configure_hyper_bridge() {
|
||||
info "Setting up hypervisor bridge"
|
||||
info "Checking $MNIC ipv4.method is auto or manual"
|
||||
@@ -927,6 +980,8 @@ docker_seed_registry() {
|
||||
docker_seed_update_percent=25
|
||||
|
||||
update_docker_containers 'netinstall' '' 'docker_seed_update' '/dev/stdout' 2>&1 | tee -a "$setup_log"
|
||||
# Use pipe exit status of 'update_docker_containers' for return code
|
||||
return ${PIPESTATUS[0]}
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -999,6 +1054,11 @@ filter_unused_nics() {
|
||||
grep_string="$grep_string\|$BONDNIC"
|
||||
done
|
||||
fi
|
||||
if [[ $MBNICS ]]; then
|
||||
for BONDNIC in "${MBNICS[@]}"; do
|
||||
grep_string="$grep_string\|$BONDNIC"
|
||||
done
|
||||
fi
|
||||
|
||||
# Finally, set filtered_nics to any NICs we aren't using (and ignore interfaces that aren't of use)
|
||||
filtered_nics=$(ip link | awk -F: '$0 !~ "lo|vir|veth|br|docker|wl|^[^0-9]"{print $2}' | grep -vwe "$grep_string" | sed 's/ //g' | sed -r 's/(.*)(\.[0-9]+)@\1/\1\2/g')
|
||||
@@ -1388,7 +1448,7 @@ network_init() {
|
||||
title "Initializing Network"
|
||||
disable_ipv6
|
||||
set_hostname
|
||||
if [[ ( $is_iso || $is_desktop_iso ) ]]; then
|
||||
if [[ $is_iso || $is_desktop_iso ]]; then
|
||||
set_management_interface
|
||||
fi
|
||||
}
|
||||
@@ -1701,6 +1761,24 @@ remove_package() {
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_pyyaml() {
|
||||
title "Ensuring python3-pyyaml is installed"
|
||||
if rpm -q python3-pyyaml >/dev/null 2>&1; then
|
||||
info "python3-pyyaml already installed"
|
||||
return 0
|
||||
fi
|
||||
info "python3-pyyaml not found, attempting to install"
|
||||
set -o pipefail
|
||||
dnf -y install python3-pyyaml 2>&1 | tee -a "$setup_log"
|
||||
local result=$?
|
||||
set +o pipefail
|
||||
if [[ $result -ne 0 ]] || ! rpm -q python3-pyyaml >/dev/null 2>&1; then
|
||||
error "Failed to install python3-pyyaml (exit=$result)"
|
||||
fail_setup
|
||||
fi
|
||||
info "python3-pyyaml installed successfully"
|
||||
}
|
||||
|
||||
# When updating the salt version, also update the version in securityonion-builds/images/iso-task/Dockerfile and salt/salt/master.defaults.yaml and salt/salt/minion.defaults.yaml
|
||||
# CAUTION! SALT VERSION UDDATES - READ BELOW
|
||||
# When updating the salt version, also update the version in:
|
||||
@@ -2084,8 +2162,12 @@ set_initial_firewall_access() {
|
||||
# Set up the management interface on the ISO
|
||||
set_management_interface() {
|
||||
title "Setting up the main interface"
|
||||
if [[ $MNIC == "bond1" ]]; then
|
||||
configure_management_bond || fail_setup
|
||||
fi
|
||||
|
||||
if [ "$address_type" = 'DHCP' ]; then
|
||||
logCmd "nmcli con mod $MNIC connection.autoconnect yes"
|
||||
logCmd "nmcli con mod $MNIC connection.autoconnect yes ipv4.method auto"
|
||||
logCmd "nmcli con up $MNIC"
|
||||
logCmd "nmcli -p connection show $MNIC"
|
||||
else
|
||||
|
||||
+10
-2
@@ -66,6 +66,9 @@ set_timezone
|
||||
# Let's see what OS we are dealing with here
|
||||
detect_os
|
||||
|
||||
# Ensure python3-pyyaml is available before any code that may need so-yaml/PyYAML
|
||||
ensure_pyyaml
|
||||
|
||||
|
||||
# Check to see if this is the setup type of "desktop".
|
||||
is_desktop=
|
||||
@@ -220,6 +223,8 @@ if [ -n "$test_profile" ]; then
|
||||
WEBPASSWD1=0n10nus3r
|
||||
WEBPASSWD2=0n10nus3r
|
||||
NODE_DESCRIPTION="${HOSTNAME} - ${install_type} - ${MSRVIP_OFFSET}"
|
||||
# opt out of telemetry for automated testing
|
||||
telemetry=1
|
||||
|
||||
update_sudoers_for_testing
|
||||
fi
|
||||
@@ -764,7 +769,10 @@ if ! [[ -f $install_opt_file ]]; then
|
||||
title "Applying the registry state"
|
||||
logCmd "salt-call state.apply -l info registry"
|
||||
title "Seeding the docker registry"
|
||||
docker_seed_registry
|
||||
if ! docker_seed_registry; then
|
||||
error "Failed to seed the docker registry"
|
||||
fail_setup
|
||||
fi
|
||||
title "Applying the manager state"
|
||||
logCmd "salt-call state.apply -l info manager"
|
||||
logCmd "salt-call state.apply influxdb -l info"
|
||||
@@ -789,7 +797,7 @@ if ! [[ -f $install_opt_file ]]; then
|
||||
error "Failed to run so-elastic-fleet-setup"
|
||||
fail_setup
|
||||
fi
|
||||
checkin_at_boot
|
||||
mark_setup_complete
|
||||
set_initial_firewall_access
|
||||
initialize_elasticsearch_indices "so-case so-casehistory so-assistant-session so-assistant-chat"
|
||||
# run a final highstate before enabling scheduled highstates.
|
||||
|
||||
+83
-2
@@ -845,18 +845,99 @@ whiptail_management_nic() {
|
||||
[ -n "$TESTING" ] && return
|
||||
|
||||
filter_unused_nics
|
||||
local management_nic_options=( "${nic_list_management[@]}" )
|
||||
if [[ $is_iso || $is_desktop_iso ]]; then
|
||||
management_nic_options+=( "BOND" "Configure a bonded management interface" )
|
||||
fi
|
||||
|
||||
MNIC=$(whiptail --title "$whiptail_title" --menu "Please select the NIC you would like to use for management.\n\nUse the arrow keys to move around and the Enter key to select." 20 75 12 "${nic_list_management[@]}" 3>&1 1>&2 2>&3 )
|
||||
MNIC=$(whiptail --title "$whiptail_title" --menu "Please select the NIC you would like to use for management.\n\nUse the arrow keys to move around and the Enter key to select." 20 75 12 "${management_nic_options[@]}" 3>&1 1>&2 2>&3 )
|
||||
local exitstatus=$?
|
||||
whiptail_check_exitstatus $exitstatus
|
||||
|
||||
while [ -z "$MNIC" ]
|
||||
do
|
||||
MNIC=$(whiptail --title "$whiptail_title" --menu "Please select the NIC you would like to use for management.\n\nUse the arrow keys to move around and the Enter key to select." 22 75 12 "${nic_list_management[@]}" 3>&1 1>&2 2>&3 )
|
||||
MNIC=$(whiptail --title "$whiptail_title" --menu "Please select the NIC you would like to use for management.\n\nUse the arrow keys to move around and the Enter key to select." 22 75 12 "${management_nic_options[@]}" 3>&1 1>&2 2>&3 )
|
||||
local exitstatus=$?
|
||||
whiptail_check_exitstatus $exitstatus
|
||||
done
|
||||
|
||||
if [[ $MNIC == "BOND" ]]; then
|
||||
whiptail_management_bond
|
||||
fi
|
||||
}
|
||||
|
||||
whiptail_management_bond() {
|
||||
|
||||
[ -n "$TESTING" ] && return
|
||||
|
||||
MBOND_MODE=$(whiptail --title "$whiptail_title" --menu \
|
||||
"Choose the bond mode for the management interface.\n\nThe management bond will be created as bond1." 20 75 7 \
|
||||
"active-backup" "One active NIC with failover (recommended)" \
|
||||
"balance-rr" "Round-robin transmit policy" \
|
||||
"balance-xor" "Transmit based on selected hash policy" \
|
||||
"broadcast" "Transmit everything on all slave interfaces" \
|
||||
"802.3ad" "Dynamic link aggregation (requires switch support)" \
|
||||
"balance-tlb" "Adaptive transmit load balancing" \
|
||||
"balance-alb" "Adaptive load balancing" 3>&1 1>&2 2>&3)
|
||||
local exitstatus=$?
|
||||
whiptail_check_exitstatus $exitstatus
|
||||
|
||||
while [ -z "$MBOND_MODE" ]
|
||||
do
|
||||
MBOND_MODE=$(whiptail --title "$whiptail_title" --menu \
|
||||
"Choose the bond mode for the management interface.\n\nThe management bond will be created as bond1." 20 75 7 \
|
||||
"active-backup" "One active NIC with failover (recommended)" \
|
||||
"balance-rr" "Round-robin transmit policy" \
|
||||
"balance-xor" "Transmit based on selected hash policy" \
|
||||
"broadcast" "Transmit everything on all slave interfaces" \
|
||||
"802.3ad" "Dynamic link aggregation (requires switch support)" \
|
||||
"balance-tlb" "Adaptive transmit load balancing" \
|
||||
"balance-alb" "Adaptive load balancing" 3>&1 1>&2 2>&3)
|
||||
local exitstatus=$?
|
||||
whiptail_check_exitstatus $exitstatus
|
||||
done
|
||||
|
||||
whiptail_management_bond_nics
|
||||
MNIC="bond1"
|
||||
|
||||
export MBOND_MODE MNIC
|
||||
}
|
||||
|
||||
whiptail_management_bond_nics() {
|
||||
|
||||
[ -n "$TESTING" ] && return
|
||||
|
||||
MBNICS=()
|
||||
filter_unused_nics
|
||||
|
||||
MBNICS=$(whiptail --title "$whiptail_title" --checklist "Please add NICs to the Management Interface:" 20 75 12 "${nic_list[@]}" 3>&1 1>&2 2>&3)
|
||||
local exitstatus=$?
|
||||
whiptail_check_exitstatus $exitstatus
|
||||
|
||||
while [ -z "$MBNICS" ]
|
||||
do
|
||||
MBNICS=$(whiptail --title "$whiptail_title" --checklist "Please add NICs to the Management Interface:" 20 75 12 "${nic_list[@]}" 3>&1 1>&2 2>&3)
|
||||
local exitstatus=$?
|
||||
whiptail_check_exitstatus $exitstatus
|
||||
done
|
||||
|
||||
MBNICS=$(echo "$MBNICS" | tr -d '"')
|
||||
|
||||
IFS=' ' read -ra MBNICS <<< "$MBNICS"
|
||||
|
||||
for bond_nic in "${MBNICS[@]}"; do
|
||||
for dev_status in "${nmcli_dev_status_list[@]}"; do
|
||||
if [[ $dev_status == "${bond_nic}:unmanaged" ]]; then
|
||||
whiptail \
|
||||
--title "$whiptail_title" \
|
||||
--msgbox "$bond_nic is unmanaged by Network Manager. Please remove it from other network management tools then re-run setup." \
|
||||
8 75
|
||||
exit
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
export MBNICS
|
||||
}
|
||||
|
||||
whiptail_net_method() {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user