#!/bin/sh # Repodar server report (report.sh/1). Source: apps/web/report/report.sh in the Repodar # repository, served verbatim at /report.sh (only the default URL below is set to the serving # instance). Licence: AGPL-3.0-only. # # Finds the sites on this server and the PHP that serves each one, and posts their exact # versions to Repodar in one request: # # REPODAR_TOKEN=rdr_... sh -c "$(curl -fsS -m 10 https://repodar.dev/report.sh)" || true # # What it reads: $FORGE_SITE_PATH and $FORGE_PHP (Forge deploy scripts), the current directory, # the directory names under /home/*/ (and whether a site has a package.json), server_name and # fastcgi_pass lines in /etc/nginx/sites-enabled/* and /etc/nginx/sites-available/, the # token from $REPODAR_TOKEN or ~/.repodar/token, and the version output of `phpX.Y` / `php`, # node, bun, deno, go, python3, ruby, java, dotnet, rustc (those on PATH) and `hostname`. # Nothing else. # What it sends: the hostname, the site names and the version numbers. Only values made of # A-Z a-z 0-9 . _ - are ever sent; anything else is dropped. # It never fails: every path ends in exit 0, so a deploy script or a cron job is never broken. # # Options: --dry-run (print the JSON, send nothing), --site example.com (report this site # only), -h (help). Environment: REPODAR_TOKEN, REPODAR_URL (self-hosted instances), # REPODAR_DEBUG=1 (explain on stderr), REPODAR_ROOT (look for /home and /etc/nginx below this # directory; for tests). set -u LC_ALL=C export LC_ALL REPODAR_DEFAULT_URL="https://repodar.dev" AGENT="report.sh/1" MAX_SITES=50 ROOT="${REPODAR_ROOT:-}" DEBUG="${REPODAR_DEBUG:-0}" DRY=0 SITE_ARG="" say() { if [ "$DEBUG" = "1" ]; then printf 'repodar: %s\n' "$*" >&2; fi return 0 } usage() { cat <<'EOF' Repodar server report (report.sh/1) REPODAR_TOKEN=rdr_... sh -c "$(curl -fsS -m 10 https://repodar.dev/report.sh)" Finds the sites on this server and the PHP that serves each one, then posts the versions to Repodar. Always exits 0. --dry-run print the JSON instead of sending it --site NAME report this one site -h, --help this help REPODAR_TOKEN report token (else read from ~/.repodar/token) REPODAR_URL Repodar instance (self-hosted), e.g. https://repodar.example.com REPODAR_DEBUG=1 explain what happens on stderr EOF } while [ "$#" -gt 0 ]; do case "$1" in --dry-run) DRY=1 ;; --site) if [ "$#" -gt 1 ]; then shift SITE_ARG="$1" fi ;; --site=*) SITE_ARG="${1#--site=}" ;; -h | --help) usage exit 0 ;; *) say "ignoring unknown argument" ;; esac shift done # A value allowed into the payload: non-empty, only A-Z a-z 0-9 . _ - safe() { case "$1" in "" | *[!A-Za-z0-9._-]*) return 1 ;; esac return 0 } # "v22.11.0", "go version go1.23.4 linux/amd64", "Python 3.12.3", "ruby 3.3.0p0 (...)", # 'openjdk version "21.0.2" 2024-01-16', "8.3.6-1ubuntu1" -> the first x.y.z (at most three # numbers) of the first line, skipping Java's "Picked up JAVA_TOOL_OPTIONS" notes; empty when # there is no version. clean_version() { v=$(printf '%s\n' "$1" | sed -e '/^Picked up/d' | head -n 1 | sed -e 's/^[^0-9]*//' -e 's/[^0-9.].*$//' -e 's/\.$//' \ -e 's/^\([0-9]*\.[0-9]*\.[0-9]*\)\..*/\1/') case "$v" in [0-9]*) ;; *) v="" ;; esac if safe "$v"; then printf '%s' "$v"; fi } run_timeout() { if command -v timeout >/dev/null 2>&1; then timeout 5 "$@" else "$@" fi } have() { command -v "$1" >/dev/null 2>&1; } # PHP version from one binary (phpX.Y, php or a path), empty when it cannot run. php_version_of() { case "$1" in "" | *[!A-Za-z0-9._/-]*) return 0 ;; esac have "$1" || return 0 out=$(run_timeout "$1" -r 'echo PHP_VERSION;' 2>/dev/null) || return 0 clean_version "$out" } # --- token --------------------------------------------------------------------------------- TOKEN="${REPODAR_TOKEN:-}" TOKEN_FILE="${HOME:-/nonexistent}/.repodar/token" if [ -z "$TOKEN" ] && [ -r "$TOKEN_FILE" ]; then TOKEN=$(head -n 1 "$TOKEN_FILE" 2>/dev/null | tr -d ' \t\r\n') fi case "$TOKEN" in rdr_*) safe "$TOKEN" || TOKEN="" ;; *) TOKEN="" ;; esac if [ -z "$TOKEN" ] && [ "$DRY" = "0" ]; then printf '%s\n' "repodar: no token (set REPODAR_TOKEN or write it to ~/.repodar/token); nothing sent" >&2 exit 0 fi # --- endpoint ------------------------------------------------------------------------------ BASE="${REPODAR_URL:-$REPODAR_DEFAULT_URL}" BASE="${BASE%/}" case "$BASE" in https://* | http://localhost | http://127.0.0.1) ;; http://localhost:* | http://127.0.0.1:*) port="${BASE##*:}" case "$port" in "" | *[!0-9]*) BASE="" ;; esac ;; *) BASE="" ;; esac case "$BASE" in *[!A-Za-z0-9.:/_-]*) BASE="" ;; esac if [ -z "$BASE" ]; then say "REPODAR_URL must be an https:// origin; nothing sent" exit 0 fi # --- host ---------------------------------------------------------------------------------- HOST="" if have hostname; then HOST=$(hostname -s 2>/dev/null) || HOST="" [ -n "$HOST" ] || HOST=$(hostname 2>/dev/null) || HOST="" fi HOST=$(printf '%s' "$HOST" | head -n 1) safe "$HOST" || HOST="" # --- sites --------------------------------------------------------------------------------- SITES="" COUNT=0 # "sitenginx file" for names found in a file not named after the site (nginx_php reads it). SITE_CONFS="" TAB=$(printf '\t') # add_site NAME [any]: lowercased, deduplicated; "any" also accepts names without a dot. add_site() { s=$(printf '%s' "$1" | tr 'A-Z' 'a-z') if ! safe "$s"; then say "skipped a site name with unsafe characters" return 0 fi case "$s" in .* | default | localhost | _) return 0 ;; esac if [ "${2:-}" != "any" ]; then case "$s" in *.*) ;; *) return 0 ;; esac fi [ "${#s}" -le 253 ] || return 0 case " $SITES " in *" $s "*) return 0 ;; esac if [ "$COUNT" -ge "$MAX_SITES" ]; then say "more than $MAX_SITES sites; the rest are left out" return 0 fi SITES="$SITES $s" COUNT=$((COUNT + 1)) } MODE=all DEPLOY_SITE="" if [ -n "$SITE_ARG" ]; then MODE=one add_site "$SITE_ARG" any elif [ -n "${FORGE_SITE_PATH:-}" ]; then MODE=deploy DEPLOY_SITE=$(basename "$FORGE_SITE_PATH" | tr 'A-Z' 'a-z') add_site "$DEPLOY_SITE" any else case "${PWD:-}" in "$ROOT"/home/*/*) rest="${PWD#"$ROOT"/home/}" rest="${rest#*/}" name="${rest%%/*}" case "$name" in .*) ;; *.*) MODE=one add_site "$name" ;; esac ;; esac fi if [ "$MODE" = "all" ]; then # Forge keeps every site in /home//. for d in "$ROOT"/home/*/*; do [ -d "$d" ] || continue add_site "$(basename "$d")" done # Plus the first server_name of every enabled nginx server block. for f in "$ROOT"/etc/nginx/sites-enabled/*; do [ -f "$f" ] && [ -r "$f" ] || continue names=$(sed -n 's/^[[:space:]]*server_name[[:space:]][[:space:]]*\([^;[:space:]]*\).*/\1/p' "$f" 2>/dev/null) # No globbing while splitting names read from a file. set -f for n in $names; do case "$n" in *'*'* | *'~'*) continue ;; esac add_site "$n" if [ "$(basename "$f")" != "$n" ]; then SITE_CONFS="$SITE_CONFS $(printf '%s' "$n" | tr 'A-Z' 'a-z')$TAB$f" fi done set +f done fi # phpX.Y from the site's nginx config: fastcgi_pass unix:/var/run/php/php8.5-fpm.sock, or # Forge's per-site pools php8.5-fpm-.sock. nginx_php() { other=$(printf '%s\n' "$SITE_CONFS" | while IFS="$TAB" read -r name file; do if [ "$name" = "$1" ]; then printf '%s' "$file" break fi done) for f in "$ROOT/etc/nginx/sites-available/$1" "$ROOT/etc/nginx/sites-enabled/$1" "$other"; do [ -f "$f" ] && [ -r "$f" ] || continue xy=$(sed -n 's/.*fastcgi_pass[[:space:]][[:space:]]*unix:[^;]*php\([0-9][0-9]*\.[0-9][0-9]*\)-fpm[^;]*;.*/\1/p' "$f" 2>/dev/null | head -n 1) if [ -n "$xy" ] && safe "$xy"; then printf 'php%s' "$xy" return 0 fi done return 0 } # --- host-level runtimes ------------------------------------------------------------------- # Every runtime on PATH, by the same commands Repodar's Forge command uses. HOST_PHP=$(php_version_of php) host_rt() { have "$1" || return 0 case "$1" in java) clean_version "$(run_timeout java -version 2>&1)" ;; python3) clean_version "$(run_timeout python3 --version 2>&1)" ;; deno) clean_version "$(run_timeout deno -V 2>/dev/null)" ;; go) clean_version "$(run_timeout go version 2>/dev/null)" ;; ruby) clean_version "$(run_timeout ruby -v 2>/dev/null)" ;; rustc) clean_version "$(run_timeout rustc --version 2>/dev/null)" ;; dotnet) clean_version "$(run_timeout dotnet --version 2>/dev/null)" ;; *) clean_version "$(run_timeout "$1" -v 2>/dev/null)" ;; esac } HOST_NODE=$(host_rt node) HOST_BUN=$(host_rt bun) HOST_DENO=$(host_rt deno) HOST_GO=$(host_rt go) HOST_PYTHON=$(host_rt python3) HOST_RUBY=$(host_rt ruby) HOST_JAVA=$(host_rt java) HOST_DOTNET=$(host_rt dotnet) HOST_RUST=$(host_rt rustc) # The site's directory (Forge: /home//), for a package.json next to it. site_dir() { if [ "$MODE" = "deploy" ] && [ "$1" = "$DEPLOY_SITE" ] && [ -d "${FORGE_SITE_PATH:-}" ]; then printf '%s' "$FORGE_SITE_PATH" return 0 fi for d in "$ROOT"/home/*/"$1"; do if [ -d "$d" ]; then printf '%s' "$d" return 0 fi done return 0 } # --- per-site PHP and the payload ---------------------------------------------------------- SITES_JSON="" VERSIONS="" N=0 OLD_IFS=$IFS IFS=' ' # The list is split with globbing off; the body (site_dir) needs globbing back. set -f for s in $SITES; do set +f IFS=$OLD_IFS [ -n "$s" ] || continue bin="" if [ "$MODE" = "deploy" ] && [ "$s" = "$DEPLOY_SITE" ] && [ -n "${FORGE_PHP:-}" ]; then bin="$FORGE_PHP" fi [ -n "$bin" ] || bin=$(nginx_php "$s") v="" if [ -n "$bin" ]; then v=$(php_version_of "$bin"); fi if [ -z "$v" ] && [ -n "$HOST_PHP" ]; then # No site-specific binary: php in PATH, unless the site's config names another version. want="${bin#php}" case "$want" in "" | */*) v="$HOST_PHP" ;; *) case "$HOST_PHP" in "$want" | "$want".*) v="$HOST_PHP" ;; esac ;; esac fi entry="{\"site\":\"$s\",\"runtimes\":{" if [ -n "$v" ]; then entry="$entry\"php\":\"$v\"" case " $VERSIONS " in *" $v "*) ;; *) VERSIONS="$VERSIONS $v" ;; esac fi dir=$(site_dir "$s") if [ -n "$HOST_NODE" ] && [ -n "$dir" ] && [ -f "$dir/package.json" ]; then if [ -n "$v" ]; then entry="$entry,"; fi entry="$entry\"node\":\"$HOST_NODE\"" fi entry="$entry}}" if [ -n "$SITES_JSON" ]; then SITES_JSON="$SITES_JSON,"; fi SITES_JSON="$SITES_JSON$entry" N=$((N + 1)) IFS=' ' done set +f IFS=$OLD_IFS HOST_RT="" add_rt() { if [ -n "$2" ]; then if [ -n "$HOST_RT" ]; then HOST_RT="$HOST_RT,"; fi HOST_RT="$HOST_RT\"$1\":\"$2\"" fi } add_rt php "$HOST_PHP" add_rt node "$HOST_NODE" add_rt bun "$HOST_BUN" add_rt deno "$HOST_DENO" add_rt go "$HOST_GO" add_rt python "$HOST_PYTHON" add_rt ruby "$HOST_RUBY" add_rt java "$HOST_JAVA" add_rt dotnet "$HOST_DOTNET" add_rt rust "$HOST_RUST" if [ "$N" -eq 0 ]; then if [ "$DRY" = "1" ]; then printf '{"host":"%s","agent":"%s","runtimes":{%s},"sites":[]}\n' "$HOST" "$AGENT" "$HOST_RT" fi printf '%s\n' "repodar: nothing to report" exit 0 fi PAYLOAD=$(printf '{"host":"%s","agent":"%s","runtimes":{%s},"sites":[%s]}' "$HOST" "$AGENT" "$HOST_RT" "$SITES_JSON") if [ "$DRY" = "1" ]; then printf '%s\n' "$PAYLOAD" exit 0 fi if ! have curl; then say "curl not found; nothing sent" exit 0 fi # The token goes to curl on stdin (-H @-), never on its command line. RESP=$(printf 'Authorization: Bearer %s\n' "$TOKEN" | curl -fsS -m 10 -X POST \ -H @- -H "Content-Type: application/json" -H "User-Agent: $AGENT" \ --data-raw "$PAYLOAD" "$BASE/ingest/runtime" 2>/dev/null) STATUS=$? if [ "$STATUS" -ne 0 ]; then say "sending failed (curl exit $STATUS); nothing else to do" exit 0 fi if [ "$DEBUG" = "1" ]; then printf '%s\n' "$RESP" | tr -cd '[:print:]\n' | head -n 20 >&2 printf '\n' >&2 fi WORD=sites [ "$N" -eq 1 ] && WORD=site LIST=$(printf '%s' "${VERSIONS# }" | sed 's/ /, /g') SUMMARY="repodar: reported $N $WORD" [ -n "$LIST" ] && SUMMARY="$SUMMARY (php $LIST)" [ -n "$HOST" ] && SUMMARY="$SUMMARY from $HOST" printf '%s\n' "$SUMMARY" exit 0