feat: add Fail2ban security detection console

This commit is contained in:
hectorzhao
2026-08-14 10:58:18 +08:00
parent b78faa1aa2
commit d30d9ea4d0
45 changed files with 1967 additions and 18 deletions
+6 -5
View File
@@ -41,11 +41,12 @@ func main() {
go func() {
log.Printf("cmpp gateway inbound server listening on %s", cmppAddr)
if err := (inbound.Server{
Addr: cmppAddr,
APIBaseURL: apiBaseURL,
PresenceStore: presenceStore,
RecoveryStore: recoveryStore,
GatewayInstanceID: getenv("GATEWAY_INSTANCE_ID", hostname()),
Addr: cmppAddr,
APIBaseURL: apiBaseURL,
PresenceStore: presenceStore,
RecoveryStore: recoveryStore,
GatewayInstanceID: getenv("GATEWAY_INSTANCE_ID", hostname()),
SecurityEventToken: os.Getenv("SECURITY_EVENT_TOKEN"),
}).ListenAndServe(); err != nil {
log.Fatalf("gateway inbound server stopped: %v", err)
}
+395
View File
@@ -0,0 +1,395 @@
// security-agent is the deliberately tiny privileged boundary for manual blocking.
// It accepts only a fixed JSON protocol over a Unix socket; it never invokes a shell
// and never accepts command, jail, action, path, or argument strings from NestJS.
package main
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
type request struct {
Action string `json:"action"`
OperationKey string `json:"operationKey"`
SourceIP string `json:"sourceIp"`
Executor string `json:"executor"`
DurationSeconds int `json:"durationSeconds"`
Version int `json:"version"`
Rules []rule `json:"rules"`
}
type rule struct {
Code string `json:"code"`
Enabled bool `json:"enabled"`
Threshold int `json:"threshold"`
WindowSeconds int `json:"windowSeconds"`
CooldownSeconds int `json:"cooldownSeconds"`
}
type response struct {
OK bool `json:"ok"`
Reference string `json:"reference,omitempty"`
Blocked bool `json:"blocked,omitempty"`
Active bool `json:"active,omitempty"`
Error string `json:"error,omitempty"`
}
type block struct {
OperationKey string `json:"operationKey"`
SourceIP string `json:"sourceIp"`
Executor string `json:"executor"`
ExpiresAt time.Time `json:"expiresAt"`
}
type state struct {
Blocks map[string]block `json:"blocks"`
RuleVersion int `json:"ruleVersion"`
}
var allowedDurations = map[int]bool{600: true, 3600: true, 86400: true, 604800: true}
var allowedRules = map[string]bool{"admin_login_failure": true, "client_login_failure": true, "ssh_auth_failure": true, "cmpp_auth_failure": true, "cmpp_protocol_abuse": true, "http_invalid_api_key": true, "http_signature_failure": true, "http_replay_attempt": true, "http_malicious_scan": true}
type agent struct {
mu sync.Mutex
statePath, nginxInclude, fail2banConfig string
data state
}
func main() {
if len(os.Args) == 4 && os.Args[1] == "report" {
if err := reportEvent(os.Args[2], os.Args[3]); err != nil {
log.Fatal(err)
}
return
}
socketPath := env("SECURITY_AGENT_SOCKET", "/run/cmpp-security-agent/agent.sock")
a := &agent{statePath: env("SECURITY_AGENT_STATE", "/var/lib/cmpp-security-agent/state.json"), nginxInclude: env("SECURITY_NGINX_DENY_INCLUDE", "/etc/nginx/snippets/cmpp-security-deny.conf"), fail2banConfig: env("SECURITY_FAIL2BAN_CONFIG", "/etc/fail2ban/jail.d/cmpp-platform-generated.local"), data: state{Blocks: map[string]block{}}}
if err := a.load(); err != nil {
log.Fatalf("load state: %v", err)
}
if err := os.MkdirAll(filepath.Dir(socketPath), 0750); err != nil {
log.Fatal(err)
}
_ = os.Remove(socketPath)
listener, err := net.Listen("unix", socketPath)
if err != nil {
log.Fatal(err)
}
if err := os.Chmod(socketPath, 0660); err != nil {
log.Fatal(err)
}
defer listener.Close()
log.Printf("security agent listening on %s", socketPath)
for {
connection, err := listener.Accept()
if err != nil {
log.Printf("accept: %v", err)
continue
}
go a.serve(connection)
}
}
func (a *agent) serve(connection net.Conn) {
defer connection.Close()
_ = connection.SetDeadline(time.Now().Add(5 * time.Second))
var req request
if err := json.NewDecoder(bufio.NewReader(connection)).Decode(&req); err != nil {
write(connection, response{Error: "invalid request"})
return
}
a.mu.Lock()
defer a.mu.Unlock()
a.prune()
var result response
switch req.Action {
case "block":
result = a.block(req)
case "unblock":
result = a.unblock(req)
case "status":
result = a.status(req)
case "apply_rules":
result = a.applyRules(req)
default:
result = response{Error: "unsupported action"}
}
write(connection, result)
}
func (a *agent) block(req request) response {
if net.ParseIP(req.SourceIP) == nil || !allowedDurations[req.DurationSeconds] || (req.Executor != "nftables" && req.Executor != "nginx_real_ip") || len(req.OperationKey) < 16 {
return response{Error: "invalid fixed block parameters"}
}
if existing, ok := a.data.Blocks[req.OperationKey]; ok {
return response{OK: true, Reference: req.OperationKey, Blocked: existing.ExpiresAt.After(time.Now())}
}
if req.Executor == "nftables" {
if err := nftBlock(req.SourceIP, req.DurationSeconds); err != nil {
return response{Error: err.Error()}
}
}
a.data.Blocks[req.OperationKey] = block{OperationKey: req.OperationKey, SourceIP: req.SourceIP, Executor: req.Executor, ExpiresAt: time.Now().Add(time.Duration(req.DurationSeconds) * time.Second)}
if req.Executor == "nginx_real_ip" {
if err := a.writeNginx(); err != nil {
delete(a.data.Blocks, req.OperationKey)
return response{Error: err.Error()}
}
}
if err := a.save(); err != nil {
return response{Error: err.Error()}
}
return a.status(req)
}
func (a *agent) unblock(req request) response {
if net.ParseIP(req.SourceIP) == nil || (req.Executor != "nftables" && req.Executor != "nginx_real_ip") {
return response{Error: "invalid fixed unblock parameters"}
}
if req.Executor == "nftables" {
if err := nftUnblock(req.SourceIP); err != nil {
return response{Error: err.Error()}
}
}
for key, item := range a.data.Blocks {
if item.SourceIP == req.SourceIP && item.Executor == req.Executor {
delete(a.data.Blocks, key)
}
}
if req.Executor == "nginx_real_ip" {
if err := a.writeNginx(); err != nil {
return response{Error: err.Error()}
}
}
if err := a.save(); err != nil {
return response{Error: err.Error()}
}
return response{OK: true, Reference: req.OperationKey}
}
func (a *agent) status(req request) response {
active := command("systemctl", "is-active", "--quiet", "fail2ban") == nil
if req.SourceIP == "" {
return response{OK: true, Active: active}
}
blocked := false
if req.Executor == "nftables" {
family := "blocked_ipv6"
if net.ParseIP(req.SourceIP).To4() != nil {
family = "blocked_ipv4"
}
output, err := exec.Command("nft", "list", "set", "inet", "cmpp_security", family).CombinedOutput()
if err != nil {
return response{Error: "nftables readback failed: " + strings.TrimSpace(string(output))}
}
blocked = strings.Contains(string(output), req.SourceIP+" timeout") || strings.Contains(string(output), req.SourceIP+" expires")
} else if req.Executor == "nginx_real_ip" {
content, err := os.ReadFile(a.nginxInclude)
if err != nil {
return response{Error: "nginx deny readback failed: " + err.Error()}
}
blocked = strings.Contains(string(content), "deny "+req.SourceIP+";")
} else {
return response{Error: "invalid executor"}
}
for _, item := range a.data.Blocks {
if blocked && item.SourceIP == req.SourceIP && item.Executor == req.Executor && item.ExpiresAt.After(time.Now()) {
return response{OK: true, Active: active, Blocked: true, Reference: item.OperationKey}
}
}
return response{OK: true, Active: active, Blocked: false}
}
func (a *agent) applyRules(req request) response {
if req.Version <= a.data.RuleVersion {
return response{OK: true, Reference: strconv.Itoa(a.data.RuleVersion)}
}
for _, item := range req.Rules {
if !allowedRules[item.Code] || item.Threshold < 1 || item.Threshold > 100000 || item.WindowSeconds < 10 || item.WindowSeconds > 86400 || item.CooldownSeconds < 0 || item.CooldownSeconds > 604800 {
return response{Error: "invalid fixed rule configuration"}
}
}
var ssh, scan *rule
for index := range req.Rules {
if req.Rules[index].Code == "ssh_auth_failure" {
ssh = &req.Rules[index]
}
if req.Rules[index].Code == "http_malicious_scan" {
scan = &req.Rules[index]
}
}
content := "# Generated by cmpp-security-agent. Manual changes will be overwritten.\n"
if ssh != nil {
content += jail("sshd", *ssh)
}
if scan != nil {
content += jail("cmpp-http-scan", *scan)
}
previous, previousErr := os.ReadFile(a.fail2banConfig)
if err := atomicWrite(a.fail2banConfig, []byte(content), 0640); err != nil {
return response{Error: err.Error()}
}
if err := command("fail2ban-client", "-t"); err != nil {
restore(a.fail2banConfig, previous, previousErr, 0640)
return response{Error: "fail2ban validation failed: " + err.Error()}
}
if err := command("fail2ban-client", "reload"); err != nil {
restore(a.fail2banConfig, previous, previousErr, 0640)
return response{Error: "fail2ban reload failed: " + err.Error()}
}
a.data.RuleVersion = req.Version
if err := a.save(); err != nil {
return response{Error: err.Error()}
}
return response{OK: true, Reference: strconv.Itoa(req.Version), Active: true}
}
func jail(name string, item rule) string {
enabled := "false"
if item.Enabled {
enabled = "true"
}
bantime := item.CooldownSeconds
if bantime < item.WindowSeconds {
bantime = item.WindowSeconds
}
return fmt.Sprintf("\n[%s]\nenabled = %s\nfindtime = %d\nmaxretry = %d\nbantime = %d\naction = cmpp-report-only\n", name, enabled, item.WindowSeconds, item.Threshold, bantime)
}
func nftBlock(ip string, seconds int) error {
family := "blocked_ipv6"
if net.ParseIP(ip).To4() != nil {
family = "blocked_ipv4"
}
return command("nft", "add", "element", "inet", "cmpp_security", family, fmt.Sprintf("{ %s timeout %ds }", ip, seconds))
}
func nftUnblock(ip string) error {
family := "blocked_ipv6"
if net.ParseIP(ip).To4() != nil {
family = "blocked_ipv4"
}
err := command("nft", "delete", "element", "inet", "cmpp_security", family, fmt.Sprintf("{ %s }", ip))
if err != nil && strings.Contains(err.Error(), "No such file") {
return nil
}
return err
}
func (a *agent) writeNginx() error {
ips := []string{}
for _, item := range a.data.Blocks {
if item.Executor == "nginx_real_ip" && item.ExpiresAt.After(time.Now()) {
ips = append(ips, item.SourceIP)
}
}
sort.Strings(ips)
lines := []string{"# Generated by cmpp-security-agent."}
for _, ip := range ips {
lines = append(lines, "deny "+ip+";")
}
previous, previousErr := os.ReadFile(a.nginxInclude)
if err := atomicWrite(a.nginxInclude, []byte(strings.Join(lines, "\n")+"\n"), 0640); err != nil {
return err
}
if err := command("nginx", "-t"); err != nil {
restore(a.nginxInclude, previous, previousErr, 0640)
return err
}
if err := command("systemctl", "reload", "nginx"); err != nil {
restore(a.nginxInclude, previous, previousErr, 0640)
_ = command("systemctl", "reload", "nginx")
return err
}
return nil
}
func (a *agent) prune() {
changed := false
for key, item := range a.data.Blocks {
if !item.ExpiresAt.After(time.Now()) {
delete(a.data.Blocks, key)
changed = true
}
}
if changed {
_ = a.writeNginx()
_ = a.save()
}
}
func (a *agent) load() error {
bytes, err := os.ReadFile(a.statePath)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
return json.Unmarshal(bytes, &a.data)
}
func (a *agent) save() error {
bytes, err := json.MarshalIndent(a.data, "", " ")
if err != nil {
return err
}
return atomicWrite(a.statePath, bytes, 0600)
}
func atomicWrite(path string, bytes []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil {
return err
}
temporary := path + ".tmp"
if err := os.WriteFile(temporary, bytes, mode); err != nil {
return err
}
return os.Rename(temporary, path)
}
func restore(path string, previous []byte, previousErr error, mode os.FileMode) {
if previousErr == nil {
_ = atomicWrite(path, previous, mode)
} else if errors.Is(previousErr, os.ErrNotExist) {
_ = os.Remove(path)
}
}
func command(name string, args ...string) error {
output, err := exec.Command(name, args...).CombinedOutput()
if err != nil {
return fmt.Errorf("%s: %s", err, strings.TrimSpace(string(output)))
}
return nil
}
func write(connection net.Conn, value response) { _ = json.NewEncoder(connection).Encode(value) }
func env(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
func reportEvent(jail, ip string) error {
ruleCode := map[string]string{"sshd": "ssh_auth_failure", "cmpp-http-scan": "http_malicious_scan"}[jail]
if ruleCode == "" || net.ParseIP(ip) == nil {
return errors.New("unsupported report event")
}
body := fmt.Sprintf(`{"ruleCode":%q,"sourceIp":%q,"protocol":%q,"resultCode":%q}`, ruleCode, ip, "fail2ban", jail)
client := &http.Client{Timeout: 3 * time.Second}
request, err := http.NewRequest(http.MethodPost, env("SECURITY_EVENT_URL", "http://127.0.0.1:3000/api/gateway/events/security-detection"), strings.NewReader(body))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Security-Event-Token", os.Getenv("SECURITY_EVENT_TOKEN"))
response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("event collector returned %s", response.Status)
}
return nil
}