Ctrl K
ComunidadGuías…

sshmuxer/sshmuxer.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Package sshmuxer handles the underlying SSH server
// and multiplexing forwarding sessions.
package sshmuxer

import (
	"log/slog"
	"net"
	"os"
	"os/signal"
	"runtime"
	"strconv"
	"sync"
	"time"

	"github.com/antoniomika/sish/httpmuxer"
	"github.com/antoniomika/sish/utils"
	"github.com/antoniomika/syncmap"
	"github.com/pires/go-proxyproto"
	"github.com/spf13/viper"
	"golang.org/x/crypto/ssh"
)

// Start initializes the ssh muxer service. It will start necessary components
// and begin listening for SSH connections.
func Start() {
	var (
		httpPort  int
		httpsPort int
		sshPort   int
	)

	_, httpPortString, err := utils.ParseAddress(viper.GetString("http-address"))
	if err != nil {
		utils.Fatal(
			"unable to parse address",
			"address", viper.GetString("http-address"),
			"err", err,
		)
	}

	_, httpsPortString, err := utils.ParseAddress(viper.GetString("https-address"))
	if err != nil {
		utils.Fatal(
			"unable to parse address",
			"address", viper.GetString("https-address"),
			"err", err,
		)
	}

	_, sshPortString, err := utils.ParseAddress(viper.GetString("ssh-address"))
	if err != nil {
		utils.Fatal(
			"unable to parse address",
			"address", viper.GetString("ssh-address"),
			"err", err,
		)
	}

	httpPort, err = strconv.Atoi(httpPortString)
	if err != nil {
		utils.Fatal(
			"unable to parse port",
			"port", httpPortString,
			"err", err,
		)
	}

	httpsPort, err = strconv.Atoi(httpsPortString)
	if err != nil {
		utils.Fatal(
			"unable to parse port",
			"port", httpsPortString,
			"err", err,
		)
	}

	sshPort, err = strconv.Atoi(sshPortString)
	if err != nil {
		utils.Fatal(
			"unable to parse port",
			"port", sshPortString,
			"err", err,
		)
	}

	if viper.GetInt("http-port-override") != 0 {
		httpPort = viper.GetInt("http-port-override")
	}

	if viper.GetInt("https-port-override") != 0 {
		httpsPort = viper.GetInt("https-port-override")
	}

	utils.WatchKeys()

	state := utils.NewState()
	state.Ports.HTTPPort = httpPort
	state.Ports.HTTPSPort = httpsPort
	state.Ports.SSHPort = sshPort

	state.Console.State = state

	go httpmuxer.Start(state)

	debugInterval := viper.GetDuration("debug-interval")

	if viper.GetBool("debug") && debugInterval > 0 {
		go func() {
			for {
				remoteAddrs := func(conns *syncmap.Map[string, *utils.SSHConnection]) []string {
					addrs := []string{}
					conns.Range(func(_ string, conn *utils.SSHConnection) bool {
						addrs = append(addrs, conn.SSHConn.RemoteAddr().String())
						return true
					})
					return addrs
				}

				listeners := []string{}
				state.Listeners.Range(func(key string, _ net.Listener) bool {
					listeners = append(listeners, key)
					return true
				})

				clients := map[string]any{}
				state.SSHConnections.Range(func(key string, value *utils.SSHConnection) bool {
					names := []string{}
					value.Listeners.Range(func(name string, _ net.Listener) bool {
						names = append(names, name)
						return true
					})

					clients[key] = map[string]any{"user": value.User(), "listeners": names}
					return true
				})

				httpListeners := map[string][]string{}
				state.HTTPListeners.Range(func(key string, value *utils.HTTPHolder) bool {
					httpListeners[key] = remoteAddrs(value.SSHConnections)
					return true
				})

				aliasListeners := map[string][]string{}
				state.AliasListeners.Range(func(key string, value *utils.AliasHolder) bool {
					aliasListeners[key] = remoteAddrs(value.SSHConnections)
					return true
				})

				tcpListeners := map[string][]string{}
				state.TCPListeners.Range(func(key string, value *utils.TCPHolder) bool {
					tcpListeners[key] = remoteAddrs(value.SSHConnections)
					return true
				})

				consoleClients := map[string][]string{}
				state.Console.Clients.Range(func(key string, value []*utils.WebClient) bool {
					addrs := []string{}
					for _, cl := range value {
						addrs = append(addrs, cl.Conn.RemoteAddr().String())
					}

					consoleClients[key] = addrs
					return true
				})

				// Only the routes, since the tokens grant console access.
				consoleRoutes := []string{}
				state.Console.RouteTokens.Range(func(key, _ string) bool {
					consoleRoutes = append(consoleRoutes, key)
					return true
				})

				slog.Debug(
					"server state",
					"goroutines", runtime.NumGoroutine(),
					"listeners", listeners,
					"clients", clients,
					"httpListeners", httpListeners,
					"aliasListeners", aliasListeners,
					"tcpListeners", tcpListeners,
					"consoleClients", consoleClients,
					"consoleRoutes", consoleRoutes,
				)

				time.Sleep(debugInterval)
			}
		}()
	}

	slog.Info(
		"starting SSH service",
		"address", viper.GetString("ssh-address"),
	)

	sshConfig := utils.GetSSHConfig()

	var listener net.Listener

	l, err := utils.Listen(viper.GetString("ssh-address"))
	if err != nil {
		utils.Fatal(
			"unable to listen",
			"address", viper.GetString("ssh-address"),
			"err", err,
		)
	}

	if viper.GetBool("proxy-protocol-listener") {
		hListener := &proxyproto.Listener{
			Listener: l,
		}

		utils.LoadProxyProtoConfig(hListener)
		listener = hListener
	} else {
		listener = l
	}

	state.Listeners.Store(viper.GetString("ssh-address"), listener)

	defer func() {
		err := listener.Close()
		if err != nil {
			slog.Error(
				"error closing listener",
				"err", err,
			)
		}

		state.Listeners.Delete(viper.GetString("ssh-address"))
	}()

	c := make(chan os.Signal, 1)
	signal.Notify(c, os.Interrupt)
	go func() {
		for range c {
			os.Exit(0)
		}
	}()

	for {
		conn, err := listener.Accept()
		if err != nil {
			slog.Error(
				"error accepting from listener",
				"err", err,
			)
			continue
		}

		go handleConn(conn, state, sshConfig)
	}
}

// handleConn handles a single accepted SSH connection: it performs the SSH
// handshake and wires up the per-connection goroutines (cleanup on close,
// request/channel handling, unbound/deadline cleanup and keepalives). It is
// split out of Start so the connection lifecycle can be exercised by tests.
func handleConn(conn net.Conn, state *utils.State, sshConfig *ssh.ServerConfig) {
	clientRemote, _, err := net.SplitHostPort(conn.RemoteAddr().String())

	if err != nil || state.IPFilter.Blocked(clientRemote) {
		err := conn.Close()
		if err != nil {
			slog.Error(
				"error closing new conn",
				"err", err,
			)
		}

		if viper.GetBool("debug") {
			slog.Debug(
				"blocked connection",
				"remoteAddr", conn.RemoteAddr().String(),
				"localAddr", conn.LocalAddr().String(),
			)
		}

		return
	}

	clientLoggedInMutex := &sync.Mutex{}

	clientLoggedInMutex.Lock()
	clientLoggedIn := false
	clientLoggedInMutex.Unlock()

	if viper.GetBool("cleanup-unauthed") {
		go func() {
			<-time.After(viper.GetDuration("cleanup-unauthed-timeout"))
			clientLoggedInMutex.Lock()
			if !clientLoggedIn {
				err := conn.Close()
				if err != nil {
					slog.Error(
						"error closing unauthenticated connection",
						"err", err,
					)
				}
			}
			clientLoggedInMutex.Unlock()
		}()
	}

	slog.Info(
		"accepted ssh connection",
		"remoteAddr", conn.RemoteAddr().String(),
	)

	sshConn, chans, reqs, err := ssh.NewServerConn(conn, sshConfig)
	clientLoggedInMutex.Lock()
	clientLoggedIn = true
	clientLoggedInMutex.Unlock()
	if err != nil {
		err := conn.Close()
		if err != nil {
			slog.Error(
				"error closing connection",
				"err", err,
			)
		}
		return
	}

	pubKeyFingerprint := ""

	if sshConn.Permissions != nil {
		if _, ok := sshConn.Permissions.Extensions["pubKey"]; ok {
			pubKeyFingerprint = sshConn.Permissions.Extensions["pubKeyFingerprint"]
		}
	}

	holderConn := &utils.SSHConnection{
		SSHConn:                sshConn,
		Listeners:              syncmap.New[string, net.Listener](),
		Closed:                 &sync.Once{},
		Close:                  make(chan bool),
		Exec:                   make(chan bool),
		Messages:               make(chan string),
		Session:                make(chan bool),
		SetupLock:              &sync.Mutex{},
		TCPAliasesAllowedUsers: []string{pubKeyFingerprint},
		ConnectedAt:            time.Now(),
	}

	state.SSHConnections.Store(sshConn.RemoteAddr().String(), holderConn)

	go func() {
		err := sshConn.Wait()
		if err != nil {
			slog.Debug(
				"closing SSH connection",
				"err", err,
			)
		}

		select {
		case <-holderConn.Close:
			break
		default:
			holderConn.CleanUp(state)
		}
	}()

	go handleRequests(reqs, holderConn, state)
	go handleChannels(chans, holderConn, state)

	go func() {
		select {
		case <-holderConn.Exec:
		case <-time.After(1 * time.Second):
			break
		}

		runTime := 0.0
		ticker := time.NewTicker(1 * time.Second)

		for {
			select {
			case <-ticker.C:
				runTime++

				if holderConn.Deadline != nil && time.Now().After(*holderConn.Deadline) {
					holderConn.SendLog(slog.LevelInfo, "Connection deadline reached. Closing connection.", "connection deadline reached")
					time.Sleep(1 * time.Millisecond)
					holderConn.CleanUp(state)
					return
				}

				if ((viper.GetBool("cleanup-unbound") && runTime > viper.GetDuration("cleanup-unbound-timeout").Seconds()) || holderConn.AutoClose) && holderConn.ListenerCount() == 0 {
					holderConn.SendLog(slog.LevelInfo, "No forwarding requests sent. Closing connection.", "no forwarding requests sent")
					time.Sleep(1 * time.Millisecond)
					holderConn.CleanUp(state)
				}
			case <-holderConn.Close:
				return
			}
		}
	}()

	if viper.GetBool("ping-client") {
		go func() {
			tickDuration := viper.GetDuration("ping-client-interval")
			ticker := time.NewTicker(tickDuration)

			for {
				err := conn.SetDeadline(time.Now().Add(tickDuration).Add(viper.GetDuration("ping-client-timeout")))
				if err != nil {
					slog.Error(
						"unable to set deadline",
						"err", err,
					)
				}

				select {
				case <-ticker.C:
					_, _, err := sshConn.SendRequest("keepalive@sish", true, nil)
					if err != nil {
						slog.Error(
							"error retrieving keepalive response",
							"err", err,
						)
						return
					}
				case <-holderConn.Close:
					return
				}
			}
		}()
	}
}