recovery(metis): simplify node password schema
This commit is contained in:
parent
fe1ee5ac99
commit
d9ab9fb98c
@ -42,7 +42,7 @@ Metis produces fully configured recovery SD cards for any node in the lab (RPi 4
|
||||
- `metis-sentinel` stays slim for the DaemonSet that samples node facts
|
||||
- Class overlays: define `boot_overlay`/`root_overlay` on a class to merge static files into boot/root at burn time (e.g., cloud-init/netplan drop-ins, GPU driver configs). Per-node config still injects hostname/IP/k3s/SSH/Longhorn.
|
||||
- Linux loop-mount helper (losetup/mount) exists for automation; wiring into CLI burn is next. Windows writer/GUI stub forthcoming.
|
||||
- Vault: Metis can read per-node secrets from `kv/data/atlas/nodes/<hostname>` using VAULT_ADDR plus either VAULT_TOKEN or AppRole (VAULT_ROLE_ID/VAULT_SECRET_ID). Expected fields: ssh_password, k3s_token, cloud_init, extra map.
|
||||
- Vault: Metis can read per-node secrets from `kv/data/atlas/nodes/<hostname>` using VAULT_ADDR plus either VAULT_TOKEN or AppRole (VAULT_ROLE_ID/VAULT_SECRET_ID). Expected fields: atlas_password, root_password, k3s_token, cloud_init, extra map.
|
||||
- Sentinel: `metis-sentinel` collects host facts and can either print them, write local history, or push them into the Metis service. The intended deployment shape is a DaemonSet on cluster nodes plus an Ariadne-triggered Metis watch that recomputes recommended class targets and drift history.
|
||||
- Facts aggregation: `metis facts --inventory inv.yaml --snapshots ./snapshots` reads sentinel snapshot JSON files and prints per-class drift summary (kernels, containerd, k3s, package samples). Use exported ConfigMaps or `METIS_SENTINEL_OUT` history as input.
|
||||
- `metis config --inventory inv.yaml --node titan-13` prints the merged node config (hostname/IP/k3s labels/taints/Longhorn UUIDs and optional USB scratch metadata).
|
||||
|
||||
@ -131,7 +131,7 @@ func TestSecretsWrite(t *testing.T) {
|
||||
Hostname: "n1",
|
||||
IP: "10.0.0.1",
|
||||
}
|
||||
sec := &secrets.NodeSecrets{K3sToken: "tok", SSHPassword: "pw", Extra: map[string]string{"foo": "bar"}}
|
||||
sec := &secrets.NodeSecrets{K3sToken: "tok", AtlasPassword: "pw", Extra: map[string]string{"foo": "bar"}}
|
||||
files, err := buildFiles(cfg, sec)
|
||||
if err != nil {
|
||||
t.Fatalf("buildFiles: %v", err)
|
||||
@ -187,10 +187,10 @@ func TestBuildFilesAddsPasswordArtifactsAndRedactsSecrets(t *testing.T) {
|
||||
},
|
||||
}
|
||||
sec := &secrets.NodeSecrets{
|
||||
SSHPassword: "atlas-pass",
|
||||
RootPassword: "root-pass",
|
||||
K3sToken: "super-secret-token",
|
||||
Extra: map[string]string{"api_key": "secret"},
|
||||
AtlasPassword: "atlas-pass",
|
||||
RootPassword: "root-pass",
|
||||
K3sToken: "super-secret-token",
|
||||
Extra: map[string]string{"api_key": "secret"},
|
||||
}
|
||||
files, err := buildFiles(cfg, sec)
|
||||
if err != nil {
|
||||
@ -220,7 +220,7 @@ func TestBuildFilesAddsPasswordArtifactsAndRedactsSecrets(t *testing.T) {
|
||||
if strings.Contains(secretsJSON, "atlas-pass") || strings.Contains(secretsJSON, "root-pass") || strings.Contains(secretsJSON, "super-secret-token") {
|
||||
t.Fatalf("secrets.json should be redacted: %s", secretsJSON)
|
||||
}
|
||||
if !strings.Contains(secretsJSON, `"has_ssh_password": true`) || !strings.Contains(secretsJSON, `"extra_keys": [`) {
|
||||
if !strings.Contains(secretsJSON, `"has_atlas_password": true`) || !strings.Contains(secretsJSON, `"extra_keys": [`) {
|
||||
t.Fatalf("secrets.json should keep redacted debug metadata: %s", secretsJSON)
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,15 +45,9 @@ func firstbootEnvContent(cfg *config.NodeConfig, sec *secrets.NodeSecrets) strin
|
||||
if value := effectiveAtlasPassword(sec); value != "" {
|
||||
b.WriteString(fmt.Sprintf("METIS_ATLAS_PASSWORD=%s\n", shellQuote(value)))
|
||||
}
|
||||
if value := effectiveAtlasPasswordHash(sec); value != "" {
|
||||
b.WriteString(fmt.Sprintf("METIS_ATLAS_PASSWORD_HASH=%s\n", shellQuote(value)))
|
||||
}
|
||||
if value := strings.TrimSpace(sec.RootPassword); value != "" {
|
||||
b.WriteString(fmt.Sprintf("METIS_ROOT_PASSWORD=%s\n", shellQuote(value)))
|
||||
}
|
||||
if value := strings.TrimSpace(sec.RootPasswordHash); value != "" {
|
||||
b.WriteString(fmt.Sprintf("METIS_ROOT_PASSWORD_HASH=%s\n", shellQuote(value)))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@ -93,9 +87,7 @@ fi
|
||||
atlas_user="${METIS_ATLAS_USER:-atlas}"
|
||||
ssh_user="${METIS_SSH_USER:-${atlas_user}}"
|
||||
atlas_password="${METIS_ATLAS_PASSWORD:-}"
|
||||
atlas_password_hash="${METIS_ATLAS_PASSWORD_HASH:-}"
|
||||
root_password="${METIS_ROOT_PASSWORD:-}"
|
||||
root_password_hash="${METIS_ROOT_PASSWORD_HASH:-}"
|
||||
|
||||
group_list=()
|
||||
for group_name in "${default_groups[@]}"; do
|
||||
@ -126,15 +118,9 @@ ensure_user() {
|
||||
apply_password() {
|
||||
local user_name="$1"
|
||||
local plain_password="$2"
|
||||
local hash_password="$3"
|
||||
if ! id "${user_name}" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if [ -n "${hash_password}" ]; then
|
||||
usermod -p "${hash_password}" "${user_name}"
|
||||
passwd -u "${user_name}" >/dev/null 2>&1 || true
|
||||
return 0
|
||||
fi
|
||||
if [ -n "${plain_password}" ]; then
|
||||
printf '%s:%s\n' "${user_name}" "${plain_password}" | chpasswd
|
||||
passwd -u "${user_name}" >/dev/null 2>&1 || true
|
||||
@ -164,11 +150,8 @@ if [ -n "${ssh_user}" ] && [ "${ssh_user}" != "root" ] && [ "${ssh_user}" != "${
|
||||
ensure_user "${ssh_user}"
|
||||
fi
|
||||
|
||||
apply_password root "${root_password}" "${root_password_hash}"
|
||||
apply_password "${atlas_user}" "${atlas_password}" "${atlas_password_hash}"
|
||||
if [ -n "${ssh_user}" ] && [ "${ssh_user}" != "root" ] && [ "${ssh_user}" != "${atlas_user}" ]; then
|
||||
apply_password "${ssh_user}" "${atlas_password}" "${atlas_password_hash}"
|
||||
fi
|
||||
apply_password root "${root_password}"
|
||||
apply_password "${atlas_user}" "${atlas_password}"
|
||||
|
||||
if [ -s "${key_file}" ]; then
|
||||
install_keys root
|
||||
@ -202,21 +185,14 @@ func hasNodePasswords(sec *secrets.NodeSecrets) bool {
|
||||
if sec == nil {
|
||||
return false
|
||||
}
|
||||
return effectiveAtlasPassword(sec) != "" || effectiveAtlasPasswordHash(sec) != "" || firstNonEmptyString(sec.RootPassword, sec.RootPasswordHash) != ""
|
||||
return effectiveAtlasPassword(sec) != "" || strings.TrimSpace(sec.RootPassword) != ""
|
||||
}
|
||||
|
||||
func effectiveAtlasPassword(sec *secrets.NodeSecrets) string {
|
||||
if sec == nil {
|
||||
return ""
|
||||
}
|
||||
return firstNonEmptyString(sec.AtlasPassword, sec.SSHPassword)
|
||||
}
|
||||
|
||||
func effectiveAtlasPasswordHash(sec *secrets.NodeSecrets) string {
|
||||
if sec == nil {
|
||||
return ""
|
||||
}
|
||||
return firstNonEmptyString(sec.AtlasPasswordHash, sec.SSHPasswordHash)
|
||||
return firstNonEmptyString(sec.AtlasPassword)
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
@ -233,9 +209,8 @@ func redactedSecretsForImage(sec *secrets.NodeSecrets) map[string]any {
|
||||
return nil
|
||||
}
|
||||
debug := map[string]any{
|
||||
"has_ssh_password": firstNonEmptyString(sec.SSHPassword, sec.SSHPasswordHash) != "",
|
||||
"has_atlas_password": firstNonEmptyString(sec.AtlasPassword, sec.AtlasPasswordHash) != "",
|
||||
"has_root_password": firstNonEmptyString(sec.RootPassword, sec.RootPasswordHash) != "",
|
||||
"has_atlas_password": strings.TrimSpace(sec.AtlasPassword) != "",
|
||||
"has_root_password": strings.TrimSpace(sec.RootPassword) != "",
|
||||
"has_k3s_token": strings.TrimSpace(sec.K3sToken) != "",
|
||||
"has_cloud_init_override": strings.TrimSpace(sec.CloudInit) != "",
|
||||
}
|
||||
|
||||
@ -26,14 +26,10 @@ func fetchSecrets(hostname string) *secrets.NodeSecrets {
|
||||
|
||||
func nodeSecretsFromEnv() *secrets.NodeSecrets {
|
||||
sec := &secrets.NodeSecrets{
|
||||
SSHPassword: strings.TrimSpace(os.Getenv("METIS_NODE_SSH_PASSWORD")),
|
||||
SSHPasswordHash: strings.TrimSpace(os.Getenv("METIS_NODE_SSH_PASSWORD_HASH")),
|
||||
AtlasPassword: strings.TrimSpace(os.Getenv("METIS_NODE_ATLAS_PASSWORD")),
|
||||
AtlasPasswordHash: strings.TrimSpace(os.Getenv("METIS_NODE_ATLAS_PASSWORD_HASH")),
|
||||
RootPassword: strings.TrimSpace(os.Getenv("METIS_NODE_ROOT_PASSWORD")),
|
||||
RootPasswordHash: strings.TrimSpace(os.Getenv("METIS_NODE_ROOT_PASSWORD_HASH")),
|
||||
AtlasPassword: strings.TrimSpace(os.Getenv("METIS_NODE_ATLAS_PASSWORD")),
|
||||
RootPassword: strings.TrimSpace(os.Getenv("METIS_NODE_ROOT_PASSWORD")),
|
||||
}
|
||||
if sec.SSHPassword == "" && sec.SSHPasswordHash == "" && sec.AtlasPassword == "" && sec.AtlasPasswordHash == "" && sec.RootPassword == "" && sec.RootPasswordHash == "" {
|
||||
if sec.AtlasPassword == "" && sec.RootPassword == "" {
|
||||
return nil
|
||||
}
|
||||
return sec
|
||||
@ -47,12 +43,8 @@ func mergeNodeSecrets(base, override *secrets.NodeSecrets) *secrets.NodeSecrets
|
||||
return base
|
||||
}
|
||||
merged := *base
|
||||
merged.SSHPassword = firstNonEmptyString(override.SSHPassword, base.SSHPassword)
|
||||
merged.SSHPasswordHash = firstNonEmptyString(override.SSHPasswordHash, base.SSHPasswordHash)
|
||||
merged.AtlasPassword = firstNonEmptyString(override.AtlasPassword, base.AtlasPassword)
|
||||
merged.AtlasPasswordHash = firstNonEmptyString(override.AtlasPasswordHash, base.AtlasPasswordHash)
|
||||
merged.RootPassword = firstNonEmptyString(override.RootPassword, base.RootPassword)
|
||||
merged.RootPasswordHash = firstNonEmptyString(override.RootPasswordHash, base.RootPasswordHash)
|
||||
merged.K3sToken = firstNonEmptyString(override.K3sToken, base.K3sToken)
|
||||
merged.CloudInit = firstNonEmptyString(override.CloudInit, base.CloudInit)
|
||||
if len(base.Extra) > 0 || len(override.Extra) > 0 {
|
||||
|
||||
@ -13,29 +13,15 @@ func TestNodeSecretHelpers(t *testing.T) {
|
||||
if got := effectiveAtlasPassword(nil); got != "" {
|
||||
t.Fatalf("effectiveAtlasPassword(nil) = %q", got)
|
||||
}
|
||||
if got := effectiveAtlasPasswordHash(nil); got != "" {
|
||||
t.Fatalf("effectiveAtlasPasswordHash(nil) = %q", got)
|
||||
}
|
||||
sec := &secrets.NodeSecrets{SSHPassword: "ssh-pass", SSHPasswordHash: "$ssh$hash"}
|
||||
if got := effectiveAtlasPassword(sec); got != "ssh-pass" {
|
||||
t.Fatalf("effectiveAtlasPassword fallback = %q", got)
|
||||
}
|
||||
if got := effectiveAtlasPasswordHash(sec); got != "$ssh$hash" {
|
||||
t.Fatalf("effectiveAtlasPasswordHash fallback = %q", got)
|
||||
}
|
||||
sec.AtlasPassword = "atlas-pass"
|
||||
sec.AtlasPasswordHash = "$atlas$hash"
|
||||
sec := &secrets.NodeSecrets{AtlasPassword: "atlas-pass"}
|
||||
if got := effectiveAtlasPassword(sec); got != "atlas-pass" {
|
||||
t.Fatalf("effectiveAtlasPassword explicit = %q", got)
|
||||
}
|
||||
if got := effectiveAtlasPasswordHash(sec); got != "$atlas$hash" {
|
||||
t.Fatalf("effectiveAtlasPasswordHash explicit = %q", got)
|
||||
t.Fatalf("effectiveAtlasPassword = %q", got)
|
||||
}
|
||||
if got := firstNonEmptyString("", " value ", "ignored"); got != "value" {
|
||||
t.Fatalf("firstNonEmptyString = %q", got)
|
||||
}
|
||||
if !hasNodePasswords(&secrets.NodeSecrets{RootPasswordHash: "$root$hash"}) {
|
||||
t.Fatal("expected root password hash to count as password material")
|
||||
if !hasNodePasswords(&secrets.NodeSecrets{RootPassword: "root-pass"}) {
|
||||
t.Fatal("expected root password to count as password material")
|
||||
}
|
||||
if hasNodePasswords(&secrets.NodeSecrets{}) {
|
||||
t.Fatal("empty node secrets should not count as password material")
|
||||
@ -47,21 +33,17 @@ func TestNodeSecretHelpers(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNodeSecretsFromEnvAndMergeNodeSecrets(t *testing.T) {
|
||||
t.Setenv("METIS_NODE_SSH_PASSWORD", "ssh-pass")
|
||||
t.Setenv("METIS_NODE_SSH_PASSWORD_HASH", "$ssh$hash")
|
||||
t.Setenv("METIS_NODE_ATLAS_PASSWORD", "atlas-pass")
|
||||
t.Setenv("METIS_NODE_ATLAS_PASSWORD_HASH", "$atlas$hash")
|
||||
t.Setenv("METIS_NODE_ROOT_PASSWORD", "root-pass")
|
||||
t.Setenv("METIS_NODE_ROOT_PASSWORD_HASH", "$root$hash")
|
||||
envSecrets := nodeSecretsFromEnv()
|
||||
if envSecrets == nil || envSecrets.RootPassword != "root-pass" || envSecrets.AtlasPasswordHash != "$atlas$hash" {
|
||||
if envSecrets == nil || envSecrets.RootPassword != "root-pass" || envSecrets.AtlasPassword != "atlas-pass" {
|
||||
t.Fatalf("nodeSecretsFromEnv = %#v", envSecrets)
|
||||
}
|
||||
merged := mergeNodeSecrets(&secrets.NodeSecrets{
|
||||
SSHPassword: "base-ssh",
|
||||
K3sToken: "base-token",
|
||||
CloudInit: "base-cloud",
|
||||
Extra: map[string]string{"base": "1"},
|
||||
AtlasPassword: "base-atlas",
|
||||
K3sToken: "base-token",
|
||||
CloudInit: "base-cloud",
|
||||
Extra: map[string]string{"base": "1"},
|
||||
}, &secrets.NodeSecrets{
|
||||
AtlasPassword: "override-atlas",
|
||||
RootPassword: "override-root",
|
||||
@ -75,40 +57,36 @@ func TestNodeSecretsFromEnvAndMergeNodeSecrets(t *testing.T) {
|
||||
if merged.Extra["base"] != "1" || merged.Extra["override"] != "2" {
|
||||
t.Fatalf("mergeNodeSecrets extras = %#v", merged.Extra)
|
||||
}
|
||||
if got := mergeNodeSecrets(nil, envSecrets); got.RootPasswordHash != "$root$hash" {
|
||||
if got := mergeNodeSecrets(nil, envSecrets); got.RootPassword != "root-pass" {
|
||||
t.Fatalf("mergeNodeSecrets nil base = %#v", got)
|
||||
}
|
||||
if got := mergeNodeSecrets(envSecrets, nil); got.SSHPassword != "ssh-pass" {
|
||||
if got := mergeNodeSecrets(envSecrets, nil); got.AtlasPassword != "atlas-pass" {
|
||||
t.Fatalf("mergeNodeSecrets nil override = %#v", got)
|
||||
}
|
||||
t.Setenv("METIS_NODE_SSH_PASSWORD", "")
|
||||
t.Setenv("METIS_NODE_SSH_PASSWORD_HASH", "")
|
||||
t.Setenv("METIS_NODE_ATLAS_PASSWORD", "")
|
||||
t.Setenv("METIS_NODE_ATLAS_PASSWORD_HASH", "")
|
||||
t.Setenv("METIS_NODE_ROOT_PASSWORD", "")
|
||||
t.Setenv("METIS_NODE_ROOT_PASSWORD_HASH", "")
|
||||
if got := nodeSecretsFromEnv(); got != nil {
|
||||
t.Fatalf("expected empty env secrets to collapse to nil, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstbootEnvContentIncludesHashes(t *testing.T) {
|
||||
func TestFirstbootEnvContentIncludesPasswords(t *testing.T) {
|
||||
cfg := &config.NodeConfig{
|
||||
Hostname: "titan-15",
|
||||
SSHUser: "atlas",
|
||||
K3s: config.K3sConfig{Version: "v1.31.5+k3s1"},
|
||||
}
|
||||
content := firstbootEnvContent(cfg, &secrets.NodeSecrets{
|
||||
AtlasPasswordHash: "$atlas$hash",
|
||||
RootPasswordHash: "$root$hash",
|
||||
AtlasPassword: "atlas-pass",
|
||||
RootPassword: "root-pass",
|
||||
})
|
||||
if !reflect.DeepEqual(parseEnvLines(content), map[string]string{
|
||||
"METIS_HOSTNAME": "'titan-15'",
|
||||
"METIS_SSH_USER": "'atlas'",
|
||||
"METIS_ATLAS_USER": "'atlas'",
|
||||
"METIS_K3S_VERSION": "'v1.31.5+k3s1'",
|
||||
"METIS_ATLAS_PASSWORD_HASH": "'$atlas$hash'",
|
||||
"METIS_ROOT_PASSWORD_HASH": "'$root$hash'",
|
||||
"METIS_HOSTNAME": "'titan-15'",
|
||||
"METIS_SSH_USER": "'atlas'",
|
||||
"METIS_ATLAS_USER": "'atlas'",
|
||||
"METIS_K3S_VERSION": "'v1.31.5+k3s1'",
|
||||
"METIS_ATLAS_PASSWORD": "'atlas-pass'",
|
||||
"METIS_ROOT_PASSWORD": "'root-pass'",
|
||||
}) {
|
||||
t.Fatalf("firstbootEnvContent = %q", content)
|
||||
}
|
||||
|
||||
@ -15,15 +15,11 @@ import (
|
||||
// NodeSecrets holds per-node secret material to inject at burn time.
|
||||
// These should live in Vault at kv/data/atlas/nodes/<hostname>.
|
||||
type NodeSecrets struct {
|
||||
SSHPassword string `json:"ssh_password,omitempty"`
|
||||
SSHPasswordHash string `json:"ssh_password_hash,omitempty"`
|
||||
AtlasPassword string `json:"atlas_password,omitempty"`
|
||||
AtlasPasswordHash string `json:"atlas_password_hash,omitempty"`
|
||||
RootPassword string `json:"root_password,omitempty"`
|
||||
RootPasswordHash string `json:"root_password_hash,omitempty"`
|
||||
K3sToken string `json:"k3s_token,omitempty"`
|
||||
CloudInit string `json:"cloud_init,omitempty"`
|
||||
Extra map[string]string `json:"extra,omitempty"`
|
||||
AtlasPassword string `json:"atlas_password,omitempty"`
|
||||
RootPassword string `json:"root_password,omitempty"`
|
||||
K3sToken string `json:"k3s_token,omitempty"`
|
||||
CloudInit string `json:"cloud_init,omitempty"`
|
||||
Extra map[string]string `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
// Client fetches node secrets from Vault using either a token or AppRole.
|
||||
|
||||
@ -16,11 +16,10 @@ func TestFetchNodeReturnsData(t *testing.T) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
"data": map[string]any{
|
||||
"ssh_password": "p1",
|
||||
"atlas_password_hash": "$atlas$hash",
|
||||
"root_password": "root-pw",
|
||||
"k3s_token": "t1",
|
||||
"cloud_init": "ci",
|
||||
"atlas_password": "atlas-pw",
|
||||
"root_password": "root-pw",
|
||||
"k3s_token": "t1",
|
||||
"cloud_init": "ci",
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -35,7 +34,7 @@ func TestFetchNodeReturnsData(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
if sec.SSHPassword != "p1" || sec.AtlasPasswordHash != "$atlas$hash" || sec.RootPassword != "root-pw" || sec.K3sToken != "t1" || sec.CloudInit != "ci" {
|
||||
if sec.AtlasPassword != "atlas-pw" || sec.RootPassword != "root-pw" || sec.K3sToken != "t1" || sec.CloudInit != "ci" {
|
||||
t.Fatalf("unexpected secrets: %+v", sec)
|
||||
}
|
||||
}
|
||||
|
||||
@ -448,12 +448,8 @@ export METIS_SSH_KEY_HECATE_DB="{{ .Data.data.hecate_db_pub }}"
|
||||
secretPath := fmt.Sprintf("kv/data/atlas/nodes/%s", nodeHostname)
|
||||
annotations["vault.hashicorp.com/agent-inject-secret-metis-node-secrets-env.sh"] = secretPath
|
||||
annotations["vault.hashicorp.com/agent-inject-template-metis-node-secrets-env.sh"] = `{{ with secret "` + secretPath + `" }}
|
||||
export METIS_NODE_SSH_PASSWORD="{{ .Data.data.ssh_password }}"
|
||||
export METIS_NODE_SSH_PASSWORD_HASH="{{ .Data.data.ssh_password_hash }}"
|
||||
export METIS_NODE_ATLAS_PASSWORD="{{ .Data.data.atlas_password }}"
|
||||
export METIS_NODE_ATLAS_PASSWORD_HASH="{{ .Data.data.atlas_password_hash }}"
|
||||
export METIS_NODE_ROOT_PASSWORD="{{ .Data.data.root_password }}"
|
||||
export METIS_NODE_ROOT_PASSWORD_HASH="{{ .Data.data.root_password_hash }}"
|
||||
{{ end }}`
|
||||
}
|
||||
return annotations
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user