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
+25 -1
View File
@@ -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
+22
View File
@@ -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 <path>` (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 <path>` 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
+21
View File
@@ -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.
+1 -1
View File
@@ -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
+65 -10
View File
@@ -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 <path> # 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 <path> # 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
+21 -17
View File
@@ -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 <key>`)
- [ ] 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 <key>`)
- [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 <path>`
## 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
+14 -2
View File
@@ -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
)
+16
View File
@@ -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=
+15 -13
View File
@@ -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]]
+14 -5
View File
@@ -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.
+118 -13
View File
@@ -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:
// 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 (
@@ -20,6 +23,7 @@ import (
// Config represents the full clip-sync configuration.
type Config struct {
Daemon DaemonConfig `toml:"daemon"`
Security SecurityConfig `toml:"security"`
Peers []PeerConfig `toml:"peers"`
}
@@ -27,51 +31,109 @@ type Config struct {
type DaemonConfig struct {
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")
}
+85 -12
View File
@@ -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,6 +12,9 @@ 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"
)
@@ -23,6 +26,9 @@ type Daemon struct {
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()
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())
if err := d.server.ListenAndServe(); err != nil {
// ListenAndServe returns http.ErrServerClosed on graceful shutdown.
if ctx.Err() == nil {
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)
}
}
+185
View File
@@ -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:])
}
+103
View File
@@ -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
}
+74
View File
@@ -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")
}
}
+11
View File
@@ -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
}
+29
View File
@@ -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 + `"`
}
+15
View File
@@ -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()
}
+11
View File
@@ -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 }
+13
View File
@@ -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 }
+164 -13
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 {
return &Broadcaster{
peers: peers,
origin: origin,
client: &http.Client{
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: 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
return nil, false
}
return body, true
}
for i := range b.peers {
go b.sendToPeer(b.peers[i], body)
// 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
}
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("")
}
+94
View File
@@ -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
}
+44
View File
@@ -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)
}
}
}
+259 -4
View File
@@ -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 = `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>clip-sync</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
h1 { font-size: 1.4rem; }
.card { background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
.kv { display: flex; justify-content: space-between; padding: .2rem 0; }
ul { list-style: none; padding: 0; margin: 0; }
li { padding: .35rem .5rem; border-bottom: 1px solid #eaeef2; font-family: monospace; font-size: .85rem; word-break: break-all; }
.origin { color: #0969da; font-weight: 600; }
</style>
</head>
<body>
<h1>clip-sync — monitoring</h1>
<div class="card">
<div class="kv"><span>État</span><strong id="status">…</strong></div>
<div class="kv"><span>Clips reçus</span><span id="received">…</span></div>
<div class="kv"><span>Dernier clip</span><span id="last">…</span></div>
</div>
<div class="card">
<h2>Historique récent</h2>
<ul id="history"><li>Chargement…</li></ul>
</div>
<script>
async function refresh() {
try {
const h = await (await fetch('/health')).json();
document.getElementById('status').textContent = h.ok ? 'OK' : 'ERREUR';
document.getElementById('received').textContent = h.stats.received_count;
document.getElementById('last').textContent = h.stats.last_received_by
? (h.stats.last_received_by + ' (' + h.stats.last_received_len + ' octets)') : '—';
} catch (e) { document.getElementById('status').textContent = 'indisponible'; }
try {
const entries = await (await fetch('/history')).json();
const ul = document.getElementById('history');
ul.innerHTML = '';
[...entries].reverse().slice(0, 20).forEach(e => {
const li = document.createElement('li');
li.innerHTML = '<span class="origin">' + escapeHtml(e.origin || 'local') + '</span> ' + escapeHtml(e.text);
ul.appendChild(li);
});
} catch (e) {}
}
function escapeHtml(s) { return s.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
refresh();
setInterval(refresh, 3000);
</script>
</body>
</html>`
+119 -50
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"git.dracodev.net/Projets/clip-sync/internal/dedup"
@@ -19,26 +20,34 @@ type mockClipboard struct {
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)
payload := dedup.Payload{
Text: "test text",
Ts: 1690000000000000000,
Origin: "other-machine",
return New(opts, cb, filter)
}
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())
}
+83
View File
@@ -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)
}
+83
View File
@@ -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)
}
}
+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.")
}
+19
View File
@@ -4,11 +4,30 @@
# 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
# 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"
-18
View File
@@ -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"