feature: create an ui
This commit is contained in:
parent
bcbf2a8d08
commit
01b78687d5
49
.gitignore
vendored
Normal file
49
.gitignore
vendored
Normal 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
314
QUICKSTART.md
Normal 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
330
README.md
Normal 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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 签名",
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
207
internal/logger/logger.go
Normal 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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
812
web/index.html
Normal 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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user