Merge branch 'main' into lint-build-func

This commit is contained in:
CanbiZ (MickLesk)
2026-03-02 15:02:31 +01:00
committed by GitHub
193 changed files with 9296 additions and 26342 deletions

View File

@@ -6,8 +6,9 @@
if ! command -v curl >/dev/null 2>&1; then
apk update && apk add curl >/dev/null 2>&1
fi
source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/core.func)
source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/error_handler.func)
COMMUNITY_SCRIPTS_URL="${COMMUNITY_SCRIPTS_URL:-https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main}"
source <(curl -fsSL "$COMMUNITY_SCRIPTS_URL/misc/core.func")
source <(curl -fsSL "$COMMUNITY_SCRIPTS_URL/misc/error_handler.func")
load_functions
catch_errors
@@ -119,7 +120,7 @@ network_check() {
update_os() {
msg_info "Updating Container OS"
$STD apk -U upgrade
source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/alpine-tools.func)
source <(curl -fsSL "$COMMUNITY_SCRIPTS_URL/misc/alpine-tools.func")
msg_ok "Updated Container OS"
}

View File

@@ -35,7 +35,11 @@
TELEMETRY_URL="https://telemetry.community-scripts.org/telemetry"
# Timeout for telemetry requests (seconds)
# Progress pings (validation/configuring) use the short timeout
TELEMETRY_TIMEOUT=5
# Final status updates (success/failed) use the longer timeout
# PocketBase may need more time under load (FindRecord + UpdateRecord)
STATUS_TIMEOUT=10
# ==============================================================================
# SECTION 0: REPOSITORY SOURCE DETECTION
@@ -91,7 +95,7 @@ detect_repo_source() {
community-scripts/ProxmoxVED) REPO_SOURCE="ProxmoxVED" ;;
"")
# No URL detected — use hardcoded fallback
# CI sed transforms this on promotion: ProxmoxVED → ProxmoxVE
# This value must match the repo: ProxmoxVE for production, ProxmoxVED for dev
REPO_SOURCE="ProxmoxVED"
;;
*)
@@ -117,16 +121,17 @@ detect_repo_source
# - Canonical source of truth for ALL exit code mappings
# - Used by both api.func (telemetry) and error_handler.func (error display)
# - Supports:
# * Generic/Shell errors (1, 2, 124, 126-130, 134, 137, 139, 141, 143)
# * curl/wget errors (6, 7, 22, 28, 35)
# * 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)
# * Package manager errors (APT, DPKG: 100-102, 255)
# * BSD sysexits (64-78)
# * Systemd/Service errors (150-154)
# * Python/pip/uv errors (160-162)
# * PostgreSQL errors (170-173)
# * MySQL/MariaDB errors (180-183)
# * MongoDB errors (190-193)
# * Proxmox custom codes (200-231)
# * Node.js/npm errors (243, 245-249)
# * Node.js/npm errors (239, 243, 245-249)
# - Returns description string for given exit code
# ------------------------------------------------------------------------------
explain_exit_code() {
@@ -135,30 +140,87 @@ explain_exit_code() {
# --- 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)" ;;
# --- 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" ;;
@@ -166,7 +228,6 @@ explain_exit_code() {
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" ;;
@@ -217,7 +278,8 @@ explain_exit_code() {
225) echo "Proxmox: No template available for OS/Version" ;;
231) echo "Proxmox: LXC stack upgrade failed" ;;
# --- Node.js / npm / pnpm / yarn (243-249) ---
# --- 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" ;;
@@ -237,16 +299,21 @@ explain_exit_code() {
# json_escape()
#
# - Escapes a string for safe JSON embedding
# - Strips ANSI escape sequences and non-printable control characters
# - Handles backslashes, quotes, newlines, tabs, and carriage returns
# ------------------------------------------------------------------------------
json_escape() {
local s="$1"
# Strip ANSI escape sequences (color codes etc.)
s=$(printf '%s' "$s" | sed 's/\x1b\[[0-9;]*[a-zA-Z]//g')
s=${s//\\/\\\\}
s=${s//"/\\"/}
s=${s//$'\n'/\\n}
s=${s//$'\r'/}
s=${s//$'\t'/\\t}
echo "$s"
# Remove any remaining control characters (0x00-0x1F except those already handled)
s=$(printf '%s' "$s" | tr -d '\000-\010\013\014\016-\037')
printf '%s' "$s"
}
# ------------------------------------------------------------------------------
@@ -283,7 +350,82 @@ get_error_text() {
fi
if [[ -n "$logfile" && -s "$logfile" ]]; then
tail -n 20 "$logfile" 2>/dev/null | sed 's/\r$//'
tail -n 20 "$logfile" 2>/dev/null | sed 's/\r$//' | sed 's/\x1b\[[0-9;]*[a-zA-Z]//g'
fi
}
# ------------------------------------------------------------------------------
# get_full_log()
#
# - Returns the FULL installation log (build + install combined)
# - Calls ensure_log_on_host() to pull container log if needed
# - Strips ANSI escape codes and carriage returns
# - Truncates to max_bytes (default: 120KB) to stay within API limits
# - 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
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
}
# ------------------------------------------------------------------------------
# build_error_string()
#
# - Builds a structured error string for telemetry reporting
# - Format: "exit_code=<N> | <explanation>\n---\n<last 20 log lines>"
# - If no log lines available, returns just the explanation
# - Arguments:
# * $1: exit_code (numeric)
# * $2: log_text (optional, output from get_error_text)
# - 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
}
@@ -369,18 +511,19 @@ detect_cpu() {
# - Detects RAM speed using dmidecode
# - Sets RAM_SPEED global (e.g., "4800" for DDR5-4800)
# - Requires root access for dmidecode
# - Returns empty if not available
# - Returns empty if not available or if speed is "Unknown" (nested VMs)
# ------------------------------------------------------------------------------
detect_ram() {
RAM_SPEED=""
if command -v dmidecode &>/dev/null; then
# Get configured memory speed (actual running speed)
RAM_SPEED=$(dmidecode -t memory 2>/dev/null | grep -m1 "Configured Memory Speed:" | grep -oE "[0-9]+" | head -1)
# 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)
RAM_SPEED=$(dmidecode -t memory 2>/dev/null | grep -m1 "Speed:" | grep -oE "[0-9]+" | head -1) || true
fi
fi
@@ -462,6 +605,7 @@ post_to_api() {
cat <<EOF
{
"random_id": "${RANDOM_UUID}",
"execution_id": "${EXECUTION_ID:-${RANDOM_UUID}}",
"type": "lxc",
"nsapp": "${NSAPP:-unknown}",
"status": "installing",
@@ -566,6 +710,7 @@ post_to_api_vm() {
cat <<EOF
{
"random_id": "${RANDOM_UUID}",
"execution_id": "${EXECUTION_ID:-${RANDOM_UUID}}",
"type": "vm",
"nsapp": "${NSAPP:-unknown}",
"status": "installing",
@@ -592,6 +737,35 @@ EOF
curl -fsS -m "${TELEMETRY_TIMEOUT}" -X POST "${TELEMETRY_URL}" \
-H "Content-Type: application/json" \
-d "$JSON_PAYLOAD" &>/dev/null || true
POST_TO_API_DONE=true
}
# ------------------------------------------------------------------------------
# post_progress_to_api()
#
# - Lightweight progress ping from host or container
# - Updates the existing telemetry record status
# - Arguments:
# * $1: status (optional, default: "configuring")
# Valid values: "validation", "configuring"
# - Signals that the installation is actively progressing (not stuck)
# - Fire-and-forget: never blocks or fails the script
# - Only executes if DIAGNOSTICS=yes and RANDOM_UUID is set
# - 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
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
}
# ------------------------------------------------------------------------------
@@ -665,13 +839,16 @@ post_update_to_api() {
else
exit_code=1
fi
local error_text=""
error_text=$(get_error_text)
if [[ -n "$error_text" ]]; then
error=$(json_escape "$error_text")
else
error=$(json_escape "$(explain_exit_code "$exit_code")")
# 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"
@@ -691,12 +868,13 @@ post_update_to_api() {
local http_code=""
# ── Attempt 1: Full payload with complete error text ──
# ── Attempt 1: Full payload with complete error text (includes full log) ──
local JSON_PAYLOAD
JSON_PAYLOAD=$(
cat <<EOF
{
"random_id": "${RANDOM_UUID}",
"execution_id": "${EXECUTION_ID:-${RANDOM_UUID}}",
"type": "${TELEMETRY_TYPE:-lxc}",
"nsapp": "${NSAPP:-unknown}",
"status": "${pb_status}",
@@ -723,7 +901,7 @@ post_update_to_api() {
EOF
)
http_code=$(curl -sS -w "%{http_code}" -m "${TELEMETRY_TIMEOUT}" -X POST "${TELEMETRY_URL}" \
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"
@@ -739,6 +917,7 @@ EOF
cat <<EOF
{
"random_id": "${RANDOM_UUID}",
"execution_id": "${EXECUTION_ID:-${RANDOM_UUID}}",
"type": "${TELEMETRY_TYPE:-lxc}",
"nsapp": "${NSAPP:-unknown}",
"status": "${pb_status}",
@@ -765,7 +944,7 @@ EOF
EOF
)
http_code=$(curl -sS -w "%{http_code}" -m "${TELEMETRY_TIMEOUT}" -X POST "${TELEMETRY_URL}" \
http_code=$(curl -sS -w "%{http_code}" -m "${STATUS_TIMEOUT}" -X POST "${TELEMETRY_URL}" \
-H "Content-Type: application/json" \
-d "$RETRY_PAYLOAD" -o /dev/null 2>/dev/null) || http_code="000"
@@ -781,6 +960,7 @@ EOF
cat <<EOF
{
"random_id": "${RANDOM_UUID}",
"execution_id": "${EXECUTION_ID:-${RANDOM_UUID}}",
"type": "${TELEMETRY_TYPE:-lxc}",
"nsapp": "${NSAPP:-unknown}",
"status": "${pb_status}",
@@ -792,12 +972,18 @@ EOF
EOF
)
curl -sS -w "%{http_code}" -m "${TELEMETRY_TIMEOUT}" -X POST "${TELEMETRY_URL}" \
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 || true
-d "$MINIMAL_PAYLOAD" -o /dev/null 2>/dev/null) || http_code="000"
# Tried 3 times - mark as done regardless to prevent infinite loops
POST_UPDATE_DONE=true
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 (api_exit_script) to retry with 3 fresh attempts.
# No infinite loop risk: EXIT trap fires exactly once.
}
# ==============================================================================
@@ -814,31 +1000,55 @@ EOF
categorize_error() {
local code="$1"
case "$code" in
# Network errors
6 | 7 | 22 | 28 | 35) echo "network" ;;
# Network errors (curl/wget)
6 | 7 | 22 | 35) echo "network" ;;
# Storage errors
214 | 217 | 219) echo "storage" ;;
# Docker / Privileged mode required
10) echo "config" ;;
# Dependency/Package errors
100 | 101 | 102 | 127 | 160 | 161 | 162) echo "dependency" ;;
# Timeout errors
28 | 124 | 211) echo "timeout" ;;
# 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" ;;
# Permission errors
126 | 152) echo "permission" ;;
# Timeout errors
124 | 28 | 211) echo "timeout" ;;
# Configuration errors (Proxmox config, invalid args)
128 | 203 | 204 | 205 | 206 | 207 | 208) echo "config" ;;
# Configuration errors
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" ;;
# Aborted by user
130) echo "aborted" ;;
# Service/Systemd errors
150 | 151 | 153 | 154) echo "service" ;;
# Resource errors (OOM, etc)
137 | 134) echo "resource" ;;
# Database errors (PostgreSQL, MySQL, MongoDB)
170 | 171 | 172 | 173 | 180 | 181 | 182 | 183 | 190 | 191 | 192 | 193) echo "database" ;;
# Default
# Node.js / JavaScript runtime errors
243 | 245 | 246 | 247 | 248 | 249) echo "runtime" ;;
# 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" ;;
# Resource errors (OOM, SIGKILL, SIGABRT)
134 | 137) echo "resource" ;;
# Signal/Process errors (SIGPIPE, SIGSEGV)
139 | 141) echo "signal" ;;
# Shell errors (general error, syntax error)
1 | 2) echo "shell" ;;
# Default - truly unknown
*) echo "unknown" ;;
esac
}
@@ -870,6 +1080,63 @@ get_install_duration() {
echo $((now - INSTALL_START_TIME))
}
# ------------------------------------------------------------------------------
# _telemetry_report_exit()
#
# - Internal handler called by EXIT trap set in init_tool_telemetry()
# - Determines success/failure from exit code and reports via appropriate API
# - Arguments:
# * $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
}
# ------------------------------------------------------------------------------
# init_tool_telemetry()
#
# - One-line telemetry setup for tools/addon scripts
# - Reads DIAGNOSTICS from /usr/local/community-scripts/diagnostics
# (persisted on PVE host during first build, and inside containers by install.func)
# - Starts install timer for duration tracking
# - Sets EXIT trap to automatically report success/failure on script exit
# - Arguments:
# * $1: tool_name (optional, falls back to $APP at exit time)
# * $2: type ("pve" for PVE host scripts, "addon" for container addons)
# - Usage:
# source <(curl -fsSL .../misc/api.func) 2>/dev/null || true
# init_tool_telemetry "post-pve-install" "pve"
# init_tool_telemetry "" "addon" # uses $APP at exit time
# ------------------------------------------------------------------------------
init_tool_telemetry() {
local name="${1:-}"
local type="${2:-pve}"
[[ -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
start_install_timer
# EXIT trap: automatically report telemetry when script ends
trap '_telemetry_report_exit "$?"' EXIT
}
# ------------------------------------------------------------------------------
# post_tool_to_api()
#
@@ -901,11 +1168,9 @@ post_tool_to_api() {
[[ ! "$exit_code" =~ ^[0-9]+$ ]] && exit_code=1
local error_text=""
error_text=$(get_error_text)
if [[ -n "$error_text" ]]; then
error=$(json_escape "$error_text")
else
error=$(json_escape "$(explain_exit_code "$exit_code")")
fi
local full_error
full_error=$(build_error_string "$exit_code" "$error_text")
error=$(json_escape "$full_error")
error_category=$(categorize_error "$exit_code")
fi
@@ -919,7 +1184,8 @@ post_tool_to_api() {
cat <<EOF
{
"random_id": "${uuid}",
"type": "tool",
"execution_id": "${EXECUTION_ID:-${uuid}}",
"type": "pve",
"nsapp": "${tool_name}",
"status": "${status}",
"exit_code": ${exit_code},
@@ -968,11 +1234,9 @@ post_addon_to_api() {
[[ ! "$exit_code" =~ ^[0-9]+$ ]] && exit_code=1
local error_text=""
error_text=$(get_error_text)
if [[ -n "$error_text" ]]; then
error=$(json_escape "$error_text")
else
error=$(json_escape "$(explain_exit_code "$exit_code")")
fi
local full_error
full_error=$(build_error_string "$exit_code" "$error_text")
error=$(json_escape "$full_error")
error_category=$(categorize_error "$exit_code")
fi
@@ -988,6 +1252,7 @@ post_addon_to_api() {
cat <<EOF
{
"random_id": "${uuid}",
"execution_id": "${EXECUTION_ID:-${uuid}}",
"type": "addon",
"nsapp": "${addon_name}",
"status": "${status}",
@@ -1067,11 +1332,9 @@ post_update_to_api_extended() {
fi
local error_text=""
error_text=$(get_error_text)
if [[ -n "$error_text" ]]; then
error=$(json_escape "$error_text")
else
error=$(json_escape "$(explain_exit_code "$exit_code")")
fi
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
@@ -1081,6 +1344,7 @@ post_update_to_api_extended() {
cat <<EOF
{
"random_id": "${RANDOM_UUID}",
"execution_id": "${EXECUTION_ID:-${RANDOM_UUID}}",
"type": "${TELEMETRY_TYPE:-lxc}",
"nsapp": "${NSAPP:-unknown}",
"status": "${pb_status}",
@@ -1095,9 +1359,27 @@ post_update_to_api_extended() {
EOF
)
curl -fsS -m "${TELEMETRY_TIMEOUT}" -X POST "${TELEMETRY_URL}" \
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" &>/dev/null || true
-d "$JSON_PAYLOAD" -o /dev/null 2>/dev/null) || http_code="000"
POST_UPDATE_DONE=true
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
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,277 +0,0 @@
# Copyright (c) 2021-2026 tteck
# Author: tteck (tteckster)
# Co-Author: MickLesk
# Co-Author: michelroegl-brunner
# License: MIT | https://github.com/community-scripts/ProxmoxVED/raw/main/LICENSE
# ==============================================================================
# INSTALL.FUNC - CONTAINER INSTALLATION & SETUP
# ==============================================================================
#
# This file provides installation functions executed inside LXC containers
# after creation. Handles:
#
# - Network connectivity verification (IPv4/IPv6)
# - OS updates and package installation
# - DNS resolution checks
# - MOTD and SSH configuration
# - Container customization and auto-login
#
# Usage:
# - Sourced by <app>-install.sh scripts
# - Executes via pct exec inside container
# - Requires internet connectivity
#
# ==============================================================================
# ==============================================================================
# SECTION 1: INITIALIZATION
# ==============================================================================
if ! command -v curl >/dev/null 2>&1; then
printf "\r\e[2K%b" '\033[93m Setup Source \033[m' >&2
apt-get update >/dev/null 2>&1
apt-get install -y curl >/dev/null 2>&1
fi
source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/core.func)
source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/error_handler.func)
load_functions
catch_errors
# ==============================================================================
# SECTION 2: NETWORK & CONNECTIVITY
# ==============================================================================
# ------------------------------------------------------------------------------
# verb_ip6()
#
# - Configures IPv6 based on DISABLEIPV6 variable
# - If DISABLEIPV6=yes: disables IPv6 via sysctl
# - Sets verbose mode via set_std_mode()
# ------------------------------------------------------------------------------
verb_ip6() {
set_std_mode # Set STD mode based on VERBOSE
if [ "$DISABLEIPV6" == "yes" ]; then
echo "net.ipv6.conf.all.disable_ipv6 = 1" >>/etc/sysctl.conf
$STD sysctl -p
fi
}
# ------------------------------------------------------------------------------
# setting_up_container()
#
# - Verifies network connectivity via hostname -I
# - Retries up to RETRY_NUM times with RETRY_EVERY seconds delay
# - Removes Python EXTERNALLY-MANAGED restrictions
# - Disables systemd-networkd-wait-online.service for faster boot
# - Exits with error if network unavailable after retries
# ------------------------------------------------------------------------------
setting_up_container() {
msg_info "Setting up Container OS"
for ((i = RETRY_NUM; i > 0; i--)); do
if [ "$(hostname -I)" != "" ]; then
break
fi
echo 1>&2 -en "${CROSS}${RD} No Network! "
sleep $RETRY_EVERY
done
if [ "$(hostname -I)" = "" ]; then
echo 1>&2 -e "\n${CROSS}${RD} No Network After $RETRY_NUM Tries${CL}"
echo -e "${NETWORK}Check Network Settings"
exit 1
fi
rm -rf /usr/lib/python3.*/EXTERNALLY-MANAGED
systemctl disable -q --now systemd-networkd-wait-online.service
msg_ok "Set up Container OS"
#msg_custom "${CM}" "${GN}" "Network Connected: ${BL}$(hostname -I)"
msg_ok "Network Connected: ${BL}$(hostname -I)"
}
# ------------------------------------------------------------------------------
# network_check()
#
# - Comprehensive network connectivity check for IPv4 and IPv6
# - Tests connectivity to multiple DNS servers:
# * IPv4: 1.1.1.1 (Cloudflare), 8.8.8.8 (Google), 9.9.9.9 (Quad9)
# * IPv6: 2606:4700:4700::1111, 2001:4860:4860::8888, 2620:fe::fe
# - Verifies DNS resolution for GitHub and Community-Scripts domains
# - Prompts user to continue if no internet detected
# - 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? <y/N> " prompt
if [[ "${prompt,,}" =~ ^(y|yes)$ ]]; then
echo -e "${INFO}${RD}Expect Issues Without Internet${CL}"
else
echo -e "${NETWORK}Check Network Settings"
exit 1
fi
fi
# DNS resolution checks for GitHub-related domains (IPv4 and/or IPv6)
GIT_HOSTS=("github.com" "raw.githubusercontent.com" "api.github.com" "git.community-scripts.org")
GIT_STATUS="Git DNS:"
DNS_FAILED=false
for HOST in "${GIT_HOSTS[@]}"; do
RESOLVEDIP=$(getent hosts "$HOST" | awk '{ print $1 }' | grep -E '(^([0-9]{1,3}\.){3}[0-9]{1,3}$)|(^[a-fA-F0-9:]+$)' | head -n1)
if [[ -z "$RESOLVEDIP" ]]; then
GIT_STATUS+="$HOST:($DNSFAIL)"
DNS_FAILED=true
else
GIT_STATUS+=" $HOST:($DNSOK)"
fi
done
if [[ "$DNS_FAILED" == true ]]; then
fatal "$GIT_STATUS"
else
msg_ok "$GIT_STATUS"
fi
set -e
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
}
# ==============================================================================
# SECTION 3: OS UPDATE & PACKAGE MANAGEMENT
# ==============================================================================
# ------------------------------------------------------------------------------
# update_os()
#
# - Updates container OS via apt-get update and dist-upgrade
# - Configures APT cacher proxy if CACHER=yes (accelerates package downloads)
# - Removes Python EXTERNALLY-MANAGED restrictions for pip
# - Sources tools.func for additional setup functions after update
# - Uses $STD wrapper to suppress output unless VERBOSE=yes
# ------------------------------------------------------------------------------
update_os() {
msg_info "Updating Container OS"
if [[ "$CACHER" == "yes" ]]; then
echo "Acquire::http::Proxy-Auto-Detect \"/usr/local/bin/apt-proxy-detect.sh\";" >/etc/apt/apt.conf.d/00aptproxy
cat <<'EOF' >/usr/local/bin/apt-proxy-detect.sh
#!/bin/bash
if nc -w1 -z "${CACHER_IP}" 3142; then
echo -n "http://${CACHER_IP}:3142"
else
echo -n "DIRECT"
fi
EOF
chmod +x /usr/local/bin/apt-proxy-detect.sh
fi
$STD apt-get update
$STD apt-get -o Dpkg::Options::="--force-confold" -y dist-upgrade
rm -rf /usr/lib/python3.*/EXTERNALLY-MANAGED
msg_ok "Updated Container OS"
source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/tools.func)
}
# ==============================================================================
# SECTION 4: MOTD & SSH CONFIGURATION
# ==============================================================================
# ------------------------------------------------------------------------------
# motd_ssh()
#
# - Configures Message of the Day (MOTD) with container information
# - Creates /etc/profile.d/00_lxc-details.sh with:
# * Application name
# * Warning banner (DEV repository)
# * OS name and version
# * Hostname and IP address
# * GitHub repository link
# - Disables executable flag on /etc/update-motd.d/* scripts
# - Enables root SSH access if SSH_ROOT=yes
# - Configures TERM environment variable for better terminal support
# ------------------------------------------------------------------------------
motd_ssh() {
grep -qxF "export TERM='xterm-256color'" /root/.bashrc || echo "export TERM='xterm-256color'" >>/root/.bashrc
if [ -f "/etc/os-release" ]; then
OS_NAME=$(grep ^NAME /etc/os-release | cut -d= -f2 | tr -d '"')
OS_VERSION=$(grep ^VERSION_ID /etc/os-release | cut -d= -f2 | tr -d '"')
elif [ -f "/etc/debian_version" ]; then
OS_NAME="Debian"
OS_VERSION=$(cat /etc/debian_version)
fi
PROFILE_FILE="/etc/profile.d/00_lxc-details.sh"
echo "echo -e \"\"" >"$PROFILE_FILE"
echo -e "echo -e \"${BOLD}${YW}${APPLICATION} LXC Container - DEV Repository${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${RD}WARNING: This is a DEVELOPMENT version (ProxmoxVED). Do NOT use in production!${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${YW} OS: ${GN}${OS_NAME} - Version: ${OS_VERSION}${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${YW} Hostname: ${GN}\$(hostname)${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${YW} IP Address: ${GN}\$(hostname -I | awk '{print \$1}')${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${YW} Repository: ${GN}https://github.com/community-scripts/ProxmoxVED${CL}\"" >>"$PROFILE_FILE"
echo "echo \"\"" >>"$PROFILE_FILE"
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
}
# ==============================================================================
# SECTION 5: CONTAINER CUSTOMIZATION
# ==============================================================================
# ------------------------------------------------------------------------------
# customize()
#
# - Customizes container for passwordless root login if PASSWORD is empty
# - Configures getty for auto-login via /etc/systemd/system/container-getty@1.service.d/override.conf
# - Creates /usr/bin/update script for easy application updates
# - Injects SSH authorized keys if SSH_AUTHORIZED_KEY variable is set
# - 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 <<EOF >$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://github.com/community-scripts/ProxmoxVED/raw/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
}

View File

@@ -1,287 +0,0 @@
# Copyright (c) 2021-2026 tteck
# Author: tteck (tteckster)
# Co-Author: MickLesk
# Co-Author: michelroegl-brunner
# License: MIT | https://github.com/community-scripts/ProxmoxVED/raw/main/LICENSE
# ==============================================================================
# INSTALL.FUNC - CONTAINER INSTALLATION & SETUP
# ==============================================================================
#
# This file provides installation functions executed inside LXC containers
# after creation. Handles:
#
# - Network connectivity verification (IPv4/IPv6)
# - OS updates and package installation
# - DNS resolution checks
# - MOTD and SSH configuration
# - Container customization and auto-login
#
# Usage:
# - Sourced by <app>-install.sh scripts
# - Executes via pct exec inside container
# - Requires internet connectivity
#
# ==============================================================================
# ==============================================================================
# SECTION 1: INITIALIZATION
# ==============================================================================
if ! command -v curl >/dev/null 2>&1; then
printf "\r\e[2K%b" '\033[93m Setup Source \033[m' >&2
apt-get update >/dev/null 2>&1
apt-get install -y curl >/dev/null 2>&1
fi
source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/core.func)
source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/error_handler.func)
load_functions
catch_errors
# ==============================================================================
# SECTION 2: NETWORK & CONNECTIVITY
# ==============================================================================
# ------------------------------------------------------------------------------
# verb_ip6()
#
# - Configures IPv6 based on IPV6_METHOD variable
# - If IPV6_METHOD=disable: disables IPv6 via sysctl
# - Sets verbose mode via set_std_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)"
mkdir -p /etc/sysctl.d
$STD tee /etc/sysctl.d/99-disable-ipv6.conf >/dev/null <<EOF
# Disable IPv6 (set by community-scripts)
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
net.ipv6.conf.lo.disable_ipv6 = 1
EOF
$STD sysctl -p /etc/sysctl.d/99-disable-ipv6.conf
msg_ok "Disabled IPv6"
fi
}
# ------------------------------------------------------------------------------
# setting_up_container()
#
# - Verifies network connectivity via hostname -I
# - Retries up to RETRY_NUM times with RETRY_EVERY seconds delay
# - Removes Python EXTERNALLY-MANAGED restrictions
# - Disables systemd-networkd-wait-online.service for faster boot
# - Exits with error if network unavailable after retries
# ------------------------------------------------------------------------------
setting_up_container() {
msg_info "Setting up Container OS"
for ((i = RETRY_NUM; i > 0; i--)); do
if [ "$(hostname -I)" != "" ]; then
break
fi
echo 1>&2 -en "${CROSS}${RD} No Network! "
sleep $RETRY_EVERY
done
if [ "$(hostname -I)" = "" ]; then
echo 1>&2 -e "\n${CROSS}${RD} No Network After $RETRY_NUM Tries${CL}"
echo -e "${NETWORK}Check Network Settings"
exit 1
fi
rm -rf /usr/lib/python3.*/EXTERNALLY-MANAGED
systemctl disable -q --now systemd-networkd-wait-online.service
msg_ok "Set up Container OS"
#msg_custom "${CM}" "${GN}" "Network Connected: ${BL}$(hostname -I)"
msg_ok "Network Connected: ${BL}$(hostname -I)"
}
# ------------------------------------------------------------------------------
# network_check()
#
# - Comprehensive network connectivity check for IPv4 and IPv6
# - Tests connectivity to multiple DNS servers:
# * IPv4: 1.1.1.1 (Cloudflare), 8.8.8.8 (Google), 9.9.9.9 (Quad9)
# * IPv6: 2606:4700:4700::1111, 2001:4860:4860::8888, 2620:fe::fe
# - Verifies DNS resolution for GitHub and Community-Scripts domains
# - Prompts user to continue if no internet detected
# - 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? <y/N> " prompt
if [[ "${prompt,,}" =~ ^(y|yes)$ ]]; then
echo -e "${INFO}${RD}Expect Issues Without Internet${CL}"
else
echo -e "${NETWORK}Check Network Settings"
exit 1
fi
fi
# DNS resolution checks for GitHub-related domains (IPv4 and/or IPv6)
GIT_HOSTS=("github.com" "raw.githubusercontent.com" "api.github.com" "git.community-scripts.org")
GIT_STATUS="Git DNS:"
DNS_FAILED=false
for HOST in "${GIT_HOSTS[@]}"; do
RESOLVEDIP=$(getent hosts "$HOST" | awk '{ print $1 }' | grep -E '(^([0-9]{1,3}\.){3}[0-9]{1,3}$)|(^[a-fA-F0-9:]+$)' | head -n1)
if [[ -z "$RESOLVEDIP" ]]; then
GIT_STATUS+="$HOST:($DNSFAIL)"
DNS_FAILED=true
else
GIT_STATUS+=" $HOST:($DNSOK)"
fi
done
if [[ "$DNS_FAILED" == true ]]; then
fatal "$GIT_STATUS"
else
msg_ok "$GIT_STATUS"
fi
set -e
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
}
# ==============================================================================
# SECTION 3: OS UPDATE & PACKAGE MANAGEMENT
# ==============================================================================
# ------------------------------------------------------------------------------
# update_os()
#
# - Updates container OS via apt-get update and dist-upgrade
# - Configures APT cacher proxy if CACHER=yes (accelerates package downloads)
# - Removes Python EXTERNALLY-MANAGED restrictions for pip
# - Sources tools.func for additional setup functions after update
# - Uses $STD wrapper to suppress output unless VERBOSE=yes
# ------------------------------------------------------------------------------
update_os() {
msg_info "Updating Container OS"
if [[ "$CACHER" == "yes" ]]; then
echo 'Acquire::http::Proxy-Auto-Detect "/usr/local/bin/apt-proxy-detect.sh";' >/etc/apt/apt.conf.d/00aptproxy
cat <<EOF >/usr/local/bin/apt-proxy-detect.sh
#!/bin/bash
if nc -w1 -z "${CACHER_IP}" 3142; then
echo -n "http://${CACHER_IP}:3142"
else
echo -n "DIRECT"
fi
EOF
chmod +x /usr/local/bin/apt-proxy-detect.sh
fi
$STD apt-get update
$STD apt-get -o Dpkg::Options::="--force-confold" -y dist-upgrade
rm -rf /usr/lib/python3.*/EXTERNALLY-MANAGED
msg_ok "Updated Container OS"
source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/tools.func)
}
# ==============================================================================
# SECTION 4: MOTD & SSH CONFIGURATION
# ==============================================================================
# ------------------------------------------------------------------------------
# motd_ssh()
#
# - Configures Message of the Day (MOTD) with container information
# - Creates /etc/profile.d/00_lxc-details.sh with:
# * Application name
# * Warning banner (DEV repository)
# * OS name and version
# * Hostname and IP address
# * GitHub repository link
# - Disables executable flag on /etc/update-motd.d/* scripts
# - Enables root SSH access if SSH_ROOT=yes
# - 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
# Get OS information (Debian / Ubuntu)
if [ -f "/etc/os-release" ]; then
OS_NAME=$(grep ^NAME /etc/os-release | cut -d= -f2 | tr -d '"')
OS_VERSION=$(grep ^VERSION_ID /etc/os-release | cut -d= -f2 | tr -d '"')
elif [ -f "/etc/debian_version" ]; then
OS_NAME="Debian"
OS_VERSION=$(cat /etc/debian_version)
fi
PROFILE_FILE="/etc/profile.d/00_lxc-details.sh"
echo "echo -e \"\"" >"$PROFILE_FILE"
echo -e "echo -e \"${BOLD}${YW}${APPLICATION} LXC Container - DEV Repository${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${RD}WARNING: This is a DEVELOPMENT version (ProxmoxVED). Do NOT use in production!${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${YW} OS: ${GN}${OS_NAME} - Version: ${OS_VERSION}${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${YW} Hostname: ${GN}\$(hostname)${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${YW} IP Address: ${GN}\$(hostname -I | awk '{print \$1}')${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${YW} Repository: ${GN}https://github.com/community-scripts/ProxmoxVED${CL}\"" >>"$PROFILE_FILE"
echo "echo \"\"" >>"$PROFILE_FILE"
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
}
# ==============================================================================
# SECTION 5: CONTAINER CUSTOMIZATION
# ==============================================================================
# ------------------------------------------------------------------------------
# customize()
#
# - Customizes container for passwordless root login if PASSWORD is empty
# - Configures getty for auto-login via /etc/systemd/system/container-getty@1.service.d/override.conf
# - Creates /usr/bin/update script for easy application updates
# - Injects SSH authorized keys if SSH_AUTHORIZED_KEY variable is set
# - 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 <<EOF >$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/ProxmoxVED/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
}

View File

@@ -1,992 +0,0 @@
# Copyright (c) 2021-2026 community-scripts ORG
# Author: tteck (tteckster)
# Co-Author: MickLesk
# Co-Author: michelroegl-brunner
# License: MIT | https://github.com/community-scripts/ProxmoxVED/raw/main/LICENSE
# ==============================================================================
# INSTALL.FUNC - UNIFIED CONTAINER INSTALLATION & SETUP
# ==============================================================================
#
# All-in-One install.func supporting multiple Linux distributions:
# - Debian, Ubuntu, Devuan (apt, systemd/sysvinit)
# - Alpine (apk, OpenRC)
# - Fedora, Rocky, AlmaLinux, CentOS (dnf/yum, systemd)
# - Arch Linux (pacman, systemd)
# - openSUSE (zypper, systemd)
# - Gentoo (emerge, OpenRC)
# - NixOS (nix, systemd)
#
# Features:
# - Automatic OS detection
# - Unified package manager abstraction
# - Init system abstraction (systemd/OpenRC/runit/sysvinit)
# - Network connectivity verification
# - MOTD and SSH configuration
# - Container customization
#
# ==============================================================================
# ==============================================================================
# SECTION 1: INITIALIZATION & OS DETECTION
# ==============================================================================
# Global variables for OS detection
OS_TYPE="" # debian, ubuntu, alpine, fedora, arch, opensuse, gentoo, nixos, devuan, rocky, alma, centos
OS_FAMILY="" # debian, alpine, rhel, arch, suse, gentoo, nixos
OS_VERSION="" # Version number
PKG_MANAGER="" # apt, apk, dnf, yum, pacman, zypper, emerge, nix-env
INIT_SYSTEM="" # systemd, openrc, runit, sysvinit
# ------------------------------------------------------------------------------
# detect_os()
#
# Detects the operating system and sets global variables:
# OS_TYPE, OS_FAMILY, OS_VERSION, PKG_MANAGER, INIT_SYSTEM
# ------------------------------------------------------------------------------
detect_os() {
if [[ -f /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
OS_TYPE="${ID:-unknown}"
OS_VERSION="${VERSION_ID:-unknown}"
elif [[ -f /etc/alpine-release ]]; then
OS_TYPE="alpine"
OS_VERSION=$(cat /etc/alpine-release)
elif [[ -f /etc/debian_version ]]; then
OS_TYPE="debian"
OS_VERSION=$(cat /etc/debian_version)
elif [[ -f /etc/redhat-release ]]; then
OS_TYPE="centos"
OS_VERSION=$(grep -oE '[0-9]+\.[0-9]+' /etc/redhat-release | head -1)
elif [[ -f /etc/arch-release ]]; then
OS_TYPE="arch"
OS_VERSION="rolling"
elif [[ -f /etc/gentoo-release ]]; then
OS_TYPE="gentoo"
OS_VERSION=$(cat /etc/gentoo-release | grep -oE '[0-9.]+')
else
OS_TYPE="unknown"
OS_VERSION="unknown"
fi
# Normalize OS type and determine family
case "$OS_TYPE" in
debian)
OS_FAMILY="debian"
PKG_MANAGER="apt"
;;
ubuntu)
OS_FAMILY="debian"
PKG_MANAGER="apt"
;;
devuan)
OS_FAMILY="debian"
PKG_MANAGER="apt"
;;
alpine)
OS_FAMILY="alpine"
PKG_MANAGER="apk"
;;
fedora)
OS_FAMILY="rhel"
PKG_MANAGER="dnf"
;;
rocky | rockylinux)
OS_TYPE="rocky"
OS_FAMILY="rhel"
PKG_MANAGER="dnf"
;;
alma | almalinux)
OS_TYPE="alma"
OS_FAMILY="rhel"
PKG_MANAGER="dnf"
;;
centos)
OS_FAMILY="rhel"
# CentOS 7 uses yum, 8+ uses dnf
if [[ "${OS_VERSION%%.*}" -ge 8 ]]; then
PKG_MANAGER="dnf"
else
PKG_MANAGER="yum"
fi
;;
rhel)
OS_FAMILY="rhel"
PKG_MANAGER="dnf"
;;
arch | archlinux)
OS_TYPE="arch"
OS_FAMILY="arch"
PKG_MANAGER="pacman"
;;
opensuse* | sles)
OS_TYPE="opensuse"
OS_FAMILY="suse"
PKG_MANAGER="zypper"
;;
gentoo)
OS_FAMILY="gentoo"
PKG_MANAGER="emerge"
;;
nixos)
OS_FAMILY="nixos"
PKG_MANAGER="nix-env"
;;
*)
OS_FAMILY="unknown"
PKG_MANAGER="unknown"
;;
esac
# Detect init system
if command -v systemctl &>/dev/null && [[ -d /run/systemd/system ]]; then
INIT_SYSTEM="systemd"
elif command -v rc-service &>/dev/null || [[ -d /etc/init.d && -f /sbin/openrc ]]; then
INIT_SYSTEM="openrc"
elif command -v sv &>/dev/null && [[ -d /etc/sv ]]; then
INIT_SYSTEM="runit"
elif [[ -f /etc/inittab ]]; then
INIT_SYSTEM="sysvinit"
else
INIT_SYSTEM="unknown"
fi
}
# ------------------------------------------------------------------------------
# Bootstrap: Ensure curl is available and source core functions
# ------------------------------------------------------------------------------
_bootstrap() {
# Minimal bootstrap to get curl installed
if ! command -v curl &>/dev/null; then
printf "\r\e[2K%b" '\033[93m Setup Source \033[m' >&2
if command -v apt-get &>/dev/null; then
apt-get update &>/dev/null && apt-get install -y curl &>/dev/null
elif command -v apk &>/dev/null; then
apk update &>/dev/null && apk add curl &>/dev/null
elif command -v dnf &>/dev/null; then
dnf install -y curl &>/dev/null
elif command -v yum &>/dev/null; then
yum install -y curl &>/dev/null
elif command -v pacman &>/dev/null; then
pacman -Sy --noconfirm curl &>/dev/null
elif command -v zypper &>/dev/null; then
zypper install -y curl &>/dev/null
elif command -v emerge &>/dev/null; then
emerge --quiet net-misc/curl &>/dev/null
fi
fi
# Source core functions
source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/core.func)
source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/error_handler.func)
load_functions
catch_errors
}
# Run bootstrap and OS detection
_bootstrap
detect_os
# ==============================================================================
# SECTION 2: PACKAGE MANAGER ABSTRACTION
# ==============================================================================
# ------------------------------------------------------------------------------
# pkg_update()
#
# Updates package manager cache/database
# ------------------------------------------------------------------------------
pkg_update() {
case "$PKG_MANAGER" in
apt)
$STD apt-get update
;;
apk)
$STD apk update
;;
dnf)
$STD dnf makecache
;;
yum)
$STD yum makecache
;;
pacman)
$STD pacman -Sy
;;
zypper)
$STD zypper refresh
;;
emerge)
$STD emerge --sync
;;
nix-env)
$STD nix-channel --update
;;
*)
msg_error "Unknown package manager: $PKG_MANAGER"
return 1
;;
esac
}
# ------------------------------------------------------------------------------
# pkg_upgrade()
#
# Upgrades all installed packages
# ------------------------------------------------------------------------------
pkg_upgrade() {
case "$PKG_MANAGER" in
apt)
$STD apt-get -o Dpkg::Options::="--force-confold" -y dist-upgrade
;;
apk)
$STD apk -U upgrade
;;
dnf)
$STD dnf -y upgrade
;;
yum)
$STD yum -y update
;;
pacman)
$STD pacman -Syu --noconfirm
;;
zypper)
$STD zypper -n update
;;
emerge)
$STD emerge --quiet --update --deep @world
;;
nix-env)
$STD nix-env -u
;;
*)
msg_error "Unknown package manager: $PKG_MANAGER"
return 1
;;
esac
}
# ------------------------------------------------------------------------------
# pkg_install(packages...)
#
# Installs one or more packages
# Arguments:
# packages - List of packages to install
# ------------------------------------------------------------------------------
pkg_install() {
local packages=("$@")
[[ ${#packages[@]} -eq 0 ]] && return 0
case "$PKG_MANAGER" in
apt)
$STD apt-get install -y "${packages[@]}"
;;
apk)
$STD apk add --no-cache "${packages[@]}"
;;
dnf)
$STD dnf install -y "${packages[@]}"
;;
yum)
$STD yum install -y "${packages[@]}"
;;
pacman)
$STD pacman -S --noconfirm "${packages[@]}"
;;
zypper)
$STD zypper install -y "${packages[@]}"
;;
emerge)
$STD emerge --quiet "${packages[@]}"
;;
nix-env)
for pkg in "${packages[@]}"; do
$STD nix-env -iA "nixos.$pkg"
done
;;
*)
msg_error "Unknown package manager: $PKG_MANAGER"
return 1
;;
esac
}
# ------------------------------------------------------------------------------
# pkg_remove(packages...)
#
# Removes one or more packages
# ------------------------------------------------------------------------------
pkg_remove() {
local packages=("$@")
[[ ${#packages[@]} -eq 0 ]] && return 0
case "$PKG_MANAGER" in
apt)
$STD apt-get remove -y "${packages[@]}"
;;
apk)
$STD apk del "${packages[@]}"
;;
dnf)
$STD dnf remove -y "${packages[@]}"
;;
yum)
$STD yum remove -y "${packages[@]}"
;;
pacman)
$STD pacman -Rs --noconfirm "${packages[@]}"
;;
zypper)
$STD zypper remove -y "${packages[@]}"
;;
emerge)
$STD emerge --quiet --unmerge "${packages[@]}"
;;
nix-env)
for pkg in "${packages[@]}"; do
$STD nix-env -e "$pkg"
done
;;
*)
msg_error "Unknown package manager: $PKG_MANAGER"
return 1
;;
esac
}
# ------------------------------------------------------------------------------
# pkg_clean()
#
# Cleans package manager cache to free space
# ------------------------------------------------------------------------------
pkg_clean() {
case "$PKG_MANAGER" in
apt)
$STD apt-get autoremove -y
$STD apt-get autoclean
;;
apk)
$STD apk cache clean
;;
dnf)
$STD dnf clean all
$STD dnf autoremove -y
;;
yum)
$STD yum clean all
;;
pacman)
$STD pacman -Scc --noconfirm
;;
zypper)
$STD zypper clean
;;
emerge)
$STD emerge --quiet --depclean
;;
nix-env)
$STD nix-collect-garbage -d
;;
*)
return 0
;;
esac
}
# ==============================================================================
# SECTION 3: SERVICE/INIT SYSTEM ABSTRACTION
# ==============================================================================
# ------------------------------------------------------------------------------
# svc_enable(service)
#
# Enables a service to start at boot
# ------------------------------------------------------------------------------
svc_enable() {
local service="$1"
[[ -z "$service" ]] && return 1
case "$INIT_SYSTEM" in
systemd)
$STD systemctl enable "$service"
;;
openrc)
$STD rc-update add "$service" default
;;
runit)
[[ -d "/etc/sv/$service" ]] && ln -sf "/etc/sv/$service" "/var/service/"
;;
sysvinit)
if command -v update-rc.d &>/dev/null; then
$STD update-rc.d "$service" defaults
elif command -v chkconfig &>/dev/null; then
$STD chkconfig "$service" on
fi
;;
*)
msg_warn "Unknown init system, cannot enable $service"
return 1
;;
esac
}
# ------------------------------------------------------------------------------
# svc_disable(service)
#
# Disables a service from starting at boot
# ------------------------------------------------------------------------------
svc_disable() {
local service="$1"
[[ -z "$service" ]] && return 1
case "$INIT_SYSTEM" in
systemd)
$STD systemctl disable "$service"
;;
openrc)
$STD rc-update del "$service" default 2>/dev/null || true
;;
runit)
rm -f "/var/service/$service"
;;
sysvinit)
if command -v update-rc.d &>/dev/null; then
$STD update-rc.d "$service" remove
elif command -v chkconfig &>/dev/null; then
$STD chkconfig "$service" off
fi
;;
*)
return 1
;;
esac
}
# ------------------------------------------------------------------------------
# svc_start(service)
#
# Starts a service immediately
# ------------------------------------------------------------------------------
svc_start() {
local service="$1"
[[ -z "$service" ]] && return 1
case "$INIT_SYSTEM" in
systemd)
$STD systemctl start "$service"
;;
openrc)
$STD rc-service "$service" start
;;
runit)
$STD sv start "$service"
;;
sysvinit)
$STD /etc/init.d/"$service" start
;;
*)
return 1
;;
esac
}
# ------------------------------------------------------------------------------
# svc_stop(service)
#
# Stops a running service
# ------------------------------------------------------------------------------
svc_stop() {
local service="$1"
[[ -z "$service" ]] && return 1
case "$INIT_SYSTEM" in
systemd)
$STD systemctl stop "$service"
;;
openrc)
$STD rc-service "$service" stop
;;
runit)
$STD sv stop "$service"
;;
sysvinit)
$STD /etc/init.d/"$service" stop
;;
*)
return 1
;;
esac
}
# ------------------------------------------------------------------------------
# svc_restart(service)
#
# Restarts a service
# ------------------------------------------------------------------------------
svc_restart() {
local service="$1"
[[ -z "$service" ]] && return 1
case "$INIT_SYSTEM" in
systemd)
$STD systemctl restart "$service"
;;
openrc)
$STD rc-service "$service" restart
;;
runit)
$STD sv restart "$service"
;;
sysvinit)
$STD /etc/init.d/"$service" restart
;;
*)
return 1
;;
esac
}
# ------------------------------------------------------------------------------
# svc_status(service)
#
# Gets service status (returns 0 if running)
# ------------------------------------------------------------------------------
svc_status() {
local service="$1"
[[ -z "$service" ]] && return 1
case "$INIT_SYSTEM" in
systemd)
systemctl is-active --quiet "$service"
;;
openrc)
rc-service "$service" status &>/dev/null
;;
runit)
sv status "$service" | grep -q "^run:"
;;
sysvinit)
/etc/init.d/"$service" status &>/dev/null
;;
*)
return 1
;;
esac
}
# ------------------------------------------------------------------------------
# svc_reload_daemon()
#
# Reloads init system daemon configuration (for systemd)
# ------------------------------------------------------------------------------
svc_reload_daemon() {
case "$INIT_SYSTEM" in
systemd)
$STD systemctl daemon-reload
;;
*)
# Other init systems don't need this
return 0
;;
esac
}
# ==============================================================================
# SECTION 4: NETWORK & CONNECTIVITY
# ==============================================================================
# ------------------------------------------------------------------------------
# get_ip()
#
# Gets the primary IPv4 address of the container
# Returns: IP address string
# ------------------------------------------------------------------------------
get_ip() {
local ip=""
# Try hostname -I first (most common)
if command -v hostname &>/dev/null; then
ip=$(hostname -I 2>/dev/null | awk '{print $1}')
fi
# Fallback to ip command
if [[ -z "$ip" ]] && command -v ip &>/dev/null; then
ip=$(ip -4 addr show scope global | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -1)
fi
# Fallback to ifconfig
if [[ -z "$ip" ]] && command -v ifconfig &>/dev/null; then
ip=$(ifconfig | grep -Eo 'inet (addr:)?([0-9]*\.){3}[0-9]*' | grep -Eo '([0-9]*\.){3}[0-9]*' | grep -v '127.0.0.1' | head -1)
fi
echo "$ip"
}
# ------------------------------------------------------------------------------
# verb_ip6()
#
# Configures IPv6 based on IPV6_METHOD variable
# If IPV6_METHOD=disable: disables IPv6 via sysctl
# ------------------------------------------------------------------------------
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)"
mkdir -p /etc/sysctl.d
cat >/etc/sysctl.d/99-disable-ipv6.conf <<EOF
# Disable IPv6 (set by community-scripts)
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
net.ipv6.conf.lo.disable_ipv6 = 1
EOF
$STD sysctl -p /etc/sysctl.d/99-disable-ipv6.conf
# For OpenRC, ensure sysctl runs at boot
if [[ "$INIT_SYSTEM" == "openrc" ]]; then
$STD rc-update add sysctl default 2>/dev/null || true
fi
msg_ok "Disabled IPv6"
fi
}
# ------------------------------------------------------------------------------
# setting_up_container()
#
# Initial container setup:
# - Verifies network connectivity
# - Removes Python EXTERNALLY-MANAGED restrictions
# - Disables network wait services
# ------------------------------------------------------------------------------
setting_up_container() {
msg_info "Setting up Container OS"
# Wait for network
local i
for ((i = RETRY_NUM; i > 0; i--)); do
if [[ -n "$(get_ip)" ]]; then
break
fi
echo 1>&2 -en "${CROSS}${RD} No Network! "
sleep "$RETRY_EVERY"
done
if [[ -z "$(get_ip)" ]]; then
echo 1>&2 -e "\n${CROSS}${RD} No Network After $RETRY_NUM Tries${CL}"
echo -e "${NETWORK}Check Network Settings"
exit 1
fi
# Remove Python EXTERNALLY-MANAGED restriction (Debian 12+, Ubuntu 23.04+)
rm -rf /usr/lib/python3.*/EXTERNALLY-MANAGED 2>/dev/null || true
# Disable network wait services for faster boot
case "$INIT_SYSTEM" in
systemd)
systemctl disable -q --now systemd-networkd-wait-online.service 2>/dev/null || true
;;
esac
msg_ok "Set up Container OS"
msg_ok "Network Connected: ${BL}$(get_ip)"
}
# ------------------------------------------------------------------------------
# network_check()
#
# Comprehensive network connectivity check for IPv4 and IPv6
# Tests connectivity to DNS servers and verifies DNS resolution
# ------------------------------------------------------------------------------
network_check() {
set +e
trap - ERR
local ipv4_connected=false
local ipv6_connected=false
sleep 1
# Check IPv4 connectivity
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 (if ping6 exists)
if command -v ping6 &>/dev/null; then
if ping6 -c 1 -W 1 2606:4700:4700::1111 &>/dev/null || ping6 -c 1 -W 1 2001:4860:4860::8888 &>/dev/null; then
msg_ok "IPv6 Internet Connected"
ipv6_connected=true
else
msg_error "IPv6 Internet Not Connected"
fi
fi
# Prompt if both fail
if [[ $ipv4_connected == false && $ipv6_connected == false ]]; then
read -r -p "No Internet detected, would you like to continue anyway? <y/N> " prompt
if [[ "${prompt,,}" =~ ^(y|yes)$ ]]; then
echo -e "${INFO}${RD}Expect Issues Without Internet${CL}"
else
echo -e "${NETWORK}Check Network Settings"
exit 1
fi
fi
# DNS resolution checks
local GIT_HOSTS=("github.com" "raw.githubusercontent.com" "git.community-scripts.org")
local GIT_STATUS="Git DNS:"
local DNS_FAILED=false
for HOST in "${GIT_HOSTS[@]}"; do
local RESOLVEDIP
RESOLVEDIP=$(getent hosts "$HOST" 2>/dev/null | awk '{ print $1 }' | head -n1)
if [[ -z "$RESOLVEDIP" ]]; then
GIT_STATUS+=" $HOST:(${DNSFAIL:-FAIL})"
DNS_FAILED=true
else
GIT_STATUS+=" $HOST:(${DNSOK:-OK})"
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
}
# ==============================================================================
# SECTION 5: OS UPDATE & PACKAGE MANAGEMENT
# ==============================================================================
# ------------------------------------------------------------------------------
# update_os()
#
# Updates container OS and sources appropriate tools.func
# ------------------------------------------------------------------------------
update_os() {
msg_info "Updating Container OS"
# Configure APT cacher proxy if enabled (Debian/Ubuntu only)
if [[ "$PKG_MANAGER" == "apt" && "${CACHER:-}" == "yes" ]]; then
echo 'Acquire::http::Proxy-Auto-Detect "/usr/local/bin/apt-proxy-detect.sh";' >/etc/apt/apt.conf.d/00aptproxy
cat <<EOF >/usr/local/bin/apt-proxy-detect.sh
#!/bin/bash
if nc -w1 -z "${CACHER_IP}" 3142; then
echo -n "http://${CACHER_IP}:3142"
else
echo -n "DIRECT"
fi
EOF
chmod +x /usr/local/bin/apt-proxy-detect.sh
fi
# Update and upgrade
pkg_update
pkg_upgrade
# Remove Python EXTERNALLY-MANAGED restriction
rm -rf /usr/lib/python3.*/EXTERNALLY-MANAGED 2>/dev/null || true
msg_ok "Updated Container OS"
# Source appropriate tools.func based on OS
case "$OS_FAMILY" in
alpine)
source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/alpine-tools.func)
;;
*)
source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/tools.func)
;;
esac
}
# ==============================================================================
# SECTION 6: MOTD & SSH CONFIGURATION
# ==============================================================================
# ------------------------------------------------------------------------------
# motd_ssh()
#
# Configures Message of the Day and SSH settings
# ------------------------------------------------------------------------------
motd_ssh() {
# Set terminal to 256-color mode
grep -qxF "export TERM='xterm-256color'" /root/.bashrc 2>/dev/null || echo "export TERM='xterm-256color'" >>/root/.bashrc
# Get OS information
local os_name="$OS_TYPE"
local os_version="$OS_VERSION"
if [[ -f /etc/os-release ]]; then
os_name=$(grep ^NAME /etc/os-release | cut -d= -f2 | tr -d '"')
os_version=$(grep ^VERSION_ID /etc/os-release | cut -d= -f2 | tr -d '"')
fi
# Create MOTD profile script
local PROFILE_FILE="/etc/profile.d/00_lxc-details.sh"
cat >"$PROFILE_FILE" <<EOF
echo ""
echo -e "${BOLD:-}${YW:-}${APPLICATION:-Container} LXC Container - DEV Repository${CL:-}"
echo -e "${RD:-}WARNING: This is a DEVELOPMENT version (ProxmoxVED). Do NOT use in production!${CL:-}"
echo -e "${YW:-} OS: ${GN:-}${os_name} - Version: ${os_version}${CL:-}"
echo -e "${YW:-} Hostname: ${GN:-}\$(hostname)${CL:-}"
echo -e "${YW:-} IP Address: ${GN:-}\$(hostname -I 2>/dev/null | awk '{print \$1}' || ip -4 addr show scope global | grep -oP '(?<=inet\s)\\d+(\\.\\d+){3}' | head -1)${CL:-}"
echo -e "${YW:-} Repository: ${GN:-}https://github.com/community-scripts/ProxmoxVED${CL:-}"
echo ""
EOF
# Disable default MOTD scripts (Debian/Ubuntu)
[[ -d /etc/update-motd.d ]] && chmod -x /etc/update-motd.d/* 2>/dev/null || true
# Configure SSH root access if requested
if [[ "${SSH_ROOT:-}" == "yes" ]]; then
local sshd_config="/etc/ssh/sshd_config"
if [[ -f "$sshd_config" ]]; then
sed -i "s/#PermitRootLogin prohibit-password/PermitRootLogin yes/g" "$sshd_config"
sed -i "s/PermitRootLogin prohibit-password/PermitRootLogin yes/g" "$sshd_config"
case "$INIT_SYSTEM" in
systemd)
svc_restart sshd 2>/dev/null || svc_restart ssh 2>/dev/null || true
;;
openrc)
svc_enable sshd 2>/dev/null || true
svc_start sshd 2>/dev/null || true
;;
*)
svc_restart sshd 2>/dev/null || true
;;
esac
fi
fi
}
# ==============================================================================
# SECTION 7: CONTAINER CUSTOMIZATION
# ==============================================================================
# ------------------------------------------------------------------------------
# customize()
#
# Customizes container for passwordless login and creates update script
# ------------------------------------------------------------------------------
customize() {
if [[ "${PASSWORD:-}" == "" ]]; then
msg_info "Customizing Container"
# Remove root password for auto-login
passwd -d root &>/dev/null || true
case "$INIT_SYSTEM" in
systemd)
# Configure getty for auto-login
local GETTY_OVERRIDE="/etc/systemd/system/container-getty@1.service.d/override.conf"
mkdir -p "$(dirname "$GETTY_OVERRIDE")"
cat >"$GETTY_OVERRIDE" <<EOF
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin root --noclear --keep-baud tty%I 115200,38400,9600 \$TERM
EOF
svc_reload_daemon
systemctl restart "$(basename "$(dirname "$GETTY_OVERRIDE")" | sed 's/\.d//')" 2>/dev/null || true
;;
openrc)
# Alpine/Gentoo: use inittab for auto-login
pkg_install util-linux 2>/dev/null || true
# Create persistent autologin boot script
mkdir -p /etc/local.d
cat <<'EOFSCRIPT' >/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
EOFSCRIPT
chmod +x /etc/local.d/autologin.start
rc-update add local 2>/dev/null || true
/etc/local.d/autologin.start 2>/dev/null || true
touch /root/.hushlogin
;;
sysvinit)
# Devuan/older systems
if [[ -f /etc/inittab ]]; then
sed -i 's|^1:2345:respawn:/sbin/getty.*|1:2345:respawn:/sbin/agetty --autologin root tty1 38400 linux|' /etc/inittab
telinit q 2>/dev/null || true
fi
;;
esac
msg_ok "Customized Container"
fi
# Create update script
echo "bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/ct/${app}.sh)\"" >/usr/bin/update
chmod +x /usr/bin/update
# Inject SSH authorized keys if provided
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
}
# ==============================================================================
# SECTION 8: UTILITY FUNCTIONS
# ==============================================================================
# ------------------------------------------------------------------------------
# validate_tz(timezone)
#
# Validates if a timezone is valid
# Returns: 0 if valid, 1 if invalid
# ------------------------------------------------------------------------------
validate_tz() {
local tz="$1"
[[ -f "/usr/share/zoneinfo/$tz" ]]
}
# ------------------------------------------------------------------------------
# set_timezone(timezone)
#
# Sets container timezone
# ------------------------------------------------------------------------------
set_timezone() {
local tz="$1"
if validate_tz "$tz"; then
ln -sf "/usr/share/zoneinfo/$tz" /etc/localtime
echo "$tz" >/etc/timezone 2>/dev/null || true
# Update tzdata if available
case "$PKG_MANAGER" in
apt)
dpkg-reconfigure -f noninteractive tzdata 2>/dev/null || true
;;
esac
msg_ok "Timezone set to $tz"
else
msg_warn "Invalid timezone: $tz"
fi
}
# ------------------------------------------------------------------------------
# os_info()
#
# Prints detected OS information (for debugging)
# ------------------------------------------------------------------------------
os_info() {
echo "OS Type: $OS_TYPE"
echo "OS Family: $OS_FAMILY"
echo "OS Version: $OS_VERSION"
echo "Pkg Manager: $PKG_MANAGER"
echo "Init System: $INIT_SYSTEM"
}

View File

@@ -46,6 +46,7 @@ variables() {
DIAGNOSTICS="yes" # sets the DIAGNOSTICS variable to "yes", used for the API call.
METHOD="default" # sets the METHOD variable to "default", used for the API call.
RANDOM_UUID="$(cat /proc/sys/kernel/random/uuid)" # generates a random UUID and sets it to the RANDOM_UUID variable.
SESSION_ID="${RANDOM_UUID:0:8}" # Short session ID (first 8 chars of UUID) for log files
BUILD_LOG="/tmp/create-lxc-${SESSION_ID}.log" # Host-side container creation log
combined_log="/tmp/install-${SESSION_ID}-combined.log" # Combined log (build + install) for failed installations
@@ -59,7 +60,7 @@ variables() {
mkdir -p /var/log/community-scripts
BUILD_LOG="/var/log/community-scripts/create-lxc-${SESSION_ID}-$(date +%Y%m%d_%H%M%S).log"
combined_log="/var/log/community-scripts/install-${SESSION_ID}-combined-$(date +%Y%m%d_%H%M%S).log"
fi
fi
# Get Proxmox VE version and kernel version
if command -v pveversion >/dev/null 2>&1; then
@@ -215,6 +216,26 @@ update_motd_ip() {
# Add the new IP address
echo -e "${TAB}${NETWORK}${YW} IP Address: ${GN}${IP}${CL}" >>"$MOTD_FILE"
fi
# Update dynamic LXC details profile if values changed (e.g., after OS upgrade)
# Only update if file exists and is from community-scripts
if [ -f "$PROFILE_FILE" ] && grep -q "community-scripts" "$PROFILE_FILE" 2>/dev/null; then
# Get current values
local current_os="$(grep ^NAME /etc/os-release | cut -d= -f2 | tr -d '"') - Version: $(grep ^VERSION_ID /etc/os-release | cut -d= -f2 | tr -d '"')"
local current_hostname="$(hostname)"
local current_ip="$(hostname -I | awk '{print $1}')"
# Update only if values actually changed
if ! grep -q "OS:.*$current_os" "$PROFILE_FILE" 2>/dev/null; then
sed -i "s|OS:.*|OS: \${GN}$current_os\${CL}\\\"|" "$PROFILE_FILE"
fi
if ! grep -q "Hostname:.*$current_hostname" "$PROFILE_FILE" 2>/dev/null; then
sed -i "s|Hostname:.*|Hostname: \${GN}$current_hostname\${CL}\\\"|" "$PROFILE_FILE"
fi
if ! grep -q "IP Address:.*$current_ip" "$PROFILE_FILE" 2>/dev/null; then
sed -i "s|IP Address:.*|IP Address: \${GN}$current_ip\${CL}\\\"|" "$PROFILE_FILE"
fi
fi
}
# ------------------------------------------------------------------------------
@@ -3404,6 +3425,69 @@ configure_ssh_settings() {
fi
}
# ------------------------------------------------------------------------------
# msg_menu()
#
# - Displays a numbered menu for update_script() functions
# - In silent mode (PHS_SILENT=1): auto-selects the default option
# - In interactive mode: shows menu via read with 10s timeout + default fallback
# - Usage: CHOICE=$(msg_menu "Title" "tag1" "Description 1" "tag2" "Desc 2" ...)
# - The first item is always the default
# - Returns the selected tag to stdout
# - If no valid selection or timeout, returns the default (first) tag
# ------------------------------------------------------------------------------
msg_menu() {
local title="$1"
shift
# Parse items into parallel arrays: tags[] and descriptions[]
local -a tags=()
local -a descs=()
while [[ $# -ge 2 ]]; do
tags+=("$1")
descs+=("$2")
shift 2
done
local default_tag="${tags[0]}"
local count=${#tags[@]}
# Silent mode: return default immediately
if [[ -n "${PHS_SILENT+x}" ]] && [[ "${PHS_SILENT}" == "1" ]]; then
echo "$default_tag"
return 0
fi
# Display menu to /dev/tty so it doesn't get captured by command substitution
{
echo ""
msg_custom "📋" "${BL}" "${title}"
echo ""
for i in "${!tags[@]}"; do
local marker=" "
[[ $i -eq 0 ]] && marker="* "
printf "${TAB3}${marker}%s) %s\n" "${tags[$i]}" "${descs[$i]}"
done
echo ""
} >/dev/tty
local selection=""
read -r -t 10 -p "${TAB3}Select [default=${default_tag}, timeout 10s]: " selection </dev/tty >/dev/tty || true
# Validate selection
if [[ -n "$selection" ]]; then
for tag in "${tags[@]}"; do
if [[ "$selection" == "$tag" ]]; then
echo "$selection"
return 0
fi
done
msg_warn "Invalid selection '${selection}' - using default: ${default_tag}"
fi
echo "$default_tag"
return 0
}
# ------------------------------------------------------------------------------
# start()
#
@@ -3553,6 +3637,7 @@ build_container() {
# Core exports for install.func
export DIAGNOSTICS="$DIAGNOSTICS"
export RANDOM_UUID="$RANDOM_UUID"
export EXECUTION_ID="$EXECUTION_ID"
export SESSION_ID="$SESSION_ID"
export CACHER="$APT_CACHER"
export CACHER_IP="$APT_CACHER_IP"
@@ -3577,6 +3662,12 @@ build_container() {
# DEV_MODE exports (optional, for debugging)
export BUILD_LOG="$BUILD_LOG"
export INSTALL_LOG="/root/.install-${SESSION_ID}.log"
export COMMUNITY_SCRIPTS_URL="$COMMUNITY_SCRIPTS_URL"
# Keep host-side logging on BUILD_LOG (not exported — invisible to container)
# Without this, get_active_logfile() would return INSTALL_LOG (a container path)
# and all host msg_info/msg_ok/msg_error would write to /root/.install-SESSION.log
# on the HOST instead of BUILD_LOG, causing incomplete telemetry logs.
_HOST_LOGFILE="$BUILD_LOG"
export dev_mode="${dev_mode:-}"
export DEV_MODE_MOTD="${DEV_MODE_MOTD:-false}"
export DEV_MODE_KEEP="${DEV_MODE_KEEP:-false}"
@@ -3663,13 +3754,11 @@ $PCT_OPTIONS_STRING"
exit 214
fi
msg_ok "Storage space validated"
# Report installation start to API (early - captures failed installs too)
post_to_api
fi
create_lxc_container || exit $?
# Transition to 'configuring' — container created, now setting up OS/userland
post_progress_to_api "configuring"
LXC_CONFIG="/etc/pve/lxc/${CTID}.conf"
# ============================================================================
@@ -4146,11 +4235,14 @@ EOF'
exit "$install_exit_code"
fi
# Prompt user for cleanup with 60s timeout (plain echo - no msg_info to avoid spinner)
# Prompt user for cleanup with 60s timeout
echo ""
# Detect error type for smart recovery options
local is_oom=false
local is_network_issue=false
local is_apt_issue=false
local is_cmd_not_found=false
local error_explanation=""
if declare -f explain_exit_code >/dev/null 2>&1; then
error_explanation="$(explain_exit_code "$install_exit_code")"
@@ -4161,26 +4253,127 @@ EOF'
is_oom=true
fi
# APT/DPKG detection: exit codes 100-102 (APT), 255 (DPKG with log evidence)
case "$install_exit_code" in
100 | 101 | 102) is_apt_issue=true ;;
255)
if [[ -f "$combined_log" ]] && grep -qiE 'dpkg|apt-get|apt\.conf|broken packages|unmet dependencies|E: Sub-process|E: Failed' "$combined_log"; then
is_apt_issue=true
fi
;;
esac
# Command not found detection
if [[ $install_exit_code -eq 127 ]]; then
is_cmd_not_found=true
fi
# Network-related detection (curl/apt/git fetch failures and transient network issues)
case "$install_exit_code" in
6 | 7 | 22 | 28 | 35 | 52 | 56 | 57 | 75 | 78) is_network_issue=true ;;
100)
# APT can fail due to network (Failed to fetch)
if [[ -f "$combined_log" ]] && grep -qiE 'Failed to fetch|Could not resolve|Connection failed|Network is unreachable|Temporary failure resolving' "$combined_log"; then
is_network_issue=true
fi
;;
128)
if [[ -f "$combined_log" ]] && grep -qiE 'RPC failed|early EOF|fetch-pack|HTTP/2 stream|Could not resolve host|Temporary failure resolving|Failed to fetch|Connection reset|Network is unreachable' "$combined_log"; then
is_network_issue=true
fi
;;
esac
# Exit 1 subclassification: analyze logs to identify actual root cause
# Many exit 1 errors are actually APT, OOM, network, or command-not-found issues
if [[ $install_exit_code -eq 1 && -f "$combined_log" ]]; then
if grep -qiE 'E: Unable to|E: Package|E: Failed to fetch|dpkg.*error|broken packages|unmet dependencies|dpkg --configure -a' "$combined_log"; then
is_apt_issue=true
fi
if grep -qiE 'Cannot allocate memory|Out of memory|oom-killer|Killed process|JavaScript heap' "$combined_log"; then
is_oom=true
fi
if grep -qiE 'Could not resolve|DNS|Connection refused|Network is unreachable|No route to host|Temporary failure resolving|Failed to fetch' "$combined_log"; then
is_network_issue=true
fi
if grep -qiE ': command not found|No such file or directory.*/s?bin/' "$combined_log"; then
is_cmd_not_found=true
fi
fi
# Show error explanation if available
if [[ -n "$error_explanation" ]]; then
echo -e "${TAB}${RD}Error: ${error_explanation}${CL}"
echo ""
fi
# Show specific hints for known error types
if [[ $install_exit_code -eq 10 ]]; then
echo -e "${TAB}${INFO} This error usually means the container needs ${GN}privileged${CL} mode or Docker/nesting support."
echo -e "${TAB}${INFO} Recreate with: Advanced Install → Container Type: ${GN}Privileged${CL}"
echo ""
fi
if [[ $install_exit_code -eq 125 || $install_exit_code -eq 126 ]]; then
echo -e "${TAB}${INFO} The command exists but cannot be executed. This may be a ${GN}permission${CL} issue."
echo -e "${TAB}${INFO} If using Docker, ensure the container is ${GN}privileged${CL} or has correct permissions."
echo ""
fi
if [[ "$is_cmd_not_found" == true ]]; then
local missing_cmd=""
if [[ -f "$combined_log" ]]; then
missing_cmd=$(grep -oiE '[a-zA-Z0-9_.-]+: command not found' "$combined_log" | tail -1 | sed 's/: command not found//')
fi
if [[ -n "$missing_cmd" ]]; then
echo -e "${TAB}${INFO} Missing command: ${GN}${missing_cmd}${CL}"
fi
echo ""
fi
# Build recovery menu based on error type
echo -e "${YW}What would you like to do?${CL}"
echo ""
echo -e " ${GN}1)${CL} Remove container and exit"
echo -e " ${GN}2)${CL} Keep container for debugging"
echo -e " ${GN}3)${CL} Retry with verbose mode"
if [[ "$is_oom" == true ]]; then
local new_ram=$((RAM_SIZE * 3 / 2))
local new_cpu=$((CORE_COUNT + 1))
echo -e " ${GN}4)${CL} Retry with more resources (RAM: ${RAM_SIZE}${new_ram} MiB, CPU: ${CORE_COUNT}${new_cpu} cores)"
fi
echo ""
echo -en "${YW}Select option [1-$([[ "$is_oom" == true ]] && echo "4" || echo "3")] (default: 1, auto-remove in 60s): ${CL}"
echo -e " ${GN}3)${CL} Retry with verbose mode (full rebuild)"
local next_option=4
local APT_OPTION="" OOM_OPTION="" DNS_OPTION=""
if [[ "$is_apt_issue" == true ]]; then
if [[ "$var_os" == "alpine" ]]; then
echo -e " ${GN}${next_option})${CL} Repair APK state and re-run install (in-place)"
else
echo -e " ${GN}${next_option})${CL} Repair APT/DPKG state and re-run install (in-place)"
fi
APT_OPTION=$next_option
next_option=$((next_option + 1))
fi
if [[ "$is_oom" == true ]]; then
local recovery_attempt="${RECOVERY_ATTEMPT:-0}"
if [[ $recovery_attempt -lt 2 ]]; then
local new_ram=$((RAM_SIZE * 2))
local new_cpu=$((CORE_COUNT * 2))
echo -e " ${GN}${next_option})${CL} Retry with more resources (RAM: ${RAM_SIZE}${new_ram} MiB, CPU: ${CORE_COUNT}${new_cpu} cores)"
OOM_OPTION=$next_option
next_option=$((next_option + 1))
else
echo -e " ${DGN}-)${CL} ${DGN}OOM retry exhausted (already retried ${recovery_attempt}x)${CL}"
fi
fi
if [[ "$is_network_issue" == true ]]; then
echo -e " ${GN}${next_option})${CL} Retry with DNS override in LXC (8.8.8.8 / 1.1.1.1)"
DNS_OPTION=$next_option
next_option=$((next_option + 1))
fi
local max_option=$((next_option - 1))
echo ""
echo -en "${YW}Select option [1-${max_option}] (default: 1, auto-remove in 60s): ${CL}"
if read -t 60 -r response; then
case "${response:-1}" in
1)
@@ -4196,7 +4389,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 <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/install.func)
source <(curl -fsSL "$COMMUNITY_SCRIPTS_URL/misc/install.func")
declare -f motd_ssh >/dev/null 2>&1 && motd_ssh || true
" >/dev/null 2>&1; then
local ct_ip
@@ -4220,6 +4413,7 @@ EOF'
export VERBOSE="yes"
export var_verbose="yes"
# Show rebuild summary
echo -e "${YW}Rebuilding with preserved settings:${CL}"
echo -e " Container ID: ${old_ctid}${CTID}"
@@ -4232,15 +4426,83 @@ EOF'
build_container
return $?
;;
4)
if [[ "$is_oom" == true ]]; then
# Retry with more resources
*)
# Handle dynamic smart recovery options via named option variables
local handled=false
if [[ -n "${APT_OPTION}" && "${response}" == "${APT_OPTION}" ]]; then
# Package manager in-place repair: fix broken state and re-run install script
handled=true
if [[ "$var_os" == "alpine" ]]; then
echo -e "\n${TAB}${HOLD}${YW}Repairing APK state in container ${CTID}...${CL}"
pct exec "$CTID" -- ash -c "
apk fix 2>/dev/null || true
apk cache clean 2>/dev/null || true
apk update 2>/dev/null || true
" >/dev/null 2>&1 || true
echo -e "${BFR}${CM}${GN}APK state repaired in container ${CTID}${CL}"
else
echo -e "\n${TAB}${HOLD}${YW}Repairing APT/DPKG state in container ${CTID}...${CL}"
pct exec "$CTID" -- bash -c "
DEBIAN_FRONTEND=noninteractive dpkg --configure -a 2>/dev/null || true
apt-get -f install -y 2>/dev/null || true
apt-get clean 2>/dev/null
apt-get update 2>/dev/null || true
" >/dev/null 2>&1 || true
echo -e "${BFR}${CM}${GN}APT/DPKG state repaired in container ${CTID}${CL}"
fi
echo ""
export VERBOSE="yes"
export var_verbose="yes"
echo -e "${YW}Re-running installation in existing container ${CTID}:${CL}"
echo -e " RAM: ${RAM_SIZE} MiB | CPU: ${CORE_COUNT} cores | Disk: ${DISK_SIZE} GB"
echo -e " Verbose: ${GN}enabled${CL}"
echo ""
msg_info "Re-running installation script..."
# Re-run install script in existing container (don't destroy/recreate)
set +Eeuo pipefail
trap - ERR
local _LXC_CAPTURE_LOG="/tmp/.install-capture-${SESSION_ID}.log"
lxc-attach -n "$CTID" -- bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/install/${var_install}.sh)" 2>&1 | tee "$_LXC_CAPTURE_LOG"
local apt_retry_exit=${PIPESTATUS[0]}
set -Eeuo pipefail
trap 'error_handler' ERR
# Check for error flag from retry
local apt_retry_code=0
if [[ -n "${SESSION_ID:-}" ]]; then
local retry_error_flag="/root/.install-${SESSION_ID}.failed"
if pct exec "$CTID" -- test -f "$retry_error_flag" 2>/dev/null; then
apt_retry_code=$(pct exec "$CTID" -- cat "$retry_error_flag" 2>/dev/null || echo "1")
pct exec "$CTID" -- rm -f "$retry_error_flag" 2>/dev/null || true
fi
fi
if [[ $apt_retry_code -eq 0 && $apt_retry_exit -ne 0 ]]; then
apt_retry_code=$apt_retry_exit
fi
if [[ $apt_retry_code -eq 0 ]]; then
msg_ok "Installation completed successfully after APT repair!"
post_update_to_api "done" "0" "force"
return 0
else
msg_error "Installation still failed after APT repair (exit code: ${apt_retry_code})"
install_exit_code=$apt_retry_code
fi
fi
if [[ -n "${OOM_OPTION}" && "${response}" == "${OOM_OPTION}" ]]; then
# Retry with doubled resources
handled=true
echo -e "\n${TAB}${HOLD}${YW}Removing container ${CTID} for rebuild with more resources...${CL}"
pct stop "$CTID" &>/dev/null || true
pct destroy "$CTID" &>/dev/null || true
echo -e "${BFR}${CM}${GN}Container ${CTID} removed${CL}"
echo ""
# Get new container ID and increase resources
local old_ctid="$CTID"
local old_ram="$RAM_SIZE"
local old_cpu="$CORE_COUNT"
@@ -4250,19 +4512,49 @@ EOF'
export CORE_COUNT=$((CORE_COUNT + 1))
export var_ram="$RAM_SIZE"
export var_cpu="$CORE_COUNT"
export VERBOSE="yes"
export var_verbose="yes"
export RECOVERY_ATTEMPT=$((${RECOVERY_ATTEMPT:-0} + 1))
# Show rebuild summary
echo -e "${YW}Rebuilding with increased resources:${CL}"
echo -e "${YW}Rebuilding with increased resources (attempt ${RECOVERY_ATTEMPT}/2):${CL}"
echo -e " Container ID: ${old_ctid}${CTID}"
echo -e " RAM: ${old_ram}${GN}${RAM_SIZE}${CL} MiB (+50%)"
echo -e " CPU: ${old_cpu}${GN}${CORE_COUNT}${CL} cores (+1)"
echo -e " RAM: ${old_ram}${GN}${RAM_SIZE}${CL} MiB (x2)"
echo -e " CPU: ${old_cpu}${GN}${CORE_COUNT}${CL} cores (x2)"
echo -e " Disk: ${DISK_SIZE} GB | Network: ${NET:-dhcp} | Bridge: ${BRG:-vmbr0}"
echo -e " Verbose: ${GN}enabled${CL}"
echo ""
msg_info "Restarting installation..."
# Re-run build_container
build_container
return $?
else
fi
if [[ -n "${DNS_OPTION}" && "${response}" == "${DNS_OPTION}" ]]; then
# Retry with DNS override in LXC
handled=true
echo -e "\n${TAB}${HOLD}${YW}Removing container ${CTID} for rebuild with DNS override...${CL}"
pct stop "$CTID" &>/dev/null || true
pct destroy "$CTID" &>/dev/null || true
echo -e "${BFR}${CM}${GN}Container ${CTID} removed${CL}"
echo ""
local old_ctid="$CTID"
export CTID=$(get_valid_container_id "$CTID")
export DNS_RETRY_OVERRIDE="true"
export VERBOSE="yes"
export var_verbose="yes"
echo -e "${YW}Rebuilding with DNS override in LXC:${CL}"
echo -e " Container ID: ${old_ctid}${CTID}"
echo -e " DNS: ${GN}8.8.8.8, 1.1.1.1${CL} (inside LXC only)"
echo -e " Verbose: ${GN}enabled${CL}"
echo ""
msg_info "Restarting installation..."
build_container
return $?
fi
if [[ "$handled" == false ]]; then
echo -e "\n${TAB}${YW}Invalid option. Container ${CTID} kept.${CL}"
exit "$install_exit_code"
fi
@@ -4274,15 +4566,23 @@ EOF'
esac
else
# Timeout - auto-remove
echo -e "\n${YW}No response - auto-removing container${CL}"
echo -e "${TAB}${HOLD}${YW}Removing container ${CTID}${CL}"
echo ""
msg_info "No response - removing container ${CTID}"
pct stop "$CTID" &>/dev/null || true
pct destroy "$CTID" &>/dev/null || true
echo -e "${BFR}${CM}${GN}Container ${CTID} removed${CL}"
msg_ok "Container ${CTID} removed"
fi
exit "$install_exit_code"
fi
# Clean up host-side capture log (not needed on success, already in combined_log on failure)
rm -f "/tmp/.install-capture-${SESSION_ID}.log" 2>/dev/null
# Re-enable error handling after successful install or recovery menu completion
set -Eeuo pipefail
trap 'error_handler' ERR
}
destroy_lxc() {
@@ -4294,16 +4594,29 @@ destroy_lxc() {
# Abort on Ctrl-C / Ctrl-D / ESC
trap 'echo; msg_error "Aborted by user (SIGINT/SIGQUIT)"; return 130' INT QUIT
if prompt_confirm "Remove this Container?" "n" 60; then
local prompt
if ! read -rp "Remove this Container? <y/N> " prompt; then
# read returns non-zero on Ctrl-D/ESC
msg_error "Aborted input (Ctrl-D/ESC)"
return 130
fi
case "${prompt,,}" in
y | yes)
if pct stop "$CT_ID" &>/dev/null && pct destroy "$CT_ID" &>/dev/null; then
msg_ok "Removed Container $CT_ID"
else
msg_error "Failed to remove Container $CT_ID"
return 1
fi
else
;;
"" | n | no)
msg_custom "" "${BL}" "Container was not removed."
fi
;;
*)
msg_warn "Invalid response. Container was not removed."
;;
esac
}
# ------------------------------------------------------------------------------
@@ -4674,6 +4987,12 @@ create_lxc_container() {
exit 206
fi
# Report installation start to API early - captures failures in storage/template/create
post_to_api
# Transition to 'validation' — Proxmox-internal checks (storage, template, cluster)
post_progress_to_api "validation"
# Storage capability check
check_storage_support "rootdir" || {
msg_error "No valid storage found for 'rootdir' [Container]"
@@ -4818,29 +5137,37 @@ create_lxc_container() {
)
if [[ ${#AVAILABLE_VERSIONS[@]} -gt 0 ]]; then
# Use prompt_select for version selection (supports unattended mode)
local selected_version
selected_version=$(prompt_select "Select ${PCT_OSTYPE} version:" 1 60 "${AVAILABLE_VERSIONS[@]}")
echo ""
echo "${BL}Available ${PCT_OSTYPE} versions:${CL}"
for i in "${!AVAILABLE_VERSIONS[@]}"; do
echo " [$((i + 1))] ${AVAILABLE_VERSIONS[$i]}"
done
echo ""
read -p "Select version [1-${#AVAILABLE_VERSIONS[@]}] or press Enter to cancel: " choice
# prompt_select always returns a value (uses default in unattended mode)
PCT_OSVERSION="$selected_version"
TEMPLATE_SEARCH="${PCT_OSTYPE}-${PCT_OSVERSION}"
if [[ "$choice" =~ ^[0-9]+$ ]] && [[ "$choice" -ge 1 ]] && [[ "$choice" -le ${#AVAILABLE_VERSIONS[@]} ]]; then
PCT_OSVERSION="${AVAILABLE_VERSIONS[$((choice - 1))]}"
TEMPLATE_SEARCH="${PCT_OSTYPE}-${PCT_OSVERSION}"
ONLINE_TEMPLATES=()
mapfile -t ONLINE_TEMPLATES < <(
pveam available -section system 2>/dev/null |
grep -E '\.(tar\.zst|tar\.xz|tar\.gz)$' |
awk '{print $2}' |
grep -E "^${TEMPLATE_SEARCH}-.*${TEMPLATE_PATTERN}" |
sort -t - -k 2 -V 2>/dev/null || true
)
ONLINE_TEMPLATES=()
mapfile -t ONLINE_TEMPLATES < <(
pveam available -section system 2>/dev/null |
grep -E '\.(tar\.zst|tar\.xz|tar\.gz)$' |
awk '{print $2}' |
grep -E "^${TEMPLATE_SEARCH}-.*${TEMPLATE_PATTERN}" |
sort -t - -k 2 -V 2>/dev/null || true
)
if [[ ${#ONLINE_TEMPLATES[@]} -gt 0 ]]; then
TEMPLATE="${ONLINE_TEMPLATES[-1]}"
TEMPLATE_SOURCE="online"
if [[ ${#ONLINE_TEMPLATES[@]} -gt 0 ]]; then
TEMPLATE="${ONLINE_TEMPLATES[-1]}"
TEMPLATE_SOURCE="online"
else
msg_error "No templates available for ${PCT_OSTYPE} ${PCT_OSVERSION}"
exit 225
fi
else
msg_error "No templates available for ${PCT_OSTYPE} ${PCT_OSVERSION}"
exit 225
msg_custom "🚫" "${YW}" "Installation cancelled"
exit 0
fi
else
msg_error "No ${PCT_OSTYPE} templates available at all"
@@ -5252,25 +5579,100 @@ EOF
# SECTION 10: ERROR HANDLING & EXIT TRAPS
# ==============================================================================
# ------------------------------------------------------------------------------
# ensure_log_on_host()
#
# - Ensures INSTALL_LOG points to a readable file on the host
# - If INSTALL_LOG points to a container path (e.g. /root/.install-*),
# tries to pull it from the container and create a combined log
# - This allows get_error_text() to find actual error output for telemetry
# - Uses timeout on pct pull to prevent hangs on dead/unresponsive containers
# ------------------------------------------------------------------------------
ensure_log_on_host() {
# Already readable on host? Nothing to do.
[[ -n "${INSTALL_LOG:-}" && -s "${INSTALL_LOG}" ]] && return 0
# Try pulling from container and creating combined log
if [[ -n "${CTID:-}" && -n "${SESSION_ID:-}" ]] && command -v pct &>/dev/null; then
local combined_log="/tmp/${NSAPP:-lxc}-${CTID}-${SESSION_ID}.log"
if [[ ! -s "$combined_log" ]]; then
# Create combined log
{
echo "================================================================================"
echo "COMBINED INSTALLATION LOG - ${APP:-LXC}"
echo "Container ID: ${CTID}"
echo "Session ID: ${SESSION_ID}"
echo "Timestamp: $(date '+%Y-%m-%d %H:%M:%S')"
echo "================================================================================"
echo ""
} >"$combined_log" 2>/dev/null || return 0
# Append BUILD_LOG if it exists
if [[ -f "${BUILD_LOG:-}" ]]; then
{
echo "================================================================================"
echo "PHASE 1: CONTAINER CREATION (Host)"
echo "================================================================================"
cat "${BUILD_LOG}"
echo ""
} >>"$combined_log"
fi
# Pull INSTALL_LOG from container (with timeout to prevent hangs on dead containers)
local temp_log="/tmp/.install-temp-${SESSION_ID}.log"
if timeout 8 pct pull "$CTID" "/root/.install-${SESSION_ID}.log" "$temp_log" 2>/dev/null; then
{
echo "================================================================================"
echo "PHASE 2: APPLICATION INSTALLATION (Container)"
echo "================================================================================"
cat "$temp_log"
echo ""
} >>"$combined_log"
rm -f "$temp_log"
fi
fi
if [[ -s "$combined_log" ]]; then
INSTALL_LOG="$combined_log"
fi
fi
}
# ------------------------------------------------------------------------------
# api_exit_script()
#
# - Exit trap handler for reporting to API telemetry
# - Captures exit code and reports to PocketBase using centralized error descriptions
# - Uses explain_exit_code() from api.func for consistent error messages
# - Posts failure status with exit code to API (error description resolved automatically)
# - Only executes on non-zero exit codes
# - ALWAYS sends telemetry FIRST before log collection to prevent pct pull
# hangs from blocking status updates (container may be dead/unresponsive)
# - For non-zero exit codes: posts "failed" status
# - For zero exit codes where post_update_to_api was never called:
# catches orphaned "installing" records (e.g., script exited cleanly
# but description() was never reached)
# ------------------------------------------------------------------------------
api_exit_script() {
exit_code=$?
local exit_code=$?
if [ $exit_code -ne 0 ]; then
post_update_to_api "failed" "$exit_code"
# ALWAYS send telemetry FIRST - ensure status is reported even if
# ensure_log_on_host hangs (e.g. pct pull on dead container)
post_update_to_api "failed" "$exit_code" 2>/dev/null || true
# Best-effort log collection (non-critical after telemetry is sent)
if declare -f ensure_log_on_host >/dev/null 2>&1; then
ensure_log_on_host 2>/dev/null || true
fi
# Stop orphaned container if we're in the install phase
if [[ "${CONTAINER_INSTALLING:-}" == "true" && -n "${CTID:-}" ]] && command -v pct &>/dev/null; then
pct stop "$CTID" 2>/dev/null || true
fi
elif [[ "${POST_TO_API_DONE:-}" == "true" && "${POST_UPDATE_DONE:-}" != "true" ]]; then
# Script exited with 0 but never sent a completion status
# exit_code=0 is never an error — report as success
post_update_to_api "done" "0"
fi
}
if command -v pveversion >/dev/null 2>&1; then
trap 'api_exit_script' EXIT
fi
trap 'post_update_to_api "failed" "$?"' ERR
trap 'post_update_to_api "failed" "130"' SIGINT
trap 'post_update_to_api "failed" "143"' SIGTERM
trap 'local _ec=$?; if [[ $_ec -ne 0 ]]; then post_update_to_api "failed" "$_ec" 2>/dev/null || true; if declare -f ensure_log_on_host &>/dev/null; then ensure_log_on_host 2>/dev/null || true; fi; fi' ERR
trap 'post_update_to_api "failed" "129" 2>/dev/null || true; if [[ -n "${CTID:-}" ]] && command -v pct &>/dev/null; then pct stop "$CTID" 2>/dev/null || true; fi; exit 129' SIGHUP
trap 'post_update_to_api "failed" "130" 2>/dev/null || true; if [[ -n "${CTID:-}" ]] && command -v pct &>/dev/null; then pct stop "$CTID" 2>/dev/null || true; fi; exit 130' SIGINT
trap 'post_update_to_api "failed" "143" 2>/dev/null || true; if [[ -n "${CTID:-}" ]] && command -v pct &>/dev/null; then pct stop "$CTID" 2>/dev/null || true; fi; exit 143' SIGTERM

View File

@@ -395,12 +395,21 @@ ssh_check() {
# get_active_logfile()
#
# - Returns the appropriate log file based on execution context
# - BUILD_LOG: Host operations (container creation)
# - _HOST_LOGFILE: Override for host context (keeps host logging on BUILD_LOG
# even after INSTALL_LOG is exported for the container)
# - INSTALL_LOG: Container operations (application installation)
# - BUILD_LOG: Host operations (container creation)
# - Fallback to BUILD_LOG if neither is set
# ------------------------------------------------------------------------------
get_active_logfile() {
if [[ -n "${INSTALL_LOG:-}" ]]; then
# Host override: _HOST_LOGFILE is set (not exported) in build.func to keep
# host-side logging in BUILD_LOG after INSTALL_LOG is exported for the container.
# Without this, all host msg_info/msg_ok/msg_error would write to
# /root/.install-SESSION.log (a container path) instead of BUILD_LOG.
if [[ -n "${_HOST_LOGFILE:-}" ]]; then
echo "$_HOST_LOGFILE"
elif [[ -n "${INSTALL_LOG:-}" ]]; then
echo "$INSTALL_LOG"
elif [[ -n "${BUILD_LOG:-}" ]]; then
echo "$BUILD_LOG"
@@ -480,7 +489,9 @@ log_section() {
# silent()
#
# - Executes command with output redirected to active log file
# - On error: displays last 10 lines of log and exits with original exit code
# - On error: displays last 20 lines of log and exits with original exit code
# - Temporarily disables error trap to capture exit code correctly
# - Sources explain_exit_code() for detailed error messages
# ------------------------------------------------------------------------------
@@ -507,11 +518,11 @@ silent() {
set -Eeuo pipefail
trap 'error_handler' ERR
if [[ $rc -ne 0 ]]; then
# Source explain_exit_code if needed
if ! declare -f explain_exit_code >/dev/null 2>&1; then
source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/error_handler.func)
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/error_handler.func)
fi
local explanation
@@ -522,15 +533,10 @@ silent() {
msg_custom "→" "${YWB}" "${cmd}"
if [[ -s "$logfile" ]]; then
local log_lines=$(wc -l <"$logfile")
echo "--- Last 10 lines of silent log ---"
tail -n 10 "$logfile"
echo "-----------------------------------"
# Show how to view full log if there are more lines
if [[ $log_lines -gt 10 ]]; then
msg_custom "📋" "${YW}" "View full log (${log_lines} lines): ${logfile}"
fi
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"
@@ -1502,8 +1508,12 @@ cleanup_lxc() {
fi
msg_ok "Cleaned"
}
# Send progress ping if available (defined in install.func)
if declare -f post_progress_to_api &>/dev/null; then
post_progress_to_api
fi
}
# ------------------------------------------------------------------------------
# check_or_create_swap()
#
@@ -1641,4 +1651,17 @@ function get_lxc_ip() {
# SIGNAL TRAPS
# ==============================================================================
# ------------------------------------------------------------------------------
# on_hup_keepalive()
#
# - SIGHUP (terminal hangup) trap handler
# - Keeps long-running scripts alive if terminal/SSH session disconnects
# - Stops spinner safely and writes warning to active log
# ------------------------------------------------------------------------------
on_hup_keepalive() {
stop_spinner
log_msg "[WARN] Received SIGHUP (terminal hangup). Continuing execution in background."
}
trap 'on_hup_keepalive' HUP
trap 'stop_spinner' EXIT INT TERM

34
misc/data/.gitignore vendored
View File

@@ -1,34 +0,0 @@
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
telemetry-service
migration/migrate
# Test binary, built with `go test -c`
*.test
# Code coverage profiles and other test artifacts
*.out
coverage.*
*.coverprofile
profile.cov
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# Editor/IDE
# .idea/
# .vscode/

View File

@@ -1,52 +0,0 @@
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum* ./
RUN go mod download 2>/dev/null || true
COPY . .
RUN go build -trimpath -ldflags "-s -w" -o /out/telemetry-service .
RUN go build -trimpath -ldflags "-s -w" -o /out/migrate ./migration/migrate.go
FROM alpine:3.23
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=build /out/telemetry-service /app/telemetry-service
COPY --from=build /out/migrate /app/migrate
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh /app/migrate
# Service config
ENV LISTEN_ADDR=":8080"
ENV MAX_BODY_BYTES="1024"
ENV RATE_LIMIT_RPM="60"
ENV RATE_BURST="20"
ENV UPSTREAM_TIMEOUT_MS="4000"
ENV ENABLE_REQUEST_LOGGING="false"
# Cache config (optional)
ENV ENABLE_CACHE="true"
ENV CACHE_TTL_SECONDS="300"
ENV ENABLE_REDIS="false"
# ENV REDIS_URL="redis://localhost:6379"
# Alert config (optional)
ENV ALERT_ENABLED="false"
# ENV SMTP_HOST=""
# ENV SMTP_PORT="587"
# ENV SMTP_USER=""
# ENV SMTP_PASSWORD=""
# ENV SMTP_FROM="telemetry@proxmoxved.local"
# ENV SMTP_TO=""
# ENV SMTP_USE_TLS="false"
ENV ALERT_FAILURE_THRESHOLD="20.0"
ENV ALERT_CHECK_INTERVAL_MIN="15"
ENV ALERT_COOLDOWN_MIN="60"
# Migration config (optional)
ENV RUN_MIGRATION="false"
ENV MIGRATION_REQUIRED="false"
ENV MIGRATION_SOURCE_URL="https://api.htl-braunau.at/dev/data"
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -q --spider http://localhost:8080/healthz || exit 1
ENTRYPOINT ["/app/entrypoint.sh"]

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Community Scripts
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,81 +0,0 @@
# Telemetry Service
A standalone Go microservice that collects anonymous telemetry data from [ProxmoxVE](https://github.com/community-scripts/ProxmoxVE) and [ProxmoxVED](https://github.com/community-scripts/ProxmoxVED) script installations.
## Overview
This service acts as a telemetry ingestion layer between the bash installation scripts and a PocketBase backend. When users run scripts from the ProxmoxVE/ProxmoxVED repositories, optional anonymous usage data is sent here for aggregation and analysis.
**What gets collected:**
- Script name and installation status (success/failed)
- Container/VM type and resource allocation (CPU, RAM, disk)
- OS type and version
- Proxmox VE version
- Anonymous session ID (randomly generated UUID)
**What is NOT collected:**
- IP addresses (not logged, not stored)
- Hostnames or domain names
- User credentials or personal information
- Hardware identifiers (MAC addresses, serial numbers)
- Network configuration or internal IPs
- Any data that could identify a person or system
**What this enables:**
- Understanding which scripts are most popular
- Identifying scripts with high failure rates
- Tracking resource allocation trends
- Improving script quality based on real-world data
## Features
- **Telemetry Ingestion** - Receives and validates telemetry data from bash scripts
- **PocketBase Integration** - Stores data in PocketBase collections
- **Rate Limiting** - Configurable per-IP rate limiting to prevent abuse
- **Caching** - In-memory or Redis-backed caching support
- **Email Alerts** - SMTP-based alerts when failure rates exceed thresholds
- **Dashboard** - Built-in HTML dashboard for telemetry visualization
- **Migration Tool** - Migrate data from external sources to PocketBase
## Architecture
```
┌─────────────────┐ ┌───────────────────┐ ┌────────────┐
│ Bash Scripts │────▶│ Telemetry Service │────▶│ PocketBase │
│ (ProxmoxVE/VED) │ │ (this repo) │ │ Database │
└─────────────────┘ └───────────────────┘ └────────────┘
```
## Project Structure
```
├── service.go # Main service, HTTP handlers, rate limiting
├── cache.go # In-memory and Redis caching
├── alerts.go # SMTP alert system
├── dashboard.go # Dashboard HTML generation
├── migration/
│ ├── migrate.go # Data migration tool
│ └── migrate.sh # Migration shell script
├── Dockerfile # Container build
├── entrypoint.sh # Container entrypoint with migration support
└── go.mod # Go module definition
```
## Related Projects
- [ProxmoxVE](https://github.com/community-scripts/ProxmoxVE) - Proxmox VE Helper Scripts
- [ProxmoxVED](https://github.com/community-scripts/ProxmoxVED) - Proxmox VE Helper Scripts (Dev)
## Privacy & Compliance
This service is designed with privacy in mind and is **GDPR/DSGVO compliant**:
-**No personal data** - Only anonymous technical metrics are collected
-**No IP logging** - Request logging is disabled by default, IPs are never stored
-**Transparent** - All collected fields are documented and the code is open source
-**No tracking** - Session IDs are randomly generated and cannot be linked to users
-**No third parties** - Data is only stored in our self-hosted PocketBase instance
## License
MIT License - see [LICENSE](LICENSE) file.

View File

@@ -1,853 +0,0 @@
package main
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"log"
"net/smtp"
"strings"
"sync"
"time"
)
// AlertConfig holds SMTP alert configuration
type AlertConfig struct {
Enabled bool
SMTPHost string
SMTPPort int
SMTPUser string
SMTPPassword string
SMTPFrom string
SMTPTo []string
UseTLS bool
FailureThreshold float64 // Alert when failure rate exceeds this (e.g., 20.0 = 20%)
CheckInterval time.Duration // How often to check
Cooldown time.Duration // Minimum time between alerts
// Weekly Report settings
WeeklyReportEnabled bool // Enable weekly summary reports
WeeklyReportDay time.Weekday // Day to send report (0=Sunday, 1=Monday, etc.)
WeeklyReportHour int // Hour to send report (0-23)
}
// WeeklyReportData contains aggregated weekly statistics
type WeeklyReportData struct {
CalendarWeek int
Year int
StartDate time.Time
EndDate time.Time
TotalInstalls int
SuccessCount int
FailedCount int
SuccessRate float64
TopApps []AppStat
TopFailedApps []AppStat
ComparedToPrev WeekComparison
OsDistribution map[string]int
TypeDistribution map[string]int
}
// AppStat represents statistics for a single app
type AppStat struct {
Name string
Total int
Failed int
FailureRate float64
}
// WeekComparison shows changes compared to previous week
type WeekComparison struct {
InstallsChange int // Difference in total installs
InstallsPercent float64 // Percentage change
FailRateChange float64 // Change in failure rate (percentage points)
}
// Alerter handles alerting functionality
type Alerter struct {
cfg AlertConfig
lastAlertAt time.Time
lastWeeklyReport time.Time
mu sync.Mutex
pb *PBClient
lastStats alertStats
alertHistory []AlertEvent
}
type alertStats struct {
successCount int
failedCount int
checkedAt time.Time
}
// AlertEvent records an alert that was sent
type AlertEvent struct {
Timestamp time.Time `json:"timestamp"`
Type string `json:"type"`
Message string `json:"message"`
FailureRate float64 `json:"failure_rate,omitempty"`
}
// NewAlerter creates a new alerter instance
func NewAlerter(cfg AlertConfig, pb *PBClient) *Alerter {
return &Alerter{
cfg: cfg,
pb: pb,
alertHistory: make([]AlertEvent, 0),
}
}
// Start begins the alert monitoring loop
func (a *Alerter) Start() {
if !a.cfg.Enabled {
log.Println("INFO: alerting disabled")
return
}
if a.cfg.SMTPHost == "" || len(a.cfg.SMTPTo) == 0 {
log.Println("WARN: alerting enabled but SMTP not configured")
return
}
go a.monitorLoop()
log.Printf("INFO: alert monitoring started (threshold: %.1f%%, interval: %v)", a.cfg.FailureThreshold, a.cfg.CheckInterval)
// Start weekly report scheduler if enabled
if a.cfg.WeeklyReportEnabled {
go a.weeklyReportLoop()
log.Printf("INFO: weekly report scheduler started (day: %s, hour: %02d:00)", a.cfg.WeeklyReportDay, a.cfg.WeeklyReportHour)
}
}
func (a *Alerter) monitorLoop() {
ticker := time.NewTicker(a.cfg.CheckInterval)
defer ticker.Stop()
for range ticker.C {
a.checkAndAlert()
}
}
func (a *Alerter) checkAndAlert() {
ctx, cancel := newTimeoutContext(10 * time.Second)
defer cancel()
// Fetch last hour's data
data, err := a.pb.FetchDashboardData(ctx, 1, "ProxmoxVE")
if err != nil {
log.Printf("WARN: alert check failed: %v", err)
return
}
// Calculate current failure rate
total := data.SuccessCount + data.FailedCount
if total < 10 {
// Not enough data to determine rate
return
}
failureRate := float64(data.FailedCount) / float64(total) * 100
// Check if we should alert
if failureRate >= a.cfg.FailureThreshold {
a.maybeSendAlert(failureRate, data.FailedCount, total)
}
}
func (a *Alerter) maybeSendAlert(rate float64, failed, total int) {
a.mu.Lock()
defer a.mu.Unlock()
// Check cooldown
if time.Since(a.lastAlertAt) < a.cfg.Cooldown {
return
}
// Send alert
subject := fmt.Sprintf("[ProxmoxVED Alert] High Failure Rate: %.1f%%", rate)
body := fmt.Sprintf(`ProxmoxVE Helper Scripts - Telemetry Alert
⚠️ High installation failure rate detected!
Current Statistics (last 24h):
- Failure Rate: %.1f%%
- Failed Installations: %d
- Total Installations: %d
- Threshold: %.1f%%
Time: %s
Please check the dashboard for more details.
---
This is an automated alert from the telemetry service.
`, rate, failed, total, a.cfg.FailureThreshold, time.Now().Format(time.RFC1123))
if err := a.sendEmail(subject, body); err != nil {
log.Printf("ERROR: failed to send alert email: %v", err)
return
}
a.lastAlertAt = time.Now()
a.alertHistory = append(a.alertHistory, AlertEvent{
Timestamp: time.Now(),
Type: "high_failure_rate",
Message: fmt.Sprintf("Failure rate %.1f%% exceeded threshold %.1f%%", rate, a.cfg.FailureThreshold),
FailureRate: rate,
})
// Keep only last 100 alerts
if len(a.alertHistory) > 100 {
a.alertHistory = a.alertHistory[len(a.alertHistory)-100:]
}
log.Printf("ALERT: sent high failure rate alert (%.1f%%)", rate)
}
func (a *Alerter) sendEmail(subject, body string) error {
return a.sendEmailWithType(subject, body, "text/plain")
}
func (a *Alerter) sendHTMLEmail(subject, body string) error {
return a.sendEmailWithType(subject, body, "text/html")
}
func (a *Alerter) sendEmailWithType(subject, body, contentType string) error {
// Build message
var msg bytes.Buffer
msg.WriteString(fmt.Sprintf("From: %s\r\n", a.cfg.SMTPFrom))
msg.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(a.cfg.SMTPTo, ", ")))
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString(fmt.Sprintf("Content-Type: %s; charset=UTF-8\r\n", contentType))
msg.WriteString("\r\n")
msg.WriteString(body)
addr := fmt.Sprintf("%s:%d", a.cfg.SMTPHost, a.cfg.SMTPPort)
var auth smtp.Auth
if a.cfg.SMTPUser != "" && a.cfg.SMTPPassword != "" {
auth = smtp.PlainAuth("", a.cfg.SMTPUser, a.cfg.SMTPPassword, a.cfg.SMTPHost)
}
if a.cfg.UseTLS {
// TLS connection
tlsConfig := &tls.Config{
ServerName: a.cfg.SMTPHost,
}
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("TLS dial failed: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, a.cfg.SMTPHost)
if err != nil {
return fmt.Errorf("SMTP client failed: %w", err)
}
defer client.Close()
if auth != nil {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP auth failed: %w", err)
}
}
if err := client.Mail(a.cfg.SMTPFrom); err != nil {
return fmt.Errorf("SMTP MAIL failed: %w", err)
}
for _, to := range a.cfg.SMTPTo {
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("SMTP RCPT failed: %w", err)
}
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("SMTP DATA failed: %w", err)
}
_, err = w.Write(msg.Bytes())
if err != nil {
return fmt.Errorf("SMTP write failed: %w", err)
}
return w.Close()
}
// Non-TLS (STARTTLS)
return smtp.SendMail(addr, auth, a.cfg.SMTPFrom, a.cfg.SMTPTo, msg.Bytes())
}
// GetAlertHistory returns recent alert events
func (a *Alerter) GetAlertHistory() []AlertEvent {
a.mu.Lock()
defer a.mu.Unlock()
result := make([]AlertEvent, len(a.alertHistory))
copy(result, a.alertHistory)
return result
}
// TestAlert sends a test alert email
func (a *Alerter) TestAlert() error {
if !a.cfg.Enabled || a.cfg.SMTPHost == "" {
return fmt.Errorf("alerting not configured")
}
subject := "[ProxmoxVED] Test Alert"
body := fmt.Sprintf(`This is a test alert from ProxmoxVE Helper Scripts telemetry service.
If you received this email, your alert configuration is working correctly.
Time: %s
SMTP Host: %s
Recipients: %s
---
This is an automated test message.
`, time.Now().Format(time.RFC1123), a.cfg.SMTPHost, strings.Join(a.cfg.SMTPTo, ", "))
return a.sendEmail(subject, body)
}
// Helper for timeout context
func newTimeoutContext(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}
// weeklyReportLoop checks periodically if it's time to send the weekly report
func (a *Alerter) weeklyReportLoop() {
// Check every hour
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
a.checkAndSendWeeklyReport()
}
}
// checkAndSendWeeklyReport sends the weekly report if it's the right time
func (a *Alerter) checkAndSendWeeklyReport() {
now := time.Now()
// Check if it's the right day and hour
if now.Weekday() != a.cfg.WeeklyReportDay || now.Hour() != a.cfg.WeeklyReportHour {
return
}
a.mu.Lock()
// Check if we already sent a report this week
_, lastWeek := a.lastWeeklyReport.ISOWeek()
_, currentWeek := now.ISOWeek()
if a.lastWeeklyReport.Year() == now.Year() && lastWeek == currentWeek {
a.mu.Unlock()
return
}
a.mu.Unlock()
// Send the weekly report
if err := a.SendWeeklyReport(); err != nil {
log.Printf("ERROR: failed to send weekly report: %v", err)
}
}
// SendWeeklyReport generates and sends the weekly summary email
func (a *Alerter) SendWeeklyReport() error {
if !a.cfg.Enabled || a.cfg.SMTPHost == "" {
return fmt.Errorf("alerting not configured")
}
ctx, cancel := newTimeoutContext(30 * time.Second)
defer cancel()
// Get data for the past week
reportData, err := a.fetchWeeklyReportData(ctx)
if err != nil {
return fmt.Errorf("failed to fetch weekly data: %w", err)
}
// Generate email content
subject := fmt.Sprintf("[ProxmoxVED] Weekly Report - Week %d, %d", reportData.CalendarWeek, reportData.Year)
body := a.generateWeeklyReportHTML(reportData)
if err := a.sendHTMLEmail(subject, body); err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
a.mu.Lock()
a.lastWeeklyReport = time.Now()
a.alertHistory = append(a.alertHistory, AlertEvent{
Timestamp: time.Now(),
Type: "weekly_report",
Message: fmt.Sprintf("Weekly report KW %d/%d sent", reportData.CalendarWeek, reportData.Year),
})
a.mu.Unlock()
log.Printf("INFO: weekly report KW %d/%d sent successfully", reportData.CalendarWeek, reportData.Year)
return nil
}
// fetchWeeklyReportData collects data for the weekly report
func (a *Alerter) fetchWeeklyReportData(ctx context.Context) (*WeeklyReportData, error) {
// Calculate the previous week's date range (Mon-Sun)
now := time.Now()
// Find last Monday
daysToLastMonday := int(now.Weekday() - time.Monday)
if daysToLastMonday < 0 {
daysToLastMonday += 7
}
// Go back to the Monday of LAST week
lastMonday := now.AddDate(0, 0, -daysToLastMonday-7)
lastMonday = time.Date(lastMonday.Year(), lastMonday.Month(), lastMonday.Day(), 0, 0, 0, 0, lastMonday.Location())
lastSunday := lastMonday.AddDate(0, 0, 6)
lastSunday = time.Date(lastSunday.Year(), lastSunday.Month(), lastSunday.Day(), 23, 59, 59, 0, lastSunday.Location())
// Get calendar week
year, week := lastMonday.ISOWeek()
// Fetch current week's data (7 days)
currentData, err := a.pb.FetchDashboardData(ctx, 7, "ProxmoxVE")
if err != nil {
return nil, fmt.Errorf("failed to fetch current week data: %w", err)
}
// Fetch previous week's data for comparison (14 days, we'll compare)
prevData, err := a.pb.FetchDashboardData(ctx, 14, "ProxmoxVE")
if err != nil {
// Non-fatal, just log
log.Printf("WARN: could not fetch previous week data: %v", err)
prevData = nil
}
// Build report data
report := &WeeklyReportData{
CalendarWeek: week,
Year: year,
StartDate: lastMonday,
EndDate: lastSunday,
TotalInstalls: currentData.TotalInstalls,
SuccessCount: currentData.SuccessCount,
FailedCount: currentData.FailedCount,
OsDistribution: make(map[string]int),
TypeDistribution: make(map[string]int),
}
// Calculate success rate
if report.TotalInstalls > 0 {
report.SuccessRate = float64(report.SuccessCount) / float64(report.TotalInstalls) * 100
}
// Top 5 installed apps
for i, app := range currentData.TopApps {
if i >= 5 {
break
}
report.TopApps = append(report.TopApps, AppStat{
Name: app.App,
Total: app.Count,
})
}
// Top 5 failed apps
for i, app := range currentData.FailedApps {
if i >= 5 {
break
}
report.TopFailedApps = append(report.TopFailedApps, AppStat{
Name: app.App,
Total: app.TotalCount,
Failed: app.FailedCount,
FailureRate: app.FailureRate,
})
}
// OS distribution
for _, os := range currentData.OsDistribution {
report.OsDistribution[os.Os] = os.Count
}
// Type distribution (LXC vs VM)
for _, t := range currentData.TypeStats {
report.TypeDistribution[t.Type] = t.Count
}
// Calculate comparison to previous week
if prevData != nil {
// Previous week stats (subtract current from 14-day total)
prevInstalls := prevData.TotalInstalls - currentData.TotalInstalls
prevFailed := prevData.FailedCount - currentData.FailedCount
prevSuccess := prevData.SuccessCount - currentData.SuccessCount
if prevInstalls > 0 {
prevFailRate := float64(prevFailed) / float64(prevInstalls) * 100
currentFailRate := 100 - report.SuccessRate
report.ComparedToPrev.InstallsChange = report.TotalInstalls - prevInstalls
if prevInstalls > 0 {
report.ComparedToPrev.InstallsPercent = float64(report.TotalInstalls-prevInstalls) / float64(prevInstalls) * 100
}
report.ComparedToPrev.FailRateChange = currentFailRate - prevFailRate
_ = prevSuccess // suppress unused warning
}
}
return report, nil
}
// generateWeeklyReportHTML creates the HTML email body for the weekly report
func (a *Alerter) generateWeeklyReportHTML(data *WeeklyReportData) string {
var b strings.Builder
// HTML Email Template
b.WriteString(`<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0;padding:0;background-color:#f6f9fc;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f6f9fc;padding:40px 20px;">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border-radius:12px;box-shadow:0 4px 6px rgba(0,0,0,0.07);">
<!-- Header -->
<tr>
<td style="background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);padding:32px 40px;border-radius:12px 12px 0 0;">
<h1 style="margin:0;color:#ffffff;font-size:24px;font-weight:600;">📊 Weekly Telemetry Report</h1>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.85);font-size:14px;">ProxmoxVE Helper Scripts</p>
</td>
</tr>
<!-- Week Info -->
<tr>
<td style="padding:24px 40px 0;">
<table width="100%" style="background:#f8fafc;border-radius:8px;padding:16px;">
<tr>
<td style="padding:12px 16px;">
<span style="color:#64748b;font-size:12px;text-transform:uppercase;letter-spacing:0.5px;">Calendar Week</span><br>
<span style="color:#1e293b;font-size:20px;font-weight:600;">Week `)
b.WriteString(fmt.Sprintf("%d, %d", data.CalendarWeek, data.Year))
b.WriteString(`</span>
</td>
<td style="padding:12px 16px;text-align:right;">
<span style="color:#64748b;font-size:12px;text-transform:uppercase;letter-spacing:0.5px;">Period</span><br>
<span style="color:#1e293b;font-size:14px;">`)
b.WriteString(fmt.Sprintf("%s %s", data.StartDate.Format("Jan 02"), data.EndDate.Format("Jan 02, 2006")))
b.WriteString(`</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- Stats Grid -->
<tr>
<td style="padding:24px 40px;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td width="25%" style="padding:8px;">
<div style="background:#f0fdf4;border-radius:8px;padding:16px;text-align:center;">
<div style="color:#16a34a;font-size:28px;font-weight:700;">`)
b.WriteString(fmt.Sprintf("%d", data.TotalInstalls))
b.WriteString(`</div>
<div style="color:#166534;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;margin-top:4px;">Total</div>
</div>
</td>
<td width="25%" style="padding:8px;">
<div style="background:#f0fdf4;border-radius:8px;padding:16px;text-align:center;">
<div style="color:#16a34a;font-size:28px;font-weight:700;">`)
b.WriteString(fmt.Sprintf("%d", data.SuccessCount))
b.WriteString(`</div>
<div style="color:#166534;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;margin-top:4px;">Successful</div>
</div>
</td>
<td width="25%" style="padding:8px;">
<div style="background:#fef2f2;border-radius:8px;padding:16px;text-align:center;">
<div style="color:#dc2626;font-size:28px;font-weight:700;">`)
b.WriteString(fmt.Sprintf("%d", data.FailedCount))
b.WriteString(`</div>
<div style="color:#991b1b;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;margin-top:4px;">Failed</div>
</div>
</td>
<td width="25%" style="padding:8px;">
<div style="background:#eff6ff;border-radius:8px;padding:16px;text-align:center;">
<div style="color:#2563eb;font-size:28px;font-weight:700;">`)
b.WriteString(fmt.Sprintf("%.1f%%", data.SuccessRate))
b.WriteString(`</div>
<div style="color:#1e40af;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;margin-top:4px;">Success Rate</div>
</div>
</td>
</tr>
</table>
</td>
</tr>
`)
// Week comparison
if data.ComparedToPrev.InstallsChange != 0 || data.ComparedToPrev.FailRateChange != 0 {
installIcon := "📈"
installColor := "#16a34a"
if data.ComparedToPrev.InstallsChange < 0 {
installIcon = "📉"
installColor = "#dc2626"
}
failIcon := "✅"
failColor := "#16a34a"
if data.ComparedToPrev.FailRateChange > 0 {
failIcon = "⚠️"
failColor = "#dc2626"
}
b.WriteString(`<tr>
<td style="padding:0 40px 24px;">
<table width="100%" style="background:#fafafa;border-radius:8px;">
<tr>
<td style="padding:16px;border-right:1px solid #e5e7eb;">
<span style="font-size:12px;color:#64748b;">vs. Previous Week</span><br>
<span style="font-size:16px;color:`)
b.WriteString(installColor)
b.WriteString(`;">`)
b.WriteString(installIcon)
b.WriteString(fmt.Sprintf(" %+d installations (%.1f%%)", data.ComparedToPrev.InstallsChange, data.ComparedToPrev.InstallsPercent))
b.WriteString(`</span>
</td>
<td style="padding:16px;">
<span style="font-size:12px;color:#64748b;">Failure Rate Change</span><br>
<span style="font-size:16px;color:`)
b.WriteString(failColor)
b.WriteString(`;">`)
b.WriteString(failIcon)
b.WriteString(fmt.Sprintf(" %+.1f percentage points", data.ComparedToPrev.FailRateChange))
b.WriteString(`</span>
</td>
</tr>
</table>
</td>
</tr>
`)
}
// Top 5 Installed Scripts
b.WriteString(`<tr>
<td style="padding:0 40px 24px;">
<h2 style="margin:0 0 16px;font-size:16px;color:#1e293b;border-bottom:2px solid #e2e8f0;padding-bottom:8px;">🏆 Top 5 Installed Scripts</h2>
<table width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;">
`)
if len(data.TopApps) > 0 {
for i, app := range data.TopApps {
bgColor := "#ffffff"
if i%2 == 0 {
bgColor = "#f8fafc"
}
b.WriteString(fmt.Sprintf(`<tr style="background:%s;">
<td style="padding:12px 16px;border-radius:4px 0 0 4px;">
<span style="background:#e0e7ff;color:#4338ca;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600;">%d</span>
<span style="margin-left:12px;font-weight:500;color:#1e293b;">%s</span>
</td>
<td style="padding:12px 16px;text-align:right;border-radius:0 4px 4px 0;color:#64748b;">%d installs</td>
</tr>`, bgColor, i+1, app.Name, app.Total))
}
} else {
b.WriteString(`<tr><td style="padding:12px 16px;color:#64748b;">No data available</td></tr>`)
}
b.WriteString(`</table>
</td>
</tr>
`)
// Top 5 Failed Scripts
b.WriteString(`<tr>
<td style="padding:0 40px 24px;">
<h2 style="margin:0 0 16px;font-size:16px;color:#1e293b;border-bottom:2px solid #e2e8f0;padding-bottom:8px;">⚠️ Top 5 Scripts with Highest Failure Rates</h2>
<table width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;">
`)
if len(data.TopFailedApps) > 0 {
for i, app := range data.TopFailedApps {
bgColor := "#ffffff"
if i%2 == 0 {
bgColor = "#fef2f2"
}
rateColor := "#dc2626"
if app.FailureRate < 20 {
rateColor = "#ea580c"
}
if app.FailureRate < 10 {
rateColor = "#ca8a04"
}
b.WriteString(fmt.Sprintf(`<tr style="background:%s;">
<td style="padding:12px 16px;border-radius:4px 0 0 4px;">
<span style="font-weight:500;color:#1e293b;">%s</span>
</td>
<td style="padding:12px 16px;text-align:center;color:#64748b;">%d / %d failed</td>
<td style="padding:12px 16px;text-align:right;border-radius:0 4px 4px 0;">
<span style="background:%s;color:#ffffff;padding:4px 10px;border-radius:12px;font-size:12px;font-weight:600;">%.1f%%</span>
</td>
</tr>`, bgColor, app.Name, app.Failed, app.Total, rateColor, app.FailureRate))
}
} else {
b.WriteString(`<tr><td style="padding:12px 16px;color:#16a34a;">🎉 No failures this week!</td></tr>`)
}
b.WriteString(`</table>
</td>
</tr>
`)
// Type Distribution
if len(data.TypeDistribution) > 0 {
b.WriteString(`<tr>
<td style="padding:0 40px 24px;">
<h2 style="margin:0 0 16px;font-size:16px;color:#1e293b;border-bottom:2px solid #e2e8f0;padding-bottom:8px;">📦 Distribution by Type</h2>
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
`)
for t, count := range data.TypeDistribution {
percent := float64(count) / float64(data.TotalInstalls) * 100
b.WriteString(fmt.Sprintf(`<td style="padding:8px;">
<div style="background:#f1f5f9;border-radius:8px;padding:16px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#475569;">%d</div>
<div style="font-size:12px;color:#64748b;margin-top:4px;">%s (%.1f%%)</div>
</div>
</td>`, count, strings.ToUpper(t), percent))
}
b.WriteString(`</tr>
</table>
</td>
</tr>
`)
}
// OS Distribution
if len(data.OsDistribution) > 0 {
b.WriteString(`<tr>
<td style="padding:0 40px 24px;">
<h2 style="margin:0 0 16px;font-size:16px;color:#1e293b;border-bottom:2px solid #e2e8f0;padding-bottom:8px;">🐧 Top Operating Systems</h2>
<table width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;">
`)
// Sort OS by count
type osEntry struct {
name string
count int
}
var osList []osEntry
for name, count := range data.OsDistribution {
osList = append(osList, osEntry{name, count})
}
for i := 0; i < len(osList); i++ {
for j := i + 1; j < len(osList); j++ {
if osList[j].count > osList[i].count {
osList[i], osList[j] = osList[j], osList[i]
}
}
}
for i, os := range osList {
if i >= 5 {
break
}
percent := float64(os.count) / float64(data.TotalInstalls) * 100
barWidth := int(percent * 2) // Scale for visual
if barWidth > 100 {
barWidth = 100
}
b.WriteString(fmt.Sprintf(`<tr>
<td style="padding:8px 16px;width:100px;">%s</td>
<td style="padding:8px 16px;">
<div style="background:#e2e8f0;border-radius:4px;height:20px;width:100%%;">
<div style="background:linear-gradient(90deg,#667eea,#764ba2);border-radius:4px;height:20px;width:%d%%;"></div>
</div>
</td>
<td style="padding:8px 16px;text-align:right;width:80px;color:#64748b;">%d (%.1f%%)</td>
</tr>`, os.name, barWidth, os.count, percent))
}
b.WriteString(`</table>
</td>
</tr>
`)
}
// Footer
b.WriteString(`<tr>
<td style="padding:24px 40px;background:#f8fafc;border-radius:0 0 12px 12px;border-top:1px solid #e2e8f0;">
<p style="margin:0;font-size:12px;color:#64748b;text-align:center;">
Generated `)
b.WriteString(time.Now().Format("Jan 02, 2006 at 15:04 MST"))
b.WriteString(`<br>
<a href="https://github.com/community-scripts/ProxmoxVE" style="color:#667eea;text-decoration:none;">ProxmoxVE Helper Scripts</a> —
This is an automated report from the telemetry service.
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`)
return b.String()
}
// generateWeeklyReportEmail creates the plain text email body (kept for compatibility)
func (a *Alerter) generateWeeklyReportEmail(data *WeeklyReportData) string {
var b strings.Builder
b.WriteString("ProxmoxVE Helper Scripts - Weekly Telemetry Report\n")
b.WriteString("==================================================\n\n")
b.WriteString(fmt.Sprintf("Calendar Week: %d, %d\n", data.CalendarWeek, data.Year))
b.WriteString(fmt.Sprintf("Period: %s - %s\n\n",
data.StartDate.Format("Jan 02, 2006"),
data.EndDate.Format("Jan 02, 2006")))
b.WriteString("OVERVIEW\n")
b.WriteString("--------\n")
b.WriteString(fmt.Sprintf("Total Installations: %d\n", data.TotalInstalls))
b.WriteString(fmt.Sprintf("Successful: %d\n", data.SuccessCount))
b.WriteString(fmt.Sprintf("Failed: %d\n", data.FailedCount))
b.WriteString(fmt.Sprintf("Success Rate: %.1f%%\n\n", data.SuccessRate))
if data.ComparedToPrev.InstallsChange != 0 || data.ComparedToPrev.FailRateChange != 0 {
b.WriteString("vs. Previous Week:\n")
b.WriteString(fmt.Sprintf(" Installations: %+d (%.1f%%)\n", data.ComparedToPrev.InstallsChange, data.ComparedToPrev.InstallsPercent))
b.WriteString(fmt.Sprintf(" Failure Rate: %+.1f pp\n\n", data.ComparedToPrev.FailRateChange))
}
b.WriteString("TOP 5 INSTALLED SCRIPTS\n")
b.WriteString("-----------------------\n")
for i, app := range data.TopApps {
if i >= 5 {
break
}
b.WriteString(fmt.Sprintf("%d. %-25s %5d installs\n", i+1, app.Name, app.Total))
}
b.WriteString("\n")
b.WriteString("TOP 5 FAILED SCRIPTS\n")
b.WriteString("--------------------\n")
if len(data.TopFailedApps) > 0 {
for i, app := range data.TopFailedApps {
if i >= 5 {
break
}
b.WriteString(fmt.Sprintf("%d. %-20s %3d/%3d failed (%.1f%%)\n",
i+1, app.Name, app.Failed, app.Total, app.FailureRate))
}
} else {
b.WriteString("No failures this week!\n")
}
b.WriteString("\n")
b.WriteString("---\n")
b.WriteString(fmt.Sprintf("Generated: %s\n", time.Now().Format("Jan 02, 2006 15:04 MST")))
b.WriteString("This is an automated report from the telemetry service.\n")
return b.String()
}
// TestWeeklyReport sends a test weekly report email
func (a *Alerter) TestWeeklyReport() error {
return a.SendWeeklyReport()
}

View File

@@ -1,158 +0,0 @@
package main
import (
"context"
"encoding/json"
"log"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
// CacheConfig holds cache configuration
type CacheConfig struct {
RedisURL string
EnableRedis bool
DefaultTTL time.Duration
}
// Cache provides caching functionality with Redis or in-memory fallback
type Cache struct {
redis *redis.Client
useRedis bool
defaultTTL time.Duration
// In-memory fallback
mu sync.RWMutex
memData map[string]cacheEntry
}
type cacheEntry struct {
data []byte
expiresAt time.Time
}
// NewCache creates a new cache instance
func NewCache(cfg CacheConfig) *Cache {
c := &Cache{
defaultTTL: cfg.DefaultTTL,
memData: make(map[string]cacheEntry),
}
if cfg.EnableRedis && cfg.RedisURL != "" {
opts, err := redis.ParseURL(cfg.RedisURL)
if err != nil {
log.Printf("WARN: invalid redis URL, using in-memory cache: %v", err)
return c
}
client := redis.NewClient(opts)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
log.Printf("WARN: redis connection failed, using in-memory cache: %v", err)
return c
}
c.redis = client
c.useRedis = true
log.Printf("INFO: connected to Redis for caching")
}
// Start cleanup goroutine for in-memory cache
if !c.useRedis {
go c.cleanupLoop()
}
return c
}
func (c *Cache) cleanupLoop() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
c.mu.Lock()
now := time.Now()
for k, v := range c.memData {
if now.After(v.expiresAt) {
delete(c.memData, k)
}
}
c.mu.Unlock()
}
}
// Get retrieves a value from cache
func (c *Cache) Get(ctx context.Context, key string, dest interface{}) bool {
if c.useRedis {
data, err := c.redis.Get(ctx, key).Bytes()
if err != nil {
return false
}
return json.Unmarshal(data, dest) == nil
}
// In-memory fallback
c.mu.RLock()
entry, ok := c.memData[key]
c.mu.RUnlock()
if !ok || time.Now().After(entry.expiresAt) {
return false
}
return json.Unmarshal(entry.data, dest) == nil
}
// Set stores a value in cache
func (c *Cache) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
if ttl == 0 {
ttl = c.defaultTTL
}
data, err := json.Marshal(value)
if err != nil {
return err
}
if c.useRedis {
return c.redis.Set(ctx, key, data, ttl).Err()
}
// In-memory fallback
c.mu.Lock()
c.memData[key] = cacheEntry{
data: data,
expiresAt: time.Now().Add(ttl),
}
c.mu.Unlock()
return nil
}
// Delete removes a key from cache
func (c *Cache) Delete(ctx context.Context, key string) error {
if c.useRedis {
return c.redis.Del(ctx, key).Err()
}
c.mu.Lock()
delete(c.memData, key)
c.mu.Unlock()
return nil
}
// InvalidateDashboard clears dashboard cache
func (c *Cache) InvalidateDashboard(ctx context.Context) {
// Delete all dashboard cache keys
for days := 1; days <= 365; days++ {
_ = c.Delete(ctx, dashboardCacheKey(days))
}
}
func dashboardCacheKey(days int) string {
return "dashboard:" + string(rune(days))
}

View File

@@ -1,173 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"time"
)
// CleanupConfig holds configuration for the cleanup job
type CleanupConfig struct {
Enabled bool
CheckInterval time.Duration // How often to run cleanup
StuckAfterHours int // Consider "installing" as stuck after X hours
}
// Cleaner handles cleanup of stuck installations
type Cleaner struct {
cfg CleanupConfig
pb *PBClient
}
// NewCleaner creates a new cleaner instance
func NewCleaner(cfg CleanupConfig, pb *PBClient) *Cleaner {
return &Cleaner{
cfg: cfg,
pb: pb,
}
}
// Start begins the cleanup loop
func (c *Cleaner) Start() {
if !c.cfg.Enabled {
log.Println("INFO: cleanup job disabled")
return
}
go c.cleanupLoop()
log.Printf("INFO: cleanup job started (interval: %v, stuck after: %d hours)", c.cfg.CheckInterval, c.cfg.StuckAfterHours)
}
func (c *Cleaner) cleanupLoop() {
// Run immediately on start
c.runCleanup()
ticker := time.NewTicker(c.cfg.CheckInterval)
defer ticker.Stop()
for range ticker.C {
c.runCleanup()
}
}
// runCleanup finds and updates stuck installations
func (c *Cleaner) runCleanup() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// Find stuck records
stuckRecords, err := c.findStuckInstallations(ctx)
if err != nil {
log.Printf("WARN: cleanup - failed to find stuck installations: %v", err)
return
}
if len(stuckRecords) == 0 {
log.Printf("INFO: cleanup - no stuck installations found")
return
}
log.Printf("INFO: cleanup - found %d stuck installations", len(stuckRecords))
// Update each record
updated := 0
for _, record := range stuckRecords {
if err := c.markAsUnknown(ctx, record.ID); err != nil {
log.Printf("WARN: cleanup - failed to update record %s: %v", record.ID, err)
continue
}
updated++
}
log.Printf("INFO: cleanup - updated %d stuck installations to 'unknown'", updated)
}
// StuckRecord represents a minimal record for cleanup
type StuckRecord struct {
ID string `json:"id"`
NSAPP string `json:"nsapp"`
Created string `json:"created"`
}
// findStuckInstallations finds records that are stuck in "installing" status
func (c *Cleaner) findStuckInstallations(ctx context.Context) ([]StuckRecord, error) {
if err := c.pb.ensureAuth(ctx); err != nil {
return nil, err
}
// Calculate cutoff time
cutoff := time.Now().Add(-time.Duration(c.cfg.StuckAfterHours) * time.Hour)
cutoffStr := cutoff.Format("2006-01-02 15:04:05")
// Build filter: status='installing' AND created < cutoff
filter := url.QueryEscape(fmt.Sprintf("status='installing' && created<'%s'", cutoffStr))
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
fmt.Sprintf("%s/api/collections/%s/records?filter=%s&perPage=100",
c.pb.baseURL, c.pb.targetColl, filter),
nil,
)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.pb.token)
resp, err := c.pb.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Items []StuckRecord `json:"items"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Items, nil
}
// markAsUnknown updates a record's status to "unknown"
func (c *Cleaner) markAsUnknown(ctx context.Context, recordID string) error {
update := TelemetryStatusUpdate{
Status: "unknown",
Error: "Installation timed out - no completion status received",
}
return c.pb.UpdateTelemetryStatus(ctx, recordID, update)
}
// RunNow triggers an immediate cleanup run (for testing/manual trigger)
func (c *Cleaner) RunNow() (int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
stuckRecords, err := c.findStuckInstallations(ctx)
if err != nil {
return 0, fmt.Errorf("failed to find stuck installations: %w", err)
}
updated := 0
for _, record := range stuckRecords {
if err := c.markAsUnknown(ctx, record.ID); err != nil {
log.Printf("WARN: cleanup - failed to update record %s: %v", record.ID, err)
continue
}
updated++
}
return updated, nil
}
// GetStuckCount returns the current number of stuck installations
func (c *Cleaner) GetStuckCount(ctx context.Context) (int, error) {
records, err := c.findStuckInstallations(ctx)
if err != nil {
return 0, err
}
return len(records), nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +0,0 @@
#!/bin/sh
set -e
echo "============================================="
echo " ProxmoxVED Telemetry Service"
echo "============================================="
# Map Coolify ENV names to migration script names
# Coolify uses PB_URL, PB_TARGET_COLLECTION
export POCKETBASE_URL="${POCKETBASE_URL:-$PB_URL}"
export POCKETBASE_COLLECTION="${POCKETBASE_COLLECTION:-$PB_TARGET_COLLECTION}"
# Run migration if enabled
if [ "$RUN_MIGRATION" = "true" ]; then
echo ""
echo "🔄 Migration mode enabled"
echo " Source: $MIGRATION_SOURCE_URL"
echo " Target: $POCKETBASE_URL"
echo " Collection: $POCKETBASE_COLLECTION"
echo ""
# Wait for PocketBase to be ready
echo "⏳ Waiting for PocketBase to be ready..."
RETRIES=30
until wget -q --spider "$POCKETBASE_URL/api/health" 2>/dev/null; do
RETRIES=$((RETRIES - 1))
if [ $RETRIES -le 0 ]; then
echo "❌ PocketBase not reachable after 30 attempts"
if [ "$MIGRATION_REQUIRED" = "true" ]; then
exit 1
fi
echo "⚠️ Continuing without migration..."
break
fi
echo " Waiting... ($RETRIES attempts left)"
sleep 2
done
if wget -q --spider "$POCKETBASE_URL/api/health" 2>/dev/null; then
echo "✅ PocketBase is ready"
echo ""
echo "🚀 Starting migration..."
/app/migrate || {
if [ "$MIGRATION_REQUIRED" = "true" ]; then
echo "❌ Migration failed!"
exit 1
fi
echo "⚠️ Migration failed, but continuing..."
}
echo ""
fi
fi
echo "🚀 Starting telemetry service..."
exec /app/telemetry-service

View File

@@ -1,10 +0,0 @@
module github.com/community-scripts/telemetry-service
go 1.25.5
require github.com/redis/go-redis/v9 v9.17.3
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
)

View File

@@ -1,10 +0,0 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=

View File

@@ -1,366 +0,0 @@
// +build ignore
// Migration script to import data from the old API to PocketBase
// Run with: go run migrate.go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
const (
defaultSourceAPI = "https://api.htl-braunau.at/dev/data"
defaultPBURL = "http://localhost:8090"
batchSize = 100
)
var (
sourceAPI string
summaryAPI string
authToken string // PocketBase auth token
)
// OldDataModel represents the data structure from the old API
type OldDataModel struct {
ID string `json:"id"`
CtType int `json:"ct_type"`
DiskSize int `json:"disk_size"`
CoreCount int `json:"core_count"`
RamSize int `json:"ram_size"`
OsType string `json:"os_type"`
OsVersion string `json:"os_version"`
DisableIP6 string `json:"disableip6"`
NsApp string `json:"nsapp"`
Method string `json:"method"`
CreatedAt string `json:"created_at"`
PveVersion string `json:"pve_version"`
Status string `json:"status"`
RandomID string `json:"random_id"`
Type string `json:"type"`
Error string `json:"error"`
}
// PBRecord represents the PocketBase record format
type PBRecord struct {
CtType int `json:"ct_type"`
DiskSize int `json:"disk_size"`
CoreCount int `json:"core_count"`
RamSize int `json:"ram_size"`
OsType string `json:"os_type"`
OsVersion string `json:"os_version"`
DisableIP6 string `json:"disableip6"`
NsApp string `json:"nsapp"`
Method string `json:"method"`
PveVersion string `json:"pve_version"`
Status string `json:"status"`
RandomID string `json:"random_id"`
Type string `json:"type"`
Error string `json:"error"`
// created_at will be set automatically by PocketBase
}
type Summary struct {
TotalEntries int `json:"total_entries"`
}
func main() {
// Setup source URLs
baseURL := os.Getenv("MIGRATION_SOURCE_URL")
if baseURL == "" {
baseURL = defaultSourceAPI
}
sourceAPI = baseURL + "/paginated"
summaryAPI = baseURL + "/summary"
// Support both POCKETBASE_URL and PB_URL (Coolify uses PB_URL)
pbURL := os.Getenv("POCKETBASE_URL")
if pbURL == "" {
pbURL = os.Getenv("PB_URL")
}
if pbURL == "" {
pbURL = defaultPBURL
}
// Support both POCKETBASE_COLLECTION and PB_TARGET_COLLECTION
pbCollection := os.Getenv("POCKETBASE_COLLECTION")
if pbCollection == "" {
pbCollection = os.Getenv("PB_TARGET_COLLECTION")
}
if pbCollection == "" {
pbCollection = "telemetry"
}
// Auth collection
authCollection := os.Getenv("PB_AUTH_COLLECTION")
if authCollection == "" {
authCollection = "telemetry_service_user"
}
// Credentials
pbIdentity := os.Getenv("PB_IDENTITY")
pbPassword := os.Getenv("PB_PASSWORD")
fmt.Println("===========================================")
fmt.Println(" Data Migration to PocketBase")
fmt.Println("===========================================")
fmt.Printf("Source API: %s\n", baseURL)
fmt.Printf("PocketBase URL: %s\n", pbURL)
fmt.Printf("Collection: %s\n", pbCollection)
fmt.Printf("Auth Collection: %s\n", authCollection)
fmt.Println("-------------------------------------------")
// Authenticate with PocketBase
if pbIdentity != "" && pbPassword != "" {
fmt.Println("🔐 Authenticating with PocketBase...")
err := authenticate(pbURL, authCollection, pbIdentity, pbPassword)
if err != nil {
fmt.Printf("❌ Authentication failed: %v\n", err)
os.Exit(1)
}
fmt.Println("✅ Authentication successful")
} else {
fmt.Println("⚠️ No credentials provided, trying without auth...")
}
fmt.Println("-------------------------------------------")
// Get total count
summary, err := getSummary()
if err != nil {
fmt.Printf("❌ Failed to get summary: %v\n", err)
os.Exit(1)
}
fmt.Printf("📊 Total entries to migrate: %d\n", summary.TotalEntries)
fmt.Println("-------------------------------------------")
// Calculate pages
totalPages := (summary.TotalEntries + batchSize - 1) / batchSize
var totalMigrated, totalFailed, totalSkipped int
for page := 1; page <= totalPages; page++ {
fmt.Printf("📦 Fetching page %d/%d (items %d-%d)...\n",
page, totalPages,
(page-1)*batchSize+1,
min(page*batchSize, summary.TotalEntries))
data, err := fetchPage(page, batchSize)
if err != nil {
fmt.Printf(" ❌ Failed to fetch page %d: %v\n", page, err)
totalFailed += batchSize
continue
}
for i, record := range data {
err := importRecord(pbURL, pbCollection, record)
if err != nil {
if isUniqueViolation(err) {
totalSkipped++
continue
}
fmt.Printf(" ❌ Failed to import record %d: %v\n", (page-1)*batchSize+i+1, err)
totalFailed++
continue
}
totalMigrated++
}
fmt.Printf(" ✅ Page %d complete (migrated: %d, skipped: %d, failed: %d)\n",
page, len(data), totalSkipped, totalFailed)
// Small delay to avoid overwhelming the server
time.Sleep(100 * time.Millisecond)
}
fmt.Println("===========================================")
fmt.Println(" Migration Complete")
fmt.Println("===========================================")
fmt.Printf("✅ Successfully migrated: %d\n", totalMigrated)
fmt.Printf("⏭️ Skipped (duplicates): %d\n", totalSkipped)
fmt.Printf("❌ Failed: %d\n", totalFailed)
fmt.Println("===========================================")
}
func getSummary() (*Summary, error) {
resp, err := http.Get(summaryAPI)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var summary Summary
if err := json.NewDecoder(resp.Body).Decode(&summary); err != nil {
return nil, err
}
return &summary, nil
}
func authenticate(pbURL, authCollection, identity, password string) error {
body := map[string]string{
"identity": identity,
"password": password,
}
jsonData, _ := json.Marshal(body)
url := fmt.Sprintf("%s/api/collections/%s/auth-with-password", pbURL, authCollection)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var result struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
if result.Token == "" {
return fmt.Errorf("no token in response")
}
authToken = result.Token
return nil
}
func fetchPage(page, limit int) ([]OldDataModel, error) {
url := fmt.Sprintf("%s?page=%d&limit=%d", sourceAPI, page, limit)
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var data []OldDataModel
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, err
}
return data, nil
}
func importRecord(pbURL, collection string, old OldDataModel) error {
// Map status: "done" -> "success"
status := old.Status
switch status {
case "done":
status = "success"
case "installing", "failed", "unknown", "success":
// keep as-is
default:
status = "unknown"
}
// Ensure ct_type is not 0 (required field)
ctType := old.CtType
if ctType == 0 {
ctType = 1 // default to unprivileged
}
// Ensure type is set
recordType := old.Type
if recordType == "" {
recordType = "lxc"
}
record := PBRecord{
CtType: ctType,
DiskSize: old.DiskSize,
CoreCount: old.CoreCount,
RamSize: old.RamSize,
OsType: old.OsType,
OsVersion: old.OsVersion,
DisableIP6: old.DisableIP6,
NsApp: old.NsApp,
Method: old.Method,
PveVersion: old.PveVersion,
Status: status,
RandomID: old.RandomID,
Type: recordType,
Error: old.Error,
}
jsonData, err := json.Marshal(record)
if err != nil {
return err
}
url := fmt.Sprintf("%s/api/collections/%s/records", pbURL, collection)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if authToken != "" {
req.Header.Set("Authorization", "Bearer "+authToken)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
return nil
}
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return contains(errStr, "UNIQUE constraint failed") ||
contains(errStr, "duplicate") ||
contains(errStr, "already exists") ||
contains(errStr, "validation_not_unique")
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
}
func containsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -1,67 +0,0 @@
#!/bin/bash
# Migration script to import data from the old API to PocketBase
# Usage: ./migrate.sh [POCKETBASE_URL] [COLLECTION_NAME]
#
# Examples:
# ./migrate.sh # Uses defaults
# ./migrate.sh http://localhost:8090 # Custom PB URL
# ./migrate.sh http://localhost:8090 my_telemetry # Custom URL and collection
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Default values
POCKETBASE_URL="${1:-http://localhost:8090}"
POCKETBASE_COLLECTION="${2:-telemetry}"
echo "============================================="
echo " ProxmoxVED Data Migration Tool"
echo "============================================="
echo ""
echo "This script will migrate telemetry data from:"
echo " Source: https://api.htl-braunau.at/dev/data"
echo " Target: $POCKETBASE_URL"
echo " Collection: $POCKETBASE_COLLECTION"
echo ""
# Check if PocketBase is reachable
echo "🔍 Checking PocketBase connection..."
if ! curl -sf "$POCKETBASE_URL/api/health" >/dev/null 2>&1; then
echo "❌ Cannot reach PocketBase at $POCKETBASE_URL"
echo " Make sure PocketBase is running and the URL is correct."
exit 1
fi
echo "✅ PocketBase is reachable"
echo ""
# Check source API
echo "🔍 Checking source API..."
SUMMARY=$(curl -sf "https://api.htl-braunau.at/dev/data/summary" 2>/dev/null || echo "")
if [ -z "$SUMMARY" ]; then
echo "❌ Cannot reach source API"
exit 1
fi
TOTAL=$(echo "$SUMMARY" | grep -o '"total_entries":[0-9]*' | cut -d: -f2)
echo "✅ Source API is reachable ($TOTAL entries available)"
echo ""
# Confirm migration
read -p "⚠️ Do you want to start the migration? [y/N] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Migration cancelled."
exit 0
fi
echo ""
echo "Starting migration..."
echo ""
# Run the Go migration script
cd "$SCRIPT_DIR"
POCKETBASE_URL="$POCKETBASE_URL" POCKETBASE_COLLECTION="$POCKETBASE_COLLECTION" go run migrate.go
echo ""
echo "Migration complete!"

View File

@@ -1,106 +0,0 @@
#!/bin/bash
# Post-migration script to fix timestamps in PocketBase
# Run this INSIDE the PocketBase container after migration completes
#
# Usage: ./fix-timestamps.sh
set -e
DB_PATH="/app/pb_data/data.db"
echo "==========================================================="
echo " Fix Timestamps in PocketBase"
echo "==========================================================="
echo ""
# Check if sqlite3 is available
if ! command -v sqlite3 &> /dev/null; then
echo "sqlite3 not found. Installing..."
apk add sqlite 2>/dev/null || apt-get update && apt-get install -y sqlite3
fi
# Check if database exists
if [ ! -f "$DB_PATH" ]; then
echo "Database not found at $DB_PATH"
echo "Trying alternative paths..."
if [ -f "/pb_data/data.db" ]; then
DB_PATH="/pb_data/data.db"
elif [ -f "/pb/pb_data/data.db" ]; then
DB_PATH="/pb/pb_data/data.db"
else
DB_PATH=$(find / -name "data.db" 2>/dev/null | head -1)
fi
if [ -z "$DB_PATH" ] || [ ! -f "$DB_PATH" ]; then
echo "Could not find PocketBase database!"
exit 1
fi
fi
echo "Database: $DB_PATH"
echo ""
# List tables
echo "Tables in database:"
sqlite3 "$DB_PATH" ".tables"
echo ""
# Find the telemetry table (usually matches collection name)
echo "Looking for telemetry/installations table..."
TABLE_NAME=$(sqlite3 "$DB_PATH" ".tables" | tr ' ' '\n' | grep -E "telemetry|installations" | head -1)
if [ -z "$TABLE_NAME" ]; then
echo "Could not auto-detect table. Available tables:"
sqlite3 "$DB_PATH" ".tables"
echo ""
read -p "Enter table name: " TABLE_NAME
fi
echo "Using table: $TABLE_NAME"
echo ""
# Check if old_created column exists
HAS_OLD_CREATED=$(sqlite3 "$DB_PATH" "PRAGMA table_info($TABLE_NAME);" | grep -c "old_created" || echo "0")
if [ "$HAS_OLD_CREATED" -eq "0" ]; then
echo "Column 'old_created' not found in table $TABLE_NAME"
echo "Migration may not have been run with timestamp preservation."
exit 1
fi
# Show sample data before update
echo "Sample data BEFORE update:"
sqlite3 "$DB_PATH" "SELECT id, created, old_created FROM $TABLE_NAME WHERE old_created IS NOT NULL AND old_created != '' LIMIT 3;"
echo ""
# Count records to update
COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM $TABLE_NAME WHERE old_created IS NOT NULL AND old_created != '';")
echo "Records to update: $COUNT"
echo ""
read -p "Proceed with timestamp update? [y/N] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
# Perform the update
echo "Updating timestamps..."
sqlite3 "$DB_PATH" "UPDATE $TABLE_NAME SET created = old_created, updated = old_created WHERE old_created IS NOT NULL AND old_created != '';"
# Show sample data after update
echo ""
echo "Sample data AFTER update:"
sqlite3 "$DB_PATH" "SELECT id, created, old_created FROM $TABLE_NAME LIMIT 3;"
echo ""
echo "==========================================================="
echo " Timestamp Update Complete!"
echo "==========================================================="
echo ""
echo "Next steps:"
echo "1. Verify data in PocketBase Admin UI"
echo "2. Remove the 'old_created' field from the collection schema"
echo ""

View File

@@ -1,77 +0,0 @@
#!/bin/sh
# Direct SQLite Import - Pure Shell, FAST batch mode!
# Imports MongoDB Extended JSON directly into PocketBase SQLite
#
# Usage:
# docker cp import-direct.sh pocketbase:/tmp/
# docker cp data.json pocketbase:/tmp/
# docker exec -it pocketbase sh -c "cd /tmp && chmod +x import-direct.sh && ./import-direct.sh"
set -e
JSON_FILE="${1:-/tmp/data.json}"
TABLE="${2:-telemetry}"
REPO="${3:-Proxmox VE}"
DB="${4:-/app/pb_data/data.db}"
BATCH=5000
echo "========================================================="
echo " Direct SQLite Import (Batch Mode)"
echo "========================================================="
echo "JSON: $JSON_FILE"
echo "Table: $TABLE"
echo "Repo: $REPO"
echo "Batch: $BATCH"
echo "---------------------------------------------------------"
# Install jq if missing
command -v jq >/dev/null || apk add --no-cache jq
# Optimize SQLite for bulk
sqlite3 "$DB" "PRAGMA journal_mode=WAL; PRAGMA synchronous=OFF; PRAGMA cache_size=100000;"
SQL_FILE="/tmp/batch.sql"
echo "[INFO] Converting JSON to SQL..."
START=$(date +%s)
# Convert entire JSON to SQL file (much faster than line-by-line sqlite3 calls)
{
echo "BEGIN TRANSACTION;"
jq -r '.[] | @json' "$JSON_FILE" | while read -r r; do
CT=$(echo "$r" | jq -r 'if .ct_type|type=="object" then .ct_type["$numberLong"] else .ct_type end // 0')
DISK=$(echo "$r" | jq -r 'if .disk_size|type=="object" then .disk_size["$numberLong"] else .disk_size end // 0')
CORE=$(echo "$r" | jq -r 'if .core_count|type=="object" then .core_count["$numberLong"] else .core_count end // 0')
RAM=$(echo "$r" | jq -r 'if .ram_size|type=="object" then .ram_size["$numberLong"] else .ram_size end // 0')
OS=$(echo "$r" | jq -r '.os_type // ""' | sed "s/'/''/g")
OSVER=$(echo "$r" | jq -r '.os_version // ""' | sed "s/'/''/g")
DIS6=$(echo "$r" | jq -r '.disable_ip6 // "no"' | sed "s/'/''/g")
APP=$(echo "$r" | jq -r '.nsapp // "unknown"' | sed "s/'/''/g")
METH=$(echo "$r" | jq -r '.method // ""' | sed "s/'/''/g")
PVE=$(echo "$r" | jq -r '.pveversion // ""' | sed "s/'/''/g")
STAT=$(echo "$r" | jq -r '.status // "unknown"')
[ "$STAT" = "done" ] && STAT="success"
RID=$(echo "$r" | jq -r '.random_id // ""' | sed "s/'/''/g")
TYPE=$(echo "$r" | jq -r '.type // "lxc"' | sed "s/'/''/g")
ERR=$(echo "$r" | jq -r '.error // ""' | sed "s/'/''/g")
DATE=$(echo "$r" | jq -r 'if .created_at|type=="object" then .created_at["$date"] else .created_at end // ""')
ID=$(head -c 100 /dev/urandom | tr -dc 'a-z0-9' | head -c 15)
REPO_ESC=$(echo "$REPO" | sed "s/'/''/g")
echo "INSERT OR IGNORE INTO $TABLE (id,created,updated,ct_type,disk_size,core_count,ram_size,os_type,os_version,disableip6,nsapp,method,pve_version,status,random_id,type,error,repo_source) VALUES ('$ID','$DATE','$DATE',$CT,$DISK,$CORE,$RAM,'$OS','$OSVER','$DIS6','$APP','$METH','$PVE','$STAT','$RID','$TYPE','$ERR','$REPO_ESC');"
done
echo "COMMIT;"
} > "$SQL_FILE"
MID=$(date +%s)
echo "[INFO] SQL generated in $((MID - START))s"
echo "[INFO] Importing into SQLite..."
sqlite3 "$DB" < "$SQL_FILE"
END=$(date +%s)
COUNT=$(wc -l < "$SQL_FILE")
rm -f "$SQL_FILE"
echo "========================================================="
echo "Done! ~$((COUNT - 2)) records in $((END - START)) seconds"
echo "========================================================="

View File

@@ -1,89 +0,0 @@
#!/bin/bash
# Migration script for Proxmox VE data
# Run directly on the server machine
#
# Usage: ./migrate-linux.sh
#
# Prerequisites:
# - Go installed (apt install golang-go)
# - Network access to source API and PocketBase
set -e
echo "==========================================================="
echo " Proxmox VE Data Migration to PocketBase"
echo "==========================================================="
# Configuration - EDIT THESE VALUES
export MIGRATION_SOURCE_URL="https://api.htl-braunau.at/data"
export POCKETBASE_URL="http://db.community-scripts.org"
export POCKETBASE_COLLECTION="telemetry"
export PB_AUTH_COLLECTION="_superusers"
export PB_IDENTITY="db_admin@community-scripts.org"
export PB_PASSWORD="YOUR_PASSWORD_HERE" # <-- CHANGE THIS!
export REPO_SOURCE="Proxmox VE"
export DATE_UNTIL="2026-02-10"
export BATCH_SIZE="500"
# Optional: Resume from specific page
# export START_PAGE="100"
# Optional: Only import records after this date
# export DATE_FROM="2020-01-01"
echo ""
echo "Configuration:"
echo " Source: $MIGRATION_SOURCE_URL"
echo " Target: $POCKETBASE_URL"
echo " Collection: $POCKETBASE_COLLECTION"
echo " Repo: $REPO_SOURCE"
echo " Until: $DATE_UNTIL"
echo " Batch: $BATCH_SIZE"
echo ""
# Check if Go is installed
if ! command -v go &> /dev/null; then
echo "Go is not installed. Installing..."
apt-get update && apt-get install -y golang-go
fi
# Download migrate.go if not present
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MIGRATE_GO="$SCRIPT_DIR/migrate.go"
if [ ! -f "$MIGRATE_GO" ]; then
echo "migrate.go not found in $SCRIPT_DIR"
echo "Please copy migrate.go to this directory first."
exit 1
fi
echo "Building migration tool..."
cd "$SCRIPT_DIR"
go build -o migrate migrate.go
echo ""
echo "Starting migration..."
echo "Press Ctrl+C to stop (you can resume later with START_PAGE)"
echo ""
./migrate
echo ""
echo "==========================================================="
echo " Post-Migration Steps"
echo "==========================================================="
echo ""
echo "1. Connect to PocketBase container:"
echo " docker exec -it <pocketbase-container> sh"
echo ""
echo "2. Find the table name:"
echo " sqlite3 /app/pb_data/data.db '.tables'"
echo ""
echo "3. Update timestamps (replace <table> with actual name):"
echo " sqlite3 /app/pb_data/data.db \"UPDATE <table> SET created = old_created, updated = old_created WHERE old_created IS NOT NULL AND old_created != ''\""
echo ""
echo "4. Verify timestamps:"
echo " sqlite3 /app/pb_data/data.db \"SELECT created, old_created FROM <table> LIMIT 5\""
echo ""
echo "5. Remove old_created field in PocketBase Admin UI"
echo ""

File diff suppressed because it is too large Load Diff

View File

@@ -1,67 +0,0 @@
#!/bin/bash
# Migration script to import data from the old API to PocketBase
# Usage: ./migrate.sh [POCKETBASE_URL] [COLLECTION_NAME]
#
# Examples:
# ./migrate.sh # Uses defaults
# ./migrate.sh http://localhost:8090 # Custom PB URL
# ./migrate.sh http://localhost:8090 my_telemetry # Custom URL and collection
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Default values
POCKETBASE_URL="${1:-http://localhost:8090}"
POCKETBASE_COLLECTION="${2:-telemetry}"
echo "============================================="
echo " ProxmoxVED Data Migration Tool"
echo "============================================="
echo ""
echo "This script will migrate telemetry data from:"
echo " Source: https://api.htl-braunau.at/dev/data"
echo " Target: $POCKETBASE_URL"
echo " Collection: $POCKETBASE_COLLECTION"
echo ""
# Check if PocketBase is reachable
echo "🔍 Checking PocketBase connection..."
if ! curl -sf "$POCKETBASE_URL/api/health" >/dev/null 2>&1; then
echo "❌ Cannot reach PocketBase at $POCKETBASE_URL"
echo " Make sure PocketBase is running and the URL is correct."
exit 1
fi
echo "✅ PocketBase is reachable"
echo ""
# Check source API
echo "🔍 Checking source API..."
SUMMARY=$(curl -sf "https://api.htl-braunau.at/dev/data/summary" 2>/dev/null || echo "")
if [ -z "$SUMMARY" ]; then
echo "❌ Cannot reach source API"
exit 1
fi
TOTAL=$(echo "$SUMMARY" | grep -o '"total_entries":[0-9]*' | cut -d: -f2)
echo "✅ Source API is reachable ($TOTAL entries available)"
echo ""
# Confirm migration
read -p "⚠️ Do you want to start the migration? [y/N] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Migration cancelled."
exit 0
fi
echo ""
echo "Starting migration..."
echo ""
# Run the Go migration script
cd "$SCRIPT_DIR"
POCKETBASE_URL="$POCKETBASE_URL" POCKETBASE_COLLECTION="$POCKETBASE_COLLECTION" go run migrate.go
echo ""
echo "Migration complete!"

View File

@@ -1,492 +0,0 @@
// +build ignore
// Migration script to import data from the old API to PocketBase
// Run with: go run migrate.go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
const (
defaultSourceAPI = "https://api.htl-braunau.at/dev/data"
defaultPBURL = "http://localhost:8090"
batchSize = 100
)
var (
sourceAPI string
summaryAPI string
authToken string // PocketBase auth token
)
// OldDataModel represents the data structure from the old API
type OldDataModel struct {
ID string `json:"id"`
CtType int `json:"ct_type"`
DiskSize int `json:"disk_size"`
CoreCount int `json:"core_count"`
RamSize int `json:"ram_size"`
OsType string `json:"os_type"`
OsVersion string `json:"os_version"`
DisableIP6 string `json:"disableip6"`
NsApp string `json:"nsapp"`
Method string `json:"method"`
CreatedAt string `json:"created_at"`
PveVersion string `json:"pve_version"`
Status string `json:"status"`
RandomID string `json:"random_id"`
Type string `json:"type"`
Error string `json:"error"`
}
// PBRecord represents the PocketBase record format
type PBRecord struct {
CtType int `json:"ct_type"`
DiskSize int `json:"disk_size"`
CoreCount int `json:"core_count"`
RamSize int `json:"ram_size"`
OsType string `json:"os_type"`
OsVersion string `json:"os_version"`
DisableIP6 string `json:"disableip6"`
NsApp string `json:"nsapp"`
Method string `json:"method"`
PveVersion string `json:"pve_version"`
Status string `json:"status"`
RandomID string `json:"random_id"`
Type string `json:"type"`
Error string `json:"error"`
// Temporary field for timestamp migration (PocketBase doesn't allow setting created/updated via API)
// After migration, run SQL: UPDATE installations SET created = old_created, updated = old_created
OldCreated string `json:"old_created,omitempty"`
}
type Summary struct {
TotalEntries int `json:"total_entries"`
}
func main() {
// Setup source URLs
baseURL := os.Getenv("MIGRATION_SOURCE_URL")
if baseURL == "" {
baseURL = defaultSourceAPI
}
sourceAPI = baseURL + "/paginated"
summaryAPI = baseURL + "/summary"
// Support both POCKETBASE_URL and PB_URL (Coolify uses PB_URL)
pbURL := os.Getenv("POCKETBASE_URL")
if pbURL == "" {
pbURL = os.Getenv("PB_URL")
}
if pbURL == "" {
pbURL = defaultPBURL
}
// Support both POCKETBASE_COLLECTION and PB_TARGET_COLLECTION
pbCollection := os.Getenv("POCKETBASE_COLLECTION")
if pbCollection == "" {
pbCollection = os.Getenv("PB_TARGET_COLLECTION")
}
if pbCollection == "" {
pbCollection = "telemetry"
}
// Auth collection
authCollection := os.Getenv("PB_AUTH_COLLECTION")
if authCollection == "" {
authCollection = "telemetry_service_user"
}
// Credentials - prefer admin auth for timestamp preservation
pbAdminEmail := os.Getenv("PB_ADMIN_EMAIL")
pbAdminPassword := os.Getenv("PB_ADMIN_PASSWORD")
pbIdentity := os.Getenv("PB_IDENTITY")
pbPassword := os.Getenv("PB_PASSWORD")
fmt.Println("===========================================")
fmt.Println(" Data Migration to PocketBase")
fmt.Println("===========================================")
fmt.Printf("Source API: %s\n", baseURL)
fmt.Printf("PocketBase URL: %s\n", pbURL)
fmt.Printf("Collection: %s\n", pbCollection)
fmt.Println("-------------------------------------------")
// Authenticate with PocketBase - prefer Admin auth for timestamp support
if pbAdminEmail != "" && pbAdminPassword != "" {
fmt.Println("🔐 Authenticating as PocketBase Admin...")
err := authenticateAdmin(pbURL, pbAdminEmail, pbAdminPassword)
if err != nil {
fmt.Printf("❌ Admin authentication failed: %v\n", err)
os.Exit(1)
}
fmt.Println("✅ Admin authentication successful (timestamps will be preserved)")
} else if pbIdentity != "" && pbPassword != "" {
fmt.Println("🔐 Authenticating with PocketBase (collection auth)...")
fmt.Println("⚠️ Note: Timestamps may not be preserved without admin auth")
err := authenticate(pbURL, authCollection, pbIdentity, pbPassword)
if err != nil {
fmt.Printf("❌ Authentication failed: %v\n", err)
os.Exit(1)
}
fmt.Println("✅ Authentication successful")
} else {
fmt.Println("⚠️ No credentials provided, trying without auth...")
}
fmt.Println("-------------------------------------------")
// Get total count
summary, err := getSummary()
if err != nil {
fmt.Printf("❌ Failed to get summary: %v\n", err)
os.Exit(1)
}
fmt.Printf("📊 Total entries to migrate: %d\n", summary.TotalEntries)
fmt.Println("-------------------------------------------")
// Calculate pages
totalPages := (summary.TotalEntries + batchSize - 1) / batchSize
var totalMigrated, totalFailed, totalSkipped int
for page := 1; page <= totalPages; page++ {
fmt.Printf("📦 Fetching page %d/%d (items %d-%d)...\n",
page, totalPages,
(page-1)*batchSize+1,
min(page*batchSize, summary.TotalEntries))
data, err := fetchPage(page, batchSize)
if err != nil {
fmt.Printf(" ❌ Failed to fetch page %d: %v\n", page, err)
totalFailed += batchSize
continue
}
for i, record := range data {
err := importRecord(pbURL, pbCollection, record)
if err != nil {
if isUniqueViolation(err) {
totalSkipped++
continue
}
fmt.Printf(" ❌ Failed to import record %d: %v\n", (page-1)*batchSize+i+1, err)
totalFailed++
continue
}
totalMigrated++
}
fmt.Printf(" ✅ Page %d complete (migrated: %d, skipped: %d, failed: %d)\n",
page, len(data), totalSkipped, totalFailed)
// Small delay to avoid overwhelming the server
time.Sleep(100 * time.Millisecond)
}
fmt.Println("===========================================")
fmt.Println(" Migration Complete")
fmt.Println("===========================================")
fmt.Printf("✅ Successfully migrated: %d\n", totalMigrated)
fmt.Printf("⏭️ Skipped (duplicates): %d\n", totalSkipped)
fmt.Printf("❌ Failed: %d\n", totalFailed)
fmt.Println("===========================================")
}
func getSummary() (*Summary, error) {
resp, err := http.Get(summaryAPI)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var summary Summary
if err := json.NewDecoder(resp.Body).Decode(&summary); err != nil {
return nil, err
}
return &summary, nil
}
// authenticateAdmin authenticates as PocketBase admin (required for setting timestamps)
func authenticateAdmin(pbURL, email, password string) error {
body := map[string]string{
"identity": email,
"password": password,
}
jsonData, _ := json.Marshal(body)
// Try new PocketBase v0.23+ endpoint first (_superusers collection)
endpoints := []string{
fmt.Sprintf("%s/api/collections/_superusers/auth-with-password", pbURL),
fmt.Sprintf("%s/api/admins/auth-with-password", pbURL), // Legacy endpoint
}
client := &http.Client{Timeout: 10 * time.Second}
var lastErr error
for _, url := range endpoints {
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
lastErr = err
continue
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
lastErr = err
continue
}
if resp.StatusCode == 404 {
resp.Body.Close()
continue // Try next endpoint
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
lastErr = fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
continue
}
var result struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
resp.Body.Close()
lastErr = err
continue
}
resp.Body.Close()
if result.Token == "" {
lastErr = fmt.Errorf("no token in response")
continue
}
authToken = result.Token
return nil
}
return fmt.Errorf("all auth endpoints failed: %v", lastErr)
}
func authenticate(pbURL, authCollection, identity, password string) error {
body := map[string]string{
"identity": identity,
"password": password,
}
jsonData, _ := json.Marshal(body)
url := fmt.Sprintf("%s/api/collections/%s/auth-with-password", pbURL, authCollection)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var result struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
if result.Token == "" {
return fmt.Errorf("no token in response")
}
authToken = result.Token
return nil
}
func fetchPage(page, limit int) ([]OldDataModel, error) {
url := fmt.Sprintf("%s?page=%d&limit=%d", sourceAPI, page, limit)
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var data []OldDataModel
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, err
}
return data, nil
}
func importRecord(pbURL, collection string, old OldDataModel) error {
// Map status: "done" -> "success"
status := old.Status
switch status {
case "done":
status = "success"
case "installing", "failed", "unknown", "success":
// keep as-is
default:
status = "unknown"
}
// ct_type: 1=unprivileged, 2=privileged in old data
// PocketBase might expect 0/1, so normalize to 0 (unprivileged) or 1 (privileged)
ctType := old.CtType
if ctType <= 1 {
ctType = 0 // unprivileged (default)
} else {
ctType = 1 // privileged/VM
}
// Ensure type is set
recordType := old.Type
if recordType == "" {
recordType = "lxc"
}
// Ensure nsapp is set (required field)
nsapp := old.NsApp
if nsapp == "" {
nsapp = "unknown"
}
record := PBRecord{
CtType: ctType,
DiskSize: old.DiskSize,
CoreCount: old.CoreCount,
RamSize: old.RamSize,
OsType: old.OsType,
OsVersion: old.OsVersion,
DisableIP6: old.DisableIP6,
NsApp: nsapp,
Method: old.Method,
PveVersion: old.PveVersion,
Status: status,
RandomID: old.RandomID,
Type: recordType,
Error: old.Error,
OldCreated: convertTimestamp(old.CreatedAt),
}
jsonData, err := json.Marshal(record)
if err != nil {
return err
}
url := fmt.Sprintf("%s/api/collections/%s/records", pbURL, collection)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if authToken != "" {
req.Header.Set("Authorization", "Bearer "+authToken)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
return nil
}
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return contains(errStr, "UNIQUE constraint failed") ||
contains(errStr, "duplicate") ||
contains(errStr, "already exists") ||
contains(errStr, "validation_not_unique")
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
}
func containsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// convertTimestamp converts various timestamp formats to PocketBase format
// PocketBase expects: "2006-01-02 15:04:05.000Z" or similar
func convertTimestamp(ts string) string {
if ts == "" {
return ""
}
// Try parsing various formats
formats := []string{
time.RFC3339, // "2006-01-02T15:04:05Z07:00"
time.RFC3339Nano, // "2006-01-02T15:04:05.999999999Z07:00"
"2006-01-02T15:04:05.000Z", // ISO with milliseconds
"2006-01-02T15:04:05Z", // ISO without milliseconds
"2006-01-02T15:04:05", // ISO without timezone
"2006-01-02 15:04:05", // SQL format
"2006-01-02 15:04:05.000", // SQL with ms
"2006-01-02 15:04:05.000 UTC", // SQL with UTC
"2006-01-02T15:04:05.000+00:00", // ISO with offset
}
var parsed time.Time
var err error
for _, format := range formats {
parsed, err = time.Parse(format, ts)
if err == nil {
break
}
}
if err != nil {
// If all parsing fails, return empty (PocketBase will set current time)
fmt.Printf(" ⚠️ Could not parse timestamp: %s\n", ts)
return ""
}
// Return in PocketBase format (UTC)
return parsed.UTC().Format("2006-01-02 15:04:05.000Z")
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -37,24 +37,79 @@ if ! declare -f explain_exit_code &>/dev/null; then
case "$code" in
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)" ;;
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" ;;
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" ;;
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)" ;;
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" ;;
130) echo "Terminated by Ctrl+C (SIGINT)" ;;
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)" ;;
150) echo "Systemd: Service failed to start" ;;
151) echo "Systemd: Service unit not found" ;;
152) echo "Permission denied (EACCES)" ;;
@@ -100,6 +155,7 @@ if ! declare -f explain_exit_code &>/dev/null; then
224) echo "Proxmox: PBS storage is for backups only" ;;
225) echo "Proxmox: No template available for OS/Version" ;;
231) echo "Proxmox: LXC stack upgrade failed" ;;
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" ;;
@@ -148,6 +204,16 @@ error_handler() {
printf "\e[?25h"
# ALWAYS report failure to API immediately - don't wait for container checks
# This ensures we capture failures that occur before/after container exists
if declare -f post_update_to_api &>/dev/null; then
post_update_to_api "failed" "$exit_code" 2>/dev/null || true
else
# Container context: post_update_to_api not available (api.func not sourced)
# Send status directly via curl so container failures are never lost
_send_abort_telemetry "$exit_code" 2>/dev/null || true
fi
# Use msg_error if available, fallback to echo
if declare -f msg_error >/dev/null 2>&1; then
msg_error "in line ${line_number}: exit code ${exit_code} (${explanation}): while executing command ${command}"
@@ -174,55 +240,92 @@ error_handler() {
active_log="$SILENT_LOGFILE"
fi
# If active_log points to a container-internal path that doesn't exist on host,
# fall back to BUILD_LOG (host-side log)
if [[ -n "$active_log" && ! -s "$active_log" && -n "${BUILD_LOG:-}" && -s "${BUILD_LOG}" ]]; then
active_log="$BUILD_LOG"
fi
# Show last log lines if available
if [[ -n "$active_log" && -s "$active_log" ]]; then
echo "--- Last 20 lines of silent log ---"
echo -e "\n${TAB}--- Last 20 lines of log ---"
tail -n 20 "$active_log"
echo "-----------------------------------"
echo -e "${TAB}-----------------------------------\n"
fi
# Detect context: Container (INSTALL_LOG set + /root exists) vs Host (BUILD_LOG)
if [[ -n "${INSTALL_LOG:-}" && -d /root ]]; then
# CONTAINER CONTEXT: Copy log and create flag file for host
local container_log="/root/.install-${SESSION_ID:-error}.log"
cp "$active_log" "$container_log" 2>/dev/null || true
# Detect context: Container (INSTALL_LOG set + inside container /root) vs Host
if [[ -n "${INSTALL_LOG:-}" && -f "${INSTALL_LOG:-}" && -d /root ]]; then
# CONTAINER CONTEXT: Copy log and create flag file for host
local container_log="/root/.install-${SESSION_ID:-error}.log"
cp "${INSTALL_LOG}" "$container_log" 2>/dev/null || true
# Create error flag file with exit code for host detection
echo "$exit_code" >"/root/.install-${SESSION_ID:-error}.failed" 2>/dev/null || true
# Log path is shown by host as combined log - no need to show container path
else
# HOST CONTEXT: Show local log path and offer container cleanup
# Create error flag file with exit code for host detection
echo "$exit_code" >"/root/.install-${SESSION_ID:-error}.failed" 2>/dev/null || true
# Log path is shown by host as combined log - no need to show container path
else
# HOST CONTEXT: Show local log path and offer container cleanup
if [[ -n "$active_log" && -s "$active_log" ]]; then
if declare -f msg_custom >/dev/null 2>&1; then
msg_custom "📋" "${YW}" "Full log: ${active_log}"
else
echo -e "${YW}Full log:${CL} ${BL}${active_log}${CL}"
fi
fi
# Offer to remove container if it exists (build errors after container creation)
if [[ -n "${CTID:-}" ]] && command -v pct &>/dev/null && pct status "$CTID" &>/dev/null; then
# Report failure to API before container cleanup
if declare -f post_update_to_api &>/dev/null; then
post_update_to_api "failed" "$exit_code"
fi
echo ""
# Offer to remove container if it exists (build errors after container creation)
if [[ -n "${CTID:-}" ]] && command -v pct &>/dev/null && pct status "$CTID" &>/dev/null; then
echo ""
if declare -f msg_custom >/dev/null 2>&1; then
echo -en "${TAB}${TAB}${YW}Remove broken container ${CTID}? (Y/n) [auto-remove in 60s]: ${CL}"
else
echo -en "${YW}Remove broken container ${CTID}? (Y/n) [auto-remove in 60s]: ${CL}"
fi
if read -t 60 -r response; then
if [[ -z "$response" || "$response" =~ ^[Yy]$ ]]; then
echo -e "\n${YW}Removing container ${CTID}${CL}"
pct stop "$CTID" &>/dev/null || true
pct destroy "$CTID" &>/dev/null || true
echo -e "${GN}${CL} Container ${CTID} removed"
elif [[ "$response" =~ ^[Nn]$ ]]; then
echo -e "\n${YW}Container ${CTID} kept for debugging${CL}"
if read -t 60 -r response; then
if [[ -z "$response" || "$response" =~ ^[Yy]$ ]]; then
echo ""
if declare -f msg_info >/dev/null 2>&1; then
msg_info "Removing container ${CTID}"
else
echo -e "${YW}Removing container ${CTID}${CL}"
fi
else
# Timeout - auto-remove
echo -e "\n${YW}No response - auto-removing container${CL}"
pct stop "$CTID" &>/dev/null || true
pct destroy "$CTID" &>/dev/null || true
if declare -f msg_ok >/dev/null 2>&1; then
msg_ok "Container ${CTID} removed"
else
echo -e "${GN}${CL} Container ${CTID} removed"
fi
elif [[ "$response" =~ ^[Nn]$ ]]; then
echo ""
if declare -f msg_warn >/dev/null 2>&1; then
msg_warn "Container ${CTID} kept for debugging"
else
echo -e "${YW}Container ${CTID} kept for debugging${CL}"
fi
fi
else
# Timeout - auto-remove
echo ""
if declare -f msg_info >/dev/null 2>&1; then
msg_info "No response - removing container ${CTID}"
else
echo -e "${YW}No response - removing container ${CTID}${CL}"
fi
pct stop "$CTID" &>/dev/null || true
pct destroy "$CTID" &>/dev/null || true
if declare -f msg_ok >/dev/null 2>&1; then
msg_ok "Container ${CTID} removed"
else
echo -e "${GN}${CL} Container ${CTID} removed"
fi
fi
# Force one final status update attempt after cleanup
# This ensures status is updated even if the first attempt failed (e.g., HTTP 400)
if declare -f post_update_to_api &>/dev/null; then
post_update_to_api "failed" "$exit_code" "force"
fi
fi
fi
@@ -230,19 +333,97 @@ error_handler() {
}
# ==============================================================================
# SECTION 3: SIGNAL HANDLERS
# SECTION 3: TELEMETRY & CLEANUP HELPERS FOR SIGNAL HANDLERS
# ==============================================================================
# ------------------------------------------------------------------------------
# _send_abort_telemetry()
#
# - Sends failure/abort status to telemetry API
# - Works in BOTH host context (post_update_to_api available) and
# container context (only curl available, api.func not sourced)
# - Container context is critical: without this, container-side failures
# and signal exits are never reported, leaving records stuck in
# "installing" or "configuring" forever
# - Arguments: $1 = exit_code
# ------------------------------------------------------------------------------
_send_abort_telemetry() {
local exit_code="${1:-1}"
# Try full API function first (host context - api.func sourced)
if declare -f post_update_to_api &>/dev/null; then
post_update_to_api "failed" "$exit_code" 2>/dev/null || true
return
fi
# Fallback: direct curl (container context - api.func NOT sourced)
# This is the ONLY way containers can report failures to telemetry
command -v curl &>/dev/null || return 0
[[ "${DIAGNOSTICS:-no}" == "no" ]] && return 0
[[ -z "${RANDOM_UUID:-}" ]] && return 0
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:-lxc}\",\"nsapp\":\"${NSAPP:-${app:-unknown}}\",\"status\":\"failed\",\"exit_code\":${exit_code}}" &>/dev/null || true
}
# ------------------------------------------------------------------------------
# _stop_container_if_installing()
#
# - Stops the LXC container if we're in the install phase
# - Prevents orphaned container processes when the host exits due to a signal
# (SSH disconnect, Ctrl+C, SIGTERM) — without this, the container keeps
# running and may send "configuring" status AFTER the host already sent
# "failed", leaving records permanently stuck in "configuring"
# - Only acts when:
# * CONTAINER_INSTALLING flag is set (during lxc-attach in build_container)
# * CTID is set (container was created)
# * pct command is available (we're on the Proxmox host, not inside a container)
# - Does NOT destroy the container — just stops it for potential debugging
# ------------------------------------------------------------------------------
_stop_container_if_installing() {
[[ "${CONTAINER_INSTALLING:-}" == "true" ]] || return 0
[[ -n "${CTID:-}" ]] || return 0
command -v pct &>/dev/null || return 0
pct stop "$CTID" 2>/dev/null || true
}
# ==============================================================================
# SECTION 4: SIGNAL HANDLERS
# ==============================================================================
# ------------------------------------------------------------------------------
# on_exit()
#
# - EXIT trap handler
# - Cleans up lock files if lockfile variable is set
# - Exits with captured exit code
# - Always runs on script termination (success or failure)
# - EXIT trap handler — runs on EVERY script termination
# - Catches orphaned "installing"/"configuring" records:
# * If post_to_api sent "installing" but post_update_to_api never ran
# * Reports final status to prevent records stuck forever
# - Best-effort log collection for failed installs
# - Stops orphaned container processes on failure
# - Cleans up lock files
# ------------------------------------------------------------------------------
on_exit() {
local exit_code=$?
# Report orphaned "installing" records to telemetry API
# Catches ALL exit paths: errors, signals, AND clean exits where
# post_to_api was called but post_update_to_api was never called
if [[ "${POST_TO_API_DONE:-}" == "true" && "${POST_UPDATE_DONE:-}" != "true" ]]; then
if [[ $exit_code -ne 0 ]]; then
_send_abort_telemetry "$exit_code"
elif declare -f post_update_to_api >/dev/null 2>&1; then
post_update_to_api "done" "0" 2>/dev/null || true
fi
fi
# Best-effort log collection on failure (non-critical, telemetry already sent)
if [[ $exit_code -ne 0 ]] && declare -f ensure_log_on_host >/dev/null 2>&1; then
ensure_log_on_host 2>/dev/null || true
fi
# Stop orphaned container if we're in the install phase and exiting with error
if [[ $exit_code -ne 0 ]]; then
_stop_container_if_installing
fi
[[ -n "${lockfile:-}" && -e "$lockfile" ]] && rm -f "$lockfile"
exit "$exit_code"
}
@@ -251,14 +432,17 @@ on_exit() {
# on_interrupt()
#
# - SIGINT (Ctrl+C) trap handler
# - Displays "Interrupted by user" message
# - Reports status FIRST (time-critical: container may be dying)
# - Stops orphaned container to prevent "configuring" ghost records
# - Exits with code 130 (128 + SIGINT=2)
# ------------------------------------------------------------------------------
on_interrupt() {
_send_abort_telemetry "130"
_stop_container_if_installing
if declare -f msg_error >/dev/null 2>&1; then
msg_error "Interrupted by user (SIGINT)"
msg_error "Interrupted by user (SIGINT)" 2>/dev/null || true
else
echo -e "\n${RD}Interrupted by user (SIGINT)${CL}"
echo -e "\n${RD}Interrupted by user (SIGINT)${CL}" 2>/dev/null || true
fi
exit 130
}
@@ -267,21 +451,40 @@ on_interrupt() {
# on_terminate()
#
# - SIGTERM trap handler
# - Displays "Terminated by signal" message
# - Reports status FIRST (time-critical: process being killed)
# - Stops orphaned container to prevent "configuring" ghost records
# - Exits with code 143 (128 + SIGTERM=15)
# - Triggered by external process termination
# ------------------------------------------------------------------------------
on_terminate() {
_send_abort_telemetry "143"
_stop_container_if_installing
if declare -f msg_error >/dev/null 2>&1; then
msg_error "Terminated by signal (SIGTERM)"
msg_error "Terminated by signal (SIGTERM)" 2>/dev/null || true
else
echo -e "\n${RD}Terminated by signal (SIGTERM)${CL}"
echo -e "\n${RD}Terminated by signal (SIGTERM)${CL}" 2>/dev/null || true
fi
exit 143
}
# ------------------------------------------------------------------------------
# on_hangup()
#
# - SIGHUP trap handler (SSH disconnect, terminal closed)
# - CRITICAL: This was previously MISSING from catch_errors(), causing
# container processes to become orphans on SSH disconnect — the #1 cause
# of records stuck in "installing" and "configuring" states
# - Reports status via direct curl (terminal is already closed, no output)
# - Stops orphaned container to prevent ghost records
# - Exits with code 129 (128 + SIGHUP=1)
# ------------------------------------------------------------------------------
on_hangup() {
_send_abort_telemetry "129"
_stop_container_if_installing
exit 129
}
# ==============================================================================
# SECTION 4: INITIALIZATION
# SECTION 5: INITIALIZATION
# ==============================================================================
# ------------------------------------------------------------------------------
@@ -293,10 +496,11 @@ on_terminate() {
# * set -o pipefail: Pipeline fails if any command fails
# * set -u: (optional) Exit on undefined variable (if STRICT_UNSET=1)
# - Sets up traps:
# * ERR → error_handler
# * EXIT → on_exit
# * INT → on_interrupt
# * TERM → on_terminate
# * ERR → error_handler (script errors)
# * EXIT → on_exit (any termination — cleanup + orphan detection)
# * INT → on_interrupt (Ctrl+C)
# * TERM → on_terminate (kill / systemd stop)
# * HUP → on_hangup (SSH disconnect / terminal closed)
# - Call this function early in every script
# ------------------------------------------------------------------------------
catch_errors() {
@@ -309,4 +513,5 @@ catch_errors() {
trap on_exit EXIT
trap on_interrupt INT
trap on_terminate TERM
trap on_hangup HUP
}

View File

@@ -172,9 +172,12 @@ _bootstrap() {
fi
fi
# Configurable base URL for development — override with COMMUNITY_SCRIPTS_URL
COMMUNITY_SCRIPTS_URL="${COMMUNITY_SCRIPTS_URL:-https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main}"
# Source core functions
source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/core.func)
source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/error_handler.func)
source <(curl -fsSL "$COMMUNITY_SCRIPTS_URL/misc/core.func")
source <(curl -fsSL "$COMMUNITY_SCRIPTS_URL/misc/error_handler.func")
load_functions
catch_errors
@@ -744,10 +747,10 @@ EOF
# Source appropriate tools.func based on OS
case "$OS_FAMILY" in
alpine)
source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/alpine-tools.func)
source <(curl -fsSL "$COMMUNITY_SCRIPTS_URL/misc/alpine-tools.func")
;;
*)
source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/tools.func)
source <(curl -fsSL "$COMMUNITY_SCRIPTS_URL/misc/tools.func")
;;
esac
}

View File

@@ -1,172 +0,0 @@
# Copyright (c) 2021-2026 community-scripts ORG
# Author: tteck (tteckster)
# Co-Author: MickLesk
# 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
fi
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/core.func)
load_functions
# This function enables IPv6 if it's not disabled and sets verbose mode
verb_ip6() {
set_std_mode # Set STD mode based on VERBOSE
if [ "$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 <<EOF
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
net.ipv6.conf.lo.disable_ipv6 = 1
EOF
$STD rc-update add sysctl default
msg_ok "Disabled IPv6"
fi
}
# This function catches errors and handles them with the error handler function
catch_errors() {
set -Eeuo pipefail
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
}
# This function handles errors
error_handler() {
local exit_code="$?"
local line_number="$1"
local command="$2"
local error_message="${RD}[ERROR]${CL} in line ${RD}$line_number${CL}: exit code ${RD}$exit_code${CL}: while executing command ${YW}$command${CL}"
echo -e "\n$error_message\n"
}
# This function sets up the Container OS by generating the locale, setting the timezone, and checking the network connection
setting_up_container() {
msg_info "Setting up Container OS"
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 1
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}"
}
# 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
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 "Internet Connected"
else
msg_error "Internet NOT Connected"
read -r -p "Would you like to continue anyway? <y/N> " prompt
if [[ "${prompt,,}" =~ ^(y|yes)$ ]]; then
echo -e "${INFO}${RD}Expect Issues Without Internet${CL}"
else
echo -e "${NETWORK}Check Network Settings"
exit 1
fi
fi
RESOLVEDIP=$(getent hosts github.com | awk '{ print $1 }')
if [[ -z "$RESOLVEDIP" ]]; then msg_error "DNS Lookup Failure"; else msg_ok "DNS Resolved github.com to ${BL}$RESOLVEDIP${CL}"; fi
set -e
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
}
# This function updates the Container OS by running apt-get update and upgrade
update_os() {
msg_info "Updating Container OS"
$STD apk -U upgrade
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/tools.func)
msg_ok "Updated Container OS"
}
# This function modifies the message of the day (motd) and SSH settings
motd_ssh() {
echo "export TERM='xterm-256color'" >>/root/.bashrc
IP=$(ip -4 addr show eth0 | awk '/inet / {print $2}' | cut -d/ -f1 | head -n 1)
if [ -f "/etc/os-release" ]; then
OS_NAME=$(grep ^NAME /etc/os-release | cut -d= -f2 | tr -d '"')
OS_VERSION=$(grep ^VERSION_ID /etc/os-release | cut -d= -f2 | tr -d '"')
else
OS_NAME="Alpine Linux"
OS_VERSION="Unknown"
fi
PROFILE_FILE="/etc/profile.d/00_lxc-details.sh"
echo "echo -e \"\"" >"$PROFILE_FILE"
echo -e "echo -e \"${BOLD}${APPLICATION} LXC Container${CL}"\" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${GATEWAY}${YW} Provided by: ${GN}community-scripts ORG ${YW}| GitHub: ${GN}https://github.com/community-scripts/ProxmoxVE${CL}\"" >>"$PROFILE_FILE"
echo "echo \"\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${OS}${YW} OS: ${GN}${OS_NAME} - Version: ${OS_VERSION}${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${HOSTNAME}${YW} Hostname: ${GN}\$(hostname)${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${INFO}${YW} IP Address: ${GN}\$(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
}
# Validate Timezone for some LXC's
validate_tz() {
[[ -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
# 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
#!/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
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
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
}

View File

@@ -1,130 +0,0 @@
# Copyright (c) 2021-2026 community-scripts ORG
# Author: michelroegl-brunner
# License: MIT | https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/LICENSE
post_to_api() {
if ! command -v curl &>/dev/null; then
return
fi
if [ "$DIAGNOSTICS" = "no" ]; then
return
fi
if [ -z "$RANDOM_UUID" ]; then
return
fi
local API_URL="http://api.community-scripts.org/upload"
local pve_version="not found"
pve_version=$(pveversion | awk -F'[/ ]' '{print $2}')
JSON_PAYLOAD=$(
cat <<EOF
{
"ct_type": $CT_TYPE,
"type":"lxc",
"disk_size": $DISK_SIZE,
"core_count": $CORE_COUNT,
"ram_size": $RAM_SIZE,
"os_type": "$var_os",
"os_version": "$var_version",
"nsapp": "$NSAPP",
"method": "$METHOD",
"pve_version": "$pve_version",
"status": "installing",
"random_id": "$RANDOM_UUID"
}
EOF
)
if [[ "$DIAGNOSTICS" == "yes" ]]; then
RESPONSE=$(curl -s -w "%{http_code}" -L -X POST "$API_URL" --post301 --post302 \
-H "Content-Type: application/json" \
-d "$JSON_PAYLOAD") || true
fi
}
post_to_api_vm() {
if [[ ! -f /usr/local/community-scripts/diagnostics ]]; then
return
fi
DIAGNOSTICS=$(grep -i "^DIAGNOSTICS=" /usr/local/community-scripts/diagnostics | awk -F'=' '{print $2}')
if ! command -v curl &>/dev/null; then
return
fi
if [ "$DIAGNOSTICS" = "no" ]; then
return
fi
if [ -z "$RANDOM_UUID" ]; then
return
fi
local API_URL="http://api.community-scripts.org/upload"
local pve_version="not found"
pve_version=$(pveversion | awk -F'[/ ]' '{print $2}')
DISK_SIZE_API=${DISK_SIZE%G}
JSON_PAYLOAD=$(
cat <<EOF
{
"ct_type": 2,
"type":"vm",
"disk_size": $DISK_SIZE_API,
"core_count": $CORE_COUNT,
"ram_size": $RAM_SIZE,
"os_type": "$var_os",
"os_version": "$var_version",
"nsapp": "$NSAPP",
"method": "$METHOD",
"pve_version": "$pve_version",
"status": "installing",
"random_id": "$RANDOM_UUID"
}
EOF
)
if [[ "$DIAGNOSTICS" == "yes" ]]; then
RESPONSE=$(curl -s -w "%{http_code}" -L -X POST "$API_URL" --post301 --post302 \
-H "Content-Type: application/json" \
-d "$JSON_PAYLOAD") || true
fi
}
POST_UPDATE_DONE=false
post_update_to_api() {
if ! command -v curl &>/dev/null; then
return
fi
if [ "$POST_UPDATE_DONE" = true ]; then
return 0
fi
local API_URL="http://api.community-scripts.org/upload/updatestatus"
local status="${1:-failed}"
local error="${2:-No error message}"
JSON_PAYLOAD=$(
cat <<EOF
{
"status": "$status",
"error": "$error",
"random_id": "$RANDOM_UUID"
}
EOF
)
if [[ "$DIAGNOSTICS" == "yes" ]]; then
RESPONSE=$(curl -s -w "%{http_code}" -L -X POST "$API_URL" --post301 --post302 \
-H "Content-Type: application/json" \
-d "$JSON_PAYLOAD") || true
fi
POST_UPDATE_DONE=true
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,699 +0,0 @@
config_file() {
CONFIG_FILE="/opt/community-scripts/.settings"
if [[ -f "/opt/community-scripts/${NSAPP}.conf" ]]; then
CONFIG_FILE="/opt/community-scripts/${NSAPP}.conf"
fi
if CONFIG_FILE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set absolute path to config file" 8 58 "$CONFIG_FILE" --title "CONFIG FILE" 3>&1 1>&2 2>&3); then
if [[ ! -f "$CONFIG_FILE" ]]; then
echo -e "${CROSS}${RD}Config file not found, exiting script!.${CL}"
exit
else
echo -e "${INFO}${BOLD}${DGN}Using config File: ${BGN}$CONFIG_FILE${CL}"
source "$CONFIG_FILE"
fi
fi
if [[ -n "${CT_ID-}" ]]; then
if [[ "$CT_ID" =~ ^([0-9]{3,4})-([0-9]{3,4})$ ]]; then
MIN_ID=${BASH_REMATCH[1]}
MAX_ID=${BASH_REMATCH[2]}
if ((MIN_ID >= MAX_ID)); then
msg_error "Invalid Container ID range. The first number must be smaller than the second number, was ${CT_ID}"
exit
fi
LIST_OF_IDS=$(pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | grep -oP '"vmid":\s*\K\d+') || true
if [[ -n "$LIST_OF_IDS" ]]; then
for ((ID = MIN_ID; ID <= MAX_ID; ID++)); do
if ! grep -q "^$ID$" <<<"$LIST_OF_IDS"; then
CT_ID=$ID
break
fi
done
fi
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
elif [[ "$CT_ID" =~ ^[0-9]+$ ]]; then
LIST_OF_IDS=$(pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | grep -oP '"vmid":\s*\K\d+') || true
if [[ -n "$LIST_OF_IDS" ]]; then
if ! grep -q "^$CT_ID$" <<<"$LIST_OF_IDS"; then
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
else
msg_error "Container ID $CT_ID already exists"
exit
fi
else
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
fi
else
msg_error "Invalid Container ID format. Needs to be 0000-9999 or 0-9999, was ${CT_ID}"
exit
fi
else
if CT_ID=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Container ID" 8 58 "$NEXTID" --title "CONTAINER ID" 3>&1 1>&2 2>&3); then
if [ -z "$CT_ID" ]; then
CT_ID="$NEXTID"
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
else
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
fi
else
exit_script
fi
fi
if [[ -n "${CT_TYPE-}" ]]; then
if [[ "$CT_TYPE" -eq 0 ]]; then
CT_TYPE_DESC="Privileged"
elif [[ "$CT_TYPE" -eq 1 ]]; then
CT_TYPE_DESC="Unprivileged"
else
msg_error "Unknown setting for CT_TYPE, should be 1 or 0, was ${CT_TYPE}"
exit
fi
echo -e "${CONTAINERTYPE}${BOLD}${DGN}Container Type: ${BGN}$CT_TYPE_DESC${CL}"
else
if CT_TYPE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --title "CONTAINER TYPE" --radiolist "Choose Type" 10 58 2 \
"1" "Unprivileged" ON \
"0" "Privileged" OFF \
3>&1 1>&2 2>&3); then
if [ -n "$CT_TYPE" ]; then
CT_TYPE_DESC="Unprivileged"
if [ "$CT_TYPE" -eq 0 ]; then
CT_TYPE_DESC="Privileged"
fi
echo -e "${CONTAINERTYPE}${BOLD}${DGN}Container Type: ${BGN}$CT_TYPE_DESC${CL}"
fi
else
exit_script
fi
fi
if [[ -n "${PW-}" ]]; then
if [[ "$PW" == "none" ]]; then
PW=""
else
if [[ "$PW" == *" "* ]]; then
msg_error "Password cannot be empty"
exit
elif [[ ${#PW} -lt 5 ]]; then
msg_error "Password must be at least 5 characters long"
exit
else
echo -e "${VERIFYPW}${BOLD}${DGN}Root Password: ${BGN}********${CL}"
fi
PW="-password $PW"
fi
else
while true; do
if PW1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --passwordbox "\nSet Root Password (needed for root ssh access)" 9 58 --title "PASSWORD (leave blank for automatic login)" 3>&1 1>&2 2>&3); then
if [[ -n "$PW1" ]]; then
if [[ "$PW1" == *" "* ]]; then
whiptail --msgbox "Password cannot contain spaces. Please try again." 8 58
elif [ ${#PW1} -lt 5 ]; then
whiptail --msgbox "Password must be at least 5 characters long. Please try again." 8 58
else
if PW2=$(whiptail --backtitle "Proxmox VE Helper Scripts" --passwordbox "\nVerify Root Password" 9 58 --title "PASSWORD VERIFICATION" 3>&1 1>&2 2>&3); then
if [[ "$PW1" == "$PW2" ]]; then
PW="-password $PW1"
echo -e "${VERIFYPW}${BOLD}${DGN}Root Password: ${BGN}********${CL}"
break
else
whiptail --msgbox "Passwords do not match. Please try again." 8 58
fi
else
exit_script
fi
fi
else
PW1="Automatic Login"
PW=""
echo -e "${VERIFYPW}${BOLD}${DGN}Root Password: ${BGN}$PW1${CL}"
break
fi
else
exit_script
fi
done
fi
if [[ -n "${HN-}" ]]; then
echo -e "${HOSTNAME}${BOLD}${DGN}Hostname: ${BGN}$HN${CL}"
else
if CT_NAME=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Hostname" 8 58 "$NSAPP" --title "HOSTNAME" 3>&1 1>&2 2>&3); then
if [ -z "$CT_NAME" ]; then
HN="$NSAPP"
else
HN=$(echo "${CT_NAME,,}" | tr -d ' ')
fi
echo -e "${HOSTNAME}${BOLD}${DGN}Hostname: ${BGN}$HN${CL}"
else
exit_script
fi
fi
if [[ -n "${DISK_SIZE-}" ]]; then
if [[ "$DISK_SIZE" =~ ^-?[0-9]+$ ]]; then
echo -e "${DISKSIZE}${BOLD}${DGN}Disk Size: ${BGN}${DISK_SIZE} GB${CL}"
else
msg_error "DISK_SIZE must be an integer, was ${DISK_SIZE}"
exit
fi
else
if DISK_SIZE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Disk Size in GB" 8 58 "$var_disk" --title "DISK SIZE" 3>&1 1>&2 2>&3); then
if [ -z "$DISK_SIZE" ]; then
DISK_SIZE="$var_disk"
echo -e "${DISKSIZE}${BOLD}${DGN}Disk Size: ${BGN}${DISK_SIZE} GB${CL}"
else
if ! [[ $DISK_SIZE =~ $INTEGER ]]; then
echo -e "{INFO}${HOLD}${RD} DISK SIZE MUST BE AN INTEGER NUMBER!${CL}"
advanced_settings
fi
echo -e "${DISKSIZE}${BOLD}${DGN}Disk Size: ${BGN}${DISK_SIZE} GB${CL}"
fi
else
exit_script
fi
fi
if [[ -n "${CORE_COUNT-}" ]]; then
if [[ "$CORE_COUNT" =~ ^-?[0-9]+$ ]]; then
echo -e "${CPUCORE}${BOLD}${DGN}CPU Cores: ${BGN}${CORE_COUNT}${CL}"
else
msg_error "CORE_COUNT must be an integer, was ${CORE_COUNT}"
exit
fi
else
if CORE_COUNT=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Allocate CPU Cores" 8 58 "$var_cpu" --title "CORE COUNT" 3>&1 1>&2 2>&3); then
if [ -z "$CORE_COUNT" ]; then
CORE_COUNT="$var_cpu"
echo -e "${CPUCORE}${BOLD}${DGN}CPU Cores: ${BGN}$CORE_COUNT${CL}"
else
echo -e "${CPUCORE}${BOLD}${DGN}CPU Cores: ${BGN}$CORE_COUNT${CL}"
fi
else
exit_script
fi
fi
if [[ -n "${RAM_SIZE-}" ]]; then
if [[ "$RAM_SIZE" =~ ^-?[0-9]+$ ]]; then
echo -e "${RAMSIZE}${BOLD}${DGN}RAM Size: ${BGN}${RAM_SIZE} MiB${CL}"
else
msg_error "RAM_SIZE must be an integer, was ${RAM_SIZE}"
exit
fi
else
if RAM_SIZE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Allocate RAM in MiB" 8 58 "$var_ram" --title "RAM" 3>&1 1>&2 2>&3); then
if [ -z "$RAM_SIZE" ]; then
RAM_SIZE="$var_ram"
echo -e "${RAMSIZE}${BOLD}${DGN}RAM Size: ${BGN}${RAM_SIZE} MiB${CL}"
else
echo -e "${RAMSIZE}${BOLD}${DGN}RAM Size: ${BGN}${RAM_SIZE} MiB${CL}"
fi
else
exit_script
fi
fi
IFACE_FILEPATH_LIST="/etc/network/interfaces"$'\n'$(find "/etc/network/interfaces.d/" -type f)
BRIDGES=""
OLD_IFS=$IFS
IFS=$'\n'
for iface_filepath in ${IFACE_FILEPATH_LIST}; do
iface_indexes_tmpfile=$(mktemp -q -u '.iface-XXXX')
(grep -Pn '^\s*iface' "${iface_filepath}" | cut -d':' -f1 && wc -l "${iface_filepath}" | cut -d' ' -f1) | awk 'FNR==1 {line=$0; next} {print line":"$0-1; line=$0}' >"${iface_indexes_tmpfile}" || true
if [ -f "${iface_indexes_tmpfile}" ]; then
while read -r pair; do
start=$(echo "${pair}" | cut -d':' -f1)
end=$(echo "${pair}" | cut -d':' -f2)
if awk "NR >= ${start} && NR <= ${end}" "${iface_filepath}" | grep -qP '^\s*(bridge[-_](ports|stp|fd|vlan-aware|vids)|ovs_type\s+OVSBridge)\b'; then
iface_name=$(sed "${start}q;d" "${iface_filepath}" | awk '{print $2}')
BRIDGES="${iface_name}"$'\n'"${BRIDGES}"
fi
done <"${iface_indexes_tmpfile}"
rm -f "${iface_indexes_tmpfile}"
fi
done
IFS=$OLD_IFS
BRIDGES=$(echo "$BRIDGES" | grep -v '^\s*$' | sort | uniq)
if [[ -n "${BRG-}" ]]; then
if echo "$BRIDGES" | grep -q "${BRG}"; then
echo -e "${BRIDGE}${BOLD}${DGN}Bridge: ${BGN}$BRG${CL}"
else
msg_error "Bridge '${BRG}' does not exist in /etc/network/interfaces or /etc/network/interfaces.d/sdn"
exit
fi
else
BRG=$(whiptail --backtitle "Proxmox VE Helper Scripts" --menu "Select network bridge:" 15 40 6 $(echo "$BRIDGES" | awk '{print $0, "Bridge"}') 3>&1 1>&2 2>&3)
if [ -z "$BRG" ]; then
exit_script
else
echo -e "${BRIDGE}${BOLD}${DGN}Bridge: ${BGN}$BRG${CL}"
fi
fi
local ip_cidr_regex='^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/([0-9]{1,2})$'
local ip_regex='^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$'
if [[ -n ${NET-} ]]; then
if [ "$NET" == "dhcp" ]; then
echo -e "${NETWORK}${BOLD}${DGN}IP Address: ${BGN}DHCP${CL}"
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}Default${CL}"
GATE=""
elif [[ "$NET" =~ $ip_cidr_regex ]]; then
echo -e "${NETWORK}${BOLD}${DGN}IP Address: ${BGN}$NET${CL}"
if [[ -n "$GATE" ]]; then
[[ "$GATE" =~ ",gw=" ]] && GATE="${GATE##,gw=}"
if [[ "$GATE" =~ $ip_regex ]]; then
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}$GATE${CL}"
GATE=",gw=$GATE"
else
msg_error "Invalid IP Address format for Gateway. Needs to be 0.0.0.0, was ${GATE}"
exit
fi
else
while true; do
GATE1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Enter gateway IP address" 8 58 --title "Gateway IP" 3>&1 1>&2 2>&3)
if [ -z "$GATE1" ]; then
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "Gateway IP address cannot be empty" 8 58
elif [[ ! "$GATE1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "Invalid IP address format" 8 58
else
GATE=",gw=$GATE1"
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}$GATE1${CL}"
break
fi
done
fi
elif [[ "$NET" == *-* ]]; then
IFS="-" read -r ip_start ip_end <<<"$NET"
if [[ ! "$ip_start" =~ $ip_cidr_regex ]] || [[ ! "$ip_end" =~ $ip_cidr_regex ]]; then
msg_error "Invalid IP range format, was $NET should be 0.0.0.0/0-0.0.0.0/0"
exit 1
fi
ip1="${ip_start%%/*}"
ip2="${ip_end%%/*}"
cidr="${ip_start##*/}"
ip_to_int() {
local IFS=.
read -r i1 i2 i3 i4 <<<"$1"
echo $(((i1 << 24) + (i2 << 16) + (i3 << 8) + i4))
}
int_to_ip() {
local ip=$1
echo "$(((ip >> 24) & 0xFF)).$(((ip >> 16) & 0xFF)).$(((ip >> 8) & 0xFF)).$((ip & 0xFF))"
}
start_int=$(ip_to_int "$ip1")
end_int=$(ip_to_int "$ip2")
for ((ip_int = start_int; ip_int <= end_int; ip_int++)); do
ip=$(int_to_ip $ip_int)
msg_info "Checking IP: $ip"
if ! ping -c 2 -W 1 "$ip" >/dev/null 2>&1; then
NET="$ip/$cidr"
msg_ok "Using free IP Address: ${BGN}$NET${CL}"
sleep 3
break
fi
done
if [[ "$NET" == *-* ]]; then
msg_error "No free IP found in range"
exit 1
fi
if [ -n "$GATE" ]; then
if [[ "$GATE" =~ $ip_regex ]]; then
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}$GATE${CL}"
GATE=",gw=$GATE"
else
msg_error "Invalid IP Address format for Gateway. Needs to be 0.0.0.0, was ${GATE}"
exit
fi
else
while true; do
GATE1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Enter gateway IP address" 8 58 --title "Gateway IP" 3>&1 1>&2 2>&3)
if [ -z "$GATE1" ]; then
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "Gateway IP address cannot be empty" 8 58
elif [[ ! "$GATE1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "Invalid IP address format" 8 58
else
GATE=",gw=$GATE1"
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}$GATE1${CL}"
break
fi
done
fi
else
msg_error "Invalid IP Address format. Needs to be 0.0.0.0/0 or a range like 10.0.0.1/24-10.0.0.10/24, was ${NET}"
exit
fi
else
while true; do
NET=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a Static IPv4 CIDR Address (/24)" 8 58 dhcp --title "IP ADDRESS" 3>&1 1>&2 2>&3)
exit_status=$?
if [ $exit_status -eq 0 ]; then
if [ "$NET" = "dhcp" ]; then
echo -e "${NETWORK}${BOLD}${DGN}IP Address: ${BGN}$NET${CL}"
break
else
if [[ "$NET" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$ ]]; then
echo -e "${NETWORK}${BOLD}${DGN}IP Address: ${BGN}$NET${CL}"
break
else
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "$NET is an invalid IPv4 CIDR address. Please enter a valid IPv4 CIDR address or 'dhcp'" 8 58
fi
fi
else
exit_script
fi
done
if [ "$NET" != "dhcp" ]; then
while true; do
GATE1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Enter gateway IP address" 8 58 --title "Gateway IP" 3>&1 1>&2 2>&3)
if [ -z "$GATE1" ]; then
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "Gateway IP address cannot be empty" 8 58
elif [[ ! "$GATE1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox "Invalid IP address format" 8 58
else
GATE=",gw=$GATE1"
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}$GATE1${CL}"
break
fi
done
else
GATE=""
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}Default${CL}"
fi
fi
if [ "$var_os" == "alpine" ]; then
APT_CACHER=""
APT_CACHER_IP=""
else
if [[ -n "${APT_CACHER_IP-}" ]]; then
if [[ ! $APT_CACHER_IP == "none" ]]; then
APT_CACHER="yes"
echo -e "${NETWORK}${BOLD}${DGN}APT-CACHER IP Address: ${BGN}$APT_CACHER_IP${CL}"
else
APT_CACHER=""
echo -e "${NETWORK}${BOLD}${DGN}APT-Cacher IP Address: ${BGN}No${CL}"
fi
else
if APT_CACHER_IP=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set APT-Cacher IP (leave blank for none)" 8 58 --title "APT-Cacher IP" 3>&1 1>&2 2>&3); then
APT_CACHER="${APT_CACHER_IP:+yes}"
echo -e "${NETWORK}${BOLD}${DGN}APT-Cacher IP Address: ${BGN}${APT_CACHER_IP:-Default}${CL}"
if [[ -n $APT_CACHER_IP ]]; then
APT_CACHER_IP="none"
fi
else
exit_script
fi
fi
fi
if [[ -n "${MTU-}" ]]; then
if [[ "$MTU" =~ ^-?[0-9]+$ ]]; then
echo -e "${DEFAULT}${BOLD}${DGN}Interface MTU Size: ${BGN}$MTU${CL}"
MTU=",mtu=$MTU"
else
msg_error "MTU must be an integer, was ${MTU}"
exit
fi
else
if MTU1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Interface MTU Size (leave blank for default [The MTU of your selected vmbr, default is 1500])" 8 58 --title "MTU SIZE" 3>&1 1>&2 2>&3); then
if [ -z "$MTU1" ]; then
MTU1="Default"
MTU=""
else
MTU=",mtu=$MTU1"
fi
echo -e "${DEFAULT}${BOLD}${DGN}Interface MTU Size: ${BGN}$MTU1${CL}"
else
exit_script
fi
fi
if [[ "$IPV6_METHOD" == "static" ]]; then
if [[ -n "$IPV6STATIC" ]]; then
IP6=",ip6=${IPV6STATIC}"
echo -e "${NETWORK}${BOLD}${DGN}IPv6 Address: ${BGN}${IPV6STATIC}${CL}"
else
msg_error "IPV6_METHOD is set to static but IPV6STATIC is empty"
exit
fi
elif [[ "$IPV6_METHOD" == "auto" ]]; then
IP6=",ip6=auto"
echo -e "${NETWORK}${BOLD}${DGN}IPv6 Address: ${BGN}auto${CL}"
else
IP6=""
echo -e "${NETWORK}${BOLD}${DGN}IPv6 Address: ${BGN}none${CL}"
fi
if [[ -n "${SD-}" ]]; then
if [[ "$SD" == "none" ]]; then
SD=""
echo -e "${SEARCH}${BOLD}${DGN}DNS Search Domain: ${BGN}Host${CL}"
else
# Strip prefix if present for config file storage
local SD_VALUE="$SD"
[[ "$SD" =~ ^-searchdomain= ]] && SD_VALUE="${SD#-searchdomain=}"
echo -e "${SEARCH}${BOLD}${DGN}DNS Search Domain: ${BGN}$SD_VALUE${CL}"
SD="-searchdomain=$SD_VALUE"
fi
else
if SD=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a DNS Search Domain (leave blank for HOST)" 8 58 --title "DNS Search Domain" 3>&1 1>&2 2>&3); then
if [ -z "$SD" ]; then
SX=Host
SD=""
else
SX=$SD
SD="-searchdomain=$SD"
fi
echo -e "${SEARCH}${BOLD}${DGN}DNS Search Domain: ${BGN}$SX${CL}"
else
exit_script
fi
fi
if [[ -n "${NS-}" ]]; then
if [[ $NS == "none" ]]; then
NS=""
echo -e "${NETWORK}${BOLD}${DGN}DNS Server IP Address: ${BGN}Host${CL}"
else
# Strip prefix if present for config file storage
local NS_VALUE="$NS"
[[ "$NS" =~ ^-nameserver= ]] && NS_VALUE="${NS#-nameserver=}"
if [[ "$NS_VALUE" =~ $ip_regex ]]; then
echo -e "${NETWORK}${BOLD}${DGN}DNS Server IP Address: ${BGN}$NS_VALUE${CL}"
NS="-nameserver=$NS_VALUE"
else
msg_error "Invalid IP Address format for DNS Server. Needs to be 0.0.0.0, was ${NS_VALUE}"
exit
fi
fi
else
if NX=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a DNS Server IP (leave blank for HOST)" 8 58 --title "DNS SERVER IP" 3>&1 1>&2 2>&3); then
if [ -z "$NX" ]; then
NX=Host
NS=""
else
NS="-nameserver=$NX"
fi
echo -e "${NETWORK}${BOLD}${DGN}DNS Server IP Address: ${BGN}$NX${CL}"
else
exit_script
fi
fi
if [[ -n "${MAC-}" ]]; then
if [[ "$MAC" == "none" ]]; then
MAC=""
echo -e "${MACADDRESS}${BOLD}${DGN}MAC Address: ${BGN}Host${CL}"
else
# Strip prefix if present for config file storage
local MAC_VALUE="$MAC"
[[ "$MAC" =~ ^,hwaddr= ]] && MAC_VALUE="${MAC#,hwaddr=}"
if [[ "$MAC_VALUE" =~ ^([A-Fa-f0-9]{2}:){5}[A-Fa-f0-9]{2}$ ]]; then
echo -e "${MACADDRESS}${BOLD}${DGN}MAC Address: ${BGN}$MAC_VALUE${CL}"
MAC=",hwaddr=$MAC_VALUE"
else
msg_error "MAC Address must be in the format xx:xx:xx:xx:xx:xx, was ${MAC_VALUE}"
exit
fi
fi
else
if MAC1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a MAC Address(leave blank for generated MAC)" 8 58 --title "MAC ADDRESS" 3>&1 1>&2 2>&3); then
if [ -z "$MAC1" ]; then
MAC1="Default"
MAC=""
else
MAC=",hwaddr=$MAC1"
echo -e "${MACADDRESS}${BOLD}${DGN}MAC Address: ${BGN}$MAC1${CL}"
fi
else
exit_script
fi
fi
if [[ -n "${VLAN-}" ]]; then
if [[ "$VLAN" == "none" ]]; then
VLAN=""
echo -e "${VLANTAG}${BOLD}${DGN}Vlan: ${BGN}Host${CL}"
else
# Strip prefix if present for config file storage
local VLAN_VALUE="$VLAN"
[[ "$VLAN" =~ ^,tag= ]] && VLAN_VALUE="${VLAN#,tag=}"
if [[ "$VLAN_VALUE" =~ ^-?[0-9]+$ ]]; then
echo -e "${VLANTAG}${BOLD}${DGN}Vlan: ${BGN}$VLAN_VALUE${CL}"
VLAN=",tag=$VLAN_VALUE"
else
msg_error "VLAN must be an integer, was ${VLAN_VALUE}"
exit
fi
fi
else
if VLAN1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a Vlan(leave blank for no VLAN)" 8 58 --title "VLAN" 3>&1 1>&2 2>&3); then
if [ -z "$VLAN1" ]; then
VLAN1="Default"
VLAN=""
else
VLAN=",tag=$VLAN1"
fi
echo -e "${VLANTAG}${BOLD}${DGN}Vlan: ${BGN}$VLAN1${CL}"
else
exit_script
fi
fi
if [[ -n "${TAGS-}" ]]; then
if [[ "$TAGS" == *"DEFAULT"* ]]; then
TAGS="${TAGS//DEFAULT/}"
TAGS="${TAGS//;/}"
TAGS="$TAGS;${var_tags:-}"
echo -e "${NETWORK}${BOLD}${DGN}Tags: ${BGN}$TAGS${CL}"
fi
else
TAGS="community-scripts;"
if ADV_TAGS=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Custom Tags?[If you remove all, there will be no tags!]" 8 58 "${TAGS}" --title "Advanced Tags" 3>&1 1>&2 2>&3); then
if [ -n "${ADV_TAGS}" ]; then
ADV_TAGS=$(echo "$ADV_TAGS" | tr -d '[:space:]')
TAGS="${ADV_TAGS}"
else
TAGS=";"
fi
echo -e "${NETWORK}${BOLD}${DGN}Tags: ${BGN}$TAGS${CL}"
else
exit_script
fi
fi
if [[ -n "${SSH-}" ]]; then
if [[ "$SSH" == "yes" ]]; then
echo -e "${ROOTSSH}${BOLD}${DGN}Root SSH Access: ${BGN}$SSH${CL}"
if [[ ! -z "$SSH_AUTHORIZED_KEY" ]]; then
echo -e "${ROOTSSH}${BOLD}${DGN}SSH Authorized Key: ${BGN}********************${CL}"
else
echo -e "${ROOTSSH}${BOLD}${DGN}SSH Authorized Key: ${BGN}None${CL}"
fi
elif [[ "$SSH" == "no" ]]; then
echo -e "${ROOTSSH}${BOLD}${DGN}Root SSH Access: ${BGN}$SSH${CL}"
else
msg_error "SSH needs to be 'yes' or 'no', was ${SSH}"
exit
fi
else
SSH_AUTHORIZED_KEY="$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "SSH Authorized key for root (leave empty for none)" 8 58 --title "SSH Key" 3>&1 1>&2 2>&3)"
if [[ -z "${SSH_AUTHORIZED_KEY}" ]]; then
SSH_AUTHORIZED_KEY=""
fi
if [[ "$PW" == -password* || -n "$SSH_AUTHORIZED_KEY" ]]; then
if (whiptail --backtitle "Proxmox VE Helper Scripts" --defaultno --title "SSH ACCESS" --yesno "Enable Root SSH Access?" 10 58); then
SSH="yes"
else
SSH="no"
fi
echo -e "${ROOTSSH}${BOLD}${DGN}Root SSH Access: ${BGN}$SSH${CL}"
else
SSH="no"
echo -e "${ROOTSSH}${BOLD}${DGN}Root SSH Access: ${BGN}$SSH${CL}"
fi
fi
if [[ -n "$ENABLE_FUSE" ]]; then
if [[ "$ENABLE_FUSE" == "yes" ]]; then
echo -e "${FUSE}${BOLD}${DGN}Enable FUSE: ${BGN}Yes${CL}"
elif [[ "$ENABLE_FUSE" == "no" ]]; then
echo -e "${FUSE}${BOLD}${DGN}Enable FUSE: ${BGN}No${CL}"
else
msg_error "Enable FUSE needs to be 'yes' or 'no', was ${ENABLE_FUSE}"
exit
fi
else
if (whiptail --backtitle "Proxmox VE Helper Scripts" --defaultno --title "FUSE" --yesno "Enable FUSE?" 10 58); then
ENABLE_FUSE="yes"
else
ENABLE_FUSE="no"
fi
echo -e "${FUSE}${BOLD}${DGN}Enable FUSE: ${BGN}$ENABLE_FUSE${CL}"
fi
if [[ -n "$ENABLE_TUN" ]]; then
if [[ "$ENABLE_TUN" == "yes" ]]; then
echo -e "${FUSE}${BOLD}${DGN}Enable TUN: ${BGN}Yes${CL}"
elif [[ "$ENABLE_TUN" == "no" ]]; then
echo -e "${FUSE}${BOLD}${DGN}Enable TUN: ${BGN}No${CL}"
else
msg_error "Enable TUN needs to be 'yes' or 'no', was ${ENABLE_TUN}"
exit
fi
else
if (whiptail --backtitle "Proxmox VE Helper Scripts" --defaultno --title "TUN" --yesno "Enable TUN?" 10 58); then
ENABLE_TUN="yes"
else
ENABLE_TUN="no"
fi
echo -e "${FUSE}${BOLD}${DGN}Enable TUN: ${BGN}$ENABLE_TUN${CL}"
fi
if [[ -n "${VERBOSE-}" ]]; then
if [[ "$VERBOSE" == "yes" ]]; then
echo -e "${SEARCH}${BOLD}${DGN}Verbose Mode: ${BGN}$VERBOSE${CL}"
elif [[ "$VERBOSE" == "no" ]]; then
echo -e "${SEARCH}${BOLD}${DGN}Verbose Mode: ${BGN}No${CL}"
else
msg_error "Verbose Mode needs to be 'yes' or 'no', was ${VERBOSE}"
exit
fi
else
if (whiptail --backtitle "Proxmox VE Helper Scripts" --defaultno --title "VERBOSE MODE" --yesno "Enable Verbose Mode?" 10 58); then
VERBOSE="yes"
else
VERBOSE="no"
fi
echo -e "${SEARCH}${BOLD}${DGN}Verbose Mode: ${BGN}$VERBOSE${CL}"
fi
if (whiptail --backtitle "Proxmox VE Helper Scripts" --title "ADVANCED SETTINGS WITH CONFIG FILE COMPLETE" --yesno "Ready to create ${APP} LXC?" 10 58); then
echo -e "${CREATING}${BOLD}${RD}Creating a ${APP} LXC using the above settings${CL}"
else
clear
header_info
echo -e "${INFO}${HOLD} ${GN}Using Config File on node $PVEHOST_NAME${CL}"
config_file
fi
}

View File

@@ -1,452 +0,0 @@
# Copyright (c) 2021-2026 community-scripts ORG
# License: MIT | https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/LICENSE
# ------------------------------------------------------------------------------
# Loads core utility groups once (colors, formatting, icons, defaults).
# ------------------------------------------------------------------------------
[[ -n "${_CORE_FUNC_LOADED:-}" ]] && return
_CORE_FUNC_LOADED=1
load_functions() {
[[ -n "${__FUNCTIONS_LOADED:-}" ]] && return
__FUNCTIONS_LOADED=1
color
formatting
icons
default_vars
set_std_mode
# add more
}
# ============================================================================
# Error & Signal Handling robust, universal, subshell-safe
# ============================================================================
_tool_error_hint() {
local cmd="$1"
local code="$2"
case "$cmd" in
curl)
case "$code" in
6) echo "Curl: Could not resolve host (DNS problem)" ;;
7) echo "Curl: Failed to connect to host (connection refused)" ;;
22) echo "Curl: HTTP error (404/403 etc)" ;;
28) echo "Curl: Operation timeout" ;;
*) echo "Curl: Unknown error ($code)" ;;
esac
;;
wget)
echo "Wget failed URL unreachable or permission denied"
;;
systemctl)
echo "Systemd unit failure check service name and permissions"
;;
jq)
echo "jq parse error malformed JSON or missing key"
;;
mariadb | mysql)
echo "MySQL/MariaDB command failed check credentials or DB"
;;
unzip)
echo "unzip failed corrupt file or missing permission"
;;
tar)
echo "tar failed invalid format or missing binary"
;;
node | npm | pnpm | yarn)
echo "Node tool failed check version compatibility or package.json"
;;
*) echo "" ;;
esac
}
catch_errors() {
set -Eeuo pipefail
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
}
# ------------------------------------------------------------------------------
# Sets ANSI color codes used for styled terminal output.
# ------------------------------------------------------------------------------
color() {
YW=$(echo "\033[33m")
YWB=$'\e[93m'
BL=$(echo "\033[36m")
RD=$(echo "\033[01;31m")
BGN=$(echo "\033[4;92m")
GN=$(echo "\033[1;92m")
DGN=$(echo "\033[32m")
CL=$(echo "\033[m")
}
# Special for spinner and colorized output via printf
color_spinner() {
CS_YW=$'\033[33m'
CS_YWB=$'\033[93m'
CS_CL=$'\033[m'
}
# ------------------------------------------------------------------------------
# Defines formatting helpers like tab, bold, and line reset sequences.
# ------------------------------------------------------------------------------
formatting() {
BFR="\\r\\033[K"
BOLD=$(echo "\033[1m")
HOLD=" "
TAB=" "
TAB3=" "
}
# ------------------------------------------------------------------------------
# Sets symbolic icons used throughout user feedback and prompts.
# ------------------------------------------------------------------------------
icons() {
CM="${TAB}✔️${TAB}"
CROSS="${TAB}✖️${TAB}"
DNSOK="✔️ "
DNSFAIL="${TAB}✖️${TAB}"
INFO="${TAB}💡${TAB}${CL}"
OS="${TAB}🖥️${TAB}${CL}"
OSVERSION="${TAB}🌟${TAB}${CL}"
CONTAINERTYPE="${TAB}📦${TAB}${CL}"
DISKSIZE="${TAB}💾${TAB}${CL}"
CPUCORE="${TAB}🧠${TAB}${CL}"
RAMSIZE="${TAB}🛠️${TAB}${CL}"
SEARCH="${TAB}🔍${TAB}${CL}"
VERBOSE_CROPPED="🔍${TAB}"
VERIFYPW="${TAB}🔐${TAB}${CL}"
CONTAINERID="${TAB}🆔${TAB}${CL}"
HOSTNAME="${TAB}🏠${TAB}${CL}"
BRIDGE="${TAB}🌉${TAB}${CL}"
NETWORK="${TAB}📡${TAB}${CL}"
GATEWAY="${TAB}🌐${TAB}${CL}"
DISABLEIPV6="${TAB}🚫${TAB}${CL}"
DEFAULT="${TAB}⚙️${TAB}${CL}"
MACADDRESS="${TAB}🔗${TAB}${CL}"
VLANTAG="${TAB}🏷️${TAB}${CL}"
ROOTSSH="${TAB}🔑${TAB}${CL}"
CREATING="${TAB}🚀${TAB}${CL}"
ADVANCED="${TAB}🧩${TAB}${CL}"
FUSE="${TAB}🗂️${TAB}${CL}"
HOURGLASS="${TAB}${TAB}"
}
# ------------------------------------------------------------------------------
# Sets default retry and wait variables used for system actions.
# ------------------------------------------------------------------------------
default_vars() {
RETRY_NUM=10
RETRY_EVERY=3
i=$RETRY_NUM
#[[ "${VAR_OS:-}" == "unknown" ]]
}
# ------------------------------------------------------------------------------
# Sets default verbose mode for script and os execution.
# ------------------------------------------------------------------------------
set_std_mode() {
if [ "${VERBOSE:-no}" = "yes" ]; then
STD=""
else
STD="silent"
fi
}
# Silent execution function
silent() {
"$@" >/dev/null 2>&1
}
# Function to download & save header files
get_header() {
local app_name=$(echo "${APP,,}" | tr -d ' ')
local app_type=${APP_TYPE:-ct}
local header_url="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/${app_type}/headers/${app_name}"
local local_header_path="/usr/local/community-scripts/headers/${app_type}/${app_name}"
mkdir -p "$(dirname "$local_header_path")"
if [ ! -s "$local_header_path" ]; then
if ! curl -fsSL "$header_url" -o "$local_header_path"; then
return 1
fi
fi
cat "$local_header_path" 2>/dev/null || true
}
header_info() {
local app_name=$(echo "${APP,,}" | tr -d ' ')
local header_content
header_content=$(get_header "$app_name") || header_content=""
clear
local term_width
term_width=$(tput cols 2>/dev/null || echo 120)
if [ -n "$header_content" ]; then
echo "$header_content"
fi
}
ensure_tput() {
if ! command -v tput >/dev/null 2>&1; then
if grep -qi 'alpine' /etc/os-release; then
apk add --no-cache ncurses >/dev/null 2>&1
elif command -v apt-get >/dev/null 2>&1; then
apt-get update -qq >/dev/null
apt-get install -y -qq ncurses-bin >/dev/null 2>&1
fi
fi
}
is_alpine() {
local os_id="${var_os:-${PCT_OSTYPE:-}}"
if [[ -z "$os_id" && -f /etc/os-release ]]; then
os_id="$(
. /etc/os-release 2>/dev/null
echo "${ID:-}"
)"
fi
[[ "$os_id" == "alpine" ]]
}
is_verbose_mode() {
local verbose="${VERBOSE:-${var_verbose:-no}}"
local tty_status
if [[ -t 2 ]]; then
tty_status="interactive"
else
tty_status="not-a-tty"
fi
[[ "$verbose" != "no" || ! -t 2 ]]
}
# ------------------------------------------------------------------------------
# Handles specific curl error codes and displays descriptive messages.
# ------------------------------------------------------------------------------
__curl_err_handler() {
local exit_code="$1"
local target="$2"
local curl_msg="$3"
case $exit_code in
1) msg_error "Unsupported protocol: $target" ;;
2) msg_error "Curl init failed: $target" ;;
3) msg_error "Malformed URL: $target" ;;
5) msg_error "Proxy resolution failed: $target" ;;
6) msg_error "Host resolution failed: $target" ;;
7) msg_error "Connection failed: $target" ;;
9) msg_error "Access denied: $target" ;;
18) msg_error "Partial file transfer: $target" ;;
22) msg_error "HTTP error (e.g. 400/404): $target" ;;
23) msg_error "Write error on local system: $target" ;;
26) msg_error "Read error from local file: $target" ;;
28) msg_error "Timeout: $target" ;;
35) msg_error "SSL connect error: $target" ;;
47) msg_error "Too many redirects: $target" ;;
51) msg_error "SSL cert verify failed: $target" ;;
52) msg_error "Empty server response: $target" ;;
55) msg_error "Send error: $target" ;;
56) msg_error "Receive error: $target" ;;
60) msg_error "SSL CA not trusted: $target" ;;
67) msg_error "Login denied by server: $target" ;;
78) msg_error "Remote file not found (404): $target" ;;
*) msg_error "Curl failed with code $exit_code: $target" ;;
esac
[[ -n "$curl_msg" ]] && printf "%s\n" "$curl_msg" >&2
exit 1
}
fatal() {
msg_error "$1"
kill -INT $$
}
spinner() {
local chars=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏)
local i=0
while true; do
local index=$((i++ % ${#chars[@]}))
printf "\r\033[2K%s %b" "${CS_YWB}${chars[$index]}${CS_CL}" "${CS_YWB}${SPINNER_MSG:-}${CS_CL}"
sleep 0.1
done
}
clear_line() {
tput cr 2>/dev/null || echo -en "\r"
tput el 2>/dev/null || echo -en "\033[K"
}
stop_spinner() {
local pid="${SPINNER_PID:-}"
[[ -z "$pid" && -f /tmp/.spinner.pid ]] && pid=$(</tmp/.spinner.pid)
if [[ -n "$pid" && "$pid" =~ ^[0-9]+$ ]]; then
if kill "$pid" 2>/dev/null; then
sleep 0.05
kill -9 "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
fi
rm -f /tmp/.spinner.pid
fi
unset SPINNER_PID SPINNER_MSG
stty sane 2>/dev/null || true
}
msg_info() {
local msg="$1"
[[ -z "$msg" ]] && return
if ! declare -p MSG_INFO_SHOWN &>/dev/null || ! declare -A MSG_INFO_SHOWN &>/dev/null; then
declare -gA MSG_INFO_SHOWN=()
fi
[[ -n "${MSG_INFO_SHOWN["$msg"]+x}" ]] && return
MSG_INFO_SHOWN["$msg"]=1
stop_spinner
SPINNER_MSG="$msg"
if is_verbose_mode || is_alpine; then
local HOURGLASS="${TAB}${TAB}"
printf "\r\e[2K%s %b" "$HOURGLASS" "${YW}${msg}${CL}" >&2
return
fi
color_spinner
spinner &
SPINNER_PID=$!
echo "$SPINNER_PID" >/tmp/.spinner.pid
disown "$SPINNER_PID" 2>/dev/null || true
}
msg_ok() {
local msg="$1"
[[ -z "$msg" ]] && return
stop_spinner
clear_line
printf "%s %b\n" "$CM" "${GN}${msg}${CL}" >&2
unset MSG_INFO_SHOWN["$msg"]
}
msg_error() {
stop_spinner
local msg="$1"
echo -e "${BFR:-} ${CROSS:-✖️} ${RD}${msg}${CL}"
}
msg_warn() {
stop_spinner
local msg="$1"
echo -e "${BFR:-} ${INFO:-} ${YWB}${msg}${CL}"
}
msg_custom() {
local symbol="${1:-"[*]"}"
local color="${2:-"\e[36m"}"
local msg="${3:-}"
[[ -z "$msg" ]] && return
stop_spinner
echo -e "${BFR:-} ${symbol} ${color}${msg}${CL:-\e[0m}"
}
run_container_safe() {
local ct="$1"
shift
local cmd="$*"
lxc-attach -n "$ct" -- bash -euo pipefail -c "
trap 'echo Aborted in container; exit 130' SIGINT SIGTERM
$cmd
" || __handle_general_error "lxc-attach to CT $ct"
}
cleanup_lxc() {
msg_info "Cleaning up"
if is_alpine; then
$STD apk cache clean || true
rm -rf /var/cache/apk/*
else
$STD apt -y autoremove || true
$STD apt -y autoclean || true
$STD apt -y clean || true
fi
# Clear temp artifacts (keep sockets/FIFOs; ignore errors)
find /tmp /var/tmp -type f -name 'tmp*' -delete 2>/dev/null || true
find /tmp /var/tmp -type f -name 'tempfile*' -delete 2>/dev/null || true
# Truncate writable log files silently (permission errors ignored)
if command -v truncate >/dev/null 2>&1; then
find /var/log -type f -writable -print0 2>/dev/null |
xargs -0 -n1 truncate -s 0 2>/dev/null || true
fi
# Node.js npm
if command -v npm &>/dev/null; then $STD npm cache clean --force || true; fi
# Node.js yarn
if command -v yarn &>/dev/null; then $STD yarn cache clean || true; fi
# Node.js pnpm
if command -v pnpm &>/dev/null; then $STD pnpm store prune || true; fi
# Go
if command -v go &>/dev/null; then $STD go clean -cache -modcache || true; fi
# Rust cargo
if command -v cargo &>/dev/null; then $STD cargo clean || true; fi
# Ruby gem
if command -v gem &>/dev/null; then $STD gem cleanup || true; fi
# Composer (PHP)
if command -v composer &>/dev/null; then $STD composer clear-cache || true; fi
if command -v journalctl &>/dev/null; then
$STD journalctl --vacuum-time=10m || true
fi
msg_ok "Cleaned"
}
check_or_create_swap() {
msg_info "Checking for active swap"
if swapon --noheadings --show | grep -q 'swap'; then
msg_ok "Swap is active"
return 0
fi
msg_error "No active swap detected"
read -p "Do you want to create a swap file? [y/N]: " create_swap
create_swap="${create_swap,,}" # to lowercase
if [[ "$create_swap" != "y" && "$create_swap" != "yes" ]]; then
msg_info "Skipping swap file creation"
return 1
fi
read -p "Enter swap size in MB (e.g., 2048 for 2GB): " swap_size_mb
if ! [[ "$swap_size_mb" =~ ^[0-9]+$ ]]; then
msg_error "Invalid size input. Aborting."
return 1
fi
local swap_file="/swapfile"
msg_info "Creating ${swap_size_mb}MB swap file at $swap_file"
if dd if=/dev/zero of="$swap_file" bs=1M count="$swap_size_mb" status=progress &&
chmod 600 "$swap_file" &&
mkswap "$swap_file" &&
swapon "$swap_file"; then
msg_ok "Swap file created and activated successfully"
else
msg_error "Failed to create or activate swap"
return 1
fi
}
trap 'stop_spinner' EXIT INT TERM

View File

@@ -1,385 +0,0 @@
#!/usr/bin/env bash
# Copyright (c) 2021-2026 tteck
# Author: tteck (tteckster)
# Co-Author: MickLesk
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# This sets verbose mode if the global variable is set to "yes"
# if [ "$VERBOSE" == "yes" ]; then set -x; fi
if command -v curl >/dev/null 2>&1; then
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/core.func)
load_functions
#echo "(create-lxc.sh) Loaded core.func via curl"
elif command -v wget >/dev/null 2>&1; then
source <(wget -qO- https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/core.func)
load_functions
#echo "(create-lxc.sh) Loaded core.func via wget"
fi
# This sets error handling options and defines the error_handler function to handle errors
set -Eeuo pipefail
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
trap on_exit EXIT
trap on_interrupt INT
trap on_terminate TERM
function on_exit() {
local exit_code="$?"
[[ -n "${lockfile:-}" && -e "$lockfile" ]] && rm -f "$lockfile"
exit "$exit_code"
}
function error_handler() {
local exit_code="$?"
local line_number="$1"
local command="$2"
printf "\e[?25h"
echo -e "\n${RD}[ERROR]${CL} in line ${RD}$line_number${CL}: exit code ${RD}$exit_code${CL}: while executing command ${YW}$command${CL}\n"
exit "$exit_code"
}
function on_interrupt() {
echo -e "\n${RD}Interrupted by user (SIGINT)${CL}"
exit 130
}
function on_terminate() {
echo -e "\n${RD}Terminated by signal (SIGTERM)${CL}"
exit 143
}
function exit_script() {
clear
printf "\e[?25h"
echo -e "\n${CROSS}${RD}User exited script${CL}\n"
kill 0
exit 1
}
function check_storage_support() {
local CONTENT="$1"
local -a VALID_STORAGES=()
while IFS= read -r line; do
local STORAGE_NAME
STORAGE_NAME=$(awk '{print $1}' <<<"$line")
[[ -z "$STORAGE_NAME" ]] && continue
VALID_STORAGES+=("$STORAGE_NAME")
done < <(pvesm status -content "$CONTENT" 2>/dev/null | awk 'NR>1')
[[ ${#VALID_STORAGES[@]} -gt 0 ]]
}
# This function selects a storage pool for a given content type (e.g., rootdir, vztmpl).
function select_storage() {
local CLASS=$1 CONTENT CONTENT_LABEL
case $CLASS in
container)
CONTENT='rootdir'
CONTENT_LABEL='Container'
;;
template)
CONTENT='vztmpl'
CONTENT_LABEL='Container template'
;;
iso)
CONTENT='iso'
CONTENT_LABEL='ISO image'
;;
images)
CONTENT='images'
CONTENT_LABEL='VM Disk image'
;;
backup)
CONTENT='backup'
CONTENT_LABEL='Backup'
;;
snippets)
CONTENT='snippets'
CONTENT_LABEL='Snippets'
;;
*)
msg_error "Invalid storage class '$CLASS'"
return 1
;;
esac
# Check for preset STORAGE variable
if [ "$CONTENT" = "rootdir" ] && [ -n "${STORAGE:-}" ]; then
if pvesm status -content "$CONTENT" | awk 'NR>1 {print $1}' | grep -qx "$STORAGE"; then
STORAGE_RESULT="$STORAGE"
msg_info "Using preset storage: $STORAGE_RESULT for $CONTENT_LABEL"
return 0
else
msg_error "Preset storage '$STORAGE' is not valid for content type '$CONTENT'."
return 2
fi
fi
local -A STORAGE_MAP
local -a MENU
local COL_WIDTH=0
while read -r TAG TYPE _ TOTAL USED FREE _; do
[[ -n "$TAG" && -n "$TYPE" ]] || continue
local STORAGE_NAME="$TAG"
local DISPLAY="${STORAGE_NAME} (${TYPE})"
local USED_FMT=$(numfmt --to=iec --from-unit=K --format %.1f <<<"$USED")
local FREE_FMT=$(numfmt --to=iec --from-unit=K --format %.1f <<<"$FREE")
local INFO="Free: ${FREE_FMT}B Used: ${USED_FMT}B"
STORAGE_MAP["$DISPLAY"]="$STORAGE_NAME"
MENU+=("$DISPLAY" "$INFO" "OFF")
((${#DISPLAY} > COL_WIDTH)) && COL_WIDTH=${#DISPLAY}
done < <(pvesm status -content "$CONTENT" | awk 'NR>1')
if [ ${#MENU[@]} -eq 0 ]; then
msg_error "No storage found for content type '$CONTENT'."
return 2
fi
if [ $((${#MENU[@]} / 3)) -eq 1 ]; then
STORAGE_RESULT="${STORAGE_MAP[${MENU[0]}]}"
STORAGE_INFO="${MENU[1]}"
return 0
fi
local WIDTH=$((COL_WIDTH + 42))
while true; do
local DISPLAY_SELECTED
DISPLAY_SELECTED=$(whiptail --backtitle "Proxmox VE Helper Scripts" \
--title "Storage Pools" \
--radiolist "Which storage pool for ${CONTENT_LABEL,,}?\n(Spacebar to select)" \
16 "$WIDTH" 6 "${MENU[@]}" 3>&1 1>&2 2>&3)
# Cancel or ESC
[[ $? -ne 0 ]] && exit_script
# Strip trailing whitespace or newline (important for storages like "storage (dir)")
DISPLAY_SELECTED=$(sed 's/[[:space:]]*$//' <<<"$DISPLAY_SELECTED")
if [[ -z "$DISPLAY_SELECTED" || -z "${STORAGE_MAP[$DISPLAY_SELECTED]+_}" ]]; then
whiptail --msgbox "No valid storage selected. Please try again." 8 58
continue
fi
STORAGE_RESULT="${STORAGE_MAP[$DISPLAY_SELECTED]}"
for ((i = 0; i < ${#MENU[@]}; i += 3)); do
if [[ "${MENU[$i]}" == "$DISPLAY_SELECTED" ]]; then
STORAGE_INFO="${MENU[$i + 1]}"
break
fi
done
return 0
done
}
# Test if required variables are set
[[ "${CTID:-}" ]] || {
msg_error "You need to set 'CTID' variable."
exit 203
}
[[ "${PCT_OSTYPE:-}" ]] || {
msg_error "You need to set 'PCT_OSTYPE' variable."
exit 204
}
# Test if ID is valid
[ "$CTID" -ge "100" ] || {
msg_error "ID cannot be less than 100."
exit 205
}
# Test if ID is in use
if qm status "$CTID" &>/dev/null || pct status "$CTID" &>/dev/null; then
echo -e "ID '$CTID' is already in use."
unset CTID
msg_error "Cannot use ID that is already in use."
exit 206
fi
# This checks for the presence of valid Container Storage and Template Storage locations
if ! check_storage_support "rootdir"; then
msg_error "No valid storage found for 'rootdir' [Container]"
exit 1
fi
if ! check_storage_support "vztmpl"; then
msg_error "No valid storage found for 'vztmpl' [Template]"
exit 1
fi
while true; do
if select_storage template; then
TEMPLATE_STORAGE="$STORAGE_RESULT"
TEMPLATE_STORAGE_INFO="$STORAGE_INFO"
msg_ok "Storage ${BL}$TEMPLATE_STORAGE${CL} ($TEMPLATE_STORAGE_INFO) [Template]"
break
fi
done
while true; do
if select_storage container; then
CONTAINER_STORAGE="$STORAGE_RESULT"
CONTAINER_STORAGE_INFO="$STORAGE_INFO"
msg_ok "Storage ${BL}$CONTAINER_STORAGE${CL} ($CONTAINER_STORAGE_INFO) [Container]"
break
fi
done
# Check free space on selected container storage
STORAGE_FREE=$(pvesm status | awk -v s="$CONTAINER_STORAGE" '$1 == s { print $6 }')
REQUIRED_KB=$((${PCT_DISK_SIZE:-8} * 1024 * 1024))
if [ "$STORAGE_FREE" -lt "$REQUIRED_KB" ]; then
msg_error "Not enough space on '$CONTAINER_STORAGE'. Needed: ${PCT_DISK_SIZE:-8}G."
exit 214
fi
# Check Cluster Quorum if in Cluster
if [ -f /etc/pve/corosync.conf ]; then
msg_info "Checking cluster quorum"
if ! pvecm status | awk -F':' '/^Quorate/ { exit ($2 ~ /Yes/) ? 0 : 1 }'; then
msg_error "Cluster is not quorate. Start all nodes or configure quorum device (QDevice)."
exit 210
fi
msg_ok "Cluster is quorate"
fi
# Update LXC template list
TEMPLATE_SEARCH="${PCT_OSTYPE}-${PCT_OSVERSION:-}"
case "$PCT_OSTYPE" in
debian | ubuntu)
TEMPLATE_PATTERN="-standard_"
;;
alpine | fedora | rocky | centos)
TEMPLATE_PATTERN="-default_"
;;
*)
TEMPLATE_PATTERN=""
;;
esac
# 1. Check local templates first
msg_info "Searching for template '$TEMPLATE_SEARCH'"
mapfile -t TEMPLATES < <(
pveam list "$TEMPLATE_STORAGE" |
awk -v s="$TEMPLATE_SEARCH" -v p="$TEMPLATE_PATTERN" '$1 ~ s && $1 ~ p {print $1}' |
sed 's/.*\///' | sort -t - -k 2 -V
)
if [ ${#TEMPLATES[@]} -gt 0 ]; then
TEMPLATE_SOURCE="local"
else
msg_info "No local template found, checking online repository"
pveam update >/dev/null 2>&1
mapfile -t TEMPLATES < <(
pveam update >/dev/null 2>&1 &&
pveam available -section system |
sed -n "s/.*\($TEMPLATE_SEARCH.*$TEMPLATE_PATTERN.*\)/\1/p" |
sort -t - -k 2 -V
)
TEMPLATE_SOURCE="online"
fi
TEMPLATE="${TEMPLATES[-1]}"
TEMPLATE_PATH="$(pvesm path $TEMPLATE_STORAGE:vztmpl/$TEMPLATE 2>/dev/null ||
echo "/var/lib/vz/template/cache/$TEMPLATE")"
msg_ok "Template ${BL}$TEMPLATE${CL} [$TEMPLATE_SOURCE]"
# 4. Validate template (exists & not corrupted)
TEMPLATE_VALID=1
if [ ! -s "$TEMPLATE_PATH" ]; then
TEMPLATE_VALID=0
elif ! tar --use-compress-program=zstdcat -tf "$TEMPLATE_PATH" >/dev/null 2>&1; then
TEMPLATE_VALID=0
fi
if [ "$TEMPLATE_VALID" -eq 0 ]; then
msg_warn "Template $TEMPLATE is missing or corrupted. Re-downloading."
[[ -f "$TEMPLATE_PATH" ]] && rm -f "$TEMPLATE_PATH"
for attempt in {1..3}; do
msg_info "Attempt $attempt: Downloading LXC template..."
if pveam download "$TEMPLATE_STORAGE" "$TEMPLATE" >/dev/null 2>&1; then
msg_ok "Template download successful."
break
fi
if [ $attempt -eq 3 ]; then
msg_error "Failed after 3 attempts. Please check network access or manually run:\n pveam download $TEMPLATE_STORAGE $TEMPLATE"
exit 208
fi
sleep $((attempt * 5))
done
fi
msg_info "Creating LXC Container"
# Check and fix subuid/subgid
grep -q "root:100000:65536" /etc/subuid || echo "root:100000:65536" >>/etc/subuid
grep -q "root:100000:65536" /etc/subgid || echo "root:100000:65536" >>/etc/subgid
# Combine all options
PCT_OPTIONS=(${PCT_OPTIONS[@]:-${DEFAULT_PCT_OPTIONS[@]}})
[[ " ${PCT_OPTIONS[@]} " =~ " -rootfs " ]] || PCT_OPTIONS+=(-rootfs "$CONTAINER_STORAGE:${PCT_DISK_SIZE:-8}")
# Secure creation of the LXC container with lock and template check
lockfile="/tmp/template.${TEMPLATE}.lock"
exec 9>"$lockfile" || {
msg_error "Failed to create lock file '$lockfile'."
exit 200
}
flock -w 60 9 || {
msg_error "Timeout while waiting for template lock"
exit 211
}
if ! pct create "$CTID" "${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE}" "${PCT_OPTIONS[@]}" &>/dev/null; then
msg_error "Container creation failed. Checking if template is corrupted or incomplete."
if [[ ! -s "$TEMPLATE_PATH" || "$(stat -c%s "$TEMPLATE_PATH")" -lt 1000000 ]]; then
msg_error "Template file too small or missing re-downloading."
rm -f "$TEMPLATE_PATH"
elif ! zstdcat "$TEMPLATE_PATH" | tar -tf - &>/dev/null; then
msg_error "Template appears to be corrupted re-downloading."
rm -f "$TEMPLATE_PATH"
else
msg_error "Template is valid, but container creation failed. Update your whole Proxmox System (pve-container) first or check https://github.com/community-scripts/ProxmoxVE/discussions/8126"
exit 209
fi
# Retry download
for attempt in {1..3}; do
msg_info "Attempt $attempt: Re-downloading template..."
if timeout 120 pveam download "$TEMPLATE_STORAGE" "$TEMPLATE" >/dev/null; then
msg_ok "Template re-download successful."
break
fi
if [ "$attempt" -eq 3 ]; then
msg_error "Three failed attempts. Aborting."
exit 208
fi
sleep $((attempt * 5))
done
sleep 1 # I/O-Sync-Delay
msg_ok "Re-downloaded LXC Template"
fi
if ! pct list | awk '{print $1}' | grep -qx "$CTID"; then
msg_error "Container ID $CTID not listed in 'pct list' unexpected failure."
exit 215
fi
if ! grep -q '^rootfs:' "/etc/pve/lxc/$CTID.conf"; then
msg_error "RootFS entry missing in container config storage not correctly assigned."
exit 216
fi
if grep -q '^hostname:' "/etc/pve/lxc/$CTID.conf"; then
CT_HOSTNAME=$(grep '^hostname:' "/etc/pve/lxc/$CTID.conf" | awk '{print $2}')
if [[ ! "$CT_HOSTNAME" =~ ^[a-z0-9-]+$ ]]; then
msg_warn "Hostname '$CT_HOSTNAME' contains invalid characters may cause issues with networking or DNS."
fi
fi
msg_ok "LXC Container ${BL}$CTID${CL} ${GN}was successfully created."

View File

@@ -1,217 +0,0 @@
# Copyright (c) 2021-2026 tteck
# Author: tteck (tteckster)
# Co-Author: MickLesk
# License: MIT
# https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
if ! command -v curl >/dev/null 2>&1; then
printf "\r\e[2K%b" '\033[93m Setup Source \033[m' >&2
apt-get update >/dev/null 2>&1
apt-get install -y curl >/dev/null 2>&1
fi
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/core.func)
load_functions
# This function enables IPv6 if it's not disabled and sets verbose mode
verb_ip6() {
set_std_mode # Set STD mode based on VERBOSE
if [ "$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 <<EOF
# Disable IPv6 (set by community-scripts)
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
net.ipv6.conf.lo.disable_ipv6 = 1
EOF
$STD sysctl -p /etc/sysctl.d/99-disable-ipv6.conf
msg_ok "Disabled IPv6"
fi
}
# This function sets error handling options and defines the error_handler function to handle errors
catch_errors() {
set -Eeuo pipefail
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
}
# This function handles errors
error_handler() {
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/api.func)
printf "\e[?25h"
local exit_code="$?"
local line_number="$1"
local command="$2"
local error_message="${RD}[ERROR]${CL} in line ${RD}$line_number${CL}: exit code ${RD}$exit_code${CL}: while executing command ${YW}$command${CL}"
echo -e "\n$error_message"
if [[ "$line_number" -eq 51 ]]; then
echo -e "The silent function has suppressed the error, run the script with verbose mode enabled, which will provide more detailed output.\n"
post_update_to_api "failed" "No error message, script ran in silent mode"
else
post_update_to_api "failed" "${command}"
fi
}
# This function sets up the Container OS by generating the locale, setting the timezone, and checking the network connection
setting_up_container() {
msg_info "Setting up Container OS"
for ((i = RETRY_NUM; i > 0; i--)); do
if [ "$(hostname -I)" != "" ]; then
break
fi
echo 1>&2 -en "${CROSS}${RD} No Network! "
sleep $RETRY_EVERY
done
if [ "$(hostname -I)" = "" ]; then
echo 1>&2 -e "\n${CROSS}${RD} No Network After $RETRY_NUM Tries${CL}"
echo -e "${NETWORK}Check Network Settings"
exit 1
fi
rm -rf /usr/lib/python3.*/EXTERNALLY-MANAGED
systemctl disable -q --now systemd-networkd-wait-online.service
msg_ok "Set up Container OS"
#msg_custom "${CM}" "${GN}" "Network Connected: ${BL}$(hostname -I)"
msg_ok "Network Connected: ${BL}$(hostname -I)"
}
# This function checks the network connection by pinging a known IP address and prompts the user to continue if the internet is not connected
# This function checks the network connection by pinging a known IP address and prompts the user to continue if the internet is not connected
network_check() {
set +e
trap - ERR
ipv4_connected=false
ipv6_connected=false
sleep 1
# Check IPv4 connectivity to Google, Cloudflare & Quad9 DNS servers.
if ping -c 1 -W 1 1.1.1.1 &>/dev/null || ping -c 1 -W 1 8.8.8.8 &>/dev/null || ping -c 1 -W 1 9.9.9.9 &>/dev/null; then
msg_ok "IPv4 Internet Connected"
ipv4_connected=true
else
msg_error "IPv4 Internet Not Connected"
fi
# Check IPv6 connectivity to Google, Cloudflare & Quad9 DNS servers.
if ping6 -c 1 -W 1 2606:4700:4700::1111 &>/dev/null || ping6 -c 1 -W 1 2001:4860:4860::8888 &>/dev/null || ping6 -c 1 -W 1 2620:fe::fe &>/dev/null; then
msg_ok "IPv6 Internet Connected"
ipv6_connected=true
else
msg_error "IPv6 Internet Not Connected"
fi
# If both IPv4 and IPv6 checks fail, prompt the user
if [[ $ipv4_connected == false && $ipv6_connected == false ]]; then
read -r -p "No Internet detected, would you like to continue anyway? <y/N> " prompt
if [[ "${prompt,,}" =~ ^(y|yes)$ ]]; then
echo -e "${INFO}${RD}Expect Issues Without Internet${CL}"
else
echo -e "${NETWORK}Check Network Settings"
exit 1
fi
fi
# DNS resolution checks for GitHub-related domains (IPv4 and/or IPv6)
GIT_HOSTS=("github.com" "raw.githubusercontent.com" "api.github.com" "git.community-scripts.org")
GIT_STATUS="Git DNS:"
DNS_FAILED=false
for HOST in "${GIT_HOSTS[@]}"; do
RESOLVEDIP=$(getent hosts "$HOST" | awk '{ print $1 }' | grep -E '(^([0-9]{1,3}\.){3}[0-9]{1,3}$)|(^[a-fA-F0-9:]+$)' | head -n1)
if [[ -z "$RESOLVEDIP" ]]; then
GIT_STATUS+="$HOST:($DNSFAIL)"
DNS_FAILED=true
else
GIT_STATUS+=" $HOST:($DNSOK)"
fi
done
if [[ "$DNS_FAILED" == true ]]; then
fatal "$GIT_STATUS"
else
msg_ok "$GIT_STATUS"
fi
set -e
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
}
# This function updates the Container OS by running apt-get update and upgrade
update_os() {
msg_info "Updating Container OS"
if [[ "$CACHER" == "yes" ]]; then
echo 'Acquire::http::Proxy-Auto-Detect "/usr/local/bin/apt-proxy-detect.sh";' >/etc/apt/apt.conf.d/00aptproxy
cat <<EOF >/usr/local/bin/apt-proxy-detect.sh
#!/bin/bash
if nc -w1 -z "${CACHER_IP}" 3142; then
echo -n "http://${CACHER_IP}:3142"
else
echo -n "DIRECT"
fi
EOF
chmod +x /usr/local/bin/apt-proxy-detect.sh
fi
$STD apt-get update
$STD apt-get -o Dpkg::Options::="--force-confold" -y dist-upgrade
rm -rf /usr/lib/python3.*/EXTERNALLY-MANAGED
msg_ok "Updated Container OS"
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/tools.func)
}
# This function modifies the message of the day (motd) and SSH settings
motd_ssh() {
# Set terminal to 256-color mode
grep -qxF "export TERM='xterm-256color'" /root/.bashrc || echo "export TERM='xterm-256color'" >>/root/.bashrc
# Get OS information (Debian / Ubuntu)
if [ -f "/etc/os-release" ]; then
OS_NAME=$(grep ^NAME /etc/os-release | cut -d= -f2 | tr -d '"')
OS_VERSION=$(grep ^VERSION_ID /etc/os-release | cut -d= -f2 | tr -d '"')
elif [ -f "/etc/debian_version" ]; then
OS_NAME="Debian"
OS_VERSION=$(cat /etc/debian_version)
fi
PROFILE_FILE="/etc/profile.d/00_lxc-details.sh"
echo "echo -e \"\"" >"$PROFILE_FILE"
echo -e "echo -e \"${BOLD}${APPLICATION} LXC Container${CL}"\" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${GATEWAY}${YW} Provided by: ${GN}community-scripts ORG ${YW}| GitHub: ${GN}https://github.com/community-scripts/ProxmoxVE${CL}\"" >>"$PROFILE_FILE"
echo "echo \"\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${OS}${YW} OS: ${GN}${OS_NAME} - Version: ${OS_VERSION}${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${HOSTNAME}${YW} Hostname: ${GN}\$(hostname)${CL}\"" >>"$PROFILE_FILE"
echo -e "echo -e \"${TAB}${INFO}${YW} IP Address: ${GN}\$(hostname -I | awk '{print \$1}')${CL}\"" >>"$PROFILE_FILE"
# Disable default MOTD scripts
chmod -x /etc/update-motd.d/*
if [[ "${SSH_ROOT}" == "yes" ]]; then
sed -i "s/#PermitRootLogin prohibit-password/PermitRootLogin yes/g" /etc/ssh/sshd_config
systemctl restart sshd
fi
}
# This function customizes the container by modifying the getty service and enabling auto-login for the root user
customize() {
if [[ "$PASSWORD" == "" ]]; then
msg_info "Customizing Container"
GETTY_OVERRIDE="/etc/systemd/system/container-getty@1.service.d/override.conf"
mkdir -p $(dirname $GETTY_OVERRIDE)
cat <<EOF >$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
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -169,7 +169,7 @@ get_active_logfile() {
# silent()
#
# - Executes command with output redirected to active log file
# - On error: displays last 10 lines of log and exits with original exit code
# - On error: displays last 20 lines of log and exits with original exit code
# - Temporarily disables error trap to capture exit code correctly
# - Sources explain_exit_code() for detailed error messages
# ------------------------------------------------------------------------------
@@ -190,7 +190,7 @@ silent() {
if [[ $rc -ne 0 ]]; then
# Source explain_exit_code if needed
if ! declare -f explain_exit_code >/dev/null 2>&1; then
source <(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/error_handler.func) 2>/dev/null || true
source <(curl -fsSL "$COMMUNITY_SCRIPTS_URL/misc/error_handler.func") 2>/dev/null || true
fi
local explanation=""
@@ -207,15 +207,9 @@ silent() {
msg_custom "→" "${YWB}" "${cmd}"
if [[ -s "$logfile" ]]; then
local log_lines=$(wc -l <"$logfile")
echo "--- Last 10 lines of log ---"
tail -n 10 "$logfile"
echo "----------------------------"
# Show how to view full log if there are more lines
if [[ $log_lines -gt 10 ]]; then
msg_custom "📋" "${YW}" "View full log (${log_lines} lines): ${logfile}"
fi
echo -e "\n${TAB}--- Last 20 lines of log ---"
tail -n 20 "$logfile"
echo -e "${TAB}----------------------------\n"
fi
exit "$rc"
@@ -535,9 +529,21 @@ cleanup_vmid() {
}
cleanup() {
local exit_code=$?
if [[ "$(dirs -p | wc -l)" -gt 1 ]]; then
popd >/dev/null || true
fi
# Report final telemetry status if post_to_api_vm was called but no update was sent
if [[ "${POST_TO_API_DONE:-}" == "true" && "${POST_UPDATE_DONE:-}" != "true" ]]; then
if declare -f post_update_to_api >/dev/null 2>&1; then
if [[ $exit_code -ne 0 ]]; then
post_update_to_api "failed" "$exit_code"
else
# Exited cleanly but description()/success was never called — shouldn't happen
post_update_to_api "failed" "1"
fi
fi
fi
}
check_root() {