mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-11-03 03:58:01 +00:00
Storing local and global cluster name/id to storage and returning them in health status
This commit is contained in:
@@ -187,6 +187,10 @@ func (c *ServerCommand) Run(args []string) int {
|
|||||||
DisableMlock: config.DisableMlock,
|
DisableMlock: config.DisableMlock,
|
||||||
MaxLeaseTTL: config.MaxLeaseTTL,
|
MaxLeaseTTL: config.MaxLeaseTTL,
|
||||||
DefaultLeaseTTL: config.DefaultLeaseTTL,
|
DefaultLeaseTTL: config.DefaultLeaseTTL,
|
||||||
|
LocalClusterName: config.LocalClusterName,
|
||||||
|
LocalClusterID: config.LocalClusterID,
|
||||||
|
GlobalClusterName: config.GlobalClusterName,
|
||||||
|
GlobalClusterID: config.GlobalClusterID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the separate HA physical backend, if it exists
|
// Initialize the separate HA physical backend, if it exists
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ type Config struct {
|
|||||||
MaxLeaseTTLRaw string `hcl:"max_lease_ttl"`
|
MaxLeaseTTLRaw string `hcl:"max_lease_ttl"`
|
||||||
DefaultLeaseTTL time.Duration `hcl:"-"`
|
DefaultLeaseTTL time.Duration `hcl:"-"`
|
||||||
DefaultLeaseTTLRaw string `hcl:"default_lease_ttl"`
|
DefaultLeaseTTLRaw string `hcl:"default_lease_ttl"`
|
||||||
|
|
||||||
|
LocalClusterName string `hcl:"local_cluster_name"`
|
||||||
|
LocalClusterID string `hcl:"local_cluster_id"`
|
||||||
|
|
||||||
|
GlobalClusterName string `hcl:"global_cluster_name"`
|
||||||
|
GlobalClusterID string `hcl:"global_cluster_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DevConfig is a Config that is used for dev mode of Vault.
|
// DevConfig is a Config that is used for dev mode of Vault.
|
||||||
@@ -277,6 +283,10 @@ func ParseConfig(d string) (*Config, error) {
|
|||||||
"telemetry",
|
"telemetry",
|
||||||
"default_lease_ttl",
|
"default_lease_ttl",
|
||||||
"max_lease_ttl",
|
"max_lease_ttl",
|
||||||
|
"local_cluster_name",
|
||||||
|
"local_cluster_id",
|
||||||
|
"global_cluster_name",
|
||||||
|
"global_cluster_id",
|
||||||
|
|
||||||
// TODO: Remove in 0.6.0
|
// TODO: Remove in 0.6.0
|
||||||
// Deprecated keys
|
// Deprecated keys
|
||||||
|
|||||||
@@ -114,6 +114,32 @@ func getSysHealth(core *vault.Core, r *http.Request) (int, *HealthResponse, erro
|
|||||||
code = standbyCode
|
code = standbyCode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch the local cluster name and identifier
|
||||||
|
var localClusterName, localClusterID string
|
||||||
|
localCluster, err := core.Cluster(true)
|
||||||
|
|
||||||
|
// Don't set the cluster details in the health status when Vault is sealed
|
||||||
|
if err != nil && err.Error() != "Vault is sealed" {
|
||||||
|
return http.StatusInternalServerError, nil, err
|
||||||
|
}
|
||||||
|
if localCluster != nil {
|
||||||
|
localClusterName = localCluster.Name
|
||||||
|
localClusterID = localCluster.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the global cluster name and identifier
|
||||||
|
var globalClusterName, globalClusterID string
|
||||||
|
globalCluster, err := core.Cluster(false)
|
||||||
|
|
||||||
|
// Don't set the cluster details in the health status when Vault is sealed
|
||||||
|
if err != nil && err.Error() != "Vault is sealed" {
|
||||||
|
return http.StatusInternalServerError, nil, err
|
||||||
|
}
|
||||||
|
if globalCluster != nil {
|
||||||
|
globalClusterName = globalCluster.Name
|
||||||
|
globalClusterID = globalCluster.ID
|
||||||
|
}
|
||||||
|
|
||||||
// Format the body
|
// Format the body
|
||||||
body := &HealthResponse{
|
body := &HealthResponse{
|
||||||
Initialized: init,
|
Initialized: init,
|
||||||
@@ -121,6 +147,10 @@ func getSysHealth(core *vault.Core, r *http.Request) (int, *HealthResponse, erro
|
|||||||
Standby: standby,
|
Standby: standby,
|
||||||
ServerTimeUTC: time.Now().UTC().Unix(),
|
ServerTimeUTC: time.Now().UTC().Unix(),
|
||||||
Version: version.GetVersion().String(),
|
Version: version.GetVersion().String(),
|
||||||
|
LocalClusterName: localClusterName,
|
||||||
|
LocalClusterID: localClusterID,
|
||||||
|
GlobalClusterName: globalClusterName,
|
||||||
|
GlobalClusterID: globalClusterID,
|
||||||
}
|
}
|
||||||
return code, body, nil
|
return code, body, nil
|
||||||
}
|
}
|
||||||
@@ -131,4 +161,8 @@ type HealthResponse struct {
|
|||||||
Standby bool `json:"standby"`
|
Standby bool `json:"standby"`
|
||||||
ServerTimeUTC int64 `json:"server_time_utc"`
|
ServerTimeUTC int64 `json:"server_time_utc"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
|
LocalClusterName string `json:"local_cluster_name"`
|
||||||
|
LocalClusterID string `json:"local_cluster_id"`
|
||||||
|
GlobalClusterName string `json:"global_cluster_name"`
|
||||||
|
GlobalClusterID string `json:"global_cluster_id"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ func TestSysHealth_get(t *testing.T) {
|
|||||||
testResponseBody(t, resp, &actual)
|
testResponseBody(t, resp, &actual)
|
||||||
expected["server_time_utc"] = actual["server_time_utc"]
|
expected["server_time_utc"] = actual["server_time_utc"]
|
||||||
expected["version"] = actual["version"]
|
expected["version"] = actual["version"]
|
||||||
|
expected["local_cluster_name"] = actual["local_cluster_name"]
|
||||||
|
expected["local_cluster_id"] = actual["local_cluster_id"]
|
||||||
|
expected["global_cluster_name"] = actual["global_cluster_name"]
|
||||||
|
expected["global_cluster_id"] = actual["global_cluster_id"]
|
||||||
if !reflect.DeepEqual(actual, expected) {
|
if !reflect.DeepEqual(actual, expected) {
|
||||||
t.Fatalf("bad: expected:%#v\nactual:%#v", expected, actual)
|
t.Fatalf("bad: expected:%#v\nactual:%#v", expected, actual)
|
||||||
}
|
}
|
||||||
@@ -52,6 +56,10 @@ func TestSysHealth_get(t *testing.T) {
|
|||||||
testResponseBody(t, resp, &actual)
|
testResponseBody(t, resp, &actual)
|
||||||
expected["server_time_utc"] = actual["server_time_utc"]
|
expected["server_time_utc"] = actual["server_time_utc"]
|
||||||
expected["version"] = actual["version"]
|
expected["version"] = actual["version"]
|
||||||
|
expected["local_cluster_name"] = actual["local_cluster_name"]
|
||||||
|
expected["local_cluster_id"] = actual["local_cluster_id"]
|
||||||
|
expected["global_cluster_name"] = actual["global_cluster_name"]
|
||||||
|
expected["global_cluster_id"] = actual["global_cluster_id"]
|
||||||
if !reflect.DeepEqual(actual, expected) {
|
if !reflect.DeepEqual(actual, expected) {
|
||||||
t.Fatalf("bad: expected:%#v\nactual:%#v", expected, actual)
|
t.Fatalf("bad: expected:%#v\nactual:%#v", expected, actual)
|
||||||
}
|
}
|
||||||
@@ -82,6 +90,10 @@ func TestSysHealth_customcodes(t *testing.T) {
|
|||||||
|
|
||||||
expected["server_time_utc"] = actual["server_time_utc"]
|
expected["server_time_utc"] = actual["server_time_utc"]
|
||||||
expected["version"] = actual["version"]
|
expected["version"] = actual["version"]
|
||||||
|
expected["local_cluster_name"] = actual["local_cluster_name"]
|
||||||
|
expected["local_cluster_id"] = actual["local_cluster_id"]
|
||||||
|
expected["global_cluster_name"] = actual["global_cluster_name"]
|
||||||
|
expected["global_cluster_id"] = actual["global_cluster_id"]
|
||||||
if !reflect.DeepEqual(actual, expected) {
|
if !reflect.DeepEqual(actual, expected) {
|
||||||
t.Fatalf("bad: expected:%#v\nactual:%#v", expected, actual)
|
t.Fatalf("bad: expected:%#v\nactual:%#v", expected, actual)
|
||||||
}
|
}
|
||||||
@@ -107,6 +119,10 @@ func TestSysHealth_customcodes(t *testing.T) {
|
|||||||
testResponseBody(t, resp, &actual)
|
testResponseBody(t, resp, &actual)
|
||||||
expected["server_time_utc"] = actual["server_time_utc"]
|
expected["server_time_utc"] = actual["server_time_utc"]
|
||||||
expected["version"] = actual["version"]
|
expected["version"] = actual["version"]
|
||||||
|
expected["local_cluster_name"] = actual["local_cluster_name"]
|
||||||
|
expected["local_cluster_id"] = actual["local_cluster_id"]
|
||||||
|
expected["global_cluster_name"] = actual["global_cluster_name"]
|
||||||
|
expected["global_cluster_id"] = actual["global_cluster_id"]
|
||||||
if !reflect.DeepEqual(actual, expected) {
|
if !reflect.DeepEqual(actual, expected) {
|
||||||
t.Fatalf("bad: expected:%#v\nactual:%#v", expected, actual)
|
t.Fatalf("bad: expected:%#v\nactual:%#v", expected, actual)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package http
|
|||||||
import (
|
import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -173,7 +172,6 @@ func TestSysUnseal_Reset(t *testing.T) {
|
|||||||
if !reflect.DeepEqual(actual, expected) {
|
if !reflect.DeepEqual(actual, expected) {
|
||||||
t.Fatalf("\nexpected:\n%#v\nactual:\n%#v\n", expected, actual)
|
t.Fatalf("\nexpected:\n%#v\nactual:\n%#v\n", expected, actual)
|
||||||
}
|
}
|
||||||
log.Printf("reached here\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = testHttpPut(t, "", addr+"/v1/sys/unseal", map[string]interface{}{
|
resp = testHttpPut(t, "", addr+"/v1/sys/unseal", map[string]interface{}{
|
||||||
|
|||||||
135
vault/cluster.go
Normal file
135
vault/cluster.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/hashicorp/go-uuid"
|
||||||
|
"github.com/hashicorp/vault/helper/jsonutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Storage path where the local cluster name and identifier are stored
|
||||||
|
coreClusterLocalPath = "core/cluster/local"
|
||||||
|
|
||||||
|
// Storage path where the global cluster name and identifier are stored
|
||||||
|
coreClusterGlobalPath = "core/cluster/global"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Structure representing the storage entry that holds cluster information
|
||||||
|
type Cluster struct {
|
||||||
|
// Name of the cluster
|
||||||
|
Name string `json:"name" structs:"name" mapstructure:"name"`
|
||||||
|
|
||||||
|
// Identifier of the cluster
|
||||||
|
ID string `json:"id" structs:"id" mapstructure:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cluster fetches the details of either local or global cluster based on the
|
||||||
|
// input. This method errors out when Vault is sealed.
|
||||||
|
func (c *Core) Cluster(isLocal bool) (*Cluster, error) {
|
||||||
|
var key string
|
||||||
|
if isLocal {
|
||||||
|
key = coreClusterLocalPath
|
||||||
|
} else {
|
||||||
|
key = coreClusterGlobalPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the storage entry. This call fails when Vault is sealed.
|
||||||
|
entry, err := c.barrier.Get(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if entry == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the cluster information
|
||||||
|
var cluster Cluster
|
||||||
|
if err = jsonutil.DecodeJSON(entry.Value, &cluster); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode cluster details: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cluster, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupCluster creates storage entries for holding Vault cluster information.
|
||||||
|
// Entries will be created only if they are not already present.
|
||||||
|
func (c *Core) setupCluster() error {
|
||||||
|
// Create or store a local name and local cluster ID, if not already stored
|
||||||
|
if err := c.setCluster(true, c.localClusterName, c.localClusterID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or store a global name and global cluster ID, if not already stored
|
||||||
|
if err := c.setCluster(false, c.globalClusterName, c.globalClusterID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCluster creates a local or global storage index for a set of cluster
|
||||||
|
// information. If cluster name or cluster ID is not supplied, this method will
|
||||||
|
// auto-generate them respectively.
|
||||||
|
func (c *Core) setCluster(isLocal bool, clusterName, clusterID string) error {
|
||||||
|
// Check if storage index is already present or not
|
||||||
|
cluster, err := c.Cluster(isLocal)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Printf("[ERR] core: failed to get cluster details: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cluster != nil {
|
||||||
|
// If index is already present, don't update it
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If clusterName is not supplied, generate one
|
||||||
|
if clusterName == "" {
|
||||||
|
clusterNameBytes, err := uuid.GenerateRandomBytes(4)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Printf("[ERR] core: failed to generate cluster name: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
prefix := "vault-"
|
||||||
|
if isLocal {
|
||||||
|
prefix += "local-"
|
||||||
|
} else {
|
||||||
|
prefix += "global-"
|
||||||
|
}
|
||||||
|
clusterName = fmt.Sprintf("%s%08x", prefix, clusterNameBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If clusterName is not supplied, generate one
|
||||||
|
if clusterID == "" {
|
||||||
|
var err error
|
||||||
|
clusterID, err = uuid.GenerateUUID()
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Printf("[ERR] core: failed to generate cluster identifier: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode the cluster information into as a JSON string
|
||||||
|
rawCluster, err := json.Marshal(&Cluster{
|
||||||
|
Name: clusterName,
|
||||||
|
ID: clusterID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Printf("[ERR] core: failed to encode cluster details: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the storage path
|
||||||
|
var key string
|
||||||
|
if isLocal {
|
||||||
|
key = coreClusterLocalPath
|
||||||
|
} else {
|
||||||
|
key = coreClusterGlobalPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store it
|
||||||
|
return c.barrier.Put(&Entry{
|
||||||
|
Key: key,
|
||||||
|
Value: rawCluster,
|
||||||
|
})
|
||||||
|
}
|
||||||
22
vault/cluster_test.go
Normal file
22
vault/cluster_test.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package vault
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCluster(t *testing.T) {
|
||||||
|
c, _, _ := TestCoreUnsealed(t)
|
||||||
|
cluster, err := c.Cluster(true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cluster == nil || cluster.Name == "" || cluster.ID == "" {
|
||||||
|
t.Fatalf("local cluster information missing: cluster:%#v", cluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
cluster, err = c.Cluster(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cluster == nil || cluster.Name == "" || cluster.ID == "" {
|
||||||
|
t.Fatalf("global cluster information missing: cluster:%#v", cluster)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -218,23 +218,54 @@ type Core struct {
|
|||||||
|
|
||||||
// cachingDisabled indicates whether caches are disabled
|
// cachingDisabled indicates whether caches are disabled
|
||||||
cachingDisabled bool
|
cachingDisabled bool
|
||||||
|
|
||||||
|
localClusterName string
|
||||||
|
localClusterID string
|
||||||
|
|
||||||
|
globalClusterName string
|
||||||
|
globalClusterID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// CoreConfig is used to parameterize a core
|
// CoreConfig is used to parameterize a core
|
||||||
type CoreConfig struct {
|
type CoreConfig struct {
|
||||||
LogicalBackends map[string]logical.Factory
|
LogicalBackends map[string]logical.Factory `json:"logical_backends" structs:"logical_backends" mapstructure:"logical_backends"`
|
||||||
CredentialBackends map[string]logical.Factory
|
|
||||||
AuditBackends map[string]audit.Factory
|
CredentialBackends map[string]logical.Factory `json:"credential_backends" structs:"credential_backends" mapstructure:"credential_backends"`
|
||||||
Physical physical.Backend
|
|
||||||
HAPhysical physical.HABackend // May be nil, which disables HA operations
|
AuditBackends map[string]audit.Factory `json:"audit_backends" structs:"audit_backends" mapstructure:"audit_backends"`
|
||||||
Seal Seal
|
|
||||||
Logger *log.Logger
|
Physical physical.Backend `json:"physical" structs:"physical" mapstructure:"physical"`
|
||||||
DisableCache bool // Disables the LRU cache on the physical backend
|
|
||||||
DisableMlock bool // Disables mlock syscall
|
// May be nil, which disables HA operations
|
||||||
CacheSize int // Custom cache size of zero for default
|
HAPhysical physical.HABackend `json:"ha_physical" structs:"ha_physical" mapstructure:"ha_physical"`
|
||||||
AdvertiseAddr string // Set as the leader address for HA
|
|
||||||
DefaultLeaseTTL time.Duration
|
Seal Seal `json:"seal" structs:"seal" mapstructure:"seal"`
|
||||||
MaxLeaseTTL time.Duration
|
|
||||||
|
Logger *log.Logger `json:"logger" structs:"logger" mapstructure:"logger"`
|
||||||
|
|
||||||
|
// Disables the LRU cache on the physical backend
|
||||||
|
DisableCache bool `json:"disable_cache" structs:"disable_cache" mapstructure:"disable_cache"`
|
||||||
|
|
||||||
|
// Disables mlock syscall
|
||||||
|
DisableMlock bool `json:"disable_mlock" structs:"disable_mlock" mapstructure:"disable_mlock"`
|
||||||
|
|
||||||
|
// Custom cache size of zero for default
|
||||||
|
CacheSize int `json:"cache_size" structs:"cache_size" mapstructure:"cache_size"`
|
||||||
|
|
||||||
|
// Set as the leader address for HA
|
||||||
|
AdvertiseAddr string `json:"advertise_addr" structs:"advertise_addr" mapstructure:"advertise_addr"`
|
||||||
|
|
||||||
|
DefaultLeaseTTL time.Duration `json:"default_lease_ttl" structs:"default_lease_ttl" mapstructure:"default_lease_ttl"`
|
||||||
|
|
||||||
|
MaxLeaseTTL time.Duration `json:"max_lease_ttl" structs:"max_lease_ttl" mapstructure:"max_lease_ttl"`
|
||||||
|
|
||||||
|
LocalClusterName string `json:"local_cluster_name" structs:"local_cluster_name" mapstructure:"local_cluster_name"`
|
||||||
|
|
||||||
|
LocalClusterID string `json:"local_cluster_id" structs:"local_cluster_id" mapstructure:"local_cluster_id"`
|
||||||
|
|
||||||
|
GlobalClusterName string `json:"global_cluster_name" structs:"global_cluster_name" mapstructure:"global_cluster_name"`
|
||||||
|
|
||||||
|
GlobalClusterID string `json:"global_cluster_id" structs:"global_cluster_id" mapstructure:"global_cluster_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCore is used to construct a new core
|
// NewCore is used to construct a new core
|
||||||
@@ -315,6 +346,10 @@ func NewCore(conf *CoreConfig) (*Core, error) {
|
|||||||
defaultLeaseTTL: conf.DefaultLeaseTTL,
|
defaultLeaseTTL: conf.DefaultLeaseTTL,
|
||||||
maxLeaseTTL: conf.MaxLeaseTTL,
|
maxLeaseTTL: conf.MaxLeaseTTL,
|
||||||
cachingDisabled: conf.DisableCache,
|
cachingDisabled: conf.DisableCache,
|
||||||
|
localClusterName: conf.LocalClusterName,
|
||||||
|
localClusterID: conf.LocalClusterID,
|
||||||
|
globalClusterName: conf.GlobalClusterName,
|
||||||
|
globalClusterID: conf.GlobalClusterID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if conf.HAPhysical != nil && conf.HAPhysical.HAEnabled() {
|
if conf.HAPhysical != nil && conf.HAPhysical.HAEnabled() {
|
||||||
@@ -970,6 +1005,9 @@ func (c *Core) postUnseal() (retErr error) {
|
|||||||
if err := c.setupAudits(); err != nil {
|
if err := c.setupAudits(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := c.setupCluster(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
c.metricsCh = make(chan struct{})
|
c.metricsCh = make(chan struct{})
|
||||||
go c.emitMetrics(c.metricsCh)
|
go c.emitMetrics(c.metricsCh)
|
||||||
c.logger.Printf("[INFO] core: post-unseal setup complete")
|
c.logger.Printf("[INFO] core: post-unseal setup complete")
|
||||||
|
|||||||
Reference in New Issue
Block a user