Compare commits

..

1 Commits

Author SHA1 Message Date
bagas 93d4fb48e7 feat: upgrade bubbletea to v2
SonarQube Scan / SonarQube Trigger (push) Failing after 2m16s
2026-03-20 18:06:08 +07:00
35 changed files with 217 additions and 451 deletions
+5 -1
View File
@@ -3,10 +3,10 @@ module tunnel_pls
go 1.26.0 go 1.26.0
require ( require (
charm.land/bubbletea/v2 v2.0.0
git.fossy.my.id/bagas/tunnel-please-grpc v1.5.0 git.fossy.my.id/bagas/tunnel-please-grpc v1.5.0
github.com/caddyserver/certmagic v0.25.1 github.com/caddyserver/certmagic v0.25.1
github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/libdns/cloudflare v0.2.2 github.com/libdns/cloudflare v0.2.2
@@ -21,10 +21,14 @@ require (
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/caddyserver/zerossl v0.1.4 // indirect github.com/caddyserver/zerossl v0.1.4 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
+10 -2
View File
@@ -1,3 +1,5 @@
charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ=
charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
git.fossy.my.id/bagas/tunnel-please-grpc v1.5.0 h1:3xszIhck4wo9CoeRq9vnkar4PhY7kz9QrR30qj2XszA= git.fossy.my.id/bagas/tunnel-please-grpc v1.5.0 h1:3xszIhck4wo9CoeRq9vnkar4PhY7kz9QrR30qj2XszA=
git.fossy.my.id/bagas/tunnel-please-grpc v1.5.0/go.mod h1:Weh6ZujgWmT8XxD3Qba7sJ6r5eyUMB9XSWynqdyOoLo= git.fossy.my.id/bagas/tunnel-please-grpc v1.5.0/go.mod h1:Weh6ZujgWmT8XxD3Qba7sJ6r5eyUMB9XSWynqdyOoLo=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
@@ -18,14 +20,20 @@ github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
+2 -2
View File
@@ -15,10 +15,10 @@ import (
"tunnel_pls/internal/port" "tunnel_pls/internal/port"
"tunnel_pls/internal/random" "tunnel_pls/internal/random"
"tunnel_pls/internal/registry" "tunnel_pls/internal/registry"
"tunnel_pls/internal/server"
"tunnel_pls/internal/transport" "tunnel_pls/internal/transport"
"tunnel_pls/internal/types"
"tunnel_pls/internal/version" "tunnel_pls/internal/version"
"tunnel_pls/server"
"tunnel_pls/types"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
+2 -2
View File
@@ -14,8 +14,8 @@ import (
"tunnel_pls/internal/config" "tunnel_pls/internal/config"
"tunnel_pls/internal/port" "tunnel_pls/internal/port"
"tunnel_pls/internal/registry" "tunnel_pls/internal/registry"
"tunnel_pls/internal/session/slug" "tunnel_pls/session/slug"
"tunnel_pls/internal/types" "tunnel_pls/types"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
+1 -3
View File
@@ -1,8 +1,6 @@
package config package config
import ( import "tunnel_pls/types"
"tunnel_pls/internal/types"
)
type Config interface { type Config interface {
Domain() string Domain() string
+1 -1
View File
@@ -3,7 +3,7 @@ package config
import ( import (
"os" "os"
"testing" "testing"
"tunnel_pls/internal/types" "tunnel_pls/types"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"tunnel_pls/internal/types" "tunnel_pls/types"
"github.com/joho/godotenv" "github.com/joho/godotenv"
) )
@@ -32,7 +32,7 @@ type config struct {
bufferSize int bufferSize int
headerSize int headerSize int
pprofEnabled bool pprofEnabled bool
pprofPort string pprofPort string
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"time" "time"
"tunnel_pls/internal/config" "tunnel_pls/internal/config"
"tunnel_pls/internal/registry" "tunnel_pls/internal/registry"
"tunnel_pls/internal/types" "tunnel_pls/types"
proto "git.fossy.my.id/bagas/tunnel-please-grpc/gen" proto "git.fossy.my.id/bagas/tunnel-please-grpc/gen"
"google.golang.org/grpc" "google.golang.org/grpc"
+9 -8
View File
@@ -7,13 +7,14 @@ import (
"io" "io"
"testing" "testing"
"time" "time"
"tunnel_pls/internal/session/forwarder"
"tunnel_pls/internal/session/interaction"
"tunnel_pls/internal/session/lifecycle"
"tunnel_pls/internal/session/slug"
"tunnel_pls/internal/types"
"tunnel_pls/internal/port"
"tunnel_pls/internal/registry" "tunnel_pls/internal/registry"
"tunnel_pls/session/forwarder"
"tunnel_pls/session/interaction"
"tunnel_pls/session/lifecycle"
"tunnel_pls/session/slug"
"tunnel_pls/types"
proto "git.fossy.my.id/bagas/tunnel-please-grpc/gen" proto "git.fossy.my.id/bagas/tunnel-please-grpc/gen"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -884,16 +885,16 @@ func (m *mockLifecycle) Connection() ssh.Conn {
return args.Get(0).(ssh.Conn) return args.Get(0).(ssh.Conn)
} }
func (m *mockLifecycle) User() string { return m.Called().String(0) } func (m *mockLifecycle) User() string { return m.Called().String(0) }
func (m *mockLifecycle) SetChannel(channel ssh.Channel) error { return m.Called(channel).Error(0) } func (m *mockLifecycle) SetChannel(channel ssh.Channel) { m.Called(channel) }
func (m *mockLifecycle) SetStatus(status types.SessionStatus) { m.Called(status) } func (m *mockLifecycle) SetStatus(status types.SessionStatus) { m.Called(status) }
func (m *mockLifecycle) IsActive() bool { return m.Called().Bool(0) } func (m *mockLifecycle) IsActive() bool { return m.Called().Bool(0) }
func (m *mockLifecycle) StartedAt() time.Time { return m.Called().Get(0).(time.Time) } func (m *mockLifecycle) StartedAt() time.Time { return m.Called().Get(0).(time.Time) }
func (m *mockLifecycle) PortRegistry() lifecycle.PortRegistry { func (m *mockLifecycle) PortRegistry() port.Port {
args := m.Called() args := m.Called()
if args.Get(0) == nil { if args.Get(0) == nil {
return nil return nil
} }
return args.Get(0).(lifecycle.PortRegistry) return args.Get(0).(port.Port)
} }
type mockEventServiceClient struct { type mockEventServiceClient struct {
+5 -5
View File
@@ -3,11 +3,11 @@ package registry
import ( import (
"fmt" "fmt"
"sync" "sync"
"tunnel_pls/internal/session/forwarder" "tunnel_pls/session/forwarder"
"tunnel_pls/internal/session/interaction" "tunnel_pls/session/interaction"
"tunnel_pls/internal/session/lifecycle" "tunnel_pls/session/lifecycle"
"tunnel_pls/internal/session/slug" "tunnel_pls/session/slug"
"tunnel_pls/internal/types" "tunnel_pls/types"
) )
type Key = types.SessionKey type Key = types.SessionKey
+9 -8
View File
@@ -4,11 +4,12 @@ import (
"sync" "sync"
"testing" "testing"
"time" "time"
"tunnel_pls/internal/session/forwarder" "tunnel_pls/internal/port"
"tunnel_pls/internal/session/interaction" "tunnel_pls/session/forwarder"
"tunnel_pls/internal/session/lifecycle" "tunnel_pls/session/interaction"
"tunnel_pls/internal/session/slug" "tunnel_pls/session/lifecycle"
"tunnel_pls/internal/types" "tunnel_pls/session/slug"
"tunnel_pls/types"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
@@ -77,15 +78,15 @@ func (ml *mockLifecycle) Connection() ssh.Conn {
return args.Get(0).(ssh.Conn) return args.Get(0).(ssh.Conn)
} }
func (ml *mockLifecycle) PortRegistry() lifecycle.PortRegistry { func (ml *mockLifecycle) PortRegistry() port.Port {
args := ml.Called() args := ml.Called()
if args.Get(0) == nil { if args.Get(0) == nil {
return nil return nil
} }
return args.Get(0).(lifecycle.PortRegistry) return args.Get(0).(port.Port)
} }
func (ml *mockLifecycle) SetChannel(channel ssh.Channel) error { return ml.Called(channel).Error(0) } func (ml *mockLifecycle) SetChannel(channel ssh.Channel) { ml.Called(channel) }
func (ml *mockLifecycle) SetStatus(status types.SessionStatus) { ml.Called(status) } func (ml *mockLifecycle) SetStatus(status types.SessionStatus) { ml.Called(status) }
func (ml *mockLifecycle) IsActive() bool { return ml.Called().Bool(0) } func (ml *mockLifecycle) IsActive() bool { return ml.Called().Bool(0) }
func (ml *mockLifecycle) StartedAt() time.Time { return ml.Called().Get(0).(time.Time) } func (ml *mockLifecycle) StartedAt() time.Time { return ml.Called().Get(0).(time.Time) }
+2 -1
View File
@@ -55,7 +55,8 @@ func TestHTTPServer_Serve(t *testing.T) {
go func() { go func() {
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
_ = listener.Close() err = listener.Close()
assert.NoError(t, err)
}() }()
err = srv.Serve(listener) err = srv.Serve(listener)
+1 -1
View File
@@ -16,7 +16,7 @@ import (
"tunnel_pls/internal/http/stream" "tunnel_pls/internal/http/stream"
"tunnel_pls/internal/middleware" "tunnel_pls/internal/middleware"
"tunnel_pls/internal/registry" "tunnel_pls/internal/registry"
"tunnel_pls/internal/types" "tunnel_pls/types"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
+5 -5
View File
@@ -11,11 +11,11 @@ import (
"testing" "testing"
"time" "time"
"tunnel_pls/internal/registry" "tunnel_pls/internal/registry"
"tunnel_pls/internal/session/forwarder" "tunnel_pls/session/forwarder"
"tunnel_pls/internal/session/interaction" "tunnel_pls/session/interaction"
"tunnel_pls/internal/session/lifecycle" "tunnel_pls/session/lifecycle"
"tunnel_pls/internal/session/slug" "tunnel_pls/session/slug"
"tunnel_pls/internal/types" "tunnel_pls/types"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
+2 -1
View File
@@ -63,7 +63,8 @@ func TestHTTPSServer_Serve(t *testing.T) {
go func() { go func() {
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
_ = listener.Close() err = listener.Close()
assert.NoError(t, err)
}() }()
err = srv.Serve(listener) err = srv.Serve(listener)
+2 -1
View File
@@ -45,7 +45,8 @@ func TestTCPServer_Serve(t *testing.T) {
go func() { go func() {
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
_ = listener.Close() err = listener.Close()
assert.NoError(t, err)
}() }()
err = srv.Serve(listener) err = srv.Serve(listener)
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"testing" "testing"
"time" "time"
"tunnel_pls/internal/config" "tunnel_pls/internal/config"
"tunnel_pls/internal/types" "tunnel_pls/types"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
@@ -13,7 +13,7 @@ import (
"tunnel_pls/internal/port" "tunnel_pls/internal/port"
"tunnel_pls/internal/random" "tunnel_pls/internal/random"
"tunnel_pls/internal/registry" "tunnel_pls/internal/registry"
"tunnel_pls/internal/session" "tunnel_pls/session"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@@ -10,8 +10,8 @@ import (
"testing" "testing"
"time" "time"
"tunnel_pls/internal/registry" "tunnel_pls/internal/registry"
"tunnel_pls/internal/session/slug" "tunnel_pls/session/slug"
"tunnel_pls/internal/types" "tunnel_pls/types"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
@@ -10,8 +10,8 @@ import (
"strconv" "strconv"
"sync" "sync"
"tunnel_pls/internal/config" "tunnel_pls/internal/config"
"tunnel_pls/internal/session/slug" "tunnel_pls/session/slug"
"tunnel_pls/internal/types" "tunnel_pls/types"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@@ -28,7 +28,6 @@ type Forwarder interface {
Close() error Close() error
} }
type forwarder struct { type forwarder struct {
mu sync.RWMutex
listener net.Listener listener net.Listener
tunnelType types.TunnelType tunnelType types.TunnelType
forwardedPort uint16 forwardedPort uint16
@@ -61,7 +60,7 @@ func (f *forwarder) copyWithBuffer(dst io.Writer, src io.Reader) (written int64,
} }
func (f *forwarder) OpenForwardedChannel(ctx context.Context, origin net.Addr) (ssh.Channel, <-chan *ssh.Request, error) { func (f *forwarder) OpenForwardedChannel(ctx context.Context, origin net.Addr) (ssh.Channel, <-chan *ssh.Request, error) {
payload := createForwardedTCPIPPayload(origin, f.ForwardedPort()) payload := createForwardedTCPIPPayload(origin, f.forwardedPort)
type channelResult struct { type channelResult struct {
channel ssh.Channel channel ssh.Channel
reqs <-chan *ssh.Request reqs <-chan *ssh.Request
@@ -142,44 +141,32 @@ func (f *forwarder) HandleConnection(dst io.ReadWriter, src ssh.Channel) {
} }
func (f *forwarder) SetType(tunnelType types.TunnelType) { func (f *forwarder) SetType(tunnelType types.TunnelType) {
f.mu.Lock()
defer f.mu.Unlock()
f.tunnelType = tunnelType f.tunnelType = tunnelType
} }
func (f *forwarder) TunnelType() types.TunnelType { func (f *forwarder) TunnelType() types.TunnelType {
f.mu.RLock()
defer f.mu.RUnlock()
return f.tunnelType return f.tunnelType
} }
func (f *forwarder) ForwardedPort() uint16 { func (f *forwarder) ForwardedPort() uint16 {
f.mu.RLock()
defer f.mu.RUnlock()
return f.forwardedPort return f.forwardedPort
} }
func (f *forwarder) SetForwardedPort(port uint16) { func (f *forwarder) SetForwardedPort(port uint16) {
f.mu.Lock()
defer f.mu.Unlock()
f.forwardedPort = port f.forwardedPort = port
} }
func (f *forwarder) SetListener(listener net.Listener) { func (f *forwarder) SetListener(listener net.Listener) {
f.mu.Lock()
defer f.mu.Unlock()
f.listener = listener f.listener = listener
} }
func (f *forwarder) Listener() net.Listener { func (f *forwarder) Listener() net.Listener {
f.mu.RLock()
defer f.mu.RUnlock()
return f.listener return f.listener
} }
func (f *forwarder) Close() error { func (f *forwarder) Close() error {
if listener := f.Listener(); listener != nil { if f.Listener() != nil {
return listener.Close() return f.listener.Close()
} }
return nil return nil
} }
@@ -10,8 +10,8 @@ import (
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
"tunnel_pls/internal/session/slug" "tunnel_pls/session/slug"
"tunnel_pls/internal/types" "tunnel_pls/types"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
@@ -3,14 +3,14 @@ package interaction
import ( import (
"strings" "strings"
"github.com/charmbracelet/bubbles/textinput" tea "charm.land/bubbletea/v2"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
func (m *model) comingSoonUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *model) comingSoonUpdate(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
_ = msg
m.showingComingSoon = false m.showingComingSoon = false
return m, tea.Batch(tea.ClearScreen, textinput.Blink) return m, nil
} }
func (m *model) comingSoonView() string { func (m *model) comingSoonView() string {
@@ -4,9 +4,8 @@ import (
"strings" "strings"
"time" "time"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
@@ -17,24 +16,22 @@ func (m *model) handleCommandSelection(item commandItem) (tea.Model, tea.Cmd) {
m.editingSlug = true m.editingSlug = true
m.slugInput.SetValue(m.interaction.slug.String()) m.slugInput.SetValue(m.interaction.slug.String())
m.slugInput.Focus() m.slugInput.Focus()
return m, tea.Batch(tea.ClearScreen, textinput.Blink) return m, nil
case "tunnel-type": case "tunnel-type":
m.showingCommands = false m.showingCommands = false
m.showingComingSoon = true m.showingComingSoon = true
return m, tea.Batch(tickCmd(5*time.Second), tea.ClearScreen, textinput.Blink) return m, tickCmd(5 * time.Second)
default: default:
m.showingCommands = false m.showingCommands = false
return m, nil return m, nil
} }
} }
func (m *model) commandsUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *model) commandsUpdate(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch { switch {
case key.Matches(msg, m.keymap.quit), msg.String() == "esc": case key.Matches(msg, m.keymap.quit), msg.String() == "esc":
m.showingCommands = false m.showingCommands = false
return m, tea.Batch(tea.ClearScreen, textinput.Blink) return m, nil
case msg.String() == "enter": case msg.String() == "enter":
selectedItem := m.commandList.SelectedItem() selectedItem := m.commandList.SelectedItem()
if selectedItem != nil { if selectedItem != nil {
@@ -42,8 +39,8 @@ func (m *model) commandsUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.handleCommandSelection(item) return m.handleCommandSelection(item)
} }
} }
m.commandList, cmd = m.commandList.Update(msg) m.commandList, _ = m.commandList.Update(msg)
return m, cmd return m, nil
} }
func (m *model) commandsView() string { func (m *model) commandsView() string {
@@ -4,20 +4,19 @@ import (
"fmt" "fmt"
"strings" "strings"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
func (m *model) dashboardUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *model) dashboardUpdate(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch { switch {
case key.Matches(msg, m.keymap.quit): case key.Matches(msg, m.keymap.quit):
m.quitting = true m.quitting = true
return m, tea.Batch(tea.ClearScreen, textinput.Blink, tea.Quit) return m, tea.Quit
case key.Matches(msg, m.keymap.command): case key.Matches(msg, m.keymap.command):
m.showingCommands = true m.showingCommands = true
return m, tea.Batch(tea.ClearScreen, textinput.Blink) return m, nil
} }
return m, nil return m, nil
} }
@@ -6,14 +6,14 @@ import (
"sync" "sync"
"tunnel_pls/internal/config" "tunnel_pls/internal/config"
"tunnel_pls/internal/random" "tunnel_pls/internal/random"
"tunnel_pls/internal/session/slug" "tunnel_pls/session/slug"
"tunnel_pls/internal/types" "tunnel_pls/types"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv" "github.com/muesli/termenv"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
@@ -120,7 +120,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tickMsg: case tickMsg:
m.showingComingSoon = false m.showingComingSoon = false
return m, tea.Batch(tea.ClearScreen, textinput.Blink) return m, nil
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
@@ -137,9 +137,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.QuitMsg: case tea.QuitMsg:
m.quitting = true m.quitting = true
return m, tea.Batch(tea.ClearScreen, textinput.Blink, tea.Quit) return m, tea.Quit
case tea.KeyMsg: case tea.KeyPressMsg:
if m.showingComingSoon { if m.showingComingSoon {
return m.comingSoonUpdate(msg) return m.comingSoonUpdate(msg)
} }
@@ -160,28 +160,28 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (i *interaction) Redraw() { func (i *interaction) Redraw() {
if i.program != nil { if i.program != nil {
i.program.Send(tea.ClearScreen()) i.program.Send(tea.WindowSizeMsg{})
} }
} }
func (m *model) View() string { func (m *model) View() tea.View {
var content string
if m.quitting { if m.quitting {
return "" content = ""
} else if m.showingComingSoon {
content = m.comingSoonView()
} else if m.editingSlug {
content = m.slugView()
} else if m.showingCommands {
content = m.commandsView()
} else {
content = m.dashboardView()
} }
if m.showingComingSoon { v := tea.NewView(content)
return m.comingSoonView() v.AltScreen = true
} v.MouseMode = tea.MouseModeCellMotion
return v
if m.editingSlug {
return m.slugView()
}
if m.showingCommands {
return m.commandsView()
}
return m.dashboardView()
} }
func (i *interaction) Start() { func (i *interaction) Start() {
@@ -249,8 +249,6 @@ func (i *interaction) Start() {
m, m,
tea.WithInput(i.channel), tea.WithInput(i.channel),
tea.WithOutput(i.channel), tea.WithOutput(i.channel),
tea.WithAltScreen(),
tea.WithMouseCellMotion(),
tea.WithoutSignals(), tea.WithoutSignals(),
tea.WithoutSignalHandler(), tea.WithoutSignalHandler(),
tea.WithFPS(30), tea.WithFPS(30),
@@ -7,12 +7,12 @@ import (
"net" "net"
"testing" "testing"
"time" "time"
"tunnel_pls/internal/types" "tunnel_pls/types"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
@@ -694,7 +694,7 @@ func TestInteraction_Integration(t *testing.T) {
func TestModel_Update_KeyMessages(t *testing.T) { func TestModel_Update_KeyMessages(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
key tea.KeyMsg key tea.KeyPressMsg
showingComingSoon bool showingComingSoon bool
editingSlug bool editingSlug bool
showingCommands bool showingCommands bool
@@ -702,25 +702,25 @@ func TestModel_Update_KeyMessages(t *testing.T) {
}{ }{
{ {
name: "key press while showing coming soon", name: "key press while showing coming soon",
key: tea.KeyMsg{Type: tea.KeyEnter}, key: tea.KeyPressMsg{Code: tea.KeyEnter},
showingComingSoon: true, showingComingSoon: true,
description: "should call comingSoonUpdate", description: "should call comingSoonUpdate",
}, },
{ {
name: "key press while editing slug", name: "key press while editing slug",
key: tea.KeyMsg{Type: tea.KeyEnter}, key: tea.KeyPressMsg{Code: tea.KeyEnter},
editingSlug: true, editingSlug: true,
description: "should call slugUpdate", description: "should call slugUpdate",
}, },
{ {
name: "key press while showing commands", name: "key press while showing commands",
key: tea.KeyMsg{Type: tea.KeyEnter}, key: tea.KeyPressMsg{Code: tea.KeyEnter},
showingCommands: true, showingCommands: true,
description: "should call commandsUpdate", description: "should call commandsUpdate",
}, },
{ {
name: "key press in dashboard view", name: "key press in dashboard view",
key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}, key: tea.KeyPressMsg{Code: 'c', Text: "c"},
description: "should call dashboardUpdate", description: "should call dashboardUpdate",
}, },
} }
@@ -777,7 +777,7 @@ func TestModel_SlugUpdate(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
tunnelType types.TunnelType tunnelType types.TunnelType
keyMsg tea.KeyMsg keyMsg tea.KeyPressMsg
inputValue string inputValue string
setupMocks func(*MockSessionRegistry, *MockSlug, *MockRandom) setupMocks func(*MockSessionRegistry, *MockSlug, *MockRandom)
expectedEdit bool expectedEdit bool
@@ -787,21 +787,21 @@ func TestModel_SlugUpdate(t *testing.T) {
{ {
name: "escape key cancels editing", name: "escape key cancels editing",
tunnelType: types.TunnelTypeHTTP, tunnelType: types.TunnelTypeHTTP,
keyMsg: tea.KeyMsg{Type: tea.KeyEsc}, keyMsg: tea.KeyPressMsg{Code: tea.KeyEsc},
setupMocks: func(msr *MockSessionRegistry, ms *MockSlug, mr *MockRandom) {}, setupMocks: func(msr *MockSessionRegistry, ms *MockSlug, mr *MockRandom) {},
expectedEdit: false, expectedEdit: false,
}, },
{ {
name: "ctrl+c cancels editing", name: "ctrl+c cancels editing",
tunnelType: types.TunnelTypeHTTP, tunnelType: types.TunnelTypeHTTP,
keyMsg: tea.KeyMsg{Type: tea.KeyCtrlC}, keyMsg: tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl},
setupMocks: func(msr *MockSessionRegistry, ms *MockSlug, mr *MockRandom) {}, setupMocks: func(msr *MockSessionRegistry, ms *MockSlug, mr *MockRandom) {},
expectedEdit: false, expectedEdit: false,
}, },
{ {
name: "enter key saves valid slug", name: "enter key saves valid slug",
tunnelType: types.TunnelTypeHTTP, tunnelType: types.TunnelTypeHTTP,
keyMsg: tea.KeyMsg{Type: tea.KeyEnter}, keyMsg: tea.KeyPressMsg{Code: tea.KeyEnter},
inputValue: "my-custom-slug", inputValue: "my-custom-slug",
setupMocks: func(msr *MockSessionRegistry, ms *MockSlug, mr *MockRandom) { setupMocks: func(msr *MockSessionRegistry, ms *MockSlug, mr *MockRandom) {
ms.On("String").Return("old-slug") ms.On("String").Return("old-slug")
@@ -816,7 +816,7 @@ func TestModel_SlugUpdate(t *testing.T) {
{ {
name: "enter key with error shows error message", name: "enter key with error shows error message",
tunnelType: types.TunnelTypeHTTP, tunnelType: types.TunnelTypeHTTP,
keyMsg: tea.KeyMsg{Type: tea.KeyEnter}, keyMsg: tea.KeyPressMsg{Code: tea.KeyEnter},
inputValue: "invalid", inputValue: "invalid",
setupMocks: func(msr *MockSessionRegistry, ms *MockSlug, mr *MockRandom) { setupMocks: func(msr *MockSessionRegistry, ms *MockSlug, mr *MockRandom) {
ms.On("String").Return("old-slug") ms.On("String").Return("old-slug")
@@ -831,7 +831,7 @@ func TestModel_SlugUpdate(t *testing.T) {
{ {
name: "ctrl+r generates random slug", name: "ctrl+r generates random slug",
tunnelType: types.TunnelTypeHTTP, tunnelType: types.TunnelTypeHTTP,
keyMsg: tea.KeyMsg{Type: tea.KeyCtrlR}, keyMsg: tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl},
setupMocks: func(msr *MockSessionRegistry, ms *MockSlug, mr *MockRandom) { setupMocks: func(msr *MockSessionRegistry, ms *MockSlug, mr *MockRandom) {
mr.On("String", 20).Return("random-generated-slug", nil) mr.On("String", 20).Return("random-generated-slug", nil)
}, },
@@ -841,7 +841,7 @@ func TestModel_SlugUpdate(t *testing.T) {
{ {
name: "ctrl+r with error does nothing", name: "ctrl+r with error does nothing",
tunnelType: types.TunnelTypeHTTP, tunnelType: types.TunnelTypeHTTP,
keyMsg: tea.KeyMsg{Type: tea.KeyCtrlR}, keyMsg: tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl},
setupMocks: func(msr *MockSessionRegistry, ms *MockSlug, mr *MockRandom) { setupMocks: func(msr *MockSessionRegistry, ms *MockSlug, mr *MockRandom) {
mr.On("String", 20).Return("", assert.AnError) mr.On("String", 20).Return("", assert.AnError)
}, },
@@ -850,14 +850,14 @@ func TestModel_SlugUpdate(t *testing.T) {
{ {
name: "regular key updates input", name: "regular key updates input",
tunnelType: types.TunnelTypeHTTP, tunnelType: types.TunnelTypeHTTP,
keyMsg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}, keyMsg: tea.KeyPressMsg{Code: 'a', Text: "a"},
setupMocks: func(msr *MockSessionRegistry, ms *MockSlug, mr *MockRandom) {}, setupMocks: func(msr *MockSessionRegistry, ms *MockSlug, mr *MockRandom) {},
expectedEdit: true, expectedEdit: true,
}, },
{ {
name: "tcp tunnel exits editing immediately", name: "tcp tunnel exits editing immediately",
tunnelType: types.TunnelTypeTCP, tunnelType: types.TunnelTypeTCP,
keyMsg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}, keyMsg: tea.KeyPressMsg{Code: 'a', Text: "a"},
setupMocks: func(msr *MockSessionRegistry, ms *MockSlug, mr *MockRandom) {}, setupMocks: func(msr *MockSessionRegistry, ms *MockSlug, mr *MockRandom) {},
expectedEdit: false, expectedEdit: false,
}, },
@@ -1006,19 +1006,19 @@ func TestModel_SlugView(t *testing.T) {
func TestModel_ComingSoonUpdate(t *testing.T) { func TestModel_ComingSoonUpdate(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
keyMsg tea.KeyMsg keyMsg tea.KeyPressMsg
}{ }{
{ {
name: "any key dismisses coming soon", name: "any key dismisses coming soon",
keyMsg: tea.KeyMsg{Type: tea.KeyEnter}, keyMsg: tea.KeyPressMsg{Code: tea.KeyEnter},
}, },
{ {
name: "escape key dismisses", name: "escape key dismisses",
keyMsg: tea.KeyMsg{Type: tea.KeyEsc}, keyMsg: tea.KeyPressMsg{Code: tea.KeyEsc},
}, },
{ {
name: "space key dismisses", name: "space key dismisses",
keyMsg: tea.KeyMsg{Type: tea.KeySpace}, keyMsg: tea.KeyPressMsg{Code: ' ', Text: " "},
}, },
} }
@@ -1097,7 +1097,7 @@ func TestModel_ComingSoonView(t *testing.T) {
func TestModel_CommandsUpdate(t *testing.T) { func TestModel_CommandsUpdate(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
keyMsg tea.KeyMsg keyMsg tea.KeyPressMsg
selectedItem list.Item selectedItem list.Item
expectCommands bool expectCommands bool
expectEditSlug bool expectEditSlug bool
@@ -1105,31 +1105,31 @@ func TestModel_CommandsUpdate(t *testing.T) {
}{ }{
{ {
name: "escape key closes commands", name: "escape key closes commands",
keyMsg: tea.KeyMsg{Type: tea.KeyEsc}, keyMsg: tea.KeyPressMsg{Code: tea.KeyEsc},
expectCommands: false, expectCommands: false,
}, },
{ {
name: "q key closes commands", name: "q key closes commands",
keyMsg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}, keyMsg: tea.KeyPressMsg{Code: 'q', Text: "q"},
expectCommands: false, expectCommands: false,
}, },
{ {
name: "enter on slug command starts editing", name: "enter on slug command starts editing",
keyMsg: tea.KeyMsg{Type: tea.KeyEnter}, keyMsg: tea.KeyPressMsg{Code: tea.KeyEnter},
selectedItem: commandItem{name: "slug", desc: "Set custom subdomain"}, selectedItem: commandItem{name: "slug", desc: "Set custom subdomain"},
expectCommands: false, expectCommands: false,
expectEditSlug: true, expectEditSlug: true,
}, },
{ {
name: "enter on tunnel-type shows coming soon", name: "enter on tunnel-type shows coming soon",
keyMsg: tea.KeyMsg{Type: tea.KeyEnter}, keyMsg: tea.KeyPressMsg{Code: tea.KeyEnter},
selectedItem: commandItem{name: "tunnel-type", desc: "Change tunnel type"}, selectedItem: commandItem{name: "tunnel-type", desc: "Change tunnel type"},
expectCommands: false, expectCommands: false,
expectComingSoon: true, expectComingSoon: true,
}, },
{ {
name: "arrow key navigates list", name: "arrow key navigates list",
keyMsg: tea.KeyMsg{Type: tea.KeyDown}, keyMsg: tea.KeyPressMsg{Code: tea.KeyDown},
expectCommands: true, expectCommands: true,
}, },
} }
@@ -1261,28 +1261,28 @@ func TestModel_CommandsView(t *testing.T) {
func TestModel_DashboardUpdate(t *testing.T) { func TestModel_DashboardUpdate(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
keyMsg tea.KeyMsg keyMsg tea.KeyPressMsg
expectQuit bool expectQuit bool
expectCommands bool expectCommands bool
}{ }{
{ {
name: "q key quits", name: "q key quits",
keyMsg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}, keyMsg: tea.KeyPressMsg{Code: 'q', Text: "q"},
expectQuit: true, expectQuit: true,
}, },
{ {
name: "ctrl+c quits", name: "ctrl+c quits",
keyMsg: tea.KeyMsg{Type: tea.KeyCtrlC}, keyMsg: tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl},
expectQuit: true, expectQuit: true,
}, },
{ {
name: "c key opens commands", name: "c key opens commands",
keyMsg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}, keyMsg: tea.KeyPressMsg{Code: 'c', Text: "c"},
expectCommands: true, expectCommands: true,
}, },
{ {
name: "other keys do nothing", name: "other keys do nothing",
keyMsg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}, keyMsg: tea.KeyPressMsg{Code: 'x', Text: "x"},
}, },
} }
@@ -1922,6 +1922,10 @@ func TestInteraction_Start_ProtocolSelection(t *testing.T) {
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
i := mockInteraction.(*interaction) i := mockInteraction.(*interaction)
if i.program != nil {
assert.NotNil(t, i.program, "program should be initialized")
}
i.Stop() i.Stop()
mockConfig.AssertExpectations(t) mockConfig.AssertExpectations(t)
@@ -4,13 +4,13 @@ import (
"fmt" "fmt"
"time" "time"
"tunnel_pls/internal/random" "tunnel_pls/internal/random"
"tunnel_pls/internal/types" "tunnel_pls/types"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
) )
type commandItem struct { type commandItem struct {
@@ -77,7 +77,7 @@ type keymap struct {
type tickMsg time.Time type tickMsg time.Time
func (m *model) Init() tea.Cmd { func (m *model) Init() tea.Cmd {
return tea.Batch(textinput.Blink, tea.WindowSize()) return tea.RequestWindowSize
} }
func getResponsiveWidth(screenWidth, padding, minWidth, maxWidth int) int { func getResponsiveWidth(screenWidth, padding, minWidth, maxWidth int) int {
@@ -3,28 +3,26 @@ package interaction
import ( import (
"fmt" "fmt"
"strings" "strings"
"tunnel_pls/internal/types" "tunnel_pls/types"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
func (m *model) slugUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *model) slugUpdate(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
if m.tunnelType != types.TunnelTypeHTTP { if m.tunnelType != types.TunnelTypeHTTP {
m.editingSlug = false m.editingSlug = false
m.slugError = "" m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink) return m, nil
} }
switch msg.String() { switch msg.String() {
case "esc", "ctrl+c": case "esc", "ctrl+c":
m.editingSlug = false m.editingSlug = false
m.slugError = "" m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink) return m, nil
case "enter": case "enter":
inputValue := m.slugInput.Value() inputValue := m.slugInput.Value()
if err := m.interaction.sessionRegistry.Update(m.interaction.user, types.SessionKey{ if err := m.interaction.sessionRegistry.Update(m.interaction.user, types.SessionKey{
@@ -39,18 +37,18 @@ func (m *model) slugUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
m.editingSlug = false m.editingSlug = false
m.slugError = "" m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink) return m, nil
default: default:
if key.Matches(msg, m.keymap.random) { if key.Matches(msg, m.keymap.random) {
newSubdomain, err := m.randomizer.String(20) newSubdomain, err := m.randomizer.String(20)
if err != nil { if err != nil {
return m, cmd return m, nil
} }
m.slugInput.SetValue(newSubdomain) m.slugInput.SetValue(newSubdomain)
} }
m.slugError = "" m.slugError = ""
m.slugInput, cmd = m.slugInput.Update(msg) m.slugInput, _ = m.slugInput.Update(msg)
return m, cmd return m, nil
} }
} }
@@ -2,13 +2,14 @@ package lifecycle
import ( import (
"errors" "errors"
"fmt"
"io" "io"
"net" "net"
"sync" "sync"
"time" "time"
"tunnel_pls/internal/session/slug"
"tunnel_pls/internal/types" portUtil "tunnel_pls/internal/port"
"tunnel_pls/session/slug"
"tunnel_pls/types"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@@ -23,12 +24,6 @@ type SessionRegistry interface {
Remove(key types.SessionKey) Remove(key types.SessionKey)
} }
type PortRegistry interface {
Unassigned() (uint16, bool)
Claim(port uint16) bool
SetStatus(port uint16, assigned bool) error
}
type lifecycle struct { type lifecycle struct {
mu sync.Mutex mu sync.Mutex
status types.SessionStatus status types.SessionStatus
@@ -39,18 +34,18 @@ type lifecycle struct {
slug slug.Slug slug slug.Slug
startedAt time.Time startedAt time.Time
sessionRegistry SessionRegistry sessionRegistry SessionRegistry
portRegistry PortRegistry portRegistry portUtil.Port
user string user string
} }
func New(conn ssh.Conn, forwarder Forwarder, slugManager slug.Slug, port PortRegistry, sessionRegistry SessionRegistry, user string) Lifecycle { func New(conn ssh.Conn, forwarder Forwarder, slugManager slug.Slug, port portUtil.Port, sessionRegistry SessionRegistry, user string) Lifecycle {
return &lifecycle{ return &lifecycle{
status: types.SessionStatusINITIALIZING, status: types.SessionStatusINITIALIZING,
conn: conn, conn: conn,
channel: nil, channel: nil,
forwarder: forwarder, forwarder: forwarder,
slug: slugManager, slug: slugManager,
startedAt: time.Time{}, startedAt: time.Now(),
sessionRegistry: sessionRegistry, sessionRegistry: sessionRegistry,
portRegistry: port, portRegistry: port,
user: user, user: user,
@@ -60,16 +55,16 @@ func New(conn ssh.Conn, forwarder Forwarder, slugManager slug.Slug, port PortReg
type Lifecycle interface { type Lifecycle interface {
Connection() ssh.Conn Connection() ssh.Conn
Channel() ssh.Channel Channel() ssh.Channel
PortRegistry() PortRegistry PortRegistry() portUtil.Port
User() string User() string
SetChannel(channel ssh.Channel) error SetChannel(channel ssh.Channel)
SetStatus(status types.SessionStatus) SetStatus(status types.SessionStatus)
IsActive() bool IsActive() bool
StartedAt() time.Time StartedAt() time.Time
Close() error Close() error
} }
func (l *lifecycle) PortRegistry() PortRegistry { func (l *lifecycle) PortRegistry() portUtil.Port {
return l.portRegistry return l.portRegistry
} }
@@ -77,25 +72,11 @@ func (l *lifecycle) User() string {
return l.user return l.user
} }
func (l *lifecycle) SetChannel(channel ssh.Channel) error { func (l *lifecycle) SetChannel(channel ssh.Channel) {
l.mu.Lock()
defer l.mu.Unlock()
if l.status == types.SessionStatusCLOSED {
return fmt.Errorf("lifecycle is closed")
}
if channel == nil {
return fmt.Errorf("channel cannot be nil")
}
if l.channel != nil {
return fmt.Errorf("channel already set")
}
l.channel = channel l.channel = channel
return nil
} }
func (l *lifecycle) Channel() ssh.Channel { func (l *lifecycle) Channel() ssh.Channel {
l.mu.Lock()
defer l.mu.Unlock()
return l.channel return l.channel
} }
@@ -106,13 +87,7 @@ func (l *lifecycle) Connection() ssh.Conn {
func (l *lifecycle) SetStatus(status types.SessionStatus) { func (l *lifecycle) SetStatus(status types.SessionStatus) {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
if l.status == types.SessionStatusCLOSED {
return
}
l.status = status l.status = status
if status == types.SessionStatusRUNNING && l.startedAt.IsZero() {
l.startedAt = time.Now()
}
} }
func (l *lifecycle) IsActive() bool { func (l *lifecycle) IsActive() bool {
@@ -123,74 +98,50 @@ func (l *lifecycle) IsActive() bool {
func (l *lifecycle) Close() error { func (l *lifecycle) Close() error {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock()
if l.status == types.SessionStatusCLOSED { if l.status == types.SessionStatusCLOSED {
closeErr := l.closeErr return l.closeErr
l.mu.Unlock()
return closeErr
} }
l.status = types.SessionStatusCLOSED l.status = types.SessionStatusCLOSED
channel := l.channel
conn := l.conn
l.mu.Unlock()
var errs []error var errs []error
if channel != nil { tunnelType := l.forwarder.TunnelType()
if err := channel.Close(); err != nil && !isClosedError(err) {
errs = append(errs, err) if l.channel != nil {
} if err := l.channel.Close(); err != nil && !isClosedError(err) {
}
if conn != nil {
if err := conn.Close(); err != nil && !isClosedError(err) {
errs = append(errs, err) errs = append(errs, err)
} }
} }
l.cleanupRegistry() if l.conn != nil {
if err := l.cleanupForwarder(); err != nil { if err := l.conn.Close(); err != nil && !isClosedError(err) {
errs = append(errs, err) errs = append(errs, err)
}
} }
closeErr := errors.Join(errs...) clientSlug := l.slug.String()
l.mu.Lock()
l.closeErr = closeErr
l.mu.Unlock()
return closeErr
}
func (l *lifecycle) cleanupRegistry() {
slugStr := l.slug.String()
if slugStr == "" {
return
}
key := types.SessionKey{ key := types.SessionKey{
Id: slugStr, Id: clientSlug,
Type: l.forwarder.TunnelType(), Type: tunnelType,
} }
l.sessionRegistry.Remove(key) l.sessionRegistry.Remove(key)
}
func (l *lifecycle) cleanupForwarder() error { if tunnelType == types.TunnelTypeTCP {
if l.forwarder.TunnelType() != types.TunnelTypeTCP { errs = append(errs, l.PortRegistry().SetStatus(l.forwarder.ForwardedPort(), false))
return nil errs = append(errs, l.forwarder.Close())
} }
var errs []error
errs = append(errs, l.portRegistry.SetStatus(l.forwarder.ForwardedPort(), false)) l.closeErr = errors.Join(errs...)
errs = append(errs, l.forwarder.Close()) return l.closeErr
return errors.Join(errs...)
} }
func isClosedError(err error) bool { func isClosedError(err error) bool {
if err == nil { if err == nil {
return false return false
} }
return errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) return errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) || err.Error() == "EOF"
} }
func (l *lifecycle) StartedAt() time.Time { func (l *lifecycle) StartedAt() time.Time {
l.mu.Lock()
defer l.mu.Unlock()
return l.startedAt return l.startedAt
} }
@@ -5,9 +5,8 @@ import (
"errors" "errors"
"io" "io"
"net" "net"
"sync"
"testing" "testing"
"tunnel_pls/internal/types" "tunnel_pls/types"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
@@ -178,14 +177,8 @@ func TestLifecycle_SetChannel(t *testing.T) {
mockSSHChannel := &MockSSHChannel{} mockSSHChannel := &MockSSHChannel{}
err := mockLifecycle.SetChannel(mockSSHChannel) mockLifecycle.SetChannel(mockSSHChannel)
assert.NoError(t, err)
assert.Equal(t, mockSSHChannel, mockLifecycle.Channel())
anotherChannel := &MockSSHChannel{}
err = mockLifecycle.SetChannel(anotherChannel)
assert.Error(t, err)
assert.Contains(t, err.Error(), "channel already set")
assert.Equal(t, mockSSHChannel, mockLifecycle.Channel()) assert.Equal(t, mockSSHChannel, mockLifecycle.Channel())
} }
@@ -283,15 +276,14 @@ func TestLifecycle_Close(t *testing.T) {
mockLifecycle := New(mockSSHConn, mockForwarder, mockSlug, mockPort, mockSessionRegistry, "mas-fuad") mockLifecycle := New(mockSSHConn, mockForwarder, mockSlug, mockPort, mockSessionRegistry, "mas-fuad")
mockLifecycle.SetStatus(types.SessionStatusRUNNING) mockLifecycle.SetStatus(types.SessionStatusRUNNING)
err := mockLifecycle.SetChannel(mockSSHChannel) mockLifecycle.SetChannel(mockSSHChannel)
assert.NoError(t, err)
if tt.alreadyClosed { if tt.alreadyClosed {
err = mockLifecycle.Close() err := mockLifecycle.Close()
assert.NoError(t, err) assert.NoError(t, err)
} }
err = mockLifecycle.Close() err := mockLifecycle.Close()
if tt.expectErr { if tt.expectErr {
assert.Error(t, err) assert.Error(t, err)
@@ -309,116 +301,3 @@ func TestLifecycle_Close(t *testing.T) {
}) })
} }
} }
func TestLifecycle_ConcurrentClose(t *testing.T) {
mockSSHConn := &MockSSHConn{}
mockSSHConn.On("Close").Return(nil)
mockForwarder := &MockForwarder{}
mockForwarder.On("TunnelType").Return(types.TunnelTypeHTTP)
mockSlug := &MockSlug{}
mockSlug.On("String").Return("test-slug")
mockPort := &MockPort{}
mockSessionRegistry := &MockSessionRegistry{}
mockSessionRegistry.On("Remove", mock.Anything).Return()
mockSSHChannel := &MockSSHChannel{}
mockSSHChannel.On("Close").Return(nil)
mockLifecycle := New(mockSSHConn, mockForwarder, mockSlug, mockPort, mockSessionRegistry, "mas-fuad")
mockLifecycle.SetStatus(types.SessionStatusRUNNING)
err := mockLifecycle.SetChannel(mockSSHChannel)
assert.NoError(t, err)
const numGoroutines = 10
var wg sync.WaitGroup
errChan := make(chan error, numGoroutines)
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
err := mockLifecycle.Close()
errChan <- err
}()
}
wg.Wait()
close(errChan)
for err := range errChan {
assert.NoError(t, err)
}
assert.False(t, mockLifecycle.IsActive())
}
func TestLifecycle_SetChannel_AfterClose(t *testing.T) {
mockSSHConn := new(MockSSHConn)
mockSSHConn.On("Close").Return(nil)
mockForwarder := &MockForwarder{}
mockForwarder.On("TunnelType").Return(types.TunnelTypeHTTP)
mockSlug := &MockSlug{}
mockSlug.On("String").Return("test-slug")
mockPort := &MockPort{}
mockSessionRegistry := &MockSessionRegistry{}
mockSessionRegistry.On("Remove", mock.Anything).Return()
mockSSHChannel := &MockSSHChannel{}
mockSSHChannel.On("Close").Return(nil)
mockLifecycle := New(mockSSHConn, mockForwarder, mockSlug, mockPort, mockSessionRegistry, "mas-fuad")
mockLifecycle.SetStatus(types.SessionStatusRUNNING)
err := mockLifecycle.SetChannel(mockSSHChannel)
assert.NoError(t, err)
err = mockLifecycle.Close()
assert.NoError(t, err)
anotherChannel := &MockSSHChannel{}
err = mockLifecycle.SetChannel(anotherChannel)
assert.Error(t, err)
assert.Contains(t, err.Error(), "lifecycle is closed")
}
func TestLifecycle_SetChannel_Nil(t *testing.T) {
mockSSHConn := new(MockSSHConn)
mockForwarder := &MockForwarder{}
mockSlug := &MockSlug{}
mockPort := &MockPort{}
mockSessionRegistry := &MockSessionRegistry{}
mockLifecycle := New(mockSSHConn, mockForwarder, mockSlug, mockPort, mockSessionRegistry, "mas-fuad")
err := mockLifecycle.SetChannel(nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "channel cannot be nil")
}
func TestLifecycle_SetStatus_AfterClose(t *testing.T) {
mockSSHConn := new(MockSSHConn)
mockSSHConn.On("Close").Return(nil)
mockForwarder := &MockForwarder{}
mockForwarder.On("TunnelType").Return(types.TunnelTypeHTTP)
mockSlug := &MockSlug{}
mockSlug.On("String").Return("test-slug")
mockPort := &MockPort{}
mockSessionRegistry := &MockSessionRegistry{}
mockSessionRegistry.On("Remove", mock.Anything).Return()
mockSSHChannel := &MockSSHChannel{}
mockSSHChannel.On("Close").Return(nil)
mockLifecycle := New(mockSSHConn, mockForwarder, mockSlug, mockPort, mockSessionRegistry, "mas-fuad")
mockLifecycle.SetStatus(types.SessionStatusRUNNING)
err := mockLifecycle.SetChannel(mockSSHChannel)
assert.NoError(t, err)
err = mockLifecycle.Close()
assert.NoError(t, err)
assert.False(t, mockLifecycle.IsActive())
mockLifecycle.SetStatus(types.SessionStatusRUNNING)
assert.False(t, mockLifecycle.IsActive(), "SetStatus should be ignored after Close")
}
@@ -12,12 +12,12 @@ import (
portUtil "tunnel_pls/internal/port" portUtil "tunnel_pls/internal/port"
"tunnel_pls/internal/random" "tunnel_pls/internal/random"
"tunnel_pls/internal/registry" "tunnel_pls/internal/registry"
"tunnel_pls/internal/session/forwarder"
"tunnel_pls/internal/session/interaction"
"tunnel_pls/internal/session/lifecycle"
"tunnel_pls/internal/session/slug"
"tunnel_pls/internal/transport" "tunnel_pls/internal/transport"
"tunnel_pls/internal/types" "tunnel_pls/session/forwarder"
"tunnel_pls/session/interaction"
"tunnel_pls/session/lifecycle"
"tunnel_pls/session/slug"
"tunnel_pls/types"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@@ -158,15 +158,13 @@ func (s *session) setupInteractiveMode(channel ssh.NewChannel) error {
} }
go func() { go func() {
err := s.HandleGlobalRequest(reqs) err = s.HandleGlobalRequest(reqs)
if err != nil { if err != nil {
log.Printf("global request handler error: %v", err) log.Printf("global request handler error: %v", err)
} }
}() }()
if err = s.lifecycle.SetChannel(ch); err != nil { s.lifecycle.SetChannel(ch)
return err
}
s.interaction.SetChannel(ch) s.interaction.SetChannel(ch)
s.interaction.SetMode(types.InteractiveModeINTERACTIVE) s.interaction.SetMode(types.InteractiveModeINTERACTIVE)
@@ -200,22 +198,23 @@ func (s *session) waitForSessionEnd() error {
} }
func (s *session) waitForTCPIPForward() *ssh.Request { func (s *session) waitForTCPIPForward() *ssh.Request {
for { select {
select { case req, ok := <-s.initialReq:
case req, ok := <-s.initialReq: if !ok {
if !ok { log.Println("Forwarding request channel closed")
log.Println("Forwarding request channel closed")
return nil
}
if req.Type == "tcpip-forward" {
return req
}
log.Printf("Ignoring unexpected global request: %s", req.Type)
_ = req.Reply(false, nil)
case <-time.After(500 * time.Millisecond):
log.Println("No tcpip-forward request received within timeout")
return nil return nil
} }
if req.Type == "tcpip-forward" {
return req
}
if err := req.Reply(false, nil); err != nil {
log.Printf("Failed to reply to request: %v", err)
}
log.Printf("Expected tcpip-forward request, got: %s", req.Type)
return nil
case <-time.After(500 * time.Millisecond):
log.Println("No forwarding request received")
return nil
} }
} }
@@ -378,7 +377,8 @@ func (s *session) HandleTCPForward(req *ssh.Request, addr string, portToBind uin
} }
go func() { go func() {
if err := tcpServer.Serve(listener); err != nil { err = tcpServer.Serve(listener)
if err != nil {
log.Printf("Failed serving tcp server: %s\n", err) log.Printf("Failed serving tcp server: %s\n", err)
} }
}() }()
@@ -15,8 +15,8 @@ import (
"time" "time"
"tunnel_pls/internal/config" "tunnel_pls/internal/config"
"tunnel_pls/internal/registry" "tunnel_pls/internal/registry"
"tunnel_pls/internal/session/lifecycle" "tunnel_pls/session/lifecycle"
"tunnel_pls/internal/types" "tunnel_pls/types"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
@@ -807,7 +807,7 @@ func (m *mockNewChanFail) Accept() (ssh.Channel, <-chan *ssh.Request, error) {
} }
func TestWaitForTCPIPForward_EdgeCases(t *testing.T) { func TestWaitForTCPIPForward_EdgeCases(t *testing.T) {
t.Run("Wrong Request Type Then Timeout", func(t *testing.T) { t.Run("Wrong Request Type", func(t *testing.T) {
_, sReqs, _, cConn, cleanup := setupSSH(t) _, sReqs, _, cConn, cleanup := setupSSH(t)
defer cleanup() defer cleanup()
@@ -817,65 +817,10 @@ func TestWaitForTCPIPForward_EdgeCases(t *testing.T) {
_, _, _ = cConn.SendRequest("not-tcpip-forward", true, nil) _, _, _ = cConn.SendRequest("not-tcpip-forward", true, nil)
}() }()
start := time.Now()
req := s.waitForTCPIPForward() req := s.waitForTCPIPForward()
elapsed := time.Since(start)
if req != nil { if req != nil {
t.Error("expected nil request") t.Error("expected nil request")
} }
if elapsed < 400*time.Millisecond {
t.Errorf("expected timeout ~500ms, got %v", elapsed)
}
})
t.Run("Multiple Non-Forward Requests Then Success", func(t *testing.T) {
_, sReqs, _, cConn, cleanup := setupSSH(t)
defer cleanup()
s := &session{initialReq: sReqs}
go func() {
time.Sleep(100 * time.Millisecond)
_, _, _ = cConn.SendRequest("keepalive@openssh.com", false, nil)
time.Sleep(100 * time.Millisecond)
_, _, _ = cConn.SendRequest("hostkeys-00@openssh.com", false, nil)
time.Sleep(100 * time.Millisecond)
_, _, _ = cConn.SendRequest("tcpip-forward", true, nil)
}()
req := s.waitForTCPIPForward()
if req == nil {
t.Error("expected tcpip-forward request, got nil")
}
if req != nil && req.Type != "tcpip-forward" {
t.Errorf("expected tcpip-forward, got %s", req.Type)
}
})
t.Run("Timeout After Non-Forward Requests", func(t *testing.T) {
_, sReqs, _, cConn, cleanup := setupSSH(t)
defer cleanup()
s := &session{initialReq: sReqs}
go func() {
time.Sleep(100 * time.Millisecond)
_, _, _ = cConn.SendRequest("keepalive@openssh.com", false, nil)
time.Sleep(100 * time.Millisecond)
_, _, _ = cConn.SendRequest("hostkeys-00@openssh.com", false, nil)
}()
start := time.Now()
req := s.waitForTCPIPForward()
elapsed := time.Since(start)
if req != nil {
t.Error("expected nil request after timeout")
}
if elapsed < 400*time.Millisecond {
t.Errorf("expected timeout ~500ms after last request, got %v", elapsed)
}
}) })
t.Run("Channel Closed", func(t *testing.T) { t.Run("Channel Closed", func(t *testing.T) {
@@ -1,14 +1,11 @@
package slug package slug
import "sync"
type Slug interface { type Slug interface {
String() string String() string
Set(slug string) Set(slug string)
} }
type slug struct { type slug struct {
mu sync.RWMutex
slug string slug string
} }
@@ -19,13 +16,9 @@ func New() Slug {
} }
func (s *slug) String() string { func (s *slug) String() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.slug return s.slug
} }
func (s *slug) Set(slug string) { func (s *slug) Set(slug string) {
s.mu.Lock()
defer s.mu.Unlock()
s.slug = slug s.slug = slug
} }