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
+174 -15
View File
@@ -2,16 +2,16 @@
//
// Usage:
//
// clip-sync [--config path]
// clip-sync [flags] # run the daemon
// clip-sync history # print local clipboard history
// clip-sync --one-shot [text] # send the clipboard (or text) once, then exit
//
// clip-sync polls the local clipboard every 500 ms and broadcasts any new
// text content to all configured peers via HTTP POST /clip. It also runs an
// HTTP server on :9137 (configurable) to receive clips from peers and write
// them to the local clipboard.
// Flags:
//
// Configuration is read from ~/.config/clip-sync/peers.toml (TOML format).
// Environment variables CLIP_SYNC_PORT and CLIP_SYNC_POLL_MS override
// the corresponding config values.
// --config path config file (default ~/.config/clip-sync/peers.toml)
// --version print version and exit
// --generate-key generate a shared key and exit
// --generate-cert generate a self-signed TLS cert/key and exit
package main
import (
@@ -21,31 +21,94 @@ import (
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"
"git.dracodev.net/Projets/clip-sync/internal/clipboard"
"git.dracodev.net/Projets/clip-sync/internal/config"
"git.dracodev.net/Projets/clip-sync/internal/daemon"
"git.dracodev.net/Projets/clip-sync/internal/history"
"git.dracodev.net/Projets/clip-sync/internal/peer"
"git.dracodev.net/Projets/clip-sync/internal/security"
"git.dracodev.net/Projets/clip-sync/internal/transfer"
)
var version = "dev"
// version is overridden at build time via -ldflags "-X main.version=...".
var version = "0.2.0"
func main() {
showVersion := flag.Bool("version", false, "print version and exit")
flag.Parse()
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.SetPrefix("")
fs := flag.NewFlagSet("clip-sync", flag.ExitOnError)
configPath := fs.String("config", "", "config file path")
showVersion := fs.Bool("version", false, "print version and exit")
generateKey := fs.Bool("generate-key", false, "generate a shared key and exit")
generateCert := fs.Bool("generate-cert", false, "generate a self-signed TLS certificate and exit")
oneShot := fs.Bool("one-shot", false, "send the clipboard once and exit")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage:\n clip-sync [flags]\n clip-sync history\n\nFlags:\n")
fs.PrintDefaults()
}
_ = fs.Parse(os.Args[1:])
// Subcommand: history
if fs.NArg() > 0 && fs.Arg(0) == "history" {
runHistory()
return
}
if *showVersion {
fmt.Printf("clip-sync %s\n", version)
return
}
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.SetPrefix("")
if *generateKey {
key, err := security.GenerateKey()
if err != nil {
log.Fatalf("clip-sync: generate key: %v", err)
}
fmt.Println(key)
return
}
cfg, err := config.Load()
if *generateCert {
runGenerateCert()
return
}
cfg, err := loadConfig(*configPath)
if err != nil {
log.Fatalf("clip-sync: config: %v", err)
}
if *oneShot {
runOneShot(cfg, fs.Arg(0))
return
}
// Subcommand: send-file <path>
if fs.NArg() > 0 && fs.Arg(0) == "send-file" {
path := ""
if fs.NArg() > 1 {
path = fs.Arg(1)
}
runSendFile(cfg, path)
return
}
runDaemon(cfg)
}
func loadConfig(path string) (*config.Config, error) {
if path != "" {
return config.LoadFrom(path)
}
return config.Load()
}
func runDaemon(cfg *config.Config) {
d, err := daemon.New(cfg)
if err != nil {
log.Fatalf("clip-sync: init: %v", err)
@@ -54,7 +117,6 @@ func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Handle graceful shutdown on SIGINT / SIGTERM.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
@@ -70,3 +132,100 @@ func main() {
log.Println("clip-sync: stopped")
}
func runOneShot(cfg *config.Config, textArg string) {
origin, err := os.Hostname()
if err != nil {
log.Fatalf("clip-sync: hostname: %v", err)
}
b := peer.NewBroadcaster(cfg.Peers, origin, peer.Options{
SharedKey: cfg.Security.SharedKey,
TLS: cfg.Security.TLS,
CertFile: cfg.Security.CertFile,
InsecureSkipVerify: cfg.Security.InsecureSkipVerify,
})
text := textArg
if text == "" {
cb, err := clipboard.New()
if err != nil {
log.Fatalf("clip-sync: clipboard: %v", err)
}
text, err = cb.Read()
if err != nil {
log.Fatalf("clip-sync: clipboard read: %v", err)
}
}
if text == "" {
log.Fatal("clip-sync: nothing to send (clipboard is empty)")
}
b.BroadcastSync(text)
fmt.Printf("clip-sync: sent %d bytes to %d peer(s)\n", len(text), b.PeerCount())
}
func runSendFile(cfg *config.Config, path string) {
if path == "" {
log.Fatal("clip-sync: usage: clip-sync send-file <path>")
}
origin, err := os.Hostname()
if err != nil {
log.Fatalf("clip-sync: hostname: %v", err)
}
f, err := transfer.ReadFile(path, origin)
if err != nil {
log.Fatalf("clip-sync: read file: %v", err)
}
b := peer.NewBroadcaster(cfg.Peers, origin, peer.Options{
SharedKey: cfg.Security.SharedKey,
TLS: cfg.Security.TLS,
CertFile: cfg.Security.CertFile,
InsecureSkipVerify: cfg.Security.InsecureSkipVerify,
})
b.SendFileSync(*f)
fmt.Printf("clip-sync: sent %q (%s) to %d peer(s)\n", f.Name, f.Mime, b.PeerCount())
}
func runHistory() {
path, err := history.DefaultPath()
if err != nil {
log.Fatalf("clip-sync: history: %v", err)
}
entries := history.New(path, 0).List()
if len(entries) == 0 {
fmt.Println("clip-sync: no history yet")
return
}
for _, e := range entries {
ts := time.Unix(0, e.Ts).Format("2006-01-02 15:04:05")
text := strings.ReplaceAll(e.Text, "\n", "\\n")
if len(text) > 80 {
text = text[:80] + "…"
}
fmt.Printf("%s [%s] %s\n", ts, e.Origin, text)
}
}
func runGenerateCert() {
certFile, err := config.DefaultCertFile()
if err != nil {
log.Fatalf("clip-sync: cert path: %v", err)
}
keyFile, err := config.DefaultKeyFile()
if err != nil {
log.Fatalf("clip-sync: key path: %v", err)
}
host, _ := os.Hostname()
if err := security.GenerateSelfSignedCert(certFile, keyFile, []string{host, "localhost"}); err != nil {
log.Fatalf("clip-sync: generate cert: %v", err)
}
fmt.Printf("clip-sync: certificate written to %s and %s\n", certFile, keyFile)
fmt.Println("Set [security] tls = true and cert_file/key_file in peers.toml, and share these files with your peers.")
}