diff --git a/.gitignore b/.gitignore index f0e2438..da9366e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,17 @@ # Node / Frontend node_modules/ -dist/ +mengyaconnect-frontend/dist/ .vite/ +# Backend Linux binary(本地/CI 构建后放入 dist,不提交) +mengyaconnect-backend/dist/* +!mengyaconnect-backend/dist/.gitkeep + +# SQLite 数据库文件(运行时生成,不提交) +mengyaconnect-backend/data/*.db +mengyaconnect-backend/data/*.db-wal +mengyaconnect-backend/data/*.db-shm + # Logs debug-logs/ *.log diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..d8e5aff --- /dev/null +++ b/build.bat @@ -0,0 +1,3 @@ +@echo off +cd /d "%~dp0mengyaconnect-frontend" +call npm run build diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..c16f1b8 --- /dev/null +++ b/build.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/mengyaconnect-frontend" +exec npm run build diff --git a/dev.bat b/dev.bat new file mode 100644 index 0000000..74c201a --- /dev/null +++ b/dev.bat @@ -0,0 +1,4 @@ +@echo off +set "ROOT=%~dp0" +start "mengyaconnect-backend" cmd /k "cd /d ""%ROOT%mengyaconnect-backend"" && go run ." +start "mengyaconnect-frontend" cmd /k "cd /d ""%ROOT%mengyaconnect-frontend"" && npm run dev" diff --git a/dev.sh b/dev.sh new file mode 100644 index 0000000..2ef7ad8 --- /dev/null +++ b/dev.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +(cd "$ROOT/mengyaconnect-backend" && go run .) & +BE_PID=$! +trap 'kill $BE_PID 2>/dev/null' EXIT INT TERM +cd "$ROOT/mengyaconnect-frontend" +npm run dev diff --git a/mengyaconnect-backend/.dockerignore b/mengyaconnect-backend/.dockerignore new file mode 100644 index 0000000..a6e0333 --- /dev/null +++ b/mengyaconnect-backend/.dockerignore @@ -0,0 +1,5 @@ +data/ +docker-compose.yml +.git +.gitignore +dist/ diff --git a/mengyaconnect-backend/Dockerfile b/mengyaconnect-backend/Dockerfile index 5940ee7..471cbfa 100644 --- a/mengyaconnect-backend/Dockerfile +++ b/mengyaconnect-backend/Dockerfile @@ -1,33 +1,23 @@ -FROM golang:1.21-alpine AS builder - -WORKDIR /app - -RUN apk add --no-cache ca-certificates - -COPY go.mod go.sum ./ -RUN go mod download - -COPY . . - -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o mengyaconnect-backend . - -FROM alpine:3.19 - -WORKDIR /app - -RUN apk add --no-cache ca-certificates && \ - adduser -D -H appuser - -COPY --from=builder /app/mengyaconnect-backend /app/mengyaconnect-backend - -ENV DATA_DIR=/app/data -ENV PORT=8080 - -RUN mkdir -p "$DATA_DIR" && chown -R appuser:appuser /app - -USER appuser - -EXPOSE 8080 - -CMD ["/app/mengyaconnect-backend"] - +# 与 go.mod 中 go 版本一致(modernc.org/sqlite 等依赖会抬高 toolchain 要求) +FROM golang:1.25-alpine AS builder +WORKDIR /app +RUN apk add --no-cache ca-certificates +COPY go.mod go.sum ./ +RUN go mod download +COPY *.go ./ +ARG TARGETOS=linux +ARG TARGETARCH +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /mengyaconnect-backend . + +FROM alpine:3.19 +WORKDIR /app +RUN apk add --no-cache ca-certificates && \ + adduser -D -H appuser +COPY --from=builder /mengyaconnect-backend /app/mengyaconnect-backend +ENV DATA_DIR=/app/data +ENV PORT=8080 +RUN chmod +x /app/mengyaconnect-backend && \ + mkdir -p "$DATA_DIR" && chown -R appuser:appuser /app +USER appuser +EXPOSE 8080 +CMD ["/app/mengyaconnect-backend"] diff --git a/mengyaconnect-backend/build-linux.bat b/mengyaconnect-backend/build-linux.bat new file mode 100644 index 0000000..1791f68 --- /dev/null +++ b/mengyaconnect-backend/build-linux.bat @@ -0,0 +1,5 @@ +@echo off +setlocal +cd /d "%~dp0" +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0build-linux.ps1" +exit /b %ERRORLEVEL% diff --git a/mengyaconnect-backend/build-linux.ps1 b/mengyaconnect-backend/build-linux.ps1 new file mode 100644 index 0000000..16984bc --- /dev/null +++ b/mengyaconnect-backend/build-linux.ps1 @@ -0,0 +1,12 @@ +$ErrorActionPreference = "Stop" +Set-Location $PSScriptRoot +New-Item -ItemType Directory -Force -Path dist | Out-Null +$env:CGO_ENABLED = "0" +$env:GOOS = "linux" +$env:GOARCH = "amd64" +$out = Join-Path $PSScriptRoot "dist\mengyaconnect-backend" +go build -trimpath -ldflags="-s -w" -o $out . +$b = [IO.File]::ReadAllBytes($out) +if ($b.Length -ge 4 -and $b[0] -eq 0x7F -and $b[1] -eq 0x45) { Write-Host "OK: Linux ELF amd64 -> $out" } +elseif ($b[0] -eq 0x4D -and $b[1] -eq 0x5A) { throw "Got Windows PE — check go env" } +else { throw "Unknown output format" } diff --git a/mengyaconnect-backend/config.go b/mengyaconnect-backend/config.go index 90c832d..88162dd 100644 --- a/mengyaconnect-backend/config.go +++ b/mengyaconnect-backend/config.go @@ -1,78 +1,80 @@ -package main - -import ( - "errors" - "net/http" - "os" - "path/filepath" - "strings" - - "github.com/gin-gonic/gin" -) - -// 数据目录辅助 -func dataBasePath() string { return getEnv("DATA_DIR", "data") } -func sshDir() string { return filepath.Join(dataBasePath(), "ssh") } -func cmdFilePath() string { return filepath.Join(dataBasePath(), "command", "command.json") } -func scriptDir() string { return filepath.Join(dataBasePath(), "script") } - -// sanitizeName 防止路径穿越攻击 -func sanitizeName(name string) (string, error) { - base := filepath.Base(name) - if base == "" || base == "." || base == ".." { - return "", errors.New("invalid name") - } - return base, nil -} - -func corsMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - c.Writer.Header().Set("Access-Control-Allow-Origin", "*") - c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") - c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") - if c.Request.Method == http.MethodOptions { - c.AbortWithStatus(http.StatusNoContent) - return - } - c.Next() - } -} - -func isOriginAllowed(origin string, allowed []string) bool { - if origin == "" { - return true - } - if len(allowed) == 0 { - return true - } - for _, item := range allowed { - if item == "*" || strings.EqualFold(strings.TrimSpace(item), origin) { - return true - } - } - return false -} - -func parseListEnv(name string) []string { - raw := strings.TrimSpace(os.Getenv(name)) - if raw == "" { - return nil - } - parts := strings.Split(raw, ",") - out := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - out = append(out, part) - } - } - return out -} - -func getEnv(key, fallback string) string { - if val := strings.TrimSpace(os.Getenv(key)); val != "" { - return val - } - return fallback -} - +package main + +import ( + "errors" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" +) + +func dataBasePath() string { return getEnv("DATA_DIR", "data") } +func dbPath() string { return filepath.Join(dataBasePath(), "mengyaconnect.db") } + +// accessPassword 返回 Web 访问密码,可通过 ACCESS_PASSWORD 环境变量覆盖 +func accessPassword() string { return getEnv("ACCESS_PASSWORD", "shumengya520") } + +// apiVersion 根路径 JSON 中的版本号,可由 API_VERSION 覆盖 +func apiVersion() string { return getEnv("API_VERSION", "1.0.0-go") } + +// validateName 检查名称合法性(防止空/路径穿越等),不再依赖 filepath.Base +func validateName(name string) error { + name = strings.TrimSpace(name) + if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\") { + return errors.New("invalid name") + } + return nil +} + +func corsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + } +} + +func isOriginAllowed(origin string, allowed []string) bool { + if origin == "" { + return true + } + if len(allowed) == 0 { + return true + } + for _, item := range allowed { + if item == "*" || strings.EqualFold(strings.TrimSpace(item), origin) { + return true + } + } + return false +} + +func parseListEnv(name string) []string { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +func getEnv(key, fallback string) string { + if val := strings.TrimSpace(os.Getenv(key)); val != "" { + return val + } + return fallback +} diff --git a/mengyaconnect-backend/data/command/command.json b/mengyaconnect-backend/data/command/command.json deleted file mode 100644 index 64dcf21..0000000 --- a/mengyaconnect-backend/data/command/command.json +++ /dev/null @@ -1,22 +0,0 @@ -[ - { - "alias": "安全关机", - "command": "shutdown -h now" - }, - { - "alias": "定时十分钟后关机", - "command": "shutdown -h 10" - }, - { - "alias": "重启", - "command": "reboot" - }, - { - "alias": "输出当前目录", - "command": "pwd" - }, - { - "alias": "列出当前目录文件", - "command": "ls -al" - } -] \ No newline at end of file diff --git a/mengyaconnect-backend/data/script/docker-info.sh b/mengyaconnect-backend/data/script/docker-info.sh deleted file mode 100644 index 83e2034..0000000 --- a/mengyaconnect-backend/data/script/docker-info.sh +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env bash - -# ============================================================================= -# Docker Info Collector - Single Column Edition -# ============================================================================= - -set -euo pipefail - -# ============================================================================= -# Colors -# ============================================================================= - -readonly NC='\033[0m' -readonly GRAY='\033[0;90m' -readonly CYAN='\033[0;36m' -readonly GREEN='\033[1;32m' -readonly YELLOW='\033[1;33m' -readonly WHITE='\033[1;37m' -readonly DIM='\033[2m' - -readonly C_HEADER="${CYAN}" -readonly C_SECTION="${YELLOW}" -readonly C_KEY="${WHITE}" -readonly C_VALUE="${CYAN}" -readonly C_OK="${GREEN}" -readonly C_WARN="${YELLOW}" -readonly C_ERR="\033[1;31m" -readonly C_DIM="${GRAY}" - -# Separator line (thick line style) -SEP_LINE="${C_DIM}══════════════════════════════════════════════════════════════════════════════${NC}" - -# ============================================================================= -# Utils -# ============================================================================= - -has_cmd() { command -v "$1" &>/dev/null; } - -print_header() { - local title="$1" - local len=${#title} - local pad=$(( (76 - len) / 2 )) - echo -e "\n${C_HEADER}╔══════════════════════════════════════════════════════════════════════════════╗" - printf "${C_HEADER}║%${pad}s${WHITE} %s %${pad}s║${NC}\n" "" "$title" "" - echo -e "${C_HEADER}╚══════════════════════════════════════════════════════════════════════════════╝${NC}" -} - -print_section() { - echo -e "\n${C_SECTION}▶ $1${NC}" - echo -e "$SEP_LINE" -} - -print_kv() { - printf " ${C_KEY}%-14s${NC} ${C_VALUE}%s${NC}\n" "$1:" "$2" -} - -print_ok() { echo -e " ${C_OK}●${NC} $1"; } -print_warn() { echo -e " ${C_WARN}●${NC} $1"; } -print_err() { echo -e " ${C_ERR}●${NC} $1"; } - -# ============================================================================= -# System Info -# ============================================================================= - -collect_server() { - print_header "服务器信息" - - # OS Information - print_section "操作系统" - local os_name kernel arch - os_name="$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s)" - kernel="$(uname -r)" - arch="$(uname -m)" - - print_kv "系统" "$os_name" - print_kv "内核版本" "$kernel" - print_kv "系统架构" "$arch" - - # Host Info - print_section "主机信息" - local hostname uptime_str - hostname="$(hostname)" - uptime_str="$(uptime -p 2>/dev/null | sed 's/up //' || uptime | sed 's/.*up //; s/,.*//')" - - print_kv "主机名" "$hostname" - print_kv "运行时长" "$uptime_str" - print_kv "当前时间" "$(date '+%Y-%m-%d %H:%M:%S')" - - # CPU - if [[ -f /proc/cpuinfo ]]; then - print_section "处理器" - local cpus cpu_model - cpus=$(grep -c '^processor' /proc/cpuinfo) - cpu_model=$(grep 'model name' /proc/cpuinfo | head -1 | cut -d: -f2 | xargs) - print_kv "核心数量" "${cpus} 核" - print_kv "型号" "$cpu_model" - fi - - # Memory - if has_cmd free; then - print_section "内存" - local mem_total mem_used mem_free mem_pct - mem_total=$(free -h | awk '/^Mem:/ {print $2}') - mem_used=$(free -h | awk '/^Mem:/ {print $3}') - mem_free=$(free -h | awk '/^Mem:/ {print $7}') - mem_pct=$(free | awk '/^Mem:/ {printf "%.1f", $3/$2 * 100}') - - print_kv "总容量" "$mem_total" - print_kv "已使用" "$mem_used (${mem_pct}%)" - print_kv "可用" "$mem_free" - fi - - # Disk - if has_cmd df; then - print_section "磁盘使用" - - df -h --output=source,fstype,size,used,pcent,target 2>/dev/null | tail -n +2 | while read -r fs type size used pct target; do - [[ "$fs" == "tmpfs" || "$fs" == "devtmpfs" || "$fs" == "overlay" ]] && continue - [[ -z "$fs" ]] && continue - # Escape % for printf - local pct_clean="${pct%%%}" - printf " ${C_DIM}[${NC}${C_VALUE}%-12s${NC}${C_DIM}]${NC} ${C_KEY}%-8s${NC} ${C_VALUE}%-7s${NC} ${C_DIM}used:${NC} ${C_VALUE}%-7s${NC} ${C_DIM}(%s%%)${NC}\n" \ - "$target" "$type" "$size" "$used" "$pct_clean" - done - fi - - # Network - if has_cmd ip; then - print_section "网络接口" - - ip -o addr show 2>/dev/null | awk '{print $2, $4}' | while read -r iface addr; do - [[ "$iface" == "lo" ]] && continue - print_kv "$iface" "$addr" - done - fi -} - -# ============================================================================= -# Docker Info -# ============================================================================= - -collect_docker() { - print_header "Docker 信息" - - if ! has_cmd docker; then - print_err "Docker 未安装" - return 1 - fi - - # Version - print_section "版本信息" - local client_ver server_ver - client_ver=$(docker version --format '{{.Client.Version}}' 2>/dev/null || echo "N/A") - server_ver=$(docker version --format '{{.Server.Version}}' 2>/dev/null || echo "N/A") - - print_kv "客户端" "$client_ver" - print_kv "服务端" "$server_ver" - if has_cmd docker-compose; then - print_kv "Docker Compose" "$(docker-compose version --short 2>/dev/null)" - fi - - # Status - if docker info &>/dev/null; then - print_ok "守护进程运行中" - else - print_warn "守护进程未运行" - return 1 - fi - - # Stats - print_section "资源统计" - local containers running images networks volumes - containers=$(docker ps -aq 2>/dev/null | wc -l) - running=$(docker ps -q 2>/dev/null | wc -l) - images=$(docker images -q 2>/dev/null | wc -l) - networks=$(docker network ls -q 2>/dev/null | wc -l) - volumes=$(docker volume ls -q 2>/dev/null | wc -l) - - print_kv "容器" "${running} 运行 / ${containers} 总计" - print_kv "镜像" "$images" - print_kv "网络" "$networks" - print_kv "存储卷" "$volumes" - - # Running containers - if [[ $running -gt 0 ]]; then - print_section "运行中的容器" - - docker ps --format "{{.Names}}|{{.Image}}|{{.Status}}" 2>/dev/null | while IFS='|' read -r name image status; do - printf " ${C_OK}●${NC} ${C_VALUE}%-20s${NC} ${C_DIM}%-30s${NC} %s\n" "$name" "${image:0:30}" "$status" - done - fi - - # All containers - if [[ $containers -gt 0 ]]; then - print_section "所有容器" - - docker ps -a --format "{{.Names}}|{{.Image}}|{{.Status}}" 2>/dev/null | head -20 | while IFS='|' read -r name image status; do - local icon="${C_DIM}○${NC}" - local color="$C_VALUE" - [[ "$status" == Up* ]] && icon="${C_OK}●${NC}" && color="$C_OK" - [[ "$status" == Exited* ]] && color="$C_DIM" - - printf " ${icon} ${color}%-20s${NC} ${C_DIM}%-25s${NC} %s\n" "$name" "${image:0:25}" "$status" - done - - [[ $containers -gt 20 ]] && echo -e " ${C_DIM}... 还有 $((containers - 20)) 个容器${NC}" - fi - - # Images - if [[ $images -gt 0 ]]; then - print_section "镜像列表" - - docker images --format "{{.Repository}}|{{.Tag}}|{{.Size}}|{{.CreatedAt}}" 2>/dev/null | grep -v "" | head -25 | while IFS='|' read -r repo tag size created; do - printf " ${C_DIM}◆${NC} ${C_VALUE}%-35s${NC} ${C_DIM}%-10s %s${NC}\n" "${repo}:${tag}" "$size" "$created" - done - - [[ $images -gt 25 ]] && echo -e " ${C_DIM}... 还有 $((images - 25)) 个镜像${NC}" - fi - - # Networks - if [[ $networks -gt 0 ]]; then - print_section "网络" - - docker network ls --format "{{.Name}}|{{.Driver}}|{{.Scope}}" 2>/dev/null | head -15 | while IFS='|' read -r name driver scope; do - printf " ${C_DIM}◎${NC} ${C_VALUE}%-20s${NC} ${C_DIM}[%s/%s]${NC}\n" "$name" "$driver" "$scope" - done - fi - - # Volumes - if [[ $volumes -gt 0 ]]; then - print_section "存储卷" - - docker volume ls --format "{{.Name}}|{{.Driver}}" 2>/dev/null | head -20 | while IFS='|' read -r name driver; do - printf " ${C_DIM}▪${NC} ${C_VALUE}%s${NC} ${C_DIM}(%s)${NC}\n" "$name" "$driver" - done - - [[ $volumes -gt 20 ]] && echo -e " ${C_DIM}... 还有 $((volumes - 20)) 个存储卷${NC}" - fi -} - -# ============================================================================= -# Main -# ============================================================================= - -main() { - collect_server - collect_docker - echo -e "\n${C_HEADER}╔══════════════════════════════════════════════════════════════════════════════╗" - echo -e "${C_HEADER}║${C_OK} ✓ 信息收集完成 ${C_HEADER}║${NC}" - echo -e "${C_HEADER}╚══════════════════════════════════════════════════════════════════════════════╝${NC}\n" -} - -main "$@" diff --git a/mengyaconnect-backend/data/script/systemctl-info.sh b/mengyaconnect-backend/data/script/systemctl-info.sh deleted file mode 100644 index b50dda0..0000000 --- a/mengyaconnect-backend/data/script/systemctl-info.sh +++ /dev/null @@ -1,802 +0,0 @@ -#!/bin/bash - -# systemctl-info - 详细的systemctl信息查看脚本 -# 作者: iFlow CLI -# 日期: 2026-02-13 -# 版本: 2.0 (模块化版本) - -# ═══════════════════════════════════════════════════════════════════════════════ -# 颜色定义 -# ═══════════════════════════════════════════════════════════════════════════════ -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[0;33m' -BLUE='\033[0;34m' -MAGENTA='\033[0;35m' -CYAN='\033[0;36m' -WHITE='\033[0;37m' -BRIGHT_RED='\033[1;31m' -BRIGHT_GREEN='\033[1;32m' -BRIGHT_YELLOW='\033[1;33m' -BRIGHT_BLUE='\033[1;34m' -BRIGHT_MAGENTA='\033[1;35m' -BRIGHT_CYAN='\033[1;36m' -BRIGHT_WHITE='\033[1;37m' -ORANGE='\033[38;5;208m' -PINK='\033[38;5;205m' -PURPLE='\033[38;5;141m' -LIME='\033[38;5;154m' -RESET='\033[0m' - -# ═══════════════════════════════════════════════════════════════════════════════ -# 分割线样式 -# ═══════════════════════════════════════════════════════════════════════════════ -SEPARATOR="${BRIGHT_CYAN}═══════════════════════════════════════════════════════════════════════════════${RESET}" -THIN_SEPARATOR="${CYAN}──────────────────────────────────────────────────────────────────────────────────────${RESET}" -DASH_SEPARATOR="${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" -LINE="${BRIGHT_CYAN}═══════════════════════════════════════════════════════════════════════════════${RESET}" - -# ═══════════════════════════════════════════════════════════════════════════════ -# 通用工具函数 -# ═══════════════════════════════════════════════════════════════════════════════ - -# 打印标题 -print_header() { - echo -e "${BRIGHT_CYAN}[Systemctl Info Viewer v2.0]${RESET}" - echo -e "${BRIGHT_CYAN}模块化 Systemd 信息查看脚本${RESET}" - echo "" -} - -# 打印带颜色的小节标题 -print_section() { - echo -e "${THIN_SEPARATOR}" - echo -e "${BRIGHT_BLUE}[ $1 ]${RESET}" - echo -e "${THIN_SEPARATOR}" -} - -# 打印带图标的信息行 -print_info() { - local icon="$1" - local label="$2" - local value="$3" - local color="$4" - echo -e "${icon} ${BRIGHT_WHITE}${label}:${RESET} ${color}${value}${RESET}" -} - -# 打印子项 -print_subitem() { - local label="$1" - local value="$2" - local color="$3" - echo -e " ${BRIGHT_CYAN}▸${RESET} ${BRIGHT_WHITE}${label}:${RESET} ${color}${value}${RESET}" -} - -# 打印带颜色的列表项 -print_list_item() { - local icon="$1" - local name="$2" - local status="$3" - local status_color="$4" - local extra="$5" - printf "${icon} ${BRIGHT_WHITE}%-45s${RESET} ${status_color}%s${RESET}%s\n" "$name" "$status" "$extra" -} - -# 获取状态图标和颜色 -get_state_icon_color() { - local state="$1" - case "$state" in - active|running|listening) - echo -e "✅|${BRIGHT_GREEN}" - ;; - inactive) - echo -e "⭕|${BRIGHT_YELLOW}" - ;; - failed) - echo -e "❌|${BRIGHT_RED}" - ;; - activating|deactivating) - echo -e "🔄|${BRIGHT_CYAN}" - ;; - *) - echo -e "❓|${BRIGHT_WHITE}" - ;; - esac -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块1: Systemd 版本信息 -# ═══════════════════════════════════════════════════════════════════════════════ -module_systemd_version() { - print_section "📋 Systemd 版本信息" - - SYSTEMD_VERSION=$(systemctl --version | head -n 1 | awk '{print $2}') - FEATURE_COUNT=$(systemctl --version | grep -c "features") - - print_info "🔧" "Systemd 版本" "$SYSTEMD_VERSION" "${BRIGHT_GREEN}" - print_info "✨" "支持功能特性" "$FEATURE_COUNT 项" "${LIME}" - - echo -e "${BRIGHT_CYAN}详细版本信息:${RESET}" - systemctl --version | while IFS= read -r line; do - echo -e " ${CYAN}${line}${RESET}" - done -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块2: 系统基础信息 -# ═══════════════════════════════════════════════════════════════════════════════ -module_system_info() { - print_section "🖥️ 系统基础信息" - - HOSTNAME=$(hostname) - KERNEL_VERSION=$(uname -r) - OS_ID=$(grep '^ID=' /etc/os-release | cut -d'=' -f2 | tr -d '"') - OS_NAME=$(grep '^PRETTY_NAME=' /etc/os-release | cut -d'=' -f2 | tr -d '"') - ARCH=$(uname -m) - UPTIME=$(uptime -p) - - print_info "🖥️" "主机名" "$HOSTNAME" "${BRIGHT_YELLOW}" - print_info "🐧" "内核版本" "$KERNEL_VERSION" "${ORANGE}" - print_info "📦" "操作系统ID" "$OS_ID" "${PINK}" - print_info "💻" "系统名称" "$OS_NAME" "${PURPLE}" - print_info "🏗️" "系统架构" "$ARCH" "${BRIGHT_CYAN}" - print_info "⏱️" "系统运行时间" "$UPTIME" "${LIME}" -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块3: Systemd 系统状态 -# ═══════════════════════════════════════════════════════════════════════════════ -module_systemd_status() { - print_section "⚙️ Systemd 系统状态" - - SYSTEM_STATE=$(systemctl is-system-running) - DEFAULT_TARGET=$(systemctl get-default) - INIT_PID=$(systemctl show --property=MainPID --value) - BOOT_TIME=$(systemctl show --property=UserspaceTimestamp --value | cut -d' ' -f1) - - case $SYSTEM_STATE in - running) - STATE_COLOR="${BRIGHT_GREEN}" - STATE_ICON="✅" - ;; - degraded) - STATE_COLOR="${BRIGHT_YELLOW}" - STATE_ICON="⚠️" - ;; - maintenance) - STATE_COLOR="${BRIGHT_RED}" - STATE_ICON="🔧" - ;; - *) - STATE_COLOR="${BRIGHT_WHITE}" - STATE_ICON="❓" - ;; - esac - - print_info "$STATE_ICON" "系统状态" "$SYSTEM_STATE" "$STATE_COLOR" - print_info "🎯" "默认运行级别" "$DEFAULT_TARGET" "${BRIGHT_CYAN}" - print_info "🔄" "Init 进程 PID" "$INIT_PID" "${ORANGE}" - print_info "🚀" "启动时间" "$BOOT_TIME" "${PURPLE}" -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块4: 服务(Service)统计与状态 -# ═══════════════════════════════════════════════════════════════════════════════ -module_service_stats() { - print_section "🔌 服务(Service)统计与状态" - - TOTAL_UNITS=$(systemctl list-unit-files --type=service --no-legend | wc -l) - ENABLED_SERVICES=$(systemctl list-unit-files --type=service --state=enabled --no-legend | wc -l) - DISABLED_SERVICES=$(systemctl list-unit-files --type=service --state=disabled --no-legend | wc -l) - STATIC_SERVICES=$(systemctl list-unit-files --type=service --state=static --no-legend | wc -l) - MASKED_SERVICES=$(systemctl list-unit-files --type=service --state=masked --no-legend | wc -l) - - RUNNING_SERVICES=$(systemctl list-units --type=service --state=running --no-legend | wc -l) - FAILED_SERVICES=$(systemctl list-units --type=service --state=failed --no-legend | wc -l) - - echo -e "${BRIGHT_CYAN}服务文件统计:${RESET}" - print_subitem "总服务数" "$TOTAL_UNITS" "${BRIGHT_WHITE}" - print_subitem "已启用(enabled)" "$ENABLED_SERVICES" "${BRIGHT_GREEN}" - print_subitem "已禁用(disabled)" "$DISABLED_SERVICES" "${BRIGHT_RED}" - print_subitem "静态服务(static)" "$STATIC_SERVICES" "${BRIGHT_YELLOW}" - print_subitem "已屏蔽(masked)" "$MASKED_SERVICES" "${PURPLE}" - - echo -e "${BRIGHT_CYAN}服务运行状态:${RESET}" - print_subitem "运行中" "$RUNNING_SERVICES" "${LIME}" - print_subitem "失败" "$FAILED_SERVICES" "${BRIGHT_RED}" -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块5: 失败的服务详情 -# ═══════════════════════════════════════════════════════════════════════════════ -module_failed_services() { - FAILED_SERVICES=$(systemctl list-units --type=service --state=failed --no-legend | wc -l) - - if [ "$FAILED_SERVICES" -gt 0 ]; then - print_section "❌ 失败的服务详情 (共 $FAILED_SERVICES 个)" - systemctl list-units --type=service --state=failed --no-pager | sed '1,1d' | while IFS= read -r line; do - if [ -n "$line" ]; then - SERVICE_NAME=$(echo "$line" | awk '{print $1}') - LOAD_STATE=$(echo "$line" | awk '{print $2}') - ACTIVE_STATE=$(echo "$line" | awk '{print $3}') - SUB_STATE=$(echo "$line" | awk '{print $4}') - DESCRIPTION=$(echo "$line" | awk '{for(i=5;i<=NF;i++)print $i}' | tr '\n' ' ' | sed 's/ $//') - - echo -e "${BRIGHT_RED}✗${RESET} ${BRIGHT_WHITE}${SERVICE_NAME}${RESET}" - echo -e " ${CYAN}描述:${RESET} ${WHITE}${DESCRIPTION}${RESET}" - echo -e " ${CYAN}状态:${RESET} ${RED}${LOAD_STATE}${RESET}|${RED}${ACTIVE_STATE}${RESET}|${RED}${SUB_STATE}${RESET}" - fi - done - else - print_section "✅ 失败的服务详情" - echo -e "${BRIGHT_GREEN} 没有失败的服务${RESET}" - fi -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块6: 已屏蔽(Masked)的服务 -# ═══════════════════════════════════════════════════════════════════════════════ -module_masked_services() { - MASKED_COUNT=$(systemctl list-unit-files --type=service --state=masked --no-legend | wc -l) - - print_section "🚫 已屏蔽(Masked)的服务" - print_subitem "已屏蔽服务数" "$MASKED_COUNT" "${PURPLE}" - - if [ "$MASKED_COUNT" -gt 0 ]; then - echo -e "${BRIGHT_CYAN}已屏蔽的服务列表:${RESET}" - systemctl list-unit-files --type=service --state=masked --no-legend | while IFS= read -r line; do - if [ -n "$line" ]; then - echo -e " ${PURPLE}✗${RESET} ${BRIGHT_WHITE}${line}${RESET}" - fi - done - fi -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块7: 运行中的服务 -# ═══════════════════════════════════════════════════════════════════════════════ -module_running_services() { - print_section "🟢 运行中的服务 (Top 20)" - - RUNNING_COUNT=$(systemctl list-units --type=service --state=running --no-legend | wc -l) - print_subitem "运行中服务总数" "$RUNNING_COUNT" "${LIME}" - - echo -e "${BRIGHT_CYAN}运行中的服务列表:${RESET}" - systemctl list-units --type=service --state=running --no-pager --no-legend | head -20 | while IFS= read -r line; do - if [ -n "$line" ]; then - SERVICE_NAME=$(echo "$line" | awk '{print $1}') - LOAD_STATE=$(echo "$line" | awk '{print $2}') - ACTIVE_STATE=$(echo "$line" | awk '{print $3}') - SUB_STATE=$(echo "$line" | awk '{print $4}') - - printf " ${BRIGHT_GREEN}●${RESET} ${BRIGHT_WHITE}%-40s${RESET} ${CYAN}%-8s${RESET} ${BRIGHT_GREEN}%-10s${RESET} ${BRIGHT_CYAN}%s${RESET}\n" "$SERVICE_NAME" "$LOAD_STATE" "$ACTIVE_STATE" "$SUB_STATE" - fi - done -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块8: Timer 定时任务 -# ═══════════════════════════════════════════════════════════════════════════════ -module_timer() { - print_section "⏰ Timer 定时任务" - - TOTAL_TIMERS=$(systemctl list-units --type=timer --all --no-legend | wc -l) - ACTIVE_TIMERS=$(systemctl list-units --type=timer --state=active --no-legend | wc -l) - - print_subitem "总 Timer 数" "$TOTAL_TIMERS" "${BRIGHT_WHITE}" - print_subitem "活跃 Timer" "$ACTIVE_TIMERS" "${LIME}" - - if [ "$ACTIVE_TIMERS" -gt 0 ]; then - echo -e "${BRIGHT_CYAN}活跃的定时任务 (Top 15):${RESET}" - systemctl list-units --type=timer --state=active --no-pager --no-legend | head -15 | while IFS= read -r line; do - if [ -n "$line" ]; then - TIMER_NAME=$(echo "$line" | awk '{print $1}') - NEXT_RUN=$(systemctl show "$TIMER_NAME" --property=NextElapseUSec --value 2>/dev/null) - - printf " ${BRIGHT_YELLOW}⏱${RESET} ${BRIGHT_WHITE}%-40s${RESET} ${CYAN}下次执行:${RESET} ${LIME}%s${RESET}\n" "$TIMER_NAME" "$NEXT_RUN" - fi - done - fi -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块9: Socket 单元 -# ═══════════════════════════════════════════════════════════════════════════════ -module_socket() { - print_section "🔌 Socket 监听" - - TOTAL_SOCKETS=$(systemctl list-units --type=socket --all --no-legend | wc -l) - LISTENING_SOCKETS=$(systemctl list-units --type=socket --state=listening --no-legend | wc -l) - - print_subitem "总 Socket 数" "$TOTAL_SOCKETS" "${BRIGHT_WHITE}" - print_subitem "监听中" "$LISTENING_SOCKETS" "${LIME}" - - if [ "$LISTENING_SOCKETS" -gt 0 ]; then - echo -e "${BRIGHT_CYAN}正在监听的 Socket (Top 15):${RESET}" - systemctl list-units --type=socket --state=listening --no-pager --no-legend | head -15 | while IFS= read -r line; do - if [ -n "$line" ]; then - SOCKET_NAME=$(echo "$line" | awk '{print $1}') - SUB_STATE=$(echo "$line" | awk '{print $4}') - - printf " ${BRIGHT_MAGENTA}🔌${RESET} ${BRIGHT_WHITE}%-40s${RESET} ${CYAN}%s${RESET}\n" "$SOCKET_NAME" "$SUB_STATE" - fi - done - fi -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块10: Target 目标单元 -# ═══════════════════════════════════════════════════════════════════════════════ -module_target() { - print_section "🎯 Target 目标单元" - - ACTIVE_TARGETS=$(systemctl list-units --type=target --state=active --no-legend | wc -l) - print_subitem "当前激活的 Target" "$ACTIVE_TARGETS" "${LIME}" - - echo -e "${BRIGHT_CYAN}重要的 Target 单元:${RESET}" - IMPORTANT_TARGETS=("default.target" "multi-user.target" "graphical.target" "basic.target" "rescue.target" "emergency.target" "network.target" "sysinit.target") - - for target in "${IMPORTANT_TARGETS[@]}"; do - TARGET_STATE=$(systemctl is-active "$target" 2>/dev/null) - TARGET_ENABLED=$(systemctl is-enabled "$target" 2>/dev/null) - - if [ -n "$TARGET_STATE" ]; then - case $TARGET_STATE in - active) STATE_ICON="✅"; STATE_COLOR="${BRIGHT_GREEN}" ;; - inactive) STATE_ICON="⭕"; STATE_COLOR="${BRIGHT_YELLOW}" ;; - *) STATE_ICON="❓"; STATE_COLOR="${BRIGHT_WHITE}" ;; - esac - - printf " ${STATE_ICON} ${BRIGHT_WHITE}%-25s${RESET} ${STATE_COLOR}状态:%-10s${RESET} ${CYAN}启用:%s${RESET}\n" "$target" "$TARGET_STATE" "$TARGET_ENABLED" - fi - done -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块11: Mount 和 Automount 单元 -# ═══════════════════════════════════════════════════════════════════════════════ -module_mount() { - print_section "📁 挂载点(Mount)信息" - - TOTAL_MOUNTS=$(systemctl list-units --type=mount --all --no-legend | wc -l) - ACTIVE_MOUNTS=$(systemctl list-units --type=mount --state=active --no-legend | wc -l) - TOTAL_AUTOMOUNTS=$(systemctl list-units --type=automount --all --no-legend | wc -l) - ACTIVE_AUTOMOUNTS=$(systemctl list-units --type=automount --state=active --no-legend | wc -l) - - print_subitem "挂载点总数" "$TOTAL_MOUNTS" "${BRIGHT_WHITE}" - print_subitem "活跃挂载点" "$ACTIVE_MOUNTS" "${LIME}" - print_subitem "自动挂载总数" "$TOTAL_AUTOMOUNTS" "${BRIGHT_WHITE}" - print_subitem "活跃自动挂载" "$ACTIVE_AUTOMOUNTS" "${LIME}" - - if [ "$ACTIVE_MOUNTS" -gt 0 ]; then - echo -e "${BRIGHT_CYAN}挂载点详情 (Top 10):${RESET}" - systemctl list-units --type=mount --state=active --no-pager --no-legend | head -10 | while IFS= read -r line; do - if [ -n "$line" ]; then - MOUNT_NAME=$(echo "$line" | awk '{print $1}') - MOUNT_POINT=$(systemctl show "$MOUNT_NAME" --property=Where --value 2>/dev/null) - SUB_STATE=$(echo "$line" | awk '{print $4}') - - printf " ${BRIGHT_CYAN}📂${RESET} ${BRIGHT_WHITE}%-35s${RESET} ${PURPLE}%s${RESET}\n" "$MOUNT_POINT" "$SUB_STATE" - fi - done - fi -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块12: Path 单元 -# ═══════════════════════════════════════════════════════════════════════════════ -module_path() { - print_section "📍 Path 路径监控单元" - - TOTAL_PATHS=$(systemctl list-units --type=path --all --no-legend | wc -l) - ACTIVE_PATHS=$(systemctl list-units --type=path --state=active --no-legend | wc -l) - - print_subitem "总 Path 数" "$TOTAL_PATHS" "${BRIGHT_WHITE}" - print_subitem "活跃 Path" "$ACTIVE_PATHS" "${LIME}" - - if [ "$ACTIVE_PATHS" -gt 0 ]; then - echo -e "${BRIGHT_CYAN}活跃的 Path 监控 (Top 10):${RESET}" - systemctl list-units --type=path --state=active --no-pager --no-legend | head -10 | while IFS= read -r line; do - if [ -n "$line" ]; then - PATH_NAME=$(echo "$line" | awk '{print $1}') - SUB_STATE=$(echo "$line" | awk '{print $4}') - PATH_PATH=$(systemctl show "$PATH_NAME" --property=PathExists --value 2>/dev/null) - - printf " ${BRIGHT_CYAN}📍${RESET} ${BRIGHT_WHITE}%-40s${RESET} ${CYAN}监控:${RESET} ${LIME}%s${RESET} ${CYAN}状态:%s${RESET}\n" "$PATH_NAME" "$PATH_PATH" "$SUB_STATE" - fi - done - fi -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块13: Device 单元 -# ═══════════════════════════════════════════════════════════════════════════════ -module_device() { - print_section "🔧 Device 设备单元" - - TOTAL_DEVICES=$(systemctl list-units --type=device --all --no-legend | wc -l) - ACTIVE_DEVICES=$(systemctl list-units --type=device --state=active --no-legend | wc -l) - - print_subitem "总设备数" "$TOTAL_DEVICES" "${BRIGHT_WHITE}" - print_subitem "活跃设备" "$ACTIVE_DEVICES" "${LIME}" - - if [ "$ACTIVE_DEVICES" -gt 0 ]; then - echo -e "${BRIGHT_CYAN}活跃的设备 (Top 10):${RESET}" - systemctl list-units --type=device --state=active --no-pager --no-legend | head -10 | while IFS= read -r line; do - if [ -n "$line" ]; then - DEVICE_NAME=$(echo "$line" | awk '{print $1}') - SUB_STATE=$(echo "$line" | awk '{print $4}') - - printf " ${BRIGHT_YELLOW}🔧${RESET} ${BRIGHT_WHITE}%-45s${RESET} ${LIME}%s${RESET}\n" "$DEVICE_NAME" "$SUB_STATE" - fi - done - fi -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块14: Scope 和 Slice 单元 -# ═══════════════════════════════════════════════════════════════════════════════ -module_scope_slice() { - print_section "📊 Scope 和 Slice 资源控制单元" - - # Scope 单元 - TOTAL_SCOPES=$(systemctl list-units --type=scope --all --no-legend | wc -l) - ACTIVE_SCOPES=$(systemctl list-units --type=scope --state=running --no-legend | wc -l) - - print_subitem "Scope 总数" "$TOTAL_SCOPES" "${BRIGHT_WHITE}" - print_subitem "运行中 Scope" "$ACTIVE_SCOPES" "${LIME}" - - if [ "$ACTIVE_SCOPES" -gt 0 ]; then - echo -e "${BRIGHT_CYAN}运行中的 Scope (Top 10):${RESET}" - systemctl list-units --type=scope --state=running --no-pager --no-legend | head -10 | while IFS= read -r line; do - if [ -n "$line" ]; then - SCOPE_NAME=$(echo "$line" | awk '{print $1}') - SUB_STATE=$(echo "$line" | awk '{print $4}') - - printf " ${BRIGHT_CYAN}📊${RESET} ${BRIGHT_WHITE}%-45s${RESET} ${LIME}%s${RESET}\n" "$SCOPE_NAME" "$SUB_STATE" - fi - done - fi - - # Slice 单元 - echo "" - TOTAL_SLICES=$(systemctl list-units --type=slice --all --no-legend | wc -l) - ACTIVE_SLICES=$(systemctl list-units --type=slice --state=active --no-legend | wc -l) - - print_subitem "Slice 总数" "$TOTAL_SLICES" "${BRIGHT_WHITE}" - print_subitem "活跃 Slice" "$ACTIVE_SLICES" "${LIME}" - - if [ "$ACTIVE_SLICES" -gt 0 ]; then - echo -e "${BRIGHT_CYAN}活跃的 Slice:${RESET}" - systemctl list-units --type=slice --state=active --no-pager --no-legend | head -10 | while IFS= read -r line; do - if [ -n "$line" ]; then - SLICE_NAME=$(echo "$line" | awk '{print $1}') - SUB_STATE=$(echo "$line" | awk '{print $4}') - MEMORY=$(systemctl show "$SLICE_NAME" --property=MemoryCurrent --value 2>/dev/null) - if [ -n "$MEMORY" ] && [ "$MEMORY" != "[not set]" ]; then - MEMORY_DISPLAY="内存: $(numfmt --to=iec $MEMORY 2>/dev/null || echo $MEMORY)" - else - MEMORY_DISPLAY="" - fi - - printf " ${BRIGHT_MAGENTA}📦${RESET} ${BRIGHT_WHITE}%-30s${RESET} ${LIME}%s${RESET} %s\n" "$SLICE_NAME" "$SUB_STATE" "$MEMORY_DISPLAY" - fi - done - fi -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块15: 依赖关系 -# ═══════════════════════════════════════════════════════════════════════════════ -module_dependencies() { - print_section "🔗 系统依赖关系" - - # 显示默认.target的依赖树 - DEFAULT_TARGET=$(systemctl get-default) - - echo -e "${BRIGHT_CYAN}默认目标 '$DEFAULT_TARGET' 的依赖 (前15个):${RESET}" - systemctl list-dependencies "$DEFAULT_TARGET" --no-pager --no-legend | head -15 | while IFS= read -r line; do - if [ -n "$line" ]; then - UNIT_TYPE=$(echo "$line" | grep -o '\.[a-z]*$' | tr -d '.') - case "$UNIT_TYPE" in - service) ICON="🔌" ;; - target) ICON="🎯" ;; - timer) ICON="⏰" ;; - socket) ICON="🔌" ;; - mount) ICON="📁" ;; - path) ICON="📍" ;; - *) ICON="📄" ;; - esac - printf " ${ICON} ${BRIGHT_WHITE}%s${RESET}\n" "$line" - fi - done - - # 显示被依赖最多的服务 - echo "" - echo -e "${BRIGHT_CYAN}系统关键.target的依赖数量:${RESET}" - for target in "multi-user.target" "graphical.target" "basic.target" "network.target"; do - DEP_COUNT=$(systemctl list-dependencies "$target" --no-legend 2>/dev/null | wc -l) - if [ -n "$DEP_COUNT" ]; then - print_subitem "$target" "$DEP_COUNT 个依赖" "${BRIGHT_CYAN}" - fi - done -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块16: Systemd 日志信息 -# ═══════════════════════════════════════════════════════════════════════════════ -module_journal() { - print_section "📝 Systemd Journal 日志摘要" - - JOURNAL_SIZE=$(journalctl --disk-usage | grep "Journals use" | awk '{print $3,$4}') - JOURNAL_ENTRIES=$(journalctl --no-pager -n 0 2>/dev/null | wc -l) - - print_subitem "日志磁盘占用" "$JOURNAL_SIZE" "${ORANGE}" - print_subitem "日志总条目" "$JOURNAL_ENTRIES" "${BRIGHT_CYAN}" - - echo -e "${BRIGHT_CYAN}最近的错误日志 (最近5条):${RESET}" - journalctl -p err -n 5 --no-pager 2>/dev/null | while IFS= read -r line; do - if [ -n "$line" ]; then - echo -e " ${RED}✗${RESET} ${WHITE}${line}${RESET}" - fi - done - - echo -e "${BRIGHT_CYAN}最近的警告日志 (最近3条):${RESET}" - journalctl -p warning -n 3 --no-pager 2>/dev/null | while IFS= read -r line; do - if [ -n "$line" ]; then - echo -e " ${YELLOW}⚠${RESET} ${WHITE}${line}${RESET}" - fi - done -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块17: Systemd 环境变量 -# ═══════════════════════════════════════════════════════════════════════════════ -module_environment() { - print_section "🔧 Systemd 环境变量" - - ENV_COUNT=$(systemctl show-environment 2>/dev/null | wc -l) - print_subitem "环境变量数量" "$ENV_COUNT" "${BRIGHT_CYAN}" - - echo -e "${BRIGHT_CYAN}系统环境变量:${RESET}" - systemctl show-environment 2>/dev/null | head -15 | while IFS= read -r line; do - if [ -n "$line" ]; then - KEY=$(echo "$line" | cut -d'=' -f1) - VALUE=$(echo "$line" | cut -d'=' -f2-) - echo -e " ${BRIGHT_YELLOW}●${RESET} ${BRIGHT_CYAN}${KEY}${RESET}=${BRIGHT_WHITE}${VALUE}${RESET}" - fi - done -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块18: Cgroup 信息 -# ═══════════════════════════════════════════════════════════════════════════════ -module_cgroup() { - print_section "🧊 Cgroup 信息" - - # 获取 cgroup 版本 - if [ -f /sys/fs/cgroup/cgroup.controllers ]; then - CGROUP_VERSION="v2 (unified)" - else - CGROUP_VERSION="v1 (legacy)" - fi - - print_subitem "Cgroup 版本" "$CGROUP_VERSION" "${BRIGHT_CYAN}" - - # 获取控制器信息 - if [ -f /sys/fs/cgroup/cgroup.controllers ]; then - CONTROLLERS=$(cat /sys/fs/cgroup/cgroup.controllers 2>/dev/null | tr ' ' ', ') - else - CONTROLLERS=$(cat /sys/fs/cgroup/devices.list 2>/dev/null | head -1 | cut -d' ' -f1 || echo "N/A") - fi - print_subitem "可用控制器" "$CONTROLLERS" "${LIME}" - - # Slice 资源统计 - echo -e "${BRIGHT_CYAN}Slice 资源使用 (Top 5):${RESET}" - for slice in $(systemctl list-units --type=slice --state=active --no-legend | awk '{print $1}' | head -5); do - MEM_CURRENT=$(systemctl show "$slice" --property=MemoryCurrent --value 2>/dev/null) - MEM_MAX=$(systemctl show "$slice" --property=MemoryMax --value 2>/dev/null) - CPU_WEIGHT=$(systemctl show "$slice" --property=CPUWeight --value 2>/dev/null) - - MEM_DISP="" - if [ -n "$MEM_CURRENT" ] && [ "$MEM_CURRENT" != "[not set]" ]; then - MEM_DISP="内存: $(numfmt --to=iec $MEM_CURRENT 2>/dev/null || echo $MEM_CURRENT)" - fi - if [ -n "$CPU_WEIGHT" ] && [ "$CPU_WEIGHT" != "[not set]" ]; then - MEM_DISP="$MEM_DISP CPU权重: $CPU_WEIGHT" - fi - - if [ -n "$MEM_DISP" ]; then - printf " ${BRIGHT_MAGENTA}📦${RESET} ${BRIGHT_WHITE}%-25s${RESET} ${LIME}%s${RESET}\n" "$slice" "$MEM_DISP" - fi - done -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块19: 系统性能信息 -# ═══════════════════════════════════════════════════════════════════════════════ -module_performance() { - print_section "📊 系统性能信息" - - # 获取 CPU 使用率 - CPU_USAGE=$(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1) - - # 获取内存信息 - MEM_INFO=$(free -h | grep "Mem:") - MEM_TOTAL=$(echo "$MEM_INFO" | awk '{print $2}') - MEM_USED=$(echo "$MEM_INFO" | awk '{print $3}') - MEM_FREE=$(echo "$MEM_INFO" | awk '{print $4}') - MEM_PERCENT=$(free | grep "Mem:" | awk '{printf "%.1f", $3/$2*100}') - - # 获取启动时间 - BOOT_TIME_SEC=$(systemctl show --property=UserspaceTimestampMonotonic --value | cut -d' ' -f1) - BOOT_TIME_SEC=${BOOT_TIME_SEC:-0} - BOOT_TIME_SEC=$((BOOT_TIME_SEC / 1000000)) - - print_subitem "CPU 使用率" "${CPU_USAGE}%" "${BRIGHT_YELLOW}" - print_subitem "内存总量" "$MEM_TOTAL" "${BRIGHT_CYAN}" - print_subitem "已用内存" "$MEM_USED (${MEM_PERCENT}%)" "${ORANGE}" - print_subitem "可用内存" "$MEM_FREE" "${LIME}" - print_subitem "启动耗时" "${BOOT_TIME_SEC} 秒" "${PURPLE}" - - # Swap 信息 - SWAP_TOTAL=$(free -h | grep "Swap:" | awk '{print $2}') - SWAP_USED=$(free -h | grep "Swap:" | awk '{print $3}') - SWAP_FREE=$(free -h | grep "Swap:" | awk '{print $4}') - - if [ "$SWAP_TOTAL" != "0" ]; then - print_subitem "Swap总量" "$SWAP_TOTAL" "${PINK}" - print_subitem "Swap已用" "$SWAP_USED" "${PINK}" - print_subitem "Swap可用" "$SWAP_FREE" "${PINK}" - fi -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块20: 电源管理状态 -# ═══════════════════════════════════════════════════════════════════════════════ -module_power_management() { - print_section "🔋 电源管理状态" - - # 检查 systemd-suspend 服务 - if systemctl list-unit-files | grep -q "systemd-suspend.service"; then - SUSPEND_STATE=$(systemctl is-enabled systemd-suspend.service 2>/dev/null || echo "N/A") - print_subitem "Suspend 服务" "$SUSPEND_STATE" "${BRIGHT_CYAN}" - fi - - # 检查 systemd-hibernate 服务 - if systemctl list-unit-files | grep -q "systemd-hibernate.service"; then - HIBERNATE_STATE=$(systemctl is-enabled systemd-hibernate.service 2>/dev/null || echo "N/A") - print_subitem "Hibernate 服务" "$HIBERNATE_STATE" "${BRIGHT_CYAN}" - fi - - # 检查 logind 状态 - if systemctl list-unit-files | grep -q "systemd-logind.service"; then - LOGIND_STATE=$(systemctl is-active systemd-logind.service 2>/dev/null || echo "N/A") - print_subitem "Logind 状态" "$LOGIND_STATE" "${LIME}" - fi - - # 显示电源相关事件 - echo -e "${BRIGHT_CYAN}最近的电源相关日志:${RESET}" - journalctl -u systemd-logind -u upower -n 3 --no-pager 2>/dev/null | while IFS= read -r line; do - if [ -n "$line" ]; then - echo -e " ${CYAN}▸${RESET} ${WHITE}${line}${RESET}" - fi - done -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块21: 关键服务状态 -# ═══════════════════════════════════════════════════════════════════════════════ -module_critical_services() { - print_section "🔑 关键系统服务状态" - - KEY_SERVICES=( - "sshd.service" - "NetworkManager.service" - "cron.service" - "rsyslog.service" - "dbus.service" - "systemd-logind.service" - "systemd-journald.service" - "systemd-udevd.service" - "polkit.service" - ) - - for service in "${KEY_SERVICES[@]}"; do - if systemctl list-unit-files 2>/dev/null | grep -q "$service"; then - SERVICE_STATE=$(systemctl is-active "$service" 2>/dev/null) - SERVICE_ENABLED=$(systemctl is-enabled "$service" 2>/dev/null) - - case $SERVICE_STATE in - active) STATE_ICON="✅"; STATE_COLOR="${BRIGHT_GREEN}" ;; - inactive) STATE_ICON="⭕"; STATE_COLOR="${BRIGHT_YELLOW}" ;; - failed) STATE_ICON="❌"; STATE_COLOR="${BRIGHT_RED}" ;; - *) STATE_ICON="❓"; STATE_COLOR="${BRIGHT_WHITE}" ;; - esac - - printf " ${STATE_ICON} ${BRIGHT_WHITE}%-30s${RESET} ${STATE_COLOR}%-10s${RESET} ${CYAN}启用:%s${RESET}\n" "$service" "$SERVICE_STATE" "$SERVICE_ENABLED" - fi - done -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 模块22: 常用命令提示 -# ═══════════════════════════════════════════════════════════════════════════════ -module_help() { - print_section "💡 常用 Systemctl 命令" - - echo -e "${BRIGHT_YELLOW}=== 服务管理 ===${RESET}" - echo -e "${BRIGHT_WHITE}systemctl status ${RESET} - 查看服务状态" - echo -e "${BRIGHT_WHITE}systemctl start ${RESET} - 启动服务" - echo -e "${BRIGHT_WHITE}systemctl stop ${RESET} - 停止服务" - echo -e "${BRIGHT_WHITE}systemctl restart ${RESET} - 重启服务" - echo -e "${BRIGHT_WHITE}systemctl enable ${RESET} - 启用开机自启" - echo -e "${BRIGHT_WHITE}systemctl disable ${RESET} - 禁用开机自启" - echo -e "${BRIGHT_WHITE}systemctl mask ${RESET} - 屏蔽服务" - echo -e "${BRIGHT_WHITE}systemctl unmask ${RESET} - 取消屏蔽" - - echo -e "${BRIGHT_YELLOW}=== 状态查看 ===${RESET}" - echo -e "${BRIGHT_WHITE}systemctl is-active ${RESET} - 检查服务是否活跃" - echo -e "${BRIGHT_WHITE}systemctl is-enabled ${RESET} - 检查服务是否启用" - echo -e "${BRIGHT_WHITE}systemctl --failed${RESET} - 查看失败的服务" - echo -e "${BRIGHT_WHITE}systemctl list-dependencies ${RESET} - 查看依赖" - - echo -e "${BRIGHT_YELLOW}=== 日志查看 ===${RESET}" - echo -e "${BRIGHT_WHITE}journalctl -u ${RESET} - 查看服务日志" - echo -e "${BRIGHT_WHITE}journalctl -xe${RESET} - 查看最近日志" - echo -e "${BRIGHT_WHITE}journalctl -p err${RESET} - 查看错误日志" - - echo -e "${BRIGHT_YELLOW}=== 电源管理 ===${RESET}" - echo -e "${BRIGHT_WHITE}systemctl suspend${RESET} - 挂起" - echo -e "${BRIGHT_WHITE}systemctl hibernate${RESET} - 休眠" - echo -e "${BRIGHT_WHITE}systemctl reboot${RESET} - 重启" - echo -e "${BRIGHT_WHITE}systemctl poweroff${RESET} - 关机" -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# 主函数 - 模块调度 -# ═══════════════════════════════════════════════════════════════════════════════ -main() { - print_header - - # 基础信息模块 - module_systemd_version - module_system_info - module_systemd_status - - # 单元统计模块 - module_service_stats - module_running_services - module_failed_services - module_masked_services - - # 各类单元模块 - module_timer - module_socket - module_target - module_mount - module_path - module_device - module_scope_slice - - # 系统信息模块 - module_dependencies - module_journal - module_environment - module_cgroup - module_performance - module_power_management - - # 服务状态模块 - module_critical_services - - # 帮助信息 - module_help - - # 结束 - echo -e "${DASH_SEPARATOR}" - echo -e "${BRIGHT_MAGENTA}✨ 信息收集完成!时间: $(date '+%Y-%m-%d %H:%M:%S') ✨${RESET}" - echo -e "${DASH_SEPARATOR}" -} - -# 执行主函数 -main "@" \ No newline at end of file diff --git a/mengyaconnect-backend/db.go b/mengyaconnect-backend/db.go new file mode 100644 index 0000000..1899bf0 --- /dev/null +++ b/mengyaconnect-backend/db.go @@ -0,0 +1,186 @@ +package main + +import ( + "database/sql" + "encoding/json" + "log" + "os" + "path/filepath" + "strings" + + _ "modernc.org/sqlite" +) + +// DB is the global SQLite connection shared by all handlers. +var DB *sql.DB + +const schema = ` +CREATE TABLE IF NOT EXISTS ssh_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + alias TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 22, + username TEXT NOT NULL, + password TEXT NOT NULL DEFAULT '', + private_key TEXT NOT NULL DEFAULT '', + passphrase TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS commands ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + alias TEXT NOT NULL, + command TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS scripts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + content TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +` + +// initDB opens the SQLite database, applies PRAGMAs and creates tables. +func initDB() error { + if err := os.MkdirAll(dataBasePath(), 0o750); err != nil { + return err + } + db, err := sql.Open("sqlite", dbPath()) + if err != nil { + return err + } + // Single writer; multiple readers are fine via WAL. + db.SetMaxOpenConns(1) + if _, err := db.Exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;`); err != nil { + return err + } + if _, err := db.Exec(schema); err != nil { + return err + } + DB = db + log.Printf("SQLite database ready: %s", dbPath()) + return nil +} + +// ─── One-time migration from legacy file storage ───────────────────────────── + +// migrateFromFiles reads legacy JSON/text files and inserts their data into +// SQLite (INSERT OR IGNORE so duplicates are skipped). After migration each +// source file is renamed to *.migrated so the routine is not repeated. +func migrateFromFiles() { + migrateSSHProfiles() + migrateCommands() + migrateScripts() +} + +func migrateSSHProfiles() { + dir := filepath.Join(dataBasePath(), "ssh") + entries, err := os.ReadDir(dir) + if err != nil { + return // directory doesn't exist — nothing to migrate + } + migrated := 0 + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + path := filepath.Join(dir, e.Name()) + raw, err := os.ReadFile(path) + if err != nil { + log.Printf("migrate ssh: read %s: %v", path, err) + continue + } + var p SSHProfile + if err := json.Unmarshal(raw, &p); err != nil { + log.Printf("migrate ssh: parse %s: %v", path, err) + continue + } + name := strings.TrimSuffix(e.Name(), ".json") + if p.Port == 0 { + p.Port = 22 + } + _, err = DB.Exec( + `INSERT OR IGNORE INTO ssh_profiles (name,alias,host,port,username,password,private_key,passphrase) + VALUES (?,?,?,?,?,?,?,?)`, + name, p.Alias, p.Host, p.Port, p.Username, p.Password, p.PrivateKey, p.Passphrase, + ) + if err != nil { + log.Printf("migrate ssh: insert %s: %v", name, err) + continue + } + _ = os.Rename(path, path+".migrated") + migrated++ + } + if migrated > 0 { + log.Printf("migrate: imported %d SSH profile(s) from %s", migrated, dir) + } +} + +func migrateCommands() { + path := filepath.Join(dataBasePath(), "command", "command.json") + raw, err := os.ReadFile(path) + if err != nil { + return + } + var cmds []Command + if err := json.Unmarshal(raw, &cmds); err != nil { + log.Printf("migrate commands: parse %s: %v", path, err) + return + } + // Check if commands table is already populated to avoid duplicating on restart. + var count int + _ = DB.QueryRow(`SELECT COUNT(*) FROM commands`).Scan(&count) + if count > 0 { + log.Printf("migrate commands: table not empty, skipping file migration") + _ = os.Rename(path, path+".migrated") + return + } + for i, cmd := range cmds { + if _, err := DB.Exec( + `INSERT INTO commands (alias, command, sort_order) VALUES (?,?,?)`, + cmd.Alias, cmd.Command, i, + ); err != nil { + log.Printf("migrate commands: insert [%d]: %v", i, err) + } + } + _ = os.Rename(path, path+".migrated") + log.Printf("migrate: imported %d command(s) from %s", len(cmds), path) +} + +func migrateScripts() { + dir := filepath.Join(dataBasePath(), "script") + entries, err := os.ReadDir(dir) + if err != nil { + return + } + migrated := 0 + for _, e := range entries { + if e.IsDir() { + continue + } + path := filepath.Join(dir, e.Name()) + content, err := os.ReadFile(path) + if err != nil { + log.Printf("migrate scripts: read %s: %v", path, err) + continue + } + if _, err := DB.Exec( + `INSERT OR IGNORE INTO scripts (name, content) VALUES (?,?)`, + e.Name(), string(content), + ); err != nil { + log.Printf("migrate scripts: insert %s: %v", e.Name(), err) + continue + } + _ = os.Rename(path, path+".migrated") + migrated++ + } + if migrated > 0 { + log.Printf("migrate: imported %d script(s) from %s", migrated, dir) + } +} diff --git a/mengyaconnect-backend/docker-compose.yml b/mengyaconnect-backend/docker-compose.yml index aa3ab1b..98ef8ae 100644 --- a/mengyaconnect-backend/docker-compose.yml +++ b/mengyaconnect-backend/docker-compose.yml @@ -2,6 +2,7 @@ version: "3.9" services: backend: + # 镜像在构建阶段按宿主机架构编译;勿写死 platform: linux/amd64,否则 ARM 云主机无 QEMU 时会 exec format error build: context: . dockerfile: Dockerfile diff --git a/mengyaconnect-backend/go.mod b/mengyaconnect-backend/go.mod index d3099da..2573ba9 100644 --- a/mengyaconnect-backend/go.mod +++ b/mengyaconnect-backend/go.mod @@ -1,6 +1,6 @@ module mengyaconnect-backend -go 1.21 +go 1.25.0 require ( github.com/gin-gonic/gin v1.10.0 @@ -13,25 +13,33 @@ require ( github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.15.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.72.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.50.0 // indirect ) diff --git a/mengyaconnect-backend/go.sum b/mengyaconnect-backend/go.sum index 5931dcc..34be42f 100644 --- a/mengyaconnect-backend/go.sum +++ b/mengyaconnect-backend/go.sum @@ -9,6 +9,8 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= @@ -28,6 +30,8 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -45,10 +49,14 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -76,6 +84,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= @@ -89,5 +99,13 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= +modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM= +modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/mengyaconnect-backend/main.go b/mengyaconnect-backend/main.go index 2366a63..74e1b55 100644 --- a/mengyaconnect-backend/main.go +++ b/mengyaconnect-backend/main.go @@ -2,14 +2,15 @@ package main import ( "context" - "encoding/json" + "crypto/rand" + "database/sql" + "encoding/hex" "errors" "fmt" "io" "log" "net/http" "os" - "path/filepath" "strconv" "strings" "sync" @@ -23,12 +24,12 @@ import ( // ─── 持久化数据类型 ─────────────────────────────────────────────── type SSHProfile struct { - Name string `json:"name,omitempty"` // 文件名(不含 .json) - Alias string `json:"alias"` - Host string `json:"host"` - Port int `json:"port"` - Username string `json:"username"` - Password string `json:"password,omitempty"` + Name string `json:"name,omitempty"` + Alias string `json:"alias"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password,omitempty"` PrivateKey string `json:"privateKey,omitempty"` Passphrase string `json:"passphrase,omitempty"` } @@ -43,7 +44,7 @@ type ScriptInfo struct { Content string `json:"content,omitempty"` } -// 配置与数据目录辅助函数见 config.go +// 配置与数据库辅助函数见 config.go / db.go type wsMessage struct { Type string `json:"type"` @@ -71,7 +72,84 @@ func (w *wsWriter) send(msg wsMessage) { _ = w.conn.WriteJSON(msg) } +// ─── 会话令牌(服务启动时随机生成一次)──────────────────────────────── + +var sessionToken string + +func initSessionToken() { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + log.Fatalf("failed to generate session token: %v", err) + } + sessionToken = hex.EncodeToString(b) +} + +// authMiddleware 校验请求中携带的令牌。 +// WebSocket 不支持自定义 Header,因此同时接受 ?token= 查询参数。 +func authMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + token := "" + if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") { + token = strings.TrimPrefix(auth, "Bearer ") + } + if token == "" { + token = c.Query("token") + } + if token != sessionToken { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + c.Next() + } +} + +// POST /api/auth/login — 用密码换取令牌 +func handleLogin(c *gin.Context) { + var body struct { + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + if body.Password != accessPassword() { + c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": gin.H{"token": sessionToken}}) +} + +// GET /api/auth/verify — 校验令牌是否有效(受 authMiddleware 保护) +func handleVerify(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"data": gin.H{"valid": true}}) +} + +// GET / — 服务说明(无需鉴权,便于网关 / 探活) +func handleAPIRoot(c *gin.Context) { + loc := time.FixedZone("CST", 8*3600) + c.JSON(http.StatusOK, gin.H{ + "description": "提供 Web SSH 终端与跳板连接(SSH 由服务端发起);浏览器访问需先 POST /api/auth/login 换取令牌,后续 API 与 WebSocket 携带 Bearer 或 ?token=", + "endpoints": gin.H{ + "health": "/health", + "auth": "/api/auth/login (POST,公开), /api/auth/verify (GET,需令牌)", + "websocket_ssh": "/api/ws/ssh (GET 升级 WebSocket,需令牌;查询参数 token= 或 Header)", + "ssh_profiles": "/api/ssh, /api/ssh/:name (CRUD,需令牌)", + "commands": "/api/commands, /api/commands/:index (CRUD,需令牌)", + "scripts": "/api/scripts, /api/scripts/:name (CRUD,需令牌)", + }, + "message": "萌芽 SSH MengyaConnect 后端 API 服务运行中", + "timestamp": time.Now().In(loc).Format(time.RFC3339), + "version": apiVersion(), + }) +} + func main() { + initSessionToken() + if err := initDB(); err != nil { + log.Fatalf("db init: %v", err) + } + migrateFromFiles() + if mode := os.Getenv("GIN_MODE"); mode != "" { gin.SetMode(mode) } @@ -88,36 +166,43 @@ func main() { }, } - // ─── 基本配置 CRUD ────────────────────────────────────────── + // ─── 不需要鉴权的路由 ──────────────────────────────────────── + router.GET("/", handleAPIRoot) router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "ok", "time": time.Now().Format(time.RFC3339), }) }) + router.POST("/api/auth/login", handleLogin) - router.GET("/api/ws/ssh", func(c *gin.Context) { + // ─── 需要鉴权的路由 ────────────────────────────────────────── + auth := router.Group("/api", authMiddleware()) + + auth.GET("/auth/verify", handleVerify) + + auth.GET("/ws/ssh", func(c *gin.Context) { handleSSHWebSocket(c, upgrader) }) - // ─── SSH 配置 CRUD ────────────────────────────────────────── - router.GET("/api/ssh", handleListSSH) - router.POST("/api/ssh", handleCreateSSH) - router.PUT("/api/ssh/:name", handleUpdateSSH) - router.DELETE("/api/ssh/:name", handleDeleteSSH) + // SSH 配置 CRUD + auth.GET("/ssh", handleListSSH) + auth.POST("/ssh", handleCreateSSH) + auth.PUT("/ssh/:name", handleUpdateSSH) + auth.DELETE("/ssh/:name", handleDeleteSSH) - // ─── 快捷命令 CRUD ───────────────────────────────────────── - router.GET("/api/commands", handleListCommands) - router.POST("/api/commands", handleCreateCommand) - router.PUT("/api/commands/:index", handleUpdateCommand) - router.DELETE("/api/commands/:index", handleDeleteCommand) + // 快捷命令 CRUD + auth.GET("/commands", handleListCommands) + auth.POST("/commands", handleCreateCommand) + auth.PUT("/commands/:index", handleUpdateCommand) + auth.DELETE("/commands/:index", handleDeleteCommand) - // ─── 脚本 CRUD ───────────────────────────────────────────── - router.GET("/api/scripts", handleListScripts) - router.GET("/api/scripts/:name", handleGetScript) - router.POST("/api/scripts", handleCreateScript) - router.PUT("/api/scripts/:name", handleUpdateScript) - router.DELETE("/api/scripts/:name", handleDeleteScript) + // 脚本 CRUD + auth.GET("/scripts", handleListScripts) + auth.GET("/scripts/:name", handleGetScript) + auth.POST("/scripts", handleCreateScript) + auth.PUT("/scripts/:name", handleUpdateScript) + auth.DELETE("/scripts/:name", handleDeleteScript) addr := getEnv("ADDR", ":"+getEnv("PORT", "8080")) server := &http.Server{ @@ -127,6 +212,7 @@ func main() { } log.Printf("SSH WebSocket server listening on %s", addr) + log.Printf("Access password configured (use ACCESS_PASSWORD env to override)") if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("server error: %v", err) } @@ -365,41 +451,33 @@ func streamToWebSocket(ctx context.Context, writer *wsWriter, reader io.Reader) } } -// CORS、中间件与环境变量工具函数见 config.go // ═══════════════════════════════════════════════════════════════════ // SSH 配置 CRUD // ═══════════════════════════════════════════════════════════════════ -// GET /api/ssh — 列出所有 SSH 配置 +// GET /api/ssh func handleListSSH(c *gin.Context) { - entries, err := os.ReadDir(sshDir()) + rows, err := DB.Query( + `SELECT name,alias,host,port,username,password,private_key,passphrase + FROM ssh_profiles ORDER BY id`) if err != nil { - c.JSON(http.StatusOK, gin.H{"data": []SSHProfile{}}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "db query failed"}) return } - var profiles []SSHProfile - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { - continue - } - raw, err := os.ReadFile(filepath.Join(sshDir(), e.Name())) - if err != nil { - continue - } + defer rows.Close() + profiles := []SSHProfile{} + for rows.Next() { var p SSHProfile - if err := json.Unmarshal(raw, &p); err != nil { + if err := rows.Scan(&p.Name, &p.Alias, &p.Host, &p.Port, + &p.Username, &p.Password, &p.PrivateKey, &p.Passphrase); err != nil { continue } - p.Name = strings.TrimSuffix(e.Name(), ".json") profiles = append(profiles, p) } - if profiles == nil { - profiles = []SSHProfile{} - } c.JSON(http.StatusOK, gin.H{"data": profiles}) } -// POST /api/ssh — 新建 SSH 配置 +// POST /api/ssh func handleCreateSSH(c *gin.Context) { var p SSHProfile if err := c.ShouldBindJSON(&p); err != nil { @@ -410,33 +488,39 @@ func handleCreateSSH(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "alias、host 和 username 为必填项"}) return } - name := p.Name + name := strings.TrimSpace(p.Name) if name == "" { name = p.Alias } - safe, err := sanitizeName(strings.ReplaceAll(name, " ", "-")) - if err != nil { + name = strings.ReplaceAll(name, " ", "-") + if err := validateName(name); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } - p.Name = "" - raw, _ := json.MarshalIndent(p, "", " ") - if err := os.MkdirAll(sshDir(), 0o750); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dir"}) + if p.Port == 0 { + p.Port = 22 + } + _, err := DB.Exec( + `INSERT INTO ssh_profiles (name,alias,host,port,username,password,private_key,passphrase) + VALUES (?,?,?,?,?,?,?,?)`, + name, p.Alias, p.Host, p.Port, p.Username, p.Password, p.PrivateKey, p.Passphrase, + ) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + c.JSON(http.StatusConflict, gin.H{"error": "name already exists"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db insert failed"}) + } return } - if err := os.WriteFile(filepath.Join(sshDir(), safe+".json"), raw, 0o600); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write file"}) - return - } - p.Name = safe + p.Name = name c.JSON(http.StatusOK, gin.H{"data": p}) } -// PUT /api/ssh/:name — 更新 SSH 配置 +// PUT /api/ssh/:name func handleUpdateSSH(c *gin.Context) { - name, err := sanitizeName(c.Param("name")) - if err != nil { + name := c.Param("name") + if err := validateName(name); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } @@ -449,34 +533,42 @@ func handleUpdateSSH(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "alias、host 和 username 为必填项"}) return } - p.Name = "" - raw, _ := json.MarshalIndent(p, "", " ") - filePath := filepath.Join(sshDir(), name+".json") - if _, err := os.Stat(filePath); os.IsNotExist(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + if p.Port == 0 { + p.Port = 22 + } + res, err := DB.Exec( + `UPDATE ssh_profiles + SET alias=?,host=?,port=?,username=?,password=?,private_key=?,passphrase=?, + updated_at=CURRENT_TIMESTAMP + WHERE name=?`, + p.Alias, p.Host, p.Port, p.Username, p.Password, p.PrivateKey, p.Passphrase, name, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db update failed"}) return } - if err := os.WriteFile(filePath, raw, 0o600); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write file"}) + if n, _ := res.RowsAffected(); n == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } p.Name = name c.JSON(http.StatusOK, gin.H{"data": p}) } -// DELETE /api/ssh/:name — 删除 SSH 配置 +// DELETE /api/ssh/:name func handleDeleteSSH(c *gin.Context) { - name, err := sanitizeName(c.Param("name")) - if err != nil { + name := c.Param("name") + if err := validateName(name); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } - if err := os.Remove(filepath.Join(sshDir(), name+".json")); err != nil { - if os.IsNotExist(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - } else { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"}) - } + res, err := DB.Exec(`DELETE FROM ssh_profiles WHERE name=?`, name) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db delete failed"}) + return + } + if n, _ := res.RowsAffected(); n == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } c.JSON(http.StatusOK, gin.H{"message": "deleted"}) @@ -486,37 +578,49 @@ func handleDeleteSSH(c *gin.Context) { // 快捷命令 CRUD // ═══════════════════════════════════════════════════════════════════ -func readCommands() ([]Command, error) { - raw, err := os.ReadFile(cmdFilePath()) +func queryAllCommands() ([]Command, error) { + rows, err := DB.Query(`SELECT alias,command FROM commands ORDER BY sort_order, id`) if err != nil { - if os.IsNotExist(err) { - return []Command{}, nil - } return nil, err } - var cmds []Command - if err := json.Unmarshal(raw, &cmds); err != nil { - return nil, err + defer rows.Close() + cmds := []Command{} + for rows.Next() { + var cmd Command + if err := rows.Scan(&cmd.Alias, &cmd.Command); err != nil { + return nil, err + } + cmds = append(cmds, cmd) } return cmds, nil } -func writeCommands(cmds []Command) error { - raw, err := json.MarshalIndent(cmds, "", " ") +// commandIDAtIndex returns the primary-key id of the command at 0-based position idx. +func commandIDAtIndex(idx int) (int64, error) { + rows, err := DB.Query(`SELECT id FROM commands ORDER BY sort_order, id`) if err != nil { - return err + return 0, err } - if err := os.MkdirAll(filepath.Dir(cmdFilePath()), 0o750); err != nil { - return err + defer rows.Close() + pos := 0 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return 0, err + } + if pos == idx { + return id, nil + } + pos++ } - return os.WriteFile(cmdFilePath(), raw, 0o600) + return 0, sql.ErrNoRows } // GET /api/commands func handleListCommands(c *gin.Context) { - cmds, err := readCommands() + cmds, err := queryAllCommands() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read commands"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "db query failed"}) return } c.JSON(http.StatusOK, gin.H{"data": cmds}) @@ -533,16 +637,16 @@ func handleCreateCommand(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "alias 和 command 为必填项"}) return } - cmds, err := readCommands() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read commands"}) - return - } - cmds = append(cmds, cmd) - if err := writeCommands(cmds); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save commands"}) + var maxOrder int + _ = DB.QueryRow(`SELECT COALESCE(MAX(sort_order),0) FROM commands`).Scan(&maxOrder) + if _, err := DB.Exec( + `INSERT INTO commands (alias,command,sort_order) VALUES (?,?,?)`, + cmd.Alias, cmd.Command, maxOrder+1, + ); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db insert failed"}) return } + cmds, _ := queryAllCommands() c.JSON(http.StatusOK, gin.H{"data": cmds}) } @@ -562,20 +666,16 @@ func handleUpdateCommand(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "alias 和 command 为必填项"}) return } - cmds, err := readCommands() + id, err := commandIDAtIndex(idx) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read commands"}) - return - } - if idx >= len(cmds) { c.JSON(http.StatusNotFound, gin.H{"error": "index out of range"}) return } - cmds[idx] = cmd - if err := writeCommands(cmds); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save commands"}) + if _, err := DB.Exec(`UPDATE commands SET alias=?,command=? WHERE id=?`, cmd.Alias, cmd.Command, id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db update failed"}) return } + cmds, _ := queryAllCommands() c.JSON(http.StatusOK, gin.H{"data": cmds}) } @@ -586,20 +686,16 @@ func handleDeleteCommand(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid index"}) return } - cmds, err := readCommands() + id, err := commandIDAtIndex(idx) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read commands"}) - return - } - if idx >= len(cmds) { c.JSON(http.StatusNotFound, gin.H{"error": "index out of range"}) return } - cmds = append(cmds[:idx], cmds[idx+1:]...) - if err := writeCommands(cmds); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save commands"}) + if _, err := DB.Exec(`DELETE FROM commands WHERE id=?`, id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db delete failed"}) return } + cmds, _ := queryAllCommands() c.JSON(http.StatusOK, gin.H{"data": cmds}) } @@ -607,45 +703,46 @@ func handleDeleteCommand(c *gin.Context) { // 脚本 CRUD // ═══════════════════════════════════════════════════════════════════ -// GET /api/scripts — 列出所有脚本名称 +// GET /api/scripts func handleListScripts(c *gin.Context) { - entries, err := os.ReadDir(scriptDir()) + rows, err := DB.Query(`SELECT name FROM scripts ORDER BY name`) if err != nil { c.JSON(http.StatusOK, gin.H{"data": []ScriptInfo{}}) return } - var scripts []ScriptInfo - for _, e := range entries { - if !e.IsDir() { - scripts = append(scripts, ScriptInfo{Name: e.Name()}) + defer rows.Close() + scripts := []ScriptInfo{} + for rows.Next() { + var s ScriptInfo + if err := rows.Scan(&s.Name); err != nil { + continue } - } - if scripts == nil { - scripts = []ScriptInfo{} + scripts = append(scripts, s) } c.JSON(http.StatusOK, gin.H{"data": scripts}) } -// GET /api/scripts/:name — 获取脚本内容 +// GET /api/scripts/:name func handleGetScript(c *gin.Context) { - name, err := sanitizeName(c.Param("name")) - if err != nil { + name := c.Param("name") + if err := validateName(name); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } - raw, err := os.ReadFile(filepath.Join(scriptDir(), name)) - if err != nil { - if os.IsNotExist(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - } else { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read"}) - } + var s ScriptInfo + err := DB.QueryRow(`SELECT name,content FROM scripts WHERE name=?`, name). + Scan(&s.Name, &s.Content) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } else if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db query failed"}) return } - c.JSON(http.StatusOK, gin.H{"data": ScriptInfo{Name: name, Content: string(raw)}}) + c.JSON(http.StatusOK, gin.H{"data": s}) } -// POST /api/scripts — 新建脚本 +// POST /api/scripts func handleCreateScript(c *gin.Context) { var s ScriptInfo if err := c.ShouldBindJSON(&s); err != nil { @@ -656,59 +753,63 @@ func handleCreateScript(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "name 为必填项"}) return } - name, err := sanitizeName(s.Name) - if err != nil { + if err := validateName(s.Name); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } - if err := os.MkdirAll(scriptDir(), 0o750); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dir"}) + _, err := DB.Exec(`INSERT INTO scripts (name,content) VALUES (?,?)`, s.Name, s.Content) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + c.JSON(http.StatusConflict, gin.H{"error": "name already exists"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db insert failed"}) + } return } - if err := os.WriteFile(filepath.Join(scriptDir(), name), []byte(s.Content), 0o640); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write"}) - return - } - c.JSON(http.StatusOK, gin.H{"data": ScriptInfo{Name: name, Content: s.Content}}) + c.JSON(http.StatusOK, gin.H{"data": s}) } -// PUT /api/scripts/:name — 更新脚本内容 +// PUT /api/scripts/:name func handleUpdateScript(c *gin.Context) { - name, err := sanitizeName(c.Param("name")) - if err != nil { + name := c.Param("name") + if err := validateName(name); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } - filePath := filepath.Join(scriptDir(), name) - if _, err := os.Stat(filePath); os.IsNotExist(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - return - } var s ScriptInfo if err := c.ShouldBindJSON(&s); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } - if err := os.WriteFile(filePath, []byte(s.Content), 0o640); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write"}) + res, err := DB.Exec( + `UPDATE scripts SET content=?,updated_at=CURRENT_TIMESTAMP WHERE name=?`, + s.Content, name, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db update failed"}) + return + } + if n, _ := res.RowsAffected(); n == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } c.JSON(http.StatusOK, gin.H{"data": ScriptInfo{Name: name, Content: s.Content}}) } -// DELETE /api/scripts/:name — 删除脚本 +// DELETE /api/scripts/:name func handleDeleteScript(c *gin.Context) { - name, err := sanitizeName(c.Param("name")) - if err != nil { + name := c.Param("name") + if err := validateName(name); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } - if err := os.Remove(filepath.Join(scriptDir(), name)); err != nil { - if os.IsNotExist(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - } else { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"}) - } + res, err := DB.Exec(`DELETE FROM scripts WHERE name=?`, name) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db delete failed"}) + return + } + if n, _ := res.RowsAffected(); n == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } c.JSON(http.StatusOK, gin.H{"message": "deleted"}) diff --git a/mengyaconnect-frontend/index.html b/mengyaconnect-frontend/index.html index 65f17a6..93d681e 100644 --- a/mengyaconnect-frontend/index.html +++ b/mengyaconnect-frontend/index.html @@ -2,7 +2,7 @@ - + 萌芽SSH diff --git a/mengyaconnect-frontend/src/App.vue b/mengyaconnect-frontend/src/App.vue index 1c5d2cd..d9927af 100644 --- a/mengyaconnect-frontend/src/App.vue +++ b/mengyaconnect-frontend/src/App.vue @@ -1,2797 +1,148 @@ - - diff --git a/mengyaconnect-frontend/src/api.js b/mengyaconnect-frontend/src/api.js index c564c6a..608e443 100644 --- a/mengyaconnect-frontend/src/api.js +++ b/mengyaconnect-frontend/src/api.js @@ -1,33 +1,47 @@ -export function getApiBase() { - const envBase = import.meta.env.VITE_API_BASE; - if (envBase) { - return String(envBase).replace(/\/+$/, ""); - } - // 默认走同源 /api(更适合反向代理 + HTTPS) - if (typeof window === "undefined") return "http://localhost:8080/api"; - return `${window.location.origin}/api`; -} - -export async function apiRequest(path, options = {}) { - const base = getApiBase(); - const res = await fetch(`${base}${path}`, { - headers: { - "Content-Type": "application/json", - ...(options.headers || {}), - }, - ...options, - }); - let body = null; - try { - body = await res.json(); - } catch { - body = null; - } - if (!res.ok) { - const message = - (body && body.error) || `请求失败 (${res.status} ${res.statusText})`; - throw new Error(message); - } - return body; -} - +const TOKEN_KEY = "mc_auth_token"; + +export function getToken() { + return localStorage.getItem(TOKEN_KEY) || ""; +} + +export function setToken(t) { + localStorage.setItem(TOKEN_KEY, t); +} + +export function clearToken() { + localStorage.removeItem(TOKEN_KEY); +} + +export function getApiBase() { + const envBase = import.meta.env.VITE_API_BASE; + if (envBase) { + return String(envBase).replace(/\/+$/, ""); + } + if (typeof window === "undefined") return "http://localhost:8080/api"; + return `${window.location.origin}/api`; +} + +export async function apiRequest(path, options = {}) { + const base = getApiBase(); + const token = getToken(); + const res = await fetch(`${base}${path}`, { + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(options.headers || {}), + }, + ...options, + }); + let body = null; + try { + body = await res.json(); + } catch { + body = null; + } + if (!res.ok) { + const message = + (body && body.error) || `请求失败 (${res.status} ${res.statusText})`; + throw new Error(message); + } + return body; +} diff --git a/mengyaconnect-frontend/src/components/AppHeader.vue b/mengyaconnect-frontend/src/components/AppHeader.vue new file mode 100644 index 0000000..8676500 --- /dev/null +++ b/mengyaconnect-frontend/src/components/AppHeader.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/mengyaconnect-frontend/src/components/LoginOverlay.vue b/mengyaconnect-frontend/src/components/LoginOverlay.vue new file mode 100644 index 0000000..2d05600 --- /dev/null +++ b/mengyaconnect-frontend/src/components/LoginOverlay.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/mengyaconnect-frontend/src/components/MobileFab.vue b/mengyaconnect-frontend/src/components/MobileFab.vue new file mode 100644 index 0000000..4d789c0 --- /dev/null +++ b/mengyaconnect-frontend/src/components/MobileFab.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/mengyaconnect-frontend/src/components/PanelOverlay.vue b/mengyaconnect-frontend/src/components/PanelOverlay.vue new file mode 100644 index 0000000..2947d5c --- /dev/null +++ b/mengyaconnect-frontend/src/components/PanelOverlay.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/mengyaconnect-frontend/src/components/QuickKeys.vue b/mengyaconnect-frontend/src/components/QuickKeys.vue new file mode 100644 index 0000000..c69f2ee --- /dev/null +++ b/mengyaconnect-frontend/src/components/QuickKeys.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/mengyaconnect-frontend/src/components/SessionSidebar.vue b/mengyaconnect-frontend/src/components/SessionSidebar.vue new file mode 100644 index 0000000..a3b444e --- /dev/null +++ b/mengyaconnect-frontend/src/components/SessionSidebar.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/mengyaconnect-frontend/src/components/TerminalPanel.vue b/mengyaconnect-frontend/src/components/TerminalPanel.vue new file mode 100644 index 0000000..23f0691 --- /dev/null +++ b/mengyaconnect-frontend/src/components/TerminalPanel.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/mengyaconnect-frontend/src/components/panels/CommandsPanel.vue b/mengyaconnect-frontend/src/components/panels/CommandsPanel.vue new file mode 100644 index 0000000..a6c9a7c --- /dev/null +++ b/mengyaconnect-frontend/src/components/panels/CommandsPanel.vue @@ -0,0 +1,57 @@ + + + diff --git a/mengyaconnect-frontend/src/components/panels/ConnectPanel.vue b/mengyaconnect-frontend/src/components/panels/ConnectPanel.vue new file mode 100644 index 0000000..7059080 --- /dev/null +++ b/mengyaconnect-frontend/src/components/panels/ConnectPanel.vue @@ -0,0 +1,50 @@ + + + diff --git a/mengyaconnect-frontend/src/components/panels/ScriptsPanel.vue b/mengyaconnect-frontend/src/components/panels/ScriptsPanel.vue new file mode 100644 index 0000000..720b57a --- /dev/null +++ b/mengyaconnect-frontend/src/components/panels/ScriptsPanel.vue @@ -0,0 +1,63 @@ + + + diff --git a/mengyaconnect-frontend/src/components/panels/SshPanel.vue b/mengyaconnect-frontend/src/components/panels/SshPanel.vue new file mode 100644 index 0000000..5e93668 --- /dev/null +++ b/mengyaconnect-frontend/src/components/panels/SshPanel.vue @@ -0,0 +1,85 @@ + + + diff --git a/mengyaconnect-frontend/src/composables/useAuth.js b/mengyaconnect-frontend/src/composables/useAuth.js new file mode 100644 index 0000000..78c6e18 --- /dev/null +++ b/mengyaconnect-frontend/src/composables/useAuth.js @@ -0,0 +1,49 @@ +import { ref } from "vue"; +import { apiRequest, setToken, clearToken, getToken } from "../api"; + +const isAuthenticated = ref(false); +const loginPassword = ref(""); +const loginError = ref(""); +const loginLoading = ref(false); + +async function submitLogin() { + if (!loginPassword.value) return; + loginLoading.value = true; + loginError.value = ""; + try { + const res = await apiRequest("/auth/login", { + method: "POST", + body: JSON.stringify({ password: loginPassword.value }), + }); + setToken(res.data.token); + isAuthenticated.value = true; + loginPassword.value = ""; + } catch (e) { + loginError.value = e.message || "密码错误,请重试"; + } finally { + loginLoading.value = false; + } +} + +async function checkAuth() { + const storedToken = getToken(); + if (!storedToken) return; + try { + await apiRequest("/auth/verify"); + isAuthenticated.value = true; + } catch { + clearToken(); + isAuthenticated.value = false; + } +} + +export function useAuth() { + return { + isAuthenticated, + loginPassword, + loginError, + loginLoading, + submitLogin, + checkAuth, + }; +} diff --git a/mengyaconnect-frontend/src/composables/useClipboard.js b/mengyaconnect-frontend/src/composables/useClipboard.js new file mode 100644 index 0000000..b10a525 --- /dev/null +++ b/mengyaconnect-frontend/src/composables/useClipboard.js @@ -0,0 +1,85 @@ +import { useSessions } from "./useSessions"; +import { useNotice } from "./useNotice"; + +function bytesToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ""; + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); + } + return btoa(binary); +} + +function chunkBase64(text, size = 76) { + const chunks = []; + for (let i = 0; i < text.length; i += size) chunks.push(text.slice(i, i + size)); + return chunks.join("\n"); +} + +function safeFilename(name) { return name.replace(/[^a-zA-Z0-9._-]/g, "-"); } + +function formatTimestamp(date = new Date()) { + const pad = (v) => String(v).padStart(2, "0"); + return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; +} + +function isEditableTarget(target) { + if (!target || typeof target !== "object") return false; + const tag = target.tagName?.toLowerCase(); + if (tag === "input" || tag === "textarea") return true; + return !!target.isContentEditable; +} + +function sendImageToTerminal(file) { + const { sessions, activeId } = useSessions(); + const { showNotice } = useNotice(); + const session = sessions.value.find((s) => s.id === activeId.value); + + if (!session?.ws || session.ws.readyState !== WebSocket.OPEN) { + showNotice("已识别剪贴板图片,但当前没有可用终端连接", "warning"); + return; + } + const maxBytes = 2 * 1024 * 1024; + if (file.size > maxBytes) { showNotice("图片过大,已取消粘贴(限制 2MB)", "warning"); return; } + + const ext = (file.type || "image/png").split("/")[1] || "png"; + const filename = safeFilename(`clipboard-${formatTimestamp()}.${ext}`); + const reader = new FileReader(); + reader.onload = () => { + try { + const base64 = bytesToBase64(reader.result); + const marker = `__MENGYA_CLIP_${Date.now()}__`; + const script = `cat <<'${marker}' | base64 -d > ${filename}\n${chunkBase64(base64)}\n${marker}\n`; + session.ws.send(JSON.stringify({ type: "input", data: script })); + session.term?.writeln(`\r\n\x1b[90m[已接收剪贴板图片,保存为 ${filename}]\x1b[0m`); + showNotice(`已发送图片到终端:${filename}`, "success"); + } catch { + showNotice("图片粘贴失败,请重试", "error"); + } + }; + reader.onerror = () => showNotice("读取剪贴板图片失败", "error"); + reader.readAsArrayBuffer(file); +} + +function handlePaste(event) { + if (!event?.clipboardData) return; + if (isEditableTarget(event.target)) return; + const text = event.clipboardData.getData("text/plain"); + if (text) return; + const items = Array.from(event.clipboardData.items || []); + const imageItem = items.find((item) => item.type?.startsWith("image/")); + if (!imageItem) return; + const file = imageItem.getAsFile(); + if (!file) return; + event.preventDefault(); + event.stopPropagation(); + sendImageToTerminal(file); +} + +function setupClipboard() { document.addEventListener("paste", handlePaste); } +function teardownClipboard() { document.removeEventListener("paste", handlePaste); } + +export function useClipboard() { + return { setupClipboard, teardownClipboard }; +} diff --git a/mengyaconnect-frontend/src/composables/useCommands.js b/mengyaconnect-frontend/src/composables/useCommands.js new file mode 100644 index 0000000..bcae21f --- /dev/null +++ b/mengyaconnect-frontend/src/composables/useCommands.js @@ -0,0 +1,95 @@ +import { reactive, ref } from "vue"; +import { apiRequest } from "../api"; +import { useSessions } from "./useSessions"; +import { useUI } from "./useUI"; + +const commandList = ref([]); +const commandLoading = ref(false); +const commandSaving = ref(false); +const commandError = ref(""); +const commandEditingIndex = ref(-1); +const commandForm = reactive({ alias: "", command: "" }); + +async function loadCommands() { + commandError.value = ""; + commandLoading.value = true; + try { + const res = await apiRequest("/commands", { method: "GET" }); + commandList.value = Array.isArray(res.data) ? res.data : []; + } catch (err) { + commandError.value = err.message || "加载快捷命令失败"; + } finally { + commandLoading.value = false; + } +} + +function resetCommandForm() { + commandEditingIndex.value = -1; + commandForm.alias = ""; + commandForm.command = ""; +} + +function editCommand(item, index) { + commandEditingIndex.value = index; + commandForm.alias = item.alias || ""; + commandForm.command = item.command || ""; +} + +async function saveCommand() { + commandError.value = ""; + if (!commandForm.alias || !commandForm.command) { + commandError.value = "alias 和 command 为必填项"; + return; + } + commandSaving.value = true; + try { + const payload = { alias: commandForm.alias, command: commandForm.command }; + if (commandEditingIndex.value >= 0) { + await apiRequest(`/commands/${commandEditingIndex.value}`, { + method: "PUT", body: JSON.stringify(payload), + }); + } else { + await apiRequest("/commands", { method: "POST", body: JSON.stringify(payload) }); + } + await loadCommands(); + resetCommandForm(); + } catch (err) { + commandError.value = err.message || "保存快捷命令失败"; + } finally { + commandSaving.value = false; + } +} + +async function deleteCommand(index) { + commandError.value = ""; + try { + await apiRequest(`/commands/${index}`, { method: "DELETE" }); + if (commandEditingIndex.value === index) resetCommandForm(); + await loadCommands(); + } catch (err) { + commandError.value = err.message || "删除快捷命令失败"; + } +} + +function applyCommandToTerminal(item) { + const { sessions, activeId } = useSessions(); + const { closeOverlay } = useUI(); + const session = sessions.value.find((s) => s.id === activeId.value); + if (!session?.ws || session.ws.readyState !== WebSocket.OPEN) { + commandError.value = "当前没有可用终端连接"; + return; + } + const cmd = item.command || ""; + if (!cmd) return; + session.ws.send(JSON.stringify({ type: "input", data: `${cmd}\n` })); + closeOverlay(); +} + +export function useCommands() { + return { + commandList, commandLoading, commandSaving, commandError, + commandEditingIndex, commandForm, + loadCommands, resetCommandForm, editCommand, saveCommand, + deleteCommand, applyCommandToTerminal, + }; +} diff --git a/mengyaconnect-frontend/src/composables/useNotice.js b/mengyaconnect-frontend/src/composables/useNotice.js new file mode 100644 index 0000000..b4a620d --- /dev/null +++ b/mengyaconnect-frontend/src/composables/useNotice.js @@ -0,0 +1,18 @@ +import { reactive } from "vue"; + +const notice = reactive({ text: "", type: "info" }); +let noticeTimer = null; + +function showNotice(text, type = "info", duration = 3200) { + notice.text = text; + notice.type = type; + if (noticeTimer) clearTimeout(noticeTimer); + noticeTimer = setTimeout(() => { + notice.text = ""; + noticeTimer = null; + }, duration); +} + +export function useNotice() { + return { notice, showNotice }; +} diff --git a/mengyaconnect-frontend/src/composables/useQuickKeys.js b/mengyaconnect-frontend/src/composables/useQuickKeys.js new file mode 100644 index 0000000..932423d --- /dev/null +++ b/mengyaconnect-frontend/src/composables/useQuickKeys.js @@ -0,0 +1,67 @@ +import { ref } from "vue"; +import { useSessions } from "./useSessions"; + +const shiftPending = ref(false); +const ctrlPending = ref(false); + +function sendQuickKey(type) { + const { sessions, activeId } = useSessions(); + const session = sessions.value.find((s) => s.id === activeId.value); + if (!session?.ws || session.ws.readyState !== WebSocket.OPEN) return; + + if (type === "shift") { shiftPending.value = !shiftPending.value; return; } + if (type === "ctrl") { ctrlPending.value = !ctrlPending.value; return; } + + let data = ""; + switch (type) { + case "ctrl_c": data = "\x03"; break; + case "ctrl_z": data = "\x1a"; break; + case "ctrl_d": data = "\x04"; break; + case "ctrl_l": data = "\x0c"; break; + case "esc": data = "\x1b"; break; + case "tab": data = "\t"; break; + case "slash": data = "/"; break; + case "minus": data = "-"; break; + case "dot": data = "."; break; + case "hash": data = "#"; break; + case "amp": data = "&"; break; + case "up": data = "\x1b[A"; break; + case "down": data = "\x1b[B"; break; + case "right": data = "\x1b[C"; break; + case "left": data = "\x1b[D"; break; + case "enter": data = "\r"; break; + default: return; + } + + if (shiftPending.value) { + if (data === "\t") data = "\x1b[Z"; + else if (data === "/") data = "?"; + else if (data === "-") data = "_"; + else if (data === ".") data = ">"; + else if (data === "\x1b[A") data = "\x1b[1;2A"; + else if (data === "\x1b[B") data = "\x1b[1;2B"; + else if (data === "\x1b[C") data = "\x1b[1;2C"; + else if (data === "\x1b[D") data = "\x1b[1;2D"; + else if (data.length === 1) data = data.toUpperCase(); + } + + if (ctrlPending.value) { + if (data === "\x1b[A") data = "\x1b[1;5A"; + else if (data === "\x1b[B") data = "\x1b[1;5B"; + else if (data === "\x1b[C") data = "\x1b[1;5C"; + else if (data === "\x1b[D") data = "\x1b[1;5D"; + else if (data.length === 1) { + const ch = data.toLowerCase(); + if (ch >= "a" && ch <= "z") data = String.fromCharCode(ch.charCodeAt(0) - 96); + else if (ch === "/") data = "\x1f"; + } + } + + session.ws.send(JSON.stringify({ type: "input", data })); + shiftPending.value = false; + ctrlPending.value = false; +} + +export function useQuickKeys() { + return { shiftPending, ctrlPending, sendQuickKey }; +} diff --git a/mengyaconnect-frontend/src/composables/useSSH.js b/mengyaconnect-frontend/src/composables/useSSH.js new file mode 100644 index 0000000..8b5ae38 --- /dev/null +++ b/mengyaconnect-frontend/src/composables/useSSH.js @@ -0,0 +1,95 @@ +import { reactive, ref } from "vue"; +import { apiRequest } from "../api"; + +const sshList = ref([]); +const sshLoading = ref(false); +const sshSaving = ref(false); +const sshError = ref(""); +const sshEditingName = ref(""); +const sshForm = reactive({ + name: "", alias: "", host: "", port: 22, + username: "", password: "", privateKey: "", passphrase: "", +}); + +async function loadSSH() { + sshError.value = ""; + sshLoading.value = true; + try { + const res = await apiRequest("/ssh", { method: "GET" }); + sshList.value = Array.isArray(res.data) ? res.data : []; + } catch (err) { + sshError.value = err.message || "加载 SSH 配置失败"; + } finally { + sshLoading.value = false; + } +} + +function resetSSHForm() { + sshEditingName.value = ""; + Object.assign(sshForm, { + name: "", alias: "", host: "", port: 22, + username: "", password: "", privateKey: "", passphrase: "", + }); +} + +function editSSH(item) { + sshEditingName.value = item.name || ""; + Object.assign(sshForm, { + name: item.name || "", alias: item.alias || "", host: item.host || "", + port: item.port || 22, username: item.username || "", password: item.password || "", + privateKey: item.privateKey || "", passphrase: item.passphrase || "", + }); +} + +async function saveSSH() { + sshError.value = ""; + if (!sshForm.alias || !sshForm.host || !sshForm.username) { + sshError.value = "alias、host 和 username 为必填项"; + return; + } + if (!sshEditingName.value && !sshForm.name) { + sshError.value = "新建配置时 name 为必填项"; + return; + } + sshSaving.value = true; + try { + const payload = { + alias: sshForm.alias, host: sshForm.host, port: sshForm.port || 22, + username: sshForm.username, password: sshForm.password, + privateKey: sshForm.privateKey, passphrase: sshForm.passphrase, + }; + if (sshEditingName.value) { + await apiRequest(`/ssh/${encodeURIComponent(sshEditingName.value)}`, { + method: "PUT", body: JSON.stringify(payload), + }); + } else { + payload.name = sshForm.name; + await apiRequest("/ssh", { method: "POST", body: JSON.stringify(payload) }); + } + await loadSSH(); + if (!sshEditingName.value) resetSSHForm(); + } catch (err) { + sshError.value = err.message || "保存 SSH 配置失败"; + } finally { + sshSaving.value = false; + } +} + +async function deleteSSH(item) { + sshError.value = ""; + if (!item.name) return; + try { + await apiRequest(`/ssh/${encodeURIComponent(item.name)}`, { method: "DELETE" }); + if (sshEditingName.value === item.name) resetSSHForm(); + await loadSSH(); + } catch (err) { + sshError.value = err.message || "删除 SSH 配置失败"; + } +} + +export function useSSH() { + return { + sshList, sshLoading, sshSaving, sshError, sshEditingName, sshForm, + loadSSH, resetSSHForm, editSSH, saveSSH, deleteSSH, + }; +} diff --git a/mengyaconnect-frontend/src/composables/useScripts.js b/mengyaconnect-frontend/src/composables/useScripts.js new file mode 100644 index 0000000..3167bd0 --- /dev/null +++ b/mengyaconnect-frontend/src/composables/useScripts.js @@ -0,0 +1,101 @@ +import { reactive, ref } from "vue"; +import { apiRequest } from "../api"; +import { useSessions } from "./useSessions"; +import { useUI } from "./useUI"; + +const scriptList = ref([]); +const scriptLoading = ref(false); +const scriptSaving = ref(false); +const scriptError = ref(""); +const scriptSelected = ref(""); +const scriptForm = reactive({ name: "", content: "" }); + +async function loadScripts() { + scriptError.value = ""; + scriptLoading.value = true; + try { + const res = await apiRequest("/scripts", { method: "GET" }); + scriptList.value = Array.isArray(res.data) ? res.data : []; + } catch (err) { + scriptError.value = err.message || "加载脚本列表失败"; + } finally { + scriptLoading.value = false; + } +} + +async function selectScript(name) { + scriptError.value = ""; + scriptSelected.value = name; + try { + const res = await apiRequest(`/scripts/${encodeURIComponent(name)}`, { method: "GET" }); + scriptForm.name = res.data?.name || name; + scriptForm.content = res.data?.content || ""; + } catch (err) { + scriptError.value = err.message || "加载脚本内容失败"; + } +} + +function resetScriptForm() { + scriptSelected.value = ""; + scriptForm.name = ""; + scriptForm.content = ""; +} + +async function saveScript() { + scriptError.value = ""; + if (!scriptSelected.value && !scriptForm.name) { + scriptError.value = "新建脚本时 name 为必填项"; + return; + } + scriptSaving.value = true; + try { + if (scriptSelected.value) { + await apiRequest(`/scripts/${encodeURIComponent(scriptSelected.value)}`, { + method: "PUT", body: JSON.stringify({ content: scriptForm.content || "" }), + }); + } else { + await apiRequest("/scripts", { + method: "POST", + body: JSON.stringify({ name: scriptForm.name, content: scriptForm.content || "" }), + }); + scriptSelected.value = scriptForm.name; + } + await loadScripts(); + } catch (err) { + scriptError.value = err.message || "保存脚本失败"; + } finally { + scriptSaving.value = false; + } +} + +async function deleteScript() { + scriptError.value = ""; + if (!scriptSelected.value) return; + try { + await apiRequest(`/scripts/${encodeURIComponent(scriptSelected.value)}`, { method: "DELETE" }); + await loadScripts(); + resetScriptForm(); + } catch (err) { + scriptError.value = err.message || "删除脚本失败"; + } +} + +function applyScriptToTerminal() { + const { sessions, activeId } = useSessions(); + const { closeOverlay } = useUI(); + const session = sessions.value.find((s) => s.id === activeId.value); + if (!session?.ws || session.ws.readyState !== WebSocket.OPEN) { + scriptError.value = "当前没有可用终端连接"; + return; + } + if (!scriptForm.content) return; + session.ws.send(JSON.stringify({ type: "input", data: `${scriptForm.content}\n` })); + closeOverlay(); +} + +export function useScripts() { + return { + scriptList, scriptLoading, scriptSaving, scriptError, scriptSelected, scriptForm, + loadScripts, selectScript, resetScriptForm, saveScript, deleteScript, applyScriptToTerminal, + }; +} diff --git a/mengyaconnect-frontend/src/composables/useSessions.js b/mengyaconnect-frontend/src/composables/useSessions.js new file mode 100644 index 0000000..777390b --- /dev/null +++ b/mengyaconnect-frontend/src/composables/useSessions.js @@ -0,0 +1,225 @@ +import { computed, markRaw, nextTick, reactive, ref, watch } from "vue"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import { getToken } from "../api"; +import { useUI } from "./useUI"; + +const sessions = ref([]); +const activeId = ref(""); +const terminalRefs = new Map(); + +const form = reactive({ + host: "", + port: 22, + username: "", + authType: "password", + password: "", + privateKey: "", + passphrase: "", +}); +const formError = ref(""); + +const wsUrl = computed(() => { + const token = getToken(); + const envUrl = import.meta.env.VITE_WS_URL; + if (envUrl) return token ? `${envUrl}?token=${token}` : envUrl; + if (typeof window === "undefined") { + const base = "ws://localhost:8080/api/ws/ssh"; + return token ? `${base}?token=${token}` : base; + } + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const base = `${protocol}//${window.location.host}/api/ws/ssh`; + return token ? `${base}?token=${token}` : base; +}); + +function setTerminalRef(id, el) { + if (el) terminalRefs.set(id, el); + else terminalRefs.delete(id); +} + +function statusLabel(status) { + const map = { + ready: "已连接", + connecting: "连接中", + connected: "已建立", + closing: "关闭中", + closed: "已关闭", + error: "错误", + }; + return map[status] ?? "准备中"; +} + +function resetForm() { + Object.assign(form, { + host: "", port: 22, username: "", authType: "password", + password: "", privateKey: "", passphrase: "", + }); + formError.value = ""; +} + +function connectWithSSH(item) { + form.host = item.host || ""; + form.port = item.port || 22; + form.username = item.username || ""; + if (item.privateKey) { + form.authType = "key"; + form.privateKey = item.privateKey; + form.passphrase = item.passphrase || ""; + form.password = ""; + } else { + form.authType = "password"; + form.password = item.password || ""; + form.privateKey = ""; + form.passphrase = ""; + } + createSession(); +} + +function createSession() { + const { closeOverlay } = useUI(); + formError.value = ""; + if (!form.host || !form.username) { formError.value = "请填写主机与用户名。"; return; } + if (form.authType === "password" && !form.password) { formError.value = "请选择密码认证时请输入密码。"; return; } + if (form.authType === "key" && !form.privateKey) { formError.value = "请选择私钥认证时请输入私钥。"; return; } + + const id = crypto.randomUUID(); + const session = { + id, + title: `${form.username}@${form.host}:${form.port}`, + status: "connecting", + ws: null, + term: null, + fit: null, + }; + sessions.value.push(session); + activeId.value = id; + + const payload = { + type: "connect", + host: form.host, port: form.port, username: form.username, + password: form.authType === "password" ? form.password : "", + privateKey: form.authType === "key" ? form.privateKey : "", + passphrase: form.authType === "key" ? form.passphrase : "", + }; + nextTick(() => initSession(session, payload)); + closeOverlay(); +} + +function initSession(session, payload) { + const container = terminalRefs.get(session.id); + if (!container) return; + + const term = markRaw( + new Terminal({ + fontFamily: `"JetBrains Mono", "Fira Code", ui-monospace, monospace`, + fontSize: 14, + lineHeight: 1.35, + cursorBlink: true, + cursorStyle: "block", + scrollback: 8000, + smoothScrollDuration: 100, + theme: { + background: "#000000", + foreground: "#e6edf3", + cursor: "#3fb950", + cursorAccent: "#000000", + selectionBackground: "rgba(63,185,80,0.25)", + black: "#0d1117", red: "#f85149", green: "#3fb950", yellow: "#d29922", + blue: "#58a6ff", magenta: "#bc8cff", cyan: "#39c5cf", white: "#b1bac4", + brightBlack: "#484f58", brightRed: "#ff7b72", brightGreen: "#56d364", + brightYellow: "#e3b341", brightBlue: "#79c0ff", brightMagenta: "#d2a8ff", + brightCyan: "#56d4dd", brightWhite: "#cdd9e5", + }, + }) + ); + + const fitAddon = markRaw(new FitAddon()); + term.loadAddon(fitAddon); + term.open(container); + fitAddon.fit(); + term.focus(); + term.writeln("\x1b[90m[正在建立连接...]\x1b[0m"); + + const ws = markRaw(new WebSocket(wsUrl.value)); + session.ws = ws; + session.term = term; + session.fit = fitAddon; + + const sendResize = () => { + if (ws.readyState !== WebSocket.OPEN) return; + ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); + }; + + ws.onopen = () => { + session.status = "connected"; + ws.send(JSON.stringify({ ...payload, cols: term.cols, rows: term.rows })); + }; + ws.onmessage = (event) => { + let msg; + try { msg = JSON.parse(event.data); } catch { term.write(event.data); return; } + if (msg.type === "output" && msg.data) term.write(msg.data); + else if (msg.type === "status") { + session.status = msg.status || session.status; + if (msg.message) term.writeln(`\r\x1b[90m[${msg.message}]\x1b[0m`); + } else if (msg.type === "error") { + session.status = "error"; + if (msg.message) term.writeln(`\r\x1b[31m[${msg.message}]\x1b[0m`); + } + }; + ws.onerror = () => { session.status = "error"; term.writeln("\r\n\x1b[31m[连接错误]\x1b[0m"); }; + ws.onclose = () => { + if (session.status !== "closed") session.status = "closed"; + term.writeln("\r\n\x1b[90m[连接已关闭]\x1b[0m"); + }; + term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "input", data })); + }); + term.onResize(() => sendResize()); + focusSession(session); +} + +function focusSession(session) { + if (!session?.term || !session?.fit) return; + nextTick(() => { + session.fit.fit(); + if (session.ws?.readyState === WebSocket.OPEN) { + session.ws.send(JSON.stringify({ type: "resize", cols: session.term.cols, rows: session.term.rows })); + } + session.term.scrollToBottom(); + session.term.focus(); + }); +} + +function setActive(id) { activeId.value = id; } + +function closeSession(id) { + const index = sessions.value.findIndex((s) => s.id === id); + if (index === -1) return; + const session = sessions.value[index]; + if (session.ws?.readyState === WebSocket.OPEN) { + session.ws.send(JSON.stringify({ type: "close" })); + session.ws.close(); + } + session.term?.dispose(); + sessions.value.splice(index, 1); + if (activeId.value === id) activeId.value = sessions.value[0]?.id || ""; +} + +function handleWindowResize() { + const session = sessions.value.find((s) => s.id === activeId.value); + if (session) focusSession(session); +} + +watch(activeId, (id) => { + const session = sessions.value.find((s) => s.id === id); + if (session) focusSession(session); +}); + +export function useSessions() { + return { + sessions, activeId, terminalRefs, form, formError, wsUrl, + setTerminalRef, statusLabel, resetForm, + connectWithSSH, createSession, initSession, focusSession, + setActive, closeSession, handleWindowResize, + }; +} diff --git a/mengyaconnect-frontend/src/composables/useUI.js b/mengyaconnect-frontend/src/composables/useUI.js new file mode 100644 index 0000000..022fed4 --- /dev/null +++ b/mengyaconnect-frontend/src/composables/useUI.js @@ -0,0 +1,36 @@ +import { ref } from "vue"; + +const activePanel = ref(""); +const showSidebar = ref(false); +const focusMode = ref(false); +const fabOpen = ref(false); + +function openPanel(name) { + activePanel.value = name; + fabOpen.value = false; +} + +function closeOverlay() { + activePanel.value = ""; +} + +function toggleSidebar() { + showSidebar.value = !showSidebar.value; +} + +function toggleFocus() { + focusMode.value = !focusMode.value; +} + +export function useUI() { + return { + activePanel, + showSidebar, + focusMode, + fabOpen, + openPanel, + closeOverlay, + toggleSidebar, + toggleFocus, + }; +} diff --git a/mengyaconnect-frontend/src/composables/useViewport.js b/mengyaconnect-frontend/src/composables/useViewport.js new file mode 100644 index 0000000..053f0f2 --- /dev/null +++ b/mengyaconnect-frontend/src/composables/useViewport.js @@ -0,0 +1,61 @@ +import { ref } from "vue"; +import { useSessions } from "./useSessions"; + +const appRef = ref(null); +let viewportFitTimer = null; + +function updateAppHeight() { + const { sessions, activeId, focusSession } = useSessions(); + const vv = window.visualViewport; + const h = vv ? vv.height : window.innerHeight; + const keyboard = vv && typeof vv.height === "number" + ? Math.max(0, window.innerHeight - vv.height - (vv.offsetTop || 0)) + : 0; + + if (appRef.value) { + appRef.value.style.height = h + "px"; + appRef.value.style.setProperty("--keyboard-inset", keyboard + "px"); + appRef.value.style.setProperty("--vvh", h + "px"); + } + + requestAnimationFrame(() => { + const session = sessions.value.find((s) => s.id === activeId.value); + if (session) focusSession(session); + }); + + if (viewportFitTimer) clearTimeout(viewportFitTimer); + viewportFitTimer = setTimeout(() => { + const session = sessions.value.find((s) => s.id === activeId.value); + if (session) focusSession(session); + }, 140); +} + +function hideSplashScreen() { + if (typeof window === "undefined") return; + const hide = window.__hideSplash; + if (typeof hide === "function") requestAnimationFrame(() => hide()); +} + +function setupViewport() { + if (window.visualViewport) { + window.visualViewport.addEventListener("resize", updateAppHeight); + window.visualViewport.addEventListener("scroll", updateAppHeight); + } else { + window.addEventListener("resize", updateAppHeight); + } + updateAppHeight(); +} + +function teardownViewport() { + if (viewportFitTimer) { clearTimeout(viewportFitTimer); viewportFitTimer = null; } + if (window.visualViewport) { + window.visualViewport.removeEventListener("resize", updateAppHeight); + window.visualViewport.removeEventListener("scroll", updateAppHeight); + } else { + window.removeEventListener("resize", updateAppHeight); + } +} + +export function useViewport() { + return { appRef, updateAppHeight, hideSplashScreen, setupViewport, teardownViewport }; +} diff --git a/mengyaconnect-frontend/src/main.js b/mengyaconnect-frontend/src/main.js index 206c851..3f1fdb9 100644 --- a/mengyaconnect-frontend/src/main.js +++ b/mengyaconnect-frontend/src/main.js @@ -1,5 +1,7 @@ import { createApp } from "vue"; import "@xterm/xterm/css/xterm.css"; +import "./styles/base.css"; +import "./styles/utils.css"; import App from "./App.vue"; import { registerSW } from "virtual:pwa-register"; diff --git a/mengyaconnect-frontend/src/styles/base.css b/mengyaconnect-frontend/src/styles/base.css new file mode 100644 index 0000000..0d4a4f3 --- /dev/null +++ b/mengyaconnect-frontend/src/styles/base.css @@ -0,0 +1,78 @@ +/* ─── CSS 变量(Termux/Terminus 深黑极简主题) ─────────────────── */ +:root { + --bg-0: #000000; + --bg-1: #0d1117; + --bg-2: #161b22; + --bg-3: #21262d; + --border: rgba(48, 54, 61, 0.9); + --border-hi: rgba(63, 185, 80, 0.45); + --border-blue: rgba(88, 166, 255, 0.45); + --accent: #3fb950; + --accent-dim: rgba(63, 185, 80, 0.15); + --accent-blue: #58a6ff; + --danger: #f85149; + --text-1: #e6edf3; + --text-2: #8b949e; + --text-3: #484f58; + color-scheme: dark; +} + +/* ─── 全局 reset ──────────────────────────────────────────────── */ +html, +body, +#app { + width: 100%; + height: 100%; + overflow: hidden; + overscroll-behavior: none; +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; + scrollbar-width: none; + -ms-overflow-style: none; + background: var(--bg-1); + color: var(--text-1); + font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", + "Segoe UI", sans-serif; +} + +html::-webkit-scrollbar, +body::-webkit-scrollbar { + width: 0; + height: 0; +} + +/* ─── xterm 滚动条主题 ───────────────────────────────────────── */ +.xterm-viewport { + overflow-y: auto !important; + overflow-x: hidden !important; + scrollbar-width: thin; + scrollbar-color: rgba(63, 185, 80, 0.4) rgba(0, 0, 0, 0.5); + -ms-overflow-style: auto; + overscroll-behavior: contain; + touch-action: pan-y; + -webkit-overflow-scrolling: touch; +} + +.xterm-viewport::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +.xterm-viewport::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.4); +} + +.xterm-viewport::-webkit-scrollbar-thumb { + border-radius: 999px; + background: rgba(63, 185, 80, 0.45); +} + +.xterm-viewport::-webkit-scrollbar-thumb:hover { + background: rgba(63, 185, 80, 0.7); +} diff --git a/mengyaconnect-frontend/src/styles/utils.css b/mengyaconnect-frontend/src/styles/utils.css new file mode 100644 index 0000000..f932e5e --- /dev/null +++ b/mengyaconnect-frontend/src/styles/utils.css @@ -0,0 +1,196 @@ +/* ─── 按钮系统 ──────────────────────────────────────────────────── */ +.btn-primary, +.btn-secondary, +.btn-ghost, +.btn-accent, +.btn-danger { + border-radius: 6px; + border: 1px solid transparent; + padding: 6px 14px; + font-size: 13px; + cursor: pointer; + outline: none; + white-space: nowrap; + transition: opacity 0.15s, background 0.15s, border-color 0.15s; + font-family: inherit; +} + +.btn-primary { + background: #238636; + border-color: rgba(63, 185, 80, 0.6); + color: #fff; +} +.btn-primary:hover:not(:disabled) { background: #2ea043; } +.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; } + +.btn-accent { + background: var(--accent-dim); + border-color: var(--border-hi); + color: var(--accent); +} +.btn-accent:hover { background: rgba(63, 185, 80, 0.25); } + +.btn-secondary { + background: var(--bg-2); + border-color: var(--border); + color: var(--text-1); +} +.btn-secondary:hover { border-color: var(--text-2); } + +.btn-ghost { + background: transparent; + border-color: var(--border); + color: var(--text-2); +} +.btn-ghost:hover { border-color: var(--text-2); color: var(--text-1); } +.btn-ghost:disabled { opacity: 0.4; cursor: not-allowed; } + +.btn-danger { + background: rgba(248, 81, 73, 0.1); + border-color: rgba(248, 81, 73, 0.4); + color: #ff7b72; +} +.btn-danger:hover { background: rgba(248, 81, 73, 0.2); } + +.btn-primary.small, +.btn-secondary.small, +.btn-ghost.small, +.btn-accent.small, +.btn-danger.small { padding: 5px 10px; font-size: 12px; } + +.tiny { padding: 4px 8px !important; font-size: 11px !important; border-radius: 5px !important; } + +/* ─── 表单元素 ───────────────────────────────────────────────────── */ +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px 12px; + margin-bottom: 12px; +} + +label { + font-size: 12px; + color: var(--text-2); + display: flex; + flex-direction: column; + gap: 4px; +} + +input, +select, +textarea { + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-2); + color: var(--text-1); + padding: 7px 9px; + font-size: 13px; + outline: none; + transition: border-color 0.15s; + font-family: inherit; +} + +input:focus, +select:focus, +textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(63, 185, 80, 0.15); +} + +textarea { resize: vertical; } + +.form-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.form-error { + color: #ff7b72; + font-size: 12px; + margin: 2px 0 4px; +} + +.form-error.small { font-size: 11px; } + +.tips { + font-size: 11px; + color: var(--text-3); + word-break: break-all; + margin-top: 4px; +} + +/* ─── 列表通用样式 ──────────────────────────────────────────────── */ +.item-list { display: flex; flex-direction: column; gap: 5px; margin-bottom: 10px; } + +.item-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-2); + gap: 8px; +} + +.item-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.item-title { display: flex; align-items: center; gap: 6px; font-size: 13px; } +.item-sub { font-size: 12px; color: var(--text-2); } +.muted { font-size: 11px; color: var(--text-3); } +.item-actions { display: flex; gap: 4px; flex-shrink: 0; flex-wrap: wrap; } + +.cmd-code { + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 11px; + padding: 2px 6px; + border-radius: 4px; + background: var(--bg-0); + border: 1px solid var(--border); + color: var(--text-2); + word-break: break-all; +} + +.empty-hint { font-size: 12px; color: var(--text-3); margin: 4px 0 12px; } + +/* ─── 子面板 ─────────────────────────────────────────────────────── */ +.sub-panel { display: flex; flex-direction: column; gap: 8px; } +.sub-panel-header { display: flex; align-items: center; justify-content: space-between; } +.sub-panel-header h3 { margin: 0; font-size: 14px; font-weight: 600; color: var(--text-1); } +.sub-form h4 { margin: 4px 0 8px; font-size: 13px; color: var(--text-2); } + +/* ─── 脚本管理布局 ──────────────────────────────────────────────── */ +.script-layout { + display: grid; + grid-template-columns: minmax(110px, 180px) minmax(0, 1fr); + gap: 10px; +} + +.script-list { display: flex; flex-direction: column; gap: 4px; } + +.script-item { + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-2); + color: var(--text-2); + padding: 6px 10px; + text-align: left; + font-size: 12px; + cursor: pointer; + transition: background 0.12s, color 0.12s; + font-family: inherit; +} +.script-item:hover { background: var(--bg-3); color: var(--text-1); } +.script-item.active { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); } + +.script-editor { display: flex; flex-direction: column; gap: 8px; } +.script-content-label textarea { min-height: 140px; font-family: "JetBrains Mono", ui-monospace, monospace; } + +/* ─── 移动端响应 ─────────────────────────────────────────────────── */ +@media (max-width: 768px) { + .item-row { flex-wrap: wrap; } + .item-actions { width: 100%; justify-content: flex-end; margin-top: 4px; } + .script-layout { grid-template-columns: 1fr; } + .form-grid { grid-template-columns: 1fr; } +}