6 Commits

Author SHA1 Message Date
Ryan Smith
fd02995f52 chore: add proxy options to default config
Adds proxy-related options to the default configuration file, with options set to disabled by default. This makes the features more discoverable for new users.
2025-05-15 17:13:32 -07:00
Ryan Smith
4ab8f2dfee refactor: simplify logging 2025-05-15 17:08:31 -07:00
Ryan Smith
cefc9952f0 feat(ssh honeypot): add proxy protocol support
Adds Proxy Protocol support to the SSH honeypot server. When enabled, the honeypot looks for a Proxy Protocol header on client connections and extracts the client IP from the header. This IP is used as the "source IP" for threat feed updates and logging.

To accommodate this change, the SSH password callback function is now set when a client connects. Previously, it was defined during server startup.
2025-05-15 16:50:03 -07:00
Ryan Smith
5c91ae0e4f feat(tcp honeypot): add proxy protocol support
Adds Proxy Protocol support to the TCP honeypot server. When enabled, the honeypot looks for a Proxy Protocol header on client connections and extracts the client IP from the header. This IP is used as the "source IP" for threat feed updates and logging.
2025-05-15 16:26:05 -07:00
Ryan Smith
363c429a1e chore: rename variables 2025-05-15 16:03:49 -07:00
Ryan Smith
8c97e05f6f feat: add proxy protocol configuration setting
This change adds a configuration option to enable Proxy Protocol (not yet implemented). Planned for TCP and SSH honeypot types. Use `<useProxyProtocol>true</useProxyProtocol>` in the XML config.
2025-05-15 16:01:23 -07:00
7 changed files with 187 additions and 110 deletions

View File

@@ -28,6 +28,7 @@
<port>2222</port>
<logEnabled>true</logEnabled>
<sendToThreatFeed>true</sendToThreatFeed>
<useProxyProtocol>false</useProxyProtocol>
<keyPath>/opt/deceptifeed/certs/ssh-key.pem</keyPath>
<banner>SSH-2.0-OpenSSH_9.6</banner>
</server>
@@ -38,6 +39,7 @@
<port>8080</port>
<logEnabled>true</logEnabled>
<sendToThreatFeed>true</sendToThreatFeed>
<sourceIpHeader></sourceIpHeader>
<rules>
<!-- Update the threat feed if any of the following rules match: -->
<include target="path" negate="true">(?i)^(/|/index\.html|/favicon\.ico|/robots\.txt|/sitemap\.xml|/\.well-known/\w+\.txt)$</include>
@@ -55,6 +57,7 @@
<port>8443</port>
<logEnabled>true</logEnabled>
<sendToThreatFeed>true</sendToThreatFeed>
<sourceIpHeader></sourceIpHeader>
<certPath>/opt/deceptifeed/certs/https-cert.pem</certPath>
<keyPath>/opt/deceptifeed/certs/https-key.pem</keyPath>
<rules>
@@ -75,6 +78,7 @@
<port>2323</port>
<logEnabled>true</logEnabled>
<sendToThreatFeed>true</sendToThreatFeed>
<useProxyProtocol>false</useProxyProtocol>
<banner>\nUser Access Verification\n\n</banner>
<prompts>
<prompt log="username">Username: </prompt>

View File

@@ -28,6 +28,7 @@
<port>2222</port>
<logEnabled>true</logEnabled>
<sendToThreatFeed>true</sendToThreatFeed>
<useProxyProtocol>false</useProxyProtocol>
<keyPath>key-ssh-private.pem</keyPath>
<banner>SSH-2.0-OpenSSH_9.6</banner>
</server>
@@ -38,6 +39,7 @@
<port>8080</port>
<logEnabled>true</logEnabled>
<sendToThreatFeed>true</sendToThreatFeed>
<sourceIpHeader></sourceIpHeader>
<rules>
<!-- Update the threat feed if any of the following rules match: -->
<include target="path" negate="true">(?i)^(/|/index\.html|/favicon\.ico|/robots\.txt|/sitemap\.xml|/\.well-known/\w+\.txt)$</include>
@@ -55,6 +57,7 @@
<port>8443</port>
<logEnabled>true</logEnabled>
<sendToThreatFeed>true</sendToThreatFeed>
<sourceIpHeader></sourceIpHeader>
<certPath>key-https-public.pem</certPath>
<keyPath>key-https-private.pem</keyPath>
<rules>
@@ -75,6 +78,7 @@
<port>2323</port>
<logEnabled>true</logEnabled>
<sendToThreatFeed>true</sendToThreatFeed>
<useProxyProtocol>false</useProxyProtocol>
<banner>\nUser Access Verification\n\n</banner>
<prompts>
<prompt log="username">Username: </prompt>

View File

@@ -104,6 +104,7 @@ type Server struct {
Headers []string `xml:"headers>header"`
Prompts []Prompt `xml:"prompts>prompt"`
SendToThreatFeed bool `xml:"sendToThreatFeed"`
UseProxyProtocol bool `xml:"useProxyProtocol"`
Rules Rules `xml:"rules"`
SourceIPHeader string `xml:"sourceIpHeader"`
LogPath string `xml:"logPath"`
@@ -257,9 +258,15 @@ func (c *Config) InitializeLoggers() error {
io.MultiWriter(file, c.Monitor),
&slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
// Remove 'message' and 'log level' fields from output.
if a.Key == slog.MessageKey || a.Key == slog.LevelKey {
switch a.Key {
case slog.MessageKey, slog.LevelKey:
// Remove default 'message' and 'log level' fields.
return slog.Attr{}
case "source_ip_error":
// Remove 'source_ip_error' field if it's empty.
if len(a.Value.String()) == 0 {
return slog.Attr{}
}
}
return a
},

View File

@@ -149,21 +149,21 @@ func listenHTTPS(cfg *config.Server, response *responseConfig) {
// based on the honeypot configuration.
func handleConnection(cfg *config.Server, customHeaders map[string]string, response *responseConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Log connection details. The log fields and format differ based on
// whether a custom source IP header is configured.
dst_ip, dst_port := getLocalAddr(r)
src_ip, _, _ := net.SplitHostPort(r.RemoteAddr)
logData := []slog.Attr{}
// Record connection details.
dstIP, dstPort := getLocalAddr(r)
srcIP, _, _ := net.SplitHostPort(r.RemoteAddr)
var remIP string
var parsed bool
var errMsg string
// If a custom source IP header is configured, set remIP to the remote
// IP and extract the client IP from the header into srcIP.
if len(cfg.SourceIPHeader) > 0 {
// A custom source IP header is configured. Set rem_ip to the
// original connecting IP and src_ip to the IP from the header. If
// the header is missing, invalid, contains multiple IPs, or if
// there a multiple headers with the same name, parsing will fail,
// and src_ip will fallback to the original connecting IP.
rem_ip := src_ip
// If the custom header is missing, invalid, contains multiple IPs,
// or if there a multiple headers with the same name, parsing will
// fail, and srcIP will fallback to the original connecting IP.
remIP = srcIP
header := r.Header[cfg.SourceIPHeader]
parsed := false
errMsg := ""
switch len(header) {
case 0:
errMsg = "missing header " + cfg.SourceIPHeader
@@ -177,39 +177,33 @@ func handleConnection(cfg *config.Server, customHeaders map[string]string, respo
}
} else {
parsed = true
src_ip = v
srcIP = v
}
default:
errMsg = "multiple instances of header " + cfg.SourceIPHeader
}
logData = append(logData,
slog.String("event_type", "http"),
slog.String("source_ip", src_ip),
slog.Bool("source_ip_parsed", parsed),
)
if !parsed {
logData = append(logData, slog.String("source_ip_error", errMsg))
}
logData = append(logData,
slog.String("remote_ip", rem_ip),
slog.String("server_ip", dst_ip),
slog.String("server_port", dst_port),
slog.String("server_name", config.GetHostname()),
)
} else {
// No custom source IP header is configured. Log the standard
// connection details, keeping src_ip as the remote connecting IP.
logData = append(logData,
slog.String("event_type", "http"),
slog.String("source_ip", src_ip),
slog.String("server_ip", dst_ip),
slog.String("server_port", dst_port),
slog.String("server_name", config.GetHostname()),
)
}
// Log standard HTTP request information.
// Log the connection details.
logData := make([]slog.Attr, 0, 9)
logData = append(logData,
slog.String("event_type", "http"),
slog.String("source_ip", srcIP),
)
if len(cfg.SourceIPHeader) > 0 {
logData = append(logData,
slog.Bool("source_ip_parsed", parsed),
slog.String("source_ip_error", errMsg),
slog.String("remote_ip", remIP),
)
}
logData = append(logData,
slog.String("server_ip", dstIP),
slog.String("server_port", dstPort),
slog.String("server_name", config.GetHostname()),
)
// Log the HTTP request information.
eventDetails := []any{
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
@@ -236,13 +230,12 @@ func handleConnection(cfg *config.Server, customHeaders map[string]string, respo
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "", logData...)
// Print a simplified version of the request to the console.
fmt.Printf("[HTTP] %s %s %s %s\n", src_ip, r.Method, r.URL.Path, r.URL.RawQuery)
fmt.Printf("[HTTP] %s %s %s %s\n", srcIP, r.Method, r.URL.Path, r.URL.RawQuery)
// Update the threat feed using the source IP address (src_ip). If a
// custom header is configured, src_ip contains the IP extracted from
// the header. Otherwise, it contains the remote connecting IP.
// Update the threat feed with srcIP. If Proxy Protocol is enabled, srcIP
// is taken from the proxy header. Otherwise, it's the connecting IP.
if shouldUpdateThreatFeed(cfg, r) {
threatfeed.Update(src_ip)
threatfeed.Update(srcIP)
}
// Apply optional custom HTTP response headers.

View File

@@ -13,6 +13,7 @@ import (
"time"
"github.com/r-smith/deceptifeed/internal/config"
"github.com/r-smith/deceptifeed/internal/proxyproto"
"github.com/r-smith/deceptifeed/internal/threatfeed"
"golang.org/x/crypto/ssh"
)
@@ -62,39 +63,6 @@ func Start(cfg *config.Server) {
return nil, fmt.Errorf("permission denied")
}
// Define the password authentication callback function.
sshConfig.PasswordCallback = func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
// Log the the username and password submitted by the client.
dst_ip, dst_port, _ := net.SplitHostPort(conn.LocalAddr().String())
src_ip, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
slog.String("event_type", "ssh"),
slog.String("source_ip", src_ip),
slog.String("server_ip", dst_ip),
slog.String("server_port", dst_port),
slog.String("server_name", config.GetHostname()),
slog.Group("event_details",
slog.String("username", conn.User()),
slog.String("password", string(password)),
slog.String("ssh_client", string(conn.ClientVersion())),
),
)
// Print a simplified version of the request to the console.
fmt.Printf("[SSH] %s Username: %q Password: %q\n", src_ip, conn.User(), string(password))
// Update the threat feed with the source IP address from the request.
if cfg.SendToThreatFeed {
threatfeed.Update(src_ip)
}
// Insert fixed delay to mimic PAM.
time.Sleep(2 * time.Second)
// Reject the authentication request.
return nil, fmt.Errorf("invalid username or password")
}
// Start the SSH server.
listener, err := net.Listen("tcp", ":"+cfg.Port)
if err != nil {
@@ -110,21 +78,87 @@ func Start(cfg *config.Server) {
continue
}
go handleConnection(conn, sshConfig)
go handleConnection(conn, sshConfig, cfg)
}
}
// handleConnection manages incoming SSH client connections. It performs the
// handshake and handles authentication callbacks.
func handleConnection(conn net.Conn, config *ssh.ServerConfig) {
func handleConnection(conn net.Conn, sshConfig *ssh.ServerConfig, cfg *config.Server) {
defer conn.Close()
// Record connection details.
dstIP, dstPort, _ := net.SplitHostPort(conn.LocalAddr().String())
srcIP, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
var remIP string
var errMsg string
var parsed bool
// If Proxy Protocol is enabled, set remIP to the remote IP and extract the
// client IP from the proxy header into srcIP.
if cfg.UseProxyProtocol {
remIP = srcIP
if clientIP, err := proxyproto.ReadHeader(conn); err != nil {
errMsg = err.Error()
} else {
parsed = true
srcIP = clientIP
}
}
// Set a connection deadline.
_ = conn.SetDeadline(time.Now().Add(serverTimeout))
// Set the password authentication callback function. This function is
// called after a successful SSH handshake. It logs the credentials,
// updates the threat feed, then responds to the client that auth failed.
sshConfig.PasswordCallback = func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
// Log the authentication attempt.
logData := make([]slog.Attr, 0, 9)
logData = append(logData,
slog.String("event_type", "ssh"),
slog.String("source_ip", srcIP),
)
if cfg.UseProxyProtocol {
logData = append(logData,
slog.Bool("source_ip_parsed", parsed),
slog.String("source_ip_error", errMsg),
slog.String("remote_ip", remIP),
)
}
logData = append(logData,
slog.String("server_ip", dstIP),
slog.String("server_port", dstPort),
slog.String("server_name", config.GetHostname()),
slog.Group("event_details",
slog.String("username", conn.User()),
slog.String("password", string(password)),
slog.String("ssh_client", string(conn.ClientVersion())),
),
)
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "", logData...)
// Print a simplified version of the request to the console.
fmt.Printf("[SSH] %s Username: %q Password: %q\n", srcIP, conn.User(), string(password))
// Update the threat feed with srcIP. If Proxy Protocol is enabled,
// srcIP is from the proxy header. Otherwise, it's the connecting IP.
if cfg.SendToThreatFeed {
threatfeed.Update(srcIP)
}
// Insert a fixed delay between authentication attempts.
time.Sleep(2 * time.Second)
// Reject the authentication request.
return nil, fmt.Errorf("invalid username or password")
}
// Perform handshake and authentication. Authentication callbacks are
// defined in the SSH server configuration. Since authentication requests
// are always rejected, this function will consistently return an error,
// and no further connection handling is necessary.
sshConn, _, _, err := ssh.NewServerConn(conn, config)
sshConn, _, _, err := ssh.NewServerConn(conn, sshConfig)
if err != nil {
return
}

View File

@@ -12,6 +12,7 @@ import (
"time"
"github.com/r-smith/deceptifeed/internal/config"
"github.com/r-smith/deceptifeed/internal/proxyproto"
"github.com/r-smith/deceptifeed/internal/threatfeed"
)
@@ -31,6 +32,13 @@ func Start(cfg *config.Server) {
}
defer listener.Close()
// Replace occurrences of "\n" with "\r\n". The configuration file uses
// "\n", but CRLF is expected for TCP protocols.
cfg.Banner = strings.ReplaceAll(cfg.Banner, "\\n", "\r\n")
for i := range cfg.Prompts {
cfg.Prompts[i].Text = strings.ReplaceAll(cfg.Prompts[i].Text, "\\n", "\r\n")
}
// Listen for and accept incoming connections.
for {
conn, err := listener.Accept()
@@ -48,21 +56,39 @@ func Start(cfg *config.Server) {
// client interaction.
func handleConnection(conn net.Conn, cfg *config.Server) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(serverTimeout))
// Print an optional banner. Replace any occurrences of the newline escape
// sequence "\\n" with "\r\n" (carriage return, line feed), used by
// protocols such as Telnet and SMTP.
if len(cfg.Banner) > 0 {
_, _ = conn.Write([]byte(strings.ReplaceAll(cfg.Banner, "\\n", "\r\n")))
// Record connection details.
dstIP, dstPort, _ := net.SplitHostPort(conn.LocalAddr().String())
srcIP, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
var remIP string
var parsed bool
var errMsg string
// If Proxy Protocol is enabled, set remIP to the remote IP and extract the
// client IP from the proxy header into srcIP.
if cfg.UseProxyProtocol {
remIP = srcIP
if clientIP, err := proxyproto.ReadHeader(conn); err != nil {
errMsg = err.Error()
} else {
parsed = true
srcIP = clientIP
}
}
// Present the prompts from the server configuration to the connected
// client and record their responses.
// Set a connection deadline.
_ = conn.SetDeadline(time.Now().Add(serverTimeout))
// Display initial banner to the client if configured.
if len(cfg.Banner) > 0 {
_, _ = conn.Write([]byte(cfg.Banner))
}
// Display configured prompts to the client and record the responses.
scanner := bufio.NewScanner(conn)
responses := make(map[string]string)
for i, prompt := range cfg.Prompts {
_, _ = conn.Write([]byte(strings.ReplaceAll(prompt.Text, "\\n", "\r\n")))
_, _ = conn.Write([]byte(prompt.Text))
scanner.Scan()
var key string
// Each prompt includes an optional Log field that serves as the key
@@ -70,7 +96,6 @@ func handleConnection(conn net.Conn, cfg *config.Server) {
// the response will not be logged. If Log is omitted, the default key
// "data00" is used, where "00" is the index plus one.
if prompt.Log == "none" {
// Skip logging for this entry.
continue
} else if len(prompt.Log) > 0 {
key = prompt.Log
@@ -80,8 +105,8 @@ func handleConnection(conn net.Conn, cfg *config.Server) {
responses[key] = scanner.Text()
}
// If no prompts are provided in the configuration, wait for the client to
// send data then record the received input.
// If no prompts are configured, wait for client input and record the
// received data.
if len(cfg.Prompts) == 0 {
scanner.Scan()
responses["data"] = scanner.Text()
@@ -99,24 +124,34 @@ func handleConnection(conn net.Conn, cfg *config.Server) {
return
}
// Log the connection along with all responses received from the client.
dst_ip, dst_port, _ := net.SplitHostPort(conn.LocalAddr().String())
src_ip, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
// Log the connection and all responses received from the client.
logData := make([]slog.Attr, 0, 9)
logData = append(logData,
slog.String("event_type", "tcp"),
slog.String("source_ip", src_ip),
slog.String("server_ip", dst_ip),
slog.String("server_port", dst_port),
slog.String("source_ip", srcIP),
)
if cfg.UseProxyProtocol {
logData = append(logData,
slog.Bool("source_ip_parsed", parsed),
slog.String("source_ip_error", errMsg),
slog.String("remote_ip", remIP),
)
}
logData = append(logData,
slog.String("server_ip", dstIP),
slog.String("server_port", dstPort),
slog.String("server_name", config.GetHostname()),
slog.Any("event_details", responses),
)
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "", logData...)
// Print a simplified version of the interaction to the console.
fmt.Printf("[TCP] %s %q\n", src_ip, responsesToString(responses))
fmt.Printf("[TCP] %s %q\n", srcIP, responsesToString(responses))
// Update the threat feed with the source IP address from the interaction.
// Update the threat feed with srcIP. If Proxy Protocol is enabled, srcIP
// is taken from the proxy header. Otherwise, it's the connecting IP.
if cfg.SendToThreatFeed {
threatfeed.Update(src_ip)
threatfeed.Update(srcIP)
}
}

View File

@@ -57,14 +57,14 @@ func Start(cfg *config.Server) {
// addresses, this may not correspond to the IP address that
// received the UDP data. However, this limitation is acceptable as
// the primary goal is to log the received data.
_, dst_port, _ := net.SplitHostPort(conn.LocalAddr().String())
src_ip, _, _ := net.SplitHostPort(remoteAddr.String())
_, dstPort, _ := net.SplitHostPort(conn.LocalAddr().String())
srcIP, _, _ := net.SplitHostPort(remoteAddr.String())
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
slog.String("event_type", "udp"),
slog.String("source_ip", src_ip+" [unreliable]"),
slog.String("source_ip", srcIP+" [unreliable]"),
slog.String("source_reliability", "unreliable"),
slog.String("server_ip", config.GetHostIP()),
slog.String("server_port", dst_port),
slog.String("server_port", dstPort),
slog.String("server_name", config.GetHostname()),
slog.Group("event_details",
slog.String("data", string(buffer[:n])),
@@ -72,7 +72,7 @@ func Start(cfg *config.Server) {
)
// Print a simplified version of the interaction to the console.
fmt.Printf("[UDP] %s Data: %q\n", src_ip, strings.TrimSpace(string(buffer[:n])))
fmt.Printf("[UDP] %s Data: %q\n", srcIP, strings.TrimSpace(string(buffer[:n])))
}()
}
}