#!/bin/bash
################################################################################
# Script Name: version/3.9.2/UPDATE.sh
# Description: Apply version 3.9.2:
#              - a form submitted the way the panel's own pages submit it
#                (multipart, through fetch) is read at last; 47 pages had been
#                reporting success and saving nothing
#              - a plan's email_limit is an integer, and reading it as a string
#                panicked the engine on /emails/new
#              - amavisd could not start on any native server: the two Perl
#                modules it binds IPv6 with were never installed
#              - a feature set a release adds now reaches a server that
#                already exists
#              Each check below asks whether THIS server carries the fix,
#              because every one of these looked like a working panel from the
#              outside - the worst of them looked like a *successful save*.
# Company/Brand: HiTechCloud by Pho Tue SoftWare Solutions JSC
################################################################################

set -uo pipefail
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m'
ok()   { echo -e "[3.9.2] ${GREEN}✓${NC} $*"; }
warn() { echo -e "[3.9.2] ${YELLOW}!${NC} $*" >&2; }
FAILURES=0
bad()  { echo -e "[3.9.2] ${RED}✗${NC} $*" >&2; FAILURES=$((FAILURES + 1)); }
[[ $EUID -eq 0 ]] || { echo "[3.9.2] This update must run as root." >&2; exit 1; }

VERSION="3.9.2"
BIN=/usr/local/bin
DRY_RUN="${HITECHCLOUD_UPDATE_DRY_RUN:-0}"

if [[ "$DRY_RUN" == 1 ]]; then
    echo "[3.9.2] would: record version ${VERSION} in /usr/local/hitechcloud/version and /etc/hitechcloud/version"
else
    mkdir -p /usr/local/hitechcloud /etc/hitechcloud 2>/dev/null || true
    echo "$VERSION" > /usr/local/hitechcloud/version 2>/dev/null || true
    echo "$VERSION" > /etc/hitechcloud/version 2>/dev/null || true
fi

engine_bin() {
    local c
    for c in "$BIN/hitechcloud-rustpanel" "$BIN/hitechcloudpanel-engine"; do
        [[ -x "$c" ]] && { echo "$c"; return 0; }
    done
    return 1
}

################################################################################
# 1. Is a form the browser sent actually read?
#
# This is the release's reason, and it is the one fault on this list that a
# customer could not have reported as a fault: the page said the save had
# worked.
#
# The panel's pages do not submit forms natively. Forty-seven templates build
# a `new FormData(form)` and fetch() it, which sends multipart/form-data. On
# the Go side 190 handlers read their fields through r.Form.Get after a bare
# r.ParseForm(), and ParseForm reads the body only when it is urlencoded; for
# multipart it parses the URL's query and stops. The Rust twin said the same
# thing in its own comment - "Multipart bodies are not parsed". So every field
# arrived empty, the handler refused the form, and the page's fetch() followed
# the redirect, read the 200 HTML that came back and reported success.
#
# The check replays the SAME request twice into the engine on a throwaway
# socket, once urlencoded and once multipart, and requires the two answers to
# agree. POST /domains/docroot is used because its two refusals are
# distinguishable and it creates nothing: an empty domain_name redirects, and
# a domain this account does not own is 403. So a server without the fix
# answers 302 to the multipart request and 403 to the urlencoded one; a server
# with it answers 403 to both. The domain named does not exist and nothing is
# written either way.
################################################################################
multipart_is_read() {
    local bin sock pid waited url_code mp_code body
    bin=$(engine_bin) || { warn "the panel's engine is not installed - the form check cannot run"; return 0; }
    sock="$(mktemp -u /run/hitechcloud-392-XXXXXX.sock)"
    "$bin" serve --socket "$sock" >/tmp/.hitechcloud-392-form.$$ 2>&1 &
    pid=$!
    waited=0
    while [[ $waited -lt 100 ]]; do
        [[ -S "$sock" ]] && break
        kill -0 "$pid" 2>/dev/null || break
        sleep 0.1
        waited=$((waited + 1))
    done
    if [[ ! -S "$sock" ]]; then
        bad "the engine did not start, so the form check could not run"
        head -5 /tmp/.hitechcloud-392-form.$$ 2>/dev/null | sed 's/^/    /' >&2
        kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null
        rm -f "$sock" /tmp/.hitechcloud-392-form.$$
        return 1
    fi

    # The identity the Go front door would have passed, as plain headers.
    # A name no account can own, so the answer is about the form and not
    # about this server's data.
    post() {
        curl -s -o /dev/null -w '%{http_code}' --max-time 15 --unix-socket "$sock" \
            -H "X-Hitech-User-Id: 2" -H "X-Hitech-Username: formcheck" \
            -H "X-Hitech-Context: formcheck" -H "X-Hitech-Plan-Id: 1" \
            -H "X-Hitech-Via: session" -H "X-Hitech-Client-Ip: 127.0.0.1" \
            -H "X-Hitech-Locale: en" -H "X-Hitech-Features: domains,docroot,account" \
            "$@" "http://localhost/domains/docroot" 2>/dev/null
    }
    url_code=$(post --data-urlencode "domain_name=update-check.invalid" --data-urlencode "new_docroot=x")
    mp_code=$(post -F "domain_name=update-check.invalid" -F "new_docroot=x")

    kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null
    rm -f "$sock" /tmp/.hitechcloud-392-form.$$

    if [[ "$mp_code" == "$url_code" ]]; then
        ok "a form sent the way the panel's own pages send it is read (multipart ${mp_code}, urlencoded ${url_code})"
        return 0
    fi
    bad "the same form answered ${url_code} urlencoded and ${mp_code} as multipart - the two must agree."
    warn "    Every page on this panel that saves through fetch() - Add Domain, the"
    warn "    application installers, the file manager, backups, PHP, Site Manager -"
    warn "    sends multipart. On a server without this fix they report success and"
    warn "    save nothing. Its panel predates 3.9.2."
    return 1
}

if [[ "$DRY_RUN" == 1 ]]; then
    echo "[3.9.2] would: replay one form into the engine twice, urlencoded and multipart, and require the same answer"
else
    multipart_is_read
fi

################################################################################
# 2. Can amavisd start?
#
# It could not, on any native server we have built. amavis binds its
# content-filter port on ::1 as well as 127.0.0.1; Net::Server needs
# IO::Socket::INET6 (and Socket6 under it) to bind IPv6 at all; amavis does
# not depend on either and we never installed them. So amavisd died at
# start-up, never wrote its pid file, and systemd waited 90 seconds and
# reported a timeout - which is what the panel showed the operator, with no
# hint of the cause sitting six lines down amavis's own journal.
#
# Only checked where amavis is installed: a server that does not run a mail
# content filter is not missing anything.
################################################################################
amavis_can_bind() {
    local missing=()
    if ! command -v amavisd >/dev/null 2>&1 && [[ ! -x /usr/sbin/amavisd ]]; then
        warn "amavis is not installed here - nothing to check"
        return 0
    fi
    if command -v rpm >/dev/null 2>&1; then
        rpm -q perl-IO-Socket-INET6 >/dev/null 2>&1 || missing+=("perl-IO-Socket-INET6")
        rpm -q perl-Socket6 >/dev/null 2>&1 || missing+=("perl-Socket6")
    elif command -v dpkg-query >/dev/null 2>&1; then
        dpkg-query -W -f='${Status}' libio-socket-inet6-perl 2>/dev/null | grep -q "ok installed" || missing+=("libio-socket-inet6-perl")
        dpkg-query -W -f='${Status}' libsocket6-perl 2>/dev/null | grep -q "ok installed" || missing+=("libsocket6-perl")
    else
        warn "no package manager recognised - amavis's Perl modules were not checked"
        return 0
    fi

    if [[ ${#missing[@]} -eq 0 ]]; then
        ok "amavis has the Perl modules it binds IPv6 with"
    else
        bad "amavis is installed but ${missing[*]} ${#missing[@]} is/are missing, so amavisd cannot start."
        warn "    It will die with \"Unresolveable host [::1]:10024 - could not load"
        warn "    IO::Socket::INET6\" and systemd will report a start-up timeout."
        warn "    Install them and restart amavisd; a fresh install now gets them."
        return 1
    fi

    # The packages being present does not prove the service recovered - a
    # server that has been failing for a while has a restart counter to clear.
    if command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files 2>/dev/null | grep -q '^amavisd\.service'; then
        local state
        state=$(systemctl is-active amavisd 2>/dev/null || true)
        if [[ "$state" == "active" ]]; then
            ok "amavisd is running"
        else
            warn "amavisd is ${state:-unknown} even with its modules installed - run: systemctl reset-failed amavisd && systemctl restart amavisd"
        fi
    fi
    return 0
}

if [[ "$DRY_RUN" == 1 ]]; then
    echo "[3.9.2] would: check amavis has perl-IO-Socket-INET6 and perl-Socket6, and report whether amavisd is running"
else
    amavis_can_bind
fi

################################################################################
# 3. Did the feature sets this release added arrive?
#
# A feature set only ever reached a server through INSTALL.sh, which copies
# configuration/hitechcloud/features once, on a fresh machine. The runtime
# bundle has carried that directory on every update since and nothing copied
# a new file out of it - so a feature set introduced after a server was built
# could not appear in its administrator's list, and no plan could be sold on
# it. mail_only.txt is the first one to land through the update itself, which
# makes it the thing to check.
################################################################################
FEATURES_DIR=/etc/hitechcloud/hitechcloud/features
if [[ "$DRY_RUN" == 1 ]]; then
    echo "[3.9.2] would: check ${FEATURES_DIR}/mail_only.txt is present"
elif [[ ! -d "$FEATURES_DIR" ]]; then
    warn "${FEATURES_DIR} does not exist - this server has no feature sets at all"
elif [[ -s "$FEATURES_DIR/mail_only.txt" ]]; then
    ok "the mail-only feature set is installed ($(grep -c . "$FEATURES_DIR/mail_only.txt") names), so a mail plan can be sold here"
else
    bad "${FEATURES_DIR}/mail_only.txt did not arrive, so a mail-only plan cannot be sold on this server."
    warn "    Run: hitechcloudcli native-install --feature-sets-only"
    warn "    It is additive - a feature set already on disk is yours and is left alone."
fi

################################################################################
# 4. Did the CLI fixes arrive with it?
#
# Two of them are in the shell/Rust tree the update copies into place, and a
# server whose copy did not arrive has the old behaviour with nothing saying
# so:
#
#   - a name whose DNS records would land in another account's zone file is
#     refused. The old check asked who owns the registrable root the public
#     suffix list picks out, while the write goes to the nearest ancestor that
#     actually has a zone file - so an account holding shop.example.org with a
#     zone of its own had every subdomain of it open to anyone.
#   - root no longer writes to predictable /tmp paths. Eleven of them; the
#     worst were a shell script root then executed, a repository package root
#     then installed, and the panel's own binaries downloaded to
#     /tmp/<name>.$$ and installed into /usr/local/bin.
################################################################################
CLI_TREE=/usr/local/hitechcloudcli
check_cli_file() {
    local path="$1" needle="$2" why="$3"
    if [[ ! -r "$CLI_TREE/$path" ]]; then
        bad "${CLI_TREE}/${path} is missing, so ${why}"
        return 1
    fi
    if ! grep -q "$needle" "$CLI_TREE/$path" 2>/dev/null; then
        bad "${CLI_TREE}/${path} is an older copy, so ${why}"
        return 1
    fi
    ok "${path} carries its fix"
    return 0
}

if [[ "$DRY_RUN" == 1 ]]; then
    echo "[3.9.2] would: check ${CLI_TREE} carries the foreign-zone refusal and the private-path helpers"
elif [[ ! -d "$CLI_TREE" ]]; then
    warn "${CLI_TREE} does not exist - the CLI tree is not installed here"
else
    check_cli_file "domains/add.rs" "zone_claim" \
        "a name under another account's zone is still written into that account's zone file."
    check_cli_file "lib/native.rs" "RUN_PRIVATE" \
        "root still writes to /tmp paths any account can create first."
fi

################################################################################
# Result
################################################################################
echo
if [[ $FAILURES -eq 0 ]]; then
    ok "3.9.2 applied."
    exit 0
fi
bad "3.9.2 applied, but ${FAILURES} check(s) failed - read them above before calling this server updated."
exit 1
