v1.0.0
Golang lint / docker-smoke (push) Successful in 17s
Golang lint / lint (push) Failing after 18s

This commit is contained in:
2026-08-06 12:22:44 +03:00
parent a47ac72db0
commit bb2fa57bbe
21 changed files with 1075 additions and 0 deletions
+76
View File
@@ -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
}
+91
View File
@@ -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)
}
}
+20
View File
@@ -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
}
+91
View File
@@ -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
}
+84
View File
@@ -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")
}
}