mirror of
https://github.com/community-scripts/ProxmoxVED.git
synced 2026-03-31 06:24:18 -04:00
Merge branch 'main' into lint-build-func
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
398
misc/api.func
398
misc/api.func
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
516
misc/build.func
516
misc/build.func
@@ -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
|
||||
@@ -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
34
misc/data/.gitignore
vendored
@@ -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/
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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!"
|
||||
@@ -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 ""
|
||||
@@ -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 "========================================================="
|
||||
@@ -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
@@ -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!"
|
||||
@@ -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")
|
||||
}
|
||||
1261
misc/data/service.go
1261
misc/data/service.go
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -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
3554
misc/tools.func
3554
misc/tools.func
File diff suppressed because it is too large
Load Diff
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user