1Panel/backend/app/service/ssh.go
巴山夜语 6b379389d3
Some checks failed
SonarCloud Scan / SonarCloud (push) Has been cancelled
WIP: refactor(service): Refactor OpenRC service manager (#8416)
* refactor(service): 重构 OpenRC 服务管理器

- 更新 IsEnabled 和 IsActive 检查逻辑,使用更可靠的命令
- 修复 ServiceExists 检查,直接使用文件路径判断
- 优化 FindServices 函数,扫描 /etc/init.d 目录
- 调整 BuildCommand 函数,支持 OpenRC 特定操作
- 修改 ParseStatus 函数,使用更新后的正则表达式

* feat(backend): 优化 Fail2ban 初始化配置以支持 Alpine 系统

- 增加对 Alpine 系统的特殊配置支持
- 改进防火墙类型检测逻辑,支持多种防火墙服务
- 增加 SSH 端口和认证日志路径的自动检测
- 优化配置文件模板,提高兼容性和安全性

* refactor(backend): 重构 SSH 日志解析功能

- 改进了对不同日志格式的支持,包括 secure, auth 和 messages 文件
- 优化了日志解析逻辑,提高了代码的可读性和可维护性
- 增加了对 RFC3339 时间格式的支持
- 改善了对失败登录尝试的解析,包括无效用户和连接关闭的情况
- 重构了日期解析和 IP 地址验证的逻辑

* refactor(upgrade): 优化升级服务中的初始化脚本选择逻辑

- 新增 selectInitScript 函数,根据系统初始化管理器类型选择合适的初始化脚本
- 支持 systemd、openrc 和 sysvinit 三种初始化管理器
- 对于 sysvinit,增加对 /etc/rc.common 文件存在性的判断,以区分不同的初始化脚本
- 默认情况下使用当前服务名称作为初始化脚本名称

* fix(upgrade): 修复升级时初始化脚本更新问题

- 修改了 criticalUpdates 数组中的服务脚本更新逻辑
- 在 selectInitScript 函数中增加了复制脚本文件的逻辑,以应对服务名和脚本名不一致的情况

* feat(snap): 添加初始化脚本到快照

- 在创建快照时,将服务脚本复制到 initscript 目录
- 然后将整个 initscript 目录复制到快照的目标目录
- 添加了日志输出,便于调试和记录

* refactor(backend): 重构快照恢复流程

- 移除了未使用的 import 语句
- 删除了注释掉的代码块
- 修改了 1Panel 服务恢复的逻辑,增加了对当前服务名称的获取
- 快照恢复过程中,根据宿主机类型,自动选择初始化服务脚本
- 添加了日志输出以提高可追踪性
- 优化了文件路径的处理方式

---------

Co-authored-by: gcsong023 <gcsong023@users.noreply.github.com>
2025-04-23 17:04:08 +08:00

632 lines
18 KiB
Go

package service
import (
"fmt"
"net"
"os"
"os/user"
"path"
"path/filepath"
"sort"
"strings"
"time"
"github.com/1Panel-dev/1Panel/backend/utils/geo"
"github.com/gin-gonic/gin"
"github.com/1Panel-dev/1Panel/backend/app/dto"
"github.com/1Panel-dev/1Panel/backend/buserr"
"github.com/1Panel-dev/1Panel/backend/constant"
"github.com/1Panel-dev/1Panel/backend/global"
"github.com/1Panel-dev/1Panel/backend/utils/cmd"
"github.com/1Panel-dev/1Panel/backend/utils/common"
"github.com/1Panel-dev/1Panel/backend/utils/files"
"github.com/1Panel-dev/1Panel/backend/utils/systemctl"
"github.com/pkg/errors"
)
const sshPath = "/etc/ssh/sshd_config"
type SSHService struct{}
type ISSHService interface {
GetSSHInfo() (*dto.SSHInfo, error)
OperateSSH(operation string) error
UpdateByFile(value string) error
Update(req dto.SSHUpdate) error
GenerateSSH(req dto.GenerateSSH) error
LoadSSHSecret(mode string) (string, error)
LoadLog(c *gin.Context, req dto.SearchSSHLog) (*dto.SSHLog, error)
LoadSSHConf() (string, error)
}
func NewISSHService() ISSHService {
return &SSHService{}
}
func (u *SSHService) GetSSHInfo() (*dto.SSHInfo, error) {
data := dto.SSHInfo{
AutoStart: true,
Status: constant.StatusEnable,
Message: "",
Port: "22",
ListenAddress: "",
PasswordAuthentication: "yes",
PubkeyAuthentication: "yes",
PermitRootLogin: "yes",
UseDNS: "yes",
}
serviceName, err := loadServiceName()
if err != nil {
data.Status = constant.StatusDisable
data.Message = err.Error()
}
isEnabled, _ := systemctl.IsEnable(serviceName)
data.AutoStart = isEnabled
isActive, err := systemctl.IsActive(serviceName)
if isActive {
data.Status = constant.StatusEnable
} else {
data.Status = constant.StatusDisable
if err != nil {
data.Message = err.Error()
}
}
sshConf, err := os.ReadFile(sshPath)
if err != nil {
data.Message = err.Error()
data.Status = constant.StatusDisable
}
lines := strings.Split(string(sshConf), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "Port ") {
data.Port = strings.ReplaceAll(line, "Port ", "")
}
if strings.HasPrefix(line, "ListenAddress ") {
itemAddr := strings.ReplaceAll(line, "ListenAddress ", "")
if len(data.ListenAddress) != 0 {
data.ListenAddress += ("," + itemAddr)
} else {
data.ListenAddress = itemAddr
}
}
if strings.HasPrefix(line, "PasswordAuthentication ") {
data.PasswordAuthentication = strings.ReplaceAll(line, "PasswordAuthentication ", "")
}
if strings.HasPrefix(line, "PubkeyAuthentication ") {
data.PubkeyAuthentication = strings.ReplaceAll(line, "PubkeyAuthentication ", "")
}
if strings.HasPrefix(line, "PermitRootLogin ") {
data.PermitRootLogin = strings.ReplaceAll(strings.ReplaceAll(line, "PermitRootLogin ", ""), "prohibit-password", "without-password")
}
if strings.HasPrefix(line, "UseDNS ") {
data.UseDNS = strings.ReplaceAll(line, "UseDNS ", "")
}
}
return &data, nil
}
func (u *SSHService) OperateSSH(operation string) error {
serviceName, err := loadServiceName()
if err != nil {
return err
}
switch operation {
case "enable":
return systemctl.Enable(serviceName)
case "disable":
return systemctl.Disable(serviceName)
case "start":
return systemctl.Start(serviceName)
case "stop":
isSocketActive, _ := systemctl.IsActive(serviceName + ".socket")
if isSocketActive {
if err := systemctl.Stop(serviceName + ".socket"); err != nil {
global.LOG.Errorf("Failed to stop %s.socket: %v", serviceName, err)
}
}
return systemctl.Stop(serviceName)
case "restart":
return systemctl.Restart(serviceName)
}
if result, err := systemctl.CustomAction(operation, serviceName); err != nil {
return fmt.Errorf("failed to execute custom action: %v", result.Output)
}
return nil
}
func (u *SSHService) Update(req dto.SSHUpdate) error {
serviceName, err := loadServiceName()
if err != nil {
return err
}
sshConf, err := os.ReadFile(sshPath)
if err != nil {
return err
}
lines := strings.Split(string(sshConf), "\n")
newFiles := updateSSHConf(lines, req.Key, req.NewValue)
file, err := os.OpenFile(sshPath, os.O_WRONLY|os.O_TRUNC, 0666)
if err != nil {
return err
}
defer file.Close()
if _, err = file.WriteString(strings.Join(newFiles, "\n")); err != nil {
return err
}
sudo := cmd.SudoHandleCmd()
if req.Key == "Port" {
stdout, _ := cmd.Execf("%s getenforce", sudo)
if stdout == "Enforcing\n" {
_, _ = cmd.Execf("%s semanage port -a -t ssh_port_t -p tcp %s", sudo, req.NewValue)
}
ruleItem := dto.PortRuleUpdate{
OldRule: dto.PortRuleOperate{
Operation: "remove",
Port: req.OldValue,
Protocol: "tcp",
Strategy: "accept",
},
NewRule: dto.PortRuleOperate{
Operation: "add",
Port: req.NewValue,
Protocol: "tcp",
Strategy: "accept",
},
}
if err := NewIFirewallService().UpdatePortRule(ruleItem); err != nil {
global.LOG.Errorf("reset firewall rules %s -> %s failed, err: %v", req.OldValue, req.NewValue, err)
}
if err = NewIHostService().Update(1, map[string]interface{}{"port": req.NewValue}); err != nil {
global.LOG.Errorf("reset host port %s -> %s failed, err: %v", req.OldValue, req.NewValue, err)
}
}
err = systemctl.Restart(serviceName)
if err != nil {
global.LOG.Errorf("handle restart %s failed, err: %v", serviceName, err)
}
return nil
}
func (u *SSHService) UpdateByFile(value string) error {
serviceName, err := loadServiceName()
if err != nil {
return err
}
file, err := os.OpenFile(sshPath, os.O_WRONLY|os.O_TRUNC, 0666)
if err != nil {
return err
}
defer file.Close()
if _, err = file.WriteString(value); err != nil {
return err
}
err = systemctl.Restart(serviceName)
if err != nil {
global.LOG.Errorf("handle restart %s failed, err: %v", serviceName, err)
}
return nil
}
func (u *SSHService) GenerateSSH(req dto.GenerateSSH) error {
if cmd.CheckIllegal(req.EncryptionMode, req.Password) {
return buserr.New(constant.ErrCmdIllegal)
}
currentUser, err := user.Current()
if err != nil {
return fmt.Errorf("load current user failed, err: %v", err)
}
secretFile := fmt.Sprintf("%s/.ssh/id_item_%s", currentUser.HomeDir, req.EncryptionMode)
secretPubFile := fmt.Sprintf("%s/.ssh/id_item_%s.pub", currentUser.HomeDir, req.EncryptionMode)
authFilePath := currentUser.HomeDir + "/.ssh/authorized_keys"
command := fmt.Sprintf("ssh-keygen -t %s -f %s/.ssh/id_item_%s | echo y", req.EncryptionMode, currentUser.HomeDir, req.EncryptionMode)
if len(req.Password) != 0 {
command = fmt.Sprintf("ssh-keygen -t %s -P %s -f %s/.ssh/id_item_%s | echo y", req.EncryptionMode, req.Password, currentUser.HomeDir, req.EncryptionMode)
}
stdout, err := cmd.Exec(command)
if err != nil {
return fmt.Errorf("generate failed, err: %v, message: %s", err, stdout)
}
defer func() {
_ = os.Remove(secretFile)
}()
defer func() {
_ = os.Remove(secretPubFile)
}()
if _, err := os.Stat(authFilePath); err != nil && errors.Is(err, os.ErrNotExist) {
authFile, err := os.Create(authFilePath)
if err != nil {
return err
}
defer authFile.Close()
}
stdout1, err := cmd.Execf("cat %s >> %s/.ssh/authorized_keys", secretPubFile, currentUser.HomeDir)
if err != nil {
return fmt.Errorf("generate failed, err: %v, message: %s", err, stdout1)
}
fileOp := files.NewFileOp()
if err := fileOp.Rename(secretFile, fmt.Sprintf("%s/.ssh/id_%s", currentUser.HomeDir, req.EncryptionMode)); err != nil {
return err
}
if err := fileOp.Rename(secretPubFile, fmt.Sprintf("%s/.ssh/id_%s.pub", currentUser.HomeDir, req.EncryptionMode)); err != nil {
return err
}
return nil
}
func (u *SSHService) LoadSSHSecret(mode string) (string, error) {
currentUser, err := user.Current()
if err != nil {
return "", fmt.Errorf("load current user failed, err: %v", err)
}
homeDir := currentUser.HomeDir
if _, err := os.Stat(fmt.Sprintf("%s/.ssh/id_%s", homeDir, mode)); err != nil {
return "", nil
}
file, err := os.ReadFile(fmt.Sprintf("%s/.ssh/id_%s", homeDir, mode))
return string(file), err
}
type sshFileItem struct {
Name string
Year int
}
func (u *SSHService) LoadLog(c *gin.Context, req dto.SearchSSHLog) (*dto.SSHLog, error) {
var fileList []sshFileItem
var data dto.SSHLog
baseDir := "/var/log"
if err := filepath.Walk(baseDir, func(pathItem string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && (strings.HasPrefix(info.Name(), "secure") || strings.HasPrefix(info.Name(), "auth") || strings.HasPrefix(info.Name(), "messages")) {
if !strings.HasSuffix(info.Name(), ".gz") {
fileList = append(fileList, sshFileItem{Name: pathItem, Year: info.ModTime().Year()})
return nil
}
itemFileName := strings.TrimSuffix(pathItem, ".gz")
if _, err := os.Stat(itemFileName); err != nil && os.IsNotExist(err) {
if err := handleGunzip(pathItem); err == nil {
fileList = append(fileList, sshFileItem{Name: itemFileName, Year: info.ModTime().Year()})
}
}
}
return nil
}); err != nil {
return nil, err
}
fileList = sortFileList(fileList)
command := ""
if len(req.Info) != 0 {
command = fmt.Sprintf(" | grep '%s'", req.Info)
}
showCountFrom := (req.Page - 1) * req.PageSize
showCountTo := req.Page * req.PageSize
nyc, _ := time.LoadLocation(common.LoadTimeZoneByCmd())
for _, file := range fileList {
commandItem := ""
switch {
case strings.HasPrefix(path.Base(file.Name), "secure"):
switch req.Status {
case constant.StatusSuccess:
commandItem = fmt.Sprintf("cat %s | grep -a Accepted %s", file.Name, command)
case constant.StatusFailed:
commandItem = fmt.Sprintf("cat %s | grep -a 'Failed password for' %s", file.Name, command)
default:
commandItem = fmt.Sprintf("cat %s | grep -aE '(Failed password for|Accepted)' %s", file.Name, command)
}
case strings.HasPrefix(path.Base(file.Name), "messages"):
switch req.Status {
case constant.StatusSuccess:
commandItem = fmt.Sprintf("cat %s | grep -aE 'sshd.*Accepted (password|publickey)' %s", file.Name, command)
case constant.StatusFailed:
commandItem = fmt.Sprintf("cat %s | grep -aE 'sshd.*(Failed password for|Connection closed by authenticating user)' %s", file.Name, command)
default:
commandItem = fmt.Sprintf("cat %s | grep -aE 'sshd.*(Accepted|Failed password for|Connection closed)' %s", file.Name, command)
}
case strings.HasPrefix(path.Base(file.Name), "auth.log"):
switch req.Status {
case constant.StatusSuccess:
commandItem = fmt.Sprintf("cat %s | grep -a Accepted %s", file.Name, command)
case constant.StatusFailed:
commandItem = fmt.Sprintf("cat %s | grep -aE 'Failed password for|Connection closed by authenticating user' %s", file.Name, command)
default:
commandItem = fmt.Sprintf("cat %s | grep -aE \"(Failed password for|Connection closed by authenticating user|Accepted)\" %s", file.Name, command)
}
}
dataItem, successCount, failedCount := loadSSHData(c, commandItem, showCountFrom, showCountTo, file.Year, nyc)
data.FailedCount += failedCount
data.TotalCount += successCount + failedCount
showCountFrom = showCountFrom - (successCount + failedCount)
showCountTo = showCountTo - (successCount + failedCount)
data.Logs = append(data.Logs, dataItem...)
}
data.SuccessfulCount = data.TotalCount - data.FailedCount
return &data, nil
}
func (u *SSHService) LoadSSHConf() (string, error) {
if _, err := os.Stat("/etc/ssh/sshd_config"); err != nil {
return "", buserr.New("ErrHttpReqNotFound")
}
content, err := os.ReadFile("/etc/ssh/sshd_config")
if err != nil {
return "", err
}
return string(content), nil
}
func sortFileList(fileNames []sshFileItem) []sshFileItem {
if len(fileNames) < 2 {
return fileNames
}
if strings.HasPrefix(path.Base(fileNames[0].Name), "secure") {
var itemFile []sshFileItem
sort.Slice(fileNames, func(i, j int) bool {
return fileNames[i].Name > fileNames[j].Name
})
itemFile = append(itemFile, fileNames[len(fileNames)-1])
itemFile = append(itemFile, fileNames[:len(fileNames)-1]...)
return itemFile
}
sort.Slice(fileNames, func(i, j int) bool {
return fileNames[i].Name < fileNames[j].Name
})
return fileNames
}
func updateSSHConf(oldFiles []string, param string, value string) []string {
var valueItems []string
if param != "ListenAddress" {
valueItems = append(valueItems, value)
} else {
if value != "" {
valueItems = strings.Split(value, ",")
}
}
var newFiles []string
for _, line := range oldFiles {
lineItem := strings.TrimSpace(line)
if (strings.HasPrefix(lineItem, param) || strings.HasPrefix(lineItem, fmt.Sprintf("#%s", param))) && len(valueItems) != 0 {
newFiles = append(newFiles, fmt.Sprintf("%s %s", param, valueItems[0]))
valueItems = valueItems[1:]
continue
}
if strings.HasPrefix(lineItem, param) && len(valueItems) == 0 {
newFiles = append(newFiles, fmt.Sprintf("#%s", line))
continue
}
newFiles = append(newFiles, line)
}
if len(valueItems) != 0 {
for _, item := range valueItems {
newFiles = append(newFiles, fmt.Sprintf("%s %s", param, item))
}
}
return newFiles
}
func loadSSHData(c *gin.Context, command string, showCountFrom, showCountTo, currentYear int, nyc *time.Location) ([]dto.SSHHistory, int, int) {
var (
datas []dto.SSHHistory
successCount int
failedCount int
)
stdout2, err := cmd.Exec(command)
if err != nil {
return datas, 0, 0
}
lines := strings.Split(string(stdout2), "\n")
for i := len(lines) - 1; i >= 0; i-- {
var itemData dto.SSHHistory
line := strings.TrimSpace(lines[i])
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) < 12 {
continue
}
// 统一时间解析逻辑
var dateStr string
var timeIndex int
if strings.Contains(parts[0], "-") { // 处理RFC3339时间格式
t, err := time.Parse(time.RFC3339Nano, parts[0])
if err == nil {
dateStr = t.Format("2006 Jan 2 15:04:05")
timeIndex = 0
}
} else { // 处理系统日志格式
dateParts := parts[:3]
if len(dateParts) < 3 {
continue
}
dateStr = strings.Join(dateParts, " ")
timeIndex = 3
}
// 根据日志类型解析内容
switch {
case strings.Contains(line, "Failed password for"):
itemData = parseFailedPasswordLog(parts, timeIndex, dateStr)
case strings.Contains(line, "Connection closed by authenticating user"):
itemData = parseConnectionClosedLog(parts, timeIndex, dateStr)
case strings.Contains(line, "Accepted"):
itemData = parseAcceptedLog(parts, timeIndex, dateStr)
default:
continue
}
if itemData.Address == "" {
continue
}
total := successCount + failedCount
if total >= showCountFrom && total < showCountTo {
itemData.Area, _ = geo.GetIPLocation(itemData.Address, common.GetLang(c))
itemData.Date = parseLogDate(currentYear, dateStr, nyc)
datas = append(datas, itemData)
}
if itemData.Status == constant.StatusSuccess {
successCount++
} else {
failedCount++
}
if total >= showCountTo {
break
}
}
return datas, successCount, failedCount
}
func parseFailedPasswordLog(parts []string, timeIndex int, dateStr string) dto.SSHHistory {
data := dto.SSHHistory{
DateStr: dateStr,
Status: constant.StatusFailed,
AuthMode: "publickey",
}
// 查找关键字段位置
for i := timeIndex; i < len(parts); i++ {
switch parts[i] {
case "for":
if i+1 < len(parts) {
data.User = parts[i+1]
}
case "from":
if i+1 < len(parts) {
data.Address = parts[i+1]
}
case "port":
if i+1 < len(parts) {
data.Port = parts[i+1]
}
case "password":
data.AuthMode = "password"
}
}
if strings.Contains(strings.Join(parts, " "), "invalid user") {
data.Message = "invalid user attempt"
}
return data
}
func parseConnectionClosedLog(parts []string, timeIndex int, dateStr string) dto.SSHHistory {
data := dto.SSHHistory{
DateStr: dateStr,
Status: constant.StatusFailed,
}
// 解析Alpine格式的特殊字段
fieldStart := timeIndex + 5 // 跳过时间、主机、进程字段
for i := fieldStart; i < len(parts); i++ {
switch {
case parts[i] == "user":
if i+1 < len(parts) {
data.User = parts[i+1]
}
case parts[i] == "port":
if i+1 < len(parts) {
data.Port = parts[i+1]
}
case isIPAddress(parts[i]):
data.Address = parts[i]
}
}
return data
}
func parseAcceptedLog(parts []string, timeIndex int, dateStr string) dto.SSHHistory {
data := dto.SSHHistory{
DateStr: dateStr,
Status: constant.StatusSuccess,
AuthMode: "password", // 默认值
}
// 处理不同日志格式
fieldStart := timeIndex + 5 // 基础字段偏移
for i := fieldStart; i < len(parts); i++ {
switch {
case parts[i] == "for":
if i+1 < len(parts) {
data.User = parts[i+1]
}
case parts[i] == "from":
if i+1 < len(parts) {
data.Address = parts[i+1]
}
case parts[i] == "port":
if i+1 < len(parts) {
data.Port = parts[i+1]
}
case strings.Contains(parts[i], "ssh2:"):
data.AuthMode = "publickey"
case parts[i] == "publickey":
data.AuthMode = "publickey"
case parts[i] == "password":
data.AuthMode = "password"
}
}
return data
}
func handleGunzip(path string) error {
if _, err := cmd.Execf("gunzip %s", path); err != nil {
return err
}
return nil
}
func loadServiceName() (string, error) {
serviceName, err := systemctl.GetServiceName("ssh")
if err != nil {
if errors.Is(err, systemctl.ErrServiceNotFound) {
return "", fmt.Errorf("SSH service unavailable. Please ensure OpenSSH server is installed and configured")
}
return "", fmt.Errorf("failed to load SSH service name: %w", err)
}
return serviceName, nil
}
func parseLogDate(currentYear int, dateStr string, loc *time.Location) time.Time {
// 处理带年份和不带年份的情况
formats := []string{
"2006 Jan 2 15:04:05",
"Jan 2 15:04:05",
}
for _, format := range formats {
t, err := time.ParseInLocation(format, dateStr, loc)
if err == nil {
if t.Year() == 0 {
return time.Date(currentYear, t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, loc)
}
return t
}
}
return time.Now().In(loc)
}
func isIPAddress(s string) bool {
return net.ParseIP(s) != nil
}