diff --git a/app/http/controllers/plugins/fail2ban/fail2ban_controller.go b/app/http/controllers/plugins/fail2ban/fail2ban_controller.go new file mode 100644 index 00000000..10b18753 --- /dev/null +++ b/app/http/controllers/plugins/fail2ban/fail2ban_controller.go @@ -0,0 +1,459 @@ +package fail2ban + +import ( + "regexp" + "strings" + + "github.com/goravel/framework/contracts/http" + "github.com/goravel/framework/facades" + "github.com/spf13/cast" + "panel/app/models" + + "panel/app/http/controllers" + "panel/app/services" + "panel/pkg/tools" +) + +type Fail2banController struct { + website services.Website +} + +func NewFail2banController() *Fail2banController { + return &Fail2banController{ + website: services.NewWebsiteImpl(), + } +} + +type Jail struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + LogPath string `json:"log_path"` + MaxRetry int `json:"max_retry"` + FindTime int `json:"find_time"` + BanTime int `json:"ban_time"` +} + +// Status 获取运行状态 +func (c *Fail2banController) Status(ctx http.Context) { + if !controllers.Check(ctx, "fail2ban") { + return + } + + status := tools.ExecShell("systemctl status fail2ban | grep Active | grep -v grep | awk '{print $2}'") + if len(status) == 0 { + controllers.Error(ctx, http.StatusInternalServerError, "获取服务运行状态失败") + return + } + + if status == "active" { + controllers.Success(ctx, true) + } else { + controllers.Success(ctx, false) + } +} + +// Reload 重载配置 +func (c *Fail2banController) Reload(ctx http.Context) { + if !controllers.Check(ctx, "fail2ban") { + return + } + + tools.ExecShell("systemctl reload fail2ban") + status := tools.ExecShell("systemctl status fail2ban | grep Active | grep -v grep | awk '{print $2}'") + if len(status) == 0 { + controllers.Error(ctx, http.StatusInternalServerError, "获取服务运行状态失败") + return + } + + if status == "active" { + controllers.Success(ctx, true) + } else { + controllers.Success(ctx, false) + } +} + +// Restart 重启服务 +func (c *Fail2banController) Restart(ctx http.Context) { + if !controllers.Check(ctx, "fail2ban") { + return + } + + tools.ExecShell("systemctl restart fail2ban") + status := tools.ExecShell("systemctl status fail2ban | grep Active | grep -v grep | awk '{print $2}'") + if len(status) == 0 { + controllers.Error(ctx, http.StatusInternalServerError, "获取服务运行状态失败") + return + } + + if status == "active" { + controllers.Success(ctx, true) + } else { + controllers.Success(ctx, false) + } +} + +// Start 启动服务 +func (c *Fail2banController) Start(ctx http.Context) { + if !controllers.Check(ctx, "fail2ban") { + return + } + + tools.ExecShell("systemctl start fail2ban") + status := tools.ExecShell("systemctl status fail2ban | grep Active | grep -v grep | awk '{print $2}'") + if len(status) == 0 { + controllers.Error(ctx, http.StatusInternalServerError, "获取服务运行状态失败") + return + } + + if status == "active" { + controllers.Success(ctx, true) + } else { + controllers.Success(ctx, false) + } +} + +// Stop 停止服务 +func (c *Fail2banController) Stop(ctx http.Context) { + if !controllers.Check(ctx, "fail2ban") { + return + } + + tools.ExecShell("systemctl stop fail2ban") + status := tools.ExecShell("systemctl status fail2ban | grep Active | grep -v grep | awk '{print $2}'") + if len(status) == 0 { + controllers.Error(ctx, http.StatusInternalServerError, "获取服务运行状态失败") + return + } + + if status != "active" { + controllers.Success(ctx, true) + } else { + controllers.Success(ctx, false) + } +} + +// List 所有 Fail2ban 规则 +func (c *Fail2banController) List(ctx http.Context) { + if !controllers.Check(ctx, "fail2ban") { + return + } + + page := ctx.Request().QueryInt("page", 1) + limit := ctx.Request().QueryInt("limit", 10) + raw := tools.ReadFile("/etc/fail2ban/jail.local") + if len(raw) == 0 { + controllers.Error(ctx, http.StatusBadRequest, "Fail2ban 规则为空") + return + } + + jailList := regexp.MustCompile(`\[(.*?)]`).FindAllStringSubmatch(raw, -1) + if len(jailList) == 0 { + controllers.Error(ctx, http.StatusBadRequest, "Fail2ban 规则为空") + return + } + + var jails []Jail + for i, jail := range jailList { + if i == 0 { + continue + } + + jailName := jail[1] + jailRaw := tools.Cut(raw, "# "+jailName+"-START", "# "+jailName+"-END") + if len(jailRaw) == 0 { + continue + } + jailEnabled := strings.Contains(jailRaw, "enabled = true") + jailLogPath := regexp.MustCompile(`logpath = (.*)`).FindStringSubmatch(jailRaw) + jailMaxRetry := regexp.MustCompile(`maxretry = (.*)`).FindStringSubmatch(jailRaw) + jailFindTime := regexp.MustCompile(`findtime = (.*)`).FindStringSubmatch(jailRaw) + jailBanTime := regexp.MustCompile(`bantime = (.*)`).FindStringSubmatch(jailRaw) + + jails = append(jails, Jail{ + Name: jailName, + Enabled: jailEnabled, + LogPath: jailLogPath[1], + MaxRetry: cast.ToInt(jailMaxRetry[1]), + FindTime: cast.ToInt(jailFindTime[1]), + BanTime: cast.ToInt(jailBanTime[1]), + }) + } + + startIndex := (page - 1) * limit + endIndex := page * limit + if startIndex > len(jails) { + controllers.Success(ctx, http.Json{ + "total": 0, + "items": []Jail{}, + }) + return + } + if endIndex > len(jails) { + endIndex = len(jails) + } + pagedJails := jails[startIndex:endIndex] + + controllers.Success(ctx, http.Json{ + "total": len(jails), + "items": pagedJails, + }) +} + +// Add 添加 Fail2ban 规则 +func (c *Fail2banController) Add(ctx http.Context) { + if !controllers.Check(ctx, "fail2ban") { + return + } + + validator, err := ctx.Request().Validate(map[string]string{ + "name": "required", + "type": "required|in:website,service", + "maxretry": "required", + "findtime": "required", + "bantime": "required", + "website_mode": "required_if:type,website", + "website_path": "required_if:type,website", + }) + if err != nil { + controllers.Error(ctx, http.StatusUnprocessableEntity, err.Error()) + return + } + if validator.Fails() { + controllers.Error(ctx, http.StatusUnprocessableEntity, validator.Errors().One()) + return + } + + jailName := ctx.Request().Input("name") + jailType := ctx.Request().Input("type") + jailMaxRetry := ctx.Request().Input("maxretry") + jailFindTime := ctx.Request().Input("findtime") + jailBanTime := ctx.Request().Input("bantime") + jailWebsiteMode := ctx.Request().Input("website_mode") + jailWebsitePath := ctx.Request().Input("website_path") + + raw := tools.ReadFile("/etc/fail2ban/jail.local") + if strings.Contains(raw, "["+jailName+"]") || (strings.Contains(raw, "["+jailName+"]"+"-cc") && jailWebsiteMode == "cc") || (strings.Contains(raw, "["+jailName+"]"+"-path") && jailWebsiteMode == "path") { + controllers.Error(ctx, http.StatusUnprocessableEntity, "规则已存在") + return + } + + switch jailType { + case "website": + var website models.Website + err := facades.Orm().Query().Where("name", jailName).FirstOrFail(&website) + if err != nil { + controllers.Error(ctx, http.StatusUnprocessableEntity, "网站不存在") + return + } + config, err := c.website.GetConfig(int(website.ID)) + if err != nil { + controllers.Error(ctx, http.StatusUnprocessableEntity, "获取网站配置失败") + return + } + var ports string + for _, port := range config.Ports { + if len(strings.Split(port, " ")) > 1 { + ports += strings.Split(port, " ")[0] + "," + } else { + ports += port + "," + } + } + + rule := ` +# ` + jailName + `-` + jailWebsiteMode + `-START +[` + jailName + `-` + jailWebsiteMode + `] +enabled = true +filter = haozi-` + jailName + `-` + jailWebsiteMode + ` +port = ` + ports + ` +maxretry = ` + jailMaxRetry + ` +findtime = ` + jailFindTime + ` +bantime = ` + jailBanTime + ` +action = %(action_mwl)s +logpath = /www/wwwlogs/` + website.Name + `.log +# ` + jailName + `-` + jailWebsiteMode + `-END +` + raw += rule + tools.WriteFile("/etc/fail2ban/jail.local", raw, 0644) + + var filter string + if jailWebsiteMode == "cc" { + filter = ` +[Definition] +failregex = ^\s-.*HTTP/.*$ +ignoreregex = +` + } else { + filter = ` +[Definition] +failregex = ^\s-.*\s` + jailWebsitePath + `.*HTTP/.*$ +ignoreregex = +` + } + tools.WriteFile("/etc/fail2ban/filter.d/haozi-"+jailName+"-"+jailWebsiteMode+".conf", filter, 0644) + + case "service": + var logPath string + var filter string + var port string + switch jailName { + case "ssh": + if tools.IsDebian() { + logPath = "/var/log/auth.log" + } else { + logPath = "/var/log/secure" + } + filter = "sshd" + port = tools.ExecShell("cat /etc/ssh/sshd_config | grep 'Port ' | awk '{print $2}'") + case "mysql": + logPath = "/www/server/mysql/mysql-error.log" + filter = "mysqld-auth" + port = tools.ExecShell("cat /www/server/mysql/conf/my.cnf | grep 'port' | head -n 1 | awk '{print $3}'") + case "pure-ftpd": + logPath = "/var/log/messages" + filter = "pure-ftpd" + port = tools.ExecShell(`cat /www/server/pure-ftpd/etc/pure-ftpd.conf | grep "Bind" | awk '{print $2}' | awk -F "," '{print $2}'`) + default: + controllers.Error(ctx, http.StatusUnprocessableEntity, "未知服务") + return + } + if len(port) == 0 { + controllers.Error(ctx, http.StatusUnprocessableEntity, "获取服务端口失败,请检查是否安装") + return + } + + rule := ` +# ` + jailName + `-START +[` + jailName + `] +enabled = true +filter = ` + filter + ` +port = ` + port + ` +maxretry = ` + jailMaxRetry + ` +findtime = ` + jailFindTime + ` +bantime = ` + jailBanTime + ` +action = %(action_mwl)s +logpath = ` + logPath + ` +# ` + jailName + `-END +` + raw += rule + tools.WriteFile("/etc/fail2ban/jail.local", raw, 0644) + } + + tools.ExecShell("fail2ban-client reload") + controllers.Success(ctx, nil) +} + +// Delete 删除规则 +func (c *Fail2banController) Delete(ctx http.Context) { + if !controllers.Check(ctx, "fail2ban") { + return + } + + jailName := ctx.Request().Input("name") + raw := tools.ReadFile("/etc/fail2ban/jail.local") + if !strings.Contains(raw, "["+jailName+"]") { + controllers.Error(ctx, http.StatusUnprocessableEntity, "规则不存在") + return + } + + rule := tools.Cut(raw, "# "+jailName+"-START", "# "+jailName+"-END") + raw = strings.Replace(raw, "\n# "+jailName+"-START"+rule+"# "+jailName+"-END", "", -1) + raw = strings.TrimSpace(raw) + tools.WriteFile("/etc/fail2ban/jail.local", raw, 0644) + + tools.ExecShell("fail2ban-client reload") + controllers.Success(ctx, nil) +} + +// BanList 获取封禁列表 +func (c *Fail2banController) BanList(ctx http.Context) { + if !controllers.Check(ctx, "fail2ban") { + return + } + + name := ctx.Request().Query("name") + if len(name) == 0 { + controllers.Error(ctx, http.StatusUnprocessableEntity, "缺少参数") + return + } + + currentlyBan := tools.ExecShell(`fail2ban-client status ` + name + ` | grep "Currently banned" | awk '{print $4}'`) + totalBan := tools.ExecShell(`fail2ban-client status ` + name + ` | grep "Total banned" | awk '{print $4}'`) + bannedIp := tools.ExecShell(`fail2ban-client status ` + name + ` | grep "Banned IP list" | awk -F ":" '{print $2}'`) + bannedIpList := strings.Split(bannedIp, " ") + + var list []map[string]string + for _, ip := range bannedIpList { + if len(ip) > 0 { + list = append(list, map[string]string{ + "name": name, + "ip": ip, + }) + } + } + + controllers.Success(ctx, http.Json{ + "currentlyBan": currentlyBan, + "totalBan": totalBan, + "bannedIpList": list, + }) +} + +// Unban 解封 +func (c *Fail2banController) Unban(ctx http.Context) { + if !controllers.Check(ctx, "fail2ban") { + return + } + + name := ctx.Request().Input("name") + ip := ctx.Request().Input("ip") + if len(name) == 0 || len(ip) == 0 { + controllers.Error(ctx, http.StatusUnprocessableEntity, "缺少参数") + return + } + + tools.ExecShell("fail2ban-client set " + name + " unbanip " + ip) + controllers.Success(ctx, nil) +} + +// SetWhiteList 设置白名单 +func (c *Fail2banController) SetWhiteList(ctx http.Context) { + if !controllers.Check(ctx, "fail2ban") { + return + } + + ip := ctx.Request().Input("ip") + if len(ip) == 0 { + controllers.Error(ctx, http.StatusUnprocessableEntity, "缺少参数") + return + } + + raw := tools.ReadFile("/etc/fail2ban/jail.local") + // 正则替换 + reg := regexp.MustCompile(`ignoreip\s*=\s*.*\n`) + if reg.MatchString(raw) { + raw = reg.ReplaceAllString(raw, "ignoreip = "+ip+"\n") + } else { + controllers.Error(ctx, http.StatusInternalServerError, "解析Fail2ban规则失败,Fail2ban可能已损坏") + return + } + + tools.WriteFile("/etc/fail2ban/jail.local", raw, 0644) + tools.ExecShell("fail2ban-client reload") + controllers.Success(ctx, nil) +} + +// GetWhiteList 获取白名单 +func (c *Fail2banController) GetWhiteList(ctx http.Context) { + if !controllers.Check(ctx, "fail2ban") { + return + } + + raw := tools.ReadFile("/etc/fail2ban/jail.local") + reg := regexp.MustCompile(`ignoreip\s*=\s*(.*)\n`) + if reg.MatchString(raw) { + ignoreIp := reg.FindStringSubmatch(raw)[1] + controllers.Success(ctx, ignoreIp) + } else { + controllers.Error(ctx, http.StatusInternalServerError, "解析Fail2ban规则失败,Fail2ban可能已损坏") + } +} diff --git a/app/plugins/fail2ban/fail2ban.go b/app/plugins/fail2ban/fail2ban.go new file mode 100644 index 00000000..2f6fbd4f --- /dev/null +++ b/app/plugins/fail2ban/fail2ban.go @@ -0,0 +1,13 @@ +package fail2ban + +var ( + Name = "Fail2ban" + Description = "Fail2ban 扫描系统日志文件并从中找出多次尝试失败的IP地址,将该IP地址加入防火墙的拒绝访问列表中。" + Slug = "fail2ban" + Version = "1.0.0" + Requires = []string{} + Excludes = []string{} + Install = `bash /www/panel/scripts/fail2ban/install.sh` + Uninstall = `bash /www/panel/scripts/fail2ban/uninstall.sh` + Update = `bash /www/panel/scripts/fail2ban/update.sh` +) diff --git a/app/services/plugin.go b/app/services/plugin.go index e2a91cac..d88f634b 100644 --- a/app/services/plugin.go +++ b/app/services/plugin.go @@ -5,6 +5,7 @@ import ( "github.com/goravel/framework/facades" "panel/app/models" + "panel/app/plugins/fail2ban" "panel/app/plugins/mysql57" "panel/app/plugins/mysql80" "panel/app/plugins/openresty" @@ -168,6 +169,17 @@ func (r *PluginImpl) All() []PanelPlugin { Uninstall: supervisor.Uninstall, Update: supervisor.Update, }) + p = append(p, PanelPlugin{ + Name: fail2ban.Name, + Description: fail2ban.Description, + Slug: fail2ban.Slug, + Version: fail2ban.Version, + Requires: fail2ban.Requires, + Excludes: fail2ban.Excludes, + Install: fail2ban.Install, + Uninstall: fail2ban.Uninstall, + Update: fail2ban.Update, + }) return p } diff --git a/public/panel/views/plugins/fail2ban.html b/public/panel/views/plugins/fail2ban.html new file mode 100644 index 00000000..970f92e5 --- /dev/null +++ b/public/panel/views/plugins/fail2ban.html @@ -0,0 +1,335 @@ + +Fail2ban +
+
+
+
+
Fail2ban 运行状态
+
+
当前状态:获取中
+
+ + + + +
+
+ 基本设置 +
+
+
+ +
+ +
+
IP白名单,以英文逗号,分隔
+
+
+
+ +
+
+
+
+
+
+
Fail2ban 规则列表
+
+
+ + + + +
+
+
+
+
+ + diff --git a/public/panel/views/plugins/fail2ban/add_rule.html b/public/panel/views/plugins/fail2ban/add_rule.html new file mode 100644 index 00000000..def482b9 --- /dev/null +++ b/public/panel/views/plugins/fail2ban/add_rule.html @@ -0,0 +1,170 @@ + + + diff --git a/public/panel/views/plugins/fail2ban/view_rule.html b/public/panel/views/plugins/fail2ban/view_rule.html new file mode 100644 index 00000000..aaedbaf3 --- /dev/null +++ b/public/panel/views/plugins/fail2ban/view_rule.html @@ -0,0 +1,101 @@ + + + + diff --git a/routes/plugin.go b/routes/plugin.go index b047d782..6a29340a 100644 --- a/routes/plugin.go +++ b/routes/plugin.go @@ -3,6 +3,7 @@ package routes import ( "github.com/goravel/framework/contracts/route" "github.com/goravel/framework/facades" + "panel/app/http/controllers/plugins/fail2ban" "panel/app/http/controllers/plugins/mysql57" "panel/app/http/controllers/plugins/mysql80" @@ -198,4 +199,19 @@ func Plugin() { route.Post("addProcess", supervisorController.AddProcess) }) + facades.Route().Prefix("api/plugins/fail2ban").Middleware(middleware.Jwt()).Group(func(route route.Route) { + fail2banController := fail2ban.NewFail2banController() + route.Get("status", fail2banController.Status) + route.Post("start", fail2banController.Start) + route.Post("stop", fail2banController.Stop) + route.Post("restart", fail2banController.Restart) + route.Post("reload", fail2banController.Reload) + route.Get("list", fail2banController.List) + route.Post("add", fail2banController.Add) + route.Post("delete", fail2banController.Delete) + route.Get("ban", fail2banController.BanList) + route.Post("unban", fail2banController.Unban) + route.Post("whiteList", fail2banController.SetWhiteList) + route.Get("whiteList", fail2banController.GetWhiteList) + }) } diff --git a/scripts/fail2ban/install.sh b/scripts/fail2ban/install.sh index f411b4d0..9eca1799 100644 --- a/scripts/fail2ban/install.sh +++ b/scripts/fail2ban/install.sh @@ -39,7 +39,7 @@ fi # 修改 fail2ban 配置文件 sed -i 's!# logtarget.*!logtarget = /var/log/fail2ban.log!' /etc/fail2ban/fail2ban.conf sed -i 's!logtarget\s*=.*!logtarget = /var/log/fail2ban.log!' /etc/fail2ban/jail.conf -cat >/etc/fail2ban/jail.local < /etc/fail2ban/jail.local << EOF [DEFAULT] ignoreip = 127.0.0.1/8 bantime = 600 @@ -78,8 +78,8 @@ if [ "${sshPort}" == "" ]; then sshPort="22" fi sed -i "s/port = 22/port = ${sshPort}/g" /etc/fail2ban/jail.local -if [ -f "/etc/pure-ftpd/pure-ftpd.conf" ]; then - ftpPort=$(cat /etc/pure-ftpd/pure-ftpd.conf | grep "Bind" | awk '{print $2}' | awk -F "," '{print $2}') +if [ -f "/www/server/pure-ftpd/etc/pure-ftpd.conf" ]; then + ftpPort=$(cat /www/server/pure-ftpd/etc/pure-ftpd.conf | grep "Bind" | awk '{print $2}' | awk -F "," '{print $2}') fi if [ "${ftpPort}" == "" ]; then ftpPort="21" @@ -87,10 +87,17 @@ if [ "${ftpPort}" == "" ]; then else sed -i "s/port = 21/port = ${ftpPort}/g" /etc/fail2ban/jail.local fi + +# Debian 的特殊处理 +if [ "${OS}" == "debian" ]; then + sed -i "s/\/var\/log\/secure/\/var\/log\/auth.log/g" /etc/fail2ban/jail.local + sed -i "s/banaction = firewallcmd-ipset/banaction = ufw/g" /etc/fail2ban/jail.local +fi + # 启动 fail2ban systemctl unmask fail2ban systemctl daemon-reload systemctl enable fail2ban systemctl restart fail2ban -panel writePlugin fail2ban +panel writePlugin fail2ban 1.0.0 diff --git a/scripts/fail2ban/uninstall.sh b/scripts/fail2ban/uninstall.sh index 928488c7..6dbe02fc 100644 --- a/scripts/fail2ban/uninstall.sh +++ b/scripts/fail2ban/uninstall.sh @@ -20,6 +20,8 @@ limitations under the License. HR="+----------------------------------------------------" OS=$(source /etc/os-release && { [[ "$ID" == "debian" ]] && echo "debian"; } || { [[ "$ID" == "centos" ]] || [[ "$ID" == "rhel" ]] || [[ "$ID" == "rocky" ]] || [[ "$ID" == "almalinux" ]] && echo "centos"; } || echo "unknown") +fail2ban-client unban --all +fail2ban-client stop systemctl stop fail2ban systemctl disable fail2ban diff --git a/scripts/fail2ban/update.sh b/scripts/fail2ban/update.sh new file mode 100644 index 00000000..e2fe97c6 --- /dev/null +++ b/scripts/fail2ban/update.sh @@ -0,0 +1,37 @@ +#!/bin/bash +export PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin:$PATH + +: ' +Copyright 2022 HaoZi Technology Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +' + +HR="+----------------------------------------------------" +OS=$(source /etc/os-release && { [[ "$ID" == "debian" ]] && echo "debian"; } || { [[ "$ID" == "centos" ]] || [[ "$ID" == "rhel" ]] || [[ "$ID" == "rocky" ]] || [[ "$ID" == "almalinux" ]] && echo "centos"; } || echo "unknown") + +if [ "${OS}" == "centos" ]; then + dnf install -y fail2ban +elif [ "${OS}" == "debian" ]; then + apt install -y fail2ban +else + echo -e $HR + echo "错误:不支持的操作系统" + exit 1 +fi + +if [ "$?" != "0" ]; then + echo -e $HR + echo "错误:fail2ban安装失败,请截图错误信息寻求帮助。" + exit 1 +fi