1.0: ssh install that never replaces what it did not read, current dependencies, a real --version #28
@@ -16,6 +16,7 @@ linters:
|
|||||||
- depguard # Dependency allow/block lists
|
- depguard # Dependency allow/block lists
|
||||||
- godot # Requires comments to end with periods
|
- godot # Requires comments to end with periods
|
||||||
- wsl # Deprecated, replaced by wsl_v5
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
|
- gomodguard # Deprecated, replaced by gomodguard_v2
|
||||||
- wrapcheck # Too verbose for internal packages
|
- wrapcheck # Too verbose for internal packages
|
||||||
- varnamelen # Short names like db, id are idiomatic Go
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
settings:
|
settings:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ setup:
|
|||||||
@script/setup
|
@script/setup
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -trimpath -ldflags "$(LDFLAGS)" -o keyfunc .
|
go build -trimpath -ldflags "$(LDFLAGS)" -o keyfunc ./cmd/keyfunc
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|||||||
@@ -1,16 +1,74 @@
|
|||||||
# keyfunc
|
# keyfunc
|
||||||
|
|
||||||
`keyfunc` turns a BIP-39 mnemonic into key pairs that can be recreated from
|
`keyfunc` is a Go command-line tool by [@sneak](https://sneak.berlin) — its
|
||||||
that mnemonic at any time. The same mnemonic, key type and index always give the
|
license is not yet chosen
|
||||||
same key.
|
([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)) — that turns a BIP-39
|
||||||
|
mnemonic into SSH keys, age identities and child mnemonics, each of which can be
|
||||||
|
recreated from that mnemonic at any time. The same mnemonic, key type and index
|
||||||
|
always give the same key.
|
||||||
|
|
||||||
It uses the BIP-85 entropy deriver from `git.eeqj.de/sneak/secret/pkg/bip85` and
|
It uses the BIP-85 entropy deriver from `git.eeqj.de/sneak/secret/pkg/bip85` and
|
||||||
takes the same steps as that repository's `agehd` package.
|
takes the same steps as that repository's `agehd` package.
|
||||||
|
|
||||||
Commands are grouped by what is derived: `keyfunc ssh ...` for ed25519 SSH
|
Commands are grouped by what is derived: `keyfunc ssh ...` for ed25519 SSH keys,
|
||||||
keys, `keyfunc age ...` for age identities and for encrypting and decrypting
|
`keyfunc age ...` for age identities and for encrypting and decrypting with
|
||||||
with them, and `keyfunc mnemonic ...` for child mnemonics derived from the
|
them, and `keyfunc mnemonic ...` for child mnemonics derived from the main one.
|
||||||
main one.
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
Build from a clone and run the binary:
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone git@git.eeqj.de:sneak/keyfunc.git
|
||||||
|
cd keyfunc
|
||||||
|
make build
|
||||||
|
./keyfunc --version
|
||||||
|
```
|
||||||
|
|
||||||
|
`make build` produces `./keyfunc`. Every deriving command needs a mnemonic; see
|
||||||
|
[Giving it the mnemonic](#giving-it-the-mnemonic) for where it is read from, then
|
||||||
|
for example:
|
||||||
|
|
||||||
|
```
|
||||||
|
./keyfunc ssh pub -n 0 --mnemonic-command 'secret get foo'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
A key you can derive again never has to be backed up. One mnemonic, kept safe
|
||||||
|
once, stands behind every key this tool produces: lose a laptop and the SSH key,
|
||||||
|
the age identity and any child mnemonic on it come back from the mnemonic alone,
|
||||||
|
at the same index, byte for byte. Nothing else has to be written down, copied
|
||||||
|
between machines, or stored in a secret manager, because it can always be
|
||||||
|
derived again.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
The entry point is a thin `cmd/keyfunc/main.go` (what `make build` builds) that
|
||||||
|
calls into `internal/`. The packages there are:
|
||||||
|
|
||||||
|
- `internal/derive` turns a mnemonic into the 32 bytes a key is made from: it
|
||||||
|
walks BIP-39 seed, BIP-32 master key and BIP-85 entropy, and holds the shared
|
||||||
|
constants (the byte count and the largest key index).
|
||||||
|
- `internal/mnemonic` finds the mnemonic to work from — a command, an
|
||||||
|
environment variable, or a terminal prompt — and refuses one that fails the
|
||||||
|
BIP-39 checksum.
|
||||||
|
- `internal/sshkey` turns the derived bytes into an ed25519 SSH key
|
||||||
|
(`sshkey.go`) and serves that key from an in-process SSH agent on a private
|
||||||
|
unix socket, keeping it out of any file (`agent.go`).
|
||||||
|
- `internal/agekey` turns the derived bytes into an age identity and encrypts
|
||||||
|
and decrypts with it.
|
||||||
|
- `internal/childmnemonic` derives a child mnemonic from the main one using
|
||||||
|
BIP-85's own mnemonic application.
|
||||||
|
- `internal/cli` builds the cobra command tree and runs it. Under it,
|
||||||
|
`cli/options` holds the flags every command shares, and `cli/ssh`, `cli/age`
|
||||||
|
and `cli/mnemonic` are the command groups.
|
||||||
|
|
||||||
|
### Adding a key type
|
||||||
|
|
||||||
|
Adding a key type is one package under `internal/` that turns the 32 derived
|
||||||
|
bytes into that type's key, plus one cobra subcommand under `internal/cli/` that
|
||||||
|
groups its commands.
|
||||||
|
|
||||||
## Derivation
|
## Derivation
|
||||||
|
|
||||||
@@ -54,8 +112,14 @@ If none of these is available and standard input is not a terminal, the tool
|
|||||||
refuses and exits with status 1. A mnemonic that fails the BIP-39 checksum is
|
refuses and exits with status 1. A mnemonic that fails the BIP-39 checksum is
|
||||||
refused with a message saying so.
|
refused with a message saying so.
|
||||||
|
|
||||||
|
`KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` are removed from the
|
||||||
|
environment before the system `ssh` (`keyfunc ssh to`) and `sftp`
|
||||||
|
(`keyfunc ssh install`) are started, so the mnemonic is never handed on to
|
||||||
|
them.
|
||||||
|
|
||||||
Every command takes `--index` / `-n` and `--mnemonic-command`, and has `--help`.
|
Every command takes `--index` / `-n` and `--mnemonic-command`, and has `--help`.
|
||||||
`keyfunc --version` prints the version set at build time.
|
`keyfunc --version` prints the version. `make build` stamps it; a binary
|
||||||
|
installed with `go install` reports the module version instead.
|
||||||
|
|
||||||
## SSH keys: `keyfunc ssh`
|
## SSH keys: `keyfunc ssh`
|
||||||
|
|
||||||
@@ -88,17 +152,51 @@ Prints the unencrypted private key in OpenSSH format (the
|
|||||||
and nothing else, so it can be redirected into a file. The key's comment is the
|
and nothing else, so it can be redirected into a file. The key's comment is the
|
||||||
same as for `pub`.
|
same as for `pub`.
|
||||||
|
|
||||||
### `keyfunc ssh install <[user@]host> [-- ssh options...]`
|
### `keyfunc ssh install <[user@]host> [-- sftp options...]`
|
||||||
|
|
||||||
Runs the system `ssh` to the host and, on the host:
|
Adds the `pub` line to `~/.ssh/authorized_keys` on the host. No command is run
|
||||||
|
on the host: the file is fetched, changed here, and written back with the
|
||||||
|
system `sftp` client in batch mode.
|
||||||
|
|
||||||
- creates `~/.ssh` with mode `0700` if it is missing;
|
The first connection lists `~/.ssh` and then fetches
|
||||||
- creates `~/.ssh/authorized_keys` with mode `0600` if it is missing;
|
`~/.ssh/authorized_keys` from it. The file reads as empty in two cases only:
|
||||||
- appends the `pub` line only if an identical line is not already there.
|
`sftp` reported `~/.ssh` itself as not being there, or the listing came up and
|
||||||
|
the file was not in it. Any other outcome of that connection fails the run — a
|
||||||
|
`~/.ssh` that is there but cannot be entered, an `authorized_keys` that is there
|
||||||
|
but cannot be read, or a connection that did not come up — and the tool prints
|
||||||
|
what `sftp` said and exits with status 1 without writing anything, rather than
|
||||||
|
put a file back holding the new key alone. The listing is what tells a missing
|
||||||
|
directory from one shut to the user, which `sftp` reports on a fetch the same
|
||||||
|
way; the wording of a missing file elsewhere does not count either, since `ssh`
|
||||||
|
writes `No such file or directory` about an `-i` it cannot find on a session
|
||||||
|
that then authenticates through the agent. If an identical line is already in
|
||||||
|
the file, the tool prints `already present` and connects no further. Otherwise
|
||||||
|
the line is added (after a newline, if the file did not end with one) and a
|
||||||
|
second connection:
|
||||||
|
|
||||||
It then prints `added` or `already present`. How this `ssh` connection
|
- makes `~/.ssh` and sets it to mode `0700`, but only when the first connection
|
||||||
authenticates is up to the user's normal `ssh` setup (existing keys, agent,
|
found none; a `~/.ssh` that was already there keeps the mode it had;
|
||||||
password). Anything after `--` is passed to `ssh` unchanged.
|
- uploads the new file as `~/.ssh/authorized_keys.keyfunc-<random>` and sets it
|
||||||
|
to mode `0600`;
|
||||||
|
- renames that file over `~/.ssh/authorized_keys`.
|
||||||
|
|
||||||
|
The tool then prints `added`. So a run that adds a line connects twice. The
|
||||||
|
rename is the step that either happens or does not: the file on the host is
|
||||||
|
never half-written. `sftp` does it in one step against servers that offer
|
||||||
|
OpenSSH's POSIX rename extension, as OpenSSH's own server does; a server
|
||||||
|
without it may refuse to rename onto a file that is already there.
|
||||||
|
|
||||||
|
If a step fails, the tool prints what `sftp` said, removes nothing, and exits
|
||||||
|
with status 1. It names the uploaded file only when the step that failed was
|
||||||
|
the upload or one after it, which is where a file of that name can be on the
|
||||||
|
host; a failure before the upload names none. Everything `sftp`
|
||||||
|
writes goes to standard error, so the tool's own standard output is only
|
||||||
|
`added` or `already present`.
|
||||||
|
|
||||||
|
Anything after `--` is passed to `sftp` unchanged, which is where the port goes
|
||||||
|
(`-P 2222`, not `-p`). How the connection authenticates is up to the user's
|
||||||
|
normal `ssh` setup, except that batch mode does not prompt: a key or an agent
|
||||||
|
has to do it, not a typed password.
|
||||||
|
|
||||||
### `keyfunc ssh to <host> [ssh arguments...]`
|
### `keyfunc ssh to <host> [ssh arguments...]`
|
||||||
|
|
||||||
@@ -106,7 +204,9 @@ Derives the key, serves it from an SSH agent that runs inside the tool on a unix
|
|||||||
socket in a new private `0700` temporary directory, then runs the system `ssh`
|
socket in a new private `0700` temporary directory, then runs the system `ssh`
|
||||||
with `-o IdentityAgent=<that socket>` followed by the host and all remaining
|
with `-o IdentityAgent=<that socket>` followed by the host and all remaining
|
||||||
arguments unchanged. The tool exits with `ssh`'s exit status and removes the
|
arguments unchanged. The tool exits with `ssh`'s exit status and removes the
|
||||||
socket and directory on the way out. The private key is never written to disk.
|
socket and directory on the way out. The private key is never written to disk. A
|
||||||
|
SIGINT, SIGTERM or SIGHUP ends `ssh` and still removes the socket and directory,
|
||||||
|
and the tool then exits with status 1 unless `ssh` reported one of its own.
|
||||||
|
|
||||||
## age identities: `keyfunc age`
|
## age identities: `keyfunc age`
|
||||||
|
|
||||||
@@ -116,6 +216,15 @@ the same steps `sneak/secret` takes in its `agehd` package. `secret` derives at
|
|||||||
a vendor-specific path today; for its keys to equal this tool's it moves to
|
a vendor-specific path today; for its keys to equal this tool's it moves to
|
||||||
this path, which is a change in `secret`, not here.
|
this path, which is a change in `secret`, not here.
|
||||||
|
|
||||||
|
Test vectors, mnemonic
|
||||||
|
`abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about`:
|
||||||
|
|
||||||
|
```
|
||||||
|
recipient index 0: age1xwdy9y6ckyfsgjc8k02e9uhsf3fmjy0ufysewlj68kmx5n67e3nsg2mftq
|
||||||
|
recipient index 1: age1pmm92sxaf5mazjwvjph7dx2zq9r5p8l3rarfgqm7hmakqhvgyy4q5p3w7j
|
||||||
|
identity index 0: AGE-SECRET-KEY-19QKK2P38598XLXMQFFU3P7J9PLDD7527T70JDHGDJ7AMNF3XT44S00JFU5
|
||||||
|
```
|
||||||
|
|
||||||
### `keyfunc age pub`
|
### `keyfunc age pub`
|
||||||
|
|
||||||
Prints the recipient, the `age1...` public key, on one line.
|
Prints the recipient, the `age1...` public key, on one line.
|
||||||
@@ -148,33 +257,64 @@ not through step 4). Default 12 words. A child mnemonic is a full mnemonic in
|
|||||||
its own right: it can seed another `keyfunc`, another wallet, or `secret`, and
|
its own right: it can seed another `keyfunc`, another wallet, or `secret`, and
|
||||||
it never has to be written down, since it can be derived again.
|
it never has to be written down, since it can be derived again.
|
||||||
|
|
||||||
## Adding a key type
|
Test vector: the child-mnemonic step is checked against BIP-85's own published
|
||||||
|
vectors, which derive from the specification's master key
|
||||||
|
`xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb`.
|
||||||
|
At key index 0 the 12-word English child mnemonic is:
|
||||||
|
|
||||||
Adding a key type is one package under `internal/` that turns the 32 derived
|
```
|
||||||
bytes into that type's key, plus one cobra subcommand under `internal/cli/` that
|
girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose
|
||||||
groups its commands.
|
```
|
||||||
|
|
||||||
## Errors
|
## Errors
|
||||||
|
|
||||||
Errors go to standard error and the exit status is 1, except for `ssh to`,
|
Errors go to standard error and the exit status is 1, except for `ssh to`,
|
||||||
which passes through `ssh`'s own exit status.
|
which passes through `ssh`'s own exit status.
|
||||||
|
|
||||||
## Building and running
|
## Entrypoints
|
||||||
|
|
||||||
```
|
The repo adheres to the
|
||||||
make build # produces ./keyfunc
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
make check # fmt-check, lint (golangci-lint) and tests
|
standard: most Makefile targets are thin shims over an executable in
|
||||||
```
|
`script/` (`build` and `clean` are the exceptions).
|
||||||
|
|
||||||
Examples:
|
- `script/bootstrap` installs everything needed to build and develop (git, make,
|
||||||
|
Go), idempotently, from nix, apt, brew or apk; it does not install the linter,
|
||||||
|
which only runs inside Docker.
|
||||||
|
- `script/setup` prepares a fresh clone: it runs `bootstrap`, then installs the
|
||||||
|
git pre-commit hook.
|
||||||
|
- `script/projectname` prints the project name; other scripts call it so they
|
||||||
|
stay identical across repos.
|
||||||
|
- `script/test` runs `go vet` and then the test suite, rerunning verbosely if a
|
||||||
|
test fails.
|
||||||
|
- `script/lint` runs the linter inside the image built from `Dockerfile.lint`
|
||||||
|
(which pins the linter by hash), so a complaint fails the build and leaves no
|
||||||
|
container behind.
|
||||||
|
- `script/fmt` formats the Go source in place.
|
||||||
|
- `script/fmt-check` checks that formatting without writing, failing if anything
|
||||||
|
is unformatted.
|
||||||
|
- `script/check` runs `test`, `lint` and `fmt-check` and changes no files.
|
||||||
|
- `script/docker` builds the Docker image tagged with the project name.
|
||||||
|
- `script/cibuild` is the CI build the Gitea workflow calls: it runs the linter,
|
||||||
|
then `docker build`.
|
||||||
|
- `script/precommit` is what the git pre-commit hook runs: `go mod tidy` and
|
||||||
|
`go fmt`, failing if `go.mod` or `go.sum` changed, then `check`.
|
||||||
|
- `script/install-precommit` installs the git pre-commit hook that runs
|
||||||
|
`script/precommit`.
|
||||||
|
|
||||||
```
|
## TODO
|
||||||
keyfunc ssh pub -n 3 --mnemonic-command 'secret get foo'
|
|
||||||
keyfunc ssh priv -n 3 > ~/.ssh/id_bip85_3
|
The open issues that stand between the tree and a 1.0 release:
|
||||||
keyfunc ssh install -n 3 user@example.com
|
|
||||||
keyfunc ssh to -n 3 user@example.com uptime
|
- [#14 Choose a license and add LICENSE](https://git.eeqj.de/sneak/keyfunc/issues/14)
|
||||||
keyfunc age pub -n 0
|
- [#15 Decide the Go module path before 1.0](https://git.eeqj.de/sneak/keyfunc/issues/15)
|
||||||
keyfunc age encrypt -n 0 --armor -o notes.age notes.txt
|
|
||||||
keyfunc age decrypt -n 0 notes.age
|
## License
|
||||||
keyfunc mnemonic -n 1 --words 24
|
|
||||||
```
|
Not yet chosen. The license is the owner's decision, still open on the tracker
|
||||||
|
([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)); the `LICENSE` file is added
|
||||||
|
when that issue is answered.
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
[@sneak](https://sneak.berlin).
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
module git.eeqj.de/sneak/keyfunc
|
module git.eeqj.de/sneak/keyfunc
|
||||||
|
|
||||||
go 1.26
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/age v1.2.1
|
filippo.io/age v1.3.2
|
||||||
git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd
|
git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd
|
||||||
github.com/btcsuite/btcd v0.24.2
|
github.com/btcsuite/btcd v0.25.0
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.6
|
github.com/btcsuite/btcd/btcutil v1.2.0
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/stretchr/testify v1.8.4
|
github.com/stretchr/testify v1.12.1
|
||||||
github.com/tyler-smith/go-bip39 v1.1.0
|
github.com/tyler-smith/go-bip39 v1.1.0
|
||||||
golang.org/x/crypto v0.38.0
|
golang.org/x/crypto v0.57.0
|
||||||
golang.org/x/term v0.32.0
|
golang.org/x/term v0.46.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3 // indirect
|
filippo.io/hpke v0.4.0 // indirect
|
||||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
github.com/btcsuite/btcd/btcec/v2 v2.5.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0 // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,136 +1,50 @@
|
|||||||
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0=
|
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs=
|
||||||
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
|
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
|
||||||
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
|
filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c=
|
||||||
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
|
filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk=
|
||||||
|
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
|
||||||
|
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
|
||||||
git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd h1:6YFV6horz2wDFPWWhour8qx8gLGyO0qoplwEeOuQ2J4=
|
git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd h1:6YFV6horz2wDFPWWhour8qx8gLGyO0qoplwEeOuQ2J4=
|
||||||
git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd/go.mod h1:gKCcMZvlBOqusn/BxR8IyFmSJQr6R4vvjJ926iNpOSI=
|
git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd/go.mod h1:gKCcMZvlBOqusn/BxR8IyFmSJQr6R4vvjJ926iNpOSI=
|
||||||
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
github.com/btcsuite/btcd v0.25.0 h1:JPbjwvHGpSywBRuorFFqTjaVP4y6Qw69XJ1nQ6MyWJM=
|
||||||
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
github.com/btcsuite/btcd v0.25.0/go.mod h1:qbPE+pEiR9643E1s1xu57awsRhlCIm1ZIi6FfeRA4KE=
|
||||||
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
|
github.com/btcsuite/btcd/btcec/v2 v2.5.0 h1:KioMXOWa76b86sTZZOmbzv/ldaQCmB8KFAyn5PbB8E8=
|
||||||
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
|
github.com/btcsuite/btcd/btcec/v2 v2.5.0/go.mod h1:+K/MYXcLBtHEQjRbjHuJChuybk4LCgjdjgRwil+e+Kk=
|
||||||
github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY=
|
github.com/btcsuite/btcd/btcutil v1.2.0 h1:p3+S2g3Q+7G5NOh4Ji+2UrBOrg5Z0Q4ykzShWG1Dhgs=
|
||||||
github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg=
|
github.com/btcsuite/btcd/btcutil v1.2.0/go.mod h1:/Taflm113pYjUpbWKKQEfa6XOtI/+WS8awxeMZpY75k=
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0 h1:yMIg99+4aBvqfl/HzJRKfxTX9rGfikoI9uvFzterhc8=
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3 h1:xM/n3yIhHAhHy04z4i43C8p4ehixJZMsnrVJkgl+MTE=
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0/go.mod h1:Y72Ren9gfhlEvnwnT78BGcSNO2UMphTKLn9AorF+5rg=
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
|
|
||||||
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
|
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
|
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00=
|
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.6 h1:zFL2+c3Lb9gEgqKNzowKUPQNb8jV7v5Oaodi/AYFd6c=
|
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.6/go.mod h1:9dFymx8HpuLqBnsPELrImQeTQfKBQqzqGbbV3jK55aE=
|
|
||||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
|
||||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
|
||||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
|
|
||||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
|
||||||
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
|
|
||||||
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
|
|
||||||
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
|
|
||||||
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
|
|
||||||
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
|
|
||||||
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
|
||||||
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
|
||||||
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
|
||||||
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
|
||||||
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
|
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
|
||||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
|
||||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
|
||||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
|
||||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
|
||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
|
||||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee h1:FPP9HDkBbPyniu+u7FHZg+kKFX1WW0gxOGteJ0h3AJk=
|
||||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee/go.mod h1:N6sz6HwJAenJ6d+/xmSl0ikfV05ZrVGmjt1ryy/WOtE=
|
||||||
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
|
||||||
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
|
||||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
|
||||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
|
||||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
|
||||||
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
|
|
||||||
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
|
|
||||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
|
||||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
|
||||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
|
||||||
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
|
|
||||||
github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
|
github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
|
||||||
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
|
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
|
||||||
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||||
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
|
||||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
|
||||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
|
||||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
|
||||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
|
||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
|
||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
|
||||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
|
|||||||
+42
-4
@@ -2,9 +2,13 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"runtime/debug"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/keyfunc/internal/cli/age"
|
"git.eeqj.de/sneak/keyfunc/internal/cli/age"
|
||||||
"git.eeqj.de/sneak/keyfunc/internal/cli/mnemonic"
|
"git.eeqj.de/sneak/keyfunc/internal/cli/mnemonic"
|
||||||
@@ -13,20 +17,43 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version is what --version prints. The build sets it.
|
// devVersion is what Version holds until a build stamps a real one.
|
||||||
|
const devVersion = "dev"
|
||||||
|
|
||||||
|
// Version is what --version prints. make build stamps it with -ldflags.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // set at build time with -ldflags
|
//nolint:gochecknoglobals // set at build time with -ldflags
|
||||||
var Version = "dev"
|
var Version = devVersion
|
||||||
|
|
||||||
|
// resolveVersion chooses what --version reports. A value stamped at
|
||||||
|
// build time wins. Otherwise, for a binary from go install, the module
|
||||||
|
// version recorded in the build info is used, unless that is empty or
|
||||||
|
// the "(devel)" of a local build. When neither names a version, the
|
||||||
|
// "dev" fallback stays.
|
||||||
|
func resolveVersion(stamped string, info *debug.BuildInfo) string {
|
||||||
|
if stamped != devVersion {
|
||||||
|
return stamped
|
||||||
|
}
|
||||||
|
|
||||||
|
if info != nil && info.Main.Version != "" &&
|
||||||
|
info.Main.Version != "(devel)" {
|
||||||
|
return info.Main.Version
|
||||||
|
}
|
||||||
|
|
||||||
|
return devVersion
|
||||||
|
}
|
||||||
|
|
||||||
// Root returns the whole command tree.
|
// Root returns the whole command tree.
|
||||||
func Root() *cobra.Command {
|
func Root() *cobra.Command {
|
||||||
|
info, _ := debug.ReadBuildInfo()
|
||||||
|
|
||||||
root := &cobra.Command{
|
root := &cobra.Command{
|
||||||
Use: "keyfunc",
|
Use: "keyfunc",
|
||||||
Short: "derive key pairs from a BIP-39 mnemonic",
|
Short: "derive key pairs from a BIP-39 mnemonic",
|
||||||
Long: "keyfunc turns a BIP-39 mnemonic into key pairs that can " +
|
Long: "keyfunc turns a BIP-39 mnemonic into key pairs that can " +
|
||||||
"be recreated from that mnemonic at any time. The same " +
|
"be recreated from that mnemonic at any time. The same " +
|
||||||
"mnemonic, key type and index always give the same key.",
|
"mnemonic, key type and index always give the same key.",
|
||||||
Version: Version,
|
Version: resolveVersion(Version, info),
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
}
|
}
|
||||||
@@ -42,8 +69,19 @@ func Root() *cobra.Command {
|
|||||||
// status of its own, which "ssh to" uses to hand on the status ssh
|
// status of its own, which "ssh to" uses to hand on the status ssh
|
||||||
// ended with. ssh has already said whatever it had to say in that
|
// ended with. ssh has already said whatever it had to say in that
|
||||||
// case, so nothing more is printed.
|
// case, so nothing more is printed.
|
||||||
|
//
|
||||||
|
// SIGINT, SIGTERM and SIGHUP cancel the command's context instead of
|
||||||
|
// killing the process outright, so the child ssh or sftp ends and the
|
||||||
|
// deferred cleanup that removes the agent socket and the install
|
||||||
|
// working directory still runs.
|
||||||
func Main() int {
|
func Main() int {
|
||||||
err := Root().Execute()
|
ctx, stop := signal.NotifyContext(
|
||||||
|
context.Background(),
|
||||||
|
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
|
||||||
|
)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
err := Root().ExecuteContext(ctx)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
+294
-56
@@ -1,57 +1,48 @@
|
|||||||
package ssh
|
package ssh
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// script is what runs on the host. It reads the key line from its own
|
// Where the key goes on the host and what the file it arrives in is
|
||||||
// standard input, so the line never appears on a command line, where
|
// called before it is renamed into place. The random end of that name
|
||||||
// anyone else on the host could read it out of the process list. It
|
// keeps two runs at once from writing to the same file.
|
||||||
// contains no single quote, so the whole of it travels through ssh
|
const (
|
||||||
// inside one pair of them. The umask keeps anything it makes to the
|
directory = ".ssh"
|
||||||
// owner from the start; the modes are then set outright, whatever the
|
authorized = ".ssh/authorized_keys"
|
||||||
// umask on the host turns out to be. A file whose last line has no
|
sidecarPrefix = ".ssh/authorized_keys.keyfunc-"
|
||||||
// newline at its end gets one before the key line goes on, so that the
|
sidecarBytes = 8
|
||||||
// two do not run into each other.
|
)
|
||||||
const script = `
|
|
||||||
set -e
|
// The modes the host is left with, as sftp's chmod spells them, and
|
||||||
umask 077
|
// the mode of the copy made here on the way.
|
||||||
directory="$HOME/.ssh"
|
const (
|
||||||
file="$directory/authorized_keys"
|
directoryMode = "700"
|
||||||
if [ ! -d "$directory" ]; then
|
fileMode = "600"
|
||||||
mkdir -p "$directory"
|
localMode = 0o600
|
||||||
chmod 700 "$directory"
|
)
|
||||||
fi
|
|
||||||
if [ ! -f "$file" ]; then
|
|
||||||
: > "$file"
|
|
||||||
chmod 600 "$file"
|
|
||||||
fi
|
|
||||||
IFS= read -r line
|
|
||||||
if grep -q -x -F -e "$line" "$file"; then
|
|
||||||
echo "already present"
|
|
||||||
else
|
|
||||||
if [ -s "$file" ] && [ -n "$(tail -c 1 "$file")" ]; then
|
|
||||||
printf "\n" >> "$file"
|
|
||||||
fi
|
|
||||||
printf "%s\n" "$line" >> "$file"
|
|
||||||
echo "added"
|
|
||||||
fi
|
|
||||||
`
|
|
||||||
|
|
||||||
// install returns the command that adds the public key to a host.
|
// install returns the command that adds the public key to a host.
|
||||||
func install() *cobra.Command {
|
func install() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "install <[user@]host> [-- ssh options...]",
|
Use: "install <[user@]host> [-- sftp options...]",
|
||||||
Short: "add the public key to a host's authorized_keys",
|
Short: "add the public key to a host's authorized_keys",
|
||||||
Long: "Runs the system ssh to the host, which makes ~/.ssh and " +
|
Long: "Downloads the host's authorized_keys with the system " +
|
||||||
"~/.ssh/authorized_keys there if they are missing and adds " +
|
"sftp, adds the public key to it here unless the same " +
|
||||||
"the public key unless the same line is already in the " +
|
"line is already there, and uploads the result as a file " +
|
||||||
"file. Anything after -- is given to ssh unchanged.",
|
"beside it which is then renamed over it. Nothing is run " +
|
||||||
|
"on the host. Anything after -- is given to sftp " +
|
||||||
|
"unchanged, which is where the port goes (-P).",
|
||||||
Args: cobra.MinimumNArgs(1),
|
Args: cobra.MinimumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
key, comment, err := derived(cmd)
|
key, comment, err := derived(cmd)
|
||||||
@@ -64,7 +55,7 @@ func install() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return send(cmd, args[0], args[1:], line)
|
return add(cmd, args[0], args[1:], line)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,24 +64,271 @@ func install() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// send runs ssh to the host with the user's options, gives it the
|
// add puts the key line in the host's authorized_keys. The file is
|
||||||
// script to run there, and writes the key line to its standard input.
|
// fetched in one sftp session and written back in another, so a run
|
||||||
// What the host says, added or already present, is passed straight on.
|
// that adds a line connects twice; a run that finds the line already
|
||||||
func send(cmd *cobra.Command, host string, options []string, line string) error {
|
// there connects once and stops.
|
||||||
argv := slices.Concat(options, []string{
|
func add(cmd *cobra.Command, host string, options []string, line string) error {
|
||||||
host, "/bin/sh -c '" + script + "'",
|
work, err := os.MkdirTemp("", "keyfunc-install-")
|
||||||
})
|
|
||||||
|
|
||||||
//nolint:gosec // the options are the user's own, meant for ssh
|
|
||||||
command := exec.CommandContext(cmd.Context(), "ssh", argv...)
|
|
||||||
command.Stdin = strings.NewReader(line + "\n")
|
|
||||||
command.Stdout = cmd.OutOrStdout()
|
|
||||||
command.Stderr = cmd.ErrOrStderr()
|
|
||||||
|
|
||||||
err := command.Run()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("running ssh: %w", err)
|
return fmt.Errorf("making a temporary directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
defer func() { _ = os.RemoveAll(work) }()
|
||||||
|
|
||||||
|
content, present, err := fetch(cmd, host, options,
|
||||||
|
filepath.Join(work, "authorized_keys"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
merged, added := merge(content, line)
|
||||||
|
if !added {
|
||||||
|
return write(cmd, "already present\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return upload(cmd, host, options, work, merged, present)
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload writes the new file to the host and renames it over
|
||||||
|
// authorized_keys, which is the step that either happens or does not.
|
||||||
|
// Nothing is removed when a step fails: the file left behind is named
|
||||||
|
// so that it can be looked at and cleared away by hand. The directory
|
||||||
|
// is made and set to its mode only when the read found none: an .ssh
|
||||||
|
// that was already there is left with the mode it had.
|
||||||
|
func upload(
|
||||||
|
cmd *cobra.Command, host string, options []string,
|
||||||
|
work, merged string, present bool,
|
||||||
|
) error {
|
||||||
|
local := filepath.Join(work, "authorized_keys.merged")
|
||||||
|
|
||||||
|
err := os.WriteFile(local, []byte(merged), localMode)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("writing the new file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sidecar, err := sidecarName()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var batch []string
|
||||||
|
|
||||||
|
if !present {
|
||||||
|
// The mkdir is allowed to fail in case the directory appeared
|
||||||
|
// between the read and now; the chmod then sets its mode.
|
||||||
|
batch = append(batch,
|
||||||
|
"-mkdir "+directory,
|
||||||
|
"chmod "+directoryMode+" "+directory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
batch = append(batch,
|
||||||
|
"put "+quoted(local)+" "+sidecar,
|
||||||
|
"chmod "+fileMode+" "+sidecar,
|
||||||
|
"rename "+sidecar+" "+authorized,
|
||||||
|
)
|
||||||
|
|
||||||
|
said, err := session(cmd, host, options, batch)
|
||||||
|
if err != nil {
|
||||||
|
// sftp echoes each command as it runs it and stops at the
|
||||||
|
// first that fails, so the name is in what it said only once
|
||||||
|
// the put was reached, which is where a file of that name
|
||||||
|
// can be on the host. Before that there is none to name.
|
||||||
|
if strings.Contains(said, sidecar) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w; %s may be left on the host", err, sidecar,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return write(cmd, "added\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// session runs one sftp session with the user's own options and the
|
||||||
|
// batch of commands, which sftp reads from its standard input and
|
||||||
|
// stops at the first of which that fails, unless it begins with a
|
||||||
|
// dash. sftp echoes the commands as it runs them, so everything it
|
||||||
|
// says goes to the error output and the tool's own output stays the
|
||||||
|
// one word it prints. What it said is also given back: a session that
|
||||||
|
// failed says there what went wrong, and the status alone does not.
|
||||||
|
func session(
|
||||||
|
cmd *cobra.Command, host string, options []string, batch []string,
|
||||||
|
) (string, error) {
|
||||||
|
argv := slices.Concat(
|
||||||
|
[]string{"-b", "-"}, options, []string{host},
|
||||||
|
)
|
||||||
|
|
||||||
|
var said bytes.Buffer
|
||||||
|
|
||||||
|
//nolint:gosec // the options are the user's own, meant for sftp
|
||||||
|
command := exec.CommandContext(cmd.Context(), "sftp", argv...)
|
||||||
|
command.Env = childEnv()
|
||||||
|
command.Stdin = strings.NewReader(strings.Join(batch, "\n") + "\n")
|
||||||
|
command.Stdout = &said
|
||||||
|
command.Stderr = &said
|
||||||
|
|
||||||
|
err := command.Run()
|
||||||
|
|
||||||
|
_, _ = cmd.ErrOrStderr().Write(said.Bytes())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return said.String(), fmt.Errorf("running sftp: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return said.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge returns the file with the key line on the end, and whether it
|
||||||
|
// had to be added. A file whose last line has no newline at its end
|
||||||
|
// gets one first, so that the two lines do not run into each other.
|
||||||
|
func merge(content, line string) (string, bool) {
|
||||||
|
if slices.Contains(strings.Split(content, "\n"), line) {
|
||||||
|
return content, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if content != "" && !strings.HasSuffix(content, "\n") {
|
||||||
|
content += "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return content + line + "\n", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch brings the host's authorized_keys into the given path and
|
||||||
|
// returns what is in it, and whether the .ssh directory was already
|
||||||
|
// there. The one session lists .ssh and then gets the file, so the
|
||||||
|
// listing settles the state of the directory before the get is read.
|
||||||
|
//
|
||||||
|
// The file reads as empty in just two cases: sftp reported .ssh itself
|
||||||
|
// as not there, or the listing succeeded and the get then reported the
|
||||||
|
// file as not there. Anything else — the listing refused, the file
|
||||||
|
// there but unreadable, the connection down — fails the run and writes
|
||||||
|
// nothing, because writing back over what was not read would leave the
|
||||||
|
// host with the new key and nothing else. sftp cannot tell a missing
|
||||||
|
// file from one in a directory it cannot enter, so the listing does:
|
||||||
|
// a directory that is there but cannot be read is a failure, not an
|
||||||
|
// empty file.
|
||||||
|
func fetch(
|
||||||
|
cmd *cobra.Command, host string, options []string, into string,
|
||||||
|
) (string, bool, error) {
|
||||||
|
said, err := session(cmd, host, options, []string{
|
||||||
|
"ls -1 " + directory,
|
||||||
|
"get " + authorized + " " + quoted(into),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if directoryAbsent(said) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if absent(said) {
|
||||||
|
return "", true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gosec // the path is a temporary file of the tool's own
|
||||||
|
content, err := os.ReadFile(into)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("reading the fetched file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(content), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// directoryAbsent says whether sftp reported .ssh itself as not being
|
||||||
|
// there, which is the one listing failure read as a host that has no
|
||||||
|
// authorized_keys yet. The reading is taken only from the line in which
|
||||||
|
// sftp reports on that directory: any other failure of the listing, in
|
||||||
|
// particular a directory that is there but cannot be entered, is left
|
||||||
|
// as a failure, so that no key is written to a host whose keys were
|
||||||
|
// never read.
|
||||||
|
func directoryAbsent(said string) bool {
|
||||||
|
for line := range strings.Lines(said) {
|
||||||
|
named, is := reportedCannotList(strings.TrimSpace(line))
|
||||||
|
if is && (named == directory ||
|
||||||
|
strings.HasSuffix(named, "/"+directory)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportedCannotList returns the path an sftp line reports it cannot
|
||||||
|
// list for want of the directory, and whether the line is such a
|
||||||
|
// report. The client writes this one wording when the directory a
|
||||||
|
// listing names is not there, giving the path the server expanded.
|
||||||
|
func reportedCannotList(line string) (string, bool) {
|
||||||
|
const (
|
||||||
|
before = `Can't ls: "`
|
||||||
|
after = `" not found`
|
||||||
|
)
|
||||||
|
|
||||||
|
if !strings.HasPrefix(line, before) ||
|
||||||
|
!strings.HasSuffix(line, after) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSuffix(strings.TrimPrefix(line, before), after), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// absent says whether sftp reported the file that was asked for as
|
||||||
|
// not being there, which is the one failure of the fetch that is read
|
||||||
|
// as an empty authorized_keys. The reading is taken only from the
|
||||||
|
// line in which sftp reports on that file, because ssh writes "no
|
||||||
|
// such file" into the same output for reasons of its own — a missing
|
||||||
|
// -i identity file draws that warning on a session that then
|
||||||
|
// authenticates through the agent — and a real read failure on such a
|
||||||
|
// session must not pass for an empty file.
|
||||||
|
func absent(said string) bool {
|
||||||
|
for line := range strings.Lines(said) {
|
||||||
|
named, is := reportedNotFound(strings.TrimSpace(line))
|
||||||
|
if is && (named == authorized ||
|
||||||
|
strings.HasSuffix(named, "/"+authorized)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportedNotFound returns the path an sftp line reports as not being
|
||||||
|
// there, and whether the line is such a report. The client writes one
|
||||||
|
// wording for a remote file it cannot find, naming the path the
|
||||||
|
// server expanded, which is the absolute one.
|
||||||
|
func reportedNotFound(line string) (string, bool) {
|
||||||
|
const (
|
||||||
|
before = `File "`
|
||||||
|
after = `" not found.`
|
||||||
|
)
|
||||||
|
|
||||||
|
if !strings.HasPrefix(line, before) ||
|
||||||
|
!strings.HasSuffix(line, after) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSuffix(strings.TrimPrefix(line, before), after), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// sidecarName returns the name the new file is uploaded under.
|
||||||
|
func sidecarName() (string, error) {
|
||||||
|
random := make([]byte, sidecarBytes)
|
||||||
|
|
||||||
|
_, err := rand.Read(random)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("making a name for the new file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sidecarPrefix + hex.EncodeToString(random), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// quoted puts the double quotes around a path that sftp needs when the
|
||||||
|
// path has a space in it. Only paths of the tool's own making are
|
||||||
|
// given to it, and they hold no quote of their own.
|
||||||
|
func quoted(path string) string {
|
||||||
|
return `"` + path + `"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
//nolint:testpackage // absent is what these wordings are read by
|
||||||
|
package ssh
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// What a session says besides its report on the file that was asked
|
||||||
|
// for: sftp echoes the command it is running, and ssh warns about an
|
||||||
|
// identity file it cannot find in the words of a missing file even
|
||||||
|
// though the session goes on to authenticate.
|
||||||
|
const (
|
||||||
|
echoed = `sftp> get .ssh/authorized_keys "/tmp/keyfunc/authorized_keys"
|
||||||
|
`
|
||||||
|
listed = "sftp> ls -1 .ssh\n"
|
||||||
|
warning = `Warning: Identity file /gone not accessible: ` +
|
||||||
|
"No such file or directory.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAbsenceIsReadOnlyFromWhatSFTPSaidAboutAuthorizedKeys holds the
|
||||||
|
// wordings the OpenSSH client was seen to use against a real server:
|
||||||
|
// a file it cannot find is reported one way, naming the path the
|
||||||
|
// server expanded, and everything else it says is a failure.
|
||||||
|
func TestAbsenceIsReadOnlyFromWhatSFTPSaidAboutAuthorizedKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sessions := map[string]struct {
|
||||||
|
said string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
"the file is not there": {
|
||||||
|
said: echoed +
|
||||||
|
`File "/home/someone/.ssh/authorized_keys" not found.` + "\n",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
"the file is not there, named as it was asked for": {
|
||||||
|
said: echoed + `File ".ssh/authorized_keys" not found.` + "\n",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
"the file is not there and an identity file is not either": {
|
||||||
|
said: warning + echoed +
|
||||||
|
`File "/home/someone/.ssh/authorized_keys" not found.` + "\n",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
"the file is there and cannot be read": {
|
||||||
|
said: echoed +
|
||||||
|
`remote open "/home/someone/.ssh/authorized_keys": ` +
|
||||||
|
"Permission denied\n",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
"only an identity file is not there": {
|
||||||
|
said: warning + echoed +
|
||||||
|
`remote open "/home/someone/.ssh/authorized_keys": ` +
|
||||||
|
"Permission denied\n",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
"some other file is not there": {
|
||||||
|
said: echoed + `File "/home/someone/.ssh/known_hosts" not found.` +
|
||||||
|
"\n",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
"the connection did not come up": {
|
||||||
|
said: "ssh: connect to host example.com port 22: " +
|
||||||
|
"Connection refused\nConnection closed\n",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, session := range sessions {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if absent(session.said) != session.want {
|
||||||
|
t.Errorf(
|
||||||
|
"read as absent: %t, wanted %t, from:\n%s",
|
||||||
|
!session.want, session.want, session.said,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo holds the
|
||||||
|
// wordings the OpenSSH client was seen to use when a listing fails: a
|
||||||
|
// directory it cannot find is reported one way, and one it cannot enter
|
||||||
|
// another, and only the first is read as a host with no .ssh yet.
|
||||||
|
func TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
listings := map[string]struct {
|
||||||
|
said string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
"the directory is not there": {
|
||||||
|
said: listed + `Can't ls: "/home/someone/.ssh" not found` + "\n",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
"the directory is not there, named as it was asked for": {
|
||||||
|
said: listed + `Can't ls: ".ssh" not found` + "\n",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
"the directory is not there and an identity file is not either": {
|
||||||
|
said: warning + listed +
|
||||||
|
`Can't ls: "/home/someone/.ssh" not found` + "\n",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
"the directory is there and cannot be entered": {
|
||||||
|
said: listed +
|
||||||
|
`remote readdir("/home/someone/.ssh/"): Permission denied` + "\n",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
"some other directory is not there": {
|
||||||
|
said: listed + `Can't ls: "/home/someone/.config" not found` + "\n",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
"the connection did not come up": {
|
||||||
|
said: "ssh: connect to host example.com port 22: " +
|
||||||
|
"Connection refused\nConnection closed\n",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, listing := range listings {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if directoryAbsent(listing.said) != listing.want {
|
||||||
|
t.Errorf(
|
||||||
|
"read as absent: %t, wanted %t, from:\n%s",
|
||||||
|
!listing.want, listing.want, listing.said,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,12 @@ package ssh
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/keyfunc/internal/cli/options"
|
"git.eeqj.de/sneak/keyfunc/internal/cli/options"
|
||||||
"git.eeqj.de/sneak/keyfunc/internal/derive"
|
"git.eeqj.de/sneak/keyfunc/internal/derive"
|
||||||
|
"git.eeqj.de/sneak/keyfunc/internal/mnemonic"
|
||||||
"git.eeqj.de/sneak/keyfunc/internal/sshkey"
|
"git.eeqj.de/sneak/keyfunc/internal/sshkey"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -84,6 +87,26 @@ func write(cmd *cobra.Command, text string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// childEnv is the tool's environment with the mnemonic variables taken
|
||||||
|
// out, for the ssh and sftp children it starts. "ssh to" exists so the
|
||||||
|
// private key never leaves the tool; the mnemonic, from either variable,
|
||||||
|
// must not leave it either.
|
||||||
|
func childEnv() []string {
|
||||||
|
environ := os.Environ()
|
||||||
|
kept := make([]string, 0, len(environ))
|
||||||
|
|
||||||
|
for _, entry := range environ {
|
||||||
|
name, _, _ := strings.Cut(entry, "=")
|
||||||
|
if name == mnemonic.Variable || name == mnemonic.CommandVariable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
kept = append(kept, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
// addComment gives a command its comment flag.
|
// addComment gives a command its comment flag.
|
||||||
func addComment(cmd *cobra.Command) {
|
func addComment(cmd *cobra.Command) {
|
||||||
cmd.Flags().String(
|
cmd.Flags().String(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"slices"
|
"slices"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -70,10 +71,18 @@ func to() *cobra.Command {
|
|||||||
func connect(ctx context.Context, argv []string) error {
|
func connect(ctx context.Context, argv []string) error {
|
||||||
//nolint:gosec // the arguments are the user's own, meant for ssh
|
//nolint:gosec // the arguments are the user's own, meant for ssh
|
||||||
command := exec.CommandContext(ctx, "ssh", argv...)
|
command := exec.CommandContext(ctx, "ssh", argv...)
|
||||||
|
command.Env = childEnv()
|
||||||
command.Stdin = os.Stdin
|
command.Stdin = os.Stdin
|
||||||
command.Stdout = os.Stdout
|
command.Stdout = os.Stdout
|
||||||
command.Stderr = os.Stderr
|
command.Stderr = os.Stderr
|
||||||
|
|
||||||
|
// A cancelled context means a signal ended the tool. Send ssh a
|
||||||
|
// SIGTERM rather than the default kill, so it puts the terminal
|
||||||
|
// back the way it found it before it goes.
|
||||||
|
command.Cancel = func() error {
|
||||||
|
return command.Process.Signal(syscall.SIGTERM)
|
||||||
|
}
|
||||||
|
|
||||||
err := command.Run()
|
err := command.Run()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+600
-59
@@ -1,11 +1,16 @@
|
|||||||
package cli_test
|
package cli_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/keyfunc/internal/cli"
|
"git.eeqj.de/sneak/keyfunc/internal/cli"
|
||||||
"git.eeqj.de/sneak/keyfunc/internal/cli/ssh"
|
"git.eeqj.de/sneak/keyfunc/internal/cli/ssh"
|
||||||
@@ -13,8 +18,25 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// runAsTool, set in the environment of a re-executed test binary, tells
|
||||||
|
// TestMain to run the tool through Main rather than the suite, so the
|
||||||
|
// signal test can drive the real signal path in a process it can send a
|
||||||
|
// signal to.
|
||||||
|
const runAsTool = "KEYFUNC_TEST_RUN_AS_TOOL"
|
||||||
|
|
||||||
|
// TestMain re-executes the test binary as the tool when runAsTool is
|
||||||
|
// set, and otherwise runs the suite. The signal test starts the tool
|
||||||
|
// this way, as a subprocess it can signal and watch clean up.
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
if os.Getenv(runAsTool) == "1" {
|
||||||
|
os.Exit(cli.Main())
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
// The modes the host is supposed to end up with, and the mode the
|
// The modes the host is supposed to end up with, and the mode the
|
||||||
// stand-in ssh needs so that it can be run at all.
|
// stand-ins need so that they can be run at all.
|
||||||
const (
|
const (
|
||||||
directoryMode = 0o700
|
directoryMode = 0o700
|
||||||
fileMode = 0o600
|
fileMode = 0o600
|
||||||
@@ -22,8 +44,21 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// failingStatus is the status the stand-in ssh ends with when a test
|
// failingStatus is the status the stand-in ssh ends with when a test
|
||||||
// wants to see a status handed on.
|
// wants to see a status handed on, and failedStatus is the status the
|
||||||
const failingStatus = 7
|
// tool itself ends with when something went wrong.
|
||||||
|
const (
|
||||||
|
failingStatus = 7
|
||||||
|
failedStatus = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// notADirectory is what a test puts where the .ssh directory belongs
|
||||||
|
// to make a step of the write session fail.
|
||||||
|
const notADirectory = "a file where the directory belongs\n"
|
||||||
|
|
||||||
|
// missingIdentity is a path with no file at it, handed to sftp after
|
||||||
|
// the dashes so that ssh warns about it in the words of a missing
|
||||||
|
// file.
|
||||||
|
const missingIdentity = "/nonexistent/keyfunc-test-identity"
|
||||||
|
|
||||||
// The host, and where on it the key ends up.
|
// The host, and where on it the key ends up.
|
||||||
const (
|
const (
|
||||||
@@ -32,27 +67,118 @@ const (
|
|||||||
keptIn = "authorized_keys"
|
keptIn = "authorized_keys"
|
||||||
)
|
)
|
||||||
|
|
||||||
// installer is a stand-in for the system ssh for the install command.
|
// remoteCommand is the command the "to" tests hand ssh after the host.
|
||||||
// It writes down what it was given and then runs the command meant for
|
const remoteCommand = "uptime"
|
||||||
// the host right here, with the home directory pointed at a directory
|
|
||||||
// standing in for the host's, so that what keyfunc sends can be
|
// The tool's own name, as it stands in the arguments a test hands to
|
||||||
// watched doing its work.
|
// Main, the ssh subcommand both commands the tests here drive live
|
||||||
|
// under, and the one of those two these tests name most.
|
||||||
|
const (
|
||||||
|
tool = "keyfunc"
|
||||||
|
subcommand = "ssh"
|
||||||
|
installing = "install"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The key line the example mnemonic gives at index 0, as it stands in
|
||||||
|
// an authorized_keys file.
|
||||||
|
const keyLine = vectorZero + " keyfunc/ssh/0\n"
|
||||||
|
|
||||||
|
// marker is a variable set beside the mnemonic ones and expected to
|
||||||
|
// reach the stand-in, so a scrubbed environment is told apart from an
|
||||||
|
// empty one.
|
||||||
|
const marker = "KEYFUNC_TEST_MARKER"
|
||||||
|
|
||||||
|
// installer is a stand-in for the system sftp for the install
|
||||||
|
// command. It writes down the arguments and every command of the
|
||||||
|
// batch it is given, echoes each command as sftp does, writes down its
|
||||||
|
// own environment when a test asks for it, and carries the commands out
|
||||||
|
// against a directory standing in for the host's home directory, so that
|
||||||
|
// what keyfunc sends can be watched doing its work. A command that
|
||||||
|
// begins with a dash may fail; any other failure ends the session, as it
|
||||||
|
// does in sftp's own batch mode.
|
||||||
|
//
|
||||||
|
// The listing and the two ways a get can fail are worded as the
|
||||||
|
// OpenSSH client words them, each naming the path the server expanded.
|
||||||
|
// A listing fails one way when .ssh is not there and another when it is
|
||||||
|
// there but shut to the user; the first is the only failure read as a
|
||||||
|
// host with no file. A get fails one way for a file that is not there,
|
||||||
|
// which after a listing that came up empty is also read as no file, and
|
||||||
|
// another for a file that is there and cannot be read, which is a
|
||||||
|
// failure. A directory shut to the user is stood in for by mode 000,
|
||||||
|
// which the listing reads off the mode itself so that the test does not
|
||||||
|
// turn on the user it runs as. An -i naming a file that is not here
|
||||||
|
// draws the warning ssh writes for it, which carries the wording of a
|
||||||
|
// missing file into a session that goes on to authenticate.
|
||||||
const installer = `
|
const installer = `
|
||||||
while [ $# -gt 1 ]; do
|
[ -n "$KEYFUNC_TEST_ENVIRONMENT" ] && env > "$KEYFUNC_TEST_ENVIRONMENT"
|
||||||
printf '%s\n' "$1" >> "$KEYFUNC_TEST_ARGUMENTS"
|
previous=
|
||||||
shift
|
for argument in "$@"; do
|
||||||
|
printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS"
|
||||||
|
if [ "$previous" = -i ] && [ ! -e "$argument" ]; then
|
||||||
|
printf 'Warning: Identity file %s not accessible: %s.\n' \
|
||||||
|
"$argument" "No such file or directory" >&2
|
||||||
|
fi
|
||||||
|
previous=$argument
|
||||||
|
done
|
||||||
|
home="$KEYFUNC_TEST_HOME"
|
||||||
|
while IFS= read -r line; do
|
||||||
|
printf 'sftp> %s\n' "$line"
|
||||||
|
printf '%s\n' "$line" >> "$KEYFUNC_TEST_BATCH"
|
||||||
|
allowed=no
|
||||||
|
case "$line" in
|
||||||
|
-*)
|
||||||
|
line=${line#-}
|
||||||
|
allowed=yes
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
eval "set -- $line"
|
||||||
|
worked=yes
|
||||||
|
case "$1" in
|
||||||
|
ls)
|
||||||
|
dir=$2
|
||||||
|
[ "$dir" = -1 ] && dir=$3
|
||||||
|
if [ ! -e "$home/$dir" ]; then
|
||||||
|
worked=no
|
||||||
|
printf 'Can'\''t ls: "%s" not found\n' "$home/$dir" >&2
|
||||||
|
elif [ -d "$home/$dir" ] && [ "$(stat -c '%a' "$home/$dir")" = 0 ]; then
|
||||||
|
worked=no
|
||||||
|
printf 'remote readdir("%s/"): Permission denied\n' \
|
||||||
|
"$home/$dir" >&2
|
||||||
|
else
|
||||||
|
for entry in "$home/$dir"/*; do
|
||||||
|
[ -e "$entry" ] || continue
|
||||||
|
printf '%s/%s\n' "$dir" "$(basename "$entry")"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
get)
|
||||||
|
if [ ! -e "$home/$2" ]; then
|
||||||
|
worked=no
|
||||||
|
printf 'File "%s" not found.\n' "$home/$2" >&2
|
||||||
|
elif ! cp "$home/$2" "$3" 2>/dev/null; then
|
||||||
|
worked=no
|
||||||
|
printf 'remote open "%s": Permission denied\n' "$home/$2" >&2
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
put) cp "$2" "$home/$3" 2>/dev/null || worked=no ;;
|
||||||
|
mkdir) mkdir "$home/$2" 2>/dev/null || worked=no ;;
|
||||||
|
chmod) chmod "$2" "$home/$3" 2>/dev/null || worked=no ;;
|
||||||
|
rename) mv "$home/$2" "$home/$3" 2>/dev/null || worked=no ;;
|
||||||
|
esac
|
||||||
|
if [ "$worked" = no ] && [ "$allowed" = no ]; then
|
||||||
|
printf 'sftp: %s failed\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
printf '%s' "$1" > "$KEYFUNC_TEST_COMMAND"
|
|
||||||
HOME="$KEYFUNC_TEST_HOME"
|
|
||||||
export HOME
|
|
||||||
eval "$1"
|
|
||||||
`
|
`
|
||||||
|
|
||||||
// caller is a stand-in for the system ssh for the to command. It
|
// caller is a stand-in for the system ssh for the to command. It
|
||||||
// writes down the arguments it was given, notes the agent socket if
|
// writes down the arguments it was given, notes the agent socket if
|
||||||
// there really is one at the path it was handed, and ends with the
|
// there really is one at the path it was handed, writes down its own
|
||||||
// status the test asked for.
|
// environment when a test asks for it, and ends with the status the
|
||||||
|
// test asked for.
|
||||||
const caller = `
|
const caller = `
|
||||||
|
[ -n "$KEYFUNC_TEST_ENVIRONMENT" ] && env > "$KEYFUNC_TEST_ENVIRONMENT"
|
||||||
for argument in "$@"; do
|
for argument in "$@"; do
|
||||||
printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS"
|
printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS"
|
||||||
done
|
done
|
||||||
@@ -63,24 +189,34 @@ fi
|
|||||||
exit "$KEYFUNC_TEST_STATUS"
|
exit "$KEYFUNC_TEST_STATUS"
|
||||||
`
|
`
|
||||||
|
|
||||||
// pretended is where a stand-in ssh writes down what it was asked to
|
// sleeper is a stand-in for the system ssh that notes the agent socket
|
||||||
// do.
|
// and then blocks, so a test can cancel the context while it is running
|
||||||
|
// and watch the tool take the agent down. The wait ends on its own only
|
||||||
|
// as a backstop, well after the test has cancelled and looked.
|
||||||
|
const sleeper = `
|
||||||
|
socket=${2#IdentityAgent=}
|
||||||
|
if [ -S "$socket" ]; then
|
||||||
|
printf '%s\n' "$socket" > "$KEYFUNC_TEST_SOCKET"
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
`
|
||||||
|
|
||||||
|
// pretended is where a stand-in writes down what it was asked to do.
|
||||||
type pretended struct {
|
type pretended struct {
|
||||||
// home stands in for the home directory on the host.
|
// home stands in for the home directory on the host.
|
||||||
home string
|
home string
|
||||||
// arguments holds what ssh was given before the command, one per
|
// arguments holds the arguments of every session, one per line.
|
||||||
// line.
|
|
||||||
arguments string
|
arguments string
|
||||||
// command holds what ssh was told to run on the host.
|
// batch holds the commands of every session, one per line.
|
||||||
command string
|
batch string
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTheKeyIsAddedToTheHostAndThenLeftAlone(t *testing.T) {
|
func TestTheKeyIsAddedToAHostThatHasNoFileYet(t *testing.T) {
|
||||||
t.Setenv(mnemonic.Variable, example())
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
pretend := pretendHost(t)
|
pretend := pretendHost(t)
|
||||||
|
|
||||||
require.Equal(t, "added\n", run(t, "ssh", "install", host))
|
require.Equal(t, "added\n", install(t, host))
|
||||||
|
|
||||||
directory, err := os.Stat(filepath.Join(pretend.home, keptUnder))
|
directory, err := os.Stat(filepath.Join(pretend.home, keptUnder))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -94,11 +230,30 @@ func TestTheKeyIsAddedToTheHostAndThenLeftAlone(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, os.FileMode(fileMode), file.Mode().Perm())
|
require.Equal(t, os.FileMode(fileMode), file.Mode().Perm())
|
||||||
|
|
||||||
added := read(t, path)
|
require.Equal(t, keyLine, read(t, path))
|
||||||
require.Equal(t, vectorZero+" keyfunc/ssh/0\n", added)
|
}
|
||||||
|
|
||||||
require.Equal(t, "already present\n", run(t, "ssh", "install", host))
|
func TestAKeyThatIsAlreadyThereIsLeftAlone(t *testing.T) {
|
||||||
require.Equal(t, added, read(t, path))
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
pretend := pretendHost(t)
|
||||||
|
path := seed(t, pretend, "somebody else\n"+keyLine)
|
||||||
|
|
||||||
|
require.Equal(t, "already present\n", install(t, host))
|
||||||
|
require.Equal(t, "somebody else\n"+keyLine, read(t, path))
|
||||||
|
|
||||||
|
// The read and nothing after it: the tool did not connect again.
|
||||||
|
require.Equal(t, 1, connections(t, pretend))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnEmptyFileGetsTheKeyAndNoBlankLineBeforeIt(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
pretend := pretendHost(t)
|
||||||
|
path := seed(t, pretend, "")
|
||||||
|
|
||||||
|
require.Equal(t, "added\n", install(t, host))
|
||||||
|
require.Equal(t, keyLine, read(t, path))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTheKeyDoesNotRunIntoALineWithNoNewlineAtItsEnd(t *testing.T) {
|
func TestTheKeyDoesNotRunIntoALineWithNoNewlineAtItsEnd(t *testing.T) {
|
||||||
@@ -106,41 +261,192 @@ func TestTheKeyDoesNotRunIntoALineWithNoNewlineAtItsEnd(t *testing.T) {
|
|||||||
|
|
||||||
pretend := pretendHost(t)
|
pretend := pretendHost(t)
|
||||||
already := "ssh-ed25519 AAAAsomebodyelse somebody@else"
|
already := "ssh-ed25519 AAAAsomebodyelse somebody@else"
|
||||||
|
path := seed(t, pretend, already)
|
||||||
|
|
||||||
require.NoError(t,
|
require.Equal(t, "added\n", install(t, host))
|
||||||
os.Mkdir(filepath.Join(pretend.home, keptUnder), directoryMode),
|
require.Equal(t, already+"\n"+keyLine, read(t, path))
|
||||||
)
|
|
||||||
|
|
||||||
path := filepath.Join(pretend.home, keptUnder, keptIn)
|
|
||||||
require.NoError(t, os.WriteFile(path, []byte(already), fileMode))
|
|
||||||
|
|
||||||
require.Equal(t, "added\n", run(t, "ssh", "install", host))
|
|
||||||
require.Equal(t,
|
|
||||||
already+"\n"+vectorZero+" keyfunc/ssh/0\n",
|
|
||||||
read(t, path),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTheKeyLineIsNotOnTheCommandLine(t *testing.T) {
|
func TestTheFileIsUploadedBesideTheOldOneAndThenRenamedOverIt(t *testing.T) {
|
||||||
t.Setenv(mnemonic.Variable, example())
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
pretend := pretendHost(t)
|
pretend := pretendHost(t)
|
||||||
|
|
||||||
run(t, "ssh", "install", host)
|
require.Equal(t, "added\n", install(t, host))
|
||||||
|
|
||||||
|
sent := recorded(t, pretend.batch)
|
||||||
|
require.Len(t, sent, 6)
|
||||||
|
|
||||||
|
// The name of the uploaded file is random, so it is read off the
|
||||||
|
// put and then looked for in the two commands that follow.
|
||||||
|
beside := strings.Fields(sent[3])[2]
|
||||||
|
require.True(t,
|
||||||
|
strings.HasPrefix(beside, ".ssh/authorized_keys.keyfunc-"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// The listing fails on a host with no .ssh, so the get never runs;
|
||||||
|
// the write session then makes the directory and puts the file.
|
||||||
|
require.Equal(t, "ls -1 .ssh", sent[0])
|
||||||
|
require.Equal(t, "-mkdir .ssh", sent[1])
|
||||||
|
require.Equal(t, "chmod 700 .ssh", sent[2])
|
||||||
|
require.Equal(t, "put", strings.Fields(sent[3])[0])
|
||||||
|
require.Equal(t, "chmod 600 "+beside, sent[4])
|
||||||
|
require.Equal(t, "rename "+beside+" .ssh/authorized_keys", sent[5])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFileThatCannotBeReadIsNotWrittenOver(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
pretend := pretendHost(t)
|
||||||
|
unreadable := unfetchable(t, pretend)
|
||||||
|
|
||||||
|
printed, said, err := attempt(t, host)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, printed)
|
||||||
|
require.Contains(t, said, "Permission denied")
|
||||||
|
|
||||||
|
// The read and nothing after it, and what was on the host is
|
||||||
|
// still what is on the host.
|
||||||
|
require.Equal(t, 1, connections(t, pretend))
|
||||||
|
require.DirExists(t, unreadable)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUnreadableDirectoryIsNotWrittenInto(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
pretend := pretendHost(t)
|
||||||
|
unlistable(t, pretend)
|
||||||
|
|
||||||
|
// The listing is refused, which is not the same as no directory, so
|
||||||
|
// the tool writes nothing rather than treat a directory it cannot
|
||||||
|
// enter as a host with no file.
|
||||||
|
printed, said, err := attempt(t, host)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, printed)
|
||||||
|
require.Contains(t, said, "Permission denied")
|
||||||
|
|
||||||
|
// The read and nothing after it: no second connection wrote a key.
|
||||||
|
require.Equal(t, 1, connections(t, pretend))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnExistingDirectoryKeepsItsModeAndIsNotRemade(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
pretend := pretendHost(t)
|
||||||
|
|
||||||
|
// A directory that is there but holds no file yet, made with a mode
|
||||||
|
// of its own so that a stray chmod would show.
|
||||||
|
const ownMode = 0o755
|
||||||
|
|
||||||
|
directory := filepath.Join(pretend.home, keptUnder)
|
||||||
|
require.NoError(t, os.Mkdir(directory, ownMode))
|
||||||
|
|
||||||
|
require.Equal(t, "added\n", install(t, host))
|
||||||
|
|
||||||
|
// The key is added and the directory keeps the mode it had: the
|
||||||
|
// write session neither made it nor set its mode.
|
||||||
|
require.Equal(t, keyLine, read(t, filepath.Join(directory, keptIn)))
|
||||||
|
|
||||||
|
kept, err := os.Stat(directory)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, os.FileMode(ownMode), kept.Mode().Perm())
|
||||||
|
|
||||||
|
sent := recorded(t, pretend.batch)
|
||||||
|
require.NotContains(t, sent, "-mkdir .ssh")
|
||||||
|
require.NotContains(t, sent, "chmod 700 .ssh")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWarningAboutAnotherFileIsNotTakenForTheOneAskedFor(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
pretend := pretendHost(t)
|
||||||
|
unreadable := unfetchable(t, pretend)
|
||||||
|
|
||||||
|
// ssh warns about an -i it cannot find in the words of a missing
|
||||||
|
// file, on a session that then authenticates perfectly well. That
|
||||||
|
// warning is not sftp reporting on authorized_keys, so the fetch
|
||||||
|
// failure is still a failure.
|
||||||
|
printed, said, err := attempt(t, host, "--", "-i", missingIdentity)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, printed)
|
||||||
|
require.Contains(t, said, "No such file or directory")
|
||||||
|
require.Contains(t, said, "Permission denied")
|
||||||
|
|
||||||
|
require.Equal(t, 1, connections(t, pretend))
|
||||||
|
require.DirExists(t, unreadable)
|
||||||
|
|
||||||
|
// The same run again, this way for the status it ends with.
|
||||||
|
given := os.Args
|
||||||
|
|
||||||
|
t.Cleanup(func() { os.Args = given })
|
||||||
|
|
||||||
|
os.Args = []string{
|
||||||
|
tool, subcommand, installing, host, "--", "-i", missingIdentity,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Equal(t, failedStatus, cli.Main())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFailedStepNamesTheUploadedFileAndChangesNothing(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
pretend := pretendHost(t)
|
||||||
|
|
||||||
|
// A file where the .ssh directory belongs: the listing shows it and
|
||||||
|
// so the directory reads as already there, but then the put has
|
||||||
|
// nowhere to put anything, so the write session ends at the put.
|
||||||
|
inTheWay := filepath.Join(pretend.home, keptUnder)
|
||||||
|
require.NoError(t,
|
||||||
|
os.WriteFile(inTheWay, []byte(notADirectory), fileMode),
|
||||||
|
)
|
||||||
|
|
||||||
|
printed, said, err := attempt(t, host)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, printed)
|
||||||
|
require.Contains(t, said, "put failed")
|
||||||
|
|
||||||
|
// The put is the first and last command the write session got to,
|
||||||
|
// and the file it was uploading is the one the message names.
|
||||||
|
sent := recorded(t, pretend.batch)
|
||||||
|
require.Len(t, sent, 3)
|
||||||
|
require.Equal(t, "put", strings.Fields(sent[2])[0])
|
||||||
|
require.Contains(t, err.Error(), strings.Fields(sent[2])[2])
|
||||||
|
|
||||||
|
require.Equal(t, notADirectory, read(t, inTheWay))
|
||||||
|
|
||||||
|
// The same run again, this way for the status it ends with.
|
||||||
|
given := os.Args
|
||||||
|
|
||||||
|
t.Cleanup(func() { os.Args = given })
|
||||||
|
|
||||||
|
os.Args = []string{tool, subcommand, installing, host}
|
||||||
|
|
||||||
|
require.Equal(t, failedStatus, cli.Main())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheKeyLineIsNotSentAsACommand(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
pretend := pretendHost(t)
|
||||||
|
|
||||||
|
install(t, host)
|
||||||
|
|
||||||
require.NotContains(t, read(t, pretend.arguments), "ssh-ed25519")
|
require.NotContains(t, read(t, pretend.arguments), "ssh-ed25519")
|
||||||
require.NotContains(t, read(t, pretend.command), "ssh-ed25519")
|
require.NotContains(t, read(t, pretend.batch), "ssh-ed25519")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWhatComesAfterTheDashesIsGivenToSSH(t *testing.T) {
|
func TestWhatComesAfterTheDashesIsGivenToSFTP(t *testing.T) {
|
||||||
t.Setenv(mnemonic.Variable, example())
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
pretend := pretendHost(t)
|
pretend := pretendHost(t)
|
||||||
|
|
||||||
run(t, "ssh", "install", host, "--", "-p", "2222")
|
install(t, host, "--", "-P", "2222")
|
||||||
|
|
||||||
|
// The same arguments twice over: adding a line takes two
|
||||||
|
// connections, one to fetch the file and one to write it back.
|
||||||
|
session := []string{"-b", "-", "-P", "2222", host}
|
||||||
require.Equal(t,
|
require.Equal(t,
|
||||||
[]string{"-p", "2222", host},
|
slices.Concat(session, session),
|
||||||
recorded(t, pretend.arguments),
|
recorded(t, pretend.arguments),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -150,7 +456,7 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) {
|
|||||||
|
|
||||||
arguments, noted := pretendCall(t)
|
arguments, noted := pretendCall(t)
|
||||||
|
|
||||||
_, err := execute(t, "ssh", "to", host, "uptime")
|
_, err := execute(t, subcommand, "to", host, remoteCommand)
|
||||||
|
|
||||||
var passed ssh.StatusError
|
var passed ssh.StatusError
|
||||||
|
|
||||||
@@ -159,7 +465,7 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) {
|
|||||||
|
|
||||||
given := recorded(t, arguments)
|
given := recorded(t, arguments)
|
||||||
require.Equal(t, "-o", given[0])
|
require.Equal(t, "-o", given[0])
|
||||||
require.Equal(t, []string{host, "uptime"}, given[2:])
|
require.Equal(t, []string{host, remoteCommand}, given[2:])
|
||||||
|
|
||||||
// The stand-in wrote the path down only because there really was
|
// The stand-in wrote the path down only because there really was
|
||||||
// a socket there while it ran.
|
// a socket there while it ran.
|
||||||
@@ -177,11 +483,130 @@ func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) {
|
|||||||
|
|
||||||
t.Cleanup(func() { os.Args = given })
|
t.Cleanup(func() { os.Args = given })
|
||||||
|
|
||||||
os.Args = []string{"keyfunc", "ssh", "to", host, "uptime"}
|
os.Args = []string{tool, subcommand, "to", host, remoteCommand}
|
||||||
|
|
||||||
require.Equal(t, failingStatus, cli.Main())
|
require.Equal(t, failingStatus, cli.Main())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestASignalTakesTheAgentDirectoryDown(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
// The three signals the tool handles, checked one after another.
|
||||||
|
signals := []struct {
|
||||||
|
name string
|
||||||
|
signal os.Signal
|
||||||
|
}{
|
||||||
|
{"SIGTERM", syscall.SIGTERM},
|
||||||
|
{"SIGINT", syscall.SIGINT},
|
||||||
|
{"SIGHUP", syscall.SIGHUP},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ending := range signals {
|
||||||
|
signalEndsTheTool(t, ending.name, ending.signal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// signalEndsTheTool runs the tool as a subprocess against a stand-in
|
||||||
|
// ssh that blocks, waits until the agent is up and ssh is running
|
||||||
|
// against it, sends the tool the signal, and requires the agent socket
|
||||||
|
// and its directory to be gone once the tool has ended. The subprocess
|
||||||
|
// goes through Main and its signal handling, so with that handling
|
||||||
|
// removed the signal kills the tool outright, no deferred cleanup runs,
|
||||||
|
// the directory is left behind, and the check fails.
|
||||||
|
func signalEndsTheTool(t *testing.T, name string, signal os.Signal) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
noted := filepath.Join(t.TempDir(), "socket")
|
||||||
|
t.Setenv("KEYFUNC_TEST_SOCKET", noted)
|
||||||
|
standIn(t, "ssh", sleeper)
|
||||||
|
|
||||||
|
//nolint:gosec // the binary is this test's own, re-run as the tool
|
||||||
|
command := exec.CommandContext(
|
||||||
|
t.Context(), os.Args[0], subcommand, "to", host, remoteCommand,
|
||||||
|
)
|
||||||
|
|
||||||
|
command.Env = append(os.Environ(), runAsTool+"=1")
|
||||||
|
require.NoError(t, command.Start())
|
||||||
|
|
||||||
|
// The stand-in notes the socket only once the agent is up and ssh
|
||||||
|
// is running against it, so this is where the signal lands.
|
||||||
|
socket := waitForSocket(t, noted)
|
||||||
|
|
||||||
|
require.NoError(t, command.Process.Signal(signal))
|
||||||
|
waitForTool(t, name, command)
|
||||||
|
|
||||||
|
// The signal ended the tool, and its deferred cleanup still ran:
|
||||||
|
// the agent socket and its directory are gone.
|
||||||
|
require.NoDirExists(t, filepath.Dir(socket), name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForTool waits for the subprocess to end, and fails the test if it
|
||||||
|
// does not end in time.
|
||||||
|
func waitForTool(t *testing.T, name string, command *exec.Cmd) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- command.Wait() }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatalf("the tool did not end after %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForSocket waits for the stand-in to write down the agent socket
|
||||||
|
// and gives back the path, which means the agent is up and ssh is
|
||||||
|
// running against it.
|
||||||
|
func waitForSocket(t *testing.T, noted string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var socket string
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
content, err := os.ReadFile(noted) //nolint:gosec // test path
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
socket = strings.TrimSpace(string(content))
|
||||||
|
|
||||||
|
return socket != ""
|
||||||
|
}, 5*time.Second, 5*time.Millisecond)
|
||||||
|
|
||||||
|
return socket
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheMnemonicIsNotHandedToSFTP(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.CommandVariable, "echo "+example())
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
t.Setenv(marker, "reaches the stand-in")
|
||||||
|
|
||||||
|
pretendHost(t)
|
||||||
|
environment := recordEnvironment(t)
|
||||||
|
|
||||||
|
install(t, host)
|
||||||
|
|
||||||
|
mnemonicWithheld(t, read(t, environment))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheMnemonicIsNotHandedToSSH(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.CommandVariable, "echo "+example())
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
t.Setenv(marker, "reaches the stand-in")
|
||||||
|
|
||||||
|
pretendCall(t)
|
||||||
|
environment := recordEnvironment(t)
|
||||||
|
|
||||||
|
_, err := execute(t, subcommand, "to", host, remoteCommand)
|
||||||
|
|
||||||
|
var passed ssh.StatusError
|
||||||
|
|
||||||
|
require.ErrorAs(t, err, &passed)
|
||||||
|
|
||||||
|
mnemonicWithheld(t, read(t, environment))
|
||||||
|
}
|
||||||
|
|
||||||
// pretendHost puts the install stand-in on the path and gives back the
|
// pretendHost puts the install stand-in on the path and gives back the
|
||||||
// places it writes to.
|
// places it writes to.
|
||||||
func pretendHost(t *testing.T) pretended {
|
func pretendHost(t *testing.T) pretended {
|
||||||
@@ -190,17 +615,110 @@ func pretendHost(t *testing.T) pretended {
|
|||||||
pretend := pretended{
|
pretend := pretended{
|
||||||
home: t.TempDir(),
|
home: t.TempDir(),
|
||||||
arguments: filepath.Join(t.TempDir(), "arguments"),
|
arguments: filepath.Join(t.TempDir(), "arguments"),
|
||||||
command: filepath.Join(t.TempDir(), "command"),
|
batch: filepath.Join(t.TempDir(), "batch"),
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Setenv("KEYFUNC_TEST_HOME", pretend.home)
|
t.Setenv("KEYFUNC_TEST_HOME", pretend.home)
|
||||||
t.Setenv("KEYFUNC_TEST_ARGUMENTS", pretend.arguments)
|
t.Setenv("KEYFUNC_TEST_ARGUMENTS", pretend.arguments)
|
||||||
t.Setenv("KEYFUNC_TEST_COMMAND", pretend.command)
|
t.Setenv("KEYFUNC_TEST_BATCH", pretend.batch)
|
||||||
standIn(t, installer)
|
standIn(t, "sftp", installer)
|
||||||
|
|
||||||
return pretend
|
return pretend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// install runs the install command, requires it to have worked, and
|
||||||
|
// gives back what the tool itself printed.
|
||||||
|
func install(t *testing.T, args ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
printed, _, err := attempt(t, args...)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return printed
|
||||||
|
}
|
||||||
|
|
||||||
|
// attempt runs the install command with the tool's own output kept
|
||||||
|
// apart from what the stand-in said, since the stand-in echoes its
|
||||||
|
// batch as sftp does. It gives back what the tool printed, what the
|
||||||
|
// stand-in said, and how the run ended.
|
||||||
|
func attempt(t *testing.T, args ...string) (string, string, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var printed, said bytes.Buffer
|
||||||
|
|
||||||
|
root := cli.Root()
|
||||||
|
root.SetOut(&printed)
|
||||||
|
root.SetErr(&said)
|
||||||
|
root.SetArgs(slices.Concat([]string{subcommand, installing}, args))
|
||||||
|
|
||||||
|
err := root.ExecuteContext(t.Context())
|
||||||
|
|
||||||
|
return printed.String(), said.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// seed puts an authorized_keys file on the stand-in host before the
|
||||||
|
// tool runs and gives back its path.
|
||||||
|
func seed(t *testing.T, pretend pretended, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
directory := filepath.Join(pretend.home, keptUnder)
|
||||||
|
require.NoError(t, os.Mkdir(directory, directoryMode))
|
||||||
|
|
||||||
|
path := filepath.Join(directory, keptIn)
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(content), fileMode))
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// unfetchable puts a directory where authorized_keys belongs on the
|
||||||
|
// stand-in host, which the stand-in can see but cannot fetch: that is
|
||||||
|
// how a file that is there and cannot be read looks from here. It
|
||||||
|
// gives back the path.
|
||||||
|
func unfetchable(t *testing.T, pretend pretended) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
os.Mkdir(filepath.Join(pretend.home, keptUnder), directoryMode),
|
||||||
|
)
|
||||||
|
|
||||||
|
path := filepath.Join(pretend.home, keptUnder, keptIn)
|
||||||
|
require.NoError(t, os.Mkdir(path, directoryMode))
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// unlistable puts a .ssh on the stand-in host that is there but shut to
|
||||||
|
// the user, a directory of mode 000, and gives back its path. Its mode
|
||||||
|
// is put back before the temporary directory is cleared so that it can
|
||||||
|
// be.
|
||||||
|
func unlistable(t *testing.T, pretend pretended) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
directory := filepath.Join(pretend.home, keptUnder)
|
||||||
|
require.NoError(t, os.Mkdir(directory, directoryMode))
|
||||||
|
require.NoError(t, os.Chmod(directory, 0))
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = os.Chmod(directory, directoryMode) })
|
||||||
|
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
|
||||||
|
// connections returns how many times the tool ran sftp, counted from
|
||||||
|
// the -b that opens each session's arguments.
|
||||||
|
func connections(t *testing.T, pretend pretended) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
for _, argument := range recorded(t, pretend.arguments) {
|
||||||
|
if argument == "-b" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
// pretendCall puts the to stand-in on the path and gives back the file
|
// pretendCall puts the to stand-in on the path and gives back the file
|
||||||
// the arguments are written down in and the file the agent socket is
|
// the arguments are written down in and the file the agent socket is
|
||||||
// noted in.
|
// noted in.
|
||||||
@@ -213,20 +731,21 @@ func pretendCall(t *testing.T) (string, string) {
|
|||||||
t.Setenv("KEYFUNC_TEST_ARGUMENTS", arguments)
|
t.Setenv("KEYFUNC_TEST_ARGUMENTS", arguments)
|
||||||
t.Setenv("KEYFUNC_TEST_SOCKET", noted)
|
t.Setenv("KEYFUNC_TEST_SOCKET", noted)
|
||||||
t.Setenv("KEYFUNC_TEST_STATUS", strconv.Itoa(failingStatus))
|
t.Setenv("KEYFUNC_TEST_STATUS", strconv.Itoa(failingStatus))
|
||||||
standIn(t, caller)
|
standIn(t, "ssh", caller)
|
||||||
|
|
||||||
return arguments, noted
|
return arguments, noted
|
||||||
}
|
}
|
||||||
|
|
||||||
// standIn writes a stand-in for the system ssh and puts it first on
|
// standIn writes a stand-in for one of the system programs and puts it
|
||||||
// the path, so that the tool finds it instead of the real one.
|
// first on the path, so that the tool finds it instead of the real
|
||||||
func standIn(t *testing.T, body string) {
|
// one.
|
||||||
|
func standIn(t *testing.T, name, body string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
|
|
||||||
err := os.WriteFile(
|
err := os.WriteFile(
|
||||||
filepath.Join(directory, "ssh"),
|
filepath.Join(directory, name),
|
||||||
[]byte("#!/bin/sh\n"+body), standInMode,
|
[]byte("#!/bin/sh\n"+body), standInMode,
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -236,6 +755,28 @@ func standIn(t *testing.T, body string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordEnvironment asks the stand-in to write its environment down and
|
||||||
|
// gives back the file it writes it to.
|
||||||
|
func recordEnvironment(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "environment")
|
||||||
|
t.Setenv("KEYFUNC_TEST_ENVIRONMENT", path)
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// mnemonicWithheld requires that neither mnemonic variable reached the
|
||||||
|
// stand-in and that the marker set beside them did, so an empty
|
||||||
|
// environment does not pass for a scrubbed one.
|
||||||
|
func mnemonicWithheld(t *testing.T, environment string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NotContains(t, environment, mnemonic.Variable+"=")
|
||||||
|
require.NotContains(t, environment, mnemonic.CommandVariable+"=")
|
||||||
|
require.Contains(t, environment, marker+"=")
|
||||||
|
}
|
||||||
|
|
||||||
// read returns what is in a file.
|
// read returns what is in a file.
|
||||||
func read(t *testing.T, path string) string {
|
func read(t *testing.T, path string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -247,7 +788,7 @@ func read(t *testing.T, path string) string {
|
|||||||
return string(content)
|
return string(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// recorded returns the arguments a stand-in wrote down, one per line.
|
// recorded returns the lines a stand-in wrote down.
|
||||||
func recorded(t *testing.T, path string) []string {
|
func recorded(t *testing.T, path string) []string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime/debug"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
release := &debug.BuildInfo{Main: debug.Module{Version: "v1.2.3"}}
|
||||||
|
local := &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}}
|
||||||
|
empty := &debug.BuildInfo{}
|
||||||
|
|
||||||
|
t.Run("stamped value wins over build info", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
require.Equal(t, "v0.1.0", resolveVersion("v0.1.0", release))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("go install reports the module version", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
require.Equal(t, "v1.2.3", resolveVersion(devVersion, release))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a local build stays dev", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
require.Equal(t, devVersion, resolveVersion(devVersion, local))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no version anywhere stays dev", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
require.Equal(t, devVersion, resolveVersion(devVersion, empty))
|
||||||
|
require.Equal(t, devVersion, resolveVersion(devVersion, nil))
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user