14 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
Ryan Smith
153191f6c5 feat: add proxy protocol support
This change adds a new proxyproto package to support Proxy Protocol versions 1 and 2. This package allows extraction of the original source IP address from Proxy Protocol headers.
2025-05-13 09:55:54 -07:00
Ryan Smith
c83ebcc342 chore: revise ordering of log data
This change moves the `remote_ip` log field after source IP data when a proxy header is configured.
2025-05-13 07:05:51 -07:00
Ryan Smith
a9dcc759f7 build: update modules 2025-05-08 16:35:03 -07:00
Ryan Smith
f9d7b767bc refactor: switch from net.IP to netip.Addr
This change switches net.IP to netip.Addr added in Go 1.18. This results in slightly better performance and memory utilization for very large threat feeds (over 500,000 entries).
2025-05-08 16:26:33 -07:00
Ryan Smith
375da6eeac feat: log custom header as source IP if set
This change updates the logging behavior of the HTTP honeypot. If a custom custom source IP header is configured:
- The actual connecting IP is logged as `remote_ip`.
- The IP extracted from the header is logged as `source_ip`.
- Any problems extracting an IP from the header results in `source_ip` falling back to the actual connecting IP.
- A new `source_ip_parsed` field indicates whether an IP was extrracted from the header.
- If parsing fails, a `source_ip_error` field is included with the error message.

If no custom header is configured, logging behavior remains unchanged.

This change improves usability of the threat feed web interface when you have HTTP honeypots behind a proxy. By logging the original client IP as `source_ip`, the application now correctly displays the actual source of the connection, rather than your proxy's IP address.
2025-05-08 13:45:58 -07:00
16 changed files with 462 additions and 171 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>

6
go.mod
View File

@@ -3,8 +3,8 @@ module github.com/r-smith/deceptifeed
go 1.24
require (
golang.org/x/crypto v0.37.0
golang.org/x/net v0.39.0
golang.org/x/crypto v0.38.0
golang.org/x/net v0.40.0
)
require golang.org/x/sys v0.32.0 // indirect
require golang.org/x/sys v0.33.0 // indirect

16
go.sum
View File

@@ -1,8 +1,8 @@
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=

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

@@ -11,6 +11,7 @@ import (
"log/slog"
"net"
"net/http"
"net/netip"
"os"
"regexp"
"strings"
@@ -148,65 +149,93 @@ 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 details of the incoming HTTP request.
dst_ip, dst_port := getLocalAddr(r)
src_ip, _, _ := net.SplitHostPort(r.RemoteAddr)
username, password, isAuth := r.BasicAuth()
if isAuth {
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
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()),
slog.Group("event_details",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.String("query", r.URL.RawQuery),
slog.String("user_agent", r.UserAgent()),
slog.String("protocol", r.Proto),
slog.String("host", r.Host),
slog.Group("basic_auth",
slog.String("username", username),
slog.String("password", password),
),
slog.Any("headers", flattenHeaders(r.Header)),
),
// 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 {
// 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]
switch len(header) {
case 0:
errMsg = "missing header " + cfg.SourceIPHeader
case 1:
v := header[0]
if _, err := netip.ParseAddr(v); err != nil {
if strings.Contains(v, ",") {
errMsg = "multiple values in header " + cfg.SourceIPHeader
} else {
errMsg = "invalid IP in header " + cfg.SourceIPHeader
}
} else {
parsed = true
srcIP = v
}
default:
errMsg = "multiple instances of header " + cfg.SourceIPHeader
}
}
// 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),
)
} else {
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
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()),
slog.Group("event_details",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.String("query", r.URL.RawQuery),
slog.String("user_agent", r.UserAgent()),
slog.String("protocol", r.Proto),
slog.String("host", r.Host),
slog.Any("headers", flattenHeaders(r.Header)),
}
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),
slog.String("query", r.URL.RawQuery),
slog.String("user_agent", r.UserAgent()),
slog.String("protocol", r.Proto),
slog.String("host", r.Host),
slog.Any("headers", flattenHeaders(r.Header)),
}
// If the request includes a "basic" Authorization header, decode and
// log the credentials.
if username, password, ok := r.BasicAuth(); ok {
eventDetails = append(eventDetails,
slog.Group("basic_auth",
slog.String("username", username),
slog.String("password", password),
),
)
}
// 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)
// Combine log data and write the final log entry.
logData = append(logData, slog.Group("event_details", eventDetails...))
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "", logData...)
// Update the threat feed with the source IP address from the request.
// If the configuration specifies an HTTP header to be used for the
// source IP, retrieve the header value and use it instead of the
// connecting IP.
// Print a simplified version of the request to the console.
fmt.Printf("[HTTP] %s %s %s %s\n", srcIP, r.Method, r.URL.Path, r.URL.RawQuery)
// 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) {
src := src_ip
if len(cfg.SourceIPHeader) > 0 {
if header := r.Header.Get(cfg.SourceIPHeader); len(header) > 0 {
src = header
}
}
threatfeed.Update(src)
threatfeed.Update(srcIP)
}
// Apply optional custom HTTP response headers.

View File

@@ -0,0 +1,178 @@
package proxyproto
import (
"bufio"
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"net/netip"
"strings"
"time"
)
// v1Signature is the byte representation of "PROXY ", which is the start of a
// Proxy Protocol v1 header.
var v1Signature = []byte{
0x50, 0x52, 0x4F, 0x58, 0x59, 0x20,
}
// v2Signature is a 12-byte constant which is the start of a Proxy Protocol v2
// header.
var v2Signature = []byte{
0x0D, 0x0A, 0x0D, 0x0A,
0x00, 0x0D, 0x0A, 0x51,
0x55, 0x49, 0x54, 0x0A,
}
// serverTimeout defines the duration after which connected clients are
// automatically disconnected, set to 2 seconds.
const serverTimeout = 2 * time.Second
// ReadHeader reads and parses a Proxy Protocol v1 or v2 header from conn. It
// extracts and returns the client IP address from the header. It sets a
// 2-second deadline on conn. If parsing fails, it returns an error. Callers
// should reset the deadline after this function returns to extend the timeout.
func ReadHeader(conn net.Conn) (string, error) {
conn.SetDeadline(time.Now().Add(serverTimeout))
reader := bufio.NewReader(conn)
peek, err := reader.Peek(12)
if err != nil {
return "", errors.New("failed to read proxy header data")
}
var clientIP string
// Determine the Proxy Protocol version and parse accordingly.
if bytes.Equal(peek, v2Signature) {
// Proxy Protocol version 2.
clientIP, err = parseVersion2(reader)
if err != nil {
return "", fmt.Errorf("proxy protocol v2: %w", err)
}
} else if bytes.HasPrefix(peek, v1Signature) {
// Proxy Protocol version 1.
clientIP, err = parseVersion1(reader)
if err != nil {
return "", fmt.Errorf("proxy protocol v1: %w", err)
}
} else {
// Not a Proxy Protocol header.
return "", errors.New("invalid or missing proxy protocol header")
}
// Ensure the header data was provided by a private IP address.
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
if ip, err := netip.ParseAddr(host); err != nil || (!ip.IsPrivate() && !ip.IsLoopback()) {
return "", errors.New("proxy connection must originate from a private IP address")
}
return clientIP, nil
}
// parseVersion1 reads and parses a Proxy Protocol vesion 1 text header and
// returns the extracted source IP address.
func parseVersion1(r *bufio.Reader) (string, error) {
// Proxy Protocol v1 ends with a CRLF (\r\n) and contains no more than 108
// bytes (including the CRLF). Read up to the newline. The presence of a
// carriage return before the newline is not validated.
buf := make([]byte, 0, 108)
for {
b, err := r.ReadByte()
if err != nil {
return "", fmt.Errorf("can't read header: %w", err)
}
buf = append(buf, b)
if b == '\n' {
break
}
if len(buf) == 108 {
return "", errors.New("invalid header")
}
}
// Split into space-delimited parts. When address information is provided,
// this should be exactly 6 parts. Other formats are not supported.
parts := strings.Fields(string(buf))
if len(parts) != 6 {
return "", errors.New("invalid or unsupported format")
}
// Read the protocol part and validate the address part. Protocols other
// than TCP4 and TCP6 are not supported by this implementation.
switch parts[1] {
case "TCP4":
// Parse and validate as an IPv4 address.
if ip, err := netip.ParseAddr(parts[2]); err != nil || !ip.Is4() || !ip.IsValid() {
return "", errors.New("invalid ipv4 source address")
}
case "TCP6":
// Parse and validate as an IPv6 address.
if ip, err := netip.ParseAddr(parts[2]); err != nil || !ip.Is6() || !ip.IsValid() {
return "", errors.New("invalid ipv6 source address")
}
default:
return "", errors.New("invalid or unsupported proxied protocol")
}
// Return the IP address part.
return parts[2], nil
}
// parseVersion2 reads and parses a Proxy Protocol vesion 2 binary header and
// returns the extracted source IP address.
func parseVersion2(r *bufio.Reader) (string, error) {
// Read the first 16 bytes into a buffer. The first 12 bytes is the Proxy
// Protocol v2 signature. Byte 13 is the protocol version and command. Byte
// 14 is the transport protocol and address family. Bytes 15-16 is the
// length of the address data.
header := make([]byte, 16)
if _, err := io.ReadFull(r, header); err != nil {
return "", fmt.Errorf("can't read header: %w", err)
}
// Byte 13 must be 0x21. The upper four bits represent the proxy protocol
// version, which must be 0x2. The lower four bits specify the command -
// 0x1 (PROXY) is the only supported command in this implementation.
if header[12] != 0x21 {
return "", errors.New("unsupported proxy command or version data")
}
// Read bytes 15-16, which specify the length (in bytes) of the address
// data in big-endian format. The address data includes source/destination
// IPs and ports. Read the specified number of bytes into a buffer. The
// length may indicate that additional bytes are part of the header beyond
// the address data. These are Type-Length-Value (TLV) vectors, which are
// read, but ignored by this implementation.
addresses := make([]byte, binary.BigEndian.Uint16(header[14:16]))
if _, err := io.ReadFull(r, addresses); err != nil {
return "", fmt.Errorf("can't read address information: %w", err)
}
// Byte 14 is the transport protocol and address family. Only TCP/UDP
// over IPv4 and IPv6 are supported in this implementation.
addrType := header[13]
// Extract, parse, validate, and return the source IP address.
// TCP over IPv4 = 0x11, UDP over IPv4 = 0x12.
if (addrType == 0x11 || addrType == 0x12) && len(addresses) >= 12 {
ip, ok := netip.AddrFromSlice(addresses[0:4])
if !ok || !ip.IsValid() {
return "", errors.New("invalid ipv4 source address")
}
return ip.String(), nil
}
// TCP over IPv6 = 0x21, UDP over IPv6 = 0x22.
if (addrType == 0x21 || addrType == 0x22) && len(addresses) >= 36 {
ip, ok := netip.AddrFromSlice(addresses[0:16])
if !ok || !ip.IsValid() {
return "", errors.New("invalid ipv6 source address")
}
return ip.String(), nil
}
return "", errors.New("unsupported transport protocol or address family")
}

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

@@ -5,7 +5,7 @@ import (
"encoding/csv"
"errors"
"fmt"
"net"
"net/netip"
"os"
"strconv"
"strings"
@@ -65,11 +65,8 @@ var (
func Update(ip string) {
// Check if the given IP string is a private address. The threat feed may
// be configured to include or exclude private IPs.
netIP := net.ParseIP(ip)
if netIP == nil || netIP.IsLoopback() {
return
}
if !cfg.ThreatFeed.IsPrivateIncluded && netIP.IsPrivate() {
parsedIP, err := netip.ParseAddr(ip)
if err != nil || parsedIP.IsLoopback() || (!cfg.ThreatFeed.IsPrivateIncluded && parsedIP.IsPrivate()) {
return
}

View File

@@ -2,10 +2,9 @@ package threatfeed
import (
"bufio"
"bytes"
"cmp"
"fmt"
"net"
"net/netip"
"os"
"slices"
"strings"
@@ -16,11 +15,11 @@ import (
// feedEntry represents an individual entry in the threat feed.
type feedEntry struct {
IP string `json:"ip"`
IPBytes net.IP `json:"-"`
Added time.Time `json:"added"`
LastSeen time.Time `json:"last_seen"`
Observations int `json:"observations"`
IP string `json:"ip"`
IPBytes netip.Addr `json:"-"`
Added time.Time `json:"added"`
LastSeen time.Time `json:"last_seen"`
Observations int `json:"observations"`
}
// feedEntries is a slice of feedEntry structs. It represents the threat feed
@@ -86,13 +85,13 @@ loop:
continue
}
parsedIP := net.ParseIP(ip)
if parsedIP == nil || (parsedIP.IsPrivate() && !cfg.ThreatFeed.IsPrivateIncluded) {
parsedIP, err := netip.ParseAddr(ip)
if err != nil || (parsedIP.IsPrivate() && !cfg.ThreatFeed.IsPrivateIncluded) {
continue
}
for _, ipnet := range excludedCIDR {
if ipnet.Contains(parsedIP) {
for _, prefix := range excludedCIDR {
if prefix.Contains(parsedIP) {
continue loop
}
}
@@ -120,9 +119,9 @@ loop:
// should contain an IP address or CIDR. It returns a map of the unique IPs and
// a slice of the CIDR ranges found in the file. The file may include comments
// using "#". The "#" symbol on a line and everything after is ignored.
func parseExcludeList(filepath string) (map[string]struct{}, []*net.IPNet, error) {
func parseExcludeList(filepath string) (map[string]struct{}, []netip.Prefix, error) {
if len(filepath) == 0 {
return map[string]struct{}{}, []*net.IPNet{}, nil
return nil, nil, nil
}
f, err := os.Open(filepath)
@@ -134,22 +133,24 @@ func parseExcludeList(filepath string) (map[string]struct{}, []*net.IPNet, error
// `ips` stores individual IPs to exclude, and `cidr` stores CIDR networks
// to exclude.
ips := make(map[string]struct{})
cidr := []*net.IPNet{}
cidr := []netip.Prefix{}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
line := scanner.Text()
// Remove comments from text.
if i := strings.Index(line, "#"); i != -1 {
line = strings.TrimSpace(line[:i])
// Remove comments and trim.
if i := strings.IndexByte(line, '#'); i != -1 {
line = line[:i]
}
line = strings.TrimSpace(line)
if len(line) == 0 {
continue
}
if len(line) > 0 {
if _, ipnet, err := net.ParseCIDR(line); err == nil {
cidr = append(cidr, ipnet)
} else {
ips[line] = struct{}{}
}
if prefix, err := netip.ParsePrefix(line); err == nil {
cidr = append(cidr, prefix)
} else {
ips[line] = struct{}{}
}
}
if err := scanner.Err(); err != nil {
@@ -164,13 +165,13 @@ func (f feedEntries) applySort(method sortMethod, direction sortDirection) {
switch method {
case byIP:
slices.SortFunc(f, func(a, b feedEntry) int {
return bytes.Compare(a.IPBytes, b.IPBytes)
return a.IPBytes.Compare(b.IPBytes)
})
case byLastSeen:
slices.SortFunc(f, func(a, b feedEntry) int {
t := a.LastSeen.Compare(b.LastSeen)
if t == 0 {
return bytes.Compare(a.IPBytes, b.IPBytes)
return a.IPBytes.Compare(b.IPBytes)
}
return t
})
@@ -178,7 +179,7 @@ func (f feedEntries) applySort(method sortMethod, direction sortDirection) {
slices.SortFunc(f, func(a, b feedEntry) int {
t := a.Added.Compare(b.Added)
if t == 0 {
return bytes.Compare(a.IPBytes, b.IPBytes)
return a.IPBytes.Compare(b.IPBytes)
}
return t
})
@@ -186,7 +187,7 @@ func (f feedEntries) applySort(method sortMethod, direction sortDirection) {
slices.SortFunc(f, func(a, b feedEntry) int {
t := cmp.Compare(a.Observations, b.Observations)
if t == 0 {
return bytes.Compare(a.IPBytes, b.IPBytes)
return a.IPBytes.Compare(b.IPBytes)
}
return t
})

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"net"
"net/http"
"net/netip"
"sync"
"golang.org/x/net/websocket"
@@ -65,7 +66,7 @@ func handleWebSocket(ws *websocket.Conn) {
if err != nil {
return
}
if netIP := net.ParseIP(ip); !netIP.IsPrivate() && !netIP.IsLoopback() {
if parsedIP, err := netip.ParseAddr(ip); err != nil || (!parsedIP.IsPrivate() && !parsedIP.IsLoopback()) {
return
}
fmt.Println("[Threat Feed]", ip, "established WebSocket connection")

View File

@@ -3,6 +3,7 @@ package threatfeed
import (
"net"
"net/http"
"net/netip"
)
// enforcePrivateIP is a middleware that restricts access to the HTTP server
@@ -12,11 +13,11 @@ func enforcePrivateIP(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
http.Error(w, "Could not get IP", http.StatusInternalServerError)
http.Error(w, "", http.StatusForbidden)
return
}
if netIP := net.ParseIP(ip); !netIP.IsPrivate() && !netIP.IsLoopback() {
if parsedIP, err := netip.ParseAddr(ip); err != nil || (!parsedIP.IsPrivate() && !parsedIP.IsLoopback()) {
http.Error(w, "", http.StatusForbidden)
return
}

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