diff --git a/docs/operations.md b/docs/operations.md index 0280bb0..fa7d39f 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -148,6 +148,19 @@ sshgate correlate --db ./sshgate.db --log /var/log/auth.log Use `--log /var/log/secure` on distributions that write SSH authentication events there. Use `--window 5m` to widen the matching window. +When `sshd` only listens behind `sshgate`, it logs every client as the gate +(for example `Accepted publickey for alice from 127.0.0.1 port 54321`), so +source IPs do not match. Each connection forwarded to the backend logs a +`CONNECTED` line with the local address of the backend socket: + +```text +[203.0.113.7] CONNECTED backend=127.0.0.1:22 local=127.0.0.1:54321 +``` + +The `local` port is the port `sshd` reports, so joining the two lines on host, +port, and a short time window recovers the client IP and fingerprint for each +authenticated session. + ## Troubleshooting - **Permission denied opening the database:** the service user needs write diff --git a/proxy.go b/proxy.go index 959939a..1c072cf 100644 --- a/proxy.go +++ b/proxy.go @@ -299,6 +299,9 @@ func handleConn(client net.Conn, route gateproxy.Route, st *store.Store, allowUn return } defer closeConnection(backend, clientIP, "backend") + // The backend sees sshgate as the peer, so sshd logs "from 127.0.0.1 port + // N". Logging the local address lets that line be joined to the client IP. + log.Printf("[%s] CONNECTED %s backend=%s local=%s", clientIP, kex.fingerprint.Hash, route.Backend, backend.LocalAddr()) _ = backend.SetDeadline(time.Now().Add(handshakeTimeout)) if _, err := backend.Write(clientID.bytes); err != nil { diff --git a/proxy_test.go b/proxy_test.go index 4eaa37f..43b63be 100644 --- a/proxy_test.go +++ b/proxy_test.go @@ -4,8 +4,11 @@ import ( "bufio" "bytes" "io" + "log" "net" "path/filepath" + "strings" + "sync" "testing" "time" @@ -169,3 +172,95 @@ func TestApprovedClientChecksBannerAndPreservesBufferedTraffic(t *testing.T) { } } } + +type lockedBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *lockedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *lockedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func TestConnectedLogsBackendLocalAddress(t *testing.T) { + logs := &lockedBuffer{} + prev := log.Writer() + log.SetOutput(logs) + t.Cleanup(func() { log.SetOutput(prev) }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = ln.Close() }() + st, err := NewStore(filepath.Join(t.TempDir(), "gate.db")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = st.Close() }() + packet := kexInitPacket() + kex, err := readSSHKexInit(bufio.NewReader(bytes.NewReader(packet)), "SSH-2.0-test") + if err != nil { + t.Fatal(err) + } + if err := st.UpsertStatus(kex.fingerprint.Hash, StatusApproved, ""); err != nil { + t.Fatal(err) + } + + // The backend's view of its peer is what sshd would log as "port N". + backendPeer := make(chan string, 1) + go func() { + c, err := ln.Accept() + if err != nil { + backendPeer <- "" + return + } + defer func() { _ = c.Close() }() + backendPeer <- c.RemoteAddr().String() + _ = c.SetDeadline(time.Now().Add(3 * time.Second)) + r := bufio.NewReader(c) + if _, err := r.ReadString('\n'); err != nil { + return + } + if _, err := c.Write([]byte("SSH-2.0-test-server\r\n")); err != nil { + return + } + _, _ = io.Copy(io.Discard, r) + }() + + client, peer := net.Pipe() + done := make(chan struct{}) + go func() { + defer close(done) + handleConn(client, gateproxy.Route{Backend: ln.Addr().String()}, st, false, nil, testBanner("SSH-2.0-test-server"), nil) + }() + _ = peer.SetDeadline(time.Now().Add(3 * time.Second)) + if _, err := peer.Write([]byte("SSH-2.0-test\r\n")); err != nil { + t.Fatal(err) + } + if _, err := bufio.NewReader(peer).ReadString('\n'); err != nil { + t.Fatal(err) + } + if _, err := peer.Write(packet); err != nil { + t.Fatal(err) + } + peerAddr := <-backendPeer + _ = peer.Close() + <-done + + if peerAddr == "" { + t.Fatal("backend never accepted a connection") + } + want := "CONNECTED " + kex.fingerprint.Hash + " backend=" + ln.Addr().String() + " local=" + peerAddr + if !strings.Contains(logs.String(), want) { + t.Fatalf("log missing %q\ngot:\n%s", want, logs.String()) + } +}