prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

server.go (3243B)


      1 package ssh
      2 
      3 import (
      4 	"context"
      5 	"errors"
      6 	"fmt"
      7 	"log/slog"
      8 	"net"
      9 	"os"
     10 	"strings"
     11 	"time"
     12 
     13 	"git.prime8.dev/moncheeta/prime8dev/internal/tui"
     14 	charmssh "github.com/charmbracelet/ssh"
     15 	"github.com/charmbracelet/wish"
     16 	wishbubbletea "github.com/charmbracelet/wish/bubbletea"
     17 	gossh "golang.org/x/crypto/ssh"
     18 )
     19 
     20 const (
     21 	defaultSSHPort  = "22"
     22 	hostKeyPath     = ".ssh/public_ssh_ed25519"
     23 	idleTimeout     = 90 * time.Second
     24 	maxTimeout      = 10 * time.Minute
     25 	rateLimitWindow = 1 * time.Minute
     26 	rateLimitMax    = 30
     27 	rateLimitBlock  = 5 * time.Minute
     28 	rateLimitMaxIPs = 2048
     29 )
     30 
     31 func Run(ctx context.Context, logger *slog.Logger) error {
     32 	publicAddr := sshListenAddr()
     33 	limiter := newIPLimiter(rateLimitWindow, rateLimitMax, rateLimitBlock, rateLimitMaxIPs)
     34 
     35 	server, err := wish.NewServer(
     36 		wish.WithAddress(publicAddr),
     37 		wish.WithHostKeyPath(hostKeyPath),
     38 		wish.WithIdleTimeout(idleTimeout),
     39 		wish.WithMaxTimeout(maxTimeout),
     40 		wish.WithPublicKeyAuth(publicKeyAuth),
     41 		wish.WithKeyboardInteractiveAuth(func(_ charmssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {
     42 			return true
     43 		}),
     44 		charmssh.WrapConn(func(_ charmssh.Context, conn net.Conn) net.Conn {
     45 			ip := remoteIP(conn.RemoteAddr())
     46 			allowed, retryAfter := limiter.Allow(ip)
     47 			if !allowed {
     48 				logger.Warn("connection rejected by rate limiter",
     49 					"ip", ip,
     50 					"retry_after", retryAfter.String(),
     51 				)
     52 				return nil
     53 			}
     54 			return conn
     55 		}),
     56 		func(s *charmssh.Server) error {
     57 			s.ServerConfigCallback = func(_ charmssh.Context) *gossh.ServerConfig {
     58 				return &gossh.ServerConfig{
     59 					Config: gossh.Config{KeyExchanges: keyExchanges},
     60 					// Guests' keys are all refused before keyboard-interactive
     61 					// lets them in, so don't drop people with many keys.
     62 					MaxAuthTries: 32,
     63 				}
     64 			}
     65 			s.ConnectionFailedCallback = func(conn net.Conn, err error) {
     66 				logger.Warn("connection failed",
     67 					"ip", remoteIP(conn.RemoteAddr()),
     68 					"error", err,
     69 				)
     70 			}
     71 			return nil
     72 		},
     73 		wish.WithMiddleware(
     74 			wishbubbletea.Middleware(tui.SSHHandler),
     75 			routeMiddleware(logger),
     76 		),
     77 	)
     78 	if err != nil {
     79 		return fmt.Errorf("failed to initialize public ssh server: %w", err)
     80 	}
     81 
     82 	go func() {
     83 		<-ctx.Done()
     84 		shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
     85 		defer cancel()
     86 		_ = server.Shutdown(shutdownCtx)
     87 	}()
     88 
     89 	logger.Info("public ssh server listening",
     90 		"addr", publicAddr,
     91 		"host_key", hostKeyPath,
     92 	)
     93 
     94 	err = server.ListenAndServe()
     95 	if err != nil && !errors.Is(err, charmssh.ErrServerClosed) {
     96 		return fmt.Errorf("public ssh server stopped with error: %w", err)
     97 	}
     98 
     99 	logger.Info("public ssh server stopped")
    100 	return nil
    101 }
    102 
    103 // keyExchanges puts the hybrid post-quantum KEX first so OpenSSH 10 clients
    104 // stop warning about a non-PQ key exchange, with classic fallbacks for older
    105 // clients. Needs Go 1.24+ (crypto/mlkem).
    106 var keyExchanges = []string{
    107 	gossh.KeyExchangeMLKEM768X25519,
    108 	gossh.KeyExchangeCurve25519,
    109 	gossh.KeyExchangeECDHP256,
    110 	gossh.KeyExchangeECDHP384,
    111 	gossh.KeyExchangeECDHP521,
    112 	gossh.KeyExchangeDH14SHA256,
    113 	gossh.KeyExchangeDH16SHA512,
    114 }
    115 
    116 func sshListenAddr() string {
    117 	port := strings.TrimSpace(os.Getenv("SSH_PORT"))
    118 	if port == "" {
    119 		port = defaultSSHPort
    120 	}
    121 
    122 	return ":" + port
    123 }