Compare commits

19 changed files with 1221 additions and 349 deletions

No files matched your search

+141 -59
View File
@@ -1,9 +1,32 @@
# WARNING
THIS TOOL IS COMPLETELY VIBE-CODED!!!
THESE LINES ARE THE ONLY ONE I HAVE WRITTEN MYSELF!!!!
I SWEAR I DID NOT READ MORE THAN 20 LINES OF CODE!
USE AT YOUR OWN RISK (AS ALWAYS)
I needed a Tool to sync 2 Files from a to b with differential transfer, all managed from a workstation c.
Maybe i use this to learn go...
Looks nice, builds fast, runs everywhere with one Binary.
# clonetool # clonetool
Block-level sync for a large file or block device between two machines (or 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" two paths on the same machine), driven from a third, passive "manager"
machine. Single static Go binary, no runtime dependencies beyond the machine. Single static Go binary, no runtime dependencies beyond the
system `ssh` client for remote endpoints. system `ssh` client for SSH endpoints.
Runs on **Linux and Windows**. Endpoints are reached either over SSH (as
below) or, for a host with no SSH server (typically Windows), by starting
`clonetool listen` there and connecting to it over an encrypted, password-
protected TCP channel — see **Listen mode (no SSH)** below.
## How it works ## How it works
@@ -44,13 +67,25 @@ system `ssh` client for remote endpoints.
is used at all — the source agent spawns the write-side helper as a is used at all — the source agent spawns the write-side helper as a
plain local subprocess. plain local subprocess.
- **Self-deploy:** if a remote endpoint has no runnable `clonetool` on - **Self-deploy:** if a remote endpoint has no runnable `clonetool` on
`PATH` (or wherever `--remote-bin` points), the manager streams *this* `PATH` (or wherever `--remote-bin` points), **or the one that's there is
binary to `~/.clonetool/bin/clonetool` on that host over the existing a different build than this binary** (see below), the manager streams
SSH connection and uses it — no install, no root. Disable with *this* binary to `~/.clonetool/bin/clonetool` on that host over the
`--deploy=false`. If the copied binary won't execute there (wrong CPU existing SSH connection and uses it — no install, no root. Disable with
architecture) the error says so; build one for the remote's arch `--deploy=false` (a stale or missing binary is then an error, except a
merely-stale `--remote-bin` is used anyway with a warning, since there's
no way to correct it). If the copied binary won't execute there (wrong
CPU architecture) the error says so; build one for the remote's arch
(`CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build`) and put it on `PATH` (`CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build`) and put it on `PATH`
or pass `--remote-bin`. or pass `--remote-bin`.
- **Version/staleness check:** every binary has a build timestamp baked in
(see Build below) and `clonetool version` prints it. Before using a
remote `clonetool`, the manager runs its `version` there and compares
that timestamp against its own — a mismatch (e.g. the remote was
self-deployed from an older build of this tool) is treated the same as
"not runnable" and triggers the self-deploy above, so the remote binary
is kept in sync with whatever you're running locally. Only the build
timestamp is compared, not `GOARCH` — that is expected to differ across
a cross-compiled deploy.
- **sudo:** if reading or writing an endpoint that is a **block device** - **sudo:** if reading or writing an endpoint that is a **block device**
fails with a permission error, `--sudo=auto` (the default) transparently fails with a permission error, `--sudo=auto` (the default) transparently
restarts that side's agent — and the helper it spawns on the peer — restarts that side's agent — and the helper it spawns on the peer —
@@ -61,67 +96,80 @@ system `ssh` client for remote endpoints.
start; `--sudo=never` never does. start; `--sudo=never` never does.
- Sizing rules: - Sizing rules:
- Destination is a **block device**: it can't be resized, so if the - Destination is a **block device**: it can't be resized, so if the
source is larger the job fails; otherwise exactly `min(source, dest)` source is larger the job fails; otherwise exactly the source's size is
bytes are synced and the remainder of the device is left untouched. synced and the remainder of the device is left untouched.
- Destination is a **regular file**: it's truncated (created if - Destination is a **regular file**: it's truncated (created if
missing) to exactly the source's size, growing or shrinking it. missing) to exactly the source's size, growing or shrinking it.
Shrinking an existing non-empty file prompts for confirmation unless Shrinking an existing non-empty file prompts for confirmation unless
`--yes` is passed. `--yes` is passed.
## Listen mode (no SSH)
For a host that has no SSH server — a Windows machine, typically — start
clonetool there in **listen mode** and point the manager's location at it
with a `tcp://host:port/path` spec instead of `host:path`. There is no
self-deploy over this path: you copy the binary to that host yourself and
start it by hand.
```
# On the source host (e.g. Windows), started manually:
clonetool listen --bind :9000 --password s3cret
# On the manager, pull that source into a local/SSH destination:
clonetool sync --source tcp://WINHOST:9000/C:\data\disk.img \
--dest backup:/srv/disk.img --password s3cret
```
- A listening host only ever **accepts** connections; it never dials out.
So when the **source** is a `tcp://` endpoint, clonetool always uses the
**pull** direction — the destination side connects to the listener and
pulls the changed blocks. The destination must therefore have a network
route to `host:port`. (Push is skipped for a listener source.)
- **Password:** required for any `tcp://` endpoint, on both the listener
(`clonetool listen`) and the manager (`clonetool sync`). Provide it with
`--password`, `--password-file PATH`, or the `CLONETOOL_PASSWORD`
environment variable (env/file keep it out of the process list and shell
history). The manager passes it to the destination agent over the already-
encrypted control channel, never on a command line.
- **Security:** the channel is TLS 1.3 (the listener uses a fresh, in-memory
self-signed certificate). The password is verified by a challenge-response
bound to the TLS session (HMAC over the connection's exported keying
material, key stretched with PBKDF2), so a man-in-the-middle can't
authenticate even though the certificate isn't checked against a CA. Both
ends authenticate each other; a wrong password fails the connection before
any file is touched. Uses only the Go standard library — still one static
binary.
- The listener serves connections until you stop it with Ctrl-C. A single
sync opens two connections to it (one for the stat, one for the data
pull), so it must stay running for the whole job.
## Build ## Build
``` ```
CGO_ENABLED=0 go build -o clonetool . CGO_ENABLED=0 go build -o clonetool .
``` ```
Cross-compile for another OS/arch by setting `GOOS`/`GOARCH` (no cgo, so Cross-compile for another architecture by setting `GOARCH` (no cgo, so
these all work from any host): this works from any host):
``` ```
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o clonetool . CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o clonetool .
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o clonetool.exe .
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o clonetool-darwin .
``` ```
`./build.sh` writes all three of the above (plus linux/amd64) into `dist/`. `./build.sh` writes linux/amd64 and linux/arm64 binaries into `dist/`. It
also stamps every binary it builds with the same build timestamp (via
`-ldflags -X main.buildTime=...`), which is how the manager tells a stale
self-deployed remote binary apart from a current one (see "Version/staleness
check" above) — build all your binaries for a given release with one
`./build.sh` run so they compare equal to each other. A plain `go build`
with no `-ldflags` leaves the timestamp at `"dev"`, which still works for
this check (it only ever compares equal to another literal copy of the same
`dev` binary, never to a real timestamp).
Copy the resulting binary to the manager host. Source and destination Copy the resulting binary to the manager host. Source and destination
hosts get it automatically (see self-deploy above), or place it yourself hosts get it automatically (see self-deploy above), or place it yourself
and point `--remote-bin` at it. The binary is architecture-specific — and point `--remote-bin` at it. The binary is architecture-specific —
cross-compile if your hosts differ. Self-deploy streams a POSIX shell cross-compile if your hosts differ.
script over SSH, so a **Windows** host can be the manager or a local
endpoint but cannot be an automatic deploy target — put `clonetool.exe`
on it yourself and point `--remote-bin` at it.
## Windows
clonetool runs on Windows and can clone **physical drives and volumes**,
not just files:
```
# Whole disk to an image file
clonetool sync --source \\.\PhysicalDrive2 --dest D:\backup\disk2.img
# Image file back onto a disk (must not be larger than the disk)
clonetool sync --source D:\backup\disk2.img --dest \\.\PhysicalDrive2
# A single volume
clonetool sync --source \\.\E: --dest \\.\F:
```
- Raw-disk paths are `\\.\PhysicalDrive<n>` (whole disk) or `\\.\<X>:` (a
volume). `\\?\` also works. Forward slashes are accepted.
- **Run from an elevated (Administrator) console** to open a raw disk.
There is no `sudo` fallback on Windows; `--sudo` is ignored. A
permission error tells you to elevate.
- A raw-disk **destination** should have no mounted filesystem in use
(take the disk offline in Disk Management, or target a volume that
nothing else has open) — Windows blocks writes to a disk region owned by
a mounted volume. Reading a live disk as the **source** is fine.
- Raw-disk I/O must be sector-aligned. `--block-size` must be a multiple
of the drive's sector size (512 or 4096); the default 4M is. clonetool
handles the final partial block itself.
- Device size is read with `IOCTL_DISK_GET_LENGTH_INFO`.
## Usage ## Usage
@@ -129,43 +177,77 @@ clonetool sync --source \\.\E: --dest \\.\F:
clonetool sync --source LOC --dest LOC [options] clonetool sync --source LOC --dest LOC [options]
``` ```
`LOC` is either a local path (`/dev/sdb`, `./image.bin`) or `LOC` is one of:
`[user@]host:path` for a path reached over SSH.
- a local path — `/dev/sdb`, `./image.bin`, `C:\data\disk.img`, `\\.\PhysicalDrive0`
- `[user@]host:path` — reached over SSH
- `tcp://host:port/path` — a host running `clonetool listen` (see **Listen
mode** above); no SSH needed there
``` ```
# Same machine # Same machine
clonetool sync --source /dev/sda --dest /dev/sdb clonetool sync --source /dev/sda --dest /dev/sdb
# Whole disk to an image file
clonetool sync --source /dev/sda --dest /srv/sda.img
# Two remote machines, orchestrated from a third # Two remote machines, orchestrated from a third
clonetool sync --source db1:/dev/vdb --dest backup-host:/srv/db1.img clonetool sync --source db1:/dev/vdb --dest backup-host:/srv/db1.img
# Windows source with no SSH: it runs `clonetool listen --bind :9000 --password p`
clonetool sync --source tcp://winbox:9000/C:\data\disk.img \
--dest backup-host:/srv/win.img --password p
# Re-run any time; only changed blocks move # Re-run any time; only changed blocks move
clonetool sync --source db1:/dev/vdb --dest backup-host:/srv/db1.img clonetool sync --source db1:/dev/vdb --dest backup-host:/srv/db1.img
``` ```
Options: Options for `sync`:
| Flag | Default | Meaning | | Flag | Default | Meaning |
|---|---|---| |---|---|---|
| `--block-size` | `4M` | Block size (accepts `K`/`M`/`G` suffixes). | | `--block-size` | `4M` | Block size (accepts `K`/`M`/`G` suffixes). |
| `--job` | — | Optional label shown in progress/log output. | | `--job` | — | Optional label shown in progress/log output. |
| `--yes` | off | Don't prompt before shrinking an existing destination file. | | `--yes` | off | Don't prompt before shrinking an existing destination file. |
| `--sudo` | `auto` | Block-device privilege escalation: `auto` (on a permission error), `always`, or `never`. Remote elevation needs passwordless sudo. | | `--sudo` | `auto` | Block-device privilege escalation: `auto` (on a permission error), `always`, or `never`. Remote elevation needs passwordless sudo. No-op on Windows (run as Administrator instead). |
| `--deploy` | `true` | Copy this binary to remote hosts that lack a runnable `clonetool`. `--deploy=false` to disable. | | `--deploy` | `true` | Copy this binary to SSH hosts that lack a runnable `clonetool`. `--deploy=false` to disable. (Never applies to `tcp://` endpoints.) |
| `--connect-timeout` | `8` | SSH connect timeout (seconds) used for the push/pull direction probe. | | `--connect-timeout` | `8` | SSH/TLS connect timeout (seconds) used for the push/pull direction probe. |
| `--ssh` | `ssh` | ssh binary to use. | | `--ssh` | `ssh` | ssh binary to use. |
| `--ssh-opt` | — | Extra `-o OPT` passed to ssh (repeatable). | | `--ssh-opt` | — | Extra `-o OPT` passed to ssh (repeatable). |
| `--remote-bin` | `clonetool` | Path to clonetool on remote hosts. | | `--remote-bin` | `clonetool` | Path to clonetool on remote SSH 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). | | `--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). |
| `--password` | — | Shared password for a `tcp://` listen endpoint. |
| `--password-file` | — | Read the listen-endpoint password from a file (or set `CLONETOOL_PASSWORD`). |
`clonetool version` prints the binary's `GOOS/GOARCH` (used internally for Options for `listen`:
the self-deploy check).
| Flag | Default | Meaning |
|---|---|---|
| `--bind` | `:9000` | Address to listen on, e.g. `:9000` or `0.0.0.0:9000`. |
| `--password` | — | Shared password (or `--password-file`, or `CLONETOOL_PASSWORD`). Required. |
| `--password-file` | — | Read the shared password from a file. |
`clonetool version` prints the binary's `GOOS/GOARCH` and build timestamp,
e.g. `clonetool linux/amd64 build=2024-06-01T12:00:00Z` (the timestamp is
used internally for the self-deploy staleness check above).
The status line shows read throughput for both sides and write throughput
for the destination separately: `rd(src)` is the source reading and
comparing its blocks, `rd(dst)` is the destination fingerprinting its
current content (only while the trailing `scan H/M` segment is present),
and `wr(dst)` is the destination actually writing changed blocks.
## Caveats ## Caveats
- Block-device size detection is implemented on Linux (`BLKGETSIZE64`) and - Block-device sizing uses `BLKGETSIZE64` on Linux and
Windows (`IOCTL_DISK_GET_LENGTH_INFO`). On other systems only regular `IOCTL_DISK_GET_LENGTH_INFO` on Windows (`\\.\PhysicalDrive0`, `\\.\C:`).
files can be synced. On other platforms (e.g. macOS) only regular files are supported; a
device path there errors out.
- **Windows raw disks:** syncing a `\\.\PhysicalDrive*` requires running
clonetool as **Administrator**, and the disk should be offline/unmounted
(a live, mounted volume can refuse writes or give inconsistent reads).
Raw-disk I/O is sector-aligned automatically; `--block-size` must be a
multiple of the sector size. `--sudo` does nothing on Windows.
- If a destination path doesn't exist yet, it's created as a regular - 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 file — clonetool won't create device nodes, so double-check device
paths for typos before running. paths for typos before running.
+181 -102
View File
@@ -19,20 +19,23 @@ func cmdAgent(args []string) error {
role := fs.String("role", "", "control|sink|source-stream (internal)") role := fs.String("role", "", "control|sink|source-stream (internal)")
path := fs.String("path", "", "path to read/write") path := fs.String("path", "", "path to read/write")
size := fs.Int64("size", 0, "total sync size in bytes") size := fs.Int64("size", 0, "total sync size in bytes")
base := fs.Int64("base", 0, "byte offset the window starts at")
blockSize := fs.Int64("block-size", defaultBlockSize, "block size in bytes") blockSize := fs.Int64("block-size", defaultBlockSize, "block size in bytes")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
in := NewFrameReader(os.Stdin)
out := NewFrameWriter(os.Stdout)
switch *role { switch *role {
case "control": case roleControl:
return runControlAgent() return runControlAgent(in, out)
case "sink": case roleSink:
return runSinkRole(*path, *size, *blockSize) return runSinkRole(in, out, *path, *base, *size, *blockSize)
case "source-stream": case roleSourceStream:
return runSourceStreamRole(*path, *size, *blockSize) return runSourceStreamRole(in, out, *path, *base, *size, *blockSize)
default: default:
return fmt.Errorf("agent: unknown or missing --role %q (want control|sink|source-stream)", *role) return fmt.Errorf("agent: unknown or missing --role %q", *role)
} }
} }
@@ -41,10 +44,7 @@ func cmdAgent(args []string) error {
// manager over stdin/stdout with CtrlMsg frames. // manager over stdin/stdout with CtrlMsg frames.
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
func runControlAgent() error { func runControlAgent(in *FrameReader, out *FrameWriter) error {
in := NewFrameReader(os.Stdin)
out := NewFrameWriter(os.Stdout)
for { for {
typ, payload, err := in.ReadFrame() typ, payload, err := in.ReadFrame()
if err != nil { if err != nil {
@@ -125,37 +125,15 @@ func peerAgentCommand(req CtrlMsg, tailArgs []string) *exec.Cmd {
func runPushDriver(req CtrlMsg, out *FrameWriter) { func runPushDriver(req CtrlMsg, out *FrameWriter) {
tailArgs := []string{ tailArgs := []string{
"agent", "--role", "sink", "agent", "--role", roleSink,
"--path", req.PeerPath, "--path", req.PeerPath,
"--base", strconv.FormatInt(req.PeerBase, 10),
"--size", strconv.FormatInt(req.Size, 10), "--size", strconv.FormatInt(req.Size, 10),
"--block-size", strconv.FormatInt(req.BlockSize, 10), "--block-size", strconv.FormatInt(req.BlockSize, 10),
} }
cmd := peerAgentCommand(req, tailArgs) link, reason := openPeerLink(req, roleSink, tailArgs)
stdin, err := cmd.StdinPipe() if reason != "" {
if err != nil { _ = out.WriteJSON(CtrlMsg{Type: msgPushFailed, Reason: reason})
_ = 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 return
} }
@@ -165,26 +143,82 @@ func runPushDriver(req CtrlMsg, out *FrameWriter) {
// as they arrive, so the two scans and the transfer all overlap. // as they arrive, so the two scans and the transfer all overlap.
srcFile, err := os.Open(req.Path) srcFile, err := os.Open(req.Path)
if err != nil { if err != nil {
_ = cmd.Process.Kill() link.kill()
_ = cmd.Wait()
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: err.Error(), NeedPriv: isPermErr(err)}) _ = out.WriteJSON(CtrlMsg{Type: msgError, Message: err.Error(), NeedPriv: isPermErr(err)})
return return
} }
defer srcFile.Close() defer srcFile.Close()
if fatalErr := pumpPush(req, alignmentFor(req.Path), srcFile, fw, fr, out); fatalErr != nil { if fatalErr := pumpPush(req, alignmentFor(req.Path), srcFile, link.fw, link.fr, out); fatalErr != nil {
_ = cmd.Process.Kill() link.kill()
_ = cmd.Wait()
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fatalErr.Error()}) _ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fatalErr.Error()})
return return
} }
if err := cmd.Wait(); err != nil { if err := link.finish(); err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fmt.Sprintf("sink process: %v (stderr: %s)", err, stderrBuf.String())}) _ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fmt.Sprintf("sink: %v (stderr: %s)", err, link.stderr())})
return return
} }
_ = out.WriteJSON(CtrlMsg{Type: msgPushOK}) _ = out.WriteJSON(CtrlMsg{Type: msgPushOK})
} }
// peerLink is a framed, READY-confirmed connection to a peer helper (sink or
// source-stream), reached either by spawning it over ssh/local (spawnPeerLink)
// or by dialing a listening clonetool over TLS (dialPeerLink). It hides which
// mechanism is in use from the push/pull drivers.
type peerLink struct {
fw *FrameWriter
fr *FrameReader
stderr func() string // bounded tail of the peer's stderr, "" for a net peer
finish func() error // wait for clean completion (cmd.Wait / conn close)
kill func() // force teardown on error
closeSend func() error // close only the send side, to unblock a parked peer
}
// openPeerLink establishes a peerLink for role, dialing a listener when the
// request targets a net peer and otherwise spawning the helper. A non-empty
// reason means setup/handshake failed softly, so the caller reports
// <push|pull>_failed and the manager can try the other direction.
func openPeerLink(req CtrlMsg, role string, tailArgs []string) (*peerLink, string) {
if req.PeerNet {
return dialPeerLink(req, role)
}
return spawnPeerLink(req, tailArgs)
}
// spawnPeerLink runs the helper over ssh (or as a local subprocess) and waits
// for its READY handshake.
func spawnPeerLink(req CtrlMsg, tailArgs []string) (*peerLink, string) {
cmd := peerAgentCommand(req, tailArgs)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err.Error()
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err.Error()
}
stderrBuf := newLimitedBuffer(4096)
cmd.Stderr = stderrBuf
if err := cmd.Start(); err != nil {
return nil, fmt.Sprintf("start ssh: %v", err)
}
fr := NewFrameReader(stdout)
timeout := time.Duration(req.ConnectTimeoutSec+2) * time.Second
if err := waitReady(fr, timeout); err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
return nil, fmt.Sprintf("%v (remote stderr: %s)", err, stderrBuf.String())
}
return &peerLink{
fw: NewFrameWriter(stdin),
fr: fr,
stderr: stderrBuf.String,
finish: cmd.Wait,
kill: func() { _ = cmd.Process.Kill(); _ = cmd.Wait() },
closeSend: stdin.Close,
}, ""
}
func waitReady(fr *FrameReader, timeout time.Duration) error { func waitReady(fr *FrameReader, timeout time.Duration) error {
type result struct { type result struct {
typ frameType typ frameType
@@ -231,7 +265,7 @@ func pumpPush(req CtrlMsg, align int64, srcFile *os.File, fw *FrameWriter, fr *F
var pendingMu sync.Mutex var pendingMu sync.Mutex
pending := make(map[uint64]bool) pending := make(map[uint64]bool)
var copied, skipped int64 var copied, skipped, srcRead int64
var lastProgress time.Time var lastProgress time.Time
maybeProgress := func() { maybeProgress := func() {
if time.Since(lastProgress) < 500*time.Millisecond { if time.Since(lastProgress) < 500*time.Millisecond {
@@ -239,11 +273,24 @@ func pumpPush(req CtrlMsg, align int64, srcFile *os.File, fw *FrameWriter, fr *F
} }
lastProgress = time.Now() lastProgress = time.Now()
_ = out.WriteJSON(CtrlMsg{ _ = out.WriteJSON(CtrlMsg{
Type: msgProgress, Copied: atomic.LoadInt64(&copied), Skipped: atomic.LoadInt64(&skipped), Type: msgProgress, Phase: "xfer",
Copied: atomic.LoadInt64(&copied), Skipped: atomic.LoadInt64(&skipped), SrcRead: atomic.LoadInt64(&srcRead),
TotalBlocks: blockCount, TotalBlocks: blockCount,
}) })
} }
// lastActivityNano records the last moment either side made progress: a
// frame arriving from the sink (hashes, ACKs, relayed progress) or the
// source loop reading and comparing a block. The idle watchdog below
// treats a stall as fatal only when *neither* side has moved for a whole
// window — otherwise a check-only run, where every block matches and so no
// DATA frames and no ACKs are ever exchanged, trips the watchdog purely
// because the wire went quiet while the two scans churned through
// identical data.
var lastActivityNano int64
atomic.StoreInt64(&lastActivityNano, time.Now().UnixNano())
markActivity := func() { atomic.StoreInt64(&lastActivityNano, time.Now().UnixNano()) }
// hashCh is sized to hold every block hash so the reader below never // hashCh is sized to hold every block hash so the reader below never
// blocks handing hashes off (which, since ACKs share the same stream, // blocks handing hashes off (which, since ACKs share the same stream,
// would otherwise be able to deadlock against the in-flight-send limit). // would otherwise be able to deadlock against the in-flight-send limit).
@@ -274,6 +321,7 @@ func pumpPush(req CtrlMsg, align int64, srcFile *os.File, fw *FrameWriter, fr *F
} }
return return
} }
markActivity()
switch typ { switch typ {
case frameBlockHash: case frameBlockHash:
idx, h, derr := decodeBlockHashFrame(payload) idx, h, derr := decodeBlockHashFrame(payload)
@@ -315,8 +363,10 @@ func pumpPush(req CtrlMsg, align int64, srcFile *os.File, fw *FrameWriter, fr *F
go func() { go func() {
sendErrCh <- runSourceLoop(sourceLoopParams{ sendErrCh <- runSourceLoop(sourceLoopParams{
File: srcFile, Size: req.Size, BlockSize: req.BlockSize, Align: align, Hashes: hashCh, Out: fw, File: srcFile, Size: req.Size, BlockSize: req.BlockSize, Align: align, Hashes: hashCh, Out: fw,
OnSkip: func(uint64) { atomic.AddInt64(&skipped, 1); maybeProgress() }, OnRead: func(uint64) { markActivity(); atomic.AddInt64(&srcRead, 1); maybeProgress() },
OnSkip: func(uint64) { markActivity(); atomic.AddInt64(&skipped, 1); maybeProgress() },
OnSend: func(idx uint64, _ [32]byte) { OnSend: func(idx uint64, _ [32]byte) {
markActivity()
sem <- struct{}{} sem <- struct{}{}
pendingMu.Lock() pendingMu.Lock()
pending[idx] = true pending[idx] = true
@@ -332,7 +382,11 @@ func pumpPush(req CtrlMsg, align int64, srcFile *os.File, fw *FrameWriter, fr *F
sendCh := sendErrCh sendCh := sendErrCh
ackCh := ackEvents ackCh := ackEvents
var fatalErr error var fatalErr error
idle := time.NewTimer(120 * time.Second) // idleTimeout is how long *both* sides can be silent before the transfer
// is declared stalled. The timer may fire sooner; a fire is only fatal
// when lastActivityNano confirms nothing has moved for the whole window.
const idleTimeout = 120 * time.Second
idle := time.NewTimer(idleTimeout)
defer idle.Stop() defer idle.Stop()
for sendCh != nil || ackCh != nil { for sendCh != nil || ackCh != nil {
if !idle.Stop() { if !idle.Stop() {
@@ -341,7 +395,7 @@ func pumpPush(req CtrlMsg, align int64, srcFile *os.File, fw *FrameWriter, fr *F
default: default:
} }
} }
idle.Reset(120 * time.Second) idle.Reset(idleTimeout)
select { select {
case sendErr := <-sendCh: case sendErr := <-sendCh:
sendCh = nil sendCh = nil
@@ -367,7 +421,11 @@ func pumpPush(req CtrlMsg, align int64, srcFile *os.File, fw *FrameWriter, fr *F
maybeProgress() maybeProgress()
} }
case <-idle.C: case <-idle.C:
fatalErr = fmt.Errorf("timed out waiting for the sink") idleFor := time.Since(time.Unix(0, atomic.LoadInt64(&lastActivityNano)))
if idleFor < idleTimeout {
continue // progress somewhere within the window; re-arm and wait
}
fatalErr = fmt.Errorf("timed out waiting for the sink (no activity for %s)", idleFor.Round(time.Second))
} }
if fatalErr != nil { if fatalErr != nil {
break break
@@ -384,7 +442,11 @@ func pumpPush(req CtrlMsg, align int64, srcFile *os.File, fw *FrameWriter, fr *F
return fmt.Errorf("sink closed the connection with %d block write confirmation(s) still outstanding", n) return fmt.Errorf("sink closed the connection with %d block write confirmation(s) still outstanding", n)
} }
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, Copied: atomic.LoadInt64(&copied), Skipped: atomic.LoadInt64(&skipped), TotalBlocks: blockCount}) _ = out.WriteJSON(CtrlMsg{
Type: msgProgress, Phase: "xfer",
Copied: atomic.LoadInt64(&copied), Skipped: atomic.LoadInt64(&skipped), SrcRead: atomic.LoadInt64(&srcRead),
TotalBlocks: blockCount,
})
return nil return nil
} }
@@ -398,39 +460,19 @@ func pumpPush(req CtrlMsg, align int64, srcFile *os.File, fw *FrameWriter, fr *F
func runPullDriver(req CtrlMsg, out *FrameWriter) { func runPullDriver(req CtrlMsg, out *FrameWriter) {
tailArgs := []string{ tailArgs := []string{
"agent", "--role", "source-stream", "agent", "--role", roleSourceStream,
"--path", req.PeerPath, "--path", req.PeerPath,
"--base", strconv.FormatInt(req.PeerBase, 10),
"--size", strconv.FormatInt(req.Size, 10), "--size", strconv.FormatInt(req.Size, 10),
"--block-size", strconv.FormatInt(req.BlockSize, 10), "--block-size", strconv.FormatInt(req.BlockSize, 10),
} }
cmd := peerAgentCommand(req, tailArgs) link, reason := openPeerLink(req, roleSourceStream, tailArgs)
stdin, err := cmd.StdinPipe() if reason != "" {
if err != nil { _ = out.WriteJSON(CtrlMsg{Type: msgPullFailed, Reason: reason})
_ = 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 return
} }
fw := link.fw
fr := link.fr
// Handshake succeeded. Open the destination and fingerprint its current // Handshake succeeded. Open the destination and fingerprint its current
// content block by block, streaming each hash to the source stream the // content block by block, streaming each hash to the source stream the
@@ -438,8 +480,7 @@ func runPullDriver(req CtrlMsg, out *FrameWriter) {
// whatever it streams back into the same file concurrently. // whatever it streams back into the same file concurrently.
dstFile, err := os.OpenFile(req.Path, os.O_RDWR, 0) dstFile, err := os.OpenFile(req.Path, os.O_RDWR, 0)
if err != nil { if err != nil {
_ = cmd.Process.Kill() link.kill()
_ = cmd.Wait()
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: err.Error(), NeedPriv: isPermErr(err)}) _ = out.WriteJSON(CtrlMsg{Type: msgError, Message: err.Error(), NeedPriv: isPermErr(err)})
return return
} }
@@ -448,7 +489,7 @@ func runPullDriver(req CtrlMsg, out *FrameWriter) {
hashErrCh := make(chan error, 1) hashErrCh := make(chan error, 1)
go func() { go func() {
err := streamHashBlocks(dstFile, req.Size, req.BlockSize, align, func(bh blockHash) error { err := streamHashBlocks(dstFile, req.Base, req.Size, req.BlockSize, align, func(bh blockHash) error {
return fw.WriteFrame(frameBlockHash, encodeBlockHashFrame(bh.index, bh.hash)) return fw.WriteFrame(frameBlockHash, encodeBlockHashFrame(bh.index, bh.hash))
}, scanProgressEmitter(out)) }, scanProgressEmitter(out))
if err == nil { if err == nil {
@@ -457,35 +498,43 @@ func runPullDriver(req CtrlMsg, out *FrameWriter) {
if err != nil { if err != nil {
// Unblock the source stream (waiting for more hashes) so the // Unblock the source stream (waiting for more hashes) so the
// dest loop below can unwind instead of hanging. // dest loop below can unwind instead of hanging.
_ = stdin.Close() _ = link.closeSend()
} }
hashErrCh <- err hashErrCh <- err
}() }()
var written int64
var lastWritten time.Time
loopErr := runDestLoop(destLoopParams{ loopErr := runDestLoop(destLoopParams{
File: dstFile, BlockSize: req.BlockSize, Align: align, In: fr, File: dstFile, Base: req.Base, BlockSize: req.BlockSize, Align: align, In: fr,
OnCtrlMsg: func(m CtrlMsg) { OnCtrlMsg: func(m CtrlMsg) {
if m.Type == msgProgress { if m.Type == msgProgress {
_ = out.WriteJSON(m) _ = out.WriteJSON(m)
} }
}, },
OnWrite: func(uint64) {
written++
if time.Since(lastWritten) >= 500*time.Millisecond {
lastWritten = time.Now()
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, Phase: "write", Written: written})
}
},
}) })
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, Phase: "write", Written: written})
hashErr := <-hashErrCh hashErr := <-hashErrCh
if hashErr != nil { if hashErr != nil {
_ = cmd.Process.Kill() link.kill()
_ = cmd.Wait()
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fmt.Sprintf("scan destination: %v", hashErr), NeedPriv: isPermErr(hashErr)}) _ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fmt.Sprintf("scan destination: %v", hashErr), NeedPriv: isPermErr(hashErr)})
return return
} }
if loopErr != nil { if loopErr != nil {
_ = cmd.Process.Kill() link.kill()
_ = cmd.Wait()
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: loopErr.Error()}) _ = out.WriteJSON(CtrlMsg{Type: msgError, Message: loopErr.Error()})
return return
} }
if err := cmd.Wait(); err != nil { if err := link.finish(); err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fmt.Sprintf("source-stream process: %v (stderr: %s)", err, stderrBuf.String())}) _ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fmt.Sprintf("source-stream: %v (stderr: %s)", err, link.stderr())})
return return
} }
_ = out.WriteJSON(CtrlMsg{Type: msgPullOK}) _ = out.WriteJSON(CtrlMsg{Type: msgPullOK})
@@ -496,10 +545,7 @@ func runPullDriver(req CtrlMsg, out *FrameWriter) {
// destination host. Dumb write endpoint: verify+pwrite+ack per block. // destination host. Dumb write endpoint: verify+pwrite+ack per block.
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
func runSinkRole(path string, size, blockSize int64) error { func runSinkRole(in *FrameReader, out *FrameWriter, path string, base, size, blockSize int64) error {
out := NewFrameWriter(os.Stdout)
in := NewFrameReader(os.Stdin)
f, err := os.OpenFile(path, os.O_RDWR, 0) f, err := os.OpenFile(path, os.O_RDWR, 0)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "sink: open %s: %v\n", path, err) fmt.Fprintf(os.Stderr, "sink: open %s: %v\n", path, err)
@@ -519,11 +565,33 @@ func runSinkRole(path string, size, blockSize int64) error {
// block is always hashed before a write for it can arrive (the source // block is always hashed before a write for it can arrive (the source
// only sends after seeing that block's hash), so the two accesses to f // only sends after seeing that block's hash), so the two accesses to f
// never race on the same region. // never race on the same region.
// While the destination scan runs, emit a heartbeat on a fixed cadence
// even if streamHashBlocks is parked inside one slow ReadAt (a big /
// non-sparse / remote target). The push driver's idle watchdog counts any
// frame from the sink as activity, so this stops a legitimately slow scan
// from being declared a stalled sink during a check-only run, where there
// is no DATA/ACK traffic to feed the watchdog. A sink that has actually
// died stops heartbeating and its pipe closes, so the watchdog still fires.
scanDone := make(chan struct{})
go func() {
t := time.NewTicker(10 * time.Second)
defer t.Stop()
for {
select {
case <-scanDone:
return
case <-t.C:
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, Phase: "scan"})
}
}
}()
hashErrCh := make(chan error, 1) hashErrCh := make(chan error, 1)
go func() { go func() {
err := streamHashBlocks(f, size, blockSize, align, func(bh blockHash) error { err := streamHashBlocks(f, base, size, blockSize, align, func(bh blockHash) error {
return out.WriteFrame(frameBlockHash, encodeBlockHashFrame(bh.index, bh.hash)) return out.WriteFrame(frameBlockHash, encodeBlockHashFrame(bh.index, bh.hash))
}, scanProgressEmitter(out)) }, scanProgressEmitter(out))
close(scanDone)
if err == nil { if err == nil {
err = out.WriteFrame(frameHashDone, nil) err = out.WriteFrame(frameHashDone, nil)
} else { } else {
@@ -533,7 +601,19 @@ func runSinkRole(path string, size, blockSize int64) error {
hashErrCh <- err hashErrCh <- err
}() }()
loopErr := runDestLoop(destLoopParams{File: f, BlockSize: blockSize, Align: align, In: in, AckOut: out}) var written int64
var lastWritten time.Time
loopErr := runDestLoop(destLoopParams{
File: f, BlockSize: blockSize, Align: align, In: in, AckOut: out,
OnWrite: func(uint64) {
written++
if time.Since(lastWritten) >= 500*time.Millisecond {
lastWritten = time.Now()
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, Phase: "write", Written: written})
}
},
})
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, Phase: "write", Written: written})
if hashErr := <-hashErrCh; hashErr != nil { if hashErr := <-hashErrCh; hashErr != nil {
return hashErr return hashErr
} }
@@ -547,10 +627,7 @@ func runSinkRole(path string, size, blockSize int64) error {
// writing straight to its own stdout. // writing straight to its own stdout.
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
func runSourceStreamRole(path string, size, blockSize int64) error { func runSourceStreamRole(in *FrameReader, out *FrameWriter, path string, base, size, blockSize int64) error {
out := NewFrameWriter(os.Stdout)
in := NewFrameReader(os.Stdin)
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "source-stream: open %s: %v\n", path, err) fmt.Fprintf(os.Stderr, "source-stream: open %s: %v\n", path, err)
@@ -599,21 +676,23 @@ func runSourceStreamRole(path string, size, blockSize int64) error {
} }
}() }()
var copied, skipped int64 var copied, skipped, srcRead int64
var lastProgress time.Time var lastProgress time.Time
maybeProgress := func() { maybeProgress := func() {
if time.Since(lastProgress) < 500*time.Millisecond { if time.Since(lastProgress) < 500*time.Millisecond {
return return
} }
lastProgress = time.Now() lastProgress = time.Now()
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, Copied: copied, Skipped: skipped, TotalBlocks: blockCount}) _ = out.WriteJSON(CtrlMsg{Type: msgProgress, Phase: "xfer", Copied: copied, Skipped: skipped, SrcRead: srcRead, TotalBlocks: blockCount})
} }
loopErr := runSourceLoop(sourceLoopParams{ loopErr := runSourceLoop(sourceLoopParams{
File: f, Size: size, BlockSize: blockSize, Align: align, Hashes: hashCh, Out: out, File: f, Size: size, BlockSize: blockSize, Align: align, Hashes: hashCh, Out: out,
OnRead: func(uint64) { srcRead++; maybeProgress() },
OnSkip: func(uint64) { skipped++; maybeProgress() }, OnSkip: func(uint64) { skipped++; maybeProgress() },
OnSend: func(uint64, [32]byte) { copied++; maybeProgress() }, OnSend: func(uint64, [32]byte) { copied++; maybeProgress() },
}) })
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, Phase: "xfer", Copied: copied, Skipped: skipped, SrcRead: srcRead, TotalBlocks: blockCount})
if readErr := <-readErrCh; readErr != nil { if readErr := <-readErrCh; readErr != nil {
return readErr return readErr
} }
+4 -3
View File
@@ -4,18 +4,19 @@ set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
mkdir -p dist mkdir -p dist
build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)
build() { build() {
os=$1 arch=$2 out=$3 os=$1 arch=$2 out=$3
echo "==> $os/$arch -> dist/$out" echo "==> $os/$arch -> dist/$out"
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags "-s -w" -o "dist/$out" . CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \
-ldflags "-s -w -X main.buildTime=$build_time" -o "dist/$out" .
} }
build linux amd64 clonetool-linux-amd64 build linux amd64 clonetool-linux-amd64
build linux arm64 clonetool-linux-arm64 build linux arm64 clonetool-linux-arm64
build windows amd64 clonetool-windows-amd64.exe build windows amd64 clonetool-windows-amd64.exe
build windows arm64 clonetool-windows-arm64.exe build windows arm64 clonetool-windows-arm64.exe
build darwin amd64 clonetool-darwin-amd64
build darwin arm64 clonetool-darwin-arm64
echo "done:" echo "done:"
ls -la dist/ ls -la dist/
+25
View File
@@ -6,8 +6,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"os" "os"
"os/exec" "os/exec"
"strconv"
"time"
) )
// errNeedPriv is wrapped into the error from a control-agent call that // errNeedPriv is wrapped into the error from a control-agent call that
@@ -187,6 +190,9 @@ func (c *Controller) connectAndPump(req CtrlMsg, cb transferCallbacks, okType, f
func (c *Controller) Close() error { func (c *Controller) Close() error {
_ = c.fw.WriteJSON(CtrlMsg{Type: msgClose}) _ = c.fw.WriteJSON(CtrlMsg{Type: msgClose})
_ = c.in.Close() _ = c.in.Close()
if c.cmd == nil {
return nil // net transport: closing the connection is enough
}
err := c.cmd.Wait() err := c.cmd.Wait()
if err != nil { if err != nil {
var exitErr *exec.ExitError var exitErr *exec.ExitError
@@ -197,3 +203,22 @@ func (c *Controller) Close() error {
} }
return nil return nil
} }
// startNetworkController connects to a listening clonetool (spec must be a net
// endpoint) over TLS, authenticates with password, and requests the control
// role. The returned Controller behaves like an ssh/local one for the manager;
// it has no child process (cmd == nil).
func startNetworkController(spec Spec, tag, password string, connectTimeoutSec int) (*Controller, error) {
_ = tag // errors are prefixed by the caller (source:/dest:)
addr := net.JoinHostPort(spec.Host, strconv.Itoa(spec.Port))
timeout := time.Duration(connectTimeoutSec+2) * time.Second
conn, fr, fw, err := dialAuth(addr, password, timeout)
if err != nil {
return nil, fmt.Errorf("connect to listener %s: %w", addr, err)
}
if err := fw.WriteJSON(CtrlMsg{Type: msgRole, Role: roleControl}); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("request control role on %s: %w", addr, err)
}
return &Controller{tag: tag, in: conn, fw: fw, fr: fr}, nil
}
+43 -10
View File
@@ -19,8 +19,12 @@ type CtrlMsg struct {
// connect_push (-> source agent) / connect_pull (-> dest agent) // connect_push (-> source agent) / connect_pull (-> dest agent)
PeerHost string `json:"peerHost,omitempty"` PeerHost string `json:"peerHost,omitempty"`
PeerUser string `json:"peerUser,omitempty"` PeerUser string `json:"peerUser,omitempty"`
PeerPort int `json:"peerPort,omitempty"`
PeerPath string `json:"peerPath,omitempty"` PeerPath string `json:"peerPath,omitempty"`
// Base/PeerBase shift every block offset on the local/peer side. Zero on
// both sides (the whole-file case) is what `sync` uses; the sink and
// source-stream roles still accept a non-zero base for a windowed copy.
Base int64 `json:"base,omitempty"`
PeerBase int64 `json:"peerBase,omitempty"`
// PeerLocal is set when both source and dest are local to the manager, // 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 // 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 // the sink/source-stream helper as a plain local subprocess instead of
@@ -34,23 +38,41 @@ type CtrlMsg struct {
// Sudo tells a push/pull driver to run the peer helper it spawns // Sudo tells a push/pull driver to run the peer helper it spawns
// (sink / source-stream) under "sudo -n" (remote) or "sudo" (local). // (sink / source-stream) under "sudo -n" (remote) or "sudo" (local).
Sudo bool `json:"sudo,omitempty"` Sudo bool `json:"sudo,omitempty"`
// Peer listen endpoint: when PeerNet is set, the push/pull driver reaches
// the peer by dialing a listening clonetool over TLS (authenticated with
// PeerPassword) instead of spawning it over ssh. PeerHost carries the
// listener host in that case; PeerPort its port. PeerPassword travels only
// over the manager<->control channel (ssh-encrypted or TLS), never argv.
PeerNet bool `json:"peerNet,omitempty"`
PeerPort int `json:"peerPort,omitempty"`
PeerPassword string `json:"peerPassword,omitempty"`
// Role is the endpoint requested on a listen connection's role-request
// frame (control | sink | source-stream), see nettransport.go / listen.go.
Role string `json:"role,omitempty"`
// failure/error detail // failure/error detail
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
// progress. Scan and transfer now overlap, so a run emits both "scan" // progress. Scan, source read and destination write all overlap and are
// messages (Hashed/TotalBlocks) and transfer messages (Copied/Skipped) // tracked independently, so a run emits several kinds of "progress"
// interleaved; the printer keeps the latest of each. // message distinguished by Phase, interleaved; the printer keeps the
Phase string `json:"phase,omitempty"` // "scan" while hashing the destination; empty during transfer // latest of each:
// "scan" - Hashed/TotalBlocks: destination read throughput while it
// fingerprints its current content.
// "xfer" - Copied/Skipped/SrcRead/TotalBlocks: source read
// throughput as it reads and compares each block (Skipped:
// matched the destination; Copied: differed and was sent).
// "write" - Written: destination write throughput as changed blocks
// are actually applied.
Phase string `json:"phase,omitempty"`
Hashed int64 `json:"hashed,omitempty"` Hashed int64 `json:"hashed,omitempty"`
Copied int64 `json:"copied,omitempty"` Copied int64 `json:"copied,omitempty"`
Skipped int64 `json:"skipped,omitempty"` Skipped int64 `json:"skipped,omitempty"`
SrcRead int64 `json:"srcRead,omitempty"`
Written int64 `json:"written,omitempty"`
TotalBlocks int64 `json:"totalBlocks,omitempty"` TotalBlocks int64 `json:"totalBlocks,omitempty"`
BytesCopied int64 `json:"bytesCopied,omitempty"`
// log
Level string `json:"level,omitempty"`
} }
const ( const (
@@ -65,8 +87,19 @@ const (
msgPullOK = "pull_ok" msgPullOK = "pull_ok"
msgPullFailed = "pull_failed" msgPullFailed = "pull_failed"
msgProgress = "progress" msgProgress = "progress"
msgLog = "log"
msgError = "error" msgError = "error"
msgClose = "close" msgClose = "close"
msgBye = "bye" msgBye = "bye"
// msgRole is the first frame on a listen connection after authentication:
// it names the role to run (control | sink | source-stream) and, for the
// one-shot data roles, the path/window to operate on.
msgRole = "role"
msgRoleOK = "role_ok"
msgRoleErr = "role_err"
)
const (
roleControl = "control"
roleSink = "sink"
roleSourceStream = "source-stream"
) )
+23 -6
View File
@@ -15,26 +15,43 @@ import (
// no install, no root. // no install, no root.
const deployedRemoteBin = ".clonetool/bin/clonetool" const deployedRemoteBin = ".clonetool/bin/clonetool"
// resolveRemoteBin returns the path to a runnable clonetool on spec's host. // resolveRemoteBin returns the path to a clonetool on spec's host that is
// It checks the configured --remote-bin first, then a copy deployed by an // both runnable and the same build as this binary (compared via the
// earlier run, and finally streams this binary over and verifies it runs. // "build=" tag in `version` output, see versionString/remoteBuildTag — an
// architecture mismatch alone, expected when cross-compiled, doesn't count
// as a mismatch). It checks the configured --remote-bin first, then a copy
// deployed by an earlier run, and finally streams this binary over and
// verifies it runs. With --deploy=false it can't correct a stale or missing
// binary, so it uses --remote-bin anyway if merely out of date (only a
// non-runnable one is still an error).
func resolveRemoteBin(cfg *SyncConfig, spec Spec, tag string) (string, error) { func resolveRemoteBin(cfg *SyncConfig, spec Spec, tag string) (string, error) {
if _, code, err := runRemote(cfg, spec, cfg.RemoteBin, "version"); err != nil { localTag := remoteBuildTag(versionString())
if out, code, err := runRemote(cfg, spec, cfg.RemoteBin, "version"); err != nil {
return "", fmt.Errorf("%s: cannot reach %s over ssh: %w", tag, spec.Host, err) return "", fmt.Errorf("%s: cannot reach %s over ssh: %w", tag, spec.Host, err)
} else if code == 0 { } else if code == 0 {
if remoteBuildTag(out) == localTag {
return cfg.RemoteBin, nil return cfg.RemoteBin, nil
} }
if !cfg.Deploy {
fmt.Fprintf(os.Stderr, "%s: %s on %s reports %q, a different build than this one (%s); continuing anyway (--deploy=false)\n",
tag, cfg.RemoteBin, spec.Host, firstLine(out), versionString())
return cfg.RemoteBin, nil
}
fmt.Fprintf(os.Stderr, "%s: %s on %s reports %q, a different build than this one (%s); looking for an up-to-date copy ...\n",
tag, cfg.RemoteBin, spec.Host, firstLine(out), versionString())
}
if !cfg.Deploy { if !cfg.Deploy {
return "", fmt.Errorf("%s: %q is not runnable on %s and --deploy=false; "+ return "", fmt.Errorf("%s: %q is not runnable on %s and --deploy=false; "+
"install clonetool there or pass --remote-bin", tag, cfg.RemoteBin, spec.Host) "install clonetool there or pass --remote-bin", tag, cfg.RemoteBin, spec.Host)
} }
if _, code, err := runRemote(cfg, spec, deployedRemoteBin, "version"); err == nil && code == 0 { if out, code, err := runRemote(cfg, spec, deployedRemoteBin, "version"); err == nil && code == 0 && remoteBuildTag(out) == localTag {
return deployedRemoteBin, nil return deployedRemoteBin, nil
} }
fmt.Fprintf(os.Stderr, "%s: no runnable clonetool on %s; copying this binary there ...\n", tag, spec.Host) fmt.Fprintf(os.Stderr, "%s: no up-to-date clonetool on %s; copying this binary there ...\n", tag, spec.Host)
if err := deployBinary(cfg, spec); err != nil { if err := deployBinary(cfg, spec); err != nil {
return "", fmt.Errorf("%s: copy clonetool to %s: %w", tag, spec.Host, err) return "", fmt.Errorf("%s: copy clonetool to %s: %w", tag, spec.Host, err)
} }
+11 -12
View File
@@ -13,20 +13,22 @@ type PathInfo struct {
Size int64 Size int64
} }
// alignmentFor returns the offset/length alignment a path's handle requires
// for positioned reads and writes. On Linux this is always 1 (block devices
// accept buffered pread/pwrite at any alignment); on Windows a raw disk handle
// requires sector-aligned I/O, so deviceAlignment reports the sector size.
func alignmentFor(path string) int64 { return deviceAlignment(path) }
func statPath(path string) (PathInfo, error) { func statPath(path string) (PathInfo, error) {
// A raw disk handle (Windows \\.\PhysicalDrive0, \\.\C:) is not something // Windows raw-disk paths (\\.\PhysicalDrive0) aren't reported as devices by
// os.Stat can describe, so ask the platform for its size directly. // os.Stat, so recognize them by name first and size them via ioctl.
if isDevicePath(path) { if isRawDevice(path) {
size, err := blockDeviceSize(path) sz, err := blockDeviceSize(path)
if err != nil { if err != nil {
if os.IsNotExist(err) {
return PathInfo{Exists: false}, nil
}
return PathInfo{}, fmt.Errorf("stat device %s: %w", path, err) return PathInfo{}, fmt.Errorf("stat device %s: %w", path, err)
} }
return PathInfo{Exists: true, IsDevice: true, Size: size}, nil return PathInfo{Exists: true, IsDevice: true, Size: sz}, nil
} }
fi, err := os.Stat(path) fi, err := os.Stat(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -63,9 +65,6 @@ func prepareDest(path string, targetSize int64) error {
} }
return nil return nil
} }
if isDevicePath(path) && !info.Exists {
return fmt.Errorf("destination device %s not found", path)
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644) f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil { if err != nil {
return fmt.Errorf("open/create %s: %w", path, err) return fmt.Errorf("open/create %s: %w", path, err)
+13
View File
@@ -26,3 +26,16 @@ func blockDeviceSize(path string) (int64, error) {
} }
return int64(size), nil return int64(size), nil
} }
// isRawDevice reports paths that must be sized via ioctl rather than os.Stat.
// On Linux block devices are recognized through os.ModeDevice instead, so this
// is always false.
func isRawDevice(string) bool { return false }
// deviceAlignment is 1 on Linux: buffered pread/pwrite on a block device accept
// any alignment.
func deviceAlignment(string) int64 { return 1 }
// canElevate reports whether a permission failure opening a device is worth
// retrying under sudo (see --sudo).
func canElevate() bool { return true }
+16 -3
View File
@@ -2,8 +2,21 @@
package main package main
import "fmt" import (
"fmt"
"runtime"
)
func blockDeviceSize(path string) (int64, error) { // Stubs so clonetool still builds (and runs for regular-file syncs) on
return 0, fmt.Errorf("block device size detection is only implemented on linux and windows (got path %s)", path) // platforms without raw block-device support wired up (e.g. macOS for local
// development). Block-device paths error out clearly.
func isRawDevice(string) bool { return false }
func blockDeviceSize(string) (int64, error) {
return 0, fmt.Errorf("block devices are not supported on %s", runtime.GOOS)
} }
func deviceAlignment(string) int64 { return 1 }
func canElevate() bool { return true }
-18
View File
@@ -1,18 +0,0 @@
//go:build !windows
package main
// isDevicePath reports whether a path must be treated as a raw device from
// its string form alone. On unix, os.Stat's mode bits already identify
// device nodes accurately (symlinks, non-standard locations and all), so
// this is always false and statPath relies on those instead.
func isDevicePath(string) bool { return false }
// alignmentFor returns the offset/length alignment a path's handle requires
// for positioned reads and writes. Unix block devices accept ordinary
// buffered pread/pwrite at any alignment, so there is nothing to round to.
func alignmentFor(string) int64 { return 1 }
// canElevate reports whether a permission failure opening a device is worth
// retrying under `sudo` (see --sudo). Always true on unix.
func canElevate() bool { return true }
+64 -65
View File
@@ -3,97 +3,96 @@
package main package main
import ( import (
"fmt" "encoding/binary"
"os"
"strings" "strings"
"syscall" "syscall"
"unsafe"
) )
// isDevicePath recognises the Win32 device namespaces clonetool can sync // IOCTL codes (winioctl.h):
// against by string form: \\.\PhysicalDrive0 (a whole disk) and \\.\C: (a //
// volume). Both the \\.\ and \\?\ prefixes, and their forward-slash // IOCTL_DISK_GET_LENGTH_INFO -> GET_LENGTH_INFORMATION { LARGE_INTEGER Length }
// spellings, are accepted. os.Stat can't describe these paths, so statPath // IOCTL_DISK_GET_DRIVE_GEOMETRY -> DISK_GEOMETRY (BytesPerSector at offset 20)
// keys off this instead of mode bits on Windows.
func isDevicePath(path string) bool {
p := strings.ReplaceAll(path, "/", `\`)
return strings.HasPrefix(p, `\\.\`) || strings.HasPrefix(p, `\\?\`)
}
// canElevate is false on Windows: there is no `sudo` equivalent to re-exec
// under, so a permission failure opening a raw disk is reported with a hint
// to run from an elevated console instead of being retried.
func canElevate() bool { return false }
const ( const (
ioctlDiskGetLengthInfo = 0x0007405C // IOCTL_DISK_GET_LENGTH_INFO ioctlDiskGetLengthInfo = 0x0007405C
ioctlDiskGetDriveGeometry = 0x00070000 // IOCTL_DISK_GET_DRIVE_GEOMETRY ioctlDiskGetDriveGeometry = 0x00070000
) )
// diskGeometry mirrors DISK_GEOMETRY (24 bytes; Cylinders is a LARGE_INTEGER). // isRawDevice recognizes the raw-disk paths that os.Stat won't report as
type diskGeometry struct { // devices: \\.\PhysicalDrive<N> and the volume form \\.\X:.
Cylinders int64 func isRawDevice(path string) bool {
MediaType uint32 l := strings.ToLower(path)
TracksPerCylinder uint32 if strings.HasPrefix(l, `\\.\physicaldrive`) {
SectorsPerTrack uint32 return true
BytesPerSector uint32 }
// \\.\C: — a single drive letter behind the device namespace.
if strings.HasPrefix(path, `\\.\`) && len(path) == 6 && path[5] == ':' {
return true
}
return false
} }
// openDeviceHandle opens path for a metadata ioctl only: zero access rights // openDevice opens a raw device handle with the sharing a live disk needs.
// (which need no privilege and don't require the volume to be unlocked) and func openDevice(path string, write bool) (syscall.Handle, error) {
// shared read/write so it doesn't disturb a mounted filesystem.
func openDeviceHandle(path string) (syscall.Handle, error) {
p, err := syscall.UTF16PtrFromString(path) p, err := syscall.UTF16PtrFromString(path)
if err != nil { if err != nil {
return syscall.InvalidHandle, err return syscall.InvalidHandle, err
} }
return syscall.CreateFile(p, 0, access := uint32(syscall.GENERIC_READ)
if write {
access |= syscall.GENERIC_WRITE
}
return syscall.CreateFile(p, access,
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE, nil, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE, nil,
syscall.OPEN_EXISTING, 0, 0) syscall.OPEN_EXISTING, 0, 0)
} }
// blockDeviceSize returns the byte length of a physical drive or volume via
// DeviceIoControl(IOCTL_DISK_GET_LENGTH_INFO).
func blockDeviceSize(path string) (int64, error) { func blockDeviceSize(path string) (int64, error) {
h, err := openDeviceHandle(path) h, err := openDevice(path, false)
if err != nil { if err != nil {
return 0, &os.PathError{Op: "open", Path: path, Err: err} return 0, err
} }
defer syscall.CloseHandle(h) defer syscall.CloseHandle(h)
var length int64 // GET_LENGTH_INFORMATION is a single LARGE_INTEGER var out [8]byte
var ret uint32 var ret uint32
err = syscall.DeviceIoControl(h, ioctlDiskGetLengthInfo, if err := syscall.DeviceIoControl(h, ioctlDiskGetLengthInfo, nil, 0,
nil, 0, &out[0], uint32(len(out)), &ret, nil); err != nil {
(*byte)(unsafe.Pointer(&length)), uint32(unsafe.Sizeof(length)), return 0, err
&ret, nil)
if err != nil {
return 0, fmt.Errorf("IOCTL_DISK_GET_LENGTH_INFO %s: %w", path, err)
} }
return length, nil return int64(binary.LittleEndian.Uint64(out[:])), nil
} }
// alignmentFor returns the sector size a raw disk handle's positioned reads // deviceSectorSize queries the disk's physical sector size (bytes-per-sector),
// and writes must be aligned to; 1 for an ordinary file path. It falls back // falling back to 512 if the geometry can't be read.
// to 512 if the geometry query fails. func deviceSectorSize(path string) int64 {
func alignmentFor(path string) int64 { h, err := openDevice(path, false)
if !isDevicePath(path) { if err != nil {
return 512
}
defer syscall.CloseHandle(h)
var geom [24]byte
var ret uint32
if err := syscall.DeviceIoControl(h, ioctlDiskGetDriveGeometry, nil, 0,
&geom[0], uint32(len(geom)), &ret, nil); err != nil {
return 512
}
bps := binary.LittleEndian.Uint32(geom[20:24])
if bps == 0 {
return 512
}
return int64(bps)
}
// deviceAlignment reports the sector alignment a raw disk handle requires;
// regular files need none.
func deviceAlignment(path string) int64 {
if isRawDevice(path) {
return deviceSectorSize(path)
}
return 1 return 1
} }
h, err := openDeviceHandle(path)
if err != nil {
return 512
}
defer syscall.CloseHandle(h)
var g diskGeometry // canElevate is false on Windows: there is no sudo. Raw-disk access requires
var ret uint32 // running clonetool as Administrator, which the operator arranges outside the tool.
err = syscall.DeviceIoControl(h, ioctlDiskGetDriveGeometry, func canElevate() bool { return false }
nil, 0,
(*byte)(unsafe.Pointer(&g)), uint32(unsafe.Sizeof(g)),
&ret, nil)
if err != nil || g.BytesPerSector == 0 {
return 512
}
return int64(g.BytesPerSector)
}
+122
View File
@@ -0,0 +1,122 @@
package main
import (
"crypto/tls"
"flag"
"fmt"
"net"
"os"
"strings"
"time"
)
// cmdListen runs clonetool in listen mode: it waits for inbound TLS
// connections (see nettransport.go), authenticates each with the shared
// password, and serves whatever role the peer requests (control for the
// manager's stat calls, source-stream/sink for the data transfer). This is the
// no-ssh path for a host — typically a Windows source — that this machine and
// the destination connect *to* instead of being reached over ssh.
func cmdListen(args []string) error {
fs := flag.NewFlagSet("listen", flag.ContinueOnError)
bind := fs.String("bind", ":9000", "address to listen on, e.g. :9000 or 0.0.0.0:9000")
password := fs.String("password", "", "shared password (or set CLONETOOL_PASSWORD, or --password-file)")
passwordFile := fs.String("password-file", "", "read the shared password from this file")
if err := fs.Parse(args); err != nil {
return err
}
pw, err := resolvePassword(*password, *passwordFile)
if err != nil {
return err
}
if pw == "" {
return fmt.Errorf("listen: a password is required (--password, --password-file, or CLONETOOL_PASSWORD)")
}
tlsCfg, err := newServerTLSConfig()
if err != nil {
return fmt.Errorf("listen: tls setup: %w", err)
}
ln, err := net.Listen("tcp", *bind)
if err != nil {
return fmt.Errorf("listen on %s: %w", *bind, err)
}
defer ln.Close()
fmt.Fprintf(os.Stderr, "clonetool listening on %s (%s); press Ctrl-C to stop\n", ln.Addr(), versionString())
for {
raw, err := ln.Accept()
if err != nil {
return fmt.Errorf("accept: %w", err)
}
go serveListenConn(raw, tlsCfg, pw)
}
}
// serveListenConn handles one accepted connection: TLS + password auth, then a
// single role request that it dispatches. Each connection is one role; the
// manager and the destination open separate connections.
func serveListenConn(raw net.Conn, tlsCfg *tls.Config, password string) {
remote := raw.RemoteAddr().String()
defer raw.Close()
conn := tls.Server(raw, tlsCfg)
const handshakeTimeout = 30 * time.Second
_ = conn.SetDeadline(time.Now().Add(handshakeTimeout))
if err := conn.Handshake(); err != nil {
fmt.Fprintf(os.Stderr, "listen: %s: tls handshake: %v\n", remote, err)
return
}
fr, fw, err := serverAuth(conn, password, handshakeTimeout)
if err != nil {
fmt.Fprintf(os.Stderr, "listen: %s: %v\n", remote, err)
return
}
_ = conn.SetDeadline(time.Time{}) // transfers set their own pacing
req, err := readCtrlFrame(fr)
if err != nil {
fmt.Fprintf(os.Stderr, "listen: %s: read role request: %v\n", remote, err)
return
}
if req.Type != msgRole {
_ = fw.WriteJSON(CtrlMsg{Type: msgRoleErr, Message: fmt.Sprintf("expected a role request, got %q", req.Type)})
return
}
if err := dispatchRole(req, fr, fw, remote); err != nil {
fmt.Fprintf(os.Stderr, "listen: %s: role %s: %v\n", remote, req.Role, err)
}
}
func dispatchRole(req CtrlMsg, fr *FrameReader, fw *FrameWriter, remote string) error {
switch req.Role {
case roleControl:
fmt.Fprintf(os.Stderr, "listen: %s: control session\n", remote)
return runControlAgent(fr, fw)
case roleSourceStream:
fmt.Fprintf(os.Stderr, "listen: %s: source-stream %s\n", remote, req.Path)
return runSourceStreamRole(fr, fw, req.Path, req.Base, req.Size, req.BlockSize)
case roleSink:
fmt.Fprintf(os.Stderr, "listen: %s: sink %s\n", remote, req.Path)
return runSinkRole(fr, fw, req.Path, req.Base, req.Size, req.BlockSize)
default:
_ = fw.WriteJSON(CtrlMsg{Type: msgRoleErr, Message: fmt.Sprintf("unknown role %q", req.Role)})
return fmt.Errorf("unknown role %q", req.Role)
}
}
// resolvePassword picks the shared password from, in order: --password-file, an
// inline --password, then the CLONETOOL_PASSWORD environment variable. A
// trailing newline in a password file is stripped.
func resolvePassword(inline, file string) (string, error) {
if file != "" {
b, err := os.ReadFile(file)
if err != nil {
return "", fmt.Errorf("read password file: %w", err)
}
return strings.TrimRight(string(b), "\r\n"), nil
}
if inline != "" {
return inline, nil
}
return os.Getenv("CLONETOOL_PASSWORD"), nil
}
+27 -4
View File
@@ -4,7 +4,6 @@ import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"runtime"
) )
func main() { func main() {
@@ -17,10 +16,12 @@ func main() {
switch os.Args[1] { switch os.Args[1] {
case "sync": case "sync":
err = cmdSync(os.Args[2:]) err = cmdSync(os.Args[2:])
case "listen":
err = cmdListen(os.Args[2:])
case "agent": case "agent":
err = cmdAgent(os.Args[2:]) err = cmdAgent(os.Args[2:])
case "version", "--version": case "version", "--version":
fmt.Printf("clonetool %s/%s\n", runtime.GOOS, runtime.GOARCH) fmt.Println(versionString())
return return
case "-h", "--help", "help": case "-h", "--help", "help":
usage() usage()
@@ -40,13 +41,19 @@ func usage() {
Usage: Usage:
clonetool sync --source LOC --dest LOC [options] clonetool sync --source LOC --dest LOC [options]
clonetool listen --bind ADDR --password SECRET
clonetool version clonetool version
clonetool agent --role {control|sink|source-stream} ... (internal, spawned automatically) 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. LOC is one of:
a local path (e.g. /dev/sda, ./image.bin, C:\data\disk.img, \\.\PhysicalDrive0)
[user@]host:path a path reached over SSH
tcp://host:port/path a clonetool started with "listen" on that host (no SSH needed)
Source, destination, and the machine running "sync" (the manager) may all be Source, destination, and the machine running "sync" (the manager) may all be
different machines: the manager only orchestrates, it never reads or writes different machines: the manager only orchestrates, it never reads or writes
a single block itself. a single block itself. A tcp:// source is a host running "clonetool listen"
(e.g. a Windows box with no SSH server); the destination pulls from it directly.
clonetool keeps no state between runs: every sync re-reads and re-hashes both clonetool keeps no state between runs: every sync re-reads and re-hashes both
the source and the destination and transfers only the blocks that differ. the source and the destination and transfers only the blocks that differ.
@@ -65,6 +72,14 @@ Options for sync:
--remote-bin PATH path to clonetool on remote hosts (default "clonetool") --remote-bin PATH path to clonetool on remote hosts (default "clonetool")
--manager-host HOST address peers should use to reach this machine, when --manager-host HOST address peers should use to reach this machine, when
source or dest has no host part (defaults to the local hostname) source or dest has no host part (defaults to the local hostname)
--password SECRET shared password for a tcp:// listen endpoint
--password-file PATH read the listen-endpoint password from a file
(or set CLONETOOL_PASSWORD)
Options for listen:
--bind ADDR address to listen on (default ":9000")
--password SECRET shared password (or --password-file, or CLONETOOL_PASSWORD)
--password-file PATH read the shared password from a file
`) `)
} }
@@ -89,12 +104,19 @@ func cmdSync(args []string) error {
sshBin := fs.String("ssh", "ssh", "ssh binary") sshBin := fs.String("ssh", "ssh", "ssh binary")
remoteBin := fs.String("remote-bin", "clonetool", "clonetool path on remote hosts") remoteBin := fs.String("remote-bin", "clonetool", "clonetool path on remote hosts")
managerHost := fs.String("manager-host", "", "address peers use to reach this machine") managerHost := fs.String("manager-host", "", "address peers use to reach this machine")
password := fs.String("password", "", "shared password for a tcp:// listen endpoint")
passwordFile := fs.String("password-file", "", "read the listen-endpoint password from a file")
var sshOpts stringSlice var sshOpts stringSlice
fs.Var(&sshOpts, "ssh-opt", `extra "-o OPT" passed to ssh (repeatable)`) fs.Var(&sshOpts, "ssh-opt", `extra "-o OPT" passed to ssh (repeatable)`)
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
pw, err := resolvePassword(*password, *passwordFile)
if err != nil {
return err
}
if *source == "" || *dest == "" { if *source == "" || *dest == "" {
fs.Usage() fs.Usage()
return fmt.Errorf("--source and --dest are required") return fmt.Errorf("--source and --dest are required")
@@ -113,6 +135,7 @@ func cmdSync(args []string) error {
Job: *job, Source: *source, Dest: *dest, BlockSize: blockSize, Job: *job, Source: *source, Dest: *dest, BlockSize: blockSize,
Yes: *yes, Sudo: *sudoMode, Deploy: *deploy, ConnectTimeoutSec: *connectTimeout, Yes: *yes, Sudo: *sudoMode, Deploy: *deploy, ConnectTimeoutSec: *connectTimeout,
SSHBin: *sshBin, SSHOpts: sshOpts, RemoteBin: *remoteBin, ManagerHost: *managerHost, SSHBin: *sshBin, SSHOpts: sshOpts, RemoteBin: *remoteBin, ManagerHost: *managerHost,
Password: pw,
}) })
} }
+128 -48
View File
@@ -28,6 +28,9 @@ type SyncConfig struct {
SSHOpts []string SSHOpts []string
RemoteBin string RemoteBin string
ManagerHost string ManagerHost string
// Password authenticates connections to a listen ("tcp://…") endpoint.
// Required when the source or dest is such an endpoint; unused for ssh/local.
Password string
} }
func runSync(cfg SyncConfig) error { func runSync(cfg SyncConfig) error {
@@ -42,18 +45,22 @@ func runSync(cfg SyncConfig) error {
if err := checkNotSame(srcSpec, dstSpec); err != nil { if err := checkNotSame(srcSpec, dstSpec); err != nil {
return err return err
} }
if (srcSpec.IsNet() || dstSpec.IsNet()) && cfg.Password == "" {
return fmt.Errorf("a tcp:// listen endpoint needs a password (--password, --password-file, or CLONETOOL_PASSWORD)")
}
// Make sure each remote endpoint has a runnable clonetool, copying this // Make sure each ssh endpoint has a runnable clonetool, copying this binary
// binary over if not (unless --deploy=false). A host that appears on // over if not (unless --deploy=false). A host that appears on both sides is
// both sides is only probed once. // only probed once. Listen ("tcp://…") endpoints are skipped: the user
// started clonetool there manually, so there is nothing to deploy.
srcRemoteBin, dstRemoteBin := cfg.RemoteBin, cfg.RemoteBin srcRemoteBin, dstRemoteBin := cfg.RemoteBin, cfg.RemoteBin
if !srcSpec.IsLocal() { if !srcSpec.IsLocal() && !srcSpec.IsNet() {
if srcRemoteBin, err = resolveRemoteBin(&cfg, srcSpec, "source"); err != nil { if srcRemoteBin, err = resolveRemoteBin(&cfg, srcSpec, "source"); err != nil {
return err return err
} }
} }
if !dstSpec.IsLocal() { if !dstSpec.IsLocal() && !dstSpec.IsNet() {
if !srcSpec.IsLocal() && sameHost(srcSpec, dstSpec) { if !srcSpec.IsLocal() && !srcSpec.IsNet() && sameHost(srcSpec, dstSpec) {
dstRemoteBin = srcRemoteBin dstRemoteBin = srcRemoteBin
} else if dstRemoteBin, err = resolveRemoteBin(&cfg, dstSpec, "dest"); err != nil { } else if dstRemoteBin, err = resolveRemoteBin(&cfg, dstSpec, "dest"); err != nil {
return err return err
@@ -90,15 +97,27 @@ func runSync(cfg SyncConfig) error {
cb := transferCallbacks{onProgress: pp.print} cb := transferCallbacks{onProgress: pp.print}
bothLocal := srcSpec.IsLocal() && dstSpec.IsLocal() bothLocal := srcSpec.IsLocal() && dstSpec.IsLocal()
srcHost, srcUser := resolveConnectHost(srcSpec, &cfg)
dstHost, dstUser := resolveConnectHost(dstSpec, &cfg)
pushReq := CtrlMsg{ pushReq := CtrlMsg{Path: srcSpec.Path, Size: targetSize, BlockSize: cfg.BlockSize}
Path: srcSpec.Path, Size: targetSize, BlockSize: cfg.BlockSize, fillPeer(&pushReq, dstSpec, &cfg, dstRemoteBin, bothLocal, dstSudo)
PeerHost: dstHost, PeerUser: dstUser, PeerPath: dstSpec.Path, PeerLocal: bothLocal, pullReq := CtrlMsg{Path: dstSpec.Path, Size: targetSize, BlockSize: cfg.BlockSize}
RemoteBin: dstRemoteBin, SSHBin: cfg.SSHBin, SSHOpts: cfg.SSHOpts, ConnectTimeoutSec: cfg.ConnectTimeoutSec, fillPeer(&pullReq, srcSpec, &cfg, srcRemoteBin, bothLocal, srcSudo)
Sudo: dstSudo,
if srcSpec.IsNet() {
// A listening source only accepts connections; it can't be driven to
// dial out for a push. The destination pulls from it directly.
fmt.Fprintf(os.Stderr, "source is a listener; pulling %s <- %s ...\n", dstSpec, srcSpec)
ok, reason, err := dstCtrl.ConnectPull(pullReq, cb)
if err != nil {
return err
} }
if !ok {
pp.finish()
return fmt.Errorf(
"could not pull from the listening source (%s); check that the destination host can reach %s:%d and that --password matches the listener",
reason, srcSpec.Host, srcSpec.Port)
}
} else {
if bothLocal { if bothLocal {
fmt.Fprintf(os.Stderr, "both source and dest are local; syncing directly (no ssh) ...\n") fmt.Fprintf(os.Stderr, "both source and dest are local; syncing directly (no ssh) ...\n")
} else { } else {
@@ -111,12 +130,6 @@ func runSync(cfg SyncConfig) error {
if !ok { if !ok {
pp.finish() pp.finish()
fmt.Fprintf(os.Stderr, "push not possible (%s); trying pull %s <- %s ...\n", reason, dstSpec, srcSpec) 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: srcRemoteBin, SSHBin: cfg.SSHBin, SSHOpts: cfg.SSHOpts, ConnectTimeoutSec: cfg.ConnectTimeoutSec,
Sudo: srcSudo,
}
ok2, reason2, err2 := dstCtrl.ConnectPull(pullReq, cb) ok2, reason2, err2 := dstCtrl.ConnectPull(pullReq, cb)
if err2 != nil { if err2 != nil {
return err2 return err2
@@ -128,6 +141,7 @@ func runSync(cfg SyncConfig) error {
reason, reason2) reason, reason2)
} }
} }
}
pp.finish() pp.finish()
label := cfg.Job label := cfg.Job
@@ -144,6 +158,20 @@ func runSync(cfg SyncConfig) error {
// restart under sudo. The returned bool reports whether the agent (and any // restart under sudo. The returned bool reports whether the agent (and any
// peer helper it later spawns for this side) is running elevated. // peer helper it later spawns for this side) is running elevated.
func bringUpController(spec Spec, tag string, cfg *SyncConfig, remoteBin, probePath string) (*Controller, PathInfo, bool, error) { func bringUpController(spec Spec, tag string, cfg *SyncConfig, remoteBin, probePath string) (*Controller, PathInfo, bool, error) {
if spec.IsNet() {
// A listen endpoint is reached over TLS; sudo/self-deploy don't apply.
c, err := startNetworkController(spec, tag, cfg.Password, cfg.ConnectTimeoutSec)
if err != nil {
return nil, PathInfo{}, false, err
}
info, err := c.Stat(probePath)
if err != nil {
c.Close()
return nil, PathInfo{}, false, err
}
return c, info, false, nil
}
sudo := cfg.Sudo == "always" && canElevate() sudo := cfg.Sudo == "always" && canElevate()
c, err := startController(spec, tag, cfg, remoteBin, sudo) c, err := startController(spec, tag, cfg, remoteBin, sudo)
if err != nil { if err != nil {
@@ -153,11 +181,6 @@ func bringUpController(spec Spec, tag string, cfg *SyncConfig, remoteBin, probeP
if err == nil { if err == nil {
return c, info, sudo, nil return c, info, sudo, nil
} }
if errors.Is(err, errNeedPriv) && !canElevate() {
c.Close()
return nil, PathInfo{}, sudo, fmt.Errorf(
"%w; on Windows, run clonetool from an elevated (Administrator) console to open a raw disk", err)
}
if errors.Is(err, errNeedPriv) && cfg.Sudo == "auto" && !sudo { if errors.Is(err, errNeedPriv) && cfg.Sudo == "auto" && !sudo {
fmt.Fprintf(os.Stderr, "%s: permission denied on %s; retrying via sudo ...\n", tag, probePath) fmt.Fprintf(os.Stderr, "%s: permission denied on %s; retrying via sudo ...\n", tag, probePath)
c.Close() c.Close()
@@ -189,6 +212,29 @@ func hostLabel(spec Spec) string {
return spec.Host return spec.Host
} }
// fillPeer populates the peer-connection fields of a connect_push/connect_pull
// request from the peer's Spec: a net (listen) peer is reached by TLS dial with
// the shared password; an ssh/local peer keeps the ssh/local fields.
func fillPeer(req *CtrlMsg, peer Spec, cfg *SyncConfig, peerRemoteBin string, bothLocal, sudo bool) {
req.PeerPath = peer.Path
req.PeerLocal = bothLocal
req.RemoteBin = peerRemoteBin
req.SSHBin = cfg.SSHBin
req.SSHOpts = cfg.SSHOpts
req.ConnectTimeoutSec = cfg.ConnectTimeoutSec
req.Sudo = sudo
if peer.IsNet() {
req.PeerNet = true
req.PeerHost = peer.Host
req.PeerPort = peer.Port
req.PeerPassword = cfg.Password
return
}
host, user := resolveConnectHost(peer, cfg)
req.PeerHost = host
req.PeerUser = user
}
func resolveConnectHost(spec Spec, cfg *SyncConfig) (host, user string) { func resolveConnectHost(spec Spec, cfg *SyncConfig) (host, user string) {
if !spec.IsLocal() { if !spec.IsLocal() {
return spec.Host, spec.User return spec.Host, spec.User
@@ -234,28 +280,62 @@ func confirmShrink(dstSpec Spec, oldSize, newSize int64) bool {
return line == "y" || line == "yes" return line == "y" || line == "yes"
} }
// progressPrinter renders a single rewriting status line on stderr. Scan // rateTracker smooths a monotonically increasing block counter into a
// and transfer now run concurrently, so it keeps the latest of each kind of // blocks/sec rate, sampling no more than a few times a second so a burst of
// update (blocks hashed so far, blocks copied/skipped) and shows them on one // same-tick progress messages doesn't produce a noisy instantaneous rate.
// line, with a rolling copy rate; the "scan H/M" segment disappears once the type rateTracker struct {
lastT time.Time
lastVal int64
rate float64
}
func (r *rateTracker) update(val int64) {
now := time.Now()
if r.lastT.IsZero() {
r.lastT, r.lastVal = now, val
return
}
dt := now.Sub(r.lastT).Seconds()
if dt < 0.4 {
return
}
inst := float64(val-r.lastVal) / dt
if r.rate == 0 {
r.rate = inst
} else {
r.rate = 0.6*r.rate + 0.4*inst
}
r.lastT, r.lastVal = now, val
}
func (r *rateTracker) mib(blockSize int64) float64 {
return r.rate * float64(blockSize) / (1024 * 1024)
}
// progressPrinter renders a single rewriting status line on stderr. Scan,
// source read and destination write all run concurrently, each tracked by
// its own phase-tagged progress message (see CtrlMsg.Phase), so it keeps the
// latest of each and shows them on one line with their own rolling
// throughput; the "scan H/M" segment and its read rate disappear once the
// destination fingerprint is complete. // destination fingerprint is complete.
type progressPrinter struct { type progressPrinter struct {
blockSize int64 blockSize int64
start time.Time
lastT time.Time
lastCopied int64
rate float64 // copied blocks/sec, smoothed
active bool active bool
hashed int64 hashed int64
copied int64 copied int64
skipped int64 skipped int64
srcRead int64
written int64
total int64 total int64
dstReadRate rateTracker // destination scan/hash throughput
srcReadRate rateTracker // source read/compare throughput
dstWriteRate rateTracker // destination write throughput
} }
func newProgressPrinter(blockSize int64) *progressPrinter { func newProgressPrinter(blockSize int64) *progressPrinter {
now := time.Now() return &progressPrinter{blockSize: blockSize}
return &progressPrinter{blockSize: blockSize, start: now, lastT: now}
} }
func (p *progressPrinter) print(m CtrlMsg) { func (p *progressPrinter) print(m CtrlMsg) {
@@ -263,22 +343,22 @@ func (p *progressPrinter) print(m CtrlMsg) {
if m.TotalBlocks > 0 { if m.TotalBlocks > 0 {
p.total = m.TotalBlocks p.total = m.TotalBlocks
} }
if m.Phase == "scan" { switch m.Phase {
case "scan":
if m.Hashed > p.hashed { if m.Hashed > p.hashed {
p.hashed = m.Hashed p.hashed = m.Hashed
} }
if p.total > 0 && p.hashed >= p.total {
p.dstReadRate.rate = 0 // scan finished; stop showing a frozen rate
} else { } else {
p.copied, p.skipped = m.Copied, m.Skipped p.dstReadRate.update(p.hashed)
now := time.Now()
if dt := now.Sub(p.lastT).Seconds(); dt >= 0.4 {
inst := float64(m.Copied-p.lastCopied) / dt
if p.rate == 0 {
p.rate = inst
} else {
p.rate = 0.6*p.rate + 0.4*inst
}
p.lastT, p.lastCopied = now, m.Copied
} }
case "xfer":
p.copied, p.skipped, p.srcRead = m.Copied, m.Skipped, m.SrcRead
p.srcReadRate.update(p.srcRead)
case "write":
p.written = m.Written
p.dstWriteRate.update(p.written)
} }
done := p.copied + p.skipped done := p.copied + p.skipped
@@ -286,15 +366,15 @@ func (p *progressPrinter) print(m CtrlMsg) {
if total <= 0 { if total <= 0 {
total = done total = done
} }
mib := p.rate * float64(p.blockSize) / (1024 * 1024)
scan := "" scan := ""
if p.total > 0 && p.hashed < p.total { if p.total > 0 && p.hashed < p.total {
scan = fmt.Sprintf(" scan %s/%s", formatCount(p.hashed), formatCount(p.total)) scan = fmt.Sprintf(" scan %s/%s", formatCount(p.hashed), formatCount(p.total))
} }
fmt.Fprintf(os.Stderr, "\r syncing %s %s/%s copied=%s skipped=%s%s %6.1f MiB/s ", fmt.Fprintf(os.Stderr, "\r syncing %s %s/%s copied=%s skipped=%s%s rd(src) %5.1f rd(dst) %5.1f wr(dst) %5.1f MiB/s ",
progressBar(done, total), formatCount(done), formatCount(total), progressBar(done, total), formatCount(done), formatCount(total),
formatCount(p.copied), formatCount(p.skipped), scan, mib) formatCount(p.copied), formatCount(p.skipped), scan,
p.srcReadRate.mib(p.blockSize), p.dstReadRate.mib(p.blockSize), p.dstWriteRate.mib(p.blockSize))
} }
// finish ends the current status line with a newline so following output // finish ends the current status line with a newline so following output
+267
View File
@@ -0,0 +1,267 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hmac"
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/json"
"fmt"
"math/big"
"net"
"strconv"
"time"
)
// The network ("listen") transport is an alternative to ssh for reaching an
// endpoint that has no ssh server — typically a Windows source started with
// `clonetool listen`. It is:
//
// - TLS 1.3 for confidentiality. The listener uses a fresh, self-signed,
// in-memory certificate generated at startup; the dialer does not verify
// it (there is no CA). Authentication is by shared password, below.
// - A password challenge-response *bound to the TLS session*. Both sides
// derive a key from the password with PBKDF2 and prove knowledge of it by
// HMAC'ing the connection's RFC 5705 exported keying material, which is
// unique to this specific TLS session. A man-in-the-middle terminating TLS
// sees a different exporter value on each leg, so it cannot produce a valid
// HMAC for either side even though the certificate is unverified. Both
// directions are checked, so each end authenticates the other.
//
// Once authenticated, the connection carries the ordinary frame protocol
// (frame.go): first a msgRole request naming the endpoint to run, then that
// role's traffic.
const (
authExporterLabel = "EXPORTER-clonetool-auth-v1"
authKDFIterations = 200_000
authKeyLen = 32
authExporterLen = 32
authMsgType = "auth"
)
// authKDFSalt is a fixed application salt. Per-connection uniqueness comes from
// the TLS exporter that the HMAC is computed over, not from this salt; it only
// domain-separates the derived key.
var authKDFSalt = []byte("clonetool-listen-auth-salt-v1")
func deriveAuthKey(password string) ([]byte, error) {
return pbkdf2.Key(sha256.New, password, authKDFSalt, authKDFIterations, authKeyLen)
}
// authTag is HMAC(key, side || exporter): "side" separates the client's proof
// from the server's so the same value can't simply be reflected.
func authTag(key []byte, side string, exporter []byte) []byte {
m := hmac.New(sha256.New, key)
m.Write([]byte(side))
m.Write(exporter)
return m.Sum(nil)
}
func exporterFor(cs tls.ConnectionState) ([]byte, error) {
return cs.ExportKeyingMaterial(authExporterLabel, nil, authExporterLen)
}
// newServerTLSConfig builds a TLS config with a fresh self-signed cert.
func newServerTLSConfig() (*tls.Config, error) {
cert, err := selfSignedCert()
if err != nil {
return nil, err
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS13,
}, nil
}
func selfSignedCert() (tls.Certificate, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return tls.Certificate{}, err
}
tmpl := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "clonetool"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * 365 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
if err != nil {
return tls.Certificate{}, err
}
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, nil
}
// dialAuth dials addr, completes the TLS handshake and the mutual password
// challenge, and returns the authenticated connection plus its frame
// reader/writer. On any failure it closes the connection.
func dialAuth(addr, password string, timeout time.Duration) (*tls.Conn, *FrameReader, *FrameWriter, error) {
d := net.Dialer{Timeout: timeout}
raw, err := d.Dial("tcp", addr)
if err != nil {
return nil, nil, nil, err
}
conn := tls.Client(raw, &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS13})
_ = conn.SetDeadline(time.Now().Add(timeout))
if err := conn.Handshake(); err != nil {
_ = conn.Close()
return nil, nil, nil, fmt.Errorf("tls handshake: %w", err)
}
key, err := deriveAuthKey(password)
if err != nil {
_ = conn.Close()
return nil, nil, nil, err
}
exporter, err := exporterFor(conn.ConnectionState())
if err != nil {
_ = conn.Close()
return nil, nil, nil, fmt.Errorf("tls keying material: %w", err)
}
fw := NewFrameWriter(conn)
fr := NewFrameReader(conn)
// Prove ourselves to the server, then verify the server back.
if err := fw.WriteJSON(CtrlMsg{Type: authMsgType, Message: hex.EncodeToString(authTag(key, "client", exporter))}); err != nil {
_ = conn.Close()
return nil, nil, nil, err
}
m, err := readCtrlFrame(fr)
if err != nil {
_ = conn.Close()
return nil, nil, nil, fmt.Errorf("authentication failed (bad password?): %w", err)
}
want := authTag(key, "server", exporter)
got, _ := hex.DecodeString(m.Message)
if m.Type != authMsgType || !hmac.Equal(got, want) {
_ = conn.Close()
return nil, nil, nil, fmt.Errorf("server authentication failed (password mismatch)")
}
_ = conn.SetDeadline(time.Time{}) // clear; per-op timeouts are handled by callers
return conn, fr, fw, nil
}
// serverAuth runs the server side of the password challenge on an
// already-TLS-handshaken connection.
func serverAuth(conn *tls.Conn, password string, timeout time.Duration) (*FrameReader, *FrameWriter, error) {
key, err := deriveAuthKey(password)
if err != nil {
return nil, nil, err
}
exporter, err := exporterFor(conn.ConnectionState())
if err != nil {
return nil, nil, fmt.Errorf("tls keying material: %w", err)
}
fw := NewFrameWriter(conn)
fr := NewFrameReader(conn)
_ = conn.SetReadDeadline(time.Now().Add(timeout))
m, err := readCtrlFrame(fr)
if err != nil {
return nil, nil, err
}
want := authTag(key, "client", exporter)
got, _ := hex.DecodeString(m.Message)
if m.Type != authMsgType || !hmac.Equal(got, want) {
return nil, nil, fmt.Errorf("client authentication failed (password mismatch)")
}
if err := fw.WriteJSON(CtrlMsg{Type: authMsgType, Message: hex.EncodeToString(authTag(key, "server", exporter))}); err != nil {
return nil, nil, err
}
_ = conn.SetReadDeadline(time.Time{})
return fr, fw, nil
}
// readCtrlFrame reads one frameCtrlJSON frame and decodes it into a CtrlMsg.
func readCtrlFrame(fr *FrameReader) (CtrlMsg, error) {
typ, payload, err := fr.ReadFrame()
if err != nil {
return CtrlMsg{}, err
}
if typ != frameCtrlJSON {
return CtrlMsg{}, fmt.Errorf("expected control frame, got type %d", typ)
}
var m CtrlMsg
if err := json.Unmarshal(payload, &m); err != nil {
return CtrlMsg{}, err
}
return m, nil
}
// dialPeerLink connects to a listening clonetool over TLS, authenticates,
// requests role for the given window, and waits for its READY handshake. It is
// the net counterpart of spawnPeerLink (see agent.go).
func dialPeerLink(req CtrlMsg, role string) (*peerLink, string) {
addr := net.JoinHostPort(req.PeerHost, strconv.Itoa(req.PeerPort))
timeout := time.Duration(req.ConnectTimeoutSec+2) * time.Second
conn, fr, fw, err := dialAuth(addr, req.PeerPassword, timeout)
if err != nil {
return nil, err.Error()
}
if err := fw.WriteJSON(CtrlMsg{
Type: msgRole, Role: role,
Path: req.PeerPath, Base: req.PeerBase, Size: req.Size, BlockSize: req.BlockSize,
}); err != nil {
_ = conn.Close()
return nil, err.Error()
}
if err := waitPeerReady(fr, timeout); err != nil {
_ = conn.Close()
return nil, err.Error()
}
return &peerLink{
fw: fw,
fr: fr,
stderr: func() string { return "" },
finish: conn.Close,
kill: func() { _ = conn.Close() },
closeSend: conn.Close,
}, ""
}
// waitPeerReady waits for a data role's frameReady, surfacing a msgRoleErr the
// listener may send instead when it can't start the requested role.
func waitPeerReady(fr *FrameReader, timeout time.Duration) error {
type result struct {
typ frameType
payload []byte
err error
}
ch := make(chan result, 1)
go func() {
typ, payload, err := fr.ReadFrame()
ch <- result{typ, payload, err}
}()
select {
case r := <-ch:
if r.err != nil {
return fmt.Errorf("handshake failed: %w", r.err)
}
switch r.typ {
case frameReady:
return nil
case frameCtrlJSON:
var m CtrlMsg
if json.Unmarshal(r.payload, &m) == nil && m.Type == msgRoleErr {
return fmt.Errorf("remote refused role: %s", m.Message)
}
return fmt.Errorf("handshake failed: unexpected control frame")
default:
return fmt.Errorf("handshake failed: unexpected frame type %d", r.typ)
}
case <-time.After(timeout):
return fmt.Errorf("handshake timed out after %s", timeout)
}
}
+75 -8
View File
@@ -3,21 +3,32 @@ package main
import ( import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
) )
// Spec is a parsed source/dest location: [user@]host:path, or a bare local // Spec is a parsed source/dest location. It is one of:
// path (Host == ""). // - a bare local path (Host == "", Net == false)
// - an ssh endpoint [user@]host:path (Host != "", Net == false)
// - a listen endpoint tcp://host:port/path (Net == true) — a clonetool
// started in "listen" mode that this side connects to over TLS instead
// of ssh. Only the source is expected to be a listener today.
type Spec struct { type Spec struct {
Raw string Raw string
User string User string
Host string Host string
Path string Path string
Net bool // tcp:// listen endpoint (connect to a listening clonetool)
Port int // listener port, when Net
} }
func (s Spec) IsLocal() bool { return s.Host == "" } func (s Spec) IsLocal() bool { return s.Host == "" && !s.Net }
func (s Spec) IsNet() bool { return s.Net }
func (s Spec) String() string { func (s Spec) String() string {
if s.Net {
return fmt.Sprintf("tcp://%s:%d/%s", s.Host, s.Port, s.Path)
}
if s.IsLocal() { if s.IsLocal() {
return s.Path return s.Path
} }
@@ -27,14 +38,38 @@ func (s Spec) String() string {
return fmt.Sprintf("%s:%s", s.Host, s.Path) return fmt.Sprintf("%s:%s", s.Host, s.Path)
} }
// parseSpec parses "[user@]host:path" or a local "path". A leading "/", // looksLikeLocalPath reports whether raw is an ordinary local path that must
// "./" or "../", or the absence of any colon, is treated as a local path so // never be mistaken for a "host:path" spec: a unix absolute/relative path, a
// that ordinary absolute/relative paths are never mistaken for a host spec. // Windows drive path (C:\... or C:/...), or a Windows UNC / raw-device path
// (\\server\share, \\.\PhysicalDrive0).
func looksLikeLocalPath(raw string) bool {
if strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "./") || strings.HasPrefix(raw, "../") {
return true
}
if strings.HasPrefix(raw, `\\`) { // UNC or \\.\ device
return true
}
// Windows drive path: a single letter, a colon, then a separator.
if len(raw) >= 3 && raw[1] == ':' &&
((raw[0] >= 'A' && raw[0] <= 'Z') || (raw[0] >= 'a' && raw[0] <= 'z')) &&
(raw[2] == '\\' || raw[2] == '/') {
return true
}
return false
}
// parseSpec parses a location: "tcp://host:port/path" (a listen endpoint),
// "[user@]host:path" (ssh), or a local path. Local paths (unix or Windows,
// including drive letters and \\.\ devices) and anything without a colon are
// treated as local so an ordinary path is never mistaken for a host spec.
func parseSpec(raw string) (Spec, error) { func parseSpec(raw string) (Spec, error) {
if raw == "" { if raw == "" {
return Spec{}, fmt.Errorf("empty location") return Spec{}, fmt.Errorf("empty location")
} }
if strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "./") || strings.HasPrefix(raw, "../") || !strings.Contains(raw, ":") { if strings.HasPrefix(raw, "tcp://") {
return parseNetSpec(raw)
}
if looksLikeLocalPath(raw) || !strings.Contains(raw, ":") {
return Spec{Raw: raw, Path: raw}, nil return Spec{Raw: raw, Path: raw}, nil
} }
idx := strings.Index(raw, ":") idx := strings.Index(raw, ":")
@@ -55,15 +90,47 @@ func parseSpec(raw string) (Spec, error) {
return Spec{Raw: raw, User: user, Host: host, Path: path}, nil return Spec{Raw: raw, User: user, Host: host, Path: path}, nil
} }
// parseNetSpec parses "tcp://host:port/path". Everything after the first "/"
// that follows the host:port authority is taken literally as the path, so a
// Windows path (C:\dir\file, \\.\PhysicalDrive0) survives unmangled.
func parseNetSpec(raw string) (Spec, error) {
rest := strings.TrimPrefix(raw, "tcp://")
slash := strings.IndexByte(rest, '/')
if slash < 0 {
return Spec{}, fmt.Errorf("cannot parse %q (expected tcp://host:port/path)", raw)
}
authority := rest[:slash]
path := rest[slash+1:]
if path == "" {
return Spec{}, fmt.Errorf("cannot parse %q: empty path", raw)
}
colon := strings.LastIndexByte(authority, ':')
if colon <= 0 || colon == len(authority)-1 {
return Spec{}, fmt.Errorf("cannot parse %q (expected tcp://host:port/path)", raw)
}
host := authority[:colon]
port, err := strconv.Atoi(authority[colon+1:])
if err != nil || port <= 0 || port > 65535 {
return Spec{}, fmt.Errorf("cannot parse %q: invalid port %q", raw, authority[colon+1:])
}
return Spec{Raw: raw, Host: host, Port: port, Path: path, Net: true}, nil
}
// checkNotSame does a best-effort local check that source and dest don't // 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. // refer to the exact same path, to avoid an obviously destructive mistake.
// It cannot resolve whether two different remote hostnames are actually the // It cannot resolve whether two different remote hostnames are actually the
// same machine. // same machine.
func checkNotSame(src, dst Spec) error { func checkNotSame(src, dst Spec) error {
if src.IsNet() != dst.IsNet() {
return nil
}
if src.IsNet() && (!strings.EqualFold(src.Host, dst.Host) || src.Port != dst.Port) {
return nil
}
if src.IsLocal() != dst.IsLocal() { if src.IsLocal() != dst.IsLocal() {
return nil return nil
} }
if !src.IsLocal() && !strings.EqualFold(src.Host, dst.Host) { if !src.IsLocal() && !src.IsNet() && !strings.EqualFold(src.Host, dst.Host) {
return nil return nil
} }
if !src.IsLocal() && src.User != dst.User { if !src.IsLocal() && src.User != dst.User {
+30 -11
View File
@@ -81,12 +81,14 @@ type blockHash struct {
hash [32]byte hash [32]byte
} }
// streamHashBlocks reads f in blockSize-byte blocks up to size, hashing each // streamHashBlocks reads f in blockSize-byte blocks over the window
// one and handing it to onHash the moment it is computed (this is what lets // [base, base+size), hashing each one and handing it to onHash the moment it
// the peer start comparing before the whole side has been scanned). It keeps // is computed (this is what lets the peer start comparing before the whole
// no hash state of its own. onProgress, if non-nil, is called with // side has been scanned). It keeps no hash state of its own. onProgress, if
// (blocksHashed, totalBlocks) before the first block and after each one. // non-nil, is called with (blocksHashed, totalBlocks) before the first block
func streamHashBlocks(f *os.File, size, blockSize, align int64, onHash func(bh blockHash) error, onProgress func(done, total int64)) error { // and after each one. base is 0 for a whole-file sync; a non-zero base
// fingerprints just a byte window within a larger handle.
func streamHashBlocks(f *os.File, base, size, blockSize, align int64, onHash func(bh blockHash) error, onProgress func(done, total int64)) error {
if err := checkBlockAlign(blockSize, align); err != nil { if err := checkBlockAlign(blockSize, align); err != nil {
return err return err
} }
@@ -96,7 +98,7 @@ func streamHashBlocks(f *os.File, size, blockSize, align int64, onHash func(bh b
onProgress(0, blockCount) onProgress(0, blockCount)
} }
for i := int64(0); i < blockCount; i++ { for i := int64(0); i < blockCount; i++ {
n, err := readBlockAt(f, buf, i*blockSize, size, align) n, err := readBlockAt(f, buf, base+i*blockSize, base+size, align)
if err != nil { if err != nil {
return fmt.Errorf("hash %s at block %d: %w", f.Name(), i, err) return fmt.Errorf("hash %s at block %d: %w", f.Name(), i, err)
} }
@@ -130,11 +132,17 @@ func scanProgressEmitter(out *FrameWriter) func(done, total int64) {
// never read twice. // never read twice.
type sourceLoopParams struct { type sourceLoopParams struct {
File *os.File // already opened for reading by the caller File *os.File // already opened for reading by the caller
Size int64 Base int64 // byte offset the window starts at (0 for a whole-file sync)
Size int64 // window length in bytes
BlockSize int64 BlockSize int64
Align int64 Align int64
Hashes <-chan blockHash Hashes <-chan blockHash
Out *FrameWriter Out *FrameWriter
// OnRead is called immediately after a block has been read and hashed
// for comparison — whether it turns out to match or differ — so
// callers can track true source read throughput independent of the
// send/ack timing tracked by OnSkip/OnSend below.
OnRead func(index uint64)
OnSkip func(index uint64) OnSkip func(index uint64)
// OnSend is called for a differing block immediately before its DATA // OnSend is called for a differing block immediately before its DATA
// frame goes on the wire — never after. The push driver relies on this // frame goes on the wire — never after. The push driver relies on this
@@ -151,12 +159,15 @@ func runSourceLoop(p sourceLoopParams) error {
f := p.File f := p.File
buf := make([]byte, p.BlockSize) buf := make([]byte, p.BlockSize)
for bh := range p.Hashes { for bh := range p.Hashes {
offset := int64(bh.index) * p.BlockSize offset := p.Base + int64(bh.index)*p.BlockSize
n, err := readBlockAt(f, buf, offset, p.Size, p.Align) n, err := readBlockAt(f, buf, offset, p.Base+p.Size, p.Align)
if err != nil { if err != nil {
return fmt.Errorf("read %s at block %d: %w", f.Name(), bh.index, err) return fmt.Errorf("read %s at block %d: %w", f.Name(), bh.index, err)
} }
hash := sha256.Sum256(buf[:n]) hash := sha256.Sum256(buf[:n])
if p.OnRead != nil {
p.OnRead(bh.index)
}
if hash == bh.hash { if hash == bh.hash {
if p.OnSkip != nil { if p.OnSkip != nil {
p.OnSkip(bh.index) p.OnSkip(bh.index)
@@ -181,6 +192,7 @@ func runSourceLoop(p sourceLoopParams) error {
// same process, before OnWritten is called). // same process, before OnWritten is called).
type destLoopParams struct { type destLoopParams struct {
File *os.File // already opened for read/write by the caller File *os.File // already opened for read/write by the caller
Base int64 // byte offset the window starts at (0 for a whole-file sync)
BlockSize int64 BlockSize int64
Align int64 // sector alignment for a raw disk handle, else 0/1 Align int64 // sector alignment for a raw disk handle, else 0/1
In *FrameReader In *FrameReader
@@ -189,6 +201,10 @@ type destLoopParams struct {
// pull mode, where the remote source-stream has no other channel back // pull mode, where the remote source-stream has no other channel back
// to the manager for progress updates). Sink-role callers leave it nil. // to the manager for progress updates). Sink-role callers leave it nil.
OnCtrlMsg func(CtrlMsg) OnCtrlMsg func(CtrlMsg)
// OnWrite is called right after a block is durably written — the true
// destination write-throughput measurement point, as opposed to
// whatever "copied" count a driver derives from the send/ack protocol.
OnWrite func(index uint64)
} }
func runDestLoop(p destLoopParams) error { func runDestLoop(p destLoopParams) error {
@@ -212,12 +228,15 @@ func runDestLoop(p destLoopParams) error {
} }
return fmt.Errorf("block %d: hash mismatch after transfer", index) return fmt.Errorf("block %d: hash mismatch after transfer", index)
} }
if err := writeBlockAt(f, block, int64(index)*p.BlockSize, p.Align); err != nil { if err := writeBlockAt(f, block, p.Base+int64(index)*p.BlockSize, p.Align); err != nil {
if p.AckOut != nil { if p.AckOut != nil {
_ = p.AckOut.WriteFrame(frameErr, encodeErrFrame(index, err.Error())) _ = p.AckOut.WriteFrame(frameErr, encodeErrFrame(index, err.Error()))
} }
return fmt.Errorf("write block %d: %w", index, err) return fmt.Errorf("write block %d: %w", index, err)
} }
if p.OnWrite != nil {
p.OnWrite(index)
}
if p.AckOut != nil { if p.AckOut != nil {
if err := p.AckOut.WriteFrame(frameAck, encodeIndexFrame(index)); err != nil { if err := p.AckOut.WriteFrame(frameAck, encodeIndexFrame(index)); err != nil {
return err return err
+16
View File
@@ -2,9 +2,25 @@ package main
import ( import (
"fmt" "fmt"
"os/exec"
"strings"
"sync" "sync"
) )
// runCmd runs name with args, returning trimmed combined output.
func runCmd(name string, args ...string) (string, error) {
out, err := exec.Command(name, args...).CombinedOutput()
return strings.TrimSpace(string(out)), err
}
// runCmdStdin is runCmd with a fixed stdin string (for tools that prompt).
func runCmdStdin(stdin, name string, args ...string) (string, error) {
c := exec.Command(name, args...)
c.Stdin = strings.NewReader(stdin)
out, err := c.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
// limitedBuffer keeps only the last maxLen bytes written to it — used to // limitedBuffer keeps only the last maxLen bytes written to it — used to
// capture a bounded tail of a subprocess's stderr for error messages // capture a bounded tail of a subprocess's stderr for error messages
// without risking unbounded memory growth from a noisy remote command. // without risking unbounded memory growth from a noisy remote command.
+35
View File
@@ -0,0 +1,35 @@
package main
import (
"fmt"
"runtime"
"strings"
)
// buildTime is stamped at build time via -ldflags "-X main.buildTime=..."
// (build.sh does this). A plain "go build" with no ldflags leaves it at
// "dev" — still a meaningful, comparable value: two "dev" builds are only
// ever the same binary bytes copied around (self-deploy), never two
// independently built ones landing on the same value by chance.
var buildTime = "dev"
// versionString is what `clonetool version` prints, and what a remote
// host's own `version` output is compared against (see remoteBuildTag) to
// tell whether it's running the same build as this binary.
func versionString() string {
return fmt.Sprintf("clonetool %s/%s build=%s", runtime.GOOS, runtime.GOARCH, buildTime)
}
// remoteBuildTag extracts the "build=..." tag from a clonetool `version`
// line, ignoring its GOOS/GOARCH — those are expected to legitimately
// differ across a cross-compiled deploy — and any surrounding ssh/shell
// noise on other lines. Returns "" if the line isn't a clonetool version
// line at all (e.g. "command not found").
func remoteBuildTag(out string) string {
line := firstLine(out)
i := strings.Index(line, "build=")
if i < 0 {
return ""
}
return strings.TrimSpace(line[i+len("build="):])
}