mirror of
https://github.com/k3s-io/kubernetes.git
synced 2025-07-23 11:50:44 +00:00
PodSecurity: policy: registry
Co-authored-by: Jordan Liggitt <liggitt@google.com>
This commit is contained in:
parent
5183ea0bf0
commit
1436d35779
178
staging/src/k8s.io/pod-security-admission/policy/checks.go
Normal file
178
staging/src/k8s.io/pod-security-admission/policy/checks.go
Normal file
@ -0,0 +1,178 @@
|
||||
/*
|
||||
Copyright 2021 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package policy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/pod-security-admission/api"
|
||||
)
|
||||
|
||||
type Check struct {
|
||||
// ID is the unique ID of the check.
|
||||
ID string
|
||||
// Level is the policy level this check belongs to.
|
||||
// Must be Baseline or Restricted.
|
||||
// Baseline checks are evaluated for baseline and restricted namespaces.
|
||||
// Restricted checks are only evaluated for restricted namespaces.
|
||||
Level api.Level
|
||||
// Versions contains one or more revisions of the check that apply to different versions.
|
||||
// If the check is not yet assigned to a version, this must be a single-item list with a MinimumVersion of "".
|
||||
// Otherwise, MinimumVersion of items must represent strictly increasing versions.
|
||||
Versions []VersionedCheck
|
||||
}
|
||||
|
||||
type VersionedCheck struct {
|
||||
// MinimumVersion is the first policy version this check applies to.
|
||||
// If unset, this check is not yet assigned to a policy version.
|
||||
// If set, must not be "latest".
|
||||
MinimumVersion api.Version
|
||||
// CheckPod determines if the pod is allowed.
|
||||
CheckPod CheckPodFn
|
||||
}
|
||||
|
||||
type CheckPodFn func(*metav1.ObjectMeta, *corev1.PodSpec) CheckResult
|
||||
|
||||
// CheckResult contains the result of checking a pod and indicates whether the pod is allowed,
|
||||
// and if not, why it was forbidden.
|
||||
//
|
||||
// Example output for (false, "host ports", "8080, 9090"):
|
||||
// When checking all pods in a namespace:
|
||||
// disallowed by policy "baseline": host ports, privileged containers, non-default capabilities
|
||||
// When checking an individual pod:
|
||||
// disallowed by policy "baseline": host ports (8080, 9090), privileged containers, non-default capabilities (CAP_NET_RAW)
|
||||
type CheckResult struct {
|
||||
// Allowed indicates if the check allowed the pod.
|
||||
Allowed bool
|
||||
// ForbiddenReason must be set if Allowed is false.
|
||||
// ForbiddenReason should be as succinct as possible and is always output.
|
||||
// Examples:
|
||||
// - "host ports"
|
||||
// - "privileged containers"
|
||||
// - "non-default capabilities"
|
||||
ForbiddenReason string
|
||||
// ForbiddenDetail should only be set if Allowed is false, and is optional.
|
||||
// ForbiddenDetail can include specific values that were disallowed and is used when checking an individual object.
|
||||
// Examples:
|
||||
// - list specific invalid host ports: "8080, 9090"
|
||||
// - list specific invalid containers: "container1, container2"
|
||||
// - list specific non-default capabilities: "CAP_NET_RAW"
|
||||
ForbiddenDetail string
|
||||
}
|
||||
|
||||
// AggergateCheckResult holds the aggregate result of running CheckPod across multiple checks.
|
||||
type AggregateCheckResult struct {
|
||||
// Allowed indicates if all checks allowed the pod.
|
||||
Allowed bool
|
||||
// ForbiddenReasons is a slice of the forbidden reasons from all the forbidden checks. It should not include empty strings.
|
||||
// ForbiddenReasons and ForbiddenDetails must have the same number of elements, and the indexes are for the same check.
|
||||
ForbiddenReasons []string
|
||||
// ForbiddenDetails is a slice of the forbidden details from all the forbidden checks. It may include empty strings.
|
||||
// ForbiddenReasons and ForbiddenDetails must have the same number of elements, and the indexes are for the same check.
|
||||
ForbiddenDetails []string
|
||||
}
|
||||
|
||||
// ForbiddenReason returns a comma-separated string of of the forbidden reasons.
|
||||
// Example: host ports, privileged containers, non-default capabilities
|
||||
func (a *AggregateCheckResult) ForbiddenReason() string {
|
||||
return strings.Join(a.ForbiddenReasons, ", ")
|
||||
}
|
||||
|
||||
// ForbiddenDetail returns a detailed forbidden message, with non-empty details formatted in
|
||||
// parentheses with the associated reason.
|
||||
// Example: host ports (8080, 9090), privileged containers, non-default capabilities (NET_RAW)
|
||||
func (a *AggregateCheckResult) ForbiddenDetail() string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(a.ForbiddenReasons); i++ {
|
||||
b.WriteString(a.ForbiddenReasons[i])
|
||||
if a.ForbiddenDetails[i] != "" {
|
||||
b.WriteString(" (")
|
||||
b.WriteString(a.ForbiddenDetails[i])
|
||||
b.WriteString(")")
|
||||
}
|
||||
if i != len(a.ForbiddenReasons)-1 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// UnknownForbiddenReason is used as the placeholder forbidden reason for checks that incorrectly disallow without providing a reason.
|
||||
const UnknownForbiddenReason = "unknown forbidden reason"
|
||||
|
||||
// AggregateCheckPod runs all the checks and aggregates the forbidden results into a single CheckResult.
|
||||
// The aggregated reason is a comma-separated
|
||||
func AggregateCheckResults(results []CheckResult) AggregateCheckResult {
|
||||
var (
|
||||
reasons []string
|
||||
details []string
|
||||
)
|
||||
for _, result := range results {
|
||||
if !result.Allowed {
|
||||
if len(result.ForbiddenReason) == 0 {
|
||||
reasons = append(reasons, UnknownForbiddenReason)
|
||||
} else {
|
||||
reasons = append(reasons, result.ForbiddenReason)
|
||||
}
|
||||
details = append(details, result.ForbiddenDetail)
|
||||
}
|
||||
}
|
||||
return AggregateCheckResult{
|
||||
Allowed: len(reasons) == 0,
|
||||
ForbiddenReasons: reasons,
|
||||
ForbiddenDetails: details,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
defaultChecks []func() Check
|
||||
experimentalChecks []func() Check
|
||||
)
|
||||
|
||||
func addCheck(f func() Check) {
|
||||
// add to experimental or versioned list
|
||||
c := f()
|
||||
if len(c.Versions) == 1 && c.Versions[0].MinimumVersion == (api.Version{}) {
|
||||
experimentalChecks = append(experimentalChecks, f)
|
||||
} else {
|
||||
defaultChecks = append(defaultChecks, f)
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultChecks returns checks that are expected to be enabled by default.
|
||||
// The results are mutually exclusive with ExperimentalChecks.
|
||||
// It returns a new copy of checks on each invocation and is expected to be called once at setup time.
|
||||
func DefaultChecks() []Check {
|
||||
retval := make([]Check, 0, len(defaultChecks))
|
||||
for _, f := range defaultChecks {
|
||||
retval = append(retval, f())
|
||||
}
|
||||
return retval
|
||||
}
|
||||
|
||||
// ExperimentalChecks returns checks that have not yet been assigned to policy versions.
|
||||
// The results are mutually exclusive with DefaultChecks.
|
||||
// It returns a new copy of checks on each invocation and is expected to be called once at setup time.
|
||||
func ExperimentalChecks() []Check {
|
||||
retval := make([]Check, 0, len(experimentalChecks))
|
||||
for _, f := range experimentalChecks {
|
||||
retval = append(retval, f())
|
||||
}
|
||||
return retval
|
||||
}
|
18
staging/src/k8s.io/pod-security-admission/policy/doc.go
Normal file
18
staging/src/k8s.io/pod-security-admission/policy/doc.go
Normal file
@ -0,0 +1,18 @@
|
||||
/*
|
||||
Copyright 2021 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Package policy contains implementations of Pod Security Standards checks
|
||||
package policy // import "k8s.io/pod-security-admission/policy"
|
146
staging/src/k8s.io/pod-security-admission/policy/registry.go
Normal file
146
staging/src/k8s.io/pod-security-admission/policy/registry.go
Normal file
@ -0,0 +1,146 @@
|
||||
/*
|
||||
Copyright 2021 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/pod-security-admission/api"
|
||||
)
|
||||
|
||||
// Evaluator holds the Checks that are used to validate a policy.
|
||||
type Evaluator interface {
|
||||
// EvaluatePod evaluates the pod against the policy for the given level & version.
|
||||
EvaluatePod(lv api.LevelVersion, podMetadata *metav1.ObjectMeta, podSpec *corev1.PodSpec) []CheckResult
|
||||
}
|
||||
|
||||
// checkRegistry provides a default implementation of an Evaluator.
|
||||
type checkRegistry struct {
|
||||
// The checks are a map of check_ID -> sorted slice of versioned checks, newest first
|
||||
baselineChecks, restrictedChecks map[api.Version][]CheckPodFn
|
||||
// maxVersion is the maximum version that is cached, guaranteed to be at least
|
||||
// the max MinimumVersion of all registered checks.
|
||||
maxVersion api.Version
|
||||
}
|
||||
|
||||
// NewEvaluator constructs a new Evaluator instance from the list of checks. If the provided checks are invalid,
|
||||
// an error is returned. A valid list of checks must meet the following requirements:
|
||||
// 1. Check.ID is unique in the list
|
||||
// 2. Check.Level must be either Baseline or Restricted
|
||||
// 3. Checks must have a non-empty set of versions, sorted in a strictly increasing order
|
||||
// 4. Check.Versions cannot include 'latest'
|
||||
func NewEvaluator(checks []Check) (Evaluator, error) {
|
||||
if err := validateChecks(checks); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &checkRegistry{
|
||||
baselineChecks: map[api.Version][]CheckPodFn{},
|
||||
restrictedChecks: map[api.Version][]CheckPodFn{},
|
||||
}
|
||||
populate(r, checks)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *checkRegistry) EvaluatePod(lv api.LevelVersion, podMetadata *metav1.ObjectMeta, podSpec *corev1.PodSpec) []CheckResult {
|
||||
if lv.Level == api.LevelPrivileged {
|
||||
return nil
|
||||
}
|
||||
if r.maxVersion.Older(lv.Version) {
|
||||
lv.Version = r.maxVersion
|
||||
}
|
||||
results := []CheckResult{}
|
||||
for _, check := range r.baselineChecks[lv.Version] {
|
||||
results = append(results, check(podMetadata, podSpec))
|
||||
}
|
||||
if lv.Level == api.LevelBaseline {
|
||||
return results
|
||||
}
|
||||
for _, check := range r.restrictedChecks[lv.Version] {
|
||||
results = append(results, check(podMetadata, podSpec))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func validateChecks(checks []Check) error {
|
||||
ids := map[string]bool{}
|
||||
for _, check := range checks {
|
||||
if ids[check.ID] {
|
||||
return fmt.Errorf("multiple checks registered for ID %s", check.ID)
|
||||
}
|
||||
ids[check.ID] = true
|
||||
if check.Level != api.LevelBaseline && check.Level != api.LevelRestricted {
|
||||
return fmt.Errorf("check %s: invalid level %s", check.ID, check.Level)
|
||||
}
|
||||
if len(check.Versions) == 0 {
|
||||
return fmt.Errorf("check %s: empty", check.ID)
|
||||
}
|
||||
maxVersion := api.Version{}
|
||||
for _, c := range check.Versions {
|
||||
if c.MinimumVersion == (api.Version{}) {
|
||||
return fmt.Errorf("check %s: undefined version found", check.ID)
|
||||
}
|
||||
if c.MinimumVersion.Latest() {
|
||||
return fmt.Errorf("check %s: version cannot be 'latest'", check.ID)
|
||||
}
|
||||
if maxVersion == c.MinimumVersion {
|
||||
return fmt.Errorf("check %s: duplicate version %s", check.ID, c.MinimumVersion)
|
||||
}
|
||||
if !maxVersion.Older(c.MinimumVersion) {
|
||||
return fmt.Errorf("check %s: versions must be strictly increasing", check.ID)
|
||||
}
|
||||
maxVersion = c.MinimumVersion
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func populate(r *checkRegistry, validChecks []Check) {
|
||||
// Find the max(MinimumVersion) across all checks.
|
||||
for _, c := range validChecks {
|
||||
lastVersion := c.Versions[len(c.Versions)-1].MinimumVersion
|
||||
if r.maxVersion.Older(lastVersion) {
|
||||
r.maxVersion = lastVersion
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range validChecks {
|
||||
if c.Level == api.LevelRestricted {
|
||||
inflateVersions(c, r.restrictedChecks, r.maxVersion)
|
||||
} else {
|
||||
inflateVersions(c, r.baselineChecks, r.maxVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func inflateVersions(check Check, versions map[api.Version][]CheckPodFn, maxVersion api.Version) {
|
||||
for i, c := range check.Versions {
|
||||
var nextVersion api.Version
|
||||
if i+1 < len(check.Versions) {
|
||||
nextVersion = check.Versions[i+1].MinimumVersion
|
||||
} else {
|
||||
// Assumes only 1 Major version.
|
||||
nextVersion = api.MajorMinorVersion(1, maxVersion.Minor()+1)
|
||||
}
|
||||
// Iterate over all versions from the minimum of the current check, to the minimum of the
|
||||
// next check, or the maxVersion++.
|
||||
for v := c.MinimumVersion; v.Older(nextVersion); v = api.MajorMinorVersion(1, v.Minor()+1) {
|
||||
versions[v] = append(versions[v], check.Versions[i].CheckPod)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,103 @@
|
||||
/*
|
||||
Copyright 2021 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/pod-security-admission/api"
|
||||
)
|
||||
|
||||
func TestCheckRegistry(t *testing.T) {
|
||||
checks := []Check{
|
||||
generateCheck("a", api.LevelBaseline, []string{"v1.0"}),
|
||||
generateCheck("b", api.LevelBaseline, []string{"v1.10"}),
|
||||
generateCheck("c", api.LevelBaseline, []string{"v1.0", "v1.5", "v1.10"}),
|
||||
generateCheck("d", api.LevelBaseline, []string{"v1.11", "v1.15", "v1.20"}),
|
||||
generateCheck("e", api.LevelRestricted, []string{"v1.0"}),
|
||||
generateCheck("f", api.LevelRestricted, []string{"v1.12", "v1.16", "v1.21"}),
|
||||
}
|
||||
|
||||
reg, err := NewEvaluator(checks)
|
||||
require.NoError(t, err)
|
||||
|
||||
levelCases := []struct {
|
||||
level api.Level
|
||||
version string
|
||||
expectedReasons []string
|
||||
}{
|
||||
{api.LevelPrivileged, "v1.0", nil},
|
||||
{api.LevelPrivileged, "latest", nil},
|
||||
{api.LevelBaseline, "v1.0", []string{"a:v1.0", "c:v1.0"}},
|
||||
{api.LevelBaseline, "v1.4", []string{"a:v1.0", "c:v1.0"}},
|
||||
{api.LevelBaseline, "v1.5", []string{"a:v1.0", "c:v1.5"}},
|
||||
{api.LevelBaseline, "v1.10", []string{"a:v1.0", "b:v1.10", "c:v1.10"}},
|
||||
{api.LevelBaseline, "v1.11", []string{"a:v1.0", "b:v1.10", "c:v1.10", "d:v1.11"}},
|
||||
{api.LevelBaseline, "latest", []string{"a:v1.0", "b:v1.10", "c:v1.10", "d:v1.20"}},
|
||||
{api.LevelRestricted, "v1.0", []string{"a:v1.0", "c:v1.0", "e:v1.0"}},
|
||||
{api.LevelRestricted, "v1.4", []string{"a:v1.0", "c:v1.0", "e:v1.0"}},
|
||||
{api.LevelRestricted, "v1.5", []string{"a:v1.0", "c:v1.5", "e:v1.0"}},
|
||||
{api.LevelRestricted, "v1.10", []string{"a:v1.0", "b:v1.10", "c:v1.10", "e:v1.0"}},
|
||||
{api.LevelRestricted, "v1.11", []string{"a:v1.0", "b:v1.10", "c:v1.10", "d:v1.11", "e:v1.0"}},
|
||||
{api.LevelRestricted, "latest", []string{"a:v1.0", "b:v1.10", "c:v1.10", "d:v1.20", "e:v1.0", "f:v1.21"}},
|
||||
{api.LevelRestricted, "v1.10000", []string{"a:v1.0", "b:v1.10", "c:v1.10", "d:v1.20", "e:v1.0", "f:v1.21"}},
|
||||
}
|
||||
for _, test := range levelCases {
|
||||
t.Run(fmt.Sprintf("%s:%s", test.level, test.version), func(t *testing.T) {
|
||||
results := reg.EvaluatePod(api.LevelVersion{test.level, versionOrPanic(test.version)}, nil, nil)
|
||||
|
||||
// Set extract the ForbiddenReasons from the results.
|
||||
var actualReasons []string
|
||||
for _, result := range results {
|
||||
actualReasons = append(actualReasons, result.ForbiddenReason)
|
||||
}
|
||||
assert.ElementsMatch(t, test.expectedReasons, actualReasons)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func generateCheck(id string, level api.Level, versions []string) Check {
|
||||
c := Check{
|
||||
ID: id,
|
||||
Level: level,
|
||||
}
|
||||
for _, ver := range versions {
|
||||
v := versionOrPanic(ver) // Copy ver so it can be used in the CheckPod closure.
|
||||
c.Versions = append(c.Versions, VersionedCheck{
|
||||
MinimumVersion: v,
|
||||
CheckPod: func(_ *metav1.ObjectMeta, _ *corev1.PodSpec) CheckResult {
|
||||
return CheckResult{
|
||||
ForbiddenReason: fmt.Sprintf("%s:%s", id, v),
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func versionOrPanic(v string) api.Version {
|
||||
ver, err := api.ParseVersion(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return ver
|
||||
}
|
42
staging/src/k8s.io/pod-security-admission/policy/visitor.go
Normal file
42
staging/src/k8s.io/pod-security-admission/policy/visitor.go
Normal file
@ -0,0 +1,42 @@
|
||||
/*
|
||||
Copyright 2021 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package policy
|
||||
|
||||
import (
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
)
|
||||
|
||||
// ContainerVisitorWithPath is called with each container and the field.Path to that container
|
||||
type ContainerVisitorWithPath func(container *corev1.Container, path *field.Path)
|
||||
|
||||
// visitContainersWithPath invokes the visitor function with a pointer to the spec
|
||||
// of every container in the given pod spec and the field.Path to that container.
|
||||
func visitContainersWithPath(podSpec *corev1.PodSpec, specPath *field.Path, visitor ContainerVisitorWithPath) {
|
||||
fldPath := specPath.Child("initContainers")
|
||||
for i := range podSpec.InitContainers {
|
||||
visitor(&podSpec.InitContainers[i], fldPath.Index(i))
|
||||
}
|
||||
fldPath = specPath.Child("containers")
|
||||
for i := range podSpec.Containers {
|
||||
visitor(&podSpec.Containers[i], fldPath.Index(i))
|
||||
}
|
||||
fldPath = specPath.Child("ephemeralContainers")
|
||||
for i := range podSpec.EphemeralContainers {
|
||||
visitor((*corev1.Container)(&podSpec.EphemeralContainers[i].EphemeralContainerCommon), fldPath.Index(i))
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user