// Package sshproxy terminates SSH authentication at the gateway and forwards // permitted session channels over an independently authenticated upstream. package sshproxy import ( "bytes" "context" "crypto/ed25519" "crypto/rand" "crypto/sha256" "crypto/subtle" "crypto/tls" "crypto/x509" "encoding/pem" "errors" "fmt" "io" "net" "strconv" "strings" "sync" "time" "github.com/pkg/sftp" "golang.org/x/crypto/ssh" "linkport/internal/model" "linkport/internal/outbound" "linkport/internal/protocol/transport" ) type Handler struct{} func New() *Handler { return &Handler{} } var _ model.Handler = (*Handler)(nil) func GenerateHostKey() (string, error) { _, key, err := ed25519.GenerateKey(rand.Reader) if err != nil { return "", err } der, err := x509.MarshalPKCS8PrivateKey(key) if err != nil { return "", err } return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})), nil } func targetAddress(p model.Proxy) string { return net.JoinHostPort(p.TargetHost, strconv.Itoa(p.TargetPort)) } func supportedKey(k ssh.PublicKey) bool { return k.Type() == ssh.KeyAlgoED25519 || k.Type() == ssh.KeyAlgoRSA } func equalSecret(a, b string) bool { x, y := sha256.Sum256([]byte(a)), sha256.Sum256([]byte(b)) return subtle.ConstantTimeCompare(x[:], y[:]) == 1 } func algorithms() ssh.Config { return ssh.Config{KeyExchanges: []string{"curve25519-sha256", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521", "diffie-hellman-group14-sha256"}, Ciphers: []string{"chacha20-poly1305@openssh.com", "aes128-gcm@openssh.com", "aes256-gcm@openssh.com", "aes128-ctr", "aes192-ctr", "aes256-ctr"}, MACs: []string{"hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512"}} } func serverConfig(p model.Proxy) (*ssh.ServerConfig, error) { signer, err := ssh.ParsePrivateKey([]byte(p.SSH.HostKey)) if err != nil { return nil, errors.New("invalid gateway host key") } if signer.PublicKey().Type() != ssh.KeyAlgoED25519 { return nil, errors.New("gateway host key must be Ed25519") } cfg := &ssh.ServerConfig{Config: algorithms(), MaxAuthTries: 5, ServerVersion: "SSH-2.0-linkport", PublicKeyAuthAlgorithms: []string{ssh.KeyAlgoED25519, ssh.KeyAlgoRSASHA256, ssh.KeyAlgoRSASHA512}} cfg.AddHostKey(signer) if p.GatewayPassword != "" || len(p.GatewayAccounts) > 0 { cfg.PasswordCallback = func(c ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { a, found := transport.Account(p, c.User(), c.RemoteAddr()) if found && a.Password != "" && equalSecret(string(password), a.Password) { return nil, nil } return nil, errors.New("authentication failed") } } entries := p.SSH.AuthorizedKeys for _, a := range p.GatewayAccounts { entries = append(append([]string(nil), entries...), a.AuthorizedKeys...) } for _, entry := range entries { key, _, options, rest, e := ssh.ParseAuthorizedKey([]byte(entry)) if e != nil || len(options) > 0 || len(bytes.TrimSpace(rest)) > 0 || !supportedKey(key) { return nil, errors.New("invalid or unsupported authorized key") } } if len(entries) > 0 { cfg.PublicKeyCallback = func(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { a, found := transport.Account(p, c.User(), c.RemoteAddr()) if found { for _, entry := range a.AuthorizedKeys { known, _, _, _, err := ssh.ParseAuthorizedKey([]byte(entry)) if err == nil && bytes.Equal(known.Marshal(), key.Marshal()) { return nil, nil } } } return nil, errors.New("authentication failed") } } if cfg.PasswordCallback == nil && cfg.PublicKeyCallback == nil { return nil, errors.New("gateway authentication is not configured") } return cfg, nil } func upstreamConfig(p model.Proxy) (*ssh.ClientConfig, error) { if err := p.CheckTargetCredential(); err != nil { return nil, err } var callback ssh.HostKeyCallback var err error if p.SSH.IgnoreHostKey { callback = ssh.InsecureIgnoreHostKey() } else { key, _, _, rest, parseErr := ssh.ParseAuthorizedKey([]byte(p.SSH.TargetHostKey)) err = parseErr if err != nil || len(bytes.TrimSpace(rest)) > 0 || !supportedKey(key) { return nil, model.AtConnectionStage("ssh_host_key_unconfirmed", errors.New("target host key must be confirmed")) } verify := ssh.FixedHostKey(key) callback = func(host string, addr net.Addr, observed ssh.PublicKey) error { if err := verify(host, addr, observed); err != nil { return model.AtConnectionStage("ssh_host_identity", err) } return nil } } cfg := &ssh.ClientConfig{Config: algorithms(), User: p.TargetUser, HostKeyAlgorithms: []string{ssh.KeyAlgoED25519, ssh.KeyAlgoRSASHA512, ssh.KeyAlgoRSASHA256}, HostKeyCallback: callback} switch p.SSH.AuthMethod { case "password": cfg.Auth = []ssh.AuthMethod{ssh.Password(p.TargetPassword)} case "privatekey", "privateKey": var signer ssh.Signer if p.SSH.Passphrase != "" { signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(p.SSH.PrivateKey), []byte(p.SSH.Passphrase)) } else { signer, err = ssh.ParsePrivateKey([]byte(p.SSH.PrivateKey)) } if err != nil { return nil, errors.New("invalid target private key or passphrase") } if !supportedKey(signer.PublicKey()) { return nil, errors.New("unsupported target private key") } if signer.PublicKey().Type() == ssh.KeyAlgoRSA { multi, ok := signer.(ssh.AlgorithmSigner) if !ok { return nil, errors.New("RSA SHA-2 signing unavailable") } signer, err = ssh.NewSignerWithAlgorithms(multi, []string{ssh.KeyAlgoRSASHA512, ssh.KeyAlgoRSASHA256}) if err != nil { return nil, err } } signer, err = model.SSHCertificateSigner(p.SSH.Certificate, signer) if err != nil { return nil, errors.New("invalid target SSH certificate or private key") } cfg.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)} default: return nil, errors.New("invalid target authentication method") } return cfg, nil } // connect leaves the connection owned by the caller; cancellation remains active // throughout the session, not only while dialing. func connect(ctx context.Context, p model.Proxy, dial model.DialFunc, cfg *ssh.ClientConfig) (*ssh.Client, func(), error) { raw, err := dial(ctx, "tcp", targetAddress(p)) if err != nil { return nil, nil, fmt.Errorf("SSH target connection: %w", err) } stop := context.AfterFunc(ctx, func() { raw.Close() }) cleanup := func() { stop(); raw.Close() } raw.SetDeadline(time.Now().Add(15 * time.Second)) conn, channels, requests, err := ssh.NewClientConn(raw, targetAddress(p), cfg) if err != nil { cleanup() return nil, nil, fmt.Errorf("SSH target handshake: %w", err) } raw.SetDeadline(time.Time{}) return ssh.NewClient(conn, channels, requests), cleanup, nil } func (h *Handler) Test(ctx context.Context, p model.Proxy, dial model.DialFunc) (model.TestResult, error) { cfg, err := upstreamConfig(p) if err != nil { var failure *model.ConnectionFailure if errors.As(err, &failure) && failure.Stage == "ssh_host_key_unconfirmed" { outbound.Begin(ctx, "ssh_host_key_unconfirmed")(err) } return model.TestResult{}, err } var fingerprint string verify := cfg.HostKeyCallback cfg.HostKeyCallback = func(host string, addr net.Addr, key ssh.PublicKey) error { done := outbound.Begin(ctx, "ssh_host_identity") if err := verify(host, addr, key); err != nil { done(err) return err } done(nil) fingerprint = ssh.FingerprintSHA256(key) return nil } c, closeConn, err := connect(ctx, p, dial, cfg) if err != nil { return model.TestResult{}, err } defer closeConn() defer c.Close() if p.Type == "sftp" { files, err := sftp.NewClient(c) if err != nil { return model.TestResult{}, errors.New("SFTP subsystem unavailable") } defer files.Close() if _, err := files.Getwd(); err != nil { return model.TestResult{}, errors.New("SFTP working directory unavailable") } } result := model.TestResult{Version: string(c.ServerVersion()), Fingerprint: fingerprint} return result, nil } // Probe deliberately aborts in HostKeyCallback, before user authentication. func Probe(ctx context.Context, p model.Proxy, dial model.DialFunc) (model.TestResult, error) { var result model.TestResult captured := errors.New("host key captured") cfg := &ssh.ClientConfig{Config: algorithms(), User: "linkport-host-key-probe", HostKeyAlgorithms: []string{ssh.KeyAlgoED25519, ssh.KeyAlgoRSASHA512, ssh.KeyAlgoRSASHA256}, HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error { result.Fingerprint = ssh.FingerprintSHA256(key) result.HostKey = string(ssh.MarshalAuthorizedKey(key)) return captured }} c, cleanup, err := connect(ctx, p, dial, cfg) if cleanup != nil { defer cleanup() } if c != nil { defer c.Close() } if errors.Is(err, captured) { return result, nil } return model.TestResult{}, err } func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial model.DialFunc, _ *tls.Config, event model.EventFunc) error { defer raw.Close() stop := context.AfterFunc(ctx, func() { raw.Close() }) defer stop() cfg, err := serverConfig(p) if err != nil { return err } cfg.AuthLogCallback = func(c ssh.ConnMetadata, method string, err error) { if method != "none" { transport.AuthenticationEvent(event, c.User(), err == nil) } } raw.SetDeadline(time.Now().Add(15 * time.Second)) down, channels, requests, err := ssh.NewServerConn(raw, cfg) if err != nil { return errors.New("SSH gateway authentication or handshake failed") } defer down.Close() account, found := transport.Account(p, down.User(), raw.RemoteAddr()) if !found { return errors.New("SSH gateway account unavailable") } p = account.Apply(p) dial = p.DialForTarget(dial) upstreamCfg, err := upstreamConfig(p) if err != nil { return err } raw.SetDeadline(time.Time{}) sessionCtx, cancel := context.WithCancel(ctx) defer cancel() upstream, cleanup, err := connect(sessionCtx, p, dial, upstreamCfg) if err != nil { return err } defer cleanup() defer upstream.Close() go func() { upstream.Wait(); down.Close() }() go func() { for req := range requests { if req.WantReply { req.Reply(false, nil) } } }() slots := make(chan struct{}, 10) var sessions sync.WaitGroup defer func() { upstream.Close(); down.Close(); sessions.Wait() }() for incoming := range channels { if incoming.ChannelType() != "session" { incoming.Reject(ssh.Prohibited, "only session channels are supported") if event != nil { event(model.EventProtocolRejected, "ssh:channel_not_allowed") } continue } select { case slots <- struct{}{}: default: incoming.Reject(ssh.ResourceShortage, "session limit reached") continue } sessions.Add(1) go func(ch ssh.NewChannel) { defer sessions.Done() defer func() { <-slots }() bridgeSession(ctx, upstream, ch, event, p.Type == "sftp") }(incoming) } return nil } func allowedRequest(req *ssh.Request) bool { switch req.Type { case "pty-req", "window-change", "shell", "exec", "signal", "break": return true case "subsystem": var v struct{ Name string } return ssh.Unmarshal(req.Payload, &v) == nil && v.Name == "sftp" case "env": var v struct{ Name, Value string } return ssh.Unmarshal(req.Payload, &v) == nil && (v.Name == "TERM" || v.Name == "LANG" || strings.HasPrefix(v.Name, "LC_")) default: return false } } func bridgeSession(ctx context.Context, up *ssh.Client, incoming ssh.NewChannel, event model.EventFunc, sftpOnly bool) { target, targetRequests, err := up.OpenChannel("session", incoming.ExtraData()) if err != nil { incoming.Reject(ssh.ConnectionFailed, "target session unavailable") return } defer target.Close() client, clientRequests, err := incoming.Accept() if err != nil { return } defer client.Close() input := newInputAudit(ctx, client) if sftpOnly { input.mode.Store(2) } var workers sync.WaitGroup var requestMu sync.Mutex var output sync.WaitGroup output.Add(2) workers.Add(5) go func() { defer workers.Done() defer target.Close() started := false for req := range clientRequests { requestMu.Lock() ok := false allowed := allowedRequest(req) startup := req.Type == "exec" || req.Type == "shell" || req.Type == "subsystem" if startup && started { allowed = false } if sftpOnly { allowed = allowed && req.Type == "subsystem" } decision := "blocked" if allowed { decision = "allowed" } if err := auditRequest(ctx, req, decision, input.output); err != nil { input.output.fail() client.Close() requestMu.Unlock() return } if allowed { if startup { started = true } if req.Type == "shell" { input.mode.Store(1) } if req.Type == "subsystem" { input.mode.Store(2) } var sendErr error ok, sendErr = target.SendRequest(req.Type, req.WantReply, req.Payload) if startup && (sendErr != nil || (req.WantReply && !ok)) { input.mode.Store(0) state := "rejected" if sendErr != nil { state = "interrupted" } if err := input.output.finish(state); err != nil { client.Close() target.Close() } } if req.Type == "pty-req" && ok { input.pty.Store(true) } } else if event != nil { event(model.EventProtocolRejected, "ssh:request_not_allowed") } if req.WantReply { req.Reply(ok, nil) } requestMu.Unlock() } }() go func() { defer workers.Done() if _, err := io.Copy(target, input); err != nil { input.output.fail() target.Close() client.Close() } target.CloseWrite() }() go func() { defer workers.Done() defer output.Done() if _, err := io.Copy(client.Stderr(), outputAudit{input: input, reader: target.Stderr(), stream: "stderr"}); err != nil { input.output.fail() target.Close() client.Close() } }() go func() { defer workers.Done() defer output.Done() if _, err := io.Copy(client, outputAudit{input: input, reader: target, stream: "stdout"}); err != nil { input.output.fail() target.Close() client.Close() } }() go func() { defer workers.Done(); output.Wait(); client.CloseWrite() }() // Keep request processing alive until upstream closes its channel so exit-status // cannot be lost when stdout reaches EOF before the exit notification. for req := range targetRequests { input.output.exit(req) ok := false if req.Type == "exit-status" || req.Type == "exit-signal" || req.Type == "xon-xoff" { ok, _ = client.SendRequest(req.Type, req.WantReply, req.Payload) } if req.WantReply { req.Reply(ok, nil) } } target.Close() output.Wait() // Target channel closure may precede forwarding the reply to exec/subsystem. // Wait for that in-flight reply before closing the downstream channel. requestMu.Lock() client.Close() requestMu.Unlock() workers.Wait() if err := input.output.finish(""); err != nil && event != nil { event(model.EventProtocolRejected, "ssh:output_audit_unavailable") } }