From bb2fa57bbeb0f317d4fdfd94520057bcdeeb37c7 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 6 Aug 2026 12:22:44 +0300 Subject: [PATCH] v1.0.0 --- .dockerignore | 6 + .gitea/workflows/lint.yml | 37 +++++ .gitignore | 2 + Dockerfile | 14 ++ Makefile | 6 + README.md | 275 +++++++++++++++++++++++++++++++++ cmd/est/main.go | 51 ++++++ config.example.toml | 24 +++ go.mod | 5 + go.sum | 4 + internal/app/context.go | 18 +++ internal/app/context_test.go | 21 +++ internal/config/config.go | 115 ++++++++++++++ internal/config/config_test.go | 105 +++++++++++++ internal/config/flags.go | 22 +++ internal/ssh/args.go | 76 +++++++++ internal/ssh/args_test.go | 91 +++++++++++ internal/ssh/config.go | 20 +++ internal/ssh/tunnel.go | 91 +++++++++++ internal/ssh/tunnel_test.go | 84 ++++++++++ scripts/docker-smoke-test.sh | 8 + 21 files changed, 1075 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/lint.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/est/main.go create mode 100644 config.example.toml create mode 100644 go.sum create mode 100644 internal/app/context.go create mode 100644 internal/app/context_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/flags.go create mode 100644 internal/ssh/args.go create mode 100644 internal/ssh/args_test.go create mode 100644 internal/ssh/config.go create mode 100644 internal/ssh/tunnel.go create mode 100644 internal/ssh/tunnel_test.go create mode 100755 scripts/docker-smoke-test.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5df17d4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.idea/ +.giea/ +.gitignore +config.* +Dockerfile +README.md \ No newline at end of file diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml new file mode 100644 index 0000000..5d49620 --- /dev/null +++ b/.gitea/workflows/lint.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4e355a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +config.toml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4b33331 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2637b00 --- /dev/null +++ b/Makefile @@ -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 . \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..29c21b8 --- /dev/null +++ b/README.md @@ -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. diff --git a/cmd/est/main.go b/cmd/est/main.go new file mode 100644 index 0000000..d74d981 --- /dev/null +++ b/cmd/est/main.go @@ -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)) +} diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..1209f0f --- /dev/null +++ b/config.example.toml @@ -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" diff --git a/go.mod b/go.mod index cc52cf2..72bf5d2 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,8 @@ module git.scuroneko.dev/ScuroNeko/est go 1.26.5 + +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/spf13/pflag v1.0.10 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e984862 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/app/context.go b/internal/app/context.go new file mode 100644 index 0000000..86696f6 --- /dev/null +++ b/internal/app/context.go @@ -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) +} diff --git a/internal/app/context_test.go b/internal/app/context_test.go new file mode 100644 index 0000000..8f8f104 --- /dev/null +++ b/internal/app/context_test.go @@ -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") + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..00bcdf0 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..fe15456 --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/config/flags.go b/internal/config/flags.go new file mode 100644 index 0000000..7aace5b --- /dev/null +++ b/internal/config/flags.go @@ -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 +} diff --git a/internal/ssh/args.go b/internal/ssh/args.go new file mode 100644 index 0000000..38c29a7 --- /dev/null +++ b/internal/ssh/args.go @@ -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 +} diff --git a/internal/ssh/args_test.go b/internal/ssh/args_test.go new file mode 100644 index 0000000..47352de --- /dev/null +++ b/internal/ssh/args_test.go @@ -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) + } +} diff --git a/internal/ssh/config.go b/internal/ssh/config.go new file mode 100644 index 0000000..dd9605f --- /dev/null +++ b/internal/ssh/config.go @@ -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 +} diff --git a/internal/ssh/tunnel.go b/internal/ssh/tunnel.go new file mode 100644 index 0000000..fa76c89 --- /dev/null +++ b/internal/ssh/tunnel.go @@ -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 +} diff --git a/internal/ssh/tunnel_test.go b/internal/ssh/tunnel_test.go new file mode 100644 index 0000000..406bd12 --- /dev/null +++ b/internal/ssh/tunnel_test.go @@ -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") + } +} diff --git a/scripts/docker-smoke-test.sh b/scripts/docker-smoke-test.sh new file mode 100755 index 0000000..6d46599 --- /dev/null +++ b/scripts/docker-smoke-test.sh @@ -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