34 Commits

Author SHA1 Message Date
2b9bca65d5 refactor(interaction): separate view and update logic into modular files
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 11m44s
- Extract slug editing logic to slug.go (slugView/slugUpdate)
- Extract commands menu logic to commands.go (commandsView/commandsUpdate)
- Extract coming soon modal to coming_soon.go (comingSoonView/comingSoonUpdate)
- Extract main dashboard logic to dashboard.go (dashboardView/dashboardUpdate)
- Create model.go for shared model struct and helper functions
- Replace math/rand with crypto/rand for random subdomain generation
- Remove legacy TLS cipher suite configuration
2026-01-17 17:33:10 +07:00
6587dc0f39 refactor(interaction): separate view and update logic into modular files
- Extract slug editing logic to slug.go (slugView/slugUpdate)
- Extract commands menu logic to commands.go (commandsView/commandsUpdate)
- Extract coming soon modal to coming_soon.go (comingSoonView/comingSoonUpdate)
- Extract main dashboard logic to dashboard.go (dashboardView/dashboardUpdate)
- Create model.go for shared model struct and helper functions
- Replace math/rand with crypto/rand for random subdomain generation
- Remove legacy TLS cipher suite configuration
2026-01-17 17:30:21 +07:00
6969d6823a Merge branch 'main' into staging
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 11m38s
2026-01-16 16:35:36 +07:00
1a04af8873 Merge branch 'main' into staging
Docker Build and Push / build-and-push-branches (push) Successful in 11m35s
Docker Build and Push / build-and-push-tags (push) Has been skipped
2026-01-16 16:28:39 +07:00
19135ceb42 refactor: convert structs to interfaces and rename accessors
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Has been cancelled
- Convert struct types to interfaces
- Rename getter and setter methods
- Add Close method to server interface
- Merge handler functionality into session file
- Handle lifecycle.Connection().Wait()
- fix panic on nil connection in SSH server
2026-01-16 15:25:31 +07:00
edb11dbc51 Merge pull request 'chore(deps): update golang docker tag to v1.25.6' (#67) from renovate/golang-1.x into main
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Successful in 11m34s
2026-01-16 05:01:06 +07:00
819f044275 chore(deps): update golang docker tag to v1.25.6 2026-01-15 22:01:02 +00:00
a7ebf2c5db Merge pull request 'fix(deps): update module golang.org/x/crypto to v0.47.0' (#66) from renovate/golang.org-x-crypto-0.x into main
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Successful in 10m34s
Reviewed-on: #66
2026-01-14 10:42:52 +00:00
64c1038f4b fix(deps): update module golang.org/x/crypto to v0.47.0 2026-01-14 10:41:47 +00:00
aafea49975 feat: integrate gRPC, session refactor, SSH headless support, and bug fixes
Docker Build and Push / build-and-push-tags (push) Successful in 11m34s
Docker Build and Push / build-and-push-branches (push) Has been skipped
- gRPC integration: slug edit handling, get sessions by user, and session requests from gRPC server
- Refactor gRPC client: simplify processEventStream and handle authenticated user info
- Session management improvements: use session key for registry, forwarder session termination, inject SessionRegistry interface
- SSH enhancements: add headless mode support for SSH -N connections
- Bug fixes:
  - prevent subdomain changes to already-in-use subdomains
  - fix startup order and environment variable keys
  - atomic ClaimPort() to prevent race conditions
- Refactors:
  - consolidate error handling
  - replace Get/Set patterns with idiomatic Go interfaces
  - change enums from string to int
- CI cleanup: remove renovate bot

Reviewed-on: #65
2026-01-14 10:16:43 +00:00
dbdf8094fa refactor: replace Get/Set patterns with idiomatic Go interfaces
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 13m4s
- rename constructors to New
- remove Get/Set-style accessors
- replace string-based enums with iota-backed types
2026-01-14 16:54:10 +07:00
ae3ed52d16 fix(port): add atomic ClaimPort() to prevent race condition
- Replace GetPortStatus/SetPortStatus calls with atomic ClaimPort() operation.
- Fixed a logic error when handling headless tunneling.
2026-01-14 16:51:50 +07:00
fb638636bf refactor: consolidate error handling with fail() function in session handlers
- Replace repetitive error handling code with fail() function in HandleGlobalRequest
- Standardize error response pattern across all handler methods
- Improve code maintainability and reduce duplication
2026-01-14 16:51:50 +07:00
da29df85b7 feat: add headless mode support for SSH -N connections
- use s.lifecycle.GetConnection().Wait() to block until SSH connection closes
- Prevent premature session closure in headless mode

In headless mode (ssh -N), there's no channel interaction to block on,
so the session would immediately return and close. Now blocking on
conn.Wait() keeps the session alive until the client disconnects.
2026-01-14 16:51:50 +07:00
8b0e08c629 fix(deps): update module github.com/caddyserver/certmagic to v0.25.1 2026-01-14 16:51:50 +07:00
f0804d6946 ci: remove renovate 2026-01-14 16:51:50 +07:00
09e526cd1e feat: add authenticated user info and restructure handleConnection
- Display authenticated username in welcome page information box
- Refactor handleConnection function for better structure and clarity
2026-01-14 16:51:50 +07:00
887ebf78b1 refactor(grpc/client): simplify processEventStream with per-event handlers
- Extract eventHandlers dispatch table
- Add per-event handlers: handleSlugChange, handleGetSessions, handleTerminateSession
- Introduce sendNode helper to centralize send/error handling and preserve connection-error propagation
- Add protoToTunnelType for tunnel-type validation
- Map unknown proto.TunnelType to types.UNKNOWN in protoToTunnelType and return a descriptive error
- Reduce boilerplate and improve readability of processEventStream
2026-01-14 16:51:50 +07:00
bef7a49f88 feat: implement forwarder session termination 2026-01-14 16:51:50 +07:00
17633b4e3c refactor: inject SessionRegistry interface instead of individual functions 2026-01-14 16:51:50 +07:00
f25d61d1d1 update: proto file to v1.3.0 2026-01-14 16:51:50 +07:00
8782b77b74 feat(session): use session key for registry 2026-01-14 16:51:50 +07:00
fc3cd886db fix: use correct environment variable key 2026-01-14 16:51:50 +07:00
b0da57db0d fix: startup order 2026-01-14 16:51:50 +07:00
0bd6eeadf3 feat: implement sessions request from grpc server 2026-01-14 16:51:50 +07:00
449f546e04 feat: implement sessions request from grpc server 2026-01-14 16:51:50 +07:00
4644420eee feat: implement get sessions by user 2026-01-14 16:51:50 +07:00
c9bf9e62bd feat(grpc): integrate slug edit handling 2026-01-14 16:51:50 +07:00
57d2136377 WIP: gRPC integration, initial implementation 2026-01-14 16:51:47 +07:00
8a34aaba80 WIP: gRPC integration, initial implementation 2026-01-14 16:51:35 +07:00
ff995a929e revert 01ddc76f7e
revert Merge pull request 'fix(deps): update module github.com/caddyserver/certmagic to v0.25.1' (#58) from renovate/github.com-caddyserver-certmagic-0.x into main
2026-01-14 16:51:35 +07:00
32ac9c1749 fix(deps): update module github.com/caddyserver/certmagic to v0.25.1
# Conflicts:
#	go.mod
2026-01-14 16:51:30 +07:00
e051a5b742 Merge pull request 'fix(deps): update module golang.org/x/crypto to v0.47.0' (#64) from renovate/golang.org-x-crypto-0.x into main
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Successful in 9m51s
renovate / renovate (push) Successful in 55s
2026-01-12 18:20:57 +00:00
d35228759c fix(deps): update module golang.org/x/crypto to v0.47.0 2026-01-12 18:20:53 +00:00
25 changed files with 1488 additions and 1390 deletions
-21
View File
@@ -1,21 +0,0 @@
name: renovate
on:
schedule:
- cron: "0 0 * * *"
push:
branches:
- staging
jobs:
renovate:
runs-on: ubuntu-latest
container: git.fossy.my.id/renovate-clanker/renovate:latest
steps:
- uses: actions/checkout@v6
- run: renovate
env:
RENOVATE_CONFIG_FILE: ${{ gitea.workspace }}/renovate-config.js
LOG_LEVEL: "debug"
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
GITHUB_COM_TOKEN: ${{ secrets.COM_TOKEN }}
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.25.5-alpine AS go_builder
FROM golang:1.25.6-alpine AS go_builder
ARG VERSION=dev
ARG BUILD_DATE=unknown
+6 -6
View File
@@ -3,15 +3,15 @@ module tunnel_pls
go 1.25.5
require (
git.fossy.my.id/bagas/tunnel-please-grpc v1.4.0
github.com/caddyserver/certmagic v0.25.0
git.fossy.my.id/bagas/tunnel-please-grpc v1.5.0
github.com/caddyserver/certmagic v0.25.1
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/joho/godotenv v1.5.1
github.com/libdns/cloudflare v0.2.2
github.com/muesli/termenv v0.16.0
golang.org/x/crypto v0.46.0
golang.org/x/crypto v0.47.0
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
)
@@ -19,7 +19,7 @@ require (
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/caddyserver/zerossl v0.1.3 // indirect
github.com/caddyserver/zerossl v0.1.4 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
@@ -48,8 +48,8 @@ require (
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
)
+12
View File
@@ -2,6 +2,8 @@ git.fossy.my.id/bagas/tunnel-please-grpc v1.3.0 h1:RhcBKUG41/om4jgN+iF/vlY/RojTe
git.fossy.my.id/bagas/tunnel-please-grpc v1.3.0/go.mod h1:fG+VkArdkceGB0bNA7IFQus9GetLAwdF5Oi4jdMlXtY=
git.fossy.my.id/bagas/tunnel-please-grpc v1.4.0 h1:tpJSKjaSmV+vxxbVx6qnStjxFVXjj2M0rygWXxLb99o=
git.fossy.my.id/bagas/tunnel-please-grpc v1.4.0/go.mod h1:fG+VkArdkceGB0bNA7IFQus9GetLAwdF5Oi4jdMlXtY=
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=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -10,8 +12,12 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/caddyserver/certmagic v0.25.0 h1:VMleO/XA48gEWes5l+Fh6tRWo9bHkhwAEhx63i+F5ic=
github.com/caddyserver/certmagic v0.25.0/go.mod h1:m9yB7Mud24OQbPHOiipAoyKPn9pKHhpSJxXR1jydBxA=
github.com/caddyserver/certmagic v0.25.1 h1:4sIKKbOt5pg6+sL7tEwymE1x2bj6CHr80da1CRRIPbY=
github.com/caddyserver/certmagic v0.25.1/go.mod h1:VhyvndxtVton/Fo/wKhRoC46Rbw1fmjvQ3GjHYSQTEY=
github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA=
github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
github.com/caddyserver/zerossl v0.1.4 h1:CVJOE3MZeFisCERZjkxIcsqIH4fnFdlYWnPYeFtBHRw=
github.com/caddyserver/zerossl v0.1.4/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -114,6 +120,8 @@ go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
@@ -126,10 +134,14 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
+173 -191
View File
@@ -2,7 +2,6 @@ package client
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
@@ -16,7 +15,6 @@ import (
proto "git.fossy.my.id/bagas/tunnel-please-grpc/gen"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/keepalive"
@@ -24,83 +22,34 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
)
type GrpcConfig struct {
Address string
UseTLS bool
InsecureSkipVerify bool
Timeout time.Duration
KeepAlive bool
MaxRetries int
KeepAliveTime time.Duration
KeepAliveTimeout time.Duration
PermitWithoutStream bool
type Client interface {
SubscribeEvents(ctx context.Context, identity, authToken string) error
ClientConn() *grpc.ClientConn
AuthorizeConn(ctx context.Context, token string) (authorized bool, user string, err error)
Close() error
CheckServerHealth(ctx context.Context) error
}
type Client struct {
type client struct {
conn *grpc.ClientConn
config *GrpcConfig
address string
sessionRegistry session.Registry
eventService proto.EventServiceClient
authorizeConnectionService proto.UserServiceClient
closing bool
}
func DefaultConfig() *GrpcConfig {
return &GrpcConfig{
Address: "localhost:50051",
UseTLS: false,
InsecureSkipVerify: false,
Timeout: 10 * time.Second,
KeepAlive: true,
MaxRetries: 3,
KeepAliveTime: 2 * time.Minute,
KeepAliveTimeout: 10 * time.Second,
PermitWithoutStream: false,
}
}
func New(config *GrpcConfig, sessionRegistry session.Registry) (*Client, error) {
if config == nil {
config = DefaultConfig()
} else {
defaults := DefaultConfig()
if config.Address == "" {
config.Address = defaults.Address
}
if config.Timeout == 0 {
config.Timeout = defaults.Timeout
}
if config.MaxRetries == 0 {
config.MaxRetries = defaults.MaxRetries
}
if config.KeepAliveTime == 0 {
config.KeepAliveTime = defaults.KeepAliveTime
}
if config.KeepAliveTimeout == 0 {
config.KeepAliveTimeout = defaults.KeepAliveTimeout
}
}
func New(address string, sessionRegistry session.Registry) (Client, error) {
var opts []grpc.DialOption
if config.UseTLS {
tlsConfig := &tls.Config{
InsecureSkipVerify: config.InsecureSkipVerify,
}
creds := credentials.NewTLS(tlsConfig)
opts = append(opts, grpc.WithTransportCredentials(creds))
} else {
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
kaParams := keepalive.ClientParameters{
Time: 2 * time.Minute,
Timeout: 10 * time.Second,
PermitWithoutStream: false,
}
if config.KeepAlive {
kaParams := keepalive.ClientParameters{
Time: config.KeepAliveTime,
Timeout: config.KeepAliveTimeout,
PermitWithoutStream: config.PermitWithoutStream,
}
opts = append(opts, grpc.WithKeepaliveParams(kaParams))
}
opts = append(opts, grpc.WithKeepaliveParams(kaParams))
opts = append(opts,
grpc.WithDefaultCallOptions(
@@ -109,24 +58,24 @@ func New(config *GrpcConfig, sessionRegistry session.Registry) (*Client, error)
),
)
conn, err := grpc.NewClient(config.Address, opts...)
conn, err := grpc.NewClient(address, opts...)
if err != nil {
return nil, fmt.Errorf("failed to connect to gRPC server at %s: %w", config.Address, err)
return nil, fmt.Errorf("failed to connect to gRPC server at %s: %w", address, err)
}
eventService := proto.NewEventServiceClient(conn)
authorizeConnectionService := proto.NewUserServiceClient(conn)
return &Client{
return &client{
conn: conn,
config: config,
address: address,
sessionRegistry: sessionRegistry,
eventService: eventService,
authorizeConnectionService: authorizeConnectionService,
}, nil
}
func (c *Client) SubscribeEvents(ctx context.Context, identity, authToken string) error {
func (c *client) SubscribeEvents(ctx context.Context, identity, authToken string) error {
const (
baseBackoff = time.Second
maxBackoff = 30 * time.Second
@@ -209,124 +158,161 @@ func (c *Client) SubscribeEvents(ctx context.Context, identity, authToken string
}
}
func (c *Client) processEventStream(subscribe grpc.BidiStreamingClient[proto.Node, proto.Events]) error {
func (c *client) processEventStream(subscribe grpc.BidiStreamingClient[proto.Node, proto.Events]) error {
handlers := c.eventHandlers(subscribe)
for {
recv, err := subscribe.Recv()
if err != nil {
return err
}
switch recv.GetType() {
case proto.EventType_SLUG_CHANGE:
user := recv.GetSlugEvent().GetUser()
oldSlug := recv.GetSlugEvent().GetOld()
newSlug := recv.GetSlugEvent().GetNew()
var userSession *session.SSHSession
userSession, err = c.sessionRegistry.Get(types.SessionKey{
Id: oldSlug,
Type: types.HTTP,
})
if err != nil {
errSend := subscribe.Send(&proto.Node{
Type: proto.EventType_SLUG_CHANGE_RESPONSE,
Payload: &proto.Node_SlugEventResponse{
SlugEventResponse: &proto.SlugChangeEventResponse{
Success: false,
Message: err.Error(),
},
},
})
if errSend != nil {
if c.isConnectionError(errSend) {
return errSend
}
log.Printf("non-connection send error for slug change failure: %v", errSend)
}
continue
}
err = c.sessionRegistry.Update(user, types.SessionKey{
Id: oldSlug,
Type: types.HTTP,
}, types.SessionKey{
Id: newSlug,
Type: types.HTTP,
})
if err != nil {
errSend := subscribe.Send(&proto.Node{
Type: proto.EventType_SLUG_CHANGE_RESPONSE,
Payload: &proto.Node_SlugEventResponse{
SlugEventResponse: &proto.SlugChangeEventResponse{
Success: false,
Message: err.Error(),
},
},
})
if errSend != nil {
if c.isConnectionError(errSend) {
return errSend
}
log.Printf("non-connection send error for slug change failure: %v", errSend)
}
continue
}
userSession.GetInteraction().Redraw()
err = subscribe.Send(&proto.Node{
Type: proto.EventType_SLUG_CHANGE_RESPONSE,
Payload: &proto.Node_SlugEventResponse{
SlugEventResponse: &proto.SlugChangeEventResponse{
Success: true,
Message: "",
},
},
})
if err != nil {
if c.isConnectionError(err) {
log.Printf("connection error sending slug change success: %v", err)
return err
}
log.Printf("non-connection send error for slug change success: %v", err)
continue
}
case proto.EventType_GET_SESSIONS:
sessions := c.sessionRegistry.GetAllSessionFromUser(recv.GetGetSessionsEvent().GetIdentity())
var details []*proto.Detail
for _, ses := range sessions {
detail := ses.Detail()
details = append(details, &proto.Detail{
Node: config.Getenv("DOMAIN", "localhost"),
ForwardingType: detail.ForwardingType,
Slug: detail.Slug,
UserId: detail.UserID,
Active: detail.Active,
StartedAt: timestamppb.New(detail.StartedAt),
})
}
err = subscribe.Send(&proto.Node{
Type: proto.EventType_GET_SESSIONS,
Payload: &proto.Node_GetSessionsEvent{
GetSessionsEvent: &proto.GetSessionsResponse{
Details: details,
},
},
})
if err != nil {
if c.isConnectionError(err) {
log.Printf("connection error sending sessions success: %v", err)
return err
}
log.Printf("non-connection send error for sessions success: %v", err)
continue
}
default:
handler, ok := handlers[recv.GetType()]
if !ok {
log.Printf("Unknown event type received: %v", recv.GetType())
continue
}
if err = handler(recv); err != nil {
return err
}
}
}
func (c *Client) GetConnection() *grpc.ClientConn {
func (c *client) eventHandlers(subscribe grpc.BidiStreamingClient[proto.Node, proto.Events]) map[proto.EventType]func(*proto.Events) error {
return map[proto.EventType]func(*proto.Events) error{
proto.EventType_SLUG_CHANGE: func(evt *proto.Events) error { return c.handleSlugChange(subscribe, evt) },
proto.EventType_GET_SESSIONS: func(evt *proto.Events) error { return c.handleGetSessions(subscribe, evt) },
proto.EventType_TERMINATE_SESSION: func(evt *proto.Events) error { return c.handleTerminateSession(subscribe, evt) },
}
}
func (c *client) handleSlugChange(subscribe grpc.BidiStreamingClient[proto.Node, proto.Events], evt *proto.Events) error {
slugEvent := evt.GetSlugEvent()
user := slugEvent.GetUser()
oldSlug := slugEvent.GetOld()
newSlug := slugEvent.GetNew()
userSession, err := c.sessionRegistry.Get(types.SessionKey{Id: oldSlug, Type: types.HTTP})
if err != nil {
return c.sendNode(subscribe, &proto.Node{
Type: proto.EventType_SLUG_CHANGE_RESPONSE,
Payload: &proto.Node_SlugEventResponse{
SlugEventResponse: &proto.SlugChangeEventResponse{Success: false, Message: err.Error()},
},
}, "slug change failure response")
}
if err = c.sessionRegistry.Update(user, types.SessionKey{Id: oldSlug, Type: types.HTTP}, types.SessionKey{Id: newSlug, Type: types.HTTP}); err != nil {
return c.sendNode(subscribe, &proto.Node{
Type: proto.EventType_SLUG_CHANGE_RESPONSE,
Payload: &proto.Node_SlugEventResponse{
SlugEventResponse: &proto.SlugChangeEventResponse{Success: false, Message: err.Error()},
},
}, "slug change failure response")
}
userSession.Interaction().Redraw()
return c.sendNode(subscribe, &proto.Node{
Type: proto.EventType_SLUG_CHANGE_RESPONSE,
Payload: &proto.Node_SlugEventResponse{
SlugEventResponse: &proto.SlugChangeEventResponse{Success: true, Message: ""},
},
}, "slug change success response")
}
func (c *client) handleGetSessions(subscribe grpc.BidiStreamingClient[proto.Node, proto.Events], evt *proto.Events) error {
sessions := c.sessionRegistry.GetAllSessionFromUser(evt.GetGetSessionsEvent().GetIdentity())
var details []*proto.Detail
for _, ses := range sessions {
detail := ses.Detail()
details = append(details, &proto.Detail{
Node: config.Getenv("DOMAIN", "localhost"),
ForwardingType: detail.ForwardingType,
Slug: detail.Slug,
UserId: detail.UserID,
Active: detail.Active,
StartedAt: timestamppb.New(detail.StartedAt),
})
}
return c.sendNode(subscribe, &proto.Node{
Type: proto.EventType_GET_SESSIONS,
Payload: &proto.Node_GetSessionsEvent{
GetSessionsEvent: &proto.GetSessionsResponse{Details: details},
},
}, "send get sessions response")
}
func (c *client) handleTerminateSession(subscribe grpc.BidiStreamingClient[proto.Node, proto.Events], evt *proto.Events) error {
terminate := evt.GetTerminateSessionEvent()
user := terminate.GetUser()
slug := terminate.GetSlug()
tunnelType, err := c.protoToTunnelType(terminate.GetTunnelType())
if err != nil {
return c.sendNode(subscribe, &proto.Node{
Type: proto.EventType_TERMINATE_SESSION,
Payload: &proto.Node_TerminateSessionEventResponse{
TerminateSessionEventResponse: &proto.TerminateSessionEventResponse{Success: false, Message: err.Error()},
},
}, "terminate session invalid tunnel type")
}
userSession, err := c.sessionRegistry.GetWithUser(user, types.SessionKey{Id: slug, Type: tunnelType})
if err != nil {
return c.sendNode(subscribe, &proto.Node{
Type: proto.EventType_TERMINATE_SESSION,
Payload: &proto.Node_TerminateSessionEventResponse{
TerminateSessionEventResponse: &proto.TerminateSessionEventResponse{Success: false, Message: err.Error()},
},
}, "terminate session fetch failed")
}
if err = userSession.Lifecycle().Close(); err != nil {
return c.sendNode(subscribe, &proto.Node{
Type: proto.EventType_TERMINATE_SESSION,
Payload: &proto.Node_TerminateSessionEventResponse{
TerminateSessionEventResponse: &proto.TerminateSessionEventResponse{Success: false, Message: err.Error()},
},
}, "terminate session close failed")
}
return c.sendNode(subscribe, &proto.Node{
Type: proto.EventType_TERMINATE_SESSION,
Payload: &proto.Node_TerminateSessionEventResponse{
TerminateSessionEventResponse: &proto.TerminateSessionEventResponse{Success: true, Message: ""},
},
}, "terminate session success response")
}
func (c *client) sendNode(subscribe grpc.BidiStreamingClient[proto.Node, proto.Events], node *proto.Node, context string) error {
if err := subscribe.Send(node); err != nil {
if c.isConnectionError(err) {
return err
}
log.Printf("%s: %v", context, err)
}
return nil
}
func (c *client) protoToTunnelType(t proto.TunnelType) (types.TunnelType, error) {
switch t {
case proto.TunnelType_HTTP:
return types.HTTP, nil
case proto.TunnelType_TCP:
return types.TCP, nil
default:
return types.UNKNOWN, fmt.Errorf("unknown tunnel type received")
}
}
func (c *client) ClientConn() *grpc.ClientConn {
return c.conn
}
func (c *Client) AuthorizeConn(ctx context.Context, token string) (authorized bool, user string, err error) {
func (c *client) AuthorizeConn(ctx context.Context, token string) (authorized bool, user string, err error) {
check, err := c.authorizeConnectionService.Check(ctx, &proto.CheckRequest{AuthToken: token})
if err != nil {
return false, "UNAUTHORIZED", err
@@ -338,17 +324,8 @@ func (c *Client) AuthorizeConn(ctx context.Context, token string) (authorized bo
return true, check.GetUser(), nil
}
func (c *Client) Close() error {
if c.conn != nil {
log.Printf("Closing gRPC connection to %s", c.config.Address)
c.closing = true
return c.conn.Close()
}
return nil
}
func (c *Client) CheckServerHealth(ctx context.Context) error {
healthClient := grpc_health_v1.NewHealthClient(c.GetConnection())
func (c *client) CheckServerHealth(ctx context.Context) error {
healthClient := grpc_health_v1.NewHealthClient(c.ClientConn())
resp, err := healthClient.Check(ctx, &grpc_health_v1.HealthCheckRequest{
Service: "",
})
@@ -361,11 +338,16 @@ func (c *Client) CheckServerHealth(ctx context.Context) error {
return nil
}
func (c *Client) GetConfig() *GrpcConfig {
return c.config
func (c *client) Close() error {
if c.conn != nil {
log.Printf("Closing gRPC connection to %s", c.address)
c.closing = true
return c.conn.Close()
}
return nil
}
func (c *Client) isConnectionError(err error) bool {
func (c *client) isConnectionError(err error) bool {
if c.closing {
return false
}
+16 -6
View File
@@ -13,7 +13,7 @@ type Manager interface {
AddPortRange(startPort, endPort uint16) error
GetUnassignedPort() (uint16, bool)
SetPortStatus(port uint16, assigned bool) error
GetPortStatus(port uint16) (bool, bool)
ClaimPort(port uint16) (claimed bool)
}
type manager struct {
@@ -74,7 +74,6 @@ func (pm *manager) GetUnassignedPort() (uint16, bool) {
for _, port := range pm.sortedPorts {
if !pm.ports[port] {
pm.ports[port] = true
return port, true
}
}
@@ -89,10 +88,21 @@ func (pm *manager) SetPortStatus(port uint16, assigned bool) error {
return nil
}
func (pm *manager) GetPortStatus(port uint16) (bool, bool) {
pm.mu.RLock()
defer pm.mu.RUnlock()
func (pm *manager) ClaimPort(port uint16) (claimed bool) {
pm.mu.Lock()
defer pm.mu.Unlock()
status, exists := pm.ports[port]
return status, exists
if exists && status {
return false
}
if !exists {
pm.ports[port] = true
return true
}
pm.ports[port] = true
return true
}
+13 -13
View File
@@ -1,18 +1,18 @@
package random
import (
mathrand "math/rand"
"strings"
"time"
)
import "crypto/rand"
func GenerateRandomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyz"
seededRand := mathrand.New(mathrand.NewSource(time.Now().UnixNano() + int64(mathrand.Intn(9999))))
var result strings.Builder
for i := 0; i < length; i++ {
randomIndex := seededRand.Intn(len(charset))
result.WriteString(string(charset[randomIndex]))
func GenerateRandomString(length int) (string, error) {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
return "", err
}
return result.String()
for i := range b {
b[i] = charset[int(b[i])%len(charset)]
}
return string(b), nil
}
+15 -16
View File
@@ -49,7 +49,7 @@ func main() {
sshConfig := &ssh.ServerConfig{
NoClientAuth: true,
ServerVersion: fmt.Sprintf("SSH-2.0-TunnlPls-%s", version.GetShortVersion()),
ServerVersion: fmt.Sprintf("SSH-2.0-TunnelPlease-%s", version.GetShortVersion()),
}
sshKeyPath := "certs/ssh/id_rsa"
@@ -77,7 +77,7 @@ func main() {
shutdownChan := make(chan os.Signal, 1)
signal.Notify(shutdownChan, os.Interrupt, syscall.SIGTERM)
var grpcClient *client.Client
var grpcClient client.Client
if isNodeMode {
grpcHost := config.Getenv("GRPC_ADDRESS", "localhost")
grpcPort := config.Getenv("GRPC_PORT", "8080")
@@ -87,21 +87,13 @@ func main() {
log.Fatalf("NODE_TOKEN is required in node mode")
}
c, err := client.New(&client.GrpcConfig{
Address: grpcAddr,
UseTLS: false,
InsecureSkipVerify: false,
Timeout: 10 * time.Second,
KeepAlive: true,
MaxRetries: 3,
}, sessionRegistry)
grpcClient, err = client.New(grpcAddr, sessionRegistry)
if err != nil {
log.Fatalf("failed to create grpc client: %v", err)
}
grpcClient = c
healthCtx, healthCancel := context.WithTimeout(ctx, 5*time.Second)
if err := grpcClient.CheckServerHealth(healthCtx); err != nil {
if err = grpcClient.CheckServerHealth(healthCtx); err != nil {
healthCancel()
log.Fatalf("gRPC health check failed: %v", err)
}
@@ -109,14 +101,15 @@ func main() {
go func() {
identity := config.Getenv("DOMAIN", "localhost")
if err := grpcClient.SubscribeEvents(ctx, identity, nodeToken); err != nil {
if err = grpcClient.SubscribeEvents(ctx, identity, nodeToken); err != nil {
errChan <- fmt.Errorf("failed to subscribe to events: %w", err)
}
}()
}
var app server.Server
go func() {
app, err := server.NewServer(sshConfig, sessionRegistry, grpcClient)
app, err = server.New(sshConfig, sessionRegistry, grpcClient)
if err != nil {
errChan <- fmt.Errorf("failed to start server: %s", err)
return
@@ -125,7 +118,7 @@ func main() {
}()
select {
case err := <-errChan:
case err = <-errChan:
log.Printf("error happen : %s", err)
case sig := <-shutdownChan:
log.Printf("received signal %s, shutting down", sig)
@@ -133,8 +126,14 @@ func main() {
cancel()
if app != nil {
if err = app.Close(); err != nil {
log.Printf("failed to close server : %s", err)
}
}
if grpcClient != nil {
if err := grpcClient.Close(); err != nil {
if err = grpcClient.Close(); err != nil {
log.Printf("failed to close grpc conn : %s", err)
}
}
-8
View File
@@ -1,8 +0,0 @@
module.exports = {
"endpoint": "https://git.fossy.my.id/api/v1",
"gitAuthor": "Renovate-Clanker <renovate-bot@fossy.my.id>",
"platform": "gitea",
"onboardingConfigFileName": "renovate.json",
"autodiscover": true,
"optimizeForDisabled": true,
};
+6 -6
View File
@@ -335,8 +335,8 @@ func (hs *httpServer) handler(conn net.Conn) {
return
}
func forwardRequest(cw HTTPWriter, initialRequest RequestHeaderManager, sshSession *session.SSHSession) {
payload := sshSession.GetForwarder().CreateForwardedTCPIPPayload(cw.GetRemoteAddr())
func forwardRequest(cw HTTPWriter, initialRequest RequestHeaderManager, sshSession session.Session) {
payload := sshSession.Forwarder().CreateForwardedTCPIPPayload(cw.GetRemoteAddr())
type channelResult struct {
channel ssh.Channel
@@ -346,7 +346,7 @@ func forwardRequest(cw HTTPWriter, initialRequest RequestHeaderManager, sshSessi
resultChan := make(chan channelResult, 1)
go func() {
channel, reqs, err := sshSession.GetLifecycle().GetConnection().OpenChannel("forwarded-tcpip", payload)
channel, reqs, err := sshSession.Lifecycle().Connection().OpenChannel("forwarded-tcpip", payload)
resultChan <- channelResult{channel, reqs, err}
}()
@@ -357,14 +357,14 @@ func forwardRequest(cw HTTPWriter, initialRequest RequestHeaderManager, sshSessi
case result := <-resultChan:
if result.err != nil {
log.Printf("Failed to open forwarded-tcpip channel: %v", result.err)
sshSession.GetForwarder().WriteBadGatewayResponse(cw.GetWriter())
sshSession.Forwarder().WriteBadGatewayResponse(cw.GetWriter())
return
}
channel = result.channel
reqs = result.reqs
case <-time.After(5 * time.Second):
log.Printf("Timeout opening forwarded-tcpip channel")
sshSession.GetForwarder().WriteBadGatewayResponse(cw.GetWriter())
sshSession.Forwarder().WriteBadGatewayResponse(cw.GetWriter())
return
}
@@ -390,6 +390,6 @@ func forwardRequest(cw HTTPWriter, initialRequest RequestHeaderManager, sshSessi
return
}
sshSession.GetForwarder().HandleConnection(cw, channel, cw.GetRemoteAddr())
sshSession.Forwarder().HandleConnection(cw, channel, cw.GetRemoteAddr())
return
}
+34 -19
View File
@@ -2,9 +2,11 @@ package server
import (
"context"
"errors"
"fmt"
"log"
"net"
"time"
"tunnel_pls/internal/config"
"tunnel_pls/internal/grpc/client"
"tunnel_pls/session"
@@ -12,14 +14,18 @@ import (
"golang.org/x/crypto/ssh"
)
type Server struct {
conn *net.Listener
type Server interface {
Start()
Close() error
}
type server struct {
listener net.Listener
config *ssh.ServerConfig
sessionRegistry session.Registry
grpcClient *client.Client
grpcClient client.Client
}
func NewServer(sshConfig *ssh.ServerConfig, sessionRegistry session.Registry, grpcClient *client.Client) (*Server, error) {
func New(sshConfig *ssh.ServerConfig, sessionRegistry session.Registry, grpcClient client.Client) (Server, error) {
listener, err := net.Listen("tcp", fmt.Sprintf(":%s", config.Getenv("PORT", "2200")))
if err != nil {
log.Fatalf("failed to listen on port 2200: %v", err)
@@ -41,19 +47,23 @@ func NewServer(sshConfig *ssh.ServerConfig, sessionRegistry session.Registry, gr
}
}
return &Server{
conn: &listener,
return &server{
listener: listener,
config: sshConfig,
sessionRegistry: sessionRegistry,
grpcClient: grpcClient,
}, nil
}
func (s *Server) Start() {
func (s *server) Start() {
log.Println("SSH server is starting on port 2200...")
for {
conn, err := (*s.conn).Accept()
conn, err := s.listener.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
log.Println("listener closed, stopping server")
return
}
log.Printf("failed to accept connection: %v", err)
continue
}
@@ -62,32 +72,37 @@ func (s *Server) Start() {
}
}
func (s *Server) handleConnection(conn net.Conn) {
sshConn, chans, forwardingReqs, err := ssh.NewServerConn(conn, s.config)
defer func(sshConn *ssh.ServerConn) {
err = sshConn.Close()
if err != nil {
log.Printf("failed to close SSH server: %v", err)
}
}(sshConn)
func (s *server) Close() error {
return s.listener.Close()
}
func (s *server) handleConnection(conn net.Conn) {
sshConn, chans, forwardingReqs, err := ssh.NewServerConn(conn, s.config)
if err != nil {
log.Printf("failed to establish SSH connection: %v", err)
err := conn.Close()
err = conn.Close()
if err != nil {
log.Printf("failed to close SSH connection: %v", err)
return
}
return
}
ctx := context.Background()
log.Println("SSH connection established:", sshConn.User())
defer func(sshConn *ssh.ServerConn) {
err = sshConn.Close()
if err != nil && !errors.Is(err, net.ErrClosed) {
log.Printf("failed to close SSH server: %v", err)
}
}(sshConn)
user := "UNAUTHORIZED"
if s.grpcClient != nil {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
_, u, _ := s.grpcClient.AuthorizeConn(ctx, sshConn.User())
user = u
cancel()
}
log.Println("SSH connection established:", sshConn.User())
sshSession := session.New(sshConn, forwardingReqs, chans, s.sessionRegistry, user)
err = sshSession.Start()
if err != nil {
+4 -10
View File
@@ -301,22 +301,16 @@ func (tm *tlsManager) initCertMagic() error {
func (tm *tlsManager) getTLSConfig() *tls.Config {
return &tls.Config{
GetCertificate: tm.getCertificate,
MinVersion: tls.VersionTLS13,
MaxVersion: tls.VersionTLS13,
SessionTicketsDisabled: false,
CipherSuites: []uint16{
tls.TLS_AES_128_GCM_SHA256,
tls.TLS_CHACHA20_POLY1305_SHA256,
},
MinVersion: tls.VersionTLS13,
MaxVersion: tls.VersionTLS13,
CurvePreferences: []tls.CurveID{
tls.X25519,
},
ClientAuth: tls.NoClientCert,
NextProtos: nil,
SessionTicketsDisabled: false,
ClientAuth: tls.NoClientCert,
}
}
+30 -30
View File
@@ -30,50 +30,50 @@ func copyWithBuffer(dst io.Writer, src io.Reader) (written int64, err error) {
return io.CopyBuffer(dst, src, buf)
}
type Forwarder struct {
type forwarder struct {
listener net.Listener
tunnelType types.TunnelType
forwardedPort uint16
slugManager slug.Manager
slug slug.Slug
lifecycle Lifecycle
}
func NewForwarder(slugManager slug.Manager) *Forwarder {
return &Forwarder{
func New(slug slug.Slug) Forwarder {
return &forwarder{
listener: nil,
tunnelType: "",
tunnelType: types.UNKNOWN,
forwardedPort: 0,
slugManager: slugManager,
slug: slug,
lifecycle: nil,
}
}
type Lifecycle interface {
GetConnection() ssh.Conn
Connection() ssh.Conn
}
type ForwardingController interface {
AcceptTCPConnections()
type Forwarder interface {
SetType(tunnelType types.TunnelType)
GetTunnelType() types.TunnelType
GetForwardedPort() uint16
SetLifecycle(lifecycle Lifecycle)
SetForwardedPort(port uint16)
SetListener(listener net.Listener)
GetListener() net.Listener
Close() error
Listener() net.Listener
TunnelType() types.TunnelType
ForwardedPort() uint16
HandleConnection(dst io.ReadWriter, src ssh.Channel, remoteAddr net.Addr)
SetLifecycle(lifecycle Lifecycle)
CreateForwardedTCPIPPayload(origin net.Addr) []byte
WriteBadGatewayResponse(dst io.Writer)
AcceptTCPConnections()
Close() error
}
func (f *Forwarder) SetLifecycle(lifecycle Lifecycle) {
func (f *forwarder) SetLifecycle(lifecycle Lifecycle) {
f.lifecycle = lifecycle
}
func (f *Forwarder) AcceptTCPConnections() {
func (f *forwarder) AcceptTCPConnections() {
for {
conn, err := f.GetListener().Accept()
conn, err := f.Listener().Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return
@@ -100,7 +100,7 @@ func (f *Forwarder) AcceptTCPConnections() {
resultChan := make(chan channelResult, 1)
go func() {
channel, reqs, err := f.lifecycle.GetConnection().OpenChannel("forwarded-tcpip", payload)
channel, reqs, err := f.lifecycle.Connection().OpenChannel("forwarded-tcpip", payload)
resultChan <- channelResult{channel, reqs, err}
}()
@@ -130,7 +130,7 @@ func (f *Forwarder) AcceptTCPConnections() {
}
}
func (f *Forwarder) HandleConnection(dst io.ReadWriter, src ssh.Channel, remoteAddr net.Addr) {
func (f *forwarder) HandleConnection(dst io.ReadWriter, src ssh.Channel, remoteAddr net.Addr) {
defer func() {
_, err := io.Copy(io.Discard, src)
if err != nil {
@@ -174,31 +174,31 @@ func (f *Forwarder) HandleConnection(dst io.ReadWriter, src ssh.Channel, remoteA
wg.Wait()
}
func (f *Forwarder) SetType(tunnelType types.TunnelType) {
func (f *forwarder) SetType(tunnelType types.TunnelType) {
f.tunnelType = tunnelType
}
func (f *Forwarder) GetTunnelType() types.TunnelType {
func (f *forwarder) TunnelType() types.TunnelType {
return f.tunnelType
}
func (f *Forwarder) GetForwardedPort() uint16 {
func (f *forwarder) ForwardedPort() uint16 {
return f.forwardedPort
}
func (f *Forwarder) SetForwardedPort(port uint16) {
func (f *forwarder) SetForwardedPort(port uint16) {
f.forwardedPort = port
}
func (f *Forwarder) SetListener(listener net.Listener) {
func (f *forwarder) SetListener(listener net.Listener) {
f.listener = listener
}
func (f *Forwarder) GetListener() net.Listener {
func (f *forwarder) Listener() net.Listener {
return f.listener
}
func (f *Forwarder) WriteBadGatewayResponse(dst io.Writer) {
func (f *forwarder) WriteBadGatewayResponse(dst io.Writer) {
_, err := dst.Write(types.BadGatewayResponse)
if err != nil {
log.Printf("failed to write Bad Gateway response: %v", err)
@@ -206,20 +206,20 @@ func (f *Forwarder) WriteBadGatewayResponse(dst io.Writer) {
}
}
func (f *Forwarder) Close() error {
if f.GetListener() != nil {
func (f *forwarder) Close() error {
if f.Listener() != nil {
return f.listener.Close()
}
return nil
}
func (f *Forwarder) CreateForwardedTCPIPPayload(origin net.Addr) []byte {
func (f *forwarder) CreateForwardedTCPIPPayload(origin net.Addr) []byte {
var buf bytes.Buffer
host, originPort := parseAddr(origin.String())
writeSSHString(&buf, "localhost")
err := binary.Write(&buf, binary.BigEndian, uint32(f.GetForwardedPort()))
err := binary.Write(&buf, binary.BigEndian, uint32(f.ForwardedPort()))
if err != nil {
log.Printf("Failed to write string to buffer: %v", err)
return nil
-313
View File
@@ -1,313 +0,0 @@
package session
import (
"bytes"
"encoding/binary"
"fmt"
"log"
"net"
portUtil "tunnel_pls/internal/port"
"tunnel_pls/internal/random"
"tunnel_pls/types"
"golang.org/x/crypto/ssh"
)
var blockedReservedPorts = []uint16{1080, 1433, 1521, 1900, 2049, 3306, 3389, 5432, 5900, 6379, 8080, 8443, 9000, 9200, 27017}
func (s *SSHSession) HandleGlobalRequest(GlobalRequest <-chan *ssh.Request) {
for req := range GlobalRequest {
switch req.Type {
case "shell", "pty-req":
err := req.Reply(true, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
case "window-change":
p := req.Payload
if len(p) < 16 {
log.Println("invalid window-change payload")
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
return
}
cols := binary.BigEndian.Uint32(p[0:4])
rows := binary.BigEndian.Uint32(p[4:8])
s.interaction.SetWH(int(cols), int(rows))
err := req.Reply(true, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
default:
log.Println("Unknown request type:", req.Type)
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
}
}
}
func (s *SSHSession) HandleTCPIPForward(req *ssh.Request) {
log.Println("Port forwarding request detected")
reader := bytes.NewReader(req.Payload)
addr, err := readSSHString(reader)
if err != nil {
log.Println("Failed to read address from payload:", err)
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
err = s.lifecycle.Close()
if err != nil {
log.Printf("failed to close session: %v", err)
}
return
}
var rawPortToBind uint32
if err := binary.Read(reader, binary.BigEndian, &rawPortToBind); err != nil {
log.Println("Failed to read port from payload:", err)
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
err = s.lifecycle.Close()
if err != nil {
log.Printf("failed to close session: %v", err)
}
return
}
if rawPortToBind > 65535 {
log.Printf("Port %d is larger than allowed port of 65535", rawPortToBind)
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
err = s.lifecycle.Close()
if err != nil {
log.Printf("failed to close session: %v", err)
}
return
}
portToBind := uint16(rawPortToBind)
if isBlockedPort(portToBind) {
log.Printf("Port %d is blocked or restricted", portToBind)
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
err = s.lifecycle.Close()
if err != nil {
log.Printf("failed to close session: %v", err)
}
return
}
if portToBind == 80 || portToBind == 443 {
s.HandleHTTPForward(req, portToBind)
return
}
if portToBind == 0 {
unassign, success := portUtil.Default.GetUnassignedPort()
portToBind = unassign
if !success {
log.Println("No available port")
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
err = s.lifecycle.Close()
if err != nil {
log.Printf("failed to close session: %v", err)
}
return
}
} else if isUse, isExist := portUtil.Default.GetPortStatus(portToBind); isExist && isUse {
log.Printf("Port %d is already in use or restricted", portToBind)
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
err = s.lifecycle.Close()
if err != nil {
log.Printf("failed to close session: %v", err)
}
return
}
err = portUtil.Default.SetPortStatus(portToBind, true)
if err != nil {
log.Println("Failed to set port status:", err)
return
}
s.HandleTCPForward(req, addr, portToBind)
}
func (s *SSHSession) HandleHTTPForward(req *ssh.Request, portToBind uint16) {
slug := random.GenerateRandomString(20)
key := types.SessionKey{Id: slug, Type: types.HTTP}
if !s.registry.Register(key, s) {
log.Printf("Failed to register client with slug: %s", slug)
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
}
return
}
buf := new(bytes.Buffer)
err := binary.Write(buf, binary.BigEndian, uint32(portToBind))
if err != nil {
log.Println("Failed to write port to buffer:", err)
s.registry.Remove(key)
err = req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
}
return
}
log.Printf("HTTP forwarding approved on port: %d", portToBind)
err = req.Reply(true, buf.Bytes())
if err != nil {
log.Println("Failed to reply to request:", err)
s.registry.Remove(key)
err = req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
}
return
}
s.forwarder.SetType(types.HTTP)
s.forwarder.SetForwardedPort(portToBind)
s.slugManager.Set(slug)
s.lifecycle.SetStatus(types.RUNNING)
s.interaction.Start()
}
func (s *SSHSession) HandleTCPForward(req *ssh.Request, addr string, portToBind uint16) {
log.Printf("Requested forwarding on %s:%d", addr, portToBind)
listener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", portToBind))
if err != nil {
log.Printf("Port %d is already in use or restricted", portToBind)
if setErr := portUtil.Default.SetPortStatus(portToBind, false); setErr != nil {
log.Printf("Failed to reset port status: %v", setErr)
}
err = req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
err = s.lifecycle.Close()
if err != nil {
log.Printf("failed to close session: %v", err)
}
return
}
key := types.SessionKey{Id: fmt.Sprintf("%d", portToBind), Type: types.TCP}
if !s.registry.Register(key, s) {
log.Printf("Failed to register TCP client with id: %s", key.Id)
if setErr := portUtil.Default.SetPortStatus(portToBind, false); setErr != nil {
log.Printf("Failed to reset port status: %v", setErr)
}
if closeErr := listener.Close(); closeErr != nil {
log.Printf("Failed to close listener: %s", closeErr)
}
err = req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
}
_ = s.lifecycle.Close()
return
}
buf := new(bytes.Buffer)
err = binary.Write(buf, binary.BigEndian, uint32(portToBind))
if err != nil {
log.Println("Failed to write port to buffer:", err)
s.registry.Remove(key)
if setErr := portUtil.Default.SetPortStatus(portToBind, false); setErr != nil {
log.Printf("Failed to reset port status: %v", setErr)
}
err = listener.Close()
if err != nil {
log.Printf("Failed to close listener: %s", err)
return
}
return
}
log.Printf("TCP forwarding approved on port: %d", portToBind)
err = req.Reply(true, buf.Bytes())
if err != nil {
log.Println("Failed to reply to request:", err)
s.registry.Remove(key)
if setErr := portUtil.Default.SetPortStatus(portToBind, false); setErr != nil {
log.Printf("Failed to reset port status: %v", setErr)
}
err = listener.Close()
if err != nil {
log.Printf("Failed to close listener: %s", err)
return
}
return
}
s.forwarder.SetType(types.TCP)
s.forwarder.SetListener(listener)
s.forwarder.SetForwardedPort(portToBind)
s.slugManager.Set(key.Id)
s.lifecycle.SetStatus(types.RUNNING)
go s.forwarder.AcceptTCPConnections()
s.interaction.Start()
}
func readSSHString(reader *bytes.Reader) (string, error) {
var length uint32
if err := binary.Read(reader, binary.BigEndian, &length); err != nil {
return "", err
}
strBytes := make([]byte, length)
if _, err := reader.Read(strBytes); err != nil {
return "", err
}
return string(strBytes), nil
}
func isBlockedPort(port uint16) bool {
if port == 80 || port == 443 {
return false
}
if port < 1024 && port != 0 {
return true
}
for _, p := range blockedReservedPorts {
if p == port {
return true
}
}
return false
}
+83
View File
@@ -0,0 +1,83 @@
package interaction
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
func (m *model) comingSoonUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.showingComingSoon = false
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
}
func (m *model) comingSoonView() string {
isCompact := shouldUseCompactLayout(m.width, 60)
var boxPadding int
var boxMargin int
if isCompact {
boxPadding = 1
boxMargin = 1
} else {
boxPadding = 3
boxMargin = 2
}
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
PaddingTop(1).
PaddingBottom(1)
messageBoxWidth := getResponsiveWidth(m.width, 10, 30, 60)
messageBoxStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#1A1A2E")).
Bold(true).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(1, boxPadding).
MarginTop(boxMargin).
MarginBottom(boxMargin).
Width(messageBoxWidth).
Align(lipgloss.Center)
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
Italic(true).
MarginTop(1)
var b strings.Builder
b.WriteString("\n\n")
var title string
if shouldUseCompactLayout(m.width, 40) {
title = "Coming Soon"
} else {
title = "⏳ Coming Soon"
}
b.WriteString(titleStyle.Render(title))
b.WriteString("\n\n")
var message string
if shouldUseCompactLayout(m.width, 50) {
message = "Coming soon!\nStay tuned."
} else {
message = "🚀 This feature is coming very soon!\n Stay tuned for updates."
}
b.WriteString(messageBoxStyle.Render(message))
b.WriteString("\n\n")
var helpText string
if shouldUseCompactLayout(m.width, 60) {
helpText = "Press any key..."
} else {
helpText = "This message will disappear in 5 seconds or press any key..."
}
b.WriteString(helpStyle.Render(helpText))
return b.String()
}
+83
View File
@@ -0,0 +1,83 @@
package interaction
import (
"strings"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
func (m *model) commandsUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch {
case key.Matches(msg, m.keymap.quit):
m.showingCommands = false
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
case msg.String() == "enter":
selectedItem := m.commandList.SelectedItem()
if selectedItem != nil {
item := selectedItem.(commandItem)
if item.name == "slug" {
m.showingCommands = false
m.editingSlug = true
m.slugInput.SetValue(m.interaction.slug.String())
m.slugInput.Focus()
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
} else if item.name == "tunnel-type" {
m.showingCommands = false
m.showingComingSoon = true
return m, tea.Batch(tickCmd(5*time.Second), tea.ClearScreen, textinput.Blink)
}
m.showingCommands = false
return m, nil
}
case msg.String() == "esc":
m.showingCommands = false
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
}
m.commandList, cmd = m.commandList.Update(msg)
return m, cmd
}
func (m *model) commandsView() string {
isCompact := shouldUseCompactLayout(m.width, 60)
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
PaddingTop(1).
PaddingBottom(1)
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
Italic(true).
MarginTop(1)
var b strings.Builder
b.WriteString("\n")
var title string
if shouldUseCompactLayout(m.width, 40) {
title = "Commands"
} else {
title = "⚡ Commands"
}
b.WriteString(titleStyle.Render(title))
b.WriteString("\n\n")
b.WriteString(m.commandList.View())
b.WriteString("\n")
var helpText string
if isCompact {
helpText = "↑/↓ Nav • Enter Select • Esc Cancel"
} else {
helpText = "↑/↓ Navigate • Enter Select • Esc Cancel"
}
b.WriteString(helpStyle.Render(helpText))
return b.String()
}
+186
View File
@@ -0,0 +1,186 @@
package interaction
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
func (m *model) dashboardUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keymap.quit):
m.quitting = true
return m, tea.Batch(tea.ClearScreen, textinput.Blink, tea.Quit)
case key.Matches(msg, m.keymap.command):
m.showingCommands = true
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
}
return m, nil
}
func (m *model) dashboardView() string {
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
PaddingTop(1)
subtitleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
Italic(true)
urlStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Underline(true).
Italic(true)
urlBoxStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#04B575")).
Bold(true).
Italic(true)
keyHintStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Bold(true)
var b strings.Builder
isCompact := shouldUseCompactLayout(m.width, 85)
var asciiArtMargin int
if isCompact {
asciiArtMargin = 0
} else {
asciiArtMargin = 1
}
asciiArtStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
MarginBottom(asciiArtMargin)
var asciiArt string
if shouldUseCompactLayout(m.width, 50) {
asciiArt = "TUNNEL PLS"
} else if isCompact {
asciiArt = `
▀█▀ █ █ █▄ █ █▄ █ ██▀ █ ▄▀▀ █ ▄▀▀
█ ▀▄█ █ ▀█ █ ▀█ █▄▄ █▄▄ ▄█▀ █▄▄ ▄█▀`
} else {
asciiArt = `
████████╗██╗ ██╗███╗ ██╗███╗ ██╗███████╗██╗ ██████╗ ██╗ ███████╗
╚══██╔══╝██║ ██║████╗ ██║████╗ ██║██╔════╝██║ ██╔══██╗██║ ██╔════╝
██║ ██║ ██║██╔██╗ ██║██╔██╗ ██║█████╗ ██║ ██████╔╝██║ ███████╗
██║ ██║ ██║██║╚██╗██║██║╚██╗██║██╔══╝ ██║ ██╔═══╝ ██║ ╚════██║
██║ ╚██████╔╝██║ ╚████║██║ ╚████║███████╗███████╗ ██║ ███████╗███████║
╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝╚══════╝╚══════╝ ╚═╝ ╚══════╝╚══════╝`
}
b.WriteString(asciiArtStyle.Render(asciiArt))
b.WriteString("\n")
if !shouldUseCompactLayout(m.width, 60) {
b.WriteString(subtitleStyle.Render("Secure tunnel service by Bagas • "))
b.WriteString(urlStyle.Render("https://fossy.my.id"))
b.WriteString("\n\n")
} else {
b.WriteString("\n")
}
boxMaxWidth := getResponsiveWidth(m.width, 10, 40, 80)
var boxPadding int
var boxMargin int
if isCompact {
boxPadding = 1
boxMargin = 1
} else {
boxPadding = 2
boxMargin = 2
}
responsiveInfoBox := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(1, boxPadding).
MarginTop(boxMargin).
MarginBottom(boxMargin).
Width(boxMaxWidth)
authenticatedUser := m.interaction.lifecycle.User()
userInfoStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
Bold(true)
sectionHeaderStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
Bold(true)
addressStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA"))
var infoContent string
if shouldUseCompactLayout(m.width, 70) {
infoContent = fmt.Sprintf("👤 %s\n\n%s\n%s",
userInfoStyle.Render(authenticatedUser),
sectionHeaderStyle.Render("🌐 FORWARDING ADDRESS:"),
addressStyle.Render(fmt.Sprintf(" %s", urlBoxStyle.Render(m.getTunnelURL()))))
} else {
infoContent = fmt.Sprintf("👤 Authenticated as: %s\n\n%s\n %s",
userInfoStyle.Render(authenticatedUser),
sectionHeaderStyle.Render("🌐 FORWARDING ADDRESS:"),
addressStyle.Render(urlBoxStyle.Render(m.getTunnelURL())))
}
b.WriteString(responsiveInfoBox.Render(infoContent))
b.WriteString("\n")
var quickActionsTitle string
if shouldUseCompactLayout(m.width, 50) {
quickActionsTitle = "Actions"
} else if isCompact {
quickActionsTitle = "Quick Actions"
} else {
quickActionsTitle = "✨ Quick Actions"
}
b.WriteString(titleStyle.Render(quickActionsTitle))
b.WriteString("\n")
var featureMargin int
if isCompact {
featureMargin = 1
} else {
featureMargin = 2
}
compactFeatureStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
MarginLeft(featureMargin)
var commandsText string
var quitText string
if shouldUseCompactLayout(m.width, 60) {
commandsText = fmt.Sprintf(" %s Commands", keyHintStyle.Render("[C]"))
quitText = fmt.Sprintf(" %s Quit", keyHintStyle.Render("[Q]"))
} else {
commandsText = fmt.Sprintf(" %s Open commands menu", keyHintStyle.Render("[C]"))
quitText = fmt.Sprintf(" %s Quit application", keyHintStyle.Render("[Q]"))
}
b.WriteString(compactFeatureStyle.Render(commandsText))
b.WriteString("\n")
b.WriteString(compactFeatureStyle.Render(quitText))
if !shouldUseCompactLayout(m.width, 70) {
b.WriteString("\n\n")
footerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
Italic(true)
b.WriteString(footerStyle.Render("Press 'C' to customize your tunnel settings"))
}
return b.String()
}
+51 -609
View File
@@ -2,12 +2,8 @@ package interaction
import (
"context"
"fmt"
"log"
"strings"
"time"
"tunnel_pls/internal/config"
"tunnel_pls/internal/random"
"tunnel_pls/session/slug"
"tunnel_pls/types"
@@ -23,40 +19,59 @@ import (
type Lifecycle interface {
Close() error
GetUser() string
User() string
}
type SessionRegistry interface {
Update(user string, oldKey, newKey types.SessionKey) error
}
type Controller interface {
type Interaction interface {
Mode() types.Mode
SetChannel(channel ssh.Channel)
SetLifecycle(lifecycle Lifecycle)
Start()
SetWH(w, h int)
Redraw()
SetSessionRegistry(registry SessionRegistry)
SetMode(m types.Mode)
SetWH(w, h int)
Start()
Redraw()
Send(message string) error
}
type Forwarder interface {
Close() error
GetTunnelType() types.TunnelType
GetForwardedPort() uint16
TunnelType() types.TunnelType
ForwardedPort() uint16
}
type Interaction struct {
type interaction struct {
channel ssh.Channel
slugManager slug.Manager
slug slug.Slug
forwarder Forwarder
lifecycle Lifecycle
sessionRegistry SessionRegistry
program *tea.Program
ctx context.Context
cancel context.CancelFunc
mode types.Mode
}
func (i *Interaction) SetWH(w, h int) {
func (i *interaction) SetMode(m types.Mode) {
i.mode = m
}
func (i *interaction) Mode() types.Mode {
return i.mode
}
func (i *interaction) Send(message string) error {
if i.channel != nil {
_, err := i.channel.Write([]byte(message))
return err
}
return nil
}
func (i *interaction) SetWH(w, h int) {
if i.program != nil {
i.program.Send(tea.WindowSizeMsg{
Width: w,
@@ -65,50 +80,11 @@ func (i *Interaction) SetWH(w, h int) {
}
}
type commandItem struct {
name string
desc string
}
type model struct {
domain string
protocol string
tunnelType types.TunnelType
port uint16
keymap keymap
help help.Model
quitting bool
showingCommands bool
editingSlug bool
showingComingSoon bool
commandList list.Model
slugInput textinput.Model
slugError string
interaction *Interaction
width int
height int
}
func (m *model) getTunnelURL() string {
if m.tunnelType == types.HTTP {
return buildURL(m.protocol, m.interaction.slugManager.Get(), m.domain)
}
return fmt.Sprintf("tcp://%s:%d", m.domain, m.port)
}
type keymap struct {
quit key.Binding
command key.Binding
random key.Binding
}
type tickMsg time.Time
func NewInteraction(slugManager slug.Manager, forwarder Forwarder) *Interaction {
func New(slug slug.Slug, forwarder Forwarder) Interaction {
ctx, cancel := context.WithCancel(context.Background())
return &Interaction{
return &interaction{
channel: nil,
slugManager: slugManager,
slug: slug,
forwarder: forwarder,
lifecycle: nil,
sessionRegistry: nil,
@@ -118,19 +94,19 @@ func NewInteraction(slugManager slug.Manager, forwarder Forwarder) *Interaction
}
}
func (i *Interaction) SetSessionRegistry(registry SessionRegistry) {
func (i *interaction) SetSessionRegistry(registry SessionRegistry) {
i.sessionRegistry = registry
}
func (i *Interaction) SetLifecycle(lifecycle Lifecycle) {
func (i *interaction) SetLifecycle(lifecycle Lifecycle) {
i.lifecycle = lifecycle
}
func (i *Interaction) SetChannel(channel ssh.Channel) {
func (i *interaction) SetChannel(channel ssh.Channel) {
i.channel = channel
}
func (i *Interaction) Stop() {
func (i *interaction) Stop() {
if i.cancel != nil {
i.cancel()
}
@@ -140,47 +116,7 @@ func (i *Interaction) Stop() {
}
}
func getResponsiveWidth(screenWidth, padding, minWidth, maxWidth int) int {
width := screenWidth - padding
if width > maxWidth {
width = maxWidth
}
if width < minWidth {
width = minWidth
}
return width
}
func shouldUseCompactLayout(width int, threshold int) bool {
return width < threshold
}
func truncateString(s string, maxLength int) string {
if len(s) <= maxLength {
return s
}
if maxLength < 4 {
return s[:maxLength]
}
return s[:maxLength-3] + "..."
}
func (i commandItem) FilterValue() string { return i.name }
func (i commandItem) Title() string { return i.name }
func (i commandItem) Description() string { return i.desc }
func tickCmd(d time.Duration) tea.Cmd {
return tea.Tick(d, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func (m *model) Init() tea.Cmd {
return tea.Batch(textinput.Blink, tea.WindowSize())
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tickMsg:
@@ -206,539 +142,53 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
if m.showingComingSoon {
m.showingComingSoon = false
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
return m.comingSoonUpdate(msg)
}
if m.editingSlug {
if m.tunnelType != types.HTTP {
m.editingSlug = false
m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
}
switch msg.String() {
case "esc":
m.editingSlug = false
m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
case "enter":
inputValue := m.slugInput.Value()
if err := m.interaction.sessionRegistry.Update(m.interaction.lifecycle.GetUser(), types.SessionKey{
Id: m.interaction.slugManager.Get(),
Type: types.HTTP,
}, types.SessionKey{
Id: inputValue,
Type: types.HTTP,
}); err != nil {
m.slugError = err.Error()
return m, nil
}
m.editingSlug = false
m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
case "ctrl+c":
m.editingSlug = false
m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
default:
if key.Matches(msg, m.keymap.random) {
newSubdomain := generateRandomSubdomain()
m.slugInput.SetValue(newSubdomain)
m.slugError = ""
m.slugInput, cmd = m.slugInput.Update(msg)
return m, cmd
}
m.slugError = ""
m.slugInput, cmd = m.slugInput.Update(msg)
return m, cmd
}
return m.slugUpdate(msg)
}
if m.showingCommands {
switch {
case key.Matches(msg, m.keymap.quit):
m.showingCommands = false
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
case msg.String() == "enter":
selectedItem := m.commandList.SelectedItem()
if selectedItem != nil {
item := selectedItem.(commandItem)
if item.name == "slug" {
m.showingCommands = false
m.editingSlug = true
m.slugInput.SetValue(m.interaction.slugManager.Get())
m.slugInput.Focus()
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
} else if item.name == "tunnel-type" {
m.showingCommands = false
m.showingComingSoon = true
return m, tea.Batch(tickCmd(5*time.Second), tea.ClearScreen, textinput.Blink)
}
m.showingCommands = false
return m, nil
}
case msg.String() == "esc":
m.showingCommands = false
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
}
m.commandList, cmd = m.commandList.Update(msg)
return m, cmd
return m.commandsUpdate(msg)
}
switch {
case key.Matches(msg, m.keymap.quit):
m.quitting = true
return m, tea.Batch(tea.ClearScreen, textinput.Blink, tea.Quit)
case key.Matches(msg, m.keymap.command):
m.showingCommands = true
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
}
return m.dashboardUpdate(msg)
}
return m, nil
}
func (i *Interaction) Redraw() {
func (i *interaction) Redraw() {
if i.program != nil {
i.program.Send(tea.ClearScreen())
}
}
func (m *model) helpView() string {
return "\n" + m.help.ShortHelpView([]key.Binding{
m.keymap.command,
m.keymap.quit,
})
}
func (m *model) View() string {
if m.quitting {
return ""
}
if m.showingComingSoon {
isCompact := shouldUseCompactLayout(m.width, 60)
var boxPadding int
var boxMargin int
if isCompact {
boxPadding = 1
boxMargin = 1
} else {
boxPadding = 3
boxMargin = 2
}
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
PaddingTop(1).
PaddingBottom(1)
messageBoxWidth := getResponsiveWidth(m.width, 10, 30, 60)
messageBoxStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#1A1A2E")).
Bold(true).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(1, boxPadding).
MarginTop(boxMargin).
MarginBottom(boxMargin).
Width(messageBoxWidth).
Align(lipgloss.Center)
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
Italic(true).
MarginTop(1)
var b strings.Builder
b.WriteString("\n\n")
var title string
if shouldUseCompactLayout(m.width, 40) {
title = "Coming Soon"
} else {
title = "⏳ Coming Soon"
}
b.WriteString(titleStyle.Render(title))
b.WriteString("\n\n")
var message string
if shouldUseCompactLayout(m.width, 50) {
message = "Coming soon!\nStay tuned."
} else {
message = "🚀 This feature is coming very soon!\n Stay tuned for updates."
}
b.WriteString(messageBoxStyle.Render(message))
b.WriteString("\n\n")
var helpText string
if shouldUseCompactLayout(m.width, 60) {
helpText = "Press any key..."
} else {
helpText = "This message will disappear in 5 seconds or press any key..."
}
b.WriteString(helpStyle.Render(helpText))
return b.String()
return m.comingSoonView()
}
if m.editingSlug {
isCompact := shouldUseCompactLayout(m.width, 70)
isVeryCompact := shouldUseCompactLayout(m.width, 50)
var boxPadding int
var boxMargin int
if isVeryCompact {
boxPadding = 1
boxMargin = 1
} else if isCompact {
boxPadding = 1
boxMargin = 1
} else {
boxPadding = 2
boxMargin = 2
}
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
PaddingTop(1).
PaddingBottom(1)
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
MarginTop(1)
inputBoxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(1, boxPadding).
MarginTop(boxMargin).
MarginBottom(boxMargin)
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
Italic(true).
MarginTop(1)
errorBoxStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000")).
Background(lipgloss.Color("#3D0000")).
Bold(true).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#FF0000")).
Padding(0, boxPadding).
MarginTop(1).
MarginBottom(1)
rulesBoxWidth := getResponsiveWidth(m.width, 10, 30, 60)
rulesBoxStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(0, boxPadding).
MarginTop(1).
MarginBottom(1).
Width(rulesBoxWidth)
var b strings.Builder
var title string
if isVeryCompact {
title = "Edit Subdomain"
} else {
title = "🔧 Edit Subdomain"
}
b.WriteString(titleStyle.Render(title))
b.WriteString("\n\n")
if m.tunnelType != types.HTTP {
warningBoxWidth := getResponsiveWidth(m.width, 10, 30, 60)
warningBoxStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFA500")).
Background(lipgloss.Color("#3D2000")).
Bold(true).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#FFA500")).
Padding(1, boxPadding).
MarginTop(boxMargin).
MarginBottom(boxMargin).
Width(warningBoxWidth)
var warningText string
if isVeryCompact {
warningText = "⚠️ TCP tunnels don't support custom subdomains."
} else {
warningText = "⚠️ TCP tunnels cannot have custom subdomains. Only HTTP/HTTPS tunnels support subdomain customization."
}
b.WriteString(warningBoxStyle.Render(warningText))
b.WriteString("\n\n")
var helpText string
if isVeryCompact {
helpText = "Press any key to go back"
} else {
helpText = "Press Enter or Esc to go back"
}
b.WriteString(helpStyle.Render(helpText))
return b.String()
}
var rulesContent string
if isVeryCompact {
rulesContent = "Rules:\n3-20 chars\na-z, 0-9, -\nNo leading/trailing -"
} else if isCompact {
rulesContent = "📋 Rules:\n • 3-20 chars\n • a-z, 0-9, -\n • No leading/trailing -"
} else {
rulesContent = "📋 Rules: \n\t• 3-20 chars \n\t• a-z, 0-9, - \n\t• No leading/trailing -"
}
b.WriteString(rulesBoxStyle.Render(rulesContent))
b.WriteString("\n")
var instruction string
if isVeryCompact {
instruction = "Custom subdomain:"
} else {
instruction = "Enter your custom subdomain:"
}
b.WriteString(instructionStyle.Render(instruction))
b.WriteString("\n")
if m.slugError != "" {
errorInputBoxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#FF0000")).
Padding(1, boxPadding).
MarginTop(boxMargin).
MarginBottom(1)
b.WriteString(errorInputBoxStyle.Render(m.slugInput.View()))
b.WriteString("\n")
b.WriteString(errorBoxStyle.Render("❌ " + m.slugError))
b.WriteString("\n")
} else {
b.WriteString(inputBoxStyle.Render(m.slugInput.View()))
b.WriteString("\n")
}
previewURL := buildURL(m.protocol, m.slugInput.Value(), m.domain)
previewWidth := getResponsiveWidth(m.width, 10, 30, 80)
if len(previewURL) > previewWidth-10 {
previewURL = truncateString(previewURL, previewWidth-10)
}
previewStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#04B575")).
Italic(true).
Width(previewWidth)
b.WriteString(previewStyle.Render(fmt.Sprintf("Preview: %s", previewURL)))
b.WriteString("\n")
var helpText string
if isVeryCompact {
helpText = "Enter: save • CTRL+R: random • Esc: cancel"
} else {
helpText = "Press Enter to save • CTRL+R for random • Esc to cancel"
}
b.WriteString(helpStyle.Render(helpText))
return b.String()
return m.slugView()
}
if m.showingCommands {
isCompact := shouldUseCompactLayout(m.width, 60)
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
PaddingTop(1).
PaddingBottom(1)
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
Italic(true).
MarginTop(1)
var b strings.Builder
b.WriteString("\n")
var title string
if shouldUseCompactLayout(m.width, 40) {
title = "Commands"
} else {
title = "⚡ Commands"
}
b.WriteString(titleStyle.Render(title))
b.WriteString("\n\n")
b.WriteString(m.commandList.View())
b.WriteString("\n")
var helpText string
if isCompact {
helpText = "↑/↓ Nav • Enter Select • Esc Cancel"
} else {
helpText = "↑/↓ Navigate • Enter Select • Esc Cancel"
}
b.WriteString(helpStyle.Render(helpText))
return b.String()
return m.commandsView()
}
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
PaddingTop(1)
subtitleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
Italic(true)
urlStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Underline(true).
Italic(true)
urlBoxStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#04B575")).
Bold(true).
Italic(true)
keyHintStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Bold(true)
var b strings.Builder
isCompact := shouldUseCompactLayout(m.width, 85)
var asciiArtMargin int
if isCompact {
asciiArtMargin = 0
} else {
asciiArtMargin = 1
}
asciiArtStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
MarginBottom(asciiArtMargin)
var asciiArt string
if shouldUseCompactLayout(m.width, 50) {
asciiArt = "TUNNEL PLS"
} else if isCompact {
asciiArt = `
▀█▀ █ █ █▄ █ █▄ █ ██▀ █ ▄▀▀ █ ▄▀▀
█ ▀▄█ █ ▀█ █ ▀█ █▄▄ █▄▄ ▄█▀ █▄▄ ▄█▀`
} else {
asciiArt = `
████████╗██╗ ██╗███╗ ██╗███╗ ██╗███████╗██╗ ██████╗ ██╗ ███████╗
╚══██╔══╝██║ ██║████╗ ██║████╗ ██║██╔════╝██║ ██╔══██╗██║ ██╔════╝
██║ ██║ ██║██╔██╗ ██║██╔██╗ ██║█████╗ ██║ ██████╔╝██║ ███████╗
██║ ██║ ██║██║╚██╗██║██║╚██╗██║██╔══╝ ██║ ██╔═══╝ ██║ ╚════██║
██║ ╚██████╔╝██║ ╚████║██║ ╚████║███████╗███████╗ ██║ ███████╗███████║
╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝╚══════╝╚══════╝ ╚═╝ ╚══════╝╚══════╝`
}
b.WriteString(asciiArtStyle.Render(asciiArt))
b.WriteString("\n")
if !shouldUseCompactLayout(m.width, 60) {
b.WriteString(subtitleStyle.Render("Secure tunnel service by Bagas • "))
b.WriteString(urlStyle.Render("https://fossy.my.id"))
b.WriteString("\n\n")
} else {
b.WriteString("\n")
}
boxMaxWidth := getResponsiveWidth(m.width, 10, 40, 80)
var boxPadding int
var boxMargin int
if isCompact {
boxPadding = 1
boxMargin = 1
} else {
boxPadding = 2
boxMargin = 2
}
responsiveInfoBox := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(1, boxPadding).
MarginTop(boxMargin).
MarginBottom(boxMargin).
Width(boxMaxWidth)
urlDisplay := m.getTunnelURL()
if shouldUseCompactLayout(m.width, 80) && len(urlDisplay) > m.width-20 {
maxLen := m.width - 25
if maxLen > 10 {
urlDisplay = truncateString(urlDisplay, maxLen)
}
}
var infoContent string
if shouldUseCompactLayout(m.width, 70) {
infoContent = fmt.Sprintf("🌐 %s", urlBoxStyle.Render(urlDisplay))
} else if isCompact {
infoContent = fmt.Sprintf("🌐 Forwarding to:\n\n %s", urlBoxStyle.Render(urlDisplay))
} else {
infoContent = fmt.Sprintf("🌐 F O R W A R D I N G T O:\n\n %s", urlBoxStyle.Render(urlDisplay))
}
b.WriteString(responsiveInfoBox.Render(infoContent))
b.WriteString("\n")
var quickActionsTitle string
if shouldUseCompactLayout(m.width, 50) {
quickActionsTitle = "Actions"
} else if isCompact {
quickActionsTitle = "Quick Actions"
} else {
quickActionsTitle = "✨ Quick Actions"
}
b.WriteString(titleStyle.Render(quickActionsTitle))
b.WriteString("\n")
var featureMargin int
if isCompact {
featureMargin = 1
} else {
featureMargin = 2
}
compactFeatureStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
MarginLeft(featureMargin)
var commandsText string
var quitText string
if shouldUseCompactLayout(m.width, 60) {
commandsText = fmt.Sprintf(" %s Commands", keyHintStyle.Render("[C]"))
quitText = fmt.Sprintf(" %s Quit", keyHintStyle.Render("[Q]"))
} else {
commandsText = fmt.Sprintf(" %s Open commands menu", keyHintStyle.Render("[C]"))
quitText = fmt.Sprintf(" %s Quit application", keyHintStyle.Render("[Q]"))
}
b.WriteString(compactFeatureStyle.Render(commandsText))
b.WriteString("\n")
b.WriteString(compactFeatureStyle.Render(quitText))
if !shouldUseCompactLayout(m.width, 70) {
b.WriteString("\n\n")
footerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
Italic(true)
b.WriteString(footerStyle.Render("Press 'C' to customize your tunnel settings"))
}
return b.String()
return m.dashboardView()
}
func (i *Interaction) Start() {
func (i *interaction) Start() {
if i.mode == types.HEADLESS {
return
}
lipgloss.SetColorProfile(termenv.TrueColor)
domain := config.Getenv("DOMAIN", "localhost")
@@ -747,8 +197,8 @@ func (i *Interaction) Start() {
protocol = "https"
}
tunnelType := i.forwarder.GetTunnelType()
port := i.forwarder.GetForwardedPort()
tunnelType := i.forwarder.TunnelType()
port := i.forwarder.ForwardedPort()
items := []list.Item{
commandItem{name: "slug", desc: "Set custom subdomain"},
@@ -816,11 +266,3 @@ func (i *Interaction) Start() {
log.Printf("Cannot close session: %s \n", err)
}
}
func buildURL(protocol, subdomain, domain string) string {
return fmt.Sprintf("%s://%s.%s", protocol, subdomain, domain)
}
func generateRandomSubdomain() string {
return random.GenerateRandomString(20)
}
+95
View File
@@ -0,0 +1,95 @@
package interaction
import (
"fmt"
"time"
"tunnel_pls/types"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
type commandItem struct {
name string
desc string
}
func (i commandItem) FilterValue() string { return i.name }
func (i commandItem) Title() string { return i.name }
func (i commandItem) Description() string { return i.desc }
type model struct {
domain string
protocol string
tunnelType types.TunnelType
port uint16
keymap keymap
help help.Model
quitting bool
showingCommands bool
editingSlug bool
showingComingSoon bool
commandList list.Model
slugInput textinput.Model
slugError string
interaction *interaction
width int
height int
}
func (m *model) getTunnelURL() string {
if m.tunnelType == types.HTTP {
return buildURL(m.protocol, m.interaction.slug.String(), m.domain)
}
return fmt.Sprintf("tcp://%s:%d", m.domain, m.port)
}
type keymap struct {
quit key.Binding
command key.Binding
random key.Binding
}
type tickMsg time.Time
func (m *model) Init() tea.Cmd {
return tea.Batch(textinput.Blink, tea.WindowSize())
}
func getResponsiveWidth(screenWidth, padding, minWidth, maxWidth int) int {
width := screenWidth - padding
if width > maxWidth {
width = maxWidth
}
if width < minWidth {
width = minWidth
}
return width
}
func shouldUseCompactLayout(width int, threshold int) bool {
return width < threshold
}
func truncateString(s string, maxLength int) string {
if len(s) <= maxLength {
return s
}
if maxLength < 4 {
return s[:maxLength]
}
return s[:maxLength-3] + "..."
}
func tickCmd(d time.Duration) tea.Cmd {
return tea.Tick(d, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func buildURL(protocol, subdomain, domain string) string {
return fmt.Sprintf("%s://%s.%s", protocol, subdomain, domain)
}
+224
View File
@@ -0,0 +1,224 @@
package interaction
import (
"fmt"
"strings"
"tunnel_pls/internal/random"
"tunnel_pls/types"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
func (m *model) slugUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
if m.tunnelType != types.HTTP {
m.editingSlug = false
m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
}
switch msg.String() {
case "esc":
m.editingSlug = false
m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
case "enter":
inputValue := m.slugInput.Value()
if err := m.interaction.sessionRegistry.Update(m.interaction.lifecycle.User(), types.SessionKey{
Id: m.interaction.slug.String(),
Type: types.HTTP,
}, types.SessionKey{
Id: inputValue,
Type: types.HTTP,
}); err != nil {
m.slugError = err.Error()
return m, nil
}
m.editingSlug = false
m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
case "ctrl+c":
m.editingSlug = false
m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
default:
if key.Matches(msg, m.keymap.random) {
newSubdomain, err := random.GenerateRandomString(20)
if err != nil {
return m, cmd
}
m.slugInput.SetValue(newSubdomain)
m.slugError = ""
m.slugInput, cmd = m.slugInput.Update(msg)
}
m.slugError = ""
m.slugInput, cmd = m.slugInput.Update(msg)
return m, cmd
}
}
func (m *model) slugView() string {
isCompact := shouldUseCompactLayout(m.width, 70)
isVeryCompact := shouldUseCompactLayout(m.width, 50)
var boxPadding int
var boxMargin int
if isVeryCompact {
boxPadding = 1
boxMargin = 1
} else if isCompact {
boxPadding = 1
boxMargin = 1
} else {
boxPadding = 2
boxMargin = 2
}
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
PaddingTop(1).
PaddingBottom(1)
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
MarginTop(1)
inputBoxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(1, boxPadding).
MarginTop(boxMargin).
MarginBottom(boxMargin)
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
Italic(true).
MarginTop(1)
errorBoxStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000")).
Background(lipgloss.Color("#3D0000")).
Bold(true).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#FF0000")).
Padding(0, boxPadding).
MarginTop(1).
MarginBottom(1)
rulesBoxWidth := getResponsiveWidth(m.width, 10, 30, 60)
rulesBoxStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(0, boxPadding).
MarginTop(1).
MarginBottom(1).
Width(rulesBoxWidth)
var b strings.Builder
var title string
if isVeryCompact {
title = "Edit Subdomain"
} else {
title = "🔧 Edit Subdomain"
}
b.WriteString(titleStyle.Render(title))
b.WriteString("\n\n")
if m.tunnelType != types.HTTP {
warningBoxWidth := getResponsiveWidth(m.width, 10, 30, 60)
warningBoxStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFA500")).
Background(lipgloss.Color("#3D2000")).
Bold(true).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#FFA500")).
Padding(1, boxPadding).
MarginTop(boxMargin).
MarginBottom(boxMargin).
Width(warningBoxWidth)
var warningText string
if isVeryCompact {
warningText = "⚠️ TCP tunnels don't support custom subdomains."
} else {
warningText = "⚠️ TCP tunnels cannot have custom subdomains. Only HTTP/HTTPS tunnels support subdomain customization."
}
b.WriteString(warningBoxStyle.Render(warningText))
b.WriteString("\n\n")
var helpText string
if isVeryCompact {
helpText = "Press any key to go back"
} else {
helpText = "Press Enter or Esc to go back"
}
b.WriteString(helpStyle.Render(helpText))
return b.String()
}
var rulesContent string
if isVeryCompact {
rulesContent = "Rules:\n3-20 chars\na-z, 0-9, -\nNo leading/trailing -"
} else if isCompact {
rulesContent = "📋 Rules:\n • 3-20 chars\n • a-z, 0-9, -\n • No leading/trailing -"
} else {
rulesContent = "📋 Rules: \n\t• 3-20 chars \n\t• a-z, 0-9, - \n\t• No leading/trailing -"
}
b.WriteString(rulesBoxStyle.Render(rulesContent))
b.WriteString("\n")
var instruction string
if isVeryCompact {
instruction = "Custom subdomain:"
} else {
instruction = "Enter your custom subdomain:"
}
b.WriteString(instructionStyle.Render(instruction))
b.WriteString("\n")
if m.slugError != "" {
errorInputBoxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#FF0000")).
Padding(1, boxPadding).
MarginTop(boxMargin).
MarginBottom(1)
b.WriteString(errorInputBoxStyle.Render(m.slugInput.View()))
b.WriteString("\n")
b.WriteString(errorBoxStyle.Render("❌ " + m.slugError))
b.WriteString("\n")
} else {
b.WriteString(inputBoxStyle.Render(m.slugInput.View()))
b.WriteString("\n")
}
previewURL := buildURL(m.protocol, m.slugInput.Value(), m.domain)
previewWidth := getResponsiveWidth(m.width, 10, 30, 80)
if len(previewURL) > previewWidth-10 {
previewURL = truncateString(previewURL, previewWidth-10)
}
previewStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#04B575")).
Italic(true).
Width(previewWidth)
b.WriteString(previewStyle.Render(fmt.Sprintf("Preview: %s", previewURL)))
b.WriteString("\n")
var helpText string
if isVeryCompact {
helpText = "Enter: save • CTRL+R: random • Esc: cancel"
} else {
helpText = "Press Enter to save • CTRL+R for random • Esc to cancel"
}
b.WriteString(helpStyle.Render(helpText))
return b.String()
}
+44 -40
View File
@@ -15,115 +15,119 @@ import (
type Forwarder interface {
Close() error
GetTunnelType() types.TunnelType
GetForwardedPort() uint16
TunnelType() types.TunnelType
ForwardedPort() uint16
}
type SessionRegistry interface {
Remove(key types.SessionKey)
}
type Lifecycle struct {
type lifecycle struct {
status types.Status
conn ssh.Conn
channel ssh.Channel
forwarder Forwarder
sessionRegistry SessionRegistry
slugManager slug.Manager
slug slug.Slug
startedAt time.Time
user string
}
func NewLifecycle(conn ssh.Conn, forwarder Forwarder, slugManager slug.Manager, user string) *Lifecycle {
return &Lifecycle{
func New(conn ssh.Conn, forwarder Forwarder, slugManager slug.Slug, user string) Lifecycle {
return &lifecycle{
status: types.INITIALIZING,
conn: conn,
channel: nil,
forwarder: forwarder,
slugManager: slugManager,
slug: slugManager,
sessionRegistry: nil,
startedAt: time.Now(),
user: user,
}
}
func (l *Lifecycle) SetSessionRegistry(registry SessionRegistry) {
func (l *lifecycle) SetSessionRegistry(registry SessionRegistry) {
l.sessionRegistry = registry
}
type SessionLifecycle interface {
Close() error
SetStatus(status types.Status)
GetConnection() ssh.Conn
GetChannel() ssh.Channel
GetUser() string
type Lifecycle interface {
Connection() ssh.Conn
Channel() ssh.Channel
User() string
SetChannel(channel ssh.Channel)
SetSessionRegistry(registry SessionRegistry)
SetStatus(status types.Status)
IsActive() bool
StartedAt() time.Time
Close() error
}
func (l *Lifecycle) GetUser() string {
func (l *lifecycle) User() string {
return l.user
}
func (l *Lifecycle) GetChannel() ssh.Channel {
func (l *lifecycle) Channel() ssh.Channel {
return l.channel
}
func (l *Lifecycle) SetChannel(channel ssh.Channel) {
func (l *lifecycle) SetChannel(channel ssh.Channel) {
l.channel = channel
}
func (l *Lifecycle) GetConnection() ssh.Conn {
func (l *lifecycle) Connection() ssh.Conn {
return l.conn
}
func (l *Lifecycle) SetStatus(status types.Status) {
func (l *lifecycle) SetStatus(status types.Status) {
l.status = status
if status == types.RUNNING && l.startedAt.IsZero() {
l.startedAt = time.Now()
}
}
func (l *Lifecycle) Close() error {
err := l.forwarder.Close()
if err != nil && !errors.Is(err, net.ErrClosed) {
return err
func (l *lifecycle) Close() error {
var firstErr error
tunnelType := l.forwarder.TunnelType()
if err := l.forwarder.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
firstErr = err
}
if l.channel != nil {
err := l.channel.Close()
if err != nil && !errors.Is(err, io.EOF) {
return err
if err := l.channel.Close(); err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) {
if firstErr == nil {
firstErr = err
}
}
}
if l.conn != nil {
err := l.conn.Close()
if err != nil && !errors.Is(err, net.ErrClosed) {
return err
if err := l.conn.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
if firstErr == nil {
firstErr = err
}
}
}
clientSlug := l.slugManager.Get()
if clientSlug != "" && l.sessionRegistry.Remove != nil {
key := types.SessionKey{Id: clientSlug, Type: l.forwarder.GetTunnelType()}
l.sessionRegistry.Remove(key)
clientSlug := l.slug.String()
key := types.SessionKey{
Id: clientSlug,
Type: tunnelType,
}
l.sessionRegistry.Remove(key)
if l.forwarder.GetTunnelType() == types.TCP {
err = portUtil.Default.SetPortStatus(l.forwarder.GetForwardedPort(), false)
if err != nil {
return err
if tunnelType == types.TCP {
if err := portUtil.Default.SetPortStatus(l.forwarder.ForwardedPort(), false); err != nil && firstErr == nil {
firstErr = err
}
}
return nil
return firstErr
}
func (l *Lifecycle) IsActive() bool {
func (l *lifecycle) IsActive() bool {
return l.status == types.RUNNING
}
func (l *Lifecycle) StartedAt() time.Time {
func (l *lifecycle) StartedAt() time.Time {
return l.startedAt
}
+26 -14
View File
@@ -9,26 +9,27 @@ import (
type Key = types.SessionKey
type Registry interface {
Get(key Key) (session *SSHSession, err error)
Get(key Key) (session Session, err error)
GetWithUser(user string, key Key) (session Session, err error)
Update(user string, oldKey, newKey Key) error
Register(key Key, session *SSHSession) (success bool)
Register(key Key, session Session) (success bool)
Remove(key Key)
GetAllSessionFromUser(user string) []*SSHSession
GetAllSessionFromUser(user string) []Session
}
type registry struct {
mu sync.RWMutex
byUser map[string]map[Key]*SSHSession
byUser map[string]map[Key]Session
slugIndex map[Key]string
}
func NewRegistry() Registry {
return &registry{
byUser: make(map[string]map[Key]*SSHSession),
byUser: make(map[string]map[Key]Session),
slugIndex: make(map[Key]string),
}
}
func (r *registry) Get(key Key) (session *SSHSession, err error) {
func (r *registry) Get(key Key) (session Session, err error) {
r.mu.RLock()
defer r.mu.RUnlock()
@@ -44,6 +45,17 @@ func (r *registry) Get(key Key) (session *SSHSession, err error) {
return client, nil
}
func (r *registry) GetWithUser(user string, key Key) (session Session, err error) {
r.mu.RLock()
defer r.mu.RUnlock()
client, ok := r.byUser[user][key]
if !ok {
return nil, fmt.Errorf("session not found")
}
return client, nil
}
func (r *registry) Update(user string, oldKey, newKey Key) error {
if oldKey.Type != newKey.Type {
return fmt.Errorf("tunnel type cannot change")
@@ -75,17 +87,17 @@ func (r *registry) Update(user string, oldKey, newKey Key) error {
delete(r.byUser[user], oldKey)
delete(r.slugIndex, oldKey)
client.slugManager.Set(newKey.Id)
client.Slug().Set(newKey.Id)
r.slugIndex[newKey] = user
if r.byUser[user] == nil {
r.byUser[user] = make(map[Key]*SSHSession)
r.byUser[user] = make(map[Key]Session)
}
r.byUser[user][newKey] = client
return nil
}
func (r *registry) Register(key Key, session *SSHSession) (success bool) {
func (r *registry) Register(key Key, session Session) (success bool) {
r.mu.Lock()
defer r.mu.Unlock()
@@ -93,9 +105,9 @@ func (r *registry) Register(key Key, session *SSHSession) (success bool) {
return false
}
userID := session.lifecycle.GetUser()
userID := session.Lifecycle().User()
if r.byUser[userID] == nil {
r.byUser[userID] = make(map[Key]*SSHSession)
r.byUser[userID] = make(map[Key]Session)
}
r.byUser[userID][key] = session
@@ -103,16 +115,16 @@ func (r *registry) Register(key Key, session *SSHSession) (success bool) {
return true
}
func (r *registry) GetAllSessionFromUser(user string) []*SSHSession {
func (r *registry) GetAllSessionFromUser(user string) []Session {
r.mu.RLock()
defer r.mu.RUnlock()
m := r.byUser[user]
if len(m) == 0 {
return []*SSHSession{}
return []Session{}
}
sessions := make([]*SSHSession, 0, len(m))
sessions := make([]Session, 0, len(m))
for _, s := range m {
sessions = append(sessions, s)
}
+364 -72
View File
@@ -1,73 +1,26 @@
package session
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"log"
"net"
"time"
"tunnel_pls/internal/config"
portUtil "tunnel_pls/internal/port"
"tunnel_pls/internal/random"
"tunnel_pls/session/forwarder"
"tunnel_pls/session/interaction"
"tunnel_pls/session/lifecycle"
"tunnel_pls/session/slug"
"tunnel_pls/types"
"golang.org/x/crypto/ssh"
)
type Session interface {
HandleGlobalRequest(ch <-chan *ssh.Request)
HandleTCPIPForward(req *ssh.Request)
HandleHTTPForward(req *ssh.Request, port uint16)
HandleTCPForward(req *ssh.Request, addr string, port uint16)
}
type SSHSession struct {
initialReq <-chan *ssh.Request
sshReqChannel <-chan ssh.NewChannel
lifecycle lifecycle.SessionLifecycle
interaction interaction.Controller
forwarder forwarder.ForwardingController
slugManager slug.Manager
registry Registry
}
func (s *SSHSession) GetLifecycle() lifecycle.SessionLifecycle {
return s.lifecycle
}
func (s *SSHSession) GetInteraction() interaction.Controller {
return s.interaction
}
func (s *SSHSession) GetForwarder() forwarder.ForwardingController {
return s.forwarder
}
func (s *SSHSession) GetSlugManager() slug.Manager {
return s.slugManager
}
func New(conn *ssh.ServerConn, forwardingReq <-chan *ssh.Request, sshChan <-chan ssh.NewChannel, sessionRegistry Registry, user string) *SSHSession {
slugManager := slug.NewManager()
forwarderManager := forwarder.NewForwarder(slugManager)
interactionManager := interaction.NewInteraction(slugManager, forwarderManager)
lifecycleManager := lifecycle.NewLifecycle(conn, forwarderManager, slugManager, user)
interactionManager.SetLifecycle(lifecycleManager)
forwarderManager.SetLifecycle(lifecycleManager)
interactionManager.SetSessionRegistry(sessionRegistry)
lifecycleManager.SetSessionRegistry(sessionRegistry)
return &SSHSession{
initialReq: forwardingReq,
sshReqChannel: sshChan,
lifecycle: lifecycleManager,
interaction: interactionManager,
forwarder: forwarderManager,
slugManager: slugManager,
registry: sessionRegistry,
}
}
type Detail struct {
ForwardingType string `json:"forwarding_type,omitempty"`
Slug string `json:"slug,omitempty"`
@@ -76,41 +29,140 @@ type Detail struct {
StartedAt time.Time `json:"started_at,omitempty"`
}
func (s *SSHSession) Detail() Detail {
return Detail{
ForwardingType: string(s.forwarder.GetTunnelType()),
Slug: s.slugManager.Get(),
UserID: s.lifecycle.GetUser(),
type Session interface {
HandleGlobalRequest(ch <-chan *ssh.Request)
HandleTCPIPForward(req *ssh.Request)
HandleHTTPForward(req *ssh.Request, port uint16)
HandleTCPForward(req *ssh.Request, addr string, port uint16)
Lifecycle() lifecycle.Lifecycle
Interaction() interaction.Interaction
Forwarder() forwarder.Forwarder
Slug() slug.Slug
Detail() *Detail
Start() error
}
type session struct {
initialReq <-chan *ssh.Request
sshChan <-chan ssh.NewChannel
lifecycle lifecycle.Lifecycle
interaction interaction.Interaction
forwarder forwarder.Forwarder
slug slug.Slug
registry Registry
}
var blockedReservedPorts = []uint16{1080, 1433, 1521, 1900, 2049, 3306, 3389, 5432, 5900, 6379, 8080, 8443, 9000, 9200, 27017}
func New(conn *ssh.ServerConn, initialReq <-chan *ssh.Request, sshChan <-chan ssh.NewChannel, sessionRegistry Registry, user string) Session {
slugManager := slug.New()
forwarderManager := forwarder.New(slugManager)
interactionManager := interaction.New(slugManager, forwarderManager)
lifecycleManager := lifecycle.New(conn, forwarderManager, slugManager, user)
interactionManager.SetLifecycle(lifecycleManager)
forwarderManager.SetLifecycle(lifecycleManager)
interactionManager.SetSessionRegistry(sessionRegistry)
lifecycleManager.SetSessionRegistry(sessionRegistry)
return &session{
initialReq: initialReq,
sshChan: sshChan,
lifecycle: lifecycleManager,
interaction: interactionManager,
forwarder: forwarderManager,
slug: slugManager,
registry: sessionRegistry,
}
}
func (s *session) Lifecycle() lifecycle.Lifecycle {
return s.lifecycle
}
func (s *session) Interaction() interaction.Interaction {
return s.interaction
}
func (s *session) Forwarder() forwarder.Forwarder {
return s.forwarder
}
func (s *session) Slug() slug.Slug {
return s.slug
}
func (s *session) Detail() *Detail {
var tunnelType string
if s.forwarder.TunnelType() == types.HTTP {
tunnelType = "HTTP"
} else if s.forwarder.TunnelType() == types.TCP {
tunnelType = "TCP"
} else {
tunnelType = "UNKNOWN"
}
return &Detail{
ForwardingType: tunnelType,
Slug: s.slug.String(),
UserID: s.lifecycle.User(),
Active: s.lifecycle.IsActive(),
StartedAt: s.lifecycle.StartedAt(),
}
}
func (s *SSHSession) Start() error {
channel := <-s.sshReqChannel
ch, reqs, err := channel.Accept()
if err != nil {
log.Printf("failed to accept channel: %v", err)
return err
func (s *session) Start() error {
var channel ssh.NewChannel
var ok bool
select {
case channel, ok = <-s.sshChan:
if !ok {
log.Println("Forwarding request channel closed")
return nil
}
ch, reqs, err := channel.Accept()
if err != nil {
log.Printf("failed to accept channel: %v", err)
return err
}
go s.HandleGlobalRequest(reqs)
s.lifecycle.SetChannel(ch)
s.interaction.SetChannel(ch)
s.interaction.SetMode(types.INTERACTIVE)
case <-time.After(500 * time.Millisecond):
s.interaction.SetMode(types.HEADLESS)
}
go s.HandleGlobalRequest(reqs)
tcpipReq := s.waitForTCPIPForward()
if tcpipReq == nil {
_, err := ch.Write([]byte(fmt.Sprintf("Port forwarding request not received. Ensure you ran the correct command with -R flag. Example: ssh %s -p %s -R 80:localhost:3000", config.Getenv("DOMAIN", "localhost"), config.Getenv("PORT", "2200"))))
err := s.interaction.Send(fmt.Sprintf("Port forwarding request not received. Ensure you ran the correct command with -R flag. Example: ssh %s -p %s -R 80:localhost:3000", config.Getenv("DOMAIN", "localhost"), config.Getenv("PORT", "2200")))
if err != nil {
return err
}
if err := s.lifecycle.Close(); err != nil {
if err = s.lifecycle.Close(); err != nil {
log.Printf("failed to close session: %v", err)
}
return fmt.Errorf("no forwarding Request")
}
s.lifecycle.SetChannel(ch)
s.interaction.SetChannel(ch)
if (s.interaction.Mode() == types.HEADLESS && config.Getenv("MODE", "standalone") == "standalone") && s.lifecycle.User() == "UNAUTHORIZED" {
if err := tcpipReq.Reply(false, nil); err != nil {
log.Printf("cannot reply to tcpip req: %s\n", err)
return err
}
if err := s.lifecycle.Close(); err != nil {
log.Printf("failed to close session: %v", err)
return err
}
return nil
}
s.HandleTCPIPForward(tcpipReq)
s.interaction.Start()
if err := s.lifecycle.Connection().Wait(); err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) {
log.Printf("ssh connection closed with error: %v", err)
}
if err := s.lifecycle.Close(); err != nil {
log.Printf("failed to close session: %v", err)
@@ -119,7 +171,7 @@ func (s *SSHSession) Start() error {
return nil
}
func (s *SSHSession) waitForTCPIPForward() *ssh.Request {
func (s *session) waitForTCPIPForward() *ssh.Request {
select {
case req, ok := <-s.initialReq:
if !ok {
@@ -139,3 +191,243 @@ func (s *SSHSession) waitForTCPIPForward() *ssh.Request {
return nil
}
}
func (s *session) HandleGlobalRequest(GlobalRequest <-chan *ssh.Request) {
for req := range GlobalRequest {
switch req.Type {
case "shell", "pty-req":
err := req.Reply(true, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
case "window-change":
p := req.Payload
if len(p) < 16 {
log.Println("invalid window-change payload")
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
return
}
cols := binary.BigEndian.Uint32(p[0:4])
rows := binary.BigEndian.Uint32(p[4:8])
s.interaction.SetWH(int(cols), int(rows))
err := req.Reply(true, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
default:
log.Println("Unknown request type:", req.Type)
err := req.Reply(false, nil)
if err != nil {
log.Println("Failed to reply to request:", err)
return
}
}
}
}
func (s *session) HandleTCPIPForward(req *ssh.Request) {
log.Println("Port forwarding request detected")
fail := func(msg string) {
log.Println(msg)
if err := req.Reply(false, nil); err != nil {
log.Println("Failed to reply to request:", err)
return
}
if err := s.lifecycle.Close(); err != nil {
log.Printf("failed to close session: %v", err)
}
}
reader := bytes.NewReader(req.Payload)
addr, err := readSSHString(reader)
if err != nil {
fail(fmt.Sprintf("Failed to read address from payload: %v", err))
return
}
var rawPortToBind uint32
if err = binary.Read(reader, binary.BigEndian, &rawPortToBind); err != nil {
fail(fmt.Sprintf("Failed to read port from payload: %v", err))
return
}
if rawPortToBind > 65535 {
fail(fmt.Sprintf("Port %d is larger than allowed port of 65535", rawPortToBind))
return
}
portToBind := uint16(rawPortToBind)
if isBlockedPort(portToBind) {
fail(fmt.Sprintf("Port %d is blocked or restricted", portToBind))
return
}
switch portToBind {
case 80, 443:
s.HandleHTTPForward(req, portToBind)
default:
s.HandleTCPForward(req, addr, portToBind)
}
}
func (s *session) HandleHTTPForward(req *ssh.Request, portToBind uint16) {
fail := func(msg string, key *types.SessionKey) {
log.Println(msg)
if key != nil {
s.registry.Remove(*key)
}
if err := req.Reply(false, nil); err != nil {
log.Println("Failed to reply to request:", err)
}
}
randomString, err := random.GenerateRandomString(20)
if err != nil {
fail(fmt.Sprintf("Failed to create slug: %s", err), nil)
return
}
key := types.SessionKey{Id: randomString, Type: types.HTTP}
if !s.registry.Register(key, s) {
fail(fmt.Sprintf("Failed to register client with slug: %s", randomString), nil)
return
}
buf := new(bytes.Buffer)
err = binary.Write(buf, binary.BigEndian, uint32(portToBind))
if err != nil {
fail(fmt.Sprintf("Failed to write port to buffer: %v", err), &key)
return
}
log.Printf("HTTP forwarding approved on port: %d", portToBind)
err = req.Reply(true, buf.Bytes())
if err != nil {
fail(fmt.Sprintf("Failed to reply to request: %v", err), &key)
return
}
s.forwarder.SetType(types.HTTP)
s.forwarder.SetForwardedPort(portToBind)
s.slug.Set(randomString)
s.lifecycle.SetStatus(types.RUNNING)
}
func (s *session) HandleTCPForward(req *ssh.Request, addr string, portToBind uint16) {
fail := func(msg string) {
log.Println(msg)
if err := req.Reply(false, nil); err != nil {
log.Println("Failed to reply to request:", err)
return
}
if err := s.lifecycle.Close(); err != nil {
log.Printf("failed to close session: %v", err)
}
}
cleanup := func(msg string, port uint16, listener net.Listener, key *types.SessionKey) {
log.Println(msg)
if key != nil {
s.registry.Remove(*key)
}
if port != 0 {
if setErr := portUtil.Default.SetPortStatus(port, false); setErr != nil {
log.Printf("Failed to reset port status: %v", setErr)
}
}
if listener != nil {
if closeErr := listener.Close(); closeErr != nil {
log.Printf("Failed to close listener: %v", closeErr)
}
}
if err := req.Reply(false, nil); err != nil {
log.Println("Failed to reply to request:", err)
}
_ = s.lifecycle.Close()
}
if portToBind == 0 {
unassigned, ok := portUtil.Default.GetUnassignedPort()
if !ok {
fail("No available port")
return
}
portToBind = unassigned
}
if claimed := portUtil.Default.ClaimPort(portToBind); !claimed {
fail(fmt.Sprintf("Port %d is already in use or restricted", portToBind))
return
}
log.Printf("Requested forwarding on %s:%d", addr, portToBind)
listener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", portToBind))
if err != nil {
cleanup(fmt.Sprintf("Port %d is already in use or restricted", portToBind), portToBind, nil, nil)
return
}
key := types.SessionKey{Id: fmt.Sprintf("%d", portToBind), Type: types.TCP}
if !s.registry.Register(key, s) {
cleanup(fmt.Sprintf("Failed to register TCP client with id: %s", key.Id), portToBind, listener, nil)
return
}
buf := new(bytes.Buffer)
err = binary.Write(buf, binary.BigEndian, uint32(portToBind))
if err != nil {
cleanup(fmt.Sprintf("Failed to write port to buffer: %v", err), portToBind, listener, &key)
return
}
log.Printf("TCP forwarding approved on port: %d", portToBind)
err = req.Reply(true, buf.Bytes())
if err != nil {
cleanup(fmt.Sprintf("Failed to reply to request: %v", err), portToBind, listener, &key)
return
}
s.forwarder.SetType(types.TCP)
s.forwarder.SetListener(listener)
s.forwarder.SetForwardedPort(portToBind)
s.slug.Set(key.Id)
s.lifecycle.SetStatus(types.RUNNING)
go s.forwarder.AcceptTCPConnections()
}
func readSSHString(reader *bytes.Reader) (string, error) {
var length uint32
if err := binary.Read(reader, binary.BigEndian, &length); err != nil {
return "", err
}
strBytes := make([]byte, length)
if _, err := reader.Read(strBytes); err != nil {
return "", err
}
return string(strBytes), nil
}
func isBlockedPort(port uint16) bool {
if port == 80 || port == 443 {
return false
}
if port < 1024 && port != 0 {
return true
}
for _, p := range blockedReservedPorts {
if p == port {
return true
}
}
return false
}
+7 -7
View File
@@ -1,24 +1,24 @@
package slug
type Manager interface {
Get() string
type Slug interface {
String() string
Set(slug string)
}
type manager struct {
type slug struct {
slug string
}
func NewManager() Manager {
return &manager{
func New() Slug {
return &slug{
slug: "",
}
}
func (s *manager) Get() string {
func (s *slug) String() string {
return s.slug
}
func (s *manager) Set(slug string) {
func (s *slug) Set(slug string) {
s.slug = slug
}
+14 -7
View File
@@ -1,18 +1,25 @@
package types
type Status string
type Status int
const (
INITIALIZING Status = "INITIALIZING"
RUNNING Status = "RUNNING"
SETUP Status = "SETUP"
INITIALIZING Status = iota
RUNNING
)
type TunnelType string
type Mode int
const (
HTTP TunnelType = "HTTP"
TCP TunnelType = "TCP"
INTERACTIVE Mode = iota
HEADLESS
)
type TunnelType int
const (
UNKNOWN TunnelType = iota
HTTP
TCP
)
type SessionKey struct {