feat(firewall): 添加防火墙管理功能
- 新增防火墙站点管理功能,支持站点的增删改查
- 实现国家白名单规则管理,支持按站点配置国家访问控制
- 集成Nginx配置自动生成与reload机制
- 添加防火墙相关数据库表结构定义
- 实现IP白名单管理功能
- 添加前端管理界面用于防火墙规则配置
- 配置数据库连接池idle超时参数
- 添加Nginx安全reload脚本防止配置错误
- 实现Redis队列管理Nginx reload任务
- 优化防火墙规则存储与查询逻辑
20 files added
2 files modified
| New file |
| | |
| | | # Firewall Country Allow Map |
| | | # Site: 独立站 (polypepprotein.com) |
| | | # Generated at: Fri Jul 31 13:49:31 CST 2026 |
| | | |
| | | map $geoip_country_code $allow_country_1 { |
| | | default 0; |
| | | } |
| New file |
| | |
| | | -- ============================================================ |
| | | -- 防火墙站点表 |
| | | -- ============================================================ |
| | | DROP TABLE IF EXISTS firewall_site; |
| | | CREATE TABLE firewall_site ( |
| | | ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', |
| | | REVISION INT DEFAULT 1 COMMENT '乐观锁', |
| | | CREATED_BY VARCHAR(32) DEFAULT 'system' COMMENT '创建人', |
| | | CREATED_TIME DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', |
| | | UPDATED_BY VARCHAR(32) DEFAULT 'system' COMMENT '更新人', |
| | | UPDATED_TIME DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', |
| | | SITE_NAME VARCHAR(100) NOT NULL COMMENT '站点名称', |
| | | DOMAIN VARCHAR(200) NOT NULL COMMENT '站点域名', |
| | | NGINX_CONF VARCHAR(255) COMMENT 'Nginx配置文件路径', |
| | | STATUS TINYINT DEFAULT 1 COMMENT '状态:1-启用,0-禁用', |
| | | REMARK VARCHAR(255) COMMENT '备注', |
| | | PRIMARY KEY (ID), |
| | | UNIQUE KEY uk_domain (DOMAIN) |
| | | ) COMMENT = '防火墙站点表'; |
| | | |
| | | -- ============================================================ |
| | | -- 国家白名单规则表 |
| | | -- ============================================================ |
| | | DROP TABLE IF EXISTS firewall_country_rule; |
| | | CREATE TABLE firewall_country_rule ( |
| | | ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', |
| | | REVISION INT DEFAULT 1 COMMENT '乐观锁', |
| | | CREATED_BY VARCHAR(32) DEFAULT 'system' COMMENT '创建人', |
| | | CREATED_TIME DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', |
| | | UPDATED_BY VARCHAR(32) DEFAULT 'system' COMMENT '更新人', |
| | | UPDATED_TIME DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', |
| | | SITE_ID BIGINT NOT NULL COMMENT '站点ID,关联firewall_site表', |
| | | COUNTRY_CODE VARCHAR(10) NOT NULL COMMENT '国家编码(ISO 3166-1 alpha-2,如 JP/US/SG)', |
| | | COUNTRY_NAME VARCHAR(50) COMMENT '国家名称', |
| | | ENABLED TINYINT DEFAULT 1 COMMENT '状态:1-启用,0-禁用', |
| | | PRIMARY KEY (ID), |
| | | UNIQUE KEY uk_site_country (SITE_ID, COUNTRY_CODE), |
| | | INDEX idx_site_id (SITE_ID) |
| | | ) COMMENT = '防火墙国家白名单规则表'; |
| | | |
| | | -- ============================================================ |
| | | -- IP白名单表 |
| | | -- ============================================================ |
| | | DROP TABLE IF EXISTS firewall_ip_white; |
| | | CREATE TABLE firewall_ip_white ( |
| | | ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', |
| | | REVISION INT DEFAULT 1 COMMENT '乐观锁', |
| | | CREATED_BY VARCHAR(32) DEFAULT 'system' COMMENT '创建人', |
| | | CREATED_TIME DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', |
| | | UPDATED_BY VARCHAR(32) DEFAULT 'system' COMMENT '更新人', |
| | | UPDATED_TIME DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', |
| | | SITE_ID BIGINT NOT NULL COMMENT '站点ID', |
| | | IP VARCHAR(64) NOT NULL COMMENT 'IP地址(支持IPv4/IPv6)', |
| | | REMARK VARCHAR(255) COMMENT '备注', |
| | | EXPIRE_TIME DATETIME COMMENT '过期时间,NULL表示永久有效', |
| | | ENABLED TINYINT DEFAULT 1 COMMENT '状态:1-启用,0-禁用', |
| | | PRIMARY KEY (ID), |
| | | UNIQUE KEY uk_site_ip (SITE_ID, IP), |
| | | INDEX idx_ip (IP) |
| | | ) COMMENT = '防火墙IP白名单表'; |
| | | |
| | | -- ---------------------------- |
| | | -- 默认数据 |
| | | -- ---------------------------- |
| | | INSERT INTO firewall_site (SITE_NAME, DOMAIN, STATUS) VALUES |
| | | ('默认站点', 'shop.xxx.com', 1); |
| New file |
| | |
| | | # Nginx 安全 reload 脚本 |
| | | # 路径: /usr/local/bin/nginx_reload.sh |
| | | # 权限: chmod +x /usr/local/bin/nginx_reload.sh |
| | | # sudo配置: springboot ALL=(ALL) NOPASSWD: /usr/local/bin/nginx_reload.sh |
| | | |
| | | # 用途: |
| | | # - 防止直接执行 nginx 命令 |
| | | # - 支持 sudo 授权最小权限原则 |
| | | # - SpringBoot 通过 sudo /usr/local/bin/nginx_reload.sh 调用 |
| | | |
| | | #!/bin/bash |
| | | |
| | | # 检查 nginx 配置文件语法 |
| | | nginx -t |
| | | |
| | | # 语法检查通过后 reload |
| | | if [ $? -eq 0 ]; then |
| | | nginx -s reload |
| | | exit $? |
| | | else |
| | | echo "Nginx config check failed, aborting reload" >&2 |
| | | exit 1 |
| | | fi |
| New file |
| | |
| | | package cc.mrbird.febs.firewall.controller; |
| | | |
| | | import cc.mrbird.febs.common.annotation.ControllerEndpoint; |
| | | import cc.mrbird.febs.common.controller.BaseController; |
| | | import cc.mrbird.febs.common.entity.FebsResponse; |
| | | import cc.mrbird.febs.common.entity.QueryRequest; |
| | | import cc.mrbird.febs.firewall.dto.CountryVo; |
| | | import cc.mrbird.febs.firewall.dto.SaveCountryRuleDto; |
| | | import cc.mrbird.febs.firewall.entity.FirewallCountryRule; |
| | | import cc.mrbird.febs.firewall.entity.FirewallSite; |
| | | import cc.mrbird.febs.firewall.mapper.FirewallSiteMapper; |
| | | import cc.mrbird.febs.firewall.service.IFirewallRuleService; |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import lombok.RequiredArgsConstructor; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.validation.annotation.Validated; |
| | | import org.springframework.web.bind.annotation.GetMapping; |
| | | import org.springframework.web.bind.annotation.PathVariable; |
| | | import org.springframework.web.bind.annotation.PostMapping; |
| | | import org.springframework.web.bind.annotation.RequestBody; |
| | | import org.springframework.web.bind.annotation.RequestMapping; |
| | | import org.springframework.web.bind.annotation.RestController; |
| | | |
| | | import javax.validation.constraints.NotNull; |
| | | import java.util.HashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | /** |
| | | * 防火墙管理控制器 |
| | | * <p> |
| | | * 提供站点、国家白名单的完整 CRUD + Nginx配置生成 + reload 等管理接口 |
| | | * |
| | | * @author auto-generated |
| | | * @date 2026-07-31 |
| | | */ |
| | | @Slf4j |
| | | @Validated |
| | | @RestController |
| | | @RequiredArgsConstructor |
| | | @RequestMapping(value = "/admin/firewall") |
| | | public class AdminFirewallController extends BaseController { |
| | | |
| | | private final IFirewallRuleService firewallRuleService; |
| | | private final FirewallSiteMapper firewallSiteMapper; |
| | | |
| | | // ==================== 站点数据表 CRUD ==================== |
| | | |
| | | /** |
| | | * 站点分页列表 |
| | | */ |
| | | @GetMapping("/siteList") |
| | | public FebsResponse siteList(FirewallSite site, QueryRequest request) { |
| | | Map<String, Object> data = getDataTable(firewallRuleService.siteList(site, request)); |
| | | return new FebsResponse().success().data(data); |
| | | } |
| | | |
| | | /** |
| | | * 保存/更新站点 |
| | | */ |
| | | @PostMapping("/site/save") |
| | | @ControllerEndpoint(operation = "保存防火墙站点") |
| | | public FebsResponse saveSite(@Validated FirewallSite site) { |
| | | return firewallRuleService.saveSite(site); |
| | | } |
| | | |
| | | /** |
| | | * 删除站点 |
| | | */ |
| | | @GetMapping("/site/delete/{id}") |
| | | @ControllerEndpoint(operation = "删除防火墙站点") |
| | | public FebsResponse deleteSite(@PathVariable @NotNull Long id) { |
| | | return firewallRuleService.deleteSite(id); |
| | | } |
| | | |
| | | /** |
| | | * 查询所有启用站点(下拉框用) |
| | | */ |
| | | @GetMapping("/sites") |
| | | public FebsResponse listSites() { |
| | | List<FirewallSite> sites = firewallSiteMapper.selectList( |
| | | new LambdaQueryWrapper<FirewallSite>() |
| | | .eq(FirewallSite::getStatus, 1) |
| | | ); |
| | | |
| | | List<Map<String, Object>> result = new java.util.ArrayList<>(); |
| | | for (FirewallSite site : sites) { |
| | | Map<String, Object> item = new HashMap<>(); |
| | | item.put("id", site.getId()); |
| | | item.put("siteName", site.getSiteName()); |
| | | item.put("domain", site.getDomain()); |
| | | item.put("status", site.getStatus()); |
| | | result.add(item); |
| | | } |
| | | return new FebsResponse().success().data(result); |
| | | } |
| | | |
| | | // ==================== 国家规则数据表 CRUD ==================== |
| | | |
| | | /** |
| | | * 国家规则分页列表 |
| | | */ |
| | | @GetMapping("/countryRuleList") |
| | | public FebsResponse countryRuleList(FirewallCountryRule rule, QueryRequest request) { |
| | | Map<String, Object> data = getDataTable(firewallRuleService.countryRuleList(rule, request)); |
| | | return new FebsResponse().success().data(data); |
| | | } |
| | | |
| | | /** |
| | | * 保存/更新单个国家规则(保存后自动生成Nginx配置) |
| | | */ |
| | | @PostMapping("/countryRule/save") |
| | | @ControllerEndpoint(operation = "保存防火墙国家规则") |
| | | public FebsResponse saveCountryRuleSingle(@Validated FirewallCountryRule rule) { |
| | | return firewallRuleService.saveCountryRuleSingle(rule); |
| | | } |
| | | |
| | | /** |
| | | * 删除单个国家规则(删除后自动生成Nginx配置) |
| | | */ |
| | | @GetMapping("/countryRule/delete/{id}") |
| | | @ControllerEndpoint(operation = "删除防火墙国家规则") |
| | | public FebsResponse deleteCountryRule(@PathVariable @NotNull Long id) { |
| | | return firewallRuleService.deleteCountryRule(id); |
| | | } |
| | | |
| | | // ==================== 国家白名单(原有接口) ==================== |
| | | |
| | | /** |
| | | * 查询站点允许的国家列表 |
| | | */ |
| | | @GetMapping("/site/{siteId}/countries") |
| | | public FebsResponse getCountries(@PathVariable @NotNull Long siteId) { |
| | | List<CountryVo> countries = firewallRuleService.getCountries(siteId); |
| | | return new FebsResponse().success().data(countries); |
| | | } |
| | | |
| | | /** |
| | | * 批量保存站点国家白名单规则(交互式checkbox页面用) |
| | | */ |
| | | @PostMapping("/site/country/save") |
| | | @ControllerEndpoint(operation = "批量保存防火墙国家白名单") |
| | | public FebsResponse saveCountryRule(@RequestBody @Validated SaveCountryRuleDto dto) { |
| | | firewallRuleService.saveCountryRule(dto.getSiteId(), dto.getCountries()); |
| | | return new FebsResponse().success().message("保存成功,Nginx 配置已更新"); |
| | | } |
| | | |
| | | // ==================== Nginx Reload ==================== |
| | | |
| | | /** |
| | | * 手动触发 Nginx 配置生成 & reload |
| | | */ |
| | | @PostMapping("/site/{siteId}/reload") |
| | | @ControllerEndpoint(operation = "手动reload Nginx配置") |
| | | public FebsResponse triggerReload(@PathVariable @NotNull Long siteId) { |
| | | firewallRuleService.triggerNginxReload(siteId); |
| | | return new FebsResponse().success().message("Nginx reload 信号已发送"); |
| | | } |
| | | } |
| New file |
| | |
| | | package cc.mrbird.febs.firewall.dto; |
| | | |
| | | import lombok.Data; |
| | | |
| | | /** |
| | | * 国家信息 VO |
| | | * |
| | | * @author auto-generated |
| | | * @date 2026-07-31 |
| | | */ |
| | | @Data |
| | | public class CountryVo { |
| | | |
| | | /** 国家编码 */ |
| | | private String code; |
| | | |
| | | /** 国家名称 */ |
| | | private String name; |
| | | } |
| New file |
| | |
| | | package cc.mrbird.febs.firewall.dto; |
| | | |
| | | import lombok.Data; |
| | | |
| | | import javax.validation.constraints.NotEmpty; |
| | | import javax.validation.constraints.NotNull; |
| | | import java.util.List; |
| | | |
| | | /** |
| | | * 国家白名单保存请求 DTO |
| | | * |
| | | * @author auto-generated |
| | | * @date 2026-07-31 |
| | | */ |
| | | @Data |
| | | public class SaveCountryRuleDto { |
| | | |
| | | @NotNull(message = "站点ID不能为空") |
| | | private Long siteId; |
| | | |
| | | @NotEmpty(message = "国家列表不能为空") |
| | | private List<String> countries; |
| | | } |
| New file |
| | |
| | | package cc.mrbird.febs.firewall.entity; |
| | | |
| | | import cc.mrbird.febs.common.entity.BaseEntity; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import lombok.Data; |
| | | import lombok.EqualsAndHashCode; |
| | | |
| | | /** |
| | | * 防火墙国家白名单规则表 |
| | | * |
| | | * @author auto-generated |
| | | * @date 2026-07-31 |
| | | */ |
| | | @Data |
| | | @EqualsAndHashCode(callSuper = true) |
| | | @TableName("firewall_country_rule") |
| | | public class FirewallCountryRule extends BaseEntity { |
| | | |
| | | /** 站点ID */ |
| | | private Long siteId; |
| | | |
| | | /** 国家编码(ISO 3166-1 alpha-2,如 JP/US/SG) */ |
| | | private String countryCode; |
| | | |
| | | /** 国家名称 */ |
| | | private String countryName; |
| | | |
| | | /** 状态:1-启用,0-禁用 */ |
| | | private Integer enabled; |
| | | } |
| New file |
| | |
| | | package cc.mrbird.febs.firewall.entity; |
| | | |
| | | import cc.mrbird.febs.common.entity.BaseEntity; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import lombok.Data; |
| | | import lombok.EqualsAndHashCode; |
| | | |
| | | import java.util.Date; |
| | | |
| | | /** |
| | | * 防火墙IP白名单表 |
| | | * |
| | | * @author auto-generated |
| | | * @date 2026-07-31 |
| | | */ |
| | | @Data |
| | | @EqualsAndHashCode(callSuper = true) |
| | | @TableName("firewall_ip_white") |
| | | public class FirewallIpWhite extends BaseEntity { |
| | | |
| | | /** 站点ID */ |
| | | private Long siteId; |
| | | |
| | | /** IP地址 */ |
| | | private String ip; |
| | | |
| | | /** 备注 */ |
| | | private String remark; |
| | | |
| | | /** 过期时间 */ |
| | | private Date expireTime; |
| | | |
| | | /** 状态:1-启用,0-禁用 */ |
| | | private Integer enabled; |
| | | } |
| New file |
| | |
| | | package cc.mrbird.febs.firewall.entity; |
| | | |
| | | import cc.mrbird.febs.common.entity.BaseEntity; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import lombok.Data; |
| | | import lombok.EqualsAndHashCode; |
| | | |
| | | /** |
| | | * 防火墙站点表 |
| | | * |
| | | * @author auto-generated |
| | | * @date 2026-07-31 |
| | | */ |
| | | @Data |
| | | @EqualsAndHashCode(callSuper = true) |
| | | @TableName("firewall_site") |
| | | public class FirewallSite extends BaseEntity { |
| | | |
| | | /** 站点名称 */ |
| | | private String siteName; |
| | | |
| | | /** 站点域名 */ |
| | | private String domain; |
| | | |
| | | /** Nginx配置文件路径 */ |
| | | private String nginxConf; |
| | | |
| | | /** 状态:1-启用,0-禁用 */ |
| | | private Integer status; |
| | | |
| | | /** 备注 */ |
| | | private String remark; |
| | | } |
| New file |
| | |
| | | package cc.mrbird.febs.firewall.mapper; |
| | | |
| | | import cc.mrbird.febs.firewall.entity.FirewallCountryRule; |
| | | import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | import org.apache.ibatis.annotations.Param; |
| | | |
| | | import java.util.List; |
| | | |
| | | /** |
| | | * 防火墙国家规则 Mapper |
| | | * |
| | | * @author auto-generated |
| | | * @date 2026-07-31 |
| | | */ |
| | | @Mapper |
| | | public interface FirewallCountryRuleMapper extends BaseMapper<FirewallCountryRule> { |
| | | |
| | | /** |
| | | * 查询站点下所有启用的国家编码 |
| | | * |
| | | * @param siteId 站点ID |
| | | * @return 国家编码列表 |
| | | */ |
| | | List<String> selectEnableCountry(@Param("siteId") Long siteId); |
| | | } |
| New file |
| | |
| | | package cc.mrbird.febs.firewall.mapper; |
| | | |
| | | import cc.mrbird.febs.firewall.entity.FirewallIpWhite; |
| | | import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | |
| | | /** |
| | | * 防火墙IP白名单 Mapper |
| | | * |
| | | * @author auto-generated |
| | | * @date 2026-07-31 |
| | | */ |
| | | @Mapper |
| | | public interface FirewallIpWhiteMapper extends BaseMapper<FirewallIpWhite> { |
| | | } |
| New file |
| | |
| | | package cc.mrbird.febs.firewall.mapper; |
| | | |
| | | import cc.mrbird.febs.firewall.entity.FirewallSite; |
| | | import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | |
| | | /** |
| | | * 防火墙站点 Mapper |
| | | * |
| | | * @author auto-generated |
| | | * @date 2026-07-31 |
| | | */ |
| | | @Mapper |
| | | public interface FirewallSiteMapper extends BaseMapper<FirewallSite> { |
| | | } |
| New file |
| | |
| | | package cc.mrbird.febs.firewall.nginx; |
| | | |
| | | import cc.mrbird.febs.firewall.entity.FirewallSite; |
| | | import cc.mrbird.febs.firewall.mapper.FirewallCountryRuleMapper; |
| | | import cc.mrbird.febs.firewall.mapper.FirewallSiteMapper; |
| | | import cn.hutool.core.io.FileUtil; |
| | | import lombok.RequiredArgsConstructor; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | import java.io.File; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.util.List; |
| | | |
| | | /** |
| | | * Nginx 国家黑名单配置文件生成器 |
| | | * <p> |
| | | * 默认放行所有国家,仅拦截配置中选中的国家(黑名单模式)。 |
| | | * <p> |
| | | * 输出文件: /etc/nginx/conf.d/firewall_country_{siteId}.conf |
| | | * <p> |
| | | * 不直接执行 reload,仅负责文件生成。 |
| | | * reload 由 NginxReloadTask 消费 Redis 队列后执行,防止误操作。 |
| | | * |
| | | * @author auto-generated |
| | | * @date 2026-07-31 |
| | | */ |
| | | @Slf4j |
| | | @Component |
| | | @RequiredArgsConstructor |
| | | public class NginxConfigGenerator { |
| | | |
| | | private final FirewallCountryRuleMapper mapper; |
| | | private final FirewallSiteMapper siteMapper; |
| | | |
| | | /** Nginx 配置目录 */ |
| | | private static final String NGINX_CONF_DIR = "/etc/nginx/conf.d/"; |
| | | /** 配置文件前缀 */ |
| | | private static final String CONF_PREFIX = "firewall_country_"; |
| | | |
| | | /** |
| | | * 生成 Nginx 国家黑名单 map 配置 |
| | | * <pre> |
| | | * map $geoip_country_code $allow_country { |
| | | * default 1; |
| | | * JP 0; |
| | | * US 0; |
| | | * } |
| | | * </pre> |
| | | * default 1 表示默认放行,列表中国家设为 0 表示拦截。 |
| | | * Nginx 中判断: if ($allow_country_1 = 0) { return 403; } |
| | | * |
| | | * @param siteId 站点ID |
| | | */ |
| | | public void generate(Long siteId) { |
| | | // 查询站点配置路径 |
| | | FirewallSite site = siteMapper.selectById(siteId); |
| | | if (site == null) { |
| | | log.warn("防火墙站点不存在: siteId={}", siteId); |
| | | return; |
| | | } |
| | | |
| | | List<String> countries = mapper.selectEnableCountry(siteId); |
| | | |
| | | StringBuilder sb = new StringBuilder(); |
| | | sb.append("# Firewall Country Block Map (Blacklist)\n"); |
| | | sb.append("# Site: ").append(site.getSiteName()).append(" (").append(site.getDomain()).append(")\n"); |
| | | sb.append("# Generated at: ").append(new java.util.Date()).append("\n\n"); |
| | | sb.append("map $geoip_country_code $allow_country_").append(siteId).append(" {\n"); |
| | | sb.append(" default 1;\n"); |
| | | |
| | | for (String c : countries) { |
| | | sb.append(" ").append(c.toUpperCase()).append(" 0;\n"); |
| | | } |
| | | |
| | | sb.append("}\n"); |
| | | |
| | | // 写入文件 |
| | | String confFileName = site.getNginxConf(); |
| | | if (confFileName == null || confFileName.isEmpty()) { |
| | | confFileName = NGINX_CONF_DIR + CONF_PREFIX + siteId + ".conf"; |
| | | } |
| | | |
| | | File confFile = new File(confFileName); |
| | | try { |
| | | FileUtil.writeString(sb.toString(), confFile, StandardCharsets.UTF_8); |
| | | log.info("Nginx 黑名单配置已生成: {}, 拦截国家: {}", confFile.getAbsolutePath(), countries); |
| | | } catch (Exception e) { |
| | | log.error("Nginx 配置文件写入失败: path={}", confFile.getAbsolutePath(), e); |
| | | throw new RuntimeException("Nginx 配置文件写入失败: " + e.getMessage(), e); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | package cc.mrbird.febs.firewall.service; |
| | | |
| | | import cc.mrbird.febs.common.entity.FebsResponse; |
| | | import cc.mrbird.febs.common.entity.QueryRequest; |
| | | import cc.mrbird.febs.firewall.dto.CountryVo; |
| | | import cc.mrbird.febs.firewall.entity.FirewallCountryRule; |
| | | import cc.mrbird.febs.firewall.entity.FirewallSite; |
| | | import com.baomidou.mybatisplus.core.metadata.IPage; |
| | | |
| | | import java.util.List; |
| | | |
| | | /** |
| | | * 防火墙规则 Service 接口 |
| | | * |
| | | * @author auto-generated |
| | | * @date 2026-07-31 |
| | | */ |
| | | public interface IFirewallRuleService { |
| | | |
| | | // ==================== 站点管理 ==================== |
| | | |
| | | /** |
| | | * 站点分页列表 |
| | | */ |
| | | IPage<FirewallSite> siteList(FirewallSite site, QueryRequest request); |
| | | |
| | | /** |
| | | * 站点保存/更新 |
| | | */ |
| | | FebsResponse saveSite(FirewallSite site); |
| | | |
| | | /** |
| | | * 站点删除 |
| | | */ |
| | | FebsResponse deleteSite(Long id); |
| | | |
| | | // ==================== 国家规则管理 ==================== |
| | | |
| | | /** |
| | | * 国家规则分页列表 |
| | | */ |
| | | IPage<FirewallCountryRule> countryRuleList(FirewallCountryRule rule, QueryRequest request); |
| | | |
| | | /** |
| | | * 单个国家规则保存/更新(保存后自动生成Nginx配置) |
| | | */ |
| | | FebsResponse saveCountryRuleSingle(FirewallCountryRule rule); |
| | | |
| | | /** |
| | | * 单个国家规则删除(删除后自动生成Nginx配置) |
| | | */ |
| | | FebsResponse deleteCountryRule(Long id); |
| | | |
| | | /** |
| | | * 查询站点允许的国家列表 |
| | | */ |
| | | List<CountryVo> getCountries(Long siteId); |
| | | |
| | | /** |
| | | * 批量保存站点国家白名单规则(删旧→插新→生成配置→reload信号) |
| | | */ |
| | | void saveCountryRule(Long siteId, List<String> countries); |
| | | |
| | | /** |
| | | * 手动触发 Nginx 配置生成 & reload |
| | | */ |
| | | void triggerNginxReload(Long siteId); |
| | | } |
| New file |
| | |
| | | package cc.mrbird.febs.firewall.service.impl; |
| | | |
| | | import cc.mrbird.febs.common.entity.FebsResponse; |
| | | import cc.mrbird.febs.common.entity.QueryRequest; |
| | | import cc.mrbird.febs.common.utils.RedisUtils; |
| | | import cc.mrbird.febs.firewall.dto.CountryVo; |
| | | import cc.mrbird.febs.firewall.entity.FirewallCountryRule; |
| | | import cc.mrbird.febs.firewall.entity.FirewallSite; |
| | | import cc.mrbird.febs.firewall.mapper.FirewallCountryRuleMapper; |
| | | import cc.mrbird.febs.firewall.mapper.FirewallSiteMapper; |
| | | import cc.mrbird.febs.firewall.nginx.NginxConfigGenerator; |
| | | import cc.mrbird.febs.firewall.service.IFirewallRuleService; |
| | | import cn.hutool.core.util.StrUtil; |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.baomidou.mybatisplus.core.metadata.IPage; |
| | | import com.baomidou.mybatisplus.extension.plugins.pagination.Page; |
| | | import lombok.RequiredArgsConstructor; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | | |
| | | import java.util.ArrayList; |
| | | import java.util.List; |
| | | |
| | | /** |
| | | * 防火墙规则 Service 实现 |
| | | * |
| | | * @author auto-generated |
| | | * @date 2026-07-31 |
| | | */ |
| | | @Slf4j |
| | | @Service |
| | | @RequiredArgsConstructor |
| | | public class FirewallRuleServiceImpl implements IFirewallRuleService { |
| | | |
| | | private final FirewallCountryRuleMapper countryRuleMapper; |
| | | private final FirewallSiteMapper siteMapper; |
| | | private final NginxConfigGenerator generator; |
| | | private final RedisUtils redisUtils; |
| | | |
| | | /** Redis 中 Nginx reload 任务队列 key */ |
| | | private static final String NGINX_RELOAD_QUEUE_KEY = "firewall:nginx_reload_queue"; |
| | | |
| | | // ==================== 站点管理 ==================== |
| | | |
| | | @Override |
| | | public IPage<FirewallSite> siteList(FirewallSite site, QueryRequest request) { |
| | | Page<FirewallSite> page = new Page<>(request.getPageNum(), request.getPageSize()); |
| | | LambdaQueryWrapper<FirewallSite> wrapper = new LambdaQueryWrapper<FirewallSite>() |
| | | .like(StrUtil.isNotBlank(site.getSiteName()), FirewallSite::getSiteName, site.getSiteName()) |
| | | .like(StrUtil.isNotBlank(site.getDomain()), FirewallSite::getDomain, site.getDomain()) |
| | | .eq(site.getStatus() != null, FirewallSite::getStatus, site.getStatus()) |
| | | .orderByDesc(FirewallSite::getCreatedTime); |
| | | return siteMapper.selectPage(page, wrapper); |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public FebsResponse saveSite(FirewallSite site) { |
| | | if (site.getId() != null) { |
| | | // 更新 |
| | | FirewallSite exist = siteMapper.selectById(site.getId()); |
| | | if (exist == null) { |
| | | return new FebsResponse().fail().message("站点不存在"); |
| | | } |
| | | siteMapper.updateById(site); |
| | | log.info("防火墙站点已更新: id={}, name={}", site.getId(), site.getSiteName()); |
| | | } else { |
| | | // 新增 |
| | | siteMapper.insert(site); |
| | | // 回填 nginxConf(如果没填则用默认值) |
| | | if (StrUtil.isBlank(site.getNginxConf())) { |
| | | site.setNginxConf("/etc/nginx/conf.d/firewall_country_" + site.getId() + ".conf"); |
| | | siteMapper.updateById(site); |
| | | } |
| | | log.info("防火墙站点已新增: id={}, name={}", site.getId(), site.getSiteName()); |
| | | } |
| | | return new FebsResponse().success().message("保存成功"); |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public FebsResponse deleteSite(Long id) { |
| | | FirewallSite site = siteMapper.selectById(id); |
| | | if (site == null) { |
| | | return new FebsResponse().fail().message("站点不存在"); |
| | | } |
| | | // 删除站点关联的国家规则 |
| | | countryRuleMapper.delete(new LambdaQueryWrapper<FirewallCountryRule>() |
| | | .eq(FirewallCountryRule::getSiteId, id)); |
| | | // 删除Nginx配置文件 |
| | | if (StrUtil.isNotBlank(site.getNginxConf())) { |
| | | cn.hutool.core.io.FileUtil.del(site.getNginxConf()); |
| | | } |
| | | siteMapper.deleteById(id); |
| | | log.info("防火墙站点已删除: id={}, name={}", id, site.getSiteName()); |
| | | return new FebsResponse().success().message("删除成功"); |
| | | } |
| | | |
| | | // ==================== 国家规则管理 ==================== |
| | | |
| | | @Override |
| | | public IPage<FirewallCountryRule> countryRuleList(FirewallCountryRule rule, QueryRequest request) { |
| | | Page<FirewallCountryRule> page = new Page<>(request.getPageNum(), request.getPageSize()); |
| | | LambdaQueryWrapper<FirewallCountryRule> wrapper = new LambdaQueryWrapper<FirewallCountryRule>() |
| | | .eq(rule.getSiteId() != null, FirewallCountryRule::getSiteId, rule.getSiteId()) |
| | | .eq(rule.getEnabled() != null, FirewallCountryRule::getEnabled, rule.getEnabled()) |
| | | .like(StrUtil.isNotBlank(rule.getCountryName()), FirewallCountryRule::getCountryName, rule.getCountryName()) |
| | | .orderByDesc(FirewallCountryRule::getCreatedTime); |
| | | return countryRuleMapper.selectPage(page, wrapper); |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public FebsResponse saveCountryRuleSingle(FirewallCountryRule rule) { |
| | | if (rule.getSiteId() == null) { |
| | | return new FebsResponse().fail().message("请选择站点"); |
| | | } |
| | | if (StrUtil.isBlank(rule.getCountryCode())) { |
| | | return new FebsResponse().fail().message("国家编码不能为空"); |
| | | } |
| | | rule.setCountryCode(rule.getCountryCode().toUpperCase()); |
| | | |
| | | if (rule.getId() != null) { |
| | | countryRuleMapper.updateById(rule); |
| | | log.info("防火墙国家规则已更新: id={}, siteId={}, country={}", rule.getId(), rule.getSiteId(), rule.getCountryCode()); |
| | | } else { |
| | | // 检查同站点下是否已存在相同国家编码 |
| | | Integer count = countryRuleMapper.selectCount(new LambdaQueryWrapper<FirewallCountryRule>() |
| | | .eq(FirewallCountryRule::getSiteId, rule.getSiteId()) |
| | | .eq(FirewallCountryRule::getCountryCode, rule.getCountryCode())); |
| | | if (count > 0) { |
| | | return new FebsResponse().fail().message("该站点下已存在相同国家编码: " + rule.getCountryCode()); |
| | | } |
| | | countryRuleMapper.insert(rule); |
| | | log.info("防火墙国家规则已新增: siteId={}, country={}", rule.getSiteId(), rule.getCountryCode()); |
| | | } |
| | | |
| | | // 保存后自动生成Nginx配置并发送reload信号 |
| | | regenerateNginxConfig(rule.getSiteId()); |
| | | return new FebsResponse().success().message("保存成功,Nginx 配置已更新"); |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public FebsResponse deleteCountryRule(Long id) { |
| | | FirewallCountryRule rule = countryRuleMapper.selectById(id); |
| | | if (rule == null) { |
| | | return new FebsResponse().fail().message("规则不存在"); |
| | | } |
| | | Long siteId = rule.getSiteId(); |
| | | countryRuleMapper.deleteById(id); |
| | | log.info("防火墙国家规则已删除: id={}, siteId={}, country={}", id, siteId, rule.getCountryCode()); |
| | | |
| | | // 删除后自动生成Nginx配置并发送reload信号 |
| | | regenerateNginxConfig(siteId); |
| | | return new FebsResponse().success().message("删除成功,Nginx 配置已更新"); |
| | | } |
| | | |
| | | // ==================== 内部方法 ==================== |
| | | |
| | | /** 重新生成Nginx配置并发送reload信号 */ |
| | | private void regenerateNginxConfig(Long siteId) { |
| | | try { |
| | | generator.generate(siteId); |
| | | redisUtils.lSet(NGINX_RELOAD_QUEUE_KEY, String.valueOf(siteId)); |
| | | log.info("Nginx reload 信号已入队: siteId={}", siteId); |
| | | } catch (Exception e) { |
| | | log.error("Nginx 配置生成失败: siteId={}", siteId, e); |
| | | } |
| | | } |
| | | |
| | | // ==================== 原有方法 ==================== |
| | | |
| | | @Override |
| | | public List<CountryVo> getCountries(Long siteId) { |
| | | List<FirewallCountryRule> rules = countryRuleMapper.selectList( |
| | | new LambdaQueryWrapper<FirewallCountryRule>() |
| | | .eq(FirewallCountryRule::getSiteId, siteId) |
| | | .eq(FirewallCountryRule::getEnabled, 1) |
| | | ); |
| | | |
| | | List<CountryVo> result = new ArrayList<>(); |
| | | for (FirewallCountryRule rule : rules) { |
| | | CountryVo vo = new CountryVo(); |
| | | vo.setCode(rule.getCountryCode()); |
| | | vo.setName(rule.getCountryName()); |
| | | result.add(vo); |
| | | } |
| | | return result; |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public void saveCountryRule(Long siteId, List<String> countries) { |
| | | // 1. 删除旧规则 |
| | | countryRuleMapper.delete(new LambdaQueryWrapper<FirewallCountryRule>() |
| | | .eq(FirewallCountryRule::getSiteId, siteId)); |
| | | |
| | | // 2. 批量插入新规则 |
| | | for (String country : countries) { |
| | | FirewallCountryRule rule = new FirewallCountryRule(); |
| | | rule.setSiteId(siteId); |
| | | rule.setCountryCode(country.toUpperCase()); |
| | | rule.setCountryName(country); |
| | | rule.setEnabled(1); |
| | | countryRuleMapper.insert(rule); |
| | | } |
| | | |
| | | log.info("防火墙国家规则已更新: siteId={}, countries={}", siteId, countries); |
| | | |
| | | // 3. 生成 Nginx 配置文件 |
| | | generator.generate(siteId); |
| | | |
| | | // 4. 发送 reload 信号 |
| | | redisUtils.lSet(NGINX_RELOAD_QUEUE_KEY, String.valueOf(siteId)); |
| | | log.info("Nginx reload 信号已入队: siteId={}", siteId); |
| | | } |
| | | |
| | | @Override |
| | | public void triggerNginxReload(Long siteId) { |
| | | generator.generate(siteId); |
| | | redisUtils.lSet(NGINX_RELOAD_QUEUE_KEY, String.valueOf(siteId)); |
| | | log.info("手动触发 Nginx reload: siteId={}", siteId); |
| | | } |
| | | } |
| New file |
| | |
| | | package cc.mrbird.febs.firewall.task; |
| | | |
| | | import cc.mrbird.febs.common.utils.RedisUtils; |
| | | import lombok.RequiredArgsConstructor; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.scheduling.annotation.Scheduled; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | /** |
| | | * Nginx Reload 后台任务 |
| | | * <p> |
| | | * 每 10 秒轮询 Redis 队列,有任务时执行 nginx reload。 |
| | | * <p> |
| | | * 设计目的: |
| | | * 1. 避免 SpringBoot 直接调用系统命令 |
| | | * 2. 支持批量合并 reload(多次修改只 reload 一次) |
| | | * 3. 防止误操作频繁 reload |
| | | * |
| | | * @author auto-generated |
| | | * @date 2026-07-31 |
| | | */ |
| | | @Slf4j |
| | | @Component |
| | | @RequiredArgsConstructor |
| | | public class NginxReloadTask { |
| | | |
| | | private final RedisUtils redisUtils; |
| | | |
| | | /** Redis reload 任务队列 key */ |
| | | private static final String NGINX_RELOAD_QUEUE_KEY = "firewall:nginx_reload_queue"; |
| | | /** reload 脚本路径 */ |
| | | private static final String RELOAD_SCRIPT = "/usr/local/bin/nginx_reload.sh"; |
| | | /** Nginx 命令(备用) */ |
| | | private static final String NGINX_CMD = "nginx -s reload"; |
| | | |
| | | /** |
| | | * 每 10 秒检查并消费 reload 队列 |
| | | */ |
| | | @Scheduled(fixedDelay = 10_000) |
| | | public void checkAndReload() { |
| | | // 批量消费队列中所有待 reload 任务 |
| | | int count = 0; |
| | | while (true) { |
| | | Object item = redisUtils.lGetIndex(NGINX_RELOAD_QUEUE_KEY, 0); |
| | | if (item == null) { |
| | | break; |
| | | } |
| | | count++; |
| | | redisUtils.lRemove(NGINX_RELOAD_QUEUE_KEY, 1, item); |
| | | log.info("消费 Nginx reload 任务: siteId={}", item); |
| | | } |
| | | |
| | | if (count > 0) { |
| | | log.info("开始执行 Nginx reload (合并 {} 个任务)", count); |
| | | executeReload(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 执行 Nginx reload |
| | | * 优先使用 sudo 脚本,失败时回退到直接命令 |
| | | */ |
| | | private void executeReload() { |
| | | // 方案1:通过 sudo 脚本执行(推荐,生产环境) |
| | | try { |
| | | Process process = Runtime.getRuntime().exec(new String[]{"sudo", RELOAD_SCRIPT}); |
| | | process.waitFor(); |
| | | if (process.exitValue() == 0) { |
| | | log.info("Nginx reload 成功 (via sudo script)"); |
| | | return; |
| | | } |
| | | log.warn("Nginx reload 脚本执行返回非0: exitCode={}", process.exitValue()); |
| | | } catch (Exception e) { |
| | | log.warn("Nginx reload 脚本执行失败,尝试直接命令: {}", e.getMessage()); |
| | | } |
| | | |
| | | // 方案2:直接执行 nginx reload(开发/测试环境) |
| | | try { |
| | | Process process = Runtime.getRuntime().exec(NGINX_CMD); |
| | | process.waitFor(); |
| | | if (process.exitValue() == 0) { |
| | | log.info("Nginx reload 成功 (via direct command)"); |
| | | } else { |
| | | log.error("Nginx reload 失败: exitCode={}", process.exitValue()); |
| | | } |
| | | } catch (Exception e) { |
| | | log.error("Nginx reload 执行异常", e); |
| | | } |
| | | } |
| | | } |
| | |
| | | return FebsUtil.view("modules/system/bsPayConfig"); |
| | | } |
| | | |
| | | @GetMapping("firewallCountryList") |
| | | @RequiresPermissions("firewallCountry:view") |
| | | public String firewallCountryList() { |
| | | return FebsUtil.view("modules/system/firewallCountryList"); |
| | | } |
| | | |
| | | @GetMapping("firewallSiteList") |
| | | @RequiresPermissions("firewallSite:view") |
| | | public String firewallSiteList() { |
| | | return FebsUtil.view("modules/system/firewallSiteList"); |
| | | } |
| | | |
| | | @GetMapping("firewallCountryRuleList") |
| | | @RequiresPermissions("firewallCountryRule:view") |
| | | public String firewallCountryRuleList() { |
| | | return FebsUtil.view("modules/system/firewallCountryRuleList"); |
| | | } |
| | | |
| | | @GetMapping("tokenviewConfig") |
| | | @RequiresPermissions("tokenviewConfig:update") |
| | | public String tokenviewConfig(Model model) { |
| | |
| | | max-lifetime: 1800000 |
| | | max-pool-size: 15 |
| | | min-idle: 5 |
| | | idle-timeout: 300000 |
| | | connection-test-query: select 1 |
| | | pool-name: FebsHikariCP |
| | | # 配置默认数据源 |
| New file |
| | |
| | | <?xml version="1.0" encoding="UTF-8"?> |
| | | <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" |
| | | "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> |
| | | <mapper namespace="cc.mrbird.febs.firewall.mapper.FirewallCountryRuleMapper"> |
| | | |
| | | <select id="selectEnableCountry" resultType="string"> |
| | | SELECT COUNTRY_CODE |
| | | FROM firewall_country_rule |
| | | WHERE SITE_ID = #{siteId} |
| | | AND ENABLED = 1 |
| | | </select> |
| | | |
| | | </mapper> |
| New file |
| | |
| | | <div class="layui-fluid layui-anim febs-anim" id="firewall-country-list" lay-title="防火墙国家白名单"> |
| | | <div class="layui-row febs-container"> |
| | | <div class="layui-col-md12"> |
| | | <div class="layui-card"> |
| | | <div class="layui-card-header">站点国家白名单管理</div> |
| | | <div class="layui-card-body"> |
| | | <!-- 站点选择 --> |
| | | <div class="layui-form-item"> |
| | | <label class="layui-form-label">选择站点:</label> |
| | | <div class="layui-input-inline" style="width: 250px;"> |
| | | <select id="site-select" lay-filter="site-select" lay-search=""> |
| | | <option value="">请选择站点</option> |
| | | </select> |
| | | </div> |
| | | <button class="layui-btn layui-btn-normal" type="button" id="reload-btn" style="margin-left: 10px;"> |
| | | <i class="layui-icon"></i> 手动reload |
| | | </button> |
| | | </div> |
| | | |
| | | <hr class="layui-bg-gray"> |
| | | |
| | | <!-- 国家选择区域 --> |
| | | <div id="country-area" style="display:none;"> |
| | | <div class="layui-form-item"> |
| | | <label class="layui-form-label">当前允许国家:</label> |
| | | <div class="layui-input-block" id="current-countries" style="padding-top: 9px;"> |
| | | <span style="color:#999;">暂无数据</span> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="layui-form-item"> |
| | | <label class="layui-form-label">选择国家:</label> |
| | | <div class="layui-input-block" id="country-checkboxes" style="padding-top: 5px;"> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="layui-form-item"> |
| | | <div class="layui-input-block"> |
| | | <button class="layui-btn" lay-submit="" lay-filter="save-country-rule" id="save-btn"> |
| | | <i class="layui-icon"></i> 保存规则 |
| | | </button> |
| | | <span class="layui-word-aux" style="margin-left: 15px;">保存后自动生成Nginx配置并reload</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <style> |
| | | .layui-form-label { width: 130px; } |
| | | .layui-form-item .layui-input-block { margin-left: 160px; } |
| | | .country-tag { |
| | | display: inline-block; |
| | | padding: 2px 12px; |
| | | margin: 2px 4px; |
| | | background: #5FB878; |
| | | color: #fff; |
| | | border-radius: 2px; |
| | | font-size: 13px; |
| | | } |
| | | .country-checkbox-item { |
| | | display: inline-block; |
| | | width: 140px; |
| | | margin: 4px 0; |
| | | } |
| | | </style> |
| | | |
| | | <script data-th-inline="none" type="text/javascript"> |
| | | layui.use(['jquery', 'form', 'febs', 'layer'], function () { |
| | | var $ = layui.jquery, |
| | | febs = layui.febs, |
| | | form = layui.form, |
| | | layer = layui.layer, |
| | | $view = $('#firewall-country-list'), |
| | | currentSiteId = null; |
| | | |
| | | // 常用国家列表 |
| | | var allCountries = [ |
| | | {code: 'BR', name: '巴西'}, |
| | | {code: 'US', name: '美国'}, |
| | | {code: 'JP', name: '日本'}, |
| | | {code: 'KR', name: '韩国'}, |
| | | {code: 'SG', name: '新加坡'}, |
| | | {code: 'GB', name: '英国'}, |
| | | {code: 'DE', name: '德国'}, |
| | | {code: 'FR', name: '法国'}, |
| | | {code: 'AU', name: '澳大利亚'}, |
| | | {code: 'CA', name: '加拿大'}, |
| | | {code: 'IT', name: '意大利'}, |
| | | {code: 'ES', name: '西班牙'}, |
| | | {code: 'NL', name: '荷兰'}, |
| | | {code: 'SE', name: '瑞典'}, |
| | | {code: 'CH', name: '瑞士'}, |
| | | {code: 'MX', name: '墨西哥'}, |
| | | {code: 'AR', name: '阿根廷'}, |
| | | {code: 'CL', name: '智利'}, |
| | | {code: 'CO', name: '哥伦比亚'}, |
| | | {code: 'PE', name: '秘鲁'}, |
| | | {code: 'TH', name: '泰国'}, |
| | | {code: 'VN', name: '越南'}, |
| | | {code: 'MY', name: '马来西亚'}, |
| | | {code: 'ID', name: '印度尼西亚'}, |
| | | {code: 'PH', name: '菲律宾'}, |
| | | {code: 'IN', name: '印度'}, |
| | | {code: 'AE', name: '阿联酋'}, |
| | | {code: 'SA', name: '沙特阿拉伯'}, |
| | | {code: 'ZA', name: '南非'}, |
| | | {code: 'RU', name: '俄罗斯'}, |
| | | {code: 'CN', name: '中国'}, |
| | | {code: 'HK', name: '中国香港'}, |
| | | {code: 'TW', name: '中国台湾'}, |
| | | {code: 'MO', name: '中国澳门'} |
| | | ]; |
| | | |
| | | // 加载站点列表 |
| | | function loadSites() { |
| | | febs.get(ctx + 'admin/firewall/sites', null, function (res) { |
| | | var sites = res.data || []; |
| | | var $select = $('#site-select'); |
| | | $select.empty(); |
| | | $select.append('<option value="">请选择站点</option>'); |
| | | sites.forEach(function (s) { |
| | | $select.append('<option value="' + s.id + '">' + s.siteName + ' (' + s.domain + ')</option>'); |
| | | }); |
| | | form.render('select'); |
| | | }); |
| | | } |
| | | |
| | | // 加载当前站点的国家白名单 |
| | | function loadCountries(siteId) { |
| | | currentSiteId = siteId; |
| | | febs.get(ctx + 'admin/firewall/site/' + siteId + '/countries', null, function (res) { |
| | | var countries = res.data || []; |
| | | var enabledCodes = {}; |
| | | countries.forEach(function (c) { |
| | | enabledCodes[c.code] = true; |
| | | }); |
| | | |
| | | // 先显示区域,再渲染表单元素(layui 在隐藏 DOM 上渲染可能失败) |
| | | $('#country-area').show(); |
| | | |
| | | // 渲染当前允许国家标签 |
| | | var $current = $('#current-countries'); |
| | | if (countries.length === 0) { |
| | | $current.html('<span style="color:#999;">暂无允许的国家</span>'); |
| | | } else { |
| | | var html = ''; |
| | | countries.forEach(function (c) { |
| | | html += '<span class="country-tag">' + c.name + ' (' + c.code + ')</span>'; |
| | | }); |
| | | $current.html(html); |
| | | } |
| | | |
| | | // 渲染复选框(在可见区域中渲染) |
| | | var $checkboxes = $('#country-checkboxes'); |
| | | var checkboxHtml = ''; |
| | | allCountries.forEach(function (c) { |
| | | var checked = enabledCodes[c.code] ? ' checked' : ''; |
| | | checkboxHtml += '<span class="country-checkbox-item">' |
| | | + '<input type="checkbox" name="country" value="' + c.code + '" title="' + c.name + ' (' + c.code + ')" lay-skin="primary"' + checked + '>' |
| | | + '</span>'; |
| | | }); |
| | | $checkboxes.html(checkboxHtml); |
| | | form.render('checkbox'); |
| | | |
| | | // 完整渲染一次表单,确保所有 layui 组件(select/checkbox/button)正确初始化 |
| | | form.render(null, 'firewall-country-list'); |
| | | }); |
| | | } |
| | | |
| | | // 站点切换 |
| | | form.on('select(site-select)', function (data) { |
| | | if (data.value) { |
| | | loadCountries(data.value); |
| | | } else { |
| | | $('#country-area').hide(); |
| | | } |
| | | }); |
| | | |
| | | // 保存规则 |
| | | form.on('submit(save-country-rule)', function () { |
| | | if (!currentSiteId) { |
| | | layer.msg('请先选择站点', {icon: 2}); |
| | | return false; |
| | | } |
| | | |
| | | var countries = []; |
| | | $view.find('input[name="country"]:checked').each(function () { |
| | | countries.push($(this).val()); |
| | | }); |
| | | |
| | | febs.post(ctx + 'admin/firewall/site/country/save', { |
| | | siteId: currentSiteId, |
| | | countries: countries |
| | | }, function (res) { |
| | | layer.msg('保存成功,Nginx 配置已更新', {icon: 1}); |
| | | loadCountries(currentSiteId); |
| | | }); |
| | | return false; |
| | | }); |
| | | |
| | | // 手动reload |
| | | $('#reload-btn').on('click', function () { |
| | | if (!currentSiteId) { |
| | | layer.msg('请先选择站点', {icon: 2}); |
| | | return; |
| | | } |
| | | layer.confirm('确定要手动触发 Nginx reload 吗?', function (index) { |
| | | febs.post(ctx + 'admin/firewall/site/' + currentSiteId + '/reload', {}, function (res) { |
| | | layer.close(index); |
| | | layer.msg('Nginx reload 信号已发送', {icon: 1}); |
| | | }); |
| | | }); |
| | | }); |
| | | |
| | | // 初始化 |
| | | loadSites(); |
| | | }); |
| | | </script> |
| New file |
| | |
| | | <div class="layui-fluid layui-anim febs-anim" id="firewall-country-rule-list" lay-title="防火墙国家规则管理"> |
| | | <div class="layui-row febs-container"> |
| | | <div class="layui-col-md12"> |
| | | <div class="layui-card"> |
| | | <div class="layui-card-body febs-table-full"> |
| | | <form class="layui-form layui-table-form" lay-filter="firewall-rule-table-form"> |
| | | <div class="layui-row"> |
| | | <div class="layui-col-md10"> |
| | | <div class="layui-form-item"> |
| | | <button class="layui-btn layui-btn-normal" type="button" id="add-btn"> |
| | | <i class="layui-icon"></i> 新增规则 |
| | | </button> |
| | | </div> |
| | | </div> |
| | | <div class="layui-col-md2 layui-col-sm12 layui-col-xs12 table-action-area"> |
| | | <div class="layui-btn layui-btn-sm layui-btn-primary febs-button-green-plain table-action" id="reset"> |
| | | <i class="layui-icon"></i> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <div class="layui-row"> |
| | | <div class="layui-col-md3"> |
| | | <div class="layui-form-item"> |
| | | <label class="layui-form-label">选择站点</label> |
| | | <div class="layui-input-block"> |
| | | <select id="filter-site-id" name="siteId" lay-filter="filter-site" lay-search=""> |
| | | <option value="">全部站点</option> |
| | | </select> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </form> |
| | | <table lay-filter="firewallRuleTable" lay-data="{id: 'firewallRuleTable'}"></table> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <!-- 表格操作栏 --> |
| | | <script type="text/html" id="firewall-rule-option"> |
| | | <a lay-event="edit"><i class="layui-icon febs-edit-area febs-blue"></i></a> |
| | | <a lay-event="del"><i class="layui-icon febs-edit-area febs-red"></i></a> |
| | | </script> |
| | | <style> |
| | | .layui-form-label { width: 110px; } |
| | | .layui-form-item .layui-input-block { margin-left: 140px; } |
| | | </style> |
| | | <script data-th-inline="none" type="text/javascript"> |
| | | layui.use(['jquery', 'form', 'table', 'febs'], function () { |
| | | var $ = layui.jquery, |
| | | febs = layui.febs, |
| | | form = layui.form, |
| | | table = layui.table, |
| | | layer = layui.layer, |
| | | $view = $('#firewall-country-rule-list'), |
| | | $reset = $view.find('#reset'), |
| | | $addBtn = $view.find('#add-btn'), |
| | | tableIns; |
| | | |
| | | form.render(); |
| | | loadSiteFilter(); |
| | | initTable(); |
| | | |
| | | // 加载站点下拉选项 |
| | | function loadSiteFilter() { |
| | | febs.get(ctx + 'admin/firewall/sites', null, function (res) { |
| | | var sites = res.data || []; |
| | | var $select = $('#filter-site-id'); |
| | | sites.forEach(function (s) { |
| | | $select.append('<option value="' + s.id + '">' + s.siteName + ' (' + s.domain + ')</option>'); |
| | | }); |
| | | form.render('select'); |
| | | }); |
| | | } |
| | | |
| | | // 站点筛选切换 |
| | | form.on('select(filter-site)', function () { |
| | | tableIns.reload(); |
| | | }); |
| | | |
| | | // 表格操作 |
| | | table.on('tool(firewallRuleTable)', function (obj) { |
| | | var data = obj.data, |
| | | layEvent = obj.event; |
| | | if (layEvent === 'edit') { |
| | | openEditModal(data); |
| | | } else if (layEvent === 'del') { |
| | | layer.confirm('确定删除 <b>' + data.countryName + ' (' + data.countryCode + ')</b> 的规则吗?<br>删除后会自动更新Nginx配置!', function (index) { |
| | | febs.get(ctx + 'admin/firewall/countryRule/delete/' + data.id, null, function () { |
| | | layer.close(index); |
| | | febs.alert.success('删除成功,Nginx配置已更新'); |
| | | tableIns.reload(); |
| | | }); |
| | | }); |
| | | } |
| | | }); |
| | | |
| | | // 新增按钮 |
| | | $addBtn.on('click', function () { |
| | | openEditModal(null); |
| | | }); |
| | | |
| | | // 刷新按钮 |
| | | $reset.on('click', function () { |
| | | tableIns.reload(); |
| | | }); |
| | | |
| | | // 初始化表格 |
| | | function initTable() { |
| | | tableIns = febs.table.init({ |
| | | elem: $view.find('table'), |
| | | id: 'firewallRuleTable', |
| | | url: ctx + 'admin/firewall/countryRuleList', |
| | | where: function () { |
| | | var siteId = $('#filter-site-id').val(); |
| | | return siteId ? { siteId: siteId } : {}; |
| | | }, |
| | | cols: [[ |
| | | {field: 'id', title: 'ID', width: 60, align: 'center'}, |
| | | {field: 'siteId', title: '站点ID', width: 80, align: 'center'}, |
| | | {field: 'countryCode', title: '国家编码', width: 110, align: 'center'}, |
| | | {field: 'countryName', title: '国家名称', width: 130, align: 'center'}, |
| | | {field: 'enabled', title: '状态', width: 90, align: 'center', |
| | | templet: function (d) { |
| | | return d.enabled === 1 |
| | | ? '<span style="color:#5FB878;">● 启用</span>' |
| | | : '<span style="color:#999;">● 禁用</span>'; |
| | | } |
| | | }, |
| | | {title: '操作', templet: '#firewall-rule-option', width: 120, align: 'center'}, |
| | | {field: 'createdTime', title: '创建时间', width: 170, align: 'center'}, |
| | | ]] |
| | | }); |
| | | } |
| | | |
| | | // 打开编辑弹窗 |
| | | function openEditModal(data) { |
| | | var title = data ? '编辑国家规则' : '新增国家规则'; |
| | | var siteIdVal = data ? 'value="' + data.siteId + '"' : ''; |
| | | |
| | | var html = '<div style="padding:20px 25px 0 0;">' + |
| | | '<form class="layui-form" lay-filter="firewall-rule-form">' + |
| | | (data ? '<div class="layui-form-item febs-hide"><label class="layui-form-label">ID</label><div class="layui-input-block"><input type="text" name="id" value="' + data.id + '"></div></div>' : '') + |
| | | ' <div class="layui-form-item">' + |
| | | ' <label class="layui-form-label febs-form-item-require">所属站点</label>' + |
| | | ' <div class="layui-input-block">' + |
| | | ' <select name="siteId" lay-verify="required" lay-search="" data-value="' + (data ? data.siteId : '') + '">' + |
| | | ' <option value="">请选择站点</option>' + |
| | | ' </select>' + |
| | | ' </div>' + |
| | | ' </div>' + |
| | | ' <div class="layui-form-item">' + |
| | | ' <label class="layui-form-label febs-form-item-require">国家编码</label>' + |
| | | ' <div class="layui-input-block">' + |
| | | ' <input type="text" name="countryCode" class="layui-input" ' + (data ? 'value="' + data.countryCode + '"' : 'placeholder="ISO国家编码,如CN/US/JP"') + ' lay-verify="required">' + |
| | | ' <div class="layui-form-mid layui-word-aux">ISO 3166-1 alpha-2编码,如BR/US/JP</div>' + |
| | | ' </div>' + |
| | | ' </div>' + |
| | | ' <div class="layui-form-item">' + |
| | | ' <label class="layui-form-label febs-form-item-require">国家名称</label>' + |
| | | ' <div class="layui-input-block">' + |
| | | ' <input type="text" name="countryName" class="layui-input" ' + (data ? 'value="' + (data.countryName || '') + '"' : 'placeholder="中文名或英文名"') + ' lay-verify="required">' + |
| | | ' </div>' + |
| | | ' </div>' + |
| | | ' <div class="layui-form-item">' + |
| | | ' <label class="layui-form-label">状态</label>' + |
| | | ' <div class="layui-input-block">' + |
| | | ' <input type="radio" name="enabled" value="1" title="启用" ' + (!data || data.enabled === 1 ? 'checked' : '') + '>' + |
| | | ' <input type="radio" name="enabled" value="0" title="禁用" ' + (data && data.enabled === 0 ? 'checked' : '') + '>' + |
| | | ' </div>' + |
| | | ' </div>' + |
| | | '</form></div>'; |
| | | |
| | | var layerIndex = layer.open({ |
| | | type: 1, |
| | | title: title, |
| | | area: ['520px', '400px'], |
| | | content: html, |
| | | success: function (layero) { |
| | | // 加载弹窗中的站点下拉 |
| | | var $popupSelect = $(layero).find('select[name="siteId"]'); |
| | | febs.get(ctx + 'admin/firewall/sites', null, function (res) { |
| | | var sites = res.data || []; |
| | | var preselected = data ? data.siteId : ''; |
| | | sites.forEach(function (s) { |
| | | var sel = s.id == preselected ? ' selected' : ''; |
| | | $popupSelect.append('<option value="' + s.id + '"' + sel + '>' + s.siteName + ' (' + s.domain + ')</option>'); |
| | | }); |
| | | form.render('select'); |
| | | }); |
| | | form.render('radio', 'firewall-rule-form'); |
| | | }, |
| | | btn: ['保存', '取消'], |
| | | yes: function (index, layero) { |
| | | var formObj = $(layero).find('form'); |
| | | var formData = form.val("firewall-rule-form"); |
| | | if (!formData.siteId) { |
| | | layer.msg('请选择站点', {icon: 2}); |
| | | return; |
| | | } |
| | | if (!formData.countryCode) { |
| | | layer.msg('请输入国家编码', {icon: 2}); |
| | | return; |
| | | } |
| | | if (!formData.countryName) { |
| | | layer.msg('请输入国家名称', {icon: 2}); |
| | | return; |
| | | } |
| | | febs.post(ctx + 'admin/firewall/countryRule/save', formData, function (res) { |
| | | layer.close(index); |
| | | febs.alert.success('保存成功,Nginx配置已自动更新'); |
| | | tableIns.reload(); |
| | | }); |
| | | }, |
| | | btn2: function () { |
| | | layer.closeAll(); |
| | | } |
| | | }); |
| | | } |
| | | }); |
| | | </script> |
| New file |
| | |
| | | <div class="layui-fluid layui-anim febs-anim" id="firewall-site-list" lay-title="防火墙站点管理"> |
| | | <div class="layui-row febs-container"> |
| | | <div class="layui-col-md12"> |
| | | <div class="layui-card"> |
| | | <div class="layui-card-body febs-table-full"> |
| | | <form class="layui-form layui-table-form" lay-filter="firewall-site-table-form"> |
| | | <div class="layui-row"> |
| | | <div class="layui-col-md10"> |
| | | <div class="layui-form-item"> |
| | | <button class="layui-btn layui-btn-normal" type="button" id="add-btn"> |
| | | <i class="layui-icon"></i> 新增站点 |
| | | </button> |
| | | </div> |
| | | </div> |
| | | <div class="layui-col-md2 layui-col-sm12 layui-col-xs12 table-action-area"> |
| | | <div class="layui-btn layui-btn-sm layui-btn-primary febs-button-green-plain table-action" id="reset"> |
| | | <i class="layui-icon"></i> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </form> |
| | | <table lay-filter="firewallSiteTable" lay-data="{id: 'firewallSiteTable'}"></table> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <!-- 表格操作栏 --> |
| | | <script type="text/html" id="firewall-site-option"> |
| | | <a lay-event="edit"><i class="layui-icon febs-edit-area febs-blue"></i></a> |
| | | <a lay-event="del"><i class="layui-icon febs-edit-area febs-red"></i></a> |
| | | </script> |
| | | <style> |
| | | .layui-form-label { width: 120px; } |
| | | .layui-form-item .layui-input-block { margin-left: 150px; } |
| | | </style> |
| | | <script data-th-inline="none" type="text/javascript"> |
| | | layui.use(['jquery', 'form', 'table', 'febs'], function () { |
| | | var $ = layui.jquery, |
| | | febs = layui.febs, |
| | | form = layui.form, |
| | | table = layui.table, |
| | | layer = layui.layer, |
| | | $view = $('#firewall-site-list'), |
| | | $reset = $view.find('#reset'), |
| | | $addBtn = $view.find('#add-btn'), |
| | | tableIns; |
| | | |
| | | form.render(); |
| | | initTable(); |
| | | |
| | | // 表格操作 |
| | | table.on('tool(firewallSiteTable)', function (obj) { |
| | | var data = obj.data, |
| | | layEvent = obj.event; |
| | | if (layEvent === 'edit') { |
| | | openEditModal(data); |
| | | } else if (layEvent === 'del') { |
| | | layer.confirm('确定删除站点 <b>' + data.siteName + '</b> 吗?该站点关联的国家规则也将一并删除!', function (index) { |
| | | febs.get(ctx + 'admin/firewall/site/delete/' + data.id, null, function () { |
| | | layer.close(index); |
| | | febs.alert.success('删除成功'); |
| | | tableIns.reload(); |
| | | }); |
| | | }); |
| | | } |
| | | }); |
| | | |
| | | // 新增按钮 |
| | | $addBtn.on('click', function () { |
| | | openEditModal(null); |
| | | }); |
| | | |
| | | // 刷新按钮 |
| | | $reset.on('click', function () { |
| | | tableIns.reload(); |
| | | }); |
| | | |
| | | // 初始化表格 |
| | | function initTable() { |
| | | tableIns = febs.table.init({ |
| | | elem: $view.find('table'), |
| | | id: 'firewallSiteTable', |
| | | url: ctx + 'admin/firewall/siteList', |
| | | cols: [[ |
| | | {field: 'id', title: 'ID', width: 60, align: 'center'}, |
| | | {field: 'siteName', title: '站点名称', width: 180, align: 'center'}, |
| | | {field: 'domain', title: '域名', width: 220, align: 'center'}, |
| | | {field: 'nginxConf', title: 'Nginx配置路径', minWidth: 280, align: 'center'}, |
| | | {field: 'status', title: '状态', width: 90, align: 'center', |
| | | templet: function (d) { |
| | | return d.status === 1 |
| | | ? '<span style="color:#5FB878;">● 启用</span>' |
| | | : '<span style="color:#999;">● 禁用</span>'; |
| | | } |
| | | }, |
| | | {title: '操作', templet: '#firewall-site-option', width: 120, align: 'center'}, |
| | | {field: 'remark', title: '备注', minWidth: 120, align: 'center'}, |
| | | ]] |
| | | }); |
| | | } |
| | | |
| | | // 打开编辑弹窗 |
| | | function openEditModal(data) { |
| | | var title = data ? '编辑站点' : '新增站点'; |
| | | var html = '<div style="padding:20px 25px 0 0;">' + |
| | | '<form class="layui-form" lay-filter="firewall-site-form">' + |
| | | (data ? '<div class="layui-form-item febs-hide"><label class="layui-form-label">ID</label><div class="layui-input-block"><input type="text" name="id" value="' + data.id + '"></div></div>' : '') + |
| | | ' <div class="layui-form-item">' + |
| | | ' <label class="layui-form-label febs-form-item-require">站点名称</label>' + |
| | | ' <div class="layui-input-block">' + |
| | | ' <input type="text" name="siteName" class="layui-input" ' + (data ? 'value="' + (data.siteName || '') + '"' : 'placeholder="如:polypepprotein独立站"') + ' lay-verify="required">' + |
| | | ' </div>' + |
| | | ' </div>' + |
| | | ' <div class="layui-form-item">' + |
| | | ' <label class="layui-form-label febs-form-item-require">域名</label>' + |
| | | ' <div class="layui-input-block">' + |
| | | ' <input type="text" name="domain" class="layui-input" ' + (data ? 'value="' + (data.domain || '') + '"' : 'placeholder="如:polypepprotein.com"') + ' lay-verify="required">' + |
| | | ' <div class="layui-form-mid layui-word-aux">用于Nginx server_name匹配</div>' + |
| | | ' </div>' + |
| | | ' </div>' + |
| | | ' <div class="layui-form-item">' + |
| | | ' <label class="layui-form-label">Nginx配置路径</label>' + |
| | | ' <div class="layui-input-block">' + |
| | | ' <input type="text" name="nginxConf" class="layui-input" ' + (data && data.nginxConf ? 'value="' + data.nginxConf + '"' : 'placeholder="/etc/nginx/conf.d/firewall_country_1.conf"') + '>' + |
| | | ' <div class="layui-form-mid layui-word-aux">生成的国家白名单配置文件路径,留空自动生成</div>' + |
| | | ' </div>' + |
| | | ' </div>' + |
| | | ' <div class="layui-form-item">' + |
| | | ' <label class="layui-form-label">状态</label>' + |
| | | ' <div class="layui-input-block">' + |
| | | ' <input type="radio" name="status" value="1" title="启用" ' + (!data || data.status === 1 ? 'checked' : '') + '>' + |
| | | ' <input type="radio" name="status" value="0" title="禁用" ' + (data && data.status === 0 ? 'checked' : '') + '>' + |
| | | ' </div>' + |
| | | ' </div>' + |
| | | ' <div class="layui-form-item">' + |
| | | ' <label class="layui-form-label">备注</label>' + |
| | | ' <div class="layui-input-block">' + |
| | | ' <input type="text" name="remark" class="layui-input" ' + (data && data.remark ? 'value="' + data.remark + '"' : '') + '>' + |
| | | ' </div>' + |
| | | ' </div>' + |
| | | '</form></div>'; |
| | | |
| | | var layerIndex = layer.open({ |
| | | type: 1, |
| | | title: title, |
| | | area: ['580px', '460px'], |
| | | content: html, |
| | | success: function (layero) { |
| | | form.render('radio', 'firewall-site-form'); |
| | | }, |
| | | btn: ['保存', '取消'], |
| | | yes: function (index, layero) { |
| | | var formObj = $(layero).find('form'); |
| | | var formData = form.val("firewall-site-form"); |
| | | febs.post(ctx + 'admin/firewall/site/save', formData, function (res) { |
| | | layer.close(index); |
| | | febs.alert.success('保存成功'); |
| | | tableIns.reload(); |
| | | }); |
| | | }, |
| | | btn2: function () { |
| | | layer.closeAll(); |
| | | } |
| | | }); |
| | | } |
| | | }); |
| | | </script> |