Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 25 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ $ dwshell myserver -c "uname -sr"
Linux 6.8.0
```

After `login`, control commands (`list`, `<host>`, `-c`) reuse the saved session
After `login`, control commands (`list`, `<agent>`, `-c`) reuse the saved session
and **never prompt** for the account password. When the session expires they
refresh silently via the trusted device, or, if there is none, tell you to run
`dwshell login` again. Leave an interactive shell with `exit` (or Ctrl-D).
Expand All @@ -162,21 +162,21 @@ the session without any of this. To supply a code non-interactively, see
| `dwshell login [--user U] [--no-trusted]` | Authenticate and persist the session (and, by default, a trusted device). |
| `dwshell logout` | Deregister the trusted device and forget local credentials. |
| `dwshell list [--json]` | List machines with OS, online state, and owned/shared. |
| `dwshell <host>` | Open an interactive shell. |
| `dwshell <host> -c "cmd"` | Run a command non-interactively; exit code is propagated. |
| `dwshell shell <host>` | Explicit form of the above. |
| `dwshell ls <host>[:<path>]` | List a remote directory (root if the path is omitted). |
| `dwshell get [-r] <host>:<remote> [local]` | Download a file (or directory with `-r`). |
| `dwshell put [-r] <local> <host>:<remote>` | Upload a file (or directory with `-r`). |
| `dwshell rm [-r] <host>:<path> [...]` | Remove remote file(s) (directories with `-r`). |
| `dwshell sync [flags] <src> <dst>` | One-way sync (size+mtime or `--checksum`); one side is `host:path`. |
| `dwshell <agent>` | Open an interactive shell. |
| `dwshell <agent> -c "cmd"` | Run a command non-interactively; exit code is propagated. |
| `dwshell shell <agent>` | Explicit form of the above. |
| `dwshell ls <agent>[:<path>]` | List a remote directory (root if the path is omitted). |
| `dwshell get [-r] <agent>:<remote> [local]` | Download a file (or directory with `-r`). |
| `dwshell put [-r] <local> <agent>:<remote>` | Upload a file (or directory with `-r`). |
| `dwshell rm [-r] <agent>:<path> [...]` | Remove remote file(s) (directories with `-r`). |
| `dwshell sync [flags] <src> <dst>` | One-way sync (size+mtime or `--checksum`); one side is `agent:path`. |
| `dwshell version` | Print the version and exit. |
| `dwshell help` | Show usage. |

#### File transfer

`ls`, `get`, `put`, and `rm` operate on the DWService filesystem app. Remote endpoints
are written `host:path` (the split is on the first colon, so a remote Windows
are written `agent:path` (the split is on the first colon, so a remote Windows
path like `GHE:C:\Users` works):

```sh
Expand All @@ -196,34 +196,34 @@ dwshell sync -n GHE:/data ./data # dry-run download-sync

Single-file transfers, recursive `get -r` / `put -r` / `rm -r`, and one-way
`sync` (transfers only files that differ by size or mtime). `sync` takes exactly
one `host:path` side; direction is inferred. It preserves mtimes (locally on
one `agent:path` side; direction is inferred. It preserves mtimes (locally on
download; on upload it sets the remote mtime via the shell — falling back to
size-only when that is unavailable, e.g. on Windows remotes). `--size-only` compares by size only; `--checksum` compares by SHA-256 (hashing
the remote via the shell); `--delete` removes destination entries missing from
the source; `-n` is a dry run.
`--own` / `--shared` disambiguate the host as elsewhere.
`--own` / `--shared` disambiguate the agent as elsewhere.

Remote paths are always rooted at `/`, so `GHE:/etc` and a relative `GHE:etc`
address the same directory, and an omitted path means the root. On Windows `/` is
the root and lists the drives; address a drive as `host:/C:/dir` or `host:C:/dir`
(`/` and `\` are interchangeable, and a bare `host:C:` means the drive root).
the root and lists the drives; address a drive as `agent:/C:/dir` or `agent:C:/dir`
(`/` and `\` are interchangeable, and a bare `agent:C:` means the drive root).

#### Host name vs subcommand
#### Agent name vs subcommand

`dwshell <host>` is a convenience shortcut: the first argument is treated as a
host **unless** it exactly matches one of the subcommands in the
`dwshell <agent>` is a convenience shortcut: the first argument is treated as an
agent **unless** it exactly matches one of the subcommands in the
[Commands](#commands) table above. So `dwshell version` prints the version, it
does not connect to a machine.

If you actually have a machine named like one of those, use the explicit `shell`
subcommand, which always treats its argument as a host:
subcommand, which always treats its argument as an agent:

```sh
dwshell shell version # connect to the host named "version"
dwshell shell list -c "id" # run a command on the host named "list"
dwshell shell version # connect to the agent named "version"
dwshell shell list -c "id" # run a command on the agent named "list"
```

`<host>` is a machine **name** or **id**, optionally prefixed `user@` (SSH-style;
`<agent>` is a machine **name** or **id**, optionally prefixed `user@` (SSH-style;
defaults to your local username). If a name is ambiguous (a name shared between an
owned agent and a share, or duplicate share names), pass the id or add `--own` /
`--shared`. The `user@` part matters only when the agent requires authentication
Expand All @@ -240,8 +240,8 @@ never taken from the command line). Access-restricted users
(`shell.users_allowed`) are enforced by the agent.

```sh
dwshell alice@myhost # log in as alice; prompts for her password if required
DWSHELL_REMOTE_PASSWORD=… dwshell alice@myhost -c "id"
dwshell alice@myserver # log in as alice; prompts for her password if required
DWSHELL_REMOTE_PASSWORD=… dwshell alice@myserver -c "id"
```

### Flags
Expand All @@ -255,7 +255,7 @@ DWSHELL_REMOTE_PASSWORD=… dwshell alice@myhost -c "id"
remote truncates the line silently and runs what is left of it, and since the
exit-code marker goes with it, `-c` waits for output that never arrives — so
pass a bigger script with `dwshell put` and run it by path instead.
- `--own` / `--shared` — resolve `<host>` among owned agents / incoming shares only.
- `--own` / `--shared` — resolve `<agent>` among owned agents / incoming shares only.
- `--term <value>` — TERM to send to a *nix remote (default: your local `$TERM`).
- `--no-term` — do not send a TERM to the remote.
- `--timeout <dur>` — command timeout for `-c` (default: none; e.g. `30s`, `5m`).
Expand Down Expand Up @@ -320,7 +320,7 @@ native UI all reuse the same login/session/connect libraries under `internal/`.
## Status

Reverse-engineered and verified end-to-end against the live service on Linux and
Windows remotes. Unit tests cover the crypto, request framing, and host
Windows remotes. Unit tests cover the crypto, request framing, and agent
resolution; `go test ./...`.

## Legal & disclaimer
Expand Down
74 changes: 37 additions & 37 deletions cmd/dwshell/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,20 @@ import (
"github.com/porech/dwshell/internal/session"
)

// parseRemote splits a "host:path" endpoint. It splits on the first colon so
// remote Windows paths (host:C:\dir) work; the host must be non-empty. An empty
// parseRemote splits an "agent:path" endpoint. It splits on the first colon so
// remote Windows paths (agent:C:\dir) work; the agent must be non-empty. An empty
// path is allowed and means the remote root (see canonicalRemotePath).
func parseRemote(s string) (host, rpath string, err error) {
func parseRemote(s string) (agent, rpath string, err error) {
i := strings.IndexByte(s, ':')
if i <= 0 {
return "", "", fmt.Errorf("expected host:path, got %q", s)
return "", "", fmt.Errorf("expected agent:path, got %q", s)
}
return s[:i], s[i+1:], nil
}

// isRemoteEndpoint reports whether s looks like a "host:path" remote endpoint
// rather than a local path. A single-letter host followed by a separator is
// treated as a local Windows drive (C:\dir), not a remote host.
// isRemoteEndpoint reports whether s looks like an "agent:path" remote endpoint
// rather than a local path. A single-letter agent followed by a separator is
// treated as a local Windows drive (C:\dir), not a remote agent.
func isRemoteEndpoint(s string) bool {
i := strings.IndexByte(s, ':')
if i <= 0 {
Expand Down Expand Up @@ -103,18 +103,18 @@ func cmdLs(ctx context.Context, args []string) int {
fs.BoolVar(&own, "own", false, "owned agents only")
fs.BoolVar(&shared, "shared", false, "incoming shares only")

endpoint, rest := extractHost(args)
endpoint, rest := extractAgent(args)
if endpoint == "" {
return fail("usage: dwshell ls <host>[:<path>]")
return fail("usage: dwshell ls <agent>[:<path>]")
}
if err := fs.Parse(rest); err != nil {
return 2
}
// The path is optional: "dwshell ls <host>" (or "<host>:") lists the root.
host, rpath := endpoint, ""
// The path is optional: "dwshell ls <agent>" (or "<agent>:") lists the root.
agent, rpath := endpoint, ""
if strings.ContainsRune(endpoint, ':') {
var err error
if host, rpath, err = parseRemote(endpoint); err != nil {
if agent, rpath, err = parseRemote(endpoint); err != nil {
return fail("%v", err)
}
}
Expand All @@ -127,7 +127,7 @@ func cmdLs(ctx context.Context, args []string) int {
if err != nil {
return fail("%v", err)
}
m, sess, err := c.ConnectApp(ctx, host, filter, "filesystem")
m, sess, err := c.ConnectApp(ctx, agent, filter, "filesystem")
if err != nil {
return fail("%v", err)
}
Expand Down Expand Up @@ -172,9 +172,9 @@ func cmdGet(ctx context.Context, args []string) int {
return 2
}
if len(pos) < 1 || len(pos) > 2 {
return fail("usage: dwshell get <host>:<remote> [local]")
return fail("usage: dwshell get <agent>:<remote> [local]")
}
host, rpath, err := parseRemote(pos[0])
agent, rpath, err := parseRemote(pos[0])
if err != nil {
return fail("%v", err)
}
Expand All @@ -191,7 +191,7 @@ func cmdGet(ctx context.Context, args []string) int {
if err != nil {
return fail("%v", err)
}
m, sess, err := c.ConnectApp(ctx, host, filter, "filesystem")
m, sess, err := c.ConnectApp(ctx, agent, filter, "filesystem")
if err != nil {
return fail("%v", err)
}
Expand Down Expand Up @@ -251,12 +251,12 @@ func cmdSync(ctx context.Context, args []string) int {
return 2
}
if len(pos) != 2 {
return fail("usage: dwshell sync <src> <dst> (exactly one of src/dst is host:path)")
return fail("usage: dwshell sync <src> <dst> (exactly one of src/dst is agent:path)")
}
src, dst := pos[0], pos[1]
srcR, dstR := isRemoteEndpoint(src), isRemoteEndpoint(dst)
if srcR == dstR {
return fail("exactly one of source and destination must be a host:path endpoint")
return fail("exactly one of source and destination must be an agent:path endpoint")
}

var dir files.Direction
Expand All @@ -266,7 +266,7 @@ func cmdSync(ctx context.Context, args []string) int {
} else {
dir, endpoint, localRoot = files.Upload, dst, src
}
host, rpath, err := parseRemote(endpoint)
agent, rpath, err := parseRemote(endpoint)
if err != nil {
return fail("%v", err)
}
Expand All @@ -279,7 +279,7 @@ func cmdSync(ctx context.Context, args []string) int {
if err != nil {
return fail("%v", err)
}
m, sess, err := c.ConnectApp(ctx, host, filter, "filesystem")
m, sess, err := c.ConnectApp(ctx, agent, filter, "filesystem")
if err != nil {
return fail("%v", err)
}
Expand All @@ -305,7 +305,7 @@ func cmdSync(ctx context.Context, args []string) int {
}
if dir == files.Upload && !sizeOnly && !checksum {
if m.OS.IsUnix() {
cfg.SetRemoteMTimes = remoteMTimeSetter(sess, m, host)
cfg.SetRemoteMTimes = remoteMTimeSetter(sess, m, agent)
} else {
fmt.Fprintln(os.Stderr, "note: cannot set mtimes on a Windows remote; comparing by size only")
}
Expand Down Expand Up @@ -335,7 +335,7 @@ func cmdSync(ctx context.Context, args []string) int {
// remoteMTimeSetter returns a function that sets mtimes on *nix remote files via
// the shell (the filesystem app has no set-mtime command). Failures are warned
// and swallowed (best-effort), so the sync still succeeds.
func remoteMTimeSetter(sess *session.Session, m *remote.Machine, host string) func(context.Context, map[string]time.Time) error {
func remoteMTimeSetter(sess *session.Session, m *remote.Machine, agent string) func(context.Context, map[string]time.Time) error {
return func(ctx context.Context, times map[string]time.Time) error {
var b strings.Builder
flush := func() error {
Expand Down Expand Up @@ -431,21 +431,21 @@ func cmdRm(ctx context.Context, args []string) int {
return 2
}
if len(pos) < 1 {
return fail("usage: dwshell rm <host>:<path> [<host>:<path>...]")
return fail("usage: dwshell rm <agent>:<path> [<agent>:<path>...]")
}

// All targets must be on the same host.
var host string
// All targets must be on the same agent.
var agent string
var rpaths []string
for _, p := range pos {
h, rp, err := parseRemote(p)
if err != nil {
return fail("%v", err)
}
if host == "" {
host = h
} else if h != host {
return fail("all paths must be on the same host (%q vs %q)", host, h)
if agent == "" {
agent = h
} else if h != agent {
return fail("all paths must be on the same agent (%q vs %q)", agent, h)
}
rpaths = append(rpaths, rp)
}
Expand All @@ -458,7 +458,7 @@ func cmdRm(ctx context.Context, args []string) int {
if err != nil {
return fail("%v", err)
}
m, sess, err := c.ConnectApp(ctx, host, filter, "filesystem")
m, sess, err := c.ConnectApp(ctx, agent, filter, "filesystem")
if err != nil {
return fail("%v", err)
}
Expand All @@ -474,7 +474,7 @@ func cmdRm(ctx context.Context, args []string) int {
rc = fail("%v", err)
continue
}
fmt.Fprintf(os.Stderr, "removed %s:%s (recursively)\n", host, rp)
fmt.Fprintf(os.Stderr, "removed %s:%s (recursively)\n", agent, rp)
}
return rc
}
Expand Down Expand Up @@ -502,7 +502,7 @@ func cmdRm(ctx context.Context, args []string) int {
}
for _, dir := range order {
for _, n := range byDir[dir] {
fmt.Fprintf(os.Stderr, "removed %s:%s%s\n", host, dir, n)
fmt.Fprintf(os.Stderr, "removed %s:%s%s\n", agent, dir, n)
}
}
return 0
Expand All @@ -524,10 +524,10 @@ func cmdPut(ctx context.Context, args []string) int {
return 2
}
if len(pos) != 2 {
return fail("usage: dwshell put <local> <host>:<remote>")
return fail("usage: dwshell put <local> <agent>:<remote>")
}
local := pos[0]
host, rpath, err := parseRemote(pos[1])
agent, rpath, err := parseRemote(pos[1])
if err != nil {
return fail("%v", err)
}
Expand All @@ -540,7 +540,7 @@ func cmdPut(ctx context.Context, args []string) int {
if err != nil {
return fail("%v", err)
}
m, sess, err := c.ConnectApp(ctx, host, filter, "filesystem")
m, sess, err := c.ConnectApp(ctx, agent, filter, "filesystem")
if err != nil {
return fail("%v", err)
}
Expand All @@ -557,7 +557,7 @@ func cmdPut(ctx context.Context, args []string) int {
if err != nil {
return fail("%v", err)
}
fmt.Fprintf(os.Stderr, "uploaded %d files (%d bytes) → %s:%s\n", count, total, host, rpath)
fmt.Fprintf(os.Stderr, "uploaded %d files (%d bytes) → %s:%s\n", count, total, agent, rpath)
return 0
}

Expand All @@ -576,7 +576,7 @@ func cmdPut(ctx context.Context, args []string) int {
if n >= 0 {
sz = fmt.Sprintf("%d bytes", n)
}
fmt.Fprintf(os.Stderr, "uploaded %s (%s) → %s:%s\n", local, sz, host, rpath)
fmt.Fprintf(os.Stderr, "uploaded %s (%s) → %s:%s\n", local, sz, agent, rpath)
return 0
}

Expand Down
32 changes: 16 additions & 16 deletions cmd/dwshell/files_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,25 @@ import "testing"

func TestParseRemote(t *testing.T) {
tests := []struct {
in string
host, path string
ok bool
in string
agent, path string
ok bool
}{
{"GHE:/etc/hostname", "GHE", "/etc/hostname", true},
{"GHE:C:\\Users\\me", "GHE", "C:\\Users\\me", true}, // first colon splits; Windows remote path survives
{"myhost:/", "myhost", "/", true},
{"/local/path", "", "", false}, // no colon
{":/nohost", "", "", false}, // empty host
{"host:", "host", "", true}, // empty path means the remote root
{":/nohost", "", "", false}, // empty agent
{"agent:", "agent", "", true}, // empty path means the remote root
}
for _, tc := range tests {
host, p, err := parseRemote(tc.in)
agent, p, err := parseRemote(tc.in)
if tc.ok {
if err != nil || host != tc.host || p != tc.path {
t.Errorf("parseRemote(%q) = (%q,%q,%v), want (%q,%q,nil)", tc.in, host, p, err, tc.host, tc.path)
if err != nil || agent != tc.agent || p != tc.path {
t.Errorf("parseRemote(%q) = (%q,%q,%v), want (%q,%q,nil)", tc.in, agent, p, err, tc.agent, tc.path)
}
} else if err == nil {
t.Errorf("parseRemote(%q) expected error, got (%q,%q)", tc.in, host, p)
t.Errorf("parseRemote(%q) expected error, got (%q,%q)", tc.in, agent, p)
}
}
}
Expand Down Expand Up @@ -74,13 +74,13 @@ func TestCanonicalRemotePath(t *testing.T) {

func TestIsRemoteEndpoint(t *testing.T) {
cases := map[string]bool{
"GHE:/etc": true,
"GHE:C:\\Users": true,
`C:\Users`: false, // local Windows drive
"C:/Users": false,
"/local/path": false,
"./rel": false,
"host:relative": true,
"GHE:/etc": true,
"GHE:C:\\Users": true,
`C:\Users`: false, // local Windows drive
"C:/Users": false,
"/local/path": false,
"./rel": false,
"agent:relative": true,
}
for in, want := range cases {
if got := isRemoteEndpoint(in); got != want {
Expand Down
Loading