diff --git a/cmd/edge-node/main.go b/cmd/edge-node/main.go index 707510f..8dfedd5 100644 --- a/cmd/edge-node/main.go +++ b/cmd/edge-node/main.go @@ -8,7 +8,10 @@ import ( "github.com/TeaOSLab/EdgeNode/internal/nodes" _ "github.com/iwind/TeaGo/bootstrap" "github.com/iwind/TeaGo/logs" + "github.com/iwind/TeaGo/maps" + "github.com/iwind/TeaGo/types" "github.com/iwind/gosock/pkg/gosock" + "net" "net/http" _ "net/http/pprof" "os" @@ -126,6 +129,113 @@ func main() { fmt.Println("ok") } }) + app.On("ip.drop", func() { + var args = os.Args[2:] + if len(args) == 0 { + fmt.Println("Usage: edge-node ip.drop IP [--timeout=SECONDS]") + return + } + var ip = args[0] + if len(net.ParseIP(ip)) == 0 { + fmt.Println("IP '" + ip + "' is invalid") + return + } + var timeoutSeconds = 0 + var options = app.ParseOptions(args[1:]) + timeout, ok := options["timeout"] + if ok { + timeoutSeconds = types.Int(timeout[0]) + } + + fmt.Println("drop ip '" + ip + "' for '" + types.String(timeoutSeconds) + "' seconds") + var sock = gosock.NewTmpSock(teaconst.ProcessName) + reply, err := sock.Send(&gosock.Command{ + Code: "dropIP", + Params: map[string]interface{}{ + "ip": ip, + "timeoutSeconds": timeoutSeconds, + }, + }) + if err != nil { + fmt.Println("[ERROR]" + err.Error()) + } else { + var errString = maps.NewMap(reply.Params).GetString("error") + if len(errString) > 0 { + fmt.Println("[ERROR]" + errString) + } else { + fmt.Println("ok") + } + } + }) + app.On("ip.reject", func() { + var args = os.Args[2:] + if len(args) == 0 { + fmt.Println("Usage: edge-node ip.reject IP [--timeout=SECONDS]") + return + } + var ip = args[0] + if len(net.ParseIP(ip)) == 0 { + fmt.Println("IP '" + ip + "' is invalid") + return + } + var timeoutSeconds = 0 + var options = app.ParseOptions(args[1:]) + timeout, ok := options["timeout"] + if ok { + timeoutSeconds = types.Int(timeout[0]) + } + + fmt.Println("reject ip '" + ip + "' for '" + types.String(timeoutSeconds) + "' seconds") + + var sock = gosock.NewTmpSock(teaconst.ProcessName) + reply, err := sock.Send(&gosock.Command{ + Code: "rejectIP", + Params: map[string]interface{}{ + "ip": ip, + "timeoutSeconds": timeoutSeconds, + }, + }) + if err != nil { + fmt.Println("[ERROR]" + err.Error()) + } else { + var errString = maps.NewMap(reply.Params).GetString("error") + if len(errString) > 0 { + fmt.Println("[ERROR]" + errString) + } else { + fmt.Println("ok") + } + } + }) + app.On("ip.remove", func() { + var args = os.Args[2:] + if len(args) == 0 { + fmt.Println("Usage: edge-node ip.remove IP") + return + } + var ip = args[0] + if len(net.ParseIP(ip)) == 0 { + fmt.Println("IP '" + ip + "' is invalid") + return + } + + var sock = gosock.NewTmpSock(teaconst.ProcessName) + reply, err := sock.Send(&gosock.Command{ + Code: "removeIP", + Params: map[string]interface{}{ + "ip": ip, + }, + }) + if err != nil { + fmt.Println("[ERROR]" + err.Error()) + } else { + var errString = maps.NewMap(reply.Params).GetString("error") + if len(errString) > 0 { + fmt.Println("[ERROR]" + errString) + } else { + fmt.Println("ok") + } + } + }) app.Run(func() { node := nodes.NewNode() node.Start() diff --git a/internal/apps/app_cmd.go b/internal/apps/app_cmd.go index 24d4d8c..9da4136 100644 --- a/internal/apps/app_cmd.go +++ b/internal/apps/app_cmd.go @@ -11,6 +11,7 @@ import ( "os/exec" "runtime" "strconv" + "strings" "syscall" "time" ) @@ -245,3 +246,19 @@ func (this *AppCmd) getPID() int { } return maps.NewMap(reply.Params).GetInt("pid") } + +// ParseOptions 分析参数中的选项 +func (this *AppCmd) ParseOptions(args []string) map[string][]string { + var result = map[string][]string{} + for _, arg := range args { + var pieces = strings.SplitN(arg, "=", 2) + var key = strings.TrimLeft(pieces[0], "- ") + key = strings.TrimSpace(key) + var value = "" + if len(pieces) == 2 { + value = strings.TrimSpace(pieces[1]) + } + result[key] = append(result[key], value) + } + return result +} diff --git a/internal/firewalls/firewall.go b/internal/firewalls/firewall.go new file mode 100644 index 0000000..1c156da --- /dev/null +++ b/internal/firewalls/firewall.go @@ -0,0 +1,42 @@ +// Copyright 2022 Liuxiangchao iwind.liu@gmail.com. All rights reserved. + +package firewalls + +import ( + "github.com/TeaOSLab/EdgeNode/internal/events" + "github.com/TeaOSLab/EdgeNode/internal/remotelogs" +) + +var currentFirewall FirewallInterface + +// 初始化 +func init() { + events.On(events.EventLoaded, func() { + var firewall = Firewall() + if firewall.Name() == "mock" { + remotelogs.Warn("FIREWALL", "'firewalld' on this system should be enabled to block attackers more effectively") + } else { + remotelogs.Println("FIREWALL", "found local firewall '"+firewall.Name()+"'") + } + }) +} + +// Firewall 查找当前系统中最适合的防火墙 +func Firewall() FirewallInterface { + if currentFirewall != nil { + return currentFirewall + } + + // firewalld + { + var firewalld = NewFirewalld() + if firewalld.IsReady() { + currentFirewall = firewalld + return currentFirewall + } + } + + // 至少返回一个 + currentFirewall = NewMockFirewall() + return currentFirewall +} diff --git a/internal/firewalls/firewall_firewalld.go b/internal/firewalls/firewall_firewalld.go new file mode 100644 index 0000000..8789d20 --- /dev/null +++ b/internal/firewalls/firewall_firewalld.go @@ -0,0 +1,135 @@ +// Copyright 2022 Liuxiangchao iwind.liu@gmail.com. All rights reserved. + +package firewalls + +import ( + "github.com/TeaOSLab/EdgeNode/internal/goman" + "github.com/TeaOSLab/EdgeNode/internal/remotelogs" + "github.com/iwind/TeaGo/types" + "os/exec" + "strings" +) + +type Firewalld struct { + isReady bool + exe string + cmdQueue chan *exec.Cmd +} + +func NewFirewalld() *Firewalld { + var firewalld = &Firewalld{ + cmdQueue: make(chan *exec.Cmd, 2048), + } + + path, err := exec.LookPath("firewall-cmd") + if err == nil && len(path) > 0 { + var cmd = exec.Command(path, "-V") + err := cmd.Run() + if err == nil { + firewalld.exe = path + firewalld.isReady = true + firewalld.init() + } + } + + return firewalld +} + +func (this *Firewalld) init() { + goman.New(func() { + for cmd := range this.cmdQueue { + err := cmd.Run() + if err != nil { + if strings.HasPrefix(err.Error(), "Warning:") { + continue + } + remotelogs.Warn("FIREWALL", "run command failed '"+cmd.String()+"': "+err.Error()) + } + } + }) +} + +// Name 名称 +func (this *Firewalld) Name() string { + return "firewalld" +} + +func (this *Firewalld) IsReady() bool { + return this.isReady +} + +func (this *Firewalld) AllowPort(port int, protocol string) error { + if !this.isReady { + return nil + } + var cmd = exec.Command(this.exe, "--add-port="+types.String(port)+"/"+protocol) + this.pushCmd(cmd) + return nil +} + +func (this *Firewalld) RemovePort(port int, protocol string) error { + if !this.isReady { + return nil + } + var cmd = exec.Command(this.exe, "--remove-port="+types.String(port)+"/"+protocol) + this.pushCmd(cmd) + return nil +} + +func (this *Firewalld) RejectSourceIP(ip string, timeoutSeconds int) error { + if !this.isReady { + return nil + } + var family = "ipv4" + if strings.Contains(ip, ":") { + family = "ipv6" + } + var args = []string{"--add-rich-rule=rule family='" + family + "' source address='" + ip + "' reject"} + if timeoutSeconds > 0 { + args = append(args, "--timeout="+types.String(timeoutSeconds)+"s") + } + var cmd = exec.Command(this.exe, args...) + this.pushCmd(cmd) + return nil +} + +func (this *Firewalld) DropSourceIP(ip string, timeoutSeconds int) error { + if !this.isReady { + return nil + } + var family = "ipv4" + if strings.Contains(ip, ":") { + family = "ipv6" + } + var args = []string{"--add-rich-rule=rule family='" + family + "' source address='" + ip + "' drop"} + if timeoutSeconds > 0 { + args = append(args, "--timeout="+types.String(timeoutSeconds)+"s") + } + var cmd = exec.Command(this.exe, args...) + this.pushCmd(cmd) + return nil +} + +func (this *Firewalld) RemoveSourceIP(ip string) error { + if !this.isReady { + return nil + } + var family = "ipv4" + if strings.Contains(ip, ":") { + family = "ipv6" + } + for _, action := range []string{"reject", "drop"} { + var args = []string{"--remove-rich-rule=rule family='" + family + "' source address='" + ip + "' " + action} + var cmd = exec.Command(this.exe, args...) + this.pushCmd(cmd) + } + return nil +} + +func (this *Firewalld) pushCmd(cmd *exec.Cmd) { + select { + case this.cmdQueue <- cmd: + default: + // we discard the command + } +} diff --git a/internal/firewalls/firewall_interface.go b/internal/firewalls/firewall_interface.go new file mode 100644 index 0000000..082b8b9 --- /dev/null +++ b/internal/firewalls/firewall_interface.go @@ -0,0 +1,27 @@ +// Copyright 2022 Liuxiangchao iwind.liu@gmail.com. All rights reserved. + +package firewalls + +// FirewallInterface 防火墙接口 +type FirewallInterface interface { + // Name 名称 + Name() string + + // IsReady 是否已准备被调用 + IsReady() bool + + // AllowPort 允许端口 + AllowPort(port int, protocol string) error + + // RemovePort 删除端口 + RemovePort(port int, protocol string) error + + // RejectSourceIP 拒绝某个源IP连接 + RejectSourceIP(ip string, timeoutSeconds int) error + + // DropSourceIP 丢弃某个源IP数据 + DropSourceIP(ip string, timeoutSeconds int) error + + // RemoveSourceIP 删除某个源IP + RemoveSourceIP(ip string) error +} diff --git a/internal/firewalls/firewall_mock.go b/internal/firewalls/firewall_mock.go new file mode 100644 index 0000000..c11d561 --- /dev/null +++ b/internal/firewalls/firewall_mock.go @@ -0,0 +1,55 @@ +// Copyright 2022 Liuxiangchao iwind.liu@gmail.com. All rights reserved. + +package firewalls + +// MockFirewall 模拟防火墙 +type MockFirewall struct { +} + +func NewMockFirewall() *MockFirewall { + return &MockFirewall{} +} + +// Name 名称 +func (this *MockFirewall) Name() string { + return "mock" +} + +// IsReady 是否已准备被调用 +func (this *MockFirewall) IsReady() bool { + return true +} + +// AllowPort 允许端口 +func (this *MockFirewall) AllowPort(port int, protocol string) error { + _ = port + _ = protocol + return nil +} + +// RemovePort 删除端口 +func (this *MockFirewall) RemovePort(port int, protocol string) error { + _ = port + _ = protocol + return nil +} + +// RejectSourceIP 拒绝某个源IP连接 +func (this *MockFirewall) RejectSourceIP(ip string, timeoutSeconds int) error { + _ = ip + _ = timeoutSeconds + return nil +} + +// DropSourceIP 丢弃某个源IP数据 +func (this *MockFirewall) DropSourceIP(ip string, timeoutSeconds int) error { + _ = ip + _ = timeoutSeconds + return nil +} + +// RemoveSourceIP 删除某个源IP +func (this *MockFirewall) RemoveSourceIP(ip string) error { + _ = ip + return nil +} diff --git a/internal/iplibrary/manager_ip_list.go b/internal/iplibrary/manager_ip_list.go index b67580b..fc8b236 100644 --- a/internal/iplibrary/manager_ip_list.go +++ b/internal/iplibrary/manager_ip_list.go @@ -197,7 +197,7 @@ func (this *IPListManager) processItems(items []*pb.IPItem, shouldExecute bool) list.Delete(item.Id) // 从WAF名单中删除 - waf.SharedIPBlackList.RemoveIP(item.IpFrom, item.ServerId) + waf.SharedIPBlackList.RemoveIP(item.IpFrom, item.ServerId, shouldExecute) // 操作事件 if shouldExecute { diff --git a/internal/nodes/node.go b/internal/nodes/node.go index c06c2d1..e5b0166 100644 --- a/internal/nodes/node.go +++ b/internal/nodes/node.go @@ -11,6 +11,7 @@ import ( "github.com/TeaOSLab/EdgeNode/internal/configs" teaconst "github.com/TeaOSLab/EdgeNode/internal/const" "github.com/TeaOSLab/EdgeNode/internal/events" + "github.com/TeaOSLab/EdgeNode/internal/firewalls" "github.com/TeaOSLab/EdgeNode/internal/goman" "github.com/TeaOSLab/EdgeNode/internal/iplibrary" "github.com/TeaOSLab/EdgeNode/internal/metrics" @@ -636,6 +637,47 @@ func (this *Node) listenSock() error { "limiter": sharedConnectionsLimiter.Len(), }, }) + case "dropIP": + var m = maps.NewMap(cmd.Params) + var ip = m.GetString("ip") + var timeSeconds = m.GetInt("timeoutSeconds") + err := firewalls.Firewall().DropSourceIP(ip, timeSeconds) + if err != nil { + _ = cmd.Reply(&gosock.Command{ + Params: map[string]interface{}{ + "error": err.Error(), + }, + }) + } else { + _ = cmd.ReplyOk() + } + case "rejectIP": + var m = maps.NewMap(cmd.Params) + var ip = m.GetString("ip") + var timeSeconds = m.GetInt("timeoutSeconds") + err := firewalls.Firewall().RejectSourceIP(ip, timeSeconds) + if err != nil { + _ = cmd.Reply(&gosock.Command{ + Params: map[string]interface{}{ + "error": err.Error(), + }, + }) + } else { + _ = cmd.ReplyOk() + } + case "removeIP": + var m = maps.NewMap(cmd.Params) + var ip = m.GetString("ip") + err := firewalls.Firewall().RemoveSourceIP(ip) + if err != nil { + _ = cmd.Reply(&gosock.Command{ + Params: map[string]interface{}{ + "error": err.Error(), + }, + }) + } else { + _ = cmd.ReplyOk() + } case "gc": runtime.GC() debug.FreeOSMemory() diff --git a/internal/nodes/waf_manager.go b/internal/nodes/waf_manager.go index be285dd..09a2e74 100644 --- a/internal/nodes/waf_manager.go +++ b/internal/nodes/waf_manager.go @@ -60,10 +60,11 @@ func (this *WAFManager) convertWAF(policy *firewallconfigs.HTTPFirewallPolicy) ( policy.Mode = firewallconfigs.FirewallModeDefend } w := &waf.WAF{ - Id: policy.Id, - IsOn: policy.IsOn, - Name: policy.Name, - Mode: policy.Mode, + Id: policy.Id, + IsOn: policy.IsOn, + Name: policy.Name, + Mode: policy.Mode, + UseLocalFirewall: policy.UseLocalFirewall, } // inbound diff --git a/internal/waf/action_block.go b/internal/waf/action_block.go index d3e14c6..fcac1f8 100644 --- a/internal/waf/action_block.go +++ b/internal/waf/action_block.go @@ -64,7 +64,7 @@ func (this *BlockAction) Perform(waf *WAF, group *RuleGroup, set *RuleSet, reque timeout = 60 // 默认封锁60秒 } - SharedIPBlackList.RecordIP(IPTypeAll, this.Scope, request.WAFServerId(), request.WAFRemoteIP(), time.Now().Unix()+int64(timeout), waf.Id, group.Id, set.Id) + SharedIPBlackList.RecordIP(IPTypeAll, this.Scope, request.WAFServerId(), request.WAFRemoteIP(), time.Now().Unix()+int64(timeout), waf.Id, waf.UseLocalFirewall, group.Id, set.Id) if writer != nil { // close the connection diff --git a/internal/waf/action_post_307.go b/internal/waf/action_post_307.go index 3800fc6..c494dde 100644 --- a/internal/waf/action_post_307.go +++ b/internal/waf/action_post_307.go @@ -56,7 +56,7 @@ func (this *Post307Action) Perform(waf *WAF, group *RuleGroup, set *RuleSet, req life = 600 // 默认10分钟 } var setId = m.GetString("setId") - SharedIPWhiteList.RecordIP("set:"+setId, this.Scope, request.WAFServerId(), request.WAFRemoteIP(), time.Now().Unix()+life, m.GetInt64("policyId"), m.GetInt64("groupId"), m.GetInt64("setId")) + SharedIPWhiteList.RecordIP("set:"+setId, this.Scope, request.WAFServerId(), request.WAFRemoteIP(), time.Now().Unix()+life, m.GetInt64("policyId"), false, m.GetInt64("groupId"), m.GetInt64("setId")) return true } } diff --git a/internal/waf/captcha_validator.go b/internal/waf/captcha_validator.go index 803b20d..da5fb92 100644 --- a/internal/waf/captcha_validator.go +++ b/internal/waf/captcha_validator.go @@ -153,7 +153,7 @@ func (this *CaptchaValidator) validate(actionConfig *CaptchaAction, policyId int } // 加入到白名单 - SharedIPWhiteList.RecordIP("set:"+strconv.FormatInt(setId, 10), actionConfig.Scope, request.WAFServerId(), request.WAFRemoteIP(), time.Now().Unix()+int64(life), policyId, groupId, setId) + SharedIPWhiteList.RecordIP("set:"+strconv.FormatInt(setId, 10), actionConfig.Scope, request.WAFServerId(), request.WAFRemoteIP(), time.Now().Unix()+int64(life), policyId, false, groupId, setId) http.Redirect(writer, request.WAFRaw(), originURL, http.StatusSeeOther) diff --git a/internal/waf/get302_validator.go b/internal/waf/get302_validator.go index 7e2de9f..4721a7f 100644 --- a/internal/waf/get302_validator.go +++ b/internal/waf/get302_validator.go @@ -44,7 +44,7 @@ func (this *Get302Validator) Run(request requests.Request, writer http.ResponseW life = 600 // 默认10分钟 } setId := m.GetString("setId") - SharedIPWhiteList.RecordIP("set:"+setId, m.GetString("scope"), request.WAFServerId(), request.WAFRemoteIP(), time.Now().Unix()+life, m.GetInt64("policyId"), m.GetInt64("groupId"), m.GetInt64("setId")) + SharedIPWhiteList.RecordIP("set:"+setId, m.GetString("scope"), request.WAFServerId(), request.WAFRemoteIP(), time.Now().Unix()+life, m.GetInt64("policyId"), false, m.GetInt64("groupId"), m.GetInt64("setId")) // 返回原始URL var url = m.GetString("url") diff --git a/internal/waf/ip_list.go b/internal/waf/ip_list.go index bb55575..1807af4 100644 --- a/internal/waf/ip_list.go +++ b/internal/waf/ip_list.go @@ -4,10 +4,12 @@ package waf import ( "github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs/firewallconfigs" + "github.com/TeaOSLab/EdgeNode/internal/firewalls" "github.com/TeaOSLab/EdgeNode/internal/utils/expires" "github.com/iwind/TeaGo/types" "sync" "sync/atomic" + "time" ) var SharedIPWhiteList = NewIPList(IPListTypeAllow) @@ -71,10 +73,19 @@ func (this *IPList) Add(ipType string, scope firewallconfigs.FirewallScope, serv } // RecordIP 记录IP -func (this *IPList) RecordIP(ipType string, scope firewallconfigs.FirewallScope, serverId int64, ip string, expiresAt int64, policyId int64, groupId int64, setId int64) { +func (this *IPList) RecordIP(ipType string, + scope firewallconfigs.FirewallScope, + serverId int64, + ip string, + expiresAt int64, + policyId int64, + useLocalFirewall bool, + groupId int64, + setId int64) { this.Add(ipType, scope, serverId, ip, expiresAt) if this.listType == IPListTypeDeny { + // 加入队列等待上传 select { case recordIPTaskChan <- &recordIPTask{ ip: ip, @@ -90,6 +101,18 @@ func (this *IPList) RecordIP(ipType string, scope firewallconfigs.FirewallScope, default: } + + // 使用本地防火墙 + if useLocalFirewall { + var seconds = expiresAt - time.Now().Unix() + if seconds > 0 { + // 最大3600,防止误封时间过长 + if seconds > 3600 { + seconds = 3600 + } + _ = firewalls.Firewall().DropSourceIP(ip, int(seconds)) + } + } } } @@ -111,13 +134,18 @@ func (this *IPList) Contains(ipType string, scope firewallconfigs.FirewallScope, } // RemoveIP 删除IP -func (this *IPList) RemoveIP(ip string, serverId int64) { +func (this *IPList) RemoveIP(ip string, serverId int64, shouldExecute bool) { this.locker.Lock() delete(this.ipMap, "*@"+ip+"@"+IPTypeAll) if serverId > 0 { delete(this.ipMap, types.String(serverId)+"@"+ip+"@"+IPTypeAll) } this.locker.Unlock() + + // 从本地防火墙中删除 + if shouldExecute { + _ = firewalls.Firewall().RemoveSourceIP(ip) + } } func (this *IPList) remove(id int64) { diff --git a/internal/waf/template_test.go b/internal/waf/template_test.go index 0ac5a3c..4fddcda 100644 --- a/internal/waf/template_test.go +++ b/internal/waf/template_test.go @@ -43,9 +43,9 @@ func Test_Template2(t *testing.T) { } waf := Template() - err = waf.Init() - if err != nil { - t.Fatal(err) + var errs = waf.Init() + if len(errs) > 0 { + t.Fatal(errs[0]) } now := time.Now() diff --git a/internal/waf/waf.go b/internal/waf/waf.go index 1117afb..2a69a22 100644 --- a/internal/waf/waf.go +++ b/internal/waf/waf.go @@ -15,13 +15,14 @@ import ( ) type WAF struct { - Id int64 `yaml:"id" json:"id"` - IsOn bool `yaml:"isOn" json:"isOn"` - Name string `yaml:"name" json:"name"` - Inbound []*RuleGroup `yaml:"inbound" json:"inbound"` - Outbound []*RuleGroup `yaml:"outbound" json:"outbound"` - CreatedVersion string `yaml:"createdVersion" json:"createdVersion"` - Mode firewallconfigs.FirewallMode `yaml:"mode" json:"mode"` + Id int64 `yaml:"id" json:"id"` + IsOn bool `yaml:"isOn" json:"isOn"` + Name string `yaml:"name" json:"name"` + Inbound []*RuleGroup `yaml:"inbound" json:"inbound"` + Outbound []*RuleGroup `yaml:"outbound" json:"outbound"` + CreatedVersion string `yaml:"createdVersion" json:"createdVersion"` + Mode firewallconfigs.FirewallMode `yaml:"mode" json:"mode"` + UseLocalFirewall bool `yaml:"useLocalFirewall" json:"useLocalFirewall"` DefaultBlockAction *BlockAction diff --git a/internal/waf/waf_test.go b/internal/waf/waf_test.go index 6d6b316..19780fc 100644 --- a/internal/waf/waf_test.go +++ b/internal/waf/waf_test.go @@ -33,9 +33,9 @@ func TestWAF_MatchRequest(t *testing.T) { waf := NewWAF() waf.AddRuleGroup(group) - err := waf.Init() - if err != nil { - t.Fatal(err) + errs := waf.Init() + if len(errs) > 0 { + t.Fatal(errs[0]) } req, err := http.NewRequest(http.MethodGet, "http://teaos.cn/hello?name=lu&age=20", nil)