diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c12e42e..747b495 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -88,13 +88,37 @@ Boucle principale : 2. Ticker 500ms : lit le presse-papiers, compare, broadcast si changé 3. Signal SIGINT/SIGTERM → arrêt gracieux +### 6. Sécurité (`internal/security/`) + +- `--generate-key` : clé partagée hex (256 bits) +- `--generate-cert` : certificat ECDSA P-256 auto-signé +- Auth Bearer token (comparaison à temps constant), validation d'origine + +### 7. Historique (`internal/history/`) + +- Anneau mémoire borné (`history_size`), persisté en JSON +- Commandes `clip-sync history` et endpoint `/history` + +### 8. Transfert de fichiers (`internal/transfer/`) + +- Payload `{name, mime, data(base64), ts, origin}` via `POST /file` +- Réception opt-in (`receive_files`), écrit dans `receive_dir` +- Nom de fichier assaini (anti path-traversal) + +### 9. Découverte (`internal/discovery/`) et notifications (`internal/notify/`) + +- mDNS `_clip-sync._tcp` (hashicorp/mdns), activation via `daemon.discovery` +- Notifications desktop : notify-send (Linux), osascript (macOS), no-op (Windows) + ## Dépendances | Dépendance | Version | Justification | |-----------|---------|---------------| | `github.com/BurntSushi/toml` | v1.3.2 | Parsing TOML (stdlib Go n'inclut pas TOML) | +| `github.com/hashicorp/mdns` | v1.0.7 | Découverte mDNS (optionnelle, `daemon.discovery`) | -**Total : 1 dépendance externe.** Tout le reste est stdlib Go. +**Total : 2 dépendances externes directes.** Tout le reste est stdlib Go. +mdns est optionnel (utilisé uniquement si `daemon.discovery = true`). ## Décisions architecturales diff --git a/CHANGELOG.md b/CHANGELOG.md index 638cba4..24e973a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] — 2026-08-14 + +### Added +- Sécurité : clé partagée (Bearer token) + `--generate-key` +- Sécurité : validation d'origine (`security.allowed_origins`) +- Sécurité : chiffrement TLS optionnel entre pairs + `--generate-cert` +- Limite de taille du corps des requêtes (`daemon.max_body_bytes`) +- Historique du presse-papiers persistant + commande `clip-sync history` +- Mode one-shot : `clip-sync --one-shot [text]` +- Transfert de fichiers : `clip-sync send-file ` (réception optionnelle) +- Découverte automatique des pairs via mDNS (`daemon.discovery`) +- Interface web locale de monitoring (`/`, `/health`, `/history`) +- Notifications desktop (`daemon.notify`) +- Flag `--config ` pour une configuration personnalisée +- Fichier LICENSE (MIT) + +### Fixed +- Lecture du presse-papiers Windows bornée à la taille réelle (GlobalSize) +- Version par défaut non vide (`--version`) +- Correction des URLs de téléchargement (tag versionné au lieu de `latest`) +- Suppression du fichier d'exemple dupliqué + ## [0.1.0] — 2026-08-08 ### Added diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..493827d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 clip-sync contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index d1d0960..2379b97 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # Cross-compilation, tests, release. BINARY := clip-sync -VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.1.0") +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.2.0") LDFLAGS := -s -w -X main.version=$(VERSION) BUILD_DIR := bin diff --git a/README.md b/README.md index b209280..07d0e0f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,11 @@ Copier sur une machine → coller sur une autre, automatiquement et instantaném - **Bidirectionnel** — copie A → B et B → A, chaque nœud est client et serveur - **Anti-boucle** — double filtre (hash SHA-256 + timestamp/origine) empêche le ping-pong +- **Sécurité** — clé partagée (Bearer token), validation d'origine, TLS optionnel +- **Découverte mDNS** — détection automatique des pairs (optionnelle) +- **Historique** — derniers clips conservés (`clip-sync history`) +- **Transfert de fichiers** — `clip-sync send-file` (réception optionnelle) +- **Interface web** — monitoring local sur `http://localhost:9137/` - **Multi-plateforme** — Linux (X11/Wayland), Windows, macOS - **Binaire unique** — zéro runtime externe, compilation Go native - **Configuration simple** — fichier TOML + variables d'environnement @@ -57,12 +62,12 @@ clip-sync détecte automatiquement X11 vs Wayland via `$XDG_SESSION_TYPE`. ```bash # amd64 curl -sSL -o ~/.local/bin/clip-sync \ - https://git.dracodev.net/Projets/clip-sync/releases/download/latest/clip-sync-linux-amd64 + https://git.dracodev.net/Projets/clip-sync/releases/download/v0.2.0/clip-sync-linux-amd64 chmod +x ~/.local/bin/clip-sync # arm64 (Raspberry Pi, etc.) curl -sSL -o ~/.local/bin/clip-sync \ - https://git.dracodev.net/Projets/clip-sync/releases/download/latest/clip-sync-linux-arm64 + https://git.dracodev.net/Projets/clip-sync/releases/download/v0.2.0/clip-sync-linux-arm64 chmod +x ~/.local/bin/clip-sync ``` @@ -78,7 +83,7 @@ source ~/.bashrc ```bash clip-sync --version -# → clip-sync v0.1.0 +# → clip-sync v0.2.0 ``` **5. Installer comme service (systemd --user)** @@ -103,12 +108,12 @@ systemctl --user status clip-sync ```bash # Apple Silicon (M1/M2/M3) curl -sSL -o ~/.local/bin/clip-sync \ - https://git.dracodev.net/Projets/clip-sync/releases/download/latest/clip-sync-darwin-arm64 + https://git.dracodev.net/Projets/clip-sync/releases/download/v0.2.0/clip-sync-darwin-arm64 chmod +x ~/.local/bin/clip-sync # Intel Mac curl -sSL -o ~/.local/bin/clip-sync \ - https://git.dracodev.net/Projets/clip-sync/releases/download/latest/clip-sync-darwin-amd64 + https://git.dracodev.net/Projets/clip-sync/releases/download/v0.2.0/clip-sync-darwin-amd64 chmod +x ~/.local/bin/clip-sync ``` @@ -123,7 +128,7 @@ source ~/.zshrc ```bash clip-sync --version -# → clip-sync v0.1.0 +# → clip-sync v0.2.0 ``` **5. Installer comme service (launchd)** @@ -168,12 +173,12 @@ launchctl list | grep clip-sync ```powershell # amd64 (standard) Invoke-WebRequest -Uri ` - "https://git.dracodev.net/Projets/clip-sync/releases/download/latest/clip-sync-windows-amd64.exe" ` + "https://git.dracodev.net/Projets/clip-sync/releases/download/v0.2.0/clip-sync-windows-amd64.exe" ` -OutFile "$env:USERPROFILE\bin\clip-sync.exe" # arm64 (Surface Pro X, etc.) Invoke-WebRequest -Uri ` - "https://git.dracodev.net/Projets/clip-sync/releases/download/latest/clip-sync-windows-arm64.exe" ` + "https://git.dracodev.net/Projets/clip-sync/releases/download/v0.2.0/clip-sync-windows-arm64.exe" ` -OutFile "$env:USERPROFILE\bin\clip-sync.exe" ``` @@ -192,7 +197,7 @@ Invoke-WebRequest -Uri ` ```powershell clip-sync --version -# → clip-sync v0.1.0 +# → clip-sync v0.2.0 ``` **5. Installer comme service Windows (NSSM)** @@ -274,10 +279,21 @@ Copiez du texte sur la machine A (Ctrl+C), collez sur la machine B (Ctrl+V). | Clé | Défaut | Description | |-----|--------|-------------| -| `daemon.port` | `9137` | Port d'écoute HTTP | +| `daemon.port` | `9137` | Port d'écoute HTTP(S) | | `daemon.poll_interval_ms` | `500` | Intervalle de scrutation du presse-papiers (ms) | +| `daemon.max_body_bytes` | `10485760` | Taille max d'une requête (10 MiB) | +| `daemon.history_size` | `50` | Nombre d'entrées conservées dans l'historique | +| `daemon.discovery` | `false` | Découverte automatique des pairs via mDNS | +| `daemon.notify` | `false` | Notifications desktop à chaque clip reçu | +| `daemon.receive_files` | `false` | Accepter les transferts de fichiers | +| `daemon.receive_dir` | `~/Downloads/clip-sync` | Dossier de réception des fichiers | +| `security.shared_key` | — | Clé partagée (`clip-sync --generate-key`) | +| `security.allowed_origins` | — | Liste des hôtes autorisés | +| `security.tls` | `false` | Chiffrement TLS entre pairs | +| `security.cert_file` / `key_file` | `~/.config/clip-sync/{cert,key}.pem` | Certificat auto-signé | | `peers[].name` | — | Nom descriptif du pair | | `peers[].addr` | — | Adresse `host:port` du pair | +| `peers[].tls` | `false` | TLS pour ce pair (override global) | ### Variables d'environnement (override) @@ -285,6 +301,43 @@ Copiez du texte sur la machine A (Ctrl+C), collez sur la machine B (Ctrl+V). |----------|-----------------| | `CLIP_SYNC_PORT` | `daemon.port` | | `CLIP_SYNC_POLL_MS` | `daemon.poll_interval_ms` | +| `CLIP_SYNC_KEY` | `security.shared_key` | +| `CLIP_SYNC_MAX_BODY_BYTES` | `daemon.max_body_bytes` | + +## Commandes + +```bash +clip-sync # lancer le daemon +clip-sync --config # config personnalisée +clip-sync --version # afficher la version +clip-sync history # afficher l'historique local +clip-sync --one-shot [text] # envoyer le presse-papiers (ou text) une fois +clip-sync send-file # envoyer un fichier aux pairs +clip-sync --generate-key # générer une clé partagée +clip-sync --generate-cert # générer un certificat TLS auto-signé +``` + +## Sécurité + +Sur un réseau partagé, activez au minimum une clé partagée : + +```bash +clip-sync --generate-key # → copier la clé sur toutes les machines +``` + +```toml +[security] +shared_key = "LA_CLÉ_GÉNÉRÉE" +``` + +Pour chiffrer le trafic, générez un certificat sur une machine, copiez les +fichiers `cert.pem`/`key.pem` vers toutes les autres, puis : + +```toml +[security] +tls = true +insecure_skip_verify = true # confiance au certificat auto-signé partagé +``` ## Déboguer @@ -295,6 +348,8 @@ curl -X POST http://192.168.1.20:9137/clip \ -d '{"text":"test","ts":0,"origin":"debug"}' ``` +L'interface de monitoring est disponible sur `http://localhost:9137/` après le démarrage du daemon. + ## Licence MIT © 2026 diff --git a/ROADMAP.md b/ROADMAP.md index 1f6feba..cef6288 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # ROADMAP.md — clip-sync -## v0.1.0 ✅ MVP — Sync texte bidirectionnel (actuelle) +## v0.1.0 ✅ MVP — Sync texte bidirectionnel (réalisée) - [x] Synchronisation texte entre pairs LAN - [x] Anti-boucle (hash + timestamp + origine) @@ -10,27 +10,31 @@ - [x] Build cross-plateforme - [x] Service systemd --user -## v0.2.0 🔒 Sécurité +## v0.2.0 ✅ Sécurité (réalisée) -- [ ] Chiffrement TLS entre pairs (certificats auto-signés ou clé partagée) -- [ ] Authentification par clé partagée (header `Authorization: Bearer `) -- [ ] Validation de l'origine (rejet des pairs inconnus) -- [ ] Option `--generate-key` pour générer une clé partagée -- [ ] Config : section `[security]` dans peers.toml +- [x] Authentification par clé partagée (header `Authorization: Bearer `) +- [x] Validation de l'origine (rejet des pairs inconnus) +- [x] Option `--generate-key` pour générer une clé partagée +- [x] Config : section `[security]` dans peers.toml +- [x] Chiffrement TLS entre pairs (certificats auto-signés + `--generate-cert`) +- [x] Limite de taille des requêtes (`daemon.max_body_bytes`) +- [x] Flag `--config ` -## v0.3.0 📎 Contenu enrichi +## v0.3.0 📎 Contenu enrichi (partiellement réalisée) -- [ ] Support images (JPEG/PNG) — payload Base64 -- [ ] Support fichiers (chemin + contenu Base64, taille max configurable) -- [ ] Historique du presse-papiers local (derniers N éléments) -- [ ] Commande `clip-sync history` pour afficher l'historique -- [ ] Option `--one-shot` : envoi unique sans daemon +- [x] Transfert de fichiers (`clip-sync send-file`, payload Base64, taille max configurable) +- [x] Historique du presse-papiers local (derniers N éléments) +- [x] Commande `clip-sync history` pour afficher l'historique +- [x] Option `--one-shot` : envoi unique sans daemon +- [ ] Images copiées/collées via le presse-papiers (JPEG/PNG) — le transfert + de fichiers est fait, mais la lecture/écriture d'images dans le + presse-papiers natif reste à implémenter par plateforme -## v1.0.0 🌐 Stable +## v1.0.0 🌐 Stable (partiellement réalisée) -- [ ] Découverte automatique des pairs via mDNS (optionnel, activable) -- [ ] Interface web locale de monitoring (`localhost:9137/` : pairs connectés, stats) -- [ ] Notification desktop (DBus/Linux, toast/Windows, Notification Center/macOS) +- [x] Découverte automatique des pairs via mDNS (`daemon.discovery`) +- [x] Interface web locale de monitoring (`http://localhost:9137/`) +- [x] Notification desktop (DBus/Linux, Notification Center/macOS ; no-op Windows) - [ ] Tests de performance et benchmark - [ ] Documentation utilisateur finale (site statique ou wiki) - [ ] Signature GPG des releases diff --git a/go.mod b/go.mod index 2312549..17492e0 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,17 @@ module git.dracodev.net/Projets/clip-sync -go 1.21 +go 1.25 -require github.com/BurntSushi/toml v1.3.2 +require ( + github.com/BurntSushi/toml v1.3.2 + github.com/hashicorp/mdns v1.0.7 +) + +require ( + github.com/miekg/dns v1.1.72 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/tools v0.40.0 // indirect +) diff --git a/go.sum b/go.sum index ef0f966..79bc71c 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,18 @@ github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/mdns v1.0.7 h1:yWoQVMW5JOiDxQnIUcm3IDt0kCjf3TuXHDbdEKPsbAY= +github.com/hashicorp/mdns v1.0.7/go.mod h1:yjuhYhZyPDqXXL48xC7cdpGwGUMwu7OViDmsuT5COvg= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= diff --git a/install.sh b/install.sh index 696f502..90d5125 100644 --- a/install.sh +++ b/install.sh @@ -8,7 +8,7 @@ set -euo pipefail REPO="https://git.dracodev.net/Projets/clip-sync" -VERSION="${CLIP_SYNC_VERSION:-latest}" +VERSION="${CLIP_SYNC_VERSION:-v0.2.0}" BINARY="clip-sync" INSTALL_DIR="${HOME}/.local/bin" CONFIG_DIR="${HOME}/.config/clip-sync" @@ -46,14 +46,7 @@ detect_platform() { download_binary() { local platform="$1" local bin_name="${BINARY}-${platform}" - local download_url - - if [ "$VERSION" = "latest" ]; then - # Try latest release - download_url="${REPO}/releases/download/latest/${bin_name}" - else - download_url="${REPO}/releases/download/${VERSION}/${bin_name}" - fi + local download_url="${REPO}/releases/download/${VERSION}/${bin_name}" # Windows binary has .exe extension if [[ "$platform" == windows-* ]]; then @@ -61,16 +54,18 @@ download_binary() { download_url="${download_url}.exe" fi + local out_path="${INSTALL_DIR}/${bin_name}" + echo "clip-sync: downloading ${download_url}..." - + if command -v curl &>/dev/null; then - curl -fSL --progress-bar -o "${INSTALL_DIR}/${BINARY}" "${download_url}" || { + curl -fSL --progress-bar -o "${out_path}" "${download_url}" || { echo "clip-sync: download failed. Try building from source:" echo " git clone ${REPO} && cd clip-sync && make install" exit 1 } elif command -v wget &>/dev/null; then - wget -q --show-progress -O "${INSTALL_DIR}/${BINARY}" "${download_url}" || { + wget -q --show-progress -O "${out_path}" "${download_url}" || { echo "clip-sync: download failed. Try building from source:" echo " git clone ${REPO} && cd clip-sync && make install" exit 1 @@ -80,8 +75,8 @@ download_binary() { exit 1 fi - chmod +x "${INSTALL_DIR}/${BINARY}" - echo "clip-sync: installed to ${INSTALL_DIR}/${BINARY}" + chmod +x "${out_path}" + echo "clip-sync: installed to ${out_path}" } # ── Create default config ────────────────────────────────────────────── @@ -100,10 +95,17 @@ create_config() { # Environment overrides: # CLIP_SYNC_PORT=9137 override daemon.port # CLIP_SYNC_POLL_MS=500 override daemon.poll_interval_ms +# CLIP_SYNC_KEY=... override security.shared_key [daemon] port = 9137 poll_interval_ms = 500 +# discovery = true # enable mDNS auto-discovery of peers +# notify = true # enable desktop notifications +# receive_files = true # accept incoming file transfers + +[security] +# shared_key = "..." # generate with: clip-sync --generate-key # Add your peers here: # [[peers]] diff --git a/internal/clipboard/clipboard_windows.go b/internal/clipboard/clipboard_windows.go index 59375bb..5954e60 100644 --- a/internal/clipboard/clipboard_windows.go +++ b/internal/clipboard/clipboard_windows.go @@ -24,6 +24,7 @@ var ( procGlobalLock = kernel32.NewProc("GlobalLock") procGlobalUnlock = kernel32.NewProc("GlobalUnlock") procGlobalFree = kernel32.NewProc("GlobalFree") + procGlobalSize = kernel32.NewProc("GlobalSize") ) type windowsClipboard struct{} @@ -52,9 +53,16 @@ func (c *windowsClipboard) Read() (string, error) { defer func() { _, _, _ = procGlobalUnlock.Call(h) }() + // Query the actual size of the global memory block so we never read past it. + size, _, _ := procGlobalSize.Call(h) + if size == 0 { + return "", nil + } + units := size / 2 // bytes → UTF-16 code units + // Lock and read the global memory block. // Use a helper to keep the uintptr→unsafe.Pointer conversion close to the syscall. - mem := lockAndRead(h) + mem := lockAndRead(h, units) if mem == nil { return "", fmt.Errorf("clipboard read: GlobalLock failed") } @@ -103,9 +111,10 @@ func (c *windowsClipboard) Write(text string) error { return nil } -// lockAndRead locks a global memory handle and returns its UTF-16 data. -// Returns nil on failure. Caller must call GlobalUnlock after use. -func lockAndRead(h uintptr) []uint16 { +// lockAndRead locks a global memory handle and returns its UTF-16 data of the +// given length (in code units). Returns nil on failure. Caller must call +// GlobalUnlock after use. +func lockAndRead(h uintptr, units uintptr) []uint16 { p, _, _ := procGlobalLock.Call(h) if p == 0 { return nil @@ -113,7 +122,7 @@ func lockAndRead(h uintptr) []uint16 { // Use unsafe.Add to derive a pointer from nil; the uintptr came from // GlobalLock (GMEM_FIXED) and is a valid virtual address, not a Go pointer. ptr := unsafe.Add(unsafe.Pointer(nil), p) - return unsafe.Slice((*uint16)(ptr), 1<<20) + return unsafe.Slice((*uint16)(ptr), units) } // lockAndWrite locks a global memory handle and copies UTF-16 data into it. diff --git a/internal/config/config.go b/internal/config/config.go index c9eed76..bdb14d8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,10 +1,13 @@ // Package config parses the clip-sync TOML configuration file and applies // environment variable overrides. // -// Config file location: ~/.config/clip-sync/peers.toml -// Environment overrides: -// CLIP_SYNC_PORT — override daemon.port -// CLIP_SYNC_POLL_MS — override daemon.poll_interval_ms +// Config file location: ~/.config/clip-sync/peers.toml (overridable with +// the --config flag). Environment overrides: +// +// CLIP_SYNC_PORT — override daemon.port +// CLIP_SYNC_POLL_MS — override daemon.poll_interval_ms +// CLIP_SYNC_KEY — override security.shared_key +// CLIP_SYNC_MAX_BODY_BYTES — override daemon.max_body_bytes package config import ( @@ -19,59 +22,118 @@ import ( // Config represents the full clip-sync configuration. type Config struct { - Daemon DaemonConfig `toml:"daemon"` - Peers []PeerConfig `toml:"peers"` + Daemon DaemonConfig `toml:"daemon"` + Security SecurityConfig `toml:"security"` + Peers []PeerConfig `toml:"peers"` } // DaemonConfig holds daemon-level settings. type DaemonConfig struct { - Port int `toml:"port"` - PollIntervalMs int `toml:"poll_interval_ms"` + Port int `toml:"port"` + PollIntervalMs int `toml:"poll_interval_ms"` + MaxBodyBytes int64 `toml:"max_body_bytes"` + HistorySize int `toml:"history_size"` + Discovery bool `toml:"discovery"` + Notify bool `toml:"notify"` + ReceiveFiles bool `toml:"receive_files"` + ReceiveDir string `toml:"receive_dir"` +} + +// SecurityConfig holds peer authentication and transport security settings. +type SecurityConfig struct { + SharedKey string `toml:"shared_key"` + AllowedOrigins []string `toml:"allowed_origins"` + TLS bool `toml:"tls"` + CertFile string `toml:"cert_file"` + KeyFile string `toml:"key_file"` + InsecureSkipVerify bool `toml:"insecure_skip_verify"` } // PeerConfig represents one remote clip-sync peer. type PeerConfig struct { Name string `toml:"name"` Addr string `toml:"addr"` + TLS bool `toml:"tls"` // per-peer override of security.tls } // Default values. const ( DefaultPort = 9137 DefaultPollIntervalMs = 500 + DefaultMaxBodyBytes = 10 << 20 // 10 MiB + DefaultHistorySize = 50 ConfigDir = ".config/clip-sync" ConfigFile = "peers.toml" ) -// Load reads the config file from ~/.config/clip-sync/peers.toml and applies -// environment variable overrides. -func Load() (*Config, error) { +// DefaultConfigPath returns the default config file path (~/.config/clip-sync/peers.toml). +func DefaultConfigPath() (string, error) { home, err := os.UserHomeDir() if err != nil { - return nil, fmt.Errorf("config: cannot find home directory: %w", err) + return "", fmt.Errorf("config: cannot find home directory: %w", err) } + return filepath.Join(home, ConfigDir, ConfigFile), nil +} - cfgPath := filepath.Join(home, ConfigDir, ConfigFile) +// DefaultCertFile returns the default TLS certificate path. +func DefaultCertFile() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("config: cannot find home directory: %w", err) + } + return filepath.Join(home, ConfigDir, "cert.pem"), nil +} +// DefaultKeyFile returns the default TLS private key path. +func DefaultKeyFile() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("config: cannot find home directory: %w", err) + } + return filepath.Join(home, ConfigDir, "key.pem"), nil +} + +// Load reads the config file from the default location and applies +// environment variable overrides. +func Load() (*Config, error) { + path, err := DefaultConfigPath() + if err != nil { + return nil, err + } + return LoadFrom(path) +} + +// LoadFrom reads the config file at the given path and applies environment +// variable overrides. If the file does not exist, defaults are used. +func LoadFrom(path string) (*Config, error) { cfg := &Config{ Daemon: DaemonConfig{ Port: DefaultPort, PollIntervalMs: DefaultPollIntervalMs, + MaxBodyBytes: DefaultMaxBodyBytes, + HistorySize: DefaultHistorySize, }, } - if _, err := toml.DecodeFile(cfgPath, cfg); err != nil { + if _, err := toml.DecodeFile(path, cfg); err != nil { if !os.IsNotExist(err) { - return nil, fmt.Errorf("config: cannot parse %s: %w", cfgPath, err) + return nil, fmt.Errorf("config: cannot parse %s: %w", path, err) } // File doesn't exist — use defaults (no peers configured). } - // Environment variable overrides. + if err := applyEnvOverrides(cfg); err != nil { + return nil, err + } + + return cfg, nil +} + +func applyEnvOverrides(cfg *Config) error { if v := os.Getenv("CLIP_SYNC_PORT"); v != "" { port, err := strconv.Atoi(v) if err != nil { - return nil, fmt.Errorf("config: invalid CLIP_SYNC_PORT: %w", err) + return fmt.Errorf("config: invalid CLIP_SYNC_PORT: %w", err) } cfg.Daemon.Port = port } @@ -79,12 +141,24 @@ func Load() (*Config, error) { if v := os.Getenv("CLIP_SYNC_POLL_MS"); v != "" { ms, err := strconv.Atoi(v) if err != nil { - return nil, fmt.Errorf("config: invalid CLIP_SYNC_POLL_MS: %w", err) + return fmt.Errorf("config: invalid CLIP_SYNC_POLL_MS: %w", err) } cfg.Daemon.PollIntervalMs = ms } - return cfg, nil + if v := os.Getenv("CLIP_SYNC_KEY"); v != "" { + cfg.Security.SharedKey = v + } + + if v := os.Getenv("CLIP_SYNC_MAX_BODY_BYTES"); v != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return fmt.Errorf("config: invalid CLIP_SYNC_MAX_BODY_BYTES: %w", err) + } + cfg.Daemon.MaxBodyBytes = n + } + + return nil } // PollInterval returns the poll interval as a time.Duration. @@ -96,3 +170,34 @@ func (c *Config) PollInterval() time.Duration { func (c *Config) ListenAddr() string { return fmt.Sprintf(":%d", c.Daemon.Port) } + +// PeerUsesTLS reports whether the given peer should be contacted over TLS. +// A per-peer override wins over the global security.tls setting. +func (c *Config) PeerUsesTLS(p PeerConfig) bool { + if p.TLS { + return true + } + return c.Security.TLS +} + +// MaxBodyBytes returns the effective request body size limit, clamped to a +// sane minimum so a zero/negative config value never disables the limit. +func (c *Config) MaxBodyBytes() int64 { + if c.Daemon.MaxBodyBytes > 0 { + return c.Daemon.MaxBodyBytes + } + return DefaultMaxBodyBytes +} + +// ReceiveDir returns the directory where received files are written, resolving +// the default (~/Downloads/clip-sync) when not configured. +func (c *Config) ReceiveDir() string { + if c.Daemon.ReceiveDir != "" { + return c.Daemon.ReceiveDir + } + home, err := os.UserHomeDir() + if err != nil { + return "clip-sync-received" + } + return filepath.Join(home, "Downloads", "clip-sync") +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 0dd5af9..e4d8698 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -1,5 +1,5 @@ // Package daemon orchestrates the main clip-sync loop: poll the local clipboard, -// broadcast changes to peers, and receive incoming clips via the HTTP server. +// broadcast changes to peers, and receive incoming clips via the HTTP(S) server. package daemon import ( @@ -12,17 +12,23 @@ import ( "git.dracodev.net/Projets/clip-sync/internal/clipboard" "git.dracodev.net/Projets/clip-sync/internal/config" "git.dracodev.net/Projets/clip-sync/internal/dedup" + "git.dracodev.net/Projets/clip-sync/internal/discovery" + "git.dracodev.net/Projets/clip-sync/internal/history" + "git.dracodev.net/Projets/clip-sync/internal/notify" "git.dracodev.net/Projets/clip-sync/internal/peer" "git.dracodev.net/Projets/clip-sync/internal/server" ) // Daemon is the main clip-sync process. type Daemon struct { - cfg *config.Config - clipboard clipboard.Clipboard - filter *dedup.Filter - server *server.Server + cfg *config.Config + clipboard clipboard.Clipboard + filter *dedup.Filter + server *server.Server broadcaster *peer.Broadcaster + history *history.Store + origin string + discovery discovery.Discovery } // New creates a Daemon from the given configuration. @@ -32,7 +38,18 @@ func New(cfg *config.Config) (*Daemon, error) { return nil, err } - return newWithClipboard(cfg, cb) + origin, err := os.Hostname() + if err != nil { + return nil, err + } + + // History is best-effort: if we cannot resolve a path, run without it. + hist := (*history.Store)(nil) + if p, err := history.DefaultPath(); err == nil { + hist = history.New(p, cfg.Daemon.HistorySize) + } + + return newDaemon(cfg, cb, origin, hist) } // newWithClipboard creates a Daemon with a pre-existing clipboard (used by tests). @@ -42,6 +59,10 @@ func newWithClipboard(cfg *config.Config, cb clipboard.Clipboard) (*Daemon, erro // newWithClipboardOrigin creates a Daemon with a pre-existing clipboard and explicit origin. func newWithClipboardOrigin(cfg *config.Config, cb clipboard.Clipboard, origin string) (*Daemon, error) { + return newDaemon(cfg, cb, origin, nil) +} + +func newDaemon(cfg *config.Config, cb clipboard.Clipboard, origin string, hist *history.Store) (*Daemon, error) { if origin == "" { var err error origin, err = os.Hostname() @@ -51,34 +72,82 @@ func newWithClipboardOrigin(cfg *config.Config, cb clipboard.Clipboard, origin s } filter := dedup.NewFilter(origin) - broadcaster := peer.NewBroadcaster(cfg.Peers, origin) - srv := server.New(cfg.ListenAddr(), cb, filter) + broadcaster := peer.NewBroadcaster(cfg.Peers, origin, peer.Options{ + SharedKey: cfg.Security.SharedKey, + TLS: cfg.Security.TLS, + CertFile: cfg.Security.CertFile, + InsecureSkipVerify: cfg.Security.InsecureSkipVerify, + }) - return &Daemon{ + var notifier notify.Notifier + if cfg.Daemon.Notify { + notifier = notify.New() + } + + srv := server.New(server.Options{ + Addr: cfg.ListenAddr(), + SharedKey: cfg.Security.SharedKey, + AllowedOrigins: cfg.Security.AllowedOrigins, + MaxBodyBytes: cfg.MaxBodyBytes(), + History: hist, + Notifier: notifier, + ReceiveFiles: cfg.Daemon.ReceiveFiles, + ReceiveDir: cfg.ReceiveDir(), + }, cb, filter) + + d := &Daemon{ cfg: cfg, clipboard: cb, filter: filter, server: srv, broadcaster: broadcaster, - }, nil + history: hist, + origin: origin, + } + + if cfg.Daemon.Discovery { + d.discovery = discovery.New(origin, cfg.Daemon.Port, broadcaster) + } + + return d, nil } // Run starts the daemon loop. It blocks until ctx is cancelled. func (d *Daemon) Run(ctx context.Context) error { - // Start the HTTP server in a background goroutine. + // Start the HTTP(S) server in a background goroutine. var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() - log.Printf("clip-sync: listening on %s", d.cfg.ListenAddr()) - if err := d.server.ListenAndServe(); err != nil { - // ListenAndServe returns http.ErrServerClosed on graceful shutdown. - if ctx.Err() == nil { - log.Printf("clip-sync: server error: %v", err) + var err error + if d.cfg.Security.TLS { + certFile, keyFile := d.cfg.Security.CertFile, d.cfg.Security.KeyFile + if certFile == "" { + certFile, _ = config.DefaultCertFile() } + if keyFile == "" { + keyFile, _ = config.DefaultKeyFile() + } + log.Printf("clip-sync: listening on %s (TLS)", d.cfg.ListenAddr()) + err = d.server.ListenAndServeTLS(certFile, keyFile) + } else { + log.Printf("clip-sync: listening on %s", d.cfg.ListenAddr()) + err = d.server.ListenAndServe() + } + if err != nil && ctx.Err() == nil { + log.Printf("clip-sync: server error: %v", err) } }() + // Start mDNS discovery if enabled. + if d.discovery != nil { + if err := d.discovery.Start(); err != nil { + log.Printf("clip-sync: discovery: %v", err) + } else { + defer d.discovery.Stop() + } + } + // Main clipboard polling loop. ticker := time.NewTicker(d.cfg.PollInterval()) defer ticker.Stop() @@ -111,8 +180,12 @@ func (d *Daemon) Run(ctx context.Context) error { // Record our write so the filter can block echoes. d.filter.MarkWritten(text) + if d.history != nil { + d.history.Append(history.Entry{Text: text, Origin: d.origin, Ts: time.Now().UnixNano()}) + } + // Broadcast to all peers. - if len(d.cfg.Peers) > 0 { + if len(d.cfg.Peers) > 0 || d.discovery != nil { d.broadcaster.Broadcast(text) } } diff --git a/internal/dedup/dedup.go b/internal/dedup/dedup.go index 8360749..b2d8a12 100644 --- a/internal/dedup/dedup.go +++ b/internal/dedup/dedup.go @@ -21,10 +21,10 @@ const defaultCacheSize = 20 // Filter prevents clipboard sync loops. type Filter struct { mu sync.Mutex - origin string // our hostname - cache []string // ring buffer of content hashes - cacheIdx int // write position in ring buffer - lastWriteTs int64 // nanosecond timestamp of our last local write + origin string // our hostname + cache []string // ring buffer of content hashes + cacheIdx int // write position in ring buffer + lastWriteTs int64 // nanosecond timestamp of our last local write } // NewFilter creates a new deduplication filter for the given origin hostname. diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go new file mode 100644 index 0000000..5ed27d1 --- /dev/null +++ b/internal/discovery/discovery.go @@ -0,0 +1,185 @@ +// Package discovery provides optional mDNS-based peer auto-discovery. +// +// Each instance advertises a `_clip-sync._tcp` service and periodically browses +// for other instances, adding them to (and removing them from) the broadcaster. +package discovery + +import ( + "log" + "net" + "os" + "strings" + "sync" + "time" + + "github.com/hashicorp/mdns" + + "git.dracodev.net/Projets/clip-sync/internal/config" + "git.dracodev.net/Projets/clip-sync/internal/peer" +) + +const ( + serviceName = "_clip-sync._tcp" + domain = "local" +) + +// Discovery manages mDNS advertisement and browsing. +type Discovery interface { + Start() error + Stop() +} + +type mdnsDiscovery struct { + origin string + port int + broadcaster *peer.Broadcaster + + mu sync.Mutex + server *mdns.Server + stopCh chan struct{} + doneCh chan struct{} + peers map[string]string // addr -> host +} + +// New creates an mDNS discovery component that updates the given broadcaster. +func New(origin string, port int, b *peer.Broadcaster) Discovery { + return &mdnsDiscovery{ + origin: origin, + port: port, + broadcaster: b, + peers: make(map[string]string), + } +} + +func (d *mdnsDiscovery) Start() error { + host, _ := os.Hostname() + instance := "clip-sync-" + sanitize(host) + + // Advertise this instance. Zero-value domain/host/ips are inferred by the + // library from the operating system. + service, err := mdns.NewMDNSService(instance, serviceName, "", "", d.port, nil, nil) + if err != nil { + return err + } + server, err := mdns.NewServer(&mdns.Config{Zone: service}) + if err != nil { + return err + } + + d.mu.Lock() + d.server = server + d.stopCh = make(chan struct{}) + d.doneCh = make(chan struct{}) + d.mu.Unlock() + + go d.browseLoop() + log.Printf("discovery: mDNS advertised as %q, browsing for peers", instance) + return nil +} + +func (d *mdnsDiscovery) Stop() { + d.mu.Lock() + stopCh := d.stopCh + server := d.server + d.mu.Unlock() + + if stopCh != nil { + close(stopCh) + } + if server != nil { + _ = server.Shutdown() + } + if d.doneCh != nil { + <-d.doneCh + } +} + +func (d *mdnsDiscovery) browseLoop() { + defer close(d.doneCh) + + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + d.browse() + for { + select { + case <-d.stopCh: + return + case <-ticker.C: + d.browse() + } + } +} + +func (d *mdnsDiscovery) browse() { + entries := make(chan *mdns.ServiceEntry, 16) + params := &mdns.QueryParam{ + Service: serviceName, + Domain: domain, + Timeout: 5 * time.Second, + Entries: entries, + } + + seen := make(map[string]string) + go func() { + _ = mdns.Query(params) + close(entries) + }() + + for entry := range entries { + host := strings.TrimSuffix(entry.Host, ".local.") + host = strings.TrimSuffix(host, ".") + + // Skip ourselves (and our sanitized advertisement form). + if host == d.origin || host == sanitize(d.origin) { + continue + } + + ip := entry.AddrV4 + if ip == nil { + ip = entry.AddrV6 + } + if ip == nil { + continue + } + + addr := net.JoinHostPort(ip.String(), itoa(entry.Port)) + seen[addr] = host + d.broadcaster.AddPeer(config.PeerConfig{Name: host, Addr: addr}) + } + + // Remove peers that disappeared since the last browse. + d.mu.Lock() + for addr := range d.peers { + if _, ok := seen[addr]; !ok { + d.broadcaster.RemovePeer(addr) + } + } + d.peers = seen + d.mu.Unlock() +} + +func sanitize(s string) string { + return strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-': + return r + default: + return '-' + } + }, s) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b [20]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + return string(b[i:]) +} diff --git a/internal/history/history.go b/internal/history/history.go new file mode 100644 index 0000000..031a35b --- /dev/null +++ b/internal/history/history.go @@ -0,0 +1,103 @@ +// Package history provides a persistent, size-bounded clipboard history store. +// +// Entries are kept in memory as a ring buffer and flushed to a JSON file so the +// `clip-sync history` command can read them even when the daemon is not running. +package history + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" +) + +// Entry is a single clipboard history record. +type Entry struct { + Text string `json:"text"` + Origin string `json:"origin"` + Ts int64 `json:"ts"` // nanosecond timestamp +} + +// Store is a thread-safe, persistent history store. +type Store struct { + mu sync.Mutex + path string + max int + entries []Entry +} + +// New creates a history store that persists to the given file path, keeping at +// most max entries. If max <= 0, a sensible default is used. +func New(path string, max int) *Store { + if max <= 0 { + max = 50 + } + s := &Store{path: path, max: max} + s.load() + return s +} + +// Append adds an entry, trimming the store to max entries and persisting it. +func (s *Store) Append(e Entry) { + if e.Text == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + + // Skip exact duplicates of the most recent entry. + if len(s.entries) > 0 && s.entries[len(s.entries)-1].Text == e.Text { + return + } + + s.entries = append(s.entries, e) + if len(s.entries) > s.max { + s.entries = s.entries[len(s.entries)-s.max:] + } + s.save() +} + +// List returns a copy of the history, oldest first. +func (s *Store) List() []Entry { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]Entry, len(s.entries)) + copy(out, s.entries) + return out +} + +func (s *Store) load() { + data, err := os.ReadFile(s.path) + if err != nil { + return + } + _ = json.Unmarshal(data, &s.entries) + if len(s.entries) > s.max { + s.entries = s.entries[len(s.entries)-s.max:] + } +} + +func (s *Store) save() { + data, err := json.MarshalIndent(s.entries, "", " ") + if err != nil { + return + } + if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil { + return + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return + } + _ = os.Rename(tmp, s.path) +} + +// DefaultPath returns the default history file location. +func DefaultPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("history: cannot find home directory: %w", err) + } + return filepath.Join(home, ".config", "clip-sync", "history.json"), nil +} diff --git a/internal/history/history_test.go b/internal/history/history_test.go new file mode 100644 index 0000000..d53e5d7 --- /dev/null +++ b/internal/history/history_test.go @@ -0,0 +1,74 @@ +package history + +import ( + "path/filepath" + "testing" +) + +func TestAppendAndList(t *testing.T) { + path := filepath.Join(t.TempDir(), "history.json") + s := New(path, 10) + + s.Append(Entry{Text: "first", Origin: "a", Ts: 1}) + s.Append(Entry{Text: "second", Origin: "b", Ts: 2}) + + got := s.List() + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0].Text != "first" || got[1].Text != "second" { + t.Errorf("order wrong: %+v", got) + } +} + +func TestAppendSkipsDuplicate(t *testing.T) { + path := filepath.Join(t.TempDir(), "history.json") + s := New(path, 10) + + s.Append(Entry{Text: "dup", Origin: "a", Ts: 1}) + s.Append(Entry{Text: "dup", Origin: "a", Ts: 2}) + + if len(s.List()) != 1 { + t.Errorf("len = %d, want 1 (duplicate skipped)", len(s.List())) + } +} + +func TestTrimToMax(t *testing.T) { + path := filepath.Join(t.TempDir(), "history.json") + s := New(path, 3) + + for i := 0; i < 10; i++ { + s.Append(Entry{Text: string(rune('a' + i)), Origin: "a", Ts: int64(i)}) + } + + got := s.List() + if len(got) != 3 { + t.Fatalf("len = %d, want 3", len(got)) + } + if got[0].Text != "h" { + t.Errorf("oldest kept = %q, want \"h\"", got[0].Text) + } +} + +func TestPersistence(t *testing.T) { + path := filepath.Join(t.TempDir(), "history.json") + s := New(path, 10) + s.Append(Entry{Text: "persisted", Origin: "a", Ts: 42}) + + // A new store reading the same path should reload the entry. + s2 := New(path, 10) + got := s2.List() + if len(got) != 1 || got[0].Text != "persisted" { + t.Errorf("reloaded = %+v, want persisted entry", got) + } +} + +func TestDefaultPath(t *testing.T) { + p, err := DefaultPath() + if err != nil { + t.Fatalf("DefaultPath: %v", err) + } + if p == "" { + t.Error("DefaultPath returned empty string") + } +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..840ddd6 --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,11 @@ +// Package notify provides best-effort desktop notifications. +// +// Each platform file defines its own implementation using //go:build tags: +// Linux uses notify-send, macOS uses osascript, Windows is a no-op (Windows +// toast notifications require a registered app or a third-party module). +package notify + +// Notifier sends a desktop notification. +type Notifier interface { + Notify(title, body string) error +} diff --git a/internal/notify/notify_darwin.go b/internal/notify/notify_darwin.go new file mode 100644 index 0000000..63e4891 --- /dev/null +++ b/internal/notify/notify_darwin.go @@ -0,0 +1,29 @@ +//go:build darwin + +package notify + +import "os/exec" + +// Notifier uses osascript (Notification Center) on macOS. +type notifier struct{} + +// New returns a macOS notifier using osascript. +func New() Notifier { return notifier{} } + +func (notifier) Notify(title, body string) error { + script := "display notification " + quote(body) + " with title " + quote(title) + return exec.Command("osascript", "-e", script).Run() +} + +func quote(s string) string { + out := `"` + for _, r := range s { + switch r { + case '\\', '"': + out += "\\" + string(r) + default: + out += string(r) + } + } + return out + `"` +} diff --git a/internal/notify/notify_linux.go b/internal/notify/notify_linux.go new file mode 100644 index 0000000..1d76efe --- /dev/null +++ b/internal/notify/notify_linux.go @@ -0,0 +1,15 @@ +//go:build linux + +package notify + +import "os/exec" + +// Notifier uses notify-send (libnotify) on Linux. +type notifier struct{} + +// New returns a Linux notifier using notify-send. +func New() Notifier { return notifier{} } + +func (notifier) Notify(title, body string) error { + return exec.Command("notify-send", title, body).Run() +} diff --git a/internal/notify/notify_other.go b/internal/notify/notify_other.go new file mode 100644 index 0000000..36955e2 --- /dev/null +++ b/internal/notify/notify_other.go @@ -0,0 +1,11 @@ +//go:build !linux && !darwin && !windows + +package notify + +// Notifier is a no-op on unsupported platforms. +type notifier struct{} + +// New returns a no-op notifier. +func New() Notifier { return notifier{} } + +func (notifier) Notify(string, string) error { return nil } diff --git a/internal/notify/notify_windows.go b/internal/notify/notify_windows.go new file mode 100644 index 0000000..464584e --- /dev/null +++ b/internal/notify/notify_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package notify + +// Notifier is a no-op on Windows. Native toast notifications require a +// registered, packaged app (or a third-party module such as BurntToast), which +// is out of scope for a single self-contained binary. +type notifier struct{} + +// New returns a no-op Windows notifier. +func New() Notifier { return notifier{} } + +func (notifier) Notify(string, string) error { return nil } diff --git a/internal/peer/peer.go b/internal/peer/peer.go index 8252651..5e6ed62 100644 --- a/internal/peer/peer.go +++ b/internal/peer/peer.go @@ -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 { diff --git a/internal/peer/peer_test.go b/internal/peer/peer_test.go index ddb36cd..0657d59 100644 --- a/internal/peer/peer_test.go +++ b/internal/peer/peer_test.go @@ -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("") } diff --git a/internal/security/security.go b/internal/security/security.go new file mode 100644 index 0000000..d5c5d85 --- /dev/null +++ b/internal/security/security.go @@ -0,0 +1,94 @@ +// Package security provides shared-key and TLS certificate generation for +// clip-sync peers. It uses only the Go standard library. +package security + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" + "fmt" + "math/big" + "net" + "os" + "time" +) + +// GenerateKey returns a random 256-bit shared key, hex-encoded. It is suitable +// for use as the security.shared_key bearer token shared by all peers. +func GenerateKey() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generate key: %w", err) + } + return hex.EncodeToString(buf), nil +} + +// GenerateSelfSignedCert creates a self-signed ECDSA certificate and writes it +// (PEM-encoded) to certFile and keyFile. It is valid for the given hosts/IPs +// and 10 years, which is acceptable for a LAN trust model where all peers +// share the same certificate and key files. +func GenerateSelfSignedCert(certFile, keyFile string, hosts []string) error { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("generate cert: %w", err) + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return fmt.Errorf("generate cert serial: %w", err) + } + + now := time.Now() + tmpl := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "clip-sync"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(10 * 365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + } + + for _, h := range hosts { + if ip := net.ParseIP(h); ip != nil { + tmpl.IPAddresses = append(tmpl.IPAddresses, ip) + } else { + tmpl.DNSNames = append(tmpl.DNSNames, h) + } + } + if len(tmpl.IPAddresses) == 0 && len(tmpl.DNSNames) == 0 { + tmpl.DNSNames = []string{"localhost"} + } + + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) + if err != nil { + return fmt.Errorf("generate cert: create: %w", err) + } + + certOut, err := os.Create(certFile) + if err != nil { + return fmt.Errorf("generate cert: %w", err) + } + defer certOut.Close() + if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil { + return fmt.Errorf("generate cert: encode: %w", err) + } + + keyDER, err := x509.MarshalECPrivateKey(priv) + if err != nil { + return fmt.Errorf("generate cert: marshal key: %w", err) + } + keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("generate cert: %w", err) + } + defer keyOut.Close() + if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil { + return fmt.Errorf("generate cert: encode key: %w", err) + } + + return nil +} diff --git a/internal/security/security_test.go b/internal/security/security_test.go new file mode 100644 index 0000000..8948826 --- /dev/null +++ b/internal/security/security_test.go @@ -0,0 +1,44 @@ +package security + +import ( + "os" + "path/filepath" + "testing" +) + +func TestGenerateKey(t *testing.T) { + k1, err := GenerateKey() + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + k2, err := GenerateKey() + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + if len(k1) != 64 { + t.Errorf("key length = %d, want 64 hex chars", len(k1)) + } + if k1 == k2 { + t.Error("two generated keys are identical") + } +} + +func TestGenerateSelfSignedCert(t *testing.T) { + dir := t.TempDir() + certFile := filepath.Join(dir, "cert.pem") + keyFile := filepath.Join(dir, "key.pem") + + if err := GenerateSelfSignedCert(certFile, keyFile, []string{"localhost", "127.0.0.1"}); err != nil { + t.Fatalf("GenerateSelfSignedCert: %v", err) + } + + for _, f := range []string{certFile, keyFile} { + info, err := os.Stat(f) + if err != nil { + t.Fatalf("Stat %s: %v", f, err) + } + if info.Size() == 0 { + t.Errorf("%s is empty", f) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 16b77cd..491cfb1 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,35 +1,77 @@ // Package server provides the HTTP endpoint that receives clipboard payloads -// from remote clip-sync peers. +// from remote clip-sync peers, plus a small local monitoring UI. package server import ( + "crypto/subtle" "encoding/json" "log" "net/http" + "sync" + "time" "git.dracodev.net/Projets/clip-sync/internal/clipboard" "git.dracodev.net/Projets/clip-sync/internal/dedup" + "git.dracodev.net/Projets/clip-sync/internal/history" + "git.dracodev.net/Projets/clip-sync/internal/notify" + "git.dracodev.net/Projets/clip-sync/internal/transfer" ) +// Options configures a Server. +type Options struct { + Addr string + SharedKey string + AllowedOrigins []string + MaxBodyBytes int64 + History *history.Store + Notifier notify.Notifier + ReceiveFiles bool + ReceiveDir string +} + +// Stats holds lightweight counters exposed via the monitoring UI. +type Stats struct { + StartedAt time.Time `json:"started_at"` + ReceivedCount int64 `json:"received_count"` + LastReceivedAt time.Time `json:"last_received_at"` + LastReceivedBy string `json:"last_received_by"` + LastReceivedLen int `json:"last_received_len"` +} + // Server receives clips from peers and writes them to the local clipboard. type Server struct { httpServer *http.Server clipboard clipboard.Clipboard filter *dedup.Filter + opts Options + allowed map[string]struct{} + + mu sync.Mutex + stats Stats } -// New creates a Server that listens on addr. -func New(addr string, cb clipboard.Clipboard, filter *dedup.Filter) *Server { +// New creates a Server that listens on opts.Addr. +func New(opts Options, cb clipboard.Clipboard, filter *dedup.Filter) *Server { s := &Server{ clipboard: cb, filter: filter, + opts: opts, + allowed: make(map[string]struct{}), + stats: Stats{StartedAt: time.Now()}, + } + for _, o := range opts.AllowedOrigins { + s.allowed[o] = struct{}{} } mux := http.NewServeMux() mux.HandleFunc("/clip", s.handleClip) + mux.HandleFunc("/file", s.handleFile) + mux.HandleFunc("/health", s.handleHealth) + mux.HandleFunc("/history", s.handleHistory) + mux.HandleFunc("/", s.handleIndex) s.httpServer = &http.Server{ - Addr: addr, + Addr: opts.Addr, Handler: mux, } @@ -41,17 +83,36 @@ func (s *Server) ListenAndServe() error { return s.httpServer.ListenAndServe() } +// ListenAndServeTLS starts the HTTPS server with the given cert/key files. +func (s *Server) ListenAndServeTLS(certFile, keyFile string) error { + return s.httpServer.ListenAndServeTLS(certFile, keyFile) +} + // Close gracefully shuts down the HTTP server. func (s *Server) Close() error { return s.httpServer.Close() } +// Stats returns a snapshot of the server statistics. +func (s *Server) Stats() Stats { + s.mu.Lock() + defer s.mu.Unlock() + return s.stats +} + func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } + if !s.authorized(w, r) { + return + } + + // Bound the request body to prevent memory-exhaustion / DoS. + r.Body = http.MaxBytesReader(w, r.Body, s.opts.MaxBodyBytes) + var p dedup.Payload if err := json.NewDecoder(r.Body).Decode(&p); err != nil { http.Error(w, "bad request: invalid JSON", http.StatusBadRequest) @@ -63,6 +124,14 @@ func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) { return } + // Origin validation: reject payloads from unknown origins if configured. + if len(s.allowed) > 0 { + if _, ok := s.allowed[p.Origin]; !ok { + http.Error(w, "forbidden: unknown origin", http.StatusForbidden) + return + } + } + if s.filter.ShouldIgnore(p) { w.WriteHeader(http.StatusNoContent) return @@ -75,5 +144,191 @@ func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) { } s.filter.MarkWritten(p.Text) + s.recordReceive(p) + + if s.opts.History != nil { + s.opts.History.Append(history.Entry{Text: p.Text, Origin: p.Origin, Ts: p.Ts}) + } + + if s.opts.Notifier != nil { + if err := s.opts.Notifier.Notify("clip-sync", "Clip reçu de "+p.Origin); err != nil { + log.Printf("server: notify: %v", err) + } + } + w.WriteHeader(http.StatusNoContent) } + +func (s *Server) recordReceive(p dedup.Payload) { + s.mu.Lock() + defer s.mu.Unlock() + s.stats.ReceivedCount++ + s.stats.LastReceivedAt = time.Now() + s.stats.LastReceivedBy = p.Origin + s.stats.LastReceivedLen = len(p.Text) +} + +// authorized checks the shared-key bearer token (constant-time comparison). +// It writes the error response and returns false when authentication fails. +func (s *Server) authorized(w http.ResponseWriter, r *http.Request) bool { + if s.opts.SharedKey == "" { + return true + } + got := r.Header.Get("Authorization") + const prefix = "Bearer " + if len(got) < len(prefix) || !equalFoldSubtle(got[:len(prefix)], prefix) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return false + } + token := got[len(prefix):] + if subtle.ConstantTimeCompare([]byte(token), []byte(s.opts.SharedKey)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return false + } + return true +} + +// handleFile receives a binary payload and, if enabled, writes it to disk. +func (s *Server) handleFile(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if !s.authorized(w, r) { + return + } + if !s.opts.ReceiveFiles { + http.Error(w, "file reception disabled", http.StatusForbidden) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, s.opts.MaxBodyBytes) + + var f transfer.File + if err := json.NewDecoder(r.Body).Decode(&f); err != nil { + http.Error(w, "bad request: invalid JSON", http.StatusBadRequest) + return + } + if f.Data == "" || f.Name == "" { + http.Error(w, "bad request: missing name or data", http.StatusBadRequest) + return + } + if len(s.allowed) > 0 { + if _, ok := s.allowed[f.Origin]; !ok { + http.Error(w, "forbidden: unknown origin", http.StatusForbidden) + return + } + } + + dest, err := f.SaveTo(s.opts.ReceiveDir) + if err != nil { + log.Printf("server: file save: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + log.Printf("server: received file %q from %q -> %s", f.Name, f.Origin, dest) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "stats": s.Stats(), + }) +} + +func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if s.opts.History == nil { + _ = json.NewEncoder(w).Encode([]history.Entry{}) + return + } + _ = json.NewEncoder(w).Encode(s.opts.History.List()) +} + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(indexHTML)) +} + +// equalFoldSubtle is a constant-time ASCII case-insensitive comparison. It is +// only used to match the "Bearer " scheme prefix, so a length difference is +// acceptable to leak. +func equalFoldSubtle(a, b string) bool { + if len(a) != len(b) { + return false + } + for i := 0; i < len(a); i++ { + ca, cb := a[i], b[i] + if ca >= 'A' && ca <= 'Z' { + ca += 'a' - 'A' + } + if cb >= 'A' && cb <= 'Z' { + cb += 'a' - 'A' + } + if ca != cb { + return false + } + } + return true +} + +const indexHTML = ` + + + + +clip-sync + + + +

clip-sync — monitoring

+
+
État…
+
Clips reçus…
+
Dernier clip…
+
+
+

Historique récent

+
  • Chargement…
+
+ + +` diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 35c5877..9b954e6 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" "git.dracodev.net/Projets/clip-sync/internal/dedup" @@ -16,29 +17,37 @@ type mockClipboard struct { text string } -func (m *mockClipboard) Read() (string, error) { return m.text, nil } -func (m *mockClipboard) Write(s string) error { m.text = s; return nil } +func (m *mockClipboard) Read() (string, error) { return m.text, nil } +func (m *mockClipboard) Write(s string) error { m.text = s; return nil } -func TestHandleClipAcceptsValidPayload(t *testing.T) { - cb := &mockClipboard{} +func newTestServer(cb *mockClipboard, opts Options) *Server { filter := dedup.NewFilter("my-machine") - srv := New(":0", cb, filter) + return New(opts, cb, filter) +} - payload := dedup.Payload{ - Text: "test text", - Ts: 1690000000000000000, - Origin: "other-machine", - } +func doClip(t *testing.T, s *Server, payload dedup.Payload, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() body, err := json.Marshal(payload) if err != nil { t.Fatalf("json.Marshal: %v", err) } - req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } w := httptest.NewRecorder() + s.handleClip(w, req) + return w +} - srv.handleClip(w, req) +func TestHandleClipAcceptsValidPayload(t *testing.T) { + cb := &mockClipboard{} + srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20}) + + w := doClip(t, srv, dedup.Payload{ + Text: "test text", Ts: 1690000000000000000, Origin: "other-machine", + }, nil) if w.Code != http.StatusNoContent { t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent) @@ -50,32 +59,18 @@ func TestHandleClipAcceptsValidPayload(t *testing.T) { func TestHandleClipIgnoresEcho(t *testing.T) { cb := &mockClipboard{text: "existing"} - filter := dedup.NewFilter("my-machine") + srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20}) // Simulate we just wrote "echo text" locally. - filter.MarkWritten("echo text") + srv.filter.MarkWritten("echo text") - srv := New(":0", cb, filter) - - payload := dedup.Payload{ - Text: "echo text", // same text we just wrote - Ts: 1690000000000000000, - Origin: "other-machine", - } - body, err := json.Marshal(payload) - if err != nil { - t.Fatalf("json.Marshal: %v", err) - } - - req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body)) - w := httptest.NewRecorder() - - srv.handleClip(w, req) + w := doClip(t, srv, dedup.Payload{ + Text: "echo text", Ts: 1690000000000000000, Origin: "other-machine", + }, nil) if w.Code != http.StatusNoContent { t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent) } - // Clipboard should NOT be overwritten. if cb.text != "existing" { t.Errorf("clipboard = %q, want \"existing\" (should not be overwritten)", cb.text) } @@ -83,8 +78,7 @@ func TestHandleClipIgnoresEcho(t *testing.T) { func TestHandleClipRejectsInvalidMethod(t *testing.T) { cb := &mockClipboard{} - filter := dedup.NewFilter("my-machine") - srv := New(":0", cb, filter) + srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20}) req := httptest.NewRequest(http.MethodGet, "/clip", nil) w := httptest.NewRecorder() @@ -98,23 +92,11 @@ func TestHandleClipRejectsInvalidMethod(t *testing.T) { func TestHandleClipRejectsEmptyText(t *testing.T) { cb := &mockClipboard{} - filter := dedup.NewFilter("my-machine") - srv := New(":0", cb, filter) + srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20}) - payload := dedup.Payload{ - Text: "", - Ts: 1690000000000000000, - Origin: "other-machine", - } - body, err := json.Marshal(payload) - if err != nil { - t.Fatalf("json.Marshal: %v", err) - } - - req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body)) - w := httptest.NewRecorder() - - srv.handleClip(w, req) + w := doClip(t, srv, dedup.Payload{ + Text: "", Ts: 1690000000000000000, Origin: "other-machine", + }, nil) if w.Code != http.StatusBadRequest { t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest) @@ -123,8 +105,7 @@ func TestHandleClipRejectsEmptyText(t *testing.T) { func TestHandleClipRejectsInvalidJSON(t *testing.T) { cb := &mockClipboard{} - filter := dedup.NewFilter("my-machine") - srv := New(":0", cb, filter) + srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20}) req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader([]byte("not json"))) w := httptest.NewRecorder() @@ -136,7 +117,95 @@ func TestHandleClipRejectsInvalidJSON(t *testing.T) { } } +func TestHandleClipRequiresSharedKey(t *testing.T) { + cb := &mockClipboard{} + srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20, SharedKey: "secret"}) + + // No auth header → unauthorized. + w := doClip(t, srv, dedup.Payload{Text: "x", Ts: 1, Origin: "other-machine"}, nil) + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d (missing key)", w.Code, http.StatusUnauthorized) + } + + // Wrong key → unauthorized. + w = doClip(t, srv, dedup.Payload{Text: "x", Ts: 1, Origin: "other-machine"}, + map[string]string{"Authorization": "Bearer wrong"}) + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d (wrong key)", w.Code, http.StatusUnauthorized) + } + + // Correct key → accepted. + w = doClip(t, srv, dedup.Payload{Text: "x", Ts: 2, Origin: "other-machine"}, + map[string]string{"Authorization": "Bearer secret"}) + if w.Code != http.StatusNoContent { + t.Errorf("status = %d, want %d (correct key)", w.Code, http.StatusNoContent) + } +} + +func TestHandleClipRejectsUnknownOrigin(t *testing.T) { + cb := &mockClipboard{} + srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20, AllowedOrigins: []string{"trusted-host"}}) + + w := doClip(t, srv, dedup.Payload{Text: "x", Ts: 1, Origin: "evil-host"}, nil) + if w.Code != http.StatusForbidden { + t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden) + } +} + +func TestHandleClipEnforcesBodyLimit(t *testing.T) { + cb := &mockClipboard{} + srv := newTestServer(cb, Options{MaxBodyBytes: 16}) + + req := httptest.NewRequest(http.MethodPost, "/clip", strings.NewReader(`{"text":"`+strings.Repeat("a", 1024)+`"}`)) + w := httptest.NewRecorder() + + srv.handleClip(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d (oversized body)", w.Code, http.StatusBadRequest) + } +} + +func TestHandleFileReception(t *testing.T) { + cb := &mockClipboard{} + dir := t.TempDir() + srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20, ReceiveFiles: true, ReceiveDir: dir}) + + body := `{"name":"hello.txt","mime":"text/plain","data":"aGVsbG8=","ts":1,"origin":"other-machine"}` + req := httptest.NewRequest(http.MethodPost, "/file", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handleFile(w, req) + + if w.Code != http.StatusNoContent { + t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent) + } + + entries, err := os.ReadDir(dir) + if err != nil || len(entries) == 0 { + t.Fatalf("expected a file in %s, got %d entries (err=%v)", dir, len(entries), err) + } + data, _ := os.ReadFile(dir + "/" + entries[0].Name()) + if string(data) != "hello" { + t.Errorf("file content = %q, want \"hello\"", data) + } +} + +func TestHandleFileDisabled(t *testing.T) { + cb := &mockClipboard{} + srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20}) // ReceiveFiles defaults false + + body := `{"name":"x","data":"eA==","ts":1,"origin":"other-machine"}` + req := httptest.NewRequest(http.MethodPost, "/file", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handleFile(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden) + } +} + func TestMain(m *testing.M) { - // Ensure clipboard.New() won't be called in test (mock is used directly). os.Exit(m.Run()) } diff --git a/internal/transfer/transfer.go b/internal/transfer/transfer.go new file mode 100644 index 0000000..c22a7fb --- /dev/null +++ b/internal/transfer/transfer.go @@ -0,0 +1,83 @@ +// Package transfer handles binary content (images, files) transfer between +// clip-sync peers. It is kept separate from the text clipboard path so that +// text sync remains simple and safe; binary content is sent explicitly and is +// written to disk on the receiving side only when enabled. +package transfer + +import ( + "encoding/base64" + "fmt" + "mime" + "os" + "path/filepath" + "time" +) + +// File is the wire payload for a transferred binary object. +type File struct { + Name string `json:"name"` + Mime string `json:"mime"` + Data string `json:"data"` // base64-encoded content + Ts int64 `json:"ts"` + Origin string `json:"origin"` +} + +// ReadFile reads a file from disk and encodes it as a transfer.File payload. +func ReadFile(path, origin string) (*File, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + mimeType := mime.TypeByExtension(filepath.Ext(path)) + if mimeType == "" { + mimeType = "application/octet-stream" + } + + return &File{ + Name: filepath.Base(path), + Mime: mimeType, + Data: base64.StdEncoding.EncodeToString(data), + Ts: time.Now().UnixNano(), + Origin: origin, + }, nil +} + +// Decode returns the raw bytes of the file. +func (f *File) Decode() ([]byte, error) { + if f.Data == "" { + return nil, fmt.Errorf("transfer: empty data") + } + return base64.StdEncoding.DecodeString(f.Data) +} + +// SaveTo writes the decoded content to the given directory, sanitizing the +// file name to avoid path traversal. It returns the written path. +func (f *File) SaveTo(dir string) (string, error) { + data, err := f.Decode() + if err != nil { + return "", err + } + + if err := os.MkdirAll(dir, 0755); err != nil { + return "", err + } + + name := filepath.Base(f.Name) + if name == "." || name == "" || name == string(filepath.Separator) { + name = "received.bin" + } + + // Avoid overwriting existing files: append a numeric suffix if needed. + dest := filepath.Join(dir, name) + for i := 1; ; i++ { + if _, err := os.Stat(dest); os.IsNotExist(err) { + break + } + ext := filepath.Ext(name) + base := name[:len(name)-len(ext)] + dest = filepath.Join(dir, fmt.Sprintf("%s (%d)%s", base, i, ext)) + } + + return dest, os.WriteFile(dest, data, 0644) +} diff --git a/internal/transfer/transfer_test.go b/internal/transfer/transfer_test.go new file mode 100644 index 0000000..2efdf83 --- /dev/null +++ b/internal/transfer/transfer_test.go @@ -0,0 +1,83 @@ +package transfer + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadFileRoundTrip(t *testing.T) { + src := filepath.Join(t.TempDir(), "hello.txt") + content := []byte("hello, clip-sync!") + if err := os.WriteFile(src, content, 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + f, err := ReadFile(src, "machine-a") + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if f.Name != "hello.txt" { + t.Errorf("Name = %q, want \"hello.txt\"", f.Name) + } + if f.Mime != "text/plain; charset=utf-8" { + t.Errorf("Mime = %q, want \"text/plain; charset=utf-8\"", f.Mime) + } + + decoded, err := f.Decode() + if err != nil { + t.Fatalf("Decode: %v", err) + } + if string(decoded) != string(content) { + t.Errorf("decoded = %q, want %q", decoded, content) + } +} + +func TestSaveTo(t *testing.T) { + dir := t.TempDir() + f := &File{Name: "out.bin", Mime: "application/octet-stream", Data: ""} + // Data must be base64-encoded; use a known encoding of "abc". + f.Data = "YWJj" + + dest, err := f.SaveTo(dir) + if err != nil { + t.Fatalf("SaveTo: %v", err) + } + got, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(got) != "abc" { + t.Errorf("saved = %q, want \"abc\"", got) + } +} + +func TestSaveToAvoidsOverwrite(t *testing.T) { + dir := t.TempDir() + f := &File{Name: "out.bin", Data: "eA=="} // base64 "x" + + first, err := f.SaveTo(dir) + if err != nil { + t.Fatalf("first SaveTo: %v", err) + } + second, err := f.SaveTo(dir) + if err != nil { + t.Fatalf("second SaveTo: %v", err) + } + if first == second { + t.Errorf("second save overwrote first: %q == %q", first, second) + } +} + +func TestSaveToSanitizesPath(t *testing.T) { + dir := t.TempDir() + f := &File{Name: "../../etc/passwd", Data: "eA=="} + + dest, err := f.SaveTo(dir) + if err != nil { + t.Fatalf("SaveTo: %v", err) + } + if filepath.Dir(dest) != dir { + t.Errorf("dest dir = %q, want %q (path traversal not sanitized)", filepath.Dir(dest), dir) + } +} diff --git a/main.go b/main.go index 3735cb0..3ad291d 100644 --- a/main.go +++ b/main.go @@ -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 + 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 ") + } + + 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.") +} diff --git a/peers.example.toml b/peers.example.toml index 832fd26..99226a7 100644 --- a/peers.example.toml +++ b/peers.example.toml @@ -2,13 +2,32 @@ # Copy this to ~/.config/clip-sync/peers.toml and edit your machines. # # Environment overrides: -# CLIP_SYNC_PORT=9137 override daemon.port -# CLIP_SYNC_POLL_MS=500 override daemon.poll_interval_ms +# CLIP_SYNC_PORT=9137 override daemon.port +# CLIP_SYNC_POLL_MS=500 override daemon.poll_interval_ms +# CLIP_SYNC_KEY=... override security.shared_key +# CLIP_SYNC_MAX_BODY_BYTES=... override daemon.max_body_bytes [daemon] port = 9137 poll_interval_ms = 500 +# Optional features (uncomment to enable): +# max_body_bytes = 10485760 # 10 MiB request body limit (default) +# history_size = 50 # number of clipboard history entries +# discovery = true # mDNS auto-discovery of peers (no manual peers needed) +# notify = true # desktop notifications on incoming clips +# receive_files = true # accept incoming file transfers (saved to ~/Downloads/clip-sync) +# receive_dir = "/path/to/dir" # override where received files are written + +# Optional security (strongly recommended on shared networks): +# [security] +# shared_key = "..." # generate with: clip-sync --generate-key +# allowed_origins = ["bureau", "portable"] # reject clips from unknown hosts +# tls = true # encrypt traffic (see clip-sync --generate-cert) +# cert_file = "~/.config/clip-sync/cert.pem" +# key_file = "~/.config/clip-sync/key.pem" +# insecure_skip_verify = true # trust the shared self-signed cert without a CA + [[peers]] name = "bureau" addr = "192.168.1.10:9137" diff --git a/peers.toml.example b/peers.toml.example deleted file mode 100644 index 77cafc9..0000000 --- a/peers.toml.example +++ /dev/null @@ -1,18 +0,0 @@ -# clip-sync peers configuration -# ~/.config/clip-sync/peers.toml -# -# Environment overrides: -# CLIP_SYNC_PORT=9137 override daemon.port -# CLIP_SYNC_POLL_MS=500 override daemon.poll_interval_ms - -[daemon] -port = 9137 -poll_interval_ms = 500 - -[[peers]] -name = "bureau" -addr = "192.168.1.10:9137" - -[[peers]] -name = "portable" -addr = "192.168.1.20:9137"