commit d28aae585f6e49111a64688c3c2a13d6c7cd8e6c Author: shirainbown Date: Wed Apr 15 00:52:25 2026 +0800 Initial commit: LAN Manager with Go backend and Vue frontend diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5434026 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +lan-manager +lan-manager-darwin +lan-manager-linux + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out + +# Go workspace file +go.work + +# Dependency directories +vendor/ +node_modules/ + +# Build outputs +web/dist/ +server/static/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Data +data/ +*.db +*.db-journal +*.log + +# Environment files +.env +*.env.local + +# Temporary files +tmp/ +temp/ +*.tmp diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cdcc5d4 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +.PHONY: all build web server clean run dev + +all: build + +web: + cd web && npm install && npm run build + rm -rf server/static/* + cp -r web/dist/* server/static/ + +server: + cd server && go build -o ../lan-manager . + +build: web server + +clean: + rm -f lan-manager + rm -rf server/static/* + rm -rf web/dist + +dev: + cd web && npm run dev + +run: + cd server && go run . diff --git a/README.md b/README.md new file mode 100644 index 0000000..16ad01d --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# 局域网机器管理后台 (LAN Manager) + +一个轻量的局域网机器管理后台,用于集中管理多台虚拟机/主机(20 台以内),展示机器信息、服务部署情况及机器之间的转发/依赖关系。 + +## 功能特性 + +- **访客模式**:无需登录,可查看机器在线状态、已同步的系统数据(CPU/内存/磁盘等)、服务列表(脱敏显示) +- **管理员模式**:登录后可查看完整信息(含 IP、MAC、拓扑关系),执行增删改、SSH 信息获取、导入导出等操作 +- **机器管理**:主机名、IP、MAC、操作系统、备注、在线状态自动检测 +- **服务管理**:为每台机器维护多个服务条目(名称、端口、协议、备注) +- **关系管理**:建立机器之间的端口转发、依赖、主从等关系 +- **拓扑可视化**:基于 AntV G6 的力导向图,支持拖拽、缩放 +- **SSH 系统信息获取**:支持手动触发,也可配置自动同步;SSH 密码采用 AES-256-GCM 加密存储 +- **数据导入/导出**:JSON 格式完整备份与恢复 +- **操作日志**:记录所有增删改操作及来源 IP + +## 技术栈 + +- **后端**:Go 1.21+ + Gin + SQLite (modernc.org/sqlite) +- **前端**:Vue 3 + Vite + Element Plus + AntV G6 +- **认证**:Session/Cookie +- **SSH**:golang.org/x/crypto/ssh(密码认证) + +## 快速开始 + +### 开发模式 + +```bash +# 1. 安装前端依赖并启动开发服务器 +cd web +npm install +npm run dev + +# 2. 启动后端(在项目根目录另开终端) +cd server +go run . +``` + +前端开发服务器默认在 `http://localhost:5173`,并代理 `/api` 到 `http://127.0.0.1:8080`。 + +### 生产构建(单二进制部署) + +```bash +make build +# 生成的 lan-manager 可直接运行 +./lan-manager +``` + +## Debian 12 一键部署 + +```bash +# 1. 解压部署包 +tar -xzf deploy/lan-manager-debian12.tar.gz +cd lan-manager-debian12 + +# 2. 编辑环境变量(务必修改 ADMIN_PASS 和 ENCRYPT_KEY) +nano lan-manager.env + +# 3. 执行安装脚本 +sudo bash install.sh +``` + +安装完成后服务自动启动,通过 `systemctl status lan-manager` 查看状态。 + +## Nginx 反向代理示例 + +```nginx +server { + listen 443 ssl; + server_name aa.cn; + + ssl_certificate /path/to/aa.cn.crt; + ssl_certificate_key /path/to/aa.cn.key; + + client_max_body_size 50M; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + } +} +``` + +## 环境变量 + +| 变量名 | 默认值 | 说明 | +|--------|--------|------| +| `HOST` | `0.0.0.0` | 监听地址 | +| `PORT` | `8080` | 监听端口 | +| `DB_PATH` | `./data/lan-manager.db` | SQLite 数据库路径 | +| `DATA_DIR` | `./data` | 数据目录 | +| `ADMIN_USER` | `admin` | 管理员用户名 | +| `ADMIN_PASS` | `admin` | 管理员密码(生产环境务必修改) | +| `SESSION_SECRET` | `lan-manager-secret-change-in-production` | Session 密钥 | +| `ENCRYPT_KEY` | `lan-manager-default-key-change-in-production` | SSH 密码 AES 加密密钥 | +| `PING_INTERVAL` | `60` | Ping 检测间隔(秒) | +| `SSH_TIMEOUT` | `10` | SSH 连接超时(秒) | +| `LOG_RETENTION_DAYS` | `0` | 日志保留天数(0 表示永久保留) | +| `UI_REFRESH_INTERVAL` | `10000` | 前端页面自动刷新间隔(毫秒,0 表示关闭) | +| `WEB_STATIC_PATH` | `""` | 外置静态文件目录(空则使用 embed) | + +## 项目结构 + +``` +lan-manager/ +├── deploy/ # Debian 12 部署包 +├── docs/ # 需求文档与计划 +├── scripts/ # systemd 服务示例 +├── server/ # Go 后端 +│ ├── main.go +│ ├── config/ +│ ├── db/ +│ ├── handlers/ +│ ├── middleware/ +│ ├── models/ +│ ├── services/ +│ └── static/ # 嵌入的前端构建产物 +├── web/ # Vue 前端 +│ ├── src/ +│ └── dist/ +├── Makefile +├── go.mod +└── README.md +``` + +## 注意事项 + +- **生产环境务必修改**:`ADMIN_PASS`、`SESSION_SECRET`、`ENCRYPT_KEY` +- **SSH 密码安全**:数据库中 SSH 密码经 AES-256-GCM 加密存储,API 永不返回密码明文 +- **自动同步 SSH**:若机器已保存 SSH 用户名和密码,后台每次 Ping 检测成功后会自动通过 SSH 抓取系统信息并同步到数据库 +- **在线检测机制**:每分钟对所有机器 IP 执行 ICMP Ping,3 秒内无响应视为离线 +- **磁盘显示策略**:机器列表卡片仅展示 `/` 根分区占用;详情页展示所有真实磁盘分区 diff --git a/deploy/lan-manager-debian12.tar.gz b/deploy/lan-manager-debian12.tar.gz new file mode 100644 index 0000000..d2f6d36 Binary files /dev/null and b/deploy/lan-manager-debian12.tar.gz differ diff --git a/deploy/lan-manager-debian12/README.md b/deploy/lan-manager-debian12/README.md new file mode 100644 index 0000000..6a55126 --- /dev/null +++ b/deploy/lan-manager-debian12/README.md @@ -0,0 +1,175 @@ +# LAN Manager Debian 12 部署包 + +本包包含 LAN Manager 的 Linux amd64 编译二进制、systemd 服务文件及一键安装脚本。前端资源已静态嵌入二进制中,无需额外安装 Node.js 或 Nginx 即可直接运行。 + +## 系统要求 + +- Debian 12 (bookworm) +- systemd +- 不需要安装 SQLite(使用纯 Go 实现的 SQLite 驱动,无需 CGO) + +## 包内文件说明 + +``` +lan-manager # 服务端可执行文件(已嵌入前端) +lan-manager.env # 环境变量配置文件 +lan-manager.service # systemd 服务单元 +install.sh # 一键安装脚本 +uninstall.sh # 一键卸载脚本 +README.md # 本说明文件 +``` + +## 快速安装 + +1. 将本包上传到 Debian 12 服务器(例如 `/root/lan-manager-debian12/`) +2. 进入目录并执行安装脚本: + +```bash +cd lan-manager-debian12 +sudo bash install.sh +``` + +安装脚本会完成以下操作: +- 创建系统用户 `lanmgr` +- 将程序安装到 `/opt/lan-manager/` +- 创建数据目录 `/opt/lan-manager/data/` +- 注册并启动 systemd 服务 `lan-manager` +- 设置开机自启 + +## 安装后验证 + +```bash +# 查看服务状态 +systemctl status lan-manager + +# 查看实时日志 +journalctl -u lan-manager -f + +# 测试访问 +curl http://127.0.0.1:8080/api/auth/me +``` + +## 配置修改 + +所有配置通过 `/opt/lan-manager/lan-manager.env` 中的环境变量管理。常用配置项: + +| 变量 | 说明 | 默认值 | +|------|------|--------| +| `HOST` | 监听地址 | `0.0.0.0` | +| `PORT` | 监听端口 | `8080` | +| `DATA_DIR` | 数据目录 | `/opt/lan-manager/data` | +| `DB_PATH` | SQLite 数据库文件路径 | `/opt/lan-manager/data/lan-manager.db` | +| `ADMIN_USER` | 管理员用户名 | `admin` | +| `ADMIN_PASS` | 管理员密码 | `changeme` | +| `SESSION_SECRET` | Session Cookie 密钥 | `lan-manager-secret-change-in-production` | +| `PING_INTERVAL` | 自动 Ping/SSH 采集间隔(秒) | `60` | +| `UI_REFRESH_INTERVAL` | 前端自动刷新间隔(毫秒) | `10000` | + +修改配置后重启服务生效: + +```bash +sudo systemctl restart lan-manager +``` + +## 防火墙与公网访问 + +如果需要通过公网访问,建议开放端口或使用 Nginx 反向代理: + +**方式 A:直接开放端口** +```bash +sudo ufw allow 8080/tcp +``` + +**方式 B:Nginx 反向代理(推荐)** +```nginx +server { + listen 80; + server_name your-domain.com; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +## 安全提醒 + +- **首次部署后,请立即修改 `ADMIN_PASS` 和 `SESSION_SECRET`。** +- 默认情况下访客可直接查看机器列表,但无法进入机器详情页;后端 API 已对详情接口做了权限拦截。 +- 建议使用 HTTPS(配合 Nginx + Let's Encrypt/certbot)。 + +## 数据备份与恢复(升级前必做) + +系统已内置 JSON 全量导出/导入功能,可完整保留机器、服务、关系数据,以及 SSH 配置信息。 + +### 方式 A:Web 界面(管理员登录后) +进入 **机器列表** 页面,点击右上角的: +- **导出数据**:下载 `lan-manager-backup-YYYYMMdd.json` +- **导入数据**:选择之前下载的备份文件,系统会以 **覆盖模式** 恢复全部数据 + +### 方式 B:命令行 +```bash +# 导出(需要管理员 Cookie 或 session) +curl -b your-session-cookie http://localhost:8080/api/export > lan-manager-backup.json + +# 导入(覆盖模式,会清空现有数据后恢复) +curl -b your-session-cookie -X POST http://localhost:8080/api/import \ + -F "file=@lan-manager-backup.json" \ + -F "mode=overwrite" +``` + +### ⚠️ 备份文件安全提醒 +导出文件中 **包含 SSH 密码**(已按数据库中的密文/明文形式导出)。请妥善保管备份文件,避免泄露。 + +## 升级步骤 + +1. **先导出数据备份**(见上文) +2. 停止服务: + ```bash + sudo systemctl stop lan-manager + ``` +3. 替换二进制: + ```bash + sudo cp /path/to/new/lan-manager /opt/lan-manager/ + sudo chown lanmgr:lanmgr /opt/lan-manager/lan-manager + sudo chmod +x /opt/lan-manager/lan-manager + ``` +4. 启动服务: + ```bash + sudo systemctl start lan-manager + ``` +5. **验证数据完整性**,如有问题可执行导入恢复。 + +## 卸载 + +如果你想完全卸载 LAN Manager,使用自带的卸载脚本即可: + +```bash +cd /root/lan-manager-debian12 # 或你存放安装包的目录 +sudo bash uninstall.sh +``` + +卸载脚本会执行以下操作: +- 停止并禁用 `lan-manager` systemd 服务 +- 删除 `/etc/systemd/system/lan-manager.service` +- 删除程序及数据目录 `/opt/lan-manager/` +- 删除运行用户 `lanmgr` +- 提示清理防火墙规则(如有) + +**⚠️ 注意**:卸载会一并删除 `/opt/lan-manager/data/` 下的 SQLite 数据库。如需保留历史数据,请提前备份: +```bash +cp /opt/lan-manager/data/lan-manager.db /backup/lan-manager.db +``` + +--- + +## 常见问题 + +**Q: 服务启动失败,日志提示端口被占用?** +A: 修改 `lan-manager.env` 中的 `PORT` 为其他端口,然后 `systemctl restart lan-manager`。 + +**Q: 如何备份数据?** +A: 直接复制 SQLite 数据库文件即可:`cp /opt/lan-manager/data/lan-manager.db /backup/lan-manager.db`。 diff --git a/deploy/lan-manager-debian12/install.sh b/deploy/lan-manager-debian12/install.sh new file mode 100644 index 0000000..eeee8b5 --- /dev/null +++ b/deploy/lan-manager-debian12/install.sh @@ -0,0 +1,73 @@ +#!/bin/bash +set -e + +INSTALL_DIR="/opt/lan-manager" +SERVICE_NAME="lan-manager" +USER_NAME="lanmgr" + +echo "=== LAN Manager Debian 12 安装脚本 ===" + +# 1. 检查 root 权限 +if [ "$EUID" -ne 0 ]; then + echo "请使用 root 权限运行:sudo bash install.sh" + exit 1 +fi + +# 2. 创建用户(如果不存在) +if ! id "$USER_NAME" &>/dev/null; then + echo "[1/7] 创建运行用户 $USER_NAME ..." + useradd --system --no-create-home --shell /usr/sbin/nologin "$USER_NAME" +else + echo "[1/7] 用户 $USER_NAME 已存在,跳过" +fi + +# 3. 停止服务(如果正在运行)以便覆盖二进制 +echo "[2/7] 停止现有服务(如果正在运行)..." +if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then + systemctl stop "$SERVICE_NAME" + sleep 1 +fi + +# 4. 复制程序文件 +echo "[3/7] 复制程序文件到 $INSTALL_DIR ..." +mkdir -p "$INSTALL_DIR" +cp lan-manager "$INSTALL_DIR/" +cp lan-manager.env "$INSTALL_DIR/" + +# 5. 创建数据目录并授权 +echo "[4/7] 创建数据目录 ..." +mkdir -p "$INSTALL_DIR/data" +chown -R "$USER_NAME:$USER_NAME" "$INSTALL_DIR" +chmod +x "$INSTALL_DIR/lan-manager" + +# 6. 安装 systemd 服务 +echo "[5/7] 安装 systemd 服务 ..." +cp lan-manager.service /etc/systemd/system/ +chmod 644 /etc/systemd/system/lan-manager.service +systemctl daemon-reload + +# 7. 启动并设置开机自启 +echo "[6/7] 启动服务并设置开机自启 ..." +systemctl enable --now lan-manager + +# 8. 检查状态 +echo "[7/7] 检查服务状态 ..." +sleep 2 +if systemctl is-active --quiet lan-manager; then + echo "✅ 服务已正常启动" +else + echo "❌ 服务启动失败,请查看日志:journalctl -u lan-manager -n 50" + exit 1 +fi + +echo "" +echo "=== 安装完成 ===" +echo "访问地址: http://$(hostname -I | awk '{print $1}'):8080" +echo "" +echo "⚠️ 重要提示:" +echo " 1. 默认管理员账号: admin / changeme" +echo " 2. 请务必修改 $INSTALL_DIR/lan-manager.env 中的 ADMIN_PASS 和 SESSION_SECRET" +echo " 3. 修改配置后执行: systemctl restart lan-manager" +echo " 4. 查看日志: journalctl -u lan-manager -f" +echo " 5. 如需开放防火墙端口: ufw allow 8080/tcp" +echo "" diff --git a/deploy/lan-manager-debian12/lan-manager.env b/deploy/lan-manager-debian12/lan-manager.env new file mode 100644 index 0000000..a5751f3 --- /dev/null +++ b/deploy/lan-manager-debian12/lan-manager.env @@ -0,0 +1,31 @@ +# LAN Manager Environment Configuration +# 服务监听地址与端口 +HOST=0.0.0.0 +PORT=8080 + +# 数据目录与数据库路径(建议放在 /opt/lan-manager/data) +DATA_DIR=/opt/lan-manager/data +DB_PATH=/opt/lan-manager/data/lan-manager.db + +# 后台自动 Ping/SSH 同步间隔(秒) +PING_INTERVAL=60 +SSH_TIMEOUT=10 + +# 管理员账号(首次登录后务必修改) +ADMIN_USER=admin +ADMIN_PASS=changeme + +# Session 密钥(生产环境务必修改,越长越安全) +SESSION_SECRET=lan-manager-secret-change-in-production + +# 日志保留天数(0 表示永久保留) +LOG_RETENTION_DAYS=30 + +# 前端自动刷新间隔(毫秒,0 表示关闭) +UI_REFRESH_INTERVAL=10000 + +# 密码加密密钥(生产环境务必修改,任意长度字符串即可) +ENCRYPT_KEY=lan-manager-secret-change-in-production + +# 可选:外部静态文件路径(默认使用嵌入的前端,无需修改) +# WEB_STATIC_PATH=/opt/lan-manager/static diff --git a/deploy/lan-manager-debian12/lan-manager.service b/deploy/lan-manager-debian12/lan-manager.service new file mode 100644 index 0000000..e608dd4 --- /dev/null +++ b/deploy/lan-manager-debian12/lan-manager.service @@ -0,0 +1,16 @@ +[Unit] +Description=LAN Manager +After=network.target + +[Service] +Type=simple +User=lanmgr +Group=lanmgr +WorkingDirectory=/opt/lan-manager +ExecStart=/opt/lan-manager/lan-manager +EnvironmentFile=/opt/lan-manager/lan-manager.env +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/lan-manager-debian12/uninstall.sh b/deploy/lan-manager-debian12/uninstall.sh new file mode 100644 index 0000000..ae84725 --- /dev/null +++ b/deploy/lan-manager-debian12/uninstall.sh @@ -0,0 +1,58 @@ +#!/bin/bash +set -e + +INSTALL_DIR="/opt/lan-manager" +SERVICE_NAME="lan-manager" +USER_NAME="lanmgr" + +echo "=== LAN Manager 卸载脚本 ===" + +if [ "$EUID" -ne 0 ]; then + echo "请使用 root 权限运行:sudo bash uninstall.sh" + exit 1 +fi + +# 1. 停止并禁用服务 +if systemctl list-units --type=service --all | grep -q "$SERVICE_NAME"; then + echo "[1/5] 停止并禁用 systemd 服务 ..." + systemctl stop "$SERVICE_NAME" 2>/dev/null || true + systemctl disable "$SERVICE_NAME" 2>/dev/null || true +else + echo "[1/5] 服务不存在,跳过" +fi + +# 2. 删除 systemd 服务文件 +if [ -f "/etc/systemd/system/$SERVICE_NAME.service" ]; then + echo "[2/5] 删除 systemd 服务文件 ..." + rm -f "/etc/systemd/system/$SERVICE_NAME.service" + systemctl daemon-reload +else + echo "[2/5] systemd 服务文件不存在,跳过" +fi + +# 3. 删除程序和数据目录 +if [ -d "$INSTALL_DIR" ]; then + echo "[3/5] 删除程序目录 $INSTALL_DIR ..." + rm -rf "$INSTALL_DIR" +else + echo "[3/5] 程序目录不存在,跳过" +fi + +# 4. 删除用户和组 +if id "$USER_NAME" &>/dev/null; then + echo "[4/5] 删除运行用户 $USER_NAME ..." + userdel "$USER_NAME" 2>/dev/null || true +else + echo "[4/5] 用户 $USER_NAME 不存在,跳过" +fi + +# 5. 清理防火墙规则(可选) +echo "[5/5] 检查防火墙规则 ..." +if command -v ufw &>/dev/null && ufw status | grep -q "8080/tcp"; then + echo " 发现 ufw 规则 8080/tcp,建议手动删除:ufw delete allow 8080/tcp" +fi + +echo "" +echo "=== 卸载完成 ===" +echo "如需保留数据库备份,请确认已备份 /opt/lan-manager/data/lan-manager.db" +echo "" diff --git a/docs/plan.md b/docs/plan.md new file mode 100644 index 0000000..de995ed --- /dev/null +++ b/docs/plan.md @@ -0,0 +1,67 @@ +# 局域网机器管理后台 — 实现计划 + +## 技术选型 + +| 层 | 技术 | +|---|---| +| 后端 | Go + Gin | +| 前端 | Vue 3 + Vite + Element Plus + AntV G6 | +| 数据库 | SQLite (modernc.org/sqlite,纯 Go,无需 CGO) | +| 认证 | 预设用户名密码 + Session/Cookie | +| SSH | golang.org/x/crypto/ssh(仅密码认证) | +| Ping | `golang.org/x/net/icmp` + 系统 `/bin/ping` 命令回退 | + +## 项目结构 + +``` +lan-manager/ +├── server/ +│ ├── main.go # 入口 +│ ├── config/ # 配置管理(支持配置文件 + 环境变量) +│ ├── models/ # 数据模型 +│ ├── db/ # 数据库 + migration +│ ├── handlers/ # HTTP handlers +│ ├── services/ # 业务逻辑 (auth, ping, ssh, import/export) +│ └── middleware/ # CORS, 认证, 日志 +├── web/ # Vue 前端 +│ └── src/ +├── scripts/ +│ └── lan-manager.service # Systemd 服务示例 +├── go.mod +├── go.sum +├── Makefile +└── README.md +``` + +## 实施步骤 + +### Phase 1: 后端基础 +1. 初始化 Go 项目 + 依赖(Gin, modernc.org/sqlite, crypto/ssh) +2. 配置管理:支持配置文件 + 环境变量(HOST/PORT/DB_PATH/ADMIN_USER/ADMIN_PASS 等) +3. SQLite 数据库 + 表创建(含 machines 扩展字段 cpu_info/memory_info/disk_info/uptime/listen_ports/ssh_synced_at) +4. 数据模型定义 +5. 基础 CRUD API (machines, services, relationships) +6. 认证模块:登录/登出/Session 中间件,区分访客和管理员权限 + +### Phase 2: 核心业务 +7. Ping 定时检测服务(1 分钟间隔),Linux 上优先使用系统 ping 命令,避免权限问题 +8. SSH 系统信息获取(仅密码认证,不保存凭据,单次有效),结果支持同步到机器记录 +9. 操作日志中间件(记录增删改,含用户名) +10. 导入/导出 API(JSON 格式) +11. 健康检查接口 `/api/health` + +### Phase 3: 前端 +12. Vue 3 项目初始化 + Element Plus + 路由(Vue Router) +13. 登录页面 +14. 机器列表页(访客:脱敏显示 IP/MAC/备注/关系) +15. 机器详情页(访客:隐藏敏感字段和 SSH 入口;管理员:完整功能) +16. 拓扑图页(AntV G6,仅管理员可访问) +17. 操作日志页(仅管理员) +18. 导入/导出功能(仅管理员) + +### Phase 4: 整合与部署 +19. Go embed 打包前端静态文件 + 支持外置 `WEB_STATIC_PATH` +20. 编写 Makefile(含 Linux amd64 交叉编译) +21. 编写 Systemd 服务示例脚本 +22. 测试 + 修复 +23. README 编写(含 Linux 部署说明) diff --git a/docs/requirements.md b/docs/requirements.md new file mode 100644 index 0000000..58f5c09 --- /dev/null +++ b/docs/requirements.md @@ -0,0 +1,385 @@ +# 局域网机器管理后台 — 需求文档 + +## 一、项目概述 + +搭建一个本地局域网机器管理后台,用于集中管理多台虚拟机/主机(20 台以内),直观展示机器信息、服务部署情况及机器之间的转发/依赖关系,解决"机器多、IP 多、服务分散、容易遗忘"的痛点。 + +系统分为**访客模式**和**管理员模式**: +- **访客**:无需登录,可查看机器的在线状态、已同步的系统数据(CPU/内存/磁盘等)、服务列表,但无法查看 IP 地址、MAC 地址、拓扑关系,也无法执行任何管理操作。 +- **管理员**:登录后可查看完整信息(含 IP、MAC、拓扑关系),执行增删改、SSH 信息获取、导入导出等所有操作。 + +--- + +## 二、核心功能模块 + +### 1. 用户认证 + +- **管理员账号**:预设一组用户名/密码,通过环境变量或配置文件初始化 +- **认证方式**:Session/Cookie 或简单 Token +- **未登录用户**:自动降级为访客模式,仅开放只读脱敏接口 + +### 2. 机器管理(增删改查) + +| 字段 | 说明 | 是否必填 | 访客可见 | +|------|------|---------|---------| +| 主机名/别名 | 用户自定义的易记名称,如 `web-server-01` | 是 | ✅ 是 | +| IP 地址 | 机器在局域网中的固定 IP,如 `192.168.1.100` | 是 | ❌ 否(仅管理员) | +| MAC 地址 | 可选,用于辅助识别 | 否 | ❌ 否(仅管理员) | +| 操作系统 | Linux / Windows / macOS(可下拉选择 + 自定义) | 是 | ✅ 是 | +| 系统版本 | 如 `Ubuntu 22.04`、`Windows 11`、`macOS 14` | 否 | ✅ 是 | +| 备注/描述 | 自由文本,记录用途、负责人等信息 | 否 | ❌ 否(仅管理员) | +| 状态 | 在线 / 离线(自动检测) | 自动 | ✅ 是 | +| SSH 系统信息 | CPU / 内存 / 磁盘 / 运行时间等(手动触发获取,可同步到机器记录) | 按需 | ✅ 是(已同步的数据) | +| 创建时间 / 更新时间 | 系统自动记录 | 自动 | ❌ 否(仅管理员) | + +### 3. 服务管理 + +- 每台机器可以关联**多个服务条目** +- 服务字段: + - **服务名称**(如 `nginx`、`frps`、`docker`、`mysql`、`redis` 等) + - **端口号**(如 `80`、`443`、`8080`) + - **协议**(TCP / UDP / HTTP / HTTPS) + - **备注**(自由描述,如 "反向代理到 192.168.1.50:8080") +- 服务类型支持**预设常用服务** + **自定义服务** +- **访客可见性**:访客可以看到服务名称、端口、协议,但**不可查看备注**(备注可能包含敏感 IP 或内部信息) + +### 4. 机器关系管理 + +- 机器之间可以建立**连接关系**,用于表达端口转发、代理、依赖等 +- 关系字段: + - **源机器** → **目标机器** + - **关系类型**(预设 + 自定义): + - `端口转发`(如 Nginx 反代、FRP 穿透) + - `依赖`(如 A 机器上的应用依赖 B 机器的数据库) + - `主从`(如主从复制、集群节点) + - `自定义` + - **端口信息**:源端口 → 目标端口(如 `8080 → 80`) + - **备注**:自由描述 +- 关系应支持**双向可视化**(A→B 建立关系后,两边都能看到) +- **访客可见性**:❌ **完全不可见**,拓扑图和关系列表仅对管理员开放 + +### 5. 在线状态检测 + +- 定时对每台机器执行 **ICMP Ping** 检测 +- 检测间隔:**1 分钟** +- 界面用颜色/图标直观展示在线状态:🟢 在线 / 🔴 离线 +- **访客可见性**:✅ **可见** + +### 6. SSH 系统信息获取(安全敏感) + +- **不保存任何 SSH 凭据**(用户名、密码、密钥均不持久化) +- 仅支持 **密码认证**,不支持密钥认证 +- **仅管理员可见和操作**:在机器详情页提供单独的按钮 **"获取系统信息"** +- 点击后弹窗要求输入 SSH **用户名**和**密码** +- 连接成功后获取以下信息并展示: + - 主机名、操作系统版本(精确) + - CPU 使用率 / 核心数 + 内存使用率 / 总量 + - 磁盘使用率 / 各分区 + - 运行时间(uptime) + - 监听中的端口列表 +- **单次有效**:凭据仅在本次请求中使用,用完即销毁,不写入任何存储 +- 获取到的信息可选择性地**同步到机器记录中**(如系统版本、CPU、内存、磁盘),同步后访客即可查看这些已缓存的数据 +- SSH 连接超时时间:10 秒 + +### 7. 拓扑/关系图可视化 + +- 以**节点-连线图**的形式展示所有机器及其关系 +- 节点颜色区分操作系统类型 +- 连线标注关系类型和端口号 +- 支持拖拽、缩放、点击节点查看详情 +- 使用 G6 或类似的图可视化库 +- **访客可见性**:❌ **完全不可见**,仅管理员可访问拓扑图页面 + +### 8. 数据导入/导出 + +- 支持 **JSON 格式**完整导出(机器 + 服务 + 关系) +- 支持 **JSON 格式**导入(覆盖或追加模式) +- 导出文件名带时间戳,如 `lan-manager-backup-20260414.json` +- **管理员专属功能**,访客无权限 + +### 9. 操作日志 + +- 记录所有增删改操作: + - 操作时间 + - 操作类型(创建 / 修改 / 删除) + - 操作对象(机器 / 服务 / 关系) + - 变更内容(旧值 → 新值) + - 来源 IP(请求的 IP) + - 操作用户(管理员用户名) +- 日志只增不改,可在独立页面查看 +- 支持按时间/操作类型筛选 +- **管理员专属功能** + +--- + +## 三、UI 界面设计 + +### 页面 1:机器列表(主页,访客/管理员共用) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 🔍 搜索 IP / 主机名 [Linux▼] │ +├─────────────────────────────────────────────────────────────────┤ +│ ┌─────┐ ┌─────────────┐ ┌───────────┐ ┌──────────┐ ┌────────┐ │ +│ │状态 │ │ 主机名 │ │ IP* │ │ 系统 │ │ 服务数 │ │ +│ ├─────┤ ├─────────────┤ ├───────────┤ ├──────────┤ ├────────┤ │ +│ │ 🟢 │ │ nginx-proxy │ 192.168.*.* │ Linux │ 2 │ │ +│ │ 🟢 │ │ db-master │ 192.168.*.* │ Linux │ 1 │ │ +│ │ 🔴 │ │ dev-win │ 192.168.*.* │ Windows │ 3 │ │ +│ │ 🟢 │ │ mac-build │ 192.168.*.* │ macOS │ 0 │ │ +│ └─────┘ └─────────────┘ └───────────┘ └──────────┘ └────────┘ │ +│ │ +│ 底部导航: [机器列表] [拓扑图*] [操作日志*] [导入/导出*] [登录] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +> 注:带 `*` 的功能/数据仅管理员可见,访客访问时隐藏或显示为占位符(如 IP 显示为 `***`)。 + +- 支持按系统类型筛选、按 IP / 主机名搜索(访客搜索时只能匹配主机名,IP 对访客不可见) +- 点击某行进入**机器详情** + +### 页面 2:机器详情(访客/管理员共用,但字段可见性不同) + +``` +┌──────────────────────────────────────────────────────────────┐ +│ ← 返回 nginx-proxy │ +├──────────────────────────────────────────────────────────────┤ +│ 基本信息 [编辑*] │ +│ IP: 192.168.1.1 (访客显示: ***.***.*.*) │ +│ MAC: AA:BB:CC:DD:EE:FF (访客不可见) │ +│ 系统: Linux (Ubuntu 22.04) │ +│ 状态: 🟢 在线 │ +│ 备注: 主网关入口 (访客不可见) │ +│ │ +│ SSH 系统信息(管理员触发后展示) │ +│ CPU: 12% (4 cores) │ +│ 内存: 2.1G / 8G (26%) │ +│ 磁盘: / 45G / 100G (45%), /data 120G / 500G (24%) │ +│ 运行时间: 15 days, 3:22 │ +│ 监听端口: 22, 80, 443 │ +│ [获取系统信息*] [同步到记录*] │ +│ │ +│ 服务列表 [+ 添加服务*] │ +│ ┌─────────┬──────┬──────┬──────────────────────┐ │ +│ │ 名称 │ 端口 │ 协议 │ 备注* │ │ +│ ├─────────┼──────┼──────┼──────────────────────┤ │ +│ │ nginx │ 80 │ TCP │ 反代入口 │ │ +│ │ nginx │ 443 │ TCP │ HTTPS │ │ +│ └─────────┴──────┴──────┴──────────────────────┘ │ +│ │ +│ 关联关系 [+ 添加关系*] │ +│ → db-master :80 → :3306 [端口转发] [编辑*] [删除*] │ +│ → frps :8080 → :8080 [依赖] [编辑*] [删除*] │ +└──────────────────────────────────────────────────────────────┘ +``` + +> 注:带 `*` 的按钮/字段仅对管理员显示;关联关系区域对访客完全隐藏。 + +### 页面 3:拓扑图 + +- 力导向图(force-directed graph),节点可拖拽 +- 节点颜色:Linux 蓝色、Windows 绿色、macOS 紫色 +- 连线样式: + - 端口转发:实线 + 端口标注 + - 依赖:虚线 + 文字标注 + - 主从:带箭头实线 +- 点击节点高亮关联连线,其余淡化 +- **仅管理员可访问**,访客访问时重定向到机器列表或提示登录 + +### 页面 4:操作日志 + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ [全部▼] [2026-04-14] [导出日志*] │ +├──────────────────────────────────────────────────────────────────┤ +│ 2026-04-14 15:30:22 admin 修改 机器 nginx-proxy │ +│ 备注: "" → "主网关入口" │ +│ 2026-04-14 15:28:01 admin 创建 服务 nginx on nginx-proxy │ +│ 2026-04-14 14:55:10 admin 删除 关系 db-master → cache-01 │ +│ 2026-04-14 14:30:00 admin 创建 机器 cache-01 │ +└──────────────────────────────────────────────────────────────────┘ +``` + +- **仅管理员可访问** + +--- + +## 四、技术选型 + +| 层 | 技术 | 说明 | +|---|---|---| +| 后端 | **Go 1.21+** | 单二进制部署,内置 net/http 或 Gin 框架,SSH 客户端用 golang.org/x/crypto/ssh | +| 前端 | **Vue 3 + Vite** | 轻量,配合 Element Plus / Naive UI 组件库 | +| 拓扑图 | **AntV G6** | 专门的图可视化库,力导向图 + 自定义节点样式 | +| 存储 | **SQLite** | 单文件数据库,适合 20 台以内数据量 | +| 在线检测 | Go `net` 包 ICMP Ping / 系统 ping 命令回退 | 定时 goroutine,1 分钟间隔 | +| SSH | `golang.org/x/crypto/ssh` | 纯 Go 实现,不依赖系统 ssh 命令,仅密码认证 | +| 认证 | Session/Cookie | 简单用户名密码,预设单管理员账号 | + +### 项目结构 + +``` +lan-manager/ +├── server/ +│ ├── main.go # 入口,HTTP 服务启动 +│ ├── config/ # 配置管理 +│ ├── db/ # SQLite 数据库连接 + migration +│ ├── models/ # 数据模型 (Machine, Service, Relationship, Log, User) +│ ├── handlers/ # HTTP 请求处理 +│ ├── services/ # 业务逻辑 (ping, ssh, import/export, auth) +│ └── middleware/ # CORS, 认证中间件, 日志中间件 +├── web/ +│ ├── src/ +│ │ ├── views/ # 页面组件 (MachineList, MachineDetail, Topology, Logs, Login) +│ │ ├── components/ # 复用组件 (StatusBadge, ServiceTable, RelationEditor) +│ │ ├── api/ # 前端 API 封装 +│ │ └── App.vue +│ └── index.html +├── scripts/ +│ └── lan-manager.service # Systemd 服务示例 +├── go.mod +├── go.sum +├── Makefile # 构建脚本 +└── README.md +``` + +--- + +## 五、数据存储设计 + +```sql +machines 表: + id INTEGER PRIMARY KEY AUTOINCREMENT + hostname TEXT NOT NULL -- 主机名/别名 + ip TEXT NOT NULL UNIQUE -- IP 地址 + mac TEXT -- MAC 地址 + os_type TEXT NOT NULL -- Linux / Windows / macOS / Other + os_version TEXT -- 系统版本 + notes TEXT -- 备注 + is_online INTEGER DEFAULT 0 -- 0=离线, 1=在线 + last_ping_at DATETIME -- 最后一次 ping 时间 + -- SSH 系统信息字段(管理员获取后可选择同步到这里,供访客查看) + cpu_info TEXT -- CPU 信息 JSON + memory_info TEXT -- 内存信息 JSON + disk_info TEXT -- 磁盘信息 JSON + uptime TEXT -- 运行时间 + listen_ports TEXT -- 监听端口列表,逗号分隔 + ssh_synced_at DATETIME -- 最后一次同步 SSH 数据的时间 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + +services 表: + id INTEGER PRIMARY KEY AUTOINCREMENT + machine_id INTEGER NOT NULL REFERENCES machines(id) ON DELETE CASCADE + name TEXT NOT NULL -- 服务名称 + port INTEGER NOT NULL -- 端口号 + protocol TEXT DEFAULT 'TCP' -- TCP / UDP / HTTP / HTTPS + notes TEXT -- 备注 + +relationships 表: + id INTEGER PRIMARY KEY AUTOINCREMENT + source_machine_id INTEGER NOT NULL REFERENCES machines(id) ON DELETE CASCADE + target_machine_id INTEGER NOT NULL REFERENCES machines(id) ON DELETE CASCADE + relation_type TEXT NOT NULL -- port_forward / dependency / primary_secondary / custom + source_port INTEGER -- 源端口 + target_port INTEGER -- 目标端口 + notes TEXT -- 备注 + +operation_logs 表: + id INTEGER PRIMARY KEY AUTOINCREMENT + action TEXT NOT NULL -- create / update / delete + entity_type TEXT NOT NULL -- machine / service / relationship + entity_id INTEGER -- 操作对象的 ID + entity_name TEXT -- 操作对象的名称描述 + old_value TEXT -- 旧值 (JSON) + new_value TEXT -- 新值 (JSON) + source_ip TEXT -- 请求来源 IP + username TEXT -- 操作用户名(admin 或匿名) + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +``` + +--- + +## 六、API 设计 + +### 认证 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/auth/login` | 管理员登录(用户名/密码) | +| POST | `/api/auth/logout` | 退出登录 | +| GET | `/api/auth/me` | 获取当前登录状态 | + +### 机器 + +| 方法 | 路径 | 说明 | 权限 | +|------|------|------|------| +| GET | `/api/machines` | 获取机器列表(支持 ?os_type=Linux 筛选) | 访客/管理员 | +| GET | `/api/machines/:id` | 获取单个机器详情(含服务,管理员额外含关系、IP、MAC、备注) | 访客/管理员 | +| POST | `/api/machines` | 创建机器 | 管理员 | +| PUT | `/api/machines/:id` | 更新机器 | 管理员 | +| DELETE | `/api/machines/:id` | 删除机器 | 管理员 | +| POST | `/api/machines/:id/ssh-info` | SSH 获取系统信息(需传用户名密码,单次有效) | 管理员 | +| POST | `/api/machines/:id/sync-ssh` | 将最近一次 SSH 获取的信息同步到机器记录 | 管理员 | + +### 服务 + +| 方法 | 路径 | 说明 | 权限 | +|------|------|------|------| +| GET | `/api/machines/:machine_id/services` | 获取某机器的服务列表 | 访客/管理员 | +| POST | `/api/machines/:machine_id/services` | 添加服务 | 管理员 | +| PUT | `/api/services/:id` | 更新服务 | 管理员 | +| DELETE | `/api/services/:id` | 删除服务 | 管理员 | + +### 关系 + +| 方法 | 路径 | 说明 | 权限 | +|------|------|------|------| +| GET | `/api/relationships` | 获取全部关系 | 管理员 | +| POST | `/api/relationships` | 创建关系 | 管理员 | +| PUT | `/api/relationships/:id` | 更新关系 | 管理员 | +| DELETE | `/api/relationships/:id` | 删除关系 | 管理员 | + +### 日志 / 导入导出 + +| 方法 | 路径 | 说明 | 权限 | +|------|------|------|------| +| GET | `/api/logs` | 获取操作日志 | 管理员 | +| GET | `/api/export` | 导出数据(JSON 下载) | 管理员 | +| POST | `/api/import` | 导入数据(上传 JSON) | 管理员 | + +### 其他 + +| 方法 | 路径 | 说明 | 权限 | +|------|------|------|------| +| GET | `/api/health` | 服务健康检查 | 公开 | + +--- + +## 七、已确认的设计决策 + +| 项目 | 决定 | +|------|------| +| 部署方式 | Web 应用,源码部署,Linux Systemd 服务 | +| Ping 间隔 | 1 分钟 | +| SSH 系统信息 | 支持,**不保存凭据**,仅密码认证,手动输入用户名密码,单次有效,独立按钮触发,仅管理员可用 | +| SSH 密钥 | **不支持** | +| 自动发现机器 | 不需要,纯手动添加 | +| 用户认证 | **需要**,预设单管理员账号,访客免认证只读访问脱敏数据 | +| 拓扑图 | 保留,力导向图,**仅管理员可见** | +| 导入/导出 | 需要,JSON 格式,管理员专属 | +| 操作日志 | 需要,记录所有增删改 | +| 机器规模 | 20 台以内 | +| 前端技术 | Vue 3 + 组件库 + AntV G6 | +| 后端技术 | Go + SQLite | +| 数据自动备份 | **不需要** | +| 前端构建方式 | 支持 embed 打包 + 外置静态目录两种模式 | + +--- + +## 八、待确认细节 + +1. **前端 UI 组件库**:Element Plus 还是 Naive UI?(或者你有其他偏好) +2. **前端构建方式**:把 Vue 打包后的静态文件嵌入 Go 二进制(`embed.FS`,单文件部署),还是前后端分别部署?(建议同时支持两种) +3. **操作日志保留策略**:永久保留还是定期清理(如只保留最近 90 天)? diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f738512 --- /dev/null +++ b/go.mod @@ -0,0 +1,48 @@ +module lan-manager + +go 1.26.2 + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sessions v1.1.0 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/gin-gonic/gin v1.12.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.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/context v1.1.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/sessions v1.4.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // 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.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // 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.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.48.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5c6a694 --- /dev/null +++ b/go.sum @@ -0,0 +1,103 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo= +github.com/gin-contrib/sessions v1.1.0/go.mod h1:TyYZDIs6qCQg2SOoYPgMT9pAkmZceVNEJMcv5qbIy60= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +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/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= +github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +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.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +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= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +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.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c= +modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= diff --git a/scripts/lan-manager.service b/scripts/lan-manager.service new file mode 100644 index 0000000..4938362 --- /dev/null +++ b/scripts/lan-manager.service @@ -0,0 +1,23 @@ +[Unit] +Description=LAN Manager +After=network.target + +[Service] +Type=simple +ExecStart=/opt/lan-manager/lan-manager +WorkingDirectory=/opt/lan-manager +Restart=on-failure +Environment="HOST=0.0.0.0" +Environment="PORT=8080" +Environment="DB_PATH=/opt/lan-manager/data/lan-manager.db" +Environment="DATA_DIR=/opt/lan-manager/data" +Environment="ADMIN_USER=admin" +Environment="ADMIN_PASS=changeme" +Environment="SESSION_SECRET=your-random-secret-here" +Environment="PING_INTERVAL=60" +Environment="SSH_TIMEOUT=10" +Environment="LOG_RETENTION_DAYS=90" +Environment="UI_REFRESH_INTERVAL=10000" + +[Install] +WantedBy=multi-user.target diff --git a/server/config/config.go b/server/config/config.go new file mode 100644 index 0000000..d1adec4 --- /dev/null +++ b/server/config/config.go @@ -0,0 +1,67 @@ +package config + +import ( + "log" + "os" + "strconv" +) + +type Config struct { + Host string + Port string + DBPath string + DataDir string + PingInterval int // seconds + SSHTimeout int // seconds + LogLevel string + WebStaticPath string + AdminUser string + AdminPass string + SessionSecret string + LogRetentionDays int // 0 = keep forever + UIRefreshInterval int // milliseconds, 0 = disable auto refresh + EncryptKey string // for AES password encryption +} + +func Load() *Config { + cfg := &Config{ + Host: getEnv("HOST", "0.0.0.0"), + Port: getEnv("PORT", "8080"), + DBPath: getEnv("DB_PATH", "./data/lan-manager.db"), + DataDir: getEnv("DATA_DIR", "./data"), + PingInterval: getEnvInt("PING_INTERVAL", 60), + SSHTimeout: getEnvInt("SSH_TIMEOUT", 10), + LogLevel: getEnv("LOG_LEVEL", "info"), + WebStaticPath: getEnv("WEB_STATIC_PATH", ""), + AdminUser: getEnv("ADMIN_USER", "admin"), + AdminPass: getEnv("ADMIN_PASS", "admin"), + SessionSecret: getEnv("SESSION_SECRET", "lan-manager-secret-change-in-production"), + LogRetentionDays: getEnvInt("LOG_RETENTION_DAYS", 0), + UIRefreshInterval: getEnvInt("UI_REFRESH_INTERVAL", 10000), + EncryptKey: getEnv("ENCRYPT_KEY", ""), + } + + if cfg.AdminPass == "admin" { + log.Println("[WARN] Using default admin password. Please set ADMIN_PASS env variable in production.") + } + return cfg +} + +func getEnv(key, defaultValue string) string { + if v := os.Getenv(key); v != "" { + return v + } + return defaultValue +} + +func getEnvInt(key string, defaultValue int) int { + v := os.Getenv(key) + if v == "" { + return defaultValue + } + n, err := strconv.Atoi(v) + if err != nil { + return defaultValue + } + return n +} diff --git a/server/db/db.go b/server/db/db.go new file mode 100644 index 0000000..2fd0b4a --- /dev/null +++ b/server/db/db.go @@ -0,0 +1,107 @@ +package db + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "strings" + + "lan-manager/server/config" + + _ "modernc.org/sqlite" +) + +var DB *sql.DB + +func Init(cfg *config.Config) error { + if err := os.MkdirAll(filepath.Dir(cfg.DBPath), 0750); err != nil { + return err + } + var err error + DB, err = sql.Open("sqlite", cfg.DBPath+"?_pragma=foreign_keys(1)") + if err != nil { + return err + } + if err := DB.Ping(); err != nil { + return err + } + DB.SetMaxOpenConns(1) + return migrate() +} + +func migrate() error { + stmts := []string{ + `CREATE TABLE IF NOT EXISTS machines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hostname TEXT NOT NULL, + ip TEXT NOT NULL UNIQUE, + mac TEXT, + os_type TEXT NOT NULL, + os_version TEXT, + notes TEXT, + ssh_port INTEGER DEFAULT 22, + is_online INTEGER DEFAULT 0, + last_ping_at DATETIME, + cpu_info TEXT, + memory_info TEXT, + disk_info TEXT, + uptime TEXT, + listen_ports TEXT, + ssh_synced_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `ALTER TABLE machines ADD COLUMN ssh_port INTEGER DEFAULT 22`, + `ALTER TABLE machines ADD COLUMN ssh_username TEXT`, + `ALTER TABLE machines ADD COLUMN ssh_password TEXT`, + `CREATE TABLE IF NOT EXISTS services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + machine_id INTEGER NOT NULL REFERENCES machines(id) ON DELETE CASCADE, + name TEXT NOT NULL, + port INTEGER NOT NULL, + protocol TEXT DEFAULT 'TCP', + notes TEXT, + target_machine_id INTEGER REFERENCES machines(id) ON DELETE SET NULL, + target_notes TEXT + )`, + `ALTER TABLE services ADD COLUMN target_machine_id INTEGER REFERENCES machines(id) ON DELETE SET NULL`, + `ALTER TABLE services ADD COLUMN target_notes TEXT`, + `CREATE TABLE IF NOT EXISTS relationships ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_machine_id INTEGER NOT NULL REFERENCES machines(id) ON DELETE CASCADE, + target_machine_id INTEGER NOT NULL REFERENCES machines(id) ON DELETE CASCADE, + relation_type TEXT NOT NULL, + source_port INTEGER, + target_port INTEGER, + notes TEXT + )`, + `CREATE TABLE IF NOT EXISTS operation_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER, + entity_name TEXT, + old_value TEXT, + new_value TEXT, + source_ip TEXT, + username TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE INDEX IF NOT EXISTS idx_machines_ip ON machines(ip)`, + `CREATE INDEX IF NOT EXISTS idx_services_machine ON services(machine_id)`, + `CREATE INDEX IF NOT EXISTS idx_rel_src ON relationships(source_machine_id)`, + `CREATE INDEX IF NOT EXISTS idx_rel_tgt ON relationships(target_machine_id)`, + `CREATE INDEX IF NOT EXISTS idx_logs_created ON operation_logs(created_at)`, + } + for _, s := range stmts { + if _, err := DB.Exec(s); err != nil { + // ignore "duplicate column" errors for ALTER TABLE compatibility + if strings.Contains(err.Error(), "duplicate column name") { + continue + } + return fmt.Errorf("migration failed: %w", err) + } + } + return nil +} diff --git a/server/handlers/auth.go b/server/handlers/auth.go new file mode 100644 index 0000000..9af47e1 --- /dev/null +++ b/server/handlers/auth.go @@ -0,0 +1,55 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "lan-manager/server/config" + "lan-manager/server/middleware" + "lan-manager/server/models" +) + +type AuthHandler struct { + Cfg *config.Config +} + +func NewAuthHandler(cfg *config.Config) *AuthHandler { + return &AuthHandler{Cfg: cfg} +} + +func (h *AuthHandler) Login(c *gin.Context) { + var req models.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if req.Username != h.Cfg.AdminUser || req.Password != h.Cfg.AdminPass { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + session := sessions.Default(c) + session.Set(middleware.AdminSessionKey, req.Username) + if err := session.Save(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"username": req.Username, "is_admin": true}) +} + +func (h *AuthHandler) Logout(c *gin.Context) { + session := sessions.Default(c) + session.Delete(middleware.AdminSessionKey) + _ = session.Save() + c.JSON(http.StatusOK, gin.H{"message": "logged out"}) +} + +func (h *AuthHandler) Me(c *gin.Context) { + session := sessions.Default(c) + user := session.Get(middleware.AdminSessionKey) + if user == nil { + c.JSON(http.StatusOK, gin.H{"is_admin": false, "ui_refresh_interval": h.Cfg.UIRefreshInterval}) + return + } + c.JSON(http.StatusOK, gin.H{"is_admin": true, "username": user.(string), "ui_refresh_interval": h.Cfg.UIRefreshInterval}) +} diff --git a/server/handlers/export.go b/server/handlers/export.go new file mode 100644 index 0000000..5478bd7 --- /dev/null +++ b/server/handlers/export.go @@ -0,0 +1,127 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "lan-manager/server/db" + "lan-manager/server/middleware" + "lan-manager/server/models" +) + +type ExportHandler struct{} + +func NewExportHandler() *ExportHandler { + return &ExportHandler{} +} + +func (h *ExportHandler) Export(c *gin.Context) { + data := map[string]interface{}{} + + machines := []models.Machine{} + if rows, err := db.DB.Query(`SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, created_at, updated_at FROM machines`); err == nil { + defer rows.Close() + for rows.Next() { + var m models.Machine + var lp, ss *time.Time + if err := rows.Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.CreatedAt, &m.UpdatedAt); err == nil { + m.LastPingAt = lp + m.SSHSyncedAt = ss + machines = append(machines, m) + } + } + } + data["machines"] = machines + + services := []models.Service{} + if rows, err := db.DB.Query(`SELECT id, machine_id, name, port, protocol, notes, target_machine_id, target_notes FROM services`); err == nil { + defer rows.Close() + for rows.Next() { + var s models.Service + var tm sql.NullInt64 + if err := rows.Scan(&s.ID, &s.MachineID, &s.Name, &s.Port, &s.Protocol, &s.Notes, &tm, &s.TargetNotes); err == nil { + if tm.Valid { + tmid := tm.Int64 + s.TargetMachineID = &tmid + } + services = append(services, s) + } + } + } + data["services"] = services + + relationships := []models.Relationship{} + if rows, err := db.DB.Query(`SELECT id, source_machine_id, target_machine_id, relation_type, source_port, target_port, notes FROM relationships`); err == nil { + defer rows.Close() + for rows.Next() { + var r models.Relationship + if err := rows.Scan(&r.ID, &r.SourceMachineID, &r.TargetMachineID, &r.RelationType, &r.SourcePort, &r.TargetPort, &r.Notes); err == nil { + relationships = append(relationships, r) + } + } + } + data["relationships"] = relationships + + b, _ := json.MarshalIndent(data, "", " ") + filename := "lan-manager-backup-" + time.Now().Format("20060102") + ".json" + c.Header("Content-Disposition", "attachment; filename="+filename) + c.Data(http.StatusOK, "application/json", b) +} + +func (h *ExportHandler) Import(c *gin.Context) { + file, _, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + defer file.Close() + + var data map[string]json.RawMessage + if err := json.NewDecoder(file).Decode(&data); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + mode := c.DefaultPostForm("mode", "append") + if mode == "overwrite" { + _, _ = db.DB.Exec(`DELETE FROM relationships`) + _, _ = db.DB.Exec(`DELETE FROM services`) + _, _ = db.DB.Exec(`DELETE FROM machines`) + } + + if raw, ok := data["machines"]; ok { + var list []models.Machine + if err := json.Unmarshal(raw, &list); err == nil { + for _, m := range list { + _, _ = db.DB.Exec(`INSERT OR IGNORE INTO machines (id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, cpu_info, memory_info, disk_info, uptime, listen_ports) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + m.ID, m.Hostname, m.IP, m.MAC, m.OsType, m.OsVersion, m.Notes, m.SSHPort, m.SSHUsername, m.SSHPassword, 0, m.CPUInfo, m.MemoryInfo, m.DiskInfo, m.Uptime, m.ListenPorts) + } + } + } + + if raw, ok := data["services"]; ok { + var list []models.Service + if err := json.Unmarshal(raw, &list); err == nil { + for _, s := range list { + _, _ = db.DB.Exec(`INSERT OR IGNORE INTO services (id, machine_id, name, port, protocol, notes, target_machine_id, target_notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + s.ID, s.MachineID, s.Name, s.Port, s.Protocol, s.Notes, s.TargetMachineID, s.TargetNotes) + } + } + } + + if raw, ok := data["relationships"]; ok { + var list []models.Relationship + if err := json.Unmarshal(raw, &list); err == nil { + for _, r := range list { + _, _ = db.DB.Exec(`INSERT OR IGNORE INTO relationships (id, source_machine_id, target_machine_id, relation_type, source_port, target_port, notes) VALUES (?, ?, ?, ?, ?, ?, ?)`, + r.ID, r.SourceMachineID, r.TargetMachineID, r.RelationType, r.SourcePort, r.TargetPort, r.Notes) + } + } + } + + middleware.LogOperation("import", "system", nil, "json-import", "", "", c.ClientIP(), middleware.CurrentUser(c)) + c.JSON(http.StatusOK, gin.H{"message": "imported"}) +} diff --git a/server/handlers/health.go b/server/handlers/health.go new file mode 100644 index 0000000..c726c04 --- /dev/null +++ b/server/handlers/health.go @@ -0,0 +1,35 @@ +package handlers + +import ( + "net/http" + "runtime" + "time" + + "github.com/gin-gonic/gin" + "lan-manager/server/db" +) + +var startTime = time.Now() + +type HealthHandler struct{} + +func NewHealthHandler() *HealthHandler { + return &HealthHandler{} +} + +func (h *HealthHandler) Check(c *gin.Context) { + dbErr := db.DB.Ping() + status := "ok" + dbStatus := "ok" + if dbErr != nil { + status = "error" + dbStatus = "error" + } + c.JSON(http.StatusOK, gin.H{ + "status": status, + "db": dbStatus, + "version": "1.0.0", + "uptime": time.Since(startTime).String(), + "go": runtime.Version(), + }) +} diff --git a/server/handlers/logs.go b/server/handlers/logs.go new file mode 100644 index 0000000..a59bc42 --- /dev/null +++ b/server/handlers/logs.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "database/sql" + "net/http" + + "github.com/gin-gonic/gin" + "lan-manager/server/db" + "lan-manager/server/models" +) + +type LogHandler struct{} + +func NewLogHandler() *LogHandler { + return &LogHandler{} +} + +func (h *LogHandler) List(c *gin.Context) { + action := c.Query("action") + date := c.Query("date") + query := `SELECT id, action, entity_type, entity_id, entity_name, old_value, new_value, source_ip, username, created_at FROM operation_logs WHERE 1=1` + args := []interface{}{} + if action != "" { + query += ` AND action = ?` + args = append(args, action) + } + if date != "" { + query += ` AND DATE(created_at) = ?` + args = append(args, date) + } + query += ` ORDER BY created_at DESC LIMIT 1000` + + rows, err := db.DB.Query(query, args...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + defer rows.Close() + logs := []models.OperationLog{} + for rows.Next() { + var l models.OperationLog + var eid sql.NullInt64 + if err := rows.Scan(&l.ID, &l.Action, &l.EntityType, &eid, &l.EntityName, &l.OldValue, &l.NewValue, &l.SourceIP, &l.Username, &l.CreatedAt); err == nil { + if eid.Valid { + l.EntityID = &eid.Int64 + } + logs = append(logs, l) + } + } + c.JSON(http.StatusOK, logs) +} diff --git a/server/handlers/machines.go b/server/handlers/machines.go new file mode 100644 index 0000000..3eeec8d --- /dev/null +++ b/server/handlers/machines.go @@ -0,0 +1,335 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "lan-manager/server/db" + "lan-manager/server/middleware" + "lan-manager/server/models" + "lan-manager/server/services" + "lan-manager/server/utils" +) + +type MachineHandler struct{} + +func NewMachineHandler() *MachineHandler { + return &MachineHandler{} +} + +// sanitizeMachine removes sensitive fields for guest users +func sanitizeMachine(m *models.Machine) { + m.IP = "" + m.MAC = models.NullString{} + m.Notes = models.NullString{} + m.SSHUsername = models.NullString{} + m.SSHPassword = models.NullString{} + m.ListenPorts = models.NullString{} + m.CreatedAt = time.Time{} + m.UpdatedAt = time.Time{} +} + +func (h *MachineHandler) List(c *gin.Context) { + isAdmin := middleware.IsAdmin(c) + osFilter := c.Query("os_type") + search := c.Query("search") + + query := `SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, created_at, updated_at FROM machines WHERE 1=1` + args := []interface{}{} + if osFilter != "" { + query += ` AND os_type = ?` + args = append(args, osFilter) + } + if search != "" { + if isAdmin { + query += ` AND (hostname LIKE ? OR ip LIKE ?)` + args = append(args, "%"+search+"%", "%"+search+"%") + } else { + query += ` AND hostname LIKE ?` + args = append(args, "%"+search+"%") + } + } + query += ` ORDER BY created_at DESC` + + rows, err := db.DB.Query(query, args...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + defer rows.Close() + + machines := []models.Machine{} + for rows.Next() { + var m models.Machine + var lp, ss *time.Time + err := rows.Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.CreatedAt, &m.UpdatedAt) + if err != nil { + continue + } + m.LastPingAt = lp + m.SSHSyncedAt = ss + m.SSHPassword = models.NullString{} // never return password + if !isAdmin { + sanitizeMachine(&m) + } + machines = append(machines, m) + } + rows.Close() + + // Fetch service counts + if len(machines) > 0 { + scRows, err := db.DB.Query(`SELECT machine_id, COUNT(*) FROM services GROUP BY machine_id`) + if err == nil { + defer scRows.Close() + for scRows.Next() { + var mid int64 + var cnt int + if err := scRows.Scan(&mid, &cnt); err == nil { + for i := range machines { + if machines[i].ID == mid { + machines[i].ServiceCount = cnt + break + } + } + } + } + } + } + + c.JSON(http.StatusOK, machines) +} + +func (h *MachineHandler) Get(c *gin.Context) { + isAdmin := middleware.IsAdmin(c) + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var m models.Machine + var lp, ss *time.Time + err = db.DB.QueryRow(`SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, created_at, updated_at FROM machines WHERE id = ?`, id). + Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.CreatedAt, &m.UpdatedAt) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + m.LastPingAt = lp + m.SSHSyncedAt = ss + + services := []models.Service{} + if rows, err := db.DB.Query(`SELECT id, machine_id, name, port, protocol, notes FROM services WHERE machine_id = ?`, id); err == nil { + defer rows.Close() + for rows.Next() { + var s models.Service + if err := rows.Scan(&s.ID, &s.MachineID, &s.Name, &s.Port, &s.Protocol, &s.Notes); err == nil { + if !isAdmin { + s.Notes = "" + } + services = append(services, s) + } + } + } + + relationships := []models.Relationship{} + if isAdmin { + if rows, err := db.DB.Query(`SELECT id, source_machine_id, target_machine_id, relation_type, source_port, target_port, notes FROM relationships WHERE source_machine_id = ? OR target_machine_id = ?`, id, id); err == nil { + defer rows.Close() + for rows.Next() { + var r models.Relationship + if err := rows.Scan(&r.ID, &r.SourceMachineID, &r.TargetMachineID, &r.RelationType, &r.SourcePort, &r.TargetPort, &r.Notes); err == nil { + relationships = append(relationships, r) + } + } + } + } + + m.SSHPassword = models.NullString{} // never return password + if !isAdmin { + sanitizeMachine(&m) + } + + c.JSON(http.StatusOK, gin.H{ + "machine": m, + "services": services, + "relationships": relationships, + }) +} + +func (h *MachineHandler) Create(c *gin.Context) { + var m models.Machine + if err := c.ShouldBindJSON(&m); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + encPass, _ := utils.Encrypt(m.SSHPassword.String) + passField := models.NullString{} + passField.String = encPass + passField.Valid = encPass != "" + res, err := db.DB.Exec(`INSERT INTO machines (hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + m.Hostname, m.IP, m.MAC, m.OsType, m.OsVersion, m.Notes, m.SSHPort, m.SSHUsername, passField) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + id, _ := res.LastInsertId() + m.ID = id + m.CreatedAt = time.Now() + m.UpdatedAt = time.Now() + + middleware.LogOperation("create", "machine", &m.ID, m.Hostname, "", middleware.ToJSON(m), c.ClientIP(), middleware.CurrentUser(c)) + c.JSON(http.StatusCreated, m) +} + +func (h *MachineHandler) Update(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var m models.Machine + if err := c.ShouldBindJSON(&m); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var old models.Machine + var oldPass string + var lp, ss *time.Time + _ = db.DB.QueryRow(`SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, created_at, updated_at FROM machines WHERE id = ?`, id). + Scan(&old.ID, &old.Hostname, &old.IP, &old.MAC, &old.OsType, &old.OsVersion, &old.Notes, &old.SSHPort, &old.SSHUsername, &oldPass, &old.IsOnline, &lp, &old.CPUInfo, &old.MemoryInfo, &old.DiskInfo, &old.Uptime, &old.ListenPorts, &ss, &old.CreatedAt, &old.UpdatedAt) + old.LastPingAt = lp + old.SSHSyncedAt = ss + + // Keep old password if new one is empty + passToSave := oldPass + if m.SSHPassword.String != "" { + encPass, err := utils.Encrypt(m.SSHPassword.String) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "encrypt failed"}) + return + } + passToSave = encPass + } + + _, err = db.DB.Exec(`UPDATE machines SET hostname=?, ip=?, mac=?, os_type=?, os_version=?, notes=?, ssh_port=?, ssh_username=?, ssh_password=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`, + m.Hostname, m.IP, m.MAC, m.OsType, m.OsVersion, m.Notes, m.SSHPort, m.SSHUsername, passToSave, id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + m.ID = id + old.SSHPassword = models.NullString{} + m.SSHPassword = models.NullString{} + middleware.LogOperation("update", "machine", &m.ID, m.Hostname, middleware.ToJSON(old), middleware.ToJSON(m), c.ClientIP(), middleware.CurrentUser(c)) + c.JSON(http.StatusOK, m) +} + +func (h *MachineHandler) Delete(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var name string + _ = db.DB.QueryRow(`SELECT hostname FROM machines WHERE id = ?`, id).Scan(&name) + _, err = db.DB.Exec(`DELETE FROM machines WHERE id = ?`, id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + middleware.LogOperation("delete", "machine", &id, name, "", "", c.ClientIP(), middleware.CurrentUser(c)) + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} + +func (h *MachineHandler) SSHInfo(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var req models.SSHInfoRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var ip string + var sshPort int + var savedUser, savedPass string + if err := db.DB.QueryRow(`SELECT ip, ssh_port, ssh_username, ssh_password FROM machines WHERE id = ?`, id).Scan(&ip, &sshPort, &savedUser, &savedPass); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "machine not found"}) + return + } + + user := req.Username + pass := req.Password + + // 如果前端没传密码,尝试用数据库保存的凭据 + if pass == "" { + if savedUser == "" || savedPass == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "未保存 SSH 凭据,请手动输入"}) + return + } + plainPass, decErr := utils.Decrypt(savedPass) + if decErr != nil || plainPass == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "未保存 SSH 凭据,请手动输入"}) + return + } + user = savedUser + pass = plainPass + } + + result, err := services.GetSSHInfo(ip, sshPort, user, pass) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Save credentials to machine record for auto-sync (only when user explicitly provided password) + if req.Password != "" { + encPass, _ := utils.Encrypt(req.Password) + _, _ = db.DB.Exec(`UPDATE machines SET ssh_username=?, ssh_password=? WHERE id=?`, req.Username, encPass, id) + } + + c.JSON(http.StatusOK, result) +} + +func (h *MachineHandler) SyncSSH(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var req models.SSHInfoResult + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + portsStr := "" + if len(req.ListenPorts) > 0 { + b, _ := json.Marshal(req.ListenPorts) + portsStr = string(b) + // remove brackets + if len(portsStr) > 2 { + portsStr = portsStr[1 : len(portsStr)-1] + } + } + _, err = db.DB.Exec(`UPDATE machines SET cpu_info=?, memory_info=?, disk_info=?, uptime=?, listen_ports=?, ssh_synced_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?`, + req.RawCPUInfo, req.RawMemoryInfo, req.RawDiskInfo, req.Uptime, portsStr, id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + middleware.LogOperation("update", "machine", &id, "sync-ssh", "", "", c.ClientIP(), middleware.CurrentUser(c)) + c.JSON(http.StatusOK, gin.H{"message": "synced"}) +} diff --git a/server/handlers/relationships.go b/server/handlers/relationships.go new file mode 100644 index 0000000..b802799 --- /dev/null +++ b/server/handlers/relationships.go @@ -0,0 +1,94 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "lan-manager/server/db" + "lan-manager/server/middleware" + "lan-manager/server/models" +) + +type RelationshipHandler struct{} + +func NewRelationshipHandler() *RelationshipHandler { + return &RelationshipHandler{} +} + +func (h *RelationshipHandler) List(c *gin.Context) { + rows, err := db.DB.Query(`SELECT id, source_machine_id, target_machine_id, relation_type, source_port, target_port, notes FROM relationships`) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + defer rows.Close() + list := []models.Relationship{} + for rows.Next() { + var r models.Relationship + if err := rows.Scan(&r.ID, &r.SourceMachineID, &r.TargetMachineID, &r.RelationType, &r.SourcePort, &r.TargetPort, &r.Notes); err == nil { + list = append(list, r) + } + } + c.JSON(http.StatusOK, list) +} + +func (h *RelationshipHandler) Create(c *gin.Context) { + var r models.Relationship + if err := c.ShouldBindJSON(&r); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + res, err := db.DB.Exec(`INSERT INTO relationships (source_machine_id, target_machine_id, relation_type, source_port, target_port, notes) VALUES (?, ?, ?, ?, ?, ?)`, + r.SourceMachineID, r.TargetMachineID, r.RelationType, r.SourcePort, r.TargetPort, r.Notes) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + id, _ := res.LastInsertId() + r.ID = id + middleware.LogOperation("create", "relationship", &r.ID, strconv.FormatInt(r.SourceMachineID, 10)+" -> "+strconv.FormatInt(r.TargetMachineID, 10), "", middleware.ToJSON(r), c.ClientIP(), middleware.CurrentUser(c)) + c.JSON(http.StatusCreated, r) +} + +func (h *RelationshipHandler) Update(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var r models.Relationship + if err := c.ShouldBindJSON(&r); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var old models.Relationship + _ = db.DB.QueryRow(`SELECT id, source_machine_id, target_machine_id, relation_type, source_port, target_port, notes FROM relationships WHERE id = ?`, id). + Scan(&old.ID, &old.SourceMachineID, &old.TargetMachineID, &old.RelationType, &old.SourcePort, &old.TargetPort, &old.Notes) + _, err = db.DB.Exec(`UPDATE relationships SET source_machine_id=?, target_machine_id=?, relation_type=?, source_port=?, target_port=?, notes=? WHERE id=?`, + r.SourceMachineID, r.TargetMachineID, r.RelationType, r.SourcePort, r.TargetPort, r.Notes, id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + r.ID = id + middleware.LogOperation("update", "relationship", &r.ID, strconv.FormatInt(r.SourceMachineID, 10)+" -> "+strconv.FormatInt(r.TargetMachineID, 10), middleware.ToJSON(old), middleware.ToJSON(r), c.ClientIP(), middleware.CurrentUser(c)) + c.JSON(http.StatusOK, r) +} + +func (h *RelationshipHandler) Delete(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var src, tgt int64 + _ = db.DB.QueryRow(`SELECT source_machine_id, target_machine_id FROM relationships WHERE id = ?`, id).Scan(&src, &tgt) + _, err = db.DB.Exec(`DELETE FROM relationships WHERE id = ?`, id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + middleware.LogOperation("delete", "relationship", &id, strconv.FormatInt(src, 10)+" -> "+strconv.FormatInt(tgt, 10), "", "", c.ClientIP(), middleware.CurrentUser(c)) + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} diff --git a/server/handlers/services.go b/server/handlers/services.go new file mode 100644 index 0000000..04e9d6e --- /dev/null +++ b/server/handlers/services.go @@ -0,0 +1,139 @@ +package handlers + +import ( + "database/sql" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "lan-manager/server/db" + "lan-manager/server/middleware" + "lan-manager/server/models" +) + +type ServiceHandler struct{} + +func NewServiceHandler() *ServiceHandler { + return &ServiceHandler{} +} + +func (h *ServiceHandler) Create(c *gin.Context) { + machineID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid machine_id"}) + return + } + var s models.Service + if err := c.ShouldBindJSON(&s); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + s.MachineID = machineID + res, err := db.DB.Exec(`INSERT INTO services (machine_id, name, port, protocol, notes, target_machine_id, target_notes) VALUES (?, ?, ?, ?, ?, ?, ?)`, + s.MachineID, s.Name, s.Port, s.Protocol, s.Notes, s.TargetMachineID, s.TargetNotes) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + id, _ := res.LastInsertId() + s.ID = id + middleware.LogOperation("create", "service", &s.ID, s.Name+" on machine "+strconv.FormatInt(machineID, 10), "", middleware.ToJSON(s), c.ClientIP(), middleware.CurrentUser(c)) + c.JSON(http.StatusCreated, s) +} + +func (h *ServiceHandler) Update(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var s models.Service + if err := c.ShouldBindJSON(&s); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var old models.Service + _ = db.DB.QueryRow(`SELECT id, machine_id, name, port, protocol, notes, target_machine_id, target_notes FROM services WHERE id = ?`, id). + Scan(&old.ID, &old.MachineID, &old.Name, &old.Port, &old.Protocol, &old.Notes, &old.TargetMachineID, &old.TargetNotes) + _, err = db.DB.Exec(`UPDATE services SET name=?, port=?, protocol=?, notes=?, target_machine_id=?, target_notes=? WHERE id=?`, + s.Name, s.Port, s.Protocol, s.Notes, s.TargetMachineID, s.TargetNotes, id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + s.ID = id + middleware.LogOperation("update", "service", &s.ID, s.Name, middleware.ToJSON(old), middleware.ToJSON(s), c.ClientIP(), middleware.CurrentUser(c)) + c.JSON(http.StatusOK, s) +} + +func (h *ServiceHandler) Delete(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var name string + var machineID int64 + _ = db.DB.QueryRow(`SELECT name, machine_id FROM services WHERE id = ?`, id).Scan(&name, &machineID) + _, err = db.DB.Exec(`DELETE FROM services WHERE id = ?`, id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + middleware.LogOperation("delete", "service", &id, name+" on machine "+strconv.FormatInt(machineID, 10), "", "", c.ClientIP(), middleware.CurrentUser(c)) + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} + +func (h *ServiceHandler) ListByMachine(c *gin.Context) { + machineID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid machine_id"}) + return + } + isAdmin := middleware.IsAdmin(c) + rows, err := db.DB.Query(`SELECT id, machine_id, name, port, protocol, notes, target_machine_id, target_notes FROM services WHERE machine_id = ?`, machineID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + defer rows.Close() + list := []models.Service{} + for rows.Next() { + var s models.Service + var tm sql.NullInt64 + if err := rows.Scan(&s.ID, &s.MachineID, &s.Name, &s.Port, &s.Protocol, &s.Notes, &tm, &s.TargetNotes); err == nil { + if tm.Valid { + tmid := tm.Int64 + s.TargetMachineID = &tmid + } + if !isAdmin { + s.Notes = "" + s.TargetNotes = "" + } + list = append(list, s) + } + } + c.JSON(http.StatusOK, list) +} + +func (h *ServiceHandler) ListAll(c *gin.Context) { + rows, err := db.DB.Query(`SELECT id, machine_id, name, port, protocol, notes, target_machine_id, target_notes FROM services`) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + defer rows.Close() + list := []models.Service{} + for rows.Next() { + var s models.Service + var tm sql.NullInt64 + if err := rows.Scan(&s.ID, &s.MachineID, &s.Name, &s.Port, &s.Protocol, &s.Notes, &tm, &s.TargetNotes); err == nil { + if tm.Valid { + tmid := tm.Int64 + s.TargetMachineID = &tmid + } + list = append(list, s) + } + } + c.JSON(http.StatusOK, list) +} diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..ffbd60a --- /dev/null +++ b/server/main.go @@ -0,0 +1,156 @@ +package main + +import ( + "context" + "embed" + "fmt" + "io/fs" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + "lan-manager/server/config" + "lan-manager/server/db" + "lan-manager/server/handlers" + "lan-manager/server/middleware" + "lan-manager/server/services" +) + +//go:embed all:static +var webFS embed.FS + +func main() { + cfg := config.Load() + + if err := db.Init(cfg); err != nil { + fmt.Fprintf(os.Stderr, "db init failed: %v\n", err) + os.Exit(1) + } + + gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.RedirectTrailingSlash = false + r.RedirectFixedPath = false + r.Use(gin.Recovery()) + + store := cookie.NewStore([]byte(cfg.SessionSecret)) + store.Options(sessions.Options{ + Path: "/", + MaxAge: 86400 * 7, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + r.Use(sessions.Sessions("lanmgr_session", store)) + + // API routes + api := r.Group("/api") + { + authH := handlers.NewAuthHandler(cfg) + api.POST("/auth/login", authH.Login) + api.POST("/auth/logout", authH.Logout) + api.GET("/auth/me", authH.Me) + + api.GET("/health", handlers.NewHealthHandler().Check) + + // Guest + Admin read-only routes + guest := api.Group("/") + guest.Use(middleware.OptionalAuth()) + { + mh := handlers.NewMachineHandler() + guest.GET("/machines", mh.List) + } + + // Admin only routes + admin := api.Group("/") + admin.Use(middleware.AuthRequired()) + { + mh := handlers.NewMachineHandler() + admin.GET("/machines/:id", mh.Get) + admin.POST("/machines", mh.Create) + admin.PUT("/machines/:id", mh.Update) + admin.DELETE("/machines/:id", mh.Delete) + admin.POST("/machines/:id/ssh-info", mh.SSHInfo) + admin.POST("/machines/:id/sync-ssh", mh.SyncSSH) + + sh := handlers.NewServiceHandler() + admin.GET("/machines/:id/services", sh.ListByMachine) + admin.POST("/machines/:id/services", sh.Create) + admin.PUT("/services/:id", sh.Update) + admin.DELETE("/services/:id", sh.Delete) + admin.GET("/services", sh.ListAll) + + rh := handlers.NewRelationshipHandler() + admin.GET("/relationships", rh.List) + admin.POST("/relationships", rh.Create) + admin.PUT("/relationships/:id", rh.Update) + admin.DELETE("/relationships/:id", rh.Delete) + + admin.GET("/logs", handlers.NewLogHandler().List) + + eh := handlers.NewExportHandler() + admin.GET("/export", eh.Export) + admin.POST("/import", eh.Import) + } + } + + // Static files + if cfg.WebStaticPath != "" { + r.Static("/", cfg.WebStaticPath) + r.NoRoute(func(c *gin.Context) { + c.File(cfg.WebStaticPath + "/index.html") + }) + } else { + staticFS, err := fs.Sub(webFS, "static") + if err != nil { + fmt.Fprintf(os.Stderr, "static embed failed: %v\n", err) + os.Exit(1) + } + fileServer := http.FileServer(http.FS(staticFS)) + r.NoRoute(func(c *gin.Context) { + path := c.Request.URL.Path + if path != "/" && path != "" && !strings.HasPrefix(path, "/assets/") && path != "/favicon.ico" { + c.Request.URL.Path = "/" + } + fileServer.ServeHTTP(c.Writer, c.Request) + }) + } + + // Start ping service + services.StartPingService(cfg.PingInterval) + + // Start log cleanup + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + services.CleanupLogs(ctx, cfg.LogRetentionDays) + + srv := &http.Server{ + Addr: cfg.Host + ":" + cfg.Port, + Handler: r, + } + + go func() { + fmt.Printf("Server listening on %s:%s\n", cfg.Host, cfg.Port) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + fmt.Fprintf(os.Stderr, "server error: %v\n", err) + os.Exit(1) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + fmt.Println("Shutting down server...") + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + fmt.Fprintf(os.Stderr, "server shutdown error: %v\n", err) + } + fmt.Println("Server exited") +} diff --git a/server/middleware/auth.go b/server/middleware/auth.go new file mode 100644 index 0000000..285fe4a --- /dev/null +++ b/server/middleware/auth.go @@ -0,0 +1,53 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +const AdminSessionKey = "admin_user" + +func AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + session := sessions.Default(c) + user := session.Get(AdminSessionKey) + if user == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + c.Set("admin_user", user.(string)) + c.Set("is_admin", true) + c.Next() + } +} + +func OptionalAuth() gin.HandlerFunc { + return func(c *gin.Context) { + session := sessions.Default(c) + if user := session.Get(AdminSessionKey); user != nil { + c.Set("admin_user", user.(string)) + c.Set("is_admin", true) + } else { + c.Set("is_admin", false) + } + c.Next() + } +} + +func IsAdmin(c *gin.Context) bool { + v, exists := c.Get("is_admin") + if !exists { + return false + } + return v.(bool) +} + +func CurrentUser(c *gin.Context) string { + u, _ := c.Get("admin_user") + if u == nil { + return "" + } + return u.(string) +} diff --git a/server/middleware/log.go b/server/middleware/log.go new file mode 100644 index 0000000..c1b751c --- /dev/null +++ b/server/middleware/log.go @@ -0,0 +1,19 @@ +package middleware + +import ( + "encoding/json" + "lan-manager/server/db" +) + +func LogOperation(action, entityType string, entityID *int64, entityName, oldValue, newValue, sourceIP, username string) { + _, _ = db.DB.Exec( + `INSERT INTO operation_logs (action, entity_type, entity_id, entity_name, old_value, new_value, source_ip, username) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + action, entityType, entityID, entityName, oldValue, newValue, sourceIP, username, + ) +} + +func ToJSON(v interface{}) string { + b, _ := json.Marshal(v) + return string(b) +} diff --git a/server/models/models.go b/server/models/models.go new file mode 100644 index 0000000..38d9fc9 --- /dev/null +++ b/server/models/models.go @@ -0,0 +1,114 @@ +package models + +import ( + "database/sql" + "encoding/json" + "time" +) + +type NullString struct { + sql.NullString +} + +func (ns NullString) MarshalJSON() ([]byte, error) { + if ns.Valid { + return json.Marshal(ns.String) + } + return json.Marshal("") +} + +func (ns *NullString) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + ns.Valid = false + ns.String = "" + return nil + } + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + ns.String = s + ns.Valid = true + return nil +} + +type Machine struct { + ID int64 `json:"id"` + Hostname string `json:"hostname"` + IP string `json:"ip"` + MAC NullString `json:"mac"` + OsType string `json:"os_type"` + OsVersion NullString `json:"os_version"` + Notes NullString `json:"notes"` + SSHPort int `json:"ssh_port"` + SSHUsername NullString `json:"ssh_username"` + SSHPassword NullString `json:"ssh_password"` + IsOnline bool `json:"is_online"` + LastPingAt *time.Time `json:"last_ping_at"` + CPUInfo NullString `json:"cpu_info"` + MemoryInfo NullString `json:"memory_info"` + DiskInfo NullString `json:"disk_info"` + Uptime NullString `json:"uptime"` + ListenPorts NullString `json:"listen_ports"` + SSHSyncedAt *time.Time `json:"ssh_synced_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ServiceCount int `json:"service_count"` +} + +type Service struct { + ID int64 `json:"id"` + MachineID int64 `json:"machine_id"` + Name string `json:"name"` + Port int `json:"port"` + Protocol string `json:"protocol"` + Notes string `json:"notes"` + TargetMachineID *int64 `json:"target_machine_id"` + TargetNotes string `json:"target_notes"` +} + +type Relationship struct { + ID int64 `json:"id"` + SourceMachineID int64 `json:"source_machine_id"` + TargetMachineID int64 `json:"target_machine_id"` + RelationType string `json:"relation_type"` + SourcePort *int `json:"source_port"` + TargetPort *int `json:"target_port"` + Notes string `json:"notes"` +} + +type OperationLog struct { + ID int64 `json:"id"` + Action string `json:"action"` + EntityType string `json:"entity_type"` + EntityID *int64 `json:"entity_id"` + EntityName string `json:"entity_name"` + OldValue string `json:"old_value"` + NewValue string `json:"new_value"` + SourceIP string `json:"source_ip"` + Username string `json:"username"` + CreatedAt time.Time `json:"created_at"` +} + +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type SSHInfoRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password"` +} + +type SSHInfoResult struct { + Hostname string `json:"hostname"` + OsVersion string `json:"os_version"` + CPU string `json:"cpu"` + Memory string `json:"memory"` + Disk string `json:"disk"` + Uptime string `json:"uptime"` + ListenPorts []int `json:"listen_ports"` + RawCPUInfo string `json:"raw_cpu_info"` + RawMemoryInfo string `json:"raw_memory_info"` + RawDiskInfo string `json:"raw_disk_info"` +} diff --git a/server/services/ping.go b/server/services/ping.go new file mode 100644 index 0000000..0281921 --- /dev/null +++ b/server/services/ping.go @@ -0,0 +1,182 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "lan-manager/server/db" + "lan-manager/server/utils" + "os/exec" + "runtime" + "strings" + "time" + + "net" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" +) + +func PingHost(ip string) bool { + // Try system ping first (no special privileges needed on Linux) + if ok := pingWithSystem(ip); ok { + return true + } + // Fallback to raw ICMP + return pingWithICMP(ip) +} + +func pingWithSystem(ip string) bool { + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("ping", "-n", "1", "-w", "3000", ip) + } else if runtime.GOOS == "darwin" { + // macOS ping -W is in milliseconds + cmd = exec.Command("ping", "-c", "1", "-W", "3000", ip) + } else { + // Linux ping -W is in seconds + cmd = exec.Command("ping", "-c", "1", "-W", "3", ip) + } + err := cmd.Run() + return err == nil +} + +func pingWithICMP(ip string) bool { + c, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0") + if err != nil { + return false + } + defer c.Close() + + m := &icmp.Message{ + Type: ipv4.ICMPTypeEcho, + Code: 0, + Body: &icmp.Echo{ID: 1, Seq: 1, Data: []byte("lan-manager")}, + } + wb, err := m.Marshal(nil) + if err != nil { + return false + } + if _, err := c.WriteTo(wb, &net.IPAddr{IP: parseIP(ip)}); err != nil { + return false + } + + rb := make([]byte, 1500) + _ = c.SetReadDeadline(time.Now().Add(3 * time.Second)) + n, _, err := c.ReadFrom(rb) + if err != nil { + return false + } + rm, err := icmp.ParseMessage(ipv4.ICMPTypeEchoReply.Protocol(), rb[:n]) + if err != nil { + return false + } + return rm.Type == ipv4.ICMPTypeEchoReply +} + +func parseIP(ip string) []byte { + parts := strings.Split(ip, ".") + if len(parts) != 4 { + return nil + } + res := make([]byte, 4) + for i, p := range parts { + var v byte + fmt.Sscanf(p, "%d", &v) + res[i] = v + } + return res +} + +func StartPingService(interval int) { + ticker := time.NewTicker(time.Duration(interval) * time.Second) + go func() { + for range ticker.C { + rows, err := db.DB.Query(`SELECT id, ip, ssh_port, ssh_username, ssh_password FROM machines`) + if err != nil { + fmt.Printf("[Ping] query error: %v\n", err) + continue + } + type machinePing struct { + id int64 + ip string + sshPort int + sshUsername string + sshPassword string + } + list := []machinePing{} + for rows.Next() { + var m machinePing + if err := rows.Scan(&m.id, &m.ip, &m.sshPort, &m.sshUsername, &m.sshPassword); err == nil { + list = append(list, m) + } else { + fmt.Printf("[Ping] scan error: %v\n", err) + } + } + rows.Close() + fmt.Printf("[Ping] tick processed %d machines\n", len(list)) + + for _, m := range list { + online := PingHost(m.ip) + var onlineInt int + if online { + onlineInt = 1 + } + _, dbErr := db.DB.Exec(`UPDATE machines SET is_online = ?, last_ping_at = CURRENT_TIMESTAMP WHERE id = ?`, onlineInt, m.id) + if dbErr != nil { + fmt.Printf("[Ping] update status error for %s: %v\n", m.ip, dbErr) + } else { + fmt.Printf("[Ping] %s -> online=%v\n", m.ip, online) + } + + // Auto fetch SSH info if credentials are saved + if m.sshUsername != "" && m.sshPassword != "" { + go func(mid int64, mip string, mport int, muser, mpass string) { + plainPass, decryptErr := utils.Decrypt(mpass) + if decryptErr != nil { + fmt.Printf("[SSH] decrypt failed for %s:%d: %v\n", mip, mport, decryptErr) + return + } + result, err := GetSSHInfo(mip, mport, muser, plainPass) + if err != nil { + fmt.Printf("[SSH] auto-fetch failed for %s:%d: %v\n", mip, mport, err) + return + } + portsStr := "" + if len(result.ListenPorts) > 0 { + b, _ := json.Marshal(result.ListenPorts) + portsStr = string(b) + if len(portsStr) > 2 { + portsStr = portsStr[1 : len(portsStr)-1] + } + } + _, dbErr := db.DB.Exec(`UPDATE machines SET cpu_info=?, memory_info=?, disk_info=?, uptime=?, listen_ports=?, ssh_synced_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?`, + result.RawCPUInfo, result.RawMemoryInfo, result.RawDiskInfo, result.Uptime, portsStr, mid) + if dbErr != nil { + fmt.Printf("[SSH] auto-fetch db error for %s:%d: %v\n", mip, mport, dbErr) + } else { + fmt.Printf("[SSH] auto-fetch success for %s:%d\n", mip, mport) + } + }(m.id, m.ip, m.sshPort, m.sshUsername, m.sshPassword) + } + } + } + }() +} + +func CleanupLogs(ctx context.Context, retentionDays int) { + if retentionDays <= 0 { + return + } + ticker := time.NewTicker(24 * time.Hour) + go func() { + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + _, _ = db.DB.Exec(`DELETE FROM operation_logs WHERE created_at < datetime('now', '-`+fmt.Sprintf("%d", retentionDays)+` days')`) + } + } + }() +} diff --git a/server/services/ssh.go b/server/services/ssh.go new file mode 100644 index 0000000..debe008 --- /dev/null +++ b/server/services/ssh.go @@ -0,0 +1,113 @@ +package services + +import ( + "bufio" + "bytes" + "lan-manager/server/config" + "lan-manager/server/models" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/ssh" +) + +func GetSSHInfo(ip string, port int, user, pass string) (*models.SSHInfoResult, error) { + if port <= 0 { + port = 22 + } + cfg := &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ + ssh.Password(pass), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: time.Duration(config.Load().SSHTimeout) * time.Second, + } + + client, err := ssh.Dial("tcp", ip+":"+strconv.Itoa(port), cfg) + if err != nil { + return nil, err + } + defer client.Close() + + result := &models.SSHInfoResult{} + + // Hostname + if out, err := runSSHCommand(client, "hostname"); err == nil { + result.Hostname = strings.TrimSpace(out) + } + + // OS Version + if out, err := runSSHCommand(client, "cat /etc/os-release | grep PRETTY_NAME | cut -d= -f2 | tr -d '\"'"); err == nil && out != "" { + result.OsVersion = strings.TrimSpace(out) + } else if out, err := runSSHCommand(client, "uname -sr"); err == nil { + result.OsVersion = strings.TrimSpace(out) + } + + // CPU (two samples from /proc/stat for accurate real-time usage) + cpuScript := `c1=$(awk '/^cpu /{print $2+$4,$2+$4+$5}' /proc/stat) && sleep 1 && c2=$(awk '/^cpu /{print $2+$4,$2+$4+$5}' /proc/stat) && awk 'BEGIN{split("'"$c1"'", a, " "); split("'"$c2"'", b, " "); if(b[2]-a[2]>0) printf "%.1f", 100*(b[1]-a[1])/(b[2]-a[2]); else print "0.0"}'` + if out, err := runSSHCommand(client, cpuScript); err == nil && out != "" { + cpuUsage := strings.TrimSpace(out) + if cpuUsage == "" { + cpuUsage = "0.0" + } + cores := "" + if cout, err := runSSHCommand(client, "nproc"); err == nil { + cores = strings.TrimSpace(cout) + } + result.CPU = cpuUsage + "%" + if cores != "" { + result.CPU += " (" + cores + " cores)" + } + result.RawCPUInfo = result.CPU + } + + // Memory + if out, err := runSSHCommand(client, "free -m | awk 'NR==2{printf \"%.1f/%.1f (%.0f%%)\", $3/1024,$2/1024,$3*100/$2 }'"); err == nil && out != "" { + result.Memory = strings.TrimSpace(out) + result.RawMemoryInfo = result.Memory + } + + // Disk (exclude virtual filesystems, keep all real partitions) + // Use df -hP to prevent long filesystem names from wrapping to next line + if out, err := runSSHCommand(client, "df -hP | awk 'NR>1 && $1 !~ /^(tmpfs|devtmpfs|squashfs|overlay|efivarfs|tracefs|securityfs|pstore|bpf|configfs|fusectl|mqueue|hugetlbfs|rpc_pipefs|nfsd|binfmt_misc|autofs|devpts|proc|sysfs|cgroup|cgroup2|ramfs)$/ {printf \"%s %s/%s (%s) \", $6,$3,$2,$5}'"); err == nil && out != "" { + result.Disk = strings.TrimSpace(out) + result.RawDiskInfo = result.Disk + } + + // Uptime + if out, err := runSSHCommand(client, "uptime -p 2>/dev/null || uptime | awk -F',' '{print $1}'"); err == nil { + result.Uptime = strings.TrimSpace(out) + } + + // Listen ports + if out, err := runSSHCommand(client, "ss -tlnp | awk 'NR>1 {print $4}' | sed 's/.*://' | sort -n | uniq"); err == nil && out != "" { + ports := []int{} + scanner := bufio.NewScanner(strings.NewReader(out)) + for scanner.Scan() { + p := strings.TrimSpace(scanner.Text()) + if p == "" { + continue + } + if pi, err := strconv.Atoi(p); err == nil { + ports = append(ports, pi) + } + } + result.ListenPorts = ports + } + + return result, nil +} + +func runSSHCommand(client *ssh.Client, cmd string) (string, error) { + session, err := client.NewSession() + if err != nil { + return "", err + } + defer session.Close() + var b bytes.Buffer + session.Stdout = &b + err = session.Run(cmd) + return b.String(), err +} diff --git a/server/utils/crypto.go b/server/utils/crypto.go new file mode 100644 index 0000000..c538efd --- /dev/null +++ b/server/utils/crypto.go @@ -0,0 +1,78 @@ +package utils + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "io" + "log" + "os" +) + +var encryptKey []byte + +func init() { + key := os.Getenv("ENCRYPT_KEY") + if key == "" { + key = "lan-manager-default-key-change-in-production" + log.Println("[WARN] ENCRYPT_KEY not set, using default key. Please set ENCRYPT_KEY in production.") + } + h := sha256.Sum256([]byte(key)) + encryptKey = h[:] +} + +// Encrypt 使用 AES-GCM 加密明文,返回 base64 编码的密文 +func Encrypt(plaintext string) (string, error) { + if plaintext == "" { + return "", nil + } + block, err := aes.NewCipher(encryptKey) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// Decrypt 使用 AES-GCM 解密密文,输入为 base64 编码。 +// 为了兼容升级前的明文密码,如果 base64 解码失败或解密失败,则直接返回原始字符串。 +func Decrypt(ciphertext string) (string, error) { + if ciphertext == "" { + return "", nil + } + data, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + // 兼容旧明文密码 + return ciphertext, nil + } + block, err := aes.NewCipher(encryptKey) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + // 兼容旧明文密码 + return ciphertext, nil + } + nonce, cipherData := data[:nonceSize], data[nonceSize:] + plain, err := gcm.Open(nil, nonce, cipherData, nil) + if err != nil { + // 兼容旧明文密码 + return ciphertext, nil + } + return string(plain), nil +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..180f933 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + 局域网机器管理后台 + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..76545da --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2049 @@ +{ + "name": "lan-manager-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lan-manager-web", + "version": "1.0.0", + "dependencies": { + "@antv/g6": "^4.8.0", + "@element-plus/icons-vue": "^2.3.0", + "axios": "^1.7.0", + "element-plus": "^2.7.0", + "vue": "^3.4.0", + "vue-router": "^4.2.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "esbuild": "^0.21.5", + "vite": "^5.2.0" + } + }, + "node_modules/@ant-design/colors": { + "version": "4.0.5", + "license": "MIT", + "dependencies": { + "tinycolor2": "^1.4.1" + } + }, + "node_modules/@antv/algorithm": { + "version": "0.1.26", + "license": "MIT", + "dependencies": { + "@antv/util": "^2.0.13", + "tslib": "^2.0.0" + } + }, + "node_modules/@antv/dom-util": { + "version": "2.0.4", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/@antv/event-emitter": { + "version": "0.1.3", + "license": "MIT" + }, + "node_modules/@antv/g-base": { + "version": "0.5.16", + "license": "ISC", + "dependencies": { + "@antv/event-emitter": "^0.1.1", + "@antv/g-math": "^0.1.9", + "@antv/matrix-util": "^3.1.0-beta.1", + "@antv/path-util": "~2.0.5", + "@antv/util": "~2.0.13", + "@types/d3-timer": "^2.0.0", + "d3-ease": "^1.0.5", + "d3-interpolate": "^3.0.1", + "d3-timer": "^1.0.9", + "detect-browser": "^5.1.0", + "tslib": "^2.0.3" + } + }, + "node_modules/@antv/g-canvas": { + "version": "0.5.17", + "license": "ISC", + "dependencies": { + "@antv/g-base": "^0.5.12", + "@antv/g-math": "^0.1.9", + "@antv/matrix-util": "^3.1.0-beta.1", + "@antv/path-util": "~2.0.5", + "@antv/util": "~2.0.0", + "gl-matrix": "^3.0.0", + "tslib": "^2.0.3" + } + }, + "node_modules/@antv/g-math": { + "version": "0.1.9", + "license": "ISC", + "dependencies": { + "@antv/util": "~2.0.0", + "gl-matrix": "^3.0.0" + } + }, + "node_modules/@antv/g-svg": { + "version": "0.5.7", + "license": "ISC", + "dependencies": { + "@antv/g-base": "^0.5.12", + "@antv/g-math": "^0.1.9", + "@antv/util": "~2.0.0", + "detect-browser": "^5.0.0", + "tslib": "^2.0.3" + } + }, + "node_modules/@antv/g-webgpu": { + "version": "0.7.2", + "license": "ISC", + "dependencies": { + "@antv/g-webgpu-core": "^0.7.2", + "@antv/g-webgpu-engine": "^0.7.2", + "gl-matrix": "^3.1.0", + "gl-vec2": "^1.3.0", + "lodash": "^4.17.15" + } + }, + "node_modules/@antv/g-webgpu-core": { + "version": "0.7.2", + "license": "ISC", + "dependencies": { + "eventemitter3": "^4.0.0", + "gl-matrix": "^3.1.0", + "lodash": "^4.17.15", + "probe.gl": "^3.1.1" + } + }, + "node_modules/@antv/g-webgpu-engine": { + "version": "0.7.2", + "license": "ISC", + "dependencies": { + "@antv/g-webgpu-core": "^0.7.2", + "gl-matrix": "^3.1.0", + "lodash": "^4.17.15", + "regl": "^1.3.11" + } + }, + "node_modules/@antv/g6": { + "version": "4.8.25", + "license": "MIT", + "dependencies": { + "@antv/g6-pc": "0.8.25" + } + }, + "node_modules/@antv/g6-core": { + "version": "0.8.24", + "license": "MIT", + "dependencies": { + "@antv/algorithm": "^0.1.26", + "@antv/dom-util": "^2.0.1", + "@antv/event-emitter": "~0.1.0", + "@antv/g-base": "^0.5.1", + "@antv/g-math": "^0.1.1", + "@antv/matrix-util": "^3.1.0-beta.3", + "@antv/path-util": "^2.0.3", + "@antv/util": "~2.0.5", + "ml-matrix": "^6.5.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@antv/g6-element": { + "version": "0.8.25", + "license": "MIT", + "dependencies": { + "@antv/g-base": "^0.5.1", + "@antv/g6-core": "0.8.24", + "@antv/util": "~2.0.5", + "tslib": "^2.6.2" + }, + "peerDependencies": { + "@antv/g6": "4.8.25" + } + }, + "node_modules/@antv/g6-pc": { + "version": "0.8.25", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^4.0.5", + "@antv/algorithm": "^0.1.26", + "@antv/dom-util": "^2.0.1", + "@antv/event-emitter": "~0.1.0", + "@antv/g-base": "^0.5.1", + "@antv/g-canvas": "^0.5.2", + "@antv/g-math": "^0.1.1", + "@antv/g-svg": "^0.5.1", + "@antv/g6-core": "0.8.24", + "@antv/g6-element": "0.8.25", + "@antv/g6-plugin": "0.8.25", + "@antv/hierarchy": "^0.6.10", + "@antv/layout": "^0.3.0", + "@antv/matrix-util": "^3.1.0-beta.3", + "@antv/path-util": "^2.0.3", + "@antv/util": "~2.0.5", + "color": "^3.1.3", + "d3-force": "^2.0.1", + "dagre": "^0.8.5", + "insert-css": "^2.0.0", + "ml-matrix": "^6.5.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@antv/g6-plugin": { + "version": "0.8.25", + "license": "MIT", + "dependencies": { + "@antv/dom-util": "^2.0.2", + "@antv/g-base": "^0.5.1", + "@antv/g-canvas": "^0.5.2", + "@antv/g-svg": "^0.5.2", + "@antv/g6-core": "0.8.24", + "@antv/g6-element": "0.8.25", + "@antv/matrix-util": "^3.1.0-beta.3", + "@antv/path-util": "^2.0.3", + "@antv/scale": "^0.3.4", + "@antv/util": "^2.0.9", + "insert-css": "^2.0.0" + }, + "peerDependencies": { + "@antv/g6": "4.8.25" + } + }, + "node_modules/@antv/graphlib": { + "version": "1.2.0", + "license": "MIT" + }, + "node_modules/@antv/hierarchy": { + "version": "0.6.14", + "license": "MIT" + }, + "node_modules/@antv/layout": { + "version": "0.3.25", + "license": "MIT", + "dependencies": { + "@antv/g-webgpu": "0.7.2", + "@antv/graphlib": "^1.0.0", + "@antv/util": "^3.3.2", + "d3-force": "^2.1.1", + "d3-quadtree": "^2.0.0", + "dagre-compound": "^0.0.11", + "ml-matrix": "6.5.0" + } + }, + "node_modules/@antv/layout/node_modules/@antv/util": { + "version": "3.3.11", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "gl-matrix": "^3.3.0", + "tslib": "^2.3.1" + } + }, + "node_modules/@antv/layout/node_modules/ml-matrix": { + "version": "6.5.0", + "license": "MIT", + "dependencies": { + "ml-array-rescale": "^1.3.1" + } + }, + "node_modules/@antv/matrix-util": { + "version": "3.1.0-beta.3", + "license": "ISC", + "dependencies": { + "@antv/util": "^2.0.9", + "gl-matrix": "^3.4.3", + "tslib": "^2.0.3" + } + }, + "node_modules/@antv/path-util": { + "version": "2.0.15", + "license": "ISC", + "dependencies": { + "@antv/matrix-util": "^3.0.4", + "@antv/util": "^2.0.9", + "tslib": "^2.0.3" + } + }, + "node_modules/@antv/path-util/node_modules/@antv/matrix-util": { + "version": "3.0.4", + "license": "ISC", + "dependencies": { + "@antv/util": "^2.0.9", + "gl-matrix": "^3.3.0", + "tslib": "^2.0.3" + } + }, + "node_modules/@antv/scale": { + "version": "0.3.18", + "license": "MIT", + "dependencies": { + "@antv/util": "~2.0.3", + "fecha": "~4.2.0", + "tslib": "^2.0.0" + } + }, + "node_modules/@antv/util": { + "version": "2.0.17", + "license": "ISC", + "dependencies": { + "csstype": "^3.0.8", + "tslib": "^2.0.3" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@probe.gl/env": { + "version": "3.6.0", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.0.0" + } + }, + "node_modules/@probe.gl/log": { + "version": "3.6.0", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.0.0", + "@probe.gl/env": "3.6.0" + } + }, + "node_modules/@probe.gl/stats": { + "version": "3.6.0", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/d3-timer": { + "version": "2.0.3", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.0", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/color": { + "version": "3.2.1", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "2.0.0", + "license": "BSD-3-Clause" + }, + "node_modules/d3-ease": { + "version": "1.0.7", + "license": "BSD-3-Clause" + }, + "node_modules/d3-force": { + "version": "2.1.1", + "license": "BSD-3-Clause", + "dependencies": { + "d3-dispatch": "1 - 2", + "d3-quadtree": "1 - 2", + "d3-timer": "1 - 2" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "2.0.0", + "license": "BSD-3-Clause" + }, + "node_modules/d3-timer": { + "version": "1.0.10", + "license": "BSD-3-Clause" + }, + "node_modules/dagre": { + "version": "0.8.5", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, + "node_modules/dagre-compound": { + "version": "0.0.11", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + }, + "peerDependencies": { + "dagre": "^0.8.5" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-browser": { + "version": "5.3.0", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.13.7", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.4" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "license": "MIT" + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/fecha": { + "version": "4.2.3", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "license": "MIT" + }, + "node_modules/gl-vec2": { + "version": "1.3.0", + "license": "zlib" + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphlib": { + "version": "2.1.8", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/insert-css": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/is-any-array": { + "version": "2.0.1", + "license": "MIT" + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ml-array-max": { + "version": "1.2.4", + "license": "MIT", + "dependencies": { + "is-any-array": "^2.0.0" + } + }, + "node_modules/ml-array-min": { + "version": "1.2.3", + "license": "MIT", + "dependencies": { + "is-any-array": "^2.0.0" + } + }, + "node_modules/ml-array-rescale": { + "version": "1.3.7", + "license": "MIT", + "dependencies": { + "is-any-array": "^2.0.0", + "ml-array-max": "^1.2.4", + "ml-array-min": "^1.2.3" + } + }, + "node_modules/ml-matrix": { + "version": "6.12.1", + "license": "MIT", + "dependencies": { + "is-any-array": "^2.0.1", + "ml-array-rescale": "^1.3.7" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "license": "BSD-3-Clause" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.9", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/probe.gl": { + "version": "3.6.0", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.0.0", + "@probe.gl/env": "3.6.0", + "@probe.gl/log": "3.6.0", + "@probe.gl/stats": "3.6.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/regl": { + "version": "1.7.0", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + }, + "node_modules/vite": { + "version": "5.4.21", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.32", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.6", + "license": "MIT" + }, + "node_modules/vue-router": { + "version": "4.6.4", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..df6bd6c --- /dev/null +++ b/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "lan-manager-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@antv/g6": "^4.8.0", + "@element-plus/icons-vue": "^2.3.0", + "axios": "^1.7.0", + "element-plus": "^2.7.0", + "vue": "^3.4.0", + "vue-router": "^4.2.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "esbuild": "^0.21.5", + "vite": "^5.2.0" + } +} diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..c6be7af --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/web/src/api/index.js b/web/src/api/index.js new file mode 100644 index 0000000..ecf0f0b --- /dev/null +++ b/web/src/api/index.js @@ -0,0 +1,63 @@ +import axios from 'axios' +import { refreshAuth } from '@/router' +import { ElMessage } from 'element-plus' + +const api = axios.create({ + baseURL: '/api', + timeout: 30000, + withCredentials: true, +}) + +api.interceptors.response.use( + (res) => res, + (err) => { + const msg = err.response?.data?.error || err.message || '请求失败' + if (err.response?.status === 401) { + refreshAuth() + window.location.href = '/login' + return Promise.reject(err) + } + ElMessage.error(msg) + return Promise.reject(err) + } +) + +export default api + +export let uiRefreshInterval = 10000 + +export const checkAuth = () => api.get('/auth/me').then(res => { + if (res.data && typeof res.data.ui_refresh_interval === 'number') { + uiRefreshInterval = res.data.ui_refresh_interval + } + return res +}) +export const login = (data) => api.post('/auth/login', data) +export const guestLogin = () => api.post('/auth/guest') +export const logout = () => api.post('/auth/logout') + +export const fetchMachines = (params) => api.get('/machines', { params }) +export const fetchMachine = (id) => api.get(`/machines/${id}`) +export const createMachine = (data) => api.post('/machines', data) +export const updateMachine = (id, data) => api.put(`/machines/${id}`, data) +export const deleteMachine = (id) => api.delete(`/machines/${id}`) +export const sshInfo = (id, data) => api.post(`/machines/${id}/ssh-info`, data) +export const syncSSH = (id, data) => api.post(`/machines/${id}/sync-ssh`, data) + +export const fetchServices = (machineId) => api.get(`/machines/${machineId}/services`) +export const fetchAllServices = () => api.get('/services') +export const createService = (machineId, data) => api.post(`/machines/${machineId}/services`, data) +export const updateService = (id, data) => api.put(`/services/${id}`, data) +export const deleteService = (id) => api.delete(`/services/${id}`) + +export const fetchRelationships = () => api.get('/relationships') +export const createRelationship = (data) => api.post('/relationships', data) +export const updateRelationship = (id, data) => api.put(`/relationships/${id}`, data) +export const deleteRelationship = (id) => api.delete(`/relationships/${id}`) + +export const fetchLogs = (params) => api.get('/logs', { params }) + +export const exportData = () => api.get('/export', { responseType: 'blob' }) +export const importData = (formData) => api.post('/import', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, +}) diff --git a/web/src/components/MainLayout.vue b/web/src/components/MainLayout.vue new file mode 100644 index 0000000..5dd4f26 --- /dev/null +++ b/web/src/components/MainLayout.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/web/src/main.js b/web/src/main.js new file mode 100644 index 0000000..65d68cf --- /dev/null +++ b/web/src/main.js @@ -0,0 +1,16 @@ +import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import App from './App.vue' +import router from './router' + +const app = createApp(App) + +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.use(ElementPlus) +app.use(router) +app.mount('#app') diff --git a/web/src/router/index.js b/web/src/router/index.js new file mode 100644 index 0000000..87af3db --- /dev/null +++ b/web/src/router/index.js @@ -0,0 +1,89 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { checkAuth } from '@/api' + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/Login.vue'), + meta: { public: true, guestOnly: true }, + }, + { + path: '/', + component: () => import('@/components/MainLayout.vue'), + children: [ + { path: '', redirect: '/machines' }, + { + path: 'machines', + name: 'MachineList', + component: () => import('@/views/MachineList.vue'), + meta: { public: true }, + }, + { + path: 'machines/:id', + name: 'MachineDetail', + component: () => import('@/views/MachineDetail.vue'), + meta: { public: true }, + }, + { + path: 'topology', + name: 'Topology', + component: () => import('@/views/Topology.vue'), + meta: { admin: true }, + }, + { + path: 'logs', + name: 'Logs', + component: () => import('@/views/Logs.vue'), + meta: { admin: true }, + }, + ] + }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +let authChecked = false +let authState = { is_admin: false } + +router.beforeEach(async (to, from, next) => { + if (!authChecked) { + try { + const { data } = await checkAuth() + authState = data + } catch (e) { + authState = { is_admin: false } + } + authChecked = true + } + + // 访客禁止进入机器详情页 + if (to.name === 'MachineDetail' && !authState.is_admin) { + return next('/machines') + } + + if (to.meta.admin && !authState.is_admin) { + return next('/login?redirect=' + encodeURIComponent(to.fullPath)) + } + if (to.meta.guestOnly && authState.is_admin) { + return next('/') + } + next() +}) + +export function refreshAuth() { + authChecked = false +} + +export function setAuth(data) { + authState = data +} + +export function getAuth() { + return authState +} + +export default router diff --git a/web/src/views/Login.vue b/web/src/views/Login.vue new file mode 100644 index 0000000..de21078 --- /dev/null +++ b/web/src/views/Login.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/web/src/views/Logs.vue b/web/src/views/Logs.vue new file mode 100644 index 0000000..fc2d0f9 --- /dev/null +++ b/web/src/views/Logs.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/web/src/views/MachineDetail.vue b/web/src/views/MachineDetail.vue new file mode 100644 index 0000000..f439847 --- /dev/null +++ b/web/src/views/MachineDetail.vue @@ -0,0 +1,813 @@ + + + + + diff --git a/web/src/views/MachineList.vue b/web/src/views/MachineList.vue new file mode 100644 index 0000000..9a85326 --- /dev/null +++ b/web/src/views/MachineList.vue @@ -0,0 +1,460 @@ + + + + + diff --git a/web/src/views/Topology.vue b/web/src/views/Topology.vue new file mode 100644 index 0000000..b5df942 --- /dev/null +++ b/web/src/views/Topology.vue @@ -0,0 +1,265 @@ + + + + + diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..fa5d408 --- /dev/null +++ b/web/vite.config.js @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://127.0.0.1:8080', + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + }, +})