472 lines
15 KiB
Go
472 lines
15 KiB
Go
// 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")
|
|
}
|
|
}
|