forked from I2P_Developers/i2p.i2p
SSU2: Add per-introducer relay state machine
Enable relay Set session created token to 0 as per spec Log tweaks
This commit is contained in:
10
history.txt
10
history.txt
@@ -1,3 +1,13 @@
|
||||
2022-06-14 zzz
|
||||
* SSU2:
|
||||
- Add per-introducer relay state machine
|
||||
- Enable relay
|
||||
|
||||
2022-06-12 zzz
|
||||
* SSU2:
|
||||
- Fix peer test msg 1 signature
|
||||
- Relay fixes
|
||||
|
||||
2022-06-11 zzz
|
||||
* SSU: Don't send SSU1 relay request to SSU2 peer
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ public class RouterVersion {
|
||||
/** deprecated */
|
||||
public final static String ID = "Git";
|
||||
public final static String VERSION = CoreVersion.VERSION;
|
||||
public final static long BUILD = 5;
|
||||
public final static long BUILD = 6;
|
||||
|
||||
/** for example "-test" */
|
||||
public final static String EXTRA = "";
|
||||
|
||||
@@ -33,6 +33,7 @@ import net.i2p.router.transport.TransportUtil;
|
||||
import net.i2p.router.transport.crypto.DHSessionKeyBuilder;
|
||||
import static net.i2p.router.transport.udp.InboundEstablishState.InboundState.*;
|
||||
import static net.i2p.router.transport.udp.OutboundEstablishState.OutboundState.*;
|
||||
import static net.i2p.router.transport.udp.OutboundEstablishState2.IntroState.*;
|
||||
import static net.i2p.router.transport.udp.SSU2Util.*;
|
||||
import net.i2p.router.util.DecayingHashSet;
|
||||
import net.i2p.router.util.DecayingBloomFilter;
|
||||
@@ -1340,23 +1341,49 @@ class EstablishmentManager {
|
||||
_log.debug("Send relay request for " + state + " with our intro key as " + _transport.getIntroKey());
|
||||
state.introSent();
|
||||
} else {
|
||||
// walk through the state machine for each SSU2 introducer
|
||||
OutboundEstablishState2 state2 = (OutboundEstablishState2) state;
|
||||
// establish() above ensured there is at least one valid v2 introducer
|
||||
// Look for a connected peer, if found, use the first one only.
|
||||
long now = _context.clock().now();
|
||||
UDPAddress addr = state.getRemoteAddress();
|
||||
int count = addr.getIntroducerCount();
|
||||
for (int i = 0; i < count; i++) {
|
||||
Hash h = addr.getIntroducerHash(i);
|
||||
long exp = addr.getIntroducerExpiration(i);
|
||||
if (h != null && (exp > now || exp == 0)) {
|
||||
PeerState bob = _transport.getPeerState(h);
|
||||
// TODO cross-version relaying, maybe
|
||||
if (bob != null && bob.getVersion() == 2) {
|
||||
if (h != null) {
|
||||
PeerState bob = null;
|
||||
OutboundEstablishState2.IntroState istate = state2.getIntroState(h);
|
||||
switch (istate) {
|
||||
case INTRO_STATE_INIT:
|
||||
case INTRO_STATE_CONNECTING:
|
||||
bob = _transport.getPeerState(h);
|
||||
if (bob != null) {
|
||||
if (bob.getVersion() == 2) {
|
||||
istate = INTRO_STATE_CONNECTED;
|
||||
state2.setIntroState(h, istate);
|
||||
} else {
|
||||
// TODO cross-version relaying, maybe
|
||||
istate = INTRO_STATE_REJECTED;
|
||||
state2.setIntroState(h, istate);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case INTRO_STATE_CONNECTED:
|
||||
bob = _transport.getPeerState(h);
|
||||
if (bob == null) {
|
||||
istate = INTRO_STATE_DISCONNECTED;
|
||||
state2.setIntroState(h, istate);
|
||||
}
|
||||
break;
|
||||
|
||||
}
|
||||
if (bob != null && istate == INTRO_STATE_CONNECTED) {
|
||||
if (_log.shouldDebug())
|
||||
_log.debug("Found connected introducer " + bob + " for " + state);
|
||||
long tag = addr.getIntroducerTag(i);
|
||||
sendRelayRequest(tag, (PeerState2) bob, state);
|
||||
state.introSent();
|
||||
// this transitions the state
|
||||
state2.introSent(h);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1365,19 +1392,20 @@ class EstablishmentManager {
|
||||
boolean sent = false;
|
||||
for (int i = 0; i < count; i++) {
|
||||
Hash h = addr.getIntroducerHash(i);
|
||||
long exp = addr.getIntroducerExpiration(i);
|
||||
if (h != null && (exp > now || exp == 0)) {
|
||||
if (_context.banlist().isBanlisted(h))
|
||||
continue;
|
||||
PeerState bobState = _transport.getPeerState(h);
|
||||
if (bobState != null) {
|
||||
// presumably SSU1 or we would have used it above
|
||||
if (_log.shouldDebug())
|
||||
_log.debug("Skipping SSU1-connected introducer " + bobState + " for " + state);
|
||||
continue;
|
||||
if (h != null) {
|
||||
RouterInfo bob = null;
|
||||
OutboundEstablishState2.IntroState istate = state2.getIntroState(h);
|
||||
OutboundEstablishState2.IntroState oldState = istate;
|
||||
switch (istate) {
|
||||
case INTRO_STATE_INIT:
|
||||
case INTRO_STATE_LOOKUP_SENT:
|
||||
case INTRO_STATE_HAS_RI:
|
||||
bob = _context.netDb().lookupRouterInfoLocally(h);
|
||||
if (bob != null)
|
||||
istate = INTRO_STATE_HAS_RI;
|
||||
break;
|
||||
}
|
||||
RouterInfo bob = _context.netDb().lookupRouterInfoLocally(h);
|
||||
if (bob != null) {
|
||||
if (bob != null && istate == INTRO_STATE_HAS_RI) {
|
||||
List<RouterAddress> addrs = _transport.getTargetAddresses(bob);
|
||||
for (RouterAddress ra : addrs) {
|
||||
byte[] ip = ra.getIP();
|
||||
@@ -1399,16 +1427,23 @@ class EstablishmentManager {
|
||||
DatabaseLookupMessage dlm = new DatabaseLookupMessage(_context);
|
||||
dlm.setSearchKey(h);
|
||||
dlm.setSearchType(DatabaseLookupMessage.Type.RI);
|
||||
long now = _context.clock().now();
|
||||
dlm.setMessageExpiration(now + 10*1000);
|
||||
dlm.setFrom(_context.routerHash());
|
||||
OutNetMessage m = new OutNetMessage(_context, dlm, now + 10*1000, OutNetMessage.PRIORITY_MY_NETDB_LOOKUP, bob);
|
||||
establish(m);
|
||||
istate = INTRO_STATE_CONNECTING;
|
||||
// for now, just wait until this method is called again,
|
||||
// hopefully somebody has connected
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// if we didn't try to connect, it must have had a bad RI
|
||||
if (istate == INTRO_STATE_HAS_RI)
|
||||
istate = INTRO_STATE_REJECTED;
|
||||
if (oldState != istate)
|
||||
state2.setIntroState(h, istate);
|
||||
}
|
||||
}
|
||||
if (sent) {
|
||||
@@ -1419,15 +1454,17 @@ class EstablishmentManager {
|
||||
// Otherwise, look up the RIs first.
|
||||
for (int i = 0; i < count; i++) {
|
||||
Hash h = addr.getIntroducerHash(i);
|
||||
long exp = addr.getIntroducerExpiration(i);
|
||||
if (h != null && (exp > now || exp == 0)) {
|
||||
if (_context.banlist().isBanlisted(h))
|
||||
continue;
|
||||
if (_log.shouldDebug())
|
||||
_log.debug("Looking up introducer " + h + " for " + state);
|
||||
// TODO on success job
|
||||
_context.netDb().lookupRouterInfo(h, null, null, 10*1000);
|
||||
sent = true;
|
||||
if (h != null) {
|
||||
OutboundEstablishState2.IntroState istate = state2.getIntroState(h);
|
||||
if (istate == INTRO_STATE_INIT) {
|
||||
if (_log.shouldDebug())
|
||||
_log.debug("Looking up introducer " + h + " for " + state);
|
||||
istate = INTRO_STATE_LOOKUP_SENT;
|
||||
state2.setIntroState(h, istate);
|
||||
// TODO on success job
|
||||
_context.netDb().lookupRouterInfo(h, null, null, 10*1000);
|
||||
sent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sent) {
|
||||
@@ -1569,6 +1606,9 @@ class EstablishmentManager {
|
||||
_log.debug("Dup or unknown RelayResponse: " + nonce);
|
||||
return; // already established
|
||||
}
|
||||
if (charlie.getVersion() != 2)
|
||||
return;
|
||||
OutboundEstablishState2 charlie2 = (OutboundEstablishState2) charlie;
|
||||
long token;
|
||||
if (code == 0) {
|
||||
token = DataHelper.fromLong8(data, data.length - 8);
|
||||
@@ -1581,10 +1621,17 @@ class EstablishmentManager {
|
||||
RouterInfo bobRI = _context.netDb().lookupRouterInfoLocally(bobHash);
|
||||
RouterInfo charlieRI = _context.netDb().lookupRouterInfoLocally(charlieHash);
|
||||
Hash signer;
|
||||
if (code > 0 && code < 64)
|
||||
OutboundEstablishState2.IntroState istate;
|
||||
if (code > 0 && code < 64) {
|
||||
signer = bobHash;
|
||||
else
|
||||
istate = INTRO_STATE_BOB_REJECT;
|
||||
} else {
|
||||
signer = charlieHash;
|
||||
if (code == 0)
|
||||
istate = INTRO_STATE_SUCCESS;
|
||||
else
|
||||
istate = INTRO_STATE_CHARLIE_REJECT;
|
||||
}
|
||||
RouterInfo signerRI = _context.netDb().lookupRouterInfoLocally(signer);
|
||||
if (signerRI != null) {
|
||||
// validate signed data
|
||||
@@ -1594,6 +1641,7 @@ class EstablishmentManager {
|
||||
} else {
|
||||
if (_log.shouldWarn())
|
||||
_log.warn("Signature failed relay response " + code + " as alice from:\n" + signerRI);
|
||||
istate = INTRO_STATE_FAILED;
|
||||
}
|
||||
} else {
|
||||
if (_log.shouldWarn())
|
||||
@@ -1604,6 +1652,8 @@ class EstablishmentManager {
|
||||
if (iplen != 6 && iplen != 18) {
|
||||
if (_log.shouldWarn())
|
||||
_log.warn("Bad IP length " + iplen + " from " + charlie);
|
||||
istate = INTRO_STATE_FAILED;
|
||||
charlie2.setIntroState(bobHash, istate);
|
||||
charlie.fail();
|
||||
return;
|
||||
}
|
||||
@@ -1619,6 +1669,8 @@ class EstablishmentManager {
|
||||
if (_log.shouldLog(Log.WARN))
|
||||
_log.warn("Bad relay resp from " + charlie + " for " + Addresses.toString(ip, port));
|
||||
_context.statManager().addRateData("udp.relayBadIP", 1);
|
||||
istate = INTRO_STATE_FAILED;
|
||||
charlie2.setIntroState(bobHash, istate);
|
||||
charlie.fail();
|
||||
return;
|
||||
}
|
||||
@@ -1626,6 +1678,8 @@ class EstablishmentManager {
|
||||
try {
|
||||
charlieIP = InetAddress.getByAddress(ip);
|
||||
} catch (UnknownHostException uhe) {
|
||||
istate = INTRO_STATE_FAILED;
|
||||
charlie2.setIntroState(bobHash, istate);
|
||||
charlie.fail();
|
||||
return;
|
||||
}
|
||||
@@ -1635,6 +1689,7 @@ class EstablishmentManager {
|
||||
if (charlieRI == null) {
|
||||
if (_log.shouldWarn())
|
||||
_log.warn("Charlie RI not found " + charlie);
|
||||
charlie2.setIntroState(bobHash, istate);
|
||||
// maybe it will show up later
|
||||
return;
|
||||
}
|
||||
@@ -1659,11 +1714,13 @@ class EstablishmentManager {
|
||||
if (claimed != null)
|
||||
_outboundByClaimedAddress.remove(oldId, charlie); // only if == state
|
||||
}
|
||||
charlie2.setIntroState(bobHash, istate);
|
||||
notifyActivity();
|
||||
} else if (code >= 64) {
|
||||
// that's it
|
||||
if (_log.shouldDebug())
|
||||
_log.debug("Received RelayResponse rejection " + code + " from charlie " + charlie);
|
||||
charlie2.setIntroState(bobHash, istate);
|
||||
charlie.fail();
|
||||
_liveIntroductions.remove(lnonce);
|
||||
} else {
|
||||
@@ -1671,6 +1728,7 @@ class EstablishmentManager {
|
||||
// TODO keep track
|
||||
if (_log.shouldDebug())
|
||||
_log.debug("Received RelayResponse rejection " + code + " from bob " + bob);
|
||||
charlie2.setIntroState(bobHash, istate);
|
||||
notifyActivity();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import java.net.InetSocketAddress;
|
||||
import java.net.SocketAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.southernstorm.noise.protocol.ChaChaPolyCipherState;
|
||||
import com.southernstorm.noise.protocol.CipherState;
|
||||
@@ -43,6 +45,7 @@ class OutboundEstablishState2 extends OutboundEstablishState implements SSU2Payl
|
||||
private final long _sendConnID;
|
||||
private final long _rcvConnID;
|
||||
private final RouterAddress _routerAddress;
|
||||
private final Map<Hash, IntroState> _introducers;
|
||||
private long _token;
|
||||
private HandshakeState _handshakeState;
|
||||
private final byte[] _sendHeaderEncryptKey1;
|
||||
@@ -61,6 +64,58 @@ class OutboundEstablishState2 extends OutboundEstablishState implements SSU2Payl
|
||||
private static final boolean SET_TOKEN = false;
|
||||
private static final long MAX_SKEW = 2*60*1000L;
|
||||
|
||||
/**
|
||||
* Per-introducer introduction states
|
||||
* @since 0.9.55
|
||||
*/
|
||||
public enum IntroState {
|
||||
// pending states
|
||||
// we may transition from these to another state
|
||||
// See EstablishmentManager.handlePendingIntro() for state machine
|
||||
|
||||
/** nothing happened yet */
|
||||
INTRO_STATE_INIT,
|
||||
/** lookup for the introducer RI was sent */
|
||||
INTRO_STATE_LOOKUP_SENT,
|
||||
/** we have the introducer RI */
|
||||
INTRO_STATE_HAS_RI,
|
||||
/** we are connecting to the introducer */
|
||||
INTRO_STATE_CONNECTING,
|
||||
/** we are connected to this introducer */
|
||||
INTRO_STATE_CONNECTED,
|
||||
/** we sent the relay request to this introducer */
|
||||
INTRO_STATE_RELAY_REQUEST_SENT,
|
||||
/** we got a good relay response via this introducer */
|
||||
INTRO_STATE_RELAY_CHARLIE_ACCEPTED,
|
||||
|
||||
// final states
|
||||
// we do not transition from these states
|
||||
|
||||
/** introducer has expired */
|
||||
INTRO_STATE_EXPIRED,
|
||||
/** we tried to lookup the introducer RI, no luck */
|
||||
INTRO_STATE_LOOKUP_FAILED,
|
||||
/** we rejected this introducer for some reason */
|
||||
INTRO_STATE_REJECTED,
|
||||
/** we failed to connect to the introducer */
|
||||
INTRO_STATE_CONNECT_FAILED,
|
||||
/** he disconnected from us along the way */
|
||||
INTRO_STATE_DISCONNECTED,
|
||||
/** we failed to get a relay response from this introducer */
|
||||
INTRO_STATE_RELAY_RESPONSE_TIMEOUT,
|
||||
/** we got a rejection from this introducer */
|
||||
INTRO_STATE_BOB_REJECT,
|
||||
/** we got a rejection from Charlie via this introducer */
|
||||
INTRO_STATE_CHARLIE_REJECT,
|
||||
/** unspecified failure */
|
||||
INTRO_STATE_FAILED,
|
||||
/** this peer is not an introducer */
|
||||
INTRO_STATE_INVALID,
|
||||
/** we got an accept from Charlie via this introducer */
|
||||
INTRO_STATE_SUCCESS
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Prepare to start a new handshake with the given peer.
|
||||
*
|
||||
@@ -112,9 +167,27 @@ class OutboundEstablishState2 extends OutboundEstablishState implements SSU2Payl
|
||||
}
|
||||
_mtu = mtu;
|
||||
_routerAddress = ra;
|
||||
if (addr.getIntroducerCount() > 0) {
|
||||
int intros = addr.getIntroducerCount();
|
||||
if (intros > 0) {
|
||||
_currentState = OutboundState.OB_STATE_PENDING_INTRO;
|
||||
// we will get a token in the relay response or hole punch
|
||||
_introducers = new HashMap<Hash, IntroState>(4);
|
||||
// Initial setup of per-introducer state tracking.
|
||||
// See EstablishmentManager.handlePendingIntro() for state machine
|
||||
for (int i = 0; i < intros; i++) {
|
||||
Hash h = addr.getIntroducerHash(i);
|
||||
if (h != null) {
|
||||
IntroState istate;
|
||||
long exp = addr.getIntroducerExpiration(i);
|
||||
if (exp != 0 && exp < _establishBegin)
|
||||
istate = IntroState.INTRO_STATE_EXPIRED;
|
||||
else if (_context.banlist().isBanlisted(h))
|
||||
istate = IntroState.INTRO_STATE_REJECTED;
|
||||
else
|
||||
istate = IntroState.INTRO_STATE_INIT;
|
||||
_introducers.put(h, istate);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_token = _transport.getEstablisher().getOutboundToken(_remoteHostId);
|
||||
if (_token != 0) {
|
||||
@@ -123,6 +196,7 @@ class OutboundEstablishState2 extends OutboundEstablishState implements SSU2Payl
|
||||
} else {
|
||||
_currentState = OutboundState.OB_STATE_NEEDS_TOKEN;
|
||||
}
|
||||
_introducers = null;
|
||||
}
|
||||
|
||||
_sendConnID = ctx.random().nextLong();
|
||||
@@ -562,12 +636,56 @@ class OutboundEstablishState2 extends OutboundEstablishState implements SSU2Payl
|
||||
return _pstate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return non-null current state for the SSU2 introducer specified,
|
||||
* or INTRO_STATE_INVALID if peer is not an SSU2 introducer
|
||||
* @since 0.9.55
|
||||
*/
|
||||
public IntroState getIntroState(Hash h) {
|
||||
IntroState rv;
|
||||
if (_introducers == null) {
|
||||
rv = IntroState.INTRO_STATE_INVALID;
|
||||
} else {
|
||||
synchronized(_introducers) {
|
||||
rv = _introducers.get(h);
|
||||
}
|
||||
if (rv == null)
|
||||
rv = IntroState.INTRO_STATE_INVALID;
|
||||
}
|
||||
return rv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current state for the SSU2 introducer specified
|
||||
* @since 0.9.55
|
||||
*/
|
||||
public void setIntroState(Hash h, IntroState state) {
|
||||
if (_introducers == null)
|
||||
return;
|
||||
IntroState old;
|
||||
synchronized(_introducers) {
|
||||
old = _introducers.put(h, state);
|
||||
}
|
||||
if (_log.shouldDebug())
|
||||
_log.debug("Change state for introducer " + h.toBase64() + " from " + old + " to " + state + " on " + this);
|
||||
}
|
||||
|
||||
/**
|
||||
* A relay request was sent to the SSU2 introducer specified
|
||||
* @since 0.9.55
|
||||
*/
|
||||
public void introSent(Hash h) {
|
||||
setIntroState(h, IntroState.INTRO_STATE_RELAY_REQUEST_SENT);
|
||||
introSent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "OES2 " + _remotePeer.getHash().toBase64().substring(0, 6) + ' ' + _remoteHostId +
|
||||
" lifetime: " + DataHelper.formatDuration(getLifetime()) +
|
||||
" Rcv ID: " + _rcvConnID +
|
||||
" Send ID: " + _sendConnID +
|
||||
' ' + _currentState;
|
||||
' ' + _currentState +
|
||||
(_introducers != null ? (" Introducers: " + _introducers.toString()) : "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,7 +398,7 @@ class PacketBuilder2 {
|
||||
public UDPPacket buildSessionCreatedPacket(InboundEstablishState2 state) {
|
||||
long n = _context.random().signedNextInt() & 0xFFFFFFFFL;
|
||||
UDPPacket packet = buildLongPacketHeader(state.getSendConnID(), n, SESSION_CREATED_FLAG_BYTE,
|
||||
state.getRcvConnID(), state.getToken());
|
||||
state.getRcvConnID(), 0);
|
||||
DatagramPacket pkt = packet.getPacket();
|
||||
|
||||
byte sentIP[] = state.getSentIP();
|
||||
@@ -937,10 +937,11 @@ class PacketBuilder2 {
|
||||
}
|
||||
|
||||
/**
|
||||
* Also used for hole punch with a relay request block.
|
||||
* Also used for retry with ptBlock = null
|
||||
*
|
||||
* @param packet containing only 32 byte header
|
||||
* @param ptBlock null for retry
|
||||
* @param ptBlock Peer Test or Relay Request block. Null for retry.
|
||||
*/
|
||||
private void encryptPeerTest(UDPPacket packet, byte[] chachaKey, long n,
|
||||
byte[] hdrKey1, byte[] hdrKey2, byte[] ip, int port,
|
||||
@@ -975,12 +976,12 @@ class PacketBuilder2 {
|
||||
pkt.setLength(pkt.getLength() + len + MAC_LEN);
|
||||
} catch (RuntimeException re) {
|
||||
if (!_log.shouldWarn())
|
||||
_log.error("Bad retry/test msg out", re);
|
||||
_log.error("Bad retry/test/holepunch msg out", re);
|
||||
throw re;
|
||||
} catch (GeneralSecurityException gse) {
|
||||
if (!_log.shouldWarn())
|
||||
_log.error("Bad retry/test msg out", gse);
|
||||
throw new RuntimeException("Bad retry/test msg out", gse);
|
||||
_log.error("Bad retry/test/holepunch msg out", gse);
|
||||
throw new RuntimeException("Bad retry/test/holepunch msg out", gse);
|
||||
}
|
||||
SSU2Header.encryptLongHeader(packet, hdrKey1, hdrKey2);
|
||||
}
|
||||
|
||||
@@ -577,7 +577,7 @@ public class PeerState2 extends PeerState implements SSU2Payload.PayloadCallback
|
||||
// IllegalArgumentException, buggy ack block, let the other blocks get processed
|
||||
if (_log.shouldWarn())
|
||||
_log.warn("Bad ACK block\n" + SSU2Bitfield.toString(ackThru, acks, ranges, (ranges != null ? ranges.length / 2 : 0)) +
|
||||
"\nAck through " + ackThru + " acnt " + acks + (ranges != null ? "Ranges:\n" + HexDump.dump(ranges) : "") +
|
||||
"\nAck through " + ackThru + " acnt " + acks + (ranges != null ? " Ranges:\n" + HexDump.dump(ranges) : "") +
|
||||
"from " + this, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ final class SSU2Util {
|
||||
public static final int PROTOCOL_VERSION = 2;
|
||||
|
||||
// features
|
||||
public static final boolean ENABLE_RELAY = false;
|
||||
public static final boolean ENABLE_RELAY = true;
|
||||
public static final boolean ENABLE_PEER_TEST = true;
|
||||
public static final boolean ENABLE_PATH_CHALLENGE = false;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user