27 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白名单表'; |
| | | |
| | | -- ============================================================ |
| | | -- 访问日志表 |
| | | -- ============================================================ |
| | | DROP TABLE IF EXISTS firewall_access_log; |
| | | CREATE TABLE firewall_access_log ( |
| | | 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 COMMENT '站点ID', |
| | | IP VARCHAR(64) NOT NULL COMMENT '访问者IP', |
| | | COUNTRY_CODE VARCHAR(10) COMMENT '国家编码(ISO 3166-1 alpha-2)', |
| | | COUNTRY_NAME VARCHAR(50) COMMENT '国家名称', |
| | | USER_AGENT VARCHAR(500) COMMENT '浏览器UA', |
| | | REQUEST_URI VARCHAR(500) COMMENT '请求路径', |
| | | IS_BLOCKED TINYINT DEFAULT 0 COMMENT '是否被拦截:1-是,0-否', |
| | | PRIMARY KEY (ID), |
| | | INDEX idx_site_id (SITE_ID), |
| | | INDEX idx_ip (IP), |
| | | INDEX idx_country (COUNTRY_CODE), |
| | | INDEX idx_created_time (CREATED_TIME), |
| | | INDEX idx_blocked (IS_BLOCKED) |
| | | ) COMMENT = '防火墙访问日志表'; |
| | | |
| | | -- ---------------------------- |
| | | -- 默认数据 |
| | | -- ---------------------------- |
| | | 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.FirewallAccessLog; |
| | | import cc.mrbird.febs.firewall.entity.FirewallCountryRule; |
| | | import cc.mrbird.febs.firewall.entity.FirewallIpWhite; |
| | | 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 信号已发送"); |
| | | } |
| | | |
| | | // ==================== IP白名单 CRUD ==================== |
| | | |
| | | /** |
| | | * IP白名单分页列表 |
| | | */ |
| | | @GetMapping("/ipWhiteList") |
| | | public FebsResponse ipWhiteList(FirewallIpWhite ipWhite, QueryRequest request) { |
| | | Map<String, Object> data = getDataTable(firewallRuleService.ipWhiteList(ipWhite, request)); |
| | | return new FebsResponse().success().data(data); |
| | | } |
| | | |
| | | /** |
| | | * 保存/更新IP白名单(保存后自动生成Nginx配置) |
| | | */ |
| | | @PostMapping("/ipWhite/save") |
| | | @ControllerEndpoint(operation = "保存防火墙IP白名单") |
| | | public FebsResponse saveIpWhite(@Validated FirewallIpWhite ipWhite) { |
| | | return firewallRuleService.saveIpWhite(ipWhite); |
| | | } |
| | | |
| | | /** |
| | | * 删除IP白名单(删除后自动生成Nginx配置) |
| | | */ |
| | | @GetMapping("/ipWhite/delete/{id}") |
| | | @ControllerEndpoint(operation = "删除防火墙IP白名单") |
| | | public FebsResponse deleteIpWhite(@PathVariable @NotNull Long id) { |
| | | return firewallRuleService.deleteIpWhite(id); |
| | | } |
| | | |
| | | // ==================== 访问日志查询 ==================== |
| | | |
| | | @GetMapping("/accessLogList") |
| | | public FebsResponse accessLogList(FirewallAccessLog log, QueryRequest request) { |
| | | Map<String, Object> data = getDataTable(firewallRuleService.accessLogList(log, request)); |
| | | return new FebsResponse().success().data(data); |
| | | } |
| | | } |
| New file |
| | |
| | | package cc.mrbird.febs.firewall.controller; |
| | | |
| | | import cc.mrbird.febs.common.entity.FebsResponse; |
| | | import cc.mrbird.febs.firewall.entity.FirewallAccessLog; |
| | | import cc.mrbird.febs.firewall.service.IFirewallRuleService; |
| | | import cn.hutool.extra.servlet.ServletUtil; |
| | | import lombok.RequiredArgsConstructor; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.web.bind.annotation.GetMapping; |
| | | import org.springframework.web.bind.annotation.RequestMapping; |
| | | import org.springframework.web.bind.annotation.RestController; |
| | | |
| | | import javax.servlet.http.HttpServletRequest; |
| | | |
| | | /** |
| | | * 公开访问追踪API(无需登录) |
| | | * <p> |
| | | * 前端 SPA 页面加载时调用此接口记录访问。 |
| | | * Nginx 需配置 proxy_set_header 传递国家和真实IP: |
| | | * <pre> |
| | | * proxy_set_header X-Real-IP $remote_addr; |
| | | * proxy_set_header X-Geo-Country $geoip_country_code; |
| | | * </pre> |
| | | * |
| | | * @author auto-generated |
| | | * @date 2026-07-31 |
| | | */ |
| | | @Slf4j |
| | | @RestController |
| | | @RequestMapping("api/track") |
| | | @RequiredArgsConstructor |
| | | public class ApiTrackController { |
| | | |
| | | private final IFirewallRuleService firewallRuleService; |
| | | |
| | | @GetMapping("visit") |
| | | public FebsResponse trackVisit(HttpServletRequest request) { |
| | | try { |
| | | FirewallAccessLog accessLog = new FirewallAccessLog(); |
| | | accessLog.setIp(ServletUtil.getClientIP(request)); |
| | | accessLog.setCountryCode(request.getHeader("X-Geo-Country")); |
| | | accessLog.setUserAgent(request.getHeader("User-Agent")); |
| | | accessLog.setRequestUri(request.getHeader("Referer")); |
| | | accessLog.setIsBlocked(0); |
| | | firewallRuleService.recordAccess(accessLog); |
| | | return new FebsResponse().success(); |
| | | } catch (Exception e) { |
| | | log.error("访问追踪记录失败", e); |
| | | return new FebsResponse().fail().message("记录失败"); |
| | | } |
| | | } |
| | | } |
| 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 com.baomidou.mybatisplus.annotation.IdType; |
| | | import com.baomidou.mybatisplus.annotation.TableId; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import com.fasterxml.jackson.annotation.JsonFormat; |
| | | import lombok.Data; |
| | | |
| | | import java.io.Serializable; |
| | | import java.util.Date; |
| | | |
| | | /** |
| | | * 防火墙访问日志 |
| | | * |
| | | * @author auto-generated |
| | | * @date 2026-07-31 |
| | | */ |
| | | @Data |
| | | @TableName("firewall_access_log") |
| | | public class FirewallAccessLog implements Serializable { |
| | | |
| | | private static final long serialVersionUID = 1L; |
| | | |
| | | @TableId(value = "ID", type = IdType.AUTO) |
| | | private Long id; |
| | | |
| | | private Integer revision; |
| | | |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date createdTime; |
| | | |
| | | private Long siteId; |
| | | |
| | | /** 访问者IP */ |
| | | private String ip; |
| | | |
| | | /** 国家编码(ISO 3166-1 alpha-2,如 CN/US/JP) */ |
| | | private String countryCode; |
| | | |
| | | /** 国家名称 */ |
| | | private String countryName; |
| | | |
| | | /** 浏览器UA */ |
| | | private String userAgent; |
| | | |
| | | /** 请求路径 */ |
| | | private String requestUri; |
| | | |
| | | /** 是否被拦截:1-是,0-否 */ |
| | | private Integer isBlocked; |
| | | } |
| 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.FirewallAccessLog; |
| | | import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | |
| | | /** |
| | | * 防火墙访问日志 Mapper |
| | | * |
| | | * @author auto-generated |
| | | * @date 2026-07-31 |
| | | */ |
| | | @Mapper |
| | | public interface FirewallAccessLogMapper extends BaseMapper<FirewallAccessLog> { |
| | | } |
| 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.FirewallIpWhite; |
| | | import cc.mrbird.febs.firewall.entity.FirewallSite; |
| | | import cc.mrbird.febs.firewall.mapper.FirewallIpWhiteMapper; |
| | | import cc.mrbird.febs.firewall.mapper.FirewallSiteMapper; |
| | | import cn.hutool.core.io.FileUtil; |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | 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.Date; |
| | | import java.util.List; |
| | | |
| | | /** |
| | | * Nginx IP白名单配置文件生成器 |
| | | * <p> |
| | | * 白名单模式:如果表中有启用的IP记录,则仅允许这些IP访问;表为空则放行所有IP。 |
| | | * <p> |
| | | * 输出文件: /etc/nginx/conf.d/firewall_ipwhite_{siteId}.conf |
| | | * |
| | | * @author auto-generated |
| | | * @date 2026-07-31 |
| | | */ |
| | | @Slf4j |
| | | @Component |
| | | @RequiredArgsConstructor |
| | | public class IpWhitelistNginxConfigGenerator { |
| | | |
| | | private final FirewallIpWhiteMapper ipWhiteMapper; |
| | | private final FirewallSiteMapper siteMapper; |
| | | |
| | | /** Nginx 配置目录 */ |
| | | private static final String NGINX_CONF_DIR = "/etc/nginx/conf.d/"; |
| | | /** 配置文件前缀 */ |
| | | private static final String CONF_PREFIX = "firewall_ipwhite_"; |
| | | |
| | | /** |
| | | * 生成 IP 白名单 map 配置 |
| | | * <pre> |
| | | * # 有白名单IP时: |
| | | * map $remote_addr $allow_ip_1 { |
| | | * default 0; |
| | | * 1.2.3.4 1; |
| | | * 5.6.7.8 1; |
| | | * } |
| | | * |
| | | * # 无白名单IP时: |
| | | * map $remote_addr $allow_ip_1 { |
| | | * default 1; |
| | | * } |
| | | * </pre> |
| | | * 有IP时 default 0(默认拒绝,仅白名单放行); |
| | | * 无IP时 default 1(全部放行)。 |
| | | * <p> |
| | | * Nginx 中判断: if ($allow_ip_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; |
| | | } |
| | | |
| | | // 查询站点下所有启用的白名单IP(未过期或永久有效) |
| | | List<FirewallIpWhite> ipList = ipWhiteMapper.selectList( |
| | | new LambdaQueryWrapper<FirewallIpWhite>() |
| | | .eq(FirewallIpWhite::getSiteId, siteId) |
| | | .eq(FirewallIpWhite::getEnabled, 1) |
| | | .and(w -> w.isNull(FirewallIpWhite::getExpireTime) |
| | | .or().gt(FirewallIpWhite::getExpireTime, new Date())) |
| | | ); |
| | | |
| | | boolean hasIps = !ipList.isEmpty(); |
| | | |
| | | StringBuilder sb = new StringBuilder(); |
| | | sb.append("# Firewall IP Whitelist Map\n"); |
| | | sb.append("# Site: ").append(site.getSiteName()).append(" (").append(site.getDomain()).append(")\n"); |
| | | sb.append("# Generated at: ").append(new Date()).append("\n"); |
| | | sb.append("# Total IPs: ").append(ipList.size()).append("\n"); |
| | | sb.append("# Whitelist active: ").append(hasIps).append("\n\n"); |
| | | |
| | | // 标记变量:白名单是否生效(有IP记录=1,无记录=0) |
| | | // 用 map 模拟,使变量在 http 级别可用 |
| | | sb.append("# 是否启用白名单模式: ").append(hasIps ? "1=是" : "0=否").append("\n"); |
| | | sb.append("map $host $ipwh_active_").append(siteId).append(" {\n"); |
| | | sb.append(" default ").append(hasIps ? "1" : "0").append(";\n"); |
| | | sb.append("}\n\n"); |
| | | |
| | | sb.append("map $remote_addr $allow_ip_").append(siteId).append(" {\n"); |
| | | if (!hasIps) { |
| | | // 无白名单 → 全部放行 |
| | | sb.append(" default 1;\n"); |
| | | } else { |
| | | // 有白名单 → 默认拒绝,仅白名单IP放行 |
| | | sb.append(" default 0;\n"); |
| | | for (FirewallIpWhite ip : ipList) { |
| | | sb.append(" ").append(ip.getIp()).append(" 1;\n"); |
| | | } |
| | | } |
| | | sb.append("}\n"); |
| | | |
| | | // 确定配置文件路径 |
| | | String 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 IP白名单配置已生成: {}, 白名单IP数: {}", confFile.getAbsolutePath(), ipList.size()); |
| | | } catch (Exception e) { |
| | | log.error("Nginx IP白名单配置文件写入失败: path={}", confFile.getAbsolutePath(), e); |
| | | throw new RuntimeException("Nginx IP白名单配置文件写入失败: " + e.getMessage(), e); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 获取 IP 白名单配置文件路径 |
| | | */ |
| | | public static String getConfPath(Long siteId) { |
| | | return NGINX_CONF_DIR + CONF_PREFIX + siteId + ".conf"; |
| | | } |
| | | } |
| 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.FirewallAccessLog; |
| | | import cc.mrbird.febs.firewall.entity.FirewallCountryRule; |
| | | import cc.mrbird.febs.firewall.entity.FirewallIpWhite; |
| | | 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); |
| | | |
| | | // ==================== IP白名单管理 ==================== |
| | | |
| | | /** |
| | | * IP白名单分页列表 |
| | | */ |
| | | IPage<FirewallIpWhite> ipWhiteList(FirewallIpWhite ipWhite, QueryRequest request); |
| | | |
| | | /** |
| | | * 保存/更新IP白名单(保存后自动生成Nginx配置) |
| | | */ |
| | | FebsResponse saveIpWhite(FirewallIpWhite ipWhite); |
| | | |
| | | /** |
| | | * 删除IP白名单(删除后自动生成Nginx配置) |
| | | */ |
| | | FebsResponse deleteIpWhite(Long id); |
| | | |
| | | // ==================== 访问日志管理 ==================== |
| | | |
| | | /** |
| | | * 记录一次访问 |
| | | */ |
| | | void recordAccess(FirewallAccessLog log); |
| | | |
| | | /** |
| | | * 访问日志分页列表 |
| | | */ |
| | | IPage<FirewallAccessLog> accessLogList(FirewallAccessLog log, QueryRequest request); |
| | | } |
| 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.FirewallAccessLog; |
| | | import cc.mrbird.febs.firewall.entity.FirewallCountryRule; |
| | | import cc.mrbird.febs.firewall.entity.FirewallIpWhite; |
| | | import cc.mrbird.febs.firewall.entity.FirewallSite; |
| | | import cc.mrbird.febs.firewall.mapper.FirewallAccessLogMapper; |
| | | import cc.mrbird.febs.firewall.mapper.FirewallCountryRuleMapper; |
| | | import cc.mrbird.febs.firewall.mapper.FirewallIpWhiteMapper; |
| | | import cc.mrbird.febs.firewall.mapper.FirewallSiteMapper; |
| | | import cc.mrbird.febs.firewall.nginx.IpWhitelistNginxConfigGenerator; |
| | | 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 FirewallIpWhiteMapper ipWhiteMapper; |
| | | private final FirewallAccessLogMapper accessLogMapper; |
| | | private final FirewallSiteMapper siteMapper; |
| | | private final NginxConfigGenerator generator; |
| | | private final IpWhitelistNginxConfigGenerator ipWhitelistGenerator; |
| | | 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)); |
| | | // 删除站点关联的IP白名单 |
| | | ipWhiteMapper.delete(new LambdaQueryWrapper<FirewallIpWhite>() |
| | | .eq(FirewallIpWhite::getSiteId, id)); |
| | | // 删除Nginx配置文件(国家黑名单 + IP白名单) |
| | | if (StrUtil.isNotBlank(site.getNginxConf())) { |
| | | cn.hutool.core.io.FileUtil.del(site.getNginxConf()); |
| | | } |
| | | cn.hutool.core.io.FileUtil.del(IpWhitelistNginxConfigGenerator.getConfPath(id)); |
| | | 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); |
| | | ipWhitelistGenerator.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); |
| | | ipWhitelistGenerator.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); |
| | | ipWhitelistGenerator.generate(siteId); |
| | | redisUtils.lSet(NGINX_RELOAD_QUEUE_KEY, String.valueOf(siteId)); |
| | | log.info("手动触发 Nginx reload: siteId={}", siteId); |
| | | } |
| | | |
| | | // ==================== IP白名单管理 ==================== |
| | | |
| | | @Override |
| | | public IPage<FirewallIpWhite> ipWhiteList(FirewallIpWhite ipWhite, QueryRequest request) { |
| | | Page<FirewallIpWhite> page = new Page<>(request.getPageNum(), request.getPageSize()); |
| | | LambdaQueryWrapper<FirewallIpWhite> wrapper = new LambdaQueryWrapper<FirewallIpWhite>() |
| | | .eq(ipWhite.getSiteId() != null, FirewallIpWhite::getSiteId, ipWhite.getSiteId()) |
| | | .eq(ipWhite.getEnabled() != null, FirewallIpWhite::getEnabled, ipWhite.getEnabled()) |
| | | .like(StrUtil.isNotBlank(ipWhite.getIp()), FirewallIpWhite::getIp, ipWhite.getIp()) |
| | | .like(StrUtil.isNotBlank(ipWhite.getRemark()), FirewallIpWhite::getRemark, ipWhite.getRemark()) |
| | | .orderByDesc(FirewallIpWhite::getCreatedTime); |
| | | return ipWhiteMapper.selectPage(page, wrapper); |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public FebsResponse saveIpWhite(FirewallIpWhite ipWhite) { |
| | | if (ipWhite.getSiteId() == null) { |
| | | return new FebsResponse().fail().message("请选择站点"); |
| | | } |
| | | if (StrUtil.isBlank(ipWhite.getIp())) { |
| | | return new FebsResponse().fail().message("IP地址不能为空"); |
| | | } |
| | | ipWhite.setIp(ipWhite.getIp().trim()); |
| | | |
| | | if (ipWhite.getId() != null) { |
| | | ipWhiteMapper.updateById(ipWhite); |
| | | log.info("防火墙IP白名单已更新: id={}, siteId={}, ip={}", ipWhite.getId(), ipWhite.getSiteId(), ipWhite.getIp()); |
| | | } else { |
| | | // 检查同站点下是否已存在相同IP |
| | | Integer count = ipWhiteMapper.selectCount(new LambdaQueryWrapper<FirewallIpWhite>() |
| | | .eq(FirewallIpWhite::getSiteId, ipWhite.getSiteId()) |
| | | .eq(FirewallIpWhite::getIp, ipWhite.getIp())); |
| | | if (count > 0) { |
| | | return new FebsResponse().fail().message("该站点下已存在相同IP: " + ipWhite.getIp()); |
| | | } |
| | | ipWhiteMapper.insert(ipWhite); |
| | | log.info("防火墙IP白名单已新增: siteId={}, ip={}", ipWhite.getSiteId(), ipWhite.getIp()); |
| | | } |
| | | |
| | | // 保存后自动生成Nginx配置并发送reload信号 |
| | | ipWhitelistGenerator.generate(ipWhite.getSiteId()); |
| | | redisUtils.lSet(NGINX_RELOAD_QUEUE_KEY, String.valueOf(ipWhite.getSiteId())); |
| | | return new FebsResponse().success().message("保存成功,Nginx 配置已更新"); |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public FebsResponse deleteIpWhite(Long id) { |
| | | FirewallIpWhite ipWhite = ipWhiteMapper.selectById(id); |
| | | if (ipWhite == null) { |
| | | return new FebsResponse().fail().message("IP白名单不存在"); |
| | | } |
| | | Long siteId = ipWhite.getSiteId(); |
| | | ipWhiteMapper.deleteById(id); |
| | | log.info("防火墙IP白名单已删除: id={}, siteId={}, ip={}", id, siteId, ipWhite.getIp()); |
| | | |
| | | // 删除后自动生成Nginx配置并发送reload信号 |
| | | ipWhitelistGenerator.generate(siteId); |
| | | redisUtils.lSet(NGINX_RELOAD_QUEUE_KEY, String.valueOf(siteId)); |
| | | return new FebsResponse().success().message("删除成功,Nginx 配置已更新"); |
| | | } |
| | | |
| | | // ==================== 访问日志 ==================== |
| | | |
| | | @Override |
| | | public void recordAccess(FirewallAccessLog accessLog) { |
| | | accessLogMapper.insert(accessLog); |
| | | } |
| | | |
| | | @Override |
| | | public IPage<FirewallAccessLog> accessLogList(FirewallAccessLog log, QueryRequest request) { |
| | | Page<FirewallAccessLog> page = new Page<>(request.getPageNum(), request.getPageSize()); |
| | | LambdaQueryWrapper<FirewallAccessLog> wrapper = new LambdaQueryWrapper<FirewallAccessLog>() |
| | | .eq(log.getSiteId() != null, FirewallAccessLog::getSiteId, log.getSiteId()) |
| | | .eq(log.getIsBlocked() != null, FirewallAccessLog::getIsBlocked, log.getIsBlocked()) |
| | | .eq(StrUtil.isNotBlank(log.getCountryCode()), FirewallAccessLog::getCountryCode, log.getCountryCode()) |
| | | .like(StrUtil.isNotBlank(log.getIp()), FirewallAccessLog::getIp, log.getIp()) |
| | | .orderByDesc(FirewallAccessLog::getCreatedTime); |
| | | return accessLogMapper.selectPage(page, wrapper); |
| | | } |
| | | } |
| 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("firewallIpWhiteList") |
| | | @RequiresPermissions("firewallIpWhite:view") |
| | | public String firewallIpWhiteList() { |
| | | return FebsUtil.view("modules/system/firewallIpWhiteList"); |
| | | } |
| | | |
| | | @GetMapping("firewallAccessLogList") |
| | | @RequiresPermissions("firewallAccessLog:view") |
| | | public String firewallAccessLogList() { |
| | | return FebsUtil.view("modules/system/firewallAccessLogList"); |
| | | } |
| | | |
| | | @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 |
| | |
| | | <!DOCTYPE html> |
| | | <html lang="zh-CN"> |
| | | <head> |
| | | <meta charset="UTF-8"> |
| | | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| | | <title>访问受限</title> |
| | | <style> |
| | | * { margin: 0; padding: 0; box-sizing: border-box; } |
| | | |
| | | body { |
| | | font-family: -apple-system, BlinkMacSystemFont, "Microsoft YaHei", "PingFang SC", sans-serif; |
| | | background: #0a0e27; |
| | | min-height: 100vh; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | color: #c8d6e5; |
| | | } |
| | | |
| | | .grid-bg { |
| | | position: fixed; |
| | | top: 0; left: 0; |
| | | width: 100%; height: 100%; |
| | | background-image: |
| | | linear-gradient(rgba(59,130,246,.04) 1px, transparent 1px), |
| | | linear-gradient(90deg, rgba(59,130,246,.04) 1px, transparent 1px); |
| | | background-size: 60px 60px; |
| | | pointer-events: none; |
| | | } |
| | | |
| | | .card { |
| | | position: relative; |
| | | text-align: center; |
| | | padding: 60px 40px; |
| | | max-width: 560px; |
| | | width: 90%; |
| | | } |
| | | |
| | | .shield-wrap { |
| | | position: relative; |
| | | width: 90px; height: 108px; |
| | | margin: 0 auto 30px; |
| | | } |
| | | |
| | | .shield-wrap svg { |
| | | width: 100%; height: 100%; |
| | | } |
| | | |
| | | .shield-ring { |
| | | position: absolute; |
| | | top: 50%; left: 50%; |
| | | transform: translate(-50%,-50%); |
| | | width: 150px; height: 150px; |
| | | border-radius: 50%; |
| | | background: radial-gradient(circle, rgba(239,68,68,.15) 0%, transparent 70%); |
| | | animation: ringPulse 2.5s ease-in-out infinite; |
| | | } |
| | | |
| | | @keyframes ringPulse { |
| | | 0%,100% { transform: translate(-50%,-50%) scale(1); opacity: .5; } |
| | | 50% { transform: translate(-50%,-50%) scale(1.25); opacity: .15; } |
| | | } |
| | | |
| | | h2 { |
| | | font-size: 22px; font-weight: 600; |
| | | color: #f1f5f9; |
| | | margin-bottom: 14px; |
| | | letter-spacing: .5px; |
| | | } |
| | | |
| | | h2.en { |
| | | color: #94a3b8; |
| | | margin-bottom: 30px; |
| | | font-weight: 400; |
| | | } |
| | | |
| | | .divider { |
| | | width: 60px; height: 1px; |
| | | background: linear-gradient(90deg, transparent, rgba(239,68,68,.4), transparent); |
| | | margin: 0 auto 28px; |
| | | } |
| | | |
| | | .badge { |
| | | display: inline-flex; |
| | | align-items: center; gap: 8px; |
| | | padding: 10px 22px; |
| | | background: rgba(239,68,68,.08); |
| | | border: 1px solid rgba(239,68,68,.2); |
| | | border-radius: 50px; |
| | | font-size: 13px; color: #f87171; |
| | | letter-spacing: 2px; |
| | | font-weight: 500; |
| | | } |
| | | |
| | | .badge .dot { |
| | | width: 7px; height: 7px; |
| | | border-radius: 50%; |
| | | background: #ef4444; |
| | | box-shadow: 0 0 8px rgba(239,68,68,.7); |
| | | animation: blink 2s ease-in-out infinite; |
| | | } |
| | | |
| | | @keyframes blink { |
| | | 0%,100% { opacity: 1; } |
| | | 50% { opacity: .3; } |
| | | } |
| | | </style> |
| | | </head> |
| | | <body> |
| | | <div class="grid-bg"></div> |
| | | |
| | | <div class="card"> |
| | | <!-- 盾牌图标 --> |
| | | <div class="shield-wrap"> |
| | | <div class="shield-ring"></div> |
| | | <svg viewBox="0 0 60 72" fill="none"> |
| | | <path d="M30 4 L54 16 L54 38 C54 52 42 62 30 68 C18 62 6 52 6 38 L6 16 Z" |
| | | stroke="#ef4444" stroke-width="2.2" stroke-linejoin="round"/> |
| | | <path d="M22 36 L28 43 L40 30" stroke="#ef4444" stroke-width="2.8" |
| | | stroke-linecap="round" stroke-linejoin="round"/> |
| | | </svg> |
| | | </div> |
| | | |
| | | <!-- 中文提示 --> |
| | | <h2>因平台安全与合规要求,暂不支持中国大陆 IP 访问</h2> |
| | | |
| | | <!-- 英文提示 --> |
| | | <h2 class="en">Access from Mainland China is currently unavailable.</h2> |
| | | |
| | | <div class="divider"></div> |
| | | |
| | | <!-- 底部标识 --> |
| | | <div class="badge"> |
| | | <span class="dot"></span> |
| | | 防御平台 |
| | | </div> |
| | | </div> |
| | | </body> |
| | | </html> |
| New file |
| | |
| | | <div class="layui-fluid layui-anim febs-anim" id="firewall-access-log-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-accesslog-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="refresh-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-md2"> |
| | | <div class="layui-form-item"> |
| | | <label class="layui-form-label">IP地址</label> |
| | | <div class="layui-input-block"> |
| | | <input type="text" name="ip" class="layui-input" placeholder="搜索IP"> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <div class="layui-col-md2"> |
| | | <div class="layui-form-item"> |
| | | <label class="layui-form-label">国家</label> |
| | | <div class="layui-input-block"> |
| | | <input type="text" name="countryCode" class="layui-input" placeholder="如 CN/US"> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <div class="layui-col-md2"> |
| | | <div class="layui-form-item"> |
| | | <label class="layui-form-label">状态</label> |
| | | <div class="layui-input-block"> |
| | | <select name="isBlocked" lay-filter="filter-blocked"> |
| | | <option value="">全部</option> |
| | | <option value="0">放行</option> |
| | | <option value="1">拦截</option> |
| | | </select> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <div class="layui-col-md2"> |
| | | <div class="layui-form-item"> |
| | | <label class="layui-form-label">选择站点</label> |
| | | <div class="layui-input-block"> |
| | | <select name="siteId" lay-filter="filter-site" lay-search=""> |
| | | <option value="">全部站点</option> |
| | | </select> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <div class="layui-col-md2"> |
| | | <button class="layui-btn layui-btn-sm" type="button" id="search-btn"> |
| | | <i class="layui-icon"></i> 搜索 |
| | | </button> |
| | | </div> |
| | | </div> |
| | | </form> |
| | | <table lay-filter="firewallAccessLogTable" lay-data="{id: 'firewallAccessLogTable'}"></table> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <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, |
| | | $view = $('#firewall-access-log-list'), |
| | | $reset = $view.find('#reset'), |
| | | $refreshBtn = $view.find('#refresh-btn'), |
| | | $searchBtn = $view.find('#search-btn'), |
| | | tableIns; |
| | | |
| | | form.render(); |
| | | loadSiteFilter(); |
| | | initTable(); |
| | | |
| | | function loadSiteFilter() { |
| | | febs.get(ctx + 'admin/firewall/sites', null, function (res) { |
| | | var sites = res.data || []; |
| | | var $select = $view.find('select[name="siteId"]'); |
| | | sites.forEach(function (s) { |
| | | $select.append('<option value="' + s.id + '">' + s.siteName + ' (' + s.domain + ')</option>'); |
| | | }); |
| | | form.render('select'); |
| | | }); |
| | | } |
| | | |
| | | // 定时自动刷新(每30秒) |
| | | var autoRefreshTimer = setInterval(function () { |
| | | tableIns.reload(); |
| | | }, 30000); |
| | | |
| | | $refreshBtn.on('click', function () { |
| | | tableIns.reload(); |
| | | }); |
| | | |
| | | $reset.on('click', function () { |
| | | $view.find('input[name="ip"]').val(''); |
| | | $view.find('input[name="countryCode"]').val(''); |
| | | $view.find('select[name="isBlocked"]').val(''); |
| | | $view.find('select[name="siteId"]').val(''); |
| | | form.render('select'); |
| | | tableIns.reload(); |
| | | }); |
| | | |
| | | $searchBtn.on('click', function () { |
| | | tableIns.reload(); |
| | | }); |
| | | |
| | | form.on('select(filter-blocked)', function () { |
| | | tableIns.reload(); |
| | | }); |
| | | form.on('select(filter-site)', function () { |
| | | tableIns.reload(); |
| | | }); |
| | | |
| | | function initTable() { |
| | | tableIns = febs.table.init({ |
| | | elem: $view.find('table'), |
| | | id: 'firewallAccessLogTable', |
| | | url: ctx + 'admin/firewall/accessLogList', |
| | | where: function () { |
| | | return { |
| | | ip: $view.find('input[name="ip"]').val() || undefined, |
| | | countryCode: $view.find('input[name="countryCode"]').val() || undefined, |
| | | isBlocked: $view.find('select[name="isBlocked"]').val() || undefined, |
| | | siteId: $view.find('select[name="siteId"]').val() || undefined |
| | | }; |
| | | }, |
| | | cols: [[ |
| | | {field: 'id', title: 'ID', width: 70, align: 'center'}, |
| | | {field: 'ip', title: 'IP地址', width: 160, align: 'center'}, |
| | | {field: 'countryCode', title: '国家', width: 80, align: 'center', |
| | | templet: function (d) { |
| | | return d.countryCode |
| | | ? '<span class="layui-badge layui-bg-blue">' + d.countryCode + '</span>' |
| | | : '<span style="color:#999;">未知</span>'; |
| | | } |
| | | }, |
| | | {field: 'countryName', title: '国家名称', width: 120, align: 'center'}, |
| | | {field: 'requestUri', title: '来源页面', minWidth: 180, |
| | | templet: function (d) { |
| | | return d.requestUri |
| | | ? '<span style="color:#5FB878;">' + d.requestUri + '</span>' |
| | | : '<span style="color:#999;">直接访问</span>'; |
| | | } |
| | | }, |
| | | {field: 'isBlocked', title: '状态', width: 90, align: 'center', |
| | | templet: function (d) { |
| | | return d.isBlocked === 1 |
| | | ? '<span style="color:#FF5722;">● 拦截</span>' |
| | | : '<span style="color:#5FB878;">● 放行</span>'; |
| | | } |
| | | }, |
| | | {field: 'userAgent', title: '浏览器', minWidth: 200, |
| | | templet: function (d) { |
| | | return d.userAgent ? d.userAgent.substring(0, 60) + '...' : '-'; |
| | | } |
| | | }, |
| | | {field: 'createdTime', title: '访问时间', width: 170, align: 'center'} |
| | | ]], |
| | | page: true |
| | | }); |
| | | } |
| | | }); |
| | | </script> |
| 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-ipwhite-list" lay-title="防火墙IP白名单管理"> |
| | | <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-ipwhite-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> 新增IP白名单 |
| | | </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="firewallIpWhiteTable" lay-data="{id: 'firewallIpWhiteTable'}"></table> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <!-- 表格操作栏 --> |
| | | <script type="text/html" id="firewall-ipwhite-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', 'laydate'], function () { |
| | | var $ = layui.jquery, |
| | | febs = layui.febs, |
| | | form = layui.form, |
| | | table = layui.table, |
| | | layer = layui.layer, |
| | | laydate = layui.laydate, |
| | | $view = $('#firewall-ipwhite-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(firewallIpWhiteTable)', function (obj) { |
| | | var data = obj.data, |
| | | layEvent = obj.event; |
| | | if (layEvent === 'edit') { |
| | | openEditModal(data); |
| | | } else if (layEvent === 'del') { |
| | | layer.confirm('确定删除IP白名单 <b>' + data.ip + '</b> 吗?<br>删除后会自动更新Nginx配置!', function (index) { |
| | | febs.get(ctx + 'admin/firewall/ipWhite/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: 'firewallIpWhiteTable', |
| | | url: ctx + 'admin/firewall/ipWhiteList', |
| | | 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: 'ip', title: 'IP地址', width: 180, align: 'center'}, |
| | | {field: 'remark', title: '备注', minWidth: 150, align: 'center'}, |
| | | {field: 'expireTime', title: '过期时间', width: 170, align: 'center', |
| | | templet: function (d) { |
| | | return d.expireTime || '<span style="color:#5FB878;">永久有效</span>'; |
| | | } |
| | | }, |
| | | {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-ipwhite-option', width: 120, align: 'center'}, |
| | | {field: 'createdTime', title: '创建时间', width: 170, align: 'center'}, |
| | | ]] |
| | | }); |
| | | } |
| | | |
| | | // 打开编辑弹窗 |
| | | function openEditModal(data) { |
| | | var title = data ? '编辑IP白名单' : '新增IP白名单'; |
| | | |
| | | var html = '<div style="padding:20px 25px 0 0;">' + |
| | | '<form class="layui-form" lay-filter="firewall-ipwhite-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">IP地址</label>' + |
| | | ' <div class="layui-input-block">' + |
| | | ' <input type="text" name="ip" class="layui-input" ' + (data ? 'value="' + (data.ip || '') + '"' : 'placeholder="如:1.2.3.4 或 2001:db8::1"') + ' lay-verify="required">' + |
| | | ' <div class="layui-form-mid layui-word-aux">支持IPv4和IPv6地址</div>' + |
| | | ' </div>' + |
| | | ' </div>' + |
| | | ' <div class="layui-form-item">' + |
| | | ' <label class="layui-form-label">过期时间</label>' + |
| | | ' <div class="layui-input-block">' + |
| | | ' <input type="text" name="expireTime" id="ipwhite-expire-time" class="layui-input" ' + (data && data.expireTime ? 'value="' + data.expireTime + '"' : 'placeholder="留空表示永久有效"') + '>' + |
| | | ' <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="text" name="remark" class="layui-input" ' + (data && data.remark ? 'value="' + data.remark + '"' : 'placeholder="如:办公室出口IP"') + '>' + |
| | | ' </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: ['540px', '450px'], |
| | | 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'); |
| | | }); |
| | | |
| | | // 渲染过期时间日期选择器 |
| | | laydate.render({ |
| | | elem: '#ipwhite-expire-time', |
| | | type: 'datetime', |
| | | format: 'yyyy-MM-dd HH:mm:ss' |
| | | }); |
| | | |
| | | form.render('radio', 'firewall-ipwhite-form'); |
| | | }, |
| | | btn: ['保存', '取消'], |
| | | yes: function (index, layero) { |
| | | var formObj = $(layero).find('form'); |
| | | var formData = form.val("firewall-ipwhite-form"); |
| | | if (!formData.siteId) { |
| | | layer.msg('请选择站点', {icon: 2}); |
| | | return; |
| | | } |
| | | if (!formData.ip) { |
| | | layer.msg('请输入IP地址', {icon: 2}); |
| | | return; |
| | | } |
| | | // 空字符串过期时间不提交 |
| | | if (!formData.expireTime) { |
| | | delete formData.expireTime; |
| | | } |
| | | febs.post(ctx + 'admin/firewall/ipWhite/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> |