28 Commits

Author SHA1 Message Date
e1f5d73e03 feat: add headless mode support for SSH -N connections
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 3m3s
- 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-11 15:21:11 +07:00
19fd6d59d2 Merge pull request 'main' (#62) from main into staging
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Successful in 3m32s
Reviewed-on: #62
2026-01-09 12:15:30 +00:00
e3988b339f Merge pull request 'fix(deps): update module github.com/caddyserver/certmagic to v0.25.1' (#61) from renovate/github.com-caddyserver-certmagic-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 3m21s
Reviewed-on: #61
2026-01-09 12:15:05 +00:00
336948a397 fix(deps): update module github.com/caddyserver/certmagic to v0.25.1 2026-01-09 10:00:35 +00:00
50ae422de8 Merge pull request 'staging' (#60) from staging 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 3m20s
Reviewed-on: #60
2026-01-09 09:33:28 +00:00
8467ed555e revert 01ddc76f7e
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Has been cancelled
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-09 09:33:04 +00:00
01ddc76f7e 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
Docker Build and Push / build-and-push-branches (push) Waiting to run
Docker Build and Push / build-and-push-tags (push) Has been skipped
2026-01-09 09:30:23 +00:00
ffb3565ff5 fix(deps): update module github.com/caddyserver/certmagic to v0.25.1 2026-01-09 09:30:18 +00:00
6d700ef6dd Merge pull request 'feat/grpc-integration' (#59) from feat/grpc-integration into staging
Docker Build and Push / build-and-push-branches (push) Successful in 5m25s
Docker Build and Push / build-and-push-tags (push) Has been skipped
Reviewed-on: #59
2026-01-09 09:24:20 +00:00
b8acb6da4c ci: remove renovate
Docker Build and Push / build-and-push-tags (push) Has been skipped
Docker Build and Push / build-and-push-branches (push) Has been cancelled
2026-01-08 13:03:02 +07:00
6b4127f0ef feat: add authenticated user info and restructure handleConnection
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 3m21s
- Display authenticated username in welcome page information box
- Refactor handleConnection function for better structure and clarity
2026-01-07 23:07:02 +07:00
16d48ff906 refactor(grpc/client): simplify processEventStream with per-event handlers
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 3m20s
- 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-06 20:14:56 +07:00
6213ff8a30 feat: implement forwarder session termination
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 3m36s
2026-01-06 18:32:48 +07:00
4ffaec9d9a refactor: inject SessionRegistry interface instead of individual functions
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 4m16s
2026-01-05 16:49:17 +07:00
6de0a618ee update: proto file to v1.3.0
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 4m0s
2026-01-05 00:55:51 +07:00
8cc70fa45e feat(session): use session key for registry 2026-01-05 00:50:42 +07:00
d666ae5545 fix: use correct environment variable key
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 4m1s
2026-01-04 18:21:34 +07:00
5edb3c8086 fix: startup order
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 3m51s
2026-01-04 15:19:03 +07:00
5b603d8317 feat: implement sessions request from grpc server
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 4m7s
2026-01-03 21:17:01 +07:00
5ceade81db Merge pull request 'staging' (#57) from staging 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 3m57s
renovate / renovate (push) Failing after 34s
Reviewed-on: #57
2026-01-03 13:07:49 +00:00
8fd9f8b567 feat: implement sessions request from grpc server
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Has been cancelled
2026-01-03 20:06:14 +07:00
30e84ac3b7 feat: implement get sessions by user 2026-01-02 22:58:54 +07:00
fd6ffc2500 feat(grpc): integrate slug edit handling 2026-01-02 18:27:48 +07:00
e1cd4ed981 WIP: gRPC integration, initial implementation 2026-01-01 21:03:17 +07:00
96d2b88f95 WIP: gRPC integration, initial implementation 2026-01-01 21:01:15 +07:00
8a456d2cde Merge pull request 'staging' (#55) from staging 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 5m50s
renovate / renovate (push) Successful in 35s
Reviewed-on: #55
2025-12-31 08:51:25 +00:00
8841230653 Merge pull request 'fix: prevent subdomain change to already-in-use subdomains' (#54) from staging into main
Docker Build and Push / build-and-push (push) Successful in 5m20s
renovate / renovate (push) Successful in 38s
Reviewed-on: #54
2025-12-30 12:42:05 +00:00
4d0a7deaf2 Merge pull request 'staging' (#53) from staging into main
Docker Build and Push / build-and-push (push) Successful in 3m33s
renovate / renovate (push) Successful in 22s
Reviewed-on: #53
2025-12-29 17:18:25 +00:00
11 changed files with 361 additions and 330 deletions
+2 -2
View File
@@ -263,7 +263,7 @@ func (c *Client) handleSlugChange(subscribe grpc.BidiStreamingClient[proto.Node,
}, "slug change failure response")
}
userSession.Interaction().Redraw()
userSession.GetInteraction().Redraw()
return c.sendNode(subscribe, &proto.Node{
Type: proto.EventType_SLUG_CHANGE_RESPONSE,
Payload: &proto.Node_SlugEventResponse{
@@ -321,7 +321,7 @@ func (c *Client) handleTerminateSession(subscribe grpc.BidiStreamingClient[proto
}, "terminate session fetch failed")
}
if err = userSession.Lifecycle().Close(); err != nil {
if err = userSession.GetLifecycle().Close(); err != nil {
return c.sendNode(subscribe, &proto.Node{
Type: proto.EventType_TERMINATE_SESSION,
Payload: &proto.Node_TerminateSessionEventResponse{
+6 -16
View File
@@ -13,7 +13,7 @@ type Manager interface {
AddPortRange(startPort, endPort uint16) error
GetUnassignedPort() (uint16, bool)
SetPortStatus(port uint16, assigned bool) error
ClaimPort(port uint16) (claimed bool)
GetPortStatus(port uint16) (bool, bool)
}
type manager struct {
@@ -74,6 +74,7 @@ func (pm *manager) GetUnassignedPort() (uint16, bool) {
for _, port := range pm.sortedPorts {
if !pm.ports[port] {
pm.ports[port] = true
return port, true
}
}
@@ -88,21 +89,10 @@ func (pm *manager) SetPortStatus(port uint16, assigned bool) error {
return nil
}
func (pm *manager) ClaimPort(port uint16) (claimed bool) {
pm.mu.Lock()
defer pm.mu.Unlock()
func (pm *manager) GetPortStatus(port uint16) (bool, bool) {
pm.mu.RLock()
defer pm.mu.RUnlock()
status, exists := pm.ports[port]
if exists && status {
return false
}
if !exists {
pm.ports[port] = true
return true
}
pm.ports[port] = true
return true
return status, exists
}
+6 -6
View File
@@ -335,8 +335,8 @@ func (hs *httpServer) handler(conn net.Conn) {
return
}
func forwardRequest(cw HTTPWriter, initialRequest RequestHeaderManager, sshSession session.Session) {
payload := sshSession.Forwarder().CreateForwardedTCPIPPayload(cw.GetRemoteAddr())
func forwardRequest(cw HTTPWriter, initialRequest RequestHeaderManager, sshSession *session.SSHSession) {
payload := sshSession.GetForwarder().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.Lifecycle().Connection().OpenChannel("forwarded-tcpip", payload)
channel, reqs, err := sshSession.GetLifecycle().GetConnection().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.Forwarder().WriteBadGatewayResponse(cw.GetWriter())
sshSession.GetForwarder().WriteBadGatewayResponse(cw.GetWriter())
return
}
channel = result.channel
reqs = result.reqs
case <-time.After(5 * time.Second):
log.Printf("Timeout opening forwarded-tcpip channel")
sshSession.Forwarder().WriteBadGatewayResponse(cw.GetWriter())
sshSession.GetForwarder().WriteBadGatewayResponse(cw.GetWriter())
return
}
@@ -390,6 +390,6 @@ func forwardRequest(cw HTTPWriter, initialRequest RequestHeaderManager, sshSessi
return
}
sshSession.Forwarder().HandleConnection(cw, channel, cw.GetRemoteAddr())
sshSession.GetForwarder().HandleConnection(cw, channel, cw.GetRemoteAddr())
return
}
+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
slug slug.Slug
slugManager slug.Manager
lifecycle Lifecycle
}
func New(slug slug.Slug) Forwarder {
return &forwarder{
func NewForwarder(slugManager slug.Manager) *Forwarder {
return &Forwarder{
listener: nil,
tunnelType: types.UNKNOWN,
tunnelType: "",
forwardedPort: 0,
slug: slug,
slugManager: slugManager,
lifecycle: nil,
}
}
type Lifecycle interface {
Connection() ssh.Conn
GetConnection() ssh.Conn
}
type Forwarder interface {
type ForwardingController interface {
AcceptTCPConnections()
SetType(tunnelType types.TunnelType)
SetLifecycle(lifecycle Lifecycle)
GetTunnelType() types.TunnelType
GetForwardedPort() uint16
SetForwardedPort(port uint16)
SetListener(listener net.Listener)
Listener() net.Listener
TunnelType() types.TunnelType
ForwardedPort() uint16
GetListener() net.Listener
Close() error
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.Listener().Accept()
conn, err := f.GetListener().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.Connection().OpenChannel("forwarded-tcpip", payload)
channel, reqs, err := f.lifecycle.GetConnection().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) TunnelType() types.TunnelType {
func (f *Forwarder) GetTunnelType() types.TunnelType {
return f.tunnelType
}
func (f *forwarder) ForwardedPort() uint16 {
func (f *Forwarder) GetForwardedPort() 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) Listener() net.Listener {
func (f *Forwarder) GetListener() 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.Listener() != nil {
func (f *Forwarder) Close() error {
if f.GetListener() != 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.ForwardedPort()))
err := binary.Write(&buf, binary.BigEndian, uint32(f.GetForwardedPort()))
if err != nil {
log.Printf("Failed to write string to buffer: %v", err)
return nil
+147 -89
View File
@@ -15,7 +15,7 @@ import (
var blockedReservedPorts = []uint16{1080, 1433, 1521, 1900, 2049, 3306, 3389, 5432, 5900, 6379, 8080, 8443, 9000, 9200, 27017}
func (s *session) HandleGlobalRequest(GlobalRequest <-chan *ssh.Request) {
func (s *SSHSession) HandleGlobalRequest(GlobalRequest <-chan *ssh.Request) {
for req := range GlobalRequest {
switch req.Type {
case "shell", "pty-req":
@@ -56,170 +56,228 @@ func (s *session) HandleGlobalRequest(GlobalRequest <-chan *ssh.Request) {
}
}
func (s *session) HandleTCPIPForward(req *ssh.Request) {
func (s *SSHSession) 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))
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 {
fail(fmt.Sprintf("Failed to read port from payload: %v", err))
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 {
fail(fmt.Sprintf("Port %d is larger than allowed port of 65535", rawPortToBind))
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) {
fail(fmt.Sprintf("Port %d is blocked or restricted", 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
}
switch portToBind {
case 80, 443:
if portToBind == 80 || portToBind == 443 {
s.HandleHTTPForward(req, portToBind)
default:
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 *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)
}
}
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) {
fail(fmt.Sprintf("Failed to register client with slug: %s", slug), nil)
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 {
fail(fmt.Sprintf("Failed to write port to buffer: %v", err), &key)
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 {
fail(fmt.Sprintf("Failed to reply to request: %v", err), &key)
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.slug.Set(slug)
s.slugManager.Set(slug)
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
}
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 {
cleanup(fmt.Sprintf("Port %d is already in use or restricted", portToBind), portToBind, nil, 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) {
cleanup(fmt.Sprintf("Failed to register TCP client with id: %s", key.Id), portToBind, listener, nil)
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 {
cleanup(fmt.Sprintf("Failed to write port to buffer: %v", err), portToBind, listener, &key)
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 {
cleanup(fmt.Sprintf("Failed to reply to request: %v", err), portToBind, listener, &key)
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.slug.Set(key.Id)
s.slugManager.Set(key.Id)
s.lifecycle.SetStatus(types.RUNNING)
go s.forwarder.AcceptTCPConnections()
}
+31 -31
View File
@@ -23,34 +23,34 @@ import (
type Lifecycle interface {
Close() error
User() string
GetUser() string
}
type SessionRegistry interface {
Update(user string, oldKey, newKey types.SessionKey) error
}
type Interaction interface {
Mode() types.Mode
type Controller interface {
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()
GetMode() types.Mode
Send(message string) error
}
type Forwarder interface {
Close() error
TunnelType() types.TunnelType
ForwardedPort() uint16
GetTunnelType() types.TunnelType
GetForwardedPort() uint16
}
type interaction struct {
type Interaction struct {
channel ssh.Channel
slug slug.Slug
slugManager slug.Manager
forwarder Forwarder
lifecycle Lifecycle
sessionRegistry SessionRegistry
@@ -60,22 +60,22 @@ type interaction struct {
mode types.Mode
}
func (i *interaction) SetMode(m types.Mode) {
func (i *Interaction) SetMode(m types.Mode) {
i.mode = m
}
func (i *interaction) Mode() types.Mode {
func (i *Interaction) GetMode() types.Mode {
return i.mode
}
func (i *interaction) Send(message string) error {
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) {
func (i *Interaction) SetWH(w, h int) {
if i.program != nil {
i.program.Send(tea.WindowSizeMsg{
Width: w,
@@ -103,14 +103,14 @@ type model struct {
commandList list.Model
slugInput textinput.Model
slugError string
interaction *interaction
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 buildURL(m.protocol, m.interaction.slugManager.Get(), m.domain)
}
return fmt.Sprintf("tcp://%s:%d", m.domain, m.port)
}
@@ -123,11 +123,11 @@ type keymap struct {
type tickMsg time.Time
func New(slug slug.Slug, forwarder Forwarder) Interaction {
func NewInteraction(slugManager slug.Manager, forwarder Forwarder) *Interaction {
ctx, cancel := context.WithCancel(context.Background())
return &interaction{
return &Interaction{
channel: nil,
slug: slug,
slugManager: slugManager,
forwarder: forwarder,
lifecycle: nil,
sessionRegistry: nil,
@@ -137,19 +137,19 @@ func New(slug slug.Slug, 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()
}
@@ -242,8 +242,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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(),
if err := m.interaction.sessionRegistry.Update(m.interaction.lifecycle.GetUser(), types.SessionKey{
Id: m.interaction.slugManager.Get(),
Type: types.HTTP,
}, types.SessionKey{
Id: inputValue,
@@ -285,7 +285,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if item.name == "slug" {
m.showingCommands = false
m.editingSlug = true
m.slugInput.SetValue(m.interaction.slug.String())
m.slugInput.SetValue(m.interaction.slugManager.Get())
m.slugInput.Focus()
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
} else if item.name == "tunnel-type" {
@@ -317,7 +317,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
func (i *interaction) Redraw() {
func (i *Interaction) Redraw() {
if i.program != nil {
i.program.Send(tea.ClearScreen())
}
@@ -691,7 +691,7 @@ func (m *model) View() string {
MarginBottom(boxMargin).
Width(boxMaxWidth)
authenticatedUser := m.interaction.lifecycle.User()
authenticatedUser := m.interaction.lifecycle.GetUser()
userInfoStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
@@ -767,7 +767,7 @@ func (m *model) View() string {
return b.String()
}
func (i *interaction) Start() {
func (i *Interaction) Start() {
if i.mode == types.HEADLESS {
return
}
@@ -779,8 +779,8 @@ func (i *interaction) Start() {
protocol = "https"
}
tunnelType := i.forwarder.TunnelType()
port := i.forwarder.ForwardedPort()
tunnelType := i.forwarder.GetTunnelType()
port := i.forwarder.GetForwardedPort()
items := []list.Item{
commandItem{name: "slug", desc: "Set custom subdomain"},
+40 -44
View File
@@ -15,119 +15,115 @@ import (
type Forwarder interface {
Close() error
TunnelType() types.TunnelType
ForwardedPort() uint16
GetTunnelType() types.TunnelType
GetForwardedPort() 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
slug slug.Slug
slugManager slug.Manager
startedAt time.Time
user string
}
func New(conn ssh.Conn, forwarder Forwarder, slugManager slug.Slug, user string) Lifecycle {
return &lifecycle{
func NewLifecycle(conn ssh.Conn, forwarder Forwarder, slugManager slug.Manager, user string) *Lifecycle {
return &Lifecycle{
status: types.INITIALIZING,
conn: conn,
channel: nil,
forwarder: forwarder,
slug: slugManager,
slugManager: slugManager,
sessionRegistry: nil,
startedAt: time.Now(),
user: user,
}
}
func (l *lifecycle) SetSessionRegistry(registry SessionRegistry) {
func (l *Lifecycle) SetSessionRegistry(registry SessionRegistry) {
l.sessionRegistry = registry
}
type Lifecycle interface {
Connection() ssh.Conn
Channel() ssh.Channel
User() string
type SessionLifecycle interface {
Close() error
SetStatus(status types.Status)
GetConnection() ssh.Conn
GetChannel() ssh.Channel
GetUser() string
SetChannel(channel ssh.Channel)
SetSessionRegistry(registry SessionRegistry)
SetStatus(status types.Status)
IsActive() bool
StartedAt() time.Time
Close() error
}
func (l *lifecycle) User() string {
func (l *Lifecycle) GetUser() string {
return l.user
}
func (l *lifecycle) Channel() ssh.Channel {
func (l *Lifecycle) GetChannel() 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) Connection() ssh.Conn {
func (l *Lifecycle) GetConnection() 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 {
var firstErr error
tunnelType := l.forwarder.TunnelType()
if err := l.forwarder.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
firstErr = err
func (l *Lifecycle) Close() error {
err := l.forwarder.Close()
if err != nil && !errors.Is(err, net.ErrClosed) {
return err
}
if l.channel != nil {
if err := l.channel.Close(); err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) {
if firstErr == nil {
firstErr = err
}
err := l.channel.Close()
if err != nil && !errors.Is(err, io.EOF) {
return err
}
}
if l.conn != nil {
if err := l.conn.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
if firstErr == nil {
firstErr = err
}
err := l.conn.Close()
if err != nil && !errors.Is(err, net.ErrClosed) {
return err
}
}
clientSlug := l.slug.String()
key := types.SessionKey{
Id: clientSlug,
Type: tunnelType,
}
clientSlug := l.slugManager.Get()
if clientSlug != "" && l.sessionRegistry.Remove != nil {
key := types.SessionKey{Id: clientSlug, Type: l.forwarder.GetTunnelType()}
l.sessionRegistry.Remove(key)
}
if tunnelType == types.TCP {
if err := portUtil.Default.SetPortStatus(l.forwarder.ForwardedPort(), false); err != nil && firstErr == nil {
firstErr = err
if l.forwarder.GetTunnelType() == types.TCP {
err = portUtil.Default.SetPortStatus(l.forwarder.GetForwardedPort(), false)
if err != nil {
return err
}
}
return firstErr
return nil
}
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
}
+16 -16
View File
@@ -9,27 +9,27 @@ import (
type Key = types.SessionKey
type Registry interface {
Get(key Key) (session Session, err error)
GetWithUser(user string, key Key) (session Session, err error)
Get(key Key) (session *SSHSession, err error)
GetWithUser(user string, key Key) (session *SSHSession, err error)
Update(user string, oldKey, newKey Key) error
Register(key Key, session Session) (success bool)
Register(key Key, session *SSHSession) (success bool)
Remove(key Key)
GetAllSessionFromUser(user string) []Session
GetAllSessionFromUser(user string) []*SSHSession
}
type registry struct {
mu sync.RWMutex
byUser map[string]map[Key]Session
byUser map[string]map[Key]*SSHSession
slugIndex map[Key]string
}
func NewRegistry() Registry {
return &registry{
byUser: make(map[string]map[Key]Session),
byUser: make(map[string]map[Key]*SSHSession),
slugIndex: make(map[Key]string),
}
}
func (r *registry) Get(key Key) (session Session, err error) {
func (r *registry) Get(key Key) (session *SSHSession, err error) {
r.mu.RLock()
defer r.mu.RUnlock()
@@ -45,7 +45,7 @@ func (r *registry) Get(key Key) (session Session, err error) {
return client, nil
}
func (r *registry) GetWithUser(user string, key Key) (session Session, err error) {
func (r *registry) GetWithUser(user string, key Key) (session *SSHSession, err error) {
r.mu.RLock()
defer r.mu.RUnlock()
@@ -87,17 +87,17 @@ func (r *registry) Update(user string, oldKey, newKey Key) error {
delete(r.byUser[user], oldKey)
delete(r.slugIndex, oldKey)
client.Slug().Set(newKey.Id)
client.slugManager.Set(newKey.Id)
r.slugIndex[newKey] = user
if r.byUser[user] == nil {
r.byUser[user] = make(map[Key]Session)
r.byUser[user] = make(map[Key]*SSHSession)
}
r.byUser[user][newKey] = client
return nil
}
func (r *registry) Register(key Key, session Session) (success bool) {
func (r *registry) Register(key Key, session *SSHSession) (success bool) {
r.mu.Lock()
defer r.mu.Unlock()
@@ -105,9 +105,9 @@ func (r *registry) Register(key Key, session Session) (success bool) {
return false
}
userID := session.Lifecycle().User()
userID := session.lifecycle.GetUser()
if r.byUser[userID] == nil {
r.byUser[userID] = make(map[Key]Session)
r.byUser[userID] = make(map[Key]*SSHSession)
}
r.byUser[userID][key] = session
@@ -115,16 +115,16 @@ func (r *registry) Register(key Key, session Session) (success bool) {
return true
}
func (r *registry) GetAllSessionFromUser(user string) []Session {
func (r *registry) GetAllSessionFromUser(user string) []*SSHSession {
r.mu.RLock()
defer r.mu.RUnlock()
m := r.byUser[user]
if len(m) == 0 {
return []Session{}
return []*SSHSession{}
}
sessions := make([]Session, 0, len(m))
sessions := make([]*SSHSession, 0, len(m))
for _, s := range m {
sessions = append(sessions, s)
}
+65 -79
View File
@@ -14,6 +14,61 @@ import (
"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"`
@@ -22,90 +77,21 @@ type Detail struct {
StartedAt time.Time `json:"started_at,omitempty"`
}
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
}
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(),
func (s *SSHSession) Detail() Detail {
return Detail{
ForwardingType: string(s.forwarder.GetTunnelType()),
Slug: s.slugManager.Get(),
UserID: s.lifecycle.GetUser(),
Active: s.lifecycle.IsActive(),
StartedAt: s.lifecycle.StartedAt(),
}
}
func (s *session) Start() error {
func (s *SSHSession) Start() error {
var channel ssh.NewChannel
var ok bool
select {
case channel, ok = <-s.sshChan:
case channel, ok = <-s.sshReqChannel:
if !ok {
log.Println("Forwarding request channel closed")
return nil
@@ -136,7 +122,7 @@ func (s *session) Start() error {
return fmt.Errorf("no forwarding Request")
}
if (s.interaction.Mode() == types.HEADLESS && config.Getenv("MODE", "standalone") == "standalone") && s.lifecycle.User() == "UNAUTHORIZED" {
if (s.interaction.GetMode() == types.HEADLESS && config.Getenv("MODE", "standalone") == "standalone") || s.lifecycle.GetUser() == "UNAUTHORIZED" {
if err := tcpipReq.Reply(false, nil); err != nil {
log.Printf("cannot reply to tcpip req: %s\n", err)
return err
@@ -151,7 +137,7 @@ func (s *session) Start() error {
s.HandleTCPIPForward(tcpipReq)
s.interaction.Start()
s.lifecycle.Connection().Wait()
s.lifecycle.GetConnection().Wait()
if err := s.lifecycle.Close(); err != nil {
log.Printf("failed to close session: %v", err)
return err
@@ -159,7 +145,7 @@ func (s *session) Start() error {
return nil
}
func (s *session) waitForTCPIPForward() *ssh.Request {
func (s *SSHSession) waitForTCPIPForward() *ssh.Request {
select {
case req, ok := <-s.initialReq:
if !ok {
+7 -7
View File
@@ -1,24 +1,24 @@
package slug
type Slug interface {
String() string
type Manager interface {
Get() string
Set(slug string)
}
type slug struct {
type manager struct {
slug string
}
func New() Slug {
return &slug{
func NewManager() Manager {
return &manager{
slug: "",
}
}
func (s *slug) String() string {
func (s *manager) Get() string {
return s.slug
}
func (s *slug) Set(slug string) {
func (s *manager) Set(slug string) {
s.slug = slug
}
+11 -10
View File
@@ -1,25 +1,26 @@
package types
type Status int
type Status string
const (
INITIALIZING Status = iota
RUNNING
INITIALIZING Status = "INITIALIZING"
RUNNING Status = "RUNNING"
SETUP Status = "SETUP"
)
type Mode int
type Mode string
const (
INTERACTIVE Mode = iota
HEADLESS
INTERACTIVE Mode = "INTERACTIVE"
HEADLESS Mode = "HEADLESS"
)
type TunnelType int
type TunnelType string
const (
UNKNOWN TunnelType = iota
HTTP
TCP
UNKNOWN TunnelType = "UNKNOWN"
HTTP TunnelType = "HTTP"
TCP TunnelType = "TCP"
)
type SessionKey struct {