From 7be7368bb94c83ecc910ca5ecedd2581984ad499 Mon Sep 17 00:00:00 2001 From: ssongliu Date: Wed, 2 Sep 2026 14:48:59 +0800 Subject: [PATCH] fix: improve firewall lifecycle recovery (#13686) --- agent/app/dto/firewall.go | 2 + agent/app/model/firewall.go | 20 +- agent/app/repo/docker_port_guard.go | 43 ++++- agent/app/service/firewall.go | 174 +++++++++++++++--- agent/app/service/firewall_docker.go | 69 ++++++- agent/app/service/firewall_sync.go | 54 ++++-- agent/init/migration/migrate.go | 1 + agent/init/migration/migrations/init.go | 18 ++ agent/utils/firewall/docker_guard/manager.go | 108 +++++++++-- agent/utils/firewall/docker_guard/nftables.go | 114 ++++++++++-- .../firewall/docker_guard/policy_inspect.go | 132 +++++++++++-- agent/utils/firewall/docker_guard/runtime.go | 71 ++++++- agent/utils/firewall/filter/adapter.go | 9 +- agent/utils/firewall/filter/check.go | 57 +++++- .../firewall/filter/providers/ufw/adapter.go | 26 ++- .../utils/firewall/filter/runtime/runtime.go | 8 + agent/utils/firewall/lifecycle/operator.go | 28 ++- agent/utils/firewall/sync/diff.go | 3 + frontend/src/api/interface/firewall.ts | 2 + frontend/src/lang/modules/en.ts | 6 +- frontend/src/lang/modules/es-es.ts | 6 +- frontend/src/lang/modules/fa.ts | 6 +- frontend/src/lang/modules/ja.ts | 6 +- frontend/src/lang/modules/ko.ts | 6 +- frontend/src/lang/modules/lo.ts | 7 +- frontend/src/lang/modules/ms.ts | 6 +- frontend/src/lang/modules/pt-br.ts | 6 +- frontend/src/lang/modules/ru.ts | 6 +- frontend/src/lang/modules/tr.ts | 6 +- frontend/src/lang/modules/zh-Hant.ts | 7 +- frontend/src/lang/modules/zh.ts | 7 +- .../src/views/host/firewall/setting/index.vue | 2 +- .../src/views/host/firewall/sync/index.vue | 18 +- 33 files changed, 883 insertions(+), 151 deletions(-) diff --git a/agent/app/dto/firewall.go b/agent/app/dto/firewall.go index be7fda426..4c2d5846d 100644 --- a/agent/app/dto/firewall.go +++ b/agent/app/dto/firewall.go @@ -170,6 +170,8 @@ type DockerPortGuardEndpoint struct { Application string `json:"application,omitempty"` PolicyUUID string `json:"policyUUID,omitempty"` Mode string `json:"mode,omitempty"` + NativeAction string `json:"nativeAction,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` Sources []string `json:"sources"` Effective bool `json:"effective"` Description string `json:"description,omitempty"` diff --git a/agent/app/model/firewall.go b/agent/app/model/firewall.go index a6987b3a4..2478cc35f 100644 --- a/agent/app/model/firewall.go +++ b/agent/app/model/firewall.go @@ -16,14 +16,18 @@ const FirewallRuleSequenceStep int64 = 1 << 32 type DockerPortGuardPolicy struct { BaseModel - UUID string `gorm:"size:64;not null;uniqueIndex" json:"uuid"` - Family string `gorm:"size:16;not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"family"` - HostIP string `gorm:"size:64;not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"hostIP"` - HostPort uint16 `gorm:"not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"hostPort"` - Protocol string `gorm:"size:8;not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"protocol"` - Mode string `gorm:"size:32;not null" json:"mode"` - Sources string `gorm:"type:text" json:"-"` - Description string `gorm:"type:text" json:"description"` + UUID string `gorm:"size:64;not null;uniqueIndex" json:"uuid"` + ReadOnly bool `gorm:"not null;default:false;uniqueIndex:idx_docker_port_guard_endpoint" json:"-"` + Family string `gorm:"size:16;not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"family"` + HostIP string `gorm:"size:64;not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"hostIP"` + HostPort uint16 `gorm:"not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"hostPort"` + Protocol string `gorm:"size:8;not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"protocol"` + Mode string `gorm:"size:32;not null" json:"mode"` + Sources string `gorm:"type:text" json:"-"` + Description string `gorm:"type:text" json:"description"` + NativeAction string `gorm:"size:32;not null;default:''" json:"-"` + NativeRules string `gorm:"type:text" json:"-"` + Sequence int64 `gorm:"not null;default:0" json:"-"` } type ForwardingRule struct { diff --git a/agent/app/repo/docker_port_guard.go b/agent/app/repo/docker_port_guard.go index cc76170c4..dfe2b5e4b 100644 --- a/agent/app/repo/docker_port_guard.go +++ b/agent/app/repo/docker_port_guard.go @@ -10,30 +10,47 @@ import ( ) type IDockerPortGuardRepo interface { - List(context.Context) ([]model.DockerPortGuardPolicy, error) + ListManaged(context.Context) ([]model.DockerPortGuardPolicy, error) + ListRuntimeReadOnly(context.Context) ([]model.DockerPortGuardPolicy, error) DeleteBatch(context.Context, []string) error UpsertBatch(context.Context, []model.DockerPortGuardPolicy) error + ReplaceRuntimeReadOnly(context.Context, []model.DockerPortGuardPolicy) error } type DockerPortGuardRepo struct{} func NewIDockerPortGuardRepo() IDockerPortGuardRepo { return &DockerPortGuardRepo{} } -func (r *DockerPortGuardRepo) List(ctx context.Context) ([]model.DockerPortGuardPolicy, error) { +func (r *DockerPortGuardRepo) ListManaged(ctx context.Context) ([]model.DockerPortGuardPolicy, error) { var policies []model.DockerPortGuardPolicy - err := global.DB.WithContext(ctx).Order("family, host_ip, host_port, protocol").Find(&policies).Error + err := global.DB.WithContext(ctx). + Where("read_only = ?", false). + Order("family, host_ip, host_port, protocol"). + Find(&policies).Error + return policies, err +} + +func (r *DockerPortGuardRepo) ListRuntimeReadOnly(ctx context.Context) ([]model.DockerPortGuardPolicy, error) { + var policies []model.DockerPortGuardPolicy + err := global.DB.WithContext(ctx). + Where("read_only = ?", true). + Order("family, sequence, host_ip, host_port, protocol"). + Find(&policies).Error return policies, err } func (r *DockerPortGuardRepo) DeleteBatch(ctx context.Context, uuids []string) error { - return global.DB.WithContext(ctx).Where("uuid IN ?", uuids).Delete(&model.DockerPortGuardPolicy{}).Error + return global.DB.WithContext(ctx). + Where("read_only = ? AND uuid IN ?", false, uuids). + Delete(&model.DockerPortGuardPolicy{}).Error } func (r *DockerPortGuardRepo) UpsertBatch(ctx context.Context, policies []model.DockerPortGuardPolicy) error { return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { for i := range policies { + policies[i].ReadOnly = false if err := tx.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "family"}, {Name: "host_ip"}, {Name: "host_port"}, {Name: "protocol"}}, + Columns: []clause.Column{{Name: "read_only"}, {Name: "family"}, {Name: "host_ip"}, {Name: "host_port"}, {Name: "protocol"}}, DoUpdates: clause.AssignmentColumns([]string{"mode", "sources", "description", "updated_at"}), }).Create(&policies[i]).Error; err != nil { return err @@ -42,3 +59,19 @@ func (r *DockerPortGuardRepo) UpsertBatch(ctx context.Context, policies []model. return nil }) } + +func (r *DockerPortGuardRepo) ReplaceRuntimeReadOnly(ctx context.Context, policies []model.DockerPortGuardPolicy) error { + return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("read_only = ?", true). + Delete(&model.DockerPortGuardPolicy{}).Error; err != nil { + return err + } + if len(policies) == 0 { + return nil + } + for i := range policies { + policies[i].ReadOnly = true + } + return tx.Create(&policies).Error + }) +} diff --git a/agent/app/service/firewall.go b/agent/app/service/firewall.go index 553acecbc..710056d1d 100644 --- a/agent/app/service/firewall.go +++ b/agent/app/service/firewall.go @@ -156,15 +156,58 @@ func (s *FirewallService) OperateFirewall(request dto.FirewallLifecycleOperation if err != nil { return err } - if err := lifecycle.NewOperator(client).Operate(lifecycle.Operation(request.Operation), request.WithDockerRestart, s.addPortsBeforeStart); err != nil { - return err + operation := lifecycle.Operation(request.Operation) + operationErr := lifecycle.NewOperator(client).Operate(operation, request.WithDockerRestart, s.addPortsBeforeStart) + restoreFirewalld := client.Name() == lifecycle.ProviderFirewalld && + (operation == lifecycle.OperationStart || operation == lifecycle.OperationRestart) + if operation != lifecycle.OperationStart && operation != lifecycle.OperationRestart { + return operationErr } - if request.Operation == "start" || request.Operation == "restart" { - ReconcileDockerPortGuardBestEffort(context.Background()) + if operationErr != nil { + var completedErr *lifecycle.CompletedOperationError + var dockerRestartErr *lifecycle.DockerRestartError + if !errors.As(operationErr, &completedErr) && !errors.As(operationErr, &dockerRestartErr) { + return operationErr + } + if global.LOG != nil { + global.LOG.Warnf("firewall %s completed with post-start recovery errors: %v", operation, operationErr) + } } + if restoreFirewalld { + restoreErr := s.restoreFirewalldRuntimeDependents(context.Background(), operation) + if restoreErr != nil && global.LOG != nil { + global.LOG.Errorf("restore firewalld runtime dependents after %s failed: %v", operation, restoreErr) + } + return nil + } + ReconcileDockerPortGuardBestEffort(context.Background()) return nil } +func (s *FirewallService) restoreFirewalldRuntimeDependents(ctx context.Context, operation lifecycle.Operation) error { + restoreForwarding := s.restoreForwarding + if restoreForwarding == nil { + restoreForwarding = func(ctx context.Context) error { return newForwardingService().Restore(ctx) } + } + restoreDockerGuard := s.restoreDockerGuard + if restoreDockerGuard == nil { + restoreDockerGuard = ReconcileDockerPortGuard + } + dockerActive := s.dockerActive + if dockerActive == nil { + dockerActive = func() (bool, error) { return controller.CheckActive("docker") } + } + + active, err := dockerActive() + restoreErr := restoreFirewalldDependents( + ctx, fmt.Sprintf("after firewalld %s", operation), err == nil && active, restoreForwarding, restoreDockerGuard, + ) + if err != nil { + return errors.Join(fmt.Errorf("check Docker status after firewalld %s: %w", operation, err), restoreErr) + } + return restoreErr +} + func (s *FirewallService) OperateFilterChain(request dto.FilterChainOperation) error { provider, err := selectedSystemFirewallProvider() if err != nil { @@ -325,7 +368,9 @@ func (s *FirewallService) Reset(ctx context.Context, request dto.FirewallRuleRes if restoreDockerGuard == nil { restoreDockerGuard = ReconcileDockerPortGuard } - restoreErr := restoreFirewalldDependents(ctx, restartDocker, restoreForwarding, restoreDockerGuard) + restoreErr := restoreFirewalldDependents( + ctx, "after resetting firewalld", restartDocker, restoreForwarding, restoreDockerGuard, + ) if err := errors.Join(resetErr, restoreErr); err != nil { return dto.FirewallRuleResetResponse{}, err } @@ -371,17 +416,18 @@ func resetServiceFirewallClient( func restoreFirewalldDependents( ctx context.Context, - restartDocker bool, + reason string, + restoreDocker bool, restoreForwarding func(context.Context) error, restoreDockerGuard func(context.Context) error, ) error { var errs []error if err := restoreForwarding(ctx); err != nil { - errs = append(errs, fmt.Errorf("restore port forwarding after resetting firewalld: %w", err)) + errs = append(errs, fmt.Errorf("restore port forwarding %s: %w", reason, err)) } - if restartDocker { + if restoreDocker { if err := restoreDockerGuard(ctx); err != nil { - errs = append(errs, fmt.Errorf("restore Docker port guard after resetting firewalld: %w", err)) + errs = append(errs, fmt.Errorf("restore Docker port guard %s: %w", reason, err)) } } return errors.Join(errs...) @@ -1970,10 +2016,63 @@ func (s *FirewallService) cleanupAppliedCreate( func (s *FirewallService) ensureSystemPort(ctx context.Context, port dto.FirewallSystemPort) error { create, err := s.prepareSystemPortCreate(ctx, port) - if err != nil || create == nil { + if err == nil && create == nil { + return nil + } + if err == nil { + err = s.createFirewallRuleItem(ctx, *create) + } + if err == nil { + return nil + } + if !errors.Is(err, filter.ErrInventoryUnavailable) { return err } - return s.createFirewallRuleItem(ctx, *create) + return s.appendUFWSystemPortUnverified(ctx, port, err) +} + +func (s *FirewallService) appendUFWSystemPortUnverified( + ctx context.Context, + port dto.FirewallSystemPort, + cause error, +) error { + if s.selectedProvider == nil || s.adapters == nil { + return cause + } + provider, providerErr := s.selectedProvider(ctx) + if providerErr != nil { + return errors.Join(cause, providerErr) + } + if provider != filter.ProviderUFW { + return cause + } + if global.LOG != nil { + global.LOG.Warnf( + "UFW inventory is unavailable while restoring accepted port %s/%s; attempting a restricted direct allow: %v", + port.Port, port.Protocol, cause, + ) + } + runtime, resolveErr := s.adapters.Resolve(provider) + if resolveErr != nil { + return errors.Join(cause, resolveErr) + } + comment := "1panel-system-port:" + systemPortKey(port) + if appendErr := runtime.AppendUnverified(ctx, systemPortRule(provider, port), comment); appendErr != nil { + if global.LOG != nil { + global.LOG.Errorf( + "restore accepted UFW port %s/%s without rule inventory failed: %v; original error: %v", + port.Port, port.Protocol, appendErr, cause, + ) + } + return errors.Join(cause, fmt.Errorf("append accepted UFW port without rule inventory: %w", appendErr)) + } + if global.LOG != nil { + global.LOG.Warnf( + "restored accepted UFW port %s/%s without rule inventory; normal rule management failed: %v", + port.Port, port.Protocol, cause, + ) + } + return nil } func (s *FirewallService) createFirewallRuleItem(ctx context.Context, item dto.FirewallRuleCreateItem) error { @@ -2595,12 +2694,17 @@ func (s *FirewallService) SyncSystemPorts(ctx context.Context, previous, current return err } if !supportsNativeRuleBatch(provider) { + var syncErrors []error for _, key := range sortedSystemPortKeys(currentSet) { if _, exists := previousSet[key]; exists { continue } if err := s.ensureSystemPort(ctx, currentSet[key]); err != nil { - return err + wrapped := fmt.Errorf("restore accepted firewall port %s: %w", key, err) + syncErrors = append(syncErrors, wrapped) + if global.LOG != nil { + global.LOG.Errorf("%v", wrapped) + } } } for _, key := range sortedSystemPortKeys(previousSet) { @@ -2608,10 +2712,14 @@ func (s *FirewallService) SyncSystemPorts(ctx context.Context, previous, current continue } if err := s.deleteSystemPort(ctx, previousSet[key]); err != nil { - return err + wrapped := fmt.Errorf("release accepted firewall port %s: %w", key, err) + syncErrors = append(syncErrors, wrapped) + if global.LOG != nil { + global.LOG.Errorf("%v", wrapped) + } } } - return nil + return errors.Join(syncErrors...) } creates := make([]dto.FirewallRuleCreateItem, 0) @@ -2806,41 +2914,61 @@ func supportsManagedFilterChains(provider string) bool { func (s *FirewallService) addPortsBeforeStart(client lifecycle.Client) error { ctx := context.Background() provider := filter.Provider(client.Name()) + var recoveryErrors []error + recordFailure := func(stage string, err error) { + if err == nil { + return + } + wrapped := fmt.Errorf("%s for %s: %w", stage, provider, err) + recoveryErrors = append(recoveryErrors, wrapped) + if global.LOG != nil { + global.LOG.Errorf("firewall post-start recovery failed: %v", wrapped) + } + } if provider == filter.ProviderIptables || provider == filter.ProviderNftables { isInit, _, err := loadDirectFirewallInitStatus(string(provider)) if err != nil { - return err + recordFailure("load managed chain status", err) + return errors.Join(recoveryErrors...) } if !isInit { return nil } } if err := s.restoreStoredFirewallRules(ctx, provider); err != nil { - return err + recordFailure("restore stored firewall rules", err) } if provider == filter.ProviderIptables || provider == filter.ProviderNftables { if provider == filter.ProviderIptables { if err := newIptablesHelperManager().SyncRequiredPorts(true); err != nil { - return err + recordFailure("synchronize required ports", err) } } else if err := newNftablesHelperManager().SyncRequiredPorts(); err != nil { - return err + recordFailure("synchronize required ports", err) } configured, err := loadConfiguredFirewallPortWhiteList() if err != nil { - return err + recordFailure("load configured accepted ports", err) + return errors.Join(recoveryErrors...) } required, err := loadRequiredFirewallPortWhiteList() if err != nil { - return err + recordFailure("load required accepted ports", err) + return errors.Join(recoveryErrors...) } - return s.SyncSystemPorts(ctx, nil, systemPorts(excludeFirewallPorts(configured, required))) + recordFailure( + "restore configured accepted ports", + s.SyncSystemPorts(ctx, nil, systemPorts(excludeFirewallPorts(configured, required))), + ) + return errors.Join(recoveryErrors...) } portWhitelist, err := loadFirewallPortWhiteList() if err != nil { - return err + recordFailure("load accepted ports", err) + return errors.Join(recoveryErrors...) } - return s.SyncSystemPorts(ctx, nil, systemPorts(portWhitelist)) + recordFailure("restore accepted ports", s.SyncSystemPorts(ctx, nil, systemPorts(portWhitelist))) + return errors.Join(recoveryErrors...) } func syncManagedAcceptedPorts(previous, current []firewall.PortWhitelist) error { diff --git a/agent/app/service/firewall_docker.go b/agent/app/service/firewall_docker.go index 15782558a..ba888f374 100644 --- a/agent/app/service/firewall_docker.go +++ b/agent/app/service/firewall_docker.go @@ -141,7 +141,7 @@ func (s *DockerPortGuardService) LoadOverview(ctx context.Context) (dto.DockerPo selectedBackend := selectedDockerFirewallBackend("") base := s.runtimeStatus(s.guardRuntime(selectedBackend), selectedBackend) base.Version = s.loadFirewallVersion(selectedBackend) - policies, err := s.policies.List(ctx) + policies, err := s.policies.ListManaged(ctx) if err != nil { return dto.DockerPortGuardList{}, err } @@ -424,7 +424,7 @@ func (s *DockerPortGuardService) loadRuleSyncCandidates( filter.ErrProviderUnavailable, selected, target, ) } - policies, err := s.policies.List(ctx) + policies, err := s.policies.ListManaged(ctx) if err != nil { return "", nil, nil, err } @@ -487,6 +487,57 @@ func dockerGuardRuntimeRuleSyncDTO(policy docker_guard.Policy) *dto.DockerPortGu } } +func dockerGuardReadOnlyRuleSyncDTO(policy docker_guard.ReadOnlyPolicy) *dto.DockerPortGuardEndpoint { + return &dto.DockerPortGuardEndpoint{ + Family: policy.Policy.Family, HostIP: policy.Policy.HostIP, HostPort: policy.Policy.HostPort, + Protocol: policy.Policy.Protocol, PolicyUUID: dockerGuardReadOnlyPolicyUUID(policy), Sources: append([]string(nil), policy.Policy.Sources...), + NativeAction: policy.Action, ReadOnly: true, TrafficPath: dockerTrafficPathUnknown, + ManagementTarget: dockerManagementNeedsDiagnosis, ManagementReason: dockerReasonNoMatchingPath, + } +} + +func dockerGuardReadOnlyPolicyUUID(policy docker_guard.ReadOnlyPolicy) string { + nativeRules, _ := json.Marshal(policy.NativeRules) + fingerprint := strings.Join([]string{ + policy.Policy.Family, policy.Policy.HostIP, strconv.Itoa(int(policy.Policy.HostPort)), + policy.Policy.Protocol, policy.Action, string(nativeRules), + }, "\x00") + return uuid.NewSHA1(uuid.NameSpaceOID, []byte(fingerprint)).String() +} + +func dockerGuardRuntimeReadOnlyModels(policies []docker_guard.ReadOnlyPolicy) ([]model.DockerPortGuardPolicy, error) { + result := make([]model.DockerPortGuardPolicy, 0, len(policies)) + for _, policy := range policies { + sources, err := json.Marshal(policy.Policy.Sources) + if err != nil { + return nil, err + } + nativeRules, err := json.Marshal(policy.NativeRules) + if err != nil { + return nil, err + } + result = append(result, model.DockerPortGuardPolicy{ + UUID: dockerGuardReadOnlyPolicyUUID(policy), + ReadOnly: true, + Family: policy.Policy.Family, HostIP: policy.Policy.HostIP, HostPort: policy.Policy.HostPort, + Protocol: policy.Policy.Protocol, Sources: string(sources), NativeAction: policy.Action, + NativeRules: string(nativeRules), Sequence: policy.Sequence, + }) + } + return result, nil +} + +func (s *DockerPortGuardService) replaceRuntimeReadOnlyPolicies( + ctx context.Context, + policies []docker_guard.ReadOnlyPolicy, +) error { + stored, err := dockerGuardRuntimeReadOnlyModels(policies) + if err != nil { + return err + } + return s.policies.ReplaceRuntimeReadOnly(ctx, stored) +} + func (s *DockerPortGuardService) reconcileLocked(ctx context.Context) (err error) { defer func() { recordDockerPortGuardReconcileError(err) }() persistedEnabled, err := dockerPortGuardPersistedEnabled() @@ -515,12 +566,22 @@ func (s *DockerPortGuardService) reconcileLocked(ctx context.Context) (err error if err != nil { return err } + inventory, err := runtime.ListPolicies() + if err != nil { + return err + } + if err := s.replaceRuntimeReadOnlyPolicies(ctx, inventory.ReadOnly); err != nil { + return err + } if !initialized { err = runtime.Initialize(policies) } else { err = runtime.Reconcile(policies) } - return err + if err != nil { + return err + } + return docker_guard.Verify(runtime, policies, inventory.ReadOnly) } func dockerPortGuardPersistedEnabled() (bool, error) { @@ -588,7 +649,7 @@ func markDockerGuardFamilyNotEffective(base *dto.DockerPortGuardBase, family str } func (s *DockerPortGuardService) runtimePolicies(ctx context.Context) ([]docker_guard.Policy, error) { - stored, err := s.policies.List(ctx) + stored, err := s.policies.ListManaged(ctx) if err != nil { return nil, err } diff --git a/agent/app/service/firewall_sync.go b/agent/app/service/firewall_sync.go index 1ce1f256d..60c3e338e 100644 --- a/agent/app/service/firewall_sync.go +++ b/agent/app/service/firewall_sync.go @@ -778,7 +778,7 @@ func databaseSyncStatesEqual[T any](left, right []T, key func(T) string) bool { func (p databaseSyncPlan) preview() dto.FirewallRuleSyncPreview { result := dto.FirewallRuleSyncPreview{ Subsystem: p.subsystem, TargetProvider: p.target, - Items: append([]dto.FirewallRuleSyncItem(nil), p.items...), + Items: append(make([]dto.FirewallRuleSyncItem, 0, len(p.items)), p.items...), } for _, item := range p.items { switch item.Status { @@ -863,6 +863,22 @@ func (p databaseSyncPlan) reconcile(run func() error) (dto.FirewallRuleSyncResul return p.completedResult(), nil } +func (p databaseSyncPlan) reconcileReadOnlyPartial(run func() error) (dto.FirewallRuleSyncResult, error) { + preview := p.preview() + for _, item := range p.items { + if item.Status == firewallRuleSyncBlocked && item.ReasonCode != firewallsync.ReasonReadOnlyRule { + return p.validationResult(), nil + } + } + if preview.Ready == 0 && preview.Removed == 0 { + return p.validationResult(), nil + } + if err := run(); err != nil { + return p.failedResult(err), err + } + return p.completedResult(), nil +} + func appendDatabaseSyncFailure(result *dto.FirewallRuleSyncResult, item dto.FirewallRuleSyncItem, err error) { if err == nil { err = errors.New("database synchronization failed") @@ -988,11 +1004,11 @@ func (s *DockerPortGuardService) previewRuleSync( if err != nil { return dto.FirewallRuleSyncPreview{}, err } - targetPolicies, err := runtime.ListPolicies() + targetInventory, err := runtime.ListPolicies() if err != nil { return dto.FirewallRuleSyncPreview{}, err } - return buildDockerDatabaseSyncPlan(filter.Provider(target), policies, targetPolicies).preview(), nil + return buildDockerDatabaseSyncPlan(filter.Provider(target), policies, targetInventory).preview(), nil } func (s *DockerPortGuardService) syncRules( @@ -1007,16 +1023,19 @@ func (s *DockerPortGuardService) syncRules( return dto.FirewallRuleSyncResult{}, err } runtimePolicies := dockerGuardPoliciesFromModels(policies) - targetPolicies, err := targetRuntime.ListPolicies() + targetInventory, err := targetRuntime.ListPolicies() if err != nil { return dto.FirewallRuleSyncResult{}, err } - plan := buildDockerDatabaseSyncPlan(filter.Provider(target), policies, targetPolicies) - result, reconcileErr := plan.reconcile(func() error { + plan := buildDockerDatabaseSyncPlan(filter.Provider(target), policies, targetInventory) + result, reconcileErr := plan.reconcileReadOnlyPartial(func() error { + if err := s.replaceRuntimeReadOnlyPolicies(ctx, targetInventory.ReadOnly); err != nil { + return err + } if err := docker_guard.ReconcileTarget(target, runtimePolicies, targetRuntime); err != nil { return err } - if err := docker_guard.Verify(targetRuntime, runtimePolicies); err != nil { + if err := docker_guard.Verify(targetRuntime, runtimePolicies, targetInventory.ReadOnly); err != nil { return err } if len(policies) == 0 { @@ -1029,10 +1048,7 @@ func (s *DockerPortGuardService) syncRules( }) recordDockerPortGuardReconcileError(reconcileErr) if reconcileErr != nil { - if len(policies) == 0 { - return result, reconcileErr - } - return result, nil + return result, reconcileErr } return result, nil } @@ -1040,7 +1056,7 @@ func (s *DockerPortGuardService) syncRules( func buildDockerDatabaseSyncPlan( target filter.Provider, policies []model.DockerPortGuardPolicy, - actual []docker_guard.Policy, + inventory docker_guard.PolicyInventory, ) databaseSyncPlan { desired := make([]databaseSyncDesired[docker_guard.Policy], 0, len(policies)) for _, policy := range policies { @@ -1049,12 +1065,22 @@ func buildDockerDatabaseSyncPlan( item: dto.FirewallRuleSyncItem{SourceUUID: policy.UUID, DockerRule: dockerGuardRuleSyncDTO(policy)}, }) } - return buildDatabaseSyncPlan( - "docker", target, desired, actual, docker_guard.PolicySyncKey, + plan := buildDatabaseSyncPlan( + "docker", target, desired, inventory.Policies, docker_guard.PolicySyncKey, func(policy docker_guard.Policy) dto.FirewallRuleSyncItem { return dto.FirewallRuleSyncItem{SourceUUID: policy.UUID, DockerRule: dockerGuardRuntimeRuleSyncDTO(policy)} }, ) + for _, policy := range inventory.ReadOnly { + plan.items = append(plan.items, dto.FirewallRuleSyncItem{ + SourceUUID: dockerGuardReadOnlyPolicyUUID(policy), + DockerRule: dockerGuardReadOnlyRuleSyncDTO(policy), + Status: firewallsync.StatusBlocked, + ReasonCode: firewallsync.ReasonReadOnlyRule, + Reason: firewallsync.ReasonMessage(firewallsync.ReasonReadOnlyRule), + }) + } + return plan } type firewallScopeReconciler struct { diff --git a/agent/init/migration/migrate.go b/agent/init/migration/migrate.go index 000383e01..a81230c51 100644 --- a/agent/init/migration/migrate.go +++ b/agent/init/migration/migrate.go @@ -108,6 +108,7 @@ func agentDBMigrations() []*gormigrate.Migration { migrations.InitDockerPortGuardStatus, migrations.NormalizeFirewallBackendSelections, migrations.SimplifyFirewallRulePolicy, + migrations.AddDockerPortGuardReadOnly, } } diff --git a/agent/init/migration/migrations/init.go b/agent/init/migration/migrations/init.go index 2833f43f3..bf89cb624 100644 --- a/agent/init/migration/migrations/init.go +++ b/agent/init/migration/migrations/init.go @@ -1793,3 +1793,21 @@ var SimplifyFirewallRulePolicy = &gormigrate.Migration{ }) }, } + +var AddDockerPortGuardReadOnly = &gormigrate.Migration{ + ID: "20260902-add-docker-port-guard-read-only", + Migrate: func(tx *gorm.DB) error { + if !tx.Migrator().HasTable(&model.DockerPortGuardPolicy{}) { + return nil + } + if err := tx.AutoMigrate(&model.DockerPortGuardPolicy{}); err != nil { + return err + } + if tx.Migrator().HasIndex(&model.DockerPortGuardPolicy{}, "idx_docker_port_guard_endpoint") { + if err := tx.Migrator().DropIndex(&model.DockerPortGuardPolicy{}, "idx_docker_port_guard_endpoint"); err != nil { + return err + } + } + return tx.Migrator().CreateIndex(&model.DockerPortGuardPolicy{}, "idx_docker_port_guard_endpoint") + }, +} diff --git a/agent/utils/firewall/docker_guard/manager.go b/agent/utils/firewall/docker_guard/manager.go index eeb42527e..1924ad3e1 100644 --- a/agent/utils/firewall/docker_guard/manager.go +++ b/agent/utils/firewall/docker_guard/manager.go @@ -3,6 +3,7 @@ package docker_guard import ( "errors" "fmt" + "sort" "strconv" "strings" "sync" @@ -134,6 +135,10 @@ func NewManagerWithRunner(runner Runner) *Manager { return &Manager{runner: runn 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") } @@ -154,7 +159,7 @@ func (m *Manager) Initialize(policies []Policy) error { } } } - return m.rebuildLocked(policies) + return m.rebuildLocked(policies, inventory) } func (m *Manager) Bind() error { @@ -174,11 +179,15 @@ func (m *Manager) Bind() error { func (m *Manager) Reconcile(policies []Policy) error { mutationMu.Lock() defer mutationMu.Unlock() - return m.rebuildLocked(policies) + inventory, err := m.ListPolicies() + if err != nil { + return err + } + return m.rebuildLocked(policies, inventory) } -func (m *Manager) ListPolicies() ([]Policy, error) { - policies := make([]Policy, 0) +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) { @@ -186,22 +195,26 @@ func (m *Manager) ListPolicies() ([]Policy, error) { } exists, err := m.chainExists(executable, Chain) if err != nil { - return nil, &FamilyError{Family: family, Err: fmt.Errorf("inspect %s chain: %w", Chain, err)} + 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 nil, &FamilyError{Family: family, Err: fmt.Errorf("list %s chain: %w", Chain, err)} + return PolicyInventory{}, &FamilyError{Family: family, Err: fmt.Errorf("list %s chain: %w", Chain, err)} } parsed, err := parseDockerGuardPolicies(output, family) if err != nil { - return nil, &FamilyError{Family: family, Err: err} + 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...) } - policies = append(policies, parsed...) } - return policies, nil + return inventory, nil } func (m *Manager) Unbind() error { @@ -368,7 +381,7 @@ func (m *Manager) restoreLifecycle(executable string, rules [][]string) error { return nil } -func (m *Manager) rebuildLocked(policies []Policy) error { +func (m *Manager) rebuildLocked(policies []Policy, inventory PolicyInventory) error { for _, family := range []string{FamilyIPv4, FamilyIPv6} { executable := executableForFamily(family) if executable == "" || !m.runner.Exists(executable) { @@ -382,12 +395,7 @@ func (m *Manager) rebuildLocked(policies []Policy) error { continue } rules := [][]string{{"-F", Chain}, {"-A", Chain, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "RETURN"}} - for _, policy := range policies { - if policy.Family != family { - continue - } - rules = append(rules, compilePolicy(policy)...) - } + rules = append(rules, orderedIPTablesRules(family, policies, inventory)...) rules = append(rules, []string{"-A", Chain, "-j", "RETURN"}) script, err := buildRestoreScript(rules) if err != nil { @@ -404,18 +412,82 @@ func (m *Manager) rebuildLocked(policies []Policy) error { 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, " \t\r\n\\\"'") { + if token == "" || strings.ContainsAny(token, "\r\n\x00") { return "", fmt.Errorf("invalid iptables-restore token %q", token) } if i > 0 { script.WriteByte(' ') } - script.WriteString(token) + if strings.ContainsAny(token, " \t\\\"'") { + script.WriteString(strconv.Quote(token)) + } else { + script.WriteString(token) + } } script.WriteByte('\n') } diff --git a/agent/utils/firewall/docker_guard/nftables.go b/agent/utils/firewall/docker_guard/nftables.go index 94d9766d4..3c2f42ac7 100644 --- a/agent/utils/firewall/docker_guard/nftables.go +++ b/agent/utils/firewall/docker_guard/nftables.go @@ -3,6 +3,7 @@ package docker_guard import ( "errors" "fmt" + "sort" "strconv" "strings" ) @@ -28,6 +29,10 @@ func NewNftablesManagerWithRunner(runner Runner) *NftablesManager { 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") } @@ -37,7 +42,7 @@ func (m *NftablesManager) Initialize(policies []Policy) error { if err := m.ensureFamily(FamilyIPv6, false); err != nil { return &FamilyError{Family: FamilyIPv6, Err: err} } - return m.rebuildLocked(policies) + return m.rebuildLocked(policies, inventory) } func (m *NftablesManager) Bind() error { @@ -55,14 +60,18 @@ func (m *NftablesManager) Bind() error { func (m *NftablesManager) Reconcile(policies []Policy) error { mutationMu.Lock() defer mutationMu.Unlock() - return m.rebuildLocked(policies) + inventory, err := m.ListPolicies() + if err != nil { + return err + } + return m.rebuildLocked(policies, inventory) } -func (m *NftablesManager) ListPolicies() ([]Policy, error) { +func (m *NftablesManager) ListPolicies() (PolicyInventory, error) { if !m.runner.Exists("nft") { - return nil, nil + return PolicyInventory{}, nil } - policies := make([]Policy, 0) + 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) { @@ -70,15 +79,19 @@ func (m *NftablesManager) ListPolicies() ([]Policy, error) { } output, err := m.run("-a", "list", "chain", tableFamily, NftTable, NftChain) if err != nil { - return nil, &FamilyError{Family: family, Err: fmt.Errorf("list %s chain: %w", NftChain, err)} + return PolicyInventory{}, &FamilyError{Family: family, Err: fmt.Errorf("list %s chain: %w", NftChain, err)} } parsed, err := parseDockerGuardPolicies(output, family) if err != nil { - return nil, &FamilyError{Family: family, Err: err} + 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...) } - policies = append(policies, parsed...) } - return policies, nil + return inventory, nil } func (m *NftablesManager) Unbind() error { @@ -234,7 +247,7 @@ func (m *NftablesManager) ensureJump(family string) error { return m.runBatch(commands) } -func (m *NftablesManager) rebuildLocked(policies []Policy) error { +func (m *NftablesManager) rebuildLocked(policies []Policy, inventory PolicyInventory) error { if !m.runner.Exists("nft") { return nil } @@ -245,11 +258,7 @@ func (m *NftablesManager) rebuildLocked(policies []Policy) error { } commands := [][]string{{"flush", "chain", tableFamily, NftTable, NftChain}} commands = append(commands, []string{"add", "rule", tableFamily, NftTable, NftChain, "ct", "state", "{", "established,related", "}", "return"}) - for _, policy := range policies { - if policy.Family == family { - commands = append(commands, compileNftPolicy(policy)...) - } - } + commands = append(commands, orderedNftRules(family, policies, inventory)...) commands = append(commands, []string{"add", "rule", tableFamily, NftTable, NftChain, "return"}) script, err := buildNftScript(commands) if err != nil { @@ -262,6 +271,81 @@ func (m *NftablesManager) rebuildLocked(policies []Policy) error { 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 diff --git a/agent/utils/firewall/docker_guard/policy_inspect.go b/agent/utils/firewall/docker_guard/policy_inspect.go index 99a42d490..54615f2f6 100644 --- a/agent/utils/firewall/docker_guard/policy_inspect.go +++ b/agent/utils/firewall/docker_guard/policy_inspect.go @@ -11,35 +11,57 @@ import ( ) type observedPolicy struct { - policy Policy - dropAll bool - droppedSource []string - allowedSource []string + policy Policy + sequence int64 + nativeRules []NativeRule + managedOrders []int64 + dropAll bool + droppedSource []string + allowedSource []string + acceptedSource []string + acceptAll bool } -func parseDockerGuardPolicies(output, family string) ([]Policy, error) { +func parseDockerGuardPolicies(output, family string) (PolicyInventory, error) { groups := make(map[string]*observedPolicy) order := make([]string, 0) + sequence := int64(0) for _, line := range strings.Split(output, "\n") { - if !strings.Contains(line, "1panel-docker:") { + line = strings.TrimSpace(line) + if line == "" { continue } - tokens, err := shellwords.Parse(strings.TrimSpace(line)) + tokens, err := shellwords.Parse(line) if err != nil { - return nil, fmt.Errorf("parse Docker guard rule: %w", err) + return PolicyInventory{}, fmt.Errorf("parse Docker guard rule: %w", err) } + managed := strings.Contains(line, "1panel-docker:") + if !managed && !hasAcceptAction(tokens) { + continue + } + sequence++ fragment, source, action, err := parseDockerGuardRuleTokens(tokens, family) if err != nil { - return nil, err + return PolicyInventory{}, err } - key := strings.Join([]string{fragment.UUID, fragment.Family, fragment.HostIP, strconv.Itoa(int(fragment.HostPort)), fragment.Protocol}, "|") + identity := fragment.UUID + if action == "accept" { + identity = action + } + key := strings.Join([]string{identity, fragment.Family, fragment.HostIP, strconv.Itoa(int(fragment.HostPort)), fragment.Protocol}, "|") group, exists := groups[key] if !exists { - group = &observedPolicy{policy: fragment} + group = &observedPolicy{policy: fragment, sequence: sequence} groups[key] = group order = append(order, key) } switch { + case action == "accept" && source != "": + group.acceptedSource = append(group.acceptedSource, source) + group.nativeRules = append(group.nativeRules, NativeRule{Family: family, Order: sequence, Tokens: nativeRuleTokens(tokens)}) + case action == "accept": + group.acceptAll = true + group.nativeRules = append(group.nativeRules, NativeRule{Family: family, Order: sequence, Tokens: nativeRuleTokens(tokens)}) case action == "return" && source != "": group.allowedSource = append(group.allowedSource, source) case action == "drop" && source != "": @@ -47,12 +69,22 @@ func parseDockerGuardPolicies(output, family string) ([]Policy, error) { case action == "drop": group.dropAll = true default: - return nil, fmt.Errorf("unsupported Docker guard rule action %q", action) + return PolicyInventory{}, fmt.Errorf("unsupported Docker guard rule action %q", action) + } + if action != "accept" { + group.managedOrders = append(group.managedOrders, sequence) } } - policies := make([]Policy, 0, len(order)) + inventory := PolicyInventory{Policies: make([]Policy, 0, len(order)), ManagedRuleOrders: make(map[string][]int64)} for _, key := range order { group := groups[key] + if group.acceptAll || len(group.acceptedSource) > 0 { + group.policy.Sources = uniqueSortedStrings(group.acceptedSource) + inventory.ReadOnly = append(inventory.ReadOnly, ReadOnlyPolicy{ + Policy: group.policy, Action: "accept", Sequence: group.sequence, NativeRules: group.nativeRules, + }) + continue + } switch { case len(group.allowedSource) > 0: group.policy.Mode = ModeAllow @@ -63,11 +95,36 @@ func parseDockerGuardPolicies(output, family string) ([]Policy, error) { case group.dropAll: group.policy.Mode = ModeAll default: - return nil, fmt.Errorf("Docker guard policy %s has no effective rules", group.policy.UUID) + return PolicyInventory{}, fmt.Errorf("Docker guard policy %s has no effective rules", group.policy.UUID) } - policies = append(policies, group.policy) + inventory.Policies = append(inventory.Policies, group.policy) + inventory.ManagedRuleOrders[managedOrderKey(group.policy.Family, group.policy.UUID)] = append([]int64(nil), group.managedOrders...) } - return policies, nil + return inventory, nil +} + +func nativeRuleTokens(tokens []string) []string { + result := make([]string, 0, len(tokens)) + for index, token := range tokens { + if token == "#" { + tokens = tokens[:index] + break + } + } + if len(tokens) >= 2 && tokens[len(tokens)-2] == "handle" { + tokens = tokens[:len(tokens)-2] + } + for index := 0; index < len(tokens); index++ { + result = append(result, tokens[index]) + if tokens[index] == "counter" && index+4 < len(tokens) && tokens[index+1] == "packets" && tokens[index+3] == "bytes" { + index += 4 + } + } + return result +} + +func managedOrderKey(family, policyUUID string) string { + return family + "\x00" + policyUUID } func parseDockerGuardRuleTokens(tokens []string, family string) (Policy, string, string, error) { @@ -79,8 +136,12 @@ func parseDockerGuardRuleTokens(tokens []string, family string) (Policy, string, policy.Protocol = nextPolicyToken(tokens, index) case "--ctorigdst": policy.HostIP = normalizeObservedHost(nextPolicyToken(tokens, index)) + case "-d": + policy.HostIP = normalizeObservedHost(nextPolicyToken(tokens, index)) case "--ctorigdstport": policy.HostPort = parsePolicyPort(nextPolicyToken(tokens, index)) + case "--dport": + policy.HostPort = parsePolicyPort(nextPolicyToken(tokens, index)) case "-s": source = nextPolicyToken(tokens, index) case "--comment", "comment": @@ -107,19 +168,52 @@ func parseDockerGuardRuleTokens(tokens []string, family string) (Policy, string, } } case "ip", "ip6": - if nextPolicyToken(tokens, index) == "saddr" { + switch nextPolicyToken(tokens, index) { + case "saddr": source = nextPolicyToken(tokens, index+1) + case "daddr": + policy.HostIP = normalizeObservedHost(nextPolicyToken(tokens, index+1)) + } + case "tcp", "udp": + if nextPolicyToken(tokens, index) == "dport" { + policy.Protocol = tokens[index] + policy.HostPort = parsePolicyPort(nextPolicyToken(tokens, index+1)) + } + case "accept", "drop", "return": + if isCommentValue(tokens, index) { + continue } - case "drop", "return": action = tokens[index] } } - if policy.UUID == "" || policy.Protocol == "" || policy.HostPort == 0 || action == "" { + if action == "" || (action != "accept" && (policy.UUID == "" || policy.Protocol == "" || policy.HostPort == 0)) { return Policy{}, "", "", fmt.Errorf("incomplete 1Panel Docker guard rule") } + if action == "accept" && policy.Protocol == "" { + policy.Protocol = "all" + } return policy, source, action, nil } +func hasAcceptAction(tokens []string) bool { + for index, token := range tokens { + if token == "-j" && strings.EqualFold(nextPolicyToken(tokens, index), "accept") { + return true + } + if strings.EqualFold(token, "accept") && !isCommentValue(tokens, index) { + return true + } + } + return false +} + +func isCommentValue(tokens []string, index int) bool { + if index == 0 { + return false + } + return tokens[index-1] == "comment" || tokens[index-1] == "--comment" +} + func normalizeObservedHost(value string) string { if prefix, err := netip.ParsePrefix(value); err == nil && prefix.Bits() == prefix.Addr().BitLen() { return prefix.Addr().String() diff --git a/agent/utils/firewall/docker_guard/runtime.go b/agent/utils/firewall/docker_guard/runtime.go index 2d8dc49ca..3516b4f13 100644 --- a/agent/utils/firewall/docker_guard/runtime.go +++ b/agent/utils/firewall/docker_guard/runtime.go @@ -2,10 +2,30 @@ package docker_guard import ( "fmt" + "slices" "github.com/1Panel-dev/1Panel/agent/constant" ) +type NativeRule struct { + Family string `json:"family"` + Order int64 `json:"order"` + Tokens []string `json:"tokens"` +} + +type ReadOnlyPolicy struct { + Policy Policy + Action string + Sequence int64 + NativeRules []NativeRule +} + +type PolicyInventory struct { + Policies []Policy + ReadOnly []ReadOnlyPolicy + ManagedRuleOrders map[string][]int64 +} + type Runtime interface { Initialize([]Policy) error Bind() error @@ -14,7 +34,7 @@ type Runtime interface { Cleanup() error Initialized(string) (bool, error) Status(string) FamilyStatus - ListPolicies() ([]Policy, error) + ListPolicies() (PolicyInventory, error) } func NewRuntime(provider string) Runtime { @@ -24,17 +44,60 @@ func NewRuntime(provider string) Runtime { return NewManager() } -func Verify(runtime Runtime, desired []Policy) error { - actual, err := runtime.ListPolicies() +func Verify(runtime Runtime, desired []Policy, preserved []ReadOnlyPolicy) error { + inventory, err := runtime.ListPolicies() if err != nil { return fmt.Errorf("verify synchronized Docker firewall policies: %w", err) } - if !PolicyStatesEqual(actual, desired) { + if !PolicyStatesEqual(inventory.Policies, desired) { return fmt.Errorf("verify synchronized Docker firewall policies: target policies do not match the database") } + if !readOnlyStatesEqual(inventory.ReadOnly, preserved) { + return fmt.Errorf("verify synchronized Docker firewall policies: read-only runtime rules changed") + } return nil } +func readOnlyStatesEqual(left, right []ReadOnlyPolicy) bool { + if len(left) != len(right) { + return false + } + leftRules := flattenNativeRules(left) + rightRules := flattenNativeRules(right) + if len(leftRules) != len(rightRules) { + return false + } + for index := range leftRules { + if leftRules[index].Family != rightRules[index].Family || !slices.Equal(leftRules[index].Tokens, rightRules[index].Tokens) { + return false + } + } + return true +} + +func flattenNativeRules(policies []ReadOnlyPolicy) []NativeRule { + rules := make([]NativeRule, 0) + for _, policy := range policies { + rules = append(rules, policy.NativeRules...) + } + slices.SortStableFunc(rules, func(left, right NativeRule) int { + if left.Family < right.Family { + return -1 + } + if left.Family > right.Family { + return 1 + } + if left.Order < right.Order { + return -1 + } + if left.Order > right.Order { + return 1 + } + return 0 + }) + return rules +} + func ReconcileTarget(backend string, policies []Policy, runtime Runtime) error { families := make(map[string]struct{}, len(policies)) needsInitialize, needsBind := false, false diff --git a/agent/utils/firewall/filter/adapter.go b/agent/utils/firewall/filter/adapter.go index 7c7bb47db..2a7d5dfb1 100644 --- a/agent/utils/firewall/filter/adapter.go +++ b/agent/utils/firewall/filter/adapter.go @@ -5,7 +5,10 @@ import ( "errors" ) -var ErrAdapterUnavailable = errors.New("firewall rule adapter is unavailable") +var ( + ErrAdapterUnavailable = errors.New("firewall rule adapter is unavailable") + ErrInventoryUnavailable = errors.New("firewall rule inventory is unavailable") +) type ChangeOperation string @@ -80,6 +83,10 @@ type RuleChecker interface { CheckRule(context.Context, FirewallRule) error } +type UnverifiedRuleAppender interface { + AppendUnverified(context.Context, FirewallRule, string) error +} + type NativeDetailReader interface { NativeDetail(context.Context, string, bool) (string, error) } diff --git a/agent/utils/firewall/filter/check.go b/agent/utils/firewall/filter/check.go index 95bb0d04b..fb026c172 100644 --- a/agent/utils/firewall/filter/check.go +++ b/agent/utils/firewall/filter/check.go @@ -111,14 +111,18 @@ func CheckCreate( exact := make([]ObservedRule, 0) for _, observed := range snapshot.Rules { if observed.ParseStatus != ParseStatusSupported { - if observed.Rule.Scope.Provider == ProviderFirewalld && observed.Rule.NativeKind == NativeKindZoneService { + if hydrated, ok := hydrateOwnedPartialRule(observed, desired); ok { + observed = hydrated + } else { + if observed.Rule.Scope.Provider == ProviderFirewalld && observed.Rule.NativeKind == NativeKindZoneService { + continue + } + result.Decision = CheckDecisionBlocked + result.Classification = CheckClassificationUnsupported + result.Reason = "opaque_rule_in_target_scope" + result.Candidates = append(result.Candidates, observed) continue } - result.Decision = CheckDecisionBlocked - result.Classification = CheckClassificationUnsupported - result.Reason = "opaque_rule_in_target_scope" - result.Candidates = append(result.Candidates, observed) - continue } observedRule, err := NormalizeRule(observed.Rule) if err != nil { @@ -164,6 +168,47 @@ func CheckCreate( return finishCheck(result) } +func hydrateOwnedPartialRule(observed ObservedRule, desired []DesiredRule) (ObservedRule, bool) { + if observed.ParseStatus != ParseStatusPartial || strings.TrimSpace(observed.Marker) == "" || + len(observed.UncertainFields) != 1 || observed.UncertainFields[0] != ObservedFieldProtocol { + return observed, false + } + var matched *DesiredRule + for index := range desired { + candidate := &desired[index] + if candidate.Origin != RuleOriginCreated && candidate.Origin != RuleOriginAdopted { + continue + } + if candidate.Rule.Scope.Normalize().Key() != observed.Rule.Scope.Normalize().Key() || + !desiredMarkerMatchesObserved(*candidate, observed.Marker) || + !ObservedRuleMatchesExpected(observed, candidate.Rule) { + continue + } + if matched != nil { + return observed, false + } + matched = candidate + } + if matched == nil { + return observed, false + } + orderIndex := observed.Rule.OrderIndex + observed.Rule = matched.Rule + observed.Rule.OrderIndex = orderIndex + observed.ParseStatus = ParseStatusSupported + observed.UncertainFields = nil + return observed, true +} + +func desiredMarkerMatchesObserved(desired DesiredRule, observedMarker string) bool { + observedMarker = strings.TrimSpace(observedMarker) + if observedMarker == strings.TrimSpace(desired.Marker) { + return observedMarker != "" + } + return desired.Origin == RuleOriginAdopted && strings.TrimSpace(desired.UUID) != "" && + observedMarker == "1panel-rule:"+strings.TrimSpace(desired.UUID) +} + func finishCheck(result RuleCheckResult) (RuleCheckResult, error) { for index, candidate := range result.Candidates { identity, err := InstanceKey(candidate) diff --git a/agent/utils/firewall/filter/providers/ufw/adapter.go b/agent/utils/firewall/filter/providers/ufw/adapter.go index 25aad4d06..c6e1036b4 100644 --- a/agent/utils/firewall/filter/providers/ufw/adapter.go +++ b/agent/utils/firewall/filter/providers/ufw/adapter.go @@ -54,6 +54,30 @@ func (a *Adapter) PrepareRule(rule filter.FirewallRule) (filter.FirewallRule, er return normalized, nil } +func (a *Adapter) AppendUnverified(ctx context.Context, rule filter.FirewallRule, comment string) error { + normalized, err := a.PrepareRule(rule) + if err != nil { + return err + } + if normalized.Action != filter.ActionAccept || normalized.DestinationPort == "" || + normalized.SourceAddress != "" || normalized.SourcePort != "" || + normalized.DestinationAddress != "" || normalized.Interface != "" { + return fmt.Errorf("%w: unverified UFW appends only support accepted ports", filter.ErrInvalidRule) + } + if a.writer == nil { + return errors.New("ufw writer is required") + } + comment = strings.TrimSpace(comment) + if comment == "" || strings.ContainsAny(comment, "\r\n\x00") { + return fmt.Errorf("%w: invalid UFW fallback comment", filter.ErrInvalidRule) + } + command := commentCommand(normalized, comment) + if err := validateCommand(command); err != nil { + return err + } + return a.writer.Run(ctx, command) +} + func (a *Adapter) Capabilities(context.Context) (filter.Capabilities, error) { return filter.Capabilities{ Scopes: []filter.ScopePattern{{ @@ -89,7 +113,7 @@ func (a *Adapter) ObserveScopes(ctx context.Context, scopes []filter.Scope) ([]f } numbered, err := a.reader.Read(ctx, "status", "numbered") if err != nil { - return nil, err + return nil, fmt.Errorf("%w: read UFW numbered status: %w", filter.ErrInventoryUnavailable, err) } notices := statusNotices(numbered) diff --git a/agent/utils/firewall/filter/runtime/runtime.go b/agent/utils/firewall/filter/runtime/runtime.go index c4844338e..e719663f3 100644 --- a/agent/utils/firewall/filter/runtime/runtime.go +++ b/agent/utils/firewall/filter/runtime/runtime.go @@ -118,6 +118,14 @@ func (e *Engine) CheckRule(ctx context.Context, rule filter.FirewallRule) error return checker.CheckRule(ctx, rule) } +func (e *Engine) AppendUnverified(ctx context.Context, rule filter.FirewallRule, comment string) error { + appender, ok := e.adapter.(filter.UnverifiedRuleAppender) + if !ok { + return fmt.Errorf("%w: %s does not support unverified rule appends", filter.ErrAdapterUnavailable, e.Provider()) + } + return appender.AppendUnverified(ctx, rule, comment) +} + func (e *Engine) CompileDesired( ctx context.Context, policyUUID string, diff --git a/agent/utils/firewall/lifecycle/operator.go b/agent/utils/firewall/lifecycle/operator.go index efa817a80..20152f9ca 100644 --- a/agent/utils/firewall/lifecycle/operator.go +++ b/agent/utils/firewall/lifecycle/operator.go @@ -1,6 +1,7 @@ package lifecycle import ( + "errors" "fmt" "os" @@ -36,11 +37,25 @@ func (e *DockerRestartError) Unwrap() error { return e.Err } +type CompletedOperationError struct { + Operation Operation + Err error +} + +func (e *CompletedOperationError) Error() string { + return fmt.Sprintf("firewall %s completed with recovery errors: %v", e.Operation, e.Err) +} + +func (e *CompletedOperationError) Unwrap() error { + return e.Err +} + func NewOperator(client Client) *Operator { return &Operator{client: client} } func (o *Operator) Operate(operation Operation, withDockerRestart bool, prepareStart func(Client) error) error { + var recoveryErrors []error switch operation { case OperationStart: if err := o.client.Start(); err != nil { @@ -48,7 +63,7 @@ func (o *Operator) Operate(operation Operation, withDockerRestart bool, prepareS } if prepareStart != nil { if err := prepareStart(o.client); err != nil { - return err + recoveryErrors = append(recoveryErrors, fmt.Errorf("prepare firewall after start: %w", err)) } } case OperationStop: @@ -59,7 +74,7 @@ func (o *Operator) Operate(operation Operation, withDockerRestart bool, prepareS } if prepareStart != nil { if err := prepareStart(o.client); err != nil { - return err + recoveryErrors = append(recoveryErrors, fmt.Errorf("prepare firewall after restart: %w", err)) } } default: @@ -68,11 +83,16 @@ func (o *Operator) Operate(operation Operation, withDockerRestart bool, prepareS if withDockerRestart { if err := controller.HandleRestart("docker"); err != nil { - return &DockerRestartError{Err: err} + recoveryErrors = append(recoveryErrors, &DockerRestartError{Err: err}) } } if o.client.Name() == ProviderFirewalld && operation == OperationStart { - return restoreFail2BanAfterFirewallStart() + if err := restoreFail2BanAfterFirewallStart(); err != nil { + recoveryErrors = append(recoveryErrors, err) + } + } + if err := errors.Join(recoveryErrors...); err != nil { + return &CompletedOperationError{Operation: operation, Err: err} } return nil } diff --git a/agent/utils/firewall/sync/diff.go b/agent/utils/firewall/sync/diff.go index e7f2a768a..0d3a27b15 100644 --- a/agent/utils/firewall/sync/diff.go +++ b/agent/utils/firewall/sync/diff.go @@ -26,6 +26,7 @@ const ( ReasonOnlyExistsInTarget ReasonCode = "only_exists_in_target" ReasonManagedOnlyInTarget ReasonCode = "managed_only_exists_in_target" ReasonUnsafeRemoval ReasonCode = "unsafe_managed_rule_removal" + ReasonReadOnlyRule ReasonCode = "read_only_rule" ) func ReasonMessage(code ReasonCode) string { @@ -38,6 +39,8 @@ func ReasonMessage(code ReasonCode) string { return "managed rule exists only in target backend" case ReasonUnsafeRemoval: return "managed runtime rule cannot be safely removed" + case ReasonReadOnlyRule: + return "read-only runtime rule is preserved but cannot be synchronized" default: return "" } diff --git a/frontend/src/api/interface/firewall.ts b/frontend/src/api/interface/firewall.ts index 6b22d1c5c..26eebcb56 100644 --- a/frontend/src/api/interface/firewall.ts +++ b/frontend/src/api/interface/firewall.ts @@ -400,6 +400,8 @@ export namespace Firewall { application?: string; policyUUID?: string; mode?: 'deny_sources' | 'allow_sources' | 'deny_all'; + nativeAction?: string; + readOnly?: boolean; sources: string[]; effective: boolean; description?: string; diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 054f78e1a..2df1411ef 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -4153,6 +4153,8 @@ const message = { stale: 'The firewall rule state is outdated. Refresh and try again.', lockoutRisk: 'This firewall change may block management access.', protectedRule: 'This protected firewall rule cannot be modified.', + dockerAcceptReadOnly: + 'This ACCEPT rule is read-only and will be preserved while other rules are synchronized. To remove it, delete it manually on the host.', cannotReconcile: 'The target rule cannot be reconciled: {0}', }, ruleSyncPartial: 'Synchronization completed: {0} succeeded, {1} already existed, and {2} failed.', @@ -4172,10 +4174,10 @@ const message = { cleanupDockerBackendHelper: 'Reset the 1Panel Docker port protection runtime rules in {0}: delete all related rules and chains while retaining database data', cleanupBeforeBackendSwitch: - 'The current {0} backend still contains 1Panel runtime rules. Reset it before switching to {1}.', + 'The current {0} backend still contains 1Panel runtime rules. Reset it before switching to {1}. Resetting only removes runtime rules; saved database policies are retained and can be initialized or synchronized after switching.', cleanupAction: 'Reset', backendSwitchNotice: - 'Reset the current backend before switching the system firewall, port forwarding, or Docker protection. Saved database policies are retained and can be initialized or synchronized after switching.', + 'Use only one firewall management method at a time. Running multiple firewalls simultaneously may cause rule conflicts, inconsistent status, or container port access failures.', switchBackendHelper: 'Switch to {0}?', switchDockerBackendHelper: 'Switch to {0}? This will update the Docker configuration and restart Docker.', uninstalledStatus: 'Not installed', diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index 65d1e6aec..9ffce81a7 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -4200,6 +4200,8 @@ const message = { stale: 'El estado de la regla está desactualizado. Actualiza e inténtalo de nuevo.', lockoutRisk: 'Este cambio puede bloquear el acceso de administración.', protectedRule: 'Esta regla protegida del firewall no se puede modificar.', + dockerAcceptReadOnly: + 'Esta regla ACCEPT es de solo lectura y se conservará al sincronizar las demás reglas. Para eliminarla, bórrela manualmente en el host.', cannotReconcile: 'No se puede sincronizar la regla de destino: {0}', }, ruleSyncPartial: 'Sincronización completada: {0} correctas, {1} ya existían y {2} fallaron.', @@ -4219,10 +4221,10 @@ const message = { cleanupDockerBackendHelper: 'Restablece las reglas de ejecución de protección de puertos Docker de 1Panel en {0}: elimina todas las reglas y cadenas relacionadas y conserva los datos de la base de datos', cleanupBeforeBackendSwitch: - 'El backend actual {0} todavía contiene reglas de ejecución de 1Panel. Restablézcalo antes de cambiar a {1}.', + 'El backend actual {0} todavía contiene reglas de ejecución de 1Panel. Restablézcalo antes de cambiar a {1}. El restablecimiento solo elimina las reglas de ejecución; las políticas de la base de datos se conservan y pueden inicializarse o sincronizarse después del cambio.', cleanupAction: 'Restablecer', backendSwitchNotice: - 'Restablece el backend actual antes de cambiar el firewall del sistema, el reenvío de puertos o la protección Docker. Las políticas de la base de datos se conservan y pueden inicializarse o sincronizarse después.', + 'Se recomienda utilizar un único método de gestión de firewall. Ejecutar varios firewalls al mismo tiempo puede causar conflictos de reglas, estados inconsistentes o problemas de acceso a los puertos de los contenedores.', switchBackendHelper: '¿Cambiar a {0}?', switchDockerBackendHelper: '¿Cambiar a {0}? Esto actualizará la configuración y reiniciará Docker.', uninstalledStatus: 'No instalado', diff --git a/frontend/src/lang/modules/fa.ts b/frontend/src/lang/modules/fa.ts index c421bf7be..af706b1e0 100644 --- a/frontend/src/lang/modules/fa.ts +++ b/frontend/src/lang/modules/fa.ts @@ -4109,6 +4109,8 @@ const message = { stale: 'وضعیت قانون فایروال قدیمی است. صفحه را تازه کنید و دوباره تلاش کنید.', lockoutRisk: 'این تغییر ممکن است دسترسی مدیریتی را مسدود کند.', protectedRule: 'این قانون محافظت‌شدهٔ فایروال قابل تغییر نیست.', + dockerAcceptReadOnly: + 'این قانون ACCEPT فقط خواندنی است و هنگام همگام‌سازی سایر قوانین حفظ می‌شود. برای حذف آن، قانون را به‌صورت دستی از میزبان پاک کنید.', cannotReconcile: 'قانون مقصد قابل همگام‌سازی نیست: {0}', }, ruleSyncPartial: 'همگام‌سازی پایان یافت: {0} موفق، {1} از قبل موجود و {2} ناموفق.', @@ -4128,10 +4130,10 @@ const message = { cleanupDockerBackendHelper: 'قوانین زمان اجرای محافظت پورت Docker مربوط به 1Panel در {0} بازنشانی می‌شوند: همه قوانین و زنجیره‌های مرتبط حذف و داده‌های پایگاه داده حفظ می‌شوند', cleanupBeforeBackendSwitch: - 'بک‌اند فعلی {0} هنوز شامل قوانین زمان اجرای 1Panel است. پیش از تغییر به {1} آن را بازنشانی کنید.', + 'بک‌اند فعلی {0} هنوز شامل قوانین زمان اجرای 1Panel است. پیش از تغییر به {1} آن را بازنشانی کنید. بازنشانی فقط قوانین زمان اجرا را پاک می‌کند؛ سیاست‌های پایگاه داده حفظ می‌شوند و پس از تغییر می‌توان آن‌ها را دوباره راه‌اندازی یا همگام‌سازی کرد.', cleanupAction: 'بازنشانی', backendSwitchNotice: - 'پیش از تغییر فایروال سیستم، انتقال پورت یا محافظت Docker، بک‌اند فعلی را بازنشانی کنید. سیاست‌های پایگاه داده حفظ می‌شوند و پس از تغییر قابل راه‌اندازی یا همگام‌سازی هستند.', + 'توصیه می‌شود تنها یک روش مدیریت فایروال فعال باشد. اجرای هم‌زمان چند فایروال ممکن است باعث تداخل قوانین، ناهماهنگی وضعیت یا اختلال در دسترسی به پورت‌های کانتینر شود.', switchBackendHelper: 'به {0} تغییر داده شود؟', switchDockerBackendHelper: 'به {0} تغییر داده شود؟ پیکربندی Docker به‌روزرسانی و Docker راه‌اندازی مجدد می‌شود.', diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index 2237e89b1..2d51a4287 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -4138,6 +4138,8 @@ const message = { stale: 'ファイアウォールルールの状態が古くなっています。更新して再試行してください。', lockoutRisk: 'この変更により管理アクセスが遮断される可能性があります。', protectedRule: '保護されたファイアウォールルールは変更できません。', + dockerAcceptReadOnly: + 'この ACCEPT ルールは読み取り専用で、他のルールを同期しても保持されます。削除する場合は、ホスト上で手動で削除してください。', cannotReconcile: '対象ルールを同期できません:{0}', }, ruleSyncPartial: '同期完了:成功 {0} 件、存在済み {1} 件、失敗 {2} 件。', @@ -4157,10 +4159,10 @@ const message = { cleanupDockerBackendHelper: '{0} の 1Panel Docker ポート保護ランタイムルールをリセットします。関連するルールとチェーンをすべて削除し、データベースデータを保持します', cleanupBeforeBackendSwitch: - '現在の {0} バックエンドには 1Panel の実行時ルールが残っています。{1} に切り替える前にリセットしてください。', + '現在の {0} バックエンドには 1Panel の実行時ルールが残っています。{1} に切り替える前にリセットしてください。リセットでは実行時ルールのみが削除され、データベースポリシーは保持されます。切り替え後に再初期化または同期できます。', cleanupAction: 'リセット', backendSwitchNotice: - 'システムファイアウォール、ポート転送、Docker 保護を切り替える前に現在のバックエンドをリセットしてください。データベースポリシーは保持され、切り替え後に再初期化または同期できます。', + 'ファイアウォールの管理方式は 1 つだけ有効にすることを推奨します。複数のファイアウォールを同時に実行すると、ルールの競合、状態の不一致、コンテナーポートへのアクセス異常が発生する可能性があります。', switchBackendHelper: '{0} に切り替えますか?', switchDockerBackendHelper: '{0} に切り替えますか?Docker の設定を更新し、Docker を再起動します。', uninstalledStatus: '未インストール', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index 996087f9a..08303312d 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -4061,6 +4061,8 @@ const message = { stale: '방화벽 규칙 상태가 오래되었습니다. 새로 고친 후 다시 시도하세요.', lockoutRisk: '이 방화벽 변경으로 관리 접근이 차단될 수 있습니다.', protectedRule: '보호된 방화벽 규칙은 수정할 수 없습니다.', + dockerAcceptReadOnly: + '이 ACCEPT 규칙은 읽기 전용이며 다른 규칙을 동기화할 때 그대로 유지됩니다. 제거하려면 호스트에서 수동으로 삭제하세요.', cannotReconcile: '대상 규칙을 동기화할 수 없습니다: {0}', }, ruleSyncPartial: '동기화 완료: 성공 {0}개, 이미 존재 {1}개, 실패 {2}개.', @@ -4080,10 +4082,10 @@ const message = { cleanupDockerBackendHelper: '{0}의 1Panel Docker 포트 보호 런타임 규칙을 재설정합니다. 관련 규칙과 체인을 모두 삭제하고 데이터베이스 데이터는 유지합니다', cleanupBeforeBackendSwitch: - '현재 {0} 백엔드에 1Panel 런타임 규칙이 남아 있습니다. {1}(으)로 전환하기 전에 먼저 재설정하세요.', + '현재 {0} 백엔드에 1Panel 런타임 규칙이 남아 있습니다. {1}(으)로 전환하기 전에 먼저 재설정하세요. 재설정은 런타임 규칙만 정리하며 데이터베이스 정책은 유지됩니다. 전환 후 다시 초기화하거나 동기화할 수 있습니다.', cleanupAction: '재설정', backendSwitchNotice: - '시스템 방화벽, 포트 전달 또는 Docker 보호를 전환하기 전에 현재 백엔드를 재설정하세요. 데이터베이스 정책은 유지되며 전환 후 다시 초기화하거나 동기화할 수 있습니다.', + '하나의 방화벽 관리 방식만 활성화하는 것이 좋습니다. 여러 방화벽을 동시에 실행하면 규칙 충돌, 상태 불일치 또는 컨테이너 포트 접근 오류가 발생할 수 있습니다.', switchBackendHelper: '{0}(으)로 전환하시겠습니까?', switchDockerBackendHelper: '{0}(으)로 전환하시겠습니까? Docker 설정을 업데이트하고 Docker를 재시작합니다.', uninstalledStatus: '설치되지 않음', diff --git a/frontend/src/lang/modules/lo.ts b/frontend/src/lang/modules/lo.ts index cdd574f2b..e3f4681c3 100644 --- a/frontend/src/lang/modules/lo.ts +++ b/frontend/src/lang/modules/lo.ts @@ -4029,6 +4029,8 @@ const message = { stale: 'ສະຖານະກົດໄຟວໍເກົ່າແລ້ວ. ກະລຸນາໂຫຼດໃໝ່ແລ້ວລອງອີກຄັ້ງ.', lockoutRisk: 'ການປ່ຽນແປງນີ້ອາດຂັດຂວາງການເຂົ້າເຖິງຈັດການ.', protectedRule: 'ກົດໄຟວໍທີ່ຖືກປົກປ້ອງນີ້ບໍ່ສາມາດແກ້ໄຂໄດ້.', + dockerAcceptReadOnly: + 'ກົດ ACCEPT ນີ້ເປັນແບບອ່ານຢ່າງດຽວ ແລະຈະຖືກເກັບໄວ້ເມື່ອຊິງກົດອື່ນ. ຫາກຕ້ອງການລຶບ ໃຫ້ລຶບດ້ວຍຕົນເອງໃນໂຮສ.', cannotReconcile: 'ບໍ່ສາມາດຊິງກົດເປົ້າໝາຍ: {0}', }, ruleSyncPartial: 'ຊິງສຳເລັດ: ສຳເລັດ {0}, ມີແລ້ວ {1}, ລົ້ມເຫຼວ {2}.', @@ -4047,10 +4049,11 @@ const message = { 'ຣີເຊັດກົດ runtime ສຳລັບການສົ່ງຕໍ່ພອດ 1Panel ໃນ {0}: ລຶບກົດ ແລະ chain ທີ່ກ່ຽວຂ້ອງທັງໝົດ ແລະເກັບຂໍ້ມູນໃນຖານຂໍ້ມູນ', cleanupDockerBackendHelper: 'ຣີເຊັດກົດ runtime ປ້ອງກັນພອດ Docker ຂອງ 1Panel ໃນ {0}: ລຶບກົດ ແລະ chain ທີ່ກ່ຽວຂ້ອງທັງໝົດ ແລະເກັບຂໍ້ມູນໃນຖານຂໍ້ມູນ', - cleanupBeforeBackendSwitch: 'ແບັກເອັນປັດຈຸບັນ {0} ຍັງມີກົດ runtime ຂອງ 1Panel. ກະລຸນາຣີເຊັດກ່ອນສະຫຼັບໄປ {1}.', + cleanupBeforeBackendSwitch: + 'ແບັກເອັນປັດຈຸບັນ {0} ຍັງມີກົດ runtime ຂອງ 1Panel. ກະລຸນາຣີເຊັດກ່ອນສະຫຼັບໄປ {1}. ການຣີເຊັດຈະລ້າງສະເພາະກົດ runtime; ນະໂຍບາຍໃນຖານຂໍ້ມູນຈະຖືກເກັບໄວ້ ແລະສາມາດເລີ່ມ ຫຼືຊິງຫຼັງສະຫຼັບ.', cleanupAction: 'ຣີເຊັດ', backendSwitchNotice: - 'ຣີເຊັດແບັກເອັນປັດຈຸບັນກ່ອນສະຫຼັບໄຟວໍລະບົບ, ການສົ່ງຕໍ່ພອດ ຫຼື ການປ້ອງກັນ Docker. ນະໂຍບາຍໃນຖານຂໍ້ມູນຈະຖືກເກັບໄວ້ ແລະສາມາດເລີ່ມ ຫຼືຊິງຫຼັງສະຫຼັບ.', + 'ແນະນຳໃຫ້ເປີດໃຊ້ວິທີຈັດການໄຟວໍພຽງແບບດຽວ. ການໃຊ້ຫຼາຍໄຟວໍພ້ອມກັນອາດເຮັດໃຫ້ກົດຂັດແຍ່ງ, ສະຖານະບໍ່ກົງກັນ ຫຼື ການເຂົ້າເຖິງພອດຄອນເທນເນີຜິດປົກກະຕິ.', switchBackendHelper: 'ຢືນຢັນການສະຫຼັບເປັນ {0} ບໍ?', switchDockerBackendHelper: 'ປ່ຽນເປັນ {0} ບໍ? ການຕັ້ງຄ່າ Docker ຈະຖືກອັບເດດ ແລະ Docker ຈະເລີ່ມໃໝ່.', uninstalledStatus: 'ຍັງບໍ່ໄດ້ຕິດຕັ້ງ', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index 9d84579ff..78ad94245 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -4221,6 +4221,8 @@ const message = { stale: 'Keadaan peraturan tembok api sudah lapuk. Muat semula dan cuba lagi.', lockoutRisk: 'Perubahan ini mungkin menyekat akses pengurusan.', protectedRule: 'Peraturan tembok api yang dilindungi ini tidak boleh diubah.', + dockerAcceptReadOnly: + 'Peraturan ACCEPT ini adalah baca sahaja dan akan dikekalkan semasa peraturan lain disegerakkan. Untuk membuangnya, padam secara manual pada hos.', cannotReconcile: 'Peraturan sasaran tidak dapat disegerakkan: {0}', }, ruleSyncPartial: 'Penyegerakan selesai: {0} berjaya, {1} sudah wujud dan {2} gagal.', @@ -4240,10 +4242,10 @@ const message = { cleanupDockerBackendHelper: 'Tetapkan semula peraturan masa jalan perlindungan port Docker 1Panel dalam {0}: padam semua peraturan dan rantaian berkaitan sambil mengekalkan data pangkalan data', cleanupBeforeBackendSwitch: - 'Bahagian belakang semasa {0} masih mengandungi peraturan masa jalan 1Panel. Tetapkan semula sebelum bertukar kepada {1}.', + 'Bahagian belakang semasa {0} masih mengandungi peraturan masa jalan 1Panel. Tetapkan semula sebelum bertukar kepada {1}. Penetapan semula hanya membersihkan peraturan masa jalan; dasar pangkalan data dikekalkan dan boleh dimulakan atau disegerakkan selepas penukaran.', cleanupAction: 'Tetapkan semula', backendSwitchNotice: - 'Tetapkan semula bahagian belakang semasa sebelum menukar tembok api sistem, pemajuan port atau perlindungan Docker. Dasar pangkalan data dikekalkan dan boleh dimulakan atau disegerakkan selepas penukaran.', + 'Disyorkan untuk mengaktifkan hanya satu kaedah pengurusan tembok api. Menjalankan berbilang tembok api serentak boleh menyebabkan konflik peraturan, status tidak konsisten atau masalah akses port kontena.', switchBackendHelper: 'Tukar kepada {0}?', switchDockerBackendHelper: 'Tukar kepada {0}? Ini akan mengemas kini konfigurasi dan memulakan semula Docker.', uninstalledStatus: 'Belum dipasang', diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index eae606cb3..b5c4c2f1a 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -4240,6 +4240,8 @@ const message = { stale: 'O estado da regra está desatualizado. Atualize e tente novamente.', lockoutRisk: 'Esta alteração pode bloquear o acesso de gerenciamento.', protectedRule: 'Esta regra protegida do firewall não pode ser modificada.', + dockerAcceptReadOnly: + 'Esta regra ACCEPT é somente leitura e será preservada durante a sincronização das outras regras. Para removê-la, exclua-a manualmente no host.', cannotReconcile: 'Não foi possível sincronizar a regra de destino: {0}', }, ruleSyncPartial: 'Sincronização concluída: {0} com sucesso, {1} já existiam e {2} falharam.', @@ -4259,10 +4261,10 @@ const message = { cleanupDockerBackendHelper: 'Redefina as regras de execução de proteção de portas Docker do 1Panel em {0}: exclua todas as regras e cadeias relacionadas e mantenha os dados do banco de dados', cleanupBeforeBackendSwitch: - 'O backend atual {0} ainda contém regras de execução do 1Panel. Redefina-o antes de mudar para {1}.', + 'O backend atual {0} ainda contém regras de execução do 1Panel. Redefina-o antes de mudar para {1}. A redefinição remove apenas as regras de execução; as políticas do banco são mantidas e podem ser inicializadas ou sincronizadas após a troca.', cleanupAction: 'Redefinir', backendSwitchNotice: - 'Redefina o backend atual antes de trocar o firewall do sistema, o encaminhamento de portas ou a proteção Docker. As políticas do banco são mantidas e podem ser inicializadas ou sincronizadas após a troca.', + 'Recomenda-se habilitar apenas uma forma de gerenciamento de firewall. Executar vários firewalls ao mesmo tempo pode causar conflitos de regras, estados inconsistentes ou falhas de acesso às portas dos contêineres.', switchBackendHelper: 'Mudar para {0}?', switchDockerBackendHelper: 'Mudar para {0}? Isso atualizará a configuração e reiniciará o Docker.', uninstalledStatus: 'Não instalado', diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index 4491acb05..9c66e0b9d 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -4207,6 +4207,8 @@ const message = { stale: 'Состояние правила устарело. Обновите данные и повторите попытку.', lockoutRisk: 'Это изменение может заблокировать доступ к управлению.', protectedRule: 'Это защищённое правило брандмауэра нельзя изменить.', + dockerAcceptReadOnly: + 'Это правило ACCEPT доступно только для чтения и будет сохранено при синхронизации остальных правил. Чтобы удалить его, сделайте это вручную на хосте.', cannotReconcile: 'Не удалось синхронизировать целевое правило: {0}', }, ruleSyncPartial: 'Синхронизация завершена: успешно — {0}, уже существовало — {1}, ошибок — {2}.', @@ -4226,10 +4228,10 @@ const message = { cleanupDockerBackendHelper: 'Сбросить правила времени выполнения защиты портов Docker 1Panel в {0}: удалить все связанные правила и цепочки, сохранив данные в базе данных', cleanupBeforeBackendSwitch: - 'Текущий бэкенд {0} всё ещё содержит правила времени выполнения 1Panel. Сбросьте его перед переключением на {1}.', + 'Текущий бэкенд {0} всё ещё содержит правила времени выполнения 1Panel. Сбросьте его перед переключением на {1}. Сброс удаляет только правила времени выполнения; политики базы данных сохраняются и после переключения могут быть повторно инициализированы или синхронизированы.', cleanupAction: 'Сбросить', backendSwitchNotice: - 'Перед переключением системного брандмауэра, переадресации портов или защиты Docker сбросьте текущий бэкенд. Политики базы данных сохраняются и могут быть инициализированы или синхронизированы после переключения.', + 'Рекомендуется использовать только один способ управления брандмауэром. Одновременная работа нескольких брандмауэров может привести к конфликтам правил, несогласованному состоянию или проблемам доступа к портам контейнеров.', switchBackendHelper: 'Переключиться на {0}?', switchDockerBackendHelper: 'Переключиться на {0}? Конфигурация Docker будет обновлена, а Docker перезапущен.', uninstalledStatus: 'Не установлено', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index 2d1eb9d82..7745bc810 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -4221,6 +4221,8 @@ const message = { stale: 'Güvenlik duvarı kuralının durumu güncel değil. Yenileyip tekrar deneyin.', lockoutRisk: 'Bu değişiklik yönetim erişimini engelleyebilir.', protectedRule: 'Bu korumalı güvenlik duvarı kuralı değiştirilemez.', + dockerAcceptReadOnly: + 'Bu ACCEPT kuralı salt okunurdur ve diğer kurallar eşitlenirken korunur. Kaldırmak için ana makinede manuel olarak silin.', cannotReconcile: 'Hedef kural eşitlenemiyor: {0}', }, ruleSyncPartial: 'Eşitleme tamamlandı: {0} başarılı, {1} zaten mevcut, {2} başarısız.', @@ -4240,10 +4242,10 @@ const message = { cleanupDockerBackendHelper: '{0} içindeki 1Panel Docker bağlantı noktası koruma çalışma zamanı kurallarını sıfırlayın: ilgili tüm kuralları ve zincirleri silip veritabanı verilerini koruyun', cleanupBeforeBackendSwitch: - 'Mevcut {0} arka ucu hâlâ 1Panel çalışma zamanı kuralları içeriyor. {1} arka ucuna geçmeden önce sıfırlayın.', + 'Mevcut {0} arka ucu hâlâ 1Panel çalışma zamanı kuralları içeriyor. {1} arka ucuna geçmeden önce sıfırlayın. Sıfırlama yalnızca çalışma zamanı kurallarını temizler; veritabanı ilkeleri korunur ve geçişten sonra yeniden başlatılabilir veya eşitlenebilir.', cleanupAction: 'Sıfırla', backendSwitchNotice: - 'Sistem güvenlik duvarı, bağlantı noktası yönlendirme veya Docker korumasını değiştirmeden önce mevcut arka ucu sıfırlayın. Veritabanı ilkeleri korunur ve geçişten sonra yeniden başlatılabilir veya eşitlenebilir.', + 'Yalnızca bir güvenlik duvarı yönetim yönteminin etkinleştirilmesi önerilir. Birden fazla güvenlik duvarının aynı anda çalışması kural çakışmalarına, tutarsız durumlara veya konteyner bağlantı noktası erişim sorunlarına yol açabilir.', switchBackendHelper: '{0} arka ucuna geçilsin mi?', switchDockerBackendHelper: '{0} arka ucuna geçilsin mi? Docker yapılandırması güncellenecek ve Docker yeniden başlatılacak.', diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index d1f9c908d..32423c5c2 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -3880,6 +3880,8 @@ const message = { stale: '防火牆規則狀態已過期,請重新整理後再試。', lockoutRisk: '此次防火牆變更可能會阻斷管理存取。', protectedRule: '此防火牆規則受保護,無法修改。', + dockerAcceptReadOnly: + '此 ACCEPT 規則為唯讀、不可同步;同步其他規則時將保留此規則。如需移除,請在主機上手動刪除。', cannotReconcile: '無法同步目標規則:{0}', }, ruleSyncPartial: '同步完成:成功 {0} 條,已存在 {1} 條,失敗 {2} 條。', @@ -3897,10 +3899,11 @@ const message = { '重設 {0} 中的 1Panel 連接埠轉送執行時規則:刪除全部相關規則及規則鏈,僅保留資料庫資料', cleanupDockerBackendHelper: '重設 {0} 中的 1Panel Docker 連接埠防護執行時規則:刪除全部相關規則及規則鏈,僅保留資料庫資料', - cleanupBeforeBackendSwitch: '目前後端 {0} 仍存在 1Panel 執行時規則,請先重設該後端,再切換到 {1}。', + cleanupBeforeBackendSwitch: + '目前後端 {0} 仍存在 1Panel 執行時規則,請先重設該後端,再切換到 {1}。重設僅清理執行時規則,資料庫策略會保留,切換後可重新初始化或同步。', cleanupAction: '重設', backendSwitchNotice: - '主機防火牆、連接埠轉送和 Docker 防護切換前都必須先重設目前後端;資料庫策略會保留,切換後可重新初始化或同步。', + '建議僅啟用一種防火牆管理方式。同時執行多個防火牆可能導致規則衝突、狀態不一致或容器連接埠存取異常。', switchBackendHelper: '確認切換為 {0}?', switchDockerBackendHelper: '確認切換為 {0}?此操作會更新 Docker 設定並重新啟動 Docker 服務。', uninstalledStatus: '未安裝', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index eade75a92..011386029 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -3929,6 +3929,8 @@ const message = { stale: '检查后防火墙状态已发生变化,请刷新后重试。', lockoutRisk: '执行后可能导致当前 1Panel 管理页面无法访问,本次操作已停止。', protectedRule: '这是保证系统正常运行的必要规则,不能修改。', + dockerAcceptReadOnly: + '该 ACCEPT 规则为只读、不可同步;同步其他规则时会保留该规则。如需移除,请在主机上手动删除。', cannotReconcile: '该规则无法自动同步:{0}', }, ruleSyncPartial: '同步完成:成功 {0} 条,已存在 {1} 条,失败 {2} 条。', @@ -3946,10 +3948,11 @@ const message = { '重置 {0} 中的 1Panel 端口转发运行时规则:删除全部相关规则及规则链,仅保留数据库数据', cleanupDockerBackendHelper: '重置 {0} 中的 1Panel Docker 端口防护运行时规则:删除全部相关规则及规则链,仅保留数据库数据', - cleanupBeforeBackendSwitch: '当前后端 {0} 仍存在 1Panel 运行时规则,请先重置该后端,再切换到 {1}。', + cleanupBeforeBackendSwitch: + '当前后端 {0} 仍存在 1Panel 运行时规则,请先重置该后端,再切换到 {1}。重置仅清理运行时规则,数据库策略会保留,切换后可以重新初始化或同步。', cleanupAction: '重置', backendSwitchNotice: - '主机防火墙、端口转发和 Docker 防护切换前都需要先重置当前后端;数据库策略会保留,切换后可以重新初始化或同步。', + '建议仅启用一种防火墙管理方式。多个防火墙同时运行可能导致规则冲突、状态不一致或容器端口访问异常。', switchBackendHelper: '确认切换为 {0}?', switchDockerBackendHelper: '确认切换为 {0}?该操作会更新 Docker 配置并重启 Docker 服务。', uninstalledStatus: '未安装', diff --git a/frontend/src/views/host/firewall/setting/index.vue b/frontend/src/views/host/firewall/setting/index.vue index 80f1a8be9..1f470635e 100644 --- a/frontend/src/views/host/firewall/setting/index.vue +++ b/frontend/src/views/host/firewall/setting/index.vue @@ -3,7 +3,7 @@