mirror of
				https://github.com/r-smith/deceptifeed.git
				synced 2025-11-04 06:03:37 +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,104 +144,143 @@ 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
 | 
				
			||||||
				slog.String("event_type", "http"),
 | 
					
 | 
				
			||||||
				slog.String("source_ip", src_ip),
 | 
							// If a custom source IP header is configured, set remIP to the remote
 | 
				
			||||||
				slog.String("server_ip", dst_ip),
 | 
							// IP and extract the client IP from the header into srcIP.
 | 
				
			||||||
				slog.String("server_port", dst_port),
 | 
							if len(cfg.SourceIPHeader) > 0 {
 | 
				
			||||||
				slog.String("server_name", config.GetHostname()),
 | 
								// If the custom header is missing, invalid, contains multiple IPs,
 | 
				
			||||||
				slog.Group("event_details",
 | 
								// or if there a multiple headers with the same name, parsing will
 | 
				
			||||||
					slog.String("method", r.Method),
 | 
								// fail, and srcIP will fallback to the original connecting IP.
 | 
				
			||||||
					slog.String("path", r.URL.Path),
 | 
								remIP = srcIP
 | 
				
			||||||
					slog.String("query", r.URL.RawQuery),
 | 
								header := r.Header[cfg.SourceIPHeader]
 | 
				
			||||||
					slog.String("user_agent", r.UserAgent()),
 | 
								switch len(header) {
 | 
				
			||||||
					slog.String("protocol", r.Proto),
 | 
								case 0:
 | 
				
			||||||
					slog.String("host", r.Host),
 | 
									errMsg = "missing header " + cfg.SourceIPHeader
 | 
				
			||||||
					slog.Group("basic_auth",
 | 
								case 1:
 | 
				
			||||||
						slog.String("username", username),
 | 
									v := header[0]
 | 
				
			||||||
						slog.String("password", password),
 | 
									if _, err := netip.ParseAddr(v); err != nil {
 | 
				
			||||||
					),
 | 
										if strings.Contains(v, ",") {
 | 
				
			||||||
					slog.Any("headers", flattenHeaders(r.Header)),
 | 
											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, "",
 | 
							logData = append(logData,
 | 
				
			||||||
				slog.String("event_type", "http"),
 | 
								slog.String("server_ip", dstIP),
 | 
				
			||||||
				slog.String("source_ip", src_ip),
 | 
								slog.String("server_port", dstPort),
 | 
				
			||||||
				slog.String("server_ip", dst_ip),
 | 
								slog.String("server_name", config.GetHostname()),
 | 
				
			||||||
				slog.String("server_port", dst_port),
 | 
							)
 | 
				
			||||||
				slog.String("server_name", config.GetHostname()),
 | 
					
 | 
				
			||||||
				slog.Group("event_details",
 | 
							// Log the HTTP request information.
 | 
				
			||||||
					slog.String("method", r.Method),
 | 
							eventDetails := []any{
 | 
				
			||||||
					slog.String("path", r.URL.Path),
 | 
								slog.String("method", r.Method),
 | 
				
			||||||
					slog.String("query", r.URL.RawQuery),
 | 
								slog.String("path", r.URL.Path),
 | 
				
			||||||
					slog.String("user_agent", r.UserAgent()),
 | 
								slog.String("query", r.URL.RawQuery),
 | 
				
			||||||
					slog.String("protocol", r.Proto),
 | 
								slog.String("user_agent", r.UserAgent()),
 | 
				
			||||||
					slog.String("host", r.Host),
 | 
								slog.String("protocol", r.Proto),
 | 
				
			||||||
					slog.Any("headers", flattenHeaders(r.Header)),
 | 
								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.
 | 
							// 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,
 | 
								if r.URL.Path == "/" || r.URL.Path == "/index.html" {
 | 
				
			||||||
		// default minimal responses will be served.
 | 
									if _, _, ok := r.BasicAuth(); ok {
 | 
				
			||||||
		if r.URL.Path == "/" || r.URL.Path == "/index.html" {
 | 
										time.Sleep(2 * time.Second)
 | 
				
			||||||
			// Serve the homepage response.
 | 
									}
 | 
				
			||||||
			if len(cfg.HomePagePath) > 0 {
 | 
					 | 
				
			||||||
				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 {
 | 
				
			||||||
 | 
									serveErrorPage(w, r, cfg.ErrorPagePath)
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		} else {
 | 
							case modeFile:
 | 
				
			||||||
			// Serve the error page response.
 | 
								// Serve a single file.
 | 
				
			||||||
			w.WriteHeader(http.StatusNotFound)
 | 
								if r.URL.Path == "/" || r.URL.Path == "/index.html" {
 | 
				
			||||||
			if len(cfg.ErrorPagePath) > 0 {
 | 
									http.ServeFile(w, r, cfg.HomePagePath)
 | 
				
			||||||
				http.ServeFile(w, r, cfg.ErrorPagePath)
 | 
								} 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
 | 
					// shouldUpdateThreatFeed determines if the threat feed should be updated based
 | 
				
			||||||
// on the server's configured rules.
 | 
					// on the server's configured rules.
 | 
				
			||||||
func shouldUpdateThreatFeed(cfg *config.Server, r *http.Request) bool {
 | 
					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
 | 
						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"
 | 
				
			||||||
@@ -16,11 +15,11 @@ 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"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// feedEntries is a slice of feedEntry structs. It represents the threat feed
 | 
					// feedEntries is a slice of feedEntry structs. It represents the threat feed
 | 
				
			||||||
@@ -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,22 +133,24 @@ 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 {
 | 
				
			||||||
@@ -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 {
 | 
				
			||||||
	if err := srv.ListenAndServe(); err != nil {
 | 
							fmt.Printf("Starting threat feed (HTTP) on port: %s\n", c.ThreatFeed.Port)
 | 
				
			||||||
		fmt.Fprintln(os.Stderr, "The Threat Feed server has stopped:", err)
 | 
							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>
 | 
					                </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