mirror of
https://github.com/tailscale/tailscale.git
synced 2025-08-13 22:47:30 +00:00
cmd/k8s-operator: Allow custom ingress class names (#16472)
This commit modifies the k8s operator to allow for customisation of the ingress class name via a new `OPERATOR_INGRESS_CLASS_NAME` environment variable. For backwards compatibility, this defaults to `tailscale`. When using helm, a new `ingress.name` value is provided that will set this environment variable and modify the name of the deployed `IngressClass` resource. Fixes https://github.com/tailscale/tailscale/issues/16248 Signed-off-by: David Bond <davidsbond93@gmail.com>
This commit is contained in:
@@ -70,6 +70,8 @@ spec:
|
||||
fieldPath: metadata.namespace
|
||||
- name: OPERATOR_LOGIN_SERVER
|
||||
value: {{ .Values.loginServer }}
|
||||
- name: OPERATOR_INGRESS_CLASS_NAME
|
||||
value: {{ .Values.ingressClass.name }}
|
||||
- name: CLIENT_ID_FILE
|
||||
value: /oauth/client_id
|
||||
- name: CLIENT_SECRET_FILE
|
||||
|
@@ -2,7 +2,7 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: IngressClass
|
||||
metadata:
|
||||
name: tailscale # class name currently can not be changed
|
||||
name: {{ .Values.ingressClass.name }}
|
||||
annotations: {} # we do not support default IngressClass annotation https://kubernetes.io/docs/concepts/services-networking/ingress/#default-ingress-class
|
||||
spec:
|
||||
controller: tailscale.com/ts-ingress # controller name currently can not be changed
|
||||
|
@@ -77,6 +77,10 @@ operatorConfig:
|
||||
|
||||
# In the case that you already have a tailscale ingressclass in your cluster (or vcluster), you can disable the creation here
|
||||
ingressClass:
|
||||
# Allows for customization of the ingress class name used by the operator to identify ingresses to reconcile. This does
|
||||
# not allow multiple operator instances to manage different ingresses, but provides an onboarding route for users that
|
||||
# may have previously set up ingress classes named "tailscale" prior to using the operator.
|
||||
name: "tailscale"
|
||||
enabled: true
|
||||
|
||||
# proxyConfig contains configuraton that will be applied to any ingress/egress
|
||||
|
@@ -5129,6 +5129,8 @@ spec:
|
||||
fieldPath: metadata.namespace
|
||||
- name: OPERATOR_LOGIN_SERVER
|
||||
value: null
|
||||
- name: OPERATOR_INGRESS_CLASS_NAME
|
||||
value: tailscale
|
||||
- name: CLIENT_ID_FILE
|
||||
value: /oauth/client_id
|
||||
- name: CLIENT_SECRET_FILE
|
||||
|
@@ -68,14 +68,15 @@ var gaugePGIngressResources = clientmetric.NewGauge(kubetypes.MetricIngressPGRes
|
||||
type HAIngressReconciler struct {
|
||||
client.Client
|
||||
|
||||
recorder record.EventRecorder
|
||||
logger *zap.SugaredLogger
|
||||
tsClient tsClient
|
||||
tsnetServer tsnetServer
|
||||
tsNamespace string
|
||||
lc localClient
|
||||
defaultTags []string
|
||||
operatorID string // stableID of the operator's Tailscale device
|
||||
recorder record.EventRecorder
|
||||
logger *zap.SugaredLogger
|
||||
tsClient tsClient
|
||||
tsnetServer tsnetServer
|
||||
tsNamespace string
|
||||
lc localClient
|
||||
defaultTags []string
|
||||
operatorID string // stableID of the operator's Tailscale device
|
||||
ingressClassName string
|
||||
|
||||
mu sync.Mutex // protects following
|
||||
// managedIngresses is a set of all ingress resources that we're currently
|
||||
@@ -162,7 +163,7 @@ func (r *HAIngressReconciler) maybeProvision(ctx context.Context, hostname strin
|
||||
return false, fmt.Errorf("error getting Tailscale Service %q: %w", hostname, err)
|
||||
}
|
||||
|
||||
if err := validateIngressClass(ctx, r.Client); err != nil {
|
||||
if err := validateIngressClass(ctx, r.Client, r.ingressClassName); err != nil {
|
||||
logger.Infof("error validating tailscale IngressClass: %v.", err)
|
||||
return false, nil
|
||||
}
|
||||
@@ -645,7 +646,7 @@ func (r *HAIngressReconciler) tailnetCertDomain(ctx context.Context) (string, er
|
||||
func (r *HAIngressReconciler) shouldExpose(ing *networkingv1.Ingress) bool {
|
||||
isTSIngress := ing != nil &&
|
||||
ing.Spec.IngressClassName != nil &&
|
||||
*ing.Spec.IngressClassName == tailscaleIngressClassName
|
||||
*ing.Spec.IngressClassName == r.ingressClassName
|
||||
pgAnnot := ing.Annotations[AnnotationProxyGroup]
|
||||
return isTSIngress && pgAnnot != ""
|
||||
}
|
||||
|
@@ -438,7 +438,12 @@ func TestValidateIngress(t *testing.T) {
|
||||
WithObjects(tt.ing).
|
||||
WithLists(&networkingv1.IngressList{Items: tt.existingIngs}).
|
||||
Build()
|
||||
|
||||
r := &HAIngressReconciler{Client: fc}
|
||||
if tt.ing.Spec.IngressClassName != nil {
|
||||
r.ingressClassName = *tt.ing.Spec.IngressClassName
|
||||
}
|
||||
|
||||
err := r.validateIngress(context.Background(), tt.ing, tt.pg)
|
||||
if (err == nil && tt.wantErr != "") || (err != nil && err.Error() != tt.wantErr) {
|
||||
t.Errorf("validateIngress() error = %v, wantErr %v", err, tt.wantErr)
|
||||
@@ -841,14 +846,15 @@ func setupIngressTest(t *testing.T) (*HAIngressReconciler, client.Client, *fakeT
|
||||
}
|
||||
|
||||
ingPGR := &HAIngressReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
defaultTags: []string{"tag:k8s"},
|
||||
tsNamespace: "operator-ns",
|
||||
tsnetServer: fakeTsnetServer,
|
||||
logger: zl.Sugar(),
|
||||
recorder: record.NewFakeRecorder(10),
|
||||
lc: lc,
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
defaultTags: []string{"tag:k8s"},
|
||||
tsNamespace: "operator-ns",
|
||||
tsnetServer: fakeTsnetServer,
|
||||
logger: zl.Sugar(),
|
||||
recorder: record.NewFakeRecorder(10),
|
||||
lc: lc,
|
||||
ingressClassName: tsIngressClass.Name,
|
||||
}
|
||||
|
||||
return ingPGR, fc, ft
|
||||
|
@@ -32,7 +32,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
tailscaleIngressClassName = "tailscale" // ingressClass.metadata.name for tailscale IngressClass resource
|
||||
tailscaleIngressControllerName = "tailscale.com/ts-ingress" // ingressClass.spec.controllerName for tailscale IngressClass resource
|
||||
ingressClassDefaultAnnotation = "ingressclass.kubernetes.io/is-default-class" // we do not support this https://kubernetes.io/docs/concepts/services-networking/ingress/#default-ingress-class
|
||||
indexIngressProxyClass = ".metadata.annotations.ingress-proxy-class"
|
||||
@@ -52,6 +51,7 @@ type IngressReconciler struct {
|
||||
managedIngresses set.Slice[types.UID]
|
||||
|
||||
defaultProxyClass string
|
||||
ingressClassName string
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -132,7 +132,7 @@ func (a *IngressReconciler) maybeCleanup(ctx context.Context, logger *zap.Sugare
|
||||
// This function adds a finalizer to ing, ensuring that we can handle orderly
|
||||
// deprovisioning later.
|
||||
func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.SugaredLogger, ing *networkingv1.Ingress) error {
|
||||
if err := validateIngressClass(ctx, a.Client); err != nil {
|
||||
if err := validateIngressClass(ctx, a.Client, a.ingressClassName); err != nil {
|
||||
logger.Warnf("error validating tailscale IngressClass: %v. In future this might be a terminal error.", err)
|
||||
}
|
||||
if !slices.Contains(ing.Finalizers, FinalizerName) {
|
||||
@@ -266,17 +266,17 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
func (a *IngressReconciler) shouldExpose(ing *networkingv1.Ingress) bool {
|
||||
return ing != nil &&
|
||||
ing.Spec.IngressClassName != nil &&
|
||||
*ing.Spec.IngressClassName == tailscaleIngressClassName &&
|
||||
*ing.Spec.IngressClassName == a.ingressClassName &&
|
||||
ing.Annotations[AnnotationProxyGroup] == ""
|
||||
}
|
||||
|
||||
// validateIngressClass attempts to validate that 'tailscale' IngressClass
|
||||
// included in Tailscale installation manifests exists and has not been modified
|
||||
// to attempt to enable features that we do not support.
|
||||
func validateIngressClass(ctx context.Context, cl client.Client) error {
|
||||
func validateIngressClass(ctx context.Context, cl client.Client, ingressClassName string) error {
|
||||
ic := &networkingv1.IngressClass{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: tailscaleIngressClassName,
|
||||
Name: ingressClassName,
|
||||
},
|
||||
}
|
||||
if err := cl.Get(ctx, client.ObjectKeyFromObject(ic), ic); apierrors.IsNotFound(err) {
|
||||
|
@@ -36,7 +36,8 @@ func TestTailscaleIngress(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ingR := &IngressReconciler{
|
||||
Client: fc,
|
||||
Client: fc,
|
||||
ingressClassName: "tailscale",
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
@@ -120,7 +121,8 @@ func TestTailscaleIngressHostname(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ingR := &IngressReconciler{
|
||||
Client: fc,
|
||||
Client: fc,
|
||||
ingressClassName: "tailscale",
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
@@ -245,7 +247,8 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ingR := &IngressReconciler{
|
||||
Client: fc,
|
||||
Client: fc,
|
||||
ingressClassName: "tailscale",
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
@@ -350,7 +353,8 @@ func TestTailscaleIngressWithServiceMonitor(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ingR := &IngressReconciler{
|
||||
Client: fc,
|
||||
Client: fc,
|
||||
ingressClassName: "tailscale",
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
@@ -498,7 +502,8 @@ func TestIngressProxyClassAnnotation(t *testing.T) {
|
||||
mustCreate(t, fc, ing)
|
||||
|
||||
ingR := &IngressReconciler{
|
||||
Client: fc,
|
||||
Client: fc,
|
||||
ingressClassName: "tailscale",
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
Client: fc,
|
||||
tsClient: &fakeTSClient{},
|
||||
@@ -568,7 +573,8 @@ func TestIngressLetsEncryptStaging(t *testing.T) {
|
||||
mustCreate(t, fc, ing)
|
||||
|
||||
ingR := &IngressReconciler{
|
||||
Client: fc,
|
||||
Client: fc,
|
||||
ingressClassName: "tailscale",
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
Client: fc,
|
||||
tsClient: &fakeTSClient{},
|
||||
@@ -675,8 +681,9 @@ func TestEmptyPath(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ingR := &IngressReconciler{
|
||||
recorder: fr,
|
||||
Client: fc,
|
||||
recorder: fr,
|
||||
Client: fc,
|
||||
ingressClassName: "tailscale",
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
|
@@ -83,6 +83,7 @@ func main() {
|
||||
defaultProxyClass = defaultEnv("PROXY_DEFAULT_CLASS", "")
|
||||
isDefaultLoadBalancer = defaultBool("OPERATOR_DEFAULT_LOAD_BALANCER", false)
|
||||
loginServer = strings.TrimSuffix(defaultEnv("OPERATOR_LOGIN_SERVER", ""), "/")
|
||||
ingressClassName = defaultEnv("OPERATOR_INGRESS_CLASS_NAME", "tailscale")
|
||||
)
|
||||
|
||||
var opts []kzap.Opts
|
||||
@@ -133,6 +134,7 @@ func main() {
|
||||
proxyFirewallMode: tsFirewallMode,
|
||||
defaultProxyClass: defaultProxyClass,
|
||||
loginServer: loginServer,
|
||||
ingressClassName: ingressClassName,
|
||||
}
|
||||
runReconcilers(rOpts)
|
||||
}
|
||||
@@ -343,7 +345,7 @@ func runReconcilers(opts reconcilerOpts) {
|
||||
// ProxyClass's name.
|
||||
proxyClassFilterForIngress := handler.EnqueueRequestsFromMapFunc(proxyClassHandlerForIngress(mgr.GetClient(), startlog))
|
||||
// Enque Ingress if a managed Service or backend Service associated with a tailscale Ingress changes.
|
||||
svcHandlerForIngress := handler.EnqueueRequestsFromMapFunc(serviceHandlerForIngress(mgr.GetClient(), startlog))
|
||||
svcHandlerForIngress := handler.EnqueueRequestsFromMapFunc(serviceHandlerForIngress(mgr.GetClient(), startlog, opts.ingressClassName))
|
||||
err = builder.
|
||||
ControllerManagedBy(mgr).
|
||||
For(&networkingv1.Ingress{}).
|
||||
@@ -358,6 +360,7 @@ func runReconcilers(opts reconcilerOpts) {
|
||||
Client: mgr.GetClient(),
|
||||
logger: opts.log.Named("ingress-reconciler"),
|
||||
defaultProxyClass: opts.defaultProxyClass,
|
||||
ingressClassName: opts.ingressClassName,
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create ingress reconciler: %v", err)
|
||||
@@ -379,19 +382,20 @@ func runReconcilers(opts reconcilerOpts) {
|
||||
ControllerManagedBy(mgr).
|
||||
For(&networkingv1.Ingress{}).
|
||||
Named("ingress-pg-reconciler").
|
||||
Watches(&corev1.Service{}, handler.EnqueueRequestsFromMapFunc(serviceHandlerForIngressPG(mgr.GetClient(), startlog))).
|
||||
Watches(&corev1.Service{}, handler.EnqueueRequestsFromMapFunc(serviceHandlerForIngressPG(mgr.GetClient(), startlog, opts.ingressClassName))).
|
||||
Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(HAIngressesFromSecret(mgr.GetClient(), startlog))).
|
||||
Watches(&tsapi.ProxyGroup{}, ingressProxyGroupFilter).
|
||||
Complete(&HAIngressReconciler{
|
||||
recorder: eventRecorder,
|
||||
tsClient: opts.tsClient,
|
||||
tsnetServer: opts.tsServer,
|
||||
defaultTags: strings.Split(opts.proxyTags, ","),
|
||||
Client: mgr.GetClient(),
|
||||
logger: opts.log.Named("ingress-pg-reconciler"),
|
||||
lc: lc,
|
||||
operatorID: id,
|
||||
tsNamespace: opts.tailscaleNamespace,
|
||||
recorder: eventRecorder,
|
||||
tsClient: opts.tsClient,
|
||||
tsnetServer: opts.tsServer,
|
||||
defaultTags: strings.Split(opts.proxyTags, ","),
|
||||
Client: mgr.GetClient(),
|
||||
logger: opts.log.Named("ingress-pg-reconciler"),
|
||||
lc: lc,
|
||||
operatorID: id,
|
||||
tsNamespace: opts.tailscaleNamespace,
|
||||
ingressClassName: opts.ingressClassName,
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create ingress-pg-reconciler: %v", err)
|
||||
@@ -697,6 +701,9 @@ type reconcilerOpts struct {
|
||||
defaultProxyClass string
|
||||
// loginServer is the coordination server URL that should be used by managed resources.
|
||||
loginServer string
|
||||
// ingressClassName is the name of the ingress class used by reconcilers of Ingress resources. This defaults
|
||||
// to "tailscale" but can be customised.
|
||||
ingressClassName string
|
||||
}
|
||||
|
||||
// enqueueAllIngressEgressProxySvcsinNS returns a reconcile request for each
|
||||
@@ -1015,7 +1022,7 @@ func proxyClassHandlerForProxyGroup(cl client.Client, logger *zap.SugaredLogger)
|
||||
// The Services of interest are backend Services for tailscale Ingress and
|
||||
// managed Services for an StatefulSet for a proxy configured for tailscale
|
||||
// Ingress
|
||||
func serviceHandlerForIngress(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc {
|
||||
func serviceHandlerForIngress(cl client.Client, logger *zap.SugaredLogger, ingressClassName string) handler.MapFunc {
|
||||
return func(ctx context.Context, o client.Object) []reconcile.Request {
|
||||
if isManagedByType(o, "ingress") {
|
||||
ingName := parentFromObjectLabels(o)
|
||||
@@ -1028,7 +1035,7 @@ func serviceHandlerForIngress(cl client.Client, logger *zap.SugaredLogger) handl
|
||||
}
|
||||
reqs := make([]reconcile.Request, 0)
|
||||
for _, ing := range ingList.Items {
|
||||
if ing.Spec.IngressClassName == nil || *ing.Spec.IngressClassName != tailscaleIngressClassName {
|
||||
if ing.Spec.IngressClassName == nil || *ing.Spec.IngressClassName != ingressClassName {
|
||||
return nil
|
||||
}
|
||||
if hasProxyGroupAnnotation(&ing) {
|
||||
@@ -1533,7 +1540,7 @@ func indexPGIngresses(o client.Object) []string {
|
||||
// serviceHandlerForIngressPG returns a handler for Service events that ensures that if the Service
|
||||
// associated with an event is a backend Service for a tailscale Ingress with ProxyGroup annotation,
|
||||
// the associated Ingress gets reconciled.
|
||||
func serviceHandlerForIngressPG(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc {
|
||||
func serviceHandlerForIngressPG(cl client.Client, logger *zap.SugaredLogger, ingressClassName string) handler.MapFunc {
|
||||
return func(ctx context.Context, o client.Object) []reconcile.Request {
|
||||
ingList := networkingv1.IngressList{}
|
||||
if err := cl.List(ctx, &ingList, client.InNamespace(o.GetNamespace())); err != nil {
|
||||
@@ -1542,7 +1549,7 @@ func serviceHandlerForIngressPG(cl client.Client, logger *zap.SugaredLogger) han
|
||||
}
|
||||
reqs := make([]reconcile.Request, 0)
|
||||
for _, ing := range ingList.Items {
|
||||
if ing.Spec.IngressClassName == nil || *ing.Spec.IngressClassName != tailscaleIngressClassName {
|
||||
if ing.Spec.IngressClassName == nil || *ing.Spec.IngressClassName != ingressClassName {
|
||||
continue
|
||||
}
|
||||
if !hasProxyGroupAnnotation(&ing) {
|
||||
|
@@ -1549,6 +1549,8 @@ func Test_isMagicDNSName(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_serviceHandlerForIngress(t *testing.T) {
|
||||
const tailscaleIngressClassName = "tailscale"
|
||||
|
||||
fc := fake.NewFakeClient()
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
@@ -1578,7 +1580,7 @@ func Test_serviceHandlerForIngress(t *testing.T) {
|
||||
}
|
||||
mustCreate(t, fc, svc1)
|
||||
wantReqs := []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: "ns-1", Name: "ing-1"}}}
|
||||
gotReqs := serviceHandlerForIngress(fc, zl.Sugar())(context.Background(), svc1)
|
||||
gotReqs := serviceHandlerForIngress(fc, zl.Sugar(), tailscaleIngressClassName)(context.Background(), svc1)
|
||||
if diff := cmp.Diff(gotReqs, wantReqs); diff != "" {
|
||||
t.Fatalf("unexpected reconcile requests (-got +want):\n%s", diff)
|
||||
}
|
||||
@@ -1605,7 +1607,7 @@ func Test_serviceHandlerForIngress(t *testing.T) {
|
||||
}
|
||||
mustCreate(t, fc, backendSvc)
|
||||
wantReqs = []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: "ns-2", Name: "ing-2"}}}
|
||||
gotReqs = serviceHandlerForIngress(fc, zl.Sugar())(context.Background(), backendSvc)
|
||||
gotReqs = serviceHandlerForIngress(fc, zl.Sugar(), tailscaleIngressClassName)(context.Background(), backendSvc)
|
||||
if diff := cmp.Diff(gotReqs, wantReqs); diff != "" {
|
||||
t.Fatalf("unexpected reconcile requests (-got +want):\n%s", diff)
|
||||
}
|
||||
@@ -1634,7 +1636,7 @@ func Test_serviceHandlerForIngress(t *testing.T) {
|
||||
}
|
||||
mustCreate(t, fc, backendSvc2)
|
||||
wantReqs = []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: "ns-3", Name: "ing-3"}}}
|
||||
gotReqs = serviceHandlerForIngress(fc, zl.Sugar())(context.Background(), backendSvc2)
|
||||
gotReqs = serviceHandlerForIngress(fc, zl.Sugar(), tailscaleIngressClassName)(context.Background(), backendSvc2)
|
||||
if diff := cmp.Diff(gotReqs, wantReqs); diff != "" {
|
||||
t.Fatalf("unexpected reconcile requests (-got +want):\n%s", diff)
|
||||
}
|
||||
@@ -1661,7 +1663,7 @@ func Test_serviceHandlerForIngress(t *testing.T) {
|
||||
},
|
||||
}
|
||||
mustCreate(t, fc, nonTSBackend)
|
||||
gotReqs = serviceHandlerForIngress(fc, zl.Sugar())(context.Background(), nonTSBackend)
|
||||
gotReqs = serviceHandlerForIngress(fc, zl.Sugar(), tailscaleIngressClassName)(context.Background(), nonTSBackend)
|
||||
if len(gotReqs) > 0 {
|
||||
t.Errorf("unexpected reconcile request for a Service that does not belong to a Tailscale Ingress: %#+v\n", gotReqs)
|
||||
}
|
||||
@@ -1675,7 +1677,7 @@ func Test_serviceHandlerForIngress(t *testing.T) {
|
||||
},
|
||||
}
|
||||
mustCreate(t, fc, someSvc)
|
||||
gotReqs = serviceHandlerForIngress(fc, zl.Sugar())(context.Background(), someSvc)
|
||||
gotReqs = serviceHandlerForIngress(fc, zl.Sugar(), tailscaleIngressClassName)(context.Background(), someSvc)
|
||||
if len(gotReqs) > 0 {
|
||||
t.Errorf("unexpected reconcile request for a Service that does not belong to any Ingress: %#+v\n", gotReqs)
|
||||
}
|
||||
|
Reference in New Issue
Block a user