#!/bin/bash # # Purpose : Send an install or update log to the CoreDesktop team, so they can # work out what went wrong and fix it # # report Send the newest log, after showing what will be sent # report FILE Send FILE # report --view [FILE] Show what would be sent, and send nothing # report --offer FILE [STEP...] # What update and install.sh run when they fail: ask on # the terminal whether to send FILE. STEP names what failed. # # Nothing is sent unless the user says yes. Before sending, the log is cleaned # of terminal codes and personal details are replaced: the home folder, user # name, host name, email addresses, IPv4 addresses and Tailscale names. # # What's sent (the receiver on federated.sh implements the other side): an # HTTPS POST of multipart/form-data to $COREDESKTOP_REPORT_URL, with fields # kind install or update, from the log's name # version CoreDesktop's version, e.g. 2026.11.08 (a82bbfc) # failed what failed, e.g. "71-siblings.sh, 46-neovim.sh" (may be empty) # kernel the running kernel, from uname -r # siblings the installed sibling projects, one "name version" per line # log the cleaned, redacted log, gzipped (log.gz, application/gzip) # A 2xx response's body is the report's ID, on one line: up to 40 letters, # digits and dashes. Any other response's # body is a message for the user. COREDESKTOP="$HOME/.local/share/coredesktop" LOGS="$HOME/.local/state/coredesktop/logs" REPORT_URL="${COREDESKTOP_REPORT_URL:-https://federated.sh/report}" TTY="${COREDESKTOP_TTY:-/dev/tty}" # The receiver refuses bigger reports MAX_BYTES=$(( 10 * 1024 * 1024 )) usage() { cat << EOF Usage: report [FILE] Send a log (the newest, if none is named) report --view [FILE] Show what would be sent, and send nothing EOF } newest_log() { local logs mapfile -t logs < <(ls -t "$LOGS"/*.log 2> /dev/null) [ "${#logs[@]}" -gt 0 ] && echo "${logs[0]}" } # Escape $1 for use in a sed regular expression regex_escape() { printf '%s' "$1" | sed 's/[][\.*^$/]/\\&/g' } # Print log $1 as it would be sent: without script(1)'s header and footer, # terminal codes or progress redraws, and with personal details replaced. # LC_ALL=C so the ranges mean bytes, whatever the locale. IPv4 addresses are # replaced twice because a match takes the character after an address, which # can be the one before the next; dotted versions such as 1.0.4.1.3 are kept. redact() { local home user host home="$(regex_escape "$HOME")" user="$(regex_escape "${USER:-$(id -un)}")" host="$(regex_escape "$(uname -n)")" LC_ALL=C sed -E \ -e '1{/^Script started on /d}' -e '/^Script done on .*COMMAND_EXIT_CODE/d' \ -e 's/\x1b\][^\x07\x1b]*(\x07|\x1b\\)//g' -e 's#\x1b\[[0-9;?]*[ -/]*[@-~]##g' \ -e 's/\x1b[()][0-9A-Za-z]//g' -e 's/\x1b[=>]//g' -e 's/\r+$//' -e 's/.*\r//' \ -e "s#${home}([/[:space:]'\":]|\$)#~\\1#g" \ -e 's/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}//g' \ -e 's/[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.ts\.net//g' \ -e 's/(^|[^0-9.])([0-9]{1,3}\.){3}[0-9]{1,3}([^0-9.]|$)/\1\3/g' \ -e 's/(^|[^0-9.])([0-9]{1,3}\.){3}[0-9]{1,3}([^0-9.]|$)/\1\3/g' \ -e "s/(^|[^A-Za-z0-9_-])${user}([^A-Za-z0-9_-]|\$)/\\1\\2/g" \ -e "s/(^|[^A-Za-z0-9_-])${host}([^A-Za-z0-9_-]|\$)/\\1\\2/g" \ "$1" } coredesktop_version() { git -C "$COREDESKTOP" log -1 --format='%cd (%h)' --date=format:%Y.%m.%d 2> /dev/null || echo unknown } # "name version" for each installed sibling, without its URL, which can name a person sibling_versions() { [ -f "$COREDESKTOP/sibling" ] || return 0 bash "$COREDESKTOP/sibling" list 2> /dev/null | grep -v '^No sibling' \ | awk '{ $NF = ""; sub(/ +$/, ""); print }' } # show_redacted FILE: page through what would be sent show_redacted() { if command -v less > /dev/null && [ -t 1 ]; then redact "$1" | less else redact "$1" fi } # send FILE [STEP...]: send FILE's report and print its ID send() { local file="$1" kind tmp response code body shift kind="$(basename "$file")" kind="${kind%%-*}" [[ "$kind" == install || "$kind" == update ]] || kind=other tmp="$(mktemp -d)" || return 1 redact "$file" | gzip -9 > "$tmp/log.gz" if (( $(stat -c %s "$tmp/log.gz") > MAX_BYTES )); then echo "This log is too big to send ($(( $(stat -c %s "$tmp/log.gz") / 1024 / 1024 )) MB compressed)." rm -rf "$tmp" return 1 fi local failed failed="$(printf '%s, ' "$@")" failed="${failed%, }" echo "Sending..." # Values go in with -F name= "$tmp/kind" coredesktop_version | tr -d '\n' > "$tmp/version" printf '%s' "$failed" > "$tmp/failed" uname -r | tr -d '\n' > "$tmp/kernel" sibling_versions > "$tmp/siblings" response="$(curl -sS --max-time 120 -A CoreDesktop-report \ -F "kind=<$tmp/kind" -F "version=<$tmp/version" -F "failed=<$tmp/failed" \ -F "kernel=<$tmp/kernel" -F "siblings=<$tmp/siblings" \ -F "log=@$tmp/log.gz;type=application/gzip;filename=log.gz" \ -w '\n%{http_code}' "$REPORT_URL" 2>&1)" local status=$? rm -rf "$tmp" code="${response##*$'\n'}" body="${response%$'\n'*}" [[ "$response" == *$'\n'* ]] || body="$response" # Without the newline that usually ends a reply body="${body%"${body##*[![:space:]]}"}" # A 2xx that isn't a report ID is some other page, not the receiver if (( status == 0 )) && [[ "$code" == 2?? && "$body" =~ ^[A-Za-z0-9-]{1,40}$ ]]; then echo "Sent. Thank you! Your report's ID is $body; mention it if you contact us about this." return 0 fi echo "Couldn't send the report: ${body:-no response}" echo "To try again later, run: report $file" return 1 } # Open the terminal once, on file descriptor 3, for ask to read answers from. # Fails if there's no terminal to ask on. open_tty() { { exec 3< "$TTY"; } 2> /dev/null } # ask QUESTION: read an answer from the terminal into $answer ask() { read -rp "$1" answer <&3 } # offer FILE [STEP...]: ask whether to send FILE, show it first if asked offer() { local file="$1" [ -f "$file" ] || return 0 open_tty || return 0 echo echo "Send this log to the CoreDesktop team, so we can look into what went wrong?" echo "Personal details such as your user name, host name and email addresses are" echo "taken out first." while ask "[v] view what would be sent, [y] send, [N] don't send: "; do case "$answer" in [vV]*) show_redacted "$file" ;; [yY]*) send "$@"; return 0 ;; *) break ;; esac done echo "Not sent. To send it later, run: report $file" } case "$1" in -h|--help) usage ;; --offer) shift; offer "$@" ;; --view) file="${2:-$(newest_log)}" [ -f "$file" ] || { echo "No log to show."; exit 1; } show_redacted "$file" ;; -*) usage; exit 1 ;; *) file="${1:-$(newest_log)}" [ -f "$file" ] || { echo "No log found in $LOGS."; exit 1; } echo "Log: $file" if ! open_tty; then # No terminal to ask on: running report is itself the request to send send "$file" exit fi while true; do ask "[v] view what would be sent, [y] send, [N] don't send: " || answer=n case "$answer" in [vV]*) show_redacted "$file" ;; [yY]*) send "$file"; exit ;; *) echo "Not sent."; exit 0 ;; esac done ;; esac