Files
coredesktop/report
T
davidandClaude Opus 5.5 1cd56f7a90 report: accept a report ID that ends with a newline
Plain-text replies usually end with one, and the ID check counted it as
part of the ID, so a report that was sent was said to have failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 11:36:43 -07:00

207 lines
7.4 KiB
Bash
Executable File

#!/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,}/<email>/g' \
-e 's/[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.ts\.net/<tailscale-name>/g' \
-e 's/(^|[^0-9.])([0-9]{1,3}\.){3}[0-9]{1,3}([^0-9.]|$)/\1<ip>\3/g' \
-e 's/(^|[^0-9.])([0-9]{1,3}\.){3}[0-9]{1,3}([^0-9.]|$)/\1<ip>\3/g' \
-e "s/(^|[^A-Za-z0-9_-])${user}([^A-Za-z0-9_-]|\$)/\\1<user>\\2/g" \
-e "s/(^|[^A-Za-z0-9_-])${host}([^A-Za-z0-9_-]|\$)/\\1<host>\\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=<file, which sends them as they are: with
# name=value, curl would treat a value starting with @ or < as a file
printf '%s' "$kind" > "$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