Files
1Panel/agent/utils/firewall/docker_guard/nftables.go

491 lines
14 KiB
Go

package docker_guard
import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
)
const (
NftTable = "nft_1panel_docker"
NftBaseChain = "NFT_1PANEL_DOCKER_FORWARD"
NftChain = "NFT_1PANEL_DOCKER"
dockerNftTable = "docker-bridges"
)
type NftablesManager struct {
runner Runner
}
func NewNftablesManager() *NftablesManager { return &NftablesManager{runner: commandRunner{}} }
func (m *NftablesManager) Initialize(policies []Policy) error {
mutationMu.Lock()
defer mutationMu.Unlock()
inventory, err := m.ListPolicies()
if err != nil {
return err
}
if !m.runner.Exists("nft") {
return errors.New("nft is not installed")
}
if err := m.ensureFamily(FamilyIPv4, true); err != nil {
return err
}
if err := m.ensureFamily(FamilyIPv6, false); err != nil {
return &FamilyError{Family: FamilyIPv6, Err: err}
}
return m.rebuildLocked(policies, inventory)
}
func (m *NftablesManager) Bind() error {
mutationMu.Lock()
defer mutationMu.Unlock()
if err := m.bindExistingFamily(FamilyIPv4, true); err != nil {
return err
}
if err := m.bindExistingFamily(FamilyIPv6, false); err != nil {
return &FamilyError{Family: FamilyIPv6, Err: err}
}
return nil
}
func (m *NftablesManager) Reconcile(policies []Policy) error {
mutationMu.Lock()
defer mutationMu.Unlock()
inventory, err := m.ListPolicies()
if err != nil {
return err
}
return m.rebuildLocked(policies, inventory)
}
func (m *NftablesManager) ListPolicies() (PolicyInventory, error) {
if !m.runner.Exists("nft") {
return PolicyInventory{}, nil
}
inventory := PolicyInventory{Policies: make([]Policy, 0), ManagedRuleOrders: make(map[string][]int64)}
for _, family := range []string{FamilyIPv4, FamilyIPv6} {
tableFamily := nftTableFamily(family)
if !m.objectExists("chain", tableFamily, NftTable, NftChain) {
continue
}
output, err := m.run("-a", "list", "chain", tableFamily, NftTable, NftChain)
if err != nil {
return PolicyInventory{}, &FamilyError{Family: family, Err: fmt.Errorf("list %s chain: %w", NftChain, err)}
}
parsed, err := parseDockerGuardPolicies(output, family)
if err != nil {
return PolicyInventory{}, &FamilyError{Family: family, Err: err}
}
inventory.Policies = append(inventory.Policies, parsed.Policies...)
inventory.ReadOnly = append(inventory.ReadOnly, parsed.ReadOnly...)
for key, orders := range parsed.ManagedRuleOrders {
inventory.ManagedRuleOrders[key] = append([]int64(nil), orders...)
}
}
return inventory, nil
}
func (m *NftablesManager) Unbind() error {
mutationMu.Lock()
defer mutationMu.Unlock()
for _, family := range []string{FamilyIPv4, FamilyIPv6} {
if err := m.unbindFamily(family); err != nil {
return &FamilyError{Family: family, Err: err}
}
}
return nil
}
func (m *NftablesManager) Cleanup() error {
mutationMu.Lock()
defer mutationMu.Unlock()
if !m.runner.Exists("nft") {
return nil
}
commands := make([][]string, 0, 2)
for _, family := range []string{FamilyIPv4, FamilyIPv6} {
tableFamily := nftTableFamily(family)
if !m.objectExists("table", tableFamily, NftTable) {
continue
}
commands = append(commands, []string{"delete", "table", tableFamily, NftTable})
}
return m.runBatch(commands)
}
func (m *NftablesManager) Initialized(family string) (bool, error) {
if nftTableFamily(family) == "" || !m.runner.Exists("nft") {
return false, nil
}
tableFamily := nftTableFamily(family)
if !m.objectExists("chain", tableFamily, NftTable, NftBaseChain) {
return false, nil
}
return m.objectExists("chain", tableFamily, NftTable, NftChain), nil
}
func (m *NftablesManager) Status(family string) FamilyStatus {
tableFamily := nftTableFamily(family)
if tableFamily == "" || !m.runner.Exists("nft") {
return FamilyStatus{State: StatusDisabled, Reason: ReasonCommandMissing}
}
if !m.objectExists("table", tableFamily, dockerNftTable) {
return FamilyStatus{State: StatusDisabled, Reason: ReasonDockerChainMissing}
}
if !m.objectExists("chain", tableFamily, NftTable, NftBaseChain) ||
!m.objectExists("chain", tableFamily, NftTable, NftChain) {
return FamilyStatus{State: StatusDisabled, Reason: ReasonGuardChainMissing}
}
status := FamilyStatus{State: StatusNotEffective, Initialized: true}
rules, err := m.run("-a", "list", "chain", tableFamily, NftTable, NftBaseChain)
if err != nil {
status.Reason = ReasonInspectFailed
return status
}
jumps := nftJumpHandles(rules)
if len(jumps) == 0 {
status.Reason = ReasonJumpMissing
return status
}
if len(jumps) > 1 {
status.Reason = ReasonJumpDuplicate
return status
}
if !nftHasFirstUniqueJump(rules) {
status.Reason = ReasonJumpNotFirst
return status
}
status.State = StatusEffective
status.Bound = true
status.Effective = true
return status
}
func (m *NftablesManager) ensureFamily(family string, required bool) error {
tableFamily := nftTableFamily(family)
if tableFamily == "" {
return fmt.Errorf("unsupported address family %q", family)
}
if !m.objectExists("table", tableFamily, dockerNftTable) {
if required {
return fmt.Errorf("%w %s", ErrDockerNftablesChainUnavailable, family)
}
return nil
}
commands := make([][]string, 0, 6)
tableExists := m.objectExists("table", tableFamily, NftTable)
if !tableExists {
commands = append(commands, []string{"add", "table", tableFamily, NftTable})
}
baseExists := tableExists && m.objectExists("chain", tableFamily, NftTable, NftBaseChain)
if !baseExists {
commands = append(commands, []string{
"add", "chain", tableFamily, NftTable, NftBaseChain,
"{", "type", "filter", "hook", "forward", "priority", "-1", ";", "policy", "accept", ";", "}",
})
}
if !tableExists || !m.objectExists("chain", tableFamily, NftTable, NftChain) {
commands = append(commands, []string{"add", "chain", tableFamily, NftTable, NftChain})
}
if baseExists {
output, err := m.run("-a", "list", "chain", tableFamily, NftTable, NftBaseChain)
if err != nil {
return err
}
for _, handle := range nftJumpHandles(output) {
commands = append(commands, []string{"delete", "rule", tableFamily, NftTable, NftBaseChain, "handle", handle})
}
}
commands = append(commands, []string{"insert", "rule", tableFamily, NftTable, NftBaseChain, "jump", NftChain})
return m.runBatch(commands)
}
func (m *NftablesManager) bindExistingFamily(family string, required bool) error {
tableFamily := nftTableFamily(family)
if !m.runner.Exists("nft") {
if required {
return errors.New("nft is not installed")
}
return nil
}
if !m.objectExists("table", tableFamily, dockerNftTable) {
if required {
return fmt.Errorf("%w %s", ErrDockerNftablesChainUnavailable, family)
}
return nil
}
if !m.objectExists("chain", tableFamily, NftTable, NftBaseChain) ||
!m.objectExists("chain", tableFamily, NftTable, NftChain) {
if required {
return fmt.Errorf("%s chain is not initialized for nftables %s", NftChain, family)
}
return nil
}
return m.ensureJump(family)
}
func (m *NftablesManager) ensureJump(family string) error {
tableFamily := nftTableFamily(family)
output, err := m.run("-a", "list", "chain", tableFamily, NftTable, NftBaseChain)
if err != nil {
return err
}
commands := make([][]string, 0, len(nftJumpHandles(output))+1)
for _, handle := range nftJumpHandles(output) {
commands = append(commands, []string{"delete", "rule", tableFamily, NftTable, NftBaseChain, "handle", handle})
}
commands = append(commands, []string{"insert", "rule", tableFamily, NftTable, NftBaseChain, "jump", NftChain})
return m.runBatch(commands)
}
func (m *NftablesManager) rebuildLocked(policies []Policy, inventory PolicyInventory) error {
if !m.runner.Exists("nft") {
return nil
}
for _, family := range []string{FamilyIPv4, FamilyIPv6} {
tableFamily := nftTableFamily(family)
if !m.objectExists("chain", tableFamily, NftTable, NftChain) {
continue
}
commands := [][]string{{"flush", "chain", tableFamily, NftTable, NftChain}}
commands = append(commands, []string{"add", "rule", tableFamily, NftTable, NftChain, "ct", "state", "{", "established,related", "}", "return"})
commands = append(commands, orderedNftRules(family, policies, inventory)...)
commands = append(commands, []string{"add", "rule", tableFamily, NftTable, NftChain, "return"})
script, err := buildNftScript(commands)
if err != nil {
return &FamilyError{Family: family, Err: err}
}
if _, err := m.runner.RunInput("nft", script); err != nil {
return &FamilyError{Family: family, Err: fmt.Errorf("restore rules: %w", err)}
}
}
return nil
}
type orderedNftRule struct {
order int64
index int
rules [][]string
}
func orderedNftRules(family string, policies []Policy, inventory PolicyInventory) [][]string {
tableFamily := nftTableFamily(family)
segments := make([]orderedNftRule, 0, len(policies)+len(inventory.ReadOnly))
maxOrder := int64(0)
index := 0
for _, orders := range inventory.ManagedRuleOrders {
for _, order := range orders {
if order > maxOrder {
maxOrder = order
}
}
}
for _, item := range inventory.ReadOnly {
for _, native := range item.NativeRules {
if native.Family != family || len(native.Tokens) == 0 || native.Tokens[0] == "-A" {
continue
}
command := []string{"add", "rule", tableFamily, NftTable, NftChain}
command = append(command, quoteNftTokens(native.Tokens)...)
segments = append(segments, orderedNftRule{order: native.Order, index: index, rules: [][]string{command}})
index++
if native.Order > maxOrder {
maxOrder = native.Order
}
}
}
for _, policy := range policies {
if policy.Family != family {
continue
}
compiled := compileNftPolicy(policy)
orders := inventory.ManagedRuleOrders[managedOrderKey(policy.Family, policy.UUID)]
for ruleIndex, rule := range compiled {
order := int64(0)
if ruleIndex < len(orders) {
order = orders[ruleIndex]
} else {
maxOrder++
order = maxOrder
}
segments = append(segments, orderedNftRule{order: order, index: index, rules: [][]string{rule}})
index++
}
}
sort.SliceStable(segments, func(left, right int) bool {
if segments[left].order == segments[right].order {
return segments[left].index < segments[right].index
}
return segments[left].order < segments[right].order
})
rules := make([][]string, 0)
for _, segment := range segments {
rules = append(rules, segment.rules...)
}
return rules
}
func quoteNftTokens(tokens []string) []string {
quoted := make([]string, 0, len(tokens))
for _, token := range tokens {
if strings.ContainsAny(token, " \t\\\"'") && !strings.HasPrefix(token, `"`) {
quoted = append(quoted, strconv.Quote(token))
continue
}
quoted = append(quoted, token)
}
return quoted
}
func compileNftPolicy(policy Policy) [][]string {
tableFamily := nftTableFamily(policy.Family)
addressKeyword := tableFamily
base := []string{"add", "rule", tableFamily, NftTable, NftChain, "meta", "l4proto", policy.Protocol}
if !isWildcardHost(policy.Family, policy.HostIP) {
base = append(base, "ct", "original", addressKeyword, "daddr", policy.HostIP)
}
base = append(base, "ct", "original", "proto-dst", strconv.Itoa(int(policy.HostPort)))
comment := strconv.Quote("1panel-docker:" + policy.UUID)
if policy.Mode == ModeAll {
return [][]string{append(append([]string{}, base...), "drop", "comment", comment)}
}
target := "drop"
capacity := len(policy.Sources)
if policy.Mode == ModeAllow {
target = "return"
capacity++
}
rules := make([][]string, 0, capacity)
for _, source := range policy.Sources {
args := append([]string{}, base...)
args = append(args, addressKeyword, "saddr", source, target, "comment", comment)
rules = append(rules, args)
}
if policy.Mode == ModeAllow {
rules = append(rules, append(append([]string{}, base...), "drop", "comment", comment))
}
return rules
}
func buildNftScript(commands [][]string) (string, error) {
var script strings.Builder
for _, command := range commands {
for index, token := range command {
if !validNftToken(token) {
return "", fmt.Errorf("invalid nftables token %q", token)
}
if index > 0 {
script.WriteByte(' ')
}
script.WriteString(token)
}
script.WriteByte('\n')
}
return script.String(), nil
}
func validNftToken(token string) bool {
if token == "" || strings.ContainsAny(token, "\r\n") {
return false
}
if strings.HasPrefix(token, `"`) {
_, err := strconv.Unquote(token)
return err == nil
}
return !strings.ContainsAny(token, " \t\\\"'")
}
func (m *NftablesManager) unbindFamily(family string) error {
if !m.runner.Exists("nft") {
return nil
}
tableFamily := nftTableFamily(family)
if !m.objectExists("chain", tableFamily, NftTable, NftBaseChain) {
return nil
}
output, err := m.run("-a", "list", "chain", tableFamily, NftTable, NftBaseChain)
if err != nil {
return err
}
commands := make([][]string, 0, len(nftJumpHandles(output)))
for _, handle := range nftJumpHandles(output) {
commands = append(commands, []string{"delete", "rule", tableFamily, NftTable, NftBaseChain, "handle", handle})
}
return m.runBatch(commands)
}
func (m *NftablesManager) runBatch(commands [][]string) error {
if len(commands) == 0 {
return nil
}
script, err := buildNftScript(commands)
if err != nil {
return err
}
if _, err := m.runner.RunInput("nft", script); err != nil {
return fmt.Errorf("batch update Docker guard lifecycle: %w", err)
}
return nil
}
func (m *NftablesManager) objectExists(kind string, args ...string) bool {
command := append([]string{"list", kind}, args...)
_, err := m.run(command...)
return err == nil
}
func (m *NftablesManager) run(args ...string) (string, error) {
return m.runner.Run("nft", args...)
}
func nftTableFamily(family string) string {
switch family {
case FamilyIPv4:
return "ip"
case FamilyIPv6:
return "ip6"
default:
return ""
}
}
func nftJumpHandles(output string) []string {
handles := make([]string, 0)
for _, line := range strings.Split(output, "\n") {
fields := strings.Fields(strings.TrimSpace(line))
if len(fields) < 4 || fields[0] != "jump" || fields[1] != NftChain {
continue
}
for index := 2; index+1 < len(fields); index++ {
if fields[index] == "handle" {
handles = append(handles, fields[index+1])
break
}
}
}
return handles
}
func nftHasFirstUniqueJump(output string) bool {
if len(nftJumpHandles(output)) != 1 {
return false
}
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if !strings.Contains(line, "# handle ") {
continue
}
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] == "table" || fields[0] == "chain" {
continue
}
return len(fields) >= 2 && fields[0] == "jump" && fields[1] == NftChain
}
return false
}