Files

391 lines
8.9 KiB
Go

package sshmonitor
import (
"bufio"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
)
const (
PAMConfigFile = "/etc/pam.d/sshd"
HookBinaryPath = "/usr/local/bin/pika-agent"
HookCommand = "ssh-login-hook"
SSHDConfigFile = "/etc/ssh/sshd_config"
UsePAMDirective = "UsePAM yes"
)
// HookManager PAM Hook 管理器
type HookManager struct{}
// NewHookManager 创建管理器
func NewHookManager() *HookManager {
return &HookManager{}
}
func pamConfigLine() string {
return fmt.Sprintf("session optional pam_exec.so %s %s", HookBinaryPath, HookCommand)
}
func legacyPamConfigLine() string {
return "session optional pam_exec.so /usr/local/bin/pika_ssh_hook.sh"
}
// Install 安装 PAM Hook
func (h *HookManager) Install() error {
// 检查是否为 root 用户
if os.Geteuid() != 0 {
return fmt.Errorf("%w: 安装 PAM Hook 需要 root 权限", os.ErrPermission)
}
// 检查是否已安装(幂等性)
if h.isInstalled() {
slog.Info("PAM Hook 已安装,跳过")
return nil
}
// 确保 SSH 配置启用 PAM
if err := h.ensureUsePAM(); err != nil {
return fmt.Errorf("配置 SSH UsePAM 失败: %w", err)
}
// 安装 Hook 可执行文件(软链接)
if err := h.ensureHookBinary(); err != nil {
return fmt.Errorf("安装 Hook 可执行文件失败: %w", err)
}
// 修改 PAM 配置
if err := h.modifyPAMConfig(true); err != nil {
// 回滚 Hook 可执行文件
_ = h.removeHookBinary()
return fmt.Errorf("修改 PAM 配置失败: %w", err)
}
slog.Info("PAM Hook 安装成功")
return nil
}
// Uninstall 卸载 PAM Hook
func (h *HookManager) Uninstall() error {
// 仅在配置确实存在时改写 PAM 文件,避免未启用过 SSH 监控时产生备份等副作用。
installed, legacyInstalled, err := h.installedConfigState()
if err != nil {
return fmt.Errorf("检查 PAM 配置失败: %w", err)
}
if installed || legacyInstalled {
if err := h.modifyPAMConfig(false); err != nil {
return fmt.Errorf("移除 PAM 配置失败: %w", err)
}
}
if err := h.removeHookBinary(); err != nil {
return fmt.Errorf("移除 PAM Hook 可执行文件失败: %w", err)
}
slog.Info("PAM Hook 卸载成功")
return nil
}
func (h *HookManager) installedConfigState() (installed, legacyInstalled bool, err error) {
f, err := os.Open(PAMConfigFile)
if err != nil {
if os.IsNotExist(err) {
return false, false, nil
}
return false, false, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
switch line {
case pamConfigLine():
installed = true
case legacyPamConfigLine():
legacyInstalled = true
}
}
if err := scanner.Err(); err != nil {
return false, false, err
}
return installed, legacyInstalled, nil
}
// isInstalled 检查是否已安装
func (h *HookManager) isInstalled() bool {
installed, _, _ := h.installedConfigState()
return installed
}
// ensureHookBinary 确保 Hook 可执行文件存在
func (h *HookManager) ensureHookBinary() error {
execPath, err := os.Executable()
if err != nil {
return err
}
if execPath == HookBinaryPath {
return nil
}
if err := os.MkdirAll(filepath.Dir(HookBinaryPath), 0755); err != nil {
return err
}
if info, err := os.Lstat(HookBinaryPath); err == nil {
if info.Mode()&os.ModeSymlink != 0 {
if target, err := os.Readlink(HookBinaryPath); err == nil && target == execPath {
return nil
}
if err := os.Remove(HookBinaryPath); err != nil {
return err
}
} else {
return fmt.Errorf("Hook 路径已存在普通文件,拒绝覆盖: %s", HookBinaryPath)
}
} else if !os.IsNotExist(err) {
return err
}
if err := os.Symlink(execPath, HookBinaryPath); err != nil {
return fmt.Errorf("创建 Hook 软链接失败: %w", err)
}
return nil
}
func (h *HookManager) removeHookBinary() error {
return removeHookSymlink(HookBinaryPath)
}
func removeHookSymlink(path string) error {
info, err := os.Lstat(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return os.Remove(path)
}
// 兼容旧版本:普通文件可能是主程序、旧版复制品或用户文件,无法确认所有权时保留。
return nil
}
// modifyPAMConfig 修改 PAM 配置
// add: true=添加配置, false=移除配置
func (h *HookManager) modifyPAMConfig(add bool) error {
// 读取现有配置
f, err := os.Open(PAMConfigFile)
if err != nil {
return err
}
var lines []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
f.Close()
if err := scanner.Err(); err != nil {
return err
}
// 修改配置
var newLines []string
found := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// 清理旧版本配置行,避免重复
if trimmed == legacyPamConfigLine() {
continue
}
// 跳过我们的配置行(移除模式)或检测是否已存在(添加模式)
if trimmed == pamConfigLine() {
found = true
if add {
newLines = append(newLines, line) // 保留
}
// 移除模式:不添加到 newLines
continue
}
newLines = append(newLines, line)
}
// 如果是添加模式且未找到,则添加到文件末尾
if add && !found {
newLines = append(newLines, pamConfigLine())
}
// 备份原配置
backupPath := PAMConfigFile + ".bak"
if err := os.Rename(PAMConfigFile, backupPath); err != nil {
return err
}
// 写入新配置
outF, err := os.Create(PAMConfigFile)
if err != nil {
// 恢复备份
os.Rename(backupPath, PAMConfigFile)
return err
}
defer outF.Close()
writer := bufio.NewWriter(outF)
for _, line := range newLines {
writer.WriteString(line + "\n")
}
if err := writer.Flush(); err != nil {
// 恢复备份
outF.Close()
os.Rename(backupPath, PAMConfigFile)
return err
}
// 设置正确的权限
os.Chmod(PAMConfigFile, 0644)
slog.Info("PAM 配置修改成功", "add", add)
return nil
}
// ensureUsePAM 确保 SSH 配置启用 PAM
func (h *HookManager) ensureUsePAM() error {
// 读取现有配置
f, err := os.Open(SSHDConfigFile)
if err != nil {
return err
}
var lines []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
f.Close()
if err := scanner.Err(); err != nil {
return err
}
// 检查和修改配置
var newLines []string
usePAMEnabled := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否是 UsePAM 配置行
if strings.HasPrefix(trimmed, "UsePAM") {
// 检查是否已启用
if strings.Contains(trimmed, "yes") && !strings.HasPrefix(trimmed, "#") {
usePAMEnabled = true
newLines = append(newLines, line)
} else {
// 注释掉旧的配置行
if !strings.HasPrefix(trimmed, "#") {
newLines = append(newLines, "# "+line)
} else {
newLines = append(newLines, line)
}
}
continue
}
newLines = append(newLines, line)
}
// 如果 UsePAM 已正确启用,无需修改
if usePAMEnabled {
slog.Info("SSH UsePAM 已启用,跳过")
return nil
}
// 如果找到了 UsePAM 但未启用,或者没找到,都需要添加配置
newLines = append(newLines, UsePAMDirective)
// 备份原配置
backupPath := SSHDConfigFile + ".bak"
if err := os.Rename(SSHDConfigFile, backupPath); err != nil {
return err
}
// 写入新配置
outF, err := os.Create(SSHDConfigFile)
if err != nil {
// 恢复备份
os.Rename(backupPath, SSHDConfigFile)
return err
}
defer outF.Close()
writer := bufio.NewWriter(outF)
for _, line := range newLines {
writer.WriteString(line + "\n")
}
if err := writer.Flush(); err != nil {
// 恢复备份
outF.Close()
os.Rename(backupPath, SSHDConfigFile)
return err
}
// 设置正确的权限
os.Chmod(SSHDConfigFile, 0600)
slog.Info("SSH UsePAM 配置成功")
// 重启 sshd 服务使配置生效
if err := h.restartSSHD(); err != nil {
slog.Warn("重启 sshd 服务失败,请手动重启", "error", err)
// 不返回错误,因为配置已经成功,只是需要手动重启
}
return nil
}
// restartSSHD 重启 sshd 服务
func (h *HookManager) restartSSHD() error {
// 尝试使用 systemctl (systemd)
cmd := exec.Command("systemctl", "restart", "sshd")
if err := cmd.Run(); err == nil {
slog.Info("已通过 systemctl 重启 sshd 服务")
return nil
}
// 尝试 service 命令 (SysV init)
cmd = exec.Command("service", "sshd", "restart")
if err := cmd.Run(); err == nil {
slog.Info("已通过 service 重启 sshd 服务")
return nil
}
// 尝试直接重启 (某些系统可能使用 ssh 而不是 sshd)
cmd = exec.Command("systemctl", "restart", "ssh")
if err := cmd.Run(); err == nil {
slog.Info("已通过 systemctl 重启 ssh 服务")
return nil
}
cmd = exec.Command("service", "ssh", "restart")
if err := cmd.Run(); err == nil {
slog.Info("已通过 service 重启 ssh 服务")
return nil
}
return fmt.Errorf("无法找到合适的方式重启 sshd 服务")
}