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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"licensing-cotton/internal/database"
|
"licensing-cotton/internal/database"
|
||||||
"licensing-cotton/internal/handlers"
|
"licensing-cotton/internal/handlers"
|
||||||
|
"licensing-cotton/internal/logger"
|
||||||
"licensing-cotton/internal/security"
|
"licensing-cotton/internal/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -14,30 +14,30 @@ func main() {
|
|||||||
|
|
||||||
// 1. 初始化数据库
|
// 1. 初始化数据库
|
||||||
if err := database.InitDB("mydb.db"); err != nil {
|
if err := database.InitDB("mydb.db"); err != nil {
|
||||||
log.Fatalf("数据库初始化失败: %v", err)
|
logger.Fatal("数据库初始化失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 初始化 ECC 密钥
|
// 2. 初始化 ECC 密钥
|
||||||
if err := security.InitECCKey(); err != nil {
|
if err := security.InitECCKey(); err != nil {
|
||||||
log.Fatalf("生成ECC密钥对失败: %v", err)
|
logger.Fatal("生成ECC密钥对失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化私钥
|
// 初始化私钥
|
||||||
if err := security.InitEd25519Keys(passphrase); err != nil {
|
if err := security.InitEd25519Keys(passphrase); err != nil {
|
||||||
log.Fatalf("Failed to initialize keys: %v", err)
|
logger.Fatal("Failed to initialize keys: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 初始化默认管理员
|
// 3. 初始化默认管理员
|
||||||
if err := handlers.EnsureDefaultAdmin("admin", "admin123"); err != nil {
|
if err := handlers.EnsureDefaultAdmin("admin", "admin123"); err != nil {
|
||||||
log.Printf("初始化默认管理员失败: %v", err)
|
logger.Warn("初始化默认管理员失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 注册路由
|
// 4. 注册路由
|
||||||
mux := handlers.RegisterRoutes()
|
mux := handlers.RegisterRoutes()
|
||||||
|
|
||||||
// 5. 启动HTTP服务器
|
// 5. 启动HTTP服务器
|
||||||
log.Println("服务器启动中,监听 :8080 ...")
|
logger.Info("服务器启动中,监听 :8895 ...")
|
||||||
if err := http.ListenAndServe("0.0.0.0:8895", mux); err != nil {
|
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 (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
|
"licensing-cotton/internal/logger"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
@ -59,7 +60,7 @@ func createTables() error {
|
|||||||
expiration DATETIME DEFAULT NULL
|
expiration DATETIME DEFAULT NULL
|
||||||
);`)
|
);`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("创建 pending_requests 失败: %v", err)
|
logger.Fatal("创建 pending_requests 失败: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"licensing-cotton/internal/database"
|
"licensing-cotton/internal/database"
|
||||||
|
"licensing-cotton/internal/logger"
|
||||||
"licensing-cotton/internal/models"
|
"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`)
|
rows, err := database.DB.Query(`SELECT id, device_id, expiration FROM devices`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Error("查询设备列表失败: %v", err)
|
||||||
http.Error(w, "查询失败", http.StatusInternalServerError)
|
http.Error(w, "查询失败", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var devices []models.Device
|
devices := []models.Device{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var d models.Device
|
var d models.Device
|
||||||
if err := rows.Scan(&d.ID, &d.DeviceID, &d.Expiration); err != nil {
|
if err := rows.Scan(&d.ID, &d.DeviceID, &d.Expiration); err != nil {
|
||||||
|
logger.Error("扫描设备记录失败: %v", err)
|
||||||
http.Error(w, "解析记录失败", http.StatusInternalServerError)
|
http.Error(w, "解析记录失败", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
devices = append(devices, d)
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(devices)
|
json.NewEncoder(w).Encode(devices)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,12 +6,12 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"licensing-cotton/internal/database"
|
"licensing-cotton/internal/database"
|
||||||
|
"licensing-cotton/internal/logger"
|
||||||
"licensing-cotton/internal/security"
|
"licensing-cotton/internal/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -153,6 +153,29 @@ func SignLicense(deviceID string, expirationTime time.Time) (License, error) {
|
|||||||
SignDate: licWithoutSign.SignDate,
|
SignDate: licWithoutSign.SignDate,
|
||||||
Signature: base64.StdEncoding.EncodeToString(signature),
|
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
|
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
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"licensing-cotton/internal/logger"
|
||||||
"licensing-cotton/internal/security"
|
"licensing-cotton/internal/security"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@ -29,7 +29,7 @@ func getUserRoleFromRequest(r *http.Request) (string, bool) {
|
|||||||
// 解析 Token 获取用户名
|
// 解析 Token 获取用户名
|
||||||
username, err := security.ParseToken(token)
|
username, err := security.ParseToken(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Token 解析失败:", err)
|
logger.Debug("Token 解析失败: %v", err)
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,7 +37,7 @@ func getUserRoleFromRequest(r *http.Request) (string, bool) {
|
|||||||
var role string
|
var role string
|
||||||
err = dbQueryRole(username, &role)
|
err = dbQueryRole(username, &role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("数据库查询角色失败:", err)
|
logger.Debug("数据库查询角色失败: %v", err)
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -67,7 +67,7 @@ func HandleListPendingRequests(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var pendingRequests []models.PendingRequest
|
pendingRequests := []models.PendingRequest{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var req models.PendingRequest
|
var req models.PendingRequest
|
||||||
var approvedBy sql.NullString
|
var approvedBy sql.NullString
|
||||||
|
|||||||
@ -1,26 +1,54 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import "net/http"
|
import (
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
func RegisterRoutes() *http.ServeMux {
|
func RegisterRoutes() *http.ServeMux {
|
||||||
mux := http.NewServeMux()
|
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("/login", HandleLogin)
|
||||||
|
mux.HandleFunc("/api/register", HandleRegisterUser)
|
||||||
mux.HandleFunc("/register", HandleRegisterUser)
|
mux.HandleFunc("/register", HandleRegisterUser)
|
||||||
|
|
||||||
// Device
|
// Device
|
||||||
|
mux.HandleFunc("/api/device/manage", HandleCreateOrUpdateDevice)
|
||||||
mux.HandleFunc("/device/manage", HandleCreateOrUpdateDevice)
|
mux.HandleFunc("/device/manage", HandleCreateOrUpdateDevice)
|
||||||
|
mux.HandleFunc("/api/device/list", HandleListDevices)
|
||||||
mux.HandleFunc("/device/list", HandleListDevices)
|
mux.HandleFunc("/device/list", HandleListDevices)
|
||||||
|
|
||||||
// License
|
// License
|
||||||
|
mux.HandleFunc("/api/license/sign", HandleSignLicense)
|
||||||
mux.HandleFunc("/license/sign", HandleSignLicense)
|
mux.HandleFunc("/license/sign", HandleSignLicense)
|
||||||
|
mux.HandleFunc("/api/license/verify", HandleVerifyLicense)
|
||||||
mux.HandleFunc("/license/verify", HandleVerifyLicense)
|
mux.HandleFunc("/license/verify", HandleVerifyLicense)
|
||||||
|
mux.HandleFunc("/api/license/renew", HandleRenewLicense)
|
||||||
mux.HandleFunc("/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("/admin/pending_requests", HandleListPendingRequests)
|
||||||
|
mux.HandleFunc("/api/admin/handle_request", HandleDeviceRequest)
|
||||||
mux.HandleFunc("/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)
|
mux.HandleFunc("/admin/allow_auto_renew", SetAutoRenewAllDevices)
|
||||||
|
|
||||||
return mux
|
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 (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
"errors"
|
"errors"
|
||||||
"golang.org/x/crypto/ssh"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
|
"licensing-cotton/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ed25519Priv ed25519.PrivateKey
|
ed25519Priv ed25519.PrivateKey
|
||||||
|
ed25519Pub ed25519.PublicKey
|
||||||
once sync.Once
|
once sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// InitEd25519Keys 加载并解密 OpenSSH 格式的 Ed25519 私钥
|
// InitEd25519Keys 自动生成或加载 Ed25519 私钥
|
||||||
func InitEd25519Keys(passphrase string) error {
|
func InitEd25519Keys(passphrase string) error {
|
||||||
var err error
|
var err error
|
||||||
once.Do(func() {
|
once.Do(func() {
|
||||||
// 读取私钥文件
|
keyDir := "keys"
|
||||||
data, e := os.ReadFile("keys/alaer_machines")
|
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 {
|
if e != nil {
|
||||||
err = errors.New("failed to load ed25519 private key file: " + e.Error())
|
err = errors.New("failed to load ed25519 private key file: " + e.Error())
|
||||||
return
|
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 {
|
if e != nil {
|
||||||
err = errors.New("failed to decrypt private key: " + e.Error())
|
err = errors.New("failed to decrypt private key: " + e.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 检查解析出的私钥类型
|
// 检查解析出的私钥类型
|
||||||
switch key := decryptedKey.(type) {
|
switch key := decryptedKey.(type) {
|
||||||
case ed25519.PrivateKey:
|
case ed25519.PrivateKey:
|
||||||
ed25519Priv = key
|
ed25519Priv = key
|
||||||
|
ed25519Pub = key.Public().(ed25519.PublicKey)
|
||||||
case *ed25519.PrivateKey:
|
case *ed25519.PrivateKey:
|
||||||
ed25519Priv = *key
|
ed25519Priv = *key
|
||||||
|
ed25519Pub = (*key).Public().(ed25519.PublicKey)
|
||||||
default:
|
default:
|
||||||
err = errors.New("parsed key is not an ed25519 private key, check your key format")
|
err = errors.New("parsed key is not an ed25519 private key, check your key format")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
logger.Info("Ed25519 密钥已从文件加载")
|
||||||
})
|
})
|
||||||
return err
|
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 签名
|
// Ed25519Sign 进行 Ed25519 签名
|
||||||
func Ed25519Sign(message []byte) ([]byte, error) {
|
func Ed25519Sign(message []byte) ([]byte, error) {
|
||||||
if ed25519Priv == nil {
|
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