feat: implement security, discovery, history, file transfer, web UI (P0-P3)
This commit is contained in:
+168
-17
@@ -1,68 +1,216 @@
|
||||
// Package peer sends clipboard content to remote clip-sync peers via HTTP POST.
|
||||
// Package peer sends clipboard content to remote clip-sync peers via HTTP(S) POST.
|
||||
package peer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.dracodev.net/Projets/clip-sync/internal/config"
|
||||
"git.dracodev.net/Projets/clip-sync/internal/dedup"
|
||||
"git.dracodev.net/Projets/clip-sync/internal/transfer"
|
||||
)
|
||||
|
||||
const clipPath = "/clip"
|
||||
const (
|
||||
clipPath = "/clip"
|
||||
filePath = "/file"
|
||||
)
|
||||
|
||||
// Options configures peer transport security.
|
||||
type Options struct {
|
||||
SharedKey string
|
||||
TLS bool // global TLS default
|
||||
CertFile string // CA (self-signed cert) to trust
|
||||
InsecureSkipVerify bool
|
||||
}
|
||||
|
||||
// Broadcaster sends clip payloads to all configured peers concurrently.
|
||||
type Broadcaster struct {
|
||||
mu sync.RWMutex
|
||||
peers []config.PeerConfig
|
||||
origin string
|
||||
client *http.Client
|
||||
opts Options
|
||||
}
|
||||
|
||||
// NewBroadcaster creates a Broadcaster for the configured peer list.
|
||||
func NewBroadcaster(peers []config.PeerConfig, origin string) *Broadcaster {
|
||||
func NewBroadcaster(peers []config.PeerConfig, origin string, opts Options) *Broadcaster {
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 3 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConnsPerHost: 2,
|
||||
TLSClientConfig: buildTLSConfig(opts),
|
||||
},
|
||||
}
|
||||
return &Broadcaster{
|
||||
peers: peers,
|
||||
origin: origin,
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 3 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConnsPerHost: 2,
|
||||
},
|
||||
},
|
||||
client: client,
|
||||
opts: opts,
|
||||
}
|
||||
}
|
||||
|
||||
func buildTLSConfig(opts Options) *tls.Config {
|
||||
if !opts.TLS {
|
||||
return nil
|
||||
}
|
||||
cfg := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: opts.InsecureSkipVerify, // #nosec G402 — opt-in LAN trust
|
||||
}
|
||||
if !opts.InsecureSkipVerify && opts.CertFile != "" {
|
||||
if pem, err := os.ReadFile(opts.CertFile); err == nil {
|
||||
pool := x509.NewCertPool()
|
||||
if pool.AppendCertsFromPEM(pem) {
|
||||
cfg.RootCAs = pool
|
||||
}
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Broadcast sends the given text to all configured peers in parallel.
|
||||
// Errors are logged but not returned — a single unreachable peer should not
|
||||
// block other peers or the daemon loop.
|
||||
func (b *Broadcaster) Broadcast(text string) {
|
||||
body, ok := b.marshalPayload(text)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
snap := b.snapshot()
|
||||
for i := range snap {
|
||||
go b.sendToPeer(snap[i], body)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastSync sends the given text to all peers and blocks until every send
|
||||
// has completed (or failed). Used by the one-shot mode so the process does not
|
||||
// exit before the payload is on the wire.
|
||||
func (b *Broadcaster) BroadcastSync(text string) {
|
||||
body, ok := b.marshalPayload(text)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
snap := b.snapshot()
|
||||
var wg sync.WaitGroup
|
||||
for i := range snap {
|
||||
wg.Add(1)
|
||||
go func(p config.PeerConfig) {
|
||||
defer wg.Done()
|
||||
b.sendToPeer(p, body)
|
||||
}(snap[i])
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (b *Broadcaster) marshalPayload(text string) ([]byte, bool) {
|
||||
payload := dedup.Payload{
|
||||
Text: text,
|
||||
Ts: time.Now().UnixNano(),
|
||||
Origin: b.origin,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
log.Printf("peer: marshal payload: %v", err)
|
||||
return nil, false
|
||||
}
|
||||
return body, true
|
||||
}
|
||||
|
||||
// snapshot returns a copy of the current peer list under read lock.
|
||||
func (b *Broadcaster) snapshot() []config.PeerConfig {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return append([]config.PeerConfig(nil), b.peers...)
|
||||
}
|
||||
|
||||
// PeerCount returns the number of currently configured peers.
|
||||
func (b *Broadcaster) PeerCount() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return len(b.peers)
|
||||
}
|
||||
|
||||
// AddPeer adds a peer if its address is not already present.
|
||||
func (b *Broadcaster) AddPeer(p config.PeerConfig) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for _, existing := range b.peers {
|
||||
if existing.Addr == p.Addr {
|
||||
return
|
||||
}
|
||||
}
|
||||
b.peers = append(b.peers, p)
|
||||
}
|
||||
|
||||
// RemovePeer removes a peer by address.
|
||||
func (b *Broadcaster) RemovePeer(addr string) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
out := b.peers[:0]
|
||||
for _, p := range b.peers {
|
||||
if p.Addr != addr {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
b.peers = out
|
||||
}
|
||||
|
||||
// SendFile sends a binary payload to all configured peers in parallel.
|
||||
func (b *Broadcaster) SendFile(f transfer.File) {
|
||||
body, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
log.Printf("peer: marshal file: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for i := range b.peers {
|
||||
go b.sendToPeer(b.peers[i], body)
|
||||
snap := b.snapshot()
|
||||
for i := range snap {
|
||||
go b.sendToPath(snap[i], filePath, body)
|
||||
}
|
||||
}
|
||||
|
||||
// SendFileSync sends a binary payload to all peers and blocks until done.
|
||||
func (b *Broadcaster) SendFileSync(f transfer.File) {
|
||||
body, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
log.Printf("peer: marshal file: %v", err)
|
||||
return
|
||||
}
|
||||
snap := b.snapshot()
|
||||
var wg sync.WaitGroup
|
||||
for i := range snap {
|
||||
wg.Add(1)
|
||||
go func(p config.PeerConfig) {
|
||||
defer wg.Done()
|
||||
b.sendToPath(p, filePath, body)
|
||||
}(snap[i])
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (b *Broadcaster) sendToPeer(p config.PeerConfig, body []byte) {
|
||||
url := fmt.Sprintf("http://%s%s", p.Addr, clipPath)
|
||||
b.sendToPath(p, clipPath, body)
|
||||
}
|
||||
|
||||
func (b *Broadcaster) sendToPath(p config.PeerConfig, path string, body []byte) {
|
||||
scheme := "http"
|
||||
if p.TLS || b.opts.TLS {
|
||||
scheme = "https"
|
||||
}
|
||||
url := fmt.Sprintf("%s://%s%s", scheme, p.Addr, path)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -70,6 +218,9 @@ func (b *Broadcaster) sendToPeer(p config.PeerConfig, body []byte) {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if b.opts.SharedKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+b.opts.SharedKey)
|
||||
}
|
||||
|
||||
resp, err := b.client.Do(req)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user