first batch

This commit is contained in:
Alexander Gabriel 2026-09-05 23:02:29 +02:00
commit 9e5e0fa831
18 changed files with 2056 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/clonetool

103
README.md Normal file
View File

@ -0,0 +1,103 @@
# clonetool
Block-level sync for a large file or block device between two machines (or
two paths on the same machine), driven from a third, passive "manager"
machine. Single static Go binary, no runtime dependencies beyond the
system `ssh` client for remote endpoints.
## How it works
- You run `clonetool sync` on the **manager**. Source, destination, and
manager may be three entirely different machines — the manager never
reads or writes a single block itself. It only spawns and talks to two
**control agents** (`clonetool agent --role control`, one per side,
local subprocess or over `ssh`) that do the stat/prepare/transfer work.
- The two agents compare content by **SHA-256 hash per fixed-size block**.
A block is only re-read once: it's hashed from the buffer it was read
into, and that same buffer is what gets sent on if it differs — never
read twice.
- Bulk data goes **directly between source and destination**, not through
the manager. Each run tries:
1. **push** — the source agent connects straight to the destination
host over SSH and streams changed blocks to it.
2. **pull** (fallback) — if push isn't possible (no route/keys in that
direction), the destination agent connects to the source host
instead and pulls.
3. If neither direction works, the job fails with both reasons named.
Run the manager on the source or destination host, or set up SSH
connectivity in at least one direction.
- When source and destination are **both local** to the manager, no SSH
is used at all — the source agent spawns the write-side helper as a
plain local subprocess.
- Each named **job** keeps a hash table for the destination
(`~/.clonetool/jobs/<job>.state` by default) so a re-run doesn't need to
read the destination again — it just compares freshly-hashed source
blocks against last known state. **The destination must not be modified
by anything else between syncs of the same job** — that assumption is
what lets the tool skip reading it. Running the same job against a
different destination is refused (pass `--force` to deliberately rebind
it, which discards the hash history).
- Sizing rules:
- Destination is a **block device**: it can't be resized, so if the
source is larger the job fails; otherwise exactly `min(source, dest)`
bytes are synced and the remainder of the device is left untouched.
- Destination is a **regular file**: it's truncated (created if
missing) to exactly the source's size, growing or shrinking it.
Shrinking an existing non-empty file prompts for confirmation unless
`--yes` is passed.
## Build
```
CGO_ENABLED=0 go build -o clonetool .
```
Copy the resulting binary to the manager, source, and destination hosts
(same path, or point `--remote-bin` at wherever it lives on each host —
clonetool does not deploy itself).
## Usage
```
clonetool sync --job NAME --source LOC --dest LOC [options]
```
`LOC` is either a local path (`/dev/sdb`, `./image.bin`) or
`[user@]host:path` for a path reached over SSH.
```
# Same machine
clonetool sync --job disk1 --source /dev/sda --dest /dev/sdb
# Two remote machines, orchestrated from a third
clonetool sync --job backup --source db1:/dev/vdb --dest backup-host:/srv/db1.img
# Re-run any time; only changed blocks move
clonetool sync --job backup --source db1:/dev/vdb --dest backup-host:/srv/db1.img
```
Options:
| Flag | Default | Meaning |
|---|---|---|
| `--block-size` | `4M` | Block size (accepts `K`/`M`/`G` suffixes). Changing it on an existing job discards its hash history. |
| `--state-dir` | `~/.clonetool/jobs` | Where job hash-tables live. |
| `--yes` | off | Don't prompt before shrinking an existing destination file. |
| `--force` | off | Rebind a job to a different source/dest, discarding its hash history. |
| `--connect-timeout` | `8` | SSH connect timeout (seconds) used for the push/pull direction probe. |
| `--ssh` | `ssh` | ssh binary to use. |
| `--ssh-opt` | — | Extra `-o OPT` passed to ssh (repeatable). |
| `--remote-bin` | `clonetool` | Path to clonetool on remote hosts. |
| `--manager-host` | local hostname | Address a peer should use to reach this machine, needed only when source or dest is local to the manager *and* the other side is remote and ends up needing to dial back in (pull fallback). |
## Caveats
- Block-device size detection (`BLKGETSIZE64`) is Linux-only.
- If a destination path doesn't exist yet, it's created as a regular
file — clonetool won't create device nodes, so double-check device
paths for typos before running.
- SSH host keys are accepted on first connect (`StrictHostKeyChecking=accept-new`)
and rejected if they later change, same as normal SSH behavior.
- `agent` is an internal subcommand spawned automatically by `sync`; it's
not meant to be run by hand, though it will work standalone for
debugging.

557
agent.go Normal file
View File

@ -0,0 +1,557 @@
package main
import (
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"sync"
"sync/atomic"
"time"
)
func cmdAgent(args []string) error {
fs := flag.NewFlagSet("agent", flag.ContinueOnError)
role := fs.String("role", "", "control|sink|source-stream (internal)")
path := fs.String("path", "", "path to read/write")
size := fs.Int64("size", 0, "total sync size in bytes")
blockSize := fs.Int64("block-size", defaultBlockSize, "block size in bytes")
if err := fs.Parse(args); err != nil {
return err
}
switch *role {
case "control":
return runControlAgent()
case "sink":
return runSinkRole(*path, *size, *blockSize)
case "source-stream":
return runSourceStreamRole(*path, *size, *blockSize)
default:
return fmt.Errorf("agent: unknown or missing --role %q (want control|sink|source-stream)", *role)
}
}
// ---------------------------------------------------------------------
// control role: long-lived per-side orchestration agent, driven by the
// manager over stdin/stdout with CtrlMsg frames.
// ---------------------------------------------------------------------
func runControlAgent() error {
in := NewFrameReader(os.Stdin)
out := NewFrameWriter(os.Stdout)
for {
typ, payload, err := in.ReadFrame()
if err != nil {
return nil // manager closed the pipe; nothing left to do
}
if typ != frameCtrlJSON {
return fmt.Errorf("control agent: unexpected frame type %d", typ)
}
var m CtrlMsg
if err := json.Unmarshal(payload, &m); err != nil {
return fmt.Errorf("control agent: decode message: %w", err)
}
switch m.Type {
case msgStat:
handleStat(out, m)
case msgPrepare:
handlePrepare(out, m)
case msgConnectPush:
hashes, err := readHashTable(in)
if err != nil {
return err
}
runPushDriver(m, hashes, out)
case msgConnectPull:
hashes, err := readHashTable(in)
if err != nil {
return err
}
runPullDriver(m, hashes, out)
case msgClose:
_ = out.WriteJSON(CtrlMsg{Type: msgBye})
return nil
default:
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fmt.Sprintf("unknown command %q", m.Type)})
}
}
}
func readHashTable(in *FrameReader) ([][32]byte, error) {
typ, payload, err := in.ReadFrame()
if err != nil {
return nil, fmt.Errorf("read hash table: %w", err)
}
if typ != frameHashTable {
return nil, fmt.Errorf("expected hash table frame, got type %d", typ)
}
return unflattenHashes(payload)
}
func handleStat(out *FrameWriter, m CtrlMsg) {
info, err := statPath(m.Path)
if err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: err.Error()})
return
}
_ = out.WriteJSON(CtrlMsg{Type: msgStatOK, Exists: info.Exists, IsDevice: info.IsDevice, Size: info.Size})
}
func handlePrepare(out *FrameWriter, m CtrlMsg) {
if err := prepareDest(m.Path, m.Size); err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: err.Error()})
return
}
_ = out.WriteJSON(CtrlMsg{Type: msgPrepareOK, Size: m.Size})
}
// ---------------------------------------------------------------------
// push driver: runs inside the SOURCE control agent. Spawns ssh straight
// to the destination host, running the "sink" role, and — once it answers
// READY — performs the whole read/hash/compare/send loop itself.
// ---------------------------------------------------------------------
func runPushDriver(req CtrlMsg, hashes [][32]byte, out *FrameWriter) {
tailArgs := []string{
"agent", "--role", "sink",
"--path", req.PeerPath,
"--size", strconv.FormatInt(req.Size, 10),
"--block-size", strconv.FormatInt(req.BlockSize, 10),
}
var cmd *exec.Cmd
if req.PeerLocal {
cmd = localAgentCommand(tailArgs)
} else {
cmd = sshCommand(req.SSHBin, req.SSHOpts, true, req.ConnectTimeoutSec, req.PeerUser, req.PeerHost, append([]string{req.RemoteBin}, tailArgs...))
}
stdin, err := cmd.StdinPipe()
if err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgPushFailed, Reason: err.Error()})
return
}
stdout, err := cmd.StdoutPipe()
if err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgPushFailed, Reason: err.Error()})
return
}
stderrBuf := newLimitedBuffer(4096)
cmd.Stderr = stderrBuf
if err := cmd.Start(); err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgPushFailed, Reason: fmt.Sprintf("start ssh: %v", err)})
return
}
fw := NewFrameWriter(stdin)
fr := NewFrameReader(stdout)
timeout := time.Duration(req.ConnectTimeoutSec+2) * time.Second
if err := waitReady(fr, timeout); err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
_ = out.WriteJSON(CtrlMsg{Type: msgPushFailed, Reason: fmt.Sprintf("%v (remote stderr: %s)", err, stderrBuf.String())})
return
}
// Handshake succeeded: we're committed to push for this run.
srcFile, err := os.Open(req.Path)
if err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: err.Error()})
return
}
defer srcFile.Close()
if fatalErr := pumpPush(req, hashes, srcFile, fw, fr, out); fatalErr != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fatalErr.Error()})
return
}
if err := cmd.Wait(); err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fmt.Sprintf("sink process: %v (stderr: %s)", err, stderrBuf.String())})
return
}
_ = out.WriteJSON(CtrlMsg{Type: msgPushOK})
}
func waitReady(fr *FrameReader, timeout time.Duration) error {
type result struct {
typ frameType
err error
}
ch := make(chan result, 1)
go func() {
typ, _, err := fr.ReadFrame()
ch <- result{typ, err}
}()
select {
case r := <-ch:
if r.err != nil {
return fmt.Errorf("handshake failed: %w", r.err)
}
if r.typ != frameReady {
return fmt.Errorf("handshake failed: unexpected frame type %d", r.typ)
}
return nil
case <-time.After(timeout):
return fmt.Errorf("handshake timed out after %s", timeout)
}
}
type ackEvent struct {
index uint64
eof bool
err error
}
// pumpPush runs the source-side read/hash/compare/send loop against fw
// (the pipe to the remote sink) while concurrently draining ACK/ERR frames
// from fr, only reporting a block as done to the manager (over out) once
// its write has been confirmed.
func pumpPush(req CtrlMsg, hashes [][32]byte, srcFile *os.File, fw *FrameWriter, fr *FrameReader, out *FrameWriter) error {
const maxInFlight = 32
sem := make(chan struct{}, maxInFlight)
var pendingMu sync.Mutex
pending := make(map[uint64][32]byte)
batch := newResultBatcher(out)
defer batch.flush()
var copied, skipped int64
var lastProgress time.Time
maybeProgress := func() {
if time.Since(lastProgress) < 500*time.Millisecond {
return
}
lastProgress = time.Now()
_ = out.WriteJSON(CtrlMsg{
Type: msgProgress, Copied: atomic.LoadInt64(&copied), Skipped: atomic.LoadInt64(&skipped),
TotalBlocks: int64(len(hashes)),
})
}
ackEvents := make(chan ackEvent, 256)
go func() {
for {
typ, payload, err := fr.ReadFrame()
if err != nil {
if err == io.EOF {
ackEvents <- ackEvent{eof: true}
} else {
ackEvents <- ackEvent{err: err}
}
return
}
switch typ {
case frameAck:
idx, err := decodeIndexFrame(payload)
if err != nil {
ackEvents <- ackEvent{err: err}
return
}
ackEvents <- ackEvent{index: idx}
case frameErr:
idx, msg, _ := decodeErrFrame(payload)
ackEvents <- ackEvent{err: fmt.Errorf("remote reported error at block %d: %s", idx, msg)}
return
default:
ackEvents <- ackEvent{err: fmt.Errorf("unexpected frame type %d from sink", typ)}
return
}
}
}()
sendErrCh := make(chan error, 1)
go func() {
sendErrCh <- runSourceLoop(sourceLoopParams{
File: srcFile, Size: req.Size, BlockSize: req.BlockSize, Hashes: hashes, Out: fw,
OnSkip: func(uint64) { atomic.AddInt64(&skipped, 1); maybeProgress() },
OnSend: func(idx uint64, hash [32]byte) {
sem <- struct{}{}
pendingMu.Lock()
pending[idx] = hash
pendingMu.Unlock()
},
})
}()
// Wait for both: the send loop to finish (all DATA frames + DONE sent)
// and the ack stream to end. Sink closes its stdout (a clean EOF) only
// after it has acked every block it received, so an EOF while blocks
// are still unconfirmed is treated as a real failure below.
sendCh := sendErrCh
ackCh := ackEvents
var fatalErr error
idle := time.NewTimer(120 * time.Second)
defer idle.Stop()
for sendCh != nil || ackCh != nil {
if !idle.Stop() {
select {
case <-idle.C:
default:
}
}
idle.Reset(120 * time.Second)
select {
case sendErr := <-sendCh:
sendCh = nil
if sendErr != nil {
fatalErr = sendErr
}
case ev := <-ackCh:
switch {
case ev.err != nil:
fatalErr = ev.err
ackCh = nil
case ev.eof:
ackCh = nil
default:
pendingMu.Lock()
h, ok := pending[ev.index]
delete(pending, ev.index)
pendingMu.Unlock()
if ok {
atomic.AddInt64(&copied, 1)
batch.add(BlockResult{Index: ev.index, Hash: hex.EncodeToString(h[:])})
}
<-sem
maybeProgress()
}
case <-idle.C:
fatalErr = fmt.Errorf("timed out waiting for the sink")
}
if fatalErr != nil {
break
}
}
if fatalErr != nil {
return fatalErr
}
pendingMu.Lock()
n := len(pending)
pendingMu.Unlock()
if n > 0 {
return fmt.Errorf("sink closed the connection with %d block write confirmation(s) still outstanding", n)
}
batch.flush()
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, Copied: atomic.LoadInt64(&copied), Skipped: atomic.LoadInt64(&skipped), TotalBlocks: int64(len(hashes))})
return nil
}
// ---------------------------------------------------------------------
// pull driver: runs inside the DEST control agent. Spawns ssh straight to
// the source host, running the "source-stream" role, feeds it the hash
// table, then writes whatever it streams back directly to the local
// destination — no round trip needed to confirm a write, since dest-agent
// itself performed it.
// ---------------------------------------------------------------------
func runPullDriver(req CtrlMsg, hashes [][32]byte, out *FrameWriter) {
tailArgs := []string{
"agent", "--role", "source-stream",
"--path", req.PeerPath,
"--size", strconv.FormatInt(req.Size, 10),
"--block-size", strconv.FormatInt(req.BlockSize, 10),
}
var cmd *exec.Cmd
if req.PeerLocal {
cmd = localAgentCommand(tailArgs)
} else {
cmd = sshCommand(req.SSHBin, req.SSHOpts, true, req.ConnectTimeoutSec, req.PeerUser, req.PeerHost, append([]string{req.RemoteBin}, tailArgs...))
}
stdin, err := cmd.StdinPipe()
if err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgPullFailed, Reason: err.Error()})
return
}
stdout, err := cmd.StdoutPipe()
if err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgPullFailed, Reason: err.Error()})
return
}
stderrBuf := newLimitedBuffer(4096)
cmd.Stderr = stderrBuf
if err := cmd.Start(); err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgPullFailed, Reason: fmt.Sprintf("start ssh: %v", err)})
return
}
fw := NewFrameWriter(stdin)
fr := NewFrameReader(stdout)
timeout := time.Duration(req.ConnectTimeoutSec+2) * time.Second
if err := waitReady(fr, timeout); err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
_ = out.WriteJSON(CtrlMsg{Type: msgPullFailed, Reason: fmt.Sprintf("%v (remote stderr: %s)", err, stderrBuf.String())})
return
}
// Handshake succeeded: send the hash table and commit to pull.
if err := fw.WriteFrame(frameHashTable, flattenHashes(hashes)); err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fmt.Sprintf("send hash table: %v", err)})
return
}
dstFile, err := os.OpenFile(req.Path, os.O_RDWR, 0)
if err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: err.Error()})
return
}
defer dstFile.Close()
batch := newResultBatcher(out)
var copied int64
loopErr := runDestLoop(destLoopParams{
File: dstFile, BlockSize: req.BlockSize, In: fr,
OnWritten: func(idx uint64, hash [32]byte) {
atomic.AddInt64(&copied, 1)
batch.add(BlockResult{Index: idx, Hash: hex.EncodeToString(hash[:])})
},
OnCtrlMsg: func(m CtrlMsg) {
if m.Type == msgProgress {
_ = out.WriteJSON(m)
}
},
})
batch.flush()
if loopErr != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: loopErr.Error()})
return
}
if err := cmd.Wait(); err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fmt.Sprintf("source-stream process: %v (stderr: %s)", err, stderrBuf.String())})
return
}
_ = out.WriteJSON(CtrlMsg{Type: msgPullOK})
}
// ---------------------------------------------------------------------
// sink role: one-shot process spawned (over ssh, in push mode) on the
// destination host. Dumb write endpoint: verify+pwrite+ack per block.
// ---------------------------------------------------------------------
func runSinkRole(path string, size, blockSize int64) error {
out := NewFrameWriter(os.Stdout)
in := NewFrameReader(os.Stdin)
f, err := os.OpenFile(path, os.O_RDWR, 0)
if err != nil {
fmt.Fprintf(os.Stderr, "sink: open %s: %v\n", path, err)
return err
}
defer f.Close()
if err := out.WriteFrame(frameReady, nil); err != nil {
return err
}
return runDestLoop(destLoopParams{File: f, BlockSize: blockSize, In: in, AckOut: out})
}
// ---------------------------------------------------------------------
// source-stream role: one-shot process spawned (over ssh, in pull mode) on
// the source host. Reads the hash table, then performs the same
// read/hash/compare/send loop a local push driver would, writing straight
// to its own stdout.
// ---------------------------------------------------------------------
func runSourceStreamRole(path string, size, blockSize int64) error {
out := NewFrameWriter(os.Stdout)
in := NewFrameReader(os.Stdin)
f, err := os.Open(path)
if err != nil {
fmt.Fprintf(os.Stderr, "source-stream: open %s: %v\n", path, err)
return err
}
defer f.Close()
if err := out.WriteFrame(frameReady, nil); err != nil {
return err
}
typ, payload, err := in.ReadFrame()
if err != nil {
return fmt.Errorf("source-stream: read hash table: %w", err)
}
if typ != frameHashTable {
return fmt.Errorf("source-stream: expected hash table frame, got type %d", typ)
}
hashes, err := unflattenHashes(payload)
if err != nil {
return err
}
var copied, skipped int64
var lastProgress time.Time
maybeProgress := func() {
if time.Since(lastProgress) < 500*time.Millisecond {
return
}
lastProgress = time.Now()
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, Copied: copied, Skipped: skipped, TotalBlocks: int64(len(hashes))})
}
return runSourceLoop(sourceLoopParams{
File: f, Size: size, BlockSize: blockSize, Hashes: hashes, Out: out,
OnSkip: func(uint64) { skipped++; maybeProgress() },
OnSend: func(uint64, [32]byte) { copied++; maybeProgress() },
})
}
// ---------------------------------------------------------------------
// resultBatcher: coalesces BlockResult entries into occasional
// block_done_batch messages so the manager isn't hit with one control
// message per synced block.
// ---------------------------------------------------------------------
type resultBatcher struct {
out *FrameWriter
mu sync.Mutex
buf []BlockResult
last time.Time
}
func newResultBatcher(out *FrameWriter) *resultBatcher {
return &resultBatcher{out: out, last: time.Now()}
}
func (b *resultBatcher) add(r BlockResult) {
b.mu.Lock()
b.buf = append(b.buf, r)
shouldFlush := len(b.buf) >= 256 || time.Since(b.last) > 500*time.Millisecond
b.mu.Unlock()
if shouldFlush {
b.flush()
}
}
func (b *resultBatcher) flush() {
b.mu.Lock()
if len(b.buf) == 0 {
b.mu.Unlock()
return
}
entries := b.buf
b.buf = nil
b.last = time.Now()
b.mu.Unlock()
_ = b.out.WriteJSON(CtrlMsg{Type: msgBlockDoneBatch, Entries: entries})
}

204
control.go Normal file
View File

@ -0,0 +1,204 @@
package main
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
)
// Controller is the manager's handle on one long-lived control agent
// (spawned locally or over ssh), used for stat/prepare/connect_push/
// connect_pull/close. The manager never does any source/dest I/O itself —
// every byte of the file/device it's syncing is read or written by an
// agent process, either this one or the sink/source-stream it spawns.
type Controller struct {
tag string
cmd *exec.Cmd
in io.WriteCloser
fw *FrameWriter
fr *FrameReader
}
func startController(spec Spec, tag string, cfg *SyncConfig) (*Controller, error) {
var cmd *exec.Cmd
if spec.IsLocal() {
cmd = localAgentCommand([]string{"agent", "--role", "control"})
} else {
remoteArgs := []string{cfg.RemoteBin, "agent", "--role", "control"}
cmd = sshCommand(cfg.SSHBin, cfg.SSHOpts, false, cfg.ConnectTimeoutSec, spec.User, spec.Host, remoteArgs)
}
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("start %s control agent: %w", tag, err)
}
go relayPrefixed(stderr, tag)
return &Controller{tag: tag, cmd: cmd, in: stdin, fw: NewFrameWriter(stdin), fr: NewFrameReader(stdout)}, nil
}
func relayPrefixed(r io.Reader, tag string) {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 64*1024), 1024*1024)
for sc.Scan() {
fmt.Fprintf(os.Stderr, "[%s] %s\n", tag, sc.Text())
}
}
func (c *Controller) readOne() (CtrlMsg, error) {
typ, payload, err := c.fr.ReadFrame()
if err != nil {
return CtrlMsg{}, fmt.Errorf("%s: read control frame: %w", c.tag, err)
}
if typ != frameCtrlJSON {
return CtrlMsg{}, fmt.Errorf("%s: unexpected frame type %d on control channel", c.tag, typ)
}
var m CtrlMsg
if err := json.Unmarshal(payload, &m); err != nil {
return CtrlMsg{}, fmt.Errorf("%s: decode control message: %w", c.tag, err)
}
return m, nil
}
func (c *Controller) call(req CtrlMsg) (CtrlMsg, error) {
if err := c.fw.WriteJSON(req); err != nil {
return CtrlMsg{}, fmt.Errorf("%s: send %s: %w", c.tag, req.Type, err)
}
return c.readOne()
}
func (c *Controller) Stat(path string) (PathInfo, error) {
resp, err := c.call(CtrlMsg{Type: msgStat, Path: path})
if err != nil {
return PathInfo{}, err
}
switch resp.Type {
case msgStatOK:
return PathInfo{Exists: resp.Exists, IsDevice: resp.IsDevice, Size: resp.Size}, nil
case msgError:
return PathInfo{}, fmt.Errorf("%s: stat %s: %s", c.tag, path, resp.Message)
default:
return PathInfo{}, fmt.Errorf("%s: unexpected response %q to stat", c.tag, resp.Type)
}
}
func (c *Controller) Prepare(path string, size int64) error {
resp, err := c.call(CtrlMsg{Type: msgPrepare, Path: path, Size: size})
if err != nil {
return err
}
switch resp.Type {
case msgPrepareOK:
return nil
case msgError:
return fmt.Errorf("%s: prepare %s: %s", c.tag, path, resp.Message)
default:
return fmt.Errorf("%s: unexpected response %q to prepare", c.tag, resp.Type)
}
}
// transferCallbacks receives streaming updates while a connect_push or
// connect_pull is in flight.
type transferCallbacks struct {
onProgress func(CtrlMsg)
onBlockDone func(BlockResult)
}
// ConnectPush asks the source control agent to try connecting straight out
// to the destination host and driving the whole transfer itself. ok=false
// with a non-empty reason means the SSH handshake didn't succeed (caller
// should try ConnectPull instead); a non-nil err means something failed
// after the transfer was already committed.
func (c *Controller) ConnectPush(req CtrlMsg, hashes [][32]byte, cb transferCallbacks) (ok bool, reason string, err error) {
req.Type = msgConnectPush
return c.connectAndPump(req, hashes, cb, msgPushOK, msgPushFailed)
}
// ConnectPull asks the destination control agent to try connecting out to
// the source host and pulling the transfer.
func (c *Controller) ConnectPull(req CtrlMsg, hashes [][32]byte, cb transferCallbacks) (ok bool, reason string, err error) {
req.Type = msgConnectPull
return c.connectAndPump(req, hashes, cb, msgPullOK, msgPullFailed)
}
func (c *Controller) connectAndPump(req CtrlMsg, hashes [][32]byte, cb transferCallbacks, okType, failedType string) (bool, string, error) {
if err := c.fw.WriteJSON(req); err != nil {
return false, "", fmt.Errorf("%s: send %s: %w", c.tag, req.Type, err)
}
if err := c.fw.WriteFrame(frameHashTable, flattenHashes(hashes)); err != nil {
return false, "", fmt.Errorf("%s: send hash table: %w", c.tag, err)
}
for {
m, err := c.readOne()
if err != nil {
return false, "", err
}
switch m.Type {
case msgProgress:
if cb.onProgress != nil {
cb.onProgress(m)
}
case msgBlockDoneBatch:
if cb.onBlockDone != nil {
for _, e := range m.Entries {
cb.onBlockDone(e)
}
}
case okType:
return true, "", nil
case failedType:
return false, m.Reason, nil
case msgError:
return false, "", fmt.Errorf("%s: %s", c.tag, m.Message)
default:
return false, "", fmt.Errorf("%s: unexpected message %q during transfer", c.tag, m.Type)
}
}
}
func (c *Controller) Close() error {
_ = c.fw.WriteJSON(CtrlMsg{Type: msgClose})
_ = c.in.Close()
err := c.cmd.Wait()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return nil // agent exiting after we closed its stdin is expected
}
return err
}
return nil
}
func flattenHashes(hashes [][32]byte) []byte {
buf := make([]byte, len(hashes)*32)
for i, h := range hashes {
copy(buf[i*32:], h[:])
}
return buf
}
func unflattenHashes(b []byte) ([][32]byte, error) {
if len(b)%32 != 0 {
return nil, fmt.Errorf("hash table: %d bytes is not a multiple of 32", len(b))
}
out := make([][32]byte, len(b)/32)
for i := range out {
copy(out[i][:], b[i*32:i*32+32])
}
return out, nil
}

71
ctrlmsg.go Normal file
View File

@ -0,0 +1,71 @@
package main
// CtrlMsg is the single envelope used for every message exchanged between
// the manager and a control agent over frameCtrlJSON frames. It is
// deliberately denormalized (not one struct per message type) to keep
// (de)serialization trivial.
type CtrlMsg struct {
Type string `json:"type"`
// stat request/response
Path string `json:"path,omitempty"`
Exists bool `json:"exists,omitempty"`
IsDevice bool `json:"isDevice,omitempty"`
Size int64 `json:"size,omitempty"`
// connect_push (-> source agent) / connect_pull (-> dest agent)
PeerHost string `json:"peerHost,omitempty"`
PeerUser string `json:"peerUser,omitempty"`
PeerPort int `json:"peerPort,omitempty"`
PeerPath string `json:"peerPath,omitempty"`
// PeerLocal is set when both source and dest are local to the manager,
// so the driver (itself already a local child of the manager) can spawn
// the sink/source-stream helper as a plain local subprocess instead of
// over ssh — no loopback SSH access required for same-machine jobs.
PeerLocal bool `json:"peerLocal,omitempty"`
RemoteBin string `json:"remoteBin,omitempty"`
SSHBin string `json:"sshBin,omitempty"`
SSHOpts []string `json:"sshOpts,omitempty"`
ConnectTimeoutSec int `json:"connectTimeoutSec,omitempty"`
BlockSize int64 `json:"blockSize,omitempty"`
// failure/error detail
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
// progress / block_done_batch
Copied int64 `json:"copied,omitempty"`
Skipped int64 `json:"skipped,omitempty"`
TotalBlocks int64 `json:"totalBlocks,omitempty"`
BytesCopied int64 `json:"bytesCopied,omitempty"`
Entries []BlockResult `json:"entries,omitempty"`
// log
Level string `json:"level,omitempty"`
}
// BlockResult is one confirmed-written block's new hash, reported from a
// control agent back to the manager so it can update the job state file.
type BlockResult struct {
Index uint64 `json:"i"`
Hash string `json:"h"` // hex-encoded 32 bytes
}
const (
msgStat = "stat"
msgStatOK = "stat_ok"
msgPrepare = "prepare"
msgPrepareOK = "prepare_ok"
msgConnectPush = "connect_push"
msgConnectPull = "connect_pull"
msgPushOK = "push_ok"
msgPushFailed = "push_failed"
msgPullOK = "pull_ok"
msgPullFailed = "pull_failed"
msgProgress = "progress"
msgBlockDoneBatch = "block_done_batch"
msgLog = "log"
msgError = "error"
msgClose = "close"
msgBye = "bye"
)

62
device.go Normal file
View File

@ -0,0 +1,62 @@
package main
import (
"fmt"
"os"
)
// PathInfo describes a source/dest path as seen locally by whichever
// process (control agent, sink, source-stream) actually opens it.
type PathInfo struct {
Exists bool
IsDevice bool
Size int64
}
func statPath(path string) (PathInfo, error) {
fi, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return PathInfo{Exists: false}, nil
}
return PathInfo{}, err
}
mode := fi.Mode()
isDevice := mode&os.ModeDevice != 0 && mode&os.ModeCharDevice == 0
size := fi.Size()
if isDevice {
sz, err := blockDeviceSize(path)
if err != nil {
return PathInfo{}, fmt.Errorf("stat block device %s: %w", path, err)
}
size = sz
}
return PathInfo{Exists: true, IsDevice: isDevice, Size: size}, nil
}
// prepareDest makes the destination ready to receive exactly targetSize
// bytes: a regular file is created if missing and truncated (grown or
// shrunk) to targetSize; a block device is only validated to be large
// enough (it can't be resized) — the caller is expected to have already
// capped targetSize at the device's own size when it is the limiting side.
func prepareDest(path string, targetSize int64) error {
info, err := statPath(path)
if err != nil {
return err
}
if info.Exists && info.IsDevice {
if targetSize > info.Size {
return fmt.Errorf("destination device %s is only %d bytes, need %d", path, info.Size, targetSize)
}
return nil
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("open/create %s: %w", path, err)
}
defer f.Close()
if err := f.Truncate(targetSize); err != nil {
return fmt.Errorf("truncate %s to %d: %w", path, targetSize, err)
}
return nil
}

28
device_linux.go Normal file
View File

@ -0,0 +1,28 @@
//go:build linux
package main
import (
"os"
"unsafe"
"syscall"
)
// BLKGETSIZE64 = _IOR(0x12, 114, sizeof(uint64)) on Linux.
const blkGetSize64 = 0x80081272
func blockDeviceSize(path string) (int64, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
}
defer f.Close()
var size uint64
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), blkGetSize64, uintptr(unsafe.Pointer(&size)))
if errno != 0 {
return 0, errno
}
return int64(size), nil
}

9
device_other.go Normal file
View File

@ -0,0 +1,9 @@
//go:build !linux
package main
import "fmt"
func blockDeviceSize(path string) (int64, error) {
return 0, fmt.Errorf("block device size detection is only implemented on linux (got path %s)", path)
}

136
frame.go Normal file
View File

@ -0,0 +1,136 @@
package main
import (
"bufio"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"sync"
)
// Wire format shared by the control channel (manager <-> control agent) and
// the data channel (source-agent/dest-agent <-> sink/source-stream). Every
// frame is:
//
// uint32 BE length (= 1 + len(payload))
// byte type
// []byte payload (length-1 bytes)
const maxFrameLen = 256 * 1024 * 1024 // sanity cap, well above block-size + header defaults
type frameType byte
const (
frameCtrlJSON frameType = 0x01 // payload: JSON object (control channel)
frameHashTable frameType = 0x02 // payload: raw concatenated 32-byte hashes
frameData frameType = 0x03 // payload: 8-byte index + 32-byte hash + block bytes
frameAck frameType = 0x04 // payload: 8-byte index
frameErr frameType = 0x05 // payload: 8-byte index + UTF-8 message
frameDone frameType = 0x06 // payload: empty
frameReady frameType = 0x07 // payload: empty
)
// FrameWriter serializes concurrent writers onto one underlying stream.
type FrameWriter struct {
mu sync.Mutex
w io.Writer
}
func NewFrameWriter(w io.Writer) *FrameWriter { return &FrameWriter{w: w} }
func (fw *FrameWriter) WriteFrame(typ frameType, payload []byte) error {
fw.mu.Lock()
defer fw.mu.Unlock()
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(1+len(payload)))
if _, err := fw.w.Write(hdr[:]); err != nil {
return err
}
if _, err := fw.w.Write([]byte{byte(typ)}); err != nil {
return err
}
if len(payload) > 0 {
if _, err := fw.w.Write(payload); err != nil {
return err
}
}
return nil
}
func (fw *FrameWriter) WriteJSON(v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
return fw.WriteFrame(frameCtrlJSON, b)
}
// FrameReader reads frames from a stream. Not safe for concurrent use.
type FrameReader struct {
r *bufio.Reader
}
func NewFrameReader(r io.Reader) *FrameReader {
return &FrameReader{r: bufio.NewReaderSize(r, 64*1024)}
}
func (fr *FrameReader) ReadFrame() (frameType, []byte, error) {
var hdr [4]byte
if _, err := io.ReadFull(fr.r, hdr[:]); err != nil {
return 0, nil, err
}
n := binary.BigEndian.Uint32(hdr[:])
if n == 0 || n > maxFrameLen {
return 0, nil, fmt.Errorf("frame: invalid length %d", n)
}
buf := make([]byte, n)
if _, err := io.ReadFull(fr.r, buf); err != nil {
return 0, nil, err
}
return frameType(buf[0]), buf[1:], nil
}
func encodeDataFrame(index uint64, hash [32]byte, payload []byte) []byte {
buf := make([]byte, 8+32+len(payload))
binary.BigEndian.PutUint64(buf[0:8], index)
copy(buf[8:40], hash[:])
copy(buf[40:], payload)
return buf
}
func decodeDataFrame(b []byte) (index uint64, hash [32]byte, payload []byte, err error) {
if len(b) < 40 {
return 0, hash, nil, fmt.Errorf("data frame too short: %d bytes", len(b))
}
index = binary.BigEndian.Uint64(b[0:8])
copy(hash[:], b[8:40])
payload = b[40:]
return index, hash, payload, nil
}
func encodeIndexFrame(index uint64) []byte {
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, index)
return buf
}
func decodeIndexFrame(b []byte) (uint64, error) {
if len(b) < 8 {
return 0, fmt.Errorf("index frame too short: %d bytes", len(b))
}
return binary.BigEndian.Uint64(b[:8]), nil
}
func encodeErrFrame(index uint64, msg string) []byte {
buf := make([]byte, 8+len(msg))
binary.BigEndian.PutUint64(buf[0:8], index)
copy(buf[8:], msg)
return buf
}
func decodeErrFrame(b []byte) (uint64, string, error) {
if len(b) < 8 {
return 0, "", fmt.Errorf("err frame too short: %d bytes", len(b))
}
return binary.BigEndian.Uint64(b[:8]), string(b[8:]), nil
}

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module clonetool
go 1.27.1

204
jobstate.go Normal file
View File

@ -0,0 +1,204 @@
package main
import (
"bufio"
"encoding/binary"
"fmt"
"io"
"os"
"path/filepath"
)
const jobStateMagic = "CTJ1"
// JobState is the manager-local trust anchor for one named job: the
// per-block hashes of the destination as of the last confirmed write. It is
// never derived by reading the destination — only by recording what this
// tool itself wrote there (or confirmed already matched).
type JobState struct {
BlockSize int64
SrcSpec string
DstSpec string
Size int64
Hashes [][32]byte // zero hash = never synced
}
func statePathFor(stateDir, job string) string {
return filepath.Join(stateDir, job+".state")
}
func defaultStateDir() string {
home, err := os.UserHomeDir()
if err != nil {
return ".clonetool/jobs"
}
return filepath.Join(home, ".clonetool", "jobs")
}
// LoadJobState reads the job's state file. If it doesn't exist yet, an
// empty state is returned (first run). If it exists but was recorded
// against a different source/dest spec, an error is returned unless force
// is set, in which case the old hash history is discarded.
func LoadJobState(path, srcSpec, dstSpec string, blockSize int64, force bool) (*JobState, error) {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return &JobState{BlockSize: blockSize, SrcSpec: srcSpec, DstSpec: dstSpec}, nil
}
return nil, err
}
defer f.Close()
r := bufio.NewReader(f)
magic := make([]byte, 4)
if _, err := io.ReadFull(r, magic); err != nil {
return nil, fmt.Errorf("read job state %s: %w", path, err)
}
if string(magic) != jobStateMagic {
return nil, fmt.Errorf("job state %s: bad magic", path)
}
var version uint8
if err := binary.Read(r, binary.BigEndian, &version); err != nil {
return nil, err
}
if version != 1 {
return nil, fmt.Errorf("job state %s: unsupported version %d", path, version)
}
var storedBlockSize uint32
if err := binary.Read(r, binary.BigEndian, &storedBlockSize); err != nil {
return nil, err
}
var blockCount uint64
if err := binary.Read(r, binary.BigEndian, &blockCount); err != nil {
return nil, err
}
storedSrc, err := readLPString(r)
if err != nil {
return nil, err
}
storedDst, err := readLPString(r)
if err != nil {
return nil, err
}
var size int64
if err := binary.Read(r, binary.BigEndian, &size); err != nil {
return nil, err
}
if !force && (storedSrc != srcSpec || storedDst != dstSpec) {
return nil, fmt.Errorf(
"job state %s was recorded for source=%q dest=%q, but this run uses source=%q dest=%q; "+
"reusing its hash history against different endpoints could skip blocks never written there. "+
"Pass --force to rebind this job (discards hash history)",
path, storedSrc, storedDst, srcSpec, dstSpec)
}
hashes := make([][32]byte, blockCount)
for i := range hashes {
if _, err := io.ReadFull(r, hashes[i][:]); err != nil {
return nil, fmt.Errorf("read job state %s: %w", path, err)
}
}
js := &JobState{BlockSize: int64(storedBlockSize), SrcSpec: srcSpec, DstSpec: dstSpec, Size: size, Hashes: hashes}
if force {
js.SrcSpec, js.DstSpec = srcSpec, dstSpec
}
if js.BlockSize != blockSize {
// Block size changed: the stored per-index hashes no longer line up
// with block boundaries, so start over rather than misinterpret them.
js.BlockSize = blockSize
js.Hashes = nil
}
return js, nil
}
// Resize grows (with zero/"unknown" hashes) or shrinks the hash table to
// match a new block count.
func (js *JobState) Resize(blockCount uint64) {
if uint64(len(js.Hashes)) == blockCount {
return
}
grown := make([][32]byte, blockCount)
copy(grown, js.Hashes)
js.Hashes = grown
}
func (js *JobState) Save(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
tmp := path + ".tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
if err != nil {
return err
}
w := bufio.NewWriter(f)
writeErr := func() error {
if _, err := w.WriteString(jobStateMagic); err != nil {
return err
}
if err := binary.Write(w, binary.BigEndian, uint8(1)); err != nil {
return err
}
if err := binary.Write(w, binary.BigEndian, uint32(js.BlockSize)); err != nil {
return err
}
if err := binary.Write(w, binary.BigEndian, uint64(len(js.Hashes))); err != nil {
return err
}
if err := writeLPString(w, js.SrcSpec); err != nil {
return err
}
if err := writeLPString(w, js.DstSpec); err != nil {
return err
}
if err := binary.Write(w, binary.BigEndian, js.Size); err != nil {
return err
}
for _, h := range js.Hashes {
if _, err := w.Write(h[:]); err != nil {
return err
}
}
return w.Flush()
}()
if writeErr != nil {
f.Close()
os.Remove(tmp)
return writeErr
}
if err := f.Sync(); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}
func readLPString(r io.Reader) (string, error) {
var n uint16
if err := binary.Read(r, binary.BigEndian, &n); err != nil {
return "", err
}
buf := make([]byte, n)
if _, err := io.ReadFull(r, buf); err != nil {
return "", err
}
return string(buf), nil
}
func writeLPString(w io.Writer, s string) error {
if len(s) > 65535 {
return fmt.Errorf("string too long to store (%d bytes)", len(s))
}
if err := binary.Write(w, binary.BigEndian, uint16(len(s))); err != nil {
return err
}
_, err := io.WriteString(w, s)
return err
}

132
main.go Normal file
View File

@ -0,0 +1,132 @@
package main
import (
"flag"
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
var err error
switch os.Args[1] {
case "sync":
err = cmdSync(os.Args[2:])
case "agent":
err = cmdAgent(os.Args[2:])
case "-h", "--help", "help":
usage()
return
default:
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "clonetool: "+err.Error())
os.Exit(1)
}
}
func usage() {
fmt.Fprint(os.Stderr, `clonetool - block-level file/device sync
Usage:
clonetool sync --job NAME --source LOC --dest LOC [options]
clonetool agent --role {control|sink|source-stream} ... (internal, spawned automatically)
LOC is either a local path, or [user@]host:path for a path reached over SSH.
Source, destination, and the machine running "sync" (the manager) may all be
different machines: the manager only orchestrates, it never reads or writes
a single block itself.
Options for sync:
--block-size SIZE block size, e.g. 4M (default 4M)
--state-dir DIR where job hash-tables are stored (default ~/.clonetool/jobs)
--yes don't prompt before shrinking an existing destination file
--force rebind this job to a new source/dest, discarding hash history
--connect-timeout SEC ssh connect timeout for the push/pull direction probe (default 8)
--ssh PATH ssh binary to use (default "ssh")
--ssh-opt OPT extra "-o OPT" passed to ssh (repeatable)
--remote-bin PATH path to clonetool on remote hosts (default "clonetool")
--manager-host HOST address peers should use to reach this machine, when
source or dest has no host part (defaults to the local hostname)
The destination must not be modified by anything else between syncs of the
same job: repeated runs trust the job's recorded hash table instead of
re-reading the destination.
`)
}
type stringSlice []string
func (s *stringSlice) String() string { return fmt.Sprint([]string(*s)) }
func (s *stringSlice) Set(v string) error {
*s = append(*s, v)
return nil
}
func cmdSync(args []string) error {
fs := flag.NewFlagSet("sync", flag.ContinueOnError)
job := fs.String("job", "", "job name (required)")
source := fs.String("source", "", "source location (required)")
dest := fs.String("dest", "", "destination location (required)")
blockSizeStr := fs.String("block-size", "4M", "block size, e.g. 4M")
stateDir := fs.String("state-dir", defaultStateDir(), "job state directory")
yes := fs.Bool("yes", false, "don't prompt before shrinking destination")
force := fs.Bool("force", false, "rebind job to a new source/dest")
connectTimeout := fs.Int("connect-timeout", defaultConnectTimeoutSec, "ssh connect timeout (seconds)")
sshBin := fs.String("ssh", "ssh", "ssh binary")
remoteBin := fs.String("remote-bin", "clonetool", "clonetool path on remote hosts")
managerHost := fs.String("manager-host", "", "address peers use to reach this machine")
var sshOpts stringSlice
fs.Var(&sshOpts, "ssh-opt", `extra "-o OPT" passed to ssh (repeatable)`)
if err := fs.Parse(args); err != nil {
return err
}
if *job == "" || *source == "" || *dest == "" {
fs.Usage()
return fmt.Errorf("--job, --source and --dest are required")
}
blockSize, err := parseSize(*blockSizeStr)
if err != nil {
return fmt.Errorf("--block-size: %w", err)
}
return runSync(SyncConfig{
Job: *job, Source: *source, Dest: *dest, BlockSize: blockSize, StateDir: *stateDir,
Yes: *yes, Force: *force, ConnectTimeoutSec: *connectTimeout, SSHBin: *sshBin,
SSHOpts: sshOpts, RemoteBin: *remoteBin, ManagerHost: *managerHost,
})
}
func parseSize(s string) (int64, error) {
if s == "" {
return 0, fmt.Errorf("empty size")
}
mult := int64(1)
numPart := s
switch s[len(s)-1] {
case 'k', 'K':
mult = 1024
numPart = s[:len(s)-1]
case 'm', 'M':
mult = 1024 * 1024
numPart = s[:len(s)-1]
case 'g', 'G':
mult = 1024 * 1024 * 1024
numPart = s[:len(s)-1]
}
var n int64
if _, err := fmt.Sscanf(numPart, "%d", &n); err != nil {
return 0, fmt.Errorf("invalid size %q", s)
}
if n <= 0 {
return 0, fmt.Errorf("size must be positive")
}
return n * mult, nil
}

212
manager.go Normal file
View File

@ -0,0 +1,212 @@
package main
import (
"bufio"
"encoding/hex"
"fmt"
"os"
"strings"
"time"
)
const defaultBlockSize = 4 * 1024 * 1024
const defaultConnectTimeoutSec = 8
// SyncConfig holds everything the `sync` command needs. It is also handed
// (the transport-relevant fields of it) to control agents as part of
// connect_push/connect_pull requests, so they know how to reach the peer.
type SyncConfig struct {
Job string
Source string
Dest string
BlockSize int64
StateDir string
Yes bool
Force bool
ConnectTimeoutSec int
SSHBin string
SSHOpts []string
RemoteBin string
ManagerHost string
}
func runSync(cfg SyncConfig) error {
srcSpec, err := parseSpec(cfg.Source)
if err != nil {
return fmt.Errorf("--source: %w", err)
}
dstSpec, err := parseSpec(cfg.Dest)
if err != nil {
return fmt.Errorf("--dest: %w", err)
}
if err := checkNotSame(srcSpec, dstSpec); err != nil {
return err
}
srcCtrl, err := startController(srcSpec, "source", &cfg)
if err != nil {
return fmt.Errorf("start source control agent: %w", err)
}
defer srcCtrl.Close()
dstCtrl, err := startController(dstSpec, "dest", &cfg)
if err != nil {
return fmt.Errorf("start dest control agent: %w", err)
}
defer dstCtrl.Close()
srcInfo, err := srcCtrl.Stat(srcSpec.Path)
if err != nil {
return fmt.Errorf("stat source: %w", err)
}
if !srcInfo.Exists {
return fmt.Errorf("source %s does not exist", srcSpec)
}
dstInfo, err := dstCtrl.Stat(dstSpec.Path)
if err != nil {
return fmt.Errorf("stat destination: %w", err)
}
targetSize, err := computeTargetSize(srcSpec, srcInfo, dstSpec, dstInfo, cfg.Yes)
if err != nil {
return err
}
if err := dstCtrl.Prepare(dstSpec.Path, targetSize); err != nil {
return fmt.Errorf("prepare destination: %w", err)
}
blockCount := (targetSize + cfg.BlockSize - 1) / cfg.BlockSize
statePath := statePathFor(cfg.StateDir, cfg.Job)
state, err := LoadJobState(statePath, cfg.Source, cfg.Dest, cfg.BlockSize, cfg.Force)
if err != nil {
return err
}
state.Resize(uint64(blockCount))
lastSave := time.Now()
sinceSave := 0
checkpoint := func(force bool) {
sinceSave++
if !force && sinceSave < 2000 && time.Since(lastSave) < 5*time.Second {
return
}
if err := state.Save(statePath); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not checkpoint job state: %v\n", err)
return
}
lastSave = time.Now()
sinceSave = 0
}
cb := transferCallbacks{
onProgress: func(m CtrlMsg) { printProgress(m) },
onBlockDone: func(r BlockResult) {
h, err := hex.DecodeString(r.Hash)
if err != nil || len(h) != 32 {
return
}
if r.Index < uint64(len(state.Hashes)) {
copy(state.Hashes[r.Index][:], h)
}
checkpoint(false)
},
}
bothLocal := srcSpec.IsLocal() && dstSpec.IsLocal()
srcHost, srcUser := resolveConnectHost(srcSpec, &cfg)
dstHost, dstUser := resolveConnectHost(dstSpec, &cfg)
pushReq := CtrlMsg{
Path: srcSpec.Path, Size: targetSize, BlockSize: cfg.BlockSize,
PeerHost: dstHost, PeerUser: dstUser, PeerPath: dstSpec.Path, PeerLocal: bothLocal,
RemoteBin: cfg.RemoteBin, SSHBin: cfg.SSHBin, SSHOpts: cfg.SSHOpts, ConnectTimeoutSec: cfg.ConnectTimeoutSec,
}
if bothLocal {
fmt.Fprintf(os.Stderr, "both source and dest are local; syncing directly (no ssh) ...\n")
} else {
fmt.Fprintf(os.Stderr, "attempting push %s -> %s ...\n", srcSpec, dstSpec)
}
ok, reason, err := srcCtrl.ConnectPush(pushReq, state.Hashes, cb)
if err != nil {
return err
}
if !ok {
fmt.Fprintf(os.Stderr, "push not possible (%s); trying pull %s <- %s ...\n", reason, dstSpec, srcSpec)
pullReq := CtrlMsg{
Path: dstSpec.Path, Size: targetSize, BlockSize: cfg.BlockSize,
PeerHost: srcHost, PeerUser: srcUser, PeerPath: srcSpec.Path, PeerLocal: bothLocal,
RemoteBin: cfg.RemoteBin, SSHBin: cfg.SSHBin, SSHOpts: cfg.SSHOpts, ConnectTimeoutSec: cfg.ConnectTimeoutSec,
}
ok2, reason2, err2 := dstCtrl.ConnectPull(pullReq, state.Hashes, cb)
if err2 != nil {
return err2
}
if !ok2 {
return fmt.Errorf(
"could not establish a direct connection in either direction (push: %s; pull: %s); "+
"run the manager on the source or destination host, or set up SSH connectivity in at least one direction",
reason, reason2)
}
}
fmt.Fprintln(os.Stderr)
state.Size = targetSize
checkpoint(true)
fmt.Fprintf(os.Stderr, "done: job %q, %d blocks, source=%s dest=%s\n", cfg.Job, blockCount, srcSpec, dstSpec)
return nil
}
func resolveConnectHost(spec Spec, cfg *SyncConfig) (host, user string) {
if !spec.IsLocal() {
return spec.Host, spec.User
}
host = cfg.ManagerHost
if host == "" {
if h, err := os.Hostname(); err == nil {
host = h
}
}
return host, ""
}
// computeTargetSize applies the sizing/truncation rules: a block-device
// destination can't grow, so the source must fit inside it (sync exactly
// min(src,dst), leaving the remainder of the device untouched); a
// regular-file destination is truncated to the source's size, shrinking
// with confirmation if it currently holds more data than that.
func computeTargetSize(srcSpec Spec, srcInfo PathInfo, dstSpec Spec, dstInfo PathInfo, yes bool) (int64, error) {
if dstInfo.Exists && dstInfo.IsDevice {
if srcInfo.Size > dstInfo.Size {
return 0, fmt.Errorf("source %s (%s) is larger than destination device %s (%s); a device can't be grown",
srcSpec, humanBytes(srcInfo.Size), dstSpec, humanBytes(dstInfo.Size))
}
return srcInfo.Size, nil
}
if dstInfo.Exists && dstInfo.Size > srcInfo.Size {
if !yes {
if !confirmShrink(dstSpec, dstInfo.Size, srcInfo.Size) {
return 0, fmt.Errorf("aborted: destination %s would be truncated from %s to %s", dstSpec, humanBytes(dstInfo.Size), humanBytes(srcInfo.Size))
}
}
}
return srcInfo.Size, nil
}
func confirmShrink(dstSpec Spec, oldSize, newSize int64) bool {
fmt.Fprintf(os.Stderr, "destination %s is %s; it will be truncated to %s to match the source. Continue? [y/N] ",
dstSpec, humanBytes(oldSize), humanBytes(newSize))
reader := bufio.NewReader(os.Stdin)
line, _ := reader.ReadString('\n')
line = strings.TrimSpace(strings.ToLower(line))
return line == "y" || line == "yes"
}
func printProgress(m CtrlMsg) {
total := m.TotalBlocks
if total <= 0 {
total = m.Copied + m.Skipped
}
fmt.Fprintf(os.Stderr, "\rblocks: %d/%d copied=%d skipped=%d ", m.Copied+m.Skipped, total, m.Copied, m.Skipped)
}

17
prompts.md Normal file
View File

@ -0,0 +1,17 @@
# CloneTool
i need a clone-tool.
it should be run from a single binary without any dependencies (maybe on go?).
it should be able to clone a big file or a block device from one computer to another or on the same compuser from one device to another.
it should compare blocks and if they differ, copy it from source to destination.
to prevent double reading, keep the blocks in memory until diff was checked.
i want to start it from one computer which is the manager of the copy process, source and destination can be other computers.
blocks should be copied directly from source to destination.
i dont want to have any dependencies.
it is allowed to use ssh and dd if nessesary.
maybe it makes sense to use a copy job name and track block hashes in some file to compare them without reading from destination (which must not be changed between syncs) i want to be able to run the sync multiple time in one job.
if source is smaller than target, stop after end of source if destination is a device.
if destination is a file, truncate it to the size of source
first try to connect from source to destination, of not possible, try to connect from destination to source. transfer all data via ssh

76
spec.go Normal file
View File

@ -0,0 +1,76 @@
package main
import (
"fmt"
"path/filepath"
"strings"
)
// Spec is a parsed source/dest location: [user@]host:path, or a bare local
// path (Host == "").
type Spec struct {
Raw string
User string
Host string
Path string
}
func (s Spec) IsLocal() bool { return s.Host == "" }
func (s Spec) String() string {
if s.IsLocal() {
return s.Path
}
if s.User != "" {
return fmt.Sprintf("%s@%s:%s", s.User, s.Host, s.Path)
}
return fmt.Sprintf("%s:%s", s.Host, s.Path)
}
// parseSpec parses "[user@]host:path" or a local "path". A leading "/",
// "./" or "../", or the absence of any colon, is treated as a local path so
// that ordinary absolute/relative paths are never mistaken for a host spec.
func parseSpec(raw string) (Spec, error) {
if raw == "" {
return Spec{}, fmt.Errorf("empty location")
}
if strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "./") || strings.HasPrefix(raw, "../") || !strings.Contains(raw, ":") {
return Spec{Raw: raw, Path: raw}, nil
}
idx := strings.Index(raw, ":")
hostpart := raw[:idx]
path := raw[idx+1:]
if hostpart == "" || path == "" || strings.ContainsAny(hostpart, "/ ") {
return Spec{}, fmt.Errorf("cannot parse location %q (expected [user@]host:path or a local path)", raw)
}
user := ""
host := hostpart
if at := strings.Index(hostpart, "@"); at >= 0 {
user = hostpart[:at]
host = hostpart[at+1:]
}
if host == "" {
return Spec{}, fmt.Errorf("cannot parse location %q: empty host", raw)
}
return Spec{Raw: raw, User: user, Host: host, Path: path}, nil
}
// checkNotSame does a best-effort local check that source and dest don't
// refer to the exact same path, to avoid an obviously destructive mistake.
// It cannot resolve whether two different remote hostnames are actually the
// same machine.
func checkNotSame(src, dst Spec) error {
if src.IsLocal() != dst.IsLocal() {
return nil
}
if !src.IsLocal() && !strings.EqualFold(src.Host, dst.Host) {
return nil
}
if !src.IsLocal() && src.User != dst.User {
return nil
}
if filepath.Clean(src.Path) == filepath.Clean(dst.Path) {
return fmt.Errorf("source and destination resolve to the same path (%s)", src.Path)
}
return nil
}

135
syncside.go Normal file
View File

@ -0,0 +1,135 @@
package main
import (
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"os"
)
// readBlockAt reads exactly min(len(buf), totalSize-offset) bytes at offset,
// treating a fully-satisfied read as success even if the underlying
// implementation also reports io.EOF for it.
func readBlockAt(f *os.File, buf []byte, offset, totalSize int64) (int, error) {
remaining := totalSize - offset
if remaining <= 0 {
return 0, io.EOF
}
want := int64(len(buf))
if remaining < want {
want = remaining
}
n, err := f.ReadAt(buf[:want], offset)
if err != nil && !(err == io.EOF && int64(n) == want) {
return n, err
}
return n, nil
}
// sourceLoopParams drives the single read -> hash -> compare -> maybe-send
// pass over a source path. It is used both by the standalone
// "source-stream" role (writing to its own stdout) and by a source control
// agent's push driver (writing into a spawned ssh subprocess's stdin).
type sourceLoopParams struct {
File *os.File // already opened for reading by the caller
Size int64
BlockSize int64
Hashes [][32]byte // previous known hashes, len == block count
Out *FrameWriter
OnSkip func(index uint64)
OnSend func(index uint64, hash [32]byte) // called after the DATA frame is written
}
func runSourceLoop(p sourceLoopParams) error {
f := p.File
buf := make([]byte, p.BlockSize)
blockCount := uint64(len(p.Hashes))
for index := uint64(0); index < blockCount; index++ {
offset := int64(index) * p.BlockSize
n, err := readBlockAt(f, buf, offset, p.Size)
if err != nil {
return fmt.Errorf("read %s at block %d: %w", f.Name(), index, err)
}
hash := sha256.Sum256(buf[:n])
if hash == p.Hashes[index] {
if p.OnSkip != nil {
p.OnSkip(index)
}
continue
}
if err := p.Out.WriteFrame(frameData, encodeDataFrame(index, hash, buf[:n])); err != nil {
return fmt.Errorf("send block %d: %w", index, err)
}
if p.OnSend != nil {
p.OnSend(index, hash)
}
}
return p.Out.WriteFrame(frameDone, nil)
}
// destLoopParams drives the receive -> verify -> pwrite pass on the
// destination path. It is used both by the standalone "sink" role (reading
// from its own stdin, and required to ack/err back over AckOut) and by a
// dest control agent's pull driver (reading from a spawned ssh subprocess's
// stdout; no AckOut needed since the write is confirmed locally, in the
// same process, before OnWritten is called).
type destLoopParams struct {
File *os.File // already opened for read/write by the caller
BlockSize int64
In *FrameReader
AckOut *FrameWriter // optional
OnWritten func(index uint64, hash [32]byte)
// OnCtrlMsg handles an interleaved frameCtrlJSON frame (used only in
// pull mode, where the remote source-stream has no other channel back
// to the manager for progress updates). Sink-role callers leave it nil.
OnCtrlMsg func(CtrlMsg)
}
func runDestLoop(p destLoopParams) error {
f := p.File
for {
typ, payload, err := p.In.ReadFrame()
if err != nil {
return fmt.Errorf("read frame: %w", err)
}
switch typ {
case frameDone:
return f.Sync()
case frameData:
index, hash, block, err := decodeDataFrame(payload)
if err != nil {
return err
}
if sha256.Sum256(block) != hash {
if p.AckOut != nil {
_ = p.AckOut.WriteFrame(frameErr, encodeErrFrame(index, "hash mismatch after transfer"))
}
return fmt.Errorf("block %d: hash mismatch after transfer", index)
}
if _, err := f.WriteAt(block, int64(index)*p.BlockSize); err != nil {
if p.AckOut != nil {
_ = p.AckOut.WriteFrame(frameErr, encodeErrFrame(index, err.Error()))
}
return fmt.Errorf("write block %d: %w", index, err)
}
if p.AckOut != nil {
if err := p.AckOut.WriteFrame(frameAck, encodeIndexFrame(index)); err != nil {
return err
}
}
if p.OnWritten != nil {
p.OnWritten(index, hash)
}
case frameCtrlJSON:
if p.OnCtrlMsg != nil {
var m CtrlMsg
if err := json.Unmarshal(payload, &m); err == nil {
p.OnCtrlMsg(m)
}
}
default:
return fmt.Errorf("unexpected frame type %d on data channel", typ)
}
}
}

57
transport.go Normal file
View File

@ -0,0 +1,57 @@
package main
import (
"fmt"
"os"
"os/exec"
"strings"
)
// shellQuote wraps s in single quotes so it survives the remote shell that
// OpenSSH hands its non-option arguments to (ssh joins them itself; it does
// not exec the remote command with a distinct argv the way os/exec does).
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
func shellJoin(args []string) string {
parts := make([]string, len(args))
for i, a := range args {
parts[i] = shellQuote(a)
}
return strings.Join(parts, " ")
}
// sshCommand builds an *exec.Cmd that runs remoteArgs on host via ssh.
// batchMode disables interactive prompts (used for the push/pull direction
// probe, so an unreachable/unauthenticated attempt fails fast instead of
// hanging); it is left off for the manager's own control connections so a
// password prompt still works when the user is present at a terminal.
func sshCommand(sshBin string, extraOpts []string, batchMode bool, connectTimeoutSec int, user, host string, remoteArgs []string) *exec.Cmd {
args := []string{"-o", "StrictHostKeyChecking=accept-new"}
if batchMode {
args = append(args, "-o", "BatchMode=yes")
}
if connectTimeoutSec > 0 {
args = append(args, "-o", fmt.Sprintf("ConnectTimeout=%d", connectTimeoutSec))
}
for _, o := range extraOpts {
args = append(args, "-o", o)
}
userHost := host
if user != "" {
userHost = user + "@" + host
}
args = append(args, userHost, shellJoin(remoteArgs))
return exec.Command(sshBin, args...)
}
// localAgentCommand re-execs this same binary as an agent, for a spec with
// no host part.
func localAgentCommand(remoteArgs []string) *exec.Cmd {
self, err := os.Executable()
if err != nil {
self = os.Args[0]
}
return exec.Command(self, remoteArgs...)
}

49
util.go Normal file
View File

@ -0,0 +1,49 @@
package main
import (
"fmt"
"sync"
)
// limitedBuffer keeps only the last maxLen bytes written to it — used to
// capture a bounded tail of a subprocess's stderr for error messages
// without risking unbounded memory growth from a noisy remote command.
type limitedBuffer struct {
mu sync.Mutex
buf []byte
maxLen int
}
func newLimitedBuffer(maxLen int) *limitedBuffer {
return &limitedBuffer{maxLen: maxLen}
}
func (b *limitedBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
b.buf = append(b.buf, p...)
if len(b.buf) > b.maxLen {
b.buf = b.buf[len(b.buf)-b.maxLen:]
}
return len(p), nil
}
func (b *limitedBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return string(b.buf)
}
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
units := "KMGTPE"
return fmt.Sprintf("%.1f%ciB", float64(n)/float64(div), units[exp])
}