feat: implement security, discovery, history, file transfer, web UI (P0-P3)

This commit is contained in:
2026-08-14 09:45:54 -04:00
parent 8281478f0c
commit 884415c1c1
32 changed files with 1923 additions and 200 deletions
+168 -17
View File
@@ -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 {
+2 -2
View File
@@ -36,7 +36,7 @@ func TestBroadcastSendsPayload(t *testing.T) {
{Name: "test-peer", Addr: ts.Listener.Addr().String()},
}
b := NewBroadcaster(peers, "my-machine")
b := NewBroadcaster(peers, "my-machine", Options{})
b.Broadcast("hello, peer!")
select {
@@ -57,7 +57,7 @@ func TestBroadcastSendsPayload(t *testing.T) {
func TestBroadcastEmptyPeers(t *testing.T) {
// Should not panic or error with zero peers.
b := NewBroadcaster(nil, "my-machine")
b := NewBroadcaster(nil, "my-machine", Options{})
b.Broadcast("test")
b.Broadcast("")
}