-
-
Couldn't load subscription status.
- Fork 1.9k
Open
Labels
enhancementNew feature or requestNew feature or request
Description
This is not a bug.
Totally untested, use at your own risk. It works here on Ubuntu 24.04.3 LTS. May not work on other distros (dialog changes a lot from version to version).
#!/usr/bin/env bash
#
# hysteria2-tui.sh — TUI client for Hysteria v2 Traffic Stats API using dialog
#
# Requirements: bash, dialog, curl
# Optional: jq (nicer tables & totals)
#
# Notes:
# - Thousands separators via printf "%'d" need LC_NUMERIC with grouping
# (e.g., export LC_NUMERIC=en_US.UTF-8).
# - Right alignment uses GNU column's -R; on macOS/BSD (no -R), numbers won't align.
#
# Idea: Artem S. Tashkinov <aros AT gmx dot com>
# Implementation: ChatGPT5
# Created: 2025-09-16T19:02:08Z
# License: Public Domain / CC0 (free to use, modify, redistribute)
set -euo pipefail
# ------------ utilities ------------
TMP_DIR="$(mktemp -d)"
cleanup() { rm -rf "$TMP_DIR"; }
trap cleanup EXIT
has_jq() { command -v jq >/dev/null 2>&1; }
has_gnu_column_right() { column --help 2>&1 | grep -q -- '-R'; }
DIALOG=${DIALOG:-dialog}
CURL=${CURL:-curl}
msg() {
local title="$1"; shift
local text="$*"
"$DIALOG" --backtitle "Hysteria v2 Traffic Stats TUI" --title "$title" --msgbox "$text" 12 80
}
# Show a textbox for ~3s without relying on dialog's --timeout.
# Returns 0 to continue refreshing, 1 to leave the view (user closed the box).
show_textbox_for_3s_or_until_user_exits() {
local title="$1" file="$2" height="$3" width="$4"
# Start dialog without --timeout in the background
set +e
"$DIALOG" --backtitle "Hysteria v2 Traffic Stats TUI" \
--title "$title" --textbox "$file" "$height" "$width" &
local dlg_pid=$!
set -e
# Poll for up to ~3s to see if user closed it early
local i
for i in {1..30}; do # 30 * 0.1s = 3s
if ! kill -0 "$dlg_pid" 2>/dev/null; then
# Dialog already exited -> user pressed a key; treat as "leave view"
set +e; wait "$dlg_pid"; set -e
return 1
fi
sleep 0.1
done
# Still running after 3s -> close it ourselves and continue refreshing
set +e
kill "$dlg_pid" 2>/dev/null
wait "$dlg_pid" 2>/dev/null
set -e
return 0
}
# ------------ configuration state ------------
SCHEME="http"
HOSTPORT=""
SECRET=""
set_connection() {
# host:port
local hcfile="$TMP_DIR/hostport.txt"
"$DIALOG" --backtitle "Hysteria v2 Traffic Stats TUI" \
--title "Connect to Hysteria Traffic Stats API" \
--inputbox "Enter host:port (default: localhost:8888):" 10 70 2> "$hcfile" || return 1
HOSTPORT="$(<"$hcfile")"
if [[ -z "${HOSTPORT// }" ]]; then
HOSTPORT="localhost:8888"
fi
# scheme
local choicefile="$TMP_DIR/scheme.choice"
"$DIALOG" --backtitle "Hysteria v2 Traffic Stats TUI" \
--title "Scheme" --menu "Choose HTTP scheme:" 12 50 2 \
"http" "Plain HTTP (default)" \
"https" "HTTPS (use behind reverse proxy)" \
2> "$choicefile" || return 1
SCHEME="$(<"$choicefile")"
# secret
local pwfile="$TMP_DIR/secret.txt"
"$DIALOG" --backtitle "Hysteria v2 Traffic Stats TUI" \
--title "API Secret" \
--passwordbox "Enter API secret (Authorization header). Leave blank if server has no secret:" 10 70 2> "$pwfile" || return 1
SECRET="$(<"$pwfile")"
}
build_base_url() { printf "%s://%s" "$SCHEME" "$HOSTPORT"; }
# ------------ API calls (array-safe args) ------------
api_get() {
local path="$1"
local accept_header="${2:-application/json}"
local url; url="$(build_base_url)${path}"
local -a args
args=(-sS --fail -H "Accept: ${accept_header}")
[[ -n "${SECRET}" ]] && args+=(-H "Authorization: ${SECRET}")
"$CURL" "${args[@]}" "$url"
}
api_post_json() {
local path="$1" json_body="$2"
local url; url="$(build_base_url)${path}"
local -a args
args=(-sS --fail -H "Content-Type: application/json")
[[ -n "${SECRET}" ]] && args+=(-H "Authorization: ${SECRET}")
printf "%s" "$json_body" | "$CURL" "${args[@]}" -d @- "$url"
}
# ------------ snapshot table builders ------------
make_traffic_table() {
# Read the whole JSON from stdin once, reuse it for rows and totals.
local json_file="$TMP_DIR/traffic_json.$$"
cat > "$json_file"
if has_jq; then
local tmp="$TMP_DIR/traffic_rows.$$"
{
echo -e "USER\tTX(bytes)\tRX(bytes)"
jq -r 'to_entries[] | [.key, .value.tx, .value.rx] | @tsv' < "$json_file" \
| while IFS=$'\t' read -r user tx rx; do
printf "%s\t%'d\t%'d\n" "$user" "$tx" "$rx"
done
local total_tx total_rx
total_tx=$(jq 'to_entries | [.[].value.tx] | add // 0' < "$json_file")
total_rx=$(jq 'to_entries | [.[].value.rx] | add // 0' < "$json_file")
printf "TOTAL\t%'d\t%'d\n" "$total_tx" "$total_rx"
} > "$tmp"
if has_gnu_column_right; then
column -t -s$'\t' -R 2,3 < "$tmp"
else
column -t -s$'\t' < "$tmp"
printf "\n(Note: Right alignment not supported by this 'column'.)\n"
fi
rm -f "$tmp"
else
cat "$json_file"
printf "\n(Install 'jq' for table & totals.)\n"
fi
rm -f "$json_file"
}
make_online_table() {
local json_file="$TMP_DIR/online_json.$$"
cat > "$json_file"
if has_jq; then
local tmp="$TMP_DIR/online_rows.$$"
{
echo -e "USER\tCONNECTIONS"
jq -r 'to_entries[] | [.key, .value] | @tsv' < "$json_file"
} > "$tmp"
if has_gnu_column_right; then
column -t -s$'\t' -R 2 < "$tmp"
else
column -t -s$'\t' < "$tmp"
printf "\n(Note: Right alignment not supported by this 'column'.)\n"
fi
rm -f "$tmp"
else
cat "$json_file"
printf "\n(Install 'jq' for a nicer table.)\n"
fi
rm -f "$json_file"
}
# ------------ views/actions ------------
show_traffic() {
local clear_choice_file="$TMP_DIR/clear.choice"
"$DIALOG" --backtitle "Hysteria v2 Traffic Stats TUI" \
--title "Traffic Options" --menu "After fetching /traffic, do you want to clear counters?" 12 70 2 \
"no" "Just fetch totals" \
"yes" "Fetch and clear (uses /traffic?clear=1)" \
2> "$clear_choice_file" || return 0
local choice; choice="$(<"$clear_choice_file")"
local path="/traffic"
[[ "$choice" == "yes" ]] && path="/traffic?clear=1"
while true; do
local out_file="$TMP_DIR/traffic.out"
if output="$(api_get "$path" "application/json")"; then
# buffer JSON once (prevents totals==0)
printf "%s\n" "$output" | make_traffic_table > "$out_file"
# Show ~3s, but *we* control the timeout:
if ! show_textbox_for_3s_or_until_user_exits "Traffic Totals ($path)" "$out_file" 22 90; then
break
fi
else
msg "Error" "Failed to fetch ${path}.\nCheck server, port, or secret."
break
fi
done
}
show_online() {
while true; do
local out_file="$TMP_DIR/online.out"
if output="$(api_get "/online" "application/json")"; then
printf "%s\n" "$output" | make_online_table > "$out_file"
if ! show_textbox_for_3s_or_until_user_exits "Online Users (/online)" "$out_file" 22 70; then
break
fi
else
msg "Error" "Failed to fetch /online."
break
fi
done
}
show_streams() {
# One-off snapshot
local fmtfile="$TMP_DIR/fmt.choice"
"$DIALOG" --backtitle "Hysteria v2 Traffic Stats TUI" \
--title "Streams Format" --menu "Select output format for /dump/streams:" 12 70 2 \
"json" "Raw JSON (pretty if jq is installed)" \
"text" "Human-readable table (Accept: text/plain)" \
2> "$fmtfile" || return 0
local fmt; fmt="$(<"$fmtfile")"
local out_file="$TMP_DIR/streams.out"
if [[ "$fmt" == "text" ]]; then
if output="$(api_get "/dump/streams" "text/plain")"; then
printf "%s\n" "$output" > "$out_file"
"$DIALOG" --backtitle "Hysteria v2 Traffic Stats TUI" \
--title "Stream Dump (text)" --textbox "$out_file" 24 110
else
msg "Error" "Failed to fetch /dump/streams (text)."
fi
else
if output="$(api_get "/dump/streams" "application/json")"; then
if has_jq; then
printf "%s\n" "$output" | jq . > "$out_file"
else
printf "%s\n" "$output" > "$out_file"
fi
"$DIALOG" --backtitle "Hysteria v2 Traffic Stats TUI" \
--title "Stream Dump (JSON)" --textbox "$out_file" 24 110
else
msg "Error" "Failed to fetch /dump/streams (json)."
fi
fi
}
kick_users() {
local json
if ! json="$(api_get "/online" "application/json")"; then
msg "Error" "Failed to fetch /online. Cannot build user list."
return
fi
local -a checklist_args=()
if has_jq; then
while IFS=$'\t' read -r user conns; do
checklist_args+=("$user" "connections:$conns" "off")
done <<< "$(printf "%s\n" "$json" | jq -r 'to_entries[] | "\(.key)\t\(.value)"')"
fi
if [[ ${#checklist_args[@]} -eq 0 ]]; then
local input="$TMP_DIR/kick.manual"
"$DIALOG" --backtitle "Hysteria v2 Traffic Stats TUI" \
--title "Kick (manual)" \
--inputbox "Enter comma-separated usernames to kick:" 10 70 2> "$input" || return 0
local list; list="$(<"$input")"
list="${list//[[:space:]]/}"
[[ -z "$list" ]] && return 0
IFS=',' read -r -a _users <<< "$list"
local json_body='['
for i in "${!_users[@]}"; do
((i)) && json_body+=','
json_body+="\"${_users[$i]}\""
done
json_body+=']'
if api_post_json "/kick" "$json_body" >/dev/null 2>&1; then
msg "Kick" "Requested kick for: ${list}"
else
msg "Error" "Kick failed."
fi
return 0
fi
local sel_file="$TMP_DIR/kick.sel"
"$DIALOG" --backtitle "Hysteria v2 Traffic Stats TUI" \
--title "Kick Users" \
--checklist "Select users to kick (they may reconnect unless you block them in auth backend):" 20 80 15 \
"${checklist_args[@]}" 2> "$sel_file" || return 0
local selection; selection="$(<"$sel_file")"
selection="${selection//\"/}"
[[ -z "$selection" ]] && return 0
# shellcheck disable=SC2206
local users=( $selection )
local json_body='['
for i in "${!users[@]}"; do
((i)) && json_body+=','
json_body+="\"${users[$i]}\""
done
json_body+=']'
if api_post_json "/kick" "$json_body" >/dev/null 2>&1; then
msg "Kick" "Kick requested for: ${users[*]}"
else
msg "Error" "Kick failed."
fi
}
settings_menu() {
local secret_label; secret_label=$([[ -n "$SECRET" ]] && echo "(set)" || echo "(empty)")
local choice_file="$TMP_DIR/settings.choice"
"$DIALOG" --backtitle "Hysteria v2 Traffic Stats TUI" --title "Settings" \
--menu "Current: ${SCHEME}://${HOSTPORT}\nSecret: ${secret_label}" \
14 70 6 \
"conn" "Change host:port / scheme / secret" \
"scheme" "Toggle http<->https quickly" \
2> "$choice_file" || return 0
local ch; ch="$(<"$choice_file")"
case "$ch" in
conn) set_connection ;;
scheme) SCHEME=$([[ "$SCHEME" == "http" ]] && echo "https" || echo "http") ;;
esac
}
main_menu() {
while true; do
local choice_file="$TMP_DIR/main.choice"
"$DIALOG" --backtitle "Hysteria v2 Traffic Stats TUI" \
--title "Main Menu — ${SCHEME}://${HOSTPORT}" \
--menu "Select an action:" 18 80 10 \
"traffic" "View total traffic per user (/traffic) — auto-refresh" \
"online" "View online users (/online) — auto-refresh" \
"streams" "View active stream dump (/dump/streams)" \
"kick" "Kick users (/kick)" \
"settings" "Connection & auth settings" \
"quit" "Exit" \
2> "$choice_file" || break
case "$( <"$choice_file" )" in
traffic) show_traffic ;;
online) show_online ;;
streams) show_streams ;;
kick) kick_users ;;
settings) settings_menu ;;
quit) break ;;
esac
done
}
# ------------ bootstrap ------------
if ! command -v dialog >/dev/null 2>&1; then
echo "ERROR: 'dialog' is required. Install it via your package manager."
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
echo "ERROR: 'curl' is required."
exit 1
fi
if ! set_connection; then
echo "Cancelled."
exit 1
fi
main_menuMetadata
Metadata
Assignees
Labels
enhancementNew feature or requestNew feature or request