mirror of https://github.com/cbeuw/Cloak
Untested server
parent
3fd7e01566
commit
ae30ed6ba4
@ -0,0 +1,209 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mux "github.com/cbeuw/Cloak/internal/multiplex"
|
||||
"github.com/cbeuw/Cloak/internal/server"
|
||||
"github.com/cbeuw/Cloak/internal/util"
|
||||
)
|
||||
|
||||
var version string
|
||||
|
||||
func pipe(dst io.ReadWriteCloser, src io.ReadWriteCloser) {
|
||||
for {
|
||||
i, err := io.Copy(dst, src)
|
||||
if err != nil || i == 0 {
|
||||
go dst.Close()
|
||||
go src.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dispatchConnection(conn net.Conn, sta *server.State) {
|
||||
goWeb := func(data []byte) {
|
||||
webConn, err := net.Dial("tcp", sta.WebServerAddr)
|
||||
if err != nil {
|
||||
log.Printf("Making connection to redirection server: %v\n", err)
|
||||
go webConn.Close()
|
||||
return
|
||||
}
|
||||
webConn.Write(data)
|
||||
go pipe(webConn, conn)
|
||||
go pipe(conn, webConn)
|
||||
}
|
||||
|
||||
buf := make([]byte, 1500)
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
i, err := io.ReadAtLeast(conn, buf, 1)
|
||||
if err != nil {
|
||||
go conn.Close()
|
||||
return
|
||||
}
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
data := buf[:i]
|
||||
ch, err := server.ParseClientHello(data)
|
||||
if err != nil {
|
||||
log.Printf("+1 non SS non (or malformed) TLS traffic from %v\n", conn.RemoteAddr())
|
||||
goWeb(data)
|
||||
return
|
||||
}
|
||||
|
||||
isSS, SID := server.TouchStone(ch, sta)
|
||||
if !isSS {
|
||||
log.Printf("+1 non SS TLS traffic from %v\n", conn.RemoteAddr())
|
||||
goWeb(data)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: verify SID
|
||||
|
||||
reply := server.ComposeReply(ch)
|
||||
_, err = conn.Write(reply)
|
||||
if err != nil {
|
||||
log.Printf("Sending reply to remote: %v\n", err)
|
||||
go conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// Two discarded messages: ChangeCipherSpec and Finished
|
||||
discardBuf := make([]byte, 1024)
|
||||
for c := 0; c < 2; c++ {
|
||||
_, err = util.ReadTillDrain(conn, discardBuf)
|
||||
if err != nil {
|
||||
log.Printf("Reading discarded message %v: %v\n", c, err)
|
||||
go conn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
var arrSID [32]byte
|
||||
copy(arrSID[:], SID)
|
||||
sesh := sta.GetSession(arrSID)
|
||||
if sesh == nil {
|
||||
sesh.AddConnection(conn)
|
||||
} else {
|
||||
sesh := mux.MakeSession(0, conn, util.MakeObfs(SID), util.MakeDeobfs(SID), util.ReadTillDrain)
|
||||
sta.PutSession(arrSID, sesh)
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
newStream, err := sesh.AcceptStream()
|
||||
if err != nil {
|
||||
log.Printf("Failed to get new stream: %v", err)
|
||||
}
|
||||
ssConn, err := net.Dial("tcp", sta.SS_LOCAL_HOST+":"+sta.SS_LOCAL_PORT)
|
||||
if err != nil {
|
||||
log.Printf("Failed to connect to ssserver: %v", err)
|
||||
}
|
||||
go pipe(ssConn, newStream)
|
||||
go pipe(newStream, ssConn)
|
||||
}
|
||||
}()
|
||||
}()
|
||||
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Should be 127.0.0.1 to listen to ss-server on this machine
|
||||
var localHost string
|
||||
// server_port in ss config, same as remotePort in plugin mode
|
||||
var localPort string
|
||||
// server in ss config, the outbound listening ip
|
||||
var remoteHost string
|
||||
// Outbound listening ip, should be 443
|
||||
var remotePort string
|
||||
var pluginOpts string
|
||||
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
|
||||
if os.Getenv("SS_LOCAL_HOST") != "" {
|
||||
localHost = os.Getenv("SS_LOCAL_HOST")
|
||||
localPort = os.Getenv("SS_LOCAL_PORT")
|
||||
remoteHost = os.Getenv("SS_REMOTE_HOST")
|
||||
remotePort = os.Getenv("SS_REMOTE_PORT")
|
||||
pluginOpts = os.Getenv("SS_PLUGIN_OPTIONS")
|
||||
} else {
|
||||
localAddr := flag.String("r", "", "localAddr: 127.0.0.1:server_port as set in SS config")
|
||||
flag.StringVar(&remoteHost, "s", "0.0.0.0", "remoteHost: outbound listing ip, set to 0.0.0.0 to listen to everything")
|
||||
flag.StringVar(&remotePort, "p", "443", "remotePort: outbound listing port, should be 443")
|
||||
flag.StringVar(&pluginOpts, "c", "server.json", "pluginOpts: path to server.json or options seperated by semicolons")
|
||||
askVersion := flag.Bool("v", false, "Print the version number")
|
||||
printUsage := flag.Bool("h", false, "Print this message")
|
||||
flag.Parse()
|
||||
|
||||
if *askVersion {
|
||||
fmt.Printf("ck-server %s\n", version)
|
||||
return
|
||||
}
|
||||
|
||||
if *printUsage {
|
||||
flag.Usage()
|
||||
return
|
||||
}
|
||||
|
||||
if *localAddr == "" {
|
||||
log.Fatal("Must specify localAddr")
|
||||
}
|
||||
localHost = strings.Split(*localAddr, ":")[0]
|
||||
localPort = strings.Split(*localAddr, ":")[1]
|
||||
log.Printf("Starting standalone mode, listening on %v:%v to ss at %v:%v\n", remoteHost, remotePort, localHost, localPort)
|
||||
}
|
||||
sta := &server.State{
|
||||
SS_LOCAL_HOST: localHost,
|
||||
SS_LOCAL_PORT: localPort,
|
||||
SS_REMOTE_HOST: remoteHost,
|
||||
SS_REMOTE_PORT: remotePort,
|
||||
Now: time.Now,
|
||||
UsedRandom: map[[32]byte]int{},
|
||||
}
|
||||
err := sta.ParseConfig(pluginOpts)
|
||||
if err != nil {
|
||||
log.Fatalf("Configuration file error: %v", err)
|
||||
}
|
||||
|
||||
go sta.UsedRandomCleaner()
|
||||
|
||||
listen := func(addr, port string) {
|
||||
listener, err := net.Listen("tcp", addr+":"+port)
|
||||
log.Println("Listening on " + addr + ":" + port)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
log.Printf("%v", err)
|
||||
continue
|
||||
}
|
||||
go dispatchConnection(conn, sta)
|
||||
}
|
||||
}
|
||||
|
||||
// When listening on an IPv6 and IPv4, SS gives REMOTE_HOST as e.g. ::|0.0.0.0
|
||||
listeningIP := strings.Split(sta.SS_REMOTE_HOST, "|")
|
||||
for i, ip := range listeningIP {
|
||||
if net.ParseIP(ip).To4() == nil {
|
||||
// IPv6 needs square brackets
|
||||
ip = "[" + ip + "]"
|
||||
}
|
||||
|
||||
// The last listener must block main() because the program exits on main return.
|
||||
if i == len(listeningIP)-1 {
|
||||
listen(ip, sta.SS_REMOTE_PORT)
|
||||
} else {
|
||||
go listen(ip, sta.SS_REMOTE_PORT)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
{
|
||||
"ServerName":"www.bing.com",
|
||||
"Key":"UNhY4JhezH9gQYqvDMWrWH9CwlcKiECVqejMrND2VFwEOF8c8XRX8iYVdjKW2BAfym2zppExMPteovDB/Q8phdD53FnH39tQ1daaVLn9+FIGOAdk+UZZ2aOt5jSK638YPg==",
|
||||
"TicketTimeHint":3600,
|
||||
"Browser":"chrome"
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
{
|
||||
"WebServerAddr":"204.79.197.200:443",
|
||||
"Key":"H2pMM834RzkouOoRGNhbiQRnm4Ggy8sg+S6ve5yYfqUEOF8c8XRX8iYVdjKW2BAfym2zppExMPteovDB/Q8phdD53FnH39tQ1daaVLn9+FIGOAdk+UZZ2aOt5jSK638YPg=="
|
||||
}
|
@ -0,0 +1,163 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/cbeuw/Cloak/internal/util"
|
||||
)
|
||||
|
||||
// ClientHello contains every field in a ClientHello message
|
||||
type ClientHello struct {
|
||||
handshakeType byte
|
||||
length int
|
||||
clientVersion []byte
|
||||
random []byte
|
||||
sessionIdLen int
|
||||
sessionId []byte
|
||||
cipherSuitesLen int
|
||||
cipherSuites []byte
|
||||
compressionMethodsLen int
|
||||
compressionMethods []byte
|
||||
extensionsLen int
|
||||
extensions map[[2]byte][]byte
|
||||
}
|
||||
|
||||
func parseExtensions(input []byte) (ret map[[2]byte][]byte, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = errors.New("Malformed Extensions")
|
||||
}
|
||||
}()
|
||||
pointer := 0
|
||||
totalLen := len(input)
|
||||
ret = make(map[[2]byte][]byte)
|
||||
for pointer < totalLen {
|
||||
var typ [2]byte
|
||||
copy(typ[:], input[pointer:pointer+2])
|
||||
pointer += 2
|
||||
length := util.BtoInt(input[pointer : pointer+2])
|
||||
pointer += 2
|
||||
data := input[pointer : pointer+length]
|
||||
pointer += length
|
||||
ret[typ] = data
|
||||
}
|
||||
return ret, err
|
||||
}
|
||||
|
||||
// AddRecordLayer adds record layer to data
|
||||
func AddRecordLayer(input []byte, typ []byte, ver []byte) []byte {
|
||||
length := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(length, uint16(len(input)))
|
||||
ret := make([]byte, 5+len(input))
|
||||
copy(ret[0:1], typ)
|
||||
copy(ret[1:3], ver)
|
||||
copy(ret[3:5], length)
|
||||
copy(ret[5:], input)
|
||||
return ret
|
||||
}
|
||||
|
||||
// PeelRecordLayer peels off the record layer
|
||||
func PeelRecordLayer(data []byte) []byte {
|
||||
ret := data[5:]
|
||||
return ret
|
||||
}
|
||||
|
||||
// ParseClientHello parses everything on top of the TLS layer
|
||||
// (including the record layer) into ClientHello type
|
||||
func ParseClientHello(data []byte) (ret *ClientHello, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = errors.New("Malformed ClientHello")
|
||||
}
|
||||
}()
|
||||
data = PeelRecordLayer(data)
|
||||
pointer := 0
|
||||
// Handshake Type
|
||||
handshakeType := data[pointer]
|
||||
if handshakeType != 0x01 {
|
||||
return ret, errors.New("Not a ClientHello")
|
||||
}
|
||||
pointer += 1
|
||||
// Length
|
||||
length := util.BtoInt(data[pointer : pointer+3])
|
||||
pointer += 3
|
||||
if length != len(data[pointer:]) {
|
||||
return ret, errors.New("Hello length doesn't match")
|
||||
}
|
||||
// Client Version
|
||||
clientVersion := data[pointer : pointer+2]
|
||||
pointer += 2
|
||||
// Random
|
||||
random := data[pointer : pointer+32]
|
||||
pointer += 32
|
||||
// Session ID
|
||||
sessionIdLen := int(data[pointer])
|
||||
pointer += 1
|
||||
sessionId := data[pointer : pointer+sessionIdLen]
|
||||
pointer += sessionIdLen
|
||||
// Cipher Suites
|
||||
cipherSuitesLen := util.BtoInt(data[pointer : pointer+2])
|
||||
pointer += 2
|
||||
cipherSuites := data[pointer : pointer+cipherSuitesLen]
|
||||
pointer += cipherSuitesLen
|
||||
// Compression Methods
|
||||
compressionMethodsLen := int(data[pointer])
|
||||
pointer += 1
|
||||
compressionMethods := data[pointer : pointer+compressionMethodsLen]
|
||||
pointer += compressionMethodsLen
|
||||
// Extensions
|
||||
extensionsLen := util.BtoInt(data[pointer : pointer+2])
|
||||
pointer += 2
|
||||
extensions, err := parseExtensions(data[pointer:])
|
||||
ret = &ClientHello{
|
||||
handshakeType,
|
||||
length,
|
||||
clientVersion,
|
||||
random,
|
||||
sessionIdLen,
|
||||
sessionId,
|
||||
cipherSuitesLen,
|
||||
cipherSuites,
|
||||
compressionMethodsLen,
|
||||
compressionMethods,
|
||||
extensionsLen,
|
||||
extensions,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func composeServerHello(ch *ClientHello) []byte {
|
||||
var serverHello [10][]byte
|
||||
serverHello[0] = []byte{0x02} // handshake type
|
||||
serverHello[1] = []byte{0x00, 0x00, 0x4d} // length 77
|
||||
serverHello[2] = []byte{0x03, 0x03} // server version
|
||||
serverHello[3] = util.PsudoRandBytes(32, time.Now().UnixNano()) // random
|
||||
serverHello[4] = []byte{0x20} // session id length 32
|
||||
serverHello[5] = ch.sessionId // session id
|
||||
serverHello[6] = []byte{0xc0, 0x30} // cipher suite TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
serverHello[7] = []byte{0x00} // compression method null
|
||||
serverHello[8] = []byte{0x00, 0x05} // extensions length 5
|
||||
serverHello[9] = []byte{0xff, 0x01, 0x00, 0x01, 0x00} // extensions renegotiation_info
|
||||
ret := []byte{}
|
||||
for i := 0; i < 10; i++ {
|
||||
ret = append(ret, serverHello[i]...)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// ComposeReply composes the ServerHello, ChangeCipherSpec and Finished messages
|
||||
// together with their respective record layers into one byte slice. The content
|
||||
// of these messages are random and useless for this plugin
|
||||
func ComposeReply(ch *ClientHello) []byte {
|
||||
TLS12 := []byte{0x03, 0x03}
|
||||
shBytes := AddRecordLayer(composeServerHello(ch), []byte{0x16}, TLS12)
|
||||
ccsBytes := AddRecordLayer([]byte{0x01}, []byte{0x14}, TLS12)
|
||||
finished := make([]byte, 64)
|
||||
finished = util.PsudoRandBytes(40, time.Now().UnixNano())
|
||||
fBytes := AddRecordLayer(finished, []byte{0x16}, TLS12)
|
||||
ret := append(shBytes, ccsBytes...)
|
||||
ret = append(ret, fBytes...)
|
||||
return ret
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"github.com/cbeuw/ecies"
|
||||
"log"
|
||||
)
|
||||
|
||||
// input ticket, return SID
|
||||
func decryptSessionTicket(pv *ecies.PrivateKey, ticket []byte) ([]byte, error) {
|
||||
ciphertext := make([]byte, 153)
|
||||
ciphertext[0] = 0x04
|
||||
copy(ciphertext[1:], ticket)
|
||||
plaintext, err := pv.Decrypt(ciphertext, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return plaintext[0:32], nil
|
||||
}
|
||||
|
||||
func validateRandom(random []byte, SID []byte, time int64) bool {
|
||||
t := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(t, uint64(time/12*60*60))
|
||||
rand := random[0:16]
|
||||
preHash := make([]byte, 56)
|
||||
copy(preHash[0:32], SID)
|
||||
copy(preHash[32:40], t)
|
||||
copy(preHash[40:56], rand)
|
||||
h := sha256.New()
|
||||
h.Write(preHash)
|
||||
return bytes.Equal(h.Sum(nil)[0:16], random[16:32])
|
||||
}
|
||||
func TouchStone(ch *ClientHello, sta *State) (bool, []byte) {
|
||||
var random [32]byte
|
||||
copy(random[:], ch.random)
|
||||
used := sta.getUsedRandom(random)
|
||||
if used != 0 {
|
||||
log.Println("Replay! Duplicate random")
|
||||
return false, nil
|
||||
}
|
||||
sta.putUsedRandom(random)
|
||||
|
||||
SID, err := decryptSessionTicket(sta.pv, ch.extensions[[2]byte{0x00, 0x23}])
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
isSS := validateRandom(ch.random, SID, sta.Now().Unix())
|
||||
if !isSS {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, SID
|
||||
}
|
@ -0,0 +1,159 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/elliptic"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mux "github.com/cbeuw/Cloak/internal/multiplex"
|
||||
"github.com/cbeuw/ecies"
|
||||
)
|
||||
|
||||
type rawConfig struct {
|
||||
WebServerAddr string
|
||||
Key string
|
||||
}
|
||||
type stateManager interface {
|
||||
ParseConfig(string) error
|
||||
SetAESKey(string)
|
||||
PutUsedRandom([32]byte)
|
||||
}
|
||||
|
||||
// State type stores the global state of the program
|
||||
type State struct {
|
||||
WebServerAddr string
|
||||
Now func() time.Time
|
||||
SS_LOCAL_HOST string
|
||||
SS_LOCAL_PORT string
|
||||
SS_REMOTE_HOST string
|
||||
SS_REMOTE_PORT string
|
||||
UsedRandomM sync.RWMutex
|
||||
UsedRandom map[[32]byte]int
|
||||
pv *ecies.PrivateKey
|
||||
|
||||
SessionsM sync.RWMutex
|
||||
Sessions map[[32]byte]*mux.Session
|
||||
}
|
||||
|
||||
// semi-colon separated value.
|
||||
func ssvToJson(ssv string) (ret []byte) {
|
||||
unescape := func(s string) string {
|
||||
r := strings.Replace(s, "\\\\", "\\", -1)
|
||||
r = strings.Replace(r, "\\=", "=", -1)
|
||||
r = strings.Replace(r, "\\;", ";", -1)
|
||||
return r
|
||||
}
|
||||
lines := strings.Split(unescape(ssv), ";")
|
||||
ret = []byte("{")
|
||||
for _, ln := range lines {
|
||||
if ln == "" {
|
||||
break
|
||||
}
|
||||
sp := strings.SplitN(ln, "=", 2)
|
||||
key := sp[0]
|
||||
value := sp[1]
|
||||
ret = append(ret, []byte("\""+key+"\":\""+value+"\",")...)
|
||||
|
||||
}
|
||||
ret = ret[:len(ret)-1] // remove the last comma
|
||||
ret = append(ret, '}')
|
||||
return ret
|
||||
}
|
||||
|
||||
// Structue: [D 32 bytes][marshalled public key]
|
||||
func parseKey(b64 string) (*ecies.PrivateKey, error) {
|
||||
b, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d := b[0:32]
|
||||
marshalled := b[32:]
|
||||
x, y := elliptic.Unmarshal(ecies.DefaultCurve, marshalled)
|
||||
pub := ecies.PublicKey{
|
||||
X: x,
|
||||
Y: y,
|
||||
Curve: ecies.DefaultCurve,
|
||||
Params: ecies.ParamsFromCurve(ecies.DefaultCurve),
|
||||
}
|
||||
|
||||
pv := &ecies.PrivateKey{
|
||||
PublicKey: pub,
|
||||
D: new(big.Int).SetBytes(d),
|
||||
}
|
||||
return pv, nil
|
||||
}
|
||||
|
||||
// ParseConfig parses the config (either a path to json or in-line ssv config) into a State variable
|
||||
func (sta *State) ParseConfig(conf string) (err error) {
|
||||
var content []byte
|
||||
if strings.Contains(conf, ";") && strings.Contains(conf, "=") {
|
||||
content = ssvToJson(conf)
|
||||
} else {
|
||||
content, err = ioutil.ReadFile(conf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var preParse rawConfig
|
||||
err = json.Unmarshal(content, &preParse)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sta.WebServerAddr = preParse.WebServerAddr
|
||||
pv, err := parseKey(preParse.Key)
|
||||
sta.pv = pv
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sta *State) GetSession(SID [32]byte) *mux.Session {
|
||||
sta.SessionsM.Lock()
|
||||
defer sta.SessionsM.Unlock()
|
||||
if sesh, ok := sta.Sessions[SID]; ok {
|
||||
return sesh
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (sta *State) PutSession(SID [32]byte, sesh *mux.Session) {
|
||||
sta.SessionsM.Lock()
|
||||
sta.Sessions[SID] = sesh
|
||||
sta.SessionsM.Unlock()
|
||||
}
|
||||
|
||||
func (sta *State) getUsedRandom(random [32]byte) int {
|
||||
sta.UsedRandomM.Lock()
|
||||
defer sta.UsedRandomM.Unlock()
|
||||
return sta.UsedRandom[random]
|
||||
|
||||
}
|
||||
|
||||
// PutUsedRandom adds a random field into map UsedRandom
|
||||
func (sta *State) putUsedRandom(random [32]byte) {
|
||||
sta.UsedRandomM.Lock()
|
||||
sta.UsedRandom[random] = int(sta.Now().Unix())
|
||||
sta.UsedRandomM.Unlock()
|
||||
}
|
||||
|
||||
// UsedRandomCleaner clears the cache of used random fields every 12 hours
|
||||
func (sta *State) UsedRandomCleaner() {
|
||||
for {
|
||||
time.Sleep(12 * time.Hour)
|
||||
now := int(sta.Now().Unix())
|
||||
sta.UsedRandomM.Lock()
|
||||
for key, t := range sta.UsedRandom {
|
||||
if now-t > 12*3600 {
|
||||
delete(sta.UsedRandom, key)
|
||||
}
|
||||
}
|
||||
sta.UsedRandomM.Unlock()
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue