From 030cd9ec9a305a1d8f7773354ce40cab05c41d7f Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner Date: Tue, 9 Sep 2025 16:03:17 +0200 Subject: [PATCH] Got the terminal working --- package-lock.json | 90 ++++ package.json | 7 + scripts/core/build.func | 801 ++++++++++++++++++++++++++++ scripts/core/core.func | 409 ++++++++++++++ scripts/core/install.func | 206 +++++++ scripts/ct/debian.sh | 43 ++ scripts/demo.sh | 46 -- scripts/install/debian-install.sh | 22 + scripts/test-script.sh | 14 + server.js | 101 ++-- src/app/_components/ScriptsList.tsx | 1 + src/app/_components/Terminal.tsx | 246 ++++++--- src/server/lib/scripts.ts | 7 +- src/styles/globals.css | 16 + 14 files changed, 1860 insertions(+), 149 deletions(-) create mode 100755 scripts/core/build.func create mode 100644 scripts/core/core.func create mode 100755 scripts/core/install.func create mode 100755 scripts/ct/debian.sh delete mode 100644 scripts/demo.sh create mode 100755 scripts/install/debian-install.sh create mode 100755 scripts/test-script.sh diff --git a/package-lock.json b/package-lock.json index d6adb7d..41ca333 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,13 +16,20 @@ "@trpc/react-query": "^11.0.0", "@trpc/server": "^11.0.0", "@types/ws": "^8.18.1", + "@xterm/addon-attach": "^0.11.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/xterm": "^5.5.0", "next": "^15.2.3", + "node-pty": "^1.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", "server-only": "^0.0.1", "simple-git": "^3.28.0", + "strip-ansi": "^7.1.2", "superjson": "^2.2.1", "ws": "^8.18.3", + "xterm": "^5.3.0", "zod": "^3.24.2" }, "devDependencies": { @@ -2115,6 +2122,39 @@ "win32" ] }, + "node_modules/@xterm/addon-attach": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-attach/-/addon-attach-0.11.0.tgz", + "integrity": "sha512-JboCN0QAY6ZLY/SSB/Zl2cQ5zW1Eh4X3fH7BnuR1NB7xGRhzbqU2Npmpiw/3zFlxDaU88vtKzok44JKi2L2V2Q==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz", + "integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2155,6 +2195,18 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -5039,6 +5091,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nan": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", + "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -5167,6 +5225,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/node-pty": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", + "integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "nan": "^2.17.0" + } + }, "node_modules/nypm": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.1.tgz", @@ -6320,6 +6388,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -6877,6 +6960,13 @@ } } }, + "node_modules/xterm": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", + "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==", + "deprecated": "This package is now deprecated. Move to @xterm/xterm instead.", + "license": "MIT" + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/package.json b/package.json index bb368b3..469798a 100644 --- a/package.json +++ b/package.json @@ -29,13 +29,20 @@ "@trpc/react-query": "^11.0.0", "@trpc/server": "^11.0.0", "@types/ws": "^8.18.1", + "@xterm/addon-attach": "^0.11.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/xterm": "^5.5.0", "next": "^15.2.3", + "node-pty": "^1.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", "server-only": "^0.0.1", "simple-git": "^3.28.0", + "strip-ansi": "^7.1.2", "superjson": "^2.2.1", "ws": "^8.18.3", + "xterm": "^5.3.0", "zod": "^3.24.2" }, "devDependencies": { diff --git a/scripts/core/build.func b/scripts/core/build.func new file mode 100755 index 0000000..75ac19f --- /dev/null +++ b/scripts/core/build.func @@ -0,0 +1,801 @@ +# Copyright (c) 2021-2025 michelroegl-brunner +# Author: michelroegl-brunner +# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE + +variables() { + NSAPP=$(echo "${APP,,}" | tr -d ' ') # This function sets the NSAPP variable by converting the value of the APP variable to lowercase and removing any spaces. + var_install="${NSAPP}-install" # sets the var_install variable by appending "-install" to the value of NSAPP. + INTEGER='^[0-9]+([.][0-9]+)?$' # it defines the INTEGER regular expression pattern. + PVEHOST_NAME=$(hostname) # gets the Proxmox Hostname and sets it to Uppercase + METHOD="default" # sets the METHOD variable to "default", used for the API call. + CT_TYPE=${var_unprivileged:-$CT_TYPE} +} + + + +source "$(dirname "${BASH_SOURCE[0]}")/core.func" + +# This function enables error handling in the script by setting options and defining a trap for the ERR signal. +catch_errors() { + set -Eeo pipefail + trap 'error_handler $LINENO "$BASH_COMMAND"' ERR +} + +# This function is called when an error occurs. It receives the exit code, line number, and command that caused the error, and displays an error message. +error_handler() { + + printf "\e[?25h" + local exit_code="$?" + local line_number="$1" + local command="$2" + local error_message="${RD}[ERROR]${CL} in line ${RD}$line_number${CL}: exit code ${RD}$exit_code${CL}: while executing command ${YW}$command${CL}" + + echo -e "\n$error_message\n" +} + +# Check if the shell is using bash +shell_check() { + if [[ "$(basename "$SHELL")" != "bash" ]]; then + clear + msg_error "Your default shell is currently not set to Bash. To use these scripts, please switch to the Bash shell." + echo -e "\nExiting..." + sleep 2 + exit + fi +} + +# Run as root only +root_check() { + if [[ "$(id -u)" -ne 0 || $(ps -o comm= -p $PPID) == "sudo" ]]; then + clear + msg_error "Please run this script as root." + echo -e "\nExiting..." + sleep 2 + exit + fi +} + +# This function checks the version of Proxmox Virtual Environment (PVE) and exits if the version is not supported. +# Supported: Proxmox VE 8.0.x – 8.9.x and 9.0 (NOT 9.1+) +pve_check() { + local PVE_VER + PVE_VER="$(pveversion | awk -F'/' '{print $2}' | awk -F'-' '{print $1}')" + + # Check for Proxmox VE 8.x: allow 8.0–8.9 + if [[ "$PVE_VER" =~ ^8\.([0-9]+) ]]; then + local MINOR="${BASH_REMATCH[1]}" + if ((MINOR < 0 || MINOR > 9)); then + msg_error "This version of Proxmox VE is not supported." + msg_error "Supported: Proxmox VE version 8.0 – 8.9" + exit 1 + fi + return 0 + fi + + # Check for Proxmox VE 9.x: allow ONLY 9.0 + if [[ "$PVE_VER" =~ ^9\.([0-9]+) ]]; then + local MINOR="${BASH_REMATCH[1]}" + if ((MINOR != 0)); then + msg_error "This version of Proxmox VE is not yet supported." + msg_error "Supported: Proxmox VE version 9.0" + exit 1 + fi + return 0 + fi + + # All other unsupported versions + msg_error "This version of Proxmox VE is not supported." + msg_error "Supported versions: Proxmox VE 8.0 – 8.x or 9.0" + exit 1 +} + +# When a node is running tens of containers, it's possible to exceed the kernel's cryptographic key storage allocations. +# These are tuneable, so verify if the currently deployment is approaching the limits, advise the user on how to tune the limits, and exit the script. +# https://cleveruptime.com/docs/files/proc-key-users | https://docs.kernel.org/security/keys/core.html +maxkeys_check() { + # Read kernel parameters + per_user_maxkeys=$(cat /proc/sys/kernel/keys/maxkeys 2>/dev/null || echo 0) + per_user_maxbytes=$(cat /proc/sys/kernel/keys/maxbytes 2>/dev/null || echo 0) + + # Exit if kernel parameters are unavailable + if [[ "$per_user_maxkeys" -eq 0 || "$per_user_maxbytes" -eq 0 ]]; then + echo -e "${CROSS}${RD} Error: Unable to read kernel parameters. Ensure proper permissions.${CL}" + exit 1 + fi + + # Fetch key usage for user ID 100000 (typical for containers) + used_lxc_keys=$(awk '/100000:/ {print $2}' /proc/key-users 2>/dev/null || echo 0) + used_lxc_bytes=$(awk '/100000:/ {split($5, a, "/"); print a[1]}' /proc/key-users 2>/dev/null || echo 0) + + # Calculate thresholds and suggested new limits + threshold_keys=$((per_user_maxkeys - 100)) + threshold_bytes=$((per_user_maxbytes - 1000)) + new_limit_keys=$((per_user_maxkeys * 2)) + new_limit_bytes=$((per_user_maxbytes * 2)) + + # Check if key or byte usage is near limits + failure=0 + if [[ "$used_lxc_keys" -gt "$threshold_keys" ]]; then + echo -e "${CROSS}${RD} Warning: Key usage is near the limit (${used_lxc_keys}/${per_user_maxkeys}).${CL}" + echo -e "${INFO} Suggested action: Set ${GN}kernel.keys.maxkeys=${new_limit_keys}${CL} in ${BOLD}/etc/sysctl.d/98-community-scripts.conf${CL}." + failure=1 + fi + if [[ "$used_lxc_bytes" -gt "$threshold_bytes" ]]; then + echo -e "${CROSS}${RD} Warning: Key byte usage is near the limit (${used_lxc_bytes}/${per_user_maxbytes}).${CL}" + echo -e "${INFO} Suggested action: Set ${GN}kernel.keys.maxbytes=${new_limit_bytes}${CL} in ${BOLD}/etc/sysctl.d/98-community-scripts.conf${CL}." + failure=1 + fi + + # Provide next steps if issues are detected + if [[ "$failure" -eq 1 ]]; then + echo -e "${INFO} To apply changes, run: ${BOLD}service procps force-reload${CL}" + exit 1 + fi + + echo -e "${CM}${GN} All kernel key limits are within safe thresholds.${CL}" +} + +# This function checks the system architecture and exits if it's not "amd64". +arch_check() { + if [ "$(dpkg --print-architecture)" != "amd64" ]; then + echo -e "\n ${INFO}${YWB}This script will not work with PiMox! \n" + echo -e "\n ${YWB}Visit https://github.com/asylumexp/Proxmox for ARM64 support. \n" + echo -e "Exiting..." + sleep 2 + exit + fi +} + +# Function to get the current IP address based on the distribution +get_current_ip() { + if [ -f /etc/os-release ]; then + # Check for Debian/Ubuntu (uses hostname -I) + if grep -qE 'ID=debian|ID=ubuntu' /etc/os-release; then + CURRENT_IP=$(hostname -I | awk '{print $1}') + # Check for Alpine (uses ip command) + elif grep -q 'ID=alpine' /etc/os-release; then + CURRENT_IP=$(ip -4 addr show eth0 | awk '/inet / {print $2}' | cut -d/ -f1 | head -n 1) + else + CURRENT_IP="Unknown" + fi + fi + echo "$CURRENT_IP" +} + +# Function to update the IP address in the MOTD file +update_motd_ip() { + MOTD_FILE="/etc/motd" + + if [ -f "$MOTD_FILE" ]; then + # Remove existing IP Address lines to prevent duplication + sed -i '/IP Address:/d' "$MOTD_FILE" + + IP=$(get_current_ip) + # Add the new IP address + echo -e "${TAB}${NETWORK}${YW} IP Address: ${GN}${IP}${CL}" >>"$MOTD_FILE" + fi +} + +# This function checks if the script is running through SSH and prompts the user to confirm if they want to proceed or exit. +ssh_check() { + if [ -n "${SSH_CLIENT:+x}" ]; then + if whiptail --backtitle "Proxmox VE Helper Scripts" --defaultno --title "SSH DETECTED" --yesno "It's advisable to utilize the Proxmox shell rather than SSH, as there may be potential complications with variable retrieval. Proceed using SSH?" 10 72; then + whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox --title "Proceed using SSH" "You've chosen to proceed using SSH. If any issues arise, please run the script in the Proxmox shell before creating a repository issue." 10 72 + else + clear + echo "Exiting due to SSH usage. Please consider using the Proxmox shell." + exit + fi + fi +} + +base_settings() { + # Default Settings + CT_TYPE=${var_unprivileged:-"1"} + DISK_SIZE=${var_disk:-"4"} + CORE_COUNT=${var_cpu:-"1"} + RAM_SIZE=${var_ram:-"1024"} + VERBOSE=${var_verbose:-"${1:-no}"} + PW=${var_pw:-""} + CT_ID=${var_ctid:-$NEXTID} + HN=${var_hostname:-$NSAPP} + BRG=${var_brg:-"vmbr0"} + NET=${var_net:-"dhcp"} + IPV6_METHOD=${var_ipv6_method:-"none"} + IPV6_STATIC=${var_ipv6_static:-""} + GATE=${var_gateway:-""} + APT_CACHER=${var_apt_cacher:-""} + APT_CACHER_IP=${var_apt_cacher_ip:-""} + MTU=${var_mtu:-""} + SD=${var_storage:-""} + NS=${var_ns:-""} + MAC=${var_mac:-""} + VLAN=${var_vlan:-""} + SSH=${var_ssh:-"no"} + SSH_AUTHORIZED_KEY=${var_ssh_authorized_key:-""} + UDHCPC_FIX=${var_udhcpc_fix:-""} + TAGS="community-script;${var_tags:-}" + ENABLE_FUSE=${var_fuse:-"${1:-no}"} + ENABLE_TUN=${var_tun:-"${1:-no}"} + + # Since these 2 are only defined outside of default_settings function, we add a temporary fallback. TODO: To align everything, we should add these as constant variables (e.g. OSTYPE and OSVERSION), but that would currently require updating the default_settings function for all existing scripts + if [ -z "$var_os" ]; then + var_os="debian" + fi + if [ -z "$var_version" ]; then + var_version="12" + fi +} + +write_config() { + mkdir -p /opt/community-scripts + # This function writes the configuration to a file. + if whiptail --backtitle "Proxmox VE Helper Scripts" --defaultno --title "Write configfile" --yesno "Do you want to write the selections to a config file?" 10 60; then + FILEPATH="/opt/community-scripts/${NSAPP}.conf" + [[ "$GATE" =~ ",gw=" ]] && local GATE="${GATE##,gw=}" + + # Strip prefixes from parameters for config file storage + local SD_VALUE="${SD}" + local NS_VALUE="${NS}" + local MAC_VALUE="${MAC}" + local VLAN_VALUE="${VLAN}" + [[ "$SD" =~ ^-searchdomain= ]] && SD_VALUE="${SD#-searchdomain=}" + [[ "$NS" =~ ^-nameserver= ]] && NS_VALUE="${NS#-nameserver=}" + [[ "$MAC" =~ ^,hwaddr= ]] && MAC_VALUE="${MAC#,hwaddr=}" + [[ "$VLAN" =~ ^,tag= ]] && VLAN_VALUE="${VLAN#,tag=}" + + if [[ ! -f $FILEPATH ]]; then + cat <"$FILEPATH" +# ${NSAPP} Configuration File +# Generated on $(date) + +CT_TYPE="${CT_TYPE}" +DISK_SIZE="${DISK_SIZE}" +CORE_COUNT="${CORE_COUNT}" +RAM_SIZE="${RAM_SIZE}" +VERBOSE="${VERBOSE}" +PW="${PW##-password }" +#CT_ID=$NEXTID +HN="${HN}" +BRG="${BRG}" +NET="${NET}" +IPV6_METHOD="${IPV6_METHOD:-none}" +# Set this only if using "IPV6_METHOD=static" +#IPV6STATIC="fd00::1234/64" + +GATE="${GATE:-none}" +APT_CACHER_IP="${APT_CACHER_IP:-none}" +MTU="${MTU:-1500}" +SD="${SD_VALUE:-none}" +NS="${NS_VALUE:-none}" +MAC="${MAC_VALUE:-none}" +VLAN="${VLAN_VALUE:-none}" +SSH="${SSH}" +SSH_AUTHORIZED_KEY="${SSH_AUTHORIZED_KEY}" +TAGS="${TAGS:-none}" +ENABLE_FUSE="$ENABLE_FUSE" +ENABLE_TUN="$ENABLE_TUN" +EOF + + echo -e "${INFO}${BOLD}${GN}Writing configuration to ${FILEPATH}${CL}" + else + echo -e "${INFO}${BOLD}${RD}Configuration file already exists at ${FILEPATH}${CL}" + if whiptail --backtitle "Proxmox VE Helper Scripts" --defaultno --title "Overwrite configfile" --yesno "Do you want to overwrite the existing config file?" 10 60; then + rm -f "$FILEPATH" + cat <"$FILEPATH" +# ${NSAPP} Configuration File +# Generated on $(date) + +CT_TYPE="${CT_TYPE}" +DISK_SIZE="${DISK_SIZE}" +CORE_COUNT="${CORE_COUNT}" +RAM_SIZE="${RAM_SIZE}" +VERBOSE="${VERBOSE}" +PW="${PW##-password }" +#CT_ID=$NEXTID +HN="${HN}" +BRG="${BRG}" +NET="${NET}" +IPV6_METHOD="${IPV6_METHOD:-none}" + +# Set this only if using "IPV6_METHOD=static" +#IPV6STATIC="fd00::1234/64" + +GATE="${GATE:-none}" +APT_CACHER_IP="${APT_CACHER_IP:-none}" +MTU="${MTU:-1500}" +SD="${SD_VALUE:-none}" +NS="${NS_VALUE:-none}" +MAC="${MAC_VALUE:-none}" +VLAN="${VLAN_VALUE:-none}" +SSH="${SSH}" +SSH_AUTHORIZED_KEY="${SSH_AUTHORIZED_KEY}" +TAGS="${TAGS:-none}" +ENABLE_FUSE="$ENABLE_FUSE" +ENABLE_TUN="$ENABLE_TUN" +EOF + echo -e "${INFO}${BOLD}${GN}Writing configuration to ${FILEPATH}${CL}" + else + echo -e "${INFO}${BOLD}${RD}Configuration file not overwritten${CL}" + fi + fi + fi +} + +# This function displays the default values for various settings. +echo_default() { + # Convert CT_TYPE to description + CT_TYPE_DESC="Unprivileged" + if [ "$CT_TYPE" -eq 0 ]; then + CT_TYPE_DESC="Privileged" + fi + + # Output the selected values with icons + echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}${CT_ID}${CL}" + echo -e "${OS}${BOLD}${DGN}Operating System: ${BGN}$var_os ($var_version)${CL}" + echo -e "${CONTAINERTYPE}${BOLD}${DGN}Container Type: ${BGN}$CT_TYPE_DESC${CL}" + echo -e "${DISKSIZE}${BOLD}${DGN}Disk Size: ${BGN}${DISK_SIZE} GB${CL}" + echo -e "${CPUCORE}${BOLD}${DGN}CPU Cores: ${BGN}${CORE_COUNT}${CL}" + echo -e "${RAMSIZE}${BOLD}${DGN}RAM Size: ${BGN}${RAM_SIZE} MiB${CL}" + if [ "$VERBOSE" == "yes" ]; then + echo -e "${SEARCH}${BOLD}${DGN}Verbose Mode: ${BGN}Enabled${CL}" + fi + echo -e "${CREATING}${BOLD}${BL}Creating a ${APP} LXC using the above default settings${CL}" + echo -e " " +} + +# This function is called when the user decides to exit the script. It clears the screen and displays an exit message. +exit_script() { + clear + echo -e "\n${CROSS}${RD}User exited script${CL}\n" + exit +} + + +install_script() { + pve_check + shell_check + root_check + arch_check + #ssh_check + maxkeys_check + + + + + if systemctl is-active -q ping-instances.service; then + systemctl -q stop ping-instances.service + fi + NEXTID=$(pvesh get /cluster/nextid) + timezone=$(cat /etc/timezone) + #header_info + echo "TEST" + while true; do + + TMP_CHOICE=$(whiptail --backtitle "Proxmox VE Helper Scripts" \ + --title "SETTINGS" \ + --menu "Choose an option:" 20 60 6 \ + "1" "Default Settings" \ + "2" "Default Settings (with verbose)" \ + "3" "Advanced Settings" \ + "4" "Exit" \ + --default-item "1" 3>&1 1>&2 2>&3) || true + + if [ -z "$TMP_CHOICE" ]; then + echo -e "\n${CROSS}${RD}Menu canceled. Exiting script.${CL}\n" + exit 0 + fi + + CHOICE="$TMP_CHOICE" + + case $CHOICE in + 1) + header_info + echo -e "${DEFAULT}${BOLD}${BL}Using Default Settings on node $PVEHOST_NAME${CL}" + VERBOSE="no" + METHOD="default" + base_settings "$VERBOSE" + echo_default + break + ;; + 2) + header_info + echo -e "${DEFAULT}${BOLD}${BL}Using Default Settings on node $PVEHOST_NAME (${VERBOSE_CROPPED}Verbose)${CL}" + VERBOSE="yes" + METHOD="default" + base_settings "$VERBOSE" + echo_default + break + ;; + 3) + header_info + echo -e "${ADVANCED}${BOLD}${RD}Using Advanced Settings on node $PVEHOST_NAME${CL}" + METHOD="advanced" + base_settings + advanced_settings + break + ;; + 4) + echo -e "\n${CROSS}${RD}Script terminated. Have a great day!${CL}\n" + exit 0 + ;; + *) + echo -e "\n${CROSS}${RD}Invalid option, please try again.${CL}\n" + ;; + esac + done +} + +check_container_resources() { + # Check actual RAM & Cores + current_ram=$(free -m | awk 'NR==2{print $2}') + current_cpu=$(nproc) + + # Check whether the current RAM is less than the required RAM or the CPU cores are less than required + if [[ "$current_ram" -lt "$var_ram" ]] || [[ "$current_cpu" -lt "$var_cpu" ]]; then + echo -e "\n${INFO}${HOLD} ${GN}Required: ${var_cpu} CPU, ${var_ram}MB RAM ${CL}| ${RD}Current: ${current_cpu} CPU, ${current_ram}MB RAM${CL}" + echo -e "${YWB}Please ensure that the ${APP} LXC is configured with at least ${var_cpu} vCPU and ${var_ram} MB RAM for the build process.${CL}\n" + echo -ne "${INFO}${HOLD} May cause data loss! ${INFO} Continue update with under-provisioned LXC? [y/N] " + read -r prompt + if [[ ! "${prompt,,}" =~ ^(y|yes)$ ]]; then + echo -e "${CROSS}${HOLD} ${YWB}Exiting based on user input.${CL}" + exit 1 + fi + else + echo -e "" + fi +} + +check_container_storage() { + # Check if the /boot partition is more than 80% full + total_size=$(df /boot --output=size | tail -n 1) + local used_size=$(df /boot --output=used | tail -n 1) + usage=$((100 * used_size / total_size)) + if ((usage > 80)); then + # Prompt the user for confirmation to continue + echo -e "${INFO}${HOLD} ${YWB}Warning: Storage is dangerously low (${usage}%).${CL}" + echo -ne "Continue anyway? [y/N] " + read -r prompt + if [[ ! "${prompt,,}" =~ ^(y|yes)$ ]]; then + echo -e "${CROSS}${HOLD}${YWB}Exiting based on user input.${CL}" + exit 1 + fi + fi +} + +start() { + source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/tools.func) + if command -v pveversion >/dev/null 2>&1; then + install_script + else + CHOICE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --title "${APP} LXC Update/Setting" --menu \ + "Support/Update functions for ${APP} LXC. Choose an option:" \ + 12 60 3 \ + "1" "YES (Silent Mode)" \ + "2" "YES (Verbose Mode)" \ + "3" "NO (Cancel Update)" --nocancel --default-item "1" 3>&1 1>&2 2>&3) + + case "$CHOICE" in + 1) + VERBOSE="no" + set_std_mode + ;; + 2) + VERBOSE="yes" + set_std_mode + ;; + 3) + clear + exit_script + exit + ;; + esac + update_script + fi +} + +# This function collects user settings and integrates all the collected information. +build_container() { + # if [ "$VERBOSE" == "yes" ]; then set -x; fi + + NET_STRING="-net0 name=eth0,bridge=$BRG$MAC,ip=$NET$GATE$VLAN$MTU" + case "$IPV6_METHOD" in + auto) NET_STRING="$NET_STRING,ip6=auto" ;; + dhcp) NET_STRING="$NET_STRING,ip6=dhcp" ;; + static) + NET_STRING="$NET_STRING,ip6=$IPV6_ADDR" + [ -n "$IPV6_GATE" ] && NET_STRING="$NET_STRING,gw6=$IPV6_GATE" + ;; + none) ;; + esac + if [ "$CT_TYPE" == "1" ]; then + FEATURES="keyctl=1,nesting=1" + else + FEATURES="nesting=1" + fi + + if [ "$ENABLE_FUSE" == "yes" ]; then + FEATURES="$FEATURES,fuse=1" + fi + + + TEMP_DIR=$(mktemp -d) + pushd "$TEMP_DIR" >/dev/null + if [ "$var_os" == "alpine" ]; then + export FUNCTIONS_FILE_PATH="$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/alpine-install.func)" + else + export FUNCTIONS_FILE_PATH="$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/install.func)" + fi + + export DIAGNOSTICS="$DIAGNOSTICS" + export RANDOM_UUID="$RANDOM_UUID" + export CACHER="$APT_CACHER" + export CACHER_IP="$APT_CACHER_IP" + export tz="$timezone" + export APPLICATION="$APP" + export app="$NSAPP" + export PASSWORD="$PW" + export VERBOSE="$VERBOSE" + export SSH_ROOT="${SSH}" + export SSH_AUTHORIZED_KEY + export CTID="$CT_ID" + export CTTYPE="$CT_TYPE" + export ENABLE_FUSE="$ENABLE_FUSE" + export ENABLE_TUN="$ENABLE_TUN" + export PCT_OSTYPE="$var_os" + export PCT_OSVERSION="$var_version" + export PCT_DISK_SIZE="$DISK_SIZE" + export PCT_OPTIONS=" + -features $FEATURES + -hostname $HN + -tags $TAGS + $SD + $NS + $NET_STRING + -onboot 1 + -cores $CORE_COUNT + -memory $RAM_SIZE + -unprivileged $CT_TYPE + $PW + " + # This executes create_lxc.sh and creates the container and .conf file + bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/create_lxc.sh)" $? + + LXC_CONFIG="/etc/pve/lxc/${CTID}.conf" + + # USB passthrough for privileged LXC (CT_TYPE=0) + if [ "$CT_TYPE" == "0" ]; then + cat <>"$LXC_CONFIG" +# USB passthrough +lxc.cgroup2.devices.allow: a +lxc.cap.drop: +lxc.cgroup2.devices.allow: c 188:* rwm +lxc.cgroup2.devices.allow: c 189:* rwm +lxc.mount.entry: /dev/serial/by-id dev/serial/by-id none bind,optional,create=dir +lxc.mount.entry: /dev/ttyUSB0 dev/ttyUSB0 none bind,optional,create=file +lxc.mount.entry: /dev/ttyUSB1 dev/ttyUSB1 none bind,optional,create=file +lxc.mount.entry: /dev/ttyACM0 dev/ttyACM0 none bind,optional,create=file +lxc.mount.entry: /dev/ttyACM1 dev/ttyACM1 none bind,optional,create=file +EOF + fi + + # VAAPI passthrough for privileged containers or known apps + VAAPI_APPS=( + "immich" + "Channels" + "Emby" + "ErsatzTV" + "Frigate" + "Jellyfin" + "Plex" + "Scrypted" + "Tdarr" + "Unmanic" + "Ollama" + "FileFlows" + "Open WebUI" + ) + + is_vaapi_app=false + for vaapi_app in "${VAAPI_APPS[@]}"; do + if [[ "$APP" == "$vaapi_app" ]]; then + is_vaapi_app=true + break + fi + done + + if ([ "$CT_TYPE" == "0" ] || [ "$is_vaapi_app" == "true" ]) && + ([[ -e /dev/dri/renderD128 ]] || [[ -e /dev/dri/card0 ]] || [[ -e /dev/fb0 ]]); then + + echo "" + msg_custom "⚙️ " "\e[96m" "Configuring VAAPI passthrough for LXC container" + if [ "$CT_TYPE" != "0" ]; then + msg_custom "⚠️ " "\e[33m" "Container is unprivileged – VAAPI passthrough may not work without additional host configuration (e.g., idmap)." + fi + msg_custom "ℹ️ " "\e[96m" "VAAPI enables GPU hardware acceleration (e.g., for video transcoding in Jellyfin or Plex)." + echo "" + read -rp "➤ Automatically mount all available VAAPI devices? [Y/n]: " VAAPI_ALL + + if [[ "$VAAPI_ALL" =~ ^[Yy]$|^$ ]]; then + if [ "$CT_TYPE" == "0" ]; then + # PRV Container → alles zulässig + [[ -e /dev/dri/renderD128 ]] && { + echo "lxc.cgroup2.devices.allow: c 226:128 rwm" >>"$LXC_CONFIG" + echo "lxc.mount.entry: /dev/dri/renderD128 dev/dri/renderD128 none bind,optional,create=file" >>"$LXC_CONFIG" + } + [[ -e /dev/dri/card0 ]] && { + echo "lxc.cgroup2.devices.allow: c 226:0 rwm" >>"$LXC_CONFIG" + echo "lxc.mount.entry: /dev/dri/card0 dev/dri/card0 none bind,optional,create=file" >>"$LXC_CONFIG" + } + [[ -e /dev/fb0 ]] && { + echo "lxc.cgroup2.devices.allow: c 29:0 rwm" >>"$LXC_CONFIG" + echo "lxc.mount.entry: /dev/fb0 dev/fb0 none bind,optional,create=file" >>"$LXC_CONFIG" + } + [[ -d /dev/dri ]] && { + echo "lxc.mount.entry: /dev/dri dev/dri none bind,optional,create=dir" >>"$LXC_CONFIG" + } + else + # UNPRV Container → nur devX für UI + [[ -e /dev/dri/card0 ]] && echo "dev0: /dev/dri/card0,gid=44" >>"$LXC_CONFIG" + [[ -e /dev/dri/card1 ]] && echo "dev0: /dev/dri/card1,gid=44" >>"$LXC_CONFIG" + [[ -e /dev/dri/renderD128 ]] && echo "dev1: /dev/dri/renderD128,gid=104" >>"$LXC_CONFIG" + fi + fi + + fi + if [ "$CT_TYPE" == "1" ] && [ "$is_vaapi_app" == "true" ]; then + if [[ -e /dev/dri/card0 ]]; then + echo "dev0: /dev/dri/card0,gid=44" >>"$LXC_CONFIG" + elif [[ -e /dev/dri/card1 ]]; then + echo "dev0: /dev/dri/card1,gid=44" >>"$LXC_CONFIG" + fi + if [[ -e /dev/dri/renderD128 ]]; then + echo "dev1: /dev/dri/renderD128,gid=104" >>"$LXC_CONFIG" + fi + fi + + # TUN device passthrough + if [ "$ENABLE_TUN" == "yes" ]; then + cat <>"$LXC_CONFIG" +lxc.cgroup2.devices.allow: c 10:200 rwm +lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file +EOF + fi + + # This starts the container and executes -install.sh + msg_info "Starting LXC Container" + pct start "$CTID" + + # wait for status 'running' + for i in {1..10}; do + if pct status "$CTID" | grep -q "status: running"; then + msg_ok "Started LXC Container" + break + fi + sleep 1 + if [ "$i" -eq 10 ]; then + msg_error "LXC Container did not reach running state" + exit 1 + fi + done + + if [ "$var_os" != "alpine" ]; then + msg_info "Waiting for network in LXC container" + for i in {1..10}; do + # 1. Primary check: ICMP ping (fastest, but may be blocked by ISP/firewall) + if pct exec "$CTID" -- ping -c1 -W1 deb.debian.org >/dev/null 2>&1; then + msg_ok "Network in LXC is reachable (ping)" + break + fi + # Wait and retry if not reachable yet + if [ "$i" -lt 10 ]; then + msg_warn "No network in LXC yet (try $i/10) – waiting..." + sleep 3 + else + # After 10 unsuccessful ping attempts, try HTTP connectivity via wget as fallback + msg_warn "Ping failed 10 times. Trying HTTP connectivity check (wget) as fallback..." + if pct exec "$CTID" -- wget -q --spider http://deb.debian.org; then + msg_ok "Network in LXC is reachable (wget fallback)" + else + msg_error "No network in LXC after all checks." + read -r -p "Set fallback DNS (1.1.1.1/8.8.8.8)? [y/N]: " choice + case "$choice" in + [yY]*) + pct set "$CTID" --nameserver 1.1.1.1 + pct set "$CTID" --nameserver 8.8.8.8 + # Final attempt with wget after DNS change + if pct exec "$CTID" -- wget -q --spider http://deb.debian.org; then + msg_ok "Network reachable after DNS fallback" + else + msg_error "Still no network/DNS in LXC! Aborting customization." + exit_script + fi + ;; + *) + msg_error "Aborted by user – no DNS fallback set." + exit_script + ;; + esac + fi + break + fi + done + fi + + msg_info "Customizing LXC Container" + : "${tz:=Etc/UTC}" + if [ "$var_os" == "alpine" ]; then + sleep 3 + pct exec "$CTID" -- /bin/sh -c 'cat </etc/apk/repositories +http://dl-cdn.alpinelinux.org/alpine/latest-stable/main +http://dl-cdn.alpinelinux.org/alpine/latest-stable/community +EOF' + pct exec "$CTID" -- ash -c "apk add bash newt curl openssh nano mc ncurses jq >/dev/null" + else + sleep 3 + pct exec "$CTID" -- bash -c "sed -i '/$LANG/ s/^# //' /etc/locale.gen" + pct exec "$CTID" -- bash -c "locale_line=\$(grep -v '^#' /etc/locale.gen | grep -E '^[a-zA-Z]' | awk '{print \$1}' | head -n 1) && \ + echo LANG=\$locale_line >/etc/default/locale && \ + locale-gen >/dev/null && \ + export LANG=\$locale_line" + + if [[ -z "${tz:-}" ]]; then + tz=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "Etc/UTC") + fi + if pct exec "$CTID" -- test -e "/usr/share/zoneinfo/$tz"; then + pct exec "$CTID" -- bash -c "tz='$tz'; echo \"\$tz\" >/etc/timezone && ln -sf \"/usr/share/zoneinfo/\$tz\" /etc/localtime" + else + msg_warn "Skipping timezone setup – zone '$tz' not found in container" + fi + + pct exec "$CTID" -- bash -c "apt-get update >/dev/null && apt-get install -y sudo curl mc gnupg2 jq >/dev/null" + fi + msg_ok "Customized LXC Container" + + lxc-attach -n "$CTID" -- bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/install/${var_install}.sh)" +} + +# This function sets the description of the container. +description() { + IP=$(pct exec "$CTID" ip a s dev eth0 | awk '/inet / {print $2}' | cut -d/ -f1) + + # Generate LXC Description + DESCRIPTION=$( + cat < + + Logo + + +

${APP} LXC

+ +

+ + spend Coffee + +

+ + + + GitHub + + + + Discussions + + + + Issues + + +EOF + ) + + # Set Description in LXC + pct set "$CTID" -description "$DESCRIPTION" + + if [[ -f /etc/systemd/system/ping-instances.service ]]; then + systemctl start ping-instances.service + fi + +} diff --git a/scripts/core/core.func b/scripts/core/core.func new file mode 100644 index 0000000..584d509 --- /dev/null +++ b/scripts/core/core.func @@ -0,0 +1,409 @@ +# Copyright (c) 2021-2025 community-scripts ORG +# License: MIT | https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/LICENSE + +# ------------------------------------------------------------------------------ +# Loads core utility groups once (colors, formatting, icons, defaults). +# ------------------------------------------------------------------------------ + +[[ -n "${_CORE_FUNC_LOADED:-}" ]] && return +_CORE_FUNC_LOADED=1 + +load_functions() { + [[ -n "${__FUNCTIONS_LOADED:-}" ]] && return + __FUNCTIONS_LOADED=1 + color + formatting + icons + default_vars + set_std_mode + # add more +} + +# ============================================================================ +# Error & Signal Handling – robust, universal, subshell-safe +# ============================================================================ + +_tool_error_hint() { + local cmd="$1" + local code="$2" + case "$cmd" in + curl) + case "$code" in + 6) echo "Curl: Could not resolve host (DNS problem)" ;; + 7) echo "Curl: Failed to connect to host (connection refused)" ;; + 22) echo "Curl: HTTP error (404/403 etc)" ;; + 28) echo "Curl: Operation timeout" ;; + *) echo "Curl: Unknown error ($code)" ;; + esac + ;; + wget) + echo "Wget failed – URL unreachable or permission denied" + ;; + systemctl) + echo "Systemd unit failure – check service name and permissions" + ;; + jq) + echo "jq parse error – malformed JSON or missing key" + ;; + mariadb | mysql) + echo "MySQL/MariaDB command failed – check credentials or DB" + ;; + unzip) + echo "unzip failed – corrupt file or missing permission" + ;; + tar) + echo "tar failed – invalid format or missing binary" + ;; + node | npm | pnpm | yarn) + echo "Node tool failed – check version compatibility or package.json" + ;; + *) echo "" ;; + esac +} + +catch_errors() { + set -Eeuo pipefail + trap 'error_handler $LINENO "$BASH_COMMAND"' ERR +} + +# ------------------------------------------------------------------------------ +# Sets ANSI color codes used for styled terminal output. +# ------------------------------------------------------------------------------ +color() { + YW=$(echo "\033[33m") + YWB=$'\e[93m' + BL=$(echo "\033[36m") + RD=$(echo "\033[01;31m") + BGN=$(echo "\033[4;92m") + GN=$(echo "\033[1;92m") + DGN=$(echo "\033[32m") + CL=$(echo "\033[m") +} + +# Special for spinner and colorized output via printf +color_spinner() { + CS_YW=$'\033[33m' + CS_YWB=$'\033[93m' + CS_CL=$'\033[m' +} + +# ------------------------------------------------------------------------------ +# Defines formatting helpers like tab, bold, and line reset sequences. +# ------------------------------------------------------------------------------ +formatting() { + BFR="\\r\\033[K" + BOLD=$(echo "\033[1m") + HOLD=" " + TAB=" " + TAB3=" " +} + +# ------------------------------------------------------------------------------ +# Sets symbolic icons used throughout user feedback and prompts. +# ------------------------------------------------------------------------------ +icons() { + CM="${TAB}✔️${TAB}" + CROSS="${TAB}✖️${TAB}" + DNSOK="✔️ " + DNSFAIL="${TAB}✖️${TAB}" + INFO="${TAB}💡${TAB}${CL}" + OS="${TAB}🖥️${TAB}${CL}" + OSVERSION="${TAB}🌟${TAB}${CL}" + CONTAINERTYPE="${TAB}📦${TAB}${CL}" + DISKSIZE="${TAB}💾${TAB}${CL}" + CPUCORE="${TAB}🧠${TAB}${CL}" + RAMSIZE="${TAB}🛠️${TAB}${CL}" + SEARCH="${TAB}🔍${TAB}${CL}" + VERBOSE_CROPPED="🔍${TAB}" + VERIFYPW="${TAB}🔐${TAB}${CL}" + CONTAINERID="${TAB}🆔${TAB}${CL}" + HOSTNAME="${TAB}🏠${TAB}${CL}" + BRIDGE="${TAB}🌉${TAB}${CL}" + NETWORK="${TAB}📡${TAB}${CL}" + GATEWAY="${TAB}🌐${TAB}${CL}" + DISABLEIPV6="${TAB}🚫${TAB}${CL}" + DEFAULT="${TAB}⚙️${TAB}${CL}" + MACADDRESS="${TAB}🔗${TAB}${CL}" + VLANTAG="${TAB}🏷️${TAB}${CL}" + ROOTSSH="${TAB}🔑${TAB}${CL}" + CREATING="${TAB}🚀${TAB}${CL}" + ADVANCED="${TAB}🧩${TAB}${CL}" + FUSE="${TAB}🗂️${TAB}${CL}" + HOURGLASS="${TAB}⏳${TAB}" + +} + +# ------------------------------------------------------------------------------ +# Sets default retry and wait variables used for system actions. +# ------------------------------------------------------------------------------ +default_vars() { + RETRY_NUM=10 + RETRY_EVERY=3 + i=$RETRY_NUM + #[[ "${VAR_OS:-}" == "unknown" ]] +} + +# ------------------------------------------------------------------------------ +# Sets default verbose mode for script and os execution. +# ------------------------------------------------------------------------------ +set_std_mode() { + if [ "${VERBOSE:-no}" = "yes" ]; then + STD="" + else + STD="silent" + fi +} + +# Silent execution function +silent() { + "$@" >/dev/null 2>&1 +} + +# Function to download & save header files +get_header() { + local app_name=$(echo "${APP,,}" | tr -d ' ') + local app_type=${APP_TYPE:-ct} + local header_url="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/${app_type}/headers/${app_name}" + local local_header_path="/usr/local/community-scripts/headers/${app_type}/${app_name}" + + mkdir -p "$(dirname "$local_header_path")" + + if [ ! -s "$local_header_path" ]; then + if ! curl -fsSL "$header_url" -o "$local_header_path"; then + return 1 + fi + fi + + cat "$local_header_path" 2>/dev/null || true +} + +header_info() { + local app_name=$(echo "${APP,,}" | tr -d ' ') + local header_content + + header_content=$(get_header "$app_name") || header_content="" + + clear + local term_width + term_width=$(tput cols 2>/dev/null || echo 120) + + if [ -n "$header_content" ]; then + echo "$header_content" + fi +} + +ensure_tput() { + if ! command -v tput >/dev/null 2>&1; then + if grep -qi 'alpine' /etc/os-release; then + apk add --no-cache ncurses >/dev/null 2>&1 + elif command -v apt-get >/dev/null 2>&1; then + apt-get update -qq >/dev/null + apt-get install -y -qq ncurses-bin >/dev/null 2>&1 + fi + fi +} + +is_alpine() { + local os_id="${var_os:-${PCT_OSTYPE:-}}" + + if [[ -z "$os_id" && -f /etc/os-release ]]; then + os_id="$( + . /etc/os-release 2>/dev/null + echo "${ID:-}" + )" + fi + + [[ "$os_id" == "alpine" ]] +} + +is_verbose_mode() { + local verbose="${VERBOSE:-${var_verbose:-no}}" + local tty_status + if [[ -t 2 ]]; then + tty_status="interactive" + else + tty_status="not-a-tty" + fi + [[ "$verbose" != "no" || ! -t 2 ]] +} + +# ------------------------------------------------------------------------------ +# Handles specific curl error codes and displays descriptive messages. +# ------------------------------------------------------------------------------ +__curl_err_handler() { + local exit_code="$1" + local target="$2" + local curl_msg="$3" + + case $exit_code in + 1) msg_error "Unsupported protocol: $target" ;; + 2) msg_error "Curl init failed: $target" ;; + 3) msg_error "Malformed URL: $target" ;; + 5) msg_error "Proxy resolution failed: $target" ;; + 6) msg_error "Host resolution failed: $target" ;; + 7) msg_error "Connection failed: $target" ;; + 9) msg_error "Access denied: $target" ;; + 18) msg_error "Partial file transfer: $target" ;; + 22) msg_error "HTTP error (e.g. 400/404): $target" ;; + 23) msg_error "Write error on local system: $target" ;; + 26) msg_error "Read error from local file: $target" ;; + 28) msg_error "Timeout: $target" ;; + 35) msg_error "SSL connect error: $target" ;; + 47) msg_error "Too many redirects: $target" ;; + 51) msg_error "SSL cert verify failed: $target" ;; + 52) msg_error "Empty server response: $target" ;; + 55) msg_error "Send error: $target" ;; + 56) msg_error "Receive error: $target" ;; + 60) msg_error "SSL CA not trusted: $target" ;; + 67) msg_error "Login denied by server: $target" ;; + 78) msg_error "Remote file not found (404): $target" ;; + *) msg_error "Curl failed with code $exit_code: $target" ;; + esac + + [[ -n "$curl_msg" ]] && printf "%s\n" "$curl_msg" >&2 + exit 1 +} + +fatal() { + msg_error "$1" + kill -INT $$ +} + +spinner() { + local chars=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏) + local i=0 + while true; do + local index=$((i++ % ${#chars[@]})) + printf "\r\033[2K%s %b" "${CS_YWB}${chars[$index]}${CS_CL}" "${CS_YWB}${SPINNER_MSG:-}${CS_CL}" + sleep 0.1 + done +} + +clear_line() { + tput cr 2>/dev/null || echo -en "\r" + tput el 2>/dev/null || echo -en "\033[K" +} + +stop_spinner() { + local pid="${SPINNER_PID:-}" + [[ -z "$pid" && -f /tmp/.spinner.pid ]] && pid=$(/dev/null; then + sleep 0.05 + kill -9 "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fi + rm -f /tmp/.spinner.pid + fi + + unset SPINNER_PID SPINNER_MSG + stty sane 2>/dev/null || true +} + +msg_info() { + local msg="$1" + [[ -z "$msg" ]] && return + + if ! declare -p MSG_INFO_SHOWN &>/dev/null || ! declare -A MSG_INFO_SHOWN &>/dev/null; then + declare -gA MSG_INFO_SHOWN=() + fi + [[ -n "${MSG_INFO_SHOWN["$msg"]+x}" ]] && return + MSG_INFO_SHOWN["$msg"]=1 + + stop_spinner + SPINNER_MSG="$msg" + + if is_verbose_mode || is_alpine; then + local HOURGLASS="${TAB}⏳${TAB}" + printf "\r\e[2K%s %b" "$HOURGLASS" "${YW}${msg}${CL}" >&2 + return + fi + + color_spinner + spinner & + SPINNER_PID=$! + echo "$SPINNER_PID" >/tmp/.spinner.pid + disown "$SPINNER_PID" 2>/dev/null || true +} + +msg_ok() { + local msg="$1" + [[ -z "$msg" ]] && return + stop_spinner + clear_line + printf "%s %b\n" "$CM" "${GN}${msg}${CL}" >&2 + unset MSG_INFO_SHOWN["$msg"] +} + +msg_error() { + stop_spinner + local msg="$1" + echo -e "${BFR:-} ${CROSS:-✖️} ${RD}${msg}${CL}" +} + +msg_warn() { + stop_spinner + local msg="$1" + echo -e "${BFR:-} ${INFO:-ℹ️} ${YWB}${msg}${CL}" +} + +msg_custom() { + local symbol="${1:-"[*]"}" + local color="${2:-"\e[36m"}" + local msg="${3:-}" + [[ -z "$msg" ]] && return + stop_spinner + echo -e "${BFR:-} ${symbol} ${color}${msg}${CL:-\e[0m}" +} + +run_container_safe() { + local ct="$1" + shift + local cmd="$*" + + lxc-attach -n "$ct" -- bash -euo pipefail -c " + trap 'echo Aborted in container; exit 130' SIGINT SIGTERM + $cmd + " || __handle_general_error "lxc-attach to CT $ct" +} + +check_or_create_swap() { + msg_info "Checking for active swap" + + if swapon --noheadings --show | grep -q 'swap'; then + msg_ok "Swap is active" + return 0 + fi + + msg_error "No active swap detected" + + read -p "Do you want to create a swap file? [y/N]: " create_swap + create_swap="${create_swap,,}" # to lowercase + + if [[ "$create_swap" != "y" && "$create_swap" != "yes" ]]; then + msg_info "Skipping swap file creation" + return 1 + fi + + read -p "Enter swap size in MB (e.g., 2048 for 2GB): " swap_size_mb + if ! [[ "$swap_size_mb" =~ ^[0-9]+$ ]]; then + msg_error "Invalid size input. Aborting." + return 1 + fi + + local swap_file="/swapfile" + + msg_info "Creating ${swap_size_mb}MB swap file at $swap_file" + if dd if=/dev/zero of="$swap_file" bs=1M count="$swap_size_mb" status=progress && + chmod 600 "$swap_file" && + mkswap "$swap_file" && + swapon "$swap_file"; then + msg_ok "Swap file created and activated successfully" + else + msg_error "Failed to create or activate swap" + return 1 + fi +} + +trap 'stop_spinner' EXIT INT TERM \ No newline at end of file diff --git a/scripts/core/install.func b/scripts/core/install.func new file mode 100755 index 0000000..7758b17 --- /dev/null +++ b/scripts/core/install.func @@ -0,0 +1,206 @@ +# Copyright (c) 2021-2025 michelroegl-brunner +# Author: michelroegl-brunner +# License: MIT +# https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE + +if ! command -v curl >/dev/null 2>&1; then + printf "\r\e[2K%b" '\033[93m Setup Source \033[m' >&2 + apt-get update >/dev/null 2>&1 + apt-get install -y curl >/dev/null 2>&1 +fi +source "$(dirname "${BASH_SOURCE[0]}")/core.func" +load_functions +# This function enables IPv6 if it's not disabled and sets verbose mode +verb_ip6() { + set_std_mode # Set STD mode based on VERBOSE + + if [ "$DISABLEIPV6" == "yes" ]; then + echo "net.ipv6.conf.all.disable_ipv6 = 1" >>/etc/sysctl.conf + $STD sysctl -p + fi +} + +# This function sets error handling options and defines the error_handler function to handle errors +catch_errors() { + set -Eeuo pipefail + trap 'error_handler $LINENO "$BASH_COMMAND"' ERR +} + +# This function handles errors +error_handler() { + printf "\e[?25h" + local exit_code="$?" + local line_number="$1" + local command="$2" + local error_message="${RD}[ERROR]${CL} in line ${RD}$line_number${CL}: exit code ${RD}$exit_code${CL}: while executing command ${YW}$command${CL}" + echo -e "\n$error_message" + if [[ "$line_number" -eq 51 ]]; then + echo -e "The silent function has suppressed the error, run the script with verbose mode enabled, which will provide more detailed output.\n" + post_update_to_api "failed" "No error message, script ran in silent mode" + else + post_update_to_api "failed" "${command}" + fi +} + +# This function sets up the Container OS by generating the locale, setting the timezone, and checking the network connection +setting_up_container() { + msg_info "Setting up Container OS" + for ((i = RETRY_NUM; i > 0; i--)); do + if [ "$(hostname -I)" != "" ]; then + break + fi + echo 1>&2 -en "${CROSS}${RD} No Network! " + sleep $RETRY_EVERY + done + if [ "$(hostname -I)" = "" ]; then + echo 1>&2 -e "\n${CROSS}${RD} No Network After $RETRY_NUM Tries${CL}" + echo -e "${NETWORK}Check Network Settings" + exit 1 + fi + rm -rf /usr/lib/python3.*/EXTERNALLY-MANAGED + systemctl disable -q --now systemd-networkd-wait-online.service + msg_ok "Set up Container OS" + #msg_custom "${CM}" "${GN}" "Network Connected: ${BL}$(hostname -I)" + msg_ok "Network Connected: ${BL}$(hostname -I)" +} + +# This function checks the network connection by pinging a known IP address and prompts the user to continue if the internet is not connected +# This function checks the network connection by pinging a known IP address and prompts the user to continue if the internet is not connected +network_check() { + set +e + trap - ERR + ipv4_connected=false + ipv6_connected=false + sleep 1 + + # Check IPv4 connectivity to Google, Cloudflare & Quad9 DNS servers. + if ping -c 1 -W 1 1.1.1.1 &>/dev/null || ping -c 1 -W 1 8.8.8.8 &>/dev/null || ping -c 1 -W 1 9.9.9.9 &>/dev/null; then + msg_ok "IPv4 Internet Connected" + ipv4_connected=true + else + msg_error "IPv4 Internet Not Connected" + fi + + # Check IPv6 connectivity to Google, Cloudflare & Quad9 DNS servers. + if ping6 -c 1 -W 1 2606:4700:4700::1111 &>/dev/null || ping6 -c 1 -W 1 2001:4860:4860::8888 &>/dev/null || ping6 -c 1 -W 1 2620:fe::fe &>/dev/null; then + msg_ok "IPv6 Internet Connected" + ipv6_connected=true + else + msg_error "IPv6 Internet Not Connected" + fi + + # If both IPv4 and IPv6 checks fail, prompt the user + if [[ $ipv4_connected == false && $ipv6_connected == false ]]; then + read -r -p "No Internet detected, would you like to continue anyway? " prompt + if [[ "${prompt,,}" =~ ^(y|yes)$ ]]; then + echo -e "${INFO}${RD}Expect Issues Without Internet${CL}" + else + echo -e "${NETWORK}Check Network Settings" + exit 1 + fi + fi + + # DNS resolution checks for GitHub-related domains (IPv4 and/or IPv6) + GIT_HOSTS=("github.com" "raw.githubusercontent.com" "api.github.com" "git.community-scripts.org") + GIT_STATUS="Git DNS:" + DNS_FAILED=false + + for HOST in "${GIT_HOSTS[@]}"; do + RESOLVEDIP=$(getent hosts "$HOST" | awk '{ print $1 }' | grep -E '(^([0-9]{1,3}\.){3}[0-9]{1,3}$)|(^[a-fA-F0-9:]+$)' | head -n1) + if [[ -z "$RESOLVEDIP" ]]; then + GIT_STATUS+="$HOST:($DNSFAIL)" + DNS_FAILED=true + else + GIT_STATUS+=" $HOST:($DNSOK)" + fi + done + + if [[ "$DNS_FAILED" == true ]]; then + fatal "$GIT_STATUS" + else + msg_ok "$GIT_STATUS" + fi + + set -e + trap 'error_handler $LINENO "$BASH_COMMAND"' ERR +} + +# This function updates the Container OS by running apt-get update and upgrade +update_os() { + msg_info "Updating Container OS" + if [[ "$CACHER" == "yes" ]]; then + echo 'Acquire::http::Proxy-Auto-Detect "/usr/local/bin/apt-proxy-detect.sh";' >/etc/apt/apt.conf.d/00aptproxy + cat </usr/local/bin/apt-proxy-detect.sh +#!/bin/bash +if nc -w1 -z "${CACHER_IP}" 3142; then + echo -n "http://${CACHER_IP}:3142" +else + echo -n "DIRECT" +fi +EOF + chmod +x /usr/local/bin/apt-proxy-detect.sh + fi + $STD apt-get update + $STD apt-get -o Dpkg::Options::="--force-confold" -y dist-upgrade + rm -rf /usr/lib/python3.*/EXTERNALLY-MANAGED + msg_ok "Updated Container OS" + + source "$(dirname "${BASH_SOURCE[0]}")/tools.func") +} + +# This function modifies the message of the day (motd) and SSH settings +motd_ssh() { + # Set terminal to 256-color mode + grep -qxF "export TERM='xterm-256color'" /root/.bashrc || echo "export TERM='xterm-256color'" >>/root/.bashrc + + # Get OS information (Debian / Ubuntu) + if [ -f "/etc/os-release" ]; then + OS_NAME=$(grep ^NAME /etc/os-release | cut -d= -f2 | tr -d '"') + OS_VERSION=$(grep ^VERSION_ID /etc/os-release | cut -d= -f2 | tr -d '"') + elif [ -f "/etc/debian_version" ]; then + OS_NAME="Debian" + OS_VERSION=$(cat /etc/debian_version) + fi + + PROFILE_FILE="/etc/profile.d/00_lxc-details.sh" + echo "echo -e \"\"" >"$PROFILE_FILE" + echo -e "echo -e \"${BOLD}${APPLICATION} LXC Container${CL}"\" >>"$PROFILE_FILE" + echo -e "echo -e \"${TAB}${GATEWAY}${YW} Provided by: ${GN}community-scripts ORG ${YW}| GitHub: ${GN}https://github.com/community-scripts/ProxmoxVE${CL}\"" >>"$PROFILE_FILE" + echo "echo \"\"" >>"$PROFILE_FILE" + echo -e "echo -e \"${TAB}${OS}${YW} OS: ${GN}${OS_NAME} - Version: ${OS_VERSION}${CL}\"" >>"$PROFILE_FILE" + echo -e "echo -e \"${TAB}${HOSTNAME}${YW} Hostname: ${GN}\$(hostname)${CL}\"" >>"$PROFILE_FILE" + echo -e "echo -e \"${TAB}${INFO}${YW} IP Address: ${GN}\$(hostname -I | awk '{print \$1}')${CL}\"" >>"$PROFILE_FILE" + + # Disable default MOTD scripts + chmod -x /etc/update-motd.d/* + + if [[ "${SSH_ROOT}" == "yes" ]]; then + sed -i "s/#PermitRootLogin prohibit-password/PermitRootLogin yes/g" /etc/ssh/sshd_config + systemctl restart sshd + fi +} + +# This function customizes the container by modifying the getty service and enabling auto-login for the root user +customize() { + if [[ "$PASSWORD" == "" ]]; then + msg_info "Customizing Container" + GETTY_OVERRIDE="/etc/systemd/system/container-getty@1.service.d/override.conf" + mkdir -p $(dirname $GETTY_OVERRIDE) + cat <$GETTY_OVERRIDE + [Service] + ExecStart= + ExecStart=-/sbin/agetty --autologin root --noclear --keep-baud tty%I 115200,38400,9600 \$TERM +EOF + systemctl daemon-reload + systemctl restart $(basename $(dirname $GETTY_OVERRIDE) | sed 's/\.d//') + msg_ok "Customized Container" + fi + + + if [[ -n "${SSH_AUTHORIZED_KEY}" ]]; then + mkdir -p /root/.ssh + echo "${SSH_AUTHORIZED_KEY}" >/root/.ssh/authorized_keys + chmod 700 /root/.ssh + chmod 600 /root/.ssh/authorized_keys + fi +} \ No newline at end of file diff --git a/scripts/ct/debian.sh b/scripts/ct/debian.sh new file mode 100755 index 0000000..3da7ac3 --- /dev/null +++ b/scripts/ct/debian.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/../core/build.func" +# Copyright (c) 2021-2025 tteck +# Author: tteck (tteckster) +# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE +# Source: https://www.debian.org/ + +APP="Debian" +var_tags="${var_tags:-os}" +var_cpu="${var_cpu:-1}" +var_ram="${var_ram:-512}" +var_disk="${var_disk:-2}" +var_os="${var_os:-debian}" +var_version="${var_version:-12}" +var_unprivileged="${var_unprivileged:-1}" + +header_info "$APP" +variables +color +catch_errors + +function update_script() { + header_info + check_container_storage + check_container_resources + if [[ ! -d /var ]]; then + msg_error "No ${APP} Installation Found!" + exit + fi + msg_info "Updating $APP LXC" + $STD apt-get update + $STD apt-get -y upgrade + msg_ok "Updated $APP LXC" + exit +} + +start +build_container +description + +msg_ok "Completed Successfully!\n" +echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}" \ No newline at end of file diff --git a/scripts/demo.sh b/scripts/demo.sh deleted file mode 100644 index bfa92b0..0000000 --- a/scripts/demo.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/bash - -# Demo script for PVE Scripts Local Management -# This script demonstrates live output streaming - -echo "🚀 Starting PVE Script Demo..." -echo "================================" -echo "" - -echo "📋 System Information:" -echo " - Hostname: $(hostname)" -echo " - User: $(whoami)" -echo " - Date: $(date)" -echo " - Uptime: $(uptime)" -echo "" - -echo "🔧 Simulating Proxmox operations..." -echo " - Checking Proxmox API connection..." -sleep 2 -echo " ✅ API connection successful" -echo "" - -echo " - Listing VMs..." -sleep 1 -echo " 📦 VM 100: Ubuntu Server 22.04 (running)" -echo " 📦 VM 101: Windows Server 2022 (stopped)" -echo " 📦 VM 102: Debian 12 (running)" -echo "" - -echo " - Checking storage..." -sleep 1 -echo " 💾 Local storage: 500GB (200GB used)" -echo " 💾 NFS storage: 2TB (800GB used)" -echo "" - -echo " - Checking cluster status..." -sleep 1 -echo " 🏗️ Node: pve-01 (online)" -echo " 🏗️ Node: pve-02 (online)" -echo " 🏗️ Node: pve-03 (maintenance)" -echo "" - -echo "🎯 Demo completed successfully!" -echo "================================" -echo "This script ran for demonstration purposes." -echo "In a real scenario, this would perform actual Proxmox operations." diff --git a/scripts/install/debian-install.sh b/scripts/install/debian-install.sh new file mode 100755 index 0000000..389ef7a --- /dev/null +++ b/scripts/install/debian-install.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +# Copyright (c) 2021-2025 tteck +# Author: tteck (tteckster) +# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE +# Source: https://www.debian.org/ + +source /dev/stdin <<<"$FUNCTIONS_FILE_PATH" +color +verb_ip6 +catch_errors +setting_up_container +network_check +update_os + +motd_ssh +customize + +msg_info "Cleaning up" +$STD apt-get -y autoremove +$STD apt-get -y autoclean +msg_ok "Cleaned" \ No newline at end of file diff --git a/scripts/test-script.sh b/scripts/test-script.sh new file mode 100755 index 0000000..235b0bb --- /dev/null +++ b/scripts/test-script.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +echo "Hello from test script!" +echo "Current directory: $(pwd)" +echo "Script arguments: $@" +echo "Environment variables:" +env | grep -E "(PATH|HOME|USER)" | head -5 + +for i in {1..5}; do + echo "Count: $i" + sleep 1 +done + +echo "Test script completed!" diff --git a/server.js b/server.js index 2441155..164b664 100644 --- a/server.js +++ b/server.js @@ -4,12 +4,13 @@ import next from 'next'; import { WebSocketServer } from 'ws'; import { spawn } from 'child_process'; import { join, resolve } from 'path'; +import stripAnsi from 'strip-ansi'; +import { spawn as ptySpawn } from 'node-pty'; const dev = process.env.NODE_ENV !== 'production'; -const hostname = 'localhost'; +const hostname = '0.0.0.0'; const port = process.env.PORT || 3000; -// when using middleware `hostname` and `port` must be provided below const app = next({ dev, hostname, port }); const handle = app.getRequestHandler(); @@ -27,10 +28,19 @@ class ScriptExecutionHandler { setupWebSocket() { this.wss.on('connection', (ws, request) => { console.log('New WebSocket connection for script execution'); + console.log('Client IP:', request.socket.remoteAddress); + console.log('User-Agent:', request.headers['user-agent']); + console.log('WebSocket readyState:', ws.readyState); + console.log('Request URL:', request.url); + + // Set connection metadata + ws.connectionTime = Date.now(); + ws.clientIP = request.socket.remoteAddress; ws.on('message', (data) => { try { const message = JSON.parse(data.toString()); + console.log('Received message from client:', message); this.handleMessage(ws, message); } catch (error) { console.error('Error parsing WebSocket message:', error); @@ -42,8 +52,8 @@ class ScriptExecutionHandler { } }); - ws.on('close', () => { - console.log('WebSocket connection closed'); + ws.on('close', (code, reason) => { + console.log(`WebSocket connection closed: ${code} - ${reason}`); this.cleanupActiveExecutions(ws); }); @@ -55,13 +65,16 @@ class ScriptExecutionHandler { } async handleMessage(ws, message) { - const { action, scriptPath, executionId } = message; + const { action, scriptPath, executionId, input } = message; + console.log('Handling message:', { action, scriptPath, executionId }); switch (action) { case 'start': if (scriptPath && executionId) { + console.log('Starting script execution for:', scriptPath); await this.startScriptExecution(ws, scriptPath, executionId); } else { + console.log('Missing scriptPath or executionId'); this.sendMessage(ws, { type: 'error', data: 'Missing scriptPath or executionId', @@ -76,6 +89,12 @@ class ScriptExecutionHandler { } break; + case 'input': + if (executionId && input !== undefined) { + this.sendInputToProcess(executionId, input); + } + break; + default: this.sendMessage(ws, { type: 'error', @@ -87,11 +106,17 @@ class ScriptExecutionHandler { async startScriptExecution(ws, scriptPath, executionId) { try { + console.log('Starting script execution...'); // Basic validation const scriptsDir = join(process.cwd(), 'scripts'); const resolvedPath = resolve(scriptPath); + console.log('Scripts directory:', scriptsDir); + console.log('Resolved path:', resolvedPath); + console.log('Is within scripts dir:', resolvedPath.startsWith(resolve(scriptsDir))); + if (!resolvedPath.startsWith(resolve(scriptsDir))) { + console.log('Script path validation failed'); this.sendMessage(ws, { type: 'error', data: 'Script path is not within the allowed scripts directory', @@ -110,15 +135,25 @@ class ScriptExecutionHandler { return; } - // Start script execution - const process = spawn('bash', [scriptPath], { + // Start script execution with pty for proper TTY support + const childProcess = ptySpawn('bash', [scriptPath], { cwd: scriptsDir, - stdio: ['pipe', 'pipe', 'pipe'], - shell: true + name: 'xterm-256color', + cols: 80, + rows: 24, + env: { + ...process.env, + TERM: 'xterm-256color', // Enable proper terminal support + FORCE_ANSI: 'true', // Allow ANSI codes for proper display + COLUMNS: '80', // Set terminal width + LINES: '24' // Set terminal height + } }); + + // pty handles encoding automatically // Store the execution - this.activeExecutions.set(executionId, { process, ws }); + this.activeExecutions.set(executionId, { process: childProcess, ws }); // Send start message this.sendMessage(ws, { @@ -127,8 +162,8 @@ class ScriptExecutionHandler { timestamp: Date.now() }); - // Handle stdout - process.stdout?.on('data', (data) => { + // Handle pty data (both stdout and stderr combined) + childProcess.onData((data) => { this.sendMessage(ws, { type: 'output', data: data.toString(), @@ -136,32 +171,11 @@ class ScriptExecutionHandler { }); }); - // Handle stderr - process.stderr?.on('data', (data) => { - this.sendMessage(ws, { - type: 'error', - data: data.toString(), - timestamp: Date.now() - }); - }); - // Handle process exit - process.on('exit', (code, signal) => { + childProcess.onExit((exitCode, signal) => { this.sendMessage(ws, { type: 'end', - data: `Script execution finished with code: ${code}, signal: ${signal}`, - timestamp: Date.now() - }); - - // Clean up - this.activeExecutions.delete(executionId); - }); - - // Handle process error - process.on('error', (error) => { - this.sendMessage(ws, { - type: 'error', - data: `Process error: ${error.message}`, + data: `Script execution finished with code: ${exitCode}, signal: ${signal}`, timestamp: Date.now() }); @@ -192,6 +206,16 @@ class ScriptExecutionHandler { } } + sendInputToProcess(executionId, input) { + const execution = this.activeExecutions.get(executionId); + if (execution && execution.process.write) { + console.log('Sending input to process:', JSON.stringify(input), 'Length:', input.length); + execution.process.write(input); + } else { + console.log('No active execution found for input:', executionId); + } + } + sendMessage(ws, message) { if (ws.readyState === 1) { // WebSocket.OPEN ws.send(JSON.stringify(message)); @@ -208,6 +232,8 @@ class ScriptExecutionHandler { } } +// TerminalHandler removed - not used by current application + app.prepare().then(() => { const httpServer = createServer(async (req, res) => { try { @@ -229,15 +255,16 @@ app.prepare().then(() => { } }); - // Create WebSocket handler + // Create WebSocket handlers const scriptHandler = new ScriptExecutionHandler(httpServer); + // Note: TerminalHandler removed as it's not being used by the current application httpServer .once('error', (err) => { console.error(err); process.exit(1); }) - .listen(port, () => { + .listen(port, hostname, () => { console.log(`> Ready on http://${hostname}:${port}`); console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`); }); diff --git a/src/app/_components/ScriptsList.tsx b/src/app/_components/ScriptsList.tsx index ab1b3b6..816faa3 100644 --- a/src/app/_components/ScriptsList.tsx +++ b/src/app/_components/ScriptsList.tsx @@ -4,6 +4,7 @@ import { api } from '~/trpc/react'; import { useState } from 'react'; import { Terminal } from './Terminal'; + interface ScriptsListProps { onRunScript: (scriptPath: string, scriptName: string) => void; } diff --git a/src/app/_components/Terminal.tsx b/src/app/_components/Terminal.tsx index 0495891..029e77d 100644 --- a/src/app/_components/Terminal.tsx +++ b/src/app/_components/Terminal.tsx @@ -1,7 +1,10 @@ 'use client'; import { useEffect, useRef, useState } from 'react'; -import { api } from '~/trpc/react'; +import { Terminal as XTerm } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { WebLinksAddon } from '@xterm/addon-web-links'; +import '@xterm/xterm/css/xterm.css'; interface TerminalProps { scriptPath: string; @@ -17,70 +20,203 @@ interface TerminalMessage { export function Terminal({ scriptPath, onClose }: TerminalProps) { const [isConnected, setIsConnected] = useState(false); const [isRunning, setIsRunning] = useState(false); - const [output, setOutput] = useState([]); - const [executionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`); + const terminalRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef(null); const wsRef = useRef(null); - const outputRef = useRef(null); + const [executionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`); + const isConnectingRef = useRef(false); + const hasConnectedRef = useRef(false); const scriptName = scriptPath.split('/').pop() || scriptPath.split('\\').pop() || 'Unknown Script'; useEffect(() => { - // Connect to WebSocket - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/ws/script-execution`; - - const ws = new WebSocket(wsUrl); - wsRef.current = ws; + // Initialize xterm.js terminal with proper timing + if (!terminalRef.current || xtermRef.current) return; - ws.onopen = () => { - console.log('WebSocket connected'); - setIsConnected(true); + // Use setTimeout to ensure DOM is fully ready + const initTerminal = () => { + if (!terminalRef.current || xtermRef.current) return; + + const terminal = new XTerm({ + theme: { + background: '#000000', + foreground: '#00ff00', + cursor: '#00ff00', + }, + fontSize: 14, + fontFamily: 'Courier New, monospace', + cursorBlink: true, + cursorStyle: 'block', + scrollback: 1000, + tabStopWidth: 4, + }); + + // Add addons + const fitAddon = new FitAddon(); + const webLinksAddon = new WebLinksAddon(); + terminal.loadAddon(fitAddon); + terminal.loadAddon(webLinksAddon); + + // Open terminal + terminal.open(terminalRef.current); + + // Fit after a small delay to ensure proper sizing + setTimeout(() => { + fitAddon.fit(); + }, 100); + + // Store references + xtermRef.current = terminal; + fitAddonRef.current = fitAddon; + + // Handle terminal input + terminal.onData((data) => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + action: 'input', + executionId, + input: data + })); + } + }); + + // Handle terminal resize + const handleResize = () => { + if (fitAddonRef.current) { + fitAddonRef.current.fit(); + } + }; + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + terminal.dispose(); + }; }; - ws.onmessage = (event) => { - try { - const message: TerminalMessage = JSON.parse(event.data); - handleMessage(message); - } catch (error) { - console.error('Error parsing WebSocket message:', error); - } - }; - - ws.onclose = () => { - console.log('WebSocket disconnected'); - setIsConnected(false); - setIsRunning(false); - }; - - ws.onerror = (error) => { - console.error('WebSocket error:', error); - setIsConnected(false); - }; + // Initialize with a small delay + const timeoutId = setTimeout(initTerminal, 50); return () => { - if (ws.readyState === WebSocket.OPEN) { - ws.close(); + clearTimeout(timeoutId); + if (xtermRef.current) { + xtermRef.current.dispose(); + xtermRef.current = null; + fitAddonRef.current = null; } }; }, []); + useEffect(() => { + // Prevent multiple connections in React Strict Mode + if (hasConnectedRef.current || isConnectingRef.current || (wsRef.current && wsRef.current.readyState === WebSocket.OPEN)) { + console.log('WebSocket already connected, connecting, or has connected, skipping...'); + return; + } + + // Close any existing connection first + if (wsRef.current) { + console.log('Closing existing WebSocket connection'); + wsRef.current.close(); + wsRef.current = null; + } + + isConnectingRef.current = true; + hasConnectedRef.current = true; + + // Small delay to prevent rapid reconnection + const connectWithDelay = () => { + // Connect to WebSocket + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws/script-execution`; + + console.log('Connecting to WebSocket:', wsUrl); + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + console.log('WebSocket connected successfully'); + console.log('Script path:', scriptPath); + console.log('Execution ID:', executionId); + setIsConnected(true); + isConnectingRef.current = false; + + // Send start message immediately after connection + ws.send(JSON.stringify({ + action: 'start', + scriptPath, + executionId + })); + }; + + ws.onmessage = (event) => { + try { + const message: TerminalMessage = JSON.parse(event.data); + console.log('Received message:', message); + handleMessage(message); + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }; + + ws.onclose = (event) => { + console.log('WebSocket disconnected:', event.code, event.reason); + setIsConnected(false); + setIsRunning(false); + isConnectingRef.current = false; + }; + + ws.onerror = (error) => { + console.error('WebSocket error:', error); + console.error('WebSocket readyState:', ws.readyState); + setIsConnected(false); + isConnectingRef.current = false; + }; + }; + + // Add small delay to prevent rapid reconnection + const timeoutId = setTimeout(connectWithDelay, 100); + + return () => { + clearTimeout(timeoutId); + isConnectingRef.current = false; + hasConnectedRef.current = false; + if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) { + console.log('Cleaning up WebSocket connection'); + wsRef.current.close(); + } + }; + }, [scriptPath, executionId]); + const handleMessage = (message: TerminalMessage) => { + if (!xtermRef.current) return; + const timestamp = new Date(message.timestamp).toLocaleTimeString(); const prefix = `[${timestamp}] `; switch (message.type) { case 'start': - setOutput(prev => [...prev, `${prefix}🚀 ${message.data}`]); + xtermRef.current.writeln(`${prefix}🚀 ${message.data}`); setIsRunning(true); break; case 'output': - setOutput(prev => [...prev, message.data]); + // Write directly to terminal - xterm.js handles ANSI codes natively + xtermRef.current.write(message.data); break; case 'error': - setOutput(prev => [...prev, `${prefix}❌ ${message.data}`]); + // Check if this looks like ANSI terminal output (contains escape codes) + if (message.data.includes('\x1B[') || message.data.includes('\u001b[')) { + // This is likely terminal output sent to stderr, treat it as normal output + xtermRef.current.write(message.data); + } else { + // This is a real error, show it with error prefix + xtermRef.current.writeln(`${prefix}❌ ${message.data}`); + } break; case 'end': - setOutput(prev => [...prev, `${prefix}✅ ${message.data}`]); + xtermRef.current.writeln(`${prefix}✅ ${message.data}`); setIsRunning(false); break; } @@ -106,15 +242,10 @@ export function Terminal({ scriptPath, onClose }: TerminalProps) { }; const clearOutput = () => { - setOutput([]); - }; - - // Auto-scroll to bottom when new output arrives - useEffect(() => { - if (outputRef.current) { - outputRef.current.scrollTop = outputRef.current.scrollHeight; + if (xtermRef.current) { + xtermRef.current.clear(); } - }, [output]); + }; return (
@@ -141,23 +272,10 @@ export function Terminal({ scriptPath, onClose }: TerminalProps) { {/* Terminal Output */}
- {output.length === 0 ? ( -
-

Terminal ready. Click "Start Script" to begin execution.

-

Script: {scriptPath}

-
- ) : ( - output.map((line, index) => ( -
- {line} -
- )) - )} -
+ ref={terminalRef} + className="h-96 w-full" + style={{ minHeight: '384px' }} + /> {/* Terminal Controls */}
@@ -203,4 +321,4 @@ export function Terminal({ scriptPath, onClose }: TerminalProps) {
); -} +} \ No newline at end of file diff --git a/src/server/lib/scripts.ts b/src/server/lib/scripts.ts index dbe0733..cdd17c7 100644 --- a/src/server/lib/scripts.ts +++ b/src/server/lib/scripts.ts @@ -69,8 +69,11 @@ export class ScriptManager { */ private async isExecutable(filePath: string): Promise { try { - await access(filePath, 0o111); // Check execute permission - return true; + const stats = await stat(filePath); + // Check if file has execute permission for owner, group, or others + const mode = stats.mode; + const isExecutable = !!(mode & parseInt('111', 8)); + return isExecutable; } catch { return false; } diff --git a/src/styles/globals.css b/src/styles/globals.css index 8fe04fa..825cf67 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -4,3 +4,19 @@ --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; } + +/* Terminal-specific styles for ANSI escape code rendering */ +.terminal-output { + font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + line-height: 1.2; +} + +.terminal-output span { + display: inline; +} + +/* Ensure proper rendering of ANSI colors */ +.terminal-output * { + color: inherit; + background-color: inherit; +}