feat: implement security, discovery, history, file transfer, web UI (P0-P3)
This commit is contained in:
+25
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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]]
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 + `"`
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
+163
-12
@@ -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 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)
|
||||
}
|
||||
|
||||
for i := range b.peers {
|
||||
go b.sendToPeer(b.peers[i], body)
|
||||
// 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 {
|
||||
|
||||
@@ -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("")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
refresh();
|
||||
setInterval(refresh, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
+119
-50
@@ -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)
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user