9 Commits

Author SHA1 Message Date
Ryan Smith
c0d8651c7f build: clean ./bin/ for Docker build
Running `make all` before `docker build` would result in unwanted binaries being copied from `./bin/` into the Docker image (via `COPY . .`).

This change updates the Docker build to run `make clean build`, which removes the bin directory then builds a fresh binary.

This approach was chosen over including a `.dockerignore` file or using `COPY --exclude ...` (currently listed as unstable).
2025-05-22 10:57:23 -07:00
Ryan Smith
0def1728ee feat: enable live monitoring when logs are disabled
This change adds live monitoring support when logging on a honeypot is disabled.
2025-05-20 10:00:40 -07:00
Ryan Smith
b7b8aa6110 feat(threatfeed): display proxy protocol state
Adds Proxy Protocol state to SSH and TCP honeypots when viewing the configuration via `/config`.
2025-05-16 08:30:38 -07:00
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
9 changed files with 193 additions and 116 deletions

View File

@@ -3,7 +3,7 @@ FROM golang:latest AS build-stage
WORKDIR /build
COPY . .
RUN git update-index -q --refresh
RUN make
RUN make clean build
FROM alpine:latest
RUN apk add --no-cache tzdata

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"`
@@ -221,7 +222,6 @@ func validateRegexRules(rules Rules) error {
func (c *Config) InitializeLoggers() error {
const maxSize = 50
c.Monitor = logmonitor.New()
openedLogFiles := make(map[string]*slog.Logger)
for i := range c.Servers {
@@ -231,14 +231,14 @@ func (c *Config) InitializeLoggers() error {
logPath := c.Servers[i].LogPath
// If no log path is specified or if logging is disabled, discard logs.
// If no log path is specified or logging is disabled, write to a log
// monitor for live monitoring. No log data is written to disk.
if len(logPath) == 0 || !c.Servers[i].LogEnabled {
c.Servers[i].Logger = slog.New(slog.DiscardHandler)
c.Servers[i].Logger = slog.New(slog.NewJSONHandler(c.Monitor, nil))
continue
}
// Check if this log path has already been opened. If so, reuse the
// logger.
// Reuse the logger if this log path has already been opened.
if logger, exists := openedLogFiles[logPath]; exists {
c.Servers[i].Logger = logger
continue
@@ -257,9 +257,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

@@ -52,6 +52,7 @@
<tr><th>Log Path</th><td class="blue">{{if .LogPath}}{{.LogPath}}{{else}}<span class="gray">(not set)</span>{{end}}</td></tr>
{{if eq .Type.String "https"}}<tr><th>Certificate</th><td class="blue">{{if .CertPath}}{{.CertPath}}{{else}}<span class="gray">(not set){{end}}</span></td></tr>{{end}}
{{if or (eq .Type.String "https") (eq .Type.String "ssh")}}<tr><th>Private Key</th><td class="blue">{{if .KeyPath}}{{.KeyPath}}{{else}}<span class="gray">(not set){{end}}</span></td></tr>{{end}}
{{if or (eq .Type.String "tcp") (eq .Type.String "ssh")}}<tr><th>Proxy Protocol</th><td>{{if .UseProxyProtocol}}<span class="green">Enabled{{else}}<span class="gray">Disabled{{end}}</span></td></tr>{{end}}
{{if .HomePagePath}}<tr><th>Home Page</th><td class="blue">{{.HomePagePath}}</td></tr>{{end}}
{{if .ErrorPagePath}}<tr><th>Error Page</th><td class="blue">{{.ErrorPagePath}}</td></tr>{{end}}
{{if .Banner}}<tr><th>Banner</th><td class="magenta">{{.Banner}}</td></tr>{{end}}

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])))
}()
}
}