feature: create an ui

This commit is contained in:
karllzy 2025-11-01 15:19:24 +08:00
parent bcbf2a8d08
commit 01b78687d5
13 changed files with 1899 additions and 27 deletions

49
.gitignore vendored Normal file
View File

@ -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

314
QUICKSTART.md Normal file
View File

@ -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

330
README.md Normal file
View File

@ -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

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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 签名",
})
}

View File

@ -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
}

View File

@ -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

View File

@ -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

207
internal/logger/logger.go Normal file
View File

@ -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()
}
}

View File

@ -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 {
// 如果无密码解析失败,尝试用密码解析
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 {

812
web/index.html Normal file
View File

@ -0,0 +1,812 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>License Management System</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
background: white;
padding: 20px;
border-radius: 10px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.header h1 {
color: #333;
display: inline-block;
}
.user-info {
float: right;
line-height: 32px;
}
.btn-logout {
background: #e74c3c;
color: white;
border: none;
padding: 8px 16px;
border-radius: 5px;
cursor: pointer;
margin-left: 10px;
}
.btn-logout:hover {
background: #c0392b;
}
.login-container {
max-width: 400px;
margin: 100px auto;
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
.login-container h2 {
text-align: center;
margin-bottom: 30px;
color: #333;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
color: #555;
font-weight: 500;
}
input[type="text"],
input[type="password"],
input[type="datetime-local"],
select,
textarea {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
}
.btn {
width: 100%;
padding: 12px;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
}
.btn:hover {
background: #5568d3;
}
.btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.dashboard {
display: none;
}
.dashboard.active {
display: block;
}
.tabs {
background: white;
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.tab-buttons {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #eee;
}
.tab-btn {
padding: 12px 24px;
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: #666;
border-bottom: 3px solid transparent;
transition: all 0.3s;
}
.tab-btn:hover {
color: #667eea;
}
.tab-btn.active {
color: #667eea;
border-bottom-color: #667eea;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.card {
background: white;
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.card h3 {
margin-bottom: 20px;
color: #333;
}
.device-item,
.request-item {
background: #f8f9fa;
padding: 15px;
margin-bottom: 10px;
border-radius: 5px;
border-left: 4px solid #667eea;
}
.device-item.expired {
border-left-color: #e74c3c;
}
.btn-small {
padding: 6px 12px;
font-size: 14px;
width: auto;
margin-right: 5px;
}
.btn-danger {
background: #e74c3c;
}
.btn-danger:hover {
background: #c0392b;
}
.btn-success {
background: #27ae60;
}
.btn-success:hover {
background: #229954;
}
.alert {
padding: 15px;
margin-bottom: 20px;
border-radius: 5px;
display: none;
}
.alert.show {
display: block;
}
.alert-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.form-row {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.form-row .form-group {
flex: 1;
}
code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.auto-renew-switch {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
.switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 34px;
}
.slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #667eea;
}
input:checked + .slider:before {
transform: translateX(26px);
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
}
.badge-success {
background: #d4edda;
color: #155724;
}
.badge-danger {
background: #f8d7da;
color: #721c24;
}
.badge-warning {
background: #fff3cd;
color: #856404;
}
</style>
</head>
<body>
<!-- Login Screen -->
<div id="loginScreen" class="login-container">
<h2>🔐 License Management</h2>
<div id="loginAlert" class="alert"></div>
<form id="loginForm">
<div class="form-group">
<label>用户名</label>
<input type="text" id="username" required placeholder="admin">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="password" required placeholder="••••••••">
</div>
<button type="submit" class="btn">登录</button>
</form>
</div>
<!-- Dashboard -->
<div id="dashboard" class="dashboard">
<div class="header">
<h1>📋 License Management System</h1>
<div class="user-info">
<span>欢迎, <strong id="currentUser"></strong></span>
<button class="btn-logout" onclick="logout()">退出</button>
</div>
</div>
<div class="container">
<div id="dashboardAlert" class="alert"></div>
<div class="tabs">
<div class="tab-buttons">
<button class="tab-btn active" onclick="switchTab('devices')">设备管理</button>
<button class="tab-btn" onclick="switchTab('sign')">签发License</button>
<button class="tab-btn" onclick="switchTab('requests')">续期审批</button>
<button class="tab-btn" onclick="switchTab('settings')">系统设置</button>
</div>
<!-- Devices Tab -->
<div id="devices" class="tab-content active">
<div class="card">
<h3>设备列表</h3>
<button class="btn btn-small" onclick="loadDevices()">刷新列表</button>
<div id="deviceList"></div>
</div>
<div class="card">
<h3>创建/更新设备</h3>
<form id="deviceForm">
<div class="form-row">
<div class="form-group">
<label>设备ID</label>
<input type="text" id="deviceId" required placeholder="dev-001">
</div>
<div class="form-group">
<label>到期时间</label>
<input type="datetime-local" id="expiration" required>
</div>
</div>
<button type="submit" class="btn">保存设备</button>
</form>
</div>
</div>
<!-- Sign License Tab -->
<div id="sign" class="tab-content">
<div class="card">
<h3>签发License</h3>
<form id="signForm">
<div class="form-group">
<label>设备ID</label>
<input type="text" id="signDeviceId" required placeholder="dev-001">
</div>
<button type="submit" class="btn">签发License</button>
</form>
<div id="licenseResult" style="margin-top: 20px;"></div>
</div>
</div>
<!-- Requests Tab -->
<div id="requests" class="tab-content">
<div class="card">
<h3>待审批的续期请求</h3>
<button class="btn btn-small" onclick="loadPendingRequests()">刷新列表</button>
<div id="requestList"></div>
</div>
</div>
<!-- Settings Tab -->
<div id="settings" class="tab-content">
<div class="card">
<h3>系统设置</h3>
<div class="auto-renew-switch">
<span>自动续期所有设备:</span>
<label class="switch">
<input type="checkbox" id="autoRenewSwitch" onchange="toggleAutoRenew()">
<span class="slider"></span>
</label>
<span id="autoRenewStatus" class="badge badge-warning">关闭</span>
</div>
</div>
<div class="card">
<h3>🔑 Ed25519 公钥</h3>
<p style="margin-bottom: 15px; color: #666;">
此公钥用于验证 License 签名的有效性。您可以安全地将此公钥分发给需要验证 License 的设备。
</p>
<button class="btn btn-small" onclick="loadPublicKey()">刷新公钥</button>
<div id="publicKeyResult" style="margin-top: 20px;"></div>
</div>
</div>
</div>
</div>
</div>
<script>
const API_BASE = '/api';
let currentToken = '';
let currentUsername = '';
// Tab switching
function switchTab(tabName) {
document.querySelectorAll('.tab-content').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.getElementById(tabName).classList.add('active');
event.target.classList.add('active');
if (tabName === 'devices') {
loadDevices();
} else if (tabName === 'requests') {
loadPendingRequests();
}
}
// Show alert
function showAlert(elementId, message, type = 'success') {
const alert = document.getElementById(elementId);
alert.className = `alert alert-${type} show`;
alert.textContent = message;
setTimeout(() => alert.classList.remove('show'), 3000);
}
// Login
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const response = await fetch(`${API_BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok) {
currentToken = data.token;
currentUsername = username;
document.getElementById('currentUser').textContent = username;
document.getElementById('loginScreen').style.display = 'none';
document.getElementById('dashboard').classList.add('active');
loadDevices();
loadAutoRenewStatus();
} else {
showAlert('loginAlert', data.message || '登录失败', 'error');
}
} catch (error) {
showAlert('loginAlert', '网络错误: ' + error.message, 'error');
}
});
// Logout
function logout() {
currentToken = '';
currentUsername = '';
document.getElementById('loginScreen').style.display = 'block';
document.getElementById('dashboard').classList.remove('active');
}
// Load devices
async function loadDevices() {
try {
const response = await fetch(`${API_BASE}/device/list`, {
headers: { 'Authorization': `Bearer ${currentToken}` }
});
if (!response.ok) {
const errorText = await response.text();
console.error('设备列表加载失败:', response.status, errorText);
showAlert('dashboardAlert', `加载设备列表失败: ${errorText}`, 'error');
return;
}
const devices = await response.json();
const listDiv = document.getElementById('deviceList');
// 确保 devices 是数组
if (!Array.isArray(devices)) {
console.error('设备列表数据格式错误:', typeof devices, devices);
listDiv.innerHTML = `<p style="color: #e74c3c;">数据格式错误: ${typeof devices}</p>`;
return;
}
if (devices.length === 0) {
listDiv.innerHTML = '<p>暂无设备</p>';
return;
}
listDiv.innerHTML = devices.map(device => {
const expDate = new Date(device.expiration);
const isExpired = expDate < new Date();
return `
<div class="device-item ${isExpired ? 'expired' : ''}">
<strong>${device.device_id}</strong> -
到期: ${expDate.toLocaleString('zh-CN')}
${isExpired ? '<span class="badge badge-danger">已过期</span>' : '<span class="badge badge-success">有效</span>'}
</div>
`;
}).join('');
} catch (error) {
console.error('加载设备失败:', error);
showAlert('dashboardAlert', '加载设备失败: ' + error.message, 'error');
}
}
// Create/Update device
document.getElementById('deviceForm').addEventListener('submit', async (e) => {
e.preventDefault();
const deviceId = document.getElementById('deviceId').value;
const expiration = document.getElementById('expiration').value;
// Convert local datetime to RFC3339
const expirationISO = new Date(expiration).toISOString();
try {
const response = await fetch(`${API_BASE}/device/manage`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${currentToken}`
},
body: JSON.stringify({ device_id: deviceId, expiration: expirationISO })
});
const text = await response.text();
if (response.ok) {
showAlert('dashboardAlert', text, 'success');
document.getElementById('deviceForm').reset();
loadDevices();
} else {
showAlert('dashboardAlert', text || '操作失败', 'error');
}
} catch (error) {
showAlert('dashboardAlert', '网络错误: ' + error.message, 'error');
}
});
// Sign license
document.getElementById('signForm').addEventListener('submit', async (e) => {
e.preventDefault();
const deviceId = document.getElementById('signDeviceId').value;
try {
const response = await fetch(`${API_BASE}/license/sign`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${currentToken}`
},
body: JSON.stringify({ device_id: deviceId })
});
const license = await response.json();
if (response.ok) {
const resultDiv = document.getElementById('licenseResult');
resultDiv.innerHTML = `
<h4>签发成功!</h4>
<pre style="background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto;">${JSON.stringify(license, null, 2)}</pre>
`;
showAlert('dashboardAlert', 'License签发成功', 'success');
} else {
showAlert('dashboardAlert', license.message || '签发失败', 'error');
}
} catch (error) {
showAlert('dashboardAlert', '网络错误: ' + error.message, 'error');
}
});
// Load pending requests
async function loadPendingRequests() {
try {
const response = await fetch(`${API_BASE}/admin/pending_requests`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${currentToken}` }
});
if (!response.ok) {
showAlert('dashboardAlert', '加载申请列表失败', 'error');
return;
}
const requests = await response.json();
const listDiv = document.getElementById('requestList');
// 确保 requests 是数组
if (!Array.isArray(requests)) {
listDiv.innerHTML = '<p style="color: #e74c3c;">数据格式错误</p>';
return;
}
if (requests.length === 0) {
listDiv.innerHTML = '<p>暂无待审批申请</p>';
return;
}
listDiv.innerHTML = requests.map(req => {
const reqDate = new Date(req.request_time);
return `
<div class="request-item">
<strong>设备ID: ${req.device_id}</strong><br>
申请时间: ${reqDate.toLocaleString('zh-CN')}<br>
<button class="btn btn-small btn-success" onclick="approveRequest('${req.device_id}')">批准</button>
<button class="btn btn-small btn-danger" onclick="rejectRequest('${req.device_id}')">拒绝</button>
</div>
`;
}).join('');
} catch (error) {
showAlert('dashboardAlert', '加载申请失败: ' + error.message, 'error');
}
}
// Approve request
async function approveRequest(deviceId) {
const expiration = new Date();
expiration.setFullYear(expiration.getFullYear() + 1);
try {
const response = await fetch(`${API_BASE}/admin/handle_request`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${currentToken}`
},
body: JSON.stringify({
device_id: deviceId,
expiration: expiration.toISOString(),
approved_by: currentUsername,
action: 'approve'
})
});
if (response.ok) {
showAlert('dashboardAlert', '申请已批准', 'success');
loadPendingRequests();
loadDevices();
} else {
showAlert('dashboardAlert', '批准失败', 'error');
}
} catch (error) {
showAlert('dashboardAlert', '网络错误: ' + error.message, 'error');
}
}
// Reject request
async function rejectRequest(deviceId) {
const expiration = new Date();
expiration.setFullYear(expiration.getFullYear() + 1);
try {
const response = await fetch(`${API_BASE}/admin/handle_request`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${currentToken}`
},
body: JSON.stringify({
device_id: deviceId,
expiration: expiration.toISOString(),
approved_by: currentUsername,
action: 'reject'
})
});
if (response.ok) {
showAlert('dashboardAlert', '申请已拒绝', 'success');
loadPendingRequests();
} else {
showAlert('dashboardAlert', '拒绝失败', 'error');
}
} catch (error) {
showAlert('dashboardAlert', '网络错误: ' + error.message, 'error');
}
}
// Load auto renew status
async function loadAutoRenewStatus() {
// This would require a GET endpoint, for now we'll just show the toggle
}
// Toggle auto renew
async function toggleAutoRenew() {
const enabled = document.getElementById('autoRenewSwitch').checked;
const status = document.getElementById('autoRenewStatus');
try {
const response = await fetch(`${API_BASE}/admin/allow_auto_renew`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${currentToken}`
},
body: JSON.stringify({ enabled: enabled.toString() })
});
if (response.ok) {
status.textContent = enabled ? '开启' : '关闭';
status.className = enabled ? 'badge badge-success' : 'badge badge-warning';
showAlert('dashboardAlert', enabled ? '自动续期已开启' : '自动续期已关闭', 'success');
} else {
document.getElementById('autoRenewSwitch').checked = !enabled;
showAlert('dashboardAlert', '设置失败', 'error');
}
} catch (error) {
document.getElementById('autoRenewSwitch').checked = !enabled;
showAlert('dashboardAlert', '网络错误: ' + error.message, 'error');
}
}
// Load public key
async function loadPublicKey() {
const resultDiv = document.getElementById('publicKeyResult');
resultDiv.innerHTML = '<p>加载中...</p>';
try {
const response = await fetch(`${API_BASE}/license/public-key`);
if (response.ok) {
const data = await response.json();
resultDiv.innerHTML = `
<div style="margin-bottom: 20px;">
<h4 style="margin-bottom: 10px;">SSH 格式 (推荐)</h4>
<pre style="background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; word-break: break-all;">${data.ssh_format.trim()}</pre>
</div>
<div>
<h4 style="margin-bottom: 10px;">Base64 格式</h4>
<pre style="background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; word-break: break-all;">${data.base64}</pre>
</div>
`;
} else {
resultDiv.innerHTML = '<p style="color: #e74c3c;">加载公钥失败</p>';
}
} catch (error) {
resultDiv.innerHTML = `<p style="color: #e74c3c;">网络错误: ${error.message}</p>`;
}
}
// Set default expiration to 1 year from now
window.addEventListener('DOMContentLoaded', () => {
const expInput = document.getElementById('expiration');
const nextYear = new Date();
nextYear.setFullYear(nextYear.getFullYear() + 1);
const localDatetime = new Date(nextYear.getTime() - nextYear.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
expInput.value = localDatetime;
});
// Auto-load public key when switching to settings tab
document.addEventListener('DOMContentLoaded', function() {
const settingsBtn = document.querySelector('.tab-btn[onclick*="settings"]');
if (settingsBtn) {
settingsBtn.addEventListener('click', function() {
setTimeout(loadPublicKey, 100);
});
}
});
</script>
</body>
</html>