feat: implement SSH honeypot server with auth and config
Add core SSH server with password authentication, per-IP failure tracking, credential memory with TTL, and static credential support. Includes TOML config loading with validation, Ed25519 host key auto-generation, and a Nix package output. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
230
internal/server/server.go
Normal file
230
internal/server/server.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.t-juice.club/torjus/oubliette/internal/auth"
|
||||
"git.t-juice.club/torjus/oubliette/internal/config"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const sessionTimeout = 30 * time.Second
|
||||
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
authenticator *auth.Authenticator
|
||||
sshConfig *ssh.ServerConfig
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func New(cfg config.Config, logger *slog.Logger) (*Server, error) {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
authenticator: auth.NewAuthenticator(cfg.Auth),
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
hostKey, err := loadOrGenerateHostKey(cfg.SSH.HostKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("host key: %w", err)
|
||||
}
|
||||
|
||||
s.sshConfig = &ssh.ServerConfig{
|
||||
PasswordCallback: s.passwordCallback,
|
||||
ServerVersion: "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6",
|
||||
}
|
||||
s.sshConfig.AddHostKey(hostKey)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
listener, err := net.Listen("tcp", s.cfg.SSH.ListenAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
s.logger.Info("SSH server listening", "addr", s.cfg.SSH.ListenAddr)
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
listener.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
s.logger.Error("accept error", "err", err)
|
||||
continue
|
||||
}
|
||||
go s.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleConn(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
sshConn, chans, reqs, err := ssh.NewServerConn(conn, s.sshConfig)
|
||||
if err != nil {
|
||||
s.logger.Debug("SSH handshake failed", "remote_addr", conn.RemoteAddr(), "err", err)
|
||||
return
|
||||
}
|
||||
defer sshConn.Close()
|
||||
|
||||
s.logger.Info("SSH connection established",
|
||||
"remote_addr", sshConn.RemoteAddr(),
|
||||
"user", sshConn.User(),
|
||||
)
|
||||
|
||||
go ssh.DiscardRequests(reqs)
|
||||
|
||||
for newChan := range chans {
|
||||
if newChan.ChannelType() != "session" {
|
||||
newChan.Reject(ssh.UnknownChannelType, "unknown channel type")
|
||||
continue
|
||||
}
|
||||
|
||||
channel, requests, err := newChan.Accept()
|
||||
if err != nil {
|
||||
s.logger.Error("channel accept error", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
go s.handleSession(channel, requests, sshConn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request, conn *ssh.ServerConn) {
|
||||
defer channel.Close()
|
||||
|
||||
// Handle session requests (pty-req, shell, etc.)
|
||||
go func() {
|
||||
for req := range requests {
|
||||
switch req.Type {
|
||||
case "pty-req", "shell":
|
||||
if req.WantReply {
|
||||
req.Reply(true, nil)
|
||||
}
|
||||
default:
|
||||
if req.WantReply {
|
||||
req.Reply(false, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Write a fake banner.
|
||||
fmt.Fprint(channel, "Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-89-generic x86_64)\r\n\r\n")
|
||||
fmt.Fprintf(channel, "Last login: %s from 10.0.0.1\r\n", time.Now().Add(-2*time.Hour).Format("Mon Jan 2 15:04:05 2006"))
|
||||
fmt.Fprintf(channel, "%s@ubuntu:~$ ", conn.User())
|
||||
|
||||
// Hold connection open until timeout or client disconnect.
|
||||
timer := time.NewTimer(sessionTimeout)
|
||||
defer timer.Stop()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
buf := make([]byte, 256)
|
||||
for {
|
||||
_, err := channel.Read(buf)
|
||||
if err != nil {
|
||||
close(done)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-timer.C:
|
||||
s.logger.Info("session timed out", "remote_addr", conn.RemoteAddr(), "user", conn.User())
|
||||
case <-done:
|
||||
s.logger.Info("session closed by client", "remote_addr", conn.RemoteAddr(), "user", conn.User())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) passwordCallback(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
||||
ip := extractIP(conn.RemoteAddr())
|
||||
d := s.authenticator.Authenticate(ip, conn.User(), string(password))
|
||||
|
||||
s.logger.Info("auth attempt",
|
||||
"remote_addr", conn.RemoteAddr(),
|
||||
"username", conn.User(),
|
||||
"accepted", d.Accepted,
|
||||
"reason", d.Reason,
|
||||
)
|
||||
|
||||
if d.Accepted {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("rejected")
|
||||
}
|
||||
|
||||
func extractIP(addr net.Addr) string {
|
||||
host, _, err := net.SplitHostPort(addr.String())
|
||||
if err != nil {
|
||||
// Might not have a port, try using the string directly.
|
||||
return addr.String()
|
||||
}
|
||||
// Normalize IPv4-mapped IPv6 addresses.
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return host
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
return v4.String()
|
||||
}
|
||||
return ip.String()
|
||||
}
|
||||
|
||||
func loadOrGenerateHostKey(path string) (ssh.Signer, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
signer, err := ssh.ParsePrivateKey(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing host key: %w", err)
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("reading host key: %w", err)
|
||||
}
|
||||
|
||||
// Generate new Ed25519 key.
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating key: %w", err)
|
||||
}
|
||||
|
||||
privBytes, err := ssh.MarshalPrivateKey(priv, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling key: %w", err)
|
||||
}
|
||||
|
||||
pemData := pem.EncodeToMemory(privBytes)
|
||||
if err := os.WriteFile(path, pemData, 0600); err != nil {
|
||||
return nil, fmt.Errorf("writing host key: %w", err)
|
||||
}
|
||||
|
||||
signer, err := ssh.ParsePrivateKey(pemData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing generated key: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("generated new host key", "path", path)
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
219
internal/server/server_test.go
Normal file
219
internal/server/server_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.t-juice.club/torjus/oubliette/internal/config"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type testAddr struct {
|
||||
str string
|
||||
network string
|
||||
}
|
||||
|
||||
func (a testAddr) Network() string { return a.network }
|
||||
func (a testAddr) String() string { return a.str }
|
||||
|
||||
func newAddr(s, network string) net.Addr {
|
||||
return testAddr{str: s, network: network}
|
||||
}
|
||||
|
||||
func TestHostKey_Generate(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "host_key")
|
||||
|
||||
signer, err := loadOrGenerateHostKey(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if signer == nil {
|
||||
t.Fatal("signer is nil")
|
||||
}
|
||||
|
||||
// File should exist with correct permissions.
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat host key: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0600 {
|
||||
t.Errorf("permissions = %o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostKey_Load(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "host_key")
|
||||
|
||||
// Generate first.
|
||||
signer1, err := loadOrGenerateHostKey(path)
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
|
||||
// Load existing.
|
||||
signer2, err := loadOrGenerateHostKey(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
|
||||
// Keys should be the same.
|
||||
if string(signer1.PublicKey().Marshal()) != string(signer2.PublicKey().Marshal()) {
|
||||
t.Error("loaded key differs from generated key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
addr string
|
||||
want string
|
||||
}{
|
||||
{"192.168.1.1:22", "192.168.1.1"},
|
||||
{"[::1]:22", "::1"},
|
||||
{"[::ffff:192.168.1.1]:22", "192.168.1.1"},
|
||||
{"10.0.0.1:12345", "10.0.0.1"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.addr, func(t *testing.T) {
|
||||
addr := newAddr(tt.addr, "tcp")
|
||||
got := extractIP(addr)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractIP(%q) = %q, want %q", tt.addr, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationSSHConnect(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := config.Config{
|
||||
SSH: config.SSHConfig{
|
||||
ListenAddr: "127.0.0.1:0",
|
||||
HostKeyPath: filepath.Join(tmpDir, "host_key"),
|
||||
},
|
||||
Auth: config.AuthConfig{
|
||||
AcceptAfter: 2,
|
||||
CredentialTTLDuration: time.Hour,
|
||||
StaticCredentials: []config.Credential{
|
||||
{Username: "root", Password: "toor"},
|
||||
},
|
||||
},
|
||||
LogLevel: "debug",
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
srv, err := New(cfg, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("creating server: %v", err)
|
||||
}
|
||||
|
||||
// Use a listener to get the actual port.
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
addr := listener.Addr().String()
|
||||
listener.Close()
|
||||
|
||||
cfg.SSH.ListenAddr = addr
|
||||
srv.cfg = cfg
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- srv.ListenAndServe(ctx)
|
||||
}()
|
||||
|
||||
// Wait for server to be ready.
|
||||
var conn net.Conn
|
||||
for i := range 50 {
|
||||
conn, err = net.DialTimeout("tcp", addr, 100*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
break
|
||||
}
|
||||
if i == 49 {
|
||||
t.Fatalf("server not ready after retries: %v", err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Test static credential login.
|
||||
t.Run("static_cred", func(t *testing.T) {
|
||||
clientCfg := &ssh.ClientConfig{
|
||||
User: "root",
|
||||
Auth: []ssh.AuthMethod{ssh.Password("toor")},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
client, err := ssh.Dial("tcp", addr, clientCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("SSH dial: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
t.Fatalf("new session: %v", err)
|
||||
}
|
||||
defer session.Close()
|
||||
})
|
||||
|
||||
// Test wrong password is rejected.
|
||||
t.Run("wrong_password", func(t *testing.T) {
|
||||
clientCfg := &ssh.ClientConfig{
|
||||
User: "root",
|
||||
Auth: []ssh.AuthMethod{ssh.Password("wrong")},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
_, err := ssh.Dial("tcp", addr, clientCfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong password")
|
||||
}
|
||||
})
|
||||
|
||||
// Test threshold acceptance: after enough failed dials, a subsequent
|
||||
// dial with the same credentials should succeed via threshold or
|
||||
// remembered credential.
|
||||
t.Run("threshold", func(t *testing.T) {
|
||||
clientCfg := &ssh.ClientConfig{
|
||||
User: "threshuser",
|
||||
Auth: []ssh.AuthMethod{ssh.Password("threshpass")},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Make several dials to accumulate failures past the threshold.
|
||||
for range 5 {
|
||||
c, err := ssh.Dial("tcp", addr, clientCfg)
|
||||
if err == nil {
|
||||
// Threshold reached, success!
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// After enough failures the credential should be remembered.
|
||||
client, err := ssh.Dial("tcp", addr, clientCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("expected threshold/remembered acceptance after many attempts: %v", err)
|
||||
}
|
||||
client.Close()
|
||||
})
|
||||
|
||||
cancel()
|
||||
}
|
||||
Reference in New Issue
Block a user