30 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
Ryan Smith
dc06d64b5b build: prevent -dirty tag in Docker builds
Ensure Docker builds don't add a `-dirty` suffix to the version number.

When building for Docker, `make` is called which in turn calls `git describe` to get the version number. Sometimes this would append `-dirty` even when clean.

This change adds `git update-index -q --refresh` before calling `make` to ensure the version tag is returned correctly.
2025-04-16 15:34:47 -07:00
Ryan Smith
41345f04bd build: add Linux ARM64 build target 2025-04-16 10:34:48 -07:00
Ryan Smith
c0e6010143 build: replace amd64 with x64 in binary names
This change replaces 'amd64' with 'x64' in the file names of the output binaries.

The binary names are now set directly in each build target, rather than declared as variables.
2025-04-16 10:33:36 -07:00
Ryan Smith
2736c20158 feat(threatfeed): display TLS configuration 2025-04-16 08:51:08 -07:00
Ryan Smith
8ebec3a8c4 feat: add TLS support to threat feed server
This change adds optional support for running the threat feed server over HTTPS. This is controlled via the configuration file. Depending on the confgiuration, the threat feed may operate over either HTTP or HTTPS, but not both.

The following configuration options are added to the threat feed (the `<threatFeed>` section in the conffguration file):
- `<enableTLS>` - If `true`, the threat feed uses TLS. If `false` or if this is missing, use HTTP.
- `<certPath>` - Path to TLS cert in PEM format.
- `<keyPath>` - Path to private key in PEM format.

Default configuration files are updated to include the new settings. The TLS feature is off by default. Existing user configuration files only need to be updated if this feature is needed. Otherwise, existing configuration files start the threat feed using HTTP as before.

When the threat feed server starts in TLS mode, it automatically generates a self-signed cert if the cert and key files aen't found.
2025-04-16 08:33:36 -07:00
Ryan Smith
650489bd5c feat: add fixed delay to basic auth 2025-04-16 07:44:24 -07:00
Ryan Smith
da42f21f75 refactor: move cert generator to separate package 2025-04-16 07:43:15 -07:00
Ryan Smith
0a4d4536ba chore: revise error strings and comments 2025-04-16 07:35:58 -07:00
Ryan Smith
148d99876f build: update modules 2025-04-16 07:24:18 -07:00
Ryan Smith
90fbc24479 feat: controlled error responses for HTTP honeypot
Add `withCustomError` middleware that intercepts HTTP error responses and replaces them with a custom error response.

This is used when the HTTP honeypot is configured to serve content from a directory. It ensures that all error responses from http.FileServerFS are controlled and predicatable.
2025-04-15 14:44:26 -07:00
Ryan Smith
60fe095dff feat: disable directory listings when serving custom content
Add noDirectoryFS wrapper to disable directory listings from http.FileServerFS. This is used when the HTTP honeypot is configured to serve custom content from a specified directory.
2025-04-15 14:39:07 -07:00
Ryan Smith
40dbc05d6f feat: serve content from a directory in HTTP honeypot
Add support for serving static files from a directory specified via the existing `homePagePath` setting. When this setting points to a directory, the honeypot serves files rooted at that directory. The original behavior of serving a single file is still supported.

When serving from a directory, the honeypot may serve files from the directory root and from any subdirectories. Symbolic links are followed, provided they don't lead outside the specified root directory.

Main changes:
- Add `responseMode` type to represent how the honeypot serves content (built-in default, specific file, files from a directory).
- Add `responseConfig` struct to store the responseMode and related configuration.
- Add `determineConfig` function to construct a responseConfig when the honeypot starts.
- Update the honeypot request handler to serve content based on the response mode.
- Add `serveErrorPage` function to serve error responses as needed.
2025-04-15 12:48:58 -07:00
Ryan Smith
9e14d3886a Merge pull request #1 from eltociear/patch-1
docs: update README.md
2025-04-10 07:10:47 -07:00
Ikko Eltociear Ashimine
abaa098099 docs: update README.md
honeyot  -> honeypot
2025-04-10 14:51:00 +09:00
Ryan Smith
62b166c62a Update README.md
Update threat feed example output to show 'observations' count rather than 'threat_score'. The threat score feature was replaced with observation count in a previous change.
2025-04-08 11:10:18 -07:00
Ryan Smith
53fd03cd46 Revise screenshot 2025-04-08 10:37:21 -07:00
23 changed files with 816 additions and 337 deletions

View File

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

View File

@@ -2,10 +2,7 @@
SOURCE := ./cmd/deceptifeed/
BIN_DIRECTORY := ./bin/
BIN_DEFAULT := deceptifeed
BIN_LINUX := $(BIN_DEFAULT)_linux_amd64
BIN_FREEBSD := $(BIN_DEFAULT)_freebsd_amd64
BIN_WINDOWS := $(BIN_DEFAULT)_windows_amd64.exe
BIN_DEFAULT := $(BIN_DIRECTORY)deceptifeed
INSTALL_SCRIPT := ./scripts/install.sh
UNINSTALL_SCRIPT := ./scripts/install.sh --uninstall
VERSION := $(shell git describe --tags --dirty --broken)
@@ -17,39 +14,47 @@ CGO_ENABLED := 0
build:
@echo "Building for current operating system..."
@mkdir -p $(BIN_DIRECTORY)
CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DIRECTORY)$(BIN_DEFAULT) $(SOURCE)
@echo "Build complete: $(BIN_DIRECTORY)$(BIN_DEFAULT)"
CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DEFAULT) $(SOURCE)
@echo "Build complete: $(BIN_DEFAULT)"
@echo
.PHONY: all
all: build build-linux build-freebsd build-windows
all: build build-linux build-linux-arm build-freebsd build-windows
.PHONY: build-linux
build-linux:
@echo "Building for Linux..."
@mkdir -p $(BIN_DIRECTORY)
GOOS=linux GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DIRECTORY)$(BIN_LINUX) $(SOURCE)
@echo "Build complete: $(BIN_DIRECTORY)$(BIN_LINUX)"
GOOS=linux GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DEFAULT)_linux_x64 $(SOURCE)
@echo "Build complete: $(BIN_DEFAULT)_linux_x64"
@echo
.PHONY: build-linux-arm
build-linux-arm:
@echo "Building for Linux (ARM)..."
@mkdir -p $(BIN_DIRECTORY)
GOOS=linux GOARCH=arm64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DEFAULT)_linux_ARM64 $(SOURCE)
@echo "Build complete: $(BIN_DEFAULT)_linux_ARM64"
@echo
.PHONY: build-freebsd
build-freebsd:
@echo "Building for FreeBSD..."
@mkdir -p $(BIN_DIRECTORY)
GOOS=freebsd GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DIRECTORY)$(BIN_FREEBSD) $(SOURCE)
@echo "Build complete: $(BIN_DIRECTORY)$(BIN_FREEBSD)"
GOOS=freebsd GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DEFAULT)_freebsd_x64 $(SOURCE)
@echo "Build complete: $(BIN_DEFAULT)_freebsd_x64"
@echo
.PHONY: build-windows
build-windows:
@echo "Building for Windows..."
@mkdir -p $(BIN_DIRECTORY)
GOOS=windows GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DIRECTORY)$(BIN_WINDOWS) $(SOURCE)
@echo "Build complete: $(BIN_DIRECTORY)$(BIN_WINDOWS)"
GOOS=windows GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DEFAULT)_windows_x64.exe $(SOURCE)
@echo "Build complete: $(BIN_DEFAULT)_windows_x64.exe"
@echo
.PHONY: install
install: $(BIN_DIRECTORY)$(BIN_DEFAULT)
install: $(BIN_DEFAULT)
@bash $(INSTALL_SCRIPT)
.PHONY: uninstall

View File

@@ -167,7 +167,7 @@ Here is a breakdown of the arguments:
- **Honeypot Servers:**
- Run any number of honeypot services simultaneously.
- Honeypots are low interaction (no simulated environments for attackers to access).
- **SSH honeyot:** Record and reject login attempts to a fake SSH service.
- **SSH honeypot:** Record and reject login attempts to a fake SSH service.
- **HTTP/HTTPS honeypot:** Record requested URLs and HTTP headers.
- **Generic TCP/UDP services:** Record data sent by connecting clients.
- **Threat Feed Server:**
@@ -228,15 +228,15 @@ $ curl http://threatfeed.example.com:9000/json
"threat_feed": [
{
"ip": "10.32.16.110",
"added": "2024-11-12T16:18:36-08:00",
"last_seen": "2024-11-15T04:27:59-08:00",
"threat_score": 27
"added": "2025-02-12T16:18:36-08:00",
"last_seen": "2025-03-15T04:27:59-08:00",
"observations": 27
},
{
"ip": "192.168.2.21",
"added": "2024-11-14T23:09:11-08:00",
"last_seen": "2024-11-17T00:40:51-08:00",
"threat_score": 51
"added": "2025-04-02T23:09:11-08:00",
"last_seen": "2025-04-08T00:40:51-08:00",
"observations": 51
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -14,6 +14,9 @@
<threatExpiryHours>336</threatExpiryHours>
<includePrivateIPs>false</includePrivateIPs>
<excludeListPath></excludeListPath>
<enableTLS>false</enableTLS>
<certPath>/opt/deceptifeed/certs/threatfeed-cert.pem</certPath>
<keyPath>/opt/deceptifeed/certs/threatfeed-key.pem</keyPath>
</threatFeed>
<!-- Honeypot Server Configuration -->
@@ -25,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>
@@ -35,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>
@@ -52,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>
@@ -72,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

@@ -14,6 +14,9 @@
<threatExpiryHours>336</threatExpiryHours>
<includePrivateIPs>false</includePrivateIPs>
<excludeListPath></excludeListPath>
<enableTLS>false</enableTLS>
<certPath>key-threatfeed-public.pem</certPath>
<keyPath>key-threatfeed-private.pem</keyPath>
</threatFeed>
<!-- Honeypot Server Configuration -->
@@ -25,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>
@@ -35,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>
@@ -52,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>
@@ -72,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.36.0
golang.org/x/net v0.38.0
golang.org/x/crypto v0.38.0
golang.org/x/net v0.40.0
)
require golang.org/x/sys v0.31.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.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
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

@@ -0,0 +1,95 @@
package certutil
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"os"
"time"
)
// GenerateSelfSigned creates a self-signed certificate and returns it as a
// tls.Certificate. If certPath and keyPath are provided, the generated
// certificate and private key are saved to disk.
func GenerateSelfSigned(certPath string, keyPath string) (tls.Certificate, error) {
// Generate 2048-bit RSA private key.
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return tls.Certificate{}, fmt.Errorf("failed to generate private key: %w", err)
}
// Set the certificate validity period to 10 years.
notBefore := time.Now()
notAfter := notBefore.AddDate(10, 0, 0)
// Generate a random certificate serial number.
serialNumber := make([]byte, 16)
_, err = rand.Read(serialNumber)
if err != nil {
return tls.Certificate{}, fmt.Errorf("failed to generate certificate serial number: %w", err)
}
// Configure the certificate template.
template := x509.Certificate{
SerialNumber: new(big.Int).SetBytes(serialNumber),
Subject: pkix.Name{CommonName: "localhost"},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
// Create the certificate.
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return tls.Certificate{}, fmt.Errorf("failed to create certificate: %w", err)
}
certPEM := &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}
keyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}
// Save the certificate and key to disk.
if len(certPath) > 0 && len(keyPath) > 0 {
// Silently ignore any potential errors and continue.
_ = writeCertAndKey(certPEM, keyPEM, certPath, keyPath)
}
return tls.X509KeyPair(pem.EncodeToMemory(certPEM), pem.EncodeToMemory(keyPEM))
}
// writeCertAndKey saves the public certificate and private key in PEM format
// to the specified paths.
func writeCertAndKey(cert *pem.Block, key *pem.Block, certPath string, keyPath string) error {
// Save the certificate file to disk.
certFile, err := os.Create(certPath)
if err != nil {
return err
}
defer certFile.Close()
if err := pem.Encode(certFile, cert); err != nil {
return err
}
// Save the private key file to disk.
keyFile, err := os.Create(keyPath)
if err != nil {
return err
}
defer keyFile.Close()
// Limit key access to the owner only.
_ = keyFile.Chmod(0600)
if err := pem.Encode(keyFile, key); err != nil {
return err
}
return nil
}

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"`
@@ -145,6 +146,9 @@ type ThreatFeed struct {
ExpiryHours int `xml:"threatExpiryHours"`
IsPrivateIncluded bool `xml:"includePrivateIPs"`
ExcludeListPath string `xml:"excludeListPath"`
EnableTLS bool `xml:"enableTLS"`
CertPath string `xml:"certPath"`
KeyPath string `xml:"keyPath"`
}
// Load reads an optional XML configuration file and unmarshals its contents
@@ -218,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 {
@@ -228,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
@@ -254,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
},

29
internal/httpserver/fs.go Normal file
View File

@@ -0,0 +1,29 @@
package httpserver
import "io/fs"
// noDirectoryFS is a wrapper around fs.FS that disables directory listings.
type noDirectoryFS struct {
fs fs.FS
}
// Open opens the named file from the underlying fs.FS. The file is wrapped in
// a noReadDirFile to disable directory listings.
func (fs noDirectoryFS) Open(name string) (fs.File, error) {
f, err := fs.fs.Open(name)
if err != nil {
return nil, err
}
return noReadDirFile{f}, nil
}
// noReadDirFile wraps fs.File and overrides ReadDir to disable directory
// listings.
type noReadDirFile struct {
fs.File
}
// ReadDir always returns an error to disable directory listings.
func (noReadDirFile) ReadDir(int) ([]fs.DirEntry, error) {
return nil, fs.ErrInvalid
}

View File

@@ -2,46 +2,98 @@ package httpserver
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"io"
"io/fs"
"log"
"log/slog"
"math/big"
"net"
"net/http"
"net/netip"
"os"
"regexp"
"strings"
"time"
"github.com/r-smith/deceptifeed/internal/certutil"
"github.com/r-smith/deceptifeed/internal/config"
"github.com/r-smith/deceptifeed/internal/threatfeed"
)
// Start initializes and starts an HTTP or HTTPS honeypot server. The server
// is a simple HTTP server designed to log all details from incoming requests.
// Optionally, a single static HTML file can be served as the homepage,
// otherwise, the server will return only HTTP status codes to clients.
// Interactions with the HTTP server are sent to the threat feed.
// responseMode represents the HTTP response behavior for the honeypot.
// Depending on the configuration, the honeypot can serve a built-in default
// response, serve a specific file, or serve files from a specified directory.
type responseMode int
const (
modeDefault responseMode = iota // Serve the built-in default response.
modeFile // Serve a specific file.
modeDirectory // Serve files from a specified directory.
)
// responseConfig defines how the honeypot serves HTTP responses. It includes
// the response mode (default, file, or directory) and, for directory mode, an
// http.FileServer and file descriptor to the directory.
type responseConfig struct {
mode responseMode
fsRoot *os.Root
fsHandler http.Handler
}
// determineConfig reads the given configuration and returns a responseConfig,
// selecting the honeypot's response mode based on whether the HomePagePath
// setting is empty, a file, or a directory.
func determineConfig(cfg *config.Server) *responseConfig {
if len(cfg.HomePagePath) == 0 {
return &responseConfig{mode: modeDefault}
}
info, err := os.Stat(cfg.HomePagePath)
if err != nil {
return &responseConfig{mode: modeDefault}
}
if info.IsDir() {
root, err := os.OpenRoot(cfg.HomePagePath)
if err != nil {
return &responseConfig{mode: modeDefault}
}
return &responseConfig{
mode: modeDirectory,
fsRoot: root,
fsHandler: withCustomError(http.FileServerFS(noDirectoryFS{root.FS()}), cfg.ErrorPagePath),
}
}
return &responseConfig{
mode: modeFile,
}
}
// Start initializes and starts an HTTP or HTTPS honeypot server. It logs all
// request details and updates the threat feed as needed. If a filesystem path
// is specified in the configuration, the honeypot serves static content from
// the path.
func Start(cfg *config.Server) {
response := determineConfig(cfg)
if response.mode == modeDirectory {
defer response.fsRoot.Close()
}
switch cfg.Type {
case config.HTTP:
listenHTTP(cfg)
listenHTTP(cfg, response)
case config.HTTPS:
listenHTTPS(cfg)
listenHTTPS(cfg, response)
}
}
// listenHTTP initializes and starts an HTTP (plaintext) honeypot server.
func listenHTTP(cfg *config.Server) {
func listenHTTP(cfg *config.Server, response *responseConfig) {
mux := http.NewServeMux()
mux.HandleFunc("/", handleConnection(cfg, parseCustomHeaders(cfg.Headers)))
mux.HandleFunc("/", handleConnection(cfg, parseCustomHeaders(cfg.Headers), response))
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: mux,
@@ -58,10 +110,10 @@ func listenHTTP(cfg *config.Server) {
}
}
// listenHTTP initializes and starts an HTTPS (encrypted) honeypot server.
func listenHTTPS(cfg *config.Server) {
// listenHTTPS initializes and starts an HTTPS (encrypted) honeypot server.
func listenHTTPS(cfg *config.Server, response *responseConfig) {
mux := http.NewServeMux()
mux.HandleFunc("/", handleConnection(cfg, parseCustomHeaders(cfg.Headers)))
mux.HandleFunc("/", handleConnection(cfg, parseCustomHeaders(cfg.Headers), response))
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: mux,
@@ -72,10 +124,9 @@ func listenHTTPS(cfg *config.Server) {
}
// If the cert and key aren't found, generate a self-signed certificate.
if _, err := os.Stat(cfg.CertPath); os.IsNotExist(err) {
if _, err := os.Stat(cfg.KeyPath); os.IsNotExist(err) {
// Generate a self-signed certificate.
cert, err := generateSelfSignedCert(cfg.CertPath, cfg.KeyPath)
if _, err := os.Stat(cfg.CertPath); errors.Is(err, fs.ErrNotExist) {
if _, err := os.Stat(cfg.KeyPath); errors.Is(err, fs.ErrNotExist) {
cert, err := certutil.GenerateSelfSigned(cfg.CertPath, cfg.KeyPath)
if err != nil {
fmt.Fprintln(os.Stderr, "Failed to generate HTTPS certificate:", err)
return
@@ -93,104 +144,143 @@ func listenHTTPS(cfg *config.Server) {
}
}
// handleConnection is the handler for incoming HTTP and HTTPS client requests.
// It logs the details of each request and generates responses based on the
// requested URL. When the root or index.html is requested, it serves either an
// HTML file specified in the configuration or a default page prompting for
// basic HTTP authentication. Requests for any other URLs will return a 404
// error to the client.
func handleConnection(cfg *config.Server, customHeaders map[string]string) http.HandlerFunc {
// handleConnection processes incoming HTTP and HTTPS client requests. It logs
// the details of each request, updates the threat feed, and serves responses
// 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),
),
)
}
// 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...)
// 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 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.
// 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 any custom HTTP response headers.
// Apply optional custom HTTP response headers.
for header, value := range customHeaders {
w.Header().Set(header, value)
}
// Serve a response based on the requested URL. If the root URL or
// /index.html is requested, serve the homepage. For all other
// requests, serve the error page with a 404 Not Found response.
// Optionally, a single static HTML file may be specified for both the
// homepage and the error page. If no custom files are provided,
// default minimal responses will be served.
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
// Serve the homepage response.
if len(cfg.HomePagePath) > 0 {
http.ServeFile(w, r, cfg.HomePagePath)
} else {
// Serve a response based on the honeypot configuration.
switch response.mode {
case modeDefault:
// Built-in default response.
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
if _, _, ok := r.BasicAuth(); ok {
time.Sleep(2 * time.Second)
}
w.Header()["WWW-Authenticate"] = []string{"Basic"}
w.WriteHeader(http.StatusUnauthorized)
} else {
serveErrorPage(w, r, cfg.ErrorPagePath)
}
} else {
// Serve the error page response.
w.WriteHeader(http.StatusNotFound)
if len(cfg.ErrorPagePath) > 0 {
http.ServeFile(w, r, cfg.ErrorPagePath)
case modeFile:
// Serve a single file.
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
http.ServeFile(w, r, cfg.HomePagePath)
} else {
serveErrorPage(w, r, cfg.ErrorPagePath)
}
case modeDirectory:
// Serve files from a directory.
response.fsHandler.ServeHTTP(w, r)
}
}
}
// serveErrorPage serves an error HTTP response code and optional html page.
func serveErrorPage(w http.ResponseWriter, r *http.Request, path string) {
if len(path) == 0 {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
http.ServeFile(w, r, path)
}
// shouldUpdateThreatFeed determines if the threat feed should be updated based
// on the server's configured rules.
func shouldUpdateThreatFeed(cfg *config.Server, r *http.Request) bool {
@@ -295,91 +385,3 @@ func getLocalAddr(r *http.Request) (ip string, port string) {
}
return ip, port
}
// generateSelfSignedCert creates a self-signed TLS certificate and private key
// and returns the resulting tls.Certificate. If file paths are provided, the
// certificate and key are also saved to disk.
func generateSelfSignedCert(certPath string, keyPath string) (tls.Certificate, error) {
// Generate 2048-bit RSA private key.
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return tls.Certificate{}, fmt.Errorf("failed to generate private key: %w", err)
}
// Set the certificate validity period to 10 years.
notBefore := time.Now()
notAfter := notBefore.AddDate(10, 0, 0)
// Generate a random serial number for the certificate.
serialNumber := make([]byte, 16)
_, err = rand.Read(serialNumber)
if err != nil {
return tls.Certificate{}, fmt.Errorf("failed to generate certificate serial number: %w", err)
}
// Set up the template for creating the certificate.
template := x509.Certificate{
SerialNumber: new(big.Int).SetBytes(serialNumber),
Subject: pkix.Name{CommonName: "localhost"},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
}
// Use the template to create a self-signed X.509 certificate.
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return tls.Certificate{}, fmt.Errorf("failed to create certificate: %w", err)
}
certPEM := &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}
keyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}
// Save the certificate and key to disk.
if len(certPath) > 0 && len(keyPath) > 0 {
_ = writeCertAndKey(certPEM, keyPEM, certPath, keyPath)
// If saving fails, ignore the errors and use the in-memory
// certificate.
}
// Parse the public certificate and private key bytes into a tls.Certificate.
cert, err := tls.X509KeyPair(pem.EncodeToMemory(certPEM), pem.EncodeToMemory(keyPEM))
if err != nil {
return tls.Certificate{}, fmt.Errorf("failed to load certificate and private key: %w", err)
}
// Return the tls.Certificate.
return cert, nil
}
// writeCertAndKey saves the public certificate and private key in PEM format
// to the specified file paths.
func writeCertAndKey(cert *pem.Block, key *pem.Block, certPath string, keyPath string) error {
// Save the certificate file to disk.
certFile, err := os.Create(certPath)
if err != nil {
return err
}
defer certFile.Close()
if err := pem.Encode(certFile, cert); err != nil {
return err
}
// Save the private key file to disk.
keyFile, err := os.Create(keyPath)
if err != nil {
return err
}
defer keyFile.Close()
// Limit key access to the owner only.
_ = keyFile.Chmod(0600)
if err := pem.Encode(keyFile, key); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,48 @@
package httpserver
import (
"net/http"
)
// withCustomError is a middleware that intercepts 4xx/5xx HTTP error responses
// and replaces them with a custom error response.
func withCustomError(next http.Handler, errorPath string) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
e := &errorInterceptor{origWriter: w, origRequest: r, errorPath: errorPath}
next.ServeHTTP(e, r)
})
}
// errorInterceptor intercepts HTTP responses to override error status codes
// and to serve a custom error response.
type errorInterceptor struct {
origWriter http.ResponseWriter
origRequest *http.Request
overridden bool
errorPath string
}
// WriteHeader intercepts error response codes (4xx or 5xx) to serve a custom
// error response.
func (e *errorInterceptor) WriteHeader(statusCode int) {
if statusCode >= 400 && statusCode <= 599 {
e.overridden = true
serveErrorPage(e.origWriter, e.origRequest, e.errorPath)
return
}
e.origWriter.WriteHeader(statusCode)
}
// Write writes the response body only if the response code was not overridden.
// Otherwise, the body is discarded.
func (e *errorInterceptor) Write(b []byte) (int, error) {
if !e.overridden {
return e.origWriter.Write(b)
}
return 0, nil
}
// Header returns the response headers from the original ResponseWriter.
func (e *errorInterceptor) Header() http.Header {
return e.origWriter.Header()
}

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
}
@@ -139,7 +173,7 @@ func loadOrGeneratePrivateKey(path string) (ssh.Signer, error) {
// Load the specified file and return the parsed private key.
privateKey, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read private key from '%s': %w", path, err)
return nil, fmt.Errorf("failed to read private key '%s': %w", path, err)
}
signer, err := ssh.ParsePrivateKey(privateKey)
if err != nil {
@@ -150,20 +184,19 @@ func loadOrGeneratePrivateKey(path string) (ssh.Signer, error) {
// Generate and return a new private key.
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("failed to generate RSA private key: %w", err)
return nil, fmt.Errorf("failed to generate private key: %w", err)
}
// Save the private key to disk.
if len(path) > 0 {
// Silently ignore any potential errors and continue.
_ = writePrivateKey(path, privateKey)
// If saving fails, ignore the errors and use the in-memory private
// key.
}
// Convert the key to ssh.Signer.
signer, err := ssh.NewSignerFromKey(privateKey)
if err != nil {
return nil, fmt.Errorf("failed to convert RSA key to SSH signer: %w", err)
return nil, fmt.Errorf("failed to convert key to SSH signer: %w", err)
}
return signer, nil
} else {
@@ -171,8 +204,7 @@ func loadOrGeneratePrivateKey(path string) (ssh.Signer, error) {
}
}
// writePrivateKey saves a private key in PEM format to the specified file
// path.
// writePrivateKey saves a private key in PEM format to the specified path.
func writePrivateKey(path string, privateKey *rsa.PrivateKey) error {
privBytes := x509.MarshalPKCS1PrivateKey(privateKey)
privPem := &pem.Block{

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

@@ -1,11 +1,15 @@
package threatfeed
import (
"crypto/tls"
"errors"
"fmt"
"io/fs"
"net/http"
"os"
"time"
"github.com/r-smith/deceptifeed/internal/certutil"
"github.com/r-smith/deceptifeed/internal/config"
"golang.org/x/net/websocket"
)
@@ -91,9 +95,32 @@ func Start(c *config.Config) {
IdleTimeout: 0,
}
// Start the threat feed HTTP server.
fmt.Printf("Starting Threat Feed server on port: %s\n", c.ThreatFeed.Port)
if err := srv.ListenAndServe(); err != nil {
fmt.Fprintln(os.Stderr, "The Threat Feed server has stopped:", err)
// Start the threat feed (HTTP) server if TLS is not enabled.
if !c.ThreatFeed.EnableTLS {
fmt.Printf("Starting threat feed (HTTP) on port: %s\n", c.ThreatFeed.Port)
if err := srv.ListenAndServe(); err != nil {
fmt.Fprintln(os.Stderr, "The threat feed server has stopped:", err)
}
return
}
// Generate a self-signed cert if the provided key and cert aren't found.
if _, err := os.Stat(c.ThreatFeed.CertPath); errors.Is(err, fs.ErrNotExist) {
if _, err := os.Stat(c.ThreatFeed.KeyPath); errors.Is(err, fs.ErrNotExist) {
cert, err := certutil.GenerateSelfSigned(c.ThreatFeed.CertPath, c.ThreatFeed.KeyPath)
if err != nil {
fmt.Fprintln(os.Stderr, "Failed to generate threat feed TLS certificate:", err)
return
}
// Add cert to server config.
srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
}
}
// Start the threat feed (HTTPS) server.
fmt.Printf("Starting threat feed (HTTPS) on port: %s\n", c.ThreatFeed.Port)
if err := srv.ListenAndServeTLS(c.ThreatFeed.CertPath, c.ThreatFeed.KeyPath); err != nil {
fmt.Fprintln(os.Stderr, "The threat feed server has stopped:", err)
}
}

View File

@@ -29,7 +29,10 @@
</thead>
<tbody>
<tr><th>State</th><td>{{if .C.ThreatFeed.Enabled}}<span class="green">Enabled{{else}}<span class="red">Disabled{{end}}</span></td></tr>
<tr><th>Database</th><td class="blue">{{if .C.ThreatFeed.DatabasePath}}{{.C.ThreatFeed.DatabasePath}}{{else}}<span class="gray">(not set)</span>{{end}}</td></tr>
<tr><th>TLS</th><td>{{if .C.ThreatFeed.EnableTLS}}<span class="green">Enabled{{else}}<span class="red">Disabled{{end}}</span></td></tr>
{{if .C.ThreatFeed.EnableTLS}}<tr><th>Certificate</th><td class="blue">{{if .C.ThreatFeed.CertPath}}{{.C.ThreatFeed.CertPath}}{{else}}<span class="gray">(not set){{end}}</span></td></tr>{{end}}
{{if .C.ThreatFeed.EnableTLS}}<tr><th>Private Key</th><td class="blue">{{if .C.ThreatFeed.KeyPath}}{{.C.ThreatFeed.KeyPath}}{{else}}<span class="gray">(not set){{end}}</span></td></tr>{{end}}
<tr><th>Threat Database</th><td class="blue">{{if .C.ThreatFeed.DatabasePath}}{{.C.ThreatFeed.DatabasePath}}{{else}}<span class="gray">(not set)</span>{{end}}</td></tr>
<tr><th>Include Private IPs</th><td>{{if .C.ThreatFeed.IsPrivateIncluded}}<span class="red">Yes{{else}}<span class="green">No{{end}}</span></td></tr>
<tr><th>Expiry Hours</th><td class="orange">{{if eq .C.ThreatFeed.ExpiryHours 0}}<span class="gray">(never expire)</span>{{else}}{{.C.ThreatFeed.ExpiryHours}}{{end}}</td></tr>
<tr><th>Exclude List</th><td class="blue">{{if .C.ThreatFeed.ExcludeListPath}}{{.C.ThreatFeed.ExcludeListPath}}{{else}}<span class="gray">(not set)</span>{{end}}</td></tr>
@@ -49,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])))
}()
}
}