mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-17 05:53:15 +02:00
Add sections to integrations
This commit is contained in:
@@ -17,6 +17,10 @@ func init() {
|
|||||||
Register(&mikrotikIntegration{})
|
Register(&mikrotikIntegration{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Interface Implementation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
func (m *mikrotikIntegration) ID() string {
|
func (m *mikrotikIntegration) ID() string {
|
||||||
return "mikrotik"
|
return "mikrotik"
|
||||||
}
|
}
|
||||||
@@ -41,6 +45,10 @@ func (m *mikrotikIntegration) Validate(cfg config.AdvancedActionsConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Block/Unblock
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
func (m *mikrotikIntegration) BlockIP(req Request) error {
|
func (m *mikrotikIntegration) BlockIP(req Request) error {
|
||||||
if err := m.Validate(req.Config); err != nil {
|
if err := m.Validate(req.Config); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -59,6 +67,10 @@ func (m *mikrotikIntegration) UnblockIP(req Request) error {
|
|||||||
return m.runCommand(req, cmd)
|
return m.runCommand(req, cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// SSH Communication
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
func (m *mikrotikIntegration) runCommand(req Request, command string) error {
|
func (m *mikrotikIntegration) runCommand(req Request, command string) error {
|
||||||
cfg := req.Config.Mikrotik
|
cfg := req.Config.Mikrotik
|
||||||
|
|
||||||
@@ -97,7 +109,6 @@ func (m *mikrotikIntegration) runCommand(req Request, command string) error {
|
|||||||
address := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", port))
|
address := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", port))
|
||||||
client, err := ssh.Dial("tcp", address, clientCfg)
|
client, err := ssh.Dial("tcp", address, clientCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Provide more specific error messages for common connection issues
|
|
||||||
if netErr, ok := err.(net.Error); ok {
|
if netErr, ok := err.(net.Error); ok {
|
||||||
if netErr.Timeout() {
|
if netErr.Timeout() {
|
||||||
return fmt.Errorf("connection to mikrotik at %s timed out: %w", address, err)
|
return fmt.Errorf("connection to mikrotik at %s timed out: %w", address, err)
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ func init() {
|
|||||||
Register(&opnsenseIntegration{})
|
Register(&opnsenseIntegration{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Interface Implementation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
func (o *opnsenseIntegration) ID() string {
|
func (o *opnsenseIntegration) ID() string {
|
||||||
return "opnsense"
|
return "opnsense"
|
||||||
}
|
}
|
||||||
@@ -41,6 +45,10 @@ func (o *opnsenseIntegration) Validate(cfg config.AdvancedActionsConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Block/Unblock
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
func (o *opnsenseIntegration) BlockIP(req Request) error {
|
func (o *opnsenseIntegration) BlockIP(req Request) error {
|
||||||
if err := o.Validate(req.Config); err != nil {
|
if err := o.Validate(req.Config); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -55,6 +63,10 @@ func (o *opnsenseIntegration) UnblockIP(req Request) error {
|
|||||||
return o.callAPI(req, "delete", req.IP)
|
return o.callAPI(req, "delete", req.IP)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// OPNsense API
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
func (o *opnsenseIntegration) callAPI(req Request, action, ip string) error {
|
func (o *opnsenseIntegration) callAPI(req Request, action, ip string) error {
|
||||||
cfg := req.Config.OPNsense
|
cfg := req.Config.OPNsense
|
||||||
apiURL := strings.TrimSuffix(cfg.BaseURL, "/") + fmt.Sprintf("/api/firewall/alias_util/%s/%s", action, cfg.Alias)
|
apiURL := strings.TrimSuffix(cfg.BaseURL, "/") + fmt.Sprintf("/api/firewall/alias_util/%s/%s", action, cfg.Alias)
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ import (
|
|||||||
|
|
||||||
type pfSenseIntegration struct{}
|
type pfSenseIntegration struct{}
|
||||||
|
|
||||||
// FirewallAliasResponse represents the response structure from pfSense API
|
// =========================================================================
|
||||||
|
// Types
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
type FirewallAliasResponse struct {
|
type FirewallAliasResponse struct {
|
||||||
Data FirewallAlias `json:"data"`
|
Data FirewallAlias `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FirewallAlias represents a firewall alias in pfSense
|
|
||||||
type FirewallAlias struct {
|
type FirewallAlias struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -31,6 +33,10 @@ type FirewallAlias struct {
|
|||||||
Detail []string `json:"detail"`
|
Detail []string `json:"detail"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Interface Implementation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
Register(&pfSenseIntegration{})
|
Register(&pfSenseIntegration{})
|
||||||
}
|
}
|
||||||
@@ -56,6 +62,10 @@ func (p *pfSenseIntegration) Validate(cfg config.AdvancedActionsConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Block/Unblock
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
func (p *pfSenseIntegration) BlockIP(req Request) error {
|
func (p *pfSenseIntegration) BlockIP(req Request) error {
|
||||||
if err := p.Validate(req.Config); err != nil {
|
if err := p.Validate(req.Config); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -70,12 +80,14 @@ func (p *pfSenseIntegration) UnblockIP(req Request) error {
|
|||||||
return p.modifyAliasIP(req, req.IP, "", false)
|
return p.modifyAliasIP(req, req.IP, "", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// modifyAliasIP implements the GET-modify-PATCH pattern for pfSense alias management
|
// =========================================================================
|
||||||
|
// pfSense API
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
func (p *pfSenseIntegration) modifyAliasIP(req Request, ip, description string, add bool) error {
|
func (p *pfSenseIntegration) modifyAliasIP(req Request, ip, description string, add bool) error {
|
||||||
cfg := req.Config.PfSense
|
cfg := req.Config.PfSense
|
||||||
baseURL := strings.TrimSuffix(cfg.BaseURL, "/")
|
baseURL := strings.TrimSuffix(cfg.BaseURL, "/")
|
||||||
|
|
||||||
// Create HTTP client
|
|
||||||
httpClient := &http.Client{
|
httpClient := &http.Client{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
}
|
}
|
||||||
@@ -93,7 +105,6 @@ func (p *pfSenseIntegration) modifyAliasIP(req Request, ip, description string,
|
|||||||
if req.Logger != nil {
|
if req.Logger != nil {
|
||||||
req.Logger("Alias %s not found, creating it automatically", cfg.Alias)
|
req.Logger("Alias %s not found, creating it automatically", cfg.Alias)
|
||||||
}
|
}
|
||||||
// Create a new alias with default values
|
|
||||||
newAlias := &FirewallAlias{
|
newAlias := &FirewallAlias{
|
||||||
Name: cfg.Alias,
|
Name: cfg.Alias,
|
||||||
Type: "host",
|
Type: "host",
|
||||||
@@ -111,9 +122,7 @@ func (p *pfSenseIntegration) modifyAliasIP(req Request, ip, description string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modify the address array
|
|
||||||
if add {
|
if add {
|
||||||
// Check if IP already exists
|
|
||||||
ipExists := false
|
ipExists := false
|
||||||
for _, addr := range alias.Address {
|
for _, addr := range alias.Address {
|
||||||
if addr == ip {
|
if addr == ip {
|
||||||
@@ -124,24 +133,21 @@ func (p *pfSenseIntegration) modifyAliasIP(req Request, ip, description string,
|
|||||||
if !ipExists {
|
if !ipExists {
|
||||||
alias.Address = append(alias.Address, ip)
|
alias.Address = append(alias.Address, ip)
|
||||||
if description != "" {
|
if description != "" {
|
||||||
// Add description to detail array, matching the address array length
|
|
||||||
alias.Detail = append(alias.Detail, description)
|
alias.Detail = append(alias.Detail, description)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if req.Logger != nil {
|
if req.Logger != nil {
|
||||||
req.Logger("IP %s already exists in alias %s", ip, cfg.Alias)
|
req.Logger("IP %s already exists in alias %s", ip, cfg.Alias)
|
||||||
}
|
}
|
||||||
return nil // IP already blocked, consider it success
|
return nil
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Remove IP from address array
|
|
||||||
found := false
|
found := false
|
||||||
newAddress := make([]string, 0, len(alias.Address))
|
newAddress := make([]string, 0, len(alias.Address))
|
||||||
newDetail := make([]string, 0, len(alias.Detail))
|
newDetail := make([]string, 0, len(alias.Detail))
|
||||||
for i, addr := range alias.Address {
|
for i, addr := range alias.Address {
|
||||||
if addr != ip {
|
if addr != ip {
|
||||||
newAddress = append(newAddress, addr)
|
newAddress = append(newAddress, addr)
|
||||||
// Keep corresponding detail if it exists
|
|
||||||
if i < len(alias.Detail) {
|
if i < len(alias.Detail) {
|
||||||
newDetail = append(newDetail, alias.Detail[i])
|
newDetail = append(newDetail, alias.Detail[i])
|
||||||
}
|
}
|
||||||
@@ -153,20 +159,17 @@ func (p *pfSenseIntegration) modifyAliasIP(req Request, ip, description string,
|
|||||||
if req.Logger != nil {
|
if req.Logger != nil {
|
||||||
req.Logger("IP %s not found in alias %s", ip, cfg.Alias)
|
req.Logger("IP %s not found in alias %s", ip, cfg.Alias)
|
||||||
}
|
}
|
||||||
return nil // IP not in alias, consider it success
|
return nil
|
||||||
}
|
}
|
||||||
alias.Address = newAddress
|
alias.Address = newAddress
|
||||||
alias.Detail = newDetail
|
alias.Detail = newDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH the alias with updated configuration
|
|
||||||
if err := p.updateAlias(httpClient, baseURL, cfg.APIToken, alias, req.Logger); err != nil {
|
if err := p.updateAlias(httpClient, baseURL, cfg.APIToken, alias, req.Logger); err != nil {
|
||||||
return fmt.Errorf("failed to update alias %s: %w", cfg.Alias, err)
|
return fmt.Errorf("failed to update alias %s: %w", cfg.Alias, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply firewall changes
|
|
||||||
if err := p.applyFirewallChanges(httpClient, baseURL, cfg.APIToken, req.Logger); err != nil {
|
if err := p.applyFirewallChanges(httpClient, baseURL, cfg.APIToken, req.Logger); err != nil {
|
||||||
// Log warning but don't fail - the alias was updated successfully
|
|
||||||
if req.Logger != nil {
|
if req.Logger != nil {
|
||||||
req.Logger("Warning: failed to apply firewall changes: %v", err)
|
req.Logger("Warning: failed to apply firewall changes: %v", err)
|
||||||
}
|
}
|
||||||
@@ -183,11 +186,9 @@ func (p *pfSenseIntegration) modifyAliasIP(req Request, ip, description string,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getAliasByName retrieves a firewall alias by name using GET /api/v2/firewall/aliases
|
|
||||||
func (p *pfSenseIntegration) getAliasByName(client *http.Client, baseURL, apiToken, aliasName string, logger func(string, ...interface{})) (*FirewallAlias, error) {
|
func (p *pfSenseIntegration) getAliasByName(client *http.Client, baseURL, apiToken, aliasName string, logger func(string, ...interface{})) (*FirewallAlias, error) {
|
||||||
apiURL := baseURL + "/api/v2/firewall/aliases"
|
apiURL := baseURL + "/api/v2/firewall/aliases"
|
||||||
|
|
||||||
// Add query parameter for alias name filtering
|
|
||||||
u, err := url.Parse(apiURL)
|
u, err := url.Parse(apiURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse URL: %w", err)
|
return nil, fmt.Errorf("failed to parse URL: %w", err)
|
||||||
@@ -226,7 +227,6 @@ func (p *pfSenseIntegration) getAliasByName(client *http.Client, baseURL, apiTok
|
|||||||
return nil, fmt.Errorf("pfSense API GET failed: status %s, response: %s", resp.Status, bodyStr)
|
return nil, fmt.Errorf("pfSense API GET failed: status %s, response: %s", resp.Status, bodyStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The plural endpoint returns an array of aliases in the data field
|
|
||||||
var listResp struct {
|
var listResp struct {
|
||||||
Data []FirewallAlias `json:"data"`
|
Data []FirewallAlias `json:"data"`
|
||||||
}
|
}
|
||||||
@@ -234,7 +234,6 @@ func (p *pfSenseIntegration) getAliasByName(client *http.Client, baseURL, apiTok
|
|||||||
return nil, fmt.Errorf("failed to decode pfSense alias response: %w", err)
|
return nil, fmt.Errorf("failed to decode pfSense alias response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the alias with matching name (query parameter may return multiple results)
|
|
||||||
for i := range listResp.Data {
|
for i := range listResp.Data {
|
||||||
if listResp.Data[i].Name == aliasName {
|
if listResp.Data[i].Name == aliasName {
|
||||||
return &listResp.Data[i], nil
|
return &listResp.Data[i], nil
|
||||||
@@ -244,11 +243,9 @@ func (p *pfSenseIntegration) getAliasByName(client *http.Client, baseURL, apiTok
|
|||||||
return nil, fmt.Errorf("alias %s not found", aliasName)
|
return nil, fmt.Errorf("alias %s not found", aliasName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// createAlias creates a new firewall alias using POST /api/v2/firewall/alias
|
|
||||||
func (p *pfSenseIntegration) createAlias(client *http.Client, baseURL, apiToken string, alias *FirewallAlias, logger func(string, ...interface{})) (*FirewallAlias, error) {
|
func (p *pfSenseIntegration) createAlias(client *http.Client, baseURL, apiToken string, alias *FirewallAlias, logger func(string, ...interface{})) (*FirewallAlias, error) {
|
||||||
apiURL := baseURL + "/api/v2/firewall/alias"
|
apiURL := baseURL + "/api/v2/firewall/alias"
|
||||||
|
|
||||||
// Prepare POST payload - exclude ID as it will be generated by pfSense
|
|
||||||
postPayload := map[string]interface{}{
|
postPayload := map[string]interface{}{
|
||||||
"name": alias.Name,
|
"name": alias.Name,
|
||||||
"type": alias.Type,
|
"type": alias.Type,
|
||||||
@@ -294,7 +291,6 @@ func (p *pfSenseIntegration) createAlias(client *http.Client, baseURL, apiToken
|
|||||||
return nil, fmt.Errorf("pfSense API POST failed: status %s, response: %s", resp.Status, bodyStr)
|
return nil, fmt.Errorf("pfSense API POST failed: status %s, response: %s", resp.Status, bodyStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the response to get the created alias with its ID
|
|
||||||
var createResp FirewallAliasResponse
|
var createResp FirewallAliasResponse
|
||||||
if err := json.Unmarshal(bodyBytes, &createResp); err != nil {
|
if err := json.Unmarshal(bodyBytes, &createResp); err != nil {
|
||||||
return nil, fmt.Errorf("failed to decode pfSense alias creation response: %w", err)
|
return nil, fmt.Errorf("failed to decode pfSense alias creation response: %w", err)
|
||||||
@@ -307,20 +303,13 @@ func (p *pfSenseIntegration) createAlias(client *http.Client, baseURL, apiToken
|
|||||||
return &createResp.Data, nil
|
return &createResp.Data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateAlias updates a firewall alias using PATCH /api/v2/firewall/alias
|
|
||||||
// The id must be included in the request body, not in the URL path
|
|
||||||
func (p *pfSenseIntegration) updateAlias(client *http.Client, baseURL, apiToken string, alias *FirewallAlias, logger func(string, ...interface{})) error {
|
func (p *pfSenseIntegration) updateAlias(client *http.Client, baseURL, apiToken string, alias *FirewallAlias, logger func(string, ...interface{})) error {
|
||||||
apiURL := baseURL + "/api/v2/firewall/alias"
|
apiURL := baseURL + "/api/v2/firewall/alias"
|
||||||
|
|
||||||
// Prepare PATCH payload - include id in the request body
|
|
||||||
// pfSense requires that detail cannot have more items than address
|
|
||||||
// Always include detail array to ensure it matches address length
|
|
||||||
detailToSend := alias.Detail
|
detailToSend := alias.Detail
|
||||||
if len(detailToSend) > len(alias.Address) {
|
if len(detailToSend) > len(alias.Address) {
|
||||||
// Truncate detail to match address length
|
|
||||||
detailToSend = detailToSend[:len(alias.Address)]
|
detailToSend = detailToSend[:len(alias.Address)]
|
||||||
}
|
}
|
||||||
// If address is empty, detail must also be empty
|
|
||||||
if len(alias.Address) == 0 {
|
if len(alias.Address) == 0 {
|
||||||
detailToSend = []string{}
|
detailToSend = []string{}
|
||||||
}
|
}
|
||||||
@@ -331,7 +320,7 @@ func (p *pfSenseIntegration) updateAlias(client *http.Client, baseURL, apiToken
|
|||||||
"type": alias.Type,
|
"type": alias.Type,
|
||||||
"descr": alias.Descr,
|
"descr": alias.Descr,
|
||||||
"address": alias.Address,
|
"address": alias.Address,
|
||||||
"detail": detailToSend, // Always include detail to ensure it's cleared when address is empty
|
"detail": detailToSend,
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := json.Marshal(patchPayload)
|
data, err := json.Marshal(patchPayload)
|
||||||
@@ -376,7 +365,7 @@ func (p *pfSenseIntegration) updateAlias(client *http.Client, baseURL, apiToken
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyFirewallChanges applies firewall changes using POST /api/v2/firewall/apply
|
// Applies firewall changes
|
||||||
func (p *pfSenseIntegration) applyFirewallChanges(client *http.Client, baseURL, apiToken string, logger func(string, ...interface{})) error {
|
func (p *pfSenseIntegration) applyFirewallChanges(client *http.Client, baseURL, apiToken string, logger func(string, ...interface{})) error {
|
||||||
apiURL := baseURL + "/api/v2/firewall/apply"
|
apiURL := baseURL + "/api/v2/firewall/apply"
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import (
|
|||||||
"github.com/swissmakers/fail2ban-ui/internal/config"
|
"github.com/swissmakers/fail2ban-ui/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Request represents a block/unblock request for an integration plugin.
|
// =========================================================================
|
||||||
|
// Types
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// Block/Unblock request for an integration.
|
||||||
type Request struct {
|
type Request struct {
|
||||||
Context context.Context
|
Context context.Context
|
||||||
IP string
|
IP string
|
||||||
@@ -17,7 +21,7 @@ type Request struct {
|
|||||||
Logger func(format string, args ...interface{})
|
Logger func(format string, args ...interface{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Integration exposes functionality required by an external firewall vendor.
|
// Exposes functionality required by an external firewall vendor.
|
||||||
type Integration interface {
|
type Integration interface {
|
||||||
ID() string
|
ID() string
|
||||||
DisplayName() string
|
DisplayName() string
|
||||||
@@ -28,7 +32,11 @@ type Integration interface {
|
|||||||
|
|
||||||
var registry = map[string]Integration{}
|
var registry = map[string]Integration{}
|
||||||
|
|
||||||
// Register adds an integration to the global registry.
|
// =========================================================================
|
||||||
|
// Registry
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// Adds an integration to the registry.
|
||||||
func Register(integration Integration) {
|
func Register(integration Integration) {
|
||||||
if integration == nil {
|
if integration == nil {
|
||||||
return
|
return
|
||||||
@@ -36,13 +44,13 @@ func Register(integration Integration) {
|
|||||||
registry[integration.ID()] = integration
|
registry[integration.ID()] = integration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get returns the integration by id.
|
// Returns the integration by id.
|
||||||
func Get(id string) (Integration, bool) {
|
func Get(id string) (Integration, bool) {
|
||||||
integration, ok := registry[id]
|
integration, ok := registry[id]
|
||||||
return integration, ok
|
return integration, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// MustGet obtains the integration or panics – used during init.
|
// Returns the integration or panics.
|
||||||
func MustGet(id string) Integration {
|
func MustGet(id string) Integration {
|
||||||
integration, ok := Get(id)
|
integration, ok := Get(id)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -51,7 +59,7 @@ func MustGet(id string) Integration {
|
|||||||
return integration
|
return integration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supported returns ids of all registered integrations.
|
// Returns all registered integration ids.
|
||||||
func Supported() []string {
|
func Supported() []string {
|
||||||
keys := make([]string, 0, len(registry))
|
keys := make([]string, 0, len(registry))
|
||||||
for id := range registry {
|
for id := range registry {
|
||||||
|
|||||||
Reference in New Issue
Block a user