staging #60

Merged
bagas merged 16 commits from staging into main 2026-01-09 09:33:28 +00:00
7 changed files with 349 additions and 304 deletions
Showing only changes of commit fd6ffc2500 - Show all commits

View File

@@ -3,13 +3,14 @@ package client
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
"io"
"log" "log"
"time" "time"
"tunnel_pls/session" "tunnel_pls/session"
"git.fossy.my.id/bagas/tunnel-please-grpc/gen" proto "git.fossy.my.id/bagas/tunnel-please-grpc/gen"
"github.com/golang/protobuf/ptypes/empty"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials"
@@ -26,14 +27,17 @@ type GrpcConfig struct {
Timeout time.Duration Timeout time.Duration
KeepAlive bool KeepAlive bool
MaxRetries int MaxRetries int
KeepAliveTime time.Duration
KeepAliveTimeout time.Duration
PermitWithoutStream bool
} }
type Client struct { type Client struct {
conn *grpc.ClientConn conn *grpc.ClientConn
config *GrpcConfig config *GrpcConfig
sessionRegistry session.Registry sessionRegistry session.Registry
IdentityService gen.IdentityClient slugService proto.SlugChangeClient
eventService gen.EventServiceClient eventService proto.EventServiceClient
} }
func DefaultConfig() *GrpcConfig { func DefaultConfig() *GrpcConfig {
@@ -44,12 +48,32 @@ func DefaultConfig() *GrpcConfig {
Timeout: 10 * time.Second, Timeout: 10 * time.Second,
KeepAlive: true, KeepAlive: true,
MaxRetries: 3, MaxRetries: 3,
KeepAliveTime: 2 * time.Minute,
KeepAliveTimeout: 10 * time.Second,
PermitWithoutStream: false,
} }
} }
func New(config *GrpcConfig, sessionRegistry session.Registry) (*Client, error) { func New(config *GrpcConfig, sessionRegistry session.Registry) (*Client, error) {
if config == nil { if config == nil {
config = DefaultConfig() 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
}
} }
var opts []grpc.DialOption var opts []grpc.DialOption
@@ -66,9 +90,9 @@ func New(config *GrpcConfig, sessionRegistry session.Registry) (*Client, error)
if config.KeepAlive { if config.KeepAlive {
kaParams := keepalive.ClientParameters{ kaParams := keepalive.ClientParameters{
Time: 10 * time.Second, Time: config.KeepAliveTime,
Timeout: 3 * time.Second, Timeout: config.KeepAliveTimeout,
PermitWithoutStream: false, PermitWithoutStream: config.PermitWithoutStream,
} }
opts = append(opts, grpc.WithKeepaliveParams(kaParams)) opts = append(opts, grpc.WithKeepaliveParams(kaParams))
} }
@@ -85,94 +109,120 @@ func New(config *GrpcConfig, sessionRegistry session.Registry) (*Client, error)
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", config.Address, err)
} }
identityService := gen.NewIdentityClient(conn) slugService := proto.NewSlugChangeClient(conn)
eventService := gen.NewEventServiceClient(conn) eventService := proto.NewEventServiceClient(conn)
return &Client{ return &Client{
conn: conn, conn: conn,
config: config, config: config,
IdentityService: identityService, slugService: slugService,
eventService: eventService,
sessionRegistry: sessionRegistry, sessionRegistry: sessionRegistry,
eventService: eventService,
}, nil }, nil
} }
func (c *Client) SubscribeEvents(ctx context.Context) error { func (c *Client) SubscribeEvents(ctx context.Context, identity string) error {
for { subscribe, err := c.eventService.Subscribe(ctx)
if ctx.Err() != nil { if err != nil {
log.Println("Context cancelled, stopping event subscription") return err
return ctx.Err() }
err = subscribe.Send(&proto.Client{
Type: proto.EventType_AUTHENTICATION,
Payload: &proto.Client_AuthEvent{
AuthEvent: &proto.Authentication{
Identity: identity,
AuthToken: "test_auth_key",
},
},
})
if err != nil {
log.Println("Authentication failed to send to gRPC server:", err)
return err
}
log.Println("Authentication Successfully sent to gRPC server")
err = c.processEventStream(subscribe)
if err != nil {
return err
}
return nil
} }
log.Println("Subscribing to events...") func (c *Client) processEventStream(subscribe grpc.BidiStreamingClient[proto.Client, proto.Controller]) error {
stream, err := c.eventService.Subscribe(ctx, &empty.Empty{}) for {
recv, err := subscribe.Recv()
if err != nil { if err != nil {
log.Printf("Failed to subscribe: %v. Retrying in 10 seconds...", err) if isConnectionError(err) {
select { log.Printf("connection error receiving from gRPC server: %v", err)
case <-time.After(10 * time.Second): return err
case <-ctx.Done(): }
return ctx.Err() log.Printf("non-connection receive error from gRPC server: %v", err)
continue
}
switch recv.GetType() {
case proto.EventType_SLUG_CHANGE:
oldSlug := recv.GetSlugEvent().GetOld()
newSlug := recv.GetSlugEvent().GetNew()
session, err := c.sessionRegistry.Get(oldSlug)
if err != nil {
errSend := subscribe.Send(&proto.Client{
Type: proto.EventType_SLUG_CHANGE_RESPONSE,
Payload: &proto.Client_SlugEventResponse{
SlugEventResponse: &proto.SlugChangeEventResponse{
Success: false,
Message: err.Error(),
},
},
})
if errSend != nil {
if isConnectionError(errSend) {
log.Printf("connection error sending slug change failure: %v", errSend)
return errSend
}
log.Printf("non-connection send error for slug change failure: %v", errSend)
} }
continue continue
} }
err = c.sessionRegistry.Update(oldSlug, newSlug)
if err := c.processEventStream(ctx, stream); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
log.Printf("Stream error: %v. Reconnecting in 10 seconds...", err)
select {
case <-time.After(10 * time.Second):
case <-ctx.Done():
return ctx.Err()
}
}
}
}
func (c *Client) processEventStream(ctx context.Context, stream gen.EventService_SubscribeClient) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
event, err := stream.Recv()
if err != nil { if err != nil {
st, ok := status.FromError(err) errSend := subscribe.Send(&proto.Client{
if !ok { Type: proto.EventType_SLUG_CHANGE_RESPONSE,
return fmt.Errorf("non-gRPC error: %w", err) Payload: &proto.Client_SlugEventResponse{
SlugEventResponse: &proto.SlugChangeEventResponse{
Success: false,
Message: err.Error(),
},
},
})
if errSend != nil {
if isConnectionError(errSend) {
log.Printf("connection error sending slug change failure: %v", errSend)
return errSend
} }
log.Printf("non-connection send error for slug change failure: %v", errSend)
switch st.Code() {
case codes.Unavailable, codes.Canceled, codes.DeadlineExceeded:
return fmt.Errorf("stream closed [%s]: %s", st.Code(), st.Message())
default:
return fmt.Errorf("gRPC error [%s]: %s", st.Code(), st.Message())
} }
}
if event != nil {
dataEvent := event.GetDataEvent()
if dataEvent != nil {
oldSlug := dataEvent.GetOld()
newSlug := dataEvent.GetNew()
userSession, exist := c.sessionRegistry.Get(oldSlug)
if !exist {
log.Printf("Session with slug '%s' not found, ignoring event", oldSlug)
continue continue
} }
success := c.sessionRegistry.Update(oldSlug, newSlug) session.GetInteraction().Redraw()
err = subscribe.Send(&proto.Client{
if success { Type: proto.EventType_SLUG_CHANGE_RESPONSE,
log.Printf("Successfully updated session slug from '%s' to '%s'", oldSlug, newSlug) Payload: &proto.Client_SlugEventResponse{
userSession.GetInteraction().Redraw() SlugEventResponse: &proto.SlugChangeEventResponse{
} else { Success: true,
log.Printf("Failed to update session slug from '%s' to '%s'", oldSlug, newSlug) Message: "",
},
},
})
if err != nil {
if 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
} }
default:
log.Printf("Unknown event type received: %v", recv.GetType())
} }
} }
} }
@@ -209,3 +259,18 @@ func (c *Client) CheckServerHealth(ctx context.Context) error {
func (c *Client) GetConfig() *GrpcConfig { func (c *Client) GetConfig() *GrpcConfig {
return c.config return c.config
} }
func isConnectionError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, io.EOF) {
return true
}
switch status.Code(err) {
case codes.Unavailable, codes.Canceled, codes.DeadlineExceeded:
return true
default:
return false
}
}

View File

@@ -91,13 +91,9 @@ func main() {
cancel() cancel()
ctx, cancel = context.WithCancel(context.Background()) ctx, cancel = context.WithCancel(context.Background())
//go func(err error) {
// if !errors.Is(err, ctx.Err()) {
// log.Fatalf("Event subscription error: %s", err)
// }
//}(grpcClient.SubscribeEvents(ctx))
go func() { go func() {
err := grpcClient.SubscribeEvents(ctx) identity := config.Getenv("DOMAIN", "localhost")
err = grpcClient.SubscribeEvents(ctx, identity)
if err != nil { if err != nil {
return return
} }

View File

@@ -313,8 +313,8 @@ func (hs *httpServer) handler(conn net.Conn) {
return return
} }
sshSession, exist := hs.sessionRegistry.Get(slug) sshSession, err := hs.sessionRegistry.Get(slug)
if !exist { if err != nil {
_, err = conn.Write([]byte("HTTP/1.1 301 Moved Permanently\r\n" + _, err = conn.Write([]byte("HTTP/1.1 301 Moved Permanently\r\n" +
fmt.Sprintf("Location: https://tunnl.live/tunnel-not-found?slug=%s\r\n", slug) + fmt.Sprintf("Location: https://tunnl.live/tunnel-not-found?slug=%s\r\n", slug) +
"Content-Length: 0\r\n" + "Content-Length: 0\r\n" +

View File

@@ -89,8 +89,8 @@ func (hs *httpServer) handlerTLS(conn net.Conn) {
return return
} }
sshSession, exist := hs.sessionRegistry.Get(slug) sshSession, err := hs.sessionRegistry.Get(slug)
if !exist { if err != nil {
_, err = conn.Write([]byte("HTTP/1.1 301 Moved Permanently\r\n" + _, err = conn.Write([]byte("HTTP/1.1 301 Moved Permanently\r\n" +
fmt.Sprintf("Location: https://tunnl.live/tunnel-not-found?slug=%s\r\n", slug) + fmt.Sprintf("Location: https://tunnl.live/tunnel-not-found?slug=%s\r\n", slug) +
"Content-Length: 0\r\n" + "Content-Length: 0\r\n" +

View File

@@ -1,152 +0,0 @@
package interaction
const (
backspaceChar = 8
deleteChar = 127
enterChar = 13
escapeChar = 27
ctrlC = 3
forwardSlash = '/'
minPrintableChar = 32
maxPrintableChar = 126
minSlugLength = 3
maxSlugLength = 20
clearScreen = "\033[H\033[2J"
clearLine = "\033[K"
clearToLineEnd = "\r\033[K"
backspaceSeq = "\b \b"
minBoxWidth = 50
paddingRight = 4
)
var forbiddenSlugs = map[string]struct{}{
"ping": {},
"staging": {},
"admin": {},
"root": {},
"api": {},
"www": {},
"support": {},
"help": {},
"status": {},
"health": {},
"login": {},
"logout": {},
"signup": {},
"register": {},
"settings": {},
"config": {},
"null": {},
"undefined": {},
"example": {},
"test": {},
"dev": {},
"system": {},
"administrator": {},
"dashboard": {},
"account": {},
"profile": {},
"user": {},
"users": {},
"auth": {},
"oauth": {},
"callback": {},
"webhook": {},
"webhooks": {},
"static": {},
"assets": {},
"cdn": {},
"mail": {},
"email": {},
"ftp": {},
"ssh": {},
"git": {},
"svn": {},
"blog": {},
"news": {},
"about": {},
"contact": {},
"terms": {},
"privacy": {},
"legal": {},
"billing": {},
"payment": {},
"checkout": {},
"cart": {},
"shop": {},
"store": {},
"download": {},
"uploads": {},
"images": {},
"img": {},
"css": {},
"js": {},
"fonts": {},
"public": {},
"private": {},
"internal": {},
"external": {},
"proxy": {},
"cache": {},
"debug": {},
"metrics": {},
"monitoring": {},
"graphql": {},
"rest": {},
"rpc": {},
"socket": {},
"ws": {},
"wss": {},
"app": {},
"apps": {},
"mobile": {},
"desktop": {},
"embed": {},
"widget": {},
"docs": {},
"documentation": {},
"wiki": {},
"forum": {},
"community": {},
"feedback": {},
"report": {},
"abuse": {},
"spam": {},
"security": {},
"verify": {},
"confirm": {},
"reset": {},
"password": {},
"recovery": {},
"unsubscribe": {},
"subscribe": {},
"notifications": {},
"alerts": {},
"messages": {},
"inbox": {},
"outbox": {},
"sent": {},
"draft": {},
"trash": {},
"archive": {},
"search": {},
"explore": {},
"discover": {},
"trending": {},
"popular": {},
"featured": {},
"new": {},
"latest": {},
"top": {},
"best": {},
"hot": {},
"random": {},
"all": {},
"any": {},
"none": {},
"true": {},
"false": {},
}

View File

@@ -28,7 +28,7 @@ type Lifecycle interface {
type Controller interface { type Controller interface {
SetChannel(channel ssh.Channel) SetChannel(channel ssh.Channel)
SetLifecycle(lifecycle Lifecycle) SetLifecycle(lifecycle Lifecycle)
SetSlugModificator(func(oldSlug, newSlug string) bool) SetSlugModificator(func(oldSlug, newSlug string) error)
Start() Start()
SetWH(w, h int) SetWH(w, h int)
Redraw() Redraw()
@@ -45,7 +45,7 @@ type Interaction struct {
slugManager slug.Manager slugManager slug.Manager
forwarder Forwarder forwarder Forwarder
lifecycle Lifecycle lifecycle Lifecycle
updateClientSlug func(oldSlug, newSlug string) bool updateClientSlug func(oldSlug, newSlug string) error
program *tea.Program program *tea.Program
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
@@ -121,7 +121,7 @@ func (i *Interaction) SetChannel(channel ssh.Channel) {
i.channel = channel i.channel = channel
} }
func (i *Interaction) SetSlugModificator(modificator func(oldSlug, newSlug string) (success bool)) { func (i *Interaction) SetSlugModificator(modificator func(oldSlug, newSlug string) error) {
i.updateClientSlug = modificator i.updateClientSlug = modificator
} }
@@ -218,20 +218,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(tea.ClearScreen, textinput.Blink) return m, tea.Batch(tea.ClearScreen, textinput.Blink)
case "enter": case "enter":
inputValue := m.slugInput.Value() inputValue := m.slugInput.Value()
if err := m.interaction.updateClientSlug(m.interaction.slugManager.Get(), inputValue); err != nil {
if isForbiddenSlug(inputValue) { m.slugError = err.Error()
m.slugError = "This subdomain is reserved. Please choose a different one."
return m, nil
} else if !isValidSlug(inputValue) {
m.slugError = "Invalid subdomain. Follow the rules."
return m, nil return m, nil
} }
if !m.interaction.updateClientSlug(m.interaction.slugManager.Get(), inputValue) {
m.slugError = "Someone already uses this subdomain."
return m, nil
}
m.editingSlug = false m.editingSlug = false
m.slugError = "" m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink) return m, tea.Batch(tea.ClearScreen, textinput.Blink)
@@ -823,30 +813,3 @@ func buildURL(protocol, subdomain, domain string) string {
func generateRandomSubdomain() string { func generateRandomSubdomain() string {
return random.GenerateRandomString(20) return random.GenerateRandomString(20)
} }
func isValidSlug(slug string) bool {
if len(slug) < minSlugLength || len(slug) > maxSlugLength {
return false
}
if slug[0] == '-' || slug[len(slug)-1] == '-' {
return false
}
for _, c := range slug {
if !isValidSlugChar(byte(c)) {
return false
}
}
return true
}
func isValidSlugChar(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'
}
func isForbiddenSlug(slug string) bool {
_, ok := forbiddenSlugs[slug]
return ok
}

View File

@@ -1,10 +1,13 @@
package session package session
import "sync" import (
"fmt"
"sync"
)
type Registry interface { type Registry interface {
Get(slug string) (session *SSHSession, exist bool) Get(slug string) (session *SSHSession, err error)
Update(oldSlug, newSlug string) (success bool) Update(oldSlug, newSlug string) error
Register(slug string, session *SSHSession) (success bool) Register(slug string, session *SSHSession) (success bool)
Remove(slug string) Remove(slug string)
} }
@@ -19,31 +22,40 @@ func NewRegistry() Registry {
} }
} }
func (r *registry) Get(slug string) (session *SSHSession, exist bool) { func (r *registry) Get(slug string) (session *SSHSession, err error) {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
session, exist = r.clients[slug] client, ok := r.clients[slug]
return if !ok {
return nil, fmt.Errorf("session not found")
}
return client, nil
}
func (r *registry) Update(oldSlug, newSlug string) error {
if isForbiddenSlug(newSlug) {
return fmt.Errorf("this subdomain is reserved. Please choose a different one")
} else if !isValidSlug(newSlug) {
return fmt.Errorf("invalid subdomain. Follow the rules")
} }
func (r *registry) Update(oldSlug, newSlug string) (success bool) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
if _, exists := r.clients[newSlug]; exists && newSlug != oldSlug { if _, exists := r.clients[newSlug]; exists && newSlug != oldSlug {
return false return fmt.Errorf("someone already uses this subdomain")
} }
client, ok := r.clients[oldSlug] client, ok := r.clients[oldSlug]
if !ok { if !ok {
return false return fmt.Errorf("session not found")
} }
delete(r.clients, oldSlug) delete(r.clients, oldSlug)
client.slugManager.Set(newSlug) client.slugManager.Set(newSlug)
r.clients[newSlug] = client r.clients[newSlug] = client
return true return nil
} }
func (r *registry) Register(slug string, session *SSHSession) (success bool) { func (r *registry) Register(slug string, session *SSHSession) (success bool) {
@@ -64,3 +76,164 @@ func (r *registry) Remove(slug string) {
delete(r.clients, slug) delete(r.clients, slug)
} }
func isValidSlug(slug string) bool {
if len(slug) < minSlugLength || len(slug) > maxSlugLength {
return false
}
if slug[0] == '-' || slug[len(slug)-1] == '-' {
return false
}
for _, c := range slug {
if !isValidSlugChar(byte(c)) {
return false
}
}
return true
}
func isValidSlugChar(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'
}
func isForbiddenSlug(slug string) bool {
_, ok := forbiddenSlugs[slug]
return ok
}
var forbiddenSlugs = map[string]struct{}{
"ping": {},
"staging": {},
"admin": {},
"root": {},
"api": {},
"www": {},
"support": {},
"help": {},
"status": {},
"health": {},
"login": {},
"logout": {},
"signup": {},
"register": {},
"settings": {},
"config": {},
"null": {},
"undefined": {},
"example": {},
"test": {},
"dev": {},
"system": {},
"administrator": {},
"dashboard": {},
"account": {},
"profile": {},
"user": {},
"users": {},
"auth": {},
"oauth": {},
"callback": {},
"webhook": {},
"webhooks": {},
"static": {},
"assets": {},
"cdn": {},
"mail": {},
"email": {},
"ftp": {},
"ssh": {},
"git": {},
"svn": {},
"blog": {},
"news": {},
"about": {},
"contact": {},
"terms": {},
"privacy": {},
"legal": {},
"billing": {},
"payment": {},
"checkout": {},
"cart": {},
"shop": {},
"store": {},
"download": {},
"uploads": {},
"images": {},
"img": {},
"css": {},
"js": {},
"fonts": {},
"public": {},
"private": {},
"internal": {},
"external": {},
"proxy": {},
"cache": {},
"debug": {},
"metrics": {},
"monitoring": {},
"graphql": {},
"rest": {},
"rpc": {},
"socket": {},
"ws": {},
"wss": {},
"app": {},
"apps": {},
"mobile": {},
"desktop": {},
"embed": {},
"widget": {},
"docs": {},
"documentation": {},
"wiki": {},
"forum": {},
"community": {},
"feedback": {},
"report": {},
"abuse": {},
"spam": {},
"security": {},
"verify": {},
"confirm": {},
"reset": {},
"password": {},
"recovery": {},
"unsubscribe": {},
"subscribe": {},
"notifications": {},
"alerts": {},
"messages": {},
"inbox": {},
"outbox": {},
"sent": {},
"draft": {},
"trash": {},
"archive": {},
"search": {},
"explore": {},
"discover": {},
"trending": {},
"popular": {},
"featured": {},
"new": {},
"latest": {},
"top": {},
"best": {},
"hot": {},
"random": {},
"all": {},
"any": {},
"none": {},
"true": {},
"false": {},
}
var (
minSlugLength = 3
maxSlugLength = 20
)