#!/bin/bash
################################################################################
# Script Name: version/3.0.0/UPDATE.sh
# Description: Close what the security review found open, on servers that have it.
# Company: hitechcloud.org
# Copyright (c) hitechcloud.org
################################################################################
#
# No migration. 3.0.0 is the first product release and it upgrades from 2.1.x
# as an ordinary update: no schema change, no new service, no configuration to
# convert.
#
# What this does is apply the three findings of the release's security review
# to a server that already exists, because two of them are states on disk that
# a new binary cannot fix by itself:
#
#   - OpenLiteSpeed's WebAdmin console, which ships listening on every
#     interface. A second administration panel on the public internet, with
#     credentials this panel does not manage.
#   - The distribution's machine-wide varnishd on 0.0.0.0:6081, which the
#     installer's comment says is left stopped and which nothing stopped.
#   - The webmail backend, which bound every interface rather than loopback.
#     That one is in the new code; the service only has to be restarted to
#     pick it up, and that is checked rather than assumed.
#
# Every step is skipped cleanly on a server that does not have the thing.
################################################################################

set -uo pipefail

GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
ok()   { echo -e "[3.0.0] ${GREEN}✓${NC} $*"; }
warn() { echo -e "[3.0.0] ${YELLOW}!${NC} $*" >&2; }

[[ $EUID -eq 0 ]] || { echo "[3.0.0] This update must run as root." >&2; exit 1; }

# unit_exists NAME - asked without a pipe. `systemctl list-unit-files | grep -q`
# reports 141 under `set -o pipefail`, because grep -q exits on its first match
# and systemctl dies of SIGPIPE, so a match reads as a failure.
unit_exists() {
    local found
    found=$(systemctl list-unit-files --no-legend --no-pager "$1" 2>/dev/null)
    [[ -n "$found" ]]
}

# 1. OpenLiteSpeed's administration console, onto loopback.
OLS_ADMIN="/usr/local/lsws/admin/conf/admin_config.conf"
if [[ -f "$OLS_ADMIN" ]]; then
    if grep -qE '^[[:space:]]*address[[:space:]]+127\.0\.0\.1:' "$OLS_ADMIN"; then
        ok "OpenLiteSpeed's WebAdmin console is already on loopback"
    else
        [[ -f "${OLS_ADMIN}.hitechcloud-original" ]] || cp -a "$OLS_ADMIN" "${OLS_ADMIN}.hitechcloud-original"
        sed -i -E 's|^([[:space:]]*address[[:space:]]+).*$|\1127.0.0.1:7080|' "$OLS_ADMIN"
        if unit_exists 'hitechcloud-openlitespeed.service'; then
            systemctl restart hitechcloud-openlitespeed >/dev/null 2>&1 || true
        fi
        ok "OpenLiteSpeed's WebAdmin console now listens on 127.0.0.1:7080 only - it was on every interface"
    fi
fi

# 2. The distribution's machine-wide varnishd.
if unit_exists 'varnish.service'; then
    was_active=$(systemctl is-active varnish.service 2>/dev/null)
    systemctl disable --now varnish.service >/dev/null 2>&1 || true
    systemctl mask varnish.service >/dev/null 2>&1 || true
    if [[ "$was_active" == active ]]; then
        ok "The machine-wide varnishd was running on 0.0.0.0:6081 and is now stopped and masked - per-account caches are unaffected"
    else
        ok "The machine-wide varnishd is masked"
    fi
fi

# 3. The webmail backend, onto loopback. The new code does it; this only has
#    to restart the service and then check, because a restart that did not
#    take is exactly the kind of thing that goes unnoticed.
if unit_exists 'hitechcloud-webmail.service'; then
    systemctl restart hitechcloud-webmail >/dev/null 2>&1 || true
    sleep 3
    if [[ "$(systemctl is-active hitechcloud-webmail 2>/dev/null)" != active ]]; then
        warn "The webmail did not come back after a restart. Check: journalctl -u hitechcloud-webmail"
    else
        public=$(ss -lntp 2>/dev/null | awk '$4 ~ /:8892$/ && $4 !~ /^127\.0\.0\.1:|^\[::1\]:/ {print $4}')
        if [[ -n "$public" ]]; then
            warn "The webmail backend is still listening on ${public}. If HOST is set in its environment, that is why."
        else
            ok "The webmail backend listens on loopback only"
        fi
    fi
fi

# 4. The search engines, onto loopback.
#
# The installer appended `network.host: 127.0.0.1` and said in a comment that
# the node was on loopback. Elasticsearch 8's package auto-configuration
# writes `http.host: 0.0.0.0` earlier in the same file, and http.host wins for
# the HTTP layer - so the REST API bound every interface regardless, and a
# search engine open to the internet is a database open to the internet
# holding customers' indexed site content. Found answering from off the
# machine on 9201.
#
# Commented rather than deleted, because this parser rejects a duplicate key:
# the setting cannot be appended over one that is already there.
for engine in elasticsearch opensearch; do
    conf="/etc/${engine}/${engine}.yml"
    [[ -f "$conf" ]] || continue
    if grep -qE '^[[:space:]]*http\.host:[[:space:]]*127\.0\.0\.1' "$conf"; then
        ok "${engine} already binds its REST API to loopback"
        continue
    fi
    [[ -f "${conf}.hitechcloud-pre300" ]] || cp -a "$conf" "${conf}.hitechcloud-pre300"
    sed -i 's/^\([[:space:]]*http\.host\)/#\1/' "$conf"
    printf '\n# --- HiTechCloud Panel 3.0.0 ---\n# http.host is what binds the REST API and it overrides network.host.\nhttp.host: 127.0.0.1\n' >> "$conf"
    if unit_exists "${engine}.service" && [[ "$(systemctl is-active "$engine" 2>/dev/null)" == active ]]; then
        systemctl restart "$engine" >/dev/null 2>&1 || true
        sleep 5
        if [[ "$(systemctl is-active "$engine" 2>/dev/null)" == active ]]; then
            ok "${engine} now binds its REST API to loopback - it was on every interface"
        else
            warn "${engine} did not come back after the change. Its previous configuration is at ${conf}.hitechcloud-pre300"
        fi
    else
        ok "${engine} configured for loopback; it is not running"
    fi
done

# And a last look at what this machine offers the internet, so an operator
# sees it once rather than never. Reported, not changed: which ports a server
# should answer on is the operator's decision and a firewall's job, and an
# update that closes a port somebody depends on is its own kind of outage.
public_ports=$(ss -lntp 2>/dev/null |
    awk 'NR>1 && $4 !~ /^127\.|^\[::1\]|^\[::ffff:127\./ {split($4,a,":"); print a[length(a)]}' |
    sort -un | tr '\n' ' ')
[[ -n "$public_ports" ]] && ok "Reachable from outside this machine: ${public_ports}"

if command -v csf >/dev/null 2>&1 && [[ -f /etc/csf/csf.conf ]]; then
    if grep -qE '^TESTING = "1"' /etc/csf/csf.conf 2>/dev/null || ! systemctl is-active --quiet lfd 2>/dev/null; then
        warn "CSF is installed but not enforcing. Nothing above is being filtered. Turn it on from Security > Firewall, or with: csf -e"
    fi
fi

# 5. Which edition this server is, said out loud.
#
# 3.0.0 is the release where the two editions are real, and an update is
# exactly when somebody should be told which one they are on - especially a
# server that was Enterprise only because every install used to write the
# pre-split default key into its own config.
key=$(awk -F= '/^\[LICENSE\]/{s=1;next} /^\[/{s=0} s&&/^[[:space:]]*key[[:space:]]*=/{gsub(/^[[:space:]]+|[[:space:]]+$/,"",$2);print $2;exit}' \
      /etc/hitechcloud/hitechcloud/conf/hitechcloud.config 2>/dev/null)
if [[ -n "$key" ]]; then
    ok "This server holds a licence key: Enterprise. Nothing changes for it."
else
    warn "This server has no licence key, so it is Community from now on: three hosting accounts, fifty domains, and the admin panel on 2087 closed. Everything it hosts keeps working, on the hosting panel at 2083. Add a key with: hitechcloudcli license <key>"
fi

ok "3.0.0 update complete"
exit 0
