mirror of
https://github.com/PeernetOfficial/core.git
synced 2026-07-17 02:47:51 +01:00
Alpha 2 release. Includes Traverse message according to latest Whitepaper 0.8.
Send and traverse code needs some cleanup.
This commit is contained in:
15
Bootstrap.go
15
Bootstrap.go
@@ -240,15 +240,16 @@ func (peer *PeerInfo) cmdResponseBootstrapFindSelf(msg *MessageResponse, closest
|
||||
if contactArbitraryPeer(closePeer.PublicKey, []*net.UDPAddr{{IP: closePeer.IP, Port: int(port)}}) {
|
||||
// Blacklist the target Peer ID, IP:Port for contact in the next 10 minutes.
|
||||
// TODO
|
||||
}
|
||||
|
||||
// If NAT is detected and the port is not forwarded, send a Traverse message.
|
||||
// NAT detection is the same algorithm as connection.IsBehindNAT.
|
||||
if closePeer.PortReportedExternal == 0 && closePeer.Port != closePeer.PortReportedInternal {
|
||||
// TODO send traverse message
|
||||
//fmt.Printf("FIND_SELF Traverse message needed for target %s target port %d internal %d\n", closePeer.IP.String(), closePeer.Port, closePeer.PortReportedInternal)
|
||||
// If NAT is detected and the port is not forwarded, send a Traverse message.
|
||||
// NAT detection is the same algorithm as connection.IsBehindNAT.
|
||||
if closePeer.PortReportedExternal == 0 && closePeer.Port != closePeer.PortReportedInternal {
|
||||
// TODO: PortExternal needs to be guaranteed. send() needs to be broken up.
|
||||
if raw, err := createVirtualAnnouncement(msg.MessageRaw.connection.Network, closePeer.PublicKey, &bootstrapFindSelf{}); err == nil {
|
||||
peer.sendTraverse(raw, closePeer.PublicKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
148
Command Traverse.go
Normal file
148
Command Traverse.go
Normal file
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
File Name: Command Traverse.go
|
||||
Copyright: 2021 Peernet s.r.o.
|
||||
Author: Peter Kleissner
|
||||
*/
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec"
|
||||
)
|
||||
|
||||
// cmdTraverseForward handles an incoming traverse message that should be forwarded to another peer
|
||||
func (peer *PeerInfo) cmdTraverseForward(msg *MessageTraverse) {
|
||||
// Verify the signature. This makes sure that a fowarded message cannot be replayed by others.
|
||||
if !msg.SignerPublicKey.IsEqual(peer.PublicKey) || !msg.SignerPublicKey.IsEqual(msg.SenderPublicKey) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if msg.Expires.Before(time.Now()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the target peer is known in the peer list. If not, nothing will be done.
|
||||
// The original sender should only send the Traverse message as answer to a Response that contains a reported peer that is behind a NAT.
|
||||
// In that case the target peer should be still in this peers' peer list.
|
||||
peerTarget := PeerlistLookup(msg.TargetPeer)
|
||||
if peerTarget == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the right IP:Port of the original sender to share to the target peer.
|
||||
allowIPv4 := peerTarget.Features&(1<<FeatureIPv4Listen) > 0
|
||||
allowIPv6 := peerTarget.Features&(1<<FeatureIPv6Listen) > 0
|
||||
connectionIPv4 := peer.GetConnection2Share(false, allowIPv4, false)
|
||||
connectionIPv6 := peer.GetConnection2Share(false, false, allowIPv6)
|
||||
|
||||
if connectionIPv4 == nil && connectionIPv6 == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := msgEncodeTraverseSetAddress(msg.Payload, connectionIPv4, connectionIPv6); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
peerTarget.send(&PacketRaw{Command: CommandTraverse, Payload: msg.Payload})
|
||||
}
|
||||
|
||||
func (peer *PeerInfo) cmdTraverseReceive(msg *MessageTraverse) {
|
||||
if msg.Expires.Before(time.Now()) {
|
||||
return
|
||||
}
|
||||
|
||||
// already an active connection established? nothing todo.
|
||||
peerOriginalSender := PeerlistLookup(msg.SignerPublicKey)
|
||||
if peerOriginalSender != nil {
|
||||
// could process the packet?
|
||||
//if connections := peerOriginalSender.GetConnections(true); len(connections) > 0 {
|
||||
// rawPacketsIncoming <- networkWire{network: connections[0].Network, sender: addressOriginalSender, raw: msg.EmbeddedPacketRaw, receiverPublicKey: peerPublicKey, unicast: true}
|
||||
//}
|
||||
return
|
||||
}
|
||||
|
||||
// ---- fork packetWorker to decode and validate embedded packet ---
|
||||
decoded, senderPublicKey, err := PacketDecrypt(msg.EmbeddedPacketRaw, peerPublicKey)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !senderPublicKey.IsEqual(msg.SignerPublicKey) {
|
||||
return
|
||||
} else if senderPublicKey.IsEqual(peerPublicKey) {
|
||||
return
|
||||
} else if decoded.Protocol != 0 {
|
||||
return
|
||||
} else if decoded.Command != CommandAnnouncement {
|
||||
return
|
||||
}
|
||||
|
||||
// --------
|
||||
virtualMessage := &MessageRaw{PacketRaw: *decoded}
|
||||
announce, err := msgDecodeAnnouncement(virtualMessage)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Proper handling of announcement todo, virtual announcement function
|
||||
var hashesNotFound [][]byte
|
||||
if announce.Actions&(1<<ActionFindSelf) > 0 {
|
||||
hashesNotFound = append(hashesNotFound, peer.NodeID)
|
||||
}
|
||||
if announce.Actions&(1<<ActionFindPeer) > 0 && len(announce.FindPeerKeys) > 0 {
|
||||
for _, findPeer := range announce.FindPeerKeys {
|
||||
hashesNotFound = append(hashesNotFound, findPeer.Hash)
|
||||
}
|
||||
}
|
||||
if announce.Actions&(1<<ActionFindValue) > 0 {
|
||||
for _, findHash := range announce.FindDataKeys {
|
||||
hashesNotFound = append(hashesNotFound, findHash.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO
|
||||
//peer.sendResponse(announce.Sequence, true, nil, nil, hashesNotFound)
|
||||
//packets, err := msgEncodeResponse(true, nil, nil, hashesNotFound)
|
||||
//sendAllNetworks()
|
||||
var addresses []*net.UDPAddr
|
||||
|
||||
if !msg.IPv4.IsUnspecified() {
|
||||
addressOriginalSenderIPv4 := &net.UDPAddr{IP: msg.IPv4, Port: int(msg.PortIPv4)}
|
||||
if msg.PortIPv4ReportedExternal > 0 {
|
||||
addressOriginalSenderIPv4.Port = int(msg.PortIPv4ReportedExternal)
|
||||
}
|
||||
addresses = append(addresses, addressOriginalSenderIPv4)
|
||||
}
|
||||
|
||||
if !msg.IPv6.IsUnspecified() {
|
||||
addressOriginalSenderIPv6 := &net.UDPAddr{IP: msg.IPv6, Port: int(msg.PortIPv6)}
|
||||
if msg.PortIPv4ReportedExternal > 0 {
|
||||
addressOriginalSenderIPv6.Port = int(msg.PortIPv6ReportedExternal)
|
||||
}
|
||||
|
||||
addresses = append(addresses, addressOriginalSenderIPv6)
|
||||
}
|
||||
|
||||
// for now send a packet which should open up the NAT and establish a connection
|
||||
contactArbitraryPeer(msg.SignerPublicKey, addresses)
|
||||
}
|
||||
|
||||
// createVirtualAnnouncement is temporary code and will be improved.
|
||||
func createVirtualAnnouncement(network *Network, receiverPublicKey *btcec.PublicKey, sequenceData interface{}) (raw []byte, err error) {
|
||||
packets := msgEncodeAnnouncement(true, ShouldSendFindSelf(), nil, nil, nil)
|
||||
if len(packets) == 0 || packets[0].err != nil {
|
||||
return nil, errors.New("error creating virtual packet")
|
||||
}
|
||||
|
||||
packet := &PacketRaw{Command: CommandAnnouncement, Payload: packets[0].raw}
|
||||
setAnnouncementPorts(packet, network)
|
||||
|
||||
packet.Sequence = msgArbitrarySequence(receiverPublicKey, sequenceData).sequence
|
||||
packet.Protocol = ProtocolVersion
|
||||
|
||||
return PacketEncrypt(peerPrivateKey, receiverPublicKey, packet)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
// Version is the current core library version
|
||||
const Version = "0.2"
|
||||
const Version = "Alpha 2"
|
||||
|
||||
var config struct {
|
||||
LogFile string `yaml:"LogFile"` // Log file
|
||||
|
||||
@@ -102,7 +102,7 @@ func (peer *PeerInfo) IsConnectable(allowLocal, allowIPv4, allowIPv6 bool) bool
|
||||
|
||||
// GetConnection2Share returns a connection to share. Nil if none.
|
||||
// allowLocal specifies whether it is OK to return local IPs.
|
||||
func (peer *PeerInfo) GetConnection2Share(allowLocal, allowIPv4, allowIPv6 bool) (connections *Connection) {
|
||||
func (peer *PeerInfo) GetConnection2Share(allowLocal, allowIPv4, allowIPv6 bool) (connection *Connection) {
|
||||
peer.RLock()
|
||||
defer peer.RUnlock()
|
||||
|
||||
@@ -340,8 +340,10 @@ func (peer *PeerInfo) send(packet *PacketRaw) (err error) {
|
||||
|
||||
if err = c.Network.send(c.Address.IP, c.Address.Port, raw); err == nil {
|
||||
// Send Traverse message if the peer is behind NAT and this is the first message.
|
||||
if isFirstPacketOut && c.IsBehindNAT() {
|
||||
//fmt.Printf("Traverse message needed for target %s\n", c.Address.String())
|
||||
if isFirstPacketOut && c.IsBehindNAT() && c.traversePeer != nil {
|
||||
if raw, err := createVirtualAnnouncement(c.Network, peer.PublicKey, &bootstrapFindSelf{}); err == nil {
|
||||
c.traversePeer.sendTraverse(raw, peer.PublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -370,9 +372,10 @@ func (peer *PeerInfo) send(packet *PacketRaw) (err error) {
|
||||
|
||||
if err = c.Network.send(c.Address.IP, c.Address.Port, raw); err == nil {
|
||||
// Send Traverse message if the peer is behind NAT and this is the first message.
|
||||
if isFirstPacketOut && c.IsBehindNAT() {
|
||||
//fmt.Printf("Traverse message needed for target %s\n", c.Address.String())
|
||||
|
||||
if isFirstPacketOut && c.IsBehindNAT() && c.traversePeer != nil {
|
||||
if raw, err := createVirtualAnnouncement(c.Network, peer.PublicKey, &bootstrapFindSelf{}); err == nil {
|
||||
c.traversePeer.sendTraverse(raw, peer.PublicKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,23 @@ type MessageResponse struct {
|
||||
HashesNotFound [][]byte // Hashes that were reported back as not found
|
||||
}
|
||||
|
||||
// MessageTraverse is the decoded traverse message.
|
||||
// It is sent by an original sender to a relay, to a final receiver (targert peer).
|
||||
type MessageTraverse struct {
|
||||
*MessageRaw // Underlying raw message.
|
||||
TargetPeer *btcec.PublicKey // End receiver peer ID.
|
||||
AuthorizedRelayPeer *btcec.PublicKey // Peer ID that is authorized to relay this message to the end receiver.
|
||||
Expires time.Time // Expiration time when this forwarded message becomes invalid.
|
||||
EmbeddedPacketRaw []byte // Embedded packet.
|
||||
SignerPublicKey *btcec.PublicKey // Public key that signed this message, ECDSA (secp256k1) 257-bit
|
||||
IPv4 net.IP // IPv4 address of the original sender. Set by authorized relay. 0 if not set.
|
||||
PortIPv4 uint16 // Port (actual one used for connection) of the original sender. Set by authorized relay.
|
||||
PortIPv4ReportedExternal uint16 // External port as reported by the original sender. This is used in case of port forwarding (manual or automated).
|
||||
IPv6 net.IP // IPv6 address of the original sender. Set by authorized relay. 0 if not set.
|
||||
PortIPv6 uint16 // Port (actual one used for connection) of the original sender. Set by authorized relay.
|
||||
PortIPv6ReportedExternal uint16 // External port as reported by the original sender. This is used in case of port forwarding (manual or automated).
|
||||
}
|
||||
|
||||
// ---- message decoding ----
|
||||
|
||||
// Minimum length of Announcement payload header without User Agent
|
||||
@@ -783,6 +800,141 @@ createPacketLoop:
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Traverse ----
|
||||
|
||||
const traversePayloadHeaderSize = 76 + 65 + 28
|
||||
|
||||
// msgDecodeTraverse decodes a traverse message.
|
||||
// It does not verify if the receiver is authorized to read or forward this message.
|
||||
// It validates the signature, but does not validate the signer.
|
||||
func msgDecodeTraverse(msg *MessageRaw) (result *MessageTraverse, err error) {
|
||||
result = &MessageTraverse{
|
||||
MessageRaw: msg,
|
||||
}
|
||||
|
||||
if len(msg.Payload) < traversePayloadHeaderSize {
|
||||
return nil, errors.New("traverse: invalid minimum length")
|
||||
}
|
||||
|
||||
targetPeerIDcompressed := msg.Payload[0:33]
|
||||
authorizedRelayPeerIDcompressed := msg.Payload[33:66]
|
||||
|
||||
if result.TargetPeer, err = btcec.ParsePubKey(targetPeerIDcompressed, btcec.S256()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.AuthorizedRelayPeer, err = btcec.ParsePubKey(authorizedRelayPeerIDcompressed, btcec.S256()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// receiver and target must not be the same
|
||||
if result.TargetPeer.IsEqual(result.AuthorizedRelayPeer) {
|
||||
return nil, errors.New("traverse: target and relay invalid")
|
||||
}
|
||||
|
||||
expires64 := binary.LittleEndian.Uint64(msg.Payload[66 : 66+8])
|
||||
result.Expires = time.Unix(int64(expires64), 0)
|
||||
|
||||
sizePacketEmbed := binary.LittleEndian.Uint16(msg.Payload[74 : 74+2])
|
||||
if int(sizePacketEmbed) != len(msg.Payload)-traversePayloadHeaderSize {
|
||||
return nil, errors.New("traverse: size embedded packet mismatch")
|
||||
}
|
||||
|
||||
result.EmbeddedPacketRaw = msg.Payload[76 : 76+sizePacketEmbed]
|
||||
|
||||
signature := msg.Payload[76+sizePacketEmbed : 76+sizePacketEmbed+65]
|
||||
|
||||
result.SignerPublicKey, _, err = btcec.RecoverCompact(btcec.S256(), signature, hashData(msg.Payload[:76+sizePacketEmbed]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// IPv4
|
||||
ipv4B := make([]byte, 4)
|
||||
copy(ipv4B[:], msg.Payload[76+sizePacketEmbed+65:76+sizePacketEmbed+65+4])
|
||||
|
||||
result.IPv4 = ipv4B
|
||||
result.PortIPv4 = binary.LittleEndian.Uint16(msg.Payload[76+sizePacketEmbed+65+4 : 76+sizePacketEmbed+65+4+2])
|
||||
result.PortIPv4ReportedExternal = binary.LittleEndian.Uint16(msg.Payload[76+sizePacketEmbed+65+6 : 76+sizePacketEmbed+65+6+2])
|
||||
|
||||
// IPv6
|
||||
ipv6B := make([]byte, 16)
|
||||
copy(ipv6B[:], msg.Payload[76+sizePacketEmbed+65+8:76+sizePacketEmbed+65+8+16])
|
||||
|
||||
result.IPv6 = ipv6B
|
||||
result.PortIPv6 = binary.LittleEndian.Uint16(msg.Payload[76+sizePacketEmbed+65+24 : 76+sizePacketEmbed+65+24+2])
|
||||
result.PortIPv6ReportedExternal = binary.LittleEndian.Uint16(msg.Payload[76+sizePacketEmbed+65+26 : 76+sizePacketEmbed+65+26+2])
|
||||
|
||||
// TODO: Validate IPv4 and IPv6. Only external ones allowed.
|
||||
if result.IPv6.To4() != nil {
|
||||
return nil, errors.New("traverse: ipv6 address mismatch")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// msgEncodeTraverse encodes a traverse message
|
||||
func msgEncodeTraverse(senderPrivateKey *btcec.PrivateKey, embeddedPacketRaw []byte, receiverEnd *btcec.PublicKey, relayPeer *btcec.PublicKey) (packetRaw []byte, err error) {
|
||||
sizePacketEmbed := len(embeddedPacketRaw)
|
||||
if isPacketSizeExceed(traversePayloadHeaderSize, sizePacketEmbed) {
|
||||
return nil, errors.New("traverse encode: embedded packet too big")
|
||||
}
|
||||
|
||||
raw := make([]byte, traversePayloadHeaderSize+sizePacketEmbed)
|
||||
|
||||
targetPeerID := receiverEnd.SerializeCompressed()
|
||||
copy(raw[0:33], targetPeerID)
|
||||
authorizedRelayPeerID := relayPeer.SerializeCompressed()
|
||||
copy(raw[33:66], authorizedRelayPeerID)
|
||||
|
||||
expires64 := time.Now().Add(time.Hour).UTC().Unix()
|
||||
binary.LittleEndian.PutUint64(raw[66:66+8], uint64(expires64))
|
||||
|
||||
binary.LittleEndian.PutUint16(raw[74:74+2], uint16(sizePacketEmbed))
|
||||
copy(raw[76:76+sizePacketEmbed], embeddedPacketRaw)
|
||||
|
||||
// add signature
|
||||
signature, err := btcec.SignCompact(btcec.S256(), senderPrivateKey, hashData(raw[:76+sizePacketEmbed]), true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copy(raw[76+sizePacketEmbed:76+sizePacketEmbed+65], signature)
|
||||
|
||||
// IP and ports are to be filled by authorized relay peer
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// msgEncodeTraverseSetAddress sets the IP and Port
|
||||
//func msgEncodeTraverseSetAddress(raw []byte, IPv4 net.IP, PortIPv4, PortIPv4ReportedExternal uint16, IPv6 net.IP, PortIPv6, PortIPv6ReportedExternal uint16) (err error) {
|
||||
func msgEncodeTraverseSetAddress(raw []byte, connectionIPv4, connectionIPv6 *Connection) (err error) {
|
||||
if isPacketSizeExceed(len(raw), 0) {
|
||||
return errors.New("traverse encode 2: embedded packet too big")
|
||||
} else if len(raw) < traversePayloadHeaderSize {
|
||||
return errors.New("traverse encode 2: invalid packet")
|
||||
}
|
||||
|
||||
sizePacketEmbed := binary.LittleEndian.Uint16(raw[74 : 74+2])
|
||||
if int(sizePacketEmbed) != len(raw)-traversePayloadHeaderSize {
|
||||
return errors.New("traverse encode 2: size embedded packet mismatch")
|
||||
}
|
||||
|
||||
// IPv4
|
||||
if connectionIPv4 != nil && connectionIPv4.IsIPv4() {
|
||||
copy(raw[76+sizePacketEmbed+65:76+sizePacketEmbed+65+4], connectionIPv4.Address.IP.To4())
|
||||
binary.LittleEndian.PutUint16(raw[76+sizePacketEmbed+65+4:76+sizePacketEmbed+65+4+2], uint16(connectionIPv4.Address.Port))
|
||||
binary.LittleEndian.PutUint16(raw[76+sizePacketEmbed+65+6:76+sizePacketEmbed+65+6+2], connectionIPv4.PortExternal)
|
||||
}
|
||||
|
||||
// IPv6
|
||||
if connectionIPv6 != nil && connectionIPv6.IsIPv6() {
|
||||
copy(raw[76+sizePacketEmbed+65+8:76+sizePacketEmbed+65+8+16], connectionIPv6.Address.IP.To16())
|
||||
binary.LittleEndian.PutUint16(raw[76+sizePacketEmbed+65+24:76+sizePacketEmbed+65+24+2], uint16(connectionIPv6.Address.Port))
|
||||
binary.LittleEndian.PutUint16(raw[76+sizePacketEmbed+65+26:76+sizePacketEmbed+65+26+2], connectionIPv6.PortExternal)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- messages sending ----
|
||||
|
||||
// pingConnection sends a ping to the target peer via the specified connection
|
||||
@@ -822,3 +974,13 @@ func (peer *PeerInfo) sendResponse(sequence uint32, sendUA bool, hash2Peers []Ha
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// sendTraverse sends a traverse message
|
||||
func (peer *PeerInfo) sendTraverse(embeddedPacketRaw []byte, receiverEnd *btcec.PublicKey) (err error) {
|
||||
packetRaw, err := msgEncodeTraverse(peerPrivateKey, embeddedPacketRaw, receiverEnd, peer.PublicKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return peer.send(&PacketRaw{Command: CommandTraverse, Payload: packetRaw})
|
||||
}
|
||||
|
||||
11
Network.go
11
Network.go
@@ -203,7 +203,7 @@ func packetWorker(packets <-chan networkWire) {
|
||||
if len(announce.UserAgent) > 0 {
|
||||
peer.UserAgent = announce.UserAgent
|
||||
}
|
||||
peer.Features = response.Features
|
||||
peer.Features = announce.Features
|
||||
|
||||
peer.cmdLocalDiscovery(announce)
|
||||
}
|
||||
@@ -225,6 +225,15 @@ func packetWorker(packets <-chan networkWire) {
|
||||
case CommandChat: // Chat [debug]
|
||||
peer.cmdChat(raw)
|
||||
|
||||
case CommandTraverse:
|
||||
if traverse, _ := msgDecodeTraverse(raw); traverse != nil {
|
||||
if traverse.TargetPeer.IsEqual(peerPublicKey) && traverse.AuthorizedRelayPeer.IsEqual(peer.PublicKey) {
|
||||
peer.cmdTraverseReceive(traverse)
|
||||
} else if traverse.AuthorizedRelayPeer.IsEqual(peerPublicKey) {
|
||||
peer.cmdTraverseForward(traverse)
|
||||
}
|
||||
}
|
||||
|
||||
default: // Unknown command
|
||||
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
The core library which is needed for any Peernet application. It provides connectivity to the network and all basic functions. For details about Peernet see https://peernet.org/. For the current technical roadmap and upcoming releases see the [Talk forum](https://talk.peernet.org/discussion/10/technical-roadmap).
|
||||
|
||||
Current version: 0.2 (pre alpha 2)
|
||||
Current version: Alpha 2
|
||||
|
||||
Current development status: Initial connectivity works. DHT functionality is in development.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user