package service import ( "context" "encoding/base64" "encoding/json" "fmt" "os" "os/exec" "regexp" "sort" "strconv" "strings" "time" "github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/utils/re" ) type LogService struct{} const maxSystemLogCursorSkip = 100 const maxFileSystemLogLines = 1000 var ( syslogPriorityKeywords = []struct { keyword string priority string }{ {"emerg", "0"}, {"panic", "0"}, {"alert", "1"}, {"crit", "2"}, {"fatal", "2"}, {"error", "3"}, {"err", "3"}, {"warn", "4"}, {"notice", "5"}, {"debug", "7"}, {"info", "6"}, } ) type systemLogCursor struct { EndTime int64 `json:"endTime"` Skipped int `json:"skipped"` JournalCursor string `json:"journalCursor,omitempty"` } type ILogService interface { ListSystemLogFile() ([]string, error) GetSystemLogStatus() (dto.SystemLogStatus, error) ReadSystemLog(req dto.SystemLogReq) (dto.SystemLogRes, error) ListRunningServices() ([]string, error) } func (u *LogService) ReadSystemLog(req dto.SystemLogReq) (dto.SystemLogRes, error) { pageSize := req.PageSize if pageSize == 0 { pageSize = 100 } now := time.Now() startTime := req.StartTime if startTime.IsZero() { startTime = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) } endTime := req.EndTime if endTime.IsZero() { endTime = now } cursor, err := decodeSystemLogCursor(req.Cursor) if err != nil { return dto.SystemLogRes{}, err } if cursor != nil && cursor.JournalCursor == "" { endTime = time.Unix(0, cursor.EndTime*int64(time.Microsecond)) } journalctl, err := exec.LookPath("journalctl") if err != nil { return u.readFileSystemLog(req, startTime, endTime, pageSize, cursor) } queryArgs := buildJournalQueryArgs(req, startTime, endTime, pageSize, cursor) output, err := executeJournalQuery(journalctl, queryArgs) if err != nil { return handleJournalQueryError(req, output, err, func() ([]byte, error) { probeReq := req probeReq.Keyword = "" probeReq.Priority = "" probeReq.Service = "" probeArgs := buildJournalQueryArgs(probeReq, startTime, endTime, 1, cursor) return executeJournalQuery(journalctl, probeArgs) }) } content := strings.TrimSpace(string(output)) if content == "" || strings.HasPrefix(content, "-- No entries --") { return dto.SystemLogRes{Source: "journalctl", Items: []dto.SystemLogItem{}}, nil } items := trimJournalLogItemsToStartTime(parseJournalLogItems(content), startTime) if cursor != nil && cursor.JournalCursor == "" { items, err = skipSystemLogCursorItems(items, *cursor) if err != nil { return dto.SystemLogRes{}, err } } return buildSystemLogResponse("journalctl", items, pageSize, cursor) } func executeJournalQuery(journalctl string, queryArgs []string) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() return exec.CommandContext(ctx, journalctl, queryArgs...).CombinedOutput() } func (u *LogService) GetSystemLogStatus() (dto.SystemLogStatus, error) { journalctl, err := exec.LookPath("journalctl") if err != nil { return dto.SystemLogStatus{ Source: "file", KeywordFilterSupported: true, }, nil } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) versionOutput, err := exec.CommandContext(ctx, journalctl, "--version").CombinedOutput() cancel() if err != nil { return dto.SystemLogStatus{}, fmt.Errorf("read journalctl version failed: %s", strings.TrimSpace(string(versionOutput))) } ctx, cancel = context.WithTimeout(context.Background(), 3*time.Second) helpOutput, err := exec.CommandContext(ctx, journalctl, "--help").CombinedOutput() cancel() if err != nil { return dto.SystemLogStatus{}, fmt.Errorf("read journalctl capabilities failed: %s", strings.TrimSpace(string(helpOutput))) } supported := journalctlHelpSupportsGrep(string(helpOutput)) status := dto.SystemLogStatus{ Source: "journalctl", Version: firstOutputLine(string(versionOutput)), KeywordFilterSupported: supported, } if !supported { status.Message = i18n.Get("ErrSystemLogKeywordFilterUnsupported") } return status, nil } func journalctlHelpSupportsGrep(help string) bool { return strings.Contains(help, "--grep=") } func handleJournalQueryError( req dto.SystemLogReq, output []byte, queryErr error, probe func() ([]byte, error), ) (dto.SystemLogRes, error) { if hasSystemLogFilter(req) && probe != nil { probeOutput, probeErr := probe() if probeErr == nil { return dto.SystemLogRes{Source: "journalctl", Items: []dto.SystemLogItem{}}, nil } return dto.SystemLogRes{}, newJournalQueryError(probeOutput, probeErr) } return dto.SystemLogRes{}, newJournalQueryError(output, queryErr) } func newJournalQueryError(output []byte, queryErr error) error { message := strings.TrimSpace(string(output)) if message == "" && queryErr != nil { message = queryErr.Error() } return fmt.Errorf("read host system logs failed: %s", message) } func trimJournalLogItemsToStartTime(items []dto.SystemLogItem, startTime time.Time) []dto.SystemLogItem { startTimestamp := startTime.UnixMicro() for i, item := range items { if item.Timestamp < startTimestamp { return items[:i] } } return items } func hasSystemLogFilter(req dto.SystemLogReq) bool { return strings.TrimSpace(req.Keyword) != "" || strings.TrimSpace(req.Priority) != "" || strings.TrimSpace(req.Service) != "" } func firstOutputLine(output string) string { for _, line := range strings.Split(output, "\n") { if line = strings.TrimSpace(line); line != "" { return line } } return "" } func buildJournalQueryArgs(req dto.SystemLogReq, startTime, endTime time.Time, pageSize int, cursor *systemLogCursor) []string { args := []string{ "--no-pager", "--reverse", "--output=json", } if cursor != nil && cursor.JournalCursor != "" { args = append(args, "--after-cursor="+cursor.JournalCursor) } else { args = append(args, "--since", formatJournalQueryTime(startTime), "--until", formatJournalQueryTime(endTime), ) } if service := strings.TrimSpace(req.Service); service != "" { args = append(args, "-u", service) } if priority := strings.TrimSpace(req.Priority); priority != "" { args = append(args, "--priority", priority+".."+priority) } if keyword := strings.TrimSpace(req.Keyword); keyword != "" { args = append(args, "--grep", regexp.QuoteMeta(keyword), "--case-sensitive=no") } queryLines := pageSize + 1 if cursor != nil && cursor.JournalCursor == "" { queryLines += cursor.Skipped } return append(args, "--lines="+strconv.Itoa(queryLines)) } func (u *LogService) readFileSystemLog(req dto.SystemLogReq, startTime, endTime time.Time, pageSize int, cursor *systemLogCursor) (dto.SystemLogRes, error) { items := make([]dto.SystemLogItem, 0) for _, logFile := range []string{"/var/log/syslog", "/var/log/messages", "/var/log/system.log"} { if _, err := os.Stat(logFile); err != nil { continue } content, err := readLastSystemLogLines(logFile, maxFileSystemLogLines) if err != nil { return dto.SystemLogRes{}, err } for _, line := range strings.Split(content, "\n") { item, ok := parseFileSystemLogItem(line) if !ok || !matchSystemLogItem(item, req, startTime, endTime) { continue } items = append(items, item) } } sort.SliceStable(items, func(i, j int) bool { return items[i].Timestamp > items[j].Timestamp }) if cursor != nil { var err error items, err = skipSystemLogCursorItems(items, *cursor) if err != nil { return dto.SystemLogRes{}, err } } return buildSystemLogResponse("file", items, pageSize, cursor) } func readLastSystemLogLines(logFile string, lines int) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() output, err := exec.CommandContext(ctx, "tail", "-n", strconv.Itoa(lines), logFile).Output() if err != nil { return "", err } return string(output), nil } func buildSystemLogResponse(source string, items []dto.SystemLogItem, pageSize int, cursor *systemLogCursor) (dto.SystemLogRes, error) { hasMore := len(items) > pageSize if hasMore { items = items[:pageSize] } res := dto.SystemLogRes{Source: source, Items: items, HasMore: hasMore} if !hasMore || len(items) == 0 { return res, nil } lastItem := items[len(items)-1] next := systemLogCursor{EndTime: lastItem.Timestamp} if lastItem.Cursor != "" { next.JournalCursor = lastItem.Cursor } else { next.Skipped = countSystemLogTimestamp(items, lastItem.Timestamp) if cursor != nil && cursor.EndTime == lastItem.Timestamp { next.Skipped += cursor.Skipped } } nextCursor, err := encodeSystemLogCursor(next) if err != nil { return dto.SystemLogRes{}, err } res.NextCursor = nextCursor return res, nil } func decodeSystemLogCursor(value string) (*systemLogCursor, error) { value = strings.TrimSpace(value) if value == "" { return nil, nil } decoded, err := base64.RawURLEncoding.DecodeString(value) if err != nil { return nil, fmt.Errorf("invalid system log cursor") } var cursor systemLogCursor if err := json.Unmarshal(decoded, &cursor); err != nil || cursor.EndTime <= 0 || cursor.Skipped < 0 || cursor.Skipped > maxSystemLogCursorSkip { return nil, fmt.Errorf("invalid system log cursor") } return &cursor, nil } func encodeSystemLogCursor(cursor systemLogCursor) (string, error) { value, err := json.Marshal(cursor) if err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(value), nil } func skipSystemLogCursorItems(items []dto.SystemLogItem, cursor systemLogCursor) ([]dto.SystemLogItem, error) { skipped := 0 for len(items) > 0 && skipped < cursor.Skipped && items[0].Timestamp == cursor.EndTime { items = items[1:] skipped++ } if skipped != cursor.Skipped { return nil, fmt.Errorf("system log cursor has expired") } return items, nil } func countSystemLogTimestamp(items []dto.SystemLogItem, timestamp int64) int { count := 0 for _, item := range items { if item.Timestamp == timestamp { count++ } } return count } func formatJournalQueryTime(value time.Time) string { // Fractional seconds are rejected by some journalctl versions. return value.In(time.Local).Format("2006-01-02 15:04:05") } func parseFileSystemLogItem(line string) (dto.SystemLogItem, bool) { raw := strings.TrimSpace(line) if raw == "" { return dto.SystemLogItem{}, false } priority := "6" if matches := re.GetRegex(re.SyslogPRIPattern).FindStringSubmatch(raw); len(matches) == 2 { value, _ := strconv.Atoi(matches[1]) priority = strconv.Itoa(value % 8) raw = strings.TrimSpace(strings.TrimPrefix(raw, matches[0])) } logTime, rest, ok := parseFileSystemLogTime(raw) if !ok { return dto.SystemLogItem{}, false } service, message := parseFileSystemLogService(rest) if priority == "6" { priority = detectFileSystemLogPriority(message) } return dto.SystemLogItem{ Timestamp: logTime.UnixMicro(), Time: logTime.Format("2006-01-02 15:04:05"), Priority: priority, Service: service, Message: message, Raw: line, }, true } func parseFileSystemLogTime(value string) (time.Time, string, bool) { if matches := re.GetRegex(re.SyslogRFC3339Pattern).FindStringSubmatch(value); len(matches) == 3 { for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05.999999999Z07:00"} { if parsed, err := time.Parse(layout, matches[1]); err == nil { return parsed.Local(), matches[2], true } } for _, layout := range []string{"2006-01-02T15:04:05.999999999", "2006-01-02 15:04:05.999999999"} { if parsed, err := time.ParseInLocation(layout, matches[1], time.Local); err == nil { return parsed, matches[2], true } } } if matches := re.GetRegex(re.SyslogRFC3164Pattern).FindStringSubmatch(value); len(matches) == 3 { parsed, err := time.ParseInLocation("Jan _2 15:04:05", matches[1], time.Local) if err != nil { return time.Time{}, "", false } now := time.Now() parsed = time.Date(now.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), parsed.Second(), 0, time.Local) if parsed.After(now.Add(24 * time.Hour)) { parsed = parsed.AddDate(-1, 0, 0) } return parsed, matches[2], true } return time.Time{}, "", false } func parseFileSystemLogService(value string) (string, string) { if matches := re.GetRegex(re.SyslogServicePattern).FindStringSubmatch(value); len(matches) == 3 { return matches[1], matches[2] } // RFC5424 records have the form HOST APP-NAME PROCID MSGID STRUCTURED-DATA MSG. fields := strings.Fields(value) if len(fields) >= 6 { return fields[1], strings.Join(fields[5:], " ") } return "", value } func detectFileSystemLogPriority(message string) string { lowerMessage := strings.ToLower(message) for _, item := range syslogPriorityKeywords { if strings.Contains(lowerMessage, item.keyword) { return item.priority } } return "6" } func matchSystemLogItem(item dto.SystemLogItem, req dto.SystemLogReq, startTime, endTime time.Time) bool { itemTime := time.UnixMicro(item.Timestamp) if itemTime.Before(startTime) || itemTime.After(endTime) { return false } if priority := strings.TrimSpace(req.Priority); priority != "" && item.Priority != priority { return false } if service := strings.TrimSpace(req.Service); service != "" && !matchFileSystemLogService(item.Service, service) { return false } if keyword := strings.TrimSpace(req.Keyword); keyword != "" && !strings.Contains(strings.ToLower(item.Raw), strings.ToLower(keyword)) { return false } return true } func matchFileSystemLogService(itemService, requestedService string) bool { itemService = strings.TrimSpace(itemService) requestedService = strings.TrimSpace(requestedService) if itemService == "" || requestedService == "" { return itemService == requestedService } return strings.EqualFold(strings.TrimSuffix(itemService, ".service"), strings.TrimSuffix(requestedService, ".service")) } func (u *LogService) ListRunningServices() ([]string, error) { systemctl, err := exec.LookPath("systemctl") if err != nil { return []string{}, nil } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() args := []string{"list-units", "--type=service", "--state=running", "--no-legend", "--no-pager", "--plain"} output, err := exec.CommandContext(ctx, systemctl, args...).Output() if err != nil { return []string{}, nil } services := make([]string, 0) for _, line := range strings.Split(string(output), "\n") { fields := strings.Fields(line) if len(fields) == 0 || !strings.HasSuffix(fields[0], ".service") { continue } services = append(services, fields[0]) } sort.Strings(services) return services, nil } func parseJournalLogItems(content string) []dto.SystemLogItem { entries := strings.Split(content, "\n") items := make([]dto.SystemLogItem, 0, len(entries)) for _, entry := range entries { var fields map[string]interface{} if err := json.Unmarshal([]byte(entry), &fields); err != nil { continue } items = append(items, dto.SystemLogItem{ Timestamp: journalFieldMicroseconds(fields), Cursor: journalFieldString(fields, "__CURSOR"), Time: formatJournalTimestamp(journalFieldString(fields, "__REALTIME_TIMESTAMP")), Priority: journalFieldString(fields, "PRIORITY"), Service: journalFieldString(fields, "_SYSTEMD_UNIT"), Message: journalFieldString(fields, "MESSAGE"), Raw: entry, }) } return items } func journalFieldMicroseconds(fields map[string]interface{}) int64 { value, _ := strconv.ParseInt(journalFieldString(fields, "__REALTIME_TIMESTAMP"), 10, 64) return value } func journalFieldString(fields map[string]interface{}, key string) string { value, ok := fields[key] if !ok || value == nil { return "" } switch item := value.(type) { case string: return item case float64: return strconv.FormatInt(int64(item), 10) case []interface{}: values := make([]string, 0, len(item)) for _, value := range item { values = append(values, fmt.Sprint(value)) } return strings.Join(values, " ") default: return fmt.Sprint(item) } } func formatJournalTimestamp(value string) string { microseconds, err := strconv.ParseInt(value, 10, 64) if err != nil { return value } return time.Unix(0, microseconds*int64(time.Microsecond)).Local().Format("2006-01-02 15:04:05") } func NewILogService() ILogService { return &LogService{} } func (u *LogService) ListSystemLogFile() ([]string, error) { var listFile []string files, err := os.ReadDir(global.Dir.LogDir) if err != nil { return nil, err } listMap := make(map[string]struct{}) for _, item := range files { if item.IsDir() || !strings.HasPrefix(item.Name(), "1Panel") { continue } if item.Name() == "1Panel.log" || item.Name() == "1Panel-Core.log" { itemName := time.Now().Format("2006-01-02") if _, ok := listMap[itemName]; ok { continue } listMap[itemName] = struct{}{} listFile = append(listFile, itemName) continue } itemFileName := strings.TrimPrefix(item.Name(), "1Panel-Core-") itemFileName = strings.TrimPrefix(itemFileName, "1Panel-") itemFileName = strings.TrimSuffix(itemFileName, ".gz") itemFileName = strings.TrimSuffix(itemFileName, ".log") if len(itemFileName) == 0 { continue } if _, ok := listMap[itemFileName]; ok { continue } listMap[itemFileName] = struct{}{} listFile = append(listFile, itemFileName) } if len(listFile) < 2 { return listFile, nil } sort.Slice(listFile, func(i, j int) bool { return listFile[i] > listFile[j] }) return listFile, nil }