feat: Support export for ssh logs (#9889)

Refs #3496
This commit is contained in:
ssongliu 2025-08-07 21:40:39 +08:00 committed by GitHub
parent 4e6f872c02
commit 5a8ddde495
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 211 additions and 53 deletions

View file

@ -2,6 +2,10 @@ package v2
import (
"encoding/base64"
"net/http"
"net/url"
"os"
"strconv"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
@ -201,7 +205,7 @@ func (b *BaseApi) DeleteRootCert(c *gin.Context) {
// @Summary Load host SSH logs
// @Accept json
// @Param request body dto.SearchSSHLog true "request"
// @Success 200 {object} dto.SSHLog
// @Success 200 {object} dto.PageResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/ssh/log [post]
@ -211,12 +215,49 @@ func (b *BaseApi) LoadSSHLogs(c *gin.Context) {
return
}
data, err := sshService.LoadLog(c, req)
total, data, err := sshService.LoadLog(c, req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
helper.SuccessWithData(c, dto.PageResult{
Total: total,
Items: data,
})
}
// @Tags SSH
// @Summary Export host SSH logs
// @Accept json
// @Param request body dto.SearchSSHLog true "request"
// @Success 200 {object} dto.PageResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/ssh/log/export [post]
func (b *BaseApi) ExportSSHLogs(c *gin.Context) {
var req dto.SearchSSHLog
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
tmpFile, err := sshService.ExportLog(c, req)
if err != nil {
helper.InternalServer(c, err)
return
}
file, err := os.Open(tmpFile)
if err != nil {
helper.InternalServer(c, err)
return
}
defer func() {
_ = file.Close()
_ = os.RemoveAll(tmpFile)
}()
info, _ := file.Stat()
c.Header("Content-Length", strconv.FormatInt(info.Size(), 10))
c.Header("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(info.Name()))
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), file)
}
// @Tags SSH

View file

@ -54,12 +54,6 @@ type SearchSSHLog struct {
Info string `json:"info"`
Status string `json:"Status" validate:"required,oneof=Success Failed All"`
}
type SSHLog struct {
Logs []SSHHistory `json:"logs"`
TotalCount int `json:"totalCount"`
SuccessfulCount int `json:"successfulCount"`
FailedCount int `json:"failedCount"`
}
type SSHHistory struct {
Date time.Time `json:"date"`

View file

@ -13,6 +13,7 @@ import (
"time"
"github.com/1Panel-dev/1Panel/agent/utils/copier"
csvexport "github.com/1Panel-dev/1Panel/agent/utils/csv_export"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"github.com/1Panel-dev/1Panel/agent/utils/geo"
"github.com/gin-gonic/gin"
@ -38,9 +39,11 @@ type ISSHService interface {
OperateSSH(operation string) error
UpdateByFile(value string) error
Update(req dto.SSHUpdate) error
LoadLog(ctx *gin.Context, req dto.SearchSSHLog) (*dto.SSHLog, error)
LoadSSHConf() (string, error)
LoadLog(ctx *gin.Context, req dto.SearchSSHLog) (int64, []dto.SSHHistory, error)
ExportLog(ctx *gin.Context, req dto.SearchSSHLog) (string, error)
SyncRootCert() error
CreateRootCert(req dto.CreateRootCert) error
SearchRootCerts(req dto.SearchWithPage) (int64, interface{}, error)
@ -392,13 +395,13 @@ type sshFileItem struct {
Year int
}
func (u *SSHService) LoadLog(ctx *gin.Context, req dto.SearchSSHLog) (*dto.SSHLog, error) {
func (u *SSHService) LoadLog(ctx *gin.Context, req dto.SearchSSHLog) (int64, []dto.SSHHistory, error) {
var fileList []sshFileItem
var data dto.SSHLog
var data []dto.SSHHistory
baseDir := "/var/log"
fileItems, err := os.ReadDir(baseDir)
if err != nil {
return &data, err
return 0, data, err
}
for _, item := range fileItems {
if item.IsDir() || (!strings.HasPrefix(item.Name(), "secure") && !strings.HasPrefix(item.Name(), "auth")) {
@ -427,6 +430,7 @@ func (u *SSHService) LoadLog(ctx *gin.Context, req dto.SearchSSHLog) (*dto.SSHLo
showCountFrom := (req.Page - 1) * req.PageSize
showCountTo := req.Page * req.PageSize
nyc, _ := time.LoadLocation(common.LoadTimeZoneByCmd())
itemFailed, itemTotal := 0, 0
for _, file := range fileList {
commandItem := ""
if strings.HasPrefix(path.Base(file.Name), "secure") {
@ -450,15 +454,38 @@ func (u *SSHService) LoadLog(ctx *gin.Context, req dto.SearchSSHLog) (*dto.SSHLo
}
}
dataItem, successCount, failedCount := loadSSHData(ctx, commandItem, showCountFrom, showCountTo, file.Year, nyc)
data.FailedCount += failedCount
data.TotalCount += successCount + failedCount
itemFailed += failedCount
itemTotal += successCount + failedCount
showCountFrom = showCountFrom - (successCount + failedCount)
showCountTo = showCountTo - (successCount + failedCount)
data.Logs = append(data.Logs, dataItem...)
if showCountTo != -1 {
showCountTo = showCountTo - (successCount + failedCount)
}
data = append(data, dataItem...)
}
data.SuccessfulCount = data.TotalCount - data.FailedCount
return &data, nil
total := itemTotal
if req.Status == constant.StatusFailed {
total = itemFailed
}
if req.Status == constant.StatusSuccess {
total = itemTotal - itemFailed
}
return int64(total), data, nil
}
func (u *SSHService) ExportLog(ctx *gin.Context, req dto.SearchSSHLog) (string, error) {
_, logs, err := u.LoadLog(ctx, req)
if err != nil {
return "", err
}
tmpFileName := path.Join(global.Dir.TmpDir, "export/ssh-log", fmt.Sprintf("1panel-ssh-log-%s.csv", time.Now().Format(constant.DateTimeSlimLayout)))
if _, err := os.Stat(path.Dir(tmpFileName)); err != nil {
_ = os.MkdirAll(path.Dir(tmpFileName), constant.DirPerm)
}
if err := csvexport.ExportSSHLogs(tmpFileName, logs); err != nil {
return "", err
}
return tmpFileName, nil
}
func (u *SSHService) LoadSSHConf() (string, error) {
@ -543,7 +570,7 @@ func loadSSHData(ctx *gin.Context, command string, showCountFrom, showCountTo, c
case strings.Contains(lines[i], "Failed password for"):
itemData = loadFailedSecureDatas(lines[i])
if checkIsStandard(itemData) {
if successCount+failedCount >= showCountFrom && successCount+failedCount < showCountTo {
if successCount+failedCount >= showCountFrom && (showCountTo == -1 || successCount+failedCount < showCountTo) {
itemData.Area, _ = geo.GetIPLocation(getLoc, itemData.Address, common.GetLang(ctx))
itemData.Date = loadDate(currentYear, itemData.DateStr, nyc)
datas = append(datas, itemData)
@ -553,7 +580,7 @@ func loadSSHData(ctx *gin.Context, command string, showCountFrom, showCountTo, c
case strings.Contains(lines[i], "Connection closed by authenticating user"):
itemData = loadFailedAuthDatas(lines[i])
if checkIsStandard(itemData) {
if successCount+failedCount >= showCountFrom && successCount+failedCount < showCountTo {
if successCount+failedCount >= showCountFrom && (showCountTo == -1 || successCount+failedCount < showCountTo) {
itemData.Area, _ = geo.GetIPLocation(getLoc, itemData.Address, common.GetLang(ctx))
itemData.Date = loadDate(currentYear, itemData.DateStr, nyc)
datas = append(datas, itemData)
@ -563,7 +590,7 @@ func loadSSHData(ctx *gin.Context, command string, showCountFrom, showCountTo, c
case strings.Contains(lines[i], "Accepted "):
itemData = loadSuccessDatas(lines[i])
if checkIsStandard(itemData) {
if successCount+failedCount >= showCountFrom && successCount+failedCount < showCountTo {
if successCount+failedCount >= showCountFrom && (showCountTo == -1 || successCount+failedCount < showCountTo) {
itemData.Area, _ = geo.GetIPLocation(getLoc, itemData.Address, common.GetLang(ctx))
itemData.Date = loadDate(currentYear, itemData.DateStr, nyc)
datas = append(datas, itemData)

View file

@ -33,6 +33,7 @@ func (s *HostRouter) InitRouter(Router *gin.RouterGroup) {
hostRouter.POST("/ssh/search", baseApi.GetSSHInfo)
hostRouter.POST("/ssh/update", baseApi.UpdateSSH)
hostRouter.POST("/ssh/log", baseApi.LoadSSHLogs)
hostRouter.POST("/ssh/log/export", baseApi.ExportSSHLogs)
hostRouter.POST("/ssh/conffile/update", baseApi.UpdateSSHByfile)
hostRouter.POST("/ssh/operate", baseApi.OperateSSH)

View file

@ -0,0 +1,41 @@
package csvexport
import (
"encoding/csv"
"os"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/constant"
)
func ExportSSHLogs(filename string, logs []dto.SSHHistory) error {
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
writer := csv.NewWriter(file)
defer writer.Flush()
if err := writer.Write([]string{"IP", "Area", "Port", "AuthMode", "User", "Status", "Date"}); err != nil {
return err
}
for _, log := range logs {
record := []string{
log.Address,
log.Area,
log.Port,
log.AuthMode,
log.User,
log.Status,
log.Date.Format(constant.DateTimeLayout),
}
if err := writer.Write(record); err != nil {
return err
}
}
return nil
}

View file

@ -194,24 +194,6 @@ export namespace Host {
export interface analysisSSHLog extends ReqPage {
orderBy: string;
}
export interface logAnalysisRes {
total: number;
items: Array<logAnalysis>;
successfulCount: number;
failedCount: number;
}
export interface sshLog {
logs: Array<sshHistory>;
successfulCount: number;
failedCount: number;
}
export interface logAnalysis {
address: string;
area: string;
successfulCount: number;
failedCount: number;
status: string;
}
export interface sshHistory {
date: Date;
area: string;

View file

@ -96,5 +96,11 @@ export const syncCert = () => {
return http.post(`/hosts/ssh/cert/sync`);
};
export const loadSSHLogs = (params: Host.searchSSHLog) => {
return http.post<Host.sshLog>(`/hosts/ssh/log`, params);
return http.post<ResPage<Host.sshHistory>>(`/hosts/ssh/log`, params);
};
export const exportSSHLogs = (params: Host.searchSSHLog) => {
return http.download<BlobPart>('/hosts/ssh/log/export', params, {
responseType: 'blob',
timeout: TimeoutEnum.T_40S,
});
};

View file

@ -5,6 +5,11 @@
<el-alert type="info" :title="$t('ssh.sshAlert2')" :closable="false" />
<div class="mt-2"><el-alert type="info" :title="$t('ssh.sshAlert')" :closable="false" /></div>
</template>
<template #leftToolBar>
<el-button type="primary" @click="onExport">
{{ $t('commons.button.export') }}
</el-button>
</template>
<template #rightToolBar>
<el-select v-model="searchStatus" @change="search()" class="p-w-200">
<template #prefix>{{ $t('commons.table.status') }}</template>
@ -49,13 +54,46 @@
</ComplexTable>
</template>
</LayoutContent>
<DialogPro v-model="open" :title="$t('commons.button.export')" size="mini">
<el-form class="mt-5" ref="backupForm" @submit.prevent v-loading="loading">
<el-form-item :label="$t('commons.table.status')">
<el-select v-model="exportConfig.status" class="w-full">
<el-option :label="$t('commons.table.all')" value="All"></el-option>
<el-option :label="$t('commons.status.success')" value="Success"></el-option>
<el-option :label="$t('commons.status.failed')" value="Failed"></el-option>
</el-select>
</el-form-item>
<el-form-item :label="$t('container.lines')">
<el-select class="tailClass" v-model.number="exportConfig.count">
<el-option :value="-1" :label="$t('commons.table.all')" />
<el-option :value="100" :label="100" />
<el-option :value="200" :label="200" />
<el-option :value="500" :label="500" />
<el-option :value="1000" :label="1000" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="open = false" :disabled="loading">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button type="primary" @click="onSubmitExport" :disabled="loading">
{{ $t('commons.button.confirm') }}
</el-button>
</span>
</template>
</DialogPro>
</div>
</template>
<script setup lang="ts">
import { dateFormat } from '@/utils/util';
import { dateFormat, getDateStr } from '@/utils/util';
import { onMounted, reactive, ref } from 'vue';
import { loadSSHLogs } from '@/api/modules/host';
import { exportSSHLogs, loadSSHLogs } from '@/api/modules/host';
import { MsgSuccess } from '@/utils/message';
import i18n from '@/lang';
const loading = ref();
const data = ref();
@ -65,6 +103,12 @@ const paginationConfig = reactive({
pageSize: 10,
total: 0,
});
const open = ref();
const exportConfig = reactive({
count: 100,
status: 'All',
});
const searchInfo = ref();
const searchStatus = ref('All');
@ -79,16 +123,38 @@ const search = async () => {
await loadSSHLogs(params)
.then((res) => {
loading.value = false;
data.value = res.data?.logs || [];
if (searchStatus.value === 'Success') {
paginationConfig.total = res.data.successfulCount;
}
if (searchStatus.value === 'Failed') {
paginationConfig.total = res.data.failedCount;
}
if (searchStatus.value === 'All') {
paginationConfig.total = res.data.failedCount + res.data.successfulCount;
}
data.value = res.data.items || [];
paginationConfig.total = res.data.total;
})
.catch(() => {
loading.value = false;
});
};
const onExport = async () => {
open.value = true;
exportConfig.status = 'All';
exportConfig.count = -1;
};
const onSubmitExport = async () => {
let params = {
info: '',
status: exportConfig.status,
page: 1,
pageSize: exportConfig.count,
};
await exportSSHLogs(params)
.then((res) => {
const downloadUrl = window.URL.createObjectURL(new Blob([res]));
const a = document.createElement('a');
a.style.display = 'none';
a.href = downloadUrl;
a.download = '1panel-ssh-log-' + getDateStr() + '.csv';
const event = new MouseEvent('click');
a.dispatchEvent(event);
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
open.value = false;
})
.catch(() => {
loading.value = false;