refactor(maestro): typed perimeter advertisement replaces Option pair (NAP-272)
ci / rust (push) Failing after 5m58s
ci / rust (push) Failing after 5m58s
`(perimeter_id, private_addr)` was carried as two `Option<String>`s with an
unwritten "both Some or both None" invariant, enforced ad-hoc at every reader
and silently violable (`with_perimeter(Some, None)`).
Collapse it to `Option<PerimeterCandidate>` (reusing the existing protocol
type, so it flows unconverted from Heartbeat → registry → AttachCandidates):
- protocol: `Heartbeat { perimeter: Option<PerimeterCandidate> }` (empty still
serializes to the legacy `{"type":"Heartbeat"}` shape).
- registry: `DaemonEntry.perimeter`, `update_perimeter`, `perimeter_for` lose
the defensive AND-check; `build_attach_candidates` uses it directly.
- daemon: `RegistryClient.with_perimeter(Option<PerimeterCandidate>)`; the
both-or-neither rule now lives once, in `DaemonConfig::perimeter()`, which
warns and drops a half-configured pair instead of advertising it.
Unblocked by NAP-270's coverage; broker T2 + perimeter-mismatch e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -131,7 +131,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
manager.clone(),
|
manager.clone(),
|
||||||
Some(events.clone()),
|
Some(events.clone()),
|
||||||
)
|
)
|
||||||
.with_perimeter(config.perimeter_id.clone(), config.private_addr.clone());
|
.with_perimeter(config.perimeter());
|
||||||
let catalog = client.catalog();
|
let catalog = client.catalog();
|
||||||
|
|
||||||
let local_state = local_api::state_for(
|
let local_state = local_api::state_for(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use std::net::SocketAddr;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use maestro_config::{derive_daemon_ws_url, MaestroConfigFile};
|
use maestro_config::{derive_daemon_ws_url, MaestroConfigFile};
|
||||||
|
use maestro_protocol::PerimeterCandidate;
|
||||||
|
|
||||||
pub use maestro_config::{
|
pub use maestro_config::{
|
||||||
default_config_path, resolve_config_path, DaemonSection, SharedSection, CONFIG_PATH_ENV,
|
default_config_path, resolve_config_path, DaemonSection, SharedSection, CONFIG_PATH_ENV,
|
||||||
@@ -107,6 +108,30 @@ impl DaemonConfig {
|
|||||||
pub fn fifo_dir(&self) -> PathBuf {
|
pub fn fifo_dir(&self) -> PathBuf {
|
||||||
self.state_dir.join("fifos")
|
self.state_dir.join("fifos")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Combine the two perimeter config keys into the typed advertisement the
|
||||||
|
/// daemon broadcasts. A perimeter is only advertised when BOTH the id and
|
||||||
|
/// the private address are set — this is the single place the "both or
|
||||||
|
/// neither" invariant lives (NAP-272). A half-configured pair is dropped
|
||||||
|
/// with a warning rather than silently advertising a useless value.
|
||||||
|
pub fn perimeter(&self) -> Option<PerimeterCandidate> {
|
||||||
|
match (self.perimeter_id.clone(), self.private_addr.clone()) {
|
||||||
|
(Some(perimeter_id), Some(private_addr)) => Some(PerimeterCandidate {
|
||||||
|
perimeter_id,
|
||||||
|
private_addr,
|
||||||
|
}),
|
||||||
|
(None, None) => None,
|
||||||
|
(id, addr) => {
|
||||||
|
tracing::warn!(
|
||||||
|
perimeter_id = ?id,
|
||||||
|
private_addr = ?addr,
|
||||||
|
"ignoring half-configured perimeter — set both [daemon].perimeter_id and \
|
||||||
|
[daemon].private_addr, or neither"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Env-var overrides applied by [`load_config`]. Tests pass explicit
|
/// Env-var overrides applied by [`load_config`]. Tests pass explicit
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_protocol::{DaemonInbound, DaemonOutbound, RpcResult, PROTOCOL_VERSION};
|
use maestro_protocol::{
|
||||||
|
DaemonInbound, DaemonOutbound, PerimeterCandidate, RpcResult, PROTOCOL_VERSION,
|
||||||
|
};
|
||||||
use tokio::sync::broadcast::error::RecvError;
|
use tokio::sync::broadcast::error::RecvError;
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
|
||||||
@@ -47,11 +49,10 @@ pub struct RegistryClient {
|
|||||||
hostname: String,
|
hostname: String,
|
||||||
daemon_url: String,
|
daemon_url: String,
|
||||||
token: String,
|
token: String,
|
||||||
/// Operator-set perimeter id advertised in heartbeats so peer daemons
|
/// Operator-set perimeter (id + reachable address) advertised in heartbeats
|
||||||
/// can dial this one over T2 when they share the same perimeter.
|
/// so peer daemons can dial this one over T2 when they share the same
|
||||||
perimeter_id: Option<String>,
|
/// perimeter. Typed so "both or neither" holds by construction (NAP-272).
|
||||||
/// Daemon's reachable address on the declared perimeter.
|
perimeter: Option<PerimeterCandidate>,
|
||||||
private_addr: Option<String>,
|
|
||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
/// Local mirror of the global catalog (peer daemons + peer sessions),
|
/// Local mirror of the global catalog (peer daemons + peer sessions),
|
||||||
/// populated from `DaemonInbound::CatalogEvent` frames the registry
|
/// populated from `DaemonInbound::CatalogEvent` frames the registry
|
||||||
@@ -108,8 +109,7 @@ impl RegistryClient {
|
|||||||
hostname: hostname.into(),
|
hostname: hostname.into(),
|
||||||
daemon_url: daemon_url.into(),
|
daemon_url: daemon_url.into(),
|
||||||
token: token.into(),
|
token: token.into(),
|
||||||
perimeter_id: None,
|
perimeter: None,
|
||||||
private_addr: None,
|
|
||||||
manager,
|
manager,
|
||||||
catalog,
|
catalog,
|
||||||
broker,
|
broker,
|
||||||
@@ -129,15 +129,11 @@ impl RegistryClient {
|
|||||||
self.catalog.clone()
|
self.catalog.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the perimeter advertised in heartbeats. Pass both or neither —
|
/// Set the perimeter advertised in heartbeats. The typed argument encodes
|
||||||
/// a private address without a perimeter id is meaningless.
|
/// "both id and addr, or neither" — a private address without a perimeter
|
||||||
pub fn with_perimeter(
|
/// id is no longer representable (NAP-272).
|
||||||
mut self,
|
pub fn with_perimeter(mut self, perimeter: Option<PerimeterCandidate>) -> Self {
|
||||||
perimeter_id: Option<String>,
|
self.perimeter = perimeter;
|
||||||
private_addr: Option<String>,
|
|
||||||
) -> Self {
|
|
||||||
self.perimeter_id = perimeter_id;
|
|
||||||
self.private_addr = private_addr;
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,8 +238,7 @@ impl RegistryClient {
|
|||||||
_ = hb.tick() => {
|
_ = hb.tick() => {
|
||||||
sink.send(Message::Text(serde_json::to_string(
|
sink.send(Message::Text(serde_json::to_string(
|
||||||
&DaemonOutbound::Heartbeat {
|
&DaemonOutbound::Heartbeat {
|
||||||
perimeter_id: self.perimeter_id.clone(),
|
perimeter: self.perimeter.clone(),
|
||||||
private_addr: self.private_addr.clone(),
|
|
||||||
},
|
},
|
||||||
)?)).await?;
|
)?)).await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,14 +113,13 @@ pub enum DaemonOutbound {
|
|||||||
SessionUpdated {
|
SessionUpdated {
|
||||||
session: Session,
|
session: Session,
|
||||||
},
|
},
|
||||||
/// Liveness ticker. The optional `perimeter_id` / `private_addr` advertise
|
/// Liveness ticker. The optional `perimeter` advertises the daemon's
|
||||||
/// the daemon's reachability for the T2 transport (NAP-242). Old daemons
|
/// reachability for the T2 transport (NAP-242); the typed object encodes the
|
||||||
/// that don't set them still produce the bare `{"type":"Heartbeat"}` shape.
|
/// "both id and addr, or neither" invariant once (NAP-272). Old daemons that
|
||||||
|
/// don't set it still produce the bare `{"type":"Heartbeat"}` shape.
|
||||||
Heartbeat {
|
Heartbeat {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
perimeter_id: Option<String>,
|
perimeter: Option<PerimeterCandidate>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
private_addr: Option<String>,
|
|
||||||
},
|
},
|
||||||
CreateSessionResult {
|
CreateSessionResult {
|
||||||
reply_to: ReplyTo,
|
reply_to: ReplyTo,
|
||||||
@@ -522,13 +521,12 @@ mod tests {
|
|||||||
DaemonOutbound::SessionUpdated {
|
DaemonOutbound::SessionUpdated {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
},
|
},
|
||||||
|
DaemonOutbound::Heartbeat { perimeter: None },
|
||||||
DaemonOutbound::Heartbeat {
|
DaemonOutbound::Heartbeat {
|
||||||
perimeter_id: None,
|
perimeter: Some(PerimeterCandidate {
|
||||||
private_addr: None,
|
perimeter_id: "wg-naps62".into(),
|
||||||
},
|
private_addr: "10.0.0.7:7070".into(),
|
||||||
DaemonOutbound::Heartbeat {
|
}),
|
||||||
perimeter_id: Some("wg-naps62".into()),
|
|
||||||
private_addr: Some("10.0.0.7:7070".into()),
|
|
||||||
},
|
},
|
||||||
DaemonOutbound::CreateSessionResult {
|
DaemonOutbound::CreateSessionResult {
|
||||||
reply_to,
|
reply_to,
|
||||||
@@ -690,12 +688,8 @@ mod tests {
|
|||||||
let legacy = serde_json::json!({ "type": "Heartbeat" });
|
let legacy = serde_json::json!({ "type": "Heartbeat" });
|
||||||
let parsed: DaemonOutbound = serde_json::from_value(legacy).unwrap();
|
let parsed: DaemonOutbound = serde_json::from_value(legacy).unwrap();
|
||||||
match parsed {
|
match parsed {
|
||||||
DaemonOutbound::Heartbeat {
|
DaemonOutbound::Heartbeat { perimeter } => {
|
||||||
perimeter_id,
|
assert!(perimeter.is_none());
|
||||||
private_addr,
|
|
||||||
} => {
|
|
||||||
assert!(perimeter_id.is_none());
|
|
||||||
assert!(private_addr.is_none());
|
|
||||||
}
|
}
|
||||||
other => panic!("expected Heartbeat, got {other:?}"),
|
other => panic!("expected Heartbeat, got {other:?}"),
|
||||||
}
|
}
|
||||||
@@ -703,13 +697,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn heartbeat_empty_serializes_to_legacy_shape() {
|
fn heartbeat_empty_serializes_to_legacy_shape() {
|
||||||
// A new daemon that doesn't set perimeter/addr must produce wire bytes
|
// A new daemon that doesn't set a perimeter must produce wire bytes
|
||||||
// identical to an old daemon — registries doing equality checks or
|
// identical to an old daemon — registries doing equality checks or
|
||||||
// logging payloads see no surprise.
|
// logging payloads see no surprise.
|
||||||
let hb = DaemonOutbound::Heartbeat {
|
let hb = DaemonOutbound::Heartbeat { perimeter: None };
|
||||||
perimeter_id: None,
|
|
||||||
private_addr: None,
|
|
||||||
};
|
|
||||||
let v = serde_json::to_value(&hb).unwrap();
|
let v = serde_json::to_value(&hb).unwrap();
|
||||||
assert_eq!(v, serde_json::json!({ "type": "Heartbeat" }));
|
assert_eq!(v, serde_json::json!({ "type": "Heartbeat" }));
|
||||||
}
|
}
|
||||||
@@ -717,13 +708,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn heartbeat_with_fields_round_trips() {
|
fn heartbeat_with_fields_round_trips() {
|
||||||
let hb = DaemonOutbound::Heartbeat {
|
let hb = DaemonOutbound::Heartbeat {
|
||||||
perimeter_id: Some("wg-naps62".into()),
|
perimeter: Some(PerimeterCandidate {
|
||||||
private_addr: Some("10.0.0.7:7070".into()),
|
perimeter_id: "wg-naps62".into(),
|
||||||
|
private_addr: "10.0.0.7:7070".into(),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
let v = serde_json::to_value(&hb).unwrap();
|
let v = serde_json::to_value(&hb).unwrap();
|
||||||
assert_eq!(v["type"], "Heartbeat");
|
assert_eq!(v["type"], "Heartbeat");
|
||||||
assert_eq!(v["perimeter_id"], "wg-naps62");
|
assert_eq!(v["perimeter"]["perimeter_id"], "wg-naps62");
|
||||||
assert_eq!(v["private_addr"], "10.0.0.7:7070");
|
assert_eq!(v["perimeter"]["private_addr"], "10.0.0.7:7070");
|
||||||
assert_eq!(hb, round_trip(&hb));
|
assert_eq!(hb, round_trip(&hb));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -191,16 +191,11 @@ async fn dispatch_text(
|
|||||||
DaemonOutbound::SessionUpdated { session } => {
|
DaemonOutbound::SessionUpdated { session } => {
|
||||||
registry.update_session(daemon_id, session).await;
|
registry.update_session(daemon_id, session).await;
|
||||||
}
|
}
|
||||||
DaemonOutbound::Heartbeat {
|
DaemonOutbound::Heartbeat { perimeter } => {
|
||||||
perimeter_id,
|
|
||||||
private_addr,
|
|
||||||
} => {
|
|
||||||
// Per-read timeout above is the heartbeat watchdog; capture the
|
// Per-read timeout above is the heartbeat watchdog; capture the
|
||||||
// perimeter advertisement so the broker can hand it back as a T2
|
// perimeter advertisement so the broker can hand it back as a T2
|
||||||
// candidate (NAP-247).
|
// candidate (NAP-247).
|
||||||
registry
|
registry.update_perimeter(daemon_id, perimeter).await;
|
||||||
.update_perimeter(daemon_id, perimeter_id, private_addr)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
DaemonOutbound::CreateSessionResult { reply_to, result } => {
|
DaemonOutbound::CreateSessionResult { reply_to, result } => {
|
||||||
registry.complete_create(reply_to, result).await;
|
registry.complete_create(reply_to, result).await;
|
||||||
|
|||||||
@@ -121,11 +121,10 @@ struct DaemonEntry {
|
|||||||
version: String,
|
version: String,
|
||||||
daemon_url: String,
|
daemon_url: String,
|
||||||
outbound: mpsc::Sender<DaemonInbound>,
|
outbound: mpsc::Sender<DaemonInbound>,
|
||||||
/// Latest perimeter advertised by this daemon in its heartbeats.
|
/// Latest perimeter advertised by this daemon in its heartbeats (id + addr
|
||||||
/// `None` until the daemon advertises one (NAP-247 T2 dial target).
|
/// together, or absent). `None` until the daemon advertises one (NAP-247 T2
|
||||||
perimeter_id: Option<String>,
|
/// dial target; typed to encode the both-or-neither invariant, NAP-272).
|
||||||
/// Latest private address advertised on the declared perimeter.
|
perimeter: Option<PerimeterCandidate>,
|
||||||
private_addr: Option<String>,
|
|
||||||
/// Fencing token minted per WS connection in [`Registry::register`]. Only
|
/// Fencing token minted per WS connection in [`Registry::register`]. Only
|
||||||
/// the handler holding the matching token may [`Registry::deregister`] this
|
/// the handler holding the matching token may [`Registry::deregister`] this
|
||||||
/// entry, so a stale handler (old socket timing out after the daemon
|
/// entry, so a stale handler (old socket timing out after the daemon
|
||||||
@@ -206,8 +205,7 @@ impl Registry {
|
|||||||
version: version.clone(),
|
version: version.clone(),
|
||||||
daemon_url: daemon_url.clone(),
|
daemon_url: daemon_url.clone(),
|
||||||
outbound,
|
outbound,
|
||||||
perimeter_id: None,
|
perimeter: None,
|
||||||
private_addr: None,
|
|
||||||
connection_token,
|
connection_token,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -334,30 +332,23 @@ impl Registry {
|
|||||||
|
|
||||||
/// Update a daemon's advertised perimeter from its heartbeat. Called by
|
/// Update a daemon's advertised perimeter from its heartbeat. Called by
|
||||||
/// the WS handler on every `Heartbeat` frame (NAP-247).
|
/// the WS handler on every `Heartbeat` frame (NAP-247).
|
||||||
pub async fn update_perimeter(
|
pub async fn update_perimeter(&self, daemon_id: &str, perimeter: Option<PerimeterCandidate>) {
|
||||||
&self,
|
|
||||||
daemon_id: &str,
|
|
||||||
perimeter_id: Option<String>,
|
|
||||||
private_addr: Option<String>,
|
|
||||||
) {
|
|
||||||
let mut state = self.inner.lock().await;
|
let mut state = self.inner.lock().await;
|
||||||
if let Some(entry) = state.daemons.get_mut(daemon_id) {
|
if let Some(entry) = state.daemons.get_mut(daemon_id) {
|
||||||
entry.perimeter_id = perimeter_id;
|
entry.perimeter = perimeter;
|
||||||
entry.private_addr = private_addr;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `(perimeter_id, private_addr)` last advertised by this daemon, when
|
/// The perimeter (`perimeter_id` + `private_addr`) last advertised by this
|
||||||
/// both are set. The broker uses this to assemble `AttachCandidates` for
|
/// daemon, when set. The broker uses this to assemble `AttachCandidates`
|
||||||
/// the T2 transport (NAP-247).
|
/// for the T2 transport (NAP-247). The both-or-neither invariant is encoded
|
||||||
pub async fn perimeter_for(&self, daemon_id: &str) -> Option<(String, String)> {
|
/// in the type, so no defensive AND-check is needed (NAP-272).
|
||||||
|
pub async fn perimeter_for(&self, daemon_id: &str) -> Option<PerimeterCandidate> {
|
||||||
let state = self.inner.lock().await;
|
let state = self.inner.lock().await;
|
||||||
state.daemons.get(daemon_id).and_then(|e| {
|
state
|
||||||
match (e.perimeter_id.clone(), e.private_addr.clone()) {
|
.daemons
|
||||||
(Some(pid), Some(addr)) => Some((pid, addr)),
|
.get(daemon_id)
|
||||||
_ => None,
|
.and_then(|e| e.perimeter.clone())
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a `ResolveAttach` from a home daemon (NAP-247). Look up which
|
/// Handle a `ResolveAttach` from a home daemon (NAP-247). Look up which
|
||||||
@@ -427,13 +418,7 @@ impl Registry {
|
|||||||
// T1 (direct WSS) stays None — NAP-235 was canceled.
|
// T1 (direct WSS) stays None — NAP-235 was canceled.
|
||||||
// T2: hand back the target's perimeter so the home daemon can match.
|
// T2: hand back the target's perimeter so the home daemon can match.
|
||||||
// T3 (relay) stays None — NAP-238 spike pending.
|
// T3 (relay) stays None — NAP-238 spike pending.
|
||||||
let perimeter = self
|
let perimeter = self.perimeter_for(&target).await;
|
||||||
.perimeter_for(&target)
|
|
||||||
.await
|
|
||||||
.map(|(pid, addr)| PerimeterCandidate {
|
|
||||||
perimeter_id: pid,
|
|
||||||
private_addr: addr,
|
|
||||||
});
|
|
||||||
RpcResult::Ok {
|
RpcResult::Ok {
|
||||||
value: AttachCandidates {
|
value: AttachCandidates {
|
||||||
direct_wss: None,
|
direct_wss: None,
|
||||||
@@ -720,10 +705,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The `(perimeter_id, private_addr)` pair only yields a candidate when
|
/// The `(perimeter_id, private_addr)` pair only yields a candidate when
|
||||||
/// BOTH halves are advertised — a half-configured daemon must not leak a
|
/// The perimeter is advertised as one typed object, so "both id and addr,
|
||||||
/// T2 dial target (NAP-270).
|
/// or neither" is enforced by construction — `perimeter_for` round-trips
|
||||||
|
/// `None` and a full advertisement, and a half-pair is unrepresentable
|
||||||
|
/// (NAP-270 / NAP-272).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn perimeter_for_requires_both_id_and_addr() {
|
async fn perimeter_for_round_trips_typed_advertisement() {
|
||||||
let reg = Registry::new();
|
let reg = Registry::new();
|
||||||
let (_t, _rx) = register(®, "d1").await;
|
let (_t, _rx) = register(®, "d1").await;
|
||||||
|
|
||||||
@@ -732,22 +719,16 @@ mod tests {
|
|||||||
"nothing advertised"
|
"nothing advertised"
|
||||||
);
|
);
|
||||||
|
|
||||||
reg.update_perimeter("d1", Some("wg".into()), None).await;
|
let adv = PerimeterCandidate {
|
||||||
assert!(
|
perimeter_id: "wg".into(),
|
||||||
reg.perimeter_for("d1").await.is_none(),
|
private_addr: "10.0.0.1:7070".into(),
|
||||||
"addr missing → None"
|
};
|
||||||
);
|
reg.update_perimeter("d1", Some(adv.clone())).await;
|
||||||
|
assert_eq!(reg.perimeter_for("d1").await, Some(adv));
|
||||||
|
|
||||||
reg.update_perimeter("d1", None, Some("10.0.0.1:7070".into()))
|
// Clearing it goes back to None.
|
||||||
.await;
|
reg.update_perimeter("d1", None).await;
|
||||||
assert!(reg.perimeter_for("d1").await.is_none(), "id missing → None");
|
assert!(reg.perimeter_for("d1").await.is_none());
|
||||||
|
|
||||||
reg.update_perimeter("d1", Some("wg".into()), Some("10.0.0.1:7070".into()))
|
|
||||||
.await;
|
|
||||||
assert_eq!(
|
|
||||||
reg.perimeter_for("d1").await,
|
|
||||||
Some(("wg".into(), "10.0.0.1:7070".into()))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A home daemon asking to attach to a session it already owns is refused
|
/// A home daemon asking to attach to a session it already owns is refused
|
||||||
|
|||||||
Reference in New Issue
Block a user