133 lines
5.4 KiB
Go
133 lines
5.4 KiB
Go
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)
|
||
}
|
||
}
|