v1.0.0
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
.idea/
|
||||||
|
.giea/
|
||||||
|
.gitignore
|
||||||
|
config.*
|
||||||
|
Dockerfile
|
||||||
|
README.md
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Golang lint
|
||||||
|
run-name: Linting code
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: go-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Verify formatting
|
||||||
|
run: |
|
||||||
|
files="$(gofmt -l .)"
|
||||||
|
if [ -n "$files" ]; then
|
||||||
|
echo "These files are not gofmt-formatted:"
|
||||||
|
echo "$files"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run go test
|
||||||
|
run: go test ./...
|
||||||
|
|
||||||
|
- name: Run go vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: Run golangci-lint
|
||||||
|
run: golangci-lint run
|
||||||
|
|
||||||
|
docker-smoke:
|
||||||
|
runs-on: docker-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Build image and verify SSH client
|
||||||
|
run: ./scripts/docker-smoke-test.sh
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
.idea/
|
||||||
|
config.toml
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
FROM golang:1.26-alpine AS builder
|
||||||
|
USER root
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum /src/
|
||||||
|
RUN go mod download
|
||||||
|
COPY . /src/
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/est ./cmd/est
|
||||||
|
|
||||||
|
FROM alpine:3.24
|
||||||
|
RUN apk add --no-cache openssh-client && addgroup -S est && adduser -S -D -H -G est est
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder --chown=est:est /out/est /app/est
|
||||||
|
USER est:est
|
||||||
|
ENTRYPOINT ["/app/est"]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
docker-build:
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
-t git.scuroneko.dev/scuroneko/est:latest \
|
||||||
|
-t git.scuroneko.dev/scuroneko/est:1.0.0 \
|
||||||
|
--push .
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
# est
|
||||||
|
|
||||||
|
`est` is a small command-line tool that manages SSH port-forwarding tunnels
|
||||||
|
from a TOML configuration file. It starts one `ssh` process per tunnel and
|
||||||
|
shuts every process down when the application receives `SIGINT` (`Ctrl+C`) or
|
||||||
|
`SIGTERM`.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Local forwarding (`rtl`): expose a local port and forward it through SSH to
|
||||||
|
an address reachable from the remote server.
|
||||||
|
- Remote forwarding (`ltr`): expose a port on the remote SSH server and
|
||||||
|
forward it to a local address.
|
||||||
|
- Run multiple tunnels from one configuration file.
|
||||||
|
- Use SSH host aliases, a shared SSH config file, and per-tunnel identity keys.
|
||||||
|
- Fail early when mandatory tunnel fields are missing.
|
||||||
|
- Start SSH with `ExitOnForwardFailure=yes`, so a refused port forward fails
|
||||||
|
the application instead of silently leaving a connected but unusable tunnel.
|
||||||
|
|
||||||
|
`ssh` must be installed and available on `PATH`.
|
||||||
|
|
||||||
|
## Install the CLI
|
||||||
|
|
||||||
|
Install the latest released version with Go:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install git.scuroneko.dev/ScuroNeko/est/cmd/est@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
The command is installed to `$GOBIN`, or to `$(go env GOPATH)/bin` when
|
||||||
|
`GOBIN` is not set. Ensure that directory is on your `PATH`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
|
est --config /etc/est/tunnels.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
For a private Git server, configure the Go tool to fetch the module directly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go env -w GOPRIVATE=git.scuroneko.dev
|
||||||
|
go env -w GONOSUMDB=git.scuroneko.dev
|
||||||
|
go env -w GOPROXY=direct
|
||||||
|
```
|
||||||
|
|
||||||
|
Your Git SSH key or access token must already grant read access to the
|
||||||
|
repository. Pin a version for repeatable deployments:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install git.scuroneko.dev/ScuroNeko/est/cmd/est@v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run from source
|
||||||
|
|
||||||
|
The required Go version is declared in [go.mod](go.mod).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp config.example.toml config.toml
|
||||||
|
# Edit config.toml before starting est.
|
||||||
|
go run ./cmd/est --config ./config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
`config.toml` is ignored by Git so private hosts and key paths are not
|
||||||
|
committed accidentally. The default configuration path is `./config.toml`;
|
||||||
|
use `--config` or `-c` to select another file.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Pull the published image:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker login git.scuroneko.dev
|
||||||
|
docker pull git.scuroneko.dev/scuroneko/est:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Or build it locally:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build -t git.scuroneko.dev/scuroneko/est:local .
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the local Docker smoke test to build the final image and verify that its
|
||||||
|
SSH client is available:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/docker-smoke-test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The release build publishes both `linux/amd64` and `linux/arm64` images. It
|
||||||
|
requires a Buildx builder with ARM64 emulation installed on an AMD64 host:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run --privileged --rm tonistiigi/binfmt --install arm64
|
||||||
|
docker buildx create --name est-builder --driver docker-container --use
|
||||||
|
docker buildx inspect --bootstrap
|
||||||
|
make docker-build
|
||||||
|
```
|
||||||
|
|
||||||
|
`make docker-build` pushes
|
||||||
|
`git.scuroneko.dev/scuroneko/est:latest` and
|
||||||
|
`git.scuroneko.dev/scuroneko/est:1.0.0`. Adjust the Makefile tags before a
|
||||||
|
different release.
|
||||||
|
|
||||||
|
Do not bake your private key or tunnel configuration into the image. Mount
|
||||||
|
them at runtime instead. The command below reuses the host SSH configuration,
|
||||||
|
keys, and `known_hosts` file without granting the container write access:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run --rm --init \
|
||||||
|
--user "$(id -u):$(id -g)" \
|
||||||
|
--env HOME=/ssh \
|
||||||
|
--volume "$PWD/config.toml:/app/config.toml:ro" \
|
||||||
|
--volume "$HOME/.ssh:/ssh:ro" \
|
||||||
|
git.scuroneko.dev/scuroneko/est:latest --config /app/config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
With `HOME=/ssh`, `est` and OpenSSH can discover `/ssh/config` and
|
||||||
|
`/ssh/known_hosts`. The mounted configuration can use paths such as
|
||||||
|
`identityFile = "/ssh/id_ed25519"`. If you use another mount location, set
|
||||||
|
`sshConfig` and `identityFile` to paths inside the container.
|
||||||
|
|
||||||
|
The image intentionally does not publish ports. For `rtl` tunnels, publish the
|
||||||
|
local listening port explicitly when it must be reachable outside Docker, for
|
||||||
|
example `-p 127.0.0.1:5433:5433`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The configuration file is TOML. Every `[[entry]]` section describes one
|
||||||
|
tunnel.
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|----------------|---------------|-----------------------------------------------------------------------------------------------------|
|
||||||
|
| `direction` | yes | Tunnel direction: `ltr` or `rtl`. |
|
||||||
|
| `host` | yes | SSH destination or host alias, for example `user@example.com` or `vps`. |
|
||||||
|
| `localIP` | no | Address on the machine that runs `est`. Defaults to `127.0.0.1`. |
|
||||||
|
| `localPort` | yes | Port on the machine that runs `est`. Must be non-zero. |
|
||||||
|
| `remoteIP` | no | Remote bind/destination address, depending on the direction. Defaults to `127.0.0.1`. |
|
||||||
|
| `remotePort` | yes | Remote bind/destination port. Must be non-zero. |
|
||||||
|
| `identityFile` | no | Private SSH key path for this tunnel. |
|
||||||
|
| `retry` | no | A top-level or per-entry retry table described below. |
|
||||||
|
| `sshConfig` | no, top level | SSH config path applied to every tunnel. |
|
||||||
|
|
||||||
|
`host` must be non-empty and ports must be non-zero. An empty `localIP` or
|
||||||
|
`remoteIP` is converted to `127.0.0.1`. When `sshConfig` is omitted, `est`
|
||||||
|
uses `~/.ssh/config` if the file exists.
|
||||||
|
|
||||||
|
The optional top-level retry table applies to every entry:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[retry]
|
||||||
|
count = 5
|
||||||
|
delay = "5s"
|
||||||
|
```
|
||||||
|
|
||||||
|
`count` is the number of reconnect attempts after the initial SSH process
|
||||||
|
exits. Its default is `5`; use `0` to disable reconnects and `-1` to retry
|
||||||
|
forever. `delay` is a positive Go duration such as `"5s"`, `"500ms"`, or
|
||||||
|
`"1m"`; its default is `"5s"`.
|
||||||
|
|
||||||
|
Add `[entry.retry]` immediately after a tunnel entry to override the top-level
|
||||||
|
retry settings for that tunnel. This is a full override: if an entry retry
|
||||||
|
block omits `count` or `delay`, the omitted value uses the standard default,
|
||||||
|
not the top-level value.
|
||||||
|
|
||||||
|
### Remote forwarding (`ltr`)
|
||||||
|
|
||||||
|
`ltr` means local-to-remote. It opens a port on the remote SSH server and
|
||||||
|
forwards connections to an address reachable from the local machine:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[entry]]
|
||||||
|
direction = "ltr"
|
||||||
|
host = "vps"
|
||||||
|
|
||||||
|
localIP = "127.0.0.1"
|
||||||
|
localPort = 22
|
||||||
|
|
||||||
|
remoteIP = "127.0.0.1"
|
||||||
|
remotePort = 2222
|
||||||
|
|
||||||
|
[entry.retry]
|
||||||
|
count = -1
|
||||||
|
delay = "2s"
|
||||||
|
```
|
||||||
|
|
||||||
|
This produces the equivalent SSH command:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh -N -T -o ExitOnForwardFailure=yes \
|
||||||
|
-R 127.0.0.1:2222:127.0.0.1:22 vps
|
||||||
|
```
|
||||||
|
|
||||||
|
Connecting to `127.0.0.1:2222` on `vps` reaches `127.0.0.1:22` on the machine
|
||||||
|
running `est`. Binding a remote address other than loopback may require the SSH
|
||||||
|
server's `GatewayPorts` setting.
|
||||||
|
|
||||||
|
### Local forwarding (`rtl`)
|
||||||
|
|
||||||
|
`rtl` means remote-to-local. It opens a port on the machine that runs `est`
|
||||||
|
and forwards connections to an address reachable from the remote SSH server:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[entry]]
|
||||||
|
direction = "rtl"
|
||||||
|
host = "vps"
|
||||||
|
|
||||||
|
localIP = "127.0.0.1"
|
||||||
|
localPort = 5433
|
||||||
|
|
||||||
|
remoteIP = "127.0.0.1"
|
||||||
|
remotePort = 5432
|
||||||
|
identityFile = "/home/user/.ssh/vps_key"
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalent SSH command:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh -N -T -o ExitOnForwardFailure=yes \
|
||||||
|
-i /home/user/.ssh/vps_key \
|
||||||
|
-L 127.0.0.1:5433:127.0.0.1:5432 vps
|
||||||
|
```
|
||||||
|
|
||||||
|
After the connection is established, the service reachable by `vps` at
|
||||||
|
`127.0.0.1:5432` is available locally at `127.0.0.1:5433`.
|
||||||
|
|
||||||
|
### Shared SSH config
|
||||||
|
|
||||||
|
Set `sshConfig` at the top level to use a non-default SSH configuration file:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
sshConfig = "/etc/est/ssh_config"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
direction = "ltr"
|
||||||
|
host = "vps"
|
||||||
|
localIP = "127.0.0.1"
|
||||||
|
localPort = 22
|
||||||
|
remoteIP = "127.0.0.1"
|
||||||
|
remotePort = 2222
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lifecycle and errors
|
||||||
|
|
||||||
|
Press `Ctrl+C` or send `SIGTERM` to stop `est`. The shared application context
|
||||||
|
is cancelled and the `ssh` subprocesses started by it are terminated.
|
||||||
|
|
||||||
|
If any tunnel fails to start or exhausts its reconnect attempts, `est` logs the
|
||||||
|
SSH exit code and standard error, cancels the shared context, and stops the
|
||||||
|
remaining tunnels. Add `-v`, `-vv`, or `-vvv` to the SSH arguments temporarily
|
||||||
|
when a connection needs deeper diagnosis.
|
||||||
|
|
||||||
|
## Development checks
|
||||||
|
|
||||||
|
Run the Go checks locally:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./...
|
||||||
|
go vet ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
The Gitea workflow verifies Go formatting, runs `golangci-lint`, and builds
|
||||||
|
the Docker image with the SSH-client smoke test on pushes and pull requests.
|
||||||
|
|
||||||
|
## Release status
|
||||||
|
|
||||||
|
The CLI and Docker workflows are documented, but the current repository is
|
||||||
|
not ready for a `v1.0.0` release yet. See the release checklist below.
|
||||||
|
|
||||||
|
### v1.0.0 release checklist
|
||||||
|
|
||||||
|
- Confirm that the Go and Docker CI jobs pass for the release commit.
|
||||||
|
- Publish the versioned Go module tag and Docker image, then verify the
|
||||||
|
installation instructions from a clean environment.
|
||||||
|
- Publish a tag such as `v1.0.0` only after the checklist is complete and the
|
||||||
|
public CLI/configuration contract is considered stable.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"git.scuroneko.dev/ScuroNeko/est/internal/app"
|
||||||
|
"git.scuroneko.dev/ScuroNeko/est/internal/config"
|
||||||
|
"git.scuroneko.dev/ScuroNeko/est/internal/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx, stop := app.NewContext(context.Background())
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
flags, err := config.ParseFlags()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := config.LoadConfig(flags.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
log.Printf("loaded %d tunnel configuration(s) from %s", len(cfg.Entries), flags.ConfigPath)
|
||||||
|
|
||||||
|
retries := make([]config.RetrySettings, len(cfg.Entries))
|
||||||
|
for index, entry := range cfg.Entries {
|
||||||
|
retry, err := cfg.RetrySettings(entry)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid retry configuration for %q: %v", entry.Host, err)
|
||||||
|
}
|
||||||
|
retries[index] = retry
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, entry := range cfg.Entries {
|
||||||
|
retry := retries[index]
|
||||||
|
go func(entry config.ProxyEntry, retry config.RetrySettings) {
|
||||||
|
if entry.Direction == config.LocalToRemote {
|
||||||
|
log.Printf("%s:%d -> %s:%d(%s)", entry.LocalIP, entry.LocalPort, entry.RemoteIP, entry.RemotePort, entry.Host)
|
||||||
|
} else {
|
||||||
|
log.Printf("%s:%d <- %s:%d(%s)", entry.LocalIP, entry.LocalPort, entry.RemoteIP, entry.RemotePort, entry.Host)
|
||||||
|
}
|
||||||
|
if err := ssh.RunTunnel(ctx, cfg.SSHConfig, entry, retry); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}(entry, retry)
|
||||||
|
}
|
||||||
|
<-ctx.Done()
|
||||||
|
log.Printf("shutting down: %v", context.Cause(ctx))
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[retry]
|
||||||
|
count = 5
|
||||||
|
delay = "5s"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
direction = "ltr"
|
||||||
|
remotePort = 2222
|
||||||
|
remoteIP = "192.168.0.1"
|
||||||
|
localPort = 22
|
||||||
|
localIP = "127.0.0.1"
|
||||||
|
host = "user@127.0.0.1"
|
||||||
|
|
||||||
|
# This block completely overrides the top-level [retry] block for this tunnel.
|
||||||
|
[entry.retry]
|
||||||
|
count = -1
|
||||||
|
delay = "5s"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
direction = "rtl"
|
||||||
|
remotePort = 5432
|
||||||
|
remoteIP = "127.0.0.1"
|
||||||
|
localPort = 54321
|
||||||
|
localIP = "127.0.0.1" # You can remove this line. If local or remote ip empty, est will use "127.0.0.1"
|
||||||
|
host = "my_server"
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
module git.scuroneko.dev/ScuroNeko/est
|
module git.scuroneko.dev/ScuroNeko/est
|
||||||
|
|
||||||
go 1.26.5
|
go 1.26.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.6.0
|
||||||
|
github.com/spf13/pflag v1.0.10
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Package app provides application lifecycle primitives.
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewContext returns a context for the whole application lifecycle.
|
||||||
|
//
|
||||||
|
// The context is cancelled when the parent is cancelled or when the process
|
||||||
|
// receives an interrupt or a termination signal. Call the returned function
|
||||||
|
// during shutdown to unregister signal notifications and release resources.
|
||||||
|
func NewContext(parent context.Context) (context.Context, context.CancelFunc) {
|
||||||
|
return signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewContextCancelsWithParent(t *testing.T) {
|
||||||
|
parent, cancelParent := context.WithCancel(context.Background())
|
||||||
|
ctx, stop := NewContext(parent)
|
||||||
|
t.Cleanup(stop)
|
||||||
|
|
||||||
|
cancelParent()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("application context was not cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Package config loads est tunnel configuration and command-line options.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProxyDirection identifies the direction of an SSH port forward.
|
||||||
|
type ProxyDirection string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// RemoteToLocal creates a local SSH forward with the -L option.
|
||||||
|
RemoteToLocal ProxyDirection = "rtl"
|
||||||
|
// LocalToRemote creates a remote SSH forward with the -R option.
|
||||||
|
LocalToRemote ProxyDirection = "ltr"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultRetryCount is the number of reconnect attempts used when retry is
|
||||||
|
// not specified.
|
||||||
|
DefaultRetryCount = 5
|
||||||
|
// DefaultRetryDelay is the wait between reconnect attempts when retry is
|
||||||
|
// not specified.
|
||||||
|
DefaultRetryDelay = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrInvalidRetryCount is returned when retry count is less than -1.
|
||||||
|
ErrInvalidRetryCount = errors.New("retry count must be -1 or greater")
|
||||||
|
// ErrInvalidRetryDelay is returned when retry delay is invalid or not positive.
|
||||||
|
ErrInvalidRetryDelay = errors.New("retry delay must be a positive duration")
|
||||||
|
)
|
||||||
|
|
||||||
|
// RetryConfig configures tunnel reconnection behavior.
|
||||||
|
// Count is the number of attempts after the initial SSH process exits; -1
|
||||||
|
// retries indefinitely. Delay uses time.ParseDuration syntax, such as "5s".
|
||||||
|
type RetryConfig struct {
|
||||||
|
Count *int `toml:"count"`
|
||||||
|
Delay string `toml:"delay"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetrySettings is a validated retry configuration ready for execution.
|
||||||
|
type RetrySettings struct {
|
||||||
|
Count int
|
||||||
|
Delay time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings resolves RetryConfig fields with est's default retry values.
|
||||||
|
func (retry RetryConfig) Settings() (RetrySettings, error) {
|
||||||
|
settings := RetrySettings{Count: DefaultRetryCount, Delay: DefaultRetryDelay}
|
||||||
|
if retry.Count != nil {
|
||||||
|
settings.Count = *retry.Count
|
||||||
|
}
|
||||||
|
if settings.Count < -1 {
|
||||||
|
return RetrySettings{}, ErrInvalidRetryCount
|
||||||
|
}
|
||||||
|
if retry.Delay != "" {
|
||||||
|
delay, err := time.ParseDuration(retry.Delay)
|
||||||
|
if err != nil || delay <= 0 {
|
||||||
|
return RetrySettings{}, ErrInvalidRetryDelay
|
||||||
|
}
|
||||||
|
settings.Delay = delay
|
||||||
|
}
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProxyEntry defines one SSH tunnel.
|
||||||
|
type ProxyEntry struct {
|
||||||
|
Direction ProxyDirection `toml:"direction"`
|
||||||
|
RemotePort uint16 `toml:"remotePort"`
|
||||||
|
RemoteIP string `toml:"remoteIP"`
|
||||||
|
LocalPort uint16 `toml:"localPort"`
|
||||||
|
LocalIP string `toml:"localIP"`
|
||||||
|
Host string `toml:"host"`
|
||||||
|
|
||||||
|
IdentityFile string `toml:"identityFile,omitempty"`
|
||||||
|
Retry *RetryConfig `toml:"retry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is the TOML configuration used by est.
|
||||||
|
type Config struct {
|
||||||
|
SSHConfig string `toml:"sshConfig"`
|
||||||
|
Retry *RetryConfig `toml:"retry"`
|
||||||
|
Entries []ProxyEntry `toml:"entry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetrySettings resolves the retry configuration for entry. An entry retry
|
||||||
|
// block fully overrides the top-level retry block.
|
||||||
|
func (cfg Config) RetrySettings(entry ProxyEntry) (RetrySettings, error) {
|
||||||
|
if entry.Retry != nil {
|
||||||
|
return entry.Retry.Settings()
|
||||||
|
}
|
||||||
|
if cfg.Retry != nil {
|
||||||
|
return cfg.Retry.Settings()
|
||||||
|
}
|
||||||
|
return RetryConfig{}.Settings()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig decodes a TOML configuration file from path.
|
||||||
|
func LoadConfig(path string) (Config, error) {
|
||||||
|
var config Config
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return config, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
if _, err := toml.NewDecoder(file).Decode(&config); err != nil {
|
||||||
|
return config, err
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func intPtr(value int) *int {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "tunnels.toml")
|
||||||
|
contents := `sshConfig = "/etc/ssh/est.conf"
|
||||||
|
|
||||||
|
[retry]
|
||||||
|
count = 3
|
||||||
|
delay = "2s"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
direction = "ltr"
|
||||||
|
host = "vps"
|
||||||
|
localPort = 22
|
||||||
|
remotePort = 2222
|
||||||
|
identityFile = "/keys/vps"
|
||||||
|
|
||||||
|
[entry.retry]
|
||||||
|
count = -1
|
||||||
|
delay = "100ms"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.SSHConfig != "/etc/ssh/est.conf" {
|
||||||
|
t.Errorf("SSHConfig = %q, want %q", cfg.SSHConfig, "/etc/ssh/est.conf")
|
||||||
|
}
|
||||||
|
if len(cfg.Entries) != 1 {
|
||||||
|
t.Fatalf("entries = %d, want 1", len(cfg.Entries))
|
||||||
|
}
|
||||||
|
entry := cfg.Entries[0]
|
||||||
|
if entry.Direction != LocalToRemote || entry.Host != "vps" || entry.LocalPort != 22 || entry.RemotePort != 2222 || entry.IdentityFile != "/keys/vps" {
|
||||||
|
t.Errorf("entry = %#v, want decoded tunnel values", entry)
|
||||||
|
}
|
||||||
|
retry, err := cfg.RetrySettings(entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RetrySettings() error = %v", err)
|
||||||
|
}
|
||||||
|
if retry.Count != -1 || retry.Delay != 100*time.Millisecond {
|
||||||
|
t.Errorf("entry retry = %#v, want count=-1 delay=100ms", retry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigRetrySettings(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cfg Config
|
||||||
|
entry ProxyEntry
|
||||||
|
want RetrySettings
|
||||||
|
}{
|
||||||
|
{name: "defaults", want: RetrySettings{Count: DefaultRetryCount, Delay: DefaultRetryDelay}},
|
||||||
|
{name: "top level", cfg: Config{Retry: &RetryConfig{Count: intPtr(2), Delay: "1s"}}, want: RetrySettings{Count: 2, Delay: time.Second}},
|
||||||
|
{name: "entry override", cfg: Config{Retry: &RetryConfig{Count: intPtr(2), Delay: "1s"}}, entry: ProxyEntry{Retry: &RetryConfig{Count: intPtr(0), Delay: "50ms"}}, want: RetrySettings{Count: 0, Delay: 50 * time.Millisecond}},
|
||||||
|
{name: "entry override uses defaults", cfg: Config{Retry: &RetryConfig{Count: intPtr(-1), Delay: "1s"}}, entry: ProxyEntry{Retry: &RetryConfig{}}, want: RetrySettings{Count: DefaultRetryCount, Delay: DefaultRetryDelay}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := tt.cfg.RetrySettings(tt.entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RetrySettings() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("RetrySettings() = %#v, want %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryConfigSettingsRejectsInvalidValues(t *testing.T) {
|
||||||
|
for _, retry := range []RetryConfig{
|
||||||
|
{Count: intPtr(-2)},
|
||||||
|
{Delay: "not-a-duration"},
|
||||||
|
{Delay: "0s"},
|
||||||
|
} {
|
||||||
|
if _, err := retry.Settings(); err == nil {
|
||||||
|
t.Errorf("Settings() error = nil for %#v", retry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigMissingFile(t *testing.T) {
|
||||||
|
_, err := LoadConfig(filepath.Join(t.TempDir(), "missing.toml"))
|
||||||
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("LoadConfig() error = %v, want not-exist error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Flags contains command-line options accepted by est.
|
||||||
|
type Flags struct {
|
||||||
|
ConfigPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFlags parses est command-line flags.
|
||||||
|
func ParseFlags() (*Flags, error) {
|
||||||
|
configPath := pflag.StringP("config", "c", "./config.toml", "toml config path")
|
||||||
|
pflag.Parse()
|
||||||
|
if configPath == nil {
|
||||||
|
return nil, errors.New("no config path")
|
||||||
|
}
|
||||||
|
return &Flags{ConfigPath: *configPath}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Package ssh builds and runs SSH port-forwarding commands.
|
||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.scuroneko.dev/ScuroNeko/est/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildRemoteToLocalString(sourceIP, remoteIP string, sourcePort, remotePort uint16) string {
|
||||||
|
if sourceIP == "" {
|
||||||
|
sourceIP = "127.0.0.1"
|
||||||
|
}
|
||||||
|
if remoteIP == "" {
|
||||||
|
remoteIP = "127.0.0.1"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s:%d:%s:%d", sourceIP, sourcePort, remoteIP, remotePort)
|
||||||
|
}
|
||||||
|
func buildLocalToRemoteString(sourceIP, remoteIP string, sourcePort, remotePort uint16) string {
|
||||||
|
if sourceIP == "" {
|
||||||
|
sourceIP = "127.0.0.1"
|
||||||
|
}
|
||||||
|
if remoteIP == "" {
|
||||||
|
remoteIP = "127.0.0.1"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s:%d:%s:%d", remoteIP, remotePort, sourceIP, sourcePort)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrEmptyHost is returned when a tunnel has no SSH destination.
|
||||||
|
ErrEmptyHost = errors.New("empty host")
|
||||||
|
// ErrEmptyLocalPort is returned when a tunnel has no local port.
|
||||||
|
ErrEmptyLocalPort = errors.New("empty local port")
|
||||||
|
// ErrEmptyRemotePort is returned when a tunnel has no remote port.
|
||||||
|
ErrEmptyRemotePort = errors.New("empty remote port")
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateArgs(entry config.ProxyEntry) error {
|
||||||
|
if entry.Host == "" {
|
||||||
|
return ErrEmptyHost
|
||||||
|
}
|
||||||
|
if entry.LocalPort == 0 {
|
||||||
|
return ErrEmptyLocalPort
|
||||||
|
}
|
||||||
|
if entry.RemotePort == 0 {
|
||||||
|
return ErrEmptyRemotePort
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func prepareArgs(configPath string, entry config.ProxyEntry) ([]string, error) {
|
||||||
|
if err := validateArgs(entry); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
args := []string{"-N", "-T", "-o", "ExitOnForwardFailure=yes"}
|
||||||
|
if configPath != "" {
|
||||||
|
args = append(args, "-F", configPath)
|
||||||
|
} else if configPath = getConfigOrEmpty(); configPath != "" {
|
||||||
|
args = append(args, "-F", configPath)
|
||||||
|
}
|
||||||
|
if entry.IdentityFile != "" {
|
||||||
|
args = append(args, "-i", entry.IdentityFile)
|
||||||
|
}
|
||||||
|
switch entry.Direction {
|
||||||
|
case config.LocalToRemote:
|
||||||
|
args = append(args, "-R", buildLocalToRemoteString(entry.LocalIP, entry.RemoteIP, entry.LocalPort, entry.RemotePort))
|
||||||
|
case config.RemoteToLocal:
|
||||||
|
args = append(args, "-L", buildRemoteToLocalString(entry.LocalIP, entry.RemoteIP, entry.LocalPort, entry.RemotePort))
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid direction: %v; should be ltr on rtl", entry.Direction)
|
||||||
|
}
|
||||||
|
if entry.Host != "" {
|
||||||
|
args = append(args, entry.Host)
|
||||||
|
}
|
||||||
|
return args, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.scuroneko.dev/ScuroNeko/est/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrepareArgsRemoteForwardUsesLoopbackDefaults(t *testing.T) {
|
||||||
|
args, err := prepareArgs("/etc/ssh/est.conf", config.ProxyEntry{
|
||||||
|
Direction: config.LocalToRemote,
|
||||||
|
Host: "vps",
|
||||||
|
LocalPort: 22,
|
||||||
|
RemotePort: 2222,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepareArgs() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
"-N", "-T", "-o", "ExitOnForwardFailure=yes",
|
||||||
|
"-F", "/etc/ssh/est.conf",
|
||||||
|
"-R", "127.0.0.1:2222:127.0.0.1:22",
|
||||||
|
"vps",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(args, want) {
|
||||||
|
t.Errorf("prepareArgs() = %#v, want %#v", args, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareArgsLocalForwardWithIdentityFile(t *testing.T) {
|
||||||
|
args, err := prepareArgs("/etc/ssh/est.conf", config.ProxyEntry{
|
||||||
|
Direction: config.RemoteToLocal,
|
||||||
|
Host: "db-vps",
|
||||||
|
LocalIP: "127.0.0.2",
|
||||||
|
LocalPort: 5433,
|
||||||
|
RemoteIP: "10.0.0.10",
|
||||||
|
RemotePort: 5432,
|
||||||
|
IdentityFile: "/keys/db-vps",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepareArgs() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
"-N", "-T", "-o", "ExitOnForwardFailure=yes",
|
||||||
|
"-F", "/etc/ssh/est.conf",
|
||||||
|
"-i", "/keys/db-vps",
|
||||||
|
"-L", "127.0.0.2:5433:10.0.0.10:5432",
|
||||||
|
"db-vps",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(args, want) {
|
||||||
|
t.Errorf("prepareArgs() = %#v, want %#v", args, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareArgsRejectsInvalidEntry(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
entry config.ProxyEntry
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{name: "missing host", entry: config.ProxyEntry{LocalPort: 1, RemotePort: 2}, want: ErrEmptyHost},
|
||||||
|
{name: "missing local port", entry: config.ProxyEntry{Host: "vps", RemotePort: 2}, want: ErrEmptyLocalPort},
|
||||||
|
{name: "missing remote port", entry: config.ProxyEntry{Host: "vps", LocalPort: 1}, want: ErrEmptyRemotePort},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := prepareArgs("/etc/ssh/est.conf", tt.entry)
|
||||||
|
if !errors.Is(err, tt.want) {
|
||||||
|
t.Errorf("prepareArgs() error = %v, want %v", err, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareArgsRejectsUnknownDirection(t *testing.T) {
|
||||||
|
_, err := prepareArgs("/etc/ssh/est.conf", config.ProxyEntry{
|
||||||
|
Direction: "unknown",
|
||||||
|
Host: "vps",
|
||||||
|
LocalPort: 1,
|
||||||
|
RemotePort: 2,
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid direction") {
|
||||||
|
t.Fatalf("prepareArgs() error = %v, want invalid-direction error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getConfigOrEmpty() string {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(homeDir, ".ssh", "config")
|
||||||
|
file, err := os.Open(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
_ = file.Close()
|
||||||
|
return configPath
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.scuroneko.dev/ScuroNeko/est/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errTunnelExited = errors.New("ssh tunnel exited unexpectedly")
|
||||||
|
|
||||||
|
// RunTunnel starts an SSH process for entry and waits for it to exit.
|
||||||
|
// When SSH exits, it reconnects according to retry.
|
||||||
|
// The process and any pending retry wait are terminated when ctx is cancelled.
|
||||||
|
func RunTunnel(ctx context.Context, configPath string, entry config.ProxyEntry, retry config.RetrySettings) error {
|
||||||
|
args, err := prepareArgs(configPath, entry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runWithRetry(ctx, retry.Count, retry.Delay, func() error {
|
||||||
|
return runTunnelOnce(ctx, args)
|
||||||
|
}, func(attempt int, err error) {
|
||||||
|
if retry.Count == -1 {
|
||||||
|
log.Printf("ssh tunnel %q failed; retrying in %s (attempt %d): %v", entry.Host, retry.Delay, attempt, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("ssh tunnel %q failed; retrying in %s (%d/%d): %v", entry.Host, retry.Delay, attempt, retry.Count, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWithRetry(ctx context.Context, retries int, delay time.Duration, run func() error, onRetry func(int, error)) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for attempt := 0; ; {
|
||||||
|
err := run()
|
||||||
|
if err == nil || ctx.Err() != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if retries != -1 && attempt >= retries {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt++
|
||||||
|
if onRetry != nil {
|
||||||
|
onRetry(attempt, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTunnelOnce(ctx context.Context, args []string) error {
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd := exec.CommandContext(ctx, "ssh", args...)
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return fmt.Errorf("ssh stopped: %w", ctx.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"ssh exit code %d: %s",
|
||||||
|
exitErr.ExitCode(),
|
||||||
|
strings.TrimSpace(stderr.String()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("can't run ssh: %w", err)
|
||||||
|
}
|
||||||
|
return errTunnelExited
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunWithRetryStopsAfterConfiguredRetries(t *testing.T) {
|
||||||
|
wantErr := errors.New("ssh exited")
|
||||||
|
calls := 0
|
||||||
|
err := runWithRetry(context.Background(), 2, 0, func() error {
|
||||||
|
calls++
|
||||||
|
return wantErr
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
if !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("runWithRetry() error = %v, want %v", err, wantErr)
|
||||||
|
}
|
||||||
|
if calls != 3 {
|
||||||
|
t.Errorf("run() calls = %d, want 3", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWithRetryDoesNotRetryWhenDisabled(t *testing.T) {
|
||||||
|
wantErr := errors.New("ssh exited")
|
||||||
|
calls := 0
|
||||||
|
err := runWithRetry(context.Background(), 0, 0, func() error {
|
||||||
|
calls++
|
||||||
|
return wantErr
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
if !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("runWithRetry() error = %v, want %v", err, wantErr)
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Errorf("run() calls = %d, want 1", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWithRetryRetriesIndefinitelyUntilSuccess(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
err := runWithRetry(context.Background(), -1, 0, func() error {
|
||||||
|
calls++
|
||||||
|
if calls < 4 {
|
||||||
|
return errors.New("temporary ssh failure")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runWithRetry() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if calls != 4 {
|
||||||
|
t.Errorf("run() calls = %d, want 4", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWithRetryStopsWaitingWhenContextCancelled(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
firstCall := make(chan struct{})
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- runWithRetry(ctx, -1, time.Hour, func() error {
|
||||||
|
close(firstCall)
|
||||||
|
return errors.New("temporary ssh failure")
|
||||||
|
}, nil)
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-firstCall
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("runWithRetry() error = %v, want context cancellation", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("runWithRetry() did not stop after context cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
image_tag="${1:-est:smoke}"
|
||||||
|
|
||||||
|
docker build --tag "$image_tag" .
|
||||||
|
docker run --rm --entrypoint ssh "$image_tag" -V
|
||||||
Reference in New Issue
Block a user