#!/bin/bash
# Starts/stops redis user services
# Parameters start|stop $USER
if (( $# < 2 )); then
    echo "You must specify an action and a user"
    exit 1
fi

ACTION=$1
USER=$2
HOMEDIR=$(/usr/bin/getent passwd "$USER" | /usr/bin/cut -d: -f6)

# Cage-dir selection: resolve the same host classification the writer's
# get_dir_filenames() reaches, but from user space.  This unit runs as the
# tenant (User=%i in user-redis@.service), so it cannot read the root-owned
# httpd_config.xml the writer greps for <namespace>2</namespace>: that grep
# silently fails on permission here and would always fall back to ~/.cagefs,
# breaking LiteSpeed Containers hosts (namespace=2, no CageFS) that must use
# ~/.lsns.  The absence of /etc/cagefs/cagefs.mp is a root-independent,
# user-readable signal that distinguishes the two mutually exclusive host
# types: LiteSpeed Containers (no CageFS) -> ~/.lsns; CloudLinux CageFS -> ~/.cagefs.
#
# This cagefs.mp proxy only agrees with the writer under the environmental
# invariant that CloudLinux CageFS and LiteSpeed Containers are mutually
# exclusive: cagefs.mp absent <=> namespace=2.  On such supported hosts the
# reader and writer always resolve the same cage.  Off that invariant they
# diverge in BOTH directions, and each is an unsupported CloudLinux+Containers
# configuration:
#   - both markers (cagefs.mp present AND namespace=2): writer picks ~/.lsns,
#     this reader picks ~/.cagefs.
#   - neither marker (cagefs.mp absent AND namespace!=2): writer picks
#     ~/.cagefs, this reader falls back to ~/.lsns.
# Note redis_cache.sh's validate_environment() keys off a different pair of
# markers (cagefs.mp and the container_file), so it is not a guarantee that
# this reader and the namespace-based writer stay in sync.
if [[ ! -e "/etc/cagefs/cagefs.mp" ]] ; then
  SOCKDIR1="${HOMEDIR}/.lsns"
  SOCKDIR="${HOMEDIR}/.lsns/tmp"
  REDISDIR="${HOMEDIR}/.lsns/tmp/redis"
  OTHERREDISDIR="${HOMEDIR}/.cagefs/tmp/redis"
else
  SOCKDIR1="${HOMEDIR}/.cagefs"
  SOCKDIR="${HOMEDIR}/.cagefs/tmp"
  REDISDIR="${HOMEDIR}/.cagefs/tmp/redis"
  OTHERREDISDIR="${HOMEDIR}/.lsns/tmp/redis"
fi

# Cage-divergence assertion (availability diagnostic, not a security control):
# this unit selects the cage from cagefs.mp, but the root writer selects it from
# the httpd_config.xml namespace. Those signals only agree under the supported
# invariant (cagefs.mp absent <=> namespace=2); off it, the writer serves this
# user from the OTHER cage and our redis-server publishes its socket where no
# poller/client looks, so cache/flush silently no-ops. We cannot re-read the
# root-owned httpd_config.xml from tenant space, but the writer's root-owned
# (uid 0) redis.size/redis.package land in the cage IT chose -- so a root-owned
# marker in OTHERREDISDIR is a user-readable footprint of a divergent decision.
# Warn loudly (journal) instead of failing silently; do not refuse to start,
# since a genuine start is still preferable to none and this may be transient
# during a config change.
#
# Stale-marker suppression via mtime, NOT mere presence. The writer's markers
# are immutable (FS_IOC_SETFLAGS) and the cleanup paths only ever touch the
# CURRENT cage, so a host that migrated cage types (e.g. Containers -> CageFS)
# keeps an undeletable stale marker in the now-unused cage forever. We must not
# warn about that leftover -- but we MUST still warn about a live divergence.
#
# Presence alone cannot tell the two apart: a live divergent writer keeps
# writing fresh markers into the OTHER cage (its chosen cage) on every
# Accounts::Modify / Accounts::Remove hook and plugin upgrade, because the root
# writer runs from a shell path (validate_redis.sh -> do_syncpkgfiles ->
# write_user_package_file) that is NOT gated by the WHM-layer
# redis_users_supported() dual-marker guard -- that guard only blocks the PHP
# management flows, not the periodic reconcile. So an OTHER-cage marker can be
# either a pre-migration leftover OR a currently-writing divergent writer.
#
# We distinguish them by recency: suppress only when OUR cage holds a root-owned
# marker at least as new as the newest OTHER-cage one. On a genuine migration
# the writer now agrees with us and writes only OUR cage, so our marker is the
# newer (and the other is a frozen leftover); on a live divergence the writer
# keeps refreshing the OTHER cage, so the other marker is strictly newer and we
# warn. mtime is second-resolution; ties suppress (treated as "not newer"),
# which is safe -- a real divergence keeps re-writing and wins on a later start.
#
# Residual gap (cannot be closed from tenant space): this only fires when the
# writer left a root-owned in-cage marker at all. A divergent user sized purely
# from the root-owned, out-of-cage maps (redis_size.conf / redis_pkg_user_size
# .conf, consulted below) can start with NO in-cage marker in either cage, so
# the "neither marker" divergence is invisible here. Detecting it would require
# reading the root-owned httpd_config.xml, which this unit (User=%i) cannot; the
# WHM-side redis_users_supported() dual-marker guard is the intended net for it.
#
# Every probe requires a NON-SYMLINK regular file ([ ! -L ] && [ -f ]) before
# reading owner or mtime: the cage dirs are tenant-owned (0700), so a tenant
# could otherwise plant redis.size as a symlink to any root-owned file to forge
# a uid-0 verdict -- spuriously firing the warning (OTHER cage) or spuriously
# suppressing a real one (OUR cage). This mirrors the symlink-safe check used by
# rm_size_dir_file/rm_package_dir_file in lsns_common.sh.
_newest_root_marker_mtime() {
  # $1 = redis dir. Prints the greatest mtime (epoch secs) among its root-owned
  # (uid 0) regular markers, or nothing if it holds none.
  local _d=$1 _f _mt _best=""
  for _f in redis.size redis.package; do
    if [ ! -L "${_d}/${_f}" ] && [ -f "${_d}/${_f}" ] \
        && [ "$(stat -c '%u' "${_d}/${_f}" 2>/dev/null)" = "0" ]; then
      _mt=$(stat -c '%Y' "${_d}/${_f}" 2>/dev/null)
      if [ -n "${_mt}" ] && { [ -z "${_best}" ] || [ "${_mt}" -gt "${_best}" ]; }; then
        _best="${_mt}"
      fi
    fi
  done
  [ -n "${_best}" ] && echo "${_best}"
}
_our_marker_mtime=$(_newest_root_marker_mtime "${REDISDIR}")
for _m in redis.size redis.package; do
  if [ ! -L "${OTHERREDISDIR}/${_m}" ] && [ -f "${OTHERREDISDIR}/${_m}" ] \
      && [ "$(stat -c '%u' "${OTHERREDISDIR}/${_m}" 2>/dev/null)" = "0" ]; then
    _other_mtime=$(stat -c '%Y' "${OTHERREDISDIR}/${_m}" 2>/dev/null)
    # Suppress only when our cage has an at-least-as-new marker (writer now
    # agrees with us; the other is a frozen migration leftover). Warn when our
    # cage has none, or the other-cage marker is strictly newer (live writer
    # still refreshing the wrong cage).
    if [ -z "${_our_marker_mtime}" ] || { [ -n "${_other_mtime}" ] \
        && [ "${_other_mtime}" -gt "${_our_marker_mtime}" ]; }; then
      echo "WARNING: cage divergence for ${USER}: root-owned ${OTHERREDISDIR}/${_m} exists but this service uses ${REDISDIR}. The root writer and this service disagree on the cage (cagefs.mp vs httpd_config.xml namespace); the redis socket will be published where clients do not look and caching will not work until the CageFS/LiteSpeed Containers configuration is made consistent." >&2
      break
    fi
  fi
done

if [ "$ACTION" == "start" ]; then

    # V12 (CWE-276): ensure per-user redis runtime dirs exist and are 0700.
    # mkdir -p is a no-op when the dir already exists, so this also remediates
    # pre-existing 0777 dirs created before this hardening was applied.
    mkdir -p "$SOCKDIR1" -m 0700
    chown "$USER" "$SOCKDIR1"
    chmod 0700 "$SOCKDIR1"
    mkdir -p "$SOCKDIR" -m 0700
    chown "$USER" "$SOCKDIR"
    chmod 0700 "$SOCKDIR"
    mkdir -p "$REDISDIR" -m 0700
    chown "$USER" "$REDISDIR"
    chmod 0700 "$REDISDIR"

    DIR="$(dirname "$0")"
    PACKAGE_SIZES="${DIR}/../conf/redis_package_size.conf"
    if [ -e "${DIR}/../conf/use-valkey" ]; then
        REDIS="valkey"
    else
        REDIS="redis"
    fi

    # §7b: reject size/package files not owned by root so a tenant cannot forge
    # their memory cap by creating a competing redis.size in the tenant-owned dir.
    _redis_size_owner() { stat -c '%u' "$1" 2>/dev/null; }

    # §13b: two-tier authoritative lookup — both files are root-owned,
    # out-of-cage, on a root-controlled ancestor path.
    #
    # 1. Explicit per-user admin override (redis_size.conf / CONF): set by
    #    direct admin action (update_user_size_conf) for users with a custom
    #    size, independent of package membership.
    # 2. Package-derived per-user map (redis_pkg_user_size.conf): populated by
    #    do_syncpkgfiles / do_pkg for every user in a sized package.  Kept
    #    separate so package reconciliation never clobbers explicit overrides.
    #
    # A tenant can hide the in-cage redis.size/redis.package by renaming the
    # tenant-owned REDISDIR, but they cannot rename or forge either of these
    # host-path files whose entire ancestor chain is root-controlled.
    SIZEMB=""
    _SIZE_CONF="${DIR}/../conf/redis_size.conf"
    if [ -r "${_SIZE_CONF}" ]; then
        _auth_size=$(awk -F'"' -v u="${USER}" '$2 == u { print $4; exit }' "${_SIZE_CONF}" 2>/dev/null)
        if [[ "${_auth_size}" =~ ^[1-9][0-9]*$ ]]; then
            SIZEMB="${_auth_size}"
            echo "SIZEMB from authoritative size conf (${USER}): ${SIZEMB}"
        fi
    fi

    if [ -z "${SIZEMB}" ]; then
        _PKG_SIZE_CONF="${DIR}/../conf/redis_pkg_user_size.conf"
        if [ -r "${_PKG_SIZE_CONF}" ]; then
            _pkg_auth_size=$(awk -F'"' -v u="${USER}" '$2 == u { print $4; exit }' "${_PKG_SIZE_CONF}" 2>/dev/null)
            if [[ "${_pkg_auth_size}" =~ ^[1-9][0-9]*$ ]]; then
                SIZEMB="${_pkg_auth_size}"
                echo "SIZEMB from package-derived size conf (${USER}): ${SIZEMB}"
            fi
        fi
    fi

    if [ -z "${SIZEMB}" ] && [ -e "${REDISDIR}/redis.size" ]; then
        _owner=$(_redis_size_owner "${REDISDIR}/redis.size")
        if [ "${_owner}" = "0" ]; then
            SIZEMB=$(cat "${REDISDIR}/redis.size" 2>/dev/null)
            echo "SIZEMB from size file: ${SIZEMB}"
        else
            echo "redis.size not root-owned (uid=${_owner}); ignoring"
        fi
    fi

    if [ -z "${SIZEMB}" ] && [ -e "${REDISDIR}/redis.package" ]; then
        _owner=$(_redis_size_owner "${REDISDIR}/redis.package")
        if [ "${_owner}" = "0" ]; then
            PACKAGE=$(cat "${REDISDIR}/redis.package" 2>/dev/null)
            PKGLINE=$(grep "${PACKAGE}", < "${PACKAGE_SIZES}" 2>/dev/null)
            SIZEMB=$(echo "${PKGLINE}" | cut -d',' -f2 | tr -d '\r')
            echo "SIZEMB from package ${PACKAGE}: ${SIZEMB}"
        else
            echo "redis.package not root-owned (uid=${_owner}); ignoring"
        fi
    fi

    if [ -z "${SIZEMB}" ] && SIZEMB=$("cat" "${DIR}/../conf/redis_default_size.conf" 2>/dev/null); then
        echo "SIZEMB from default file: ${SIZEMB}"
    fi

    if [ -z "${SIZEMB}" ]; then
        SIZEMB="64"
        echo "SIZEMB final default: ${SIZEMB}"
    fi

    # §7 (defense-in-depth): clamp to a strictly-numeric, sane value before
    # passing to --maxmemory.  A non-numeric or empty value would produce a
    # malformed "mb" argument; a zero/negative would disable the limit.
    # Accept only positive integers; fall back to the built-in default on any
    # non-conforming value.
    if ! [[ "${SIZEMB}" =~ ^[1-9][0-9]*$ ]]; then
        echo "SIZEMB '${SIZEMB}' is not a valid positive integer; using default 64"
        SIZEMB="64"
    fi

    rm -f "${REDISDIR}"/*.rdb

    "/usr/bin/${REDIS}-server" --port 0 --unixsocketperm 700 --unixsocket "${SOCKDIR}"/redis.sock --dir "${REDISDIR}" --maxmemory "${SIZEMB}"mb --supervised systemd
elif [ "$ACTION" == "stop" ]; then
    echo "Stopping redis for ${USER}"
else
    echo "Undefined action: ${ACTION}"
    exit 1
fi
