Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2806d6f8dd | ||
|
|
f2f07a0f9a | ||
|
|
e889bd4e88 | ||
|
|
908adf51a1 | ||
|
|
2c3e341343 | ||
|
|
21c21e17dd | ||
|
|
7e73567f2a | ||
|
|
6405d41c20 | ||
|
|
10d84909c0 |
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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"
|
||||||
)
|
)
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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 }
|
||||||
@@ -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
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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="):])
|
||||||
|
}
|
||||||
Reference in new issue
Block a user