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

600 lines
17 KiB
Go

package docker_guard
import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
firewallutil "github.com/1Panel-dev/1Panel/agent/utils/firewall"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/lifecycle"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/nftables_helper"
)
const (
Chain = "1PANEL_DOCKER"
DockerChain = "DOCKER-USER"
FamilyIPv4 = constant.FirewallFamilyIPv4
FamilyIPv6 = constant.FirewallFamilyIPv6
ModeSources = "deny_sources"
ModeAllow = "allow_sources"
ModeAll = "deny_all"
StatusEffective = "effective"
StatusDisabled = "disabled"
StatusNotEffective = "not_effective"
ReasonCommandMissing = "command_missing"
ReasonDockerChainMissing = "docker_chain_missing"
ReasonGuardChainMissing = "guard_chain_missing"
ReasonJumpMissing = "jump_missing"
ReasonJumpNotFirst = "jump_not_first"
ReasonJumpDuplicate = "jump_duplicate"
ReasonInspectFailed = "inspect_failed"
)
var (
ErrDockerChainUnavailable = errors.New("Docker DOCKER-USER chain is unavailable")
ErrDockerIptablesChainUnavailable = fmt.Errorf("%w for iptables", ErrDockerChainUnavailable)
ErrDockerNftablesChainUnavailable = fmt.Errorf("%w for nftables", ErrDockerChainUnavailable)
)
type FamilyError struct {
Family string
Err error
}
func (e *FamilyError) Error() string { return fmt.Sprintf("%s Docker port guard: %v", e.Family, e.Err) }
func (e *FamilyError) Unwrap() error { return e.Err }
type Policy struct {
UUID string
Family string
HostIP string
HostPort uint16
Protocol string
Mode string
Sources []string
}
type FamilyStatus struct {
State string
Reason string
Initialized bool
Bound bool
Effective bool
}
type Runner interface {
Run(executable string, args ...string) (string, error)
RunInput(executable, input string, args ...string) (string, error)
Exists(executable string) bool
}
type commandRunner struct{}
func (commandRunner) Run(executable string, args ...string) (string, error) {
executable = dockerGuardExecutable(executable)
manager := cmd.NewCommandMgr(cmd.WithTimeout(60 * time.Second))
stdout, err := manager.RunWithOptionalSudoAndStdout(executable, args...)
if err != nil {
return stdout, fmt.Errorf("command=%s %s failed: %w", executable, strings.Join(args, " "), err)
}
return stdout, nil
}
func (commandRunner) RunInput(executable, input string, args ...string) (string, error) {
executable = dockerGuardExecutable(executable)
if executable == "nft" {
return "", nftables_helper.RunScript(input)
}
manager := cmd.NewCommandMgr(cmd.WithTimeout(60*time.Second), cmd.WithStdin(strings.NewReader(input)))
stdout, err := manager.RunWithOptionalSudoAndStdout(executable, args...)
return stdout, firewallutil.WrapBatchCommandError(executable+" "+strings.Join(args, " "), input, err)
}
func (commandRunner) Exists(executable string) bool {
resolved := dockerGuardExecutable(executable)
return resolved != "" && cmd.Which(resolved)
}
func dockerGuardExecutable(logical string) string {
commands, err := lifecycle.ResolveIptablesCommands()
if err != nil {
return logical
}
switch logical {
case "iptables":
return commands.IPv4
case "iptables-restore":
return commands.Restore4
case "ip6tables":
return commands.IPv6
case "ip6tables-restore":
return commands.Restore6
default:
return logical
}
}
type Manager struct {
runner Runner
}
var mutationMu sync.Mutex
func NewManager() *Manager { return &Manager{runner: commandRunner{}} }
func (m *Manager) Initialize(policies []Policy) error {
mutationMu.Lock()
defer mutationMu.Unlock()
inventory, err := m.ListPolicies()
if err != nil {
return err
}
if !m.runner.Exists("iptables-restore") {
return errors.New("iptables-restore is not installed")
}
if err := m.ensureFamily("iptables", true); err != nil {
return err
}
if m.runner.Exists("ip6tables") {
available, err := m.chainExists("ip6tables", DockerChain)
if err != nil {
return &FamilyError{Family: FamilyIPv6, Err: fmt.Errorf("inspect %s chain: %w", DockerChain, err)}
}
if available {
if !m.runner.Exists("ip6tables-restore") {
return &FamilyError{Family: FamilyIPv6, Err: errors.New("ip6tables-restore is not installed")}
}
if err := m.ensureFamily("ip6tables", false); err != nil {
return &FamilyError{Family: FamilyIPv6, Err: err}
}
}
}
return m.rebuildLocked(policies, inventory)
}
func (m *Manager) Bind() error {
mutationMu.Lock()
defer mutationMu.Unlock()
if err := m.bindExistingFamily("iptables", true); err != nil {
return err
}
if m.runner.Exists("ip6tables") {
if err := m.bindExistingFamily("ip6tables", false); err != nil {
return &FamilyError{Family: FamilyIPv6, Err: err}
}
}
return nil
}
func (m *Manager) 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 *Manager) ListPolicies() (PolicyInventory, error) {
inventory := PolicyInventory{Policies: make([]Policy, 0), ManagedRuleOrders: make(map[string][]int64)}
for _, family := range []string{FamilyIPv4, FamilyIPv6} {
executable := executableForFamily(family)
if executable == "" || !m.runner.Exists(executable) {
continue
}
exists, err := m.chainExists(executable, Chain)
if err != nil {
return PolicyInventory{}, &FamilyError{Family: family, Err: fmt.Errorf("inspect %s chain: %w", Chain, err)}
}
if !exists {
continue
}
output, err := m.run(executable, "-S", Chain)
if err != nil {
return PolicyInventory{}, &FamilyError{Family: family, Err: fmt.Errorf("list %s chain: %w", Chain, 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 *Manager) Unbind() error {
mutationMu.Lock()
defer mutationMu.Unlock()
if err := m.unbindFamily("iptables"); err != nil {
return err
}
if m.runner.Exists("ip6tables") {
if err := m.unbindFamily("ip6tables"); err != nil {
return err
}
}
return nil
}
func (m *Manager) Cleanup() error {
mutationMu.Lock()
defer mutationMu.Unlock()
for _, executable := range []string{"iptables", "ip6tables"} {
if !m.runner.Exists(executable) {
continue
}
output, err := m.run(executable, "-S")
if err != nil {
return err
}
rules := dockerGuardLifecycleRules(output, false, false)
if len(rules) == 0 {
continue
}
if err := m.restoreLifecycle(executable, rules); err != nil {
return err
}
}
return nil
}
func (m *Manager) Initialized(family string) (bool, error) {
executable := executableForFamily(family)
if executable == "" || !m.runner.Exists(executable) {
return false, nil
}
return m.chainExists(executable, Chain)
}
func (m *Manager) Status(family string) FamilyStatus {
executable := executableForFamily(family)
if executable == "" || !m.runner.Exists(executable) {
return FamilyStatus{State: StatusDisabled, Reason: ReasonCommandMissing}
}
chains, err := m.run(executable, "-S")
if err != nil {
return FamilyStatus{State: StatusNotEffective, Reason: ReasonInspectFailed}
}
if !chainDeclared(chains, DockerChain) {
return FamilyStatus{State: StatusDisabled, Reason: ReasonDockerChainMissing}
}
if !chainDeclared(chains, Chain) {
return FamilyStatus{State: StatusDisabled, Reason: ReasonGuardChainMissing}
}
status := FamilyStatus{State: StatusNotEffective, Initialized: true}
rules, err := m.run(executable, "-S", DockerChain)
if err != nil {
status.Reason = ReasonInspectFailed
return status
}
jumps := countJumps(rules)
if jumps == 0 {
status.Reason = ReasonJumpMissing
return status
}
if jumps > 1 {
status.Reason = ReasonJumpDuplicate
return status
}
if !hasFirstUniqueJump(rules) {
status.Reason = ReasonJumpNotFirst
return status
}
status.State = StatusEffective
status.Bound = true
status.Effective = true
return status
}
func (m *Manager) bindExistingFamily(executable string, required bool) error {
if !m.runner.Exists(executable) {
if required {
return fmt.Errorf("%s is not installed", executable)
}
return nil
}
output, err := m.run(executable, "-S")
if err != nil {
return err
}
if !chainDeclared(output, DockerChain) {
if required {
return ErrDockerIptablesChainUnavailable
}
return nil
}
if !chainDeclared(output, Chain) {
if required {
return fmt.Errorf("%s chain is not initialized for %s", Chain, executable)
}
return nil
}
return m.restoreLifecycle(executable, dockerGuardLifecycleRules(output, true, false))
}
func (m *Manager) ensureFamily(executable string, required bool) error {
if !m.runner.Exists(executable) {
if required {
return fmt.Errorf("%s is not installed", executable)
}
return nil
}
output, err := m.run(executable, "-S")
if err != nil {
return err
}
if !chainDeclared(output, DockerChain) {
if required {
return ErrDockerIptablesChainUnavailable
}
return nil
}
return m.restoreLifecycle(executable, dockerGuardLifecycleRules(output, true, !chainDeclared(output, Chain)))
}
func dockerGuardLifecycleRules(output string, bind, createOwned bool) [][]string {
rules := make([][]string, 0, countJumps(output)+3)
if createOwned {
rules = append(rules, []string{"-N", Chain})
}
for i := 0; i < countJumps(output); i++ {
rules = append(rules, []string{"-D", DockerChain, "-j", Chain})
}
if bind {
rules = append(rules, []string{"-I", DockerChain, "1", "-j", Chain})
} else if chainDeclared(output, Chain) {
rules = append(rules, []string{"-F", Chain}, []string{"-X", Chain})
}
return rules
}
func (m *Manager) restoreLifecycle(executable string, rules [][]string) error {
if len(rules) == 0 {
return nil
}
restoreExecutable := executable + "-restore"
if !m.runner.Exists(restoreExecutable) {
return fmt.Errorf("%s is not installed", restoreExecutable)
}
script, err := buildRestoreScript(rules)
if err != nil {
return err
}
if _, err := m.runner.RunInput(restoreExecutable, script, "--noflush", "--wait"); err != nil {
return fmt.Errorf("batch update Docker guard lifecycle: %w", err)
}
return nil
}
func (m *Manager) rebuildLocked(policies []Policy, inventory PolicyInventory) error {
for _, family := range []string{FamilyIPv4, FamilyIPv6} {
executable := executableForFamily(family)
if executable == "" || !m.runner.Exists(executable) {
continue
}
exists, err := m.chainExists(executable, Chain)
if err != nil {
return &FamilyError{Family: family, Err: fmt.Errorf("inspect %s chain: %w", Chain, err)}
}
if !exists {
continue
}
rules := [][]string{{"-F", Chain}, {"-A", Chain, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "RETURN"}}
rules = append(rules, orderedIPTablesRules(family, policies, inventory)...)
rules = append(rules, []string{"-A", Chain, "-j", "RETURN"})
script, err := buildRestoreScript(rules)
if err != nil {
return err
}
restoreExecutable := executable + "-restore"
if !m.runner.Exists(restoreExecutable) {
return &FamilyError{Family: family, Err: fmt.Errorf("%s is not installed", restoreExecutable)}
}
if _, err := m.runner.RunInput(restoreExecutable, script, "--noflush", "--wait"); err != nil {
return &FamilyError{Family: family, Err: fmt.Errorf("restore rules: %w", err)}
}
}
return nil
}
type orderedIPTablesRule struct {
order int64
index int
rules [][]string
}
func orderedIPTablesRules(family string, policies []Policy, inventory PolicyInventory) [][]string {
segments := make([]orderedIPTablesRule, 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) < 2 || native.Tokens[0] != "-A" || native.Tokens[1] != Chain {
continue
}
segments = append(segments, orderedIPTablesRule{order: native.Order, index: index, rules: [][]string{append([]string(nil), native.Tokens...)}})
index++
if native.Order > maxOrder {
maxOrder = native.Order
}
}
}
for _, policy := range policies {
if policy.Family != family {
continue
}
compiled := compilePolicy(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, orderedIPTablesRule{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 buildRestoreScript(rules [][]string) (string, error) {
var script strings.Builder
script.WriteString("*filter\n")
for _, rule := range rules {
for i, token := range rule {
if token == "" || strings.ContainsAny(token, "\r\n\x00") {
return "", fmt.Errorf("invalid iptables-restore token %q", token)
}
if i > 0 {
script.WriteByte(' ')
}
if strings.ContainsAny(token, " \t\\\"'") {
script.WriteString(strconv.Quote(token))
} else {
script.WriteString(token)
}
}
script.WriteByte('\n')
}
script.WriteString("COMMIT\n")
return script.String(), nil
}
func compilePolicy(policy Policy) [][]string {
base := []string{"-A", Chain, "-p", policy.Protocol, "-m", "conntrack"}
if !isWildcardHost(policy.Family, policy.HostIP) {
base = append(base, "--ctorigdst", policy.HostIP)
}
base = append(base, "--ctorigdstport", strconv.Itoa(int(policy.HostPort)))
comment := "1panel-docker:" + policy.UUID
if policy.Mode == ModeAll {
return [][]string{append(append([]string{}, base...), "-m", "comment", "--comment", comment, "-j", "DROP")}
}
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, "-s", source, "-m", "comment", "--comment", comment, "-j", target)
rules = append(rules, args)
}
if policy.Mode == ModeAllow {
rules = append(rules, append(append([]string{}, base...), "-m", "comment", "--comment", comment, "-j", "DROP"))
}
return rules
}
func (m *Manager) unbindFamily(executable string) error {
if !m.runner.Exists(executable) {
return nil
}
if output, err := m.run(executable, "-S", DockerChain); err == nil {
rules := make([][]string, 0, countJumps(output))
for i := 0; i < countJumps(output); i++ {
rules = append(rules, []string{"-D", DockerChain, "-j", Chain})
}
return m.restoreLifecycle(executable, rules)
}
return nil
}
func (m *Manager) chainExists(executable, chain string) (bool, error) {
output, err := m.run(executable, "-S")
if err != nil {
return false, err
}
return chainDeclared(output, chain), nil
}
func chainDeclared(output, chain string) bool {
needle := "-N " + chain
for _, line := range strings.Split(output, "\n") {
if strings.TrimSpace(line) == needle {
return true
}
}
return false
}
func (m *Manager) run(executable string, args ...string) (string, error) {
commandArgs := append([]string{"-w", "-t", "filter"}, args...)
return m.runner.Run(executable, commandArgs...)
}
func executableForFamily(family string) string {
switch family {
case FamilyIPv4:
return "iptables"
case FamilyIPv6:
return "ip6tables"
default:
return ""
}
}
func countJumps(output string) int {
count := 0
for _, line := range strings.Split(output, "\n") {
fields := strings.Fields(line)
if len(fields) == 4 && fields[0] == "-A" && fields[1] == DockerChain && fields[2] == "-j" && fields[3] == Chain {
count++
}
}
return count
}
func hasFirstUniqueJump(output string) bool {
firstRule := ""
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "-A "+DockerChain+" ") {
if firstRule == "" {
firstRule = line
}
}
}
return firstRule == "-A "+DockerChain+" -j "+Chain && countJumps(output) == 1
}
func isWildcardHost(family, hostIP string) bool {
return (family == FamilyIPv4 && (hostIP == "" || hostIP == "0.0.0.0")) ||
(family == FamilyIPv6 && (hostIP == "" || hostIP == "::"))
}