mirror of
https://github.com/r-smith/deceptifeed.git
synced 2025-11-02 21:23:39 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0d8651c7f | ||
|
|
0def1728ee | ||
|
|
b7b8aa6110 | ||
|
|
fd02995f52 | ||
|
|
4ab8f2dfee | ||
|
|
cefc9952f0 | ||
|
|
5c91ae0e4f | ||
|
|
363c429a1e | ||
|
|
8c97e05f6f | ||
|
|
153191f6c5 | ||
|
|
c83ebcc342 | ||
|
|
a9dcc759f7 | ||
|
|
f9d7b767bc | ||
|
|
375da6eeac | ||
|
|
dc06d64b5b | ||
|
|
41345f04bd | ||
|
|
c0e6010143 | ||
|
|
2736c20158 | ||
|
|
8ebec3a8c4 | ||
|
|
650489bd5c | ||
|
|
da42f21f75 | ||
|
|
0a4d4536ba | ||
|
|
148d99876f | ||
|
|
90fbc24479 | ||
|
|
60fe095dff | ||
|
|
40dbc05d6f | ||
|
|
9e14d3886a | ||
|
|
abaa098099 | ||
|
|
62b166c62a | ||
|
|
53fd03cd46 |
@@ -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
|
||||
|
||||
33
Makefile
33
Makefile
@@ -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
|
||||
|
||||
14
README.md
14
README.md
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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
6
go.mod
@@ -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
16
go.sum
@@ -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=
|
||||
|
||||
95
internal/certutil/certutil.go
Normal file
95
internal/certutil/certutil.go
Normal 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
|
||||
}
|
||||
@@ -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
29
internal/httpserver/fs.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
48
internal/httpserver/middleware.go
Normal file
48
internal/httpserver/middleware.go
Normal 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()
|
||||
}
|
||||
178
internal/proxyproto/proxyproto.go
Normal file
178
internal/proxyproto/proxyproto.go
Normal 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")
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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])))
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user