mirror of
https://github.com/telemt/telemt.git
synced 2026-10-30 04:20:31 +03:00
Bounded Debugging + Websocket Carriers + Carriers Negotiation
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
+143
-136
@@ -1,6 +1,5 @@
|
||||
use base64::Engine as _;
|
||||
|
||||
use crate::config::WebCarrier;
|
||||
use crate::crypto::SecureRandom;
|
||||
|
||||
/// Browser security policy for the transient Telegram Desktop bridge page.
|
||||
@@ -14,14 +13,17 @@ pub(crate) struct BridgePage {
|
||||
pub(crate) content_security_policy: String,
|
||||
}
|
||||
|
||||
/// Renders the selected HTTPS WEB carrier bridge with a fresh CSP nonce.
|
||||
/// Renders the bounded WEB carrier-negotiation bridge with a fresh CSP nonce.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn render(
|
||||
host: &str,
|
||||
bootstrap: &str,
|
||||
batch_limit: usize,
|
||||
queue_limit: usize,
|
||||
queue_items: usize,
|
||||
carrier: WebCarrier,
|
||||
negotiation_enabled: bool,
|
||||
candidate_count: usize,
|
||||
carrier_deadlines: [u64; 4],
|
||||
rng: &SecureRandom,
|
||||
) -> BridgePage {
|
||||
let mut nonce = [0u8; 18];
|
||||
@@ -34,7 +36,19 @@ pub(crate) fn render(
|
||||
.replace("__BATCH_LIMIT__", &batch_limit.to_string())
|
||||
.replace("__QUEUE_LIMIT__", &queue_limit.to_string())
|
||||
.replace("__QUEUE_ITEMS__", &queue_items.to_string())
|
||||
.replace("__CARRIER__", carrier.as_str());
|
||||
.replace(
|
||||
"__NEGOTIATION_ENABLED__",
|
||||
if negotiation_enabled { "true" } else { "false" },
|
||||
)
|
||||
.replace("__CANDIDATE_COUNT__", &candidate_count.to_string())
|
||||
.replace(
|
||||
"__CARRIER_DEADLINES__",
|
||||
&carrier_deadlines
|
||||
.iter()
|
||||
.map(u64::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
);
|
||||
BridgePage {
|
||||
body,
|
||||
content_security_policy: format!(
|
||||
@@ -54,13 +68,18 @@ const DOCUMENT: &str = r##"<!doctype html>
|
||||
<script nonce="__NONCE__">
|
||||
(()=>{
|
||||
'use strict';
|
||||
const relayOrigin='https://__HOST__',bootstrap="__BOOTSTRAP__",carrier='__CARRIER__';
|
||||
const bootstrap="__BOOTSTRAP__";
|
||||
const relayOrigin='https://__HOST__',carrierCapabilities='https,https-lanes,websocket,websocket-lanes';
|
||||
const negotiationEnabled=__NEGOTIATION_ENABLED__,candidateCount=__CANDIDATE_COUNT__,candidateDeadlines=[__CARRIER_DEADLINES__];
|
||||
const effectiveDeadlines=candidateDeadlines.slice(0,candidateCount-1).concat(candidateDeadlines[3]);
|
||||
const batchLimit=__BATCH_LIMIT__,queueLimit=__QUEUE_LIMIT__,queueItemLimit=__QUEUE_ITEMS__;
|
||||
const laneQueueLimit=Math.min(queueLimit,8388608),laneItemLimit=Math.min(queueItemLimit,1024),closedLaneLimit=4096;
|
||||
const fragment=location.hash,androidNonce=/^#android=([A-Za-z0-9_-]{43})$/.exec(fragment)?.[1]||'';
|
||||
history.replaceState(null,'',location.pathname);
|
||||
let initialized=false,closed=false,port=null,sessionToken='',createStarted=false,socket=null,socketReady=false;
|
||||
let initialized=false,closed=false,port=null,sessionToken='',createStarted=false,socket=null,socketReady=false,carrier='';
|
||||
let queuedBytes=0,queuedItems=0,upSequence=1,downCursor='0',upRunning=false,pollController=null;
|
||||
let helloFrame=null,welcomeSent=false,carrierAttempt=1,carrierFailure='',carrierCommitted=false;
|
||||
let negotiationStartedAt=0,carrierTimer=null,attemptController=null,attemptEpoch=1,candidateRunning=false,switching=false;
|
||||
const pending=[],upPending=[],lanes=new Map(),closedLanes=new Set(),closedLaneOrder=[];
|
||||
const status=state=>{if(port&&!closed)port.postMessage({t:'status',state})};
|
||||
const pause=milliseconds=>new Promise(resolve=>setTimeout(resolve,milliseconds));
|
||||
@@ -69,6 +88,7 @@ const options=(method,token,body,headers,signal,keepalive)=>({
|
||||
method,body,signal,keepalive:!!keepalive,mode:'same-origin',credentials:'omit',cache:'no-store',redirect:'error',referrerPolicy:'no-referrer',
|
||||
headers:Object.assign(token?{Authorization:'Bearer '+token}:{},body?{'Content-Type':'application/octet-stream'}:{},headers||{})
|
||||
});
|
||||
const attemptHeaders=()=>negotiationEnabled?Object.assign({'X-Carrier-Capabilities':carrierCapabilities,'X-Carrier-Attempt':String(carrierAttempt)},carrierFailure?{'X-Carrier-Failure':carrierFailure}:{}):{};
|
||||
function reserve(data,lane){
|
||||
let buffered=socket?socket.bufferedAmount:0;for(const value of lanes.values())if(value.socket)buffered+=value.socket.bufferedAmount;
|
||||
if(!data.byteLength||data.byteLength>queueLimit-queuedBytes-buffered||queuedItems>=queueItemLimit)return false;
|
||||
@@ -99,6 +119,27 @@ function splitFrames(value){
|
||||
}
|
||||
if(!result.length)throw new Error('empty frame batch');return result;
|
||||
}
|
||||
function findProbe(){
|
||||
for(let index=0;index<pending.length;index++){
|
||||
const value=pending[index],view=new DataView(value);let offset=0,frames=0;
|
||||
while(offset<value.byteLength){
|
||||
if(value.byteLength-offset<8||frames++>=4096)throw new Error('invalid frame batch');
|
||||
const type=view.getUint8(offset),id=(view.getUint8(offset+1)<<16)|(view.getUint8(offset+2)<<8)|view.getUint8(offset+3);
|
||||
const size=view.getUint32(offset+4),end=offset+8+size;
|
||||
if((type===2&&!size)||size>1048576||end>value.byteLength)throw new Error('invalid frame');
|
||||
if(type===1||type===2)return {source:value,index,start:offset,end,id,data:value.slice(offset,end)};
|
||||
offset=end;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function consumeProbe(probe){
|
||||
if(pending[probe.index]!==probe.source)throw new Error('stale carrier probe');
|
||||
const before=probe.source.slice(0,probe.start),after=probe.source.slice(probe.end),remaining=before.byteLength+after.byteLength;
|
||||
if(!remaining){pending.splice(probe.index,1);release(probe.end-probe.start,1,null);return}
|
||||
const merged=new Uint8Array(remaining);merged.set(new Uint8Array(before),0);merged.set(new Uint8Array(after),before.byteLength);
|
||||
pending[probe.index]=merged.buffer;release(probe.end-probe.start,0,null);
|
||||
}
|
||||
function joinPending(values,lane){
|
||||
let total=0,count=0,frames=0;
|
||||
while(count<values.length){
|
||||
@@ -145,21 +186,77 @@ async function request(path,makeOptions){
|
||||
}
|
||||
}
|
||||
function fail(){if(closed)return;status('failed');if(port)port.postMessage({t:'close'});close(true)}
|
||||
async function createSession(first){
|
||||
function knownCarrier(value){return value==='https'||value==='https-lanes'||value==='websocket'||value==='websocket-lanes'}
|
||||
function armCarrierDeadline(epoch){
|
||||
if(!negotiationStartedAt||epoch!==attemptEpoch)return;
|
||||
if(carrierTimer)clearTimeout(carrierTimer);
|
||||
const remaining=negotiationStartedAt+effectiveDeadlines[carrierAttempt-1]*1000-Date.now();
|
||||
carrierTimer=setTimeout(()=>advanceCarrier('timeout',epoch),Math.max(0,remaining));
|
||||
}
|
||||
function resetCandidate(){
|
||||
if(pollController)pollController.abort();pollController=null;
|
||||
if(socket){const previous=socket;socket=null;previous.close()}socketReady=false;
|
||||
for(const lane of lanes.values()){if(lane.controller)lane.controller.abort();if(lane.socket)lane.socket.close()}
|
||||
lanes.clear();closedLanes.clear();closedLaneOrder.length=0;upPending.length=0;upSequence=1;downCursor='0';upRunning=false;
|
||||
sessionToken='';carrier='';candidateRunning=false;
|
||||
}
|
||||
function advanceCarrier(reason,epoch){
|
||||
if(closed||carrierCommitted||epoch!==attemptEpoch||switching)return;
|
||||
switching=true;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;
|
||||
if(attemptController)attemptController.abort();attemptController=null;
|
||||
resetCandidate();
|
||||
if(carrierAttempt>=candidateCount||Date.now()>=negotiationStartedAt+effectiveDeadlines[effectiveDeadlines.length-1]*1000){switching=false;fail();return}
|
||||
carrierAttempt++;carrierFailure=reason;attemptEpoch++;const nextEpoch=attemptEpoch;switching=false;
|
||||
status('reconnecting');armCarrierDeadline(nextEpoch);createSession(nextEpoch);
|
||||
}
|
||||
function maybeStartCandidate(){
|
||||
let probe;try{probe=findProbe()}catch(error){fail();return}
|
||||
if(!probe||closed||carrierCommitted)return;
|
||||
if(negotiationEnabled&&!negotiationStartedAt){negotiationStartedAt=Date.now();armCarrierDeadline(attemptEpoch)}
|
||||
if(!sessionToken||candidateRunning)return;
|
||||
candidateRunning=true;const epoch=attemptEpoch;
|
||||
if(carrier==='https')probeHttp(probe,null,epoch);
|
||||
else if(carrier==='https-lanes')probeHttp(probe,probe.id,epoch);
|
||||
else if(carrier==='websocket')openCandidateSocket(probe,null,epoch);
|
||||
else if(carrier==='websocket-lanes')openCandidateSocket(probe,probe.id,epoch);
|
||||
else advanceCarrier('protocol',epoch);
|
||||
}
|
||||
async function createSession(epoch){
|
||||
attemptController=new AbortController();
|
||||
try{
|
||||
status('connecting');
|
||||
const response=await request('/api/v1/session',()=>options('POST',bootstrap,first));
|
||||
if(response.status!==200||response.headers.get('X-Carrier-Mode')!==carrier)throw new Error('session creation rejected');
|
||||
sessionToken=response.headers.get('X-Session-Token')||'';downCursor=response.headers.get('X-Down-Cursor')||'0';
|
||||
if(!sessionToken)throw new Error('missing session token');
|
||||
if(closed){deleteSession();return}
|
||||
const welcome=await response.arrayBuffer();
|
||||
port.postMessage(welcome,[welcome]);status('connected');
|
||||
if(carrier==='https-lanes')ensureLane(0);
|
||||
if(carrier==='websocket')openSocket();
|
||||
for(const data of pending.splice(0)){release(data.byteLength,1,null);queueCarrier(data)}
|
||||
if(carrier==='https')poll();else if(carrier==='https-lanes')pollLane(lanes.get(0));
|
||||
}catch(error){fail()}
|
||||
const response=await request('/api/v1/session',()=>options('POST',bootstrap,helloFrame,attemptHeaders(),attemptController.signal));
|
||||
if(closed||epoch!==attemptEpoch){await response.arrayBuffer();return}
|
||||
if(response.status!==200){await response.arrayBuffer();if(negotiationStartedAt)advanceCarrier('http',epoch);else fail();return}
|
||||
const selected=response.headers.get('X-Carrier-Mode')||'',echo=response.headers.get('X-Carrier-Attempt')||'';
|
||||
if(!knownCarrier(selected)||(negotiationEnabled?echo!==String(carrierAttempt):echo!=='')){await response.arrayBuffer();if(negotiationStartedAt)advanceCarrier('protocol',epoch);else fail();return}
|
||||
const token=response.headers.get('X-Session-Token')||'',cursor=response.headers.get('X-Down-Cursor')||'';
|
||||
if(!token||cursor!=='0'){await response.arrayBuffer();if(negotiationStartedAt)advanceCarrier('protocol',epoch);else fail();return}
|
||||
const welcome=await response.arrayBuffer();if(closed||epoch!==attemptEpoch)return;
|
||||
carrier=selected;sessionToken=token;downCursor=cursor;
|
||||
if(!welcomeSent){welcomeSent=true;port.postMessage(welcome,[welcome])}
|
||||
maybeStartCandidate();
|
||||
}catch(error){if(closed||epoch!==attemptEpoch)return;if(negotiationStartedAt)advanceCarrier('network',epoch);else fail()}
|
||||
}
|
||||
async function probeHttp(probe,laneID,epoch){
|
||||
try{
|
||||
const headers={'X-Up-Seq':'1'};if(laneID!==null)headers['X-Lane-ID']=String(laneID);
|
||||
const response=await request('/api/v1/up',()=>options('POST',sessionToken,probe.data,headers,attemptController.signal));
|
||||
if(closed||epoch!==attemptEpoch){await response.arrayBuffer();return}
|
||||
if(response.status!==204){await response.arrayBuffer();advanceCarrier('http',epoch);return}
|
||||
if(response.headers.get('X-Up-Ack')!=='1'){advanceCarrier('protocol',epoch);return}
|
||||
if(laneID===null)upSequence=2;else ensureLane(laneID).sequence=2;
|
||||
commitCarrier(probe,epoch);
|
||||
}catch(error){if(!closed&&epoch===attemptEpoch)advanceCarrier('network',epoch)}
|
||||
}
|
||||
function commitCarrier(probe,epoch){
|
||||
if(closed||carrierCommitted||epoch!==attemptEpoch)return;
|
||||
try{consumeProbe(probe)}catch(error){fail();return}
|
||||
carrierCommitted=true;candidateRunning=false;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;
|
||||
status('connected');
|
||||
if(carrier==='https')poll();
|
||||
else if(carrier==='https-lanes'){const control=ensureLane(0);pollLane(control);const lane=lanes.get(probe.id);if(lane&&!lane.polling)pollLane(lane)}
|
||||
for(const data of pending.splice(0)){release(data.byteLength,1,null);queueCarrier(data)}
|
||||
}
|
||||
function queueCarrier(data){
|
||||
try{
|
||||
@@ -181,15 +278,30 @@ async function runUp(){
|
||||
}catch(error){fail()}
|
||||
finally{upRunning=false;if(!closed&&sessionToken&&upPending.length)runUp()}
|
||||
}
|
||||
function openSocket(){
|
||||
if(socket||closed)return;socket=new WebSocket(socketURL(),'tproxy-v1.'+sessionToken);socket.binaryType='arraybuffer';
|
||||
socket.onopen=()=>{if(closed)return;socketReady=true;status('connected');runSocketUp()};
|
||||
socket.onmessage=event=>{
|
||||
if(closed||!(event.data instanceof ArrayBuffer)){fail();return}
|
||||
try{const bound=frameBound(event.data,4096,batchLimit);if(bound.bytes!==event.data.byteLength)throw new Error('invalid frame batch')}catch(error){fail();return}
|
||||
function openCandidateSocket(probe,laneID,epoch){
|
||||
const protocol=laneID===null?(negotiationEnabled?'tproxy-auto-v1.':'tproxy-v1.')+sessionToken:(negotiationEnabled?'tproxy-auto-lane-v1.':'tproxy-lane-v1.')+sessionToken+'.'+String(laneID);
|
||||
const next=new WebSocket(socketURL(),protocol);next.binaryType='arraybuffer';let opened=false,lane=null;
|
||||
if(laneID===null)socket=next;else{lane=ensureLane(laneID);lane.socket=next}
|
||||
next.onopen=()=>{
|
||||
if(closed||epoch!==attemptEpoch)return;opened=true;
|
||||
if(lane){lane.ready=true}else socketReady=true;
|
||||
try{next.send(probe.data);if(!negotiationEnabled)commitCarrier(probe,epoch)}catch(error){advanceCarrier('upgrade',epoch)}
|
||||
};
|
||||
next.onmessage=event=>{
|
||||
if(closed||epoch!==attemptEpoch||!(event.data instanceof ArrayBuffer))return;
|
||||
if(!carrierCommitted){if(event.data.byteLength!==0){advanceCarrier('protocol',epoch);return}commitCarrier(probe,epoch);return}
|
||||
try{
|
||||
if(lane){const values=splitFrames(event.data);for(const value of values)if(value.id!==lane.id)throw new Error('cross-lane frame');if(values.some(value=>value.type===3))lane.remoteClosed=true}
|
||||
else{const bound=frameBound(event.data,4096,batchLimit);if(bound.bytes!==event.data.byteLength)throw new Error('invalid frame batch')}
|
||||
}catch(error){if(lane)finishLane(lane,true);else fail();return}
|
||||
port.postMessage({t:'traffic',up:0,down:event.data.byteLength});port.postMessage(event.data,[event.data]);status('connected');
|
||||
};
|
||||
socket.onerror=()=>{};socket.onclose=()=>{socketReady=false;if(!closed)fail()};
|
||||
next.onerror=()=>{};
|
||||
next.onclose=()=>{
|
||||
if(epoch!==attemptEpoch||closed)return;
|
||||
if(!carrierCommitted){advanceCarrier(opened?'network':'upgrade',epoch);return}
|
||||
if(lane){lane.ready=false;lane.socket=null;finishLane(lane,true)}else{socketReady=false;fail()}
|
||||
};
|
||||
}
|
||||
function queueSocket(data){if(!reserve(data,null)){fail();return}upPending.push(data);runSocketUp()}
|
||||
async function waitSocket(next,size,limit){
|
||||
@@ -306,7 +418,7 @@ function deleteSession(){
|
||||
if(sessionToken)fetch(relayOrigin+'/api/v1/session',options('DELETE',sessionToken,null,null,undefined,true)).catch(()=>{});
|
||||
}
|
||||
function close(notifyServer){
|
||||
if(closed)return;closed=true;if(pollController)pollController.abort();
|
||||
if(closed)return;closed=true;if(carrierTimer)clearTimeout(carrierTimer);if(attemptController)attemptController.abort();if(pollController)pollController.abort();
|
||||
if(socket)socket.close();for(const lane of lanes.values()){if(lane.controller)lane.controller.abort();if(lane.socket)lane.socket.close()}
|
||||
if(notifyServer)deleteSession();pending.length=0;upPending.length=0;
|
||||
for(const lane of lanes.values())lane.pending.length=0;lanes.clear();queuedBytes=0;queuedItems=0;if(port)port.close();
|
||||
@@ -315,8 +427,8 @@ function activatePort(nextPort){
|
||||
initialized=true;port=nextPort;
|
||||
port.onmessage=message=>{
|
||||
if(message.data instanceof ArrayBuffer){
|
||||
if(!createStarted){createStarted=true;createSession(message.data)}
|
||||
else if(!sessionToken){if(!reserve(message.data,null)){fail();return}pending.push(message.data)}
|
||||
if(!createStarted){createStarted=true;helloFrame=message.data;createSession(attemptEpoch)}
|
||||
else if(!carrierCommitted){if(!reserve(message.data,null)){fail();return}pending.push(message.data);maybeStartCandidate()}
|
||||
else queueCarrier(message.data);
|
||||
}else if(message.data&&message.data.t==='close')close(true);
|
||||
};
|
||||
@@ -348,111 +460,6 @@ addEventListener('pagehide',()=>close(true),{once:true});
|
||||
</html>
|
||||
"##;
|
||||
|
||||
// Rendered wire-contract tests remain separate from the embedded document.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rendered_page_contains_no_template_markers_or_capability() {
|
||||
let page = render(
|
||||
"proxy.example.com",
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
2 * 1024 * 1024,
|
||||
32 * 1024 * 1024,
|
||||
16 * 1024,
|
||||
WebCarrier::HttpsLanes,
|
||||
&SecureRandom::new(),
|
||||
);
|
||||
assert!(!page.body.contains("__"));
|
||||
assert!(!page.body.contains("bridge="));
|
||||
assert!(page.body.contains("X-Up-Seq"));
|
||||
assert!(page.body.contains("carrier='https-lanes'"));
|
||||
assert!(page.body.contains("X-Lane-ID"));
|
||||
assert!(page.body.contains("const when=Date.parse(header)"));
|
||||
assert!(page.body.contains("},{once:false});"));
|
||||
assert!(
|
||||
page.body
|
||||
.contains("if(!sessionToken)throw new Error('missing session token')")
|
||||
);
|
||||
assert!(!page.body.contains("welcomeBytes"));
|
||||
assert!(
|
||||
page.body
|
||||
.contains("for(const value of splitFrames(data))if(value.id!==lane.id)")
|
||||
);
|
||||
assert!(
|
||||
page.body
|
||||
.contains("let frames;try{frames=splitFrames(value)}catch(error){fail();return}")
|
||||
);
|
||||
assert!(
|
||||
page.content_security_policy
|
||||
.contains("frame-ancestors http://127.0.0.1:*")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendered_page_is_parseable_by_ios_native_carrier() {
|
||||
let bootstrap = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||
let page = render(
|
||||
"proxy.example.com",
|
||||
bootstrap,
|
||||
2 * 1024 * 1024,
|
||||
32 * 1024 * 1024,
|
||||
16 * 1024,
|
||||
WebCarrier::Https,
|
||||
&SecureRandom::new(),
|
||||
);
|
||||
let accepted_shapes = [
|
||||
format!("const bootstrap=\"{bootstrap}\""),
|
||||
format!("const bootstrap='{bootstrap}'"),
|
||||
format!("bootstrap=\"{bootstrap}\""),
|
||||
];
|
||||
|
||||
assert!(
|
||||
accepted_shapes
|
||||
.iter()
|
||||
.any(|shape| page.body.contains(shape)),
|
||||
"the iOS native carrier cannot parse a comma-declared single-quoted bootstrap"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendered_page_advertises_exact_websocket_carriers() {
|
||||
let websocket = render(
|
||||
"proxy.example.com",
|
||||
"CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
|
||||
2 * 1024 * 1024,
|
||||
32 * 1024 * 1024,
|
||||
16 * 1024,
|
||||
WebCarrier::Websocket,
|
||||
&SecureRandom::new(),
|
||||
);
|
||||
assert!(websocket.body.contains("carrier='websocket'"));
|
||||
assert!(
|
||||
websocket
|
||||
.body
|
||||
.contains("new WebSocket(socketURL(),'tproxy-v1.'+sessionToken)")
|
||||
);
|
||||
assert!(
|
||||
websocket
|
||||
.content_security_policy
|
||||
.contains("connect-src 'self' wss://proxy.example.com")
|
||||
);
|
||||
|
||||
let lanes = render(
|
||||
"proxy.example.com",
|
||||
"DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
|
||||
2 * 1024 * 1024,
|
||||
32 * 1024 * 1024,
|
||||
16 * 1024,
|
||||
WebCarrier::WebsocketLanes,
|
||||
&SecureRandom::new(),
|
||||
);
|
||||
assert!(lanes.body.contains("carrier='websocket-lanes'"));
|
||||
assert!(
|
||||
lanes
|
||||
.body
|
||||
.contains("'tproxy-lane-v1.'+sessionToken+'.'+String(lane.id)")
|
||||
);
|
||||
assert!(!lanes.body.contains("__"));
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
Reference in New Issue
Block a user