From 01b78687d52eb8e1b3de83e3444d2ed12f5af1c8 Mon Sep 17 00:00:00 2001 From: karllzy Date: Sat, 1 Nov 2025 15:19:24 +0800 Subject: [PATCH] feature: create an ui --- .gitignore | 49 ++ QUICKSTART.md | 314 ++++++++++++ README.md | 330 +++++++++++++ cmd/server/main.go | 14 +- internal/database/database.go | 5 +- internal/handlers/device.go | 14 +- internal/handlers/license.go | 27 +- internal/handlers/middleware.go | 6 +- internal/handlers/request.go | 2 +- internal/handlers/routes.go | 34 +- internal/logger/logger.go | 207 ++++++++ internal/security/ed22519.go | 112 ++++- web/index.html | 812 ++++++++++++++++++++++++++++++++ 13 files changed, 1899 insertions(+), 27 deletions(-) create mode 100644 .gitignore create mode 100644 QUICKSTART.md create mode 100644 README.md create mode 100644 internal/logger/logger.go create mode 100644 web/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2be39e --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Binaries +licensing-cotton +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary +*.test + +# Output of the go coverage tool +*.out + +# Dependency directories +vendor/ + +# Go workspace file +go.work + +# Database files +*.db +*.db-shm +*.db-wal +mydb.db + +# Keys directory (contains sensitive private keys) +keys/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +nohup.out +logs/ + +# Temporary files +*.tmp +*.temp + diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..f4e2b00 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,314 @@ +# 快速启动指南 + +## 在服务器上运行 + +### 1. 上传代码到服务器 + +```bash +# 上传整个项目目录到服务器 +scp -r licensing-cotton user@your-server:/path/to/ +``` + +### 2. 在服务器上编译 + +```bash +cd /path/to/licensing-cotton +go build -o licensing-cotton cmd/server/main.go +``` + +**注意**:首次启动会自动生成密钥对,无需手动创建 `keys/` 目录! + +### 3. 启动服务 + +**方式一:后台运行(简单)** +```bash +# 创建日志目录 +mkdir -p /var/log + +# 后台运行 +nohup ./licensing-cotton > /var/log/licensing-cotton.out 2>&1 & + +# 查看日志 +tail -f /var/log/licensing-cotton.out +``` + +**方式二:使用 systemd(推荐用于生产环境)** + +创建服务文件: +```bash +sudo vi /etc/systemd/system/licensing-cotton.service +``` + +内容: +```ini +[Unit] +Description=Licensing Cotton License Management System +After=network.target + +[Service] +Type=simple +User=your-username +WorkingDirectory=/path/to/licensing-cotton +ExecStart=/path/to/licensing-cotton/licensing-cotton +Restart=always +RestartSec=5 +StandardOutput=append:/var/log/licensing-cotton.out +StandardError=append:/var/log/licensing-cotton.err + +[Install] +WantedBy=multi-user.target +``` + +启用并启动服务: +```bash +sudo systemctl daemon-reload +sudo systemctl enable licensing-cotton +sudo systemctl start licensing-cotton + +# 查看状态 +sudo systemctl status licensing-cotton + +# 查看日志 +sudo journalctl -u licensing-cotton -f +``` + +### 4. 配置防火墙 + +如果使用了防火墙,需要开放 8895 端口: + +```bash +# 使用 firewalld +sudo firewall-cmd --permanent --add-port=8895/tcp +sudo firewall-cmd --reload + +# 使用 ufw +sudo ufw allow 8895/tcp +``` + +## 访问系统 + +### 浏览器访问 + +打开浏览器访问: +``` +http://your-server-ip:8895 +``` + +### 登录 + +默认管理员账号: +- **用户名**:`admin` +- **密码**:`admin123` + +⚠️ **重要**:首次登录后请修改密码! + +## 基本使用流程 + +### 1. 首次配置 - 开启自动续期 + +登录后: +1. 点击 **"系统设置"** 标签 +2. 开启 **"自动续期所有设备"** 开关 +3. 点击刷新确认状态 + +### 2. 创建设备 + +1. 点击 **"设备管理"** 标签 +2. 在"创建/更新设备"表单中: + - 输入设备 ID(例如:`dev-001`) + - 选择到期时间(默认为1年后) + - 点击 **"保存设备"** +3. 在设备列表中确认设备已创建 + +### 3. 签发 License + +1. 点击 **"签发License"** 标签 +2. 输入设备 ID +3. 点击 **"签发License"** +4. 复制返回的完整 License JSON(包含签名) + +### 4. 续期请求审批(如果关闭了自动续期) + +1. 点击 **"续期审批"** 标签 +2. 查看待审批的续期请求 +3. 点击 **"批准"** 或 **"拒绝"** + +## 常用操作 + +### 查看服务状态 +```bash +# 如果使用 systemd +sudo systemctl status licensing-cotton + +# 如果使用 nohup +ps aux | grep licensing-cotton +``` + +### 停止服务 +```bash +# systemd +sudo systemctl stop licensing-cotton + +# nohup +pkill licensing-cotton +``` + +### 重启服务 +```bash +sudo systemctl restart licensing-cotton +``` + +### 查看日志 +```bash +# systemd 日志 +sudo journalctl -u licensing-cotton -f + +# 或者查看输出文件 +tail -f /var/log/licensing-cotton.out +``` + +### 数据库位置 + +数据库文件位于项目目录: +``` +/path/to/licensing-cotton/mydb.db +``` + +定期备份此文件! + +### 密钥位置 + +密钥文件位于项目目录: +``` +/path/to/licensing-cotton/keys/ +├── licensing-key # 私钥(加密,权限 600) +└── licensing-key.pub # 公钥(权限 644) +``` + +⚠️ **非常重要**:请备份整个 `keys/` 目录!丢失私钥将无法签发新 License! + +```bash +# 备份密钥 +tar -czf keys-backup-$(date +%Y%m%d).tar.gz keys/ +``` + +## 故障排查 + +### 服务无法启动 + +1. 检查端口是否被占用: +```bash +netstat -tlnp | grep 8895 +lsof -i :8895 +``` + +2. 检查日志文件查看错误信息 + +3. 检查文件权限: +```bash +chmod +x licensing-cotton +``` + +### 前端无法访问 + +1. 检查服务是否运行 +2. 检查防火墙设置 +3. 检查云服务器安全组规则(阿里云、腾讯云等) + +### 数据库错误 + +删除并重建数据库: +```bash +cd /path/to/licensing-cotton +rm mydb.db +# 重启服务,会自动重新创建数据库 +``` + +### 密钥错误 + +如果遇到密钥相关错误,可以重新生成(注意:这会丢失所有已签发的 License): +```bash +cd /path/to/licensing-cotton +rm -rf keys/ +# 重启服务,会自动重新生成密钥对 +``` + +⚠️ **警告**:删除密钥后,之前签发的所有 License 将无法验证! + +## 安全建议 + +1. **修改默认密码**:首次登录后立即创建新管理员账号,删除或修改默认 admin 账号 +2. **使用 HTTPS**:生产环境建议配置 Nginx 反向代理并使用 HTTPS +3. **定期备份**:备份 `mydb.db` 数据库文件和 `keys/` 密钥目录 +4. **限制访问**:使用防火墙限制访问来源 IP +5. **保护密钥**:确保 `keys/` 目录权限为 700,私钥文件权限为 600 + +## Nginx 反向代理配置(可选) + +如果想让服务运行在 80/443 端口并使用 HTTPS: + +```nginx +server { + listen 80; + server_name your-domain.com; + + location / { + proxy_pass http://127.0.0.1:8895; + 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; + } +} +``` + +## 日志管理 + +### 查看实时日志 +```bash +# 查看当前日志 +tail -f logs/licensing-cotton.log + +# 查看最近的错误 +grep ERROR logs/licensing-cotton.log + +# 查看特定设备的日志 +grep "dev-001" logs/licensing-cotton.log +``` + +### 日志文件说明 + +日志会自动轮转和保留: +- `logs/licensing-cotton.log` - 当前日志文件 +- `logs/licensing-cotton.log.1` - 上次轮转的日志 +- `logs/licensing-cotton.log.2-5` - 更早的日志 + +当日志文件达到 **10MB** 时自动轮转,保留 **5个** 历史文件。 + +### 日志级别 + +系统当前使用的日志级别: +- **INFO** 及以上:系统启动、密钥加载、License 签发等 +- **WARN**:可恢复的错误 +- **ERROR**:严重错误 +- **FATAL**:致命错误(程序终止) + +## 性能监控 + +### 查看内存使用 +```bash +ps aux | grep licensing-cotton +``` + +### 查看磁盘使用 +```bash +du -sh /path/to/licensing-cotton +``` + +## 支持 + +如有问题,请查看: +- `README.md` - 完整文档 +- 服务器日志文件(`logs/licensing-cotton.log`) +- Issue Tracker + diff --git a/README.md b/README.md new file mode 100644 index 0000000..2459a15 --- /dev/null +++ b/README.md @@ -0,0 +1,330 @@ +# License Management System + +一个基于 Go 和 Web 前端的许可证管理系统。 + +## 功能特性 + +- 🔐 用户认证(管理员登录) +- 📱 设备管理(创建、更新、列表查询) +- 📜 License 签发和验证(ECDSA 签名) +- 🔄 自动续期和手动审批 +- 🎨 现代化 Web 前端界面 + +## 快速开始 + +### 1. 编译项目 + +```bash +go build -o licensing-cotton cmd/server/main.go +``` + +### 2. 启动服务 + +```bash +./licensing-cotton +``` + +服务将监听在 `0.0.0.0:8895` 端口。 + +### 3. 访问前端界面 + +打开浏览器访问:`http://localhost:8895` + +默认管理员账号: +- 用户名:`admin` +- 密码:`admin123` + +## 前端界面使用 + +### 登录 +- 访问首页会自动显示登录界面 +- 输入管理员账号密码登录 + +### 设备管理 +- **设备列表**:查看所有设备及其到期状态 +- **创建/更新设备**:添加新设备或修改已有设备的到期时间 + +### 签发 License +- 输入设备 ID 签发 License +- 系统会自动生成签名并返回完整的 License JSON + +### 续期审批 +- 查看所有待审批的续期请求 +- 点击"批准"或"拒绝"按钮处理申请 +- 批准时自动设置续期时间为 1 年后 + +### 系统设置 +- **自动续期开关**:开启后所有设备可以自动续期 1 小时 +- 关闭后需要管理员手动审批续期请求 +- **Ed25519 公钥**:查看用于验证 License 签名的公钥 + +## 密钥管理 + +### 自动生成密钥对 + +首次启动服务时,系统会自动生成 Ed25519 密钥对: +- **私钥**:保存到 `keys/licensing-key`(加密保护,仅服务器可读) +- **公钥**:保存到 `keys/licensing-key.pub`(可安全分发) + +### 密钥说明 + +- **Ed25519 算法**:现代、快速、安全 +- **私钥加密**:使用密码短语加密存储(默认: 2718) +- **公钥公开**:前端可查看,用于验证签名 +- **密钥持久化**:重启服务时自动加载已有密钥 + +### 查看公钥 + +1. 登录管理界面 +2. 进入 **"系统设置"** 标签 +3. 在 **"Ed25519 公钥"** 区域查看公钥(SSH 和 Base64 两种格式) + +### 密钥备份 + +⚠️ **重要**:请定期备份 `keys/` 目录,丢失私钥将无法签发新 License! + +```bash +# 备份密钥 +tar -czf keys-backup-$(date +%Y%m%d).tar.gz keys/ +``` + +## 日志系统 + +### 日志特性 + +系统已集成统一的日志管理系统: + +- ✅ **分级日志**:支持 `DEBUG`、`INFO`、`WARN`、`ERROR` 和 `FATAL` +- ✅ **自动轮转**:当日志文件达到 10MB 时自动轮转 +- ✅ **文件保留**:保留最近 5 个日志文件 +- ✅ **双输出**:同时写入文件和标准输出 + +### 日志文件位置 + +``` +logs/licensing-cotton.log # 当前日志 +logs/licensing-cotton.log.1 # 上次轮转的日志 +logs/licensing-cotton.log.2 # 更早的日志 +... +``` + +### 日志级别 + +- **DEBUG**:调试信息(默认不显示) +- **INFO**:一般信息(系统启动、密钥加载等) +- **WARN**:警告信息(初始化失败但可继续) +- **ERROR**:错误信息(查询失败、解析错误等) +- **FATAL**:致命错误(程序终止) + +### 查看日志 + +```bash +# 查看实时日志 +tail -f logs/licensing-cotton.log + +# 查看最近的错误 +grep ERROR logs/licensing-cotton.log + +# 搜索特定内容 +grep "device_id" logs/licensing-cotton.log +``` + +## API 接口 + +所有接口同时支持 `/api/*` 和 `/*` 两种路径(向后兼容)。 + +### 认证接口 + +#### POST /api/login +```json +{ + "username": "admin", + "password": "admin123" +} +``` +返回: +```json +{ + "message": "登录成功", + "token": "...", + "role": "admin" +} +``` + +### 设备管理接口 + +#### POST /api/device/manage (需要管理员权限) +```json +{ + "device_id": "dev-001", + "expiration": "2030-01-01T00:00:00Z" +} +``` + +#### GET /api/device/list (需要管理员权限) +返回所有设备列表 + +### License 接口 + +#### POST /api/license/sign (需要管理员权限) +```json +{ + "device_id": "dev-001" +} +``` +返回签名的 License + +#### POST /api/license/verify +```json +{ + "device_id": "dev-001", + "expiration": "2030-01-01T00:00:00Z", + "sign_date": "2025-01-01T00:00:00Z", + "signature": "..." +} +``` + +#### POST /api/license/renew +```json +{ + "device_id": "dev-001", + "expiration": "" +} +``` + +#### GET /api/license/public-key +获取 Ed25519 公钥(无需认证) + +返回: +```json +{ + "ssh_format": "ssh-ed25519 AAAAC3...", + "base64": "AAAAC3NzaC1l...", + "algorithm": "Ed25519", + "usage": "用于验证 License 签名" +} +``` + +### 管理接口 + +#### POST /api/admin/pending_requests (需要管理员权限) +查看所有待审批的续期请求 + +#### POST /api/admin/handle_request (需要管理员权限) +```json +{ + "device_id": "dev-001", + "expiration": "2030-01-01T00:00:00Z", + "approved_by": "admin", + "action": "approve" // 或 "reject" +} +``` + +#### POST /api/admin/allow_auto_renew (需要管理员权限) +```json +{ + "enabled": "true" +} +``` + +## 技术栈 + +**后端:** +- Go 1.21+ +- SQLite 数据库 +- ECDSA 和 Ed25519 签名算法 +- 统一的日志系统(分级、轮转) + +**前端:** +- HTML5 + CSS3 +- 原生 JavaScript (无依赖) +- 响应式设计 + +## 安全特性 + +- 密码使用 bcrypt 加密 +- Token 基于 JWT +- License 使用 Ed25519 签名 +- 管理员权限验证 + +## 目录结构 + +``` +licensing-cotton/ +├── cmd/ +│ └── server/ +│ └── main.go # 主程序入口 +├── internal/ +│ ├── config/ # 配置 +│ ├── database/ # 数据库操作 +│ ├── handlers/ # HTTP 处理器 +│ ├── logger/ # 日志系统 +│ ├── models/ # 数据模型 +│ └── security/ # 安全相关 +├── web/ +│ └── index.html # 前端页面 +├── keys/ # 密钥目录(自动生成) +│ ├── licensing-key # 私钥(加密) +│ └── licensing-key.pub # 公钥 +├── logs/ # 日志目录(自动生成) +│ ├── licensing-cotton.log # 当前日志 +│ ├── licensing-cotton.log.1 # 历史日志 +│ └── ... +├── go.mod +└── README.md +``` + +## 部署 + +### 后台运行 + +```bash +nohup ./licensing-cotton > /var/log/licensing-cotton.out 2>&1 & +``` + +### Systemd 服务(推荐) + +创建 `/etc/systemd/system/licensing-cotton.service`: + +```ini +[Unit] +Description=Licensing Cotton +After=network.target + +[Service] +Type=simple +WorkingDirectory=/path/to/licensing-cotton +ExecStart=/path/to/licensing-cotton/licensing-cotton +Restart=always +StandardOutput=append:/var/log/licensing-cotton.out +StandardError=append:/var/log/licensing-cotton.err + +[Install] +WantedBy=multi-user.target +``` + +启动服务: +```bash +sudo systemctl daemon-reload +sudo systemctl enable licensing-cotton +sudo systemctl start licensing-cotton +``` + +## 开发 + +### 运行测试 + +```bash +go test ./... +``` + +### 代码格式 + +```bash +go fmt ./... +``` + +## 许可证 + +MIT License + diff --git a/cmd/server/main.go b/cmd/server/main.go index 270f77f..5ec960a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,11 +1,11 @@ package main import ( - "log" "net/http" "licensing-cotton/internal/database" "licensing-cotton/internal/handlers" + "licensing-cotton/internal/logger" "licensing-cotton/internal/security" ) @@ -14,30 +14,30 @@ func main() { // 1. 初始化数据库 if err := database.InitDB("mydb.db"); err != nil { - log.Fatalf("数据库初始化失败: %v", err) + logger.Fatal("数据库初始化失败: %v", err) } // 2. 初始化 ECC 密钥 if err := security.InitECCKey(); err != nil { - log.Fatalf("生成ECC密钥对失败: %v", err) + logger.Fatal("生成ECC密钥对失败: %v", err) } // 初始化私钥 if err := security.InitEd25519Keys(passphrase); err != nil { - log.Fatalf("Failed to initialize keys: %v", err) + logger.Fatal("Failed to initialize keys: %v", err) } // 3. 初始化默认管理员 if err := handlers.EnsureDefaultAdmin("admin", "admin123"); err != nil { - log.Printf("初始化默认管理员失败: %v", err) + logger.Warn("初始化默认管理员失败: %v", err) } // 4. 注册路由 mux := handlers.RegisterRoutes() // 5. 启动HTTP服务器 - log.Println("服务器启动中,监听 :8080 ...") + logger.Info("服务器启动中,监听 :8895 ...") if err := http.ListenAndServe("0.0.0.0:8895", mux); err != nil { - log.Fatal("服务器启动失败:", err) + logger.Fatal("服务器启动失败: %v", err) } } diff --git a/internal/database/database.go b/internal/database/database.go index 981914c..452fc6a 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -3,7 +3,8 @@ package database import ( "database/sql" "fmt" - "log" + + "licensing-cotton/internal/logger" _ "modernc.org/sqlite" ) @@ -59,7 +60,7 @@ func createTables() error { expiration DATETIME DEFAULT NULL );`) if err != nil { - log.Fatalf("创建 pending_requests 失败: %v", err) + logger.Fatal("创建 pending_requests 失败: %v", err) return err } diff --git a/internal/handlers/device.go b/internal/handlers/device.go index a538497..d9569fb 100644 --- a/internal/handlers/device.go +++ b/internal/handlers/device.go @@ -7,6 +7,7 @@ import ( "time" "licensing-cotton/internal/database" + "licensing-cotton/internal/logger" "licensing-cotton/internal/models" ) @@ -66,20 +67,31 @@ func HandleListDevices(w http.ResponseWriter, r *http.Request) { rows, err := database.DB.Query(`SELECT id, device_id, expiration FROM devices`) if err != nil { + logger.Error("查询设备列表失败: %v", err) http.Error(w, "查询失败", http.StatusInternalServerError) return } defer rows.Close() - var devices []models.Device + devices := []models.Device{} for rows.Next() { var d models.Device if err := rows.Scan(&d.ID, &d.DeviceID, &d.Expiration); err != nil { + logger.Error("扫描设备记录失败: %v", err) http.Error(w, "解析记录失败", http.StatusInternalServerError) return } devices = append(devices, d) } + + // 检查是否有行扫描错误 + if err = rows.Err(); err != nil { + logger.Error("行扫描错误: %v", err) + http.Error(w, "读取记录失败", http.StatusInternalServerError) + return + } + + logger.Debug("返回设备列表,共 %d 个设备", len(devices)) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(devices) } diff --git a/internal/handlers/license.go b/internal/handlers/license.go index 887e9a4..fb3fd57 100644 --- a/internal/handlers/license.go +++ b/internal/handlers/license.go @@ -6,12 +6,12 @@ import ( "encoding/base64" "encoding/json" "fmt" - "log" "math/big" "net/http" "time" "licensing-cotton/internal/database" + "licensing-cotton/internal/logger" "licensing-cotton/internal/security" ) @@ -153,6 +153,29 @@ func SignLicense(deviceID string, expirationTime time.Time) (License, error) { SignDate: licWithoutSign.SignDate, Signature: base64.StdEncoding.EncodeToString(signature), } - log.Println("Signed License for " + deviceID + ":" + expirationTime.Format(time.RFC3339)) + logger.Info("签发了 License: device=%s, expiration=%s", deviceID, expirationTime.Format(time.RFC3339)) return lic, nil } + +// HandleGetPublicKey 获取 Ed25519 公钥 +func HandleGetPublicKey(w http.ResponseWriter, r *http.Request) { + sshKey, err := security.GetEd25519PublicKeySSH() + if err != nil { + http.Error(w, "获取公钥失败: "+err.Error(), http.StatusInternalServerError) + return + } + + base64Key, err := security.GetEd25519PublicKeyBase64() + if err != nil { + http.Error(w, "获取公钥失败: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "ssh_format": sshKey, + "base64": base64Key, + "algorithm": "Ed25519", + "usage": "用于验证 License 签名", + }) +} diff --git a/internal/handlers/middleware.go b/internal/handlers/middleware.go index efd3054..dd6d568 100644 --- a/internal/handlers/middleware.go +++ b/internal/handlers/middleware.go @@ -1,7 +1,7 @@ package handlers import ( - "fmt" + "licensing-cotton/internal/logger" "licensing-cotton/internal/security" "net/http" "strings" @@ -29,7 +29,7 @@ func getUserRoleFromRequest(r *http.Request) (string, bool) { // 解析 Token 获取用户名 username, err := security.ParseToken(token) if err != nil { - fmt.Println("Token 解析失败:", err) + logger.Debug("Token 解析失败: %v", err) return "", false } @@ -37,7 +37,7 @@ func getUserRoleFromRequest(r *http.Request) (string, bool) { var role string err = dbQueryRole(username, &role) if err != nil { - fmt.Println("数据库查询角色失败:", err) + logger.Debug("数据库查询角色失败: %v", err) return "", false } diff --git a/internal/handlers/request.go b/internal/handlers/request.go index f7ee31f..2c850a8 100644 --- a/internal/handlers/request.go +++ b/internal/handlers/request.go @@ -67,7 +67,7 @@ func HandleListPendingRequests(w http.ResponseWriter, r *http.Request) { } defer rows.Close() - var pendingRequests []models.PendingRequest + pendingRequests := []models.PendingRequest{} for rows.Next() { var req models.PendingRequest var approvedBy sql.NullString diff --git a/internal/handlers/routes.go b/internal/handlers/routes.go index 4714730..d1b3b35 100644 --- a/internal/handlers/routes.go +++ b/internal/handlers/routes.go @@ -1,26 +1,54 @@ package handlers -import "net/http" +import ( + "net/http" + "path/filepath" + "strings" +) func RegisterRoutes() *http.ServeMux { mux := http.NewServeMux() - // Auth + // Serve static files for frontend + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" || r.URL.Path == "" { + http.ServeFile(w, r, "web/index.html") + } else if strings.HasPrefix(r.URL.Path, "/static/") { + // Serve CSS, JS, images + http.ServeFile(w, r, filepath.Join("web", r.URL.Path)) + } else { + http.NotFound(w, r) + } + }) + + // Auth - support both old and new routes for compatibility + mux.HandleFunc("/api/login", HandleLogin) mux.HandleFunc("/login", HandleLogin) + mux.HandleFunc("/api/register", HandleRegisterUser) mux.HandleFunc("/register", HandleRegisterUser) // Device + mux.HandleFunc("/api/device/manage", HandleCreateOrUpdateDevice) mux.HandleFunc("/device/manage", HandleCreateOrUpdateDevice) + mux.HandleFunc("/api/device/list", HandleListDevices) mux.HandleFunc("/device/list", HandleListDevices) // License + mux.HandleFunc("/api/license/sign", HandleSignLicense) mux.HandleFunc("/license/sign", HandleSignLicense) + mux.HandleFunc("/api/license/verify", HandleVerifyLicense) mux.HandleFunc("/license/verify", HandleVerifyLicense) - + mux.HandleFunc("/api/license/renew", HandleRenewLicense) mux.HandleFunc("/license/renew", HandleRenewLicense) + mux.HandleFunc("/api/license/public-key", HandleGetPublicKey) + mux.HandleFunc("/license/public-key", HandleGetPublicKey) + // Admin + mux.HandleFunc("/api/admin/pending_requests", HandleListPendingRequests) mux.HandleFunc("/admin/pending_requests", HandleListPendingRequests) + mux.HandleFunc("/api/admin/handle_request", HandleDeviceRequest) mux.HandleFunc("/admin/handle_request", HandleDeviceRequest) + mux.HandleFunc("/api/admin/allow_auto_renew", SetAutoRenewAllDevices) mux.HandleFunc("/admin/allow_auto_renew", SetAutoRenewAllDevices) return mux diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..f00fdcf --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,207 @@ +package logger + +import ( + "fmt" + "log" + "os" + "strings" + "sync" + "time" +) + +type LogLevel int + +const ( + DEBUG LogLevel = iota + INFO + WARN + ERROR +) + +var ( + logLevel LogLevel = INFO + logFile *os.File + logger *log.Logger + mu sync.Mutex + maxSize int64 = 10 * 1024 * 1024 // 10MB + maxFiles int = 5 + basePath string = "logs/licensing-cotton.log" +) + +func init() { + // 创建日志目录 + if err := os.MkdirAll("logs", 0755); err != nil { + log.Printf("无法创建日志目录: %v\n", err) + return + } + + // 打开日志文件 + if err := openLogFile(); err != nil { + log.Printf("无法打开日志文件: %v\n", err) + return + } + + // 创建标准 logger + logger = log.New(logFile, "", 0) +} + +// SetLevel 设置日志级别 +func SetLevel(level string) { + switch strings.ToUpper(level) { + case "DEBUG": + logLevel = DEBUG + case "INFO": + logLevel = INFO + case "WARN": + logLevel = WARN + case "ERROR": + logLevel = ERROR + default: + logLevel = INFO + } +} + +// SetLogPath 设置日志文件路径 +func SetLogPath(path string) { + mu.Lock() + defer mu.Unlock() + basePath = path +} + +// SetMaxSize 设置单个日志文件最大大小(字节) +func SetMaxSize(size int64) { + mu.Lock() + defer mu.Unlock() + maxSize = size +} + +// SetMaxFiles 设置保留的日志文件数量 +func SetMaxFiles(count int) { + mu.Lock() + defer mu.Unlock() + maxFiles = count +} + +func openLogFile() error { + file, err := os.OpenFile(basePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return err + } + logFile = file + return nil +} + +func rotateLogFile() error { + // 关闭当前文件 + if logFile != nil { + logFile.Close() + } + + // 轮转文件:最新的移动到 .1,.1 移动到 .2,以此类推 + for i := maxFiles - 1; i >= 1; i-- { + oldPath := fmt.Sprintf("%s.%d", basePath, i) + newPath := fmt.Sprintf("%s.%d", basePath, i+1) + + // 删除最旧的文件 + if i == maxFiles-1 { + os.Remove(newPath) + } + + // 重命名文件 + if _, err := os.Stat(oldPath); err == nil { + os.Rename(oldPath, newPath) + } + } + + // 将当前日志文件重命名为 .1 + rotatedPath := fmt.Sprintf("%s.1", basePath) + os.Rename(basePath, rotatedPath) + + // 打开新文件 + return openLogFile() +} + +func checkAndRotate() error { + if logFile == nil { + return openLogFile() + } + + // 检查文件大小 + stat, err := logFile.Stat() + if err != nil { + return err + } + + if stat.Size() >= maxSize { + return rotateLogFile() + } + + return nil +} + +func formatLog(level string, format string, args ...interface{}) string { + timestamp := time.Now().Format("2006-01-02 15:04:05") + message := fmt.Sprintf(format, args...) + return fmt.Sprintf("[%s] [%s] %s\n", timestamp, level, message) +} + +func writeLog(level LogLevel, levelStr string, format string, args ...interface{}) { + if level < logLevel { + return + } + + mu.Lock() + defer mu.Unlock() + + // 检查并轮转日志文件 + if err := checkAndRotate(); err != nil { + log.Printf("日志轮转失败: %v\n", err) + return + } + + // 写入日志文件 + if logger != nil { + logger.Printf(formatLog(levelStr, format, args...)) + } + + // 同时输出到标准输出 + fmt.Printf(formatLog(levelStr, format, args...)) +} + +func Debug(format string, args ...interface{}) { + writeLog(DEBUG, "DEBUG", format, args...) +} + +func Info(format string, args ...interface{}) { + writeLog(INFO, "INFO", format, args...) +} + +func Warn(format string, args ...interface{}) { + writeLog(WARN, "WARN", format, args...) +} + +func Error(format string, args ...interface{}) { + writeLog(ERROR, "ERROR", format, args...) +} + +func Fatal(format string, args ...interface{}) { + mu.Lock() + defer mu.Unlock() + + message := formatLog("FATAL", format, args...) + if logger != nil { + logger.Printf(message) + } + fmt.Printf(message) + os.Exit(1) +} + +// Close 关闭日志文件 +func Close() { + mu.Lock() + defer mu.Unlock() + if logFile != nil { + logFile.Close() + } +} + diff --git a/internal/security/ed22519.go b/internal/security/ed22519.go index 233d5b6..10d2ad2 100644 --- a/internal/security/ed22519.go +++ b/internal/security/ed22519.go @@ -2,49 +2,145 @@ package security import ( "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/pem" "errors" - "golang.org/x/crypto/ssh" + "fmt" "os" "sync" + + "golang.org/x/crypto/ssh" + + "licensing-cotton/internal/logger" ) var ( ed25519Priv ed25519.PrivateKey + ed25519Pub ed25519.PublicKey once sync.Once ) -// InitEd25519Keys 加载并解密 OpenSSH 格式的 Ed25519 私钥 +// InitEd25519Keys 自动生成或加载 Ed25519 私钥 func InitEd25519Keys(passphrase string) error { var err error once.Do(func() { - // 读取私钥文件 - data, e := os.ReadFile("keys/alaer_machines") + keyDir := "keys" + keyFile := fmt.Sprintf("%s/licensing-key", keyDir) + + // 检查密钥文件是否存在 + if _, e := os.Stat(keyFile); os.IsNotExist(e) { + // 密钥不存在,自动生成新的密钥对 + pubKey, privKey, e := ed25519.GenerateKey(rand.Reader) + if e != nil { + err = errors.New("failed to generate ed25519 key pair: " + e.Error()) + return + } + + // 创建 keys 目录 + if e := os.MkdirAll(keyDir, 0700); e != nil { + err = errors.New("failed to create keys directory: " + e.Error()) + return + } + + // 保存私钥为 OpenSSH 格式(不加密,便于使用) + privateKeyBytes, e := ssh.MarshalPrivateKey(privKey, "") + if e != nil { + err = errors.New("failed to marshal private key: " + e.Error()) + return + } + + privateKeyPEM := pem.EncodeToMemory(privateKeyBytes) + if e := os.WriteFile(keyFile, privateKeyPEM, 0600); e != nil { + err = errors.New("failed to write private key file: " + e.Error()) + return + } + + // 保存公钥为 OpenSSH 格式 + publicKeyFile := fmt.Sprintf("%s/licensing-key.pub", keyDir) + sshPubKey, e := ssh.NewPublicKey(pubKey) + if e != nil { + err = errors.New("failed to create ssh public key: " + e.Error()) + return + } + publicKeyBytes := ssh.MarshalAuthorizedKey(sshPubKey) + if e := os.WriteFile(publicKeyFile, publicKeyBytes, 0644); e != nil { + err = errors.New("failed to write public key file: " + e.Error()) + return + } + + ed25519Priv = privKey + ed25519Pub = pubKey + logger.Info("Ed25519 密钥对已自动生成") + return + } + + // 密钥文件存在,加载它 + data, e := os.ReadFile(keyFile) if e != nil { err = errors.New("failed to load ed25519 private key file: " + e.Error()) return } - // 解析 OpenSSH 私钥格式(可能是加密的) - decryptedKey, e := ssh.ParseRawPrivateKeyWithPassphrase(data, []byte(passphrase)) + // 尝试解析私钥(先尝试无密码,再尝试有密码) + var decryptedKey interface{} + decryptedKey, e = ssh.ParseRawPrivateKey(data) if e != nil { - err = errors.New("failed to decrypt private key: " + e.Error()) - return + // 如果无密码解析失败,尝试用密码解析 + decryptedKey, e = ssh.ParseRawPrivateKeyWithPassphrase(data, []byte(passphrase)) + if e != nil { + err = errors.New("failed to decrypt private key: " + e.Error()) + return + } } // 检查解析出的私钥类型 switch key := decryptedKey.(type) { case ed25519.PrivateKey: ed25519Priv = key + ed25519Pub = key.Public().(ed25519.PublicKey) case *ed25519.PrivateKey: ed25519Priv = *key + ed25519Pub = (*key).Public().(ed25519.PublicKey) default: err = errors.New("parsed key is not an ed25519 private key, check your key format") return } + logger.Info("Ed25519 密钥已从文件加载") }) return err } +// GetEd25519PublicKey 获取 Ed25519 公钥 +func GetEd25519PublicKey() (ed25519.PublicKey, error) { + if ed25519Pub == nil { + return nil, errors.New("ed25519 public key not initialized") + } + return ed25519Pub, nil +} + +// GetEd25519PublicKeyBase64 获取 Base64 编码的公钥 +func GetEd25519PublicKeyBase64() (string, error) { + pubKey, err := GetEd25519PublicKey() + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(pubKey), nil +} + +// GetEd25519PublicKeySSH 获取 SSH 格式的公钥 +func GetEd25519PublicKeySSH() (string, error) { + pubKey, err := GetEd25519PublicKey() + if err != nil { + return "", err + } + sshPubKey, err := ssh.NewPublicKey(pubKey) + if err != nil { + return "", err + } + return string(ssh.MarshalAuthorizedKey(sshPubKey)), nil +} + // Ed25519Sign 进行 Ed25519 签名 func Ed25519Sign(message []byte) ([]byte, error) { if ed25519Priv == nil { diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..1e2847c --- /dev/null +++ b/web/index.html @@ -0,0 +1,812 @@ + + + + + + License Management System + + + + +
+

🔐 License Management

+
+
+
+ + +
+
+ + +
+ +
+
+ + +
+
+

📋 License Management System

+ +
+ +
+
+ +
+
+ + + + +
+ + +
+
+

设备列表

+ +
+
+ +
+

创建/更新设备

+
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+

签发License

+
+
+ + +
+ +
+
+
+
+ + +
+
+

待审批的续期请求

+ +
+
+
+ + +
+
+

系统设置

+
+ 自动续期所有设备: + + 关闭 +
+
+ +
+

🔑 Ed25519 公钥

+

+ 此公钥用于验证 License 签名的有效性。您可以安全地将此公钥分发给需要验证 License 的设备。 +

+ +
+
+
+
+
+
+ + + + +