feat(M4): 生命周期 + 审计 — 到期锁定/回收 cron、审计查询/CSV 导出/每日归档、settings 动态策略
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ws_usernode/internal/config"
|
||||
"ws_usernode/internal/mail"
|
||||
"ws_usernode/internal/model"
|
||||
"ws_usernode/internal/system"
|
||||
)
|
||||
|
||||
// LifecycleService 账号生命周期维护(PLAN F2):
|
||||
// - 到期:锁定系统账号 + 清空 authorized_keys(SSH 立即失效)+ 状态置 expired + 邮件提醒;
|
||||
// - 回收:超过回收期仍未延期 → 删除系统账号与密钥(保留审计)+ 邮件通知。
|
||||
//
|
||||
// 由 cron 每日触发(单实例部署,PLAN §11)。
|
||||
type LifecycleService struct {
|
||||
db *gorm.DB
|
||||
cfg *config.Config
|
||||
sys system.Manager
|
||||
users *UserService
|
||||
settings *SettingService
|
||||
mailer mail.Mailer
|
||||
audit *AuditService
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewLifecycleService 创建生命周期服务。
|
||||
func NewLifecycleService(db *gorm.DB, cfg *config.Config, sys system.Manager, users *UserService, settings *SettingService, mailer mail.Mailer, audit *AuditService, log *slog.Logger) *LifecycleService {
|
||||
return &LifecycleService{db: db, cfg: cfg, sys: sys, users: users, settings: settings, mailer: mailer, audit: audit, log: log}
|
||||
}
|
||||
|
||||
// ScanExpired 扫描到期账号(expire_at < now 且状态非 disabled/expired):
|
||||
// 锁定系统账号 + 清空 authorized_keys + 状态置 expired + 邮件通知(到期提醒)。
|
||||
// 返回本次处理的账号数。
|
||||
func (s *LifecycleService) ScanExpired(ctx context.Context) (int, error) {
|
||||
now := time.Now()
|
||||
var rows []model.User
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("status = ? AND expire_at IS NOT NULL AND expire_at < ?", model.UserStatusActive, now).
|
||||
Find(&rows).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
processed := 0
|
||||
for i := range rows {
|
||||
u := &rows[i]
|
||||
if !s.systemAccountOK(u.Username) {
|
||||
s.log.Warn("lifecycle: 系统账号缺失,跳过锁定", "username", u.Username)
|
||||
continue
|
||||
}
|
||||
if err := s.sys.SetLock(ctx, u.Username, true); err != nil {
|
||||
s.log.Error("lifecycle: 锁定系统账号失败", "username", u.Username, "err", err)
|
||||
_ = s.audit.Record(ctx, 0, "system", "lifecycle.expire", "user", fmt.Sprint(u.ID), map[string]any{"err": err.Error()}, "cron", model.ResultFailed)
|
||||
continue
|
||||
}
|
||||
if err := s.sys.SyncAuthorizedKeys(ctx, u.Username, nil); err != nil {
|
||||
s.log.Error("lifecycle: 清空 authorized_keys 失败", "username", u.Username, "err", err)
|
||||
_ = s.audit.Record(ctx, 0, "system", "lifecycle.expire", "user", fmt.Sprint(u.ID), map[string]any{"err": err.Error()}, "cron", model.ResultFailed)
|
||||
continue
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", u.ID).Update("status", model.UserStatusExpired).Error; err != nil {
|
||||
s.log.Error("lifecycle: 更新状态失败", "username", u.Username, "err", err)
|
||||
continue
|
||||
}
|
||||
processed++
|
||||
s.notifyExpired(ctx, u)
|
||||
_ = s.audit.Record(ctx, 0, "system", "lifecycle.expire", "user", fmt.Sprint(u.ID), nil, "cron", model.ResultSuccess)
|
||||
}
|
||||
return processed, nil
|
||||
}
|
||||
|
||||
// Recycle 回收超期账号:expired 状态且过期时间超过回收期 → 删除系统账号与密钥
|
||||
// (保留审计)+ 邮件通知(回收提醒)。返回本次回收的账号数。
|
||||
func (s *LifecycleService) Recycle(ctx context.Context) (int, error) {
|
||||
period := s.settings.RecyclePeriod(ctx)
|
||||
cutoff := time.Now().Add(-period)
|
||||
var rows []model.User
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("status = ? AND expire_at IS NOT NULL AND expire_at < ?", model.UserStatusExpired, cutoff).
|
||||
Find(&rows).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
recycled := 0
|
||||
for i := range rows {
|
||||
u := &rows[i]
|
||||
if err := s.users.Delete(ctx, u.ID); err != nil {
|
||||
s.log.Error("lifecycle: 回收账号失败", "username", u.Username, "err", err)
|
||||
_ = s.audit.Record(ctx, 0, "system", "lifecycle.recycle", "user", fmt.Sprint(u.ID), map[string]any{"err": err.Error()}, "cron", model.ResultFailed)
|
||||
continue
|
||||
}
|
||||
recycled++
|
||||
s.notifyRecycled(ctx, u)
|
||||
_ = s.audit.Record(ctx, 0, "system", "lifecycle.recycle", "user", fmt.Sprint(u.ID), nil, "cron", model.ResultSuccess)
|
||||
}
|
||||
return recycled, nil
|
||||
}
|
||||
|
||||
// systemAccountOK 与 UserService 一致:dry-run 模式跳过真实检查。
|
||||
func (s *LifecycleService) systemAccountOK(username string) bool {
|
||||
if s.cfg.System.DryRun {
|
||||
return true
|
||||
}
|
||||
ok, err := s.sys.Exists(context.Background(), username)
|
||||
return err == nil && ok
|
||||
}
|
||||
|
||||
// notifyExpired 到期提醒:账号已到期,回收期内可延期恢复。
|
||||
func (s *LifecycleService) notifyExpired(ctx context.Context, u *model.User) {
|
||||
body := fmt.Sprintf(`您的服务器账号(%s)已到期,系统账号已被锁定,SSH 登录已不可用。
|
||||
|
||||
如需继续使用,请在回收期(%s)内联系管理员延期恢复;超过回收期账号将被自动回收。
|
||||
`, u.Username, s.settings.RecyclePeriod(ctx).String())
|
||||
if err := s.mailer.Send(ctx, u.Email, "服务器账号已到期", body); err != nil {
|
||||
s.log.Warn("lifecycle: 到期邮件发送失败", "username", u.Username, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// notifyRecycled 回收提醒:账号已超回收期被回收。
|
||||
func (s *LifecycleService) notifyRecycled(ctx context.Context, u *model.User) {
|
||||
body := fmt.Sprintf(`您的服务器账号(%s)已超过回收期且未办理延期,账号已被回收删除(系统账号与密钥均已清理)。
|
||||
|
||||
如需重新使用,请重新提交账号申请。
|
||||
`, u.Username)
|
||||
if err := s.mailer.Send(ctx, u.Email, "服务器账号已回收", body); err != nil {
|
||||
s.log.Warn("lifecycle: 回收邮件发送失败", "username", u.Username, "err", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user