diff --git a/.github/workflows/4_builderpackage_dashboard.yml b/.github/workflows/4_builderpackage_dashboard.yml index 3985fb5d04bc..3936eb6e955a 100644 --- a/.github/workflows/4_builderpackage_dashboard.yml +++ b/.github/workflows/4_builderpackage_dashboard.yml @@ -252,7 +252,7 @@ jobs: build-base: needs: [validate-job] name: Build dashboard - uses: wazuh/wazuh-dashboard/.github/workflows/4_builderpackage_dashboard_core.yml@4.14.11 + uses: wazuh/wazuh-dashboard/.github/workflows/4_builderpackage_dashboard_core.yml@4.14.12 with: CHECKOUT_TO: ${{ github.head_ref || github.ref_name }} ARCHITECTURE: ${{ inputs.architecture }} @@ -265,7 +265,7 @@ jobs: permissions: pull-requests: write id-token: write - uses: wazuh/wazuh-dashboard-plugins/.github/workflows/4_builderpackage_plugins.yml@4.14.11 + uses: wazuh/wazuh-dashboard-plugins/.github/workflows/4_builderpackage_plugins.yml@4.14.12 with: reference: ${{ inputs.reference_wazuh_plugins }} execution_repository: 'wazuh-dashboard/4_builderpackage_dashboard' @@ -277,7 +277,7 @@ jobs: permissions: pull-requests: write id-token: write - uses: wazuh/wazuh-security-dashboards-plugin/.github/workflows/4_builderpackage_security_plugin.yml@4.14.11 + uses: wazuh/wazuh-security-dashboards-plugin/.github/workflows/4_builderpackage_security_plugin.yml@4.14.12 with: reference: ${{ inputs.reference_security_plugins }} execution_repository: 'wazuh-dashboard/4_builderpackage_dashboard' diff --git a/.github/workflows/5_builderpackage_dashboard.yml b/.github/workflows/5_builderpackage_dashboard.yml index bd501f7fc17a..8b861de0f53d 100644 --- a/.github/workflows/5_builderpackage_dashboard.yml +++ b/.github/workflows/5_builderpackage_dashboard.yml @@ -578,15 +578,28 @@ jobs: env: username: 'wazuh-devel-xdrsiem-dashboard' run: | - git clone https://${{ env.username }}:${{ secrets.DASHBOARD_BOT_SMOKE_TEST_TOKEN }}@github.com/wazuh/wazuh-automation.git - # Workaround: AWS CodeBuild ARM runners do not have pip3 or openssh-client pre-installed - if ! command -v pip3 &>/dev/null || ! command -v ssh &>/dev/null; then + git clone https://${{ env.username }}:${{ secrets.DASHBOARD_BOT_SMOKE_TEST_TOKEN }}@github.com/wazuh/wazuh-automation.git -b $(bash dev-tools/product_version.sh) + # Workaround: AWS CodeBuild runners may lack openssh-client, and the Allocator package + # requires Python 3.12 specifically, while the runner's default python3 is 3.10. + # Runners with pyenv expose an inactive python3.12 shim, so `command -v` is not enough: + # activate pyenv's 3.12 (persisted via GITHUB_ENV for later steps) or install it via apt. + if ! python3.12 --version &>/dev/null; then + PY312=$(command -v pyenv &>/dev/null && pyenv versions --bare | grep '^3\.12' | tail -1 || true) + if [ -n "$PY312" ]; then + export PYENV_VERSION="$PY312" + echo "PYENV_VERSION=$PY312" >> "$GITHUB_ENV" + else + sudo apt-get update + sudo apt-get install -y python3.12 python3.12-venv + fi + fi + if ! command -v ssh &>/dev/null; then sudo apt-get update - command -v pip3 &>/dev/null || sudo apt-get install -y python3-pip - command -v ssh &>/dev/null || sudo apt-get install -y openssh-client + sudo apt-get install -y openssh-client fi + python3.12 -m ensurepip --upgrade cd wazuh-automation - pip3 install --retries 3 -r deployability/deps/requirements.txt + python3.12 -m pip install --retries 3 ./deployability/modules/allocation - name: RPM - Configure AWS credentials uses: aws-actions/configure-aws-credentials@v6 @@ -606,7 +619,7 @@ jobs: INSTANCE_NAME="centos_8_arm_large_aws" COMPOSITE_NAME="centos_stream-8-arm64" fi - python3 wazuh-automation/deployability/modules/allocation/main.py --action create --provider aws --size large --composite-name "${COMPOSITE_NAME}" --instance-name "${INSTANCE_NAME}" --inventory-output "/tmp/inventory.yaml" --track-output "/tmp/track.yaml" --label-team dashboard --label-termination-date 1d --label-creator github-actions --working-dir /tmp/dashboard --custom-tags "Product:wazuh-dashboard,Purpose:package-build,ExecutionId:${{ github.run_id }},Stage:ci" + python3.12 -m allocation.main --action create --provider aws --size large --composite-name "${COMPOSITE_NAME}" --instance-name "${INSTANCE_NAME}" --inventory-output "/tmp/inventory.yaml" --track-output "/tmp/track.yaml" --label-team dashboard --label-termination-date 1d --label-creator github-actions --working-dir /tmp/dashboard --custom-tags "Product:wazuh-dashboard,Purpose:package-build,ExecutionId:${{ github.run_id }},Stage:ci" ansible_host=$(grep 'ansible_host:' /tmp/inventory.yaml | sed 's/.*: *//') ansible_port=$(grep 'ansible_port:' /tmp/inventory.yaml | sed 's/.*: *//') ansible_user=$(grep 'ansible_user:' /tmp/inventory.yaml | sed 's/.*: *//') @@ -709,10 +722,9 @@ jobs: if: ${{ always() }} ######################################## run: | - if [ "${{ inputs.system }}" = "rpm" ]; then + if [ "${{ inputs.system }}" = "rpm" ] && [ -f /tmp/track.yaml ]; then echo "Destroying Allocator Machine" - cd wazuh-automation/deployability - python3 modules/allocation/main.py --action delete --track-output "/tmp/track.yaml" + python3.12 -m allocation.main --action delete --track-output "/tmp/track.yaml" fi upload-package: diff --git a/config/opensearch_dashboards.prod.yml b/config/opensearch_dashboards.prod.yml index 832380688183..29b1a66a398e 100644 --- a/config/opensearch_dashboards.prod.yml +++ b/config/opensearch_dashboards.prod.yml @@ -1,7 +1,7 @@ server.host: 0.0.0.0 server.port: 443 opensearch.hosts: https://localhost:9200 -opensearch.ssl.verificationMode: certificate +opensearch.ssl.verificationMode: full #opensearch.username: #opensearch.password: opensearch.requestHeadersAllowlist: ['securitytenant', 'Authorization'] diff --git a/dev-tools/build-packages/build-packages.sh b/dev-tools/build-packages/build-packages.sh index 991bc9449b8b..6b49d9f6747d 100755 --- a/dev-tools/build-packages/build-packages.sh +++ b/dev-tools/build-packages/build-packages.sh @@ -94,42 +94,6 @@ get_packages(){ cd .. } -# Candidate refs of wazuh-installation-assistant to download the shared -# credentials library from, in order, space separated. The Wazuh repositories -# cut the same tag names, so a tag build resolves to the matching tag: -# 1. WAZUH_CREDENTIALS_LIB_REF, an explicit override -# 2. the tag being built -- and then nothing else: a release must never -# fall back to a branch that keeps moving after it is cut -# 3. the branch being built, which only exists upstream when it was created -# there too (a feature branch 404s and falls through) -# 4. the version branch, then the version tag -credentials_lib_refs() { - local refs="" tag="" branch="" ref - if [ "${GITHUB_REF_TYPE:-}" = "tag" ] && [ -n "${GITHUB_REF_NAME:-}" ]; then - tag="${GITHUB_REF_NAME}" - else - tag="$(git -C "${root_dir}" describe --tags --exact-match 2>/dev/null || true)" - fi - if [ -z "${tag}" ]; then - branch="${GITHUB_HEAD_REF:-}" - [ -n "${branch}" ] || [ "${GITHUB_REF_TYPE:-}" != "branch" ] || branch="${GITHUB_REF_NAME:-}" - [ -n "${branch}" ] || branch="$(git -C "${root_dir}" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" - [ "${branch}" != "HEAD" ] || branch="" - fi - for ref in "${WAZUH_CREDENTIALS_LIB_REF:-}" "${tag}" "${branch}"; do - [ -n "${ref}" ] || continue - case " ${refs} " in *" ${ref} "*) continue ;; esac - refs="${refs:+${refs} }${ref}" - done - if [ -z "${tag}" ]; then - for ref in "${version}" "v${version}"; do - case " ${refs} " in *" ${ref} "*) continue ;; esac - refs="${refs:+${refs} }${ref}" - done - fi - echo "${refs}" -} - build_tar() { log log "Building base package..." @@ -148,8 +112,6 @@ build_tar() { run_with_retry docker run -t --rm \ -e "RETRY_MAX_ATTEMPTS=${RETRY_MAX_ATTEMPTS}" \ -e "RETRY_DELAY_SECONDS=${RETRY_DELAY_SECONDS}" \ - -e "WAZUH_CREDENTIALS_LIB_REFS=$(credentials_lib_refs)" \ - -e "WAZUH_CREDENTIALS_LIB_SHA256=${WAZUH_CREDENTIALS_LIB_SHA256:-}" \ -v "${tmp_dir}/:/tmp:Z" -v "${output_dir}/:/output:Z" \ "${container_name}" "${version}" "${revision}" "${architecture}" "${verbose}" || return 1 cd .. diff --git a/dev-tools/build-packages/config/wazuh-dashboard b/dev-tools/build-packages/config/wazuh-dashboard index fce2b1523ba1..95912fe2c949 100644 --- a/dev-tools/build-packages/config/wazuh-dashboard +++ b/dev-tools/build-packages/config/wazuh-dashboard @@ -61,22 +61,6 @@ start() { return 1 fi - # Resolve the kibanaserver and wazuh-internal-client passwords into the - # keystore, as the systemd unit's ExecStartPre does. It names every missing - # key on failure. - if ! /usr/share/wazuh-dashboard/bin/resolve-credentials --prestart; then - emit "$name not started: its credentials could not be resolved" - return 1 - fi - - # Resolve the kibanaserver and wazuh-internal-client passwords into the - # keystore, as the systemd unit's ExecStartPre does. It names every missing - # key on failure. - if ! /usr/share/wazuh-dashboard/bin/resolve-credentials --prestart; then - emit "$name not started: its credentials could not be resolved" - return 1 - fi - # Run the program! diff --git a/dev-tools/build-packages/credentials/README.md b/dev-tools/build-packages/credentials/README.md index e049f49e443f..56d653e21002 100644 --- a/dev-tools/build-packages/credentials/README.md +++ b/dev-tools/build-packages/credentials/README.md @@ -84,9 +84,24 @@ Issued by `--install` only: A pair staged in `ca/` by the operator (no marker) is validated and used, and never removed. The leaf is RSA 2048, SHA-256, 10 years backdated one day, `serverAuth,clientAuth`, `CN=`. `dashboard.pem` holds the leaf followed by the CA. Its SANs are `DNS:`, the - FQDN when it differs, every global address of the default-route interfaces, or - `WAZUH_DASHBOARD_CERT_SANS` (an empty list is refused); `localhost`, `127.0.0.1` and `::1` are - always added. The CA certificate is published as `export/ca.pem`. + FQDN when it differs, every global address of the host, or `WAZUH_DASHBOARD_CERT_SANS` (an + empty list is refused); `localhost`, `127.0.0.1` and `::1` are always added. The CA certificate + is published as `export/ca.pem`. +- Addresses, the manager's rule: every interface, whether or not it carries the default route or + is up. For IPv6 that is usually up only: on a down interface the kernel removes the addresses + or keeps them tentative, which are left out, as the manager does. Interfaces with no address + check, such as dummy ones, keep them, and they are named. IPv4 from the interfaces and the + `/32 host LOCAL` entries of `/proc/net/fib_trie`; IPv6 from `/proc/net/if_inet6`, without + deprecated, tentative or DAD-failed ones. Loopback and link-local + are left out. Unlike the manager, two kinds of IPv4 entries are named too, since `fib_trie` + records no scope and no interface: addresses added with `scope link` or `scope host`, and + single addresses routed to the host with `ip route add local
`. The default-route + interfaces come first, the preferred route's first, so the URL the install prints stays on them. + When `fib_trie` cannot be read, only the interfaces that are up are named, with a warning. +- The certificate shows these addresses to every client, container bridges and Kubernetes service + addresses included. To name others, or fewer, set `WAZUH_DASHBOARD_CERT_SANS` in `local.env`. +- To re-issue: remove the pair and run `--install`. With no CA in `ca/`, a new one is minted, and + clients must trust the new `export/ca.pem`. ## Peer CAs diff --git a/dev-tools/build-packages/credentials/resolve-credentials.js b/dev-tools/build-packages/credentials/resolve-credentials.js index 77b934da6fa9..201cc80e53b0 100644 --- a/dev-tools/build-packages/credentials/resolve-credentials.js +++ b/dev-tools/build-packages/credentials/resolve-credentials.js @@ -531,27 +531,31 @@ function fqdn(host) { return out.status === 0 && name ? name : null; } -// Interfaces that carry a default route, from the kernel's routing tables. -function defaultRouteInterfaces() { +// A file under /proc, or null when it cannot be read. +function readProc(file) { + try { + return fs.readFileSync(file, 'utf8'); + } catch (e) { + return null; + } +} + +// Interfaces that carry a default route, preferred first: the kernel lists the routes to one +// destination by metric. +function parseDefaultRoutes(route, ipv6Route) { const names = new Set(); - const read = (file) => { - try { - return fs.readFileSync(file, 'utf8').split('\n'); - } catch (e) { - return []; - } - }; - for (const line of read('/proc/net/route').slice(1)) { - const [iface, destination] = line.trim().split(/\s+/); - if (destination === '00000000') names.add(iface); + for (const line of route.split('\n').slice(1)) { + // The mask too: a VPN's 0.0.0.0/1 and 128.0.0.0/1 are not the default route. + const [iface, destination, , , , , , mask] = line.trim().split(/\s+/); + if (destination === '00000000' && mask === '00000000') names.add(iface); } - for (const line of read('/proc/net/ipv6_route')) { + for (const line of ipv6Route.split('\n')) { const fields = line.trim().split(/\s+/); if (fields[0] === '0'.repeat(32) && fields[1] === '00' && fields[9] !== 'lo') { names.add(fields[9]); } } - return names; + return [...names]; } function isGlobalAddress(address) { @@ -559,17 +563,126 @@ function isGlobalAddress(address) { return !(a.startsWith('127.') || a.startsWith('169.254.') || a === '::1' || /^fe[89ab]/.test(a)); } -function discoverAddresses() { - const interfaces = os.networkInterfaces(); +// The IPv4 addresses of every interface, up or down: the kernel keeps a `/32 host LOCAL` entry +// for each, under the `|--
` line of its key. +function parseFibTrie(text) { const addresses = []; - for (const name of defaultRouteInterfaces()) { - for (const { address, internal } of interfaces[name] || []) { - if (!internal && isGlobalAddress(address)) addresses.push(address); + let key = null; + for (const line of text.split('\n')) { + const leaf = line.match(/\|-- (\S+)$/); + if (leaf) key = leaf[1]; + else if (/^\s*\/32 host LOCAL\b/.test(line) && net.isIPv4(key || '')) { + // Each table (Main, Local, a VRF's) lists it again. + if (!addresses.includes(key)) addresses.push(key); + } + } + return addresses; +} + +// IFA_F_DADFAILED, IFA_F_DEPRECATED and IFA_F_TENTATIVE: addresses a certificate must not name. +const IFA_F_UNUSABLE = 0x08 | 0x20 | 0x40; + +// The usable global IPv6 addresses, with their interface: one ` +// ` line per address. +function parseIfInet6(text) { + const addresses = []; + for (const line of text.split('\n')) { + const [hex, index, , scope, flags, name] = line.trim().split(/\s+/); + if (!/^[0-9a-f]{32}$/i.test(hex || '') || scope !== '00') continue; + if (parseInt(flags, 16) & IFA_F_UNUSABLE) continue; + const address = shortIpv6(hex.match(/.{4}/g).join(':')); + addresses.push({ name, index: parseInt(index, 16), address }); + } + return addresses; +} + +// The short form of an IPv6 address, so the kernel's and Node's spellings compare equal. +function shortIpv6(address) { + try { + return new URL(`http://[${address}]`).hostname.slice(1, -1); + } catch (e) { + return address; + } +} + +// Every global address of the host, as the manager discovers them: every interface, whether or +// not it carries the default route or is up. The default-route interfaces come first, in route +// order, IPv4 before IPv6, so the first address stays the one the install prints. +function discoverAddresses({ + interfaces = {}, + defaultRoute = [], + fibTrie = null, + ifInet6 = null, + warn = () => {}, +} = {}) { + const up = []; + for (const [name, list] of Object.entries(interfaces)) { + for (const { address, family, internal } of list) { + if (internal) continue; + const v4 = family === 'IPv4'; + up.push({ name, address: v4 ? address : shortIpv6(address), v4 }); } } + const found = up.filter((a) => a.v4); + const local = fibTrie === null ? null : parseFibTrie(fibTrie); + // Every address of an interface that is up has an entry, so a missing one means the format is + // not the one parsed here. + if (local && found.every((a) => !isGlobalAddress(a.address) || local.includes(a.address))) { + found.push(...local.map((address) => ({ name: null, address, v4: true }))); + } else { + warn( + 'could not read the addresses in /proc/net/fib_trie; ' + + 'those of interfaces that are down are left out' + ); + } + const upV6 = up.filter((a) => !a.v4); + if (ifInet6 === null) { + found.push(...upV6); + } else { + // if_inet6 is in hash order: keep Node's order for the interfaces that are up, then the rest + // by interface index. + const usable = parseIfInet6(ifInet6); + const isUp = (a) => upV6.some((u) => u.address === a.address); + found.push(...upV6.filter((u) => usable.some((a) => a.address === u.address))); + found.push( + ...usable + .filter((a) => !isUp(a)) + .sort((a, b) => a.index - b.index) + .map((a) => ({ ...a, v4: false })) + ); + } + const routeRank = (name) => { + const i = defaultRoute.indexOf((name || '').split(':')[0]); + return i === -1 ? defaultRoute.length : i; + }; + const rank = (a) => routeRank(a.name) * 2 + (a.v4 ? 0 : 1); + const addresses = []; + for (const { address } of found.sort((a, b) => rank(a) - rank(b))) { + if (isGlobalAddress(address) && !addresses.includes(address)) addresses.push(address); + } return addresses; } +function hostAddresses(warn) { + let interfaces = {}; + try { + interfaces = os.networkInterfaces(); + } catch (e) { + warn(`could not list the network interfaces: ${e.message}`); + } + return discoverAddresses({ + interfaces, + defaultRoute: parseDefaultRoutes( + readProc('/proc/net/route') || '', + readProc('/proc/net/ipv6_route') || '' + ), + fibTrie: readProc('/proc/net/fib_trie'), + // Absent when IPv6 is disabled at boot (ipv6.disable=1); then no interface has one either. + ifInet6: readProc('/proc/net/if_inet6'), + warn, + }); +} + const DNS_NAME = /^(?!-)[A-Za-z0-9*-]+(? `IP:${a}`)); + let addresses = []; + try { + addresses = discover((msg) => err(`warning: ${msg}`)); + } catch (e) { + err(`warning: could not discover the addresses of this host: ${e.message}`); + } + if (addresses.length === 0) { + err( + 'warning: found no address of this host, so the certificate names only the host name and ' + + `loopback; set WAZUH_DASHBOARD_CERT_SANS in ${path.join(ctx.configDir, 'local.env')}` + ); + } + list.push(...addresses.map((a) => `IP:${a}`)); } list.push('DNS:localhost', 'IP:127.0.0.1', 'IP:::1'); return parseSans(list.join(',')); @@ -1070,6 +1195,11 @@ module.exports = { storesVerbatim, ymlHas, parseSans, + parseDefaultRoutes, + parseFibTrie, + parseIfInet6, + discoverAddresses, + leafSans, mintCa, issueLeaf, validCa, diff --git a/dev-tools/build-packages/credentials/resolve-credentials.sh b/dev-tools/build-packages/credentials/resolve-credentials.sh deleted file mode 100755 index 5349430b5e1c..000000000000 --- a/dev-tools/build-packages/credentials/resolve-credentials.sh +++ /dev/null @@ -1,1373 +0,0 @@ -#!/bin/sh - -# Copyright (C) 2015, Wazuh Inc. -# -# This program is free software; you can redistribute it -# and/or modify it under the terms of the GNU General Public -# License (version 2) as published by the FSF - Free Software -# Foundation. -# -# The dashboard's half of the credential resolution ladder. -# -# The shared half -- the credentials file, its locking convention and password validation -- lives -# in wazuh-credentials.sh, which the manager, the indexer and the dashboard must agree on exactly. -# It is therefore NOT in this repository: it is owned by wazuh-installation-assistant and -# downloaded by base-builder.sh at package build time into lib/wazuh-credentials.sh. -# -# This file adds only what is specific to the dashboard, and that is little: the dashboard OWNS NO -# SHARED CREDENTIAL and CONSUMES TWO PASSWORDS, both into its keystore. -# -# WAZUH_INDEXER_KIBANASERVER_PASSWORD kibanaserver, owned by the indexer -# -> opensearch.username (kibanaserver), opensearch.password -# WAZUH_MANAGER_WUI_PASSWORD wazuh-internal-client, owned by the manager -# -> wazuh_core.hosts.default.password -# -# So it never generates a password and never publishes a key into the credentials file: inventing -# a value does not make the peer accept it, and a key has exactly one writer -- its owner. It is -# the only component that can end with every one of its credentials unresolved. -# -# The one secret it does own is local to it: wazuh_ai_assistant.encryptionKey, which the AI -# assistant encrypts the provider API keys it stores with. Nobody else reads it, so it is -# generated here, straight into the keystore, and never published. See its own section below. -# -# It also owns its TLS certificates, certs/dashboard.pem and dashboard-key.pem, which a fresh -# install issues from the shared CA -- minting that CA first when no component has yet -- together -# with the certs/root-ca.pem anchor. That is the one thing the dashboard writes outside its own -# files, and only through the shared library, under its lock. See its own section below. -# -# The same script runs at three moments, and the difference between them is the whole design: -# -# --install From a FRESH postinst / %post -- never from an upgrade. Stores what it can, and has -# no opinion about whether the dashboard can run. Never fails: a maintainer script -# that aborts leaves the package half-configured, breaks `apt install -f` and fails -# image builds. Exits 0 whatever it could not resolve, and says nothing about it -- -# except the TLS certificates, which are issued at this moment and no other. Ends by -# telling a first-time user where and how to log in. -# -# --upgrade From postinst / %post when a previous version was already installed. Identical to -# --install for the consumed passwords: once both keystore entries exist step 0 is -# true for both, so the only values it can fill in are the ones this host never had, -# and nothing that is already configured changes. It does NOT generate the AI -# assistant key: an upgrade adds no secret the operator did not have. Nor does it -# touch the certificates: a pair the operator replaced must not be re-examined. -# -# --prestart From the unit's ExecStartPre=+ and from the SysV init script's start. Runs the same -# ladder again, not merely a check, so a dashboard installed before the indexer or -# the manager picks up what became available since and configures itself. Exits -# non-zero naming every key it could not resolve. -# -# --clear Removes every credential this dashboard stores, the AI assistant key and the -# certificates included -- and the shared CA when this dashboard minted it, which -# the install recorded at the time -- so the next --install or --prestart resolves -# from nothing. Nothing in the product calls it: it exists for an image that was -# built by installing the package, whose postinst therefore resolved this host's -# credentials into the image layer. Every container started from such an image -# would otherwise share them. Run it as root at the end of the Dockerfile, or once -# from an entrypoint before the first start. -# -# A credential the operator configured in opensearch_dashboards.yml is theirs, and is resolved as -# far as this script is concerned. The keystore is merged over the yml when the dashboard starts -# (src/cli/serve/serve.js), so writing an entry would silently override it -- and writing -# opensearch.username=kibanaserver over a custom user would pair one account's name with another's -# password. The precedence is therefore: keystore entry > yml setting > environment > -# credentials.env. The yml check is its own section below and stays out of the ladder's functions. -# -# Validating at start rather than at install is deliberate: the answer changes between the two -# moments and only the answer at start matters. A dashboard installed first resolves nothing; by -# the time it is started the indexer and the manager have published their keys, and it resolves. -# Checking at install would have declared a problem that no longer exists. -# -# The step never opens a network connection. It validates presence and format only -- making a -# service's start depend on reaching its peer would break boot ordering and cluster restarts. -# A credential that is present but wrong still fails as a 401 at runtime, exactly as today. - -# Running as root (ExecStartPre=+, the SysV start, the maintainer scripts), nothing from the -# environment may choose what runs: a fixed PATH, and no preload or Node options. -if [ "$(command -p id -u)" = 0 ]; then - PATH=/usr/sbin:/usr/bin:/sbin:/bin - export PATH - unset LD_PRELOAD LD_LIBRARY_PATH NODE_OPTIONS -fi - -MODE="prestart" -DIR="" - -# ${1-} rather than $1, matching the helpers' convention: the bare form is an "unbound variable" -# error under a caller that runs us with `set -u`, and `shift 2` on a lone -H is a hard error in -# dash rather than a diagnosable one. -while [ -n "${1-}" ]; do - case "${1-}" in - --install) MODE="install" ; shift ;; - --upgrade) MODE="upgrade" ; shift ;; - --prestart) MODE="prestart"; shift ;; - --clear) MODE="clear" ; shift ;; - -H) - if [ -z "${2-}" ]; then - echo "resolve-credentials: -H needs a directory" >&2 - exit 2 - fi - DIR="$2" - shift 2 - ;; - -h|--help) - echo "Usage: $0 [--install|--upgrade|--prestart|--clear] [-H ]" - exit 0 - ;; - *) - echo "resolve-credentials: unknown option: ${1-}" >&2 - exit 2 - ;; - esac -done - -# Derive the installation directory from our own location (bin/resolve-credentials), so a tree -# installed elsewhere resolves against itself rather than against a compiled-in path. -if [ -z "${DIR}" ]; then - _self=$(readlink -f "$0" 2>/dev/null || echo "$0") - DIR=$(dirname "$(dirname "${_self}")") -fi - -# wazuh-credentials.sh is shared with the indexer and the manager, so it is owned by -# wazuh-installation-assistant and downloaded at build time -- it is NOT in this repository. -# It is read from lib/ of the installation directory only. -if [ -f "${DIR}/lib/wazuh-credentials.sh" ]; then - SHARED_HELPER_DIR="${DIR}/lib" -else - echo "resolve-credentials: cannot find ${DIR}/lib/wazuh-credentials.sh" >&2 - echo " it is downloaded from wazuh-installation-assistant when the package is built" >&2 - exit 2 -fi - -. "${SHARED_HELPER_DIR}/wazuh-credentials.sh" - -SERVICE_USER="wazuh-dashboard" -KEYSTORE_BIN="${DIR}/bin/opensearch-dashboards-keystore" -# The packaged keystore binary has OSD_PATH_CONF=/etc/wazuh-dashboard baked in, so this is where -# it reads and writes whatever our environment says. -CONFIG_DIR="${OSD_PATH_CONF:-/etc/wazuh-dashboard}" -KEYSTORE_FILE="${CONFIG_DIR}/opensearch_dashboards.keystore" -CONFIG_FILE="${CONFIG_DIR}/opensearch_dashboards.yml" -NODE_BIN="${DIR}/node/bin/node" -PID_FILE="/run/wazuh-dashboard/wazuh-dashboard.pid" - -# Every keystore entry this script may write, and so every one --clear removes: the consumed -# credentials, and the one secret the dashboard owns. -LADDER_ENTRIES="opensearch.username opensearch.password wazuh_core.hosts.default.password" -OWNED_ENTRIES="wazuh_ai_assistant.encryptionKey" - -LOG_TAG="resolve-credentials" - -log() { - echo "${LOG_TAG}: $*" -} - -err() { - echo "${LOG_TAG}: $*" >&2 -} - -# Routine detail, printed only with WAZUH_DASHBOARD_VERBOSE=1. -detail() { - if [ "${WAZUH_DASHBOARD_VERBOSE-}" = 1 ]; then - log "$@" - fi -} - -# Accumulated verdicts. A key is *unresolved* when nothing supplied it and we may not invent it; -# it is *invalid* when something supplied it and the value failed the policy. The two are reported -# differently because they need different fixes, but both block the start. -UNRESOLVED="" -INVALID="" - -# Set when the credentials file exists but was refused (ownership, mode, symlink, a bad ancestor, -# a malformed line), so the report can say that the value may well be there -- and why it was not -# read -- instead of only asking for it to be set. -CREDENTIALS_FILE_REFUSED=0 - -mark_unresolved() { - UNRESOLVED="${UNRESOLVED} $1" -} - -mark_invalid() { - INVALID="${INVALID} $1" -} - -# ----------------------------------------------------------------------------------------- -# Keystore -# -# The keystore is created and read by the service user, and this script runs as root from -# postinst and from ExecStartPre=+. Every call therefore goes through as_service_user: a keystore -# written by root is a keystore the service can no longer read. -# -# Secrets reach it through stdin (`add --stdin`), never argv, and never interpolated into a -# `runuser --command="..."` string: either one is a secret in ps for as long as the process lives. -# The working directory is / so the service user never has to traverse root's. -# ----------------------------------------------------------------------------------------- - -# runuser alone changes the user and nothing else: the child stays in root's session and keeps -# root's environment. From an interactive dpkg or rpm that session has root's terminal as its -# controlling terminal, and the service user -- who controls what its Node loads, through -# node.options -- could open /dev/tty and push input into root's shell (TIOCSTI). The environment -# carries whatever root exported, the credentials this script reads from it included. -# -# So every child starts in a session of its own, which has no controlling terminal: /dev/tty -# cannot be opened and TIOCSTI is refused. It gets an environment of its own, too: the fixed PATH -# and the configuration directory, nothing else. Its standard streams are the caller's to point -# away from the terminal, and every caller does. -# -# util-linux before 2.24 (RHEL 7) has no setsid -w. Without it setsid returns the child's status -# only when it does not have to fork, which is when it is not a process-group leader. It never is -# here: it runs in a subshell of a script without job control, which stays in the script's group. -SETSID_WAIT="" -if setsid -w true /dev/null 2>&1; then - SETSID_WAIT="-w" -fi - -as_service_user() { - # ${SETSID_WAIT} is unquoted on purpose: empty, it must vanish rather than be an argument. - (cd / && setsid ${SETSID_WAIT} runuser -u "${SERVICE_USER}" -- \ - env -i PATH="${PATH}" OSD_PATH_CONF="${CONFIG_DIR}" "$@") -} - -keystore() { - if [ "$(id -u)" = 0 ]; then - as_service_user "${KEYSTORE_BIN}" "$@" - else - (cd / && "${KEYSTORE_BIN}" "$@") - fi -} - -# Cached, because every call starts Node: one listing per run, updated as we add. -KEYSTORE_KEYS="" -KEYSTORE_READY=0 - -keystore_load() { - KEYSTORE_KEYS=$(keystore list /dev/null) || KEYSTORE_KEYS="" -} - -keystore_has() { - printf '%s\n' "${KEYSTORE_KEYS}" | grep -qxF -- "$1" -} - -# postinst creates the keystore before it calls us, but --clear leaves a host that may reach -# --prestart without one, and so does a tree an operator tidied by hand. `create` on an existing -# keystore asks whether to overwrite it, so it only runs when the file is absent -- and with stdin -# closed, so a race with another writer answers "no" instead of hanging the start. -keystore_ensure() { - if [ ! -x "${KEYSTORE_BIN}" ]; then - err "cannot use the keystore: ${KEYSTORE_BIN} is missing" - return 1 - fi - if [ ! -f "${KEYSTORE_FILE}" ]; then - keystore create /dev/null 2>&1 - if [ ! -f "${KEYSTORE_FILE}" ]; then - err "could not create the keystore in ${KEYSTORE_FILE}" - return 1 - fi - log "created the keystore in ${KEYSTORE_FILE}" - fi - keystore_load - return 0 -} - -# $1 is the entry; the value comes on our stdin and goes straight to the keystore's. It runs on -# the right of a pipe -- a subshell -- so the caller records the entry with keystore_mark(). -keystore_add() { - keystore add "$1" --stdin >/dev/null 2>&1 -} - -keystore_mark() { - KEYSTORE_KEYS="${KEYSTORE_KEYS} -$1" -} - -# ----------------------------------------------------------------------------------------- -# Settings -# ----------------------------------------------------------------------------------------- - -# The process environment, then the credentials file. The environment wins because it is the more -# deliberate and more immediate input, and because an orchestrator setting a value should not be -# silently overridden by a file left behind from an earlier install. -# -# Within the environment the scoped name comes first and the wazuh-docker name after it. The -# aliases are environment-only: they are what the existing containers already set, and the file -# has never carried them. -# -# An explicitly empty value is treated as absent rather than as a policy failure: that is what an -# operator who cleared a variable means, and it leaves the key unresolved with a message rather -# than blocking the install on a validation error. The first name that is set decides, so a -# cleared scoped variable is not quietly overridden by an alias still lying around. -# -# Sets SETTING_VALUE and SETTING_SOURCE rather than printing, so the caller can say where a value -# came from without a second lookup. Returns 0 when set, 1 when absent, 2 when the file itself is -# unusable -- the library has then already said why. -SETTING_VALUE="" -SETTING_SOURCE="" - -setting_get() { - _sg_name="$1" - _sg_alias="${2-}" - SETTING_VALUE="" - SETTING_SOURCE="" - - for _sg_env in "${_sg_name}" ${_sg_alias}; do - if eval "[ \"\${${_sg_env}+x}\" = x ]"; then - eval "_sg_value=\${${_sg_env}-}" - [ -n "${_sg_value}" ] || return 1 - SETTING_VALUE="${_sg_value}" - SETTING_SOURCE="the environment (${_sg_env})" - return 0 - fi - done - - _sg_status=0 - _sg_value=$(wazuh_env_get "${_sg_name}") || _sg_status=$? - case "${_sg_status}" in - 0) [ -n "${_sg_value}" ] || return 1 - SETTING_VALUE="${_sg_value}" - SETTING_SOURCE="$(wazuh_env_get_file 2>/dev/null || echo credentials.env)" - return 0 - ;; - 1) return 1 ;; - *) return 2 ;; - esac -} - -# The password policy is the owners' to enforce: the indexer and the manager generate these -# passwords, or accept an operator's, and reject what they would not use. Checking it again here -# only drifts from the shared policy, so the dashboard checks the one thing that is its own. -# -# `keystore add` trims the value, runs JSON.parse on it and stores whatever parses, so a password -# that happens to be JSON -- 123456789e10, true, "quoted", [1] -- or that has surrounding -# whitespace would be stored as something other than the text supplied. This repeats that logic -# (src/cli_keystore/add.js) with the dashboard's own Node and accepts only a value the keystore -# stores verbatim. The value reaches Node over a pipe and never through a command line. -# -# Every rejection names the rule, never the value. -KEYSTORE_VERBATIM_JS=' -let value = ""; -process.stdin.setEncoding("utf8"); -process.stdin.on("data", (chunk) => (value += chunk)); -process.stdin.on("end", () => { - const trimmed = value.trim(); - let parsed; - try { - parsed = JSON.parse(trimmed); - } catch (e) { - parsed = undefined; - } - process.exit((parsed ?? trimmed) === value ? 0 : 1); -}); -' - -keystore_stores_verbatim() { - if [ ! -x "${NODE_BIN}" ]; then - err "cannot check the value: ${NODE_BIN} is not available" - return 1 - fi - _ksv_status=0 - if [ "$(id -u)" = 0 ]; then - printf '%s' "$1" | as_service_user "${NODE_BIN}" -e "${KEYSTORE_VERBATIM_JS}" \ - >/dev/null 2>&1 || _ksv_status=$? - else - printf '%s' "$1" | (cd / && "${NODE_BIN}" -e "${KEYSTORE_VERBATIM_JS}") >/dev/null 2>&1 || - _ksv_status=$? - fi - case "${_ksv_status}" in - 0) return 0 ;; - 1) err "the value is JSON or has surrounding whitespace, which the keystore would not store as text" ;; - *) err "cannot check the value: ${NODE_BIN} exited with status ${_ksv_status}" ;; - esac - return 1 -} - -# ----------------------------------------------------------------------------------------- -# Consumed: kibanaserver and wazuh-internal-client -# -# Never generated, never published. Step 0 is the keystore entry itself: once it exists the -# credential is resolved, whatever the file now says -- a package must never reconfigure what is -# already configured, and that is also why the credentials file can be deleted once every component -# is installed and running. -# -# resolve_consumed KEY ALIAS ENTRY [USERNAME_ENTRY USERNAME] -# ----------------------------------------------------------------------------------------- - -# A username the operator already stored is kept, so only an absent one gets the account name. -store_username() { - [ -n "${1-}" ] || return 0 - keystore_has "$1" && return 0 - printf '%s' "$2" | keystore_add "$1" || return 1 - keystore_mark "$1" -} - -resolve_consumed() { - _rc_key="$1" - _rc_alias="$2" - _rc_entry="$3" - _rc_user_entry="${4-}" - _rc_user="${5-}" - - if [ "${KEYSTORE_READY}" -ne 1 ]; then - mark_unresolved "${_rc_key}" - return 1 - fi - - if keystore_has "${_rc_entry}"; then - if ! store_username "${_rc_user_entry}" "${_rc_user}"; then - err "could not write ${_rc_user_entry} to the keystore" - mark_unresolved "${_rc_key}" - return 1 - fi - log "${_rc_entry} is already in the keystore; ${_rc_key} is ignored" - return 0 - fi - - _rc_status=0 - setting_get "${_rc_key}" "${_rc_alias}" || _rc_status=$? - - if [ "${_rc_status}" -ne 0 ]; then - [ "${_rc_status}" -eq 2 ] && CREDENTIALS_FILE_REFUSED=1 - mark_unresolved "${_rc_key}" - return 1 - fi - - if ! keystore_stores_verbatim "${SETTING_VALUE}"; then - err "${_rc_key} from ${SETTING_SOURCE} cannot be stored in the keystore as text" - mark_invalid "${_rc_key}" - SETTING_VALUE="" - return 1 - fi - - if ! store_username "${_rc_user_entry}" "${_rc_user}" || - ! printf '%s' "${SETTING_VALUE}" | keystore_add "${_rc_entry}"; then - err "could not write ${_rc_entry} to the keystore" - mark_unresolved "${_rc_key}" - SETTING_VALUE="" - return 1 - fi - SETTING_VALUE="" - keystore_mark "${_rc_entry}" - - log "stored ${_rc_entry} in the keystore from ${SETTING_SOURCE}" - return 0 -} - -# ----------------------------------------------------------------------------------------- -# The configuration file -# -# Deliberately separate from the ladder above: resolve_consumed() knows nothing about the yml, and -# these functions know nothing about the keystore. The run section combines them. -# -# The yml is read by the dashboard's own Node, through @osd/config's getConfigFromFiles() -- the -# same reader the dashboard uses, so flat (opensearch.password: x), nested and mixed keys and -# ${ENV} references mean here exactly what they mean at start. Where that module cannot be -# resolved, js-yaml (which it wraps) is used directly with a lookup that accepts both forms. -# -# It runs as the service user, which owns the file, and prints nothing: the answer is the exit -# status, never a value -- 0 set, 3 not set, 2 the file could not be read. Anything that goes -# wrong (no Node, an unreadable or unparseable file, an unknown ${ENV} reference) is 2, which every -# caller treats as "not set", so the ladder carries on as without the check; a broken yml is the -# dashboard's to report when it starts. Only a file that was read and lacks a setting is 3. -# ----------------------------------------------------------------------------------------- - -CONFIG_HAS_JS=' -try { - const [home, file, key] = process.argv.slice(1); - let config; - try { - const { getConfigFromFiles } = require(require.resolve("@osd/config", { paths: [home] })); - config = getConfigFromFiles([file]); - } catch (e) { - if (!e || e.code !== "MODULE_NOT_FOUND") throw e; - const yaml = require(require.resolve("js-yaml", { paths: [home] })); - config = yaml.load(require("fs").readFileSync(file, "utf8")) || {}; - } - const lookup = (node, parts) => { - if (parts.length === 0) return node; - if (node === null || typeof node !== "object") return undefined; - for (let i = parts.length; i > 0; i--) { - const head = parts.slice(0, i).join("."); - if (Object.prototype.hasOwnProperty.call(node, head)) { - const found = lookup(node[head], parts.slice(i)); - if (found !== undefined) return found; - } - } - return undefined; - }; - const value = lookup(config, key.split(".")); - const set = (typeof value === "string" && value !== "") || - (value !== null && typeof value === "object" && Object.keys(value).length > 0); - process.exit(set ? 0 : 3); -} catch (e) { - process.exit(2); -} -' - -config_has() { - [ -x "${NODE_BIN}" ] && [ -f "${CONFIG_FILE}" ] || return 2 - _ch_status=0 - if [ "$(id -u)" = 0 ]; then - as_service_user "${NODE_BIN}" -e "${CONFIG_HAS_JS}" "${DIR}" "${CONFIG_FILE}" "$1" \ - /dev/null 2>&1 || _ch_status=$? - else - (cd / && "${NODE_BIN}" -e "${CONFIG_HAS_JS}" "${DIR}" "${CONFIG_FILE}" "$1") \ - /dev/null 2>&1 || _ch_status=$? - fi - # runuser's own failures must not pass for "not set". - case "${_ch_status}" in - 0|3) return "${_ch_status}" ;; - *) return 2 ;; - esac -} - -config_resolves_kibanaserver() { - config_has opensearch.password || return 1 - log "opensearch.password is set in ${CONFIG_FILE}; WAZUH_INDEXER_KIBANASERVER_PASSWORD is not resolved" -} - -# Without a `default` host there is nothing to authenticate, and an entry for it would create a -# host out of a lone password when the keystore is merged in. -config_resolves_wui() { - _crw_status=0 - config_has wazuh_core.hosts.default || _crw_status=$? - if [ "${_crw_status}" -eq 3 ]; then - log "${CONFIG_FILE} defines no wazuh_core.hosts.default; WAZUH_MANAGER_WUI_PASSWORD is not needed" - return 0 - fi - config_has wazuh_core.hosts.default.password || return 1 - log "wazuh_core.hosts.default.password is set in ${CONFIG_FILE}; WAZUH_MANAGER_WUI_PASSWORD is not resolved" -} - -# ----------------------------------------------------------------------------------------- -# Owned: the AI assistant encryption key -# -# Deliberately separate from the ladder: it is not a credential anybody else holds, so it is never -# read from the environment or the credentials file, never checked as a supplied value would be, -# and never published. The AI assistant is optional, so failing to generate it is a warning and -# never blocks the start -- the plugin itself says what is missing when it is used. -# -# Step 0 is the keystore entry or a value the operator configured in opensearch_dashboards.yml. -# Once either exists the key is never touched again: the provider API keys the assistant stored -# are encrypted with it, and a new key could no longer decrypt them. -# -# base64 of exactly 32 bytes is always 44 characters with one '=' pad, which also means it can -# never parse as a JSON number, so the keystore stores it as the string the plugin expects. -# ----------------------------------------------------------------------------------------- - -ENCRYPTION_KEY_ENTRY="wazuh_ai_assistant.encryptionKey" - -encryption_key_generate() { - _ekg_key="" - if command -v openssl >/dev/null 2>&1; then - _ekg_key=$(openssl rand -base64 32 2>/dev/null | tr -d '\n') || _ekg_key="" - fi - if [ -z "${_ekg_key}" ] && [ -r /dev/urandom ] && command -v base64 >/dev/null 2>&1; then - _ekg_key=$(head -c 32 /dev/urandom | base64 | tr -d '\n') || _ekg_key="" - fi - if [ -z "${_ekg_key}" ] && [ -x "${NODE_BIN}" ]; then - _ekg_key=$("${NODE_BIN}" -e \ - "process.stdout.write(require('crypto').randomBytes(32).toString('base64'))" 2>/dev/null) || _ekg_key="" - fi - [ "${#_ekg_key}" -eq 44 ] || return 1 - printf '%s' "${_ekg_key}" -} - -resolve_encryption_key() { - [ "${KEYSTORE_READY}" -eq 1 ] || return 1 - - if keystore_has "${ENCRYPTION_KEY_ENTRY}"; then - log "${ENCRYPTION_KEY_ENTRY} is already in the keystore" - return 0 - fi - if config_has "${ENCRYPTION_KEY_ENTRY}"; then - log "${ENCRYPTION_KEY_ENTRY} is set in ${CONFIG_FILE}; not generated" - return 0 - fi - - # Captured, then piped: the value is never on a command line, and never printed. - if _rek_key=$(encryption_key_generate) && - printf '%s' "${_rek_key}" | keystore_add "${ENCRYPTION_KEY_ENTRY}"; then - _rek_key="" - keystore_mark "${ENCRYPTION_KEY_ENTRY}" - log "generated ${ENCRYPTION_KEY_ENTRY}" - return 0 - fi - _rek_key="" - err "warning: could not generate ${ENCRYPTION_KEY_ENTRY}; configure it manually to enable the AI assistant" - return 1 -} - -# ----------------------------------------------------------------------------------------- -# The credentials file -# -# The dashboard publishes nothing, but it may still be the first Wazuh package on the host. It -# then creates /etc/wazuh and an empty credentials.env, so a sibling installed later finds the file -# where it expects it and the operator has one obvious place to fill in. Creating it counts as a -# write, so it happens under the shared lock, through the library's own tree creation: 0700 -# root:root directories, one component at a time, and nothing that already exists is repaired. -# -# The library's lock and tree helpers are private to it, but it is downloaded and pinned as one -# file, so the helpers used here cannot drift from the public functions that share the lock. -# ----------------------------------------------------------------------------------------- - -_credentials_file_create_locked() { - _cfc_file=$(wazuh_env_get_file) || return 1 - if [ -e "${_cfc_file}" ] || [ -L "${_cfc_file}" ]; then - _wazuh_validate_credentials_file - return $? - fi - (umask 077; set -C; : >"${_cfc_file}") 2>/dev/null || { - [ -e "${_cfc_file}" ] || return 1 - } - chown root:root "${_cfc_file}" || return 1 - chmod 0600 "${_cfc_file}" || return 1 - _wazuh_restorecon "${_cfc_file}" || return 1 - _wazuh_validate_credentials_file -} - -credentials_file_ensure() { - [ "$(id -u)" = 0 ] || return 0 - _cfe_file=$(wazuh_env_get_file 2>/dev/null) || return 1 - [ -e "${_cfe_file}" ] && return 0 - if _wazuh_with_lock _credentials_file_create_locked; then - log "created an empty ${_cfe_file}" - return 0 - fi - # Not fatal: an unusable file only means the keys it would have held stay unresolved, and - # the library has already said which rule the path broke. - return 1 -} - -# ----------------------------------------------------------------------------------------- -# Owned: the dashboard's own certificates -# -# opensearch_dashboards.yml serves HTTPS from certs/dashboard.pem and dashboard-key.pem, and trusts -# the indexer and the manager's Server API through certs/root-ca.pem. On a fresh install, and only -# then, this issues whatever of that is missing from the shared CA, so every Wazuh component on the -# host chains to one anchor: -# -# * No CA in the CA directory (/etc/wazuh/ca, or WAZUH_CA_DIR): the library mints one, and the -# manager and the indexer installed after us issue from it too. -# * A CA with its private key: reused, whoever minted it. -# * An anchor without a key: a CA managed elsewhere. It can be trusted but not issued from, so a -# missing pair is an error the operator fixes by staging one. -# -# An existing complete pair is how an operator supplies their own, so it is checked and never -# replaced. A partial pair is refused rather than completed: half of someone else's material is not -# ours to guess the other half of. And when the CA is gone but dashboard material is present, no -# new CA is minted -- it could only produce a pair that trusts a different anchor than the rest. -# -# Certificates are issued at install and never looked at again, for the same reasons as the -# manager's: resolving one is a signature, not a lookup, and it is the credential an operator -# legitimately rotates out of band. --upgrade and --prestart leave them alone; whether the dashboard -# accepts them is decided when it loads them. -# -# Placement: /etc/wazuh-dashboard belongs to the service user, who could swap certs/ or plant names -# in it while root issues. Everything therefore happens from inside the directory once it has been -# checked, in a root-only 0700 staging directory, and each file is published with `ln -T`, which -# neither follows nor replaces an existing name. The key goes before the certificate, so an -# interrupted run leaves a partial pair that the next run refuses, never a certificate without its -# key that looks complete. The result follows wazuh-certs-tool's layout: certs/ 0500 and files -# 0400, owned by the service user. -# -# WAZUH_DASHBOARD_CERT_SANS Exact comma-separated SAN list (DNS:name, IP:address, or untyped), -# environment then credentials.env. Default: the node name, the FQDN, -# every global-scope address, and loopback. -# WAZUH_DASHBOARD_NODE_NAME Certificate common name. Default: hostname -s. -# WAZUH_DASHBOARD_VERBOSE 1 prints each step of the issue. Default: only problems are printed. -# ----------------------------------------------------------------------------------------- - -CERTS_DIR="${CONFIG_DIR}/certs" -CERT_FILE="dashboard.pem" -CERT_KEY_FILE="dashboard-key.pem" -CERT_CA_FILE="root-ca.pem" - -# Dropped into the CA directory when THIS dashboard minted the shared CA, and the only thing that -# may later authorise --clear to delete its private key. "root-ca.key is present" cannot answer -# that: an operator who stages a signing CA -- their own anchor and key, so the dashboard issues -# from their PKI -- leaves exactly the same shape on disk. It is the dashboard's own name, not the -# manager's (.wazuh-manager-bootstrap-ca): each component removes only a CA it minted itself. -CA_MINT_MARKER=".wazuh-dashboard-bootstrap-ca" - -_dc_require() { - for _dcr_function in wazuh_ca_get_dir _wazuh_ca_ensure_locked _wazuh_validate_ca_files \ - _wazuh_with_lock _wazuh_restorecon wazuh_env_get; do - if ! command -v "${_dcr_function}" >/dev/null 2>&1; then - err "the shared credentials library has no ${_dcr_function}; it is too old to issue certificates" - return 1 - fi - done - for _dcr_command in awk chmod chown cmp flock ln mktemp openssl stat tr; do - if ! command -v "${_dcr_command}" >/dev/null 2>&1; then - err "cannot issue certificates without ${_dcr_command}" - return 1 - fi - done -} - -_dc_exists() { - [ -e "$1" ] || [ -L "$1" ] -} - -_dc_valid_dns() { - [ -n "$1" ] && [ "${#1}" -le 253 ] || return 1 - case "$1" in - *[!A-Za-z0-9.*-]*|.*|*.|*..*|-*|*-) return 1 ;; - esac -} - -# Loose on purpose: the alphabet keeps the value from breaking out of the OpenSSL config line, and -# OpenSSL itself rejects a malformed address when it signs. -_dc_valid_ip() { - case "$1" in - ''|*[!0-9A-Fa-f.:]*) return 1 ;; - *:*) return 0 ;; - esac - printf '%s\n' "$1" | LC_ALL=C grep -Eqx '([0-9]{1,3}\.){3}[0-9]{1,3}' -} - -# One typed SAN per line from a comma-separated list; untyped entries are classified. The loop is -# the last command of its pipeline, so an invalid entry fails the capture rather than vanishing. -_dc_normalize_sans() { - _dcn_typed=$(printf '%s' "$1" | tr ',' '\n' | while IFS= read -r _dcn_item || [ -n "${_dcn_item}" ]; do - _dcn_item=$(printf '%s' "${_dcn_item}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') - case "${_dcn_item}" in - '') continue ;; - DNS:*|dns:*) _dcn_type=DNS; _dcn_value=${_dcn_item#*:} ;; - IP:*|ip:*) _dcn_type=IP; _dcn_value=${_dcn_item#*:} ;; - *:*) _dcn_type=IP; _dcn_value=${_dcn_item} ;; - *) - _dcn_value=${_dcn_item} - if _dc_valid_ip "${_dcn_value}"; then _dcn_type=IP; else _dcn_type=DNS; fi - ;; - esac - if [ "${_dcn_type}" = IP ]; then - _dc_valid_ip "${_dcn_value}" || { err "invalid SAN entry: ${_dcn_item}"; exit 1; } - else - _dc_valid_dns "${_dcn_value}" || { err "invalid SAN entry: ${_dcn_item}"; exit 1; } - fi - printf '%s:%s\n' "${_dcn_type}" "${_dcn_value}" - done) || return 1 - printf '%s\n' "${_dcn_typed}" | awk ' - NF == 0 { next } - /^DNS:/ { key = "DNS:" tolower(substr($0, 5)) } - /^IP:/ { key = tolower($0) } - !seen[key]++ { print } - ' -} - -_dc_node_name() { - if [ -n "${WAZUH_DASHBOARD_NODE_NAME-}" ]; then - _dcnn_node=${WAZUH_DASHBOARD_NODE_NAME} - else - _dcnn_node=$(hostname -s 2>/dev/null || uname -n 2>/dev/null) || _dcnn_node="" - _dcnn_node=${_dcnn_node%%.*} - fi - if ! _dc_valid_dns "${_dcnn_node}"; then - err "invalid dashboard node name: ${_dcnn_node}; set WAZUH_DASHBOARD_NODE_NAME" - return 1 - fi - printf '%s\n' "${_dcnn_node}" -} - -# An explicit list replaces discovery; loopback is always added, since the dashboard is reached -# locally as well. Discovery never fails the issue: an address it misses is one the operator adds. -# -# The list goes to stdout, so where it came from is written to the file $2 for the caller to log: -# a log line here would end up in the list. -_dc_sans() { - _dcs_status=0 - setting_get WAZUH_DASHBOARD_CERT_SANS || _dcs_status=$? - case "${_dcs_status}" in - 0) - _dcs_list="${SETTING_VALUE}" - printf 'SANs set by WAZUH_DASHBOARD_CERT_SANS from %s\n' "${SETTING_SOURCE}" >"$2" - ;; - 1) - _dcs_list="DNS:$1" - _dcs_fqdn=$(hostname -f 2>/dev/null || :) - if [ -n "${_dcs_fqdn}" ] && _dc_valid_dns "${_dcs_fqdn}"; then - _dcs_list="${_dcs_list},DNS:${_dcs_fqdn}" - fi - if command -v ip >/dev/null 2>&1; then - _dcs_addrs=$(ip -o addr show 2>/dev/null | awk ' - ($3 == "inet" || $3 == "inet6") && / scope global/ && - !/ (tentative|dadfailed)( |$)/ { sub(/\/.*/, "", $4); printf ",IP:%s", $4 } - ') - _dcs_list="${_dcs_list}${_dcs_addrs}" - _dcs_count=$(printf '%s' "${_dcs_addrs}" | tr ',' '\n' | grep -c '^IP:') - printf 'SANs discovered: host name, FQDN and %s global address(es); set WAZUH_DASHBOARD_CERT_SANS to replace them\n' \ - "${_dcs_count}" >"$2" - else - printf '%s\n' "SANs discovered without addresses (ip is not available): the certificate names only this host and loopback" >"$2" - fi - ;; - *) err "cannot read WAZUH_DASHBOARD_CERT_SANS"; return 1 ;; - esac - SETTING_VALUE="" - _dc_normalize_sans "${_dcs_list},DNS:localhost,IP:127.0.0.1,IP:::1" -} - -# $1 config, $2 CN, $3 file with one typed SAN per line. -_dc_write_leaf_config() { - { - printf '%s\n' '[ req ]' 'prompt = no' 'default_md = sha256' 'distinguished_name = req_dn' '' - printf '%s\n' '[ req_dn ]' 'C = US' 'L = California' 'O = Wazuh' 'OU = Wazuh' - printf 'CN = %s\n\n' "$2" - printf '%s\n' '[ v3_leaf ]' \ - 'authorityKeyIdentifier = keyid,issuer' \ - 'subjectKeyIdentifier = hash' \ - 'basicConstraints = critical,CA:FALSE' \ - 'keyUsage = critical,digitalSignature,keyEncipherment' \ - 'extendedKeyUsage = serverAuth,clientAuth' \ - 'subjectAltName = @alt_names' '' '[ alt_names ]' - awk ' - /^IP:/ { ip++; print "IP." ip " = " substr($0, 4) } - /^DNS:/ { dns++; print "DNS." dns " = " substr($0, 5) } - ' "$3" - } >"$1" -} - -# $1 certificate, $2 key, $3 CA to chain to (empty: no chain check). Prints nothing on success. -_dc_validate_pair() { - if [ -L "$1" ] || [ ! -f "$1" ] || [ -L "$2" ] || [ ! -f "$2" ]; then - err "the certificate pair must be two regular files: $1, $2" - return 1 - fi - if ! openssl x509 -in "$1" -noout -checkend 0 >/dev/null 2>&1; then - err "invalid or expired certificate: $1" - return 1 - fi - _dcv_cert_pub=$(openssl x509 -in "$1" -noout -pubkey 2>/dev/null) || return 1 - _dcv_key_pub=$(openssl pkey -in "$2" -passin pass: -pubout /dev/null) || { - err "invalid private key: $2" - return 1 - } - if [ "${_dcv_cert_pub}" != "${_dcv_key_pub}" ]; then - err "the private key does not match the certificate: $1" - return 1 - fi - if [ -n "${3-}" ] && - ! openssl verify -purpose sslserver -CAfile "$3" "$1" >/dev/null 2>&1; then - err "the certificate does not chain to $3 for server use: $1" - return 1 - fi -} - -_dc_fingerprint() { - openssl x509 -in "$1" -noout -fingerprint -sha256 2>/dev/null -} - -# One line describing a certificate for the logs: subject, expiry and SHA-256 fingerprint. Public -# data only. Each field is picked by its label, since the order OpenSSL prints them in varies. -_dc_describe_cert() { - _dcd_text=$(LC_ALL=C openssl x509 -in "$1" -noout -subject -enddate -fingerprint -sha256 2>/dev/null) || { - printf '%s\n' "unreadable certificate" - return 0 - } - printf 'subject %s; valid until %s; SHA-256 %s\n' \ - "$(printf '%s\n' "${_dcd_text}" | sed -n 's/^subject= *//p' | head -n 1)" \ - "$(printf '%s\n' "${_dcd_text}" | sed -n 's/^notAfter=//p' | head -n 1)" \ - "$(printf '%s\n' "${_dcd_text}" | sed -n 's/^[Ss][Hh][Aa]256 Fingerprint=//p' | head -n 1)" -} - -# Enters the child directory $1 of the working directory and proves it is one: its `..` must be the -# directory we came from. A name swapped for a symlink between the check and the cd lands -# somewhere else, whose `..` is not ours. -_dc_enter_child() { - _dce_here=$(stat -c '%d:%i' . 2>/dev/null) || return 1 - if [ -L "$1" ] || [ ! -d "$1" ]; then - err "refusing $1: it is not a real directory" - return 1 - fi - cd -P -- "$1" || return 1 - if [ "$(stat -c '%d:%i' .. 2>/dev/null)" != "${_dce_here}" ]; then - err "$1 changed while certificates were being issued" - return 1 - fi -} - -# Enters the staging directory $1 of the certificates directory. Only root can have made a -# root-owned 0700 directory there, so passing this means the staging area is ours and no one else -# can reach into it. -_dc_enter_stage() { - _dc_enter_child "$1" || return 1 - if [ "$(stat -c '%u:%a' . 2>/dev/null)" != "0:700" ]; then - err "the staging directory in ${CERTS_DIR} is not root's own" - return 1 - fi -} - -# Runs from inside the certificates directory. $1 staging directory, $2 CA directory, $3 CN. -_dc_issue_pair() ( - _dc_enter_stage "$1" || return 1 - - # The SAN list is built here, relative to the staging directory just verified, like every - # other file of the issue. A path spelled from ${CERTS_DIR} would be resolved again on each - # use, through certs/ -- an entry of the service user's directory, which it can swap between - # the write and the read, and so choose where root writes and which names the CA signs. - _dc_sans "$3" sans.source >sans || return 1 - [ -s sans.source ] && detail "$(cat -- sans.source)" - detail "issuing ${CERT_FILE} for CN $3 from $2" \ - "(RSA 2048, SHA-256, 3650 days, serverAuth and clientAuth);" \ - "SANs $(tr '\n' ' ' /dev/null 2>&1); then - err "OpenSSL could not create the dashboard certificate request" - return 1 - fi - _dci_serial=$(openssl rand -hex 16) || return 1 - if ! openssl x509 -req -sha256 -days 3650 -set_serial "0x${_dci_serial}" \ - -in dashboard.csr -CA "$2/root-ca.pem" -CAkey "$2/root-ca.key" -passin pass: \ - -extfile leaf.cnf -extensions v3_leaf -out "${CERT_FILE}" >/dev/null 2>&1; then - err "OpenSSL could not sign the dashboard certificate with $2/root-ca.pem" - return 1 - fi - _dc_validate_pair "${CERT_FILE}" "${CERT_KEY_FILE}" "$2/root-ca.pem" || return 1 - - chown "${SERVICE_USER}:${SERVICE_USER}" "${CERT_FILE}" "${CERT_KEY_FILE}" || return 1 - chmod 0400 "${CERT_FILE}" "${CERT_KEY_FILE}" || return 1 - ln -T -- "${CERT_KEY_FILE}" "../${CERT_KEY_FILE}" || return 1 - ln -T -- "${CERT_FILE}" "../${CERT_FILE}" || return 1 - _wazuh_restorecon "../${CERT_KEY_FILE}" || return 1 - _wazuh_restorecon "../${CERT_FILE}" || return 1 - detail "published ${CERTS_DIR}/${CERT_KEY_FILE} and ${CERT_FILE} (${SERVICE_USER}, 0400); serial ${_dci_serial}" -) - -# Runs from inside the certificates directory. $1 staging directory, $2 the shared anchor. -_dc_install_anchor() ( - _dc_enter_stage "$1" || return 1 - cat -- "$2" >"${CERT_CA_FILE}" || return 1 - chown "${SERVICE_USER}:${SERVICE_USER}" "${CERT_CA_FILE}" || return 1 - chmod 0400 "${CERT_CA_FILE}" || return 1 - ln -T -- "${CERT_CA_FILE}" "../${CERT_CA_FILE}" || return 1 - _wazuh_restorecon "../${CERT_CA_FILE}" -) - -# Under the shared lock, so two packages installing at once cannot both mint a CA. -_dc_ensure_locked() ( - _dce_ca_dir=$(wazuh_ca_get_dir) || return 1 - - # The configuration directory is the service user's. certs/ is only ever entered through - # _dc_enter_child, and everything after that works relative to it. - if [ -L "${CONFIG_DIR}" ] || [ ! -d "${CONFIG_DIR}" ]; then - err "${CONFIG_DIR} is not a directory" - return 1 - fi - cd -P -- "${CONFIG_DIR}" || return 1 - _dce_created=0 - if ! _dc_exists certs; then - (umask 077; mkdir -m 0700 certs) || { err "cannot create ${CERTS_DIR}"; return 1; } - _dce_created=1 - detail "created ${CERTS_DIR}" - fi - _dc_enter_child certs || return 1 - if [ "${_dce_created}" -eq 1 ] && [ "$(stat -c '%u:%a' . 2>/dev/null)" != "0:700" ]; then - err "${CERTS_DIR} changed while certificates were being issued" - return 1 - fi - - _dce_state=absent - if _dc_exists "${CERT_FILE}" && _dc_exists "${CERT_KEY_FILE}"; then - _dce_state=complete - elif _dc_exists "${CERT_FILE}" || _dc_exists "${CERT_KEY_FILE}"; then - err "only one of ${CERT_FILE} and ${CERT_KEY_FILE} exists in ${CERTS_DIR}; refusing to complete it" - return 1 - fi - - # Mint the CA only onto a clean slate: dashboard material without a CA was provisioned by - # someone else, and a new anchor would not be the one it trusts. - if ! _dc_exists "${_dce_ca_dir}/root-ca.pem" && ! _dc_exists "${_dce_ca_dir}/root-ca.key"; then - if [ "${_dce_state}" = complete ]; then - _dc_validate_pair "${CERT_FILE}" "${CERT_KEY_FILE}" "" || return 1 - detail "${CERTS_DIR} already holds a certificate pair and ${_dce_ca_dir} has no CA; no CA is created" - detail "kept ${CERT_FILE}: $(_dc_describe_cert "${CERT_FILE}")" - return 0 - fi - if _dc_exists "${CERT_CA_FILE}"; then - err "${CERTS_DIR}/${CERT_CA_FILE} exists but there is no CA in ${_dce_ca_dir}; refusing to create another CA" - return 1 - fi - _dce_ca_was=absent - elif _dc_exists "${_dce_ca_dir}/root-ca.key"; then - _dce_ca_was=complete - else - _dce_ca_was=anchor - fi - if ! _wazuh_ca_ensure_locked; then - err "the shared root CA in ${_dce_ca_dir} could not be created or is not valid" - return 1 - fi - case "${_dce_ca_was}" in - absent) - detail "created the shared root CA in ${_dce_ca_dir}: $(_dc_describe_cert "${_dce_ca_dir}/root-ca.pem")" - # Recorded here and not by the caller because only here is the observation atomic with - # the mint: we hold the credentials lock, so the absence checked above and the CA that - # exists now are the same moment. - if _dc_exists "${_dce_ca_dir}/root-ca.key"; then - (umask 077; : >"${_dce_ca_dir}/${CA_MINT_MARKER}") 2>/dev/null || { - # Not fatal: the CA is in place and usable. Without the marker a later --clear - # keeps the CA, which is the safe direction. - err "could not record that the shared root CA was minted in ${_dce_ca_dir}" - } - fi - ;; - complete) - detail "reusing the shared root CA in ${_dce_ca_dir}: $(_dc_describe_cert "${_dce_ca_dir}/root-ca.pem")" - ;; - anchor) - log "the shared root CA in ${_dce_ca_dir} has no private key: it is trusted but cannot issue;" \ - "$(_dc_describe_cert "${_dce_ca_dir}/root-ca.pem")" - ;; - esac - - _dce_stage=$(mktemp -d .stage.XXXXXX) || return 1 - trap 'rm -rf -- "${_dce_stage}"' 0 - trap 'exit 130' 1 2 3 15 - chmod 0700 "${_dce_stage}" || return 1 - - if _dc_exists "${CERT_CA_FILE}"; then - if [ "$(_dc_fingerprint "${CERT_CA_FILE}")" != "$(_dc_fingerprint "${_dce_ca_dir}/root-ca.pem")" ]; then - log "${CERTS_DIR}/${CERT_CA_FILE} is not the CA in ${_dce_ca_dir}; it is kept as it is:" \ - "$(_dc_describe_cert "${CERT_CA_FILE}")" - else - detail "${CERTS_DIR}/${CERT_CA_FILE} is already the shared root CA" - fi - else - _dc_install_anchor "${_dce_stage}" "${_dce_ca_dir}/root-ca.pem" || return 1 - detail "installed ${CERTS_DIR}/${CERT_CA_FILE} from ${_dce_ca_dir}" - fi - - if [ "${_dce_state}" = complete ]; then - _dc_validate_pair "${CERT_FILE}" "${CERT_KEY_FILE}" "" || return 1 - detail "${CERTS_DIR} already holds a certificate pair; it is kept as it is" - detail "kept ${CERT_FILE}: $(_dc_describe_cert "${CERT_FILE}")" - if ! openssl verify -CAfile "${_dce_ca_dir}/root-ca.pem" "${CERT_FILE}" >/dev/null 2>&1; then - log "${CERT_FILE} does not chain to the shared root CA; it is kept as the operator's" - fi - else - if ! _dc_exists "${_dce_ca_dir}/root-ca.key"; then - err "the CA in ${_dce_ca_dir} has no private key, so no certificate can be issued from it" - return 1 - fi - _dce_node=$(_dc_node_name) || return 1 - _dc_issue_pair "${_dce_stage}" "${_dce_ca_dir}" "${_dce_node}" || return 1 - detail "issued ${CERTS_DIR}/${CERT_FILE}: $(_dc_describe_cert "${CERT_FILE}")" - _dce_text=$(openssl x509 -in "${CERT_FILE}" -noout -text 2>/dev/null) || _dce_text="" - detail "${CERT_FILE} SANs: $(printf '%s\n' "${_dce_text}" | sed -n '/X509v3 Subject Alternative Name:/{n;s/^ *//p;}')" - fi - - # A directory created here gets wazuh-certs-tool's layout; an existing one is the operator's. - # Applied to `.`, the directory we are in, never to a name the service user could swap. - if [ "${_dce_created}" -eq 1 ]; then - rm -rf -- "${_dce_stage}" - chown "${SERVICE_USER}:${SERVICE_USER}" . || return 1 - chmod 0500 . || return 1 - _wazuh_restorecon "${CERTS_DIR}" || return 1 - detail "set ${CERTS_DIR} to ${SERVICE_USER}:${SERVICE_USER} 0500" - fi - detail "the TLS certificates in ${CERTS_DIR} are in place" -) - -resolve_certificates() { - if [ "$(id -u)" != 0 ]; then - log "not running as root; certificates are not issued" - return 0 - fi - _dc_require || return 1 - _rcs_ca=$(wazuh_ca_get_dir 2>/dev/null) || _rcs_ca="(unresolved)" - detail "resolving the TLS certificates in ${CERTS_DIR} (shared CA directory: ${_rcs_ca})" - _wazuh_with_lock _dc_ensure_locked -} - -# The --clear half: the dashboard's three files, any staging directory an interrupted issue left -# behind, and the CA only when this dashboard minted it -- which _dc_ensure_locked() recorded with -# CA_MINT_MARKER at the time, and which is therefore baked into the image. A CA with a key but no -# marker was staged by the operator, and an anchor-only CA was handed to the host; both stay. Runs -# under the shared lock like every other write to the CA directory. -# -# certs/ is entered exactly as the issuing path enters it, and everything is removed relative to -# it. The service user owns ${CONFIG_DIR}, so it can put a symlink at certs/; a path spelled from -# ${CERTS_DIR} would be resolved through it, and root would delete these names wherever it points -# -- the shared anchor in the CA directory, for one. A certs/ that is not a real directory is -# refused rather than skipped, so a clear that could not look is never reported as done. -_dc_clear_locked() ( - if [ -L "${CONFIG_DIR}" ] || { _dc_exists "${CONFIG_DIR}" && [ ! -d "${CONFIG_DIR}" ]; }; then - err "refusing to clear ${CERTS_DIR}: ${CONFIG_DIR} is not a real directory" - return 1 - fi - if [ -d "${CONFIG_DIR}" ]; then - cd -P -- "${CONFIG_DIR}" || return 1 - if _dc_exists certs; then - if [ -L certs ] || [ ! -d certs ]; then - err "refusing to clear ${CERTS_DIR}: it is not a real directory, so what it points to is not the dashboard's" - return 1 - fi - _dc_enter_child certs || return 1 - for _dcc_file in "${CERT_FILE}" "${CERT_KEY_FILE}" "${CERT_CA_FILE}"; do - if _dc_exists "${_dcc_file}"; then - rm -f -- "${_dcc_file}" || return 1 - log "removed ${CERTS_DIR}/${_dcc_file}" - fi - done - # The trap removes the staging directory, but a SIGKILL does not run it, and what it - # leaves holds a private key. This path promises that nothing of this host's - # credentials survives into the image, so it goes too. - for _dcc_stage in .stage.*; do - [ -d "${_dcc_stage}" ] && [ ! -L "${_dcc_stage}" ] || continue - rm -rf -- "${_dcc_stage}" || return 1 - log "removed ${CERTS_DIR}/${_dcc_stage}, left behind by an interrupted issue" - done - fi - fi - _dcc_ca=$(wazuh_ca_get_dir 2>/dev/null) || _dcc_ca="" - [ -n "${_dcc_ca}" ] || return 0 - if [ -f "${_dcc_ca}/${CA_MINT_MARKER}" ]; then - rm -f -- "${_dcc_ca}/root-ca.key" "${_dcc_ca}/root-ca.pem" "${_dcc_ca}/root-ca.srl" \ - "${_dcc_ca}/${CA_MINT_MARKER}" || return 1 - log "removed the shared root CA in ${_dcc_ca}, minted by this dashboard" - elif _dc_exists "${_dcc_ca}/root-ca.key"; then - log "kept the CA in ${_dcc_ca}: this dashboard did not mint it, so its private key is not ours to remove" - elif _dc_exists "${_dcc_ca}/root-ca.pem"; then - log "kept the CA in ${_dcc_ca}: it has no private key, so it was issued elsewhere" - fi -) - -clear_certificates() { - command -v _wazuh_with_lock >/dev/null 2>&1 || return 1 - _wazuh_with_lock _dc_clear_locked -} - -# ----------------------------------------------------------------------------------------- -# First login: a fresh install ends by saying where to log in, as whom, and how to start. -# ----------------------------------------------------------------------------------------- - -# First global IP in the certificate's SANs, else its first DNS name, else the host name. -dashboard_address() { - _dad_sans=$(as_service_user openssl x509 -in "${CERTS_DIR}/${CERT_FILE}" -noout -text \ - /dev/null | sed -n '/X509v3 Subject Alternative Name:/{n;s/^ *//p;}') - _dad_host=$(printf '%s\n' "${_dad_sans}" | tr ',' '\n' | LC_ALL=C awk ' - { sub(/^ +/, "") } - /^IP Address:/ { - ip = toupper(substr($0, 12)) - if (ip ~ /^(127\.|169\.254\.)/ || ip == "0:0:0:0:0:0:0:1" || ip ~ /^FE[89AB]/) next - if (ip ~ /:/) ip = "[" ip "]" - if (ipv == "") ipv = ip - } - /^DNS:/ && dns == "" && tolower(substr($0, 5)) != "localhost" { dns = substr($0, 5) } - END { print (ipv != "" ? ipv : dns) } - ') - if [ -z "${_dad_host}" ]; then - _dad_host=$(hostname 2>/dev/null || uname -n) - fi - printf '%s\n' "${_dad_host}" -} - -print_first_login() { - _pfl_credentials=$(wazuh_env_get_file 2>/dev/null) || _pfl_credentials="/etc/wazuh/credentials.env" - echo "" - echo "Wazuh dashboard: https://$(dashboard_address)" - echo "Log in as admin, with WAZUH_INDEXER_ADMIN_PASSWORD from ${_pfl_credentials} on the indexer host." - if command -v systemctl >/dev/null 2>&1; then - echo "Start the dashboard and enable it at boot: systemctl enable --now wazuh-dashboard" - else - echo "Start the dashboard: service wazuh-dashboard start" - fi -} - -# ----------------------------------------------------------------------------------------- -# --clear -# -# The one destructive path in a tool whose every other rule is "never overwrite, never repair, -# leave what is already there alone". It exists for exactly one situation: an image built by -# installing the package, which ran the resolver in its postinst and therefore baked this host's -# credentials into a layer that every container will share. -# -# It removes the AI assistant key as well: an image that baked one would hand every container the -# same key. Provider API keys already encrypted with it can no longer be decrypted, which is -# nothing on an image that was never used, and exactly why this is not something a running host -# should ever do. -# -# What it deliberately does NOT remove: -# -# * A shared CA this dashboard did not mint. One with a private key but no CA_MINT_MARKER was -# staged by the operator so the dashboard issues from their PKI; one without a key was issued -# elsewhere and handed to this host. Neither is ours to destroy. -# * Anything in the credentials file. The dashboard owns no key there; every one belongs to a -# sibling, and removing it is the sibling's --clear. -# -# It must run as root: certs/ is the service user's 0500 and the CA directory root's 0700, so any -# other user could neither remove nor even see them, and would report a clear that did not happen. -# ----------------------------------------------------------------------------------------- - -dashboard_is_running() { - if command -v systemctl >/dev/null 2>&1 && - systemctl is-active --quiet wazuh-dashboard.service 2>/dev/null; then - return 0 - fi - [ -f "${PID_FILE}" ] || return 1 - _dir_pid=$(cat "${PID_FILE}" 2>/dev/null) - [ -n "${_dir_pid}" ] || return 1 - # A stale pidfile from an unclean stop is not a running dashboard. - kill -0 "${_dir_pid}" 2>/dev/null -} - -clear_credentials() { - if [ "$(id -u)" != 0 ]; then - err "--clear must run as root: the certificates and the shared CA cannot be removed otherwise" - return 1 - fi - - if dashboard_is_running; then - err "refusing to clear credentials while the dashboard is running" - err " stop it first: systemctl stop wazuh-dashboard" - return 1 - fi - - if ! clear_certificates; then - err "could not remove the dashboard certificates or the CA" - return 1 - fi - - if [ ! -f "${KEYSTORE_FILE}" ]; then - log "there is no keystore in ${KEYSTORE_FILE}; nothing to clear" - return 0 - fi - if [ ! -x "${KEYSTORE_BIN}" ]; then - err "cannot clear the keystore: ${KEYSTORE_BIN} is missing" - return 1 - fi - - keystore_load - for _cc_entry in ${LADDER_ENTRIES} ${OWNED_ENTRIES}; do - keystore_has "${_cc_entry}" || continue - keystore remove "${_cc_entry}" /dev/null 2>&1 - done - - # Never claim it. This path exists so that nothing of this host's credentials reaches an image, - # and a cleared-but-not-cleared keystore is the failure it is meant to stop -- so the result is - # read back rather than inferred from exit codes. - keystore_load - _cc_left="" - for _cc_entry in ${LADDER_ENTRIES} ${OWNED_ENTRIES}; do - keystore_has "${_cc_entry}" && _cc_left="${_cc_left} ${_cc_entry}" - done - if [ -n "${_cc_left}" ]; then - err "could not remove from the keystore:${_cc_left}" - return 1 - fi - - log "removed ${LADDER_ENTRIES} ${OWNED_ENTRIES} from the keystore" - log "cleared; the next start resolves from nothing" - return 0 -} - -# ----------------------------------------------------------------------------------------- -# Run -# ----------------------------------------------------------------------------------------- - -if [ "${MODE}" = "clear" ]; then - clear_credentials - exit $? -fi - -if [ "${MODE}" = "install" ] || [ "${MODE}" = "upgrade" ]; then - credentials_file_ensure || true -fi - -if keystore_ensure; then - KEYSTORE_READY=1 -else - mark_unresolved "keystore" -fi - -if ! config_resolves_kibanaserver; then - # A username set in the yml is the operator's: never write kibanaserver over it. - if config_has opensearch.username; then - resolve_consumed WAZUH_INDEXER_KIBANASERVER_PASSWORD INDEXER_PASSWORD \ - opensearch.password - else - resolve_consumed WAZUH_INDEXER_KIBANASERVER_PASSWORD INDEXER_PASSWORD \ - opensearch.password opensearch.username kibanaserver - fi -fi -if ! config_resolves_wui; then - resolve_consumed WAZUH_MANAGER_WUI_PASSWORD API_PASSWORD \ - wazuh_core.hosts.default.password -fi - -# Owned, and only at install and start -- an upgrade adds no secret. Its result never counts -# towards the verdict: the AI assistant is optional. -if [ "${MODE}" = "install" ] || [ "${MODE}" = "prestart" ]; then - resolve_encryption_key || true -fi - -# Certificates are issued once, on a fresh install -- see their section. Their result never counts -# towards the verdict, so this is the one moment a failure can be reported, and it is said plainly -# rather than left for the operator to meet as a missing file when the dashboard starts. -if [ "${MODE}" = "install" ]; then - if ! resolve_certificates; then - err "the dashboard has no TLS certificates and this install could not issue them" - err " provision ${CERT_FILE}, ${CERT_KEY_FILE} and ${CERT_CA_FILE} into ${CERTS_DIR}" - err " (e.g. with wazuh-certs-tool); the dashboard will not start without them" - fi -else - detail "TLS certificates are only issued on a fresh install; ${CERTS_DIR} is left as it is" -fi - -if [ "${MODE}" = "install" ]; then - print_first_login -fi - -# The installer has no opinion about whether the component can run: no warning, no failure, no -# special state. Nothing checks credentials until something needs them. -if [ "${MODE}" = "install" ] || [ "${MODE}" = "upgrade" ]; then - exit 0 -fi - -if [ -z "${UNRESOLVED}" ] && [ -z "${INVALID}" ]; then - exit 0 -fi - -CREDENTIALS_FILE=$(wazuh_env_get_file 2>/dev/null) || CREDENTIALS_FILE="/etc/wazuh/credentials.env" - -# The message goes to the journal, which is where someone looks when a service will not start. -# It names every missing key and where to set it, and never prints a value. -for _key in ${INVALID}; do - err "INVALID ${_key}: the keystore would not store the supplied value as text" - err " (it must not be JSON -- a number, true, false, a quoted string, an array or an object -- nor have surrounding whitespace)" - err " correct it in ${CREDENTIALS_FILE} and start the service again" -done - -# A key that went missing because the keystore itself is unusable is reported once, as that. -case " ${UNRESOLVED} " in - *" keystore "*) - err "MISSING keystore: ${KEYSTORE_FILE} could not be created or read" - exit 1 - ;; -esac - -if [ "${CREDENTIALS_FILE_REFUSED}" -eq 1 ]; then - err "REFUSED ${CREDENTIALS_FILE}: it, or a directory above it, fails the ownership, mode or format rules" - err " the reason is logged above by wazuh-credentials; it is never repaired automatically" -fi - -for _key in ${UNRESOLVED}; do - case "${_key}" in - WAZUH_INDEXER_KIBANASERVER_PASSWORD) - err "MISSING WAZUH_INDEXER_KIBANASERVER_PASSWORD (the indexer's kibanaserver account)" - err " set it in ${CREDENTIALS_FILE}, or install wazuh-indexer on this host first" - ;; - WAZUH_MANAGER_WUI_PASSWORD) - err "MISSING WAZUH_MANAGER_WUI_PASSWORD (the manager's wazuh-internal-client account)" - err " set it in ${CREDENTIALS_FILE}, or install wazuh-manager on this host first" - ;; - *) - err "MISSING ${_key}" - err " set it in ${CREDENTIALS_FILE}" - ;; - esac -done - -exit 1 diff --git a/dev-tools/build-packages/credentials/resolve-credentials.test.js b/dev-tools/build-packages/credentials/resolve-credentials.test.js index 26d373381100..b36afb92f8a4 100644 --- a/dev-tools/build-packages/credentials/resolve-credentials.test.js +++ b/dev-tools/build-packages/credentials/resolve-credentials.test.js @@ -7,7 +7,7 @@ // node --test dev-tools/build-packages/credentials/resolve-credentials.test.js // The current user stands in for root. -const { test } = require('node:test'); +const { test, mock } = require('node:test'); const assert = require('node:assert'); const crypto = require('crypto'); const { execFileSync } = require('child_process'); @@ -88,6 +88,264 @@ test('parses and validates SANs', () => { assert.throws(() => r.parseSans('DNS:bad name')); }); +// Read on a host with eth0 and eth1 up (eth1 with two addresses), eth2 down, and a local range +// routed to lo. +const FIB_TRIE = `Main: + +-- 0.0.0.0/0 3 0 4 + +-- 0.0.0.0/4 2 0 2 + |-- 0.0.0.0 + /0 universe UNICAST + +-- 10.0.0.0/10 2 0 2 + |-- 10.9.9.9 + /32 host LOCAL + |-- 10.50.0.0 + /24 host LOCAL + +-- 127.0.0.0/8 2 0 2 + +-- 127.0.0.0/31 1 0 0 + |-- 127.0.0.0 + /8 host LOCAL + |-- 127.0.0.1 + /32 host LOCAL + |-- 127.255.255.255 + /32 link BROADCAST + +-- 172.17.0.0/16 2 0 2 + +-- 172.17.0.0/30 2 0 2 + |-- 172.17.0.0 + /16 link UNICAST + |-- 172.17.0.2 + /32 host LOCAL + |-- 172.17.255.255 + /32 link BROADCAST + +-- 192.168.56.0/24 2 0 1 + |-- 192.168.56.0 + /24 link UNICAST + +-- 192.168.56.126/31 1 0 0 + |-- 192.168.56.126 + /32 host LOCAL + |-- 192.168.56.127 + /32 host LOCAL + |-- 192.168.56.255 + /32 link BROADCAST +Local: + +-- 0.0.0.0/0 3 0 4 + +-- 0.0.0.0/4 2 0 2 + |-- 0.0.0.0 + /0 universe UNICAST + +-- 10.0.0.0/10 2 0 2 + |-- 10.9.9.9 + /32 host LOCAL + |-- 10.50.0.0 + /24 host LOCAL + +-- 127.0.0.0/8 2 0 2 + +-- 127.0.0.0/31 1 0 0 + |-- 127.0.0.0 + /8 host LOCAL + |-- 127.0.0.1 + /32 host LOCAL + |-- 127.255.255.255 + /32 link BROADCAST + +-- 172.17.0.0/16 2 0 2 + +-- 172.17.0.0/30 2 0 2 + |-- 172.17.0.0 + /16 link UNICAST + |-- 172.17.0.2 + /32 host LOCAL + |-- 172.17.255.255 + /32 link BROADCAST + +-- 192.168.56.0/24 2 0 1 + |-- 192.168.56.0 + /24 link UNICAST + +-- 192.168.56.126/31 1 0 0 + |-- 192.168.56.126 + /32 host LOCAL + |-- 192.168.56.127 + /32 host LOCAL + |-- 192.168.56.255 + /32 link BROADCAST +`; + +// lo, a link-local, a usable, a deprecated, a tentative and a DAD-failed address. +const IF_INET6 = `00000000000000000000000000000001 01 80 10 80 lo +fe8000000000000070d1a2fffe571b29 03 40 20 80 eth1 +fd000000000000000000000000000002 03 40 00 82 eth1 +fd000000000000000000000000000001 03 40 00 a0 eth1 +fd000000000000000000000000000003 03 40 00 c0 eth1 +fd000000000000000000000000000004 03 40 00 88 eth1 +`; + +// Read on a host with two default routes: dA (metric 100, added last) and eth0 (metric 700). +const ROUTE = `Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT +dA\t00000000\t016433C6\t0003\t0\t0\t100\t00000000\t0\t0\t0 +eth0\t00000000\t010011AC\t0003\t0\t0\t700\t00000000\t0\t0\t0 +eth0\t000011AC\t00000000\t0001\t0\t0\t0\t0000FFFF\t0\t0\t0 +dA\t006433C6\t00000000\t0001\t0\t0\t0\t00FFFFFF\t0\t0\t0 +`; +const IPV6_ROUTE = `00000000000000000000000000000000 00 00000000000000000000000000000000 00 20010db8000000000000000000000001 00000064 00000001 00000000 00000003 dA +00000000000000000000000000000000 00 00000000000000000000000000000000 00 00000000000000000000000000000000 ffffffff 00000001 00000000 00200200 lo +`; + +const v4 = (address) => ({ address, family: 'IPv4', internal: false }); +const v6 = (address) => ({ address, family: 'IPv6', internal: false }); + +// Read on a host with a VPN that splits the default route into 0.0.0.0/1 and 128.0.0.0/1. +const SPLIT_ROUTE = `Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT +vpn0\t00000000\t00000000\t0001\t0\t0\t0\t00000080\t0\t0\t0 +eth0\t00000000\t010011AC\t0003\t0\t0\t0\t00000000\t0\t0\t0 +vpn0\t0000420A\t00000000\t0001\t0\t0\t0\t00FFFFFF\t0\t0\t0 +vpn0\t00000080\t00000000\t0001\t0\t0\t0\t00000080\t0\t0\t0 +eth0\t000011AC\t00000000\t0001\t0\t0\t0\t0000FFFF\t0\t0\t0 +`; + +test('reads the default-route interfaces, preferred first', () => { + assert.deepStrictEqual(r.parseDefaultRoutes(ROUTE, IPV6_ROUTE), ['dA', 'eth0']); + assert.deepStrictEqual(r.parseDefaultRoutes(SPLIT_ROUTE, ''), ['eth0']); + assert.deepStrictEqual(r.parseDefaultRoutes('', ''), []); +}); + +test('reads the local IPv4 addresses from the kernel routing tables', () => { + assert.deepStrictEqual(r.parseFibTrie(FIB_TRIE), [ + '10.9.9.9', + '127.0.0.1', + '172.17.0.2', + '192.168.56.126', + '192.168.56.127', + ]); + assert.deepStrictEqual(r.parseFibTrie(''), []); +}); + +test('reads the usable global IPv6 addresses', () => { + assert.deepStrictEqual(r.parseIfInet6(IF_INET6), [ + { name: 'eth1', index: 3, address: 'fd00::2' }, + ]); + assert.deepStrictEqual(r.parseIfInet6(''), []); +}); + +// The host of wazuh/wazuh-dashboard#1697: the default route on eth0, users on eth1. +function twoInterfaces() { + return { + interfaces: { + lo: [{ address: '127.0.0.1', family: 'IPv4', internal: true }], + eth1: [v4('192.168.56.126')], + eth0: [v4('10.0.2.15'), v6('fd17::1')], + }, + defaultRoute: ['eth0'], + fibTrie: `Local: + +-- 0.0.0.0/0 3 0 4 + |-- 10.0.2.15 + /32 host LOCAL + |-- 192.168.56.126 + /32 host LOCAL +`, + ifInet6: 'fd170000000000000000000000000001 02 40 00 80 eth0\n', + }; +} + +test('discovers every interface, the default-route one first', () => { + assert.deepStrictEqual(r.discoverAddresses(twoInterfaces()), [ + '10.0.2.15', + 'fd17::1', + '192.168.56.126', + ]); +}); + +test('orders the default-route interfaces by route preference', () => { + const host = { + interfaces: { eth0: [v4('172.17.0.2')], dA: [v4('198.51.100.10')], 'eth1:1': [v4('10.1.1.1')] }, + defaultRoute: ['dA', 'eth1', 'eth0'], + }; + assert.deepStrictEqual(r.discoverAddresses(host), ['198.51.100.10', '10.1.1.1', '172.17.0.2']); +}); + +test('keeps the order of the IPv6 addresses of the interfaces that are up', () => { + // if_inet6 lists them in hash order; dX (index 5) is down. + const ifInet6 = [ + '20010db8000000000000000000000030 05 40 00 80 dX', + '20010db8000000000000000000000020 04 40 00 80 d6', + '20010db8aaaa00000000000000000001 04 40 00 80 d6', + '20010db8000000000000000000000010 04 40 00 80 d6', + '20010db8000000000000000000000040 03 40 00 80 dY', + ].join('\n'); + const host = { + interfaces: { d6: [v6('2001:db8:aaaa::1'), v6('2001:0db8::10'), v6('2001:db8::20')] }, + defaultRoute: ['d6'], + fibTrie: '', + ifInet6, + }; + assert.deepStrictEqual(r.discoverAddresses(host), [ + '2001:db8:aaaa::1', + '2001:db8::10', + '2001:db8::20', + '2001:db8::40', + '2001:db8::30', + ]); +}); + +test('discovers the addresses of interfaces that are down', () => { + const warnings = []; + const addresses = r.discoverAddresses({ + interfaces: { + eth0: [v4('172.17.0.2')], + eth1: [v4('192.168.56.126'), v4('192.168.56.127'), v6('fd00::2'), v6('fd00::1')], + }, + fibTrie: FIB_TRIE, + ifInet6: IF_INET6, + warn: (msg) => warnings.push(msg), + }); + // Down ones included; loopback, the routed range and the deprecated IPv6 left out. + assert.deepStrictEqual(addresses, [ + '172.17.0.2', + '192.168.56.126', + '192.168.56.127', + '10.9.9.9', + 'fd00::2', + ]); + assert.deepStrictEqual(warnings, []); +}); + +test('keeps the up interfaces when the routing tables cannot be read', () => { + for (const fibTrie of [null, 'a format this parser does not know']) { + const warnings = []; + const host = { ...twoInterfaces(), fibTrie, warn: (msg) => warnings.push(msg) }; + assert.deepStrictEqual(r.discoverAddresses(host), ['10.0.2.15', 'fd17::1', '192.168.56.126']); + assert.strictEqual(warnings.length, 1); + } +}); + +test('takes the IPv6 addresses of the up interfaces when if_inet6 cannot be read', () => { + const host = { ...twoInterfaces(), ifInet6: null }; + host.interfaces.eth1.push(v6('fe80::1')); + assert.deepStrictEqual(r.discoverAddresses(host), ['10.0.2.15', 'fd17::1', '192.168.56.126']); + assert.deepStrictEqual(r.discoverAddresses(), []); +}); + +test('issues the certificate when discovery finds nothing or fails', () => { + const { ctx } = setup(); + // A host name nothing resolves, so no FQDN is added. + const host = 'resolver-test-1697'; + const loopback = ['DNS:localhost', 'IP:127.0.0.1', 'IP:::1']; + const run = (discover) => { + const stderr = []; + mock.method(process.stderr, 'write', (line) => stderr.push(line)); + try { + const names = r.leafSans(ctx, host, discover).map((s) => `${s.type}:${s.value}`); + return { names, stderr: stderr.join('') }; + } finally { + mock.restoreAll(); + } + }; + const none = run(() => []); + assert.deepStrictEqual(none.names, [`DNS:${host}`, ...loopback]); + assert.match(none.stderr, /found no address of this host.*WAZUH_DASHBOARD_CERT_SANS/); + const failed = run(() => { + throw new Error('boom'); + }); + assert.deepStrictEqual(failed.names, [`DNS:${host}`, ...loopback]); + assert.match(failed.stderr, /could not discover the addresses of this host: boom/); + const found = run(() => ['192.168.56.126']); + assert.deepStrictEqual(found.names, [`DNS:${host}`, 'IP:192.168.56.126', ...loopback]); + assert.strictEqual(found.stderr, ''); +}); + test('issues a leaf the CA verifies, with the SANs asked for', () => { const ca = caPair(); assert.ok(r.validCa(ca.certPem, ca.keyPem)); diff --git a/dev-tools/build-packages/deb/debian/changelog b/dev-tools/build-packages/deb/debian/changelog index f19ba221fed4..455be01db46e 100644 --- a/dev-tools/build-packages/deb/debian/changelog +++ b/dev-tools/build-packages/deb/debian/changelog @@ -10,11 +10,17 @@ wazuh-dashboard (5.0.1-RELEASE) stable; urgency=low -- Wazuh, Inc Wed, 09 Sep 2026 12:00:00 +0000 +wazuh-dashboard (4.14.12-RELEASE) stable; urgency=low + + * More info: https://documentation.wazuh.com/current/release-notes/release-4-14-12.html + + -- Wazuh, Inc Wed, 02 Dec 2026 12:00:00 +0000 + wazuh-dashboard (5.0.0-RELEASE) stable; urgency=low * More info: https://documentation.wazuh.com/current/release-notes/release-5-0-0.html - -- Wazuh, Inc Wed, 14 Oct 2026 12:00:00 +0000 + -- Wazuh, Inc Wed, 25 Nov 2026 12:00:00 +0000 wazuh-dashboard (4.14.11-RELEASE) stable; urgency=low @@ -26,7 +32,7 @@ wazuh-dashboard (4.14.10-RELEASE) stable; urgency=low * More info: https://documentation.wazuh.com/current/release-notes/release-4-14-10.html - -- Wazuh, Inc Wed, 28 Oct 2026 12:00:00 +0000 + -- Wazuh, Inc Thu, 05 Nov 2026 12:00:00 +0000 wazuh-dashboard (4.14.9-RELEASE) stable; urgency=low diff --git a/dev-tools/build-packages/deb/debian/postrm b/dev-tools/build-packages/deb/debian/postrm index 7f6422cecc89..b6074838adc4 100644 --- a/dev-tools/build-packages/deb/debian/postrm +++ b/dev-tools/build-packages/deb/debian/postrm @@ -26,13 +26,6 @@ if [ -f "/etc/default/${NAME}" ]; then . "/etc/default/${NAME}" fi -# A package counts until it is purged: installed, or removed with its -# configuration files still on the host. -is_package_present() { - status=$(dpkg-query -W -f='${db:Status-Status}' "$1" 2>/dev/null) || return 1 - [ -n "${status}" ] && [ "${status}" != "not-installed" ] -} - REMOVE_DIRS=false REMOVE_CONFIG_DIRECTORY=false REMOVE_USER_AND_GROUP=false @@ -75,13 +68,6 @@ if [ "$REMOVE_DIRS" = "true" ]; then echo -n "Deleting configuration directory..." rm -rf "${CONFIG_DIR}" echo " OK" - - # The dashboard owns no key in /etc/wazuh/credentials.env. The shared - # /etc/wazuh directory (credentials file and default CA directory) is - # only removed by the last Wazuh central component to leave the host. - if ! is_package_present wazuh-indexer && ! is_package_present wazuh-manager; then - rm -rf /etc/wazuh - fi fi fi diff --git a/dev-tools/build-packages/rpm/wazuh-dashboard.spec b/dev-tools/build-packages/rpm/wazuh-dashboard.spec index 79bf6cb822a7..096e2079f8a6 100644 --- a/dev-tools/build-packages/rpm/wazuh-dashboard.spec +++ b/dev-tools/build-packages/rpm/wazuh-dashboard.spec @@ -550,12 +550,14 @@ rm -fr %{buildroot} %attr(644, root, root) "/usr/lib/systemd/system/wazuh-dashboard.service" %changelog +* Wed Dec 02 2026 support - 4.14.12 +- More info: https://documentation.wazuh.com/current/release-notes/release-4-14-12.html +* Wed Nov 25 2026 support - 5.0.0 +- More info: https://documentation.wazuh.com/current/release-notes/release-5-0-0.html * Wed Nov 11 2026 support - 4.14.11 - More info: https://documentation.wazuh.com/current/release-notes/release-4-14-11.html -* Wed Oct 28 2026 support - 4.14.10 +* Thu Nov 05 2026 support - 4.14.10 - More info: https://documentation.wazuh.com/current/release-notes/release-4-14-10.html -* Wed Oct 14 2026 support - 5.0.0 -- More info: https://documentation.wazuh.com/current/release-notes/release-5-0-0.html * Wed Oct 14 2026 support - 4.14.9 - More info: https://documentation.wazuh.com/current/release-notes/release-4-14-9.html * Wed Sep 30 2026 support - 5.1.0 diff --git a/src/core/public/healthcheck/service.mock.ts b/src/core/public/healthcheck/service.mock.ts index c35c6af27b12..f3d4ad681245 100644 --- a/src/core/public/healthcheck/service.mock.ts +++ b/src/core/public/healthcheck/service.mock.ts @@ -8,10 +8,11 @@ import { BehaviorSubject } from 'rxjs'; import { HealthCheckStatus } from './service'; export const healthCheckServiceMock = { - createSetupContract: (): HealthCheckServiceSetup => ({ + createSetupContract: (): jest.Mocked => ({ status$: new BehaviorSubject({ status: 'gray', checks: [] } as HealthCheckStatus), + describe: jest.fn(), }), - createStartContract: (): HealthCheckServiceStart => ({ + createStartContract: (): jest.Mocked => ({ status$: new BehaviorSubject({ status: 'gray', checks: [] } as HealthCheckStatus), client: { internal: { @@ -20,5 +21,6 @@ export const healthCheckServiceMock = { }, }, getConfig: jest.fn(), + getDescription: jest.fn(), }), }; diff --git a/src/core/public/healthcheck/service.test.ts b/src/core/public/healthcheck/service.test.ts index 4f6b220a5620..055a5d1eae33 100644 --- a/src/core/public/healthcheck/service.test.ts +++ b/src/core/public/healthcheck/service.test.ts @@ -86,6 +86,27 @@ describe('HealthcheckService', () => { expect(service.computeOverallStatus(checks)).toBe(status); }); + it('returns the description a plugin registered for a check', () => { + const service = new HealthcheckService(); + const description = { + title: 'Index patterns', + description: 'Creates the index patterns.', + remediation: 'Restart the dashboard.', + }; + + service.setup().describe('saved-objects:index-patterns', description); + const start = service.start({ + http: httpServiceMock.createStartContract(), + notifications: notificationServiceMock.createStartContract(), + chrome: chromeServiceMock.createStartContract(), + uiSettings: uiSettingsServiceMock.createStartContract(), + healthCheckConfig: {} as HealthCheckConfig, + }); + + expect(start.getDescription('saved-objects:index-patterns')).toEqual(description); + expect(start.getDescription('server-api:run-as')).toBeUndefined(); + }); + it('generateNextState computes the status and sorts the checks by name', async () => { const service = new HealthcheckService(); const checks = [check('task:2', { result: 'red' }), check('task:1', { result: 'green' })]; diff --git a/src/core/public/healthcheck/service.ts b/src/core/public/healthcheck/service.ts index 3b508bd80d7e..c74417f786eb 100644 --- a/src/core/public/healthcheck/service.ts +++ b/src/core/public/healthcheck/service.ts @@ -6,7 +6,7 @@ import { BehaviorSubject } from 'rxjs'; import { get } from 'lodash'; import { TaskInfo } from 'src/core/common/healthcheck'; -import { HealthCheckServiceStartDeps } from './types'; +import { HealthCheckDescription, HealthCheckServiceStartDeps } from './types'; import { TASK } from './constants'; export function mergeArraysOfByProp(arr1: T[], arr2: T[], prop: string) { @@ -25,10 +25,14 @@ export class HealthcheckService { status: 'gray', checks: [], } as HealthCheckStatus); + private readonly descriptions = new Map(); constructor() {} setup() { const deps = { status$: this.status$, + describe: (name: string, description: HealthCheckDescription) => { + this.descriptions.set(name, description); + }, }; return deps; } @@ -36,6 +40,7 @@ export class HealthcheckService { const status$ = this.status$; const deps = { status$: this.status$, + getDescription: (name: string) => this.descriptions.get(name), client: { internal: { fetch: async (taskNames?: string[]) => { diff --git a/src/core/public/healthcheck/types.ts b/src/core/public/healthcheck/types.ts index a44db83ddc52..325b9a360673 100644 --- a/src/core/public/healthcheck/types.ts +++ b/src/core/public/healthcheck/types.ts @@ -19,8 +19,17 @@ export interface HealthCheckServiceStartDeps { healthCheckConfig: HealthCheckConfig; } +/** Plain-language text for a check, shown instead of its technical name. */ +export interface HealthCheckDescription { + title: string; + description: string; + // What to do when the check fails. + remediation: string; +} + export interface HealthCheckServiceSetup { status$: BehaviorSubject<{ status: TaskInfo['result']; checks: TaskInfo[] }>; + describe: (name: string, description: HealthCheckDescription) => void; } export interface HealthCheckServiceStart { @@ -32,4 +41,5 @@ export interface HealthCheckServiceStart { }; }; getConfig: () => Promise; + getDescription: (name: string) => HealthCheckDescription | undefined; } diff --git a/src/core/server/http/http_service.test.ts b/src/core/server/http/http_service.test.ts index ce55c438f061..e24fc550576f 100644 --- a/src/core/server/http/http_service.test.ts +++ b/src/core/server/http/http_service.test.ts @@ -75,6 +75,8 @@ const fakeHapiServer = { start: noop, stop: noop, route: noop, + // Wazuh: the NotReady server tracks its sockets through the listener + listener: { on: jest.fn() }, }; afterEach(() => { @@ -121,6 +123,8 @@ test('spins up notReady server until started if configured with `autoListen:true start: jest.fn(), stop: jest.fn(), route: jest.fn(), + // Wazuh: the NotReady server tracks its sockets through the listener + listener: { on: jest.fn() }, }; const notReadyBasePath = {}; @@ -227,6 +231,8 @@ test('stops not ready server if it is running', async () => { start: jest.fn(), stop: jest.fn(), route: jest.fn(), + // Wazuh: the NotReady server tracks its sockets through the listener + listener: { on: jest.fn() }, }; const httpServer = { isListening: () => false, @@ -245,6 +251,104 @@ test('stops not ready server if it is running', async () => { expect(mockHapiServer.stop).toHaveBeenCalledTimes(1); }); +// Wazuh: SIGTERM received while `start()` is stopping the not ready server +test('waits for the in-progress not ready server stop when stopped during start', async () => { + const configService = createConfigService(); + let resolveNotReadyStop: () => void = noop; + const notReadyHapiServer = { + start: jest.fn(), + stop: jest.fn( + () => + new Promise((resolve) => { + resolveNotReadyStop = resolve; + }) + ), + route: jest.fn(), + listener: { on: jest.fn() }, + }; + const httpServer = { + isListening: () => false, + setup: jest.fn().mockReturnValue({}), + start: jest.fn(), + stop: jest.fn(), + }; + mockHttpServer + .mockImplementationOnce(() => httpServer) + .mockImplementationOnce(() => ({ + setup: () => ({ server: notReadyHapiServer, basePath: {} }), + })); + + const service = new HttpService({ coreId, configService, env, logger, dynamicConfigService }); + + await service.setup(setupDeps); + + const startPromise = service.start({ dynamicConfigService: dynamicConfigServiceStart }); + // Let `start()` reach the not ready server stop + await new Promise((resolve) => setImmediate(resolve)); + const stopPromise = service.stop(); + + resolveNotReadyStop(); + await Promise.all([startPromise, stopPromise]); + + expect(notReadyHapiServer.stop).toHaveBeenCalledTimes(1); + expect(httpServer.stop).toHaveBeenCalledTimes(1); +}); + +// Wazuh: hapi does not track sockets still in the TLS handshake, which can block the stop +test('destroys the not ready server sockets if it does not stop within the timeout', async () => { + const configService = createConfigService(); + let resolveNotReadyStop: () => void = noop; + const notReadyHapiServer = { + start: jest.fn(), + stop: jest.fn( + () => + new Promise((resolve) => { + resolveNotReadyStop = resolve; + }) + ), + route: jest.fn(), + listener: { on: jest.fn() }, + }; + const httpServer = { + isListening: () => false, + setup: jest.fn().mockReturnValue({ server: notReadyHapiServer }), + start: noop, + stop: jest.fn(), + }; + mockHttpServer.mockImplementation(() => httpServer); + + const service = new HttpService({ coreId, configService, env, logger, dynamicConfigService }); + + await service.setup(setupDeps); + + expect(notReadyHapiServer.listener.on).toHaveBeenCalledWith('connection', expect.any(Function)); + const onConnection = notReadyHapiServer.listener.on.mock.calls[0][1]; + const socket = { + once: jest.fn(), + destroy: jest.fn(() => resolveNotReadyStop()), + }; + onConnection(socket); + + jest.useFakeTimers(); + try { + const stopPromise = service.stop(); + + jest.advanceTimersByTime(4999); + expect(socket.destroy).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + expect(socket.destroy).toHaveBeenCalledTimes(1); + + await stopPromise; + } finally { + jest.useRealTimers(); + } + + expect(notReadyHapiServer.stop).toHaveBeenCalledTimes(1); + expect(notReadyHapiServer.stop).toHaveBeenCalledWith({ timeout: 5000 }); + expect(httpServer.stop).toHaveBeenCalledTimes(1); +}); + test('register route handler', async () => { const configService = createConfigService(); diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index d10e9795c665..0047107b6ded 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -28,6 +28,8 @@ * under the License. */ +// Wazuh: socket type to track the NotReady server connections +import type { Socket } from 'net'; import { Observable, Subscription, combineLatest } from 'rxjs'; import { first, map } from 'rxjs/operators'; import { Server } from '@hapi/hapi'; @@ -57,6 +59,9 @@ import { import { BasePath, RequestHandlerContext } from '../../server'; import { registerCoreHandlers } from './lifecycle_handlers'; +// Wazuh: time to wait for the NotReady server to stop before destroying its remaining sockets +const NOT_READY_SERVER_STOP_TIMEOUT_MS = 5000; + export interface SetupDeps { context: ContextSetup; // Wazuh @@ -79,6 +84,10 @@ export class HttpService private readonly log: Logger; private readonly env: Env; private notReadyServer?: Server; + // Wazuh: in-flight stop of the NotReady server + private notReadyServerStop?: Promise; + // Wazuh: raw sockets of the NotReady server, including those still in the TLS handshake + private readonly notReadyServerSockets = new Set(); private internalSetup?: InternalHttpServiceSetup; private requestHandlerContext?: RequestHandlerContextContainer; @@ -152,11 +161,8 @@ export class HttpService public async start(deps: StartDeps) { const config = await this.config$.pipe(first()).toPromise(); if (this.shouldListen(config)) { - if (this.notReadyServer) { - this.log.debug('stopping NotReady server'); - await this.notReadyServer.stop(); - this.notReadyServer = undefined; - } + // Wazuh: shared with `stop()`, which can run concurrently on shutdown during startup + await this.stopNotReadyServer(); // If a redirect port is specified, we start an HTTP server at this port and // redirect all requests to the SSL port. if (config.ssl.enabled && config.ssl.redirectHttpFromPort !== undefined) { @@ -189,13 +195,38 @@ export class HttpService this.configSubscription.unsubscribe(); this.configSubscription = undefined; - if (this.notReadyServer) { - await this.notReadyServer.stop(); - } + // Wazuh: shared with `start()` to avoid stopping the hapi server twice + await this.stopNotReadyServer(); await this.httpServer.stop(); await this.httpsRedirectServer.stop(); } + // Wazuh: hapi rejects `stop()` while a previous `stop()` is in progress, so the NotReady server is + // stopped once and later callers wait for that same stop to finish. + private async stopNotReadyServer() { + if (this.notReadyServer) { + this.log.debug('stopping NotReady server'); + const notReadyServer = this.notReadyServer; + this.notReadyServer = undefined; + // Wazuh: hapi only tracks sockets after the TLS handshake completes, so a socket stuck in the + // handshake blocks the stop until Node's 120 s handshake timeout. Destroy every remaining + // socket once the stop timeout elapses. + const destroyTimer = setTimeout(() => { + this.log.debug(`destroying ${this.notReadyServerSockets.size} NotReady server sockets`); + this.notReadyServerSockets.forEach((socket) => socket.destroy()); + }, NOT_READY_SERVER_STOP_TIMEOUT_MS); + this.notReadyServerStop = (async () => { + try { + await notReadyServer.stop({ timeout: NOT_READY_SERVER_STOP_TIMEOUT_MS }); + } finally { + clearTimeout(destroyTimer); + this.notReadyServerSockets.clear(); + } + })(); + } + await this.notReadyServerStop; + } + // Wazuh: inject dependencies private async runNotReadyServer(config: HttpConfig, deps: any) { this.log.debug('starting NotReady server'); @@ -223,6 +254,11 @@ export class HttpService // .header('Retry-After', '30'); // }, // }); + // Wazuh: track raw sockets so the NotReady server stop can destroy them if it hangs + this.notReadyServer.listener.on('connection', (socket: Socket) => { + this.notReadyServerSockets.add(socket); + socket.once('close', () => this.notReadyServerSockets.delete(socket)); + }); await this.notReadyServer.start(); } } diff --git a/src/core/server/rendering/rendering_service.test.ts b/src/core/server/rendering/rendering_service.test.ts index e922e328b055..64fbacb7860a 100644 --- a/src/core/server/rendering/rendering_service.test.ts +++ b/src/core/server/rendering/rendering_service.test.ts @@ -51,9 +51,12 @@ import { mockRenderingSetupDeps } from './__mocks__/params'; import { InternalRenderingServiceSetup } from './types'; import { RenderingService } from './rendering_service'; import { configServiceMock } from '../config/mocks'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import { config as RawOpenSearchDashboardsConfig } from '../opensearch_dashboards_config'; import { mockCoreContext } from '../core_context.mock'; +import { pluginServiceMock } from '../plugins/plugins_service.mock'; +import { dynamicConfigServiceMock } from '../config/dynamic_config_service.mock'; +import { DiscoveredPlugin } from '../plugins'; const INJECTED_METADATA = { version: expect.any(String), @@ -243,6 +246,31 @@ describe('RenderingService', () => { expect(i18nLoader.isRegisteredLocale).toHaveBeenCalledWith('xx-XX'); expect($elStyle.attr('lang')).toBe('en'); }); + + // Wazuh + it('renders only the exposeToBrowser config of the plugins', async () => { + context.dynamicConfigService.hasDefaultConfigs.mockReturnValue(true); + const dynamicConfig = dynamicConfigServiceMock.createInternalSetupContract(); + dynamicConfig.getStartService.mockResolvedValue( + dynamicConfigServiceMock.createInternalStartContract({ + getConfig: { uiProp: 'value', serverOnlyProp: 'value' }, + bulkGetConfigs: new Map(), + listConfigs: new Map(), + }) + ); + const uiPlugins = pluginServiceMock.createUiPlugins(); + uiPlugins.public.set('testPlugin', { id: 'testPlugin' } as DiscoveredPlugin); + uiPlugins.browserConfigs.set('testPlugin', of({ uiProp: 'value' })); + + render = (await service.setup({ ...mockRenderingSetupDeps, dynamicConfig, uiPlugins })) + .render; + const content = await render(createOpenSearchDashboardsRequest(), uiSettings, { + nonce: 'test-nonce', + }); + const data = JSON.parse(load(content)('osd-injected-metadata').attr('data') || ''); + + expect(data.uiPlugins[0].config).toEqual({ uiProp: 'value' }); + }); }); }); diff --git a/src/core/server/rendering/rendering_service.tsx b/src/core/server/rendering/rendering_service.tsx index 232c441578a8..7366bedfb319 100644 --- a/src/core/server/rendering/rendering_service.tsx +++ b/src/core/server/rendering/rendering_service.tsx @@ -67,7 +67,6 @@ export class RenderingService { http, status, uiPlugins, - dynamicConfig, // Wazuh healthCheck, }: RenderingSetupDeps): Promise { @@ -112,8 +111,6 @@ export class RenderingService { } } - const dynamicConfigStartServices = await dynamicConfig.getStartService(); - const metadata: RenderingMetadata = { strictCsp: http.csp.strict, uiPublicUrl, @@ -144,15 +141,8 @@ export class RenderingService { [...uiPlugins.public].map(async ([id, plugin]) => ({ id, plugin, - // TODO Scope the client so that only exposedToBrowser configs are exposed - config: this.coreContext.dynamicConfigService.hasDefaultConfigs({ name: id }) - ? await dynamicConfigStartServices.getClient().getConfig( - { name: id }, - { - asyncLocalStorageContext: dynamicConfigStartServices.getAsyncLocalStore()!, - } - ) - : await this.getUiConfig(uiPlugins, id), + // Wazuh: only send the exposeToBrowser keys + config: await this.getUiConfig(uiPlugins, id), })) ), legacyMetadata: { diff --git a/src/core/server/root/index.test.ts b/src/core/server/root/index.test.ts index 17a53ca2eb86..8450b019fe7b 100644 --- a/src/core/server/root/index.test.ts +++ b/src/core/server/root/index.test.ts @@ -127,6 +127,24 @@ test('stops services on "shutdown" an calls `onShutdown` with error passed to `s expect(mockServer.stop).toHaveBeenCalledTimes(1); }); +// Wazuh: SIGTERM received while the server is still starting +test('calls `onShutdown` on "shutdown" even if the server fails to stop', async () => { + const mockOnShutdown = jest.fn(); + const root = new Root(rawConfigService, env, mockOnShutdown); + + await root.setup(); + + const stopError = new Error('Cannot stop server while in stopping phase'); + mockServer.stop.mockRejectedValue(stopError); + + await expect(root.shutdown()).resolves.toBeUndefined(); + + expect(mockServer.stop).toHaveBeenCalledTimes(1); + expect(logger.stop).toHaveBeenCalledTimes(1); + expect(mockOnShutdown).toHaveBeenCalledTimes(1); + expect(mockOnShutdown).toHaveBeenCalledWith(undefined); +}); + test('fails and stops services if server setup fails', async () => { const mockOnShutdown = jest.fn(); const root = new Root(rawConfigService, env, mockOnShutdown); diff --git a/src/core/server/root/index.ts b/src/core/server/root/index.ts index 73b37eadc4db..b0823482a122 100644 --- a/src/core/server/root/index.ts +++ b/src/core/server/root/index.ts @@ -91,7 +91,13 @@ export class Root { this.log.fatal(reason); } - await this.server.stop(); + // Wazuh: keep shutting down if a service fails to stop (e.g. SIGTERM received while the server + // is still starting), otherwise `onShutdown` is never called and the process does not exit. + try { + await this.server.stop(); + } catch (error) { + this.log.error(error); + } if (this.loggingConfigSubscription !== undefined) { this.loggingConfigSubscription.unsubscribe(); diff --git a/src/plugins/healthcheck/docs/API_REFERENCE.md b/src/plugins/healthcheck/docs/API_REFERENCE.md index 7c19fc264206..6c8f5e4e94f8 100644 --- a/src/plugins/healthcheck/docs/API_REFERENCE.md +++ b/src/plugins/healthcheck/docs/API_REFERENCE.md @@ -23,6 +23,28 @@ const { status$, client, getConfig } = getHealthCheck(); - `status$`: Current status observable - `client`: HTTP client for backend communication - `getConfig`: Function to get configuration +- `getDescription`: Returns the title, description and fix registered for a check, if any + +#### Describe a check + +A plugin gives a check a plain title, a one-line description and what to do when it fails. Call +it in the plugin's public `setup`, with translated text: + +```typescript +core.healthCheck.describe('server-api:run-as', { + title: i18n.translate('myPlugin.healthCheck.runAs.title', { defaultMessage: 'Run as' }), + description: i18n.translate('myPlugin.healthCheck.runAs.description', { + defaultMessage: 'Checks that the API user can use run_as.', + }), + remediation: i18n.translate('myPlugin.healthCheck.runAs.remediation', { + defaultMessage: 'Allow run_as for the API user.', + }), +}); +``` + +The table, the check flyout and the header popover show the title, and keep the check name as a +reference. The flyout shows the remediation under **What to check** only when the check is yellow +or red. A check with no description shows its name. #### getCore() diff --git a/src/plugins/healthcheck/public/components/button_header/health_check_nav_button.test.tsx b/src/plugins/healthcheck/public/components/button_header/health_check_nav_button.test.tsx index c4f6713f2ee6..fd6a6411075c 100644 --- a/src/plugins/healthcheck/public/components/button_header/health_check_nav_button.test.tsx +++ b/src/plugins/healthcheck/public/components/button_header/health_check_nav_button.test.tsx @@ -53,12 +53,17 @@ const buildStatus = ( checks, }); -const renderButton = (status: HealthCheckStatus['status'], checks: TaskInfo[] = []) => { +const renderButton = ( + status: HealthCheckStatus['status'], + checks: TaskInfo[] = [], + getDescription: HealthCheckNavButtonProps['getDescription'] = () => undefined +) => { const props: HealthCheckNavButtonProps = { coreStart, status$: new BehaviorSubject(buildStatus(status, checks)), fetch: jest.fn().mockResolvedValue(undefined), getConfig: jest.fn().mockResolvedValue({ interval: 0 }), + getDescription, }; return render(); }; @@ -144,6 +149,19 @@ describe('HealthCheckNavButton', () => { expect(trigger).toHaveAttribute('aria-expanded', 'true'); }); + it('lists a described check by its title', () => { + const { getByTestId, getByText, queryByText } = renderButton('yellow', checks, (name) => + name === 'server-api:run-as' + ? { title: 'Server API run as', description: 'Checks run_as.', remediation: 'Fix it.' } + : undefined + ); + + fireEvent.click(getByTestId('healthcheckNavButton')); + + expect(getByText('Server API run as')).toBeInTheDocument(); + expect(queryByText('run-as')).toBeNull(); + }); + it('closes the popover when the Health Check link is clicked', async () => { const { getByRole, getByTestId, queryByRole } = renderButton('yellow', checks); const trigger = getByTestId('healthcheckNavButton'); diff --git a/src/plugins/healthcheck/public/components/button_header/health_check_nav_button.tsx b/src/plugins/healthcheck/public/components/button_header/health_check_nav_button.tsx index b93bd885f43f..310b6f6eee42 100644 --- a/src/plugins/healthcheck/public/components/button_header/health_check_nav_button.tsx +++ b/src/plugins/healthcheck/public/components/button_header/health_check_nav_button.tsx @@ -21,7 +21,10 @@ import { import useObservable from 'react-use/lib/useObservable'; import { BehaviorSubject, interval, Subscription } from 'rxjs'; import { HealthCheckConfig, HealthCheckStatus } from 'src/core/common/healthcheck'; -import { HealthCheckServiceStart } from 'opensearch-dashboards/public/healthcheck'; +import { + HealthCheckDescription, + HealthCheckServiceStart, +} from 'opensearch-dashboards/public/healthcheck'; import { CoreStart } from 'opensearch-dashboards/public'; import { mapTaskStatusToHealthColor } from '../services/health'; import { RedirectAppLinks } from '../../../../opensearch_dashboards_react/public'; @@ -34,9 +37,11 @@ export interface HealthCheckNavButtonProps { status$: BehaviorSubject; fetch: HealthCheckServiceStart['client']['internal']['fetch']; getConfig: () => Promise; + getDescription: (name: string) => HealthCheckDescription | undefined; } export const HealthCheckNavButton = ({ getConfig, + getDescription, fetch, coreStart, status$, @@ -132,6 +137,7 @@ export const HealthCheckNavButton = ({ ) .map((check) => { const [category, name] = check.name.split(':'); + const title = getDescription(check.name)?.title; return ( @@ -140,7 +146,11 @@ export const HealthCheckNavButton = ({ style={{ cursor: 'default' }} > - {category}: {name} + {title ?? ( + <> + {category}: {name} + + )} diff --git a/src/plugins/healthcheck/public/components/button_header/mount_button.tsx b/src/plugins/healthcheck/public/components/button_header/mount_button.tsx index c75b46f2033c..675b3c3a17cb 100644 --- a/src/plugins/healthcheck/public/components/button_header/mount_button.tsx +++ b/src/plugins/healthcheck/public/components/button_header/mount_button.tsx @@ -20,6 +20,7 @@ export const mountButton = (core: CoreStart) => { status$={core.healthCheck.status$} fetch={core.healthCheck.client.internal.fetch} getConfig={core.healthCheck.getConfig} + getDescription={core.healthCheck.getDescription} />, el ); diff --git a/src/plugins/healthcheck/public/components/healthcheck.tsx b/src/plugins/healthcheck/public/components/healthcheck.tsx index 7933b37f31f5..34065215b72b 100644 --- a/src/plugins/healthcheck/public/components/healthcheck.tsx +++ b/src/plugins/healthcheck/public/components/healthcheck.tsx @@ -12,6 +12,7 @@ import { EuiHorizontalRule, EuiButtonEmpty, EuiLink, + EuiText, } from '@elastic/eui'; import useObservable from 'react-use/lib/useObservable'; import { useMount } from 'react-use'; @@ -86,6 +87,14 @@ export const HealthCheckContent = () => { + +

+ +

+
diff --git a/src/plugins/healthcheck/public/components/table/check_flyout.test.tsx b/src/plugins/healthcheck/public/components/table/check_flyout.test.tsx new file mode 100644 index 000000000000..d9a6d60ea0d2 --- /dev/null +++ b/src/plugins/healthcheck/public/components/table/check_flyout.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Wazuh + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { I18nProvider } from '@osd/i18n/react'; +import { TaskInfo } from '../../../../../core/common/healthcheck'; +import { CheckFlyout } from './check_flyout'; + +const check: TaskInfo = { + name: 'server-api:run-as', + status: 'finished', + result: 'green', + createdAt: null, + startedAt: null, + finishedAt: null, + duration: null, + data: null, + error: null, + enabled: true, + critical: false, +}; + +const description = { + title: 'Server API run as', + description: 'Checks that the API user can use run_as.', + remediation: 'Set run_as: true for the host.', +}; + +const renderFlyout = ( + flyoutDescription?: React.ComponentProps['description'], + overrides: Partial = {} +) => + render( + + date} + setIsFlyoutVisible={jest.fn()} + /> + + ); + +describe('CheckFlyout', () => { + it('shows the title, name and description of a described check', () => { + const { getByText, queryByText } = renderFlyout(description); + + expect(getByText('Server API run as')).toBeInTheDocument(); + expect(getByText('server-api:run-as')).toBeInTheDocument(); + expect(getByText('Checks that the API user can use run_as.')).toBeInTheDocument(); + expect(queryByText(/Check details for/)).toBeNull(); + }); + + it('hides what to check while the check passes', () => { + const { queryByText } = renderFlyout(description); + + expect(queryByText('What to check:')).toBeNull(); + expect(queryByText('Set run_as: true for the host.')).toBeNull(); + }); + + it.each(['yellow', 'red'] as const)('shows what to check when the result is %s', (result) => { + const { getByText } = renderFlyout(description, { result, error: 'run_as is disabled' }); + + expect(getByText('What to check:')).toBeInTheDocument(); + expect(getByText('Set run_as: true for the host.')).toBeInTheDocument(); + }); + + it('falls back to the check name when no description is registered', () => { + const { getByText, queryByText } = renderFlyout(); + + expect(getByText('Check details for server-api:run-as')).toBeInTheDocument(); + expect(queryByText('What to check:')).toBeNull(); + }); +}); diff --git a/src/plugins/healthcheck/public/components/table/check_flyout.tsx b/src/plugins/healthcheck/public/components/table/check_flyout.tsx index 9f9a03584ade..52dd18e4ab54 100644 --- a/src/plugins/healthcheck/public/components/table/check_flyout.tsx +++ b/src/plugins/healthcheck/public/components/table/check_flyout.tsx @@ -18,6 +18,7 @@ import { EuiDescriptionListDescription, } from '@elastic/eui'; import { FormattedMessage } from 'react-intl'; +import { HealthCheckDescription } from 'opensearch-dashboards/public/healthcheck'; import { TaskInfo } from '../../../../../core/common/healthcheck'; import { mapTaskStatusToHealthColor } from '../services/health'; import { BadgeResults } from '../utils/badge_results'; @@ -45,11 +46,17 @@ function formatDuration(ms: number) { interface CheckFlyoutProps { check: TaskInfo; + description?: HealthCheckDescription; formatDate: (date: string) => string; setIsFlyoutVisible: React.Dispatch>; } -export const CheckFlyout = ({ check, formatDate, setIsFlyoutVisible }: CheckFlyoutProps) => { +export const CheckFlyout = ({ + check, + description, + formatDate, + setIsFlyoutVisible, +}: CheckFlyoutProps) => { const { name, duration, startedAt, finishedAt, error, result, status, enabled, critical } = check; const callOut = !enabled && ( @@ -80,15 +87,34 @@ export const CheckFlyout = ({ check, formatDate, setIsFlyoutVisible }: CheckFlyo > - + {description ? ( +

{description.title}

+ ) : ( + + )}
+ {description && ( + + {name} + + )}
+ {description && ( + <> + + +

{description.description}

+
+
+ + + )} {error && ( <> {result === 'red' && ( @@ -127,6 +153,23 @@ export const CheckFlyout = ({ check, formatDate, setIsFlyoutVisible }: CheckFlyo )} )} + {description && (result === 'yellow' || result === 'red') && ( + <> + + + + + + +

{description.remediation}

+
+
+ + + )} diff --git a/src/plugins/healthcheck/public/components/table/checks_table.tsx b/src/plugins/healthcheck/public/components/table/checks_table.tsx index 8d5218e7c43b..0fcef5db26c9 100644 --- a/src/plugins/healthcheck/public/components/table/checks_table.tsx +++ b/src/plugins/healthcheck/public/components/table/checks_table.tsx @@ -17,7 +17,7 @@ import { import { isEqual } from 'lodash'; import { TaskInfo } from '../../../../../core/common/healthcheck'; import { CheckFlyout } from './check_flyout'; -import { getCore } from '../../dashboards_services'; +import { getCore, getHealthCheck } from '../../dashboards_services'; import { formatDate } from '../services/time'; import { tableColumns } from './columns'; import { TASK } from '../../constants'; @@ -136,7 +136,7 @@ export const ChecksTable: FunctionComponent = ({ checks }) => /> = ({ checks }) => {flyoutVisible && check && ( formatDate(core.uiSettings, date)} setIsFlyoutVisible={setFlyoutVisible} /> diff --git a/src/plugins/healthcheck/public/components/table/columns.tsx b/src/plugins/healthcheck/public/components/table/columns.tsx index f02bb953b5a1..46e45f0cba2a 100644 --- a/src/plugins/healthcheck/public/components/table/columns.tsx +++ b/src/plugins/healthcheck/public/components/table/columns.tsx @@ -5,13 +5,15 @@ import React from 'react'; import { EuiBasicTableProps, EuiButtonIcon, EuiText } from '@elastic/eui'; +import { HealthCheckDescription } from 'opensearch-dashboards/public/healthcheck'; import { i18n } from '@osd/i18n'; import { TaskInfo } from '../../../../../core/common/healthcheck'; import { RESULT } from '../../constants'; import { BadgeResults } from '../utils/badge_results'; export const tableColumns = ( - openFlyout: (item?: TaskInfo | null) => void + openFlyout: (item?: TaskInfo | null) => void, + getDescription: (name: string) => HealthCheckDescription | undefined ): EuiBasicTableProps['columns'] => [ { render: (item: TaskInfo) => ( @@ -32,16 +34,32 @@ export const tableColumns = ( defaultMessage: 'Check', }), render: (name: string) => { - const [type, check] = name.split(':'); + const description = getDescription(name); + + if (!description) { + const [type, check] = name.split(':'); + + return ( + + {type}:{check} + + ); + } return ( - - {type}:{check} - +
+ + {description.title} + + + {description.description} + + + {name} + +
); }, - width: '450px', - truncateText: true, }, { field: 'result',