auspex/firewall.go

645 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
)
// =============================================================================
// firewall — просмотр и упрощённое управление ufw / nftables / iptables
// =============================================================================
//
// Backend автоопределяется (ufw, если активен → nftables, если есть непустой
// ruleset → iptables как fallback). Каждое новое правило получает уникальный
// тег-комментарий "auspex-XXXXXXXX", по которому модуль находит его в списке
// после применения (для undo) и разрешает удаление ТОЛЬКО управляемых им
// правил — системные/сторонние правила показываются, но не редактируются.
//
// ГЛАВНАЯ ЗАЩИТА: любое новое правило после применения ждёт подтверждения
// (клавиша 'y') в течение cfgFWRollbackSeconds секунд. Если не подтвердить —
// правило автоматически откатывается. Это защищает от случайной блокировки
// себя же (например по SSH) при удалённой работе.
//
// ВАЖНО: команды для nftables/iptables написаны по документированному
// синтаксису, но не протестированы на живой системе в песочнице — используйте
// dry-run (клавиша 'D') при первом знакомстве с инструментом на новом сервере.
// =============================================================================
// ── Общие типы ───────────────────────────────────────────────────────────────
type fwRule struct {
Backend string
Chain string // INPUT, PREROUTING(nat), inet/auspex/input, ...
ID string // line number (iptables/ufw) или handle (nft)
Action string // ACCEPT/DROP/DNAT/LIMIT...
Proto string
Port string
Source string
Destination string
Comment string // "auspex-xxxxxxxx", если правило наше
Kind string // "rule" | "forward" | "ratelimit" | "" (не наше)
Managed bool
Extra string
}
// fwBackend — унифицированный интерфейс для ufw/nftables/iptables.
type fwBackend interface {
Name() string
ListRules() ([]fwRule, error)
// Rule — разрешить/запретить трафик. action: "allow" | "deny".
// port=="" — правило на весь источник (все порты); source=="" — отовсюду.
Rule(action, proto, port, source string) (apply func() error, undo func() error, err error)
PortForward(proto, extPort, dstIP, dstPort string) (apply func() error, undo func() error, err error)
RateLimit(proto, port string, hitCount, seconds int) (apply func() error, undo func() error, err error)
DeleteRule(r fwRule) error
}
func fwNewTag() string {
b := make([]byte, 4)
_, _ = rand.Read(b)
return "auspex-" + hex.EncodeToString(b)
}
func fwExec(name string, args ...string) error {
out, err := exec.Command(name, args...).CombinedOutput()
if err != nil {
return fmt.Errorf("%s %s: %v\n%s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return nil
}
func fwExecOut(name string, args ...string) (string, error) {
out, err := exec.Command(name, args...).CombinedOutput()
return string(out), err
}
// fwCmdPreview — человекочитаемая команда для dry-run / экрана подтверждения.
func fwCmdPreview(name string, args ...string) string {
return name + " " + strings.Join(args, " ")
}
// ── Автоопределение бэкенда ────────────────────────────────────────────────
func fwDetectBackend() (fwBackend, error) {
if _, err := exec.LookPath("ufw"); err == nil {
out, _ := fwExecOut("ufw", "status")
if strings.Contains(out, "Status: active") {
return fwUFWBackend{}, nil
}
}
if _, err := exec.LookPath("nft"); err == nil {
out, err := fwExecOut("nft", "list", "ruleset")
if err == nil && strings.TrimSpace(out) != "" {
return fwNFTBackend{}, nil
}
}
if _, err := exec.LookPath("iptables"); err == nil {
return fwIPTBackend{}, nil
}
return nil, fmt.Errorf("не найден ни ufw (активный), ни nftables (непустой ruleset), ни iptables")
}
func fwPersistenceHint(backendName string) string {
switch backendName {
case "ufw":
return "ufw сохраняет правила автоматически (/etc/ufw/*.rules)."
case "nftables":
return "nftables НЕ сохраняет правила между рестартами автоматически — добавьте вручную в /etc/nftables.conf (например 'nft list ruleset >> /etc/nftables.conf' и проверьте файл руками)."
case "iptables":
return "iptables НЕ сохраняет правила автоматически — выполните 'netfilter-persistent save' (Debian/Ubuntu) или аналог для вашего дистрибутива."
default:
return ""
}
}
// =============================================================================
// UFW backend
// =============================================================================
type fwUFWBackend struct{}
func (b fwUFWBackend) Name() string { return "ufw" }
var fwUFWLineRe = regexp.MustCompile(`^\[\s*(\d+)\]\s+(.+?)\s{2,}(ALLOW|DENY|REJECT|LIMIT)\s+(IN|OUT)\s{2,}(.+)$`)
func (b fwUFWBackend) ListRules() ([]fwRule, error) {
out, err := fwExecOut("ufw", "status", "numbered")
if err != nil {
return nil, fmt.Errorf("ufw status numbered: %v\n%s", err, out)
}
var rules []fwRule
for _, line := range strings.Split(out, "\n") {
line = strings.TrimRight(line, " \r")
m := fwUFWLineRe.FindStringSubmatch(line)
if m == nil {
continue
}
to := strings.TrimSpace(m[2])
proto := ""
port := to
if idx := strings.LastIndex(to, "/"); idx != -1 {
proto = to[idx+1:]
port = to[:idx]
}
rules = append(rules, fwRule{
Backend: "ufw", Chain: "ufw", ID: m[1],
Action: m[3], Proto: proto, Port: port,
Source: strings.TrimSpace(m[5]), Extra: line,
})
}
return rules, nil
}
// fwUFWNewestRule возвращает правило с максимальным номером — сразу после
// добавления ufw обычно дописывает новое правило в конец списка.
func (b fwUFWBackend) fwUFWNewestRule() (fwRule, error) {
rules, err := b.ListRules()
if err != nil {
return fwRule{}, err
}
if len(rules) == 0 {
return fwRule{}, fmt.Errorf("список правил ufw пуст после применения")
}
best := rules[0]
bestNum := 0
for _, r := range rules {
if n, err := strconv.Atoi(r.ID); err == nil && n > bestNum {
bestNum = n
best = r
}
}
return best, nil
}
func (b fwUFWBackend) Rule(action, proto, port, source string) (func() error, func() error, error) {
verb := "allow"
if action == "deny" {
verb = "deny"
}
var args []string
if source != "" {
args = []string{verb, "from", source}
if port != "" {
args = append(args, "to", "any", "port", port)
if proto != "" {
args = append(args, "proto", proto)
}
}
} else if port != "" {
spec := port
if proto != "" {
spec += "/" + proto
}
args = []string{verb, spec}
} else {
return nil, nil, fmt.Errorf("нужно указать порт и/или источник")
}
apply := func() error { return fwExec("ufw", args...) }
undo := func() error {
r, err := b.fwUFWNewestRule()
if err != nil {
return err
}
return b.DeleteRule(r)
}
return apply, undo, nil
}
func (b fwUFWBackend) PortForward(proto, extPort, dstIP, dstPort string) (func() error, func() error, error) {
// ufw не даёт нативного простого CLI для DNAT — работаем напрямую через
// iptables (ufw и так построен поверх него), но это НЕ персистентно через
// конфиг ufw — нужно вручную добавить в /etc/ufw/before.rules для сохранения.
tag := fwNewTag()
dnatArgs := []string{"-t", "nat", "-I", "PREROUTING", "1", "-p", proto,
"--dport", extPort, "-j", "DNAT", "--to-destination", dstIP + ":" + dstPort}
fwdArgs := []string{"-I", "FORWARD", "1", "-p", proto, "-d", dstIP,
"--dport", dstPort, "-j", "ACCEPT", "-m", "comment", "--comment", tag}
apply := func() error {
if err := fwExec("iptables", dnatArgs...); err != nil {
return err
}
return fwExec("iptables", fwdArgs...)
}
undo := func() error {
undnat := []string{"-t", "nat", "-D", "PREROUTING", "-p", proto,
"--dport", extPort, "-j", "DNAT", "--to-destination", dstIP + ":" + dstPort}
_ = fwExec("iptables", undnat...)
unfwd := []string{"-D", "FORWARD", "-p", proto, "-d", dstIP,
"--dport", dstPort, "-j", "ACCEPT", "-m", "comment", "--comment", tag}
return fwExec("iptables", unfwd...)
}
return apply, undo, nil
}
func (b fwUFWBackend) RateLimit(proto, port string, hitCount, seconds int) (func() error, func() error, error) {
// ufw имеет нативный 'limit' (примерно 6 попыток / 30 сек, пороги не кастомизируются
// через CLI) — используем его напрямую, кастомные hitCount/seconds здесь игнорируются.
spec := port + "/" + proto
apply := func() error { return fwExec("ufw", "limit", spec) }
undo := func() error { return fwExec("ufw", "delete", "limit", spec) }
return apply, undo, nil
}
func (b fwUFWBackend) DeleteRule(r fwRule) error {
return fwExec("ufw", "--force", "delete", r.ID)
}
// =============================================================================
// nftables backend
// =============================================================================
const fwNFTTable = "auspex"
const fwNFTChain = "input"
const fwNFTNatTable = "auspex_nat"
type fwNFTBackend struct{}
func (b fwNFTBackend) Name() string { return "nftables" }
func (b fwNFTBackend) ensureFilterChain() error {
if err := fwExec("nft", "add", "table", "inet", fwNFTTable); err != nil {
return err
}
return fwExec("nft", "add", "chain", "inet", fwNFTTable, fwNFTChain,
"{", "type", "filter", "hook", "input", "priority", "0", ";", "policy", "accept", ";", "}")
}
func (b fwNFTBackend) ensureNatChain() error {
if err := fwExec("nft", "add", "table", "ip", fwNFTNatTable); err != nil {
return err
}
return fwExec("nft", "add", "chain", "ip", fwNFTNatTable, "prerouting",
"{", "type", "nat", "hook", "prerouting", "priority", "-100", ";", "}")
}
var fwNFTRuleRe = regexp.MustCompile(`^\s*(.+?)\s*#\s*handle\s+(\d+)\s*$`)
var fwNFTCommentRe = regexp.MustCompile(`comment\s+"?(auspex-[0-9a-f]+)"?`)
var fwNFTPortRe = regexp.MustCompile(`dport\s+(\S+)`)
var fwNFTProtoRe = regexp.MustCompile(`^\s*(tcp|udp)\b`)
var fwNFTSaddrRe = regexp.MustCompile(`ip6?\s+saddr\s+(\S+)`)
func fwNFTParseLines(out, chainLabel string) []fwRule {
var rules []fwRule
for _, line := range strings.Split(out, "\n") {
m := fwNFTRuleRe.FindStringSubmatch(line)
if m == nil {
continue
}
body, handle := m[1], m[2]
action := "accept"
switch {
case strings.Contains(body, "drop"):
action = "drop"
case strings.Contains(body, "dnat"):
action = "dnat"
case strings.Contains(body, "reject"):
action = "reject"
}
kind := "rule"
if strings.Contains(body, "dnat") {
kind = "forward"
} else if strings.Contains(body, "limit rate") {
kind = "ratelimit"
}
proto := ""
if pm := fwNFTProtoRe.FindStringSubmatch(body); pm != nil {
proto = pm[1]
}
port := ""
if pm := fwNFTPortRe.FindStringSubmatch(body); pm != nil {
port = pm[1]
}
source := ""
if sm := fwNFTSaddrRe.FindStringSubmatch(body); sm != nil {
source = sm[1]
}
comment := ""
managed := false
if cm := fwNFTCommentRe.FindStringSubmatch(body); cm != nil {
comment = cm[1]
managed = true
}
rules = append(rules, fwRule{
Backend: "nftables", Chain: chainLabel, ID: handle,
Action: action, Proto: proto, Port: port, Source: source,
Comment: comment, Kind: kind, Managed: managed, Extra: strings.TrimSpace(body),
})
}
return rules
}
func (b fwNFTBackend) ListRules() ([]fwRule, error) {
var rules []fwRule
if out, err := fwExecOut("nft", "-a", "list", "table", "inet", fwNFTTable); err == nil {
rules = append(rules, fwNFTParseLines(out, "inet/"+fwNFTTable+"/"+fwNFTChain)...)
}
if out, err := fwExecOut("nft", "-a", "list", "table", "ip", fwNFTNatTable); err == nil {
rules = append(rules, fwNFTParseLines(out, "ip/"+fwNFTNatTable+"/prerouting")...)
}
return rules, nil
}
func (b fwNFTBackend) findByTag(tag string) (fwRule, error) {
rules, err := b.ListRules()
if err != nil {
return fwRule{}, err
}
for _, r := range rules {
if r.Comment == tag {
return r, nil
}
}
return fwRule{}, fmt.Errorf("правило с тегом %s не найдено после применения (проверьте синтаксис nft вручную)", tag)
}
func (b fwNFTBackend) Rule(action, proto, port, source string) (func() error, func() error, error) {
if err := b.ensureFilterChain(); err != nil {
return nil, nil, fmt.Errorf("не удалось подготовить таблицу/цепочку auspex: %w", err)
}
tag := fwNewTag()
verb := "accept"
if action == "deny" {
verb = "drop"
}
args := []string{"add", "rule", "inet", fwNFTTable, fwNFTChain}
if proto != "" {
args = append(args, proto)
}
if port != "" {
args = append(args, "dport", port)
}
if source != "" {
fam := "ip"
if strings.Contains(source, ":") {
fam = "ip6"
}
args = append(args, fam, "saddr", source)
}
args = append(args, verb, "comment", tag)
apply := func() error { return fwExec("nft", args...) }
undo := func() error {
r, err := b.findByTag(tag)
if err != nil {
return err
}
return b.DeleteRule(r)
}
return apply, undo, nil
}
func (b fwNFTBackend) PortForward(proto, extPort, dstIP, dstPort string) (func() error, func() error, error) {
if err := b.ensureNatChain(); err != nil {
return nil, nil, fmt.Errorf("не удалось подготовить nat-таблицу auspex: %w", err)
}
tag := fwNewTag()
args := []string{"add", "rule", "ip", fwNFTNatTable, "prerouting",
proto, "dport", extPort, "dnat", "to", dstIP + ":" + dstPort, "comment", tag}
apply := func() error { return fwExec("nft", args...) }
undo := func() error {
r, err := b.findByTag(tag)
if err != nil {
return err
}
return b.DeleteRule(r)
}
return apply, undo, nil
}
func (b fwNFTBackend) RateLimit(proto, port string, hitCount, seconds int) (func() error, func() error, error) {
if err := b.ensureFilterChain(); err != nil {
return nil, nil, fmt.Errorf("не удалось подготовить таблицу/цепочку auspex: %w", err)
}
tag := fwNewTag()
perMinute := hitCount
if seconds > 0 && seconds != 60 {
perMinute = hitCount * 60 / seconds
if perMinute < 1 {
perMinute = 1
}
}
// Вставляем В НАЧАЛО цепочки (insert без позиции = начало) — порядок важен:
// правило лимита должно идти РАНЬШЕ любого общего accept для этого порта,
// иначе более ранний accept "перехватит" пакет первым.
args := []string{"insert", "rule", "inet", fwNFTTable, fwNFTChain,
proto, "dport", port, "ct", "state", "new",
"limit", "rate", "over", fmt.Sprintf("%d/minute", perMinute),
"drop", "comment", tag}
apply := func() error { return fwExec("nft", args...) }
undo := func() error {
r, err := b.findByTag(tag)
if err != nil {
return err
}
return b.DeleteRule(r)
}
return apply, undo, nil
}
func (b fwNFTBackend) DeleteRule(r fwRule) error {
parts := strings.SplitN(r.Chain, "/", 3)
if len(parts) != 3 {
return fmt.Errorf("некорректная цепочка nft: %s", r.Chain)
}
family, table, chain := parts[0], parts[1], parts[2]
return fwExec("nft", "delete", "rule", family, table, chain, "handle", r.ID)
}
// =============================================================================
// iptables backend
// =============================================================================
type fwIPTBackend struct{}
func (b fwIPTBackend) Name() string { return "iptables" }
var fwIPTDptRe = regexp.MustCompile(`dpts?:(\S+)`)
var fwIPTCommentRe = regexp.MustCompile(`/\*\s*(auspex-[0-9a-f]+)\s*\*/`)
func fwIPTParseChain(out, chainLabel string) []fwRule {
var rules []fwRule
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(strings.TrimSpace(line))
if len(fields) < 10 {
continue
}
if _, err := strconv.Atoi(fields[0]); err != nil {
continue // не строка данных (заголовок/Chain ...)
}
num, target, proto := fields[0], fields[3], fields[4]
source, destination := fields[8], fields[9]
extra := strings.Join(fields[10:], " ")
port := ""
if m := fwIPTDptRe.FindStringSubmatch(extra); m != nil {
port = m[1]
}
comment := ""
managed := false
if m := fwIPTCommentRe.FindStringSubmatch(extra); m != nil {
comment = m[1]
managed = true
}
kind := "rule"
if strings.Contains(chainLabel, "nat") {
kind = "forward"
} else if strings.Contains(extra, "recent") {
kind = "ratelimit"
}
rules = append(rules, fwRule{
Backend: "iptables", Chain: chainLabel, ID: num,
Action: target, Proto: proto, Port: port,
Source: source, Destination: destination,
Comment: comment, Kind: kind, Managed: managed, Extra: extra,
})
}
return rules
}
func (b fwIPTBackend) ListRules() ([]fwRule, error) {
var rules []fwRule
out, err := fwExecOut("iptables", "-L", "INPUT", "-n", "-v", "--line-numbers")
if err != nil {
return nil, fmt.Errorf("iptables -L INPUT: %v\n%s", err, out)
}
rules = append(rules, fwIPTParseChain(out, "INPUT")...)
if natOut, err := fwExecOut("iptables", "-t", "nat", "-L", "PREROUTING", "-n", "-v", "--line-numbers"); err == nil {
rules = append(rules, fwIPTParseChain(natOut, "PREROUTING(nat)")...)
}
return rules, nil
}
func (b fwIPTBackend) findByTag(tag, chain string) (fwRule, error) {
rules, err := b.ListRules()
if err != nil {
return fwRule{}, err
}
for _, r := range rules {
if r.Comment == tag && strings.HasPrefix(r.Chain, chain) {
return r, nil
}
}
return fwRule{}, fmt.Errorf("правило с тегом %s не найдено после применения", tag)
}
func (b fwIPTBackend) Rule(action, proto, port, source string) (func() error, func() error, error) {
target := "ACCEPT"
if action == "deny" {
target = "DROP"
}
tag := fwNewTag()
args := []string{"-I", "INPUT", "1"}
if proto != "" {
args = append(args, "-p", proto)
}
if port != "" {
args = append(args, "--dport", port)
}
if source != "" {
args = append(args, "-s", source)
}
args = append(args, "-j", target, "-m", "comment", "--comment", tag)
apply := func() error { return fwExec("iptables", args...) }
undo := func() error {
r, err := b.findByTag(tag, "INPUT")
if err != nil {
return err
}
return b.DeleteRule(r)
}
return apply, undo, nil
}
func (b fwIPTBackend) PortForward(proto, extPort, dstIP, dstPort string) (func() error, func() error, error) {
tag := fwNewTag()
dnatArgs := []string{"-t", "nat", "-I", "PREROUTING", "1", "-p", proto,
"--dport", extPort, "-j", "DNAT", "--to-destination", dstIP + ":" + dstPort,
"-m", "comment", "--comment", tag}
fwdArgs := []string{"-I", "FORWARD", "1", "-p", proto, "-d", dstIP,
"--dport", dstPort, "-j", "ACCEPT", "-m", "comment", "--comment", tag}
apply := func() error {
if err := fwExec("iptables", dnatArgs...); err != nil {
return err
}
return fwExec("iptables", fwdArgs...)
}
undo := func() error {
// FORWARD-цепочка не входит в ListRules() (там только INPUT и nat/PREROUTING),
// поэтому удаляем оба правила напрямую по полной спецификации, без поиска по тегу.
nat, errNat := b.findByTag(tag, "PREROUTING")
if errNat == nil {
_ = b.DeleteRule(nat)
} else {
undnat := []string{"-t", "nat", "-D", "PREROUTING", "-p", proto,
"--dport", extPort, "-j", "DNAT", "--to-destination", dstIP + ":" + dstPort,
"-m", "comment", "--comment", tag}
_ = fwExec("iptables", undnat...)
}
return fwExec("iptables", "-D", "FORWARD", "-p", proto, "-d", dstIP,
"--dport", dstPort, "-j", "ACCEPT", "-m", "comment", "--comment", tag)
}
return apply, undo, nil
}
func (b fwIPTBackend) RateLimit(proto, port string, hitCount, seconds int) (func() error, func() error, error) {
tag := fwNewTag()
name := "AUSPEX_" + port
setArgs := []string{"-I", "INPUT", "1", "-p", proto, "--dport", port,
"-m", "conntrack", "--ctstate", "NEW",
"-m", "recent", "--set", "--name", name,
"-m", "comment", "--comment", tag}
dropArgs := []string{"-I", "INPUT", "1", "-p", proto, "--dport", port,
"-m", "conntrack", "--ctstate", "NEW",
"-m", "recent", "--update", "--seconds", strconv.Itoa(seconds),
"--hitcount", strconv.Itoa(hitCount), "--name", name,
"-j", "DROP", "-m", "comment", "--comment", tag}
apply := func() error {
// dropArgs добавляем первым (-I ... 1), затем setArgs тоже в позицию 1 —
// в итоге set оказывается ПЕРЕД drop, именно такой порядок нужен модулю recent.
if err := fwExec("iptables", dropArgs...); err != nil {
return err
}
return fwExec("iptables", setArgs...)
}
undo := func() error {
rules, err := b.ListRules()
if err != nil {
return err
}
var lastErr error
for _, r := range rules {
if r.Comment == tag && r.Chain == "INPUT" {
if err := b.DeleteRule(r); err != nil {
lastErr = err
}
}
}
return lastErr
}
return apply, undo, nil
}
func (b fwIPTBackend) DeleteRule(r fwRule) error {
chain := r.Chain
table := "filter"
if strings.Contains(chain, "(nat)") {
chain = strings.TrimSuffix(chain, "(nat)")
table = "nat"
}
return fwExec("iptables", "-t", table, "-D", chain, r.ID)
}