Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbd3343251 | ||
|
|
c9510a986c |
@@ -121,15 +121,22 @@ notification endpoint set, changes show only on the dashboard; see
|
|||||||
failed on it, and answers differently is reported on the check where it
|
failed on it, and answers differently is reported on the check where it
|
||||||
answers. If a pair agrees again and later disagrees, the alert is sent
|
answers. If a pair agrees again and later disagrees, the alert is sent
|
||||||
again.
|
again.
|
||||||
|
- **CNAME address change**: For a name whose nameservers answer with a CNAME
|
||||||
|
and no address, the addresses at the end of its CNAME chain differ from
|
||||||
|
those of the previous check, including when there are none now. Nothing is
|
||||||
|
sent when the previous check saved none, or when the previous addresses
|
||||||
|
were kept because the chain could not be followed or none of the name's
|
||||||
|
nameservers answered.
|
||||||
|
|
||||||
### TCP Port Monitoring
|
### TCP Port Monitoring
|
||||||
|
|
||||||
- For every configured domain and hostname, constructs a deduplicated list of
|
- For every configured domain and hostname, constructs a deduplicated list of
|
||||||
the IPv4 and IPv6 addresses in the A and AAAA records its authoritative
|
the IPv4 and IPv6 addresses in the A and AAAA records its authoritative
|
||||||
nameservers returned. When they returned a CNAME and no address, the CNAME
|
nameservers returned. When they returned a CNAME and no address, the CNAME
|
||||||
chain is followed and the addresses at its end are used; a change in those
|
chain is followed and the addresses at its end are used, and a change in those
|
||||||
sends no notification of its own. When the chain cannot be followed, the
|
is notified as a CNAME address change. When the chain cannot be followed, or
|
||||||
addresses the last check found at its end are used.
|
none of the name's nameservers answered, the addresses the last check found at
|
||||||
|
its end are used.
|
||||||
- Checks TCP connectivity on ports **80** and **443** for each IP address.
|
- Checks TCP connectivity on ports **80** and **443** for each IP address.
|
||||||
- Every **1 hour**, re-checks all ports.
|
- Every **1 hour**, re-checks all ports.
|
||||||
- Any change in port availability triggers a notification:
|
- Any change in port availability triggers a notification:
|
||||||
@@ -176,6 +183,8 @@ includes:
|
|||||||
- **DNS NS changes**: Which domain, which nameservers were added/removed.
|
- **DNS NS changes**: Which domain, which nameservers were added/removed.
|
||||||
- **NS address changes**: Which domain, which nameserver, its old and new
|
- **NS address changes**: Which domain, which nameserver, its old and new
|
||||||
addresses.
|
addresses.
|
||||||
|
- **CNAME address changes**: Which hostname, the old and new addresses at the
|
||||||
|
end of its CNAME chain.
|
||||||
- **NS query failures**: Which nameserver failed, error type (timeout, SERVFAIL,
|
- **NS query failures**: Which nameserver failed, error type (timeout, SERVFAIL,
|
||||||
REFUSED, network error), which hostname/domain affected.
|
REFUSED, network error), which hostname/domain affected.
|
||||||
- **NS recoveries**: Which nameserver recovered, which hostname/domain.
|
- **NS recoveries**: Which nameserver recovered, which hostname/domain.
|
||||||
@@ -485,8 +494,10 @@ resolves to. A state file without it loads, and the next check fills it in
|
|||||||
without a notification.
|
without a notification.
|
||||||
|
|
||||||
`cnameAddresses` lists the sorted addresses at the end of a hostname's CNAME
|
`cnameAddresses` lists the sorted addresses at the end of a hostname's CNAME
|
||||||
chain, found when its nameservers answered with a CNAME and no address. It is
|
chain, found when its nameservers answered with a CNAME and no address, and kept
|
||||||
left out otherwise.
|
from the previous check when the chain cannot be followed or none of the name's
|
||||||
|
nameservers answered. It is left out otherwise. A state file without it loads,
|
||||||
|
and the next check fills it in without a notification.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -627,7 +638,9 @@ docker run -d \
|
|||||||
completes.
|
completes.
|
||||||
- Port and TLS checks always use freshly resolved IP addresses from the DNS
|
- Port and TLS checks always use freshly resolved IP addresses from the DNS
|
||||||
phase that immediately precedes them — never stale IPs from a previous
|
phase that immediately precedes them — never stale IPs from a previous
|
||||||
cycle.
|
cycle, with one exception: when a name's CNAME chain cannot be followed,
|
||||||
|
or none of the name's nameservers answered, the addresses the previous
|
||||||
|
cycle found at the end of the chain are used.
|
||||||
4. **On change detection**: Send notifications to all configured endpoints,
|
4. **On change detection**: Send notifications to all configured endpoints,
|
||||||
update in-memory state, persist to disk.
|
update in-memory state, persist to disk.
|
||||||
5. **Shutdown**: The watcher stops checking and saves the final state to disk,
|
5. **Shutdown**: The watcher stops checking and saves the final state to disk,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|||||||
|
|
||||||
- 2026-10-01: a watched name whose nameservers answer with a CNAME and no
|
- 2026-10-01: a watched name whose nameservers answer with a CNAME and no
|
||||||
address gets port and TLS checks at the end of its CNAME chain (closes #203).
|
address gets port and TLS checks at the end of its CNAME chain (closes #203).
|
||||||
|
- 2026-10-01: a certificate within the expiry warning period is warned about on
|
||||||
|
every TLS check, where some checks used to skip it at random (closes #204).
|
||||||
- 2026-10-01: a domain's NS set is its delegation from the parent zone's
|
- 2026-10-01: a domain's NS set is its delegation from the parent zone's
|
||||||
servers, not whichever of its own servers answered first (closes #200).
|
servers, not whichever of its own servers answered first (closes #200).
|
||||||
- 2026-10-01: README has Getting Started, Rationale and TODO sections, and its
|
- 2026-10-01: README has Getting Started, Rationale and TODO sections, and its
|
||||||
|
|||||||
@@ -85,3 +85,115 @@ func TestCNAMEThatCannotBeFollowedKeepsPrevious(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCNAMEWhoseNameserversAllFailedKeepsPrevious checks a name none of
|
||||||
|
// whose nameservers answered. The addresses the previous check saved
|
||||||
|
// from following its CNAME are kept, and nothing is looked up: the
|
||||||
|
// watcher has no resolver.
|
||||||
|
func TestCNAMEWhoseNameserversAllFailedKeepsPrevious(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
w := watcher.NewForTest(nil, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
current := saved(map[string]*state.NameserverRecordState{
|
||||||
|
nsA: failed(), nsB: failed(),
|
||||||
|
})
|
||||||
|
prev := cnameState(oldIP)
|
||||||
|
|
||||||
|
w.ResolveCNAMEAddresses(t.Context(), host, current, prev)
|
||||||
|
|
||||||
|
if !slices.Equal(current.CNAMEAddresses, prev.CNAMEAddresses) {
|
||||||
|
t.Errorf(
|
||||||
|
"saved %v, want %v",
|
||||||
|
current.CNAMEAddresses, prev.CNAMEAddresses,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cnameState builds the state a check leaves behind for a name whose
|
||||||
|
// nameserver answered with a CNAME and no address, when following the
|
||||||
|
// CNAME found these addresses.
|
||||||
|
func cnameState(addresses ...string) *state.HostnameState {
|
||||||
|
hs := hostnameState(map[string]map[string][]string{
|
||||||
|
nsA: {"CNAME": {"target.example.org."}},
|
||||||
|
})
|
||||||
|
hs.CNAMEAddresses = addresses
|
||||||
|
|
||||||
|
return hs
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCNAMEAddressChangeAlerts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Each case is the state saved by the previous check and by the
|
||||||
|
// current one. The name's records are the same in both.
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prev, current *state.HostnameState
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"same addresses",
|
||||||
|
cnameState(ip1, ip2), cnameState(ip1, ip2), 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"same addresses in another order",
|
||||||
|
cnameState(ip2, ip1), cnameState(ip1, ip2), 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"address replaced",
|
||||||
|
cnameState(ip1), cnameState(ip2), 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"address added",
|
||||||
|
cnameState(ip1), cnameState(ip1, ip2), 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"no address at the end of the chain now",
|
||||||
|
cnameState(ip1), cnameState(), 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state file from before addresses were saved",
|
||||||
|
cnameState(), cnameState(ip1), 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
notifier := &mockNotifier{}
|
||||||
|
w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier)
|
||||||
|
|
||||||
|
w.DetectHostnameChanges(t.Context(), host, tt.prev, tt.current)
|
||||||
|
|
||||||
|
got := len(notifier.getNotifications())
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("sent %d notifications, want %d", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCNAMEAddressChangeAlertNamesHostnameAndAddresses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
notifier := &mockNotifier{}
|
||||||
|
w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier)
|
||||||
|
|
||||||
|
w.DetectHostnameChanges(
|
||||||
|
t.Context(), host, cnameState(ip1), cnameState(ip2, ip3),
|
||||||
|
)
|
||||||
|
|
||||||
|
want := notification{
|
||||||
|
Title: "CNAME Address Change: " + host,
|
||||||
|
Message: "Hostname: " + host +
|
||||||
|
"\nOld: " + ip1 + "\nNew: " + ip2 + ", " + ip3,
|
||||||
|
Priority: "warning",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := notifier.getNotifications()
|
||||||
|
if len(got) != 1 || got[0] != want {
|
||||||
|
t.Errorf("sent %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ func NewForTest(
|
|||||||
tlsCheck: tc,
|
tlsCheck: tc,
|
||||||
notify: n,
|
notify: n,
|
||||||
firstRun: true,
|
firstRun: true,
|
||||||
expiryNotified: make(map[string]time.Time),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +80,11 @@ func (w *Watcher) CheckAllPorts(ctx context.Context) {
|
|||||||
w.checkAllPorts(ctx)
|
w.checkAllPorts(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunTLSChecks exports runTLSChecks for testing.
|
||||||
|
func (w *Watcher) RunTLSChecks(ctx context.Context) {
|
||||||
|
w.runTLSChecks(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// BuildHostnameState exports buildHostnameState for testing.
|
// BuildHostnameState exports buildHostnameState for testing.
|
||||||
func BuildHostnameState(
|
func BuildHostnameState(
|
||||||
results map[string]*resolver.NameserverResponse,
|
results map[string]*resolver.NameserverResponse,
|
||||||
|
|||||||
+54
-26
@@ -7,7 +7,6 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -59,8 +58,6 @@ type Watcher struct {
|
|||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
done chan struct{} // closed when Run returns
|
done chan struct{} // closed when Run returns
|
||||||
firstRun bool
|
firstRun bool
|
||||||
expiryNotifiedMu sync.Mutex
|
|
||||||
expiryNotified map[string]time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Watcher instance wired into the fx lifecycle.
|
// New creates a new Watcher instance wired into the fx lifecycle.
|
||||||
@@ -77,7 +74,6 @@ func New(
|
|||||||
tlsCheck: params.TLSCheck,
|
tlsCheck: params.TLSCheck,
|
||||||
notify: params.Notify,
|
notify: params.Notify,
|
||||||
firstRun: true,
|
firstRun: true,
|
||||||
expiryNotified: make(map[string]time.Time),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
@@ -399,17 +395,30 @@ func (w *Watcher) checkHostname(
|
|||||||
// resolveCNAMEAddresses saves in current the addresses at the end of
|
// resolveCNAMEAddresses saves in current the addresses at the end of
|
||||||
// hostname's CNAME chain, when the nameservers' answers in current hold
|
// hostname's CNAME chain, when the nameservers' answers in current hold
|
||||||
// a CNAME and no address. ResolveIPAddresses looks the name up again
|
// a CNAME and no address. ResolveIPAddresses looks the name up again
|
||||||
// and follows the chain. When it fails, as when no nameserver of a zone
|
// and follows the chain. The addresses saved in prev, which may be nil,
|
||||||
// in the chain answers, the addresses saved in prev, which may be nil,
|
// are kept when none of the name's nameservers answered, and when the
|
||||||
// are kept.
|
// chain cannot be followed, as when no nameserver of a zone in it
|
||||||
|
// answers.
|
||||||
func (w *Watcher) resolveCNAMEAddresses(
|
func (w *Watcher) resolveCNAMEAddresses(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
hostname string,
|
hostname string,
|
||||||
current, prev *state.HostnameState,
|
current, prev *state.HostnameState,
|
||||||
) {
|
) {
|
||||||
|
var prevAddresses []string
|
||||||
|
if prev != nil {
|
||||||
|
prevAddresses = prev.CNAMEAddresses
|
||||||
|
}
|
||||||
|
|
||||||
|
answered := false
|
||||||
hasCNAME := false
|
hasCNAME := false
|
||||||
|
|
||||||
for _, nsState := range current.RecordsByNameserver {
|
for _, nsState := range current.RecordsByNameserver {
|
||||||
|
if nsState.Status != statusOK {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
answered = true
|
||||||
|
|
||||||
if len(nsState.Records["A"]) > 0 || len(nsState.Records["AAAA"]) > 0 {
|
if len(nsState.Records["A"]) > 0 || len(nsState.Records["AAAA"]) > 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -419,6 +428,12 @@ func (w *Watcher) resolveCNAMEAddresses(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !answered {
|
||||||
|
current.CNAMEAddresses = prevAddresses
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if !hasCNAME {
|
if !hasCNAME {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -431,9 +446,7 @@ func (w *Watcher) resolveCNAMEAddresses(
|
|||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
if prev != nil {
|
current.CNAMEAddresses = prevAddresses
|
||||||
current.CNAMEAddresses = prev.CNAMEAddresses
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -484,6 +497,37 @@ func (w *Watcher) detectHostnameChanges(
|
|||||||
w.detectNSDisappearances(ctx, hostname, prev, current)
|
w.detectNSDisappearances(ctx, hostname, prev, current)
|
||||||
w.detectNSFailures(ctx, hostname, prev, current)
|
w.detectNSFailures(ctx, hostname, prev, current)
|
||||||
w.detectInconsistencies(ctx, hostname, prev, current)
|
w.detectInconsistencies(ctx, hostname, prev, current)
|
||||||
|
w.detectCNAMEAddressChanges(ctx, hostname, prev, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectCNAMEAddressChanges notifies when the addresses at the end of
|
||||||
|
// hostname's CNAME chain differ from those the previous check saved,
|
||||||
|
// including when there are none now. When the previous check saved
|
||||||
|
// none, as in a state file from before they were saved, nothing is
|
||||||
|
// compared.
|
||||||
|
func (w *Watcher) detectCNAMEAddressChanges(
|
||||||
|
ctx context.Context,
|
||||||
|
hostname string,
|
||||||
|
prev, current *state.HostnameState,
|
||||||
|
) {
|
||||||
|
old, cur := prev.CNAMEAddresses, current.CNAMEAddresses
|
||||||
|
if len(old) == 0 || sliceEqual(old, cur) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"Hostname: %s\nOld: %s\nNew: %s",
|
||||||
|
hostname,
|
||||||
|
strings.Join(old, ", "),
|
||||||
|
strings.Join(cur, ", "),
|
||||||
|
)
|
||||||
|
|
||||||
|
w.notify.SendNotification(
|
||||||
|
ctx,
|
||||||
|
"CNAME Address Change: "+hostname,
|
||||||
|
msg,
|
||||||
|
"warning",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectRecordChanges compares each nameserver's records with those of
|
// detectRecordChanges compares each nameserver's records with those of
|
||||||
@@ -1064,22 +1108,6 @@ func (w *Watcher) checkTLSExpiry(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduplicate expiry warnings: don't re-notify for the same
|
|
||||||
// hostname within the TLS check interval.
|
|
||||||
dedupKey := fmt.Sprintf("expiry:%s:%s", hostname, ip)
|
|
||||||
|
|
||||||
w.expiryNotifiedMu.Lock()
|
|
||||||
|
|
||||||
lastNotified, seen := w.expiryNotified[dedupKey]
|
|
||||||
if seen && time.Since(lastNotified) < w.config.TLSInterval {
|
|
||||||
w.expiryNotifiedMu.Unlock()
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.expiryNotified[dedupKey] = time.Now()
|
|
||||||
w.expiryNotifiedMu.Unlock()
|
|
||||||
|
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"Host: %s\nIP: %s\nCN: %s\n"+
|
"Host: %s\nIP: %s\nCN: %s\n"+
|
||||||
"Expires: %s (%.0f days)",
|
"Expires: %s (%.0f days)",
|
||||||
|
|||||||
@@ -616,35 +616,56 @@ func TestTLSExpiryWarning(t *testing.T) {
|
|||||||
assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning")
|
assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTLSExpiryWarningDedup(t *testing.T) {
|
// TestTLSExpiryWarningEachCheck runs the TLS checks three times in a
|
||||||
|
// row on hostname and port state built here, for a certificate that
|
||||||
|
// expires within the warning period. Each check warns once, whether the
|
||||||
|
// TLS interval is a nanosecond, shorter than the time between two
|
||||||
|
// checks, or a day, longer than it.
|
||||||
|
func TestTLSExpiryWarningEachCheck(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
title := "TLS Expiry Warning: " + host
|
||||||
|
|
||||||
|
for _, interval := range []time.Duration{time.Nanosecond, 24 * time.Hour} {
|
||||||
|
t.Run(interval.String(), func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cfg := defaultTestConfig(t)
|
cfg := defaultTestConfig(t)
|
||||||
cfg.Hostnames = []string{testHost}
|
cfg.Hostnames = []string{host}
|
||||||
cfg.TLSInterval = 24 * time.Hour
|
cfg.TLSInterval = interval
|
||||||
|
|
||||||
title := "TLS Expiry Warning: " + testHost
|
// The TLS checks read the saved hostname and port state and
|
||||||
|
// look nothing up, so the watcher has no resolver.
|
||||||
|
deps := newTestDeps(t, cfg)
|
||||||
|
w := watcher.NewForTest(
|
||||||
|
cfg, deps.state, nil,
|
||||||
|
deps.portChecker, deps.tlsChecker, deps.notifier,
|
||||||
|
)
|
||||||
|
|
||||||
// The second check comes within the TLS interval of the first,
|
expiresInThreeDays(deps)
|
||||||
// so it must not warn again.
|
deps.state.SetHostnameState(host, saved(
|
||||||
var warnings int
|
map[string]*state.NameserverRecordState{
|
||||||
|
nsA: answered(map[string][]string{"A": {ip1}}),
|
||||||
deps := runChecks(t, cfg, expiresInThreeDays, func(deps *testDeps) {
|
},
|
||||||
warnings = countNotifications(deps, title)
|
))
|
||||||
|
deps.state.SetPortState(ip1+":443", &state.PortState{
|
||||||
|
Open: true, Hostnames: []string{host},
|
||||||
})
|
})
|
||||||
|
|
||||||
if warnings == 0 {
|
for check := 1; check <= 3; check++ {
|
||||||
t.Fatal("expected expiry warnings from the first check")
|
w.RunTLSChecks(t.Context())
|
||||||
}
|
|
||||||
|
|
||||||
got := countNotifications(deps, title)
|
got := countNotifications(deps, title)
|
||||||
if got != warnings {
|
if got != check {
|
||||||
t.Errorf(
|
t.Fatalf(
|
||||||
"expected %d expiry warnings (dedup), got %d",
|
"after check %d: %d expiry warnings, want %d",
|
||||||
warnings, got,
|
check, got, check,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGracefulShutdown(t *testing.T) {
|
func TestGracefulShutdown(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
Reference in New Issue
Block a user