Skip to content

[Tool] Monitoring and management console client for Hysteria API #1444

@birdie-github

Description

@birdie-github

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_menu

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions