- src/pake: trimmed fork of schollz/pake/v3, P256-only (stdlib crypto/elliptic), dropping the tscholl2/siec and filippo.io/edwards25519 transitive deps along with it. - src/logger: minimal in-repo replacement for schollz/logger with the same leveled-logging call surface. - src/cli: replaced schollz/cli (urfave/cli fork) with a small flag-package-based parser for the send/relay/receive commands actually in use; subcommand keyword can appear before or after its flags. - Dropped schollz/progressbar entirely (its show-progress code paths were already unreachable dead code since no CLI flag wired them up). go.mod/go.sum no longer reference any schollz/* module.
391 lines
9.9 KiB
Go
391 lines
9.9 KiB
Go
package swarm
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
log "github.com/tzgyn/share/src/logger"
|
|
|
|
"github.com/tzgyn/share/src/comm"
|
|
"github.com/tzgyn/share/src/tcp"
|
|
"github.com/tzgyn/share/src/utils"
|
|
)
|
|
|
|
// Options configures one participant in a swarm room. Everyone who joins
|
|
// with the same Code can both send and receive: peers who pass Files seed
|
|
// them into the room, and every peer (seeding or not) helps re-share
|
|
// whatever chunks it has already received to others who join later.
|
|
type Options struct {
|
|
RelayAddress string
|
|
Code string // room name and PAKE codephrase, both at once
|
|
Files []string // local files to seed; empty means pure receiver
|
|
OutDir string // where received files are written
|
|
OneTime bool // exit once everything currently known is complete, instead of staying to reseed
|
|
}
|
|
|
|
// Run joins the room described by opts and blocks until either an error
|
|
// occurs, or (with OneTime set) every file known in the room has been
|
|
// fully downloaded and verified. Without OneTime, Run only returns on
|
|
// error — like a real torrent peer, it stays to keep reseeding whatever
|
|
// it has to others until stopped.
|
|
func Run(opts Options) error {
|
|
return newClient(opts).run()
|
|
}
|
|
|
|
type sharedFile struct {
|
|
entry FileManifestEntry // immutable identity: ID, Name, Size, Hash
|
|
store *ChunkStore
|
|
}
|
|
|
|
type peerLink struct {
|
|
conn *comm.Comm
|
|
key []byte
|
|
remote string // remote address, for logging who's talking to whom
|
|
}
|
|
|
|
func newPeerLink(c *comm.Comm, key []byte) *peerLink {
|
|
return &peerLink{conn: c, key: key, remote: c.Connection().RemoteAddr().String()}
|
|
}
|
|
|
|
type client struct {
|
|
opts Options
|
|
selfID string
|
|
|
|
mu sync.Mutex
|
|
files map[string]*sharedFile
|
|
pending map[string]map[int64]*peerLink // fileID -> offset -> link we asked
|
|
links map[*peerLink]bool
|
|
|
|
dialedMu sync.Mutex
|
|
dialed map[string]bool // relay peer IDs already dialed, so we don't redial every peer-list update
|
|
}
|
|
|
|
func newClient(opts Options) *client {
|
|
return &client{
|
|
opts: opts,
|
|
selfID: randomID(),
|
|
files: make(map[string]*sharedFile),
|
|
pending: make(map[string]map[int64]*peerLink),
|
|
links: make(map[*peerLink]bool),
|
|
dialed: make(map[string]bool),
|
|
}
|
|
}
|
|
|
|
func randomID() string {
|
|
b := make([]byte, 8)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(err)
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func (cl *client) run() error {
|
|
for _, path := range cl.opts.Files {
|
|
if err := cl.addSeedFile(path); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
listener, listenAddr, err := startListener()
|
|
if err != nil {
|
|
return fmt.Errorf("could not open a listener for peers: %w", err)
|
|
}
|
|
defer listener.Close()
|
|
go cl.acceptLoop(listener)
|
|
|
|
relayConn, peerUpdates, err := tcp.JoinRoom(cl.opts.RelayAddress, cl.opts.Code, tcp.PeerInfo{ID: cl.selfID, Addr: listenAddr})
|
|
if err != nil {
|
|
return fmt.Errorf("could not join room: %w", err)
|
|
}
|
|
defer relayConn.Close()
|
|
|
|
for {
|
|
select {
|
|
case peers, ok := <-peerUpdates:
|
|
if !ok {
|
|
return fmt.Errorf("lost connection to relay")
|
|
}
|
|
for _, p := range peers {
|
|
if p.ID == cl.selfID || p.Addr == "" {
|
|
continue
|
|
}
|
|
cl.dialedMu.Lock()
|
|
already := cl.dialed[p.ID]
|
|
cl.dialed[p.ID] = true
|
|
cl.dialedMu.Unlock()
|
|
if !already {
|
|
go cl.dialPeer(p)
|
|
}
|
|
}
|
|
case <-time.After(500 * time.Millisecond):
|
|
if cl.opts.OneTime && cl.allKnownFilesComplete() {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (cl *client) addSeedFile(path string) error {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.IsDir() {
|
|
return fmt.Errorf("%s is a directory; folder sharing isn't supported yet", path)
|
|
}
|
|
hash, err := utils.HashFile(path, "xxhash")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name := filepath.Base(path)
|
|
id := utils.SHA256(fmt.Sprintf("%s:%d", name, info.Size()))
|
|
cl.files[id] = &sharedFile{
|
|
entry: FileManifestEntry{ID: id, Name: name, Size: info.Size(), Hash: hash},
|
|
store: NewSeedChunkStore(path, info.Size()),
|
|
}
|
|
log.Infof("seeding %s (%s)", name, utils.ByteCountDecimal(info.Size()))
|
|
return nil
|
|
}
|
|
|
|
func (cl *client) allKnownFilesComplete() bool {
|
|
cl.mu.Lock()
|
|
defer cl.mu.Unlock()
|
|
if len(cl.files) == 0 {
|
|
return false
|
|
}
|
|
for _, f := range cl.files {
|
|
if !f.store.Complete() {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func startListener() (net.Listener, string, error) {
|
|
l, err := net.Listen("tcp", ":0")
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
port := l.Addr().(*net.TCPAddr).Port
|
|
return l, net.JoinHostPort(utils.LocalIP(), strconv.Itoa(port)), nil
|
|
}
|
|
|
|
func (cl *client) acceptLoop(l net.Listener) {
|
|
for {
|
|
conn, err := l.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go func(conn net.Conn) {
|
|
c := comm.New(conn)
|
|
key, err := handshake(c, cl.opts.Code, false)
|
|
if err != nil {
|
|
log.Debugf("peer handshake failed: %v", err)
|
|
conn.Close()
|
|
return
|
|
}
|
|
cl.handlePeerLink(newPeerLink(c, key))
|
|
}(conn)
|
|
}
|
|
}
|
|
|
|
func (cl *client) dialPeer(p tcp.PeerInfo) {
|
|
conn, err := net.DialTimeout("tcp", p.Addr, 5*time.Second)
|
|
if err != nil {
|
|
log.Debugf("could not dial peer %s at %s: %v", p.ID, p.Addr, err)
|
|
return
|
|
}
|
|
c := comm.New(conn)
|
|
key, err := handshake(c, cl.opts.Code, true)
|
|
if err != nil {
|
|
log.Debugf("handshake with peer %s failed: %v", p.ID, err)
|
|
conn.Close()
|
|
return
|
|
}
|
|
cl.handlePeerLink(newPeerLink(c, key))
|
|
}
|
|
|
|
func (cl *client) handlePeerLink(link *peerLink) {
|
|
cl.mu.Lock()
|
|
cl.links[link] = true
|
|
cl.mu.Unlock()
|
|
log.Infof("connected to peer %s", link.remote)
|
|
defer func() {
|
|
cl.mu.Lock()
|
|
delete(cl.links, link)
|
|
// free up anything only this link was asked for, so others can serve it
|
|
for _, byOffset := range cl.pending {
|
|
for offset, l := range byOffset {
|
|
if l == link {
|
|
delete(byOffset, offset)
|
|
}
|
|
}
|
|
}
|
|
cl.mu.Unlock()
|
|
link.conn.Close()
|
|
log.Infof("disconnected from peer %s", link.remote)
|
|
}()
|
|
|
|
if err := cl.sendManifest(link); err != nil {
|
|
log.Debugf("send manifest failed: %v", err)
|
|
return
|
|
}
|
|
|
|
for {
|
|
msg, err := receiveMessage(link.conn, link.key)
|
|
if err != nil {
|
|
log.Debugf("peer link closed: %v", err)
|
|
return
|
|
}
|
|
if err := cl.handleMessage(link, msg); err != nil {
|
|
log.Debugf("handling %s message: %v", msg.Type, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (cl *client) sendManifest(link *peerLink) error {
|
|
cl.mu.Lock()
|
|
files := make([]FileManifestEntry, 0, len(cl.files))
|
|
for _, f := range cl.files {
|
|
e := f.entry
|
|
e.Have = f.store.HaveOffsets()
|
|
files = append(files, e)
|
|
}
|
|
cl.mu.Unlock()
|
|
return sendMessage(link.conn, link.key, Message{Type: TypeManifest, Files: files})
|
|
}
|
|
|
|
func (cl *client) handleMessage(link *peerLink, msg Message) error {
|
|
switch msg.Type {
|
|
case TypeManifest:
|
|
for _, entry := range msg.Files {
|
|
cl.learnFile(entry)
|
|
cl.considerRequests(entry.ID, entry.Have, link)
|
|
}
|
|
case TypeHave:
|
|
cl.considerRequests(msg.FileID, []int64{msg.Offset}, link)
|
|
case TypeRequest:
|
|
return cl.serveRequest(link, msg.FileID, msg.Offset)
|
|
case TypeChunk:
|
|
return cl.receiveChunk(link, msg.FileID, msg.Offset, msg.Data)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (cl *client) learnFile(entry FileManifestEntry) {
|
|
cl.mu.Lock()
|
|
_, known := cl.files[entry.ID]
|
|
cl.mu.Unlock()
|
|
if known {
|
|
return
|
|
}
|
|
path := filepath.Join(cl.opts.OutDir, entry.Name)
|
|
store, err := OpenChunkStore(path, entry.Size)
|
|
if err != nil {
|
|
log.Errorf("could not open %s for receiving: %v", path, err)
|
|
return
|
|
}
|
|
cl.mu.Lock()
|
|
cl.files[entry.ID] = &sharedFile{
|
|
entry: FileManifestEntry{ID: entry.ID, Name: entry.Name, Size: entry.Size, Hash: entry.Hash},
|
|
store: store,
|
|
}
|
|
cl.mu.Unlock()
|
|
log.Infof("new file in room: %s (%s)", entry.Name, utils.ByteCountDecimal(entry.Size))
|
|
}
|
|
|
|
// considerRequests asks link for any of the given offsets of fileID that
|
|
// we're still missing and haven't already asked someone else for.
|
|
func (cl *client) considerRequests(fileID string, offsets []int64, link *peerLink) {
|
|
cl.mu.Lock()
|
|
f, ok := cl.files[fileID]
|
|
if !ok {
|
|
cl.mu.Unlock()
|
|
return
|
|
}
|
|
if cl.pending[fileID] == nil {
|
|
cl.pending[fileID] = make(map[int64]*peerLink)
|
|
}
|
|
toRequest := make([]int64, 0, len(offsets))
|
|
for _, off := range offsets {
|
|
if f.store.HasChunk(off) {
|
|
continue
|
|
}
|
|
if _, already := cl.pending[fileID][off]; already {
|
|
continue
|
|
}
|
|
cl.pending[fileID][off] = link
|
|
toRequest = append(toRequest, off)
|
|
}
|
|
cl.mu.Unlock()
|
|
|
|
for _, off := range toRequest {
|
|
if err := sendMessage(link.conn, link.key, Message{Type: TypeRequest, FileID: fileID, Offset: off}); err != nil {
|
|
log.Debugf("request failed: %v", err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (cl *client) serveRequest(link *peerLink, fileID string, offset int64) error {
|
|
cl.mu.Lock()
|
|
f, ok := cl.files[fileID]
|
|
cl.mu.Unlock()
|
|
if !ok || !f.store.HasChunk(offset) {
|
|
return nil
|
|
}
|
|
data, err := f.store.ReadChunk(offset)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log.Infof("sending %s chunk %d/%d to %s", f.entry.Name, offset/ChunkSize+1, NumChunks(f.entry.Size), link.remote)
|
|
return sendMessage(link.conn, link.key, Message{Type: TypeChunk, FileID: fileID, Offset: offset, Data: data})
|
|
}
|
|
|
|
func (cl *client) receiveChunk(link *peerLink, fileID string, offset int64, data []byte) error {
|
|
cl.mu.Lock()
|
|
f, ok := cl.files[fileID]
|
|
if ok {
|
|
delete(cl.pending[fileID], offset)
|
|
}
|
|
links := make([]*peerLink, 0, len(cl.links))
|
|
for l := range cl.links {
|
|
links = append(links, l)
|
|
}
|
|
cl.mu.Unlock()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
log.Infof("received %s chunk %d/%d from %s", f.entry.Name, offset/ChunkSize+1, NumChunks(f.entry.Size), link.remote)
|
|
if err := f.store.WriteChunk(offset, data); err != nil {
|
|
return err
|
|
}
|
|
|
|
// let everyone else in the swarm know we can now serve this chunk too
|
|
for _, l := range links {
|
|
go func(l *peerLink) {
|
|
_ = sendMessage(l.conn, l.key, Message{Type: TypeHave, FileID: fileID, Offset: offset})
|
|
}(l)
|
|
}
|
|
|
|
if f.store.Complete() {
|
|
verified, err := f.store.VerifyHash(f.entry.Hash)
|
|
if err != nil {
|
|
log.Errorf("could not verify %s: %v", f.entry.Name, err)
|
|
} else if !verified {
|
|
log.Errorf("%s failed hash verification after transfer", f.entry.Name)
|
|
} else {
|
|
log.Infof("%s complete and verified", f.entry.Name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|