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 }