diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index cc179603..fc0f2843 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -5,13 +5,15 @@ name: Node.js CI on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] + +permissions: + contents: read jobs: build: - runs-on: ubuntu-latest strategy: @@ -20,12 +22,12 @@ jobs: # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - run: npm ci - - run: npm run build --if-present - - run: npm test + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: "npm" + - run: npm ci + - run: npm run build --if-present + - run: npm test diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index babb0d07..73a8a96a 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -4,7 +4,11 @@ on: push: branches: - main - workflow_dispatch: + workflow_dispatch: + +permissions: + contents: write + pull-requests: read jobs: update_release_draft: diff --git a/.gitignore b/.gitignore index dbc949a1..dd7c168c 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,4 @@ server.log # idea files .idea /generated/prisma +/examples diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..32e07b4f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "WillLuke.nextjs.hasPrompted": true +} \ No newline at end of file diff --git a/UPDATER_VERSION b/UPDATER_VERSION new file mode 100644 index 00000000..3eefcb9d --- /dev/null +++ b/UPDATER_VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/package.json b/package.json index 7b29b0d9..cc174691 100644 --- a/package.json +++ b/package.json @@ -25,13 +25,13 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@prisma/adapter-better-sqlite3": "^7.6.0", - "@prisma/client": "^7.6.0", + "@prisma/adapter-better-sqlite3": "^7.7.0", + "@prisma/client": "^7.7.0", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.4", "@t3-oss/env-nextjs": "^0.13.11", "@tailwindcss/typography": "^0.5.19", - "@tanstack/react-query": "^5.96.0", + "@tanstack/react-query": "^5.99.0", "@trpc/client": "^11.16.0", "@trpc/react-query": "^11.16.0", "@trpc/server": "^11.16.0", @@ -40,21 +40,21 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", - "axios": "^1.14.0", + "axios": "^1.15.0", "bcryptjs": "^3.0.3", - "better-sqlite3": "^12.8.0", + "better-sqlite3": "^12.9.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cron-validator": "^1.4.0", - "dotenv": "^17.3.1", + "dotenv": "^17.4.2", "jsonwebtoken": "^9.0.3", - "lucide-react": "^1.7.0", - "next": ">=16.2.1", + "lucide-react": "^1.8.0", + "next": "16.2.4", "node-cron": "^4.2.1", "node-pty": "^1.1.0", "pocketbase": "^0.26.8", - "react": "^19.2.4", - "react-dom": "^19.2.4", + "react": "^19.2.5", + "react-dom": "^19.2.5", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "refractor": "^5.0.0", @@ -63,7 +63,7 @@ "strip-ansi": "^7.2.0", "superjson": "^2.2.6", "tailwind-merge": "^3.5.0", - "vite": "^8.0.3", + "vite": "^8.0.8", "ws": "^8.20.0", "zod": "^4.3.6" }, @@ -80,22 +80,23 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", - "@vitest/coverage-v8": "^4.1.2", - "@vitest/ui": "^4.1.2", - "baseline-browser-mapping": "^2.10.13", - "eslint": "^10.1.0", - "eslint-config-next": "^16.2.1", - "jsdom": "^29.0.1", - "next": ">=16.2.1", - "postcss": "^8.5.8", - "prettier": "^3.8.1", + "@vitest/coverage-v8": "^4.1.4", + "@vitest/ui": "^4.1.4", + "baseline-browser-mapping": "^2.10.19", + "eslint": "^10.2.0", + "eslint-config-next": "^16.2.4", + "jsdom": "^29.0.2", + "next": "16.2.4", + "postcss": "^8.5.10", + "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.7.2", - "prisma": "^7.6.0", + "prisma": "^7.7.0", "tailwindcss": "^4.2.2", "tsx": "^4.21.0", "typescript": "^6.0.2", - "typescript-eslint": "^8.58.0", - "vitest": "^4.1.2" + "@typescript-eslint/eslint-plugin": "^8.58.2", + "@typescript-eslint/parser": "^8.58.2", + "vitest": "^4.1.4" }, "ct3aMetadata": { "initVersion": "7.39.3" @@ -110,4 +111,4 @@ "@hono/node-server": ">=1.19.10", "lodash": "^4.17.23" } -} +} \ No newline at end of file diff --git a/prisma/migrations/20260401113636_add_notes_and_presets/migration.sql b/prisma/migrations/20260401113636_add_notes_and_presets/migration.sql new file mode 100644 index 00000000..a150b48d --- /dev/null +++ b/prisma/migrations/20260401113636_add_notes_and_presets/migration.sql @@ -0,0 +1,37 @@ +-- CreateTable +CREATE TABLE "script_notes" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "script_slug" TEXT NOT NULL, + "title" TEXT NOT NULL DEFAULT '', + "content" TEXT NOT NULL, + "is_shared" BOOLEAN NOT NULL DEFAULT false, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "server_presets" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "server_id" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "cpu" INTEGER, + "ram" INTEGER, + "disk" INTEGER, + "privileged" BOOLEAN NOT NULL DEFAULT false, + "bridge" TEXT, + "vlan" TEXT, + "dns" TEXT, + "ssh" BOOLEAN NOT NULL DEFAULT false, + "nesting" BOOLEAN NOT NULL DEFAULT true, + "fuse" BOOLEAN NOT NULL DEFAULT false, + "apt_proxy_addr" TEXT, + "apt_proxy_on" BOOLEAN NOT NULL DEFAULT false, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL +); + +-- CreateIndex +CREATE INDEX "script_notes_script_slug_idx" ON "script_notes"("script_slug"); + +-- CreateIndex +CREATE INDEX "server_presets_server_id_idx" ON "server_presets"("server_id"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index de112ad3..f22916b9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -146,3 +146,39 @@ model Repository { @@map("repositories") } + +model ScriptNote { + id Int @id @default(autoincrement()) + script_slug String + title String @default("") + content String + is_shared Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([script_slug]) + @@map("script_notes") +} + +model ServerPreset { + id Int @id @default(autoincrement()) + server_id Int + name String + cpu Int? + ram Int? + disk Int? + privileged Boolean @default(false) + bridge String? + vlan String? + dns String? + ssh Boolean @default(false) + nesting Boolean @default(true) + fuse Boolean @default(false) + apt_proxy_addr String? + apt_proxy_on Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([server_id]) + @@map("server_presets") +} diff --git a/public/favicon.png b/public/favicon.png deleted file mode 100644 index 5e4b11bb..00000000 Binary files a/public/favicon.png and /dev/null differ diff --git a/public/favicon/android-chrome-192x192.png b/public/favicon/android-chrome-192x192.png new file mode 100644 index 00000000..80ef9de0 Binary files /dev/null and b/public/favicon/android-chrome-192x192.png differ diff --git a/public/favicon/android-chrome-512x512.png b/public/favicon/android-chrome-512x512.png new file mode 100644 index 00000000..22411074 Binary files /dev/null and b/public/favicon/android-chrome-512x512.png differ diff --git a/public/favicon/apple-touch-icon.png b/public/favicon/apple-touch-icon.png new file mode 100644 index 00000000..1d510935 Binary files /dev/null and b/public/favicon/apple-touch-icon.png differ diff --git a/public/favicon/favicon-16x16.png b/public/favicon/favicon-16x16.png new file mode 100644 index 00000000..d9608cea Binary files /dev/null and b/public/favicon/favicon-16x16.png differ diff --git a/public/favicon/favicon-32x32.png b/public/favicon/favicon-32x32.png new file mode 100644 index 00000000..7fe97add Binary files /dev/null and b/public/favicon/favicon-32x32.png differ diff --git a/public/favicon/favicon.ico b/public/favicon/favicon.ico new file mode 100644 index 00000000..4d201474 Binary files /dev/null and b/public/favicon/favicon.ico differ diff --git a/public/favicon/favicon.png b/public/favicon/favicon.png new file mode 100644 index 00000000..f6e74d5a Binary files /dev/null and b/public/favicon/favicon.png differ diff --git a/public/favicon/site.webmanifest b/public/favicon/site.webmanifest new file mode 100644 index 00000000..45dc8a20 --- /dev/null +++ b/public/favicon/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 00000000..efb680a5 Binary files /dev/null and b/public/logo.png differ diff --git a/restore.log b/restore.log deleted file mode 100644 index 0f654fb3..00000000 --- a/restore.log +++ /dev/null @@ -1,10 +0,0 @@ -Starting restore... -Reading container configuration... -Stopping container... -Destroying container... -Logging into PBS... -Downloading backup from PBS... -Packing backup folder... -Restoring container... -Cleaning up temporary files... -Restore completed successfully diff --git a/scripts/core/alpine-install.func b/scripts/core/alpine-install.func index 0e0c7983..1dd388da 100644 --- a/scripts/core/alpine-install.func +++ b/scripts/core/alpine-install.func @@ -4,7 +4,7 @@ # License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE if ! command -v curl >/dev/null 2>&1; then - apk update && apk add curl >/dev/null 2>&1 + apk update && apk add curl >/dev/null 2>&1 fi source "$(dirname "${BASH_SOURCE[0]}")/core.func" source "$(dirname "${BASH_SOURCE[0]}")/error-handler.func" @@ -14,8 +14,8 @@ catch_errors # Persist diagnostics setting inside container (exported from build.func) # so addon scripts running later can find the user's choice if [[ ! -f /usr/local/community-scripts/diagnostics ]]; then - mkdir -p /usr/local/community-scripts - echo "DIAGNOSTICS=${DIAGNOSTICS:-no}" >/usr/local/community-scripts/diagnostics + mkdir -p /usr/local/community-scripts + echo "DIAGNOSTICS=${DIAGNOSTICS:-no}" >/usr/local/community-scripts/diagnostics fi # Get LXC IP address (must be called INSIDE container, after network is up) @@ -33,208 +33,217 @@ get_lxc_ip # - Only executes if DIAGNOSTICS=yes and RANDOM_UUID is set # ------------------------------------------------------------------------------ post_progress_to_api() { - command -v curl &>/dev/null || return 0 - [[ "${DIAGNOSTICS:-no}" == "no" ]] && return 0 - [[ -z "${RANDOM_UUID:-}" ]] && return 0 + command -v curl &>/dev/null || return 0 + [[ "${DIAGNOSTICS:-no}" == "no" ]] && return 0 + [[ -z "${RANDOM_UUID:-}" ]] && return 0 - local progress_status="${1:-configuring}" + local progress_status="${1:-configuring}" - curl -fsS -m 5 -X POST "https://telemetry.community-scripts.org/telemetry" \ - -H "Content-Type: application/json" \ - -d "{\"random_id\":\"${RANDOM_UUID}\",\"execution_id\":\"${EXECUTION_ID:-${RANDOM_UUID}}\",\"type\":\"lxc\",\"nsapp\":\"${app:-unknown}\",\"status\":\"${progress_status}\"}" &>/dev/null || true + curl -fsS -m 5 -X POST "https://telemetry.community-scripts.org/telemetry" \ + -H "Content-Type: application/json" \ + -d "{\"random_id\":\"${RANDOM_UUID}\",\"execution_id\":\"${EXECUTION_ID:-${RANDOM_UUID}}\",\"type\":\"lxc\",\"nsapp\":\"${app:-unknown}\",\"status\":\"${progress_status}\"}" &>/dev/null || true } # 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 [ "${IPV6_METHOD:-}" = "disable" ]; then - msg_info "Disabling IPv6 (this may affect some services)" - $STD sysctl -w net.ipv6.conf.all.disable_ipv6=1 - $STD sysctl -w net.ipv6.conf.default.disable_ipv6=1 - $STD sysctl -w net.ipv6.conf.lo.disable_ipv6=1 - mkdir -p /etc/sysctl.d - $STD tee /etc/sysctl.d/99-disable-ipv6.conf >/dev/null </dev/null <&2 -en "${CROSS}${RD} No Network! " - sleep $RETRY_EVERY - i=$((i - 1)) - done - - if [ "$(ip addr show | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | cut -d'/' -f1)" = "" ]; then - echo 1>&2 -e "\n${CROSS}${RD} No Network After $RETRY_NUM Tries${CL}" - echo -e "${NETWORK}Check Network Settings" - exit 121 - fi - msg_ok "Set up Container OS" - msg_ok "Network Connected: ${BL}$(ip addr show | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1 | tail -n1)${CL}" - post_progress_to_api + msg_info "Setting up Container OS" + while [ $i -gt 0 ]; do + if [ "$(ip addr show | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | cut -d'/' -f1)" != "" ]; then + break + fi + echo 1>&2 -en "${CROSS}${RD} No Network! " + sleep $RETRY_EVERY + i=$((i - 1)) + done + + if [ "$(ip addr show | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | cut -d'/' -f1)" = "" ]; then + echo 1>&2 -e "\n${CROSS}${RD} No Network After $RETRY_NUM Tries${CL}" + echo -e "${NETWORK}Check Network Settings" + exit 121 + fi + msg_ok "Set up Container OS" + msg_ok "Network Connected: ${BL}$(ip addr show | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1 | tail -n1)${CL}" + post_progress_to_api } # 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 - - # Check IPv4 connectivity to Cloudflare, Google & 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 - - if [[ $ipv4_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 122 - fi - fi - - # DNS resolution checks for GitHub-related domains - 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 + set +e + trap - ERR + ipv4_connected=false + + # Check IPv4 connectivity to Cloudflare, Google & 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 + + if [[ $ipv4_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 122 + fi + fi + + # DNS resolution checks for GitHub-related domains + 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 apk upgrade with mirror fallback update_os() { - msg_info "Updating Container OS" - if ! $STD apk -U upgrade; then - msg_warn "apk update failed (dl-cdn.alpinelinux.org), trying alternate mirrors..." - local alpine_mirrors="mirror.init7.net ftp.halifax.rwth-aachen.de mirrors.edge.kernel.org alpine.mirror.wearetriple.com mirror.leaseweb.com uk.alpinelinux.org dl-2.alpinelinux.org dl-4.alpinelinux.org" - local apk_ok=false - for m in $(printf '%s\n' $alpine_mirrors | shuf); do - if timeout 2 bash -c "echo >/dev/tcp/$m/80" 2>/dev/null; then - msg_custom "${INFO}" "${YW}" "Attempting mirror: ${m}" - cat </etc/apk/repositories + msg_info "Updating Container OS" + if ! $STD apk -U upgrade; then + msg_warn "apk update failed (dl-cdn.alpinelinux.org), trying alternate mirrors..." + local alpine_mirrors="mirror.init7.net ftp.halifax.rwth-aachen.de mirrors.edge.kernel.org alpine.mirror.wearetriple.com mirror.leaseweb.com uk.alpinelinux.org dl-2.alpinelinux.org dl-4.alpinelinux.org" + local apk_ok=false + for m in $(printf '%s\n' $alpine_mirrors | shuf); do + if timeout 2 bash -c "echo >/dev/tcp/$m/80" 2>/dev/null; then + msg_custom "${INFO}" "${YW}" "Attempting mirror: ${m}" + cat </etc/apk/repositories http://$m/alpine/latest-stable/main http://$m/alpine/latest-stable/community EOF - if $STD apk -U upgrade; then - msg_ok "CDN set to ${m}: tests passed" - apk_ok=true - break - else - msg_warn "Mirror ${m} failed" - fi - fi - done - if [[ "$apk_ok" != true ]]; then - msg_error "All Alpine mirrors failed. Check network or try again later." - exit 1 - fi - fi - local tools_content - tools_content=$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/tools.func) || { - msg_error "Failed to download tools.func" - exit 115 - } - source /dev/stdin <<<"$tools_content" - if ! declare -f fetch_and_deploy_gh_release >/dev/null 2>&1; then - msg_error "tools.func loaded but incomplete — missing expected functions" - exit 115 - fi - msg_ok "Updated Container OS" - post_progress_to_api + if $STD apk -U upgrade; then + msg_ok "CDN set to ${m}: tests passed" + apk_ok=true + break + else + msg_warn "Mirror ${m} failed" + fi + fi + done + if [[ "$apk_ok" != true ]]; then + msg_error "All Alpine mirrors failed. Check network or try again later." + exit 1 + fi + fi + local tools_content + local _tools_local + _tools_local="$(dirname "${BASH_SOURCE[0]}")/tools.func" + if [[ -f "$_tools_local" ]]; then + tools_content=$(cat "$_tools_local") || { + msg_error "Failed to load tools.func from local" + exit 115 + } + else + tools_content=$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/tools.func) || { + msg_error "Failed to download tools.func" + exit 115 + } + fi + source /dev/stdin <<<"$tools_content" + if ! declare -f fetch_and_deploy_gh_release >/dev/null 2>&1; then + msg_error "tools.func loaded but incomplete — missing expected functions" + exit 115 + fi + msg_ok "Updated Container OS" + post_progress_to_api } # This function modifies the message of the day (motd) and SSH settings motd_ssh() { - echo "export TERM='xterm-256color'" >>/root/.bashrc - - 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}\$(grep ^NAME /etc/os-release | cut -d= -f2 | tr -d '\"') - Version: \$(grep ^VERSION_ID /etc/os-release | cut -d= -f2 | tr -d '\"')${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}\$(ip -4 addr show eth0 | awk '/inet / {print \$2}' | cut -d/ -f1 | head -n 1)${CL}\"" >>"$PROFILE_FILE" - - # Configure SSH if enabled - if [[ "${SSH_ROOT}" == "yes" ]]; then - # Enable sshd service - $STD rc-update add sshd - # Allow root login via SSH - sed -i "s/#PermitRootLogin prohibit-password/PermitRootLogin yes/g" /etc/ssh/sshd_config - # Start the sshd service - $STD /etc/init.d/sshd start - fi - post_progress_to_api + echo "export TERM='xterm-256color'" >>/root/.bashrc + + 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}\$(grep ^NAME /etc/os-release | cut -d= -f2 | tr -d '\"') - Version: \$(grep ^VERSION_ID /etc/os-release | cut -d= -f2 | tr -d '\"')${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}\$(ip -4 addr show eth0 | awk '/inet / {print \$2}' | cut -d/ -f1 | head -n 1)${CL}\"" >>"$PROFILE_FILE" + + # Configure SSH if enabled + if [[ "${SSH_ROOT}" == "yes" ]]; then + # Enable sshd service + $STD rc-update add sshd + # Allow root login via SSH + sed -i "s/#PermitRootLogin prohibit-password/PermitRootLogin yes/g" /etc/ssh/sshd_config + # Start the sshd service + $STD /etc/init.d/sshd start + fi + post_progress_to_api } # Validate Timezone for some LXC's validate_tz() { - [[ -f "/usr/share/zoneinfo/$1" ]] + [[ -f "/usr/share/zoneinfo/$1" ]] } # This function customizes the container and enables passwordless login for the root user customize() { - if [[ "$PASSWORD" == "" ]]; then - msg_info "Customizing Container" - passwd -d root >/dev/null 2>&1 + if [[ "$PASSWORD" == "" ]]; then + msg_info "Customizing Container" + passwd -d root >/dev/null 2>&1 - # Ensure agetty is available - apk add --no-cache --force-broken-world util-linux >/dev/null 2>&1 + # Ensure agetty is available + apk add --no-cache --force-broken-world util-linux >/dev/null 2>&1 - # Create persistent autologin boot script - mkdir -p /etc/local.d - cat <<'EOF' >/etc/local.d/autologin.start + # Create persistent autologin boot script + mkdir -p /etc/local.d + cat <<'EOF' >/etc/local.d/autologin.start #!/bin/sh sed -i 's|^tty1::respawn:.*|tty1::respawn:/sbin/agetty --autologin root --noclear tty1 38400 linux|' /etc/inittab kill -HUP 1 EOF - touch /root/.hushlogin + touch /root/.hushlogin - chmod +x /etc/local.d/autologin.start - rc-update add local >/dev/null 2>&1 + chmod +x /etc/local.d/autologin.start + rc-update add local >/dev/null 2>&1 - # Apply autologin immediately for current session - /etc/local.d/autologin.start + # Apply autologin immediately for current session + /etc/local.d/autologin.start - msg_ok "Customized Container" - fi + msg_ok "Customized Container" + fi - echo "bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${app}.sh)\"" >/usr/bin/update - chmod +x /usr/bin/update - post_progress_to_api + echo "bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${app}.sh)\"" >/usr/bin/update + chmod +x /usr/bin/update + post_progress_to_api } diff --git a/scripts/core/api.func b/scripts/core/api.func index 6e820e57..e3d99780 100644 --- a/scripts/core/api.func +++ b/scripts/core/api.func @@ -59,52 +59,52 @@ STATUS_TIMEOUT=10 # - Skips detection if REPO_SOURCE is already set (e.g., by environment) # ------------------------------------------------------------------------------ detect_repo_source() { - # Allow explicit override via environment - [[ -n "${REPO_SOURCE:-}" ]] && return 0 - - local content="" owner_repo="" - - # Method 1: Read from /proc/$$/cmdline - # When invoked via: bash -c "$(curl -fsSL https://.../ct/app.sh)" - # the full CT/VM script content is in /proc/$$/cmdline (same PID through source chain) - if [[ -r /proc/$$/cmdline ]]; then - content=$(tr '\0' ' ' /dev/null) || true - fi - - # Method 2: Read from the original script file (bash ct/app.sh / bash vm/app.sh) - if [[ -z "$content" ]] || ! echo "$content" | grep -qE 'githubusercontent\.com|community-scripts\.org' 2>/dev/null; then - if [[ -f "$0" ]] && [[ "$0" != *bash* ]]; then - content=$(head -10 "$0" 2>/dev/null) || true - fi - fi - - # Extract owner/repo from URL patterns found in the script content - if [[ -n "$content" ]]; then - # GitHub raw URL: raw.githubusercontent.com/OWNER/REPO/... - owner_repo=$(echo "$content" | grep -oE 'raw\.githubusercontent\.com/[^/]+/[^/]+' | head -1 | sed 's|raw\.githubusercontent\.com/||') || true - - # Gitea URL: git.community-scripts.org/OWNER/REPO/... - if [[ -z "$owner_repo" ]]; then - owner_repo=$(echo "$content" | grep -oE 'git\.community-scripts\.org/[^/]+/[^/]+' | head -1 | sed 's|git\.community-scripts\.org/||') || true - fi - fi - - # Map detected owner/repo to canonical repo_source value - case "$owner_repo" in - community-scripts/ProxmoxVE) REPO_SOURCE="ProxmoxVE" ;; - community-scripts/ProxmoxVED) REPO_SOURCE="ProxmoxVED" ;; - "") - # No URL detected — use hardcoded fallback - # This value must match the repo: ProxmoxVE for production, ProxmoxVED for dev - REPO_SOURCE="ProxmoxVE" - ;; - *) - # Fork or unknown repo - REPO_SOURCE="external" - ;; - esac - - export REPO_SOURCE + # Allow explicit override via environment + [[ -n "${REPO_SOURCE:-}" ]] && return 0 + + local content="" owner_repo="" + + # Method 1: Read from /proc/$$/cmdline + # When invoked via: bash -c "$(curl -fsSL https://.../ct/app.sh)" + # the full CT/VM script content is in /proc/$$/cmdline (same PID through source chain) + if [[ -r /proc/$$/cmdline ]]; then + content=$(tr '\0' ' ' /dev/null) || true + fi + + # Method 2: Read from the original script file (bash ct/app.sh / bash vm/app.sh) + if [[ -z "$content" ]] || ! echo "$content" | grep -qE 'githubusercontent\.com|community-scripts\.org' 2>/dev/null; then + if [[ -f "$0" ]] && [[ "$0" != *bash* ]]; then + content=$(head -10 "$0" 2>/dev/null) || true + fi + fi + + # Extract owner/repo from URL patterns found in the script content + if [[ -n "$content" ]]; then + # GitHub raw URL: raw.githubusercontent.com/OWNER/REPO/... + owner_repo=$(echo "$content" | grep -oE 'raw\.githubusercontent\.com/[^/]+/[^/]+' | head -1 | sed 's|raw\.githubusercontent\.com/||') || true + + # Gitea URL: git.community-scripts.org/OWNER/REPO/... + if [[ -z "$owner_repo" ]]; then + owner_repo=$(echo "$content" | grep -oE 'git\.community-scripts\.org/[^/]+/[^/]+' | head -1 | sed 's|git\.community-scripts\.org/||') || true + fi + fi + + # Map detected owner/repo to canonical repo_source value + case "$owner_repo" in + community-scripts/ProxmoxVE) REPO_SOURCE="ProxmoxVE" ;; + community-scripts/ProxmoxVED) REPO_SOURCE="ProxmoxVED" ;; + "") + # No URL detected — use hardcoded fallback + # This value must match the repo: ProxmoxVE for production, ProxmoxVED for dev + REPO_SOURCE="ProxmoxVE" + ;; + *) + # Fork or unknown repo + REPO_SOURCE="external" + ;; + esac + + export REPO_SOURCE } # Run detection immediately when api.func is sourced @@ -119,7 +119,7 @@ detect_repo_source # # - Maps numeric exit codes to human-readable error descriptions # - Canonical source of truth for ALL exit code mappings -# - Used by both api.func (telemetry) and error-handler.func (error display) +# - Used by both api.func (telemetry) and error_handler.func (error display) # - Supports: # * Generic/Shell errors (1-3, 10, 124-132, 134, 137, 139, 141, 143-146) # * curl/wget errors (4-8, 16, 18, 22-28, 30, 32-36, 39, 44-48, 51-52, 55-57, 59, 61, 63, 75, 78-79, 92, 95) @@ -138,204 +138,204 @@ detect_repo_source # - Returns description string for given exit code # ------------------------------------------------------------------------------ explain_exit_code() { - local code="$1" - case "$code" in - # --- Generic / Shell --- - 1) echo "General error / Operation not permitted" ;; - 2) echo "Misuse of shell builtins (e.g. syntax error)" ;; - 3) echo "General syntax or argument error" ;; - 10) echo "Docker / privileged mode required (unsupported environment)" ;; - - # --- curl / wget errors (commonly seen in downloads) --- - 4) echo "curl: Feature not supported or protocol error" ;; - 5) echo "curl: Could not resolve proxy" ;; - 6) echo "curl: DNS resolution failed (could not resolve host)" ;; - 7) echo "curl: Failed to connect (network unreachable / host down)" ;; - 8) echo "curl: Server reply error (FTP/SFTP or apk untrusted key)" ;; - 16) echo "curl: HTTP/2 framing layer error" ;; - 18) echo "curl: Partial file (transfer not completed)" ;; - 22) echo "curl: HTTP error returned (404, 429, 500+)" ;; - 23) echo "curl: Write error (disk full or permissions)" ;; - 24) echo "curl: Write to local file failed" ;; - 25) echo "curl: Upload failed" ;; - 26) echo "curl: Read error on local file (I/O)" ;; - 27) echo "curl: Out of memory (memory allocation failed)" ;; - 28) echo "curl: Operation timeout (network slow or server not responding)" ;; - 30) echo "curl: FTP port command failed" ;; - 32) echo "curl: FTP SIZE command failed" ;; - 33) echo "curl: HTTP range error" ;; - 34) echo "curl: HTTP post error" ;; - 35) echo "curl: SSL/TLS handshake failed (certificate error)" ;; - 36) echo "curl: FTP bad download resume" ;; - 39) echo "curl: LDAP search failed" ;; - 44) echo "curl: Internal error (bad function call order)" ;; - 45) echo "curl: Interface error (failed to bind to specified interface)" ;; - 46) echo "curl: Bad password entered" ;; - 47) echo "curl: Too many redirects" ;; - 48) echo "curl: Unknown command line option specified" ;; - 51) echo "curl: SSL peer certificate or SSH host key verification failed" ;; - 52) echo "curl: Empty reply from server (got nothing)" ;; - 55) echo "curl: Failed sending network data" ;; - 56) echo "curl: Receive error (connection reset by peer)" ;; - 57) echo "curl: Unrecoverable poll/select error (system I/O failure)" ;; - 59) echo "curl: Couldn't use specified SSL cipher" ;; - 61) echo "curl: Bad/unrecognized transfer encoding" ;; - 63) echo "curl: Maximum file size exceeded" ;; - 75) echo "Temporary failure (retry later)" ;; - 78) echo "curl: Remote file not found (404 on FTP/file)" ;; - 79) echo "curl: SSH session error (key exchange/auth failed)" ;; - 92) echo "curl: HTTP/2 stream error (protocol violation)" ;; - 95) echo "curl: HTTP/3 layer error" ;; - - # --- Package manager / APT / DPKG --- - 100) echo "APT: Package manager error (broken packages / dependency problems)" ;; - 101) echo "APT: Configuration error (bad sources.list, malformed config)" ;; - 102) echo "APT: Lock held by another process (dpkg/apt still running)" ;; - - # --- Script Validation & Setup (103-123) --- - 103) echo "Validation: Shell is not Bash" ;; - 104) echo "Validation: Not running as root (or invoked via sudo)" ;; - 105) echo "Validation: Proxmox VE version not supported" ;; - 106) echo "Validation: Architecture not supported (ARM / PiMox)" ;; - 107) echo "Validation: Kernel key parameters unreadable" ;; - 108) echo "Validation: Kernel key limits exceeded" ;; - 109) echo "Proxmox: No available container ID after max attempts" ;; - 110) echo "Proxmox: Failed to apply default.vars" ;; - 111) echo "Proxmox: App defaults file not available" ;; - 112) echo "Proxmox: Invalid install menu option" ;; - 113) echo "LXC: Under-provisioned — user aborted update" ;; - 114) echo "LXC: Storage too low — user aborted update" ;; - 115) echo "Download: install.func download failed or incomplete" ;; - 116) echo "Proxmox: Default bridge vmbr0 not found" ;; - 117) echo "LXC: Container did not reach running state" ;; - 118) echo "LXC: No IP assigned to container after timeout" ;; - 119) echo "Proxmox: No valid storage for rootdir content" ;; - 120) echo "Proxmox: No valid storage for vztmpl content" ;; - 121) echo "LXC: Container network not ready (no IP after retries)" ;; - 122) echo "LXC: No internet connectivity — user declined to continue" ;; - 123) echo "LXC: Local IP detection failed" ;; - - # --- BSD sysexits.h (64-78) --- - 64) echo "Usage error (wrong arguments)" ;; - 65) echo "Data format error (bad input data)" ;; - 66) echo "Input file not found (cannot open input)" ;; - 67) echo "User not found (addressee unknown)" ;; - 68) echo "Host not found (hostname unknown)" ;; - 69) echo "Service unavailable" ;; - 70) echo "Internal software error" ;; - 71) echo "System error (OS-level failure)" ;; - 72) echo "Critical OS file missing" ;; - 73) echo "Cannot create output file" ;; - 74) echo "I/O error" ;; - 76) echo "Remote protocol error" ;; - 77) echo "Permission denied" ;; - - # --- Common shell/system errors --- - 124) echo "Command timed out (timeout command)" ;; - 125) echo "Command failed to start (Docker daemon or execution error)" ;; - 126) echo "Command invoked cannot execute (permission problem?)" ;; - 127) echo "Command not found" ;; - 128) echo "Invalid argument to exit" ;; - 129) echo "Killed by SIGHUP (terminal closed / hangup)" ;; - 130) echo "Aborted by user (SIGINT)" ;; - 131) echo "Killed by SIGQUIT (core dumped)" ;; - 132) echo "Killed by SIGILL (illegal CPU instruction)" ;; - 134) echo "Process aborted (SIGABRT - possibly Node.js heap overflow)" ;; - 137) echo "Killed (SIGKILL / Out of memory?)" ;; - 139) echo "Segmentation fault (core dumped)" ;; - 141) echo "Broken pipe (SIGPIPE - output closed prematurely)" ;; - 143) echo "Terminated (SIGTERM)" ;; - 144) echo "Killed by signal 16 (SIGUSR1 / SIGSTKFLT)" ;; - 146) echo "Killed by signal 18 (SIGTSTP)" ;; - - # --- Systemd / Service errors (150-154) --- - 150) echo "Systemd: Service failed to start" ;; - 151) echo "Systemd: Service unit not found" ;; - 152) echo "Permission denied (EACCES)" ;; - 153) echo "Build/compile failed (make/gcc/cmake)" ;; - 154) echo "Node.js: Native addon build failed (node-gyp)" ;; - # --- Python / pip / uv (160-162) --- - 160) echo "Python: Virtualenv / uv environment missing or broken" ;; - 161) echo "Python: Dependency resolution failed" ;; - 162) echo "Python: Installation aborted (permissions or EXTERNALLY-MANAGED)" ;; - - # --- PostgreSQL (170-173) --- - 170) echo "PostgreSQL: Connection failed (server not running / wrong socket)" ;; - 171) echo "PostgreSQL: Authentication failed (bad user/password)" ;; - 172) echo "PostgreSQL: Database does not exist" ;; - 173) echo "PostgreSQL: Fatal error in query / syntax" ;; - - # --- MySQL / MariaDB (180-183) --- - 180) echo "MySQL/MariaDB: Connection failed (server not running / wrong socket)" ;; - 181) echo "MySQL/MariaDB: Authentication failed (bad user/password)" ;; - 182) echo "MySQL/MariaDB: Database does not exist" ;; - 183) echo "MySQL/MariaDB: Fatal error in query / syntax" ;; - - # --- MongoDB (190-193) --- - 190) echo "MongoDB: Connection failed (server not running)" ;; - 191) echo "MongoDB: Authentication failed (bad user/password)" ;; - 192) echo "MongoDB: Database not found" ;; - 193) echo "MongoDB: Fatal query error" ;; - - # --- Proxmox Custom Codes (200-231) --- - 200) echo "Proxmox: Failed to create lock file" ;; - 203) echo "Proxmox: Missing CTID variable" ;; - 204) echo "Proxmox: Missing PCT_OSTYPE variable" ;; - 205) echo "Proxmox: Invalid CTID (<100)" ;; - 206) echo "Proxmox: CTID already in use" ;; - 207) echo "Proxmox: Password contains unescaped special characters" ;; - 208) echo "Proxmox: Invalid configuration (DNS/MAC/Network format)" ;; - 209) echo "Proxmox: Container creation failed" ;; - 210) echo "Proxmox: Cluster not quorate" ;; - 211) echo "Proxmox: Timeout waiting for template lock" ;; - 212) echo "Proxmox: Storage type 'iscsidirect' does not support containers (VMs only)" ;; - 213) echo "Proxmox: Storage type does not support 'rootdir' content" ;; - 214) echo "Proxmox: Not enough storage space" ;; - 215) echo "Proxmox: Container created but not listed (ghost state)" ;; - 216) echo "Proxmox: RootFS entry missing in config" ;; - 217) echo "Proxmox: Storage not accessible" ;; - 218) echo "Proxmox: Template file corrupted or incomplete" ;; - 219) echo "Proxmox: CephFS does not support containers - use RBD" ;; - 220) echo "Proxmox: Unable to resolve template path" ;; - 221) echo "Proxmox: Template file not readable" ;; - 222) echo "Proxmox: Template download failed" ;; - 223) echo "Proxmox: Template not available after download" ;; - 224) echo "Proxmox: PBS storage is for backups only" ;; - 225) echo "Proxmox: No template available for OS/Version" ;; - 226) echo "Proxmox: VM disk import or post-creation setup failed" ;; - 231) echo "Proxmox: LXC stack upgrade failed" ;; - - # --- Tools & Addon Scripts (232-238) --- - 232) echo "Tools: Wrong execution environment (run on PVE host, not inside LXC)" ;; - 233) echo "Tools: Application not installed (update prerequisite missing)" ;; - 234) echo "Tools: No LXC containers found or available" ;; - 235) echo "Tools: Backup or restore operation failed" ;; - 236) echo "Tools: Required hardware not detected" ;; - 237) echo "Tools: Dependency package installation failed" ;; - 238) echo "Tools: OS or distribution not supported for this addon" ;; - - # --- Node.js / npm / pnpm / yarn (239-249) --- - 239) echo "npm/Node.js: Unexpected runtime error or dependency failure" ;; - 243) echo "Node.js: Out of memory (JavaScript heap out of memory)" ;; - 245) echo "Node.js: Invalid command-line option" ;; - 246) echo "Node.js: Internal JavaScript Parse Error" ;; - 247) echo "Node.js: Fatal internal error" ;; - 248) echo "Node.js: Invalid C++ addon / N-API failure" ;; - 249) echo "npm/pnpm/yarn: Unknown fatal error" ;; - - # --- Application Install/Update Errors (250-254) --- - 250) echo "App: Download failed or version not determined" ;; - 251) echo "App: File extraction failed (corrupt or incomplete archive)" ;; - 252) echo "App: Required file or resource not found" ;; - 253) echo "App: Data migration required — update aborted" ;; - 254) echo "App: User declined prompt or input timed out" ;; - - # --- DPKG --- - 255) echo "DPKG: Fatal internal error" ;; - - # --- Default --- - *) echo "Unknown error" ;; - esac + local code="$1" + case "$code" in + # --- Generic / Shell --- + 1) echo "General error / Operation not permitted" ;; + 2) echo "Misuse of shell builtins (e.g. syntax error)" ;; + 3) echo "General syntax or argument error" ;; + 10) echo "Docker / privileged mode required (unsupported environment)" ;; + + # --- curl / wget errors (commonly seen in downloads) --- + 4) echo "curl: Feature not supported or protocol error" ;; + 5) echo "curl: Could not resolve proxy" ;; + 6) echo "curl: DNS resolution failed (could not resolve host)" ;; + 7) echo "curl: Failed to connect (network unreachable / host down)" ;; + 8) echo "curl: Server reply error (FTP/SFTP or apk untrusted key)" ;; + 16) echo "curl: HTTP/2 framing layer error" ;; + 18) echo "curl: Partial file (transfer not completed)" ;; + 22) echo "curl: HTTP error returned (404, 429, 500+)" ;; + 23) echo "curl: Write error (disk full or permissions)" ;; + 24) echo "curl: Write to local file failed" ;; + 25) echo "curl: Upload failed" ;; + 26) echo "curl: Read error on local file (I/O)" ;; + 27) echo "curl: Out of memory (memory allocation failed)" ;; + 28) echo "curl: Operation timeout (network slow or server not responding)" ;; + 30) echo "curl: FTP port command failed" ;; + 32) echo "curl: FTP SIZE command failed" ;; + 33) echo "curl: HTTP range error" ;; + 34) echo "curl: HTTP post error" ;; + 35) echo "curl: SSL/TLS handshake failed (certificate error)" ;; + 36) echo "curl: FTP bad download resume" ;; + 39) echo "curl: LDAP search failed" ;; + 44) echo "curl: Internal error (bad function call order)" ;; + 45) echo "curl: Interface error (failed to bind to specified interface)" ;; + 46) echo "curl: Bad password entered" ;; + 47) echo "curl: Too many redirects" ;; + 48) echo "curl: Unknown command line option specified" ;; + 51) echo "curl: SSL peer certificate or SSH host key verification failed" ;; + 52) echo "curl: Empty reply from server (got nothing)" ;; + 55) echo "curl: Failed sending network data" ;; + 56) echo "curl: Receive error (connection reset by peer)" ;; + 57) echo "curl: Unrecoverable poll/select error (system I/O failure)" ;; + 59) echo "curl: Couldn't use specified SSL cipher" ;; + 61) echo "curl: Bad/unrecognized transfer encoding" ;; + 63) echo "curl: Maximum file size exceeded" ;; + 75) echo "Temporary failure (retry later)" ;; + 78) echo "curl: Remote file not found (404 on FTP/file)" ;; + 79) echo "curl: SSH session error (key exchange/auth failed)" ;; + 92) echo "curl: HTTP/2 stream error (protocol violation)" ;; + 95) echo "curl: HTTP/3 layer error" ;; + + # --- Package manager / APT / DPKG --- + 100) echo "APT: Package manager error (broken packages / dependency problems)" ;; + 101) echo "APT: Configuration error (bad sources.list, malformed config)" ;; + 102) echo "APT: Lock held by another process (dpkg/apt still running)" ;; + + # --- Script Validation & Setup (103-123) --- + 103) echo "Validation: Shell is not Bash" ;; + 104) echo "Validation: Not running as root (or invoked via sudo)" ;; + 105) echo "Validation: Proxmox VE version not supported" ;; + 106) echo "Validation: Architecture not supported (ARM / PiMox)" ;; + 107) echo "Validation: Kernel key parameters unreadable" ;; + 108) echo "Validation: Kernel key limits exceeded" ;; + 109) echo "Proxmox: No available container ID after max attempts" ;; + 110) echo "Proxmox: Failed to apply default.vars" ;; + 111) echo "Proxmox: App defaults file not available" ;; + 112) echo "Proxmox: Invalid install menu option" ;; + 113) echo "LXC: Under-provisioned — user aborted update" ;; + 114) echo "LXC: Storage too low — user aborted update" ;; + 115) echo "Download: install.func download failed or incomplete" ;; + 116) echo "Proxmox: Default bridge vmbr0 not found" ;; + 117) echo "LXC: Container did not reach running state" ;; + 118) echo "LXC: No IP assigned to container after timeout" ;; + 119) echo "Proxmox: No valid storage for rootdir content" ;; + 120) echo "Proxmox: No valid storage for vztmpl content" ;; + 121) echo "LXC: Container network not ready (no IP after retries)" ;; + 122) echo "LXC: No internet connectivity — user declined to continue" ;; + 123) echo "LXC: Local IP detection failed" ;; + + # --- BSD sysexits.h (64-78) --- + 64) echo "Usage error (wrong arguments)" ;; + 65) echo "Data format error (bad input data)" ;; + 66) echo "Input file not found (cannot open input)" ;; + 67) echo "User not found (addressee unknown)" ;; + 68) echo "Host not found (hostname unknown)" ;; + 69) echo "Service unavailable" ;; + 70) echo "Internal software error" ;; + 71) echo "System error (OS-level failure)" ;; + 72) echo "Critical OS file missing" ;; + 73) echo "Cannot create output file" ;; + 74) echo "I/O error" ;; + 76) echo "Remote protocol error" ;; + 77) echo "Permission denied" ;; + + # --- Common shell/system errors --- + 124) echo "Command timed out (timeout command)" ;; + 125) echo "Command failed to start (Docker daemon or execution error)" ;; + 126) echo "Command invoked cannot execute (permission problem?)" ;; + 127) echo "Command not found" ;; + 128) echo "Invalid argument to exit" ;; + 129) echo "Killed by SIGHUP (terminal closed / hangup)" ;; + 130) echo "Aborted by user (SIGINT)" ;; + 131) echo "Killed by SIGQUIT (core dumped)" ;; + 132) echo "Killed by SIGILL (illegal CPU instruction)" ;; + 134) echo "Process aborted (SIGABRT - possibly Node.js heap overflow)" ;; + 137) echo "Killed (SIGKILL / Out of memory?)" ;; + 139) echo "Segmentation fault (core dumped)" ;; + 141) echo "Broken pipe (SIGPIPE - output closed prematurely)" ;; + 143) echo "Terminated (SIGTERM)" ;; + 144) echo "Killed by signal 16 (SIGUSR1 / SIGSTKFLT)" ;; + 146) echo "Killed by signal 18 (SIGTSTP)" ;; + + # --- Systemd / Service errors (150-154) --- + 150) echo "Systemd: Service failed to start" ;; + 151) echo "Systemd: Service unit not found" ;; + 152) echo "Permission denied (EACCES)" ;; + 153) echo "Build/compile failed (make/gcc/cmake)" ;; + 154) echo "Node.js: Native addon build failed (node-gyp)" ;; + # --- Python / pip / uv (160-162) --- + 160) echo "Python: Virtualenv / uv environment missing or broken" ;; + 161) echo "Python: Dependency resolution failed" ;; + 162) echo "Python: Installation aborted (permissions or EXTERNALLY-MANAGED)" ;; + + # --- PostgreSQL (170-173) --- + 170) echo "PostgreSQL: Connection failed (server not running / wrong socket)" ;; + 171) echo "PostgreSQL: Authentication failed (bad user/password)" ;; + 172) echo "PostgreSQL: Database does not exist" ;; + 173) echo "PostgreSQL: Fatal error in query / syntax" ;; + + # --- MySQL / MariaDB (180-183) --- + 180) echo "MySQL/MariaDB: Connection failed (server not running / wrong socket)" ;; + 181) echo "MySQL/MariaDB: Authentication failed (bad user/password)" ;; + 182) echo "MySQL/MariaDB: Database does not exist" ;; + 183) echo "MySQL/MariaDB: Fatal error in query / syntax" ;; + + # --- MongoDB (190-193) --- + 190) echo "MongoDB: Connection failed (server not running)" ;; + 191) echo "MongoDB: Authentication failed (bad user/password)" ;; + 192) echo "MongoDB: Database not found" ;; + 193) echo "MongoDB: Fatal query error" ;; + + # --- Proxmox Custom Codes (200-231) --- + 200) echo "Proxmox: Failed to create lock file" ;; + 203) echo "Proxmox: Missing CTID variable" ;; + 204) echo "Proxmox: Missing PCT_OSTYPE variable" ;; + 205) echo "Proxmox: Invalid CTID (<100)" ;; + 206) echo "Proxmox: CTID already in use" ;; + 207) echo "Proxmox: Password contains unescaped special characters" ;; + 208) echo "Proxmox: Invalid configuration (DNS/MAC/Network format)" ;; + 209) echo "Proxmox: Container creation failed" ;; + 210) echo "Proxmox: Cluster not quorate" ;; + 211) echo "Proxmox: Timeout waiting for template lock" ;; + 212) echo "Proxmox: Storage type 'iscsidirect' does not support containers (VMs only)" ;; + 213) echo "Proxmox: Storage type does not support 'rootdir' content" ;; + 214) echo "Proxmox: Not enough storage space" ;; + 215) echo "Proxmox: Container created but not listed (ghost state)" ;; + 216) echo "Proxmox: RootFS entry missing in config" ;; + 217) echo "Proxmox: Storage not accessible" ;; + 218) echo "Proxmox: Template file corrupted or incomplete" ;; + 219) echo "Proxmox: CephFS does not support containers - use RBD" ;; + 220) echo "Proxmox: Unable to resolve template path" ;; + 221) echo "Proxmox: Template file not readable" ;; + 222) echo "Proxmox: Template download failed" ;; + 223) echo "Proxmox: Template not available after download" ;; + 224) echo "Proxmox: PBS storage is for backups only" ;; + 225) echo "Proxmox: No template available for OS/Version" ;; + 226) echo "Proxmox: VM disk import or post-creation setup failed" ;; + 231) echo "Proxmox: LXC stack upgrade failed" ;; + + # --- Tools & Addon Scripts (232-238) --- + 232) echo "Tools: Wrong execution environment (run on PVE host, not inside LXC)" ;; + 233) echo "Tools: Application not installed (update prerequisite missing)" ;; + 234) echo "Tools: No LXC containers found or available" ;; + 235) echo "Tools: Backup or restore operation failed" ;; + 236) echo "Tools: Required hardware not detected" ;; + 237) echo "Tools: Dependency package installation failed" ;; + 238) echo "Tools: OS or distribution not supported for this addon" ;; + + # --- Node.js / npm / pnpm / yarn (239-249) --- + 239) echo "npm/Node.js: Unexpected runtime error or dependency failure" ;; + 243) echo "Node.js: Out of memory (JavaScript heap out of memory)" ;; + 245) echo "Node.js: Invalid command-line option" ;; + 246) echo "Node.js: Internal JavaScript Parse Error" ;; + 247) echo "Node.js: Fatal internal error" ;; + 248) echo "Node.js: Invalid C++ addon / N-API failure" ;; + 249) echo "npm/pnpm/yarn: Unknown fatal error" ;; + + # --- Application Install/Update Errors (250-254) --- + 250) echo "App: Download failed or version not determined" ;; + 251) echo "App: File extraction failed (corrupt or incomplete archive)" ;; + 252) echo "App: Required file or resource not found" ;; + 253) echo "App: Data migration required — update aborted" ;; + 254) echo "App: User declined prompt or input timed out" ;; + + # --- DPKG --- + 255) echo "DPKG: Fatal internal error" ;; + + # --- Default --- + *) echo "Unknown error" ;; + esac } # ------------------------------------------------------------------------------ @@ -346,12 +346,12 @@ explain_exit_code() { # - Handles backslashes, quotes, newlines, tabs, and carriage returns # ------------------------------------------------------------------------------ json_escape() { - # Escape a string for safe JSON embedding using awk (handles any input size). - # Pipeline: strip ANSI → remove control chars → escape \ " TAB → join lines with \n - printf '%s' "$1" | - sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' | - tr -d '\000-\010\013\014\016-\037\177\r' | - awk ' + # Escape a string for safe JSON embedding using awk (handles any input size). + # Pipeline: strip ANSI → remove control chars → escape \ " TAB → join lines with \n + printf '%s' "$1" | + sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' | + tr -d '\000-\010\013\014\016-\037\177\r' | + awk ' BEGIN { ORS = "" } { gsub(/\\/, "\\\\") # backslash → \\ @@ -370,39 +370,39 @@ json_escape() { # - Handles container paths that don't exist on the host # ------------------------------------------------------------------------------ get_error_text() { - local logfile="" - if declare -f get_active_logfile >/dev/null 2>&1; then - logfile=$(get_active_logfile) - elif [[ -n "${INSTALL_LOG:-}" ]]; then - logfile="$INSTALL_LOG" - elif [[ -n "${BUILD_LOG:-}" ]]; then - logfile="$BUILD_LOG" - fi - - # If logfile is inside container (e.g. /root/.install-*), try the host copy - if [[ -n "$logfile" && ! -s "$logfile" ]]; then - # Try combined log: /tmp/--.log - if [[ -n "${CTID:-}" && -n "${SESSION_ID:-}" ]]; then - local combined_log="/tmp/${NSAPP:-lxc}-${CTID}-${SESSION_ID}.log" - if [[ -s "$combined_log" ]]; then - logfile="$combined_log" - fi - fi - fi - - # Also try BUILD_LOG as fallback if primary log is empty/missing - if [[ -z "$logfile" || ! -s "$logfile" ]] && [[ -n "${BUILD_LOG:-}" && -s "${BUILD_LOG}" ]]; then - logfile="$BUILD_LOG" - fi - - # Try SILENT_LOGFILE as last resort (captures $STD command output) - if [[ -z "$logfile" || ! -s "$logfile" ]] && [[ -n "${SILENT_LOGFILE:-}" && -s "${SILENT_LOGFILE}" ]]; then - logfile="$SILENT_LOGFILE" - fi - - if [[ -n "$logfile" && -s "$logfile" ]]; then - tail -n 20 "$logfile" 2>/dev/null | sed 's/\r$//' | sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' - fi + local logfile="" + if declare -f get_active_logfile >/dev/null 2>&1; then + logfile=$(get_active_logfile) + elif [[ -n "${INSTALL_LOG:-}" ]]; then + logfile="$INSTALL_LOG" + elif [[ -n "${BUILD_LOG:-}" ]]; then + logfile="$BUILD_LOG" + fi + + # If logfile is inside container (e.g. /root/.install-*), try the host copy + if [[ -n "$logfile" && ! -s "$logfile" ]]; then + # Try combined log: /tmp/--.log + if [[ -n "${CTID:-}" && -n "${SESSION_ID:-}" ]]; then + local combined_log="/tmp/${NSAPP:-lxc}-${CTID}-${SESSION_ID}.log" + if [[ -s "$combined_log" ]]; then + logfile="$combined_log" + fi + fi + fi + + # Also try BUILD_LOG as fallback if primary log is empty/missing + if [[ -z "$logfile" || ! -s "$logfile" ]] && [[ -n "${BUILD_LOG:-}" && -s "${BUILD_LOG}" ]]; then + logfile="$BUILD_LOG" + fi + + # Try SILENT_LOGFILE as last resort (captures $STD command output) + if [[ -z "$logfile" || ! -s "$logfile" ]] && [[ -n "${SILENT_LOGFILE:-}" && -s "${SILENT_LOGFILE}" ]]; then + logfile="$SILENT_LOGFILE" + fi + + if [[ -n "$logfile" && -s "$logfile" ]]; then + tail -n 20 "$logfile" 2>/dev/null | sed 's/\r$//' | sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' + fi } # ------------------------------------------------------------------------------ @@ -415,50 +415,50 @@ get_error_text() { # - Used for the error telemetry field (full trace instead of 20 lines) # ------------------------------------------------------------------------------ get_full_log() { - local max_bytes="${1:-122880}" # 120KB default - local logfile="" - - # Ensure logs are available on host (pulls from container if needed) - if declare -f ensure_log_on_host >/dev/null 2>&1; then - ensure_log_on_host - fi - - # Try combined log first (most complete) - if [[ -n "${CTID:-}" && -n "${SESSION_ID:-}" ]]; then - local combined_log="/tmp/${NSAPP:-lxc}-${CTID}-${SESSION_ID}.log" - if [[ -s "$combined_log" ]]; then - logfile="$combined_log" - fi - fi - - # Fall back to INSTALL_LOG - if [[ -z "$logfile" || ! -s "$logfile" ]]; then - if [[ -n "${INSTALL_LOG:-}" && -s "${INSTALL_LOG}" ]]; then - logfile="$INSTALL_LOG" - fi - fi - - # Fall back to BUILD_LOG - if [[ -z "$logfile" || ! -s "$logfile" ]]; then - if [[ -n "${BUILD_LOG:-}" && -s "${BUILD_LOG}" ]]; then - logfile="$BUILD_LOG" - fi - fi - - # Fall back to SILENT_LOGFILE (captures $STD command output) - if [[ -z "$logfile" || ! -s "$logfile" ]]; then - if [[ -n "${SILENT_LOGFILE:-}" && -s "${SILENT_LOGFILE}" ]]; then - logfile="$SILENT_LOGFILE" - fi - fi - - if [[ -n "$logfile" && -s "$logfile" ]]; then - # Strip ANSI codes, carriage returns, and anonymize IP addresses (GDPR) - sed 's/\r$//' "$logfile" 2>/dev/null | - sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' | - sed -E 's/([0-9]{1,3}\.)[0-9]{1,3}\.[0-9]{1,3}/\1x.x/g' | - head -c "$max_bytes" - fi + local max_bytes="${1:-122880}" # 120KB default + local logfile="" + + # Ensure logs are available on host (pulls from container if needed) + if declare -f ensure_log_on_host >/dev/null 2>&1; then + ensure_log_on_host + fi + + # Try combined log first (most complete) + if [[ -n "${CTID:-}" && -n "${SESSION_ID:-}" ]]; then + local combined_log="/tmp/${NSAPP:-lxc}-${CTID}-${SESSION_ID}.log" + if [[ -s "$combined_log" ]]; then + logfile="$combined_log" + fi + fi + + # Fall back to INSTALL_LOG + if [[ -z "$logfile" || ! -s "$logfile" ]]; then + if [[ -n "${INSTALL_LOG:-}" && -s "${INSTALL_LOG}" ]]; then + logfile="$INSTALL_LOG" + fi + fi + + # Fall back to BUILD_LOG + if [[ -z "$logfile" || ! -s "$logfile" ]]; then + if [[ -n "${BUILD_LOG:-}" && -s "${BUILD_LOG}" ]]; then + logfile="$BUILD_LOG" + fi + fi + + # Fall back to SILENT_LOGFILE (captures $STD command output) + if [[ -z "$logfile" || ! -s "$logfile" ]]; then + if [[ -n "${SILENT_LOGFILE:-}" && -s "${SILENT_LOGFILE}" ]]; then + logfile="$SILENT_LOGFILE" + fi + fi + + if [[ -n "$logfile" && -s "$logfile" ]]; then + # Strip ANSI codes, carriage returns, and anonymize IP addresses (GDPR) + sed 's/\r$//' "$logfile" 2>/dev/null | + sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' | + sed -E 's/([0-9]{1,3}\.)[0-9]{1,3}\.[0-9]{1,3}/\1x.x/g' | + head -c "$max_bytes" + fi } # ------------------------------------------------------------------------------ @@ -473,18 +473,18 @@ get_full_log() { # - Returns structured error string via stdout # ------------------------------------------------------------------------------ build_error_string() { - local exit_code="${1:-1}" - local log_text="${2:-}" - local explanation - explanation=$(explain_exit_code "$exit_code") - - if [[ -n "$log_text" ]]; then - # Structured format: header + separator + log lines - printf 'exit_code=%s | %s\n---\n%s' "$exit_code" "$explanation" "$log_text" - else - # No log available - just the explanation with exit code - printf 'exit_code=%s | %s' "$exit_code" "$explanation" - fi + local exit_code="${1:-1}" + local log_text="${2:-}" + local explanation + explanation=$(explain_exit_code "$exit_code") + + if [[ -n "$log_text" ]]; then + # Structured format: header + separator + log lines + printf 'exit_code=%s | %s\n---\n%s' "$exit_code" "$explanation" "$log_text" + else + # No log available - just the explanation with exit code + printf 'exit_code=%s | %s' "$exit_code" "$explanation" + fi } # ============================================================================== @@ -499,35 +499,35 @@ build_error_string() { # - Used for GPU analytics # ------------------------------------------------------------------------------ detect_gpu() { - GPU_VENDOR="unknown" - GPU_MODEL="" - GPU_PASSTHROUGH="unknown" - - local gpu_line - gpu_line=$(lspci 2>/dev/null | grep -iE "VGA|3D|Display" | head -1 || true) - - if [[ -n "$gpu_line" ]]; then - # Extract model: everything after the colon, clean up - GPU_MODEL=$(echo "$gpu_line" | sed 's/.*: //' | sed 's/ (rev .*)$//' | cut -c1-64) - - # Detect vendor and passthrough type - if echo "$gpu_line" | grep -qi "Intel"; then - GPU_VENDOR="intel" - GPU_PASSTHROUGH="igpu" - elif echo "$gpu_line" | grep -qi "AMD\|ATI"; then - GPU_VENDOR="amd" - if echo "$gpu_line" | grep -qi "Radeon RX\|Radeon Pro"; then - GPU_PASSTHROUGH="dgpu" - else - GPU_PASSTHROUGH="igpu" - fi - elif echo "$gpu_line" | grep -qi "NVIDIA"; then - GPU_VENDOR="nvidia" - GPU_PASSTHROUGH="dgpu" - fi - fi - - export GPU_VENDOR GPU_MODEL GPU_PASSTHROUGH + GPU_VENDOR="unknown" + GPU_MODEL="" + GPU_PASSTHROUGH="unknown" + + local gpu_line + gpu_line=$(lspci 2>/dev/null | grep -iE "VGA|3D|Display" | head -1 || true) + + if [[ -n "$gpu_line" ]]; then + # Extract model: everything after the colon, clean up + GPU_MODEL=$(echo "$gpu_line" | sed 's/.*: //' | sed 's/ (rev .*)$//' | cut -c1-64) + + # Detect vendor and passthrough type + if echo "$gpu_line" | grep -qi "Intel"; then + GPU_VENDOR="intel" + GPU_PASSTHROUGH="igpu" + elif echo "$gpu_line" | grep -qi "AMD\|ATI"; then + GPU_VENDOR="amd" + if echo "$gpu_line" | grep -qi "Radeon RX\|Radeon Pro"; then + GPU_PASSTHROUGH="dgpu" + else + GPU_PASSTHROUGH="igpu" + fi + elif echo "$gpu_line" | grep -qi "NVIDIA"; then + GPU_VENDOR="nvidia" + GPU_PASSTHROUGH="dgpu" + fi + fi + + export GPU_VENDOR GPU_MODEL GPU_PASSTHROUGH } # ------------------------------------------------------------------------------ @@ -538,29 +538,29 @@ detect_gpu() { # - Used for CPU analytics # ------------------------------------------------------------------------------ detect_cpu() { - CPU_VENDOR="unknown" - CPU_MODEL="" - - if [[ -f /proc/cpuinfo ]]; then - local vendor_id - vendor_id=$(grep -m1 "vendor_id" /proc/cpuinfo 2>/dev/null | cut -d: -f2 | tr -d ' ' || true) - - case "$vendor_id" in - GenuineIntel) CPU_VENDOR="intel" ;; - AuthenticAMD) CPU_VENDOR="amd" ;; - *) - # ARM doesn't have vendor_id, check for CPU implementer - if grep -qi "CPU implementer" /proc/cpuinfo 2>/dev/null; then - CPU_VENDOR="arm" - fi - ;; - esac - - # Extract model name and clean it up - CPU_MODEL=$(grep -m1 "model name" /proc/cpuinfo 2>/dev/null | cut -d: -f2 | sed 's/^ *//' | sed 's/(R)//g' | sed 's/(TM)//g' | sed 's/ */ /g' | cut -c1-64 || true) - fi - - export CPU_VENDOR CPU_MODEL + CPU_VENDOR="unknown" + CPU_MODEL="" + + if [[ -f /proc/cpuinfo ]]; then + local vendor_id + vendor_id=$(grep -m1 "vendor_id" /proc/cpuinfo 2>/dev/null | cut -d: -f2 | tr -d ' ' || true) + + case "$vendor_id" in + GenuineIntel) CPU_VENDOR="intel" ;; + AuthenticAMD) CPU_VENDOR="amd" ;; + *) + # ARM doesn't have vendor_id, check for CPU implementer + if grep -qi "CPU implementer" /proc/cpuinfo 2>/dev/null; then + CPU_VENDOR="arm" + fi + ;; + esac + + # Extract model name and clean it up + CPU_MODEL=$(grep -m1 "model name" /proc/cpuinfo 2>/dev/null | cut -d: -f2 | sed 's/^ *//' | sed 's/(R)//g' | sed 's/(TM)//g' | sed 's/ */ /g' | cut -c1-64 || true) + fi + + export CPU_VENDOR CPU_MODEL } # ------------------------------------------------------------------------------ @@ -572,20 +572,20 @@ detect_cpu() { # - Returns empty if not available or if speed is "Unknown" (nested VMs) # ------------------------------------------------------------------------------ detect_ram() { - RAM_SPEED="" + RAM_SPEED="" - if command -v dmidecode &>/dev/null; then - # Get configured memory speed (actual running speed) - # Use || true to handle "Unknown" values in nested VMs (no numeric match) - RAM_SPEED=$(dmidecode -t memory 2>/dev/null | grep -m1 "Configured Memory Speed:" | grep -oE "[0-9]+" | head -1) || true + if command -v dmidecode &>/dev/null; then + # Get configured memory speed (actual running speed) + # Use || true to handle "Unknown" values in nested VMs (no numeric match) + RAM_SPEED=$(dmidecode -t memory 2>/dev/null | grep -m1 "Configured Memory Speed:" | grep -oE "[0-9]+" | head -1) || true - # Fallback to Speed: if Configured not available - if [[ -z "$RAM_SPEED" ]]; then - RAM_SPEED=$(dmidecode -t memory 2>/dev/null | grep -m1 "Speed:" | grep -oE "[0-9]+" | head -1) || true - fi - fi + # Fallback to Speed: if Configured not available + if [[ -z "$RAM_SPEED" ]]; then + RAM_SPEED=$(dmidecode -t memory 2>/dev/null | grep -m1 "Speed:" | grep -oE "[0-9]+" | head -1) || true + fi + fi - export RAM_SPEED + export RAM_SPEED } # ------------------------------------------------------------------------------ @@ -608,59 +608,59 @@ detect_ram() { # - Never blocks or fails script execution # ------------------------------------------------------------------------------ post_to_api() { - # Prevent duplicate submissions (post_to_api is called from multiple places) - [[ "${POST_TO_API_DONE:-}" == "true" ]] && return 0 - - # Silent fail - telemetry should never break scripts - command -v curl &>/dev/null || { - [[ "${DEV_MODE:-}" == "true" ]] && echo "[DEBUG] curl not found, skipping" >&2 - return 0 - } - [[ "${DIAGNOSTICS:-no}" == "no" ]] && { - [[ "${DEV_MODE:-}" == "true" ]] && echo "[DEBUG] DIAGNOSTICS=no, skipping" >&2 - return 0 - } - [[ -z "${RANDOM_UUID:-}" ]] && { - [[ "${DEV_MODE:-}" == "true" ]] && echo "[DEBUG] RANDOM_UUID empty, skipping" >&2 - return 0 - } - - [[ "${DEV_MODE:-}" == "true" ]] && echo "[DEBUG] post_to_api() DIAGNOSTICS=$DIAGNOSTICS RANDOM_UUID=$RANDOM_UUID NSAPP=$NSAPP" >&2 - - # Set type for later status updates (preserve if already set, e.g. turnkey) - TELEMETRY_TYPE="${TELEMETRY_TYPE:-lxc}" - - local pve_version="" - if command -v pveversion &>/dev/null; then - pve_version=$(pveversion 2>/dev/null | awk -F'[/ ]' '{print $2}') || true - fi - - # Detect GPU if not already set - if [[ -z "${GPU_VENDOR:-}" ]]; then - detect_gpu - fi - local gpu_vendor="${GPU_VENDOR:-unknown}" - local gpu_model - gpu_model=$(json_escape "${GPU_MODEL:-}") - local gpu_passthrough="${GPU_PASSTHROUGH:-unknown}" - - # Detect CPU if not already set - if [[ -z "${CPU_VENDOR:-}" ]]; then - detect_cpu - fi - local cpu_vendor="${CPU_VENDOR:-unknown}" - local cpu_model - cpu_model=$(json_escape "${CPU_MODEL:-}") - - # Detect RAM if not already set - if [[ -z "${RAM_SPEED:-}" ]]; then - detect_ram - fi - local ram_speed="${RAM_SPEED:-}" - - local JSON_PAYLOAD - JSON_PAYLOAD=$( - cat </dev/null || { + [[ "${DEV_MODE:-}" == "true" ]] && echo "[DEBUG] curl not found, skipping" >&2 + return 0 + } + [[ "${DIAGNOSTICS:-no}" == "no" ]] && { + [[ "${DEV_MODE:-}" == "true" ]] && echo "[DEBUG] DIAGNOSTICS=no, skipping" >&2 + return 0 + } + [[ -z "${RANDOM_UUID:-}" ]] && { + [[ "${DEV_MODE:-}" == "true" ]] && echo "[DEBUG] RANDOM_UUID empty, skipping" >&2 + return 0 + } + + [[ "${DEV_MODE:-}" == "true" ]] && echo "[DEBUG] post_to_api() DIAGNOSTICS=$DIAGNOSTICS RANDOM_UUID=$RANDOM_UUID NSAPP=$NSAPP" >&2 + + # Set type for later status updates (preserve if already set, e.g. turnkey) + TELEMETRY_TYPE="${TELEMETRY_TYPE:-lxc}" + + local pve_version="" + if command -v pveversion &>/dev/null; then + pve_version=$(pveversion 2>/dev/null | awk -F'[/ ]' '{print $2}') || true + fi + + # Detect GPU if not already set + if [[ -z "${GPU_VENDOR:-}" ]]; then + detect_gpu + fi + local gpu_vendor="${GPU_VENDOR:-unknown}" + local gpu_model + gpu_model=$(json_escape "${GPU_MODEL:-}") + local gpu_passthrough="${GPU_PASSTHROUGH:-unknown}" + + # Detect CPU if not already set + if [[ -z "${CPU_VENDOR:-}" ]]; then + detect_cpu + fi + local cpu_vendor="${CPU_VENDOR:-unknown}" + local cpu_model + cpu_model=$(json_escape "${CPU_MODEL:-}") + + # Detect RAM if not already set + if [[ -z "${RAM_SPEED:-}" ]]; then + detect_ram + fi + local ram_speed="${RAM_SPEED:-}" + + local JSON_PAYLOAD + JSON_PAYLOAD=$( + cat <&2 - [[ "${DEV_MODE:-}" == "true" ]] && echo "[DEBUG] Payload: $JSON_PAYLOAD" >&2 - - # Send initial "installing" record with retry. - # This record MUST exist for all subsequent updates to succeed. - local http_code="" attempt - local _post_success=false - for attempt in 1 2 3; do - if [[ "${DEV_MODE:-}" == "true" ]]; then - http_code=$(curl -sS -w "%{http_code}" -m "${TELEMETRY_TIMEOUT}" -X POST "${TELEMETRY_URL}" \ - -H "Content-Type: application/json" \ - -d "$JSON_PAYLOAD" -o /dev/stderr 2>&1) || http_code="000" - echo "[DEBUG] post_to_api attempt $attempt HTTP=$http_code" >&2 - else - http_code=$(curl -sS -w "%{http_code}" -m "${TELEMETRY_TIMEOUT}" -X POST "${TELEMETRY_URL}" \ - -H "Content-Type: application/json" \ - -d "$JSON_PAYLOAD" -o /dev/null 2>/dev/null) || http_code="000" - fi - if [[ "$http_code" =~ ^2[0-9]{2}$ ]]; then - _post_success=true - break - fi - [[ "$attempt" -lt 3 ]] && sleep 1 - done - - # Only mark done if at least one attempt succeeded. - # If all 3 failed, POST_TO_API_DONE stays false so post_update_to_api - # and on_exit() know the initial record was never created. - # The server has fallback logic to create a new record on status updates, - # so subsequent calls can still succeed even without the initial record. - POST_TO_API_DONE=${_post_success} + ) + + [[ "${DEV_MODE:-}" == "true" ]] && echo "[DEBUG] Sending to: $TELEMETRY_URL" >&2 + [[ "${DEV_MODE:-}" == "true" ]] && echo "[DEBUG] Payload: $JSON_PAYLOAD" >&2 + + # Send initial "installing" record with retry. + # This record MUST exist for all subsequent updates to succeed. + local http_code="" attempt + local _post_success=false + for attempt in 1 2 3; do + if [[ "${DEV_MODE:-}" == "true" ]]; then + http_code=$(curl -sS -w "%{http_code}" -m "${TELEMETRY_TIMEOUT}" -X POST "${TELEMETRY_URL}" \ + -H "Content-Type: application/json" \ + -d "$JSON_PAYLOAD" -o /dev/stderr 2>&1) || http_code="000" + echo "[DEBUG] post_to_api attempt $attempt HTTP=$http_code" >&2 + else + http_code=$(curl -sS -w "%{http_code}" -m "${TELEMETRY_TIMEOUT}" -X POST "${TELEMETRY_URL}" \ + -H "Content-Type: application/json" \ + -d "$JSON_PAYLOAD" -o /dev/null 2>/dev/null) || http_code="000" + fi + if [[ "$http_code" =~ ^2[0-9]{2}$ ]]; then + _post_success=true + break + fi + [[ "$attempt" -lt 3 ]] && sleep 1 + done + + # Only mark done if at least one attempt succeeded. + # If all 3 failed, POST_TO_API_DONE stays false so post_update_to_api + # and on_exit() know the initial record was never created. + # The server has fallback logic to create a new record on status updates, + # so subsequent calls can still succeed even without the initial record. + POST_TO_API_DONE=${_post_success} } # ------------------------------------------------------------------------------ @@ -733,53 +733,53 @@ EOF # - Never blocks or fails script execution # ------------------------------------------------------------------------------ post_to_api_vm() { - # Read diagnostics setting from file - if [[ -f /usr/local/community-scripts/diagnostics ]]; then - DIAGNOSTICS=$(grep -i "^DIAGNOSTICS=" /usr/local/community-scripts/diagnostics 2>/dev/null | awk -F'=' '{print $2}') || true - fi - - # Silent fail - telemetry should never break scripts - command -v curl &>/dev/null || return 0 - [[ "${DIAGNOSTICS:-no}" == "no" ]] && return 0 - [[ -z "${RANDOM_UUID:-}" ]] && return 0 - - # Set type for later status updates - TELEMETRY_TYPE="vm" - - local pve_version="" - if command -v pveversion &>/dev/null; then - pve_version=$(pveversion 2>/dev/null | awk -F'[/ ]' '{print $2}') || true - fi - - # Detect GPU if not already set - if [[ -z "${GPU_VENDOR:-}" ]]; then - detect_gpu - fi - local gpu_vendor="${GPU_VENDOR:-unknown}" - local gpu_model - gpu_model=$(json_escape "${GPU_MODEL:-}") - local gpu_passthrough="${GPU_PASSTHROUGH:-unknown}" - - # Detect CPU if not already set - if [[ -z "${CPU_VENDOR:-}" ]]; then - detect_cpu - fi - local cpu_vendor="${CPU_VENDOR:-unknown}" - local cpu_model - cpu_model=$(json_escape "${CPU_MODEL:-}") - - # Detect RAM if not already set - if [[ -z "${RAM_SPEED:-}" ]]; then - detect_ram - fi - local ram_speed="${RAM_SPEED:-}" - - # Remove 'G' suffix from disk size - local DISK_SIZE_API="${DISK_SIZE%G}" - - local JSON_PAYLOAD - JSON_PAYLOAD=$( - cat </dev/null; then + pve_version=$(pveversion 2>/dev/null | awk -F'[/ ]' '{print $2}') || true + fi + + # Detect GPU if not already set + if [[ -z "${GPU_VENDOR:-}" ]]; then + detect_gpu + fi + local gpu_vendor="${GPU_VENDOR:-unknown}" + local gpu_model + gpu_model=$(json_escape "${GPU_MODEL:-}") + local gpu_passthrough="${GPU_PASSTHROUGH:-unknown}" + + # Detect CPU if not already set + if [[ -z "${CPU_VENDOR:-}" ]]; then + detect_cpu + fi + local cpu_vendor="${CPU_VENDOR:-unknown}" + local cpu_model + cpu_model=$(json_escape "${CPU_MODEL:-}") + + # Detect RAM if not already set + if [[ -z "${RAM_SPEED:-}" ]]; then + detect_ram + fi + local ram_speed="${RAM_SPEED:-}" + + # Remove 'G' suffix from disk size + local DISK_SIZE_API="${DISK_SIZE%G}" + + local JSON_PAYLOAD + JSON_PAYLOAD=$( + cat </dev/null) || http_code="000" - if [[ "$http_code" =~ ^2[0-9]{2}$ ]]; then - _post_success=true - break - fi - [[ "$attempt" -lt 3 ]] && sleep 1 - done - - POST_TO_API_DONE=${_post_success} + ) + + # Send initial "installing" record with retry (must succeed for updates to work) + local http_code="" attempt + local _post_success=false + for attempt in 1 2 3; do + http_code=$(curl -sS -w "%{http_code}" -m "${TELEMETRY_TIMEOUT}" -X POST "${TELEMETRY_URL}" \ + -H "Content-Type: application/json" \ + -d "$JSON_PAYLOAD" -o /dev/null 2>/dev/null) || http_code="000" + if [[ "$http_code" =~ ^2[0-9]{2}$ ]]; then + _post_success=true + break + fi + [[ "$attempt" -lt 3 ]] && sleep 1 + done + + POST_TO_API_DONE=${_post_success} } # ------------------------------------------------------------------------------ @@ -836,17 +836,17 @@ EOF # - Can be called multiple times safely # ------------------------------------------------------------------------------ post_progress_to_api() { - command -v curl &>/dev/null || return 0 - [[ "${DIAGNOSTICS:-no}" == "no" ]] && return 0 - [[ -z "${RANDOM_UUID:-}" ]] && return 0 + command -v curl &>/dev/null || return 0 + [[ "${DIAGNOSTICS:-no}" == "no" ]] && return 0 + [[ -z "${RANDOM_UUID:-}" ]] && return 0 - local progress_status="${1:-configuring}" - local app_name="${NSAPP:-${app:-unknown}}" - local telemetry_type="${TELEMETRY_TYPE:-lxc}" + local progress_status="${1:-configuring}" + local app_name="${NSAPP:-${app:-unknown}}" + local telemetry_type="${TELEMETRY_TYPE:-lxc}" - curl -fsS -m 5 -X POST "${TELEMETRY_URL:-https://telemetry.community-scripts.org/telemetry}" \ - -H "Content-Type: application/json" \ - -d "{\"random_id\":\"${RANDOM_UUID}\",\"execution_id\":\"${EXECUTION_ID:-${RANDOM_UUID}}\",\"type\":\"${telemetry_type}\",\"nsapp\":\"${app_name}\",\"status\":\"${progress_status}\"}" &>/dev/null || true + curl -fsS -m 5 -X POST "${TELEMETRY_URL:-https://telemetry.community-scripts.org/telemetry}" \ + -H "Content-Type: application/json" \ + -d "{\"random_id\":\"${RANDOM_UUID}\",\"execution_id\":\"${EXECUTION_ID:-${RANDOM_UUID}}\",\"type\":\"${telemetry_type}\",\"nsapp\":\"${app_name}\",\"status\":\"${progress_status}\"}" &>/dev/null || true } # ------------------------------------------------------------------------------ @@ -865,111 +865,111 @@ post_progress_to_api() { # - Never blocks or fails script execution # ------------------------------------------------------------------------------ post_update_to_api() { - # Silent fail - telemetry should never break scripts - command -v curl &>/dev/null || return 0 - - # Support "force" mode (3rd arg) to bypass duplicate check for retries after cleanup - local force="${3:-}" - POST_UPDATE_DONE=${POST_UPDATE_DONE:-false} - if [[ "$POST_UPDATE_DONE" == "true" && "$force" != "force" ]]; then - return 0 - fi - - [[ "${DIAGNOSTICS:-no}" == "no" ]] && return 0 - [[ -z "${RANDOM_UUID:-}" ]] && return 0 - - local status="${1:-failed}" - local raw_exit_code="${2:-1}" - local exit_code=0 error="" pb_status error_category="" - - # Get GPU info (if detected) - local gpu_vendor="${GPU_VENDOR:-unknown}" - local gpu_model - gpu_model=$(json_escape "${GPU_MODEL:-}") - local gpu_passthrough="${GPU_PASSTHROUGH:-unknown}" - - # Get CPU info (if detected) - local cpu_vendor="${CPU_VENDOR:-unknown}" - local cpu_model - cpu_model=$(json_escape "${CPU_MODEL:-}") - - # Get RAM info (if detected) - local ram_speed="${RAM_SPEED:-}" - - # Map status to telemetry values: installing, success, failed, unknown - case "$status" in - done | success) - pb_status="success" - exit_code=0 - error="" - error_category="" - ;; - failed) - pb_status="failed" - ;; - *) - pb_status="unknown" - ;; - esac - - # For failed/unknown status, resolve exit code and error description - local short_error="" medium_error="" - if [[ "$pb_status" == "failed" ]] || [[ "$pb_status" == "unknown" ]]; then - if [[ "$raw_exit_code" =~ ^[0-9]+$ ]]; then - exit_code="$raw_exit_code" - else - exit_code=1 - fi - # Get full installation log for error field - local log_text="" - log_text=$(get_full_log 122880) || true # 120KB max - if [[ -z "$log_text" ]]; then - # Fallback to last 20 lines - log_text=$(get_error_text) - fi - local full_error - full_error=$(build_error_string "$exit_code" "$log_text") - error=$(json_escape "$full_error") - short_error=$(json_escape "$(explain_exit_code "$exit_code")") - error_category=$(categorize_error "$exit_code") - [[ -z "$error" ]] && error="Unknown error" - - # Build medium error for attempt 2: explanation + last 100 log lines (≤16KB) - # This is the critical middle ground between full 120KB log and generic-only description - local medium_log="" - medium_log=$(get_full_log 16384) || true # 16KB max - if [[ -z "$medium_log" ]]; then - medium_log=$(get_error_text) || true - fi - local medium_full - medium_full=$(build_error_string "$exit_code" "$medium_log") - medium_error=$(json_escape "$medium_full") - [[ -z "$medium_error" ]] && medium_error="$short_error" - fi - - # Calculate duration if timer was started - local duration=0 - if [[ -n "${INSTALL_START_TIME:-}" ]]; then - duration=$(($(date +%s) - INSTALL_START_TIME)) - fi - - # Get PVE version - local pve_version="" - if command -v pveversion &>/dev/null; then - pve_version=$(pveversion 2>/dev/null | awk -F'[/ ]' '{print $2}') || true - fi - - local http_code="" - - # Strip 'G' suffix from disk size (VMs set DISK_SIZE=32G) - local DISK_SIZE_API="${DISK_SIZE:-0}" - DISK_SIZE_API="${DISK_SIZE_API%G}" - [[ ! "$DISK_SIZE_API" =~ ^[0-9]+$ ]] && DISK_SIZE_API=0 - - # ── Attempt 1: Full payload with complete error text (includes full log) ── - local JSON_PAYLOAD - JSON_PAYLOAD=$( - cat </dev/null || return 0 + + # Support "force" mode (3rd arg) to bypass duplicate check for retries after cleanup + local force="${3:-}" + POST_UPDATE_DONE=${POST_UPDATE_DONE:-false} + if [[ "$POST_UPDATE_DONE" == "true" && "$force" != "force" ]]; then + return 0 + fi + + [[ "${DIAGNOSTICS:-no}" == "no" ]] && return 0 + [[ -z "${RANDOM_UUID:-}" ]] && return 0 + + local status="${1:-failed}" + local raw_exit_code="${2:-1}" + local exit_code=0 error="" pb_status error_category="" + + # Get GPU info (if detected) + local gpu_vendor="${GPU_VENDOR:-unknown}" + local gpu_model + gpu_model=$(json_escape "${GPU_MODEL:-}") + local gpu_passthrough="${GPU_PASSTHROUGH:-unknown}" + + # Get CPU info (if detected) + local cpu_vendor="${CPU_VENDOR:-unknown}" + local cpu_model + cpu_model=$(json_escape "${CPU_MODEL:-}") + + # Get RAM info (if detected) + local ram_speed="${RAM_SPEED:-}" + + # Map status to telemetry values: installing, success, failed, unknown + case "$status" in + done | success) + pb_status="success" + exit_code=0 + error="" + error_category="" + ;; + failed) + pb_status="failed" + ;; + *) + pb_status="unknown" + ;; + esac + + # For failed/unknown status, resolve exit code and error description + local short_error="" medium_error="" + if [[ "$pb_status" == "failed" ]] || [[ "$pb_status" == "unknown" ]]; then + if [[ "$raw_exit_code" =~ ^[0-9]+$ ]]; then + exit_code="$raw_exit_code" + else + exit_code=1 + fi + # Get full installation log for error field + local log_text="" + log_text=$(get_full_log 122880) || true # 120KB max + if [[ -z "$log_text" ]]; then + # Fallback to last 20 lines + log_text=$(get_error_text) + fi + local full_error + full_error=$(build_error_string "$exit_code" "$log_text") + error=$(json_escape "$full_error") + short_error=$(json_escape "$(explain_exit_code "$exit_code")") + error_category=$(categorize_error "$exit_code") + [[ -z "$error" ]] && error="Unknown error" + + # Build medium error for attempt 2: explanation + last 100 log lines (≤16KB) + # This is the critical middle ground between full 120KB log and generic-only description + local medium_log="" + medium_log=$(get_full_log 16384) || true # 16KB max + if [[ -z "$medium_log" ]]; then + medium_log=$(get_error_text) || true + fi + local medium_full + medium_full=$(build_error_string "$exit_code" "$medium_log") + medium_error=$(json_escape "$medium_full") + [[ -z "$medium_error" ]] && medium_error="$short_error" + fi + + # Calculate duration if timer was started + local duration=0 + if [[ -n "${INSTALL_START_TIME:-}" ]]; then + duration=$(($(date +%s) - INSTALL_START_TIME)) + fi + + # Get PVE version + local pve_version="" + if command -v pveversion &>/dev/null; then + pve_version=$(pveversion 2>/dev/null | awk -F'[/ ]' '{print $2}') || true + fi + + local http_code="" + + # Strip 'G' suffix from disk size (VMs set DISK_SIZE=32G) + local DISK_SIZE_API="${DISK_SIZE:-0}" + DISK_SIZE_API="${DISK_SIZE_API%G}" + [[ ! "$DISK_SIZE_API" =~ ^[0-9]+$ ]] && DISK_SIZE_API=0 + + # ── Attempt 1: Full payload with complete error text (includes full log) ── + local JSON_PAYLOAD + JSON_PAYLOAD=$( + cat </dev/null) || http_code="000" - - if [[ "$http_code" =~ ^2[0-9]{2}$ ]]; then - POST_UPDATE_DONE=true - return 0 - fi - - # ── Attempt 2: Medium error text (truncated log ≤16KB instead of full 120KB) ── - sleep 1 - local RETRY_PAYLOAD - RETRY_PAYLOAD=$( - cat </dev/null) || http_code="000" + + if [[ "$http_code" =~ ^2[0-9]{2}$ ]]; then + POST_UPDATE_DONE=true + return 0 + fi + + # ── Attempt 2: Medium error text (truncated log ≤16KB instead of full 120KB) ── + sleep 1 + local RETRY_PAYLOAD + RETRY_PAYLOAD=$( + cat </dev/null) || http_code="000" - - if [[ "$http_code" =~ ^2[0-9]{2}$ ]]; then - POST_UPDATE_DONE=true - return 0 - fi - - # ── Attempt 3: Minimal payload with medium error (bare minimum to set status) ── - sleep 2 - local MINIMAL_PAYLOAD - MINIMAL_PAYLOAD=$( - cat </dev/null) || http_code="000" + + if [[ "$http_code" =~ ^2[0-9]{2}$ ]]; then + POST_UPDATE_DONE=true + return 0 + fi + + # ── Attempt 3: Minimal payload with medium error (bare minimum to set status) ── + sleep 2 + local MINIMAL_PAYLOAD + MINIMAL_PAYLOAD=$( + cat </dev/null) || http_code="000" + http_code=$(curl -sS -w "%{http_code}" -m "${STATUS_TIMEOUT}" -X POST "${TELEMETRY_URL}" \ + -H "Content-Type: application/json" \ + -d "$MINIMAL_PAYLOAD" -o /dev/null 2>/dev/null) || http_code="000" - if [[ "$http_code" =~ ^2[0-9]{2}$ ]]; then - POST_UPDATE_DONE=true - return 0 - fi + if [[ "$http_code" =~ ^2[0-9]{2}$ ]]; then + POST_UPDATE_DONE=true + return 0 + fi - # All 3 attempts failed — do NOT set POST_UPDATE_DONE=true. - # This allows the EXIT trap (on_exit in error-handler.func) to retry. - # No infinite loop risk: EXIT trap fires exactly once. + # All 3 attempts failed — do NOT set POST_UPDATE_DONE=true. + # This allows the EXIT trap (on_exit in error_handler.func) to retry. + # No infinite loop risk: EXIT trap fires exactly once. } # ============================================================================== @@ -1096,65 +1096,65 @@ EOF # - Used to group errors in dashboard # ------------------------------------------------------------------------------ categorize_error() { - # Allow build.func to override category based on log analysis (exit code 1 subclassification) - if [[ -n "${ERROR_CATEGORY_OVERRIDE:-}" ]]; then - echo "$ERROR_CATEGORY_OVERRIDE" - return - fi + # Allow build.func to override category based on log analysis (exit code 1 subclassification) + if [[ -n "${ERROR_CATEGORY_OVERRIDE:-}" ]]; then + echo "$ERROR_CATEGORY_OVERRIDE" + return + fi - local code="$1" - case "$code" in - # Network errors (curl/wget) - 6 | 7 | 22 | 35) echo "network" ;; + local code="$1" + case "$code" in + # Network errors (curl/wget) + 6 | 7 | 22 | 35) echo "network" ;; - # Docker / Privileged mode required - 10) echo "config" ;; + # Docker / Privileged mode required + 10) echo "config" ;; - # Timeout errors - 28 | 124 | 211) echo "timeout" ;; + # Timeout errors + 28 | 124 | 211) echo "timeout" ;; - # Storage errors (Proxmox storage) - 214 | 217 | 219 | 224) echo "storage" ;; + # Storage errors (Proxmox storage) + 214 | 217 | 219 | 224) echo "storage" ;; - # Dependency/Package errors (APT, DPKG, pip, commands) - 100 | 101 | 102 | 127 | 160 | 161 | 162 | 255) echo "dependency" ;; + # Dependency/Package errors (APT, DPKG, pip, commands) + 100 | 101 | 102 | 127 | 160 | 161 | 162 | 255) echo "dependency" ;; - # Permission errors - 126 | 152) echo "permission" ;; + # Permission errors + 126 | 152) echo "permission" ;; - # Configuration errors (Proxmox config, invalid args) - 128 | 203 | 204 | 205 | 206 | 207 | 208) echo "config" ;; + # Configuration errors (Proxmox config, invalid args) + 128 | 203 | 204 | 205 | 206 | 207 | 208) echo "config" ;; - # Proxmox container/template errors - 200 | 209 | 210 | 212 | 213 | 215 | 216 | 218 | 220 | 221 | 222 | 223 | 225 | 231) echo "proxmox" ;; + # Proxmox container/template errors + 200 | 209 | 210 | 212 | 213 | 215 | 216 | 218 | 220 | 221 | 222 | 223 | 225 | 231) echo "proxmox" ;; - # Service/Systemd errors - 150 | 151 | 153 | 154) echo "service" ;; + # Service/Systemd errors + 150 | 151 | 153 | 154) echo "service" ;; - # Database errors (PostgreSQL, MySQL, MongoDB) - 170 | 171 | 172 | 173 | 180 | 181 | 182 | 183 | 190 | 191 | 192 | 193) echo "database" ;; + # Database errors (PostgreSQL, MySQL, MongoDB) + 170 | 171 | 172 | 173 | 180 | 181 | 182 | 183 | 190 | 191 | 192 | 193) echo "database" ;; - # Node.js / JavaScript runtime errors - 243 | 245 | 246 | 247 | 248 | 249) echo "runtime" ;; + # Node.js / JavaScript runtime errors + 243 | 245 | 246 | 247 | 248 | 249) echo "runtime" ;; - # Python environment errors - # (already covered: 160-162 under dependency) + # Python environment errors + # (already covered: 160-162 under dependency) - # Aborted by user (SIGHUP=terminal closed, SIGINT=Ctrl+C, SIGTERM=killed) - 129 | 130 | 143) echo "user_aborted" ;; + # Aborted by user (SIGHUP=terminal closed, SIGINT=Ctrl+C, SIGTERM=killed) + 129 | 130 | 143) echo "user_aborted" ;; - # Resource errors (OOM, SIGKILL, SIGABRT) - 134 | 137) echo "resource" ;; + # Resource errors (OOM, SIGKILL, SIGABRT) + 134 | 137) echo "resource" ;; - # Signal/Process errors (SIGPIPE, SIGSEGV) - 139 | 141) echo "signal" ;; + # Signal/Process errors (SIGPIPE, SIGSEGV) + 139 | 141) echo "signal" ;; - # Shell errors (general error, syntax error) - 1 | 2) echo "shell" ;; + # Shell errors (general error, syntax error) + 1 | 2) echo "shell" ;; - # Default - truly unknown - *) echo "unknown" ;; - esac + # Default - truly unknown + *) echo "unknown" ;; + esac } # ------------------------------------------------------------------------------ @@ -1165,8 +1165,8 @@ categorize_error() { # - Sets INSTALL_START_TIME global variable # ------------------------------------------------------------------------------ start_install_timer() { - INSTALL_START_TIME=$(date +%s) - export INSTALL_START_TIME + INSTALL_START_TIME=$(date +%s) + export INSTALL_START_TIME } # ------------------------------------------------------------------------------ @@ -1176,12 +1176,12 @@ start_install_timer() { # - Returns 0 if timer was not started # ------------------------------------------------------------------------------ get_install_duration() { - if [[ -z "${INSTALL_START_TIME:-}" ]]; then - echo "0" - return - fi - local now=$(date +%s) - echo $((now - INSTALL_START_TIME)) + if [[ -z "${INSTALL_START_TIME:-}" ]]; then + echo "0" + return + fi + local now=$(date +%s) + echo $((now - INSTALL_START_TIME)) } # ------------------------------------------------------------------------------ @@ -1193,18 +1193,18 @@ get_install_duration() { # * $1: exit_code from the script # ------------------------------------------------------------------------------ _telemetry_report_exit() { - local ec="${1:-0}" - local status="success" - [[ "$ec" -ne 0 ]] && status="failed" - - # Lazy name resolution: use explicit name, fall back to $APP, then "unknown" - local name="${TELEMETRY_TOOL_NAME:-${APP:-unknown}}" - - if [[ "${TELEMETRY_TOOL_TYPE:-pve}" == "addon" ]]; then - post_addon_to_api "$name" "$status" "$ec" - else - post_tool_to_api "$name" "$status" "$ec" - fi + local ec="${1:-0}" + local status="success" + [[ "$ec" -ne 0 ]] && status="failed" + + # Lazy name resolution: use explicit name, fall back to $APP, then "unknown" + local name="${TELEMETRY_TOOL_NAME:-${APP:-unknown}}" + + if [[ "${TELEMETRY_TOOL_TYPE:-pve}" == "addon" ]]; then + post_addon_to_api "$name" "$status" "$ec" + else + post_tool_to_api "$name" "$status" "$ec" + fi } # ------------------------------------------------------------------------------ @@ -1224,21 +1224,21 @@ _telemetry_report_exit() { # init_tool_telemetry "" "addon" # uses $APP at exit time # ------------------------------------------------------------------------------ init_tool_telemetry() { - local name="${1:-}" - local type="${2:-pve}" + local name="${1:-}" + local type="${2:-pve}" - [[ -n "$name" ]] && TELEMETRY_TOOL_NAME="$name" - TELEMETRY_TOOL_TYPE="$type" + [[ -n "$name" ]] && TELEMETRY_TOOL_NAME="$name" + TELEMETRY_TOOL_TYPE="$type" - # Read diagnostics opt-in/opt-out - if [[ -f /usr/local/community-scripts/diagnostics ]]; then - DIAGNOSTICS=$(grep -i "^DIAGNOSTICS=" /usr/local/community-scripts/diagnostics 2>/dev/null | awk -F'=' '{print $2}') || true - fi + # Read diagnostics opt-in/opt-out + if [[ -f /usr/local/community-scripts/diagnostics ]]; then + DIAGNOSTICS=$(grep -i "^DIAGNOSTICS=" /usr/local/community-scripts/diagnostics 2>/dev/null | awk -F'=' '{print $2}') || true + fi - start_install_timer + start_install_timer - # EXIT trap: automatically report telemetry when script ends - trap '_telemetry_report_exit "$?"' EXIT + # EXIT trap: automatically report telemetry when script ends + trap '_telemetry_report_exit "$?"' EXIT } # ------------------------------------------------------------------------------ @@ -1252,40 +1252,40 @@ init_tool_telemetry() { # - For PVE host tools, not container installations # ------------------------------------------------------------------------------ post_tool_to_api() { - command -v curl &>/dev/null || return 0 - [[ "${DIAGNOSTICS:-no}" == "no" ]] && return 0 - - local tool_name="${1:-unknown}" - local status="${2:-success}" - local exit_code="${3:-0}" - local error="" error_category="" - local uuid duration - - # Generate UUID for this tool execution - uuid=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen 2>/dev/null || echo "tool-$(date +%s)") - duration=$(get_install_duration) - - # Map status - [[ "$status" == "done" ]] && status="success" - - if [[ "$status" == "failed" ]]; then - [[ ! "$exit_code" =~ ^[0-9]+$ ]] && exit_code=1 - local error_text="" - error_text=$(get_error_text) - local full_error - full_error=$(build_error_string "$exit_code" "$error_text") - error=$(json_escape "$full_error") - error_category=$(categorize_error "$exit_code") - fi - - local pve_version="" - if command -v pveversion &>/dev/null; then - pve_version=$(pveversion 2>/dev/null | awk -F'[/ ]' '{print $2}') || true - fi - - local JSON_PAYLOAD - JSON_PAYLOAD=$( - cat </dev/null || return 0 + [[ "${DIAGNOSTICS:-no}" == "no" ]] && return 0 + + local tool_name="${1:-unknown}" + local status="${2:-success}" + local exit_code="${3:-0}" + local error="" error_category="" + local uuid duration + + # Generate UUID for this tool execution + uuid=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen 2>/dev/null || echo "tool-$(date +%s)") + duration=$(get_install_duration) + + # Map status + [[ "$status" == "done" ]] && status="success" + + if [[ "$status" == "failed" ]]; then + [[ ! "$exit_code" =~ ^[0-9]+$ ]] && exit_code=1 + local error_text="" + error_text=$(get_error_text) + local full_error + full_error=$(build_error_string "$exit_code" "$error_text") + error=$(json_escape "$full_error") + error_category=$(categorize_error "$exit_code") + fi + + local pve_version="" + if command -v pveversion &>/dev/null; then + pve_version=$(pveversion 2>/dev/null | awk -F'[/ ]' '{print $2}') || true + fi + + local JSON_PAYLOAD + JSON_PAYLOAD=$( + cat </dev/null || true + curl -fsS -m "${TELEMETRY_TIMEOUT}" -X POST "${TELEMETRY_URL}" \ + -H "Content-Type: application/json" \ + -d "$JSON_PAYLOAD" &>/dev/null || true } # ------------------------------------------------------------------------------ @@ -1318,42 +1318,42 @@ EOF # - For addons installed inside containers # ------------------------------------------------------------------------------ post_addon_to_api() { - command -v curl &>/dev/null || return 0 - [[ "${DIAGNOSTICS:-no}" == "no" ]] && return 0 - - local addon_name="${1:-unknown}" - local status="${2:-success}" - local exit_code="${3:-0}" - local error="" error_category="" - local uuid duration - - # Generate UUID for this addon installation - uuid=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen 2>/dev/null || echo "addon-$(date +%s)") - duration=$(get_install_duration) - - # Map status - [[ "$status" == "done" ]] && status="success" - - if [[ "$status" == "failed" ]]; then - [[ ! "$exit_code" =~ ^[0-9]+$ ]] && exit_code=1 - local error_text="" - error_text=$(get_error_text) - local full_error - full_error=$(build_error_string "$exit_code" "$error_text") - error=$(json_escape "$full_error") - error_category=$(categorize_error "$exit_code") - fi - - # Detect OS info - local os_type="" os_version="" - if [[ -f /etc/os-release ]]; then - os_type=$(grep "^ID=" /etc/os-release | cut -d= -f2 | tr -d '"' || true) - os_version=$(grep "^VERSION_ID=" /etc/os-release | cut -d= -f2 | tr -d '"' || true) - fi - - local JSON_PAYLOAD - JSON_PAYLOAD=$( - cat </dev/null || return 0 + [[ "${DIAGNOSTICS:-no}" == "no" ]] && return 0 + + local addon_name="${1:-unknown}" + local status="${2:-success}" + local exit_code="${3:-0}" + local error="" error_category="" + local uuid duration + + # Generate UUID for this addon installation + uuid=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen 2>/dev/null || echo "addon-$(date +%s)") + duration=$(get_install_duration) + + # Map status + [[ "$status" == "done" ]] && status="success" + + if [[ "$status" == "failed" ]]; then + [[ ! "$exit_code" =~ ^[0-9]+$ ]] && exit_code=1 + local error_text="" + error_text=$(get_error_text) + local full_error + full_error=$(build_error_string "$exit_code" "$error_text") + error=$(json_escape "$full_error") + error_category=$(categorize_error "$exit_code") + fi + + # Detect OS info + local os_type="" os_version="" + if [[ -f /etc/os-release ]]; then + os_type=$(grep "^ID=" /etc/os-release | cut -d= -f2 | tr -d '"' || true) + os_version=$(grep "^VERSION_ID=" /etc/os-release | cut -d= -f2 | tr -d '"' || true) + fi + + local JSON_PAYLOAD + JSON_PAYLOAD=$( + cat </dev/null || true + curl -fsS -m "${TELEMETRY_TIMEOUT}" -X POST "${TELEMETRY_URL}" \ + -H "Content-Type: application/json" \ + -d "$JSON_PAYLOAD" &>/dev/null || true } # ------------------------------------------------------------------------------ @@ -1389,63 +1389,63 @@ EOF # * GPU info (if detect_gpu was called) # ------------------------------------------------------------------------------ post_update_to_api_extended() { - # Silent fail - telemetry should never break scripts - command -v curl &>/dev/null || return 0 - - # Prevent duplicate submissions - POST_UPDATE_DONE=${POST_UPDATE_DONE:-false} - [[ "$POST_UPDATE_DONE" == "true" ]] && return 0 - - [[ "${DIAGNOSTICS:-no}" == "no" ]] && return 0 - [[ -z "${RANDOM_UUID:-}" ]] && return 0 - - local status="${1:-failed}" - local raw_exit_code="${2:-1}" - local exit_code=0 error="" pb_status error_category="" - local duration gpu_vendor gpu_passthrough - - # Get duration - duration=$(get_install_duration) - - # Get GPU info (if detected) - gpu_vendor="${GPU_VENDOR:-}" - gpu_passthrough="${GPU_PASSTHROUGH:-}" - - # Map status to telemetry values - case "$status" in - done | success) - pb_status="success" - exit_code=0 - error="" - error_category="" - ;; - failed) - pb_status="failed" - ;; - *) - pb_status="unknown" - ;; - esac - - # For failed/unknown status, resolve exit code and error description - if [[ "$pb_status" == "failed" ]] || [[ "$pb_status" == "unknown" ]]; then - if [[ "$raw_exit_code" =~ ^[0-9]+$ ]]; then - exit_code="$raw_exit_code" - else - exit_code=1 - fi - local error_text="" - error_text=$(get_error_text) - local full_error - full_error=$(build_error_string "$exit_code" "$error_text") - error=$(json_escape "$full_error") - error_category=$(categorize_error "$exit_code") - [[ -z "$error" ]] && error="Unknown error" - fi - - local JSON_PAYLOAD - JSON_PAYLOAD=$( - cat </dev/null || return 0 + + # Prevent duplicate submissions + POST_UPDATE_DONE=${POST_UPDATE_DONE:-false} + [[ "$POST_UPDATE_DONE" == "true" ]] && return 0 + + [[ "${DIAGNOSTICS:-no}" == "no" ]] && return 0 + [[ -z "${RANDOM_UUID:-}" ]] && return 0 + + local status="${1:-failed}" + local raw_exit_code="${2:-1}" + local exit_code=0 error="" pb_status error_category="" + local duration gpu_vendor gpu_passthrough + + # Get duration + duration=$(get_install_duration) + + # Get GPU info (if detected) + gpu_vendor="${GPU_VENDOR:-}" + gpu_passthrough="${GPU_PASSTHROUGH:-}" + + # Map status to telemetry values + case "$status" in + done | success) + pb_status="success" + exit_code=0 + error="" + error_category="" + ;; + failed) + pb_status="failed" + ;; + *) + pb_status="unknown" + ;; + esac + + # For failed/unknown status, resolve exit code and error description + if [[ "$pb_status" == "failed" ]] || [[ "$pb_status" == "unknown" ]]; then + if [[ "$raw_exit_code" =~ ^[0-9]+$ ]]; then + exit_code="$raw_exit_code" + else + exit_code=1 + fi + local error_text="" + error_text=$(get_error_text) + local full_error + full_error=$(build_error_string "$exit_code" "$error_text") + error=$(json_escape "$full_error") + error_category=$(categorize_error "$exit_code") + [[ -z "$error" ]] && error="Unknown error" + fi + + local JSON_PAYLOAD + JSON_PAYLOAD=$( + cat </dev/null) || http_code="000" - - if [[ "$http_code" =~ ^2[0-9]{2}$ ]]; then - POST_UPDATE_DONE=true - return 0 - fi - - # Retry with minimal payload - sleep 1 - http_code=$(curl -sS -w "%{http_code}" -m "${STATUS_TIMEOUT}" -X POST "${TELEMETRY_URL}" \ - -H "Content-Type: application/json" \ - -d "{\"random_id\":\"${RANDOM_UUID}\",\"execution_id\":\"${EXECUTION_ID:-${RANDOM_UUID}}\",\"type\":\"${TELEMETRY_TYPE:-lxc}\",\"nsapp\":\"${NSAPP:-unknown}\",\"status\":\"${pb_status}\",\"exit_code\":${exit_code},\"install_duration\":${duration:-0}}" \ - -o /dev/null 2>/dev/null) || http_code="000" - - if [[ "$http_code" =~ ^2[0-9]{2}$ ]]; then - POST_UPDATE_DONE=true - return 0 - fi - - # Do NOT set POST_UPDATE_DONE=true — let EXIT trap retry + ) + + local http_code + http_code=$(curl -sS -w "%{http_code}" -m "${STATUS_TIMEOUT}" -X POST "${TELEMETRY_URL}" \ + -H "Content-Type: application/json" \ + -d "$JSON_PAYLOAD" -o /dev/null 2>/dev/null) || http_code="000" + + if [[ "$http_code" =~ ^2[0-9]{2}$ ]]; then + POST_UPDATE_DONE=true + return 0 + fi + + # Retry with minimal payload + sleep 1 + http_code=$(curl -sS -w "%{http_code}" -m "${STATUS_TIMEOUT}" -X POST "${TELEMETRY_URL}" \ + -H "Content-Type: application/json" \ + -d "{\"random_id\":\"${RANDOM_UUID}\",\"execution_id\":\"${EXECUTION_ID:-${RANDOM_UUID}}\",\"type\":\"${TELEMETRY_TYPE:-lxc}\",\"nsapp\":\"${NSAPP:-unknown}\",\"status\":\"${pb_status}\",\"exit_code\":${exit_code},\"install_duration\":${duration:-0}}" \ + -o /dev/null 2>/dev/null) || http_code="000" + + if [[ "$http_code" =~ ^2[0-9]{2}$ ]]; then + POST_UPDATE_DONE=true + return 0 + fi + + # Do NOT set POST_UPDATE_DONE=true — let EXIT trap retry } diff --git a/scripts/core/build.func b/scripts/core/build.func index 9c82befc..c6b8da0b 100755 --- a/scripts/core/build.func +++ b/scripts/core/build.func @@ -18,7 +18,7 @@ # # Usage: # - Sourced automatically by CT creation scripts -# - Requires core.func and error-handler.func to be loaded first +# - Requires core.func and error_handler.func to be loaded first # # ============================================================================== @@ -986,13 +986,23 @@ base_settings() { # Runtime check: Verify APT cacher is reachable if configured if [[ -n "$APT_CACHER_IP" && "$APT_CACHER" == "yes" ]]; then - if ! curl -s --connect-timeout 2 "http://${APT_CACHER_IP}:3142" >/dev/null 2>&1; then - msg_warn "APT Cacher configured but not reachable at ${APT_CACHER_IP}:3142" + local _check_host _check_port _check_url + _check_host=$(echo "$APT_CACHER_IP" | sed -e 's|https\?://||' -e 's|/.*||' | cut -d: -f1) + _check_port=$(echo "$APT_CACHER_IP" | sed -e 's|https\?://||' -e 's|/.*||' | cut -s -d: -f2) + if [[ "$APT_CACHER_IP" =~ ^https?:// ]]; then + _check_url="$APT_CACHER_IP" + _check_port="${_check_port:-80}" + else + _check_port="${_check_port:-3142}" + _check_url="http://${APT_CACHER_IP}:${_check_port}" + fi + if ! curl -s --connect-timeout 2 "${_check_url}" >/dev/null 2>&1; then + msg_warn "APT Cacher configured but not reachable at ${_check_url}" msg_custom "⚠️" "${YW}" "Disabling APT Cacher for this installation" APT_CACHER="" APT_CACHER_IP="" else - msg_ok "APT Cacher verified at ${APT_CACHER_IP}:3142" + msg_ok "APT Cacher verified at ${_check_url}" fi fi @@ -1044,7 +1054,7 @@ load_vars_file() { # Allowed var_* keys local VAR_WHITELIST=( - var_apt_cacher var_apt_cacher_ip var_brg var_cpu var_disk var_fuse var_gpu var_keyctl + var_apt_cacher var_apt_cacher_ip var_brg var_cpu var_disk var_fuse var_github_token var_gpu var_keyctl var_gateway var_hostname var_ipv6_method var_mac var_mknod var_mount_fs var_mtu var_net var_nesting var_ns var_os var_protection var_pw var_ram var_tags var_timezone var_tun var_unprivileged var_verbose var_version var_vlan var_ssh var_ssh_authorized_key var_container_storage var_template_storage var_searchdomain @@ -1199,6 +1209,13 @@ load_vars_file() { continue fi ;; + var_apt_cacher_ip) + # Allow: plain IP/hostname, http://host, https://host:port + if [[ -n "$var_val" ]] && ! [[ "$var_val" =~ ^(https?://)?[a-zA-Z0-9._-]+(:[0-9]+)?(/.*)?$ ]]; then + msg_warn "Invalid APT Cacher address '$var_val' in $file, ignoring" + continue + fi + ;; var_container_storage | var_template_storage) # Validate that the storage exists and is active on the current node local _storage_status @@ -1238,7 +1255,7 @@ default_var_settings() { # Allowed var_* keys (alphabetically sorted) # Note: Removed var_ctid (can only exist once), var_ipv6_static (static IPs are unique) local VAR_WHITELIST=( - var_apt_cacher var_apt_cacher_ip var_brg var_cpu var_disk var_fuse var_gpu var_keyctl + var_apt_cacher var_apt_cacher_ip var_brg var_cpu var_disk var_fuse var_github_token var_gpu var_keyctl var_gateway var_hostname var_ipv6_method var_mac var_mknod var_mount_fs var_mtu var_net var_nesting var_ns var_os var_protection var_pw var_ram var_tags var_timezone var_tun var_unprivileged var_verbose var_version var_vlan var_ssh var_ssh_authorized_key var_container_storage var_template_storage @@ -1311,9 +1328,11 @@ var_ipv6_method=none var_ssh=no # var_ssh_authorized_key= -# APT cacher (optional - with example) +# APT cacher (optional - IP or URL) # var_apt_cacher=yes # var_apt_cacher_ip=192.168.1.10 +# var_apt_cacher_ip=http://proxy.local +# var_apt_cacher_ip=https://proxy.local:443 # Features/Tags/verbosity var_fuse=no @@ -1331,6 +1350,10 @@ var_verbose=no # Security (root PW) – empty => autologin # var_pw= + +# GitHub Personal Access Token (optional – avoids API rate limits during installs) +# Create at https://github.com/settings/tokens – read-only public access is sufficient +# var_github_token=ghp_your_token_here EOF # Now choose storages (always prompt unless just one exists) @@ -1368,6 +1391,11 @@ EOF VERBOSE="no" fi + # 4) Map var_github_token → GITHUB_TOKEN (only if not already set in environment) + if [[ -z "${GITHUB_TOKEN:-}" && -n "${var_github_token:-}" ]]; then + export GITHUB_TOKEN="${var_github_token}" + fi + # 4) Apply base settings and show summary METHOD="mydefaults-global" base_settings "$VERBOSE" @@ -1400,7 +1428,7 @@ get_app_defaults_path() { if ! declare -p VAR_WHITELIST >/dev/null 2>&1; then # Note: Removed var_ctid (can only exist once), var_ipv6_static (static IPs are unique) declare -ag VAR_WHITELIST=( - var_apt_cacher var_apt_cacher_ip var_brg var_cpu var_disk var_fuse var_gpu + var_apt_cacher var_apt_cacher_ip var_brg var_cpu var_disk var_fuse var_github_token var_gpu var_gateway var_hostname var_ipv6_method var_mac var_mtu var_net var_ns var_os var_pw var_ram var_tags var_tun var_unprivileged var_verbose var_version var_vlan var_ssh var_ssh_authorized_key var_container_storage var_template_storage @@ -2526,7 +2554,7 @@ advanced_settings() { # Ask for IP if enabled if result=$(whiptail --backtitle "Proxmox VE Helper Scripts [Step $STEP/$MAX_STEP]" \ --title "APT CACHER IP" \ - --inputbox "\nEnter APT Cacher-NG server IP address:" 10 58 "$_apt_cacher_ip" \ + --inputbox "\nEnter APT Cacher-NG IP or URL:\n(e.g. 192.168.1.10, http://host, https://host:443)" 12 62 "$_apt_cacher_ip" \ 3>&1 1>&2 2>&3); then _apt_cacher_ip="$result" fi @@ -3530,6 +3558,7 @@ build_container() { # Gateway if [[ -n "$GATE" ]]; then case "$GATE" in + ,gw=) ;; ,gw=*) NET_STRING+="$GATE" ;; *) NET_STRING+=",gw=$GATE" ;; esac @@ -3584,17 +3613,27 @@ build_container() { fi # Build PCT_OPTIONS as string for export - TEMP_DIR=$(mktemp -d) - pushd "$TEMP_DIR" >/dev/null - local _func_url + local _func_file + local SCRIPT_DIR + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [ "$var_os" == "alpine" ]; then - _func_url="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/alpine-install.func" + _func_file="${SCRIPT_DIR}/alpine-install.func" + else + _func_file="${SCRIPT_DIR}/install.func" + fi + if [[ -f "$_func_file" ]]; then + export FUNCTIONS_FILE_PATH="$(cat "$_func_file")" else - _func_url="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/install.func" + local _func_url + if [ "$var_os" == "alpine" ]; then + _func_url="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/alpine-install.func" + else + _func_url="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/install.func" + fi + export FUNCTIONS_FILE_PATH="$(curl -fsSL "$_func_url")" fi - export FUNCTIONS_FILE_PATH="$(curl -fsSL "$_func_url")" if [[ -z "$FUNCTIONS_FILE_PATH" || ${#FUNCTIONS_FILE_PATH} -lt 100 ]]; then - msg_error "Failed to download install functions from: $_func_url" + msg_error "Failed to load install functions" exit 115 fi @@ -3998,7 +4037,7 @@ EOF # Wait for IP assignment (IPv4 or IPv6) local ip_in_lxc="" - for i in {1..20}; do + for i in {1..60}; do # Try IPv4 first ip_in_lxc=$(pct exec "$CTID" -- ip -4 addr show dev eth0 2>/dev/null | awk '/inet / {print $2}' | cut -d/ -f1) # Fallback to IPv6 if IPv4 not available @@ -4006,11 +4045,18 @@ EOF ip_in_lxc=$(pct exec "$CTID" -- ip -6 addr show dev eth0 scope global 2>/dev/null | awk '/inet6 / {print $2}' | cut -d/ -f1 | head -n1) fi [ -n "$ip_in_lxc" ] && break - sleep 1 + # Progressive backoff: 1s for first 20, 2s for next 20, 3s for last 20 + if [ "$i" -le 20 ]; then + sleep 1 + elif [ "$i" -le 40 ]; then + sleep 2 + else + sleep 3 + fi done if [ -z "$ip_in_lxc" ]; then - msg_error "No IP assigned to CT $CTID after 20s" + msg_error "No IP assigned to CT $CTID after 60 attempts" msg_custom "🔧" "${YW}" "Troubleshooting:" echo " • Verify bridge ${BRG} exists and has connectivity" echo " • Check if DHCP server is reachable (if using DHCP)" @@ -4300,7 +4346,13 @@ EOF # that sends "configuring" status AFTER the host already reported "failed" export CONTAINER_INSTALLING=true - lxc-attach -n "$CTID" -- bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/install/${var_install}.sh)" + # Use local install script if available, fallback to remote download + local _local_install="${SCRIPT_DIR}/../install/${var_install}.sh" + if [[ -f "$_local_install" ]]; then + lxc-attach -n "$CTID" -- bash -c "$(cat "$_local_install")" + else + lxc-attach -n "$CTID" -- bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/install/${var_install}.sh)" + fi local lxc_exit=$? unset CONTAINER_INSTALLING @@ -4623,7 +4675,7 @@ EOF if [[ "${DEV_MODE_MOTD:-false}" == "true" ]]; then echo -e "${TAB}${HOLD}${DGN}Setting up MOTD and SSH for debugging...${CL}" if pct exec "$CTID" -- bash -c " - source "$(dirname \"\${BASH_SOURCE[0]}\")/install.func" + source /tmp/scripts/core/install.func declare -f motd_ssh >/dev/null 2>&1 && motd_ssh || true " >/dev/null 2>&1; then local ct_ip=$(pct exec "$CTID" ip a s dev eth0 2>/dev/null | awk '/inet / {print $2}' | cut -d/ -f1) @@ -4695,7 +4747,11 @@ EOF # Re-run install script in existing container (don't destroy/recreate) set +Eeuo pipefail trap - ERR - lxc-attach -n "$CTID" -- bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/install/${var_install}.sh)" + if [[ -f "$_local_install" ]]; then + lxc-attach -n "$CTID" -- bash -c "$(cat "$_local_install")" + else + lxc-attach -n "$CTID" -- bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/install/${var_install}.sh)" + fi local apt_retry_exit=$? set -Eeuo pipefail trap 'error_handler' ERR @@ -5241,9 +5297,10 @@ create_lxc_container() { exit 205 } if qm status "$CTID" &>/dev/null || pct status "$CTID" &>/dev/null; then - unset CTID - msg_error "Cannot use ID that is already in use." - exit 206 + msg_warn "Container/VM ID $CTID is already in use (detected late). Reassigning..." + CTID=$(get_valid_container_id "$((CTID + 1))") + export CTID + msg_ok "Reassigned to container ID $CTID" fi # Report installation start to API early - captures failures in storage/template/create @@ -5719,30 +5776,77 @@ create_lxc_container() { if ! pct create "$CTID" "${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE}" $PCT_OPTIONS >"$LOGFILE" 2>&1; then msg_debug "Container creation failed on ${TEMPLATE_STORAGE}. Checking error..." - # Check if template issue - retry with fresh download - if grep -qiE 'unable to open|corrupt|invalid' "$LOGFILE"; then - msg_info "Template may be corrupted – re-downloading" - rm -f "$TEMPLATE_PATH" - pveam download "$TEMPLATE_STORAGE" "$TEMPLATE" >>"${BUILD_LOG:-/dev/null}" 2>&1 - msg_ok "Template re-downloaded" - fi + # Check if CTID collision (race condition: ID claimed between validation and creation) + if grep -qiE 'already exists|already in use' "$LOGFILE"; then + local old_ctid="$CTID" + CTID=$(get_valid_container_id "$((CTID + 1))") + export CTID + msg_warn "Container ID $old_ctid was claimed by another process. Retrying with ID $CTID" + LOGFILE="/tmp/pct_create_${CTID}_$(date +%Y%m%d_%H%M%S)_${SESSION_ID}.log" + if pct create "$CTID" "${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE}" $PCT_OPTIONS >"$LOGFILE" 2>&1; then + msg_ok "Container successfully created with new ID $CTID" + else + msg_error "Container creation failed even with new ID $CTID. See $LOGFILE" + _flush_pct_log + exit 209 + fi + else + # Not a CTID collision - check if template issue and retry with fresh download + if grep -qiE 'unable to open|corrupt|invalid' "$LOGFILE"; then + msg_info "Template may be corrupted – re-downloading" + rm -f "$TEMPLATE_PATH" + pveam download "$TEMPLATE_STORAGE" "$TEMPLATE" >>"${BUILD_LOG:-/dev/null}" 2>&1 + msg_ok "Template re-downloaded" + fi - # Retry after repair - if ! pct create "$CTID" "${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE}" $PCT_OPTIONS >>"$LOGFILE" 2>&1; then - # Fallback to local storage if not already on local - if [[ "$TEMPLATE_STORAGE" != "local" ]]; then - msg_info "Retrying container creation with fallback to local storage" - LOCAL_TEMPLATE_PATH="/var/lib/vz/template/cache/$TEMPLATE" - if [[ ! -f "$LOCAL_TEMPLATE_PATH" ]]; then - msg_ok "Trying local storage fallback" - msg_info "Downloading template to local" - pveam download local "$TEMPLATE" >>"${BUILD_LOG:-/dev/null}" 2>&1 - msg_ok "Template downloaded to local" + # Retry after repair + if ! pct create "$CTID" "${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE}" $PCT_OPTIONS >>"$LOGFILE" 2>&1; then + # Fallback to local storage if not already on local + if [[ "$TEMPLATE_STORAGE" != "local" ]]; then + msg_info "Retrying container creation with fallback to local storage" + LOCAL_TEMPLATE_PATH="/var/lib/vz/template/cache/$TEMPLATE" + if [[ ! -f "$LOCAL_TEMPLATE_PATH" ]]; then + msg_ok "Trying local storage fallback" + msg_info "Downloading template to local" + pveam download local "$TEMPLATE" >>"${BUILD_LOG:-/dev/null}" 2>&1 + msg_ok "Template downloaded to local" + else + msg_ok "Trying local storage fallback" + fi + if ! pct create "$CTID" "local:vztmpl/${TEMPLATE}" $PCT_OPTIONS >>"$LOGFILE" 2>&1; then + # Local fallback also failed - check for LXC stack version issue + if grep -qiE 'unsupported .* version' "$LOGFILE"; then + msg_warn "pct reported 'unsupported version' – LXC stack might be too old for this template" + offer_lxc_stack_upgrade_and_maybe_retry "yes" + rc=$? + case $rc in + 0) : ;; # success - container created, continue + 2) + msg_error "Upgrade declined. Please update and re-run: apt update && apt install --only-upgrade pve-container lxc-pve" + _flush_pct_log + exit 231 + ;; + 3) + msg_error "Upgrade and/or retry failed. Please inspect: $LOGFILE" + _flush_pct_log + exit 231 + ;; + esac + else + msg_error "Container creation failed. See $LOGFILE" + if whiptail --yesno "pct create failed.\nDo you want to enable verbose debug mode and view detailed logs?" 12 70; then + set -x + pct create "$CTID" "local:vztmpl/${TEMPLATE}" $PCT_OPTIONS 2>&1 | tee -a "$LOGFILE" + set +x + fi + _flush_pct_log + exit 209 + fi + else + msg_ok "Container successfully created using local fallback." + fi else - msg_ok "Trying local storage fallback" - fi - if ! pct create "$CTID" "local:vztmpl/${TEMPLATE}" $PCT_OPTIONS >>"$LOGFILE" 2>&1; then - # Local fallback also failed - check for LXC stack version issue + # Already on local storage and still failed - check LXC stack version if grep -qiE 'unsupported .* version' "$LOGFILE"; then msg_warn "pct reported 'unsupported version' – LXC stack might be too old for this template" offer_lxc_stack_upgrade_and_maybe_retry "yes" @@ -5770,50 +5874,28 @@ create_lxc_container() { _flush_pct_log exit 209 fi - else - msg_ok "Container successfully created using local fallback." fi else - # Already on local storage and still failed - check LXC stack version - if grep -qiE 'unsupported .* version' "$LOGFILE"; then - msg_warn "pct reported 'unsupported version' – LXC stack might be too old for this template" - offer_lxc_stack_upgrade_and_maybe_retry "yes" - rc=$? - case $rc in - 0) : ;; # success - container created, continue - 2) - msg_error "Upgrade declined. Please update and re-run: apt update && apt install --only-upgrade pve-container lxc-pve" - _flush_pct_log - exit 231 - ;; - 3) - msg_error "Upgrade and/or retry failed. Please inspect: $LOGFILE" - _flush_pct_log - exit 231 - ;; - esac - else - msg_error "Container creation failed. See $LOGFILE" - if whiptail --yesno "pct create failed.\nDo you want to enable verbose debug mode and view detailed logs?" 12 70; then - set -x - pct create "$CTID" "local:vztmpl/${TEMPLATE}" $PCT_OPTIONS 2>&1 | tee -a "$LOGFILE" - set +x - fi - _flush_pct_log - exit 209 - fi + msg_ok "Container successfully created after template repair." fi - else - msg_ok "Container successfully created after template repair." - fi + fi # close CTID collision else-branch fi - # Verify container exists - pct list | awk '{print $1}' | grep -qx "$CTID" || { - msg_error "Container ID $CTID not listed in 'pct list'. See $LOGFILE" + # Verify container exists (allow up to 10s for pmxcfs sync in clusters) + local _pct_visible=false + for _pct_check in {1..10}; do + if pct list | awk '{print $1}' | grep -qx "$CTID"; then + _pct_visible=true + break + fi + sleep 1 + done + if [[ "$_pct_visible" != true ]]; then + msg_error "Container ID $CTID not listed in 'pct list' after 10s. See $LOGFILE" + msg_custom "🔧" "${YW}" "This can happen in clusters with pmxcfs sync delays." _flush_pct_log exit 215 - } + fi # Verify config rootfs grep -q '^rootfs:' "/etc/pve/lxc/$CTID.conf" || { @@ -5853,6 +5935,12 @@ create_lxc_container() { # ------------------------------------------------------------------------------ description() { IP=$(pct exec "$CTID" ip a s dev eth0 | awk '/inet / {print $2}' | cut -d/ -f1) + local script_slug script_url donate_url + + script_slug="${SCRIPT_SLUG:-${NSAPP}}" + script_slug="$(echo "$script_slug" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')" + script_url="https://community-scripts.org/scripts/${script_slug}" + donate_url="https://community-scripts.org/donate" # Generate LXC Description DESCRIPTION=$( @@ -5865,8 +5953,14 @@ description() {

${APP} LXC

- - spend Coffee + + Sponsoring and donations + +

+ +

+ + Open script page

@@ -5959,9 +6053,9 @@ ensure_log_on_host() { # TRAP MANAGEMENT # ============================================================================== # All traps (ERR, EXIT, INT, TERM, HUP) are set by catch_errors() in -# error-handler.func — called at the top of this file after sourcing. +# error_handler.func — called at the top of this file after sourcing. # -# Do NOT set duplicate traps here. The handlers in error-handler.func +# Do NOT set duplicate traps here. The handlers in error_handler.func # (on_exit, on_interrupt, on_terminate, on_hangup, error_handler) already: # - Send telemetry via post_update_to_api / _send_abort_telemetry # - Stop orphaned containers via _stop_container_if_installing diff --git a/scripts/core/core.func b/scripts/core/core.func index 38d918ac..bceb9494 100644 --- a/scripts/core/core.func +++ b/scripts/core/core.func @@ -143,7 +143,7 @@ ensure_profile_loaded() { # Source all profile.d scripts to ensure PATH is complete if [[ -d /etc/profile.d ]]; then for script in /etc/profile.d/*.sh; do - [[ -r "$script" ]] && source "$script" + [[ -r "$script" ]] && source "$script" || true done fi @@ -527,29 +527,23 @@ silent() { fi if [[ $rc -ne 0 ]]; then - # Source explain_exit_code if needed - if ! declare -f explain_exit_code >/dev/null 2>&1; then - if ! source "$(dirname "${BASH_SOURCE[0]}")/error-handler.func"; then - explain_exit_code() { echo "unknown (error-handler.func download failed)"; } - fi - fi - - local explanation - explanation="$(explain_exit_code "$rc")" - - printf "\e[?25h" - msg_error "in line ${caller_line}: exit code ${rc} (${explanation})" - msg_custom "→" "${YWB}" "${cmd}" - - if [[ -s "$logfile" ]]; then - echo -e "\n${TAB}--- Last 20 lines of log ---" - tail -n 20 "$logfile" - echo -e "${TAB}-----------------------------------" - echo -e "${TAB}📋 Full log: ${logfile}\n" - fi - - exit "$rc" - fi + # Return instead of exit so that callers can use `$STD cmd || true` + # or `if $STD cmd; then ...` to handle errors gracefully. + # When no || / if is used, set -e + ERR trap will still catch it + # and error_handler() will display the error and exit. + # + # Set flag so error_handler knows to show log tail from silent's logfile + export _SILENT_FAILED_RC="$rc" + export _SILENT_FAILED_CMD="$cmd" + export _SILENT_FAILED_LINE="$caller_line" + export _SILENT_FAILED_LOG="$logfile" + + return "$rc" + fi + + # Clear stale flags on success (prevents false positives if a previous + # $STD cmd || true failed and a later non-silent command triggers error_handler) + unset _SILENT_FAILED_RC _SILENT_FAILED_CMD _SILENT_FAILED_LINE _SILENT_FAILED_LOG 2>/dev/null || true } # ------------------------------------------------------------------------------ diff --git a/scripts/core/error-handler.func b/scripts/core/error-handler.func index b65ec496..d20ce559 100644 --- a/scripts/core/error-handler.func +++ b/scripts/core/error-handler.func @@ -15,7 +15,7 @@ # - Initialization function for trap setup # # Usage: -# source <(curl -fsSL .../error-handler.func) +# source <(curl -fsSL .../error_handler.func) # catch_errors # # ------------------------------------------------------------------------------ @@ -236,6 +236,16 @@ error_handler() { command="${command//\$STD/}" + # If error originated from silent(), use its captured metadata + # This provides the actual command and line number instead of "silent ..." + if [[ -n "${_SILENT_FAILED_RC:-}" ]]; then + exit_code="$_SILENT_FAILED_RC" + command="$_SILENT_FAILED_CMD" + line_number="$_SILENT_FAILED_LINE" + # Clear flags to prevent stale data on subsequent errors + unset _SILENT_FAILED_RC _SILENT_FAILED_CMD _SILENT_FAILED_LINE + fi + if [[ "$exit_code" -eq 0 ]]; then return 0 fi @@ -279,8 +289,12 @@ error_handler() { fi # Get active log file (BUILD_LOG or INSTALL_LOG) + # Prefer silent()'s logfile when available (contains the actual command output) local active_log="" - if declare -f get_active_logfile >/dev/null 2>&1; then + if [[ -n "${_SILENT_FAILED_LOG:-}" && -s "${_SILENT_FAILED_LOG}" ]]; then + active_log="$_SILENT_FAILED_LOG" + unset _SILENT_FAILED_LOG + elif declare -f get_active_logfile >/dev/null 2>&1; then active_log="$(get_active_logfile)" elif [[ -n "${SILENT_LOGFILE:-}" ]]; then active_log="$SILENT_LOGFILE" diff --git a/scripts/core/install.func b/scripts/core/install.func index 0870b20a..075b9142 100755 --- a/scripts/core/install.func +++ b/scripts/core/install.func @@ -28,9 +28,9 @@ # ============================================================================== if ! command -v curl >/dev/null 2>&1; then - printf "\r\e[2K%b" '\033[93m Setup Source \033[m' >&2 - apt update >/dev/null 2>&1 - apt install -y curl >/dev/null 2>&1 + printf "\r\e[2K%b" '\033[93m Setup Source \033[m' >&2 + apt update >/dev/null 2>&1 + apt install -y curl >/dev/null 2>&1 fi source "$(dirname "${BASH_SOURCE[0]}")/core.func" source "$(dirname "${BASH_SOURCE[0]}")/error-handler.func" @@ -40,8 +40,8 @@ catch_errors # Persist diagnostics setting inside container (exported from build.func) # so addon scripts running later can find the user's choice if [[ ! -f /usr/local/community-scripts/diagnostics ]]; then - mkdir -p /usr/local/community-scripts - echo "DIAGNOSTICS=${DIAGNOSTICS:-no}" >/usr/local/community-scripts/diagnostics + mkdir -p /usr/local/community-scripts + echo "DIAGNOSTICS=${DIAGNOSTICS:-no}" >/usr/local/community-scripts/diagnostics fi # Get LXC IP address (must be called INSIDE container, after network is up) @@ -59,15 +59,15 @@ get_lxc_ip # - Only executes if DIAGNOSTICS=yes and RANDOM_UUID is set # ------------------------------------------------------------------------------ post_progress_to_api() { - command -v curl &>/dev/null || return 0 - [[ "${DIAGNOSTICS:-no}" == "no" ]] && return 0 - [[ -z "${RANDOM_UUID:-}" ]] && return 0 + command -v curl &>/dev/null || return 0 + [[ "${DIAGNOSTICS:-no}" == "no" ]] && return 0 + [[ -z "${RANDOM_UUID:-}" ]] && return 0 - local progress_status="${1:-configuring}" + local progress_status="${1:-configuring}" - curl -fsS -m 5 -X POST "https://telemetry.community-scripts.org/telemetry" \ - -H "Content-Type: application/json" \ - -d "{\"random_id\":\"${RANDOM_UUID}\",\"execution_id\":\"${EXECUTION_ID:-${RANDOM_UUID}}\",\"type\":\"lxc\",\"nsapp\":\"${app:-unknown}\",\"status\":\"${progress_status}\"}" &>/dev/null || true + curl -fsS -m 5 -X POST "https://telemetry.community-scripts.org/telemetry" \ + -H "Content-Type: application/json" \ + -d "{\"random_id\":\"${RANDOM_UUID}\",\"execution_id\":\"${EXECUTION_ID:-${RANDOM_UUID}}\",\"type\":\"lxc\",\"nsapp\":\"${app:-unknown}\",\"status\":\"${progress_status}\"}" &>/dev/null || true } # ============================================================================== @@ -82,20 +82,20 @@ post_progress_to_api() { # - Sets verbose mode via set_std_mode() # ------------------------------------------------------------------------------ verb_ip6() { - set_std_mode # Set STD mode based on VERBOSE + set_std_mode # Set STD mode based on VERBOSE - if [ "${IPV6_METHOD:-}" = "disable" ]; then - msg_info "Disabling IPv6 (this may affect some services)" - mkdir -p /etc/sysctl.d - $STD tee /etc/sysctl.d/99-disable-ipv6.conf >/dev/null </dev/null </dev/null) || true - fi - - 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 121 - 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)" - post_progress_to_api + msg_info "Setting up Container OS" + + # Fix Debian 13 LXC template bug where / is owned by nobody + # Only attempt in privileged containers (unprivileged cannot chown /) + if [[ "$(stat -c '%U' /)" != "root" ]]; then + (chown root:root / 2>/dev/null) || true + fi + + 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 121 + 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)" + post_progress_to_api } # ------------------------------------------------------------------------------ @@ -148,62 +148,62 @@ setting_up_container() { # - Uses fatal() on DNS resolution failure for critical hosts # ------------------------------------------------------------------------------ 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 122 - 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' ERR + 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 122 + 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' ERR } # ============================================================================== @@ -220,161 +220,161 @@ network_check() { # - Detects hash mismatch, SSL errors, and generic apt failures # ------------------------------------------------------------------------------ apt_update_safe() { - if $STD apt-get update; then - return 0 - fi - - local failed_mirror - failed_mirror=$(grep -m1 -oP '(?<=URIs: https?://)[^/]+' /etc/apt/sources.list.d/debian.sources 2>/dev/null || grep -m1 -oP '(?<=deb https?://)[^/]+' /etc/apt/sources.list 2>/dev/null || echo "unknown") - msg_warn "apt-get update failed (${failed_mirror}), trying alternate mirrors..." - - local distro - distro=$(. /etc/os-release 2>/dev/null && echo "$ID" || echo "debian") - - local eu_mirrors us_mirrors ap_mirrors - if [[ "$distro" == "ubuntu" ]]; then - eu_mirrors="de.archive.ubuntu.com fr.archive.ubuntu.com se.archive.ubuntu.com nl.archive.ubuntu.com it.archive.ubuntu.com ch.archive.ubuntu.com mirrors.xtom.de" - us_mirrors="us.archive.ubuntu.com archive.ubuntu.com mirrors.edge.kernel.org mirror.csclub.uwaterloo.ca mirrors.ocf.berkeley.edu mirror.math.princeton.edu" - ap_mirrors="au.archive.ubuntu.com jp.archive.ubuntu.com kr.archive.ubuntu.com tw.archive.ubuntu.com mirror.aarnet.edu.au" - else - eu_mirrors="ftp.de.debian.org ftp.fr.debian.org ftp.nl.debian.org ftp.uk.debian.org ftp.ch.debian.org ftp.se.debian.org ftp.it.debian.org ftp.fau.de ftp.halifax.rwth-aachen.de debian.mirror.lrz.de mirror.init7.net debian.ethz.ch mirrors.dotsrc.org debian.mirrors.ovh.net" - us_mirrors="ftp.us.debian.org ftp.ca.debian.org debian.csail.mit.edu mirrors.ocf.berkeley.edu mirrors.wikimedia.org debian.osuosl.org mirror.cogentco.com" - ap_mirrors="ftp.au.debian.org ftp.jp.debian.org ftp.tw.debian.org ftp.kr.debian.org ftp.hk.debian.org ftp.sg.debian.org mirror.aarnet.edu.au mirror.nitc.ac.in" - fi - - local tz regional others - tz=$(cat /etc/timezone 2>/dev/null || echo "UTC") - case "$tz" in - Europe/* | Arctic/*) - regional="$eu_mirrors" - others="$us_mirrors $ap_mirrors" - ;; - America/*) - regional="$us_mirrors" - others="$eu_mirrors $ap_mirrors" - ;; - Asia/* | Australia/* | Pacific/*) - regional="$ap_mirrors" - others="$eu_mirrors $us_mirrors" - ;; - *) - regional="" - others="$eu_mirrors $us_mirrors $ap_mirrors" - ;; - esac - - echo 'Acquire::By-Hash "no";' >/etc/apt/apt.conf.d/99no-by-hash - - _try_apt_mirror() { - local m=$1 - for src in /etc/apt/sources.list.d/debian.sources /etc/apt/sources.list; do - [[ -f "$src" ]] && sed -i "s|URIs: http[s]*://[^/]*/|URIs: http://${m}/|g; s|deb http[s]*://[^/]*/|deb http://${m}/|g" "$src" - done - rm -rf /var/lib/apt/lists/* - local out - out=$(apt-get update 2>&1) - if echo "$out" | grep -qi "hashsum\|hash sum"; then - msg_warn "Mirror ${m} failed (hash mismatch)" - return 1 - elif echo "$out" | grep -qi "SSL\|certificate"; then - msg_warn "Mirror ${m} failed (SSL/certificate error)" - return 1 - elif echo "$out" | grep -q "^E:"; then - msg_warn "Mirror ${m} failed (apt-get update error)" - return 1 - else - msg_ok "CDN set to ${m}: tests passed" - return 0 - fi - } - - _scan_reachable() { - local result="" - for m in $1; do - if timeout 2 bash -c "echo >/dev/tcp/$m/80" 2>/dev/null; then - result="$result $m" - fi - done - echo "$result" | xargs - } - - local apt_ok=false - - # Phase 1: Scan global mirrors first (independent of local CDN issues) - local others_ok - others_ok=$(_scan_reachable "$others") - local others_pick - others_pick=$(printf '%s\n' $others_ok | shuf | head -3 | xargs) - - for mirror in $others_pick; do - msg_custom "${INFO}" "${YW}" "Attempting mirror: ${mirror}" - if _try_apt_mirror "$mirror"; then - apt_ok=true - break - fi - done - - # Phase 2: Try primary mirror - if [[ "$apt_ok" != true ]]; then - local primary - if [[ "$distro" == "ubuntu" ]]; then - primary="archive.ubuntu.com" - else - primary="ftp.debian.org" - fi - if timeout 2 bash -c "echo >/dev/tcp/$primary/80" 2>/dev/null; then - msg_custom "${INFO}" "${YW}" "Attempting mirror: ${primary}" - if _try_apt_mirror "$primary"; then - apt_ok=true - fi - fi - fi - - # Phase 3: Fall back to regional mirrors - if [[ "$apt_ok" != true ]]; then - local regional_ok - regional_ok=$(_scan_reachable "$regional") - local regional_pick - regional_pick=$(printf '%s\n' $regional_ok | shuf | head -3 | xargs) - - for mirror in $regional_pick; do - msg_custom "${INFO}" "${YW}" "Attempting mirror: ${mirror}" - if _try_apt_mirror "$mirror"; then - apt_ok=true - break - fi - done - fi - - # Phase 4: All auto mirrors failed, prompt user - if [[ "$apt_ok" != true ]]; then - msg_warn "Multiple mirrors failed (possible CDN synchronization issue)." - if [[ "$distro" == "ubuntu" ]]; then - msg_warn "Find Ubuntu mirrors at: https://launchpad.net/ubuntu/+archivemirrors" - else - msg_warn "Find Debian mirrors at: https://www.debian.org/mirror/list" - fi - local custom_mirror - while true; do - read -rp " Enter a mirror hostname (or 'skip' to abort): " custom_mirror /dev/null || grep -m1 -oP '(?<=deb https?://)[^/]+' /etc/apt/sources.list 2>/dev/null || echo "unknown") + msg_warn "apt-get update failed (${failed_mirror}), trying alternate mirrors..." + + local distro + distro=$(. /etc/os-release 2>/dev/null && echo "$ID" || echo "debian") + + local eu_mirrors us_mirrors ap_mirrors + if [[ "$distro" == "ubuntu" ]]; then + eu_mirrors="de.archive.ubuntu.com fr.archive.ubuntu.com se.archive.ubuntu.com nl.archive.ubuntu.com it.archive.ubuntu.com ch.archive.ubuntu.com mirrors.xtom.de" + us_mirrors="us.archive.ubuntu.com archive.ubuntu.com mirrors.edge.kernel.org mirror.csclub.uwaterloo.ca mirrors.ocf.berkeley.edu mirror.math.princeton.edu" + ap_mirrors="au.archive.ubuntu.com jp.archive.ubuntu.com kr.archive.ubuntu.com tw.archive.ubuntu.com mirror.aarnet.edu.au" + else + eu_mirrors="ftp.de.debian.org ftp.fr.debian.org ftp.nl.debian.org ftp.uk.debian.org ftp.ch.debian.org ftp.se.debian.org ftp.it.debian.org ftp.fau.de ftp.halifax.rwth-aachen.de debian.mirror.lrz.de mirror.init7.net debian.ethz.ch mirrors.dotsrc.org debian.mirrors.ovh.net" + us_mirrors="ftp.us.debian.org ftp.ca.debian.org debian.csail.mit.edu mirrors.ocf.berkeley.edu mirrors.wikimedia.org debian.osuosl.org mirror.cogentco.com" + ap_mirrors="ftp.au.debian.org ftp.jp.debian.org ftp.tw.debian.org ftp.kr.debian.org ftp.hk.debian.org ftp.sg.debian.org mirror.aarnet.edu.au mirror.nitc.ac.in" + fi + + local tz regional others + tz=$(cat /etc/timezone 2>/dev/null || echo "UTC") + case "$tz" in + Europe/* | Arctic/*) + regional="$eu_mirrors" + others="$us_mirrors $ap_mirrors" + ;; + America/*) + regional="$us_mirrors" + others="$eu_mirrors $ap_mirrors" + ;; + Asia/* | Australia/* | Pacific/*) + regional="$ap_mirrors" + others="$eu_mirrors $us_mirrors" + ;; + *) + regional="" + others="$eu_mirrors $us_mirrors $ap_mirrors" + ;; + esac + + echo 'Acquire::By-Hash "no";' >/etc/apt/apt.conf.d/99no-by-hash + + _try_apt_mirror() { + local m=$1 + for src in /etc/apt/sources.list.d/debian.sources /etc/apt/sources.list; do + [[ -f "$src" ]] && sed -i "s|URIs: http[s]*://[^/]*/|URIs: http://${m}/|g; s|deb http[s]*://[^/]*/|deb http://${m}/|g" "$src" + done + rm -rf /var/lib/apt/lists/* + local out + out=$(apt-get update 2>&1) + if echo "$out" | grep -qi "hashsum\|hash sum"; then + msg_warn "Mirror ${m} failed (hash mismatch)" + return 1 + elif echo "$out" | grep -qi "SSL\|certificate"; then + msg_warn "Mirror ${m} failed (SSL/certificate error)" + return 1 + elif echo "$out" | grep -q "^E:"; then + msg_warn "Mirror ${m} failed (apt-get update error)" + return 1 + else + msg_ok "CDN set to ${m}: tests passed" + return 0 + fi + } + + _scan_reachable() { + local result="" + for m in $1; do + if timeout 2 bash -c "echo >/dev/tcp/$m/80" 2>/dev/null; then + result="$result $m" + fi + done + echo "$result" | xargs + } + + local apt_ok=false + + # Phase 1: Scan global mirrors first (independent of local CDN issues) + local others_ok + others_ok=$(_scan_reachable "$others") + local others_pick + others_pick=$(printf '%s\n' $others_ok | shuf | head -3 | xargs) + + for mirror in $others_pick; do + msg_custom "${INFO}" "${YW}" "Attempting mirror: ${mirror}" + if _try_apt_mirror "$mirror"; then + apt_ok=true + break + fi + done + + # Phase 2: Try primary mirror + if [[ "$apt_ok" != true ]]; then + local primary + if [[ "$distro" == "ubuntu" ]]; then + primary="archive.ubuntu.com" + else + primary="ftp.debian.org" + fi + if timeout 2 bash -c "echo >/dev/tcp/$primary/80" 2>/dev/null; then + msg_custom "${INFO}" "${YW}" "Attempting mirror: ${primary}" + if _try_apt_mirror "$primary"; then + apt_ok=true + fi + fi + fi + + # Phase 3: Fall back to regional mirrors + if [[ "$apt_ok" != true ]]; then + local regional_ok + regional_ok=$(_scan_reachable "$regional") + local regional_pick + regional_pick=$(printf '%s\n' $regional_ok | shuf | head -3 | xargs) + + for mirror in $regional_pick; do + msg_custom "${INFO}" "${YW}" "Attempting mirror: ${mirror}" + if _try_apt_mirror "$mirror"; then + apt_ok=true + break + fi + done + fi + + # Phase 4: All auto mirrors failed, prompt user + if [[ "$apt_ok" != true ]]; then + msg_warn "Multiple mirrors failed (possible CDN synchronization issue)." + if [[ "$distro" == "ubuntu" ]]; then + msg_warn "Find Ubuntu mirrors at: https://launchpad.net/ubuntu/+archivemirrors" + else + msg_warn "Find Debian mirrors at: https://www.debian.org/mirror/list" + fi + local custom_mirror + while true; do + read -rp " Enter a mirror hostname (or 'skip' to abort): " custom_mirror /etc/apt/apt.conf.d/00aptproxy - cat </usr/local/bin/apt-proxy-detect.sh + 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 + local _proxy_raw="${CACHER_IP}" + local _proxy_host _proxy_port _proxy_url + # Parse host and port from URL or plain IP/hostname + _proxy_host=$(echo "$_proxy_raw" | sed -e 's|https\?://||' -e 's|/.*||' | cut -d: -f1) + _proxy_port=$(echo "$_proxy_raw" | sed -e 's|https\?://||' -e 's|/.*||' | cut -s -d: -f2) + if [[ "$_proxy_raw" =~ ^https?:// ]]; then + # Full URL provided — use as-is for proxy output, extract port for nc check + _proxy_url="$_proxy_raw" + _proxy_port="${_proxy_port:-80}" + else + # Legacy: plain IP or hostname — default to http + port 3142 + _proxy_port="${_proxy_port:-3142}" + _proxy_url="http://${_proxy_raw}:${_proxy_port}" + fi + cat </usr/local/bin/apt-proxy-detect.sh #!/bin/bash -if nc -w1 -z "${CACHER_IP}" 3142; then - echo -n "http://${CACHER_IP}:3142" +if nc -w1 -z "${_proxy_host}" ${_proxy_port}; then + echo -n "${_proxy_url}" else echo -n "DIRECT" fi EOF - chmod +x /usr/local/bin/apt-proxy-detect.sh - fi - apt_update_safe - $STD apt-get -o Dpkg::Options::="--force-confold" -y dist-upgrade - rm -rf /usr/lib/python3.*/EXTERNALLY-MANAGED - msg_ok "Updated Container OS" - post_progress_to_api - - source "$(dirname "${BASH_SOURCE[0]}")/tools.func" + chmod +x /usr/local/bin/apt-proxy-detect.sh + fi + apt_update_safe + $STD apt-get -o Dpkg::Options::="--force-confold" -y dist-upgrade + rm -rf /usr/lib/python3.*/EXTERNALLY-MANAGED + msg_ok "Updated Container OS" + post_progress_to_api + + local tools_content + local _tools_local + _tools_local="$(dirname "${BASH_SOURCE[0]}")/tools.func" + if [[ -f "$_tools_local" ]]; then + tools_content=$(cat "$_tools_local") || { + msg_error "Failed to load tools.func from local" + exit 115 + } + else + tools_content=$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/tools.func) || { + msg_error "Failed to download tools.func" + exit 115 + } + fi + source /dev/stdin <<<"$tools_content" + if ! declare -f fetch_and_deploy_gh_release >/dev/null 2>&1; then + msg_error "tools.func loaded but incomplete — missing expected functions" + exit 115 + fi } # ============================================================================== @@ -428,26 +460,26 @@ EOF # - Configures TERM environment variable for better terminal support # ------------------------------------------------------------------------------ motd_ssh() { - # Set terminal to 256-color mode - grep -qxF "export TERM='xterm-256color'" /root/.bashrc || echo "export TERM='xterm-256color'" >>/root/.bashrc - - 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}\$(grep ^NAME /etc/os-release | cut -d= -f2 | tr -d '\"') - Version: \$(grep ^VERSION_ID /etc/os-release | cut -d= -f2 | tr -d '\"')${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 - post_progress_to_api + # Set terminal to 256-color mode + grep -qxF "export TERM='xterm-256color'" /root/.bashrc || echo "export TERM='xterm-256color'" >>/root/.bashrc + + 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}\$(grep ^NAME /etc/os-release | cut -d= -f2 | tr -d '\"') - Version: \$(grep ^VERSION_ID /etc/os-release | cut -d= -f2 | tr -d '\"')${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 + post_progress_to_api } # ============================================================================== @@ -464,27 +496,27 @@ motd_ssh() { # - Sets proper permissions on SSH directories and key files # ------------------------------------------------------------------------------ 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" + 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 - echo "bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${app}.sh)\"" >/usr/bin/update - chmod +x /usr/bin/update - - 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 - post_progress_to_api + systemctl daemon-reload + systemctl restart "$(basename "$(dirname "$GETTY_OVERRIDE")" | sed 's/\.d//')" + msg_ok "Customized Container" + fi + echo "bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${app}.sh)\"" >/usr/bin/update + chmod +x /usr/bin/update + + 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 + post_progress_to_api } diff --git a/scripts/core/tools.func b/scripts/core/tools.func index 52c6cb3f..eb64a9a9 100644 --- a/scripts/core/tools.func +++ b/scripts/core/tools.func @@ -1117,15 +1117,90 @@ is_package_installed() { fi } +# ------------------------------------------------------------------------------ +# validate_github_token() +# Checks a GitHub token via the /user endpoint. +# Prints a status message and returns: +# 0 - token is valid +# 1 - token is invalid / expired (HTTP 401) +# 2 - token has no public repo scope (HTTP 200 but missing scope) +# 3 - network/API error +# Also reports expiry date if the token carries an x-oauth-expiry header. +# ------------------------------------------------------------------------------ +validate_github_token() { + local token="${1:-${GITHUB_TOKEN:-}}" + [[ -z "$token" ]] && return 3 + + local response headers http_code expiry_date scopes + headers=$(mktemp) + response=$(curl -sSL -w "%{http_code}" \ + -D "$headers" \ + -o /dev/null \ + -H "Authorization: Bearer $token" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/user" 2>/dev/null) || { + rm -f "$headers" + return 3 + } + http_code="$response" + + # Read expiry header (fine-grained PATs carry this) + expiry_date=$(grep -i '^github-authentication-token-expiration:' "$headers" | + sed 's/.*: *//' | tr -d '\r\n' || true) + # Read token scopes (classic PATs) + scopes=$(grep -i '^x-oauth-scopes:' "$headers" | + sed 's/.*: *//' | tr -d '\r\n' || true) + rm -f "$headers" + + case "$http_code" in + 200) + if [[ -n "$expiry_date" ]]; then + msg_ok "GitHub token is valid (expires: $expiry_date)." + else + msg_ok "GitHub token is valid (no expiry / fine-grained PAT)." + fi + # Warn if classic PAT has no public_repo scope + if [[ -n "$scopes" && "$scopes" != *"public_repo"* && "$scopes" != *"repo"* ]]; then + msg_warn "Token has no 'public_repo' scope - private repos and some release APIs may fail." + return 2 + fi + return 0 + ;; + 401) + msg_error "GitHub token is invalid or expired (HTTP 401)." + return 1 + ;; + *) + msg_warn "GitHub token validation returned HTTP $http_code - treating as valid." + return 0 + ;; + esac +} + # ------------------------------------------------------------------------------ # Prompt user to enter a GitHub Personal Access Token (PAT) interactively # Returns 0 if a valid token was provided, 1 otherwise # ------------------------------------------------------------------------------ prompt_for_github_token() { if [[ ! -t 0 ]]; then + # Non-interactive: pick up var_github_token if set (from default.vars / app.vars / env) + if [[ -z "${GITHUB_TOKEN:-}" && -n "${var_github_token:-}" ]]; then + export GITHUB_TOKEN="${var_github_token}" + msg_ok "GitHub token loaded from var_github_token." + return 0 + fi return 1 fi + # Prefer var_github_token when already set and no interactive override needed + if [[ -z "${GITHUB_TOKEN:-}" && -n "${var_github_token:-}" ]]; then + export GITHUB_TOKEN="${var_github_token}" + msg_ok "GitHub token loaded from var_github_token." + validate_github_token || true + return 0 + fi + local reply read -rp "${TAB}Would you like to enter a GitHub Personal Access Token (PAT)? [y/N]: " reply reply="${reply:-n}" @@ -1147,10 +1222,16 @@ prompt_for_github_token() { msg_warn "Token must not contain spaces. Please try again." continue fi - break + # Validate before accepting + export GITHUB_TOKEN="$token" + if validate_github_token "$token"; then + break + else + msg_warn "Please enter a valid token, or press Ctrl+C to abort." + unset GITHUB_TOKEN + fi done - export GITHUB_TOKEN="$token" msg_ok "GitHub token has been set." return 0 } @@ -2860,7 +2941,7 @@ function fetch_and_deploy_codeberg_release() { while ((attempt < ${#api_timeouts[@]})); do resp=$(curl --connect-timeout 10 --max-time "${api_timeouts[$attempt]}" -fsSL -w "%{http_code}" -o /tmp/codeberg_rel.json "$api_url") && success=true && break - ((attempt++)) + attempt=$((attempt + 1)) if ((attempt < ${#api_timeouts[@]})); then msg_warn "API request timed out after ${api_timeouts[$((attempt - 1))]}s, retrying... (attempt $((attempt + 1))/${#api_timeouts[@]})" fi @@ -3370,7 +3451,8 @@ function fetch_and_deploy_gh_release() { if prompt_for_github_token; then header=(-H "Authorization: token $GITHUB_TOKEN") retry_delay=2 - attempt=0 + attempt=1 + continue fi fi else @@ -4458,7 +4540,7 @@ function setup_hwaccel() { local in_ct="${CTTYPE:-0}" - # ═════════════════════════════════════════════════════�����═════════════════════ + # ═══════════════════════════════════════════════════════════════════════════ # Process Selected GPUs # ═══════════════════════════════════════════════════════════════════════════ for idx in "${SELECTED_INDICES[@]}"; do diff --git a/scripts/tools/addon/arcane.sh b/scripts/tools/addon/arcane.sh new file mode 100644 index 00000000..b63b8306 --- /dev/null +++ b/scripts/tools/addon/arcane.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash + +# Copyright (c) 2021-2026 community-scripts ORG +# Author: summoningpixels +# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE +# Source: https://github.com/getarcaneapp/arcane +if ! command -v curl &>/dev/null; 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 <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/core.func) +source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/tools.func) +source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/error_handler.func) +source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/api.func) 2>/dev/null || true +declare -f init_tool_telemetry &>/dev/null && init_tool_telemetry "arcane" "addon" + +# Enable error handling +set -Eeuo pipefail +trap 'error_handler' ERR + +# ============================================================================== +# CONFIGURATION +# ============================================================================== +APP="Arcane" +APP_TYPE="addon" +INSTALL_PATH="/opt/arcane" +COMPOSE_FILE="${INSTALL_PATH}/compose.yaml" +ENV_FILE="${INSTALL_PATH}/.env" +DEFAULT_PORT=3552 + +# Initialize all core functions (colors, formatting, icons, STD mode) +load_functions + +# ============================================================================== +# HEADER +# ============================================================================== +function header_info { + clear + cat <<"EOF" + ___ ____ _________ _ ________ + / | / __ \/ ____/ | / | / / ____/ + / /| | / /_/ / / / /| | / |/ / __/ + / ___ |/ _, _/ /___/ ___ |/ /| / /___ +/_/ |_/_/ |_|\____/_/ |_/_/ |_/_____/ + +EOF +} + +# ============================================================================== +# UNINSTALL +# ============================================================================== +function uninstall() { + msg_info "Uninstalling ${APP}" + + if [[ -f "$COMPOSE_FILE" ]]; then + msg_info "Stopping and removing Docker containers" + cd "$INSTALL_PATH" + $STD docker compose down --volumes --remove-orphans + msg_ok "Stopped and removed Docker containers" + fi + + rm -rf "$INSTALL_PATH" + rm -f "/usr/local/bin/update_arcane" + msg_ok "${APP} has been uninstalled" +} + +# ============================================================================== +# UPDATE +# ============================================================================== +function update() { + msg_info "Pulling latest ${APP} image" + cd "$INSTALL_PATH" + $STD docker compose pull + msg_ok "Pulled latest image" + + msg_info "Restarting ${APP}" + $STD docker compose up -d --remove-orphans + msg_ok "Restarted ${APP}" + + msg_ok "Updated successfully" + exit +} + +# ============================================================================== +# CHECK DOCKER +# ============================================================================== +function check_docker() { + if ! command -v docker &>/dev/null; then + msg_error "Docker is not installed. This script requires an existing Docker LXC. Exiting." + exit 10 + fi + if ! docker compose version &>/dev/null; then + msg_error "Docker Compose plugin is not available. Please install it before running this script. Exiting." + exit 10 + fi + msg_ok "Docker $(docker --version | cut -d' ' -f3 | tr -d ',') and Docker Compose are available" +} + +# ============================================================================== +# INSTALL +# ============================================================================== +function install() { + check_docker + + msg_info "Creating install directory" + mkdir -p "$INSTALL_PATH" + msg_ok "Created ${INSTALL_PATH}" + + # Generate secrets and config values + local ENCRYPTION_KEY JWT_SECRET PROJ_DIR + ENCRYPTION_KEY=$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c32) + JWT_SECRET=$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c32) + PROJ_DIR="/etc/arcane/projects" + + msg_info "Creating stacks directory" + mkdir -p "$PROJ_DIR" + msg_ok "Created ${PROJ_DIR}" + + msg_info "Downloading Docker Compose file" + curl -fsSL "https://raw.githubusercontent.com/getarcaneapp/arcane/refs/heads/main/docker/examples/compose.basic.yaml" -o "$COMPOSE_FILE" + msg_ok "Downloaded Docker Compose file" + + msg_info "Downloading .env file" + curl -fsSL "https://raw.githubusercontent.com/getarcaneapp/arcane/refs/heads/main/.env.example" -o "$ENV_FILE" + chmod 600 "$ENV_FILE" + msg_ok "Downloaded .env file" + + msg_info "Configuring compose and env files" + sed -i '/^[[:space:]]*#/!s|/host/path/to/projects|'"$PROJ_DIR"'|g' "$COMPOSE_FILE" + sed -i '/^[[:space:]]*#/!s|ENCRYPTION_KEY=.*|ENCRYPTION_KEY='"$ENCRYPTION_KEY"'|g' "$COMPOSE_FILE" + sed -i '/^[[:space:]]*#/!s|JWT_SECRET=.*|JWT_SECRET='"$JWT_SECRET"'|g' "$COMPOSE_FILE" + sed -i '/^[[:space:]]*#/!s|APP_URL=.*|APP_URL=http://localhost:'"$DEFAULT_PORT"'|g' "$ENV_FILE" + sed -i '/^[[:space:]]*#/!s|ENCRYPTION_KEY=.*|#&|g' "$ENV_FILE" + sed -i '/^[[:space:]]*#/!s|JWT_SECRET=.*|#&|g' "$ENV_FILE" + msg_ok "Configured compose and env files" + + msg_info "Starting ${APP}" + cd "$INSTALL_PATH" + $STD docker compose up -d + msg_ok "Started ${APP}" + + # Create update script + msg_info "Creating update script" + cat <<'UPDATEEOF' >/usr/local/bin/update_arcane +#!/usr/bin/env bash +# Arcane Update Script +type=update bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/tools/addon/arcane.sh)" +UPDATEEOF + chmod +x /usr/local/bin/update_arcane + msg_ok "Created update script (/usr/local/bin/update_arcane)" + + echo "" + msg_ok "${APP} is reachable at: ${BL}http://${LOCAL_IP}:${DEFAULT_PORT}${CL}" + echo "" + echo -e "Arcane Credentials" + echo -e "==================" + echo -e "User: arcane" + echo -e "Password: arcane-admin" + echo "" + msg_warn "On first access, you'll be prompted to change your password." +} + +# ============================================================================== +# MAIN +# ============================================================================== + +# Handle type=update (called from update script) +if [[ "${type:-}" == "update" ]]; then + header_info + if [[ -f "$COMPOSE_FILE" ]]; then + update + else + msg_error "${APP} is not installed. Nothing to update." + exit 233 + fi + exit 0 +fi + +header_info +get_lxc_ip + +# Check if already installed +if [[ -f "$COMPOSE_FILE" ]]; then + msg_warn "${APP} is already installed." + echo "" + + echo -n "${TAB}Uninstall ${APP}? (y/N): " + read -r uninstall_prompt + if [[ "${uninstall_prompt,,}" =~ ^(y|yes)$ ]]; then + uninstall + exit 0 + fi + + echo -n "${TAB}Update ${APP}? (y/N): " + read -r update_prompt + if [[ "${update_prompt,,}" =~ ^(y|yes)$ ]]; then + update + exit 0 + fi + + msg_warn "No action selected. Exiting." + exit 0 +fi + +# Fresh installation +msg_warn "${APP} is not installed." +echo "" +echo -e "${TAB}${INFO} This will install:" +echo -e "${TAB} - Arcane (via Docker Compose)" +echo "" + +echo -n "${TAB}Install ${APP}? (y/N): " +read -r install_prompt +if [[ "${install_prompt,,}" =~ ^(y|yes)$ ]]; then + install +else + msg_warn "Installation cancelled. Exiting." + exit 0 +fi diff --git a/server.js b/server.js index edfb1085..49990891 100644 --- a/server.js +++ b/server.js @@ -332,7 +332,7 @@ class ScriptExecutionHandler { } else if (isBackup && containerId && storage) { await this.startBackupExecution(ws, containerId, executionId, storage, mode, resolved); } else if (isUpdate && containerId) { - await this.startUpdateExecution(ws, containerId, executionId, mode, resolved, backupStorage); + await this.startUpdateExecution(ws, containerId, executionId, mode, resolved, backupStorage, envVars); } else if (isShell && containerId) { await this.startShellExecution(ws, containerId, executionId, mode, resolved, containerType); } else { @@ -1303,7 +1303,7 @@ class ScriptExecutionHandler { * @param {ServerInfo|undefined} server * @param {string} [backupStorage] - Optional storage to backup to before update */ - async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = undefined, backupStorage = undefined) { + async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = undefined, backupStorage = undefined, envVars = {}) { try { // If backup storage is provided, run backup first if (backupStorage && mode === 'ssh' && server) { @@ -1364,9 +1364,9 @@ class ScriptExecutionHandler { }); if (mode === 'ssh' && server) { - await this.startSSHUpdateExecution(ws, containerId, executionId, server); + await this.startSSHUpdateExecution(ws, containerId, executionId, server, envVars); } else { - await this.startLocalUpdateExecution(ws, containerId, executionId); + await this.startLocalUpdateExecution(ws, containerId, executionId, envVars); } } catch (error) { @@ -1384,7 +1384,7 @@ class ScriptExecutionHandler { * @param {string} containerId * @param {string} executionId */ - async startLocalUpdateExecution(ws, containerId, executionId) { + async startLocalUpdateExecution(ws, containerId, executionId, envVars = {}) { const { spawn } = await import('node-pty'); // Create a shell process that will run pct enter and then update @@ -1411,9 +1411,24 @@ class ScriptExecutionHandler { }); }); + // Build env export commands (e.g. for PHS_SILENT=1) + const envExports = Object.entries(envVars) + .filter(([key]) => key.startsWith('PHS_') || key.startsWith('var_')) + .map(([key, value]) => { + const safeValue = String(value) + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"'); + return `export ${key}="${safeValue}"`; + }) + .join('; '); + // Send the update command after a delay to ensure we're in the container setTimeout(() => { - childProcess.write('update\n'); + if (envExports) { + childProcess.write(`${envExports}; update\n`); + } else { + childProcess.write('update\n'); + } }, 4000); // Handle process exit @@ -1435,7 +1450,7 @@ class ScriptExecutionHandler { * @param {string} executionId * @param {ServerInfo} server */ - async startSSHUpdateExecution(ws, containerId, executionId, server) { + async startSSHUpdateExecution(ws, containerId, executionId, server, envVars = {}) { const sshService = getSSHExecutionService(); try { @@ -1476,9 +1491,24 @@ class ScriptExecutionHandler { ws }); + // Build env export commands (e.g. for PHS_SILENT=1) + const envExports = Object.entries(envVars) + .filter(([key]) => key.startsWith('PHS_') || key.startsWith('var_')) + .map(([key, value]) => { + const safeValue = String(value) + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"'); + return `export ${key}="${safeValue}"`; + }) + .join('; '); + // Send the update command after a delay to ensure we're in the container setTimeout(() => { - /** @type {any} */ (execution).process.write('update\n'); + if (envExports) { + /** @type {any} */ (execution).process.write(`${envExports}; update\n`); + } else { + /** @type {any} */ (execution).process.write('update\n'); + } }, 4000); } catch (error) { diff --git a/src/app/_components/AppearanceButton.tsx b/src/app/_components/AppearanceButton.tsx new file mode 100644 index 00000000..f3978ec6 --- /dev/null +++ b/src/app/_components/AppearanceButton.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useState } from "react"; +import { AppearanceModal } from "./AppearanceModal"; +import { Button } from "./ui/button"; +import { Paintbrush } from "lucide-react"; + +export function AppearanceButton() { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + + setIsOpen(false)} /> + + ); +} diff --git a/src/app/_components/AppearanceModal.tsx b/src/app/_components/AppearanceModal.tsx new file mode 100644 index 00000000..ecfb67d0 --- /dev/null +++ b/src/app/_components/AppearanceModal.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "./ui/button"; +import { useRegisterModal, ModalPortal } from "./modal/ModalStackProvider"; +import { useTheme } from "./ThemeProvider"; +import { + Sun, + Moon, + Type, + Maximize2, + Minimize2, + X, + Paintbrush, +} from "lucide-react"; + +type TextSize = "small" | "medium" | "large"; +type LayoutWidth = "default" | "full"; + +function loadAppearance(): { textSize: TextSize; layoutWidth: LayoutWidth } { + if (typeof window === "undefined") + return { textSize: "medium", layoutWidth: "default" }; + try { + const ts = localStorage.getItem("pve-text-size"); + const lw = localStorage.getItem("pve-layout-width"); + return { + textSize: + ts === "small" || ts === "medium" || ts === "large" ? ts : "medium", + layoutWidth: lw === "full" ? "full" : "default", + }; + } catch { + return { textSize: "medium", layoutWidth: "default" }; + } +} + +function applyTextSize(size: TextSize) { + const root = document.documentElement; + root.classList.remove( + "text-size-small", + "text-size-medium", + "text-size-large", + ); + root.classList.add(`text-size-${size}`); + localStorage.setItem("pve-text-size", size); +} + +function applyLayoutWidth(width: LayoutWidth) { + const root = document.documentElement; + root.style.setProperty( + "--layout-max-w", + width === "full" ? "1800px" : "1440px", + ); + localStorage.setItem("pve-layout-width", width); +} + +interface AppearanceModalProps { + isOpen: boolean; + onClose: () => void; +} + +export function AppearanceModal({ isOpen, onClose }: AppearanceModalProps) { + const zIndex = useRegisterModal(isOpen, { + id: "appearance-modal", + allowEscape: true, + onClose, + }); + const { theme, setTheme } = useTheme(); + const [textSize, setTextSize] = useState("medium"); + const [layoutWidth, setLayoutWidth] = useState("default"); + + useEffect(() => { + if (isOpen) { + const a = loadAppearance(); + setTextSize(a.textSize); + setLayoutWidth(a.layoutWidth); + } + }, [isOpen]); + + if (!isOpen) return null; + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) onClose(); + }; + + return ( + +
+
+ {/* Header */} +
+
+
+ +
+

+ Appearance +

+
+ +
+ + {/* Content */} +
+ {/* Theme */} +
+

+ Theme +

+
+ {[ + { value: "light" as const, label: "Light", Icon: Sun }, + { value: "dark" as const, label: "Dark", Icon: Moon }, + ].map(({ value, label, Icon }) => ( + + ))} +
+
+ + {/* Text Size */} +
+

+ + Text Size +

+
+ {[ + { value: "small" as const, label: "Small" }, + { value: "medium" as const, label: "Medium" }, + { value: "large" as const, label: "Large" }, + ].map(({ value, label }) => ( + + ))} +
+
+ + {/* Layout Width */} +
+

+ Layout Width +

+
+ {[ + { + value: "default" as const, + label: "Default", + sub: "1440px", + Icon: Minimize2, + }, + { + value: "full" as const, + label: "Wide", + sub: "1800px", + Icon: Maximize2, + }, + ].map(({ value, label, sub, Icon }) => ( + + ))} +
+
+
+
+
+
+ ); +} diff --git a/src/app/_components/AuthGuard.tsx b/src/app/_components/AuthGuard.tsx index 4a0aacfd..a5b17a04 100644 --- a/src/app/_components/AuthGuard.tsx +++ b/src/app/_components/AuthGuard.tsx @@ -1,57 +1,35 @@ -'use client'; +"use client"; -import { useState, useEffect, type ReactNode } from 'react'; -import { useAuth } from './AuthProvider'; -import { AuthModal } from './AuthModal'; -import { SetupModal } from './SetupModal'; +import { useState, type ReactNode } from "react"; +import { useAuth } from "./AuthProvider"; +import { AuthModal } from "./AuthModal"; +import { SetupModal } from "./SetupModal"; interface AuthGuardProps { children: ReactNode; } -interface AuthConfig { - username: string | null; - enabled: boolean; - hasCredentials: boolean; - setupCompleted: boolean; -} - export function AuthGuard({ children }: AuthGuardProps) { - const { isAuthenticated, isLoading } = useAuth(); - const [authConfig, setAuthConfig] = useState(null); - const [configLoading, setConfigLoading] = useState(true); - const [setupCompleted, setSetupCompleted] = useState(false); + const { + isAuthenticated, + isLoading, + setupCompleted, + authEnabled, + refreshConfig, + } = useAuth(); + const [localSetupCompleted, setLocalSetupCompleted] = useState(false); const handleSetupComplete = async () => { - setSetupCompleted(true); - // Refresh auth config without reloading the page - await fetchAuthConfig(); + setLocalSetupCompleted(true); + await refreshConfig(); }; - const fetchAuthConfig = async () => { - try { - const response = await fetch('/api/settings/auth-credentials'); - if (response.ok) { - const config = await response.json() as AuthConfig; - setAuthConfig(config); - } - } catch (error) { - console.error('Error fetching auth config:', error); - } finally { - setConfigLoading(false); - } - }; - - useEffect(() => { - void fetchAuthConfig(); - }, []); - - // Show loading while checking auth status - if (isLoading || configLoading) { + // Show loading while AuthProvider is still checking + if (isLoading || setupCompleted === null) { return ( -
+
-
+

Loading...

@@ -59,12 +37,12 @@ export function AuthGuard({ children }: AuthGuardProps) { } // Show setup modal if setup has not been completed yet - if (authConfig && !authConfig.setupCompleted && !setupCompleted) { + if (!setupCompleted && !localSetupCompleted) { return ; } // Show auth modal if auth is enabled but user is not authenticated - if (authConfig && authConfig.enabled && !isAuthenticated) { + if (authEnabled && !isAuthenticated) { return ; } diff --git a/src/app/_components/AuthModal.tsx b/src/app/_components/AuthModal.tsx index 271509f6..f8472b0f 100644 --- a/src/app/_components/AuthModal.tsx +++ b/src/app/_components/AuthModal.tsx @@ -1,21 +1,25 @@ -'use client'; +"use client"; -import { useState } from 'react'; -import { Button } from './ui/button'; -import { Input } from './ui/input'; -import { useAuth } from './AuthProvider'; -import { Lock, User, AlertCircle } from 'lucide-react'; -import { useRegisterModal } from './modal/ModalStackProvider'; +import { useState } from "react"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { useAuth } from "./AuthProvider"; +import { Lock, User, AlertCircle } from "lucide-react"; +import { useRegisterModal, ModalPortal } from "./modal/ModalStackProvider"; interface AuthModalProps { isOpen: boolean; } export function AuthModal({ isOpen }: AuthModalProps) { - useRegisterModal(isOpen, { id: 'auth-modal', allowEscape: false, onClose: () => null }); + const zIndex = useRegisterModal(isOpen, { + id: "auth-modal", + allowEscape: false, + onClose: () => null, + }); const { login } = useAuth(); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -25,89 +29,102 @@ export function AuthModal({ isOpen }: AuthModalProps) { setError(null); const success = await login(username, password); - + if (!success) { - setError('Invalid username or password'); + setError("Invalid username or password"); } - + setIsLoading(false); }; if (!isOpen) return null; return ( -
-
- {/* Header */} -
-
- -

Authentication Required

+ +
+
+ {/* Header */} +
+
+ +

+ Authentication Required +

+
-
- {/* Content */} -
-

- Please enter your credentials to access the application. -

+ {/* Content */} +
+

+ Please enter your credentials to access the application. +

-
-
- -
- - setUsername(e.target.value)} - disabled={isLoading} - className="pl-10" - required - /> + +
+ +
+ + setUsername(e.target.value)} + disabled={isLoading} + className="pl-10" + required + /> +
-
-
- -
- - setPassword(e.target.value)} - disabled={isLoading} - className="pl-10" - required - /> +
+ +
+ + setPassword(e.target.value)} + disabled={isLoading} + className="pl-10" + required + /> +
-
- {error && ( -
- - {error} -
- )} + {error && ( +
+ + {error} +
+ )} - - + + +
-
+ ); } diff --git a/src/app/_components/AuthProvider.tsx b/src/app/_components/AuthProvider.tsx index 08880817..22466fed 100644 --- a/src/app/_components/AuthProvider.tsx +++ b/src/app/_components/AuthProvider.tsx @@ -14,9 +14,12 @@ interface AuthContextType { username: string | null; isLoading: boolean; expirationTime: number | null; + setupCompleted: boolean | null; + authEnabled: boolean | null; login: (username: string, password: string) => Promise; logout: () => void; checkAuth: () => Promise; + refreshConfig: () => Promise; } const AuthContext = createContext(undefined); @@ -30,6 +33,8 @@ export function AuthProvider({ children }: AuthProviderProps) { const [username, setUsername] = useState(null); const [isLoading, setIsLoading] = useState(true); const [expirationTime, setExpirationTime] = useState(null); + const [setupCompleted, setSetupCompleted] = useState(null); + const [authEnabled, setAuthEnabled] = useState(null); const checkAuthInternal = async (retryCount = 0) => { try { @@ -41,6 +46,9 @@ export function AuthProvider({ children }: AuthProviderProps) { enabled: boolean; }; + setSetupCompleted(setupData.setupCompleted); + setAuthEnabled(setupData.enabled); + // If setup is not completed or auth is disabled, don't verify if (!setupData.setupCompleted || !setupData.enabled) { setIsAuthenticated(false); @@ -99,6 +107,22 @@ export function AuthProvider({ children }: AuthProviderProps) { return checkAuthInternal(0); }, []); + const refreshConfig = useCallback(async () => { + try { + const response = await fetch("/api/settings/auth-credentials"); + if (response.ok) { + const data = (await response.json()) as { + setupCompleted: boolean; + enabled: boolean; + }; + setSetupCompleted(data.setupCompleted); + setAuthEnabled(data.enabled); + } + } catch (error) { + console.error("Error refreshing auth config:", error); + } + }, []); + const login = async ( username: string, password: string, @@ -158,9 +182,12 @@ export function AuthProvider({ children }: AuthProviderProps) { username, isLoading, expirationTime, + setupCompleted, + authEnabled, login, logout, checkAuth, + refreshConfig, }} > {children} diff --git a/src/app/_components/BackupWarningModal.tsx b/src/app/_components/BackupWarningModal.tsx index d5e3f5f3..21465205 100644 --- a/src/app/_components/BackupWarningModal.tsx +++ b/src/app/_components/BackupWarningModal.tsx @@ -2,7 +2,7 @@ import { Button } from "./ui/button"; import { AlertTriangle } from "lucide-react"; -import { useRegisterModal } from "./modal/ModalStackProvider"; +import { useRegisterModal, ModalPortal } from "./modal/ModalStackProvider"; interface BackupWarningModalProps { isOpen: boolean; @@ -15,7 +15,7 @@ export function BackupWarningModal({ onClose, onProceed, }: BackupWarningModalProps) { - useRegisterModal(isOpen, { + const zIndex = useRegisterModal(isOpen, { id: "backup-warning-modal", allowEscape: true, onClose, @@ -24,51 +24,56 @@ export function BackupWarningModal({ if (!isOpen) return null; return ( -
-
- {/* Header */} -
-
- -

- Backup Failed -

+ +
+
+ {/* Header */} +
+
+ +

+ Backup Failed +

+
-
- {/* Content */} -
-

- The backup failed, but you can still proceed with the update if you - wish. -
-
- Warning: Proceeding - without a backup means you won't be able to restore the - container if something goes wrong during the update. -

+ {/* Content */} +
+

+ The backup failed, but you can still proceed with the update if + you wish. +
+
+ Warning: Proceeding + without a backup means you won't be able to restore the + container if something goes wrong during the update. +

- {/* Action Buttons */} -
- - + {/* Action Buttons */} +
+ + +
-
+
); } diff --git a/src/app/_components/Badge.tsx b/src/app/_components/Badge.tsx index cbf2ff5e..4a6abbd7 100644 --- a/src/app/_components/Badge.tsx +++ b/src/app/_components/Badge.tsx @@ -1,93 +1,107 @@ -'use client'; +"use client"; -import React from 'react'; +import React from "react"; interface BadgeProps { - variant: 'type' | 'updateable' | 'privileged' | 'status' | 'note' | 'execution-mode'; + variant: + | "type" + | "updateable" + | "privileged" + | "status" + | "note" + | "execution-mode"; type?: string; - noteType?: 'info' | 'warning' | 'error'; - status?: 'success' | 'failed' | 'in_progress'; - executionMode?: 'local' | 'ssh'; + noteType?: "info" | "warning" | "error"; + status?: "success" | "failed" | "in_progress"; + executionMode?: "local" | "ssh"; children: React.ReactNode; className?: string; } -export function Badge({ variant, type, noteType, status, executionMode, children, className = '' }: BadgeProps) { +export function Badge({ + variant, + type, + noteType, + status, + executionMode, + children, + className = "", +}: BadgeProps) { const getTypeStyles = (scriptType: string) => { switch (scriptType.toLowerCase()) { - case 'ct': - return 'bg-primary/10 text-primary border-primary/20'; - case 'addon': - return 'bg-primary/10 text-primary border-primary/20'; - case 'vm': - return 'bg-success/10 text-success border-success/20'; - case 'pve': - return 'bg-warning/10 text-warning border-warning/20'; + case "ct": + return "bg-primary/10 text-primary border-primary/20"; + case "addon": + return "bg-primary/10 text-primary border-primary/20"; + case "vm": + return "bg-success/10 text-success border-success/20"; + case "pve": + return "bg-warning/10 text-warning border-warning/20"; default: - return 'bg-muted text-muted-foreground border-border'; + return "bg-muted text-muted-foreground border-border"; } }; const getVariantStyles = () => { switch (variant) { - case 'type': - return `inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${type ? getTypeStyles(type) : getTypeStyles('unknown')}`; - - case 'updateable': - return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20'; - - case 'privileged': - return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20'; - - case 'status': + case "type": + return `inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${type ? getTypeStyles(type) : getTypeStyles("unknown")}`; + + case "updateable": + return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20"; + + case "privileged": + return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20"; + + case "status": switch (status) { - case 'success': - return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20'; - case 'failed': - return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-error/10 text-error border border-error/20'; - case 'in_progress': - return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning border border-warning/20'; + case "success": + return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20"; + case "failed": + return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-error/10 text-error border border-error/20"; + case "in_progress": + return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning border border-warning/20"; default: - return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border'; + return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border"; } - - case 'execution-mode': + + case "execution-mode": switch (executionMode) { - case 'local': - return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20'; - case 'ssh': - return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20'; + case "local": + return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20"; + case "ssh": + return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20"; default: - return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border'; + return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border"; } - - case 'note': + + case "note": switch (noteType) { - case 'warning': - return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning border border-warning/20'; - case 'error': - return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20'; + case "warning": + return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning border border-warning/20"; + case "error": + return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20"; default: - return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20'; + return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20"; } - + default: - return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border'; + return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border"; } }; // Format the text for type badges const formatText = () => { - if (variant === 'type' && type) { + if (variant === "type" && type) { switch (type.toLowerCase()) { - case 'ct': - return 'LXC'; - case 'addon': - return 'ADDON'; - case 'vm': - return 'VM'; - case 'pve': - return 'PVE'; + case "ct": + return "LXC"; + case "addon": + return "ADDON"; + case "vm": + return "VM"; + case "pve": + return "PVE"; default: return type.toUpperCase(); } @@ -96,14 +110,18 @@ export function Badge({ variant, type, noteType, status, executionMode, children }; return ( - - {formatText()} - + {formatText()} ); } // Convenience components for common use cases -export const TypeBadge = ({ type, className }: { type: string; className?: string }) => ( +export const TypeBadge = ({ + type, + className, +}: { + type: string; + className?: string; +}) => ( {type} @@ -121,20 +139,60 @@ export const PrivilegedBadge = ({ className }: { className?: string }) => ( ); -export const StatusBadge = ({ status, children, className }: { status: 'success' | 'failed' | 'in_progress'; children: React.ReactNode; className?: string }) => ( +export const StatusBadge = ({ + status, + children, + className, +}: { + status: "success" | "failed" | "in_progress"; + children: React.ReactNode; + className?: string; +}) => ( {children} ); -export const ExecutionModeBadge = ({ mode, children, className }: { mode: 'local' | 'ssh'; children: React.ReactNode; className?: string }) => ( +export const ExecutionModeBadge = ({ + mode, + children, + className, +}: { + mode: "local" | "ssh"; + children: React.ReactNode; + className?: string; +}) => ( {children} ); -export const NoteBadge = ({ noteType, children, className }: { noteType: 'info' | 'warning' | 'error'; children: React.ReactNode; className?: string }) => ( +export const NoteBadge = ({ + noteType, + children, + className, +}: { + noteType: "info" | "warning" | "error"; + children: React.ReactNode; + className?: string; +}) => ( {children} -); \ No newline at end of file +); + +export const DevBadge = ({ className }: { className?: string }) => ( + + DEV + +); + +export const ArmBadge = ({ className }: { className?: string }) => ( + + ARM + +); diff --git a/src/app/_components/CategorySidebar.tsx b/src/app/_components/CategorySidebar.tsx index 61cb723c..827817b8 100644 --- a/src/app/_components/CategorySidebar.tsx +++ b/src/app/_components/CategorySidebar.tsx @@ -1,195 +1,162 @@ -'use client'; +"use client"; -import { useState } from 'react'; -import { ContextualHelpIcon } from './ContextualHelpIcon'; +import { useState } from "react"; +import { ContextualHelpIcon } from "./ContextualHelpIcon"; interface CategorySidebarProps { categories: string[]; categoryCounts: Record; + categoryDevCounts?: Record; totalScripts: number; selectedCategory: string | null; onCategorySelect: (category: string | null) => void; } // Icon mapping for categories -const CategoryIcon = ({ iconName, className = "w-5 h-5" }: { iconName: string; className?: string }) => { - const iconMap: Record = { - server: ( - - - - ), - monitor: ( - - - - ), - box: ( - - - - ), - shield: ( - - - - ), - "shield-check": ( - - - - ), - key: ( - - - - ), - archive: ( - - - - ), - database: ( - - - - ), - "chart-bar": ( - - - - ), - template: ( - - - - ), - "folder-open": ( - - - - ), - "document-text": ( - - - - ), - film: ( - - - - ), - download: ( - - - - ), - "video-camera": ( - - - - ), - home: ( - - - - ), - wifi: ( - - - - ), - "chat-alt": ( - - - - ), - clock: ( - - - - ), - code: ( - - - - ), - "external-link": ( - - - - ), - sparkles: ( - - - - ), - "currency-dollar": ( - - - - ), - puzzle: ( - - - - ), - office: ( - - - - ), - }; +const categoryIconColorMap: Record = { + server: "text-blue-500", + monitor: "text-sky-400", + box: "text-orange-400", + shield: "text-green-500", + "shield-check": "text-green-500", + key: "text-yellow-500", + archive: "text-amber-400", + database: "text-indigo-500", + "chart-bar": "text-emerald-500", + template: "text-violet-500", + "folder-open": "text-cyan-500", + "document-text": "text-slate-400", + film: "text-rose-500", + download: "text-cyan-500", + "video-camera": "text-pink-500", + home: "text-lime-500", + wifi: "text-fuchsia-500", + "chat-alt": "text-sky-500", + clock: "text-orange-500", + code: "text-green-400", + "external-link": "text-blue-400", + sparkles: "text-purple-500", + "currency-dollar": "text-emerald-400", + puzzle: "text-pink-400", + office: "text-stone-500", +}; + +const iconPaths: Record = { + server: + "M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01", + monitor: + "M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z", + box: "M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4", + shield: + "M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z", + "shield-check": + "M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z", + key: "M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1 0 21 9z", + archive: + "M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4", + database: + "M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4", + "chart-bar": + "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z", + template: + "M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z", + "folder-open": + "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z", + "document-text": + "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z", + film: "M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2m0 0V1.5a.5.5 0 01.5-.5h1a.5.5 0 01.5.5V4m-3 0H9m3 0v16a1 1 0 01-1 1H8a1 1 0 01-1-1V4m6 0h2a2 2 0 012 2v12a2 2 0 01-2 2h-2V4z", + download: + "M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z", + "video-camera": + "M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z", + home: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6", + wifi: "M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0", + "chat-alt": + "M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z", + clock: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z", + code: "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4", + "external-link": + "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14", + sparkles: + "M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z", + "currency-dollar": + "M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1", + puzzle: + "M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z", + office: + "M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4", +}; - return iconMap[iconName] ?? ( - - +const FALLBACK_PATH = + "M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5a2 2 0 00-2-2h-4a2 2 0 00-2 2v12a4 4 0 004 4 4 4 0 004-4V5z"; + +const CategoryIcon = ({ + iconName, + className = "w-5 h-5", +}: { + iconName: string; + className?: string; +}) => { + return ( + + ); }; -export function CategorySidebar({ - categories, - categoryCounts, - totalScripts, - selectedCategory, - onCategorySelect +export function CategorySidebar({ + categories, + categoryCounts, + categoryDevCounts, + totalScripts, + selectedCategory, + onCategorySelect, }: CategorySidebarProps) { const [isCollapsed, setIsCollapsed] = useState(false); // Category to icon mapping (based on metadata.json) const categoryIconMapping: Record = { - 'Proxmox & Virtualization': 'server', - 'Operating Systems': 'monitor', - 'Containers & Docker': 'box', - 'Network & Firewall': 'shield', - 'Adblock & DNS': 'shield-check', - 'Authentication & Security': 'key', - 'Backup & Recovery': 'archive', - 'Databases': 'database', - 'Monitoring & Analytics': 'chart-bar', - 'Dashboards & Frontends': 'template', - 'Files & Downloads': 'folder-open', - 'Documents & Notes': 'document-text', - 'Media & Streaming': 'film', - '*Arr Suite': 'download', - 'NVR & Cameras': 'video-camera', - 'IoT & Smart Home': 'home', - 'ZigBee, Z-Wave & Matter': 'wifi', - 'MQTT & Messaging': 'chat-alt', - 'Automation & Scheduling': 'clock', - 'AI / Coding & Dev-Tools': 'code', - 'Webservers & Proxies': 'external-link', - 'Bots & ChatOps': 'sparkles', - 'Finance & Budgeting': 'currency-dollar', - 'Gaming & Leisure': 'puzzle', - 'Business & ERP': 'office', - 'Miscellaneous': 'box' + "Proxmox & Virtualization": "server", + "Operating Systems": "monitor", + "Containers & Docker": "box", + "Network & Firewall": "shield", + "Adblock & DNS": "shield-check", + "Authentication & Security": "key", + "Backup & Recovery": "archive", + Databases: "database", + "Monitoring & Analytics": "chart-bar", + "Dashboards & Frontends": "template", + "Files & Downloads": "folder-open", + "Documents & Notes": "document-text", + "Media & Streaming": "film", + "*Arr Suite": "download", + "NVR & Cameras": "video-camera", + "IoT & Smart Home": "home", + "ZigBee, Z-Wave & Matter": "wifi", + "MQTT & Messaging": "chat-alt", + "Automation & Scheduling": "clock", + "AI / Coding & Dev-Tools": "code", + "Webservers & Proxies": "external-link", + "Bots & ChatOps": "sparkles", + "Finance & Budgeting": "currency-dollar", + "Gaming & Leisure": "puzzle", + "Business & ERP": "office", + Miscellaneous: "box", }; // Filter categories to only show those with scripts, then sort by count (descending) and alphabetically const sortedCategories = categories - .map(category => [category, categoryCounts[category] ?? 0] as const) + .map((category) => [category, categoryCounts[category] ?? 0] as const) .filter(([, count]) => count > 0) // Only show categories with at least one script .sort(([a, countA], [b, countB]) => { if (countB !== countA) return countB - countA; @@ -197,34 +164,48 @@ export function CategorySidebar({ }); return ( -
+
{/* Header */} -
+
{!isCollapsed && ( -
+
-

Categories

-

{totalScripts} Total scripts

+

+ Categories +

+

+ {totalScripts} Total scripts +

- +
)}
@@ -236,24 +217,26 @@ export function CategorySidebar({ {/* "All Categories" option */} @@ -261,33 +244,45 @@ export function CategorySidebar({ {/* Individual Categories */} {sortedCategories.map(([category, count]) => { const isSelected = selectedCategory === category; - + return ( ); })} @@ -297,32 +292,34 @@ export function CategorySidebar({ {/* Collapsed state - show only icons with counters and tooltips */} {isCollapsed && ( -
+
{/* "All Categories" option */}
- + {/* Tooltip */} -
+
All Categories ({totalScripts})
@@ -330,32 +327,34 @@ export function CategorySidebar({ {/* Individual Categories */} {sortedCategories.map(([category, count]) => { const isSelected = selectedCategory === category; - + return (
- + {/* Tooltip */} -
+
{category} ({count})
@@ -365,4 +364,4 @@ export function CategorySidebar({ )}
); -} \ No newline at end of file +} diff --git a/src/app/_components/CloneCountInputModal.tsx b/src/app/_components/CloneCountInputModal.tsx index eb32ac54..a9ae245d 100644 --- a/src/app/_components/CloneCountInputModal.tsx +++ b/src/app/_components/CloneCountInputModal.tsx @@ -1,10 +1,10 @@ -'use client'; +"use client"; -import { useState, useEffect } from 'react'; -import { Button } from './ui/button'; -import { Input } from './ui/input'; -import { Copy, X } from 'lucide-react'; -import { useRegisterModal } from './modal/ModalStackProvider'; +import { useState, useEffect } from "react"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Copy, X } from "lucide-react"; +import { useRegisterModal, ModalPortal } from "./modal/ModalStackProvider"; interface CloneCountInputModalProps { isOpen: boolean; @@ -17,11 +17,15 @@ export function CloneCountInputModal({ isOpen, onClose, onSubmit, - storageName + storageName, }: CloneCountInputModalProps) { const [cloneCount, setCloneCount] = useState(1); - - useRegisterModal(isOpen, { id: 'clone-count-input-modal', allowEscape: true, onClose }); + + const zIndex = useRegisterModal(isOpen, { + id: "clone-count-input-modal", + allowEscape: true, + onClose, + }); useEffect(() => { if (isOpen) { @@ -44,86 +48,97 @@ export function CloneCountInputModal({ }; return ( -
-
- {/* Header */} -
-
- -

Clone Count

-
- -
- - {/* Content */} -
-

- How many clones would you like to create? -

- - {storageName && ( -
-

Storage:

-

{storageName}

+ +
+
+ {/* Header */} +
+
+ +

+ Clone Count +

- )} - -
- - { - const value = parseInt(e.target.value, 10); - if (!isNaN(value) && value >= 1 && value <= 100) { - setCloneCount(value); - } else if (e.target.value === '') { - setCloneCount(1); - } - }} - className="w-full" - placeholder="1" - /> -

- Enter a number between 1 and 100 -

-
- - {/* Action Buttons */} -
-
+ + {/* Content */} +
+

+ How many clones would you like to create? +

+ + {storageName && ( +
+

Storage:

+

+ {storageName} +

+
+ )} + +
+ + { + const value = parseInt(e.target.value, 10); + if (!isNaN(value) && value >= 1 && value <= 100) { + setCloneCount(value); + } else if (e.target.value === "") { + setCloneCount(1); + } + }} + className="w-full" + placeholder="1" + /> +

+ Enter a number between 1 and 100 +

+
+ + {/* Action Buttons */} +
+ + +
+
-
+
); } - diff --git a/src/app/_components/ConfigurationModal.tsx b/src/app/_components/ConfigurationModal.tsx index f4ac0b45..a62fa58f 100644 --- a/src/app/_components/ConfigurationModal.tsx +++ b/src/app/_components/ConfigurationModal.tsx @@ -6,7 +6,7 @@ import type { Script } from "~/types/script"; import type { Server } from "~/types/server"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; -import { useRegisterModal } from "./modal/ModalStackProvider"; +import { useRegisterModal, ModalPortal } from "./modal/ModalStackProvider"; export type EnvVars = Record; @@ -27,7 +27,7 @@ export function ConfigurationModal({ server, mode, }: ConfigurationModalProps) { - useRegisterModal(isOpen, { + const zIndex = useRegisterModal(isOpen, { id: "configuration-modal", allowEscape: true, onClose, @@ -63,6 +63,25 @@ export function ConfigurationModal({ // Advanced mode state const [advancedVars, setAdvancedVars] = useState({}); + // Server presets + const [showSavePreset, setShowSavePreset] = useState(false); + const [presetName, setPresetName] = useState(""); + const { data: presetsData, refetch: refetchPresets } = + api.serverPresets.getByServerId.useQuery( + { serverId: server?.id ?? 0 }, + { enabled: !!server?.id && isOpen && mode === "advanced" }, + ); + const createPresetMutation = api.serverPresets.create.useMutation({ + onSuccess: () => { + void refetchPresets(); + setShowSavePreset(false); + setPresetName(""); + }, + }); + const deletePresetMutation = api.serverPresets.delete.useMutation({ + onSuccess: () => void refetchPresets(), + }); + // Discovered SSH keys on the Proxmox host (advanced mode only) const [discoveredSshKeys, setDiscoveredSshKeys] = useState([]); const [discoveredSshKeysLoading, setDiscoveredSshKeysLoading] = @@ -107,6 +126,7 @@ export function ConfigurationModal({ var_ns: "", // Identity + var_ctid: "", var_hostname: slug, var_pw: "", var_tags: "community-script", @@ -129,6 +149,7 @@ export function ConfigurationModal({ var_verbose: "no", var_apt_cacher: "no", var_apt_cacher_ip: "", + var_github_token: "", // Storage var_container_storage: "", @@ -138,6 +159,30 @@ export function ConfigurationModal({ } }, [actualScript, server, mode, resources, slug]); + // Load persistent APT proxy settings for advanced mode + useEffect(() => { + if (!isOpen || mode !== "advanced") return; + let cancelled = false; + fetch("/api/settings/apt-proxy") + .then((res) => res.json() as Promise<{ enabled: boolean; ip: string }>) + .then((data) => { + if (cancelled) return; + if (data.enabled && data.ip) { + setAdvancedVars((prev) => ({ + ...prev, + var_apt_cacher: "yes", + var_apt_cacher_ip: data.ip, + })); + } + }) + .catch(() => { + /* ignore */ + }); + return () => { + cancelled = true; + }; + }, [isOpen, mode]); + // Discover SSH keys on the Proxmox host when advanced mode is open useEffect(() => { if (!server?.id || !isOpen || mode !== "advanced") { @@ -438,951 +483,1156 @@ export function ConfigurationModal({ } }; + const applyPreset = ( + preset: NonNullable["presets"][number], + ) => { + setAdvancedVars((prev) => ({ + ...prev, + ...(preset.cpu != null && { var_cpu: preset.cpu }), + ...(preset.ram != null && { var_ram: preset.ram }), + ...(preset.disk != null && { var_disk: preset.disk }), + var_unprivileged: preset.privileged ? 0 : 1, + ...(preset.bridge && { var_brg: preset.bridge }), + ...(preset.vlan && { var_vlan: preset.vlan }), + ...(preset.dns && { var_ns: preset.dns }), + var_ssh: preset.ssh ? "yes" : "no", + var_nesting: preset.nesting ? 1 : 0, + var_fuse: preset.fuse ? 1 : 0, + ...(preset.apt_proxy_on && + preset.apt_proxy_addr && { + var_apt_cacher: "yes", + var_apt_cacher_ip: preset.apt_proxy_addr, + }), + })); + }; + + const saveCurrentAsPreset = () => { + if (!server?.id || !presetName.trim()) return; + createPresetMutation.mutate({ + serverId: server.id, + name: presetName.trim(), + cpu: + typeof advancedVars.var_cpu === "number" + ? advancedVars.var_cpu + : undefined, + ram: + typeof advancedVars.var_ram === "number" + ? advancedVars.var_ram + : undefined, + disk: + typeof advancedVars.var_disk === "number" + ? advancedVars.var_disk + : undefined, + privileged: advancedVars.var_unprivileged === 0, + bridge: + typeof advancedVars.var_brg === "string" + ? advancedVars.var_brg + : undefined, + vlan: + typeof advancedVars.var_vlan === "string" + ? advancedVars.var_vlan + : undefined, + dns: + typeof advancedVars.var_ns === "string" + ? advancedVars.var_ns + : undefined, + ssh: advancedVars.var_ssh === "yes", + nesting: advancedVars.var_nesting === 1, + fuse: advancedVars.var_fuse === 1, + aptProxyAddr: + typeof advancedVars.var_apt_cacher_ip === "string" + ? advancedVars.var_apt_cacher_ip + : undefined, + aptProxyOn: advancedVars.var_apt_cacher === "yes", + }); + }; + if (!isOpen) return null; const rootfsStorages = rootfsStoragesData?.storages ?? []; const templateStorages = templateStoragesData?.storages ?? []; return ( -
-
- {/* Header */} -
-

- {mode === "default" - ? "Default Configuration" - : "Advanced Configuration"} -

- -
+ + + + +
- {/* Content */} -
- {mode === "default" ? ( - /* Default Mode */ -
-
- - - {rootfsStorages.length === 0 && ( -

- Could not fetch storages. Script will use default selection. -

- )} -
+ {/* Content */} +
+ {mode === "default" ? ( + /* Default Mode */ +
+
+ + + {rootfsStorages.length === 0 && ( +

+ Could not fetch storages. Script will use default + selection. +

+ )} +
-
-

- Default Values -

-
-

Hostname: {slug}

-

Bridge: vmbr0

-

Network: DHCP

-

IPv6: Auto

-

SSH: Disabled

-

Nesting: Enabled

-

CPU: {resources?.cpu ?? 1}

-

RAM: {resources?.ram ?? 1024} MB

-

Disk: {resources?.hdd ?? 4} GB

+
+

+ Default Values +

+
+

Hostname: {slug}

+

Bridge: vmbr0

+

Network: DHCP

+

IPv6: Auto

+

SSH: Disabled

+

Nesting: Enabled

+

CPU: {resources?.cpu ?? 1}

+

RAM: {resources?.ram ?? 1024} MB

+

Disk: {resources?.hdd ?? 4} GB

+
-
- ) : ( - /* Advanced Mode */ -
- {/* Resources */} -
-

- Resources -

-
-
- - - updateAdvancedVar( - "var_cpu", - parseInt(e.target.value) || 1, - ) - } - className={errors.var_cpu ? "border-destructive" : ""} - /> - {errors.var_cpu && ( -

- {errors.var_cpu} -

- )} -
-
- - - updateAdvancedVar( - "var_ram", - parseInt(e.target.value) || 1024, - ) - } - className={errors.var_ram ? "border-destructive" : ""} - /> - {errors.var_ram && ( -

- {errors.var_ram} -

- )} -
-
- - - updateAdvancedVar( - "var_disk", - parseInt(e.target.value) || 4, - ) - } - className={errors.var_disk ? "border-destructive" : ""} - /> - {errors.var_disk && ( -

- {errors.var_disk} -

- )} + ) : ( + /* Advanced Mode */ +
+ {/* Server Presets */} + {(presetsData?.presets?.length ?? 0) > 0 && ( +
+

+ Load Preset +

+
+ {presetsData?.presets.map((preset) => ( +
+ + +
+ ))} +
-
- - setPresetName(e.target.value)} + placeholder="Preset name..." + className="max-w-[200px]" + onKeyDown={(e) => + e.key === "Enter" && saveCurrentAsPreset() + } + /> + + + + ) : ( +
+ Save Current as Preset + + )}
-
- {/* Network */} -
-

- Network -

-
-
- - - - - -
- {(advancedVars.var_net === "static" || - (typeof advancedVars.var_net === "string" && - advancedVars.var_net.includes("/"))) && ( + onChange={(e) => + updateAdvancedVar( + "var_cpu", + parseInt(e.target.value) || 1, + ) + } + className={errors.var_cpu ? "border-destructive" : ""} + /> + {errors.var_cpu && ( +

+ {errors.var_cpu} +

+ )} +
+ updateAdvancedVar( + "var_ram", + parseInt(e.target.value) || 1024, + ) + } + className={errors.var_ram ? "border-destructive" : ""} + /> + {errors.var_ram && ( +

+ {errors.var_ram} +

+ )} +
+
+ + + updateAdvancedVar( + "var_disk", + parseInt(e.target.value) || 4, + ) + } + className={errors.var_disk ? "border-destructive" : ""} + /> + {errors.var_disk && ( +

+ {errors.var_disk} +

+ )} +
+
+ + +
+
+
+ + {/* Network */} +
+

+ Network +

+
+
+ + +
+ {(advancedVars.var_net === "static" || + (typeof advancedVars.var_net === "string" && + advancedVars.var_net.includes("/"))) && ( +
+ + { + // Store in var_ip temporarily, will be moved to var_net on confirm + updateAdvancedVar("var_ip", e.target.value); + }} + placeholder="10.10.10.1/24" + className={errors.var_ip ? "border-destructive" : ""} + /> + {errors.var_ip && ( +

+ {errors.var_ip} +

+ )} +
+ )} +
+ + + updateAdvancedVar("var_brg", e.target.value) + } + placeholder="vmbr0" + /> +
+
+ + + updateAdvancedVar("var_gateway", e.target.value) + } + placeholder="Auto" + className={ + errors.var_gateway ? "border-destructive" : "" + } /> - {errors.var_ip && ( + {errors.var_gateway && (

- {errors.var_ip} + {errors.var_gateway}

)}
- )} -
- - - updateAdvancedVar("var_brg", e.target.value) - } - placeholder="vmbr0" - /> -
-
- - - updateAdvancedVar("var_gateway", e.target.value) - } - placeholder="Auto" - className={errors.var_gateway ? "border-destructive" : ""} - /> - {errors.var_gateway && ( -

- {errors.var_gateway} -

+
+ + +
+ {advancedVars.var_ipv6_method === "static" && ( +
+ + + updateAdvancedVar("var_ipv6_static", e.target.value) + } + placeholder="2001:db8::1/64" + className={ + errors.var_ipv6_static ? "border-destructive" : "" + } + /> + {errors.var_ipv6_static && ( +

+ {errors.var_ipv6_static} +

+ )} +
)} -
-
- - -
- {advancedVars.var_ipv6_method === "static" && (
+ + updateAdvancedVar( + "var_vlan", + e.target.value ? parseInt(e.target.value) : "", + ) + } + placeholder="None" + className={errors.var_vlan ? "border-destructive" : ""} + /> + {errors.var_vlan && ( +

+ {errors.var_vlan} +

+ )} +
+
+ + + updateAdvancedVar( + "var_mtu", + e.target.value ? parseInt(e.target.value) : 1500, + ) + } + placeholder="1500" + className={errors.var_mtu ? "border-destructive" : ""} + /> + {errors.var_mtu && ( +

+ {errors.var_mtu} +

+ )} +
+
+ - updateAdvancedVar("var_ipv6_static", e.target.value) + updateAdvancedVar("var_mac", e.target.value) } - placeholder="2001:db8::1/64" - className={ - errors.var_ipv6_static ? "border-destructive" : "" + placeholder="Auto" + className={errors.var_mac ? "border-destructive" : ""} + /> + {errors.var_mac && ( +

+ {errors.var_mac} +

+ )} +
+
+ + + updateAdvancedVar("var_ns", e.target.value) } + placeholder="Auto" + className={errors.var_ns ? "border-destructive" : ""} /> - {errors.var_ipv6_static && ( + {errors.var_ns && (

- {errors.var_ipv6_static} + {errors.var_ns}

)}
- )} -
- - - updateAdvancedVar( - "var_vlan", - e.target.value ? parseInt(e.target.value) : "", - ) - } - placeholder="None" - className={errors.var_vlan ? "border-destructive" : ""} - /> - {errors.var_vlan && ( -

- {errors.var_vlan} -

- )} -
-
- - - updateAdvancedVar( - "var_mtu", - e.target.value ? parseInt(e.target.value) : 1500, - ) - } - placeholder="1500" - className={errors.var_mtu ? "border-destructive" : ""} - /> - {errors.var_mtu && ( -

- {errors.var_mtu} -

- )} -
-
- - - updateAdvancedVar("var_mac", e.target.value) - } - placeholder="Auto" - className={errors.var_mac ? "border-destructive" : ""} - /> - {errors.var_mac && ( -

- {errors.var_mac} -

- )} -
-
- - - updateAdvancedVar("var_ns", e.target.value) - } - placeholder="Auto" - className={errors.var_ns ? "border-destructive" : ""} - /> - {errors.var_ns && ( -

- {errors.var_ns} -

- )} -
-
-
- - {/* Identity & Metadata */} -
-

- Identity & Metadata -

-
-
- - - updateAdvancedVar("var_hostname", e.target.value) - } - placeholder={slug} - /> -
-
- - - updateAdvancedVar("var_pw", e.target.value) - } - placeholder="Random (empty = auto-login)" - /> -
-
- - - updateAdvancedVar("var_tags", e.target.value) - } - placeholder="e.g. tag1; tag2" - />
-
- {/* SSH Access */} -
-

- SSH Access -

-
-
- - -
-
- - {discoveredSshKeysLoading && ( -

- Detecting SSH keys... -

- )} - {discoveredSshKeysError && !discoveredSshKeysLoading && ( -

- Could not detect keys on host + {/* Identity & Metadata */} +

+

+ Identity & Metadata +

+
+
+ + + updateAdvancedVar("var_ctid", e.target.value) + } + placeholder="Auto (next available)" + /> +

+ Leave empty for auto-assignment

- )} - {discoveredSshKeys.length > 0 && - !discoveredSshKeysLoading && ( -
- - -
- )} - - updateAdvancedVar( - "var_ssh_authorized_key", - e.target.value, - ) - } - placeholder="Or paste a public key: ssh-rsa AAAA..." - /> +
+
+ + + updateAdvancedVar("var_hostname", e.target.value) + } + placeholder={slug} + /> +
+
+ + + updateAdvancedVar("var_pw", e.target.value) + } + placeholder="Random (empty = auto-login)" + /> +
+
+ + + updateAdvancedVar("var_tags", e.target.value) + } + placeholder="e.g. tag1; tag2" + /> +
-
- {/* Container Features */} -
-

- Container Features -

-
-
- - -
-
- - -
-
- - -
-
- - -

- For Tailscale, WireGuard, OpenVPN -

-
-
- - -
-
- - - updateAdvancedVar("var_mount_fs", e.target.value) - } - placeholder="nfs,cifs" - /> -
-
- - + {/* SSH Access */} +
+

+ SSH Access +

+
+
+ + +
+
+ + {discoveredSshKeysLoading && ( +

+ Detecting SSH keys... +

+ )} + {discoveredSshKeysError && !discoveredSshKeysLoading && ( +

+ Could not detect keys on host +

+ )} + {discoveredSshKeys.length > 0 && + !discoveredSshKeysLoading && ( +
+ + +
+ )} + + updateAdvancedVar( + "var_ssh_authorized_key", + e.target.value, + ) + } + placeholder="Or paste a public key: ssh-rsa AAAA..." + /> +
-
- {/* System Configuration */} -
-

- System Configuration -

-
-
- - - updateAdvancedVar("var_timezone", e.target.value) - } - placeholder="System" - /> -
-
- - -
-
- - -
-
- - - updateAdvancedVar("var_apt_cacher_ip", e.target.value) - } - placeholder="192.168.1.10 or apt-cacher.internal" - className={ - errors.var_apt_cacher_ip ? "border-destructive" : "" - } - /> - {errors.var_apt_cacher_ip && ( -

- {errors.var_apt_cacher_ip} + {/* Container Features */} +

+

+ Container Features +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +

+ For Tailscale, WireGuard, OpenVPN

- )} +
+
+ + +
+
+ + + updateAdvancedVar("var_mount_fs", e.target.value) + } + placeholder="nfs,cifs" + /> +
+
+ + +
-
- {/* Storage Selection */} -
-

- Storage Selection -

-
-
- - - {rootfsStorages.length === 0 && ( + {/* System Configuration */} +
+

+ System Configuration +

+
+
+ + + updateAdvancedVar("var_timezone", e.target.value) + } + placeholder="System" + /> +
+
+ + +
+
+ + +
+
+ + + updateAdvancedVar("var_apt_cacher_ip", e.target.value) + } + placeholder="192.168.1.10 or apt-cacher.internal" + className={ + errors.var_apt_cacher_ip ? "border-destructive" : "" + } + /> + {errors.var_apt_cacher_ip && ( +

+ {errors.var_apt_cacher_ip} +

+ )} +
+
+ + + updateAdvancedVar("var_github_token", e.target.value) + } + placeholder="ghp_... (optional)" + autoComplete="off" + />

- Could not fetch storages. Leave empty for auto - selection. + Passed as GITHUB_TOKEN to avoid API rate limits

- )} +
-
- - - {templateStorages.length === 0 && ( -

- Could not fetch storages. Leave empty for auto - selection. -

- )} +
+ + {/* Storage Selection */} +
+

+ Storage Selection +

+
+
+ + + {rootfsStorages.length === 0 && ( +

+ Could not fetch storages. Leave empty for auto + selection. +

+ )} +
+
+ + + {templateStorages.length === 0 && ( +

+ Could not fetch storages. Leave empty for auto + selection. +

+ )} +
-
- )} + )} - {/* Action Buttons */} -
- - + {/* Action Buttons */} +
+ + +
-
+ ); } diff --git a/src/app/_components/ConfirmationModal.tsx b/src/app/_components/ConfirmationModal.tsx index cfe8b697..d0893147 100644 --- a/src/app/_components/ConfirmationModal.tsx +++ b/src/app/_components/ConfirmationModal.tsx @@ -1,9 +1,9 @@ -'use client'; +"use client"; -import { useMemo, useState } from 'react'; -import { Button } from './ui/button'; -import { AlertTriangle, Info } from 'lucide-react'; -import { useRegisterModal } from './modal/ModalStackProvider'; +import { useMemo, useState } from "react"; +import { Button } from "./ui/button"; +import { AlertTriangle, Info } from "lucide-react"; +import { useRegisterModal, ModalPortal } from "./modal/ModalStackProvider"; interface ConfirmationModalProps { isOpen: boolean; @@ -11,7 +11,7 @@ interface ConfirmationModalProps { onConfirm: () => void; title: string; message: string; - variant: 'simple' | 'danger'; + variant: "simple" | "danger"; confirmText?: string; // What the user must type for danger variant confirmButtonText?: string; cancelButtonText?: string; @@ -25,14 +25,18 @@ export function ConfirmationModal({ message, variant, confirmText, - confirmButtonText = 'Confirm', - cancelButtonText = 'Cancel' + confirmButtonText = "Confirm", + cancelButtonText = "Cancel", }: ConfirmationModalProps) { - const [typedText, setTypedText] = useState(''); - const isDanger = variant === 'danger'; + const [typedText, setTypedText] = useState(""); + const isDanger = variant === "danger"; const allowEscape = useMemo(() => !isDanger, [isDanger]); - useRegisterModal(isOpen, { id: 'confirmation-modal', allowEscape, onClose }); + const zIndex = useRegisterModal(isOpen, { + id: "confirmation-modal", + allowEscape, + onClose, + }); if (!isOpen) return null; const isConfirmEnabled = isDanger ? typedText === confirmText : true; @@ -40,75 +44,84 @@ export function ConfirmationModal({ const handleConfirm = () => { if (isConfirmEnabled) { onConfirm(); - setTypedText(''); // Reset for next time + setTypedText(""); // Reset for next time } }; const handleClose = () => { onClose(); - setTypedText(''); // Reset when closing + setTypedText(""); // Reset when closing }; return ( -
-
- {/* Header */} -
-
- {isDanger ? ( - - ) : ( - - )} -

{title}

+ +
+
+ {/* Header */} +
+
+ {isDanger ? ( + + ) : ( + + )} +

+ {title} +

+
-
- {/* Content */} -
-

- {message} -

+ {/* Content */} +
+

{message}

- {/* Type-to-confirm input for danger variant */} - {isDanger && confirmText && ( -
- - setTypedText(e.target.value)} - className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring" - placeholder={`Type "${confirmText}" here`} - autoComplete="off" - /> -
- )} + {/* Type-to-confirm input for danger variant */} + {isDanger && confirmText && ( +
+ + setTypedText(e.target.value)} + className="border-input bg-background text-foreground placeholder:text-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 focus:ring-2 focus:outline-none" + placeholder={`Type "${confirmText}" here`} + autoComplete="off" + /> +
+ )} - {/* Action Buttons */} -
- - + {/* Action Buttons */} +
+ + +
-
+
); } diff --git a/src/app/_components/DiffViewer.tsx b/src/app/_components/DiffViewer.tsx index 48a5b61a..3ba96602 100644 --- a/src/app/_components/DiffViewer.tsx +++ b/src/app/_components/DiffViewer.tsx @@ -1,7 +1,7 @@ -'use client'; +"use client"; -import { useState } from 'react'; -import { api } from '~/trpc/react'; +import { useState } from "react"; +import { api } from "~/trpc/react"; interface DiffViewerProps { scriptSlug: string; @@ -10,13 +10,18 @@ interface DiffViewerProps { onClose: () => void; } -export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewerProps) { +export function DiffViewer({ + scriptSlug, + filePath, + isOpen, + onClose, +}: DiffViewerProps) { const [isLoading, setIsLoading] = useState(false); // Get diff content const { data: diffData, refetch } = api.scripts.getScriptDiff.useQuery( { slug: scriptSlug, filePath }, - { enabled: isOpen && !!scriptSlug && !!filePath } + { enabled: isOpen && !!scriptSlug && !!filePath }, ); const handleBackdropClick = (e: React.MouseEvent) => { @@ -36,27 +41,31 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer const renderDiffLine = (line: string, index: number) => { const lineNumberMatch = /^([+-]?\d+):/.exec(line); const lineNumber = lineNumberMatch?.[1]; - const content = line.replace(/^[+-]?\d+:\s*/, ''); - const isAdded = line.startsWith('+'); - const isRemoved = line.startsWith('-'); + const content = line.replace(/^[+-]?\d+:\s*/, ""); + const isAdded = line.startsWith("+"); + const isRemoved = line.startsWith("-"); return (
-
+
{lineNumber}
- - {isAdded ? '+' : isRemoved ? '-' : ' '} + + {isAdded ? "+" : isRemoved ? "-" : " "} {content}
@@ -66,82 +75,117 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer return (
-
+
{/* Header */} -
+
-

Script Diff

-

{filePath}

+

Script Diff

+

{filePath}

{/* Legend */} -
+
-
+
Added (Remote)
-
+
Removed (Local)
-
+
Unchanged
{/* Diff Content */} -
+
{diffData?.success ? ( diffData.diff ? ( -
- {diffData.diff.split('\n').map((line, index) => - line.trim() ? renderDiffLine(line, index) : null - )} +
+ {diffData.diff + .split("\n") + .map((line, index) => + line.trim() ? renderDiffLine(line, index) : null, + )}
) : ( -
- - +
+ +

No differences found

-

The local and remote files are identical

+

+ The local and remote files are identical +

) ) : diffData?.error ? ( -
- - +
+ +

Error loading diff

{diffData.error}

) : ( -
-
+
+

Loading diff...

)} diff --git a/src/app/_components/DownloadedScriptsTab.tsx b/src/app/_components/DownloadedScriptsTab.tsx index 3a380208..c945770b 100644 --- a/src/app/_components/DownloadedScriptsTab.tsx +++ b/src/app/_components/DownloadedScriptsTab.tsx @@ -12,21 +12,9 @@ import { ConfirmationModal } from "./ConfirmationModal"; import { Button } from "./ui/button"; import { RefreshCw } from "lucide-react"; import type { ScriptCard as ScriptCardType } from "~/types/script"; -import type { Server } from "~/types/server"; import { getDefaultFilters, mergeFiltersWithDefaults } from "./filterUtils"; -interface DownloadedScriptsTabProps { - onInstallScript?: ( - scriptPath: string, - scriptName: string, - mode?: "local" | "ssh", - server?: Server, - ) => void; -} - -export function DownloadedScriptsTab({ - onInstallScript, -}: DownloadedScriptsTabProps) { +export function DownloadedScriptsTab() { const [selectedSlug, setSelectedSlug] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [selectedCategory, setSelectedCategory] = useState(null); @@ -34,6 +22,8 @@ export function DownloadedScriptsTab({ const [filters, setFilters] = useState(getDefaultFilters()); const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false); const [isLoadingFilters, setIsLoadingFilters] = useState(true); + const filtersInitRef = useRef(false); + const viewModeInitRef = useRef(false); const [updateAllConfirmOpen, setUpdateAllConfirmOpen] = useState(false); const [updateResult, setUpdateResult] = useState<{ successCount: number; @@ -59,29 +49,30 @@ export function DownloadedScriptsTab({ { enabled: !!selectedSlug }, ); - const loadMultipleScriptsMutation = api.scripts.loadMultipleScripts.useMutation({ - onSuccess: (data) => { - void utils.scripts.getAllDownloadedScripts.invalidate(); - void utils.scripts.getScriptCardsWithCategories.invalidate(); - setUpdateResult({ - successCount: data.successful?.length ?? 0, - failCount: data.failed?.length ?? 0, - failed: (data.failed ?? []).map((f) => ({ - slug: f.slug, - error: f.error ?? "Unknown error", - })), - }); - setTimeout(() => setUpdateResult(null), 8000); - }, - onError: (error) => { - setUpdateResult({ - successCount: 0, - failCount: 1, - failed: [{ slug: "Request failed", error: error.message }], - }); - setTimeout(() => setUpdateResult(null), 8000); - }, - }); + const loadMultipleScriptsMutation = + api.scripts.loadMultipleScripts.useMutation({ + onSuccess: (data) => { + void utils.scripts.getAllDownloadedScripts.invalidate(); + void utils.scripts.getScriptCardsWithCategories.invalidate(); + setUpdateResult({ + successCount: data.successful?.length ?? 0, + failCount: data.failed?.length ?? 0, + failed: (data.failed ?? []).map((f) => ({ + slug: f.slug, + error: f.error ?? "Unknown error", + })), + }); + setTimeout(() => setUpdateResult(null), 8000); + }, + onError: (error) => { + setUpdateResult({ + successCount: 0, + failCount: 1, + failed: [{ slug: "Request failed", error: error.message }], + }); + setTimeout(() => setUpdateResult(null), 8000); + }, + }); // Load SAVE_FILTER setting, saved filters, and view mode on component mount useEffect(() => { @@ -135,6 +126,11 @@ export function DownloadedScriptsTab({ // Save filters when they change (if SAVE_FILTER is enabled) useEffect(() => { if (!saveFiltersEnabled || isLoadingFilters) return; + // Skip the first fire after load — values haven't changed yet + if (!filtersInitRef.current) { + filtersInitRef.current = true; + return; + } const saveFilters = async () => { try { @@ -158,6 +154,11 @@ export function DownloadedScriptsTab({ // Save view mode when it changes useEffect(() => { if (isLoadingFilters) return; + // Skip the first fire after load — value hasn't changed yet + if (!viewModeInitRef.current) { + viewModeInitRef.current = true; + return; + } const saveViewMode = async () => { try { @@ -376,6 +377,21 @@ export function DownloadedScriptsTab({ compareValue = (a.name ?? "").localeCompare(b.name ?? ""); } break; + case "updated": + // Sort by date_created as a proxy (JSON doesn't have updated date) + // For downloaded scripts, treat more recent date_created as "recently updated" + const aUpdated = a?.date_created ?? ""; + const bUpdated = b?.date_created ?? ""; + if (aUpdated && bUpdated) { + compareValue = aUpdated.localeCompare(bUpdated); + } else if (aUpdated && !bUpdated) { + compareValue = -1; + } else if (!aUpdated && bUpdated) { + compareValue = 1; + } else { + compareValue = (a.name ?? "").localeCompare(b.name ?? ""); + } + break; default: compareValue = (a.name ?? "").localeCompare(b.name ?? ""); } @@ -567,11 +583,17 @@ export function DownloadedScriptsTab({ ? `, ${updateResult.failCount} failed` : ""} . - {updateResult.failCount > 0 && updateResult.failed.length > 0 && ( - `${f.slug}: ${f.error}`).join("\n")}> - (hover for details) - - )} + {updateResult.failCount > 0 && + updateResult.failed.length > 0 && ( + `${f.slug}: ${f.error}`) + .join("\n")} + > + (hover for details) + + )} )}
@@ -687,7 +709,8 @@ export function DownloadedScriptsTab({ script={scriptData?.success ? scriptData.script : null} isOpen={isModalOpen} onClose={handleCloseModal} - onInstallScript={onInstallScript} + orderedSlugs={filteredScripts.map((s) => s.slug)} + onSelectSlug={(slug) => setSelectedSlug(slug)} /> { if (isOpen) { @@ -36,54 +40,63 @@ export function ErrorModal({ if (!isOpen) return null; return ( -
-
- {/* Header */} -
-
- {type === 'success' ? ( - - ) : ( - - )} -

{title}

+ +
+
+ {/* Header */} +
+
+ {type === "success" ? ( + + ) : ( + + )} +

{title}

+
-
- {/* Content */} -
-

{message}

- {details && ( -
-

- {type === 'success' ? 'Details:' : 'Error Details:'} -

-
-                {details}
-              
-
- )} -
+ {/* Content */} +
+

{message}

+ {details && ( +
+

+ {type === "success" ? "Details:" : "Error Details:"} +

+
+                  {details}
+                
+
+ )} +
- {/* Footer */} -
- + {/* Footer */} +
+ +
-
+ ); } diff --git a/src/app/_components/ExecutionModeModal.tsx b/src/app/_components/ExecutionModeModal.tsx index fce78ca9..32eb35b4 100644 --- a/src/app/_components/ExecutionModeModal.tsx +++ b/src/app/_components/ExecutionModeModal.tsx @@ -1,32 +1,47 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import type { Server } from '../../types/server'; -import type { Script } from '../../types/script'; -import { Button } from './ui/button'; -import { ColorCodedDropdown } from './ColorCodedDropdown'; -import { SettingsModal } from './SettingsModal'; -import { ConfigurationModal, type EnvVars } from './ConfigurationModal'; -import { useRegisterModal } from './modal/ModalStackProvider'; +"use client"; +import { useState, useEffect } from "react"; +import type { Server } from "../../types/server"; +import type { Script } from "../../types/script"; +import { Button } from "./ui/button"; +import { ColorCodedDropdown } from "./ColorCodedDropdown"; +import { SettingsModal } from "./SettingsModal"; +import { ConfigurationModal, type EnvVars } from "./ConfigurationModal"; +import { useRegisterModal, ModalPortal } from "./modal/ModalStackProvider"; interface ExecutionModeModalProps { isOpen: boolean; onClose: () => void; - onExecute: (mode: 'local' | 'ssh', server?: Server, envVars?: EnvVars) => void; + onExecute: ( + mode: "local" | "ssh", + server?: Server, + envVars?: EnvVars, + ) => void; scriptName: string; script?: Script | null; } -export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName, script }: ExecutionModeModalProps) { - useRegisterModal(isOpen, { id: 'execution-mode-modal', allowEscape: true, onClose }); +export function ExecutionModeModal({ + isOpen, + onClose, + onExecute, + scriptName, + script, +}: ExecutionModeModalProps) { + const zIndex = useRegisterModal(isOpen, { + id: "execution-mode-modal", + allowEscape: true, + onClose, + }); const [servers, setServers] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selectedServer, setSelectedServer] = useState(null); const [settingsModalOpen, setSettingsModalOpen] = useState(false); const [configModalOpen, setConfigModalOpen] = useState(false); - const [configMode, setConfigMode] = useState<'default' | 'advanced'>('default'); + const [configMode, setConfigMode] = useState<"default" | "advanced">( + "default", + ); useEffect(() => { if (isOpen) { @@ -52,26 +67,26 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName, scr setLoading(true); setError(null); try { - const response = await fetch('/api/servers'); + const response = await fetch("/api/servers"); if (!response.ok) { - throw new Error('Failed to fetch servers'); + throw new Error("Failed to fetch servers"); } const data = await response.json(); // Sort servers by name alphabetically - const sortedServers = (data as Server[]).sort((a, b) => - (a.name ?? '').localeCompare(b.name ?? '') + const sortedServers = (data as Server[]).sort((a, b) => + (a.name ?? "").localeCompare(b.name ?? ""), ); setServers(sortedServers); } catch (err) { - setError(err instanceof Error ? err.message : 'An error occurred'); + setError(err instanceof Error ? err.message : "An error occurred"); } finally { setLoading(false); } }; - const handleConfigModeSelect = (mode: 'default' | 'advanced') => { + const handleConfigModeSelect = (mode: "default" | "advanced") => { if (!selectedServer) { - setError('Please select a server first'); + setError("Please select a server first"); return; } setConfigMode(mode); @@ -81,7 +96,7 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName, scr const handleConfigConfirm = (envVars: EnvVars) => { if (!selectedServer) return; setConfigModalOpen(false); - onExecute('ssh', selectedServer, envVars); + onExecute("ssh", selectedServer, envVars); onClose(); }; @@ -90,158 +105,127 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName, scr setError(null); // Clear error when server is selected }; - if (!isOpen) return null; return ( <> -
-
- {/* Header */} -
-

Select Server

- -
- - {/* Content */} -
- {error && ( -
-
-
- - - -
-
-

{error}

-
-
-
- )} - - {loading ? ( -
-
-

Loading servers...

-
- ) : servers.length === 0 ? ( -
-

No servers configured

-

Add servers in Settings to execute scripts

- -
- ) : servers.length === 1 ? ( - /* Single Server Confirmation View */ -
-
-

- Install Script Confirmation -

-

- Do you want to install "{scriptName}" on the following server? -

-
- -
-
+ + + +
+ + {/* Content */} +
+ {error && ( +
+
-
+ + +
-
-

- {selectedServer?.name ?? 'Unnamed Server'} -

-

- {selectedServer?.ip} -

+
+

{error}

+ )} - {/* Configuration Mode Selection */} -
-

- Choose configuration mode: + {loading ? ( +

+
+

+ Loading servers...

-
- - -
- - {/* Action Buttons */} -
+ ) : servers.length === 0 ? ( +
+

No servers configured

+

+ Add servers in Settings to execute scripts +

-
- ) : ( - /* Multiple Servers Selection View */ -
-
-

- Select server to execute "{scriptName}" -

-
+ ) : servers.length === 1 ? ( + /* Single Server Confirmation View */ +
+
+

+ Install Script Confirmation +

+

+ Do you want to install "{scriptName}" on the + following server? +

+
- {/* Server Selection */} -
- - -
+
+
+
+
+
+
+

+ {selectedServer?.name ?? "Unnamed Server"} +

+

+ {selectedServer?.ip} +

+
+
+
- {/* Configuration Mode Selection - only show when server is selected */} - {selectedServer && ( -
-

+ {/* Configuration Mode Selection */} +

+

Choose configuration mode:

- )} - {/* Action Buttons */} -
- + {/* Action Buttons */} +
+ +
+
+ ) : ( + /* Multiple Servers Selection View */ +
+
+

+ Select server to execute "{scriptName}" +

+
+ + {/* Server Selection */} +
+ + +
+ + {/* Configuration Mode Selection - only show when server is selected */} + {selectedServer && ( +
+

+ Choose configuration mode: +

+
+ + +
+
+ )} + + {/* Action Buttons */} +
+ +
-
- )} + )} +
-
+ {/* Server Settings Modal */} - {/* Configuration Modal */} diff --git a/src/app/_components/FilterBar.tsx b/src/app/_components/FilterBar.tsx index eb55b47f..228efaaa 100644 --- a/src/app/_components/FilterBar.tsx +++ b/src/app/_components/FilterBar.tsx @@ -13,17 +13,35 @@ import { RefreshCw, Filter, GitBranch, + Layers, + TrendingUp, + Sparkles, + Clock, + TrendingDown, + FlaskConical, + Cpu, } from "lucide-react"; import { api } from "~/trpc/react"; import { getDefaultFilters } from "./filterUtils"; +export type QuickFilter = + | "all" + | "popular" + | "new" + | "updated" + | "unpopular" + | "dev" + | "arm"; + export interface FilterState { searchQuery: string; showUpdatable: boolean | null; // null = all, true = only updatable, false = only non-updatable selectedTypes: string[]; // Array of selected types: 'lxc', 'vm', 'addon', 'pve' selectedRepositories: string[]; // Array of selected repository URLs - sortBy: "name" | "created"; // Sort criteria (removed 'updated') + sortBy: "name" | "created" | "updated"; // Sort criteria sortOrder: "asc" | "desc"; // Sort direction + quickFilter: QuickFilter; + selectedCategory: string | null; // null = all categories } interface FilterBarProps { @@ -34,6 +52,8 @@ interface FilterBarProps { updatableCount?: number; saveFiltersEnabled?: boolean; isLoadingFilters?: boolean; + categories?: string[]; + categoryCounts?: Record; } const SCRIPT_TYPES = [ @@ -51,9 +71,12 @@ export function FilterBar({ updatableCount = 0, saveFiltersEnabled = false, isLoadingFilters = false, + categories = [], + categoryCounts = {}, }: FilterBarProps) { const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false); const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false); + const [isCategoryDropdownOpen, setIsCategoryDropdownOpen] = useState(false); const [isMinimized, setIsMinimized] = useState(false); // Fetch enabled repositories @@ -86,8 +109,10 @@ export function FilterBar({ filters.showUpdatable !== null || filters.selectedTypes.length > 0 || filters.selectedRepositories.length > 0 || + filters.selectedCategory !== null || filters.sortBy !== "name" || - filters.sortOrder !== "asc"; + filters.sortOrder !== "asc" || + filters.quickFilter !== "all"; const getUpdatableButtonText = () => { if (filters.showUpdatable === null) return "Updatable: All"; @@ -158,6 +183,33 @@ export function FilterBar({ {/* Filter Content - Conditionally rendered based on minimized state */} {!isMinimized && !isLoadingFilters && ( <> + {/* Quick Filters */} +
+ {[ + { key: "all" as const, label: "All", Icon: Layers }, + { key: "new" as const, label: "New", Icon: Sparkles }, + { key: "updated" as const, label: "Updated", Icon: Clock }, + { key: "dev" as const, label: "In Dev", Icon: FlaskConical }, + { key: "arm" as const, label: "ARM", Icon: Cpu }, + ].map(({ key, label, Icon }) => { + const isActive = filters.quickFilter === key; + return ( + + ); + })} +
+ {/* Search Bar */}
@@ -319,6 +371,84 @@ export function FilterBar({ )}
+ {/* Repository Filter Buttons - Only show if more than one enabled repo */} + {categories.length > 0 && ( +
+ + + {isCategoryDropdownOpen && ( +
+
+ + {categories.map((cat) => ( + + ))} +
+
+ )} +
+ )} + {/* Repository Filter Buttons - Only show if more than one enabled repo */} {enabledRepos.length > 1 && enabledRepos.map((repo: { id: number; url: string }) => { @@ -372,7 +502,11 @@ export function FilterBar({ )} - {filters.sortBy === "name" ? "By Name" : "By Created Date"} + {filters.sortBy === "name" + ? "By Name" + : filters.sortBy === "created" + ? "By Created Date" + : "By Updated Date"} By Created Date +
)} @@ -541,12 +689,13 @@ export function FilterBar({ )} {/* Click outside to close dropdowns */} - {(isTypeDropdownOpen || isSortDropdownOpen) && ( + {(isTypeDropdownOpen || isSortDropdownOpen || isCategoryDropdownOpen) && (
{ setIsTypeDropdownOpen(false); setIsSortDropdownOpen(false); + setIsCategoryDropdownOpen(false); }} /> )} diff --git a/src/app/_components/Footer.tsx b/src/app/_components/Footer.tsx index 7d3b5e1f..33ea787f 100644 --- a/src/app/_components/Footer.tsx +++ b/src/app/_components/Footer.tsx @@ -1,8 +1,8 @@ -'use client'; +"use client"; -import { api } from '~/trpc/react'; -import { Button } from './ui/button'; -import { ExternalLink, FileText } from 'lucide-react'; +import { api } from "~/trpc/react"; +import { Button } from "./ui/button"; +import { ExternalLink, FileText, Heart } from "lucide-react"; interface FooterProps { onOpenReleaseNotes: () => void; @@ -12,48 +12,83 @@ export function Footer({ onOpenReleaseNotes }: FooterProps) { const { data: versionData } = api.version.getCurrentVersion.useQuery(); return ( -