diff --git a/backend/cmd/seed.go b/backend/cmd/seed.go index 52390de..1334ce6 100644 --- a/backend/cmd/seed.go +++ b/backend/cmd/seed.go @@ -220,4 +220,406 @@ func seedData(db *gorm.DB) { } } } + + // 6. 创建默认 Ansible Playbook 模板(15 个高频运维场景)// Logan.Li + var pbCount int64 + db.Model(&models.AnsiblePlaybook{}).Count(&pbCount) + if pbCount == 0 { + playbooks := []models.AnsiblePlaybook{ + {Name: "系统信息收集", Description: "收集主机基本信息:OS、内核、CPU、内存、磁盘、网络接口", Category: "maintenance", Tags: `["系统信息","诊断","基础"]`, Content: `--- +- name: 系统信息收集 + hosts: all + gather_facts: yes + tasks: + - name: 系统基本信息 + debug: + msg: + - "主机: {{ ansible_hostname }}" + - "OS: {{ ansible_distribution }} {{ ansible_distribution_version }}" + - "内核: {{ ansible_kernel }} ({{ ansible_architecture }})" + - "CPU: {{ ansible_processor_vcpus }} 核" + - "内存: {{ (ansible_memtotal_mb / 1024) | round(1) }} GB" + - name: 磁盘使用率 + shell: df -h / | tail -1 | awk '{print $5}' + register: disk_pct + - name: 系统负载 + shell: uptime + register: load + - name: 运行摘要 + debug: + msg: "磁盘: {{ disk_pct.stdout }} | 负载: {{ load.stdout.split('load average:')[1].strip() if 'load average:' in load.stdout else 'N/A' }}"`, CreatedBy: "system"}, + {Name: "系统安全更新", Description: "安装系统安全补丁(自动识别 apt/yum/dnf)", Category: "security", Tags: `["安全","更新","补丁"]`, Content: `--- +- name: 系统安全更新 + hosts: all + become: yes + tasks: + - name: 检测包管理器 + shell: which apt yum dnf 2>/dev/null | head -1 | xargs basename + register: pkg_mgr + - name: 更新软件包 (APT) + apt: update_cache=yes upgrade=safe + when: pkg_mgr.stdout == "apt" + - name: 更新软件包 (YUM/DNF) + shell: "{{ pkg_mgr.stdout }} update -y --security" + when: pkg_mgr.stdout in ["yum", "dnf"] + - name: 检查重启需求 + shell: test -f /var/run/reboot-required && echo "需要重启" || echo "无需重启" + register: reboot + - name: 更新完成 + debug: msg="更新完成,{{ reboot.stdout }}"`, CreatedBy: "system"}, + {Name: "磁盘空间清理", Description: "清理日志、临时文件、Docker 缓存、包管理器缓存", Category: "maintenance", Tags: `["磁盘","清理","维护"]`, Content: `--- +- name: 磁盘空间清理 + hosts: all + become: yes + tasks: + - name: 清理前磁盘使用 + shell: df -h / | awk 'NR==2{print "使用率:",$5,"可用:",$4}' + register: before + - name: 清理30天前日志 + shell: find /var/log -type f \( -name "*.log" -o -name "*.gz" \) -mtime +30 -delete 2>/dev/null; echo "done" + - name: 清理临时文件 + shell: find /tmp /var/tmp -type f -mtime +7 -delete 2>/dev/null; echo "done" + - name: 清理 journald (保留3天) + shell: journalctl --vacuum-time=3d 2>/dev/null || true + - name: 清理包管理器缓存 + shell: (apt-get clean 2>/dev/null || yum clean all 2>/dev/null || dnf clean all 2>/dev/null); echo "done" + - name: 清理 Docker (如有) + shell: docker system prune -af 2>/dev/null || true + - name: 清理后磁盘使用 + shell: df -h / | awk 'NR==2{print "使用率:",$5,"可用:",$4}' + register: after + - name: 清理摘要 + debug: msg="清理前 - {{ before.stdout }} | 清理后 - {{ after.stdout }}"`, CreatedBy: "system"}, + {Name: "服务状态巡检", Description: "检查关键服务状态,自动启动已停止的服务", Category: "maintenance", Tags: `["服务","巡检","重启"]`, Content: `--- +- name: 服务状态巡检 + hosts: all + become: yes + vars: + services: [nginx, docker, sshd, cron, rsyslog] + tasks: + - name: 收集服务状态 + service_facts: + - name: 检查并启动服务 + service: + name: "{{ item }}" + state: started + loop: "{{ services }}" + when: item in ansible_facts.services + - name: 显示服务状态 + debug: + msg: "{{ item }}: {{ ansible_facts.services[item].state if item in ansible_facts.services else '未安装' }}" + loop: "{{ services }}" + - name: 检查关键端口 + shell: ss -tlnp | awk '{print $4}' | grep -E ':22$|:80$|:443$' + register: ports + - name: 端口监听 + debug: msg="监听端口: {{ ports.stdout_lines }}"`, CreatedBy: "system"}, + {Name: "用户与SSH管理", Description: "创建运维用户、配置SSH密钥、禁用root密码登录", Category: "security", Tags: `["用户","SSH","安全"]`, Content: `--- +- name: 用户与SSH管理 + hosts: all + become: yes + vars: + ops_user: devops + ops_ssh_key: "" + tasks: + - name: 创建运维用户 + user: name={{ ops_user }} shell=/bin/bash groups=sudo append=yes state=present + - name: 配置SSH密钥 + authorized_key: user={{ ops_user }} key="{{ ops_ssh_key }}" state=present + when: ops_ssh_key != "" + - name: sudo免密 + copy: content="{{ ops_user }} ALL=(ALL) NOPASSWD:ALL" dest=/etc/sudoers.d/{{ ops_user }} validate="visudo -cf %s" + - name: 禁用root密码登录 + lineinfile: path=/etc/ssh/sshd_config regexp="^PermitRootLogin" line="PermitRootLogin prohibit-password" + notify: reload sshd + - name: 可登录用户列表 + shell: awk -F: '$$7 !~ /(false|nologin)/ {print $$1}' /etc/passwd + register: users + - name: 用户摘要 + debug: msg="可登录用户: {{ users.stdout_lines }}" + handlers: + - name: reload sshd + service: name=sshd state=reloaded`, CreatedBy: "system"}, + {Name: "Nginx配置检查", Description: "检查Nginx语法、显示虚拟主机、执行重载", Category: "configuration", Tags: `["Nginx","配置","Web"]`, Content: `--- +- name: Nginx配置管理 + hosts: all + become: yes + tasks: + - name: Nginx状态 + shell: nginx -v 2>&1 || echo "未安装" + register: nginx_ver + - name: 配置语法检查 + shell: nginx -t 2>&1 + register: nginx_test + when: "'nginx' in nginx_ver.stdout" + - name: 列出站点 + shell: ls /etc/nginx/sites-enabled/ 2>/dev/null || ls /etc/nginx/conf.d/ 2>/dev/null + register: sites + - name: 重载Nginx + service: name=nginx state=reloaded + when: nginx_test.stdout is defined and 'successful' in nginx_test.stdout + - name: 检查监听端口 + shell: ss -tlnp | grep nginx + register: nginx_ports + - name: 配置摘要 + debug: + msg: + - "版本: {{ nginx_ver.stdout }}" + - "站点: {{ sites.stdout_lines }}" + - "端口: {{ nginx_ports.stdout }}"`, CreatedBy: "system"}, + {Name: "SSL证书检查", Description: "检查本地证书和HTTPS证书到期时间", Category: "security", Tags: `["SSL","证书","HTTPS"]`, Content: `--- +- name: SSL证书到期检查 + hosts: all + vars: + domains: [] + tasks: + - name: 本地证书检查 + shell: | + for f in /etc/nginx/ssl/*.crt /etc/letsencrypt/live/*/fullchain.pem 2>/dev/null; do + [ -f "$$f" ] && echo "$$f: $$(openssl x509 -enddate -noout -in "$$f" | cut -d= -f2)" + done + register: local_certs + - name: 远程HTTPS检查 + shell: | + for d in {% for d in target_hosts.split(',') %}{{ d }} {% endfor %}; do + expiry=$$(echo | openssl s_client -servername "$$d" -connect "$$d:443" 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2) + [ -n "$$expiry" ] && echo "$$d: $$expiry" + done + register: remote_certs + when: target_hosts is defined + - name: 证书到期列表 + debug: + msg: + - "本地: {{ local_certs.stdout_lines }}" + - "远程: {{ remote_certs.stdout_lines | default([]) }}"`, CreatedBy: "system"}, + {Name: "配置文件备份", Description: "备份关键配置到压缩包", Category: "backup", Tags: `["备份","配置"]`, Content: `--- +- name: 配置备份 + hosts: all + become: yes + vars: + backup_dir: "/tmp/config-backup-{{ ansible_date_time.date }}" + tasks: + - name: 创建备份目录 + file: path={{ backup_dir }} state=directory mode=0700 + - name: 备份关键配置 + archive: + path: "{{ item }}" + dest: "{{ backup_dir }}/{{ item | basename | replace('/', '_') }}.tar.gz" + loop: [/etc/nginx, /etc/ssh/sshd_config, /etc/fstab, /etc/hosts] + ignore_errors: yes + - name: 备份crontab + shell: crontab -l > {{ backup_dir }}/crontab.txt 2>/dev/null || true + - name: 备份汇总 + shell: du -sh {{ backup_dir }} + register: size + - name: 备份完成 + debug: msg="备份: {{ backup_dir }} ({{ size.stdout.split()[0] }})"`, CreatedBy: "system"}, + {Name: "进程健康诊断", Description: "CPU/内存TOP10、僵尸进程、进程总数", Category: "monitoring", Tags: `["进程","性能","诊断"]`, Content: `--- +- name: 进程健康诊断 + hosts: all + tasks: + - name: CPU TOP10 + shell: ps aux --sort=-%cpu | head -11 + register: cpu + - name: 内存 TOP10 + shell: ps aux --sort=-%mem | head -11 + register: mem + - name: 僵尸进程 + shell: ps aux | awk '$$8=="Z"' || echo "无僵尸进程" + register: zombies + - name: 进程总数 + shell: ps aux | wc -l + register: total + - name: 诊断摘要 + debug: + msg: + - "总进程: {{ total.stdout }} | 僵尸: {{ zombies.stdout_lines | length }}" + - "=== CPU TOP10 ===" + - "{{ cpu.stdout }}" + - "=== 内存 TOP10 ===" + - "{{ mem.stdout }}"`, CreatedBy: "system"}, + {Name: "Docker环境清理", Description: "清理未使用容器、镜像、构建缓存和网络", Category: "maintenance", Tags: `["Docker","清理","容器"]`, Content: `--- +- name: Docker环境清理 + hosts: all + become: yes + tasks: + - name: 检查Docker + shell: docker --version 2>/dev/null || echo "NONE" + register: dk + - name: Docker资源概览 + shell: | + echo "容器:" && docker ps -a --format "table {{.Names}} {{.Status}}" 2>/dev/null + echo "镜像:" && docker images --format "table {{.Repository}} {{.Size}}" 2>/dev/null + register: status + when: dk.stdout != "NONE" + - name: 清理停止容器 + shell: docker container prune -f + when: dk.stdout != "NONE" + - name: 清理未用镜像 + shell: docker image prune -a -f + when: dk.stdout != "NONE" + - name: 清理构建缓存 + shell: docker builder prune -f + when: dk.stdout != "NONE" + - name: 清理未用网络/卷 + shell: docker system prune -f --volumes + when: dk.stdout != "NONE" + - name: 清理完成 + debug: msg="Docker清理完成"`, CreatedBy: "system"}, + {Name: "防火墙规则管理", Description: "查看/配置防火墙规则(ufw/firewalld/iptables)", Category: "security", Tags: `["防火墙","安全","网络"]`, Content: `--- +- name: 防火墙规则管理 + hosts: all + become: yes + vars: + open_ports: [22, 80, 443] + tasks: + - name: 防火墙类型 + shell: | + ufw status 2>/dev/null | grep -q "active" && echo ufw && exit + firewall-cmd --state 2>/dev/null | grep -q "running" && echo firewalld && exit + echo iptables + register: fw + - name: 当前规则 + shell: | + case {{ fw.stdout }} in + ufw) ufw status verbose ;; + firewalld) firewall-cmd --list-all ;; + *) iptables -L -n ;; + esac + register: rules + - name: 开放端口(UFW) + ufw: rule=allow port={{ item }} proto=tcp + loop: "{{ open_ports }}" + when: fw.stdout == "ufw" + - name: 开放端口(firewalld) + firewalld: port={{ item }}/tcp permanent=yes state=enabled immediate=yes + loop: "{{ open_ports }}" + when: fw.stdout == "firewalld" + - name: 防火墙规则 + debug: msg="{{ rules.stdout }}"`, CreatedBy: "system"}, + {Name: "时间同步检查", Description: "检查NTP/Chrony同步状态和时间一致性", Category: "configuration", Tags: `["时间","NTP","同步"]`, Content: `--- +- name: 时间同步检查 + hosts: all + become: yes + tasks: + - name: 当前时间 + shell: date '+%Y-%m-%d %H:%M:%S %Z' + register: now + - name: 同步服务 + shell: | + systemctl is-active chronyd 2>/dev/null && echo "chronyd" || + systemctl is-active ntpd 2>/dev/null && echo "ntpd" || + systemctl is-active systemd-timesyncd 2>/dev/null && echo "timesyncd" || + echo "未配置" + register: svc + - name: 同步状态 + shell: | + case {{ svc.stdout }} in + chronyd) chronyc tracking 2>/dev/null | grep -E "Stratum|System time" ;; + ntpd) ntpq -p 2>/dev/null | tail -5 ;; + timesyncd) timedatectl show-timesync 2>/dev/null ;; + *) echo "未同步" ;; + esac + register: sync + - name: 时间摘要 + debug: + msg: "时间: {{ now.stdout }} | 服务: {{ svc.stdout }} | {{ sync.stdout }}"`, CreatedBy: "system"}, + {Name: "日志轮转配置", Description: "安装logrotate并配置应用日志轮转策略", Category: "configuration", Tags: `["日志","轮转","logrotate"]`, Content: `--- +- name: 日志轮转配置 + hosts: all + become: yes + vars: + log_path: /var/log/app + keep_days: 30 + tasks: + - name: 安装logrotate + package: name=logrotate state=present + - name: 应用日志轮转 + copy: + dest: /etc/logrotate.d/app-logs + content: | + {{ log_path }}/*.log { + daily + rotate {{ keep_days }} + missingok + notifempty + compress + delaycompress + sharedscripts + postrotate + systemctl reload app 2>/dev/null || true + endscript + } + - name: 测试配置 + shell: logrotate -d /etc/logrotate.conf 2>&1 | head -10 + register: test + - name: 配置完成 + debug: msg="Logrotate已配置 | {{ test.stdout }}"`, CreatedBy: "system"}, + {Name: "内核参数调优", Description: "优化网络/内存/文件句柄等内核参数", Category: "configuration", Tags: `["内核","优化","性能"]`, Content: `--- +- name: 内核参数调优 + hosts: all + become: yes + tasks: + - name: 网络参数优化 + sysctl: + name: "{{ item.name }}" + value: "{{ item.value }}" + state: present + reload: yes + loop: + - { name: net.core.somaxconn, value: '65535' } + - { name: net.ipv4.tcp_tw_reuse, value: '1' } + - { name: net.ipv4.tcp_fin_timeout, value: '30' } + - name: 内存参数 + sysctl: name=vm.swappiness value=10 state=present reload=yes + - name: 文件句柄 + sysctl: name=fs.file-max value=655350 state=present reload=yes + - name: limits.conf + pam_limits: + domain: '*' + limit_type: "{{ item.t }}" + limit_item: "{{ item.i }}" + value: "{{ item.v }}" + loop: + - { t: soft, i: nofile, v: '65535' } + - { t: hard, i: nofile, v: '65535' } + - name: 调优完成 + debug: msg="内核参数已优化"`, CreatedBy: "system"}, + {Name: "网络连通性诊断", Description: "DNS解析、Ping延迟、端口检测、路由追踪", Category: "monitoring", Tags: `["网络","诊断","连通性"]`, Content: `--- +- name: 网络诊断 + hosts: all + vars: + targets: [8.8.8.8, 1.1.1.1] + domains: [baidu.com] + tasks: + - name: DNS解析 + shell: | + for d in {% for d in domains %}{{ d }} {% endfor %}; do + echo "$$d: $$(nslookup $$d 2>/dev/null | awk '/^Address/{print $$2}' | tail -1 || echo 'FAIL')" + done + register: dns + - name: Ping延迟 + shell: ping -c 2 -W 2 {{ item }} 2>&1 | tail -1 + loop: "{{ targets }}" + register: ping + - name: 端口检测 + shell: timeout 2 bash -c "echo >/dev/tcp/{{ item }}/53" 2>/dev/null && echo "{{ item }}:53 OK" || echo "{{ item }}:53 FAIL" + loop: "{{ targets }}" + register: port + - name: 诊断结果 + debug: + msg: + - "=== DNS ===" + - "{{ dns.stdout_lines }}" + - "=== Ping ===" + - "{{ ping.results | map(attribute='stdout') }}" + - "=== 端口 ===" + - "{{ port.results | map(attribute='stdout') }}"`, CreatedBy: "system"}, + } + if err := db.Create(&playbooks).Error; err != nil { + zap.L().Warn("failed to seed playbook templates", zap.Error(err)) + } else { + zap.L().Info("seeded 15 Ansible playbook templates") + } + } } diff --git a/backend/internal/api/handlers/ansible.go b/backend/internal/api/handlers/ansible.go index 16bff97..5919d5a 100644 --- a/backend/internal/api/handlers/ansible.go +++ b/backend/internal/api/handlers/ansible.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "os/exec" + "regexp" "strings" "time" @@ -211,12 +212,34 @@ func (h *AnsibleHandler) ExecutePlaybook(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute) defer cancel() - args := []string{tmpFile.Name(), "--connection=local"} + args := []string{tmpFile.Name()} + + // 根据目标主机决定连接方式 + if len(req.TargetHosts) > 0 { + // 查询目标主机的 SSH 信息,生成临时 inventory + var servers []models.Server + h.db.Where("hostname IN ? OR ip IN ?", req.TargetHosts, req.TargetHosts).Find(&servers) + if len(servers) > 0 { + invFile, invErr := os.CreateTemp("", "ansible-inventory-*.ini") + if invErr == nil { + defer os.Remove(invFile.Name()) + invFile.WriteString("[targets]\n") + for _, s := range servers { + sshUser := s.Username + if sshUser == "" { sshUser = "root" } + invFile.WriteString(fmt.Sprintf("%s ansible_host=%s ansible_user=%s ansible_port=%d ansible_ssh_common_args='-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'\n", + s.Hostname, s.IP, sshUser, s.Port)) + } + invFile.Close() + args = append(args, "--inventory", invFile.Name()) + } + } + } else { + args = append(args, "--connection=local") + } + // 构建 extra-vars var extraVars []string - if len(req.TargetHosts) > 0 { - extraVars = append(extraVars, "target_hosts="+strings.Join(req.TargetHosts, ",")) - } for k, v := range req.Variables { extraVars = append(extraVars, fmt.Sprintf("%s=%v", k, v)) } @@ -312,3 +335,93 @@ func (h *AnsibleHandler) DeleteInventory(c *gin.Context) { } c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已删除"}) } + +// ==================== 主机匹配 ==================== + +// MatchHostsRequest 主机匹配请求 +type MatchHostsRequest struct { + Pattern string `json:"pattern" form:"pattern"` // 匹配模式(正则或前缀) + MatchType string `json:"match_type" form:"match_type"` // "regex", "prefix", "contains" + Page int `json:"page" form:"page"` + PageSize int `json:"page_size" form:"page_size"` +} + +// MatchHosts 预览匹配的主机列表(从资产服务器表查询)// Logan.Li +// GET /api/v1/ansible/match-hosts?pattern=web*&match_type=prefix +func (h *AnsibleHandler) MatchHosts(c *gin.Context) { + var req MatchHostsRequest + c.ShouldBindQuery(&req) + if req.Page <= 0 { req.Page = 1 } + if req.PageSize <= 0 || req.PageSize > 200 { req.PageSize = 50 } + + query := h.db.Model(&models.Server{}) + + if req.Pattern != "" { + switch req.MatchType { + case "regex": + // 编译正则表达式 + re, err := regexp.Compile(req.Pattern) + if err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "正则表达式错误: " + err.Error()}) + return + } + // MySQL 使用 REGEXP 关键字 + query = query.Where("hostname REGEXP ? OR ip REGEXP ? OR tags REGEXP ?", + req.Pattern, req.Pattern, req.Pattern) + // 对于复杂的正则,MySQL 可能不支持;先查全部再 Go 过滤 + if !isMySQLSafeRegex(req.Pattern) { + var all []models.Server + h.db.Find(&all) + var matched []models.Server + for _, s := range all { + if re.MatchString(s.Hostname) || re.MatchString(s.IP) || re.MatchString(s.Tags) { + matched = append(matched, s) + } + } + total := len(matched) + start := (req.Page - 1) * req.PageSize + end := start + req.PageSize + if start > total { start = total } + if end > total { end = total } + result := matched[start:end] + for i := range result { + result[i].HasAuth = result[i].Password != "" || result[i].SSHKey != "" + } + c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: result, Total: int64(total)}}) + return + } + case "prefix": + query = query.Where("hostname LIKE ? OR ip LIKE ?", + req.Pattern+"%", req.Pattern+"%") + default: // "contains" + query = query.Where("hostname LIKE ? OR ip LIKE ? OR tags LIKE ?", + "%"+req.Pattern+"%", "%"+req.Pattern+"%", "%"+req.Pattern+"%") + } + } + + var total int64 + query.Count(&total) + + var items []models.Server + offset := (req.Page - 1) * req.PageSize + query.Offset(offset).Limit(req.PageSize).Order("hostname").Find(&items) + + // 补充 HasAuth 字段 + for i := range items { + items[i].HasAuth = items[i].Password != "" || items[i].SSHKey != "" + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: items, Total: total}}) +} + +// isMySQLSafeRegex 判断正则表达式是否可安全用于 MySQL REGEXP +func isMySQLSafeRegex(pat string) bool { + // MySQL REGEXP 支持基本正则,但如果包含复杂断言或 Unicode 则不适合 + unsafe := []string{`\b`, `(?=`, `(?!`, `(?<=`, `(? { return http.delete('/ansible/playbooks/batch-delete', { ids }); }, - executePlaybook: async (id: number | string) => { - return http.post(`/ansible/playbooks/${id}/execute`); + executePlaybook: async (id: number | string, targetHosts?: string[]) => { + return http.post(`/ansible/playbooks/${id}/execute`, targetHosts ? { target_hosts: targetHosts } : undefined); + }, + // 主机匹配预览 // Logan.Li + matchHosts: async (params: { pattern?: string; match_type?: string; page?: number; page_size?: number }) => { + return http.get('/ansible/match-hosts', params); }, // 任务执行记录 getJobs: async (params?: { page?: number; page_size?: number; status?: string }) => {