mirror of
https://github.com/tnb-labs/panel.git
synced 2026-10-30 18:21:30 +08:00
依赖注入:
- 移除 google/wire(含 wire.go/wire_gen.go 与全部 ProviderSet),
改用 samber/do v2 单注入器 + 双入口惰性构建
- 贡献模型替代命令式注册:路由 routes:、命令 commands:、
任务 jobs: 前缀经 internal/registry 收集与校验
- 构造函数统一 func NewXxx(i do.Injector) (T, error)
三层架构:
- 补全用例层:每个 biz.XxxRepo 配 XxxUsecase,
service/command/job 表现层只依赖用例,不再直接引用仓储
路由与文档:
- route/http.go 按域拆为声明式 Endpoint 贡献,
Endpoint 承载登录白名单与端点限流语义
- 调试模式下提供 OpenAPI 3.1 文档:/openapi.json 与 /docs(Scalar),
从 validate 标签生成
目录与依赖:
- internal/http/{middleware,request,rule} 拍平至 internal/*
- CLI 命令拆至 internal/command
- 日志轮转 timberjack 换为 libtnb/logrotate
- 升级 validator/cron/sessions/gormstore/sqlite/securecookie
- 数据库迁移保持 gormigrate 不变
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
247 lines
5.7 KiB
Go
247 lines
5.7 KiB
Go
package service
|
||
|
||
import (
|
||
"bufio"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"os"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/leonelquinteros/gotext"
|
||
"github.com/samber/do/v2"
|
||
|
||
"github.com/acepanel/panel/v3/internal/biz"
|
||
"github.com/acepanel/panel/v3/internal/request"
|
||
"github.com/acepanel/panel/v3/pkg/shell"
|
||
"github.com/acepanel/panel/v3/pkg/types"
|
||
)
|
||
|
||
// SSH 日志正则
|
||
var (
|
||
sshAccepted = regexp.MustCompile(`Accepted\s+(\S+)\s+for\s+(\S+)\s+from\s+(\S+)\s+port\s+(\d+)`)
|
||
sshFailed = regexp.MustCompile(`Failed\s+(\S+)\s+for\s+(?:invalid user\s+)?(\S+)\s+from\s+(\S+)\s+port\s+(\d+)`)
|
||
sshInvalidUser = regexp.MustCompile(`Invalid user\s+(\S+)\s+from\s+(\S+)\s+port\s+(\d+)`)
|
||
sshDisconnect = regexp.MustCompile(`Disconnected from\s+(?:authenticating\s+)?user\s+(\S+)\s+(\S+)\s+port\s+(\d+)`)
|
||
)
|
||
|
||
type LogService struct {
|
||
t *gotext.Locale
|
||
logRepo *biz.LogUsecase
|
||
}
|
||
|
||
func NewLogService(i do.Injector) (*LogService, error) {
|
||
return &LogService{
|
||
t: do.MustInvoke[*gotext.Locale](i),
|
||
logRepo: do.MustInvoke[*biz.LogUsecase](i),
|
||
}, nil
|
||
}
|
||
|
||
// List 获取日志列表
|
||
func (s *LogService) List(w http.ResponseWriter, r *http.Request) {
|
||
req, err := Bind[request.LogList](r)
|
||
if err != nil {
|
||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||
return
|
||
}
|
||
|
||
// 默认限制
|
||
if req.Limit == 0 {
|
||
req.Limit = 100
|
||
}
|
||
|
||
entries, err := s.logRepo.List(req.Type, req.Limit, req.Date)
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "%v", err)
|
||
return
|
||
}
|
||
|
||
Success(w, entries)
|
||
}
|
||
|
||
// Dates 获取日志日期列表
|
||
func (s *LogService) Dates(w http.ResponseWriter, r *http.Request) {
|
||
req, err := Bind[request.LogDates](r)
|
||
if err != nil {
|
||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||
return
|
||
}
|
||
|
||
dates, err := s.logRepo.ListDates(req.Type)
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "%v", err)
|
||
return
|
||
}
|
||
|
||
Success(w, dates)
|
||
}
|
||
|
||
// SSH 获取 SSH 登录日志
|
||
func (s *LogService) SSH(w http.ResponseWriter, r *http.Request) {
|
||
limit := 100
|
||
if v := r.URL.Query().Get("limit"); v != "" {
|
||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 {
|
||
limit = n
|
||
}
|
||
}
|
||
|
||
logs, err := s.sshFromJournalctl(limit)
|
||
if err != nil {
|
||
logs, err = s.sshFromLogFile(limit)
|
||
}
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "%v", err)
|
||
return
|
||
}
|
||
|
||
Success(w, logs)
|
||
}
|
||
|
||
// sshFromJournalctl 通过 journalctl 获取 SSH 日志
|
||
func (s *LogService) sshFromJournalctl(limit int) ([]types.SSHLoginLog, error) {
|
||
raw, err := shell.Execf("journalctl -u sshd -u ssh --no-pager -o json -n %d 2>/dev/null", limit*5)
|
||
if err != nil || raw == "" {
|
||
return nil, errors.New(s.t.Get("journalctl is not available"))
|
||
}
|
||
|
||
var logs []types.SSHLoginLog
|
||
scanner := bufio.NewScanner(strings.NewReader(raw))
|
||
for scanner.Scan() {
|
||
var entry struct {
|
||
RealtimeTimestamp string `json:"__REALTIME_TIMESTAMP"`
|
||
Message string `json:"MESSAGE"`
|
||
}
|
||
if json.Unmarshal(scanner.Bytes(), &entry) != nil {
|
||
continue
|
||
}
|
||
|
||
record := parseSSHMessage(entry.Message)
|
||
if record == nil {
|
||
continue
|
||
}
|
||
|
||
// 解析 journalctl 微秒时间戳
|
||
if us, err := strconv.ParseInt(entry.RealtimeTimestamp, 10, 64); err == nil {
|
||
record.Time = time.Unix(0, us*int64(time.Microsecond)).Format("2006-01-02 15:04:05")
|
||
}
|
||
|
||
logs = append(logs, *record)
|
||
if len(logs) >= limit {
|
||
break
|
||
}
|
||
}
|
||
|
||
if len(logs) == 0 {
|
||
return nil, errors.New(s.t.Get("no SSH log entries found"))
|
||
}
|
||
|
||
return logs, nil
|
||
}
|
||
|
||
// sshFromLogFile 从日志文件中解析 SSH 日志
|
||
func (s *LogService) sshFromLogFile(limit int) ([]types.SSHLoginLog, error) {
|
||
paths := []string{"/var/log/auth.log", "/var/log/secure"}
|
||
var file *os.File
|
||
for _, p := range paths {
|
||
if f, err := os.Open(p); err == nil {
|
||
file = f
|
||
break
|
||
}
|
||
}
|
||
if file == nil {
|
||
return nil, errors.New(s.t.Get("SSH log file not found"))
|
||
}
|
||
defer func(file *os.File) { _ = file.Close() }(file)
|
||
|
||
// 读取所有匹配行后取最后 limit 条(日志文件按时间正序)
|
||
var logs []types.SSHLoginLog
|
||
scanner := bufio.NewScanner(file)
|
||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||
for scanner.Scan() {
|
||
line := scanner.Text()
|
||
if !strings.Contains(line, "sshd[") {
|
||
continue
|
||
}
|
||
|
||
record := parseSSHMessage(line)
|
||
if record == nil {
|
||
continue
|
||
}
|
||
|
||
// 从行首解析 syslog 时间戳(如 "Feb 11 08:30:01")
|
||
record.Time = parseSSHLogTime(line)
|
||
|
||
logs = append(logs, *record)
|
||
}
|
||
|
||
// 取最后 limit 条
|
||
if len(logs) > limit {
|
||
logs = logs[len(logs)-limit:]
|
||
}
|
||
|
||
return logs, nil
|
||
}
|
||
|
||
// parseSSHMessage 从日志消息中提取 SSH 登录信息
|
||
func parseSSHMessage(msg string) *types.SSHLoginLog {
|
||
if m := sshAccepted.FindStringSubmatch(msg); m != nil {
|
||
return &types.SSHLoginLog{
|
||
Method: m[1],
|
||
User: m[2],
|
||
IP: m[3],
|
||
Port: m[4],
|
||
Status: "accepted",
|
||
}
|
||
}
|
||
if m := sshFailed.FindStringSubmatch(msg); m != nil {
|
||
return &types.SSHLoginLog{
|
||
Method: m[1],
|
||
User: m[2],
|
||
IP: m[3],
|
||
Port: m[4],
|
||
Status: "failed",
|
||
}
|
||
}
|
||
if m := sshInvalidUser.FindStringSubmatch(msg); m != nil {
|
||
return &types.SSHLoginLog{
|
||
User: m[1],
|
||
IP: m[2],
|
||
Port: m[3],
|
||
Method: "-",
|
||
Status: "invalid_user",
|
||
}
|
||
}
|
||
if m := sshDisconnect.FindStringSubmatch(msg); m != nil {
|
||
return &types.SSHLoginLog{
|
||
User: m[1],
|
||
IP: m[2],
|
||
Port: m[3],
|
||
Method: "-",
|
||
Status: "disconnected",
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// parseSSHLogTime 从 syslog 格式行中解析时间
|
||
func parseSSHLogTime(line string) string {
|
||
// syslog 格式:Mon DD HH:MM:SS(前 15 个字符)
|
||
if len(line) < 15 {
|
||
return "-"
|
||
}
|
||
ts := line[:15]
|
||
// 使用当前年份补全
|
||
t, err := time.Parse("Jan 2 15:04:05", ts)
|
||
if err != nil {
|
||
t, err = time.Parse("Jan 2 15:04:05", ts)
|
||
if err != nil {
|
||
return "-"
|
||
}
|
||
}
|
||
t = t.AddDate(time.Now().Year(), 0, 0)
|
||
return t.Format("2006-01-02 15:04:05")
|
||
}
|