diff --git a/PVE-Tools.sh b/PVE-Tools.sh index 30fe818..c68d8dd 100644 --- a/PVE-Tools.sh +++ b/PVE-Tools.sh @@ -8635,6 +8635,536 @@ host_network_validate_mtu() { fi } +host_network_get_iface_mac() { + local iface="$1" + local mac + mac="$(cat "/sys/class/net/${iface}/address" 2>/dev/null || true)" + [[ "$mac" =~ ^([0-9a-f]{2}:){5}[0-9a-f]{2}$ ]] && echo "$mac" || echo "" +} + +host_network_get_physical_ifaces_with_mac() { + local iface mac + for iface in $(ip -o link show 2>/dev/null | awk -F': ' '{print $2}' | cut -d'@' -f1 | sort -u); do + [[ "$iface" == "lo" ]] && continue + # 跳过 bridge、bond、vlan 等虚拟接口 + [[ -f "/sys/class/net/${iface}/device/vendor" ]] || continue + mac="$(host_network_get_iface_mac "$iface")" + [[ -n "$mac" ]] || continue + printf '%s|%s\n' "$iface" "$mac" + done +} + +host_network_validate_mac() { + local mac="$1" + [[ "$mac" =~ ^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$ ]] +} + +host_network_mac_to_iface() { + local target_mac="$1" + target_mac="$(echo "$target_mac" | tr '[:upper:]' '[:lower:]')" + local iface mac + for iface in $(ip -o link show 2>/dev/null | awk -F': ' '{print $2}' | cut -d'@' -f1 | sort -u); do + [[ "$iface" == "lo" ]] && continue + mac="$(host_network_get_iface_mac "$iface")" + [[ "$(echo "$mac" | tr '[:upper:]' '[:lower:]')" == "$target_mac" ]] && { echo "$iface"; return 0; } + done + return 1 +} + +host_network_validate_systemd_link_name() { + local name="$1" + [[ -n "$name" && ${#name} -le 15 ]] || return 1 + [[ "$name" != "." && "$name" != ".." && "$name" != "all" && "$name" != "default" ]] || return 1 + [[ "$name" =~ ^[A-Za-z0-9_.-]+$ ]] || return 1 + [[ "$name" =~ ^[0-9]+$ ]] && return 1 +} + +host_network_systemd_link_dir() { + echo "/etc/systemd/network" +} + +host_network_systemd_link_file_for_mac() { + local mac="$1" + local lower_mac + lower_mac="$(echo "$mac" | tr '[:upper:]' '[:lower:]')" + echo "$(host_network_systemd_link_dir)/10-pve-tools-${lower_mac//:/-}.link" +} + +host_network_systemd_link_get_value() { + local file="$1" + local section="$2" + local key="$3" + + [[ -f "$file" ]] || return 1 + + awk -v section="$section" -v key="$key" ' + BEGIN { in_section=0 } + { + line=$0 + sub(/[[:space:]]*#.*/, "", line) + sub(/^[[:space:]]+/, "", line) + sub(/[[:space:]]+$/, "", line) + if (line == "") next + if (line ~ /^\[[^]]+\]$/) { + in_section = (line == "[" section "]") + next + } + if (in_section && line ~ ("^" key "[[:space:]]*=[[:space:]]*")) { + sub("^" key "[[:space:]]*=[[:space:]]*", "", line) + gsub(/^"/, "", line) + gsub(/"$/, "", line) + print line + exit + } + } + ' "$file" +} + +host_network_systemd_link_file_has_binding() { + local file="$1" + local mac="$2" + local name="$3" + local lower_mac file_mac file_name + + file_mac="$(host_network_systemd_link_get_value "$file" "Match" "MACAddress" 2>/dev/null || true)" + file_name="$(host_network_systemd_link_get_value "$file" "Link" "Name" 2>/dev/null || true)" + lower_mac="$(echo "$mac" | tr '[:upper:]' '[:lower:]')" + file_mac="$(echo "$file_mac" | tr '[:upper:]' '[:lower:]')" + + [[ "$file_mac" == "$lower_mac" && "$file_name" == "$name" ]] +} + +host_network_systemd_link_list_managed_files() { + local dir + dir="$(host_network_systemd_link_dir)" + [[ -d "$dir" ]] || return 0 + find "$dir" -maxdepth 1 -type f -name '10-pve-tools-*.link' 2>/dev/null | sort +} + +host_network_systemd_link_find_conflicts() { + local mac="$1" + local name="$2" + local target_file="$3" + local dir file file_mac file_name search_dirs=() + + for dir in /etc/systemd/network /run/systemd/network /usr/local/lib/systemd/network /usr/lib/systemd/network; do + [[ -d "$dir" ]] && search_dirs+=("$dir") + done + + (( ${#search_dirs[@]} > 0 )) || return 0 + + while IFS= read -r -d '' file; do + [[ "$file" == "$target_file" ]] && continue + file_mac="$(host_network_systemd_link_get_value "$file" "Match" "MACAddress" 2>/dev/null || true)" + file_name="$(host_network_systemd_link_get_value "$file" "Link" "Name" 2>/dev/null || true)" + file_mac="$(echo "$file_mac" | tr '[:upper:]' '[:lower:]')" + if [[ "$file_mac" == "$mac" ]]; then + printf '%s|MACAddress=%s\n' "$file" "$file_mac" + elif [[ "$file_name" == "$name" ]]; then + printf '%s|Name=%s\n' "$file" "$file_name" + fi + done < <(find "${search_dirs[@]}" -maxdepth 1 -type f -name '*.link' -print0 2>/dev/null) +} + +host_network_systemd_link_write_binding() { + local mac="$1" + local name="$2" + local lower_mac target_dir target_file tmp backup_path="" + lower_mac="$(echo "$mac" | tr '[:upper:]' '[:lower:]')" + target_dir="$(host_network_systemd_link_dir)" + target_file="$(host_network_systemd_link_file_for_mac "$lower_mac")" + + mkdir -p "$target_dir" 2>/dev/null || { + log_error "无法创建 systemd .link 目录: $target_dir" + return 1 + } + + if [[ -f "$target_file" ]]; then + if ! backup_file "$target_file" backup_path >/dev/null 2>&1; then + log_error "无法备份现有 systemd .link 文件: $target_file" + return 1 + fi + fi + + tmp="$(mktemp "${target_dir}/.pve-tools-link.XXXXXX")" || { + log_error "无法创建临时文件: $target_dir" + return 1 + } + + cat > "$tmp" </dev/null 2>&1 || true + + if ! mv -f "$tmp" "$target_file"; then + rm -f "$tmp" >/dev/null 2>&1 || true + if [[ -n "$backup_path" && -f "$backup_path" ]]; then + cp -a "$backup_path" "$target_file" >/dev/null 2>&1 || true + else + rm -f "$target_file" >/dev/null 2>&1 || true + fi + log_error "写入 systemd .link 文件失败: $target_file" + return 1 + fi + + local verify_mac verify_name + verify_mac="$(host_network_systemd_link_get_value "$target_file" "Match" "MACAddress" 2>/dev/null || true)" + verify_name="$(host_network_systemd_link_get_value "$target_file" "Link" "Name" 2>/dev/null || true)" + verify_mac="$(echo "$verify_mac" | tr '[:upper:]' '[:lower:]')" + if [[ "$verify_mac" != "$lower_mac" || "$verify_name" != "$name" ]]; then + if [[ -n "$backup_path" && -f "$backup_path" ]]; then + cp -a "$backup_path" "$target_file" >/dev/null 2>&1 || rm -f "$target_file" >/dev/null 2>&1 || true + else + rm -f "$target_file" >/dev/null 2>&1 || true + fi + log_error "写入后的 systemd .link 规则校验失败: $target_file" + return 1 + fi + + printf '%s\n' "$target_file" +} + +host_network_systemd_link_remove_binding_by_mac() { + local mac="$1" + local target_file backup_path="" + + target_file="$(host_network_systemd_link_file_for_mac "$mac")" + [[ -f "$target_file" ]] || return 0 + + if ! backup_file "$target_file" backup_path >/dev/null 2>&1; then + log_error "无法备份待删除的 systemd .link 文件: $target_file" + return 1 + fi + + if ! rm -f "$target_file"; then + log_error "删除 systemd .link 文件失败: $target_file" + return 1 + fi + + [[ -e "$target_file" ]] && { + log_error "systemd .link 文件仍然存在: $target_file" + return 1 + } +} + +host_network_legacy_udev_rules_file() { + echo "/etc/udev/rules.d/70-persistent-net.rules" +} + +host_network_legacy_udev_list_bindings() { + local file line mac legacy_name + file="$(host_network_legacy_udev_rules_file)" + [[ -f "$file" ]] || return 0 + + while IFS= read -r line; do + [[ -n "$line" ]] || continue + mac="$(echo "$line" | sed -n 's/.*ATTR{address}=="\([^"]*\)".*/\1/p')" + legacy_name="$(echo "$line" | sed -n 's/.*NAME="\([^"]*\)".*/\1/p')" + [[ -n "$mac" && -n "$legacy_name" ]] || continue + printf '%s|%s\n' "$mac" "$legacy_name" + done < "$file" +} + +host_network_legacy_udev_name_conflicts() { + local mac="$1" + local name="$2" + local file line legacy_mac legacy_name lower_mac + file="$(host_network_legacy_udev_rules_file)" + [[ -f "$file" ]] || return 1 + + lower_mac="$(echo "$mac" | tr '[:upper:]' '[:lower:]')" + while IFS= read -r line; do + [[ -n "$line" ]] || continue + legacy_mac="$(echo "$line" | sed -n 's/.*ATTR{address}=="\([^"]*\)".*/\1/p')" + legacy_name="$(echo "$line" | sed -n 's/.*NAME="\([^"]*\)".*/\1/p')" + [[ -n "$legacy_mac" && -n "$legacy_name" ]] || continue + legacy_mac="$(echo "$legacy_mac" | tr '[:upper:]' '[:lower:]')" + if [[ "$legacy_name" == "$name" && "$legacy_mac" != "$lower_mac" ]]; then + return 0 + fi + done < "$file" + + return 1 +} + +host_network_legacy_udev_prune_binding() { + local mac="$1" + local file tmp backup_path="" lower_mac + + file="$(host_network_legacy_udev_rules_file)" + [[ -f "$file" ]] || return 0 + + if ! backup_file "$file" backup_path >/dev/null 2>&1; then + log_error "无法备份 legacy udev 规则文件: $file" + return 1 + fi + + lower_mac="$(echo "$mac" | tr '[:upper:]' '[:lower:]')" + tmp="$(mktemp)" || { + log_error "无法创建临时文件用于清理 legacy udev 规则" + return 1 + } + + awk -v mac="$lower_mac" ' + { + line=$0 + line_lc=tolower(line) + if (index(line_lc, "ATTR{address}==\"" mac "\"") > 0) next + print + } + ' "$file" > "$tmp" + + if ! mv -f "$tmp" "$file"; then + rm -f "$tmp" >/dev/null 2>&1 || true + if [[ -n "$backup_path" && -f "$backup_path" ]]; then + cp -a "$backup_path" "$file" >/dev/null 2>&1 || true + fi + log_error "清理 legacy udev 规则失败: $file" + return 1 + fi + + [[ -s "$file" ]] || rm -f "$file" +} + +host_network_interface_has_config_reference() { + local iface_name="$1" + awk -v iface_name="$iface_name" ' + { + line=$0 + sub(/[[:space:]]*#.*/, "", line) + sub(/^[[:space:]]+/, "", line) + sub(/[[:space:]]+$/, "", line) + if (line == "") next + + n=split(line, fields, /[[:space:]]+/) + if (fields[1] == "iface" && n >= 2 && fields[2] == iface_name) { + print line + exit + } + if (fields[1] == "auto" || fields[1] ~ /^allow-/ || fields[1] == "bridge-ports" || fields[1] == "bond-slaves" || fields[1] == "vlan-raw-device") { + for (i=2; i<=n; i++) { + if (fields[i] == iface_name) { + print line + exit + } + } + } + } + ' "$HOST_NETWORK_INTERFACES_FILE" 2>/dev/null | grep -q . +} + +host_network_mac_binding_has_dependency() { + local iface_name="$1" + host_network_interface_has_master_dependency "$iface_name" && return 0 + host_network_interface_has_config_reference "$iface_name" +} + +host_network_show_mac_bindings() { + local file iface mac link_name + echo -e "${CYAN}当前 MAC 地址绑定(systemd .link 规则):${NC}" + if host_network_systemd_link_list_managed_files | grep -q .; then + while IFS= read -r file; do + [[ -n "$file" ]] || continue + mac="$(host_network_systemd_link_get_value "$file" "Match" "MACAddress" 2>/dev/null || true)" + link_name="$(host_network_systemd_link_get_value "$file" "Link" "Name" 2>/dev/null || true)" + [[ -n "$mac" && -n "$link_name" ]] || continue + iface="$(host_network_mac_to_iface "$mac" 2>/dev/null || true)" + if [[ -n "$iface" ]]; then + printf ' %s → %s (当前接口: %s)\n' "$mac" "$link_name" "$iface" + else + printf ' %s → %s (当前未识别)\n' "$mac" "$link_name" + fi + done < <(host_network_systemd_link_list_managed_files) + else + echo " (无)" + fi + echo "$UI_DIVIDER" + echo -e "${YELLOW}旧式 udev 绑定(兼容清理中):${NC}" + if host_network_legacy_udev_list_bindings | grep -q .; then + while IFS='|' read -r mac link_name; do + [[ -n "$mac" ]] || continue + printf ' %s → %s\n' "$mac" "$link_name" + done < <(host_network_legacy_udev_list_bindings) + else + echo " (无)" + fi + echo "$UI_DIVIDER" + echo -e "${CYAN}物理网卡 MAC 地址列表:${NC}" + local entry iface_name iface_mac + while IFS='|' read -r iface_name iface_mac; do + [[ -n "$iface_name" ]] || continue + link_name="$(host_network_systemd_link_get_value "$(host_network_systemd_link_file_for_mac "$iface_mac")" "Link" "Name" 2>/dev/null || true)" + if [[ -n "$link_name" ]]; then + printf ' [%s] %s (已绑定为 %s)\n' "$iface_name" "$iface_mac" "$link_name" + else + printf ' [%s] %s\n' "$iface_name" "$iface_mac" + fi + done < <(host_network_get_physical_ifaces_with_mac) +} + +host_network_create_mac_binding() { + local entries=() + local entry iface_name iface_mac idx pick fixed_name target_file backup_path link_name + + while IFS='|' read -r iface_name iface_mac; do + [[ -n "$iface_name" ]] || continue + entries+=("$iface_name|$iface_mac") + done < <(host_network_get_physical_ifaces_with_mac) + + if (( ${#entries[@]} == 0 )); then + display_error "未发现物理网卡" "请确认系统存在带有 PCI 设备的网络接口。" + return 1 + fi + + echo -e "${CYAN}可用物理网卡(按 MAC 地址固定命名):${NC}" + idx=1 + for entry in "${entries[@]}"; do + iface_name="${entry%%|*}" + iface_mac="${entry##*|}" + link_name="$(host_network_systemd_link_get_value "$(host_network_systemd_link_file_for_mac "$iface_mac")" "Link" "Name" 2>/dev/null || true)" + if [[ -n "$link_name" ]]; then + printf ' [%d] %s | %s (已绑定: %s)\n' "$idx" "$iface_name" "$iface_mac" "$link_name" + else + printf ' [%d] %s | %s\n' "$idx" "$iface_name" "$iface_mac" + fi + idx=$((idx + 1)) + done + echo "$UI_DIVIDER" + + read -p "请选择要固定命名的网卡序号 (0 返回): " pick + [[ "$pick" == "0" ]] && return 0 + [[ "$pick" =~ ^[0-9]+$ ]] || { display_error "无效选择"; return 1; } + if (( pick < 1 || pick > ${#entries[@]} )); then + display_error "选择超出范围" + return 1 + fi + + entry="${entries[$((pick - 1))]}" + iface_name="${entry%%|*}" + iface_mac="${entry##*|}" + target_file="$(host_network_systemd_link_file_for_mac "$iface_mac")" + + read -r -p "请输入固定接口名称(如 lan0、pve-eth0): " fixed_name + [[ -n "$fixed_name" ]] || { display_error "接口名称不能为空"; return 1; } + host_network_validate_systemd_link_name "$fixed_name" || { + display_error "接口名称不合法: $fixed_name" "名称需为 1-15 位,只允许字母、数字、_、.、-,且不能是纯数字或保留名。" + return 1 + } + + if [[ "$fixed_name" != "$iface_name" ]] && host_network_iface_exists "$fixed_name"; then + display_error "目标接口名称已被占用: $fixed_name" "请改用未被现有接口使用的名称。" + return 1 + fi + + if host_network_mac_binding_has_dependency "$iface_name"; then + display_error "该网卡已被宿主机网络配置引用" "请先迁移或删除依赖该接口的 bridge / bond / VLAN / direct iface 配置,再修改命名。" + return 1 + fi + + if host_network_legacy_udev_name_conflicts "$iface_mac" "$fixed_name"; then + display_error "旧式 udev 规则中已存在同名绑定" "请先人工处理 /etc/udev/rules.d/70-persistent-net.rules 中的同名规则,再继续迁移。" + return 1 + fi + + mapfile -t conflicts < <(host_network_systemd_link_find_conflicts "$iface_mac" "$fixed_name" "$target_file") + if (( ${#conflicts[@]} > 0 )); then + local conflict_report="检测到其他 systemd .link 文件已经匹配相同 MAC 或同名接口:" + local conflict + for conflict in "${conflicts[@]}"; do + conflict_report+=$'\n - '"${conflict%%|*} (${conflict##*|})" + done + display_error "检测到 systemd .link 规则冲突" "$conflict_report" + return 1 + fi + + if [[ -f "$target_file" ]] && host_network_systemd_link_file_has_binding "$target_file" "$iface_mac" "$fixed_name"; then + display_success "固定命名规则已存在" "文件: $target_file;重启宿主机后仍会保持该命名。" + return 0 + fi + + if ! confirm_high_risk_action "为 MAC 地址 ${iface_mac} 写入固定命名 ${fixed_name}" "将生成 ${target_file},让 systemd 根据 MAC 在启动时重命名接口。" "固定名与现有接口或其他 .link 文件冲突时,接口命名可能变得不可预测。" "请确认名称唯一,并准备好控制台或带外管理手段以便回滚。" "BIND-LINK"; then + return 0 + fi + + if ! host_network_legacy_udev_prune_binding "$iface_mac"; then + display_error "清理旧式 udev 绑定失败" "请先处理 /etc/udev/rules.d/70-persistent-net.rules 后重试。" + return 1 + fi + + if ! target_file="$(host_network_systemd_link_write_binding "$iface_mac" "$fixed_name")"; then + display_error "MAC 地址固定命名写入失败" "请先处理冲突提示,或检查 /etc/systemd/network 目录权限。" + return 1 + fi + + display_success "MAC 地址固定命名规则已写入" "文件: $target_file;建议重启宿主机后生效。" +} + +host_network_delete_mac_binding() { + local file mac link_name entries=() idx pick file_list + file_list="$(host_network_systemd_link_list_managed_files)" || file_list="" + + while IFS= read -r file; do + [[ -n "$file" ]] || continue + mac="$(host_network_systemd_link_get_value "$file" "Match" "MACAddress" 2>/dev/null || true)" + link_name="$(host_network_systemd_link_get_value "$file" "Link" "Name" 2>/dev/null || true)" + [[ -n "$mac" && -n "$link_name" ]] || continue + entries+=("$file|$mac|$link_name") + done <<< "$file_list" + + if (( ${#entries[@]} == 0 )); then + display_error "没有可删除的 MAC 绑定规则" "当前没有 PVE-Tools 管理的 systemd .link 文件。" + return 1 + fi + + echo -e "${CYAN}已存在的 MAC 地址绑定:${NC}" + idx=1 + for entry in "${entries[@]}"; do + file="${entry%%|*}" + mac="${entry#*|}" + mac="${mac%%|*}" + link_name="${entry##*|}" + printf ' [%d] %s → %s (%s)\n' "$idx" "$mac" "$link_name" "$(basename "$file")" + idx=$((idx + 1)) + done + echo "$UI_DIVIDER" + + read -p "请选择要删除的绑定序号 (0 返回): " pick + [[ "$pick" == "0" ]] && return 0 + [[ "$pick" =~ ^[0-9]+$ ]] || { display_error "无效选择"; return 1; } + if (( pick < 1 || pick > ${#entries[@]} )); then + display_error "选择超出范围" + return 1 + fi + + entry="${entries[$((pick - 1))]}" + file="${entry%%|*}" + mac="${entry#*|}" + mac="${mac%%|*}" + link_name="${entry##*|}" + + if ! confirm_action "删除 MAC 地址固定命名 ${mac} → ${link_name}"; then + return 0 + fi + + if ! host_network_legacy_udev_prune_binding "$mac"; then + display_error "清理旧式 udev 绑定失败" "请检查 /etc/udev/rules.d/70-persistent-net.rules 的权限与内容。" + return 1 + fi + + if ! host_network_systemd_link_remove_binding_by_mac "$mac"; then + display_error "删除 MAC 地址固定命名失败" "请检查文件权限或磁盘状态。" + return 1 + fi + + display_success "MAC 地址固定命名已删除: ${mac} → ${link_name}" "如需恢复,请重新创建对应 .link 文件。" +} + host_network_validate_ipv4() { local ip="$1" [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1 @@ -9279,13 +9809,39 @@ host_network_bridge_menu() { show_menu_option "1" "列出当前网卡与桥接" show_menu_option "2" "创建桥接" show_menu_option "3" "删除桥接" + show_menu_option "4" "MAC 地址绑定管理(防网卡顺序变化)" show_menu_option "0" "返回" show_menu_footer - read -p "请选择操作 [0-3]: " choice + read -p "请选择操作 [0-4]: " choice case "$choice" in 1) host_network_show_current_overview ;; 2) host_network_create_bridge ;; 3) host_network_delete_bridge ;; + 4) host_network_mac_binding_menu ;; + 0) return ;; + *) log_error "无效选择" ;; + esac + pause_function + done +} + +host_network_mac_binding_menu() { + while true; do + clear + show_menu_header "MAC 地址绑定管理" + echo -e "${YELLOW}说明:通过 MAC 地址而不是接口名来固定网卡命名,避免重启或硬件变更后网卡顺序变化导致网络配置失效。${NC}" + echo -e "${YELLOW}绑定后会在 /etc/systemd/network/ 中生成 .link 规则;若系统残留旧式 udev 规则,脚本会尝试同步清理。${NC}" + echo "$UI_DIVIDER" + host_network_show_mac_bindings + echo "$UI_DIVIDER" + show_menu_option "1" "创建 MAC 地址绑定" + show_menu_option "2" "删除 MAC 地址绑定" + show_menu_option "0" "返回" + show_menu_footer + read -p "请选择操作 [0-2]: " choice + case "$choice" in + 1) host_network_create_mac_binding ;; + 2) host_network_delete_mac_binding ;; 0) return ;; *) log_error "无效选择" ;; esac diff --git a/README.md b/README.md index f8c8de8..0f7a060 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![Proxmox VE](https://img.shields.io/badge/Proxmox-VE%209.x-E57000?logo=proxmox&logoColor=white)](https://www.proxmox.com/) [![Debian](https://img.shields.io/badge/Debian-13%20(Trixie)-A81D33?logo=debian&logoColor=white)](https://www.debian.org/) -![产品截图](./images/main-cover.png) +![产品截图](./images/main.png) diff --git a/Web/todo-data.json b/Web/todo-data.json index cebd632..242751f 100644 --- a/Web/todo-data.json +++ b/Web/todo-data.json @@ -2,8 +2,8 @@ "upcoming_features": [ { "title": "支持按 MAC 地址绑定网卡到 Bridge", - "status": "in-progress", - "description": "通过 MAC 地址而不是 PCIe 地址来识别网卡,避免重启或硬件变更后网卡顺序变化,导致网络配置失效甚至失联。" + "status": "completed", + "description": "通过 systemd .link 按 MAC 地址固定网卡命名,避免重启或硬件变更后网卡顺序变化,导致网络配置失效甚至失联。" }, { "title": "提供 Intel 核显 ROM 自动编译与下载服务", @@ -88,4 +88,4 @@ "description": "PVE-Tools 1.0 发布,奠定了一键优化与维护脚本的基础。" } ] -} \ No newline at end of file +} diff --git a/images/cover.svg b/images/cover.svg new file mode 100644 index 0000000..a884852 --- /dev/null +++ b/images/cover.svg @@ -0,0 +1 @@ +
PVE
Tools Pro
\ No newline at end of file diff --git a/images/main.png b/images/main.png new file mode 100644 index 0000000..55ddc6a Binary files /dev/null and b/images/main.png differ