mirror of
https://github.com/r-smith/deceptifeed.git
synced 2025-11-04 14:13:49 +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
|
FROM golang:latest AS build-stage
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN make
|
RUN git update-index -q --refresh
|
||||||
|
RUN make clean build
|
||||||
|
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
RUN apk add --no-cache tzdata
|
RUN apk add --no-cache tzdata
|
||||||
|
|||||||
33
Makefile
33
Makefile
@@ -2,10 +2,7 @@
|
|||||||
|
|
||||||
SOURCE := ./cmd/deceptifeed/
|
SOURCE := ./cmd/deceptifeed/
|
||||||
BIN_DIRECTORY := ./bin/
|
BIN_DIRECTORY := ./bin/
|
||||||
BIN_DEFAULT := deceptifeed
|
BIN_DEFAULT := $(BIN_DIRECTORY)deceptifeed
|
||||||
BIN_LINUX := $(BIN_DEFAULT)_linux_amd64
|
|
||||||
BIN_FREEBSD := $(BIN_DEFAULT)_freebsd_amd64
|
|
||||||
BIN_WINDOWS := $(BIN_DEFAULT)_windows_amd64.exe
|
|
||||||
INSTALL_SCRIPT := ./scripts/install.sh
|
INSTALL_SCRIPT := ./scripts/install.sh
|
||||||
UNINSTALL_SCRIPT := ./scripts/install.sh --uninstall
|
UNINSTALL_SCRIPT := ./scripts/install.sh --uninstall
|
||||||
VERSION := $(shell git describe --tags --dirty --broken)
|
VERSION := $(shell git describe --tags --dirty --broken)
|
||||||
@@ -17,39 +14,47 @@ CGO_ENABLED := 0
|
|||||||
build:
|
build:
|
||||||
@echo "Building for current operating system..."
|
@echo "Building for current operating system..."
|
||||||
@mkdir -p $(BIN_DIRECTORY)
|
@mkdir -p $(BIN_DIRECTORY)
|
||||||
CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DIRECTORY)$(BIN_DEFAULT) $(SOURCE)
|
CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DEFAULT) $(SOURCE)
|
||||||
@echo "Build complete: $(BIN_DIRECTORY)$(BIN_DEFAULT)"
|
@echo "Build complete: $(BIN_DEFAULT)"
|
||||||
@echo
|
@echo
|
||||||
|
|
||||||
.PHONY: all
|
.PHONY: all
|
||||||
all: build build-linux build-freebsd build-windows
|
all: build build-linux build-linux-arm build-freebsd build-windows
|
||||||
|
|
||||||
.PHONY: build-linux
|
.PHONY: build-linux
|
||||||
build-linux:
|
build-linux:
|
||||||
@echo "Building for Linux..."
|
@echo "Building for Linux..."
|
||||||
@mkdir -p $(BIN_DIRECTORY)
|
@mkdir -p $(BIN_DIRECTORY)
|
||||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DIRECTORY)$(BIN_LINUX) $(SOURCE)
|
GOOS=linux GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DEFAULT)_linux_x64 $(SOURCE)
|
||||||
@echo "Build complete: $(BIN_DIRECTORY)$(BIN_LINUX)"
|
@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
|
@echo
|
||||||
|
|
||||||
.PHONY: build-freebsd
|
.PHONY: build-freebsd
|
||||||
build-freebsd:
|
build-freebsd:
|
||||||
@echo "Building for FreeBSD..."
|
@echo "Building for FreeBSD..."
|
||||||
@mkdir -p $(BIN_DIRECTORY)
|
@mkdir -p $(BIN_DIRECTORY)
|
||||||
GOOS=freebsd GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DIRECTORY)$(BIN_FREEBSD) $(SOURCE)
|
GOOS=freebsd GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DEFAULT)_freebsd_x64 $(SOURCE)
|
||||||
@echo "Build complete: $(BIN_DIRECTORY)$(BIN_FREEBSD)"
|
@echo "Build complete: $(BIN_DEFAULT)_freebsd_x64"
|
||||||
@echo
|
@echo
|
||||||
|
|
||||||
.PHONY: build-windows
|
.PHONY: build-windows
|
||||||
build-windows:
|
build-windows:
|
||||||
@echo "Building for Windows..."
|
@echo "Building for Windows..."
|
||||||
@mkdir -p $(BIN_DIRECTORY)
|
@mkdir -p $(BIN_DIRECTORY)
|
||||||
GOOS=windows GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DIRECTORY)$(BIN_WINDOWS) $(SOURCE)
|
GOOS=windows GOARCH=amd64 CGO_ENABLED=$(CGO_ENABLED) $(GO) build $(BUILD_OPTIONS) -o $(BIN_DEFAULT)_windows_x64.exe $(SOURCE)
|
||||||
@echo "Build complete: $(BIN_DIRECTORY)$(BIN_WINDOWS)"
|
@echo "Build complete: $(BIN_DEFAULT)_windows_x64.exe"
|
||||||
@echo
|
@echo
|
||||||
|
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
install: $(BIN_DIRECTORY)$(BIN_DEFAULT)
|
install: $(BIN_DEFAULT)
|
||||||
@bash $(INSTALL_SCRIPT)
|
@bash $(INSTALL_SCRIPT)
|
||||||
|
|
||||||
.PHONY: uninstall
|
.PHONY: uninstall
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -167,7 +167,7 @@ Here is a breakdown of the arguments:
|
|||||||
- **Honeypot Servers:**
|
- **Honeypot Servers:**
|
||||||
- Run any number of honeypot services simultaneously.
|
- Run any number of honeypot services simultaneously.
|
||||||
- Honeypots are low interaction (no simulated environments for attackers to access).
|
- 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.
|
- **HTTP/HTTPS honeypot:** Record requested URLs and HTTP headers.
|
||||||
- **Generic TCP/UDP services:** Record data sent by connecting clients.
|
- **Generic TCP/UDP services:** Record data sent by connecting clients.
|
||||||
- **Threat Feed Server:**
|
- **Threat Feed Server:**
|
||||||
@@ -228,15 +228,15 @@ $ curl http://threatfeed.example.com:9000/json
|
|||||||
"threat_feed": [
|
"threat_feed": [
|
||||||
{
|
{
|
||||||
"ip": "10.32.16.110",
|
"ip": "10.32.16.110",
|
||||||
"added": "2024-11-12T16:18:36-08:00",
|
"added": "2025-02-12T16:18:36-08:00",
|
||||||
"last_seen": "2024-11-15T04:27:59-08:00",
|
"last_seen": "2025-03-15T04:27:59-08:00",
|
||||||
"threat_score": 27
|
"observations": 27
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ip": "192.168.2.21",
|
"ip": "192.168.2.21",
|
||||||
"added": "2024-11-14T23:09:11-08:00",
|
"added": "2025-04-02T23:09:11-08:00",
|
||||||
"last_seen": "2024-11-17T00:40:51-08:00",
|
"last_seen": "2025-04-08T00:40:51-08:00",
|
||||||
"threat_score": 51
|
"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>
|
<threatExpiryHours>336</threatExpiryHours>
|
||||||
<includePrivateIPs>false</includePrivateIPs>
|
<includePrivateIPs>false</includePrivateIPs>
|
||||||
<excludeListPath></excludeListPath>
|
<excludeListPath></excludeListPath>
|
||||||
|
<enableTLS>false</enableTLS>
|
||||||
|
<certPath>/opt/deceptifeed/certs/threatfeed-cert.pem</certPath>
|
||||||
|
<keyPath>/opt/deceptifeed/certs/threatfeed-key.pem</keyPath>
|
||||||
</threatFeed>
|
</threatFeed>
|
||||||
|
|
||||||
<!-- Honeypot Server Configuration -->
|
<!-- Honeypot Server Configuration -->
|
||||||
@@ -25,6 +28,7 @@
|
|||||||
<port>2222</port>
|
<port>2222</port>
|
||||||
<logEnabled>true</logEnabled>
|
<logEnabled>true</logEnabled>
|
||||||
<sendToThreatFeed>true</sendToThreatFeed>
|
<sendToThreatFeed>true</sendToThreatFeed>
|
||||||
|
<useProxyProtocol>false</useProxyProtocol>
|
||||||
<keyPath>/opt/deceptifeed/certs/ssh-key.pem</keyPath>
|
<keyPath>/opt/deceptifeed/certs/ssh-key.pem</keyPath>
|
||||||
<banner>SSH-2.0-OpenSSH_9.6</banner>
|
<banner>SSH-2.0-OpenSSH_9.6</banner>
|
||||||
</server>
|
</server>
|
||||||
@@ -35,6 +39,7 @@
|
|||||||
<port>8080</port>
|
<port>8080</port>
|
||||||
<logEnabled>true</logEnabled>
|
<logEnabled>true</logEnabled>
|
||||||
<sendToThreatFeed>true</sendToThreatFeed>
|
<sendToThreatFeed>true</sendToThreatFeed>
|
||||||
|
<sourceIpHeader></sourceIpHeader>
|
||||||
<rules>
|
<rules>
|
||||||
<!-- Update the threat feed if any of the following rules match: -->
|
<!-- 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>
|
<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>
|
<port>8443</port>
|
||||||
<logEnabled>true</logEnabled>
|
<logEnabled>true</logEnabled>
|
||||||
<sendToThreatFeed>true</sendToThreatFeed>
|
<sendToThreatFeed>true</sendToThreatFeed>
|
||||||
|
<sourceIpHeader></sourceIpHeader>
|
||||||
<certPath>/opt/deceptifeed/certs/https-cert.pem</certPath>
|
<certPath>/opt/deceptifeed/certs/https-cert.pem</certPath>
|
||||||
<keyPath>/opt/deceptifeed/certs/https-key.pem</keyPath>
|
<keyPath>/opt/deceptifeed/certs/https-key.pem</keyPath>
|
||||||
<rules>
|
<rules>
|
||||||
@@ -72,6 +78,7 @@
|
|||||||
<port>2323</port>
|
<port>2323</port>
|
||||||
<logEnabled>true</logEnabled>
|
<logEnabled>true</logEnabled>
|
||||||
<sendToThreatFeed>true</sendToThreatFeed>
|
<sendToThreatFeed>true</sendToThreatFeed>
|
||||||
|
<useProxyProtocol>false</useProxyProtocol>
|
||||||
<banner>\nUser Access Verification\n\n</banner>
|
<banner>\nUser Access Verification\n\n</banner>
|
||||||
<prompts>
|
<prompts>
|
||||||
<prompt log="username">Username: </prompt>
|
<prompt log="username">Username: </prompt>
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
<threatExpiryHours>336</threatExpiryHours>
|
<threatExpiryHours>336</threatExpiryHours>
|
||||||
<includePrivateIPs>false</includePrivateIPs>
|
<includePrivateIPs>false</includePrivateIPs>
|
||||||
<excludeListPath></excludeListPath>
|
<excludeListPath></excludeListPath>
|
||||||
|
<enableTLS>false</enableTLS>
|
||||||
|
<certPath>key-threatfeed-public.pem</certPath>
|
||||||
|
<keyPath>key-threatfeed-private.pem</keyPath>
|
||||||
</threatFeed>
|
</threatFeed>
|
||||||
|
|
||||||
<!-- Honeypot Server Configuration -->
|
<!-- Honeypot Server Configuration -->
|
||||||
@@ -25,6 +28,7 @@
|
|||||||
<port>2222</port>
|
<port>2222</port>
|
||||||
<logEnabled>true</logEnabled>
|
<logEnabled>true</logEnabled>
|
||||||
<sendToThreatFeed>true</sendToThreatFeed>
|
<sendToThreatFeed>true</sendToThreatFeed>
|
||||||
|
<useProxyProtocol>false</useProxyProtocol>
|
||||||
<keyPath>key-ssh-private.pem</keyPath>
|
<keyPath>key-ssh-private.pem</keyPath>
|
||||||
<banner>SSH-2.0-OpenSSH_9.6</banner>
|
<banner>SSH-2.0-OpenSSH_9.6</banner>
|
||||||
</server>
|
</server>
|
||||||
@@ -35,6 +39,7 @@
|
|||||||
<port>8080</port>
|
<port>8080</port>
|
||||||
<logEnabled>true</logEnabled>
|
<logEnabled>true</logEnabled>
|
||||||
<sendToThreatFeed>true</sendToThreatFeed>
|
<sendToThreatFeed>true</sendToThreatFeed>
|
||||||
|
<sourceIpHeader></sourceIpHeader>
|
||||||
<rules>
|
<rules>
|
||||||
<!-- Update the threat feed if any of the following rules match: -->
|
<!-- 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>
|
<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>
|
<port>8443</port>
|
||||||
<logEnabled>true</logEnabled>
|
<logEnabled>true</logEnabled>
|
||||||
<sendToThreatFeed>true</sendToThreatFeed>
|
<sendToThreatFeed>true</sendToThreatFeed>
|
||||||
|
<sourceIpHeader></sourceIpHeader>
|
||||||
<certPath>key-https-public.pem</certPath>
|
<certPath>key-https-public.pem</certPath>
|
||||||
<keyPath>key-https-private.pem</keyPath>
|
<keyPath>key-https-private.pem</keyPath>
|
||||||
<rules>
|
<rules>
|
||||||
@@ -72,6 +78,7 @@
|
|||||||
<port>2323</port>
|
<port>2323</port>
|
||||||
<logEnabled>true</logEnabled>
|
<logEnabled>true</logEnabled>
|
||||||
<sendToThreatFeed>true</sendToThreatFeed>
|
<sendToThreatFeed>true</sendToThreatFeed>
|
||||||
|
<useProxyProtocol>false</useProxyProtocol>
|
||||||
<banner>\nUser Access Verification\n\n</banner>
|
<banner>\nUser Access Verification\n\n</banner>
|
||||||
<prompts>
|
<prompts>
|
||||||
<prompt log="username">Username: </prompt>
|
<prompt log="username">Username: </prompt>
|
||||||
|
|||||||
6
go.mod
6
go.mod
@@ -3,8 +3,8 @@ module github.com/r-smith/deceptifeed
|
|||||||
go 1.24
|
go 1.24
|
||||||
|
|
||||||
require (
|
require (
|
||||||
golang.org/x/crypto v0.36.0
|
golang.org/x/crypto v0.38.0
|
||||||
golang.org/x/net 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.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
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"`
|
Headers []string `xml:"headers>header"`
|
||||||
Prompts []Prompt `xml:"prompts>prompt"`
|
Prompts []Prompt `xml:"prompts>prompt"`
|
||||||
SendToThreatFeed bool `xml:"sendToThreatFeed"`
|
SendToThreatFeed bool `xml:"sendToThreatFeed"`
|
||||||
|
UseProxyProtocol bool `xml:"useProxyProtocol"`
|
||||||
Rules Rules `xml:"rules"`
|
Rules Rules `xml:"rules"`
|
||||||
SourceIPHeader string `xml:"sourceIpHeader"`
|
SourceIPHeader string `xml:"sourceIpHeader"`
|
||||||
LogPath string `xml:"logPath"`
|
LogPath string `xml:"logPath"`
|
||||||
@@ -145,6 +146,9 @@ type ThreatFeed struct {
|
|||||||
ExpiryHours int `xml:"threatExpiryHours"`
|
ExpiryHours int `xml:"threatExpiryHours"`
|
||||||
IsPrivateIncluded bool `xml:"includePrivateIPs"`
|
IsPrivateIncluded bool `xml:"includePrivateIPs"`
|
||||||
ExcludeListPath string `xml:"excludeListPath"`
|
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
|
// 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 {
|
func (c *Config) InitializeLoggers() error {
|
||||||
const maxSize = 50
|
const maxSize = 50
|
||||||
c.Monitor = logmonitor.New()
|
c.Monitor = logmonitor.New()
|
||||||
|
|
||||||
openedLogFiles := make(map[string]*slog.Logger)
|
openedLogFiles := make(map[string]*slog.Logger)
|
||||||
|
|
||||||
for i := range c.Servers {
|
for i := range c.Servers {
|
||||||
@@ -228,14 +231,14 @@ func (c *Config) InitializeLoggers() error {
|
|||||||
|
|
||||||
logPath := c.Servers[i].LogPath
|
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 {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this log path has already been opened. If so, reuse the
|
// Reuse the logger if this log path has already been opened.
|
||||||
// logger.
|
|
||||||
if logger, exists := openedLogFiles[logPath]; exists {
|
if logger, exists := openedLogFiles[logPath]; exists {
|
||||||
c.Servers[i].Logger = logger
|
c.Servers[i].Logger = logger
|
||||||
continue
|
continue
|
||||||
@@ -254,9 +257,15 @@ func (c *Config) InitializeLoggers() error {
|
|||||||
io.MultiWriter(file, c.Monitor),
|
io.MultiWriter(file, c.Monitor),
|
||||||
&slog.HandlerOptions{
|
&slog.HandlerOptions{
|
||||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||||
// Remove 'message' and 'log level' fields from output.
|
switch a.Key {
|
||||||
if a.Key == slog.MessageKey || a.Key == slog.LevelKey {
|
case slog.MessageKey, slog.LevelKey:
|
||||||
|
// Remove default 'message' and 'log level' fields.
|
||||||
return slog.Attr{}
|
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
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"errors"
|
||||||
"crypto/x509/pkix"
|
|
||||||
"encoding/pem"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/big"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/r-smith/deceptifeed/internal/certutil"
|
||||||
"github.com/r-smith/deceptifeed/internal/config"
|
"github.com/r-smith/deceptifeed/internal/config"
|
||||||
"github.com/r-smith/deceptifeed/internal/threatfeed"
|
"github.com/r-smith/deceptifeed/internal/threatfeed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Start initializes and starts an HTTP or HTTPS honeypot server. The server
|
// responseMode represents the HTTP response behavior for the honeypot.
|
||||||
// is a simple HTTP server designed to log all details from incoming requests.
|
// Depending on the configuration, the honeypot can serve a built-in default
|
||||||
// Optionally, a single static HTML file can be served as the homepage,
|
// response, serve a specific file, or serve files from a specified directory.
|
||||||
// otherwise, the server will return only HTTP status codes to clients.
|
type responseMode int
|
||||||
// Interactions with the HTTP server are sent to the threat feed.
|
|
||||||
|
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) {
|
func Start(cfg *config.Server) {
|
||||||
|
response := determineConfig(cfg)
|
||||||
|
if response.mode == modeDirectory {
|
||||||
|
defer response.fsRoot.Close()
|
||||||
|
}
|
||||||
|
|
||||||
switch cfg.Type {
|
switch cfg.Type {
|
||||||
case config.HTTP:
|
case config.HTTP:
|
||||||
listenHTTP(cfg)
|
listenHTTP(cfg, response)
|
||||||
case config.HTTPS:
|
case config.HTTPS:
|
||||||
listenHTTPS(cfg)
|
listenHTTPS(cfg, response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// listenHTTP initializes and starts an HTTP (plaintext) honeypot server.
|
// 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 := http.NewServeMux()
|
||||||
mux.HandleFunc("/", handleConnection(cfg, parseCustomHeaders(cfg.Headers)))
|
mux.HandleFunc("/", handleConnection(cfg, parseCustomHeaders(cfg.Headers), response))
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
@@ -58,10 +110,10 @@ func listenHTTP(cfg *config.Server) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// listenHTTP initializes and starts an HTTPS (encrypted) honeypot server.
|
// listenHTTPS initializes and starts an HTTPS (encrypted) honeypot server.
|
||||||
func listenHTTPS(cfg *config.Server) {
|
func listenHTTPS(cfg *config.Server, response *responseConfig) {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/", handleConnection(cfg, parseCustomHeaders(cfg.Headers)))
|
mux.HandleFunc("/", handleConnection(cfg, parseCustomHeaders(cfg.Headers), response))
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
Handler: mux,
|
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 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.CertPath); errors.Is(err, fs.ErrNotExist) {
|
||||||
if _, err := os.Stat(cfg.KeyPath); os.IsNotExist(err) {
|
if _, err := os.Stat(cfg.KeyPath); errors.Is(err, fs.ErrNotExist) {
|
||||||
// Generate a self-signed certificate.
|
cert, err := certutil.GenerateSelfSigned(cfg.CertPath, cfg.KeyPath)
|
||||||
cert, err := generateSelfSignedCert(cfg.CertPath, cfg.KeyPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "Failed to generate HTTPS certificate:", err)
|
fmt.Fprintln(os.Stderr, "Failed to generate HTTPS certificate:", err)
|
||||||
return
|
return
|
||||||
@@ -93,102 +144,141 @@ func listenHTTPS(cfg *config.Server) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleConnection is the handler for incoming HTTP and HTTPS client requests.
|
// handleConnection processes incoming HTTP and HTTPS client requests. It logs
|
||||||
// It logs the details of each request and generates responses based on the
|
// the details of each request, updates the threat feed, and serves responses
|
||||||
// requested URL. When the root or index.html is requested, it serves either an
|
// based on the honeypot configuration.
|
||||||
// HTML file specified in the configuration or a default page prompting for
|
func handleConnection(cfg *config.Server, customHeaders map[string]string, response *responseConfig) http.HandlerFunc {
|
||||||
// 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 {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Log details of the incoming HTTP request.
|
// Record connection details.
|
||||||
dst_ip, dst_port := getLocalAddr(r)
|
dstIP, dstPort := getLocalAddr(r)
|
||||||
src_ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
srcIP, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||||
username, password, isAuth := r.BasicAuth()
|
var remIP string
|
||||||
if isAuth {
|
var parsed bool
|
||||||
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
|
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("event_type", "http"),
|
||||||
slog.String("source_ip", src_ip),
|
slog.String("source_ip", srcIP),
|
||||||
slog.String("server_ip", dst_ip),
|
)
|
||||||
slog.String("server_port", dst_port),
|
if len(cfg.SourceIPHeader) > 0 {
|
||||||
|
logData = append(logData,
|
||||||
|
slog.Bool("source_ip_parsed", parsed),
|
||||||
|
slog.String("source_ip_error", errMsg),
|
||||||
|
slog.String("remote_ip", remIP),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
logData = append(logData,
|
||||||
|
slog.String("server_ip", dstIP),
|
||||||
|
slog.String("server_port", dstPort),
|
||||||
slog.String("server_name", config.GetHostname()),
|
slog.String("server_name", config.GetHostname()),
|
||||||
slog.Group("event_details",
|
)
|
||||||
|
|
||||||
|
// Log the HTTP request information.
|
||||||
|
eventDetails := []any{
|
||||||
slog.String("method", r.Method),
|
slog.String("method", r.Method),
|
||||||
slog.String("path", r.URL.Path),
|
slog.String("path", r.URL.Path),
|
||||||
slog.String("query", r.URL.RawQuery),
|
slog.String("query", r.URL.RawQuery),
|
||||||
slog.String("user_agent", r.UserAgent()),
|
slog.String("user_agent", r.UserAgent()),
|
||||||
slog.String("protocol", r.Proto),
|
slog.String("protocol", r.Proto),
|
||||||
slog.String("host", r.Host),
|
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.Group("basic_auth",
|
||||||
slog.String("username", username),
|
slog.String("username", username),
|
||||||
slog.String("password", password),
|
slog.String("password", password),
|
||||||
),
|
),
|
||||||
slog.Any("headers", flattenHeaders(r.Header)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} 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)),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// 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.
|
// Update the threat feed with srcIP. If Proxy Protocol is enabled, srcIP
|
||||||
// If the configuration specifies an HTTP header to be used for the
|
// is taken from the proxy header. Otherwise, it's the connecting IP.
|
||||||
// source IP, retrieve the header value and use it instead of the
|
|
||||||
// connecting IP.
|
|
||||||
if shouldUpdateThreatFeed(cfg, r) {
|
if shouldUpdateThreatFeed(cfg, r) {
|
||||||
src := src_ip
|
threatfeed.Update(srcIP)
|
||||||
if len(cfg.SourceIPHeader) > 0 {
|
|
||||||
if header := r.Header.Get(cfg.SourceIPHeader); len(header) > 0 {
|
|
||||||
src = header
|
|
||||||
}
|
|
||||||
}
|
|
||||||
threatfeed.Update(src)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply any custom HTTP response headers.
|
// Apply optional custom HTTP response headers.
|
||||||
for header, value := range customHeaders {
|
for header, value := range customHeaders {
|
||||||
w.Header().Set(header, value)
|
w.Header().Set(header, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve a response based on the requested URL. If the root URL or
|
// Serve a response based on the honeypot configuration.
|
||||||
// /index.html is requested, serve the homepage. For all other
|
switch response.mode {
|
||||||
// requests, serve the error page with a 404 Not Found response.
|
case modeDefault:
|
||||||
// Optionally, a single static HTML file may be specified for both the
|
// Built-in default response.
|
||||||
// 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" {
|
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||||
// Serve the homepage response.
|
if _, _, ok := r.BasicAuth(); ok {
|
||||||
if len(cfg.HomePagePath) > 0 {
|
time.Sleep(2 * time.Second)
|
||||||
http.ServeFile(w, r, cfg.HomePagePath)
|
}
|
||||||
} else {
|
|
||||||
w.Header()["WWW-Authenticate"] = []string{"Basic"}
|
w.Header()["WWW-Authenticate"] = []string{"Basic"}
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Serve the error page response.
|
serveErrorPage(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)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
if len(cfg.ErrorPagePath) > 0 {
|
return
|
||||||
http.ServeFile(w, r, cfg.ErrorPagePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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
|
// shouldUpdateThreatFeed determines if the threat feed should be updated based
|
||||||
@@ -295,91 +385,3 @@ func getLocalAddr(r *http.Request) (ip string, port string) {
|
|||||||
}
|
}
|
||||||
return ip, port
|
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"
|
"time"
|
||||||
|
|
||||||
"github.com/r-smith/deceptifeed/internal/config"
|
"github.com/r-smith/deceptifeed/internal/config"
|
||||||
|
"github.com/r-smith/deceptifeed/internal/proxyproto"
|
||||||
"github.com/r-smith/deceptifeed/internal/threatfeed"
|
"github.com/r-smith/deceptifeed/internal/threatfeed"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
@@ -62,39 +63,6 @@ func Start(cfg *config.Server) {
|
|||||||
return nil, fmt.Errorf("permission denied")
|
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.
|
// Start the SSH server.
|
||||||
listener, err := net.Listen("tcp", ":"+cfg.Port)
|
listener, err := net.Listen("tcp", ":"+cfg.Port)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -110,21 +78,87 @@ func Start(cfg *config.Server) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
go handleConnection(conn, sshConfig)
|
go handleConnection(conn, sshConfig, cfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleConnection manages incoming SSH client connections. It performs the
|
// handleConnection manages incoming SSH client connections. It performs the
|
||||||
// handshake and handles authentication callbacks.
|
// 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()
|
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))
|
_ = 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
|
// Perform handshake and authentication. Authentication callbacks are
|
||||||
// defined in the SSH server configuration. Since authentication requests
|
// defined in the SSH server configuration. Since authentication requests
|
||||||
// are always rejected, this function will consistently return an error,
|
// are always rejected, this function will consistently return an error,
|
||||||
// and no further connection handling is necessary.
|
// and no further connection handling is necessary.
|
||||||
sshConn, _, _, err := ssh.NewServerConn(conn, config)
|
sshConn, _, _, err := ssh.NewServerConn(conn, sshConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -139,7 +173,7 @@ func loadOrGeneratePrivateKey(path string) (ssh.Signer, error) {
|
|||||||
// Load the specified file and return the parsed private key.
|
// Load the specified file and return the parsed private key.
|
||||||
privateKey, err := os.ReadFile(path)
|
privateKey, err := os.ReadFile(path)
|
||||||
if err != nil {
|
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)
|
signer, err := ssh.ParsePrivateKey(privateKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -150,20 +184,19 @@ func loadOrGeneratePrivateKey(path string) (ssh.Signer, error) {
|
|||||||
// Generate and return a new private key.
|
// Generate and return a new private key.
|
||||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
if err != nil {
|
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.
|
// Save the private key to disk.
|
||||||
if len(path) > 0 {
|
if len(path) > 0 {
|
||||||
|
// Silently ignore any potential errors and continue.
|
||||||
_ = writePrivateKey(path, privateKey)
|
_ = writePrivateKey(path, privateKey)
|
||||||
// If saving fails, ignore the errors and use the in-memory private
|
|
||||||
// key.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert the key to ssh.Signer.
|
// Convert the key to ssh.Signer.
|
||||||
signer, err := ssh.NewSignerFromKey(privateKey)
|
signer, err := ssh.NewSignerFromKey(privateKey)
|
||||||
if err != nil {
|
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
|
return signer, nil
|
||||||
} else {
|
} else {
|
||||||
@@ -171,8 +204,7 @@ func loadOrGeneratePrivateKey(path string) (ssh.Signer, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// writePrivateKey saves a private key in PEM format to the specified file
|
// writePrivateKey saves a private key in PEM format to the specified path.
|
||||||
// path.
|
|
||||||
func writePrivateKey(path string, privateKey *rsa.PrivateKey) error {
|
func writePrivateKey(path string, privateKey *rsa.PrivateKey) error {
|
||||||
privBytes := x509.MarshalPKCS1PrivateKey(privateKey)
|
privBytes := x509.MarshalPKCS1PrivateKey(privateKey)
|
||||||
privPem := &pem.Block{
|
privPem := &pem.Block{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/r-smith/deceptifeed/internal/config"
|
"github.com/r-smith/deceptifeed/internal/config"
|
||||||
|
"github.com/r-smith/deceptifeed/internal/proxyproto"
|
||||||
"github.com/r-smith/deceptifeed/internal/threatfeed"
|
"github.com/r-smith/deceptifeed/internal/threatfeed"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,6 +32,13 @@ func Start(cfg *config.Server) {
|
|||||||
}
|
}
|
||||||
defer listener.Close()
|
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.
|
// Listen for and accept incoming connections.
|
||||||
for {
|
for {
|
||||||
conn, err := listener.Accept()
|
conn, err := listener.Accept()
|
||||||
@@ -48,21 +56,39 @@ func Start(cfg *config.Server) {
|
|||||||
// client interaction.
|
// client interaction.
|
||||||
func handleConnection(conn net.Conn, cfg *config.Server) {
|
func handleConnection(conn net.Conn, cfg *config.Server) {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
_ = conn.SetDeadline(time.Now().Add(serverTimeout))
|
|
||||||
|
|
||||||
// Print an optional banner. Replace any occurrences of the newline escape
|
// Record connection details.
|
||||||
// sequence "\\n" with "\r\n" (carriage return, line feed), used by
|
dstIP, dstPort, _ := net.SplitHostPort(conn.LocalAddr().String())
|
||||||
// protocols such as Telnet and SMTP.
|
srcIP, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
||||||
if len(cfg.Banner) > 0 {
|
var remIP string
|
||||||
_, _ = conn.Write([]byte(strings.ReplaceAll(cfg.Banner, "\\n", "\r\n")))
|
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
|
// Set a connection deadline.
|
||||||
// client and record their responses.
|
_ = 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)
|
scanner := bufio.NewScanner(conn)
|
||||||
responses := make(map[string]string)
|
responses := make(map[string]string)
|
||||||
for i, prompt := range cfg.Prompts {
|
for i, prompt := range cfg.Prompts {
|
||||||
_, _ = conn.Write([]byte(strings.ReplaceAll(prompt.Text, "\\n", "\r\n")))
|
_, _ = conn.Write([]byte(prompt.Text))
|
||||||
scanner.Scan()
|
scanner.Scan()
|
||||||
var key string
|
var key string
|
||||||
// Each prompt includes an optional Log field that serves as the key
|
// 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
|
// the response will not be logged. If Log is omitted, the default key
|
||||||
// "data00" is used, where "00" is the index plus one.
|
// "data00" is used, where "00" is the index plus one.
|
||||||
if prompt.Log == "none" {
|
if prompt.Log == "none" {
|
||||||
// Skip logging for this entry.
|
|
||||||
continue
|
continue
|
||||||
} else if len(prompt.Log) > 0 {
|
} else if len(prompt.Log) > 0 {
|
||||||
key = prompt.Log
|
key = prompt.Log
|
||||||
@@ -80,8 +105,8 @@ func handleConnection(conn net.Conn, cfg *config.Server) {
|
|||||||
responses[key] = scanner.Text()
|
responses[key] = scanner.Text()
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no prompts are provided in the configuration, wait for the client to
|
// If no prompts are configured, wait for client input and record the
|
||||||
// send data then record the received input.
|
// received data.
|
||||||
if len(cfg.Prompts) == 0 {
|
if len(cfg.Prompts) == 0 {
|
||||||
scanner.Scan()
|
scanner.Scan()
|
||||||
responses["data"] = scanner.Text()
|
responses["data"] = scanner.Text()
|
||||||
@@ -99,24 +124,34 @@ func handleConnection(conn net.Conn, cfg *config.Server) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the connection along with all responses received from the client.
|
// Log the connection and all responses received from the client.
|
||||||
dst_ip, dst_port, _ := net.SplitHostPort(conn.LocalAddr().String())
|
logData := make([]slog.Attr, 0, 9)
|
||||||
src_ip, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
logData = append(logData,
|
||||||
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
|
|
||||||
slog.String("event_type", "tcp"),
|
slog.String("event_type", "tcp"),
|
||||||
slog.String("source_ip", src_ip),
|
slog.String("source_ip", srcIP),
|
||||||
slog.String("server_ip", dst_ip),
|
)
|
||||||
slog.String("server_port", dst_port),
|
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.String("server_name", config.GetHostname()),
|
||||||
slog.Any("event_details", responses),
|
slog.Any("event_details", responses),
|
||||||
)
|
)
|
||||||
|
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "", logData...)
|
||||||
|
|
||||||
// Print a simplified version of the interaction to the console.
|
// 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 {
|
if cfg.SendToThreatFeed {
|
||||||
threatfeed.Update(src_ip)
|
threatfeed.Update(srcIP)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -65,11 +65,8 @@ var (
|
|||||||
func Update(ip string) {
|
func Update(ip string) {
|
||||||
// Check if the given IP string is a private address. The threat feed may
|
// Check if the given IP string is a private address. The threat feed may
|
||||||
// be configured to include or exclude private IPs.
|
// be configured to include or exclude private IPs.
|
||||||
netIP := net.ParseIP(ip)
|
parsedIP, err := netip.ParseAddr(ip)
|
||||||
if netIP == nil || netIP.IsLoopback() {
|
if err != nil || parsedIP.IsLoopback() || (!cfg.ThreatFeed.IsPrivateIncluded && parsedIP.IsPrivate()) {
|
||||||
return
|
|
||||||
}
|
|
||||||
if !cfg.ThreatFeed.IsPrivateIncluded && netIP.IsPrivate() {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ package threatfeed
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
|
||||||
"cmp"
|
"cmp"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,7 +16,7 @@ import (
|
|||||||
// feedEntry represents an individual entry in the threat feed.
|
// feedEntry represents an individual entry in the threat feed.
|
||||||
type feedEntry struct {
|
type feedEntry struct {
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
IPBytes net.IP `json:"-"`
|
IPBytes netip.Addr `json:"-"`
|
||||||
Added time.Time `json:"added"`
|
Added time.Time `json:"added"`
|
||||||
LastSeen time.Time `json:"last_seen"`
|
LastSeen time.Time `json:"last_seen"`
|
||||||
Observations int `json:"observations"`
|
Observations int `json:"observations"`
|
||||||
@@ -86,13 +85,13 @@ loop:
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedIP := net.ParseIP(ip)
|
parsedIP, err := netip.ParseAddr(ip)
|
||||||
if parsedIP == nil || (parsedIP.IsPrivate() && !cfg.ThreatFeed.IsPrivateIncluded) {
|
if err != nil || (parsedIP.IsPrivate() && !cfg.ThreatFeed.IsPrivateIncluded) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ipnet := range excludedCIDR {
|
for _, prefix := range excludedCIDR {
|
||||||
if ipnet.Contains(parsedIP) {
|
if prefix.Contains(parsedIP) {
|
||||||
continue loop
|
continue loop
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,9 +119,9 @@ loop:
|
|||||||
// should contain an IP address or CIDR. It returns a map of the unique IPs and
|
// 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
|
// 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.
|
// 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 {
|
if len(filepath) == 0 {
|
||||||
return map[string]struct{}{}, []*net.IPNet{}, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := os.Open(filepath)
|
f, err := os.Open(filepath)
|
||||||
@@ -134,24 +133,26 @@ func parseExcludeList(filepath string) (map[string]struct{}, []*net.IPNet, error
|
|||||||
// `ips` stores individual IPs to exclude, and `cidr` stores CIDR networks
|
// `ips` stores individual IPs to exclude, and `cidr` stores CIDR networks
|
||||||
// to exclude.
|
// to exclude.
|
||||||
ips := make(map[string]struct{})
|
ips := make(map[string]struct{})
|
||||||
cidr := []*net.IPNet{}
|
cidr := []netip.Prefix{}
|
||||||
scanner := bufio.NewScanner(f)
|
scanner := bufio.NewScanner(f)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := strings.TrimSpace(scanner.Text())
|
line := scanner.Text()
|
||||||
|
|
||||||
// Remove comments from text.
|
// Remove comments and trim.
|
||||||
if i := strings.Index(line, "#"); i != -1 {
|
if i := strings.IndexByte(line, '#'); i != -1 {
|
||||||
line = strings.TrimSpace(line[:i])
|
line = line[:i]
|
||||||
|
}
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(line) > 0 {
|
if prefix, err := netip.ParsePrefix(line); err == nil {
|
||||||
if _, ipnet, err := net.ParseCIDR(line); err == nil {
|
cidr = append(cidr, prefix)
|
||||||
cidr = append(cidr, ipnet)
|
|
||||||
} else {
|
} else {
|
||||||
ips[line] = struct{}{}
|
ips[line] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if err := scanner.Err(); err != nil {
|
if err := scanner.Err(); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -164,13 +165,13 @@ func (f feedEntries) applySort(method sortMethod, direction sortDirection) {
|
|||||||
switch method {
|
switch method {
|
||||||
case byIP:
|
case byIP:
|
||||||
slices.SortFunc(f, func(a, b feedEntry) int {
|
slices.SortFunc(f, func(a, b feedEntry) int {
|
||||||
return bytes.Compare(a.IPBytes, b.IPBytes)
|
return a.IPBytes.Compare(b.IPBytes)
|
||||||
})
|
})
|
||||||
case byLastSeen:
|
case byLastSeen:
|
||||||
slices.SortFunc(f, func(a, b feedEntry) int {
|
slices.SortFunc(f, func(a, b feedEntry) int {
|
||||||
t := a.LastSeen.Compare(b.LastSeen)
|
t := a.LastSeen.Compare(b.LastSeen)
|
||||||
if t == 0 {
|
if t == 0 {
|
||||||
return bytes.Compare(a.IPBytes, b.IPBytes)
|
return a.IPBytes.Compare(b.IPBytes)
|
||||||
}
|
}
|
||||||
return t
|
return t
|
||||||
})
|
})
|
||||||
@@ -178,7 +179,7 @@ func (f feedEntries) applySort(method sortMethod, direction sortDirection) {
|
|||||||
slices.SortFunc(f, func(a, b feedEntry) int {
|
slices.SortFunc(f, func(a, b feedEntry) int {
|
||||||
t := a.Added.Compare(b.Added)
|
t := a.Added.Compare(b.Added)
|
||||||
if t == 0 {
|
if t == 0 {
|
||||||
return bytes.Compare(a.IPBytes, b.IPBytes)
|
return a.IPBytes.Compare(b.IPBytes)
|
||||||
}
|
}
|
||||||
return t
|
return t
|
||||||
})
|
})
|
||||||
@@ -186,7 +187,7 @@ func (f feedEntries) applySort(method sortMethod, direction sortDirection) {
|
|||||||
slices.SortFunc(f, func(a, b feedEntry) int {
|
slices.SortFunc(f, func(a, b feedEntry) int {
|
||||||
t := cmp.Compare(a.Observations, b.Observations)
|
t := cmp.Compare(a.Observations, b.Observations)
|
||||||
if t == 0 {
|
if t == 0 {
|
||||||
return bytes.Compare(a.IPBytes, b.IPBytes)
|
return a.IPBytes.Compare(b.IPBytes)
|
||||||
}
|
}
|
||||||
return t
|
return t
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"golang.org/x/net/websocket"
|
"golang.org/x/net/websocket"
|
||||||
@@ -65,7 +66,7 @@ func handleWebSocket(ws *websocket.Conn) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if netIP := net.ParseIP(ip); !netIP.IsPrivate() && !netIP.IsLoopback() {
|
if parsedIP, err := netip.ParseAddr(ip); err != nil || (!parsedIP.IsPrivate() && !parsedIP.IsLoopback()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Println("[Threat Feed]", ip, "established WebSocket connection")
|
fmt.Println("[Threat Feed]", ip, "established WebSocket connection")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package threatfeed
|
|||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
)
|
)
|
||||||
|
|
||||||
// enforcePrivateIP is a middleware that restricts access to the HTTP server
|
// 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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Could not get IP", http.StatusInternalServerError)
|
http.Error(w, "", http.StatusForbidden)
|
||||||
return
|
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)
|
http.Error(w, "", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package threatfeed
|
package threatfeed
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/r-smith/deceptifeed/internal/certutil"
|
||||||
"github.com/r-smith/deceptifeed/internal/config"
|
"github.com/r-smith/deceptifeed/internal/config"
|
||||||
"golang.org/x/net/websocket"
|
"golang.org/x/net/websocket"
|
||||||
)
|
)
|
||||||
@@ -91,9 +95,32 @@ func Start(c *config.Config) {
|
|||||||
IdleTimeout: 0,
|
IdleTimeout: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the threat feed HTTP server.
|
// Start the threat feed (HTTP) server if TLS is not enabled.
|
||||||
fmt.Printf("Starting Threat Feed server on port: %s\n", c.ThreatFeed.Port)
|
if !c.ThreatFeed.EnableTLS {
|
||||||
|
fmt.Printf("Starting threat feed (HTTP) on port: %s\n", c.ThreatFeed.Port)
|
||||||
if err := srv.ListenAndServe(); err != nil {
|
if err := srv.ListenAndServe(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "The Threat Feed server has stopped:", err)
|
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>
|
</thead>
|
||||||
<tbody>
|
<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>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>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>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>
|
<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>
|
<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 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 "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 .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 .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}}
|
{{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
|
// addresses, this may not correspond to the IP address that
|
||||||
// received the UDP data. However, this limitation is acceptable as
|
// received the UDP data. However, this limitation is acceptable as
|
||||||
// the primary goal is to log the received data.
|
// the primary goal is to log the received data.
|
||||||
_, dst_port, _ := net.SplitHostPort(conn.LocalAddr().String())
|
_, dstPort, _ := net.SplitHostPort(conn.LocalAddr().String())
|
||||||
src_ip, _, _ := net.SplitHostPort(remoteAddr.String())
|
srcIP, _, _ := net.SplitHostPort(remoteAddr.String())
|
||||||
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
|
cfg.Logger.LogAttrs(context.Background(), slog.LevelInfo, "",
|
||||||
slog.String("event_type", "udp"),
|
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("source_reliability", "unreliable"),
|
||||||
slog.String("server_ip", config.GetHostIP()),
|
slog.String("server_ip", config.GetHostIP()),
|
||||||
slog.String("server_port", dst_port),
|
slog.String("server_port", dstPort),
|
||||||
slog.String("server_name", config.GetHostname()),
|
slog.String("server_name", config.GetHostname()),
|
||||||
slog.Group("event_details",
|
slog.Group("event_details",
|
||||||
slog.String("data", string(buffer[:n])),
|
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.
|
// 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