9 Commits

Author SHA1 Message Date
Ryan Smith
dc06d64b5b build: prevent -dirty tag in Docker builds
Ensure Docker builds don't add a `-dirty` suffix to the version number.

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

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

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

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

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

When the threat feed server starts in TLS mode, it automatically generates a self-signed cert if the cert and key files aen't found.
2025-04-16 08:33:36 -07:00
Ryan Smith
650489bd5c feat: add fixed delay to basic auth 2025-04-16 07:44:24 -07:00
Ryan Smith
da42f21f75 refactor: move cert generator to separate package 2025-04-16 07:43:15 -07:00
Ryan Smith
0a4d4536ba chore: revise error strings and comments 2025-04-16 07:35:58 -07:00
Ryan Smith
148d99876f build: update modules 2025-04-16 07:24:18 -07:00
12 changed files with 185 additions and 136 deletions

View File

@@ -2,6 +2,7 @@
FROM golang:latest AS build-stage
WORKDIR /build
COPY . .
RUN git update-index -q --refresh
RUN make
FROM alpine:latest

View File

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

View File

@@ -14,6 +14,9 @@
<threatExpiryHours>336</threatExpiryHours>
<includePrivateIPs>false</includePrivateIPs>
<excludeListPath></excludeListPath>
<enableTLS>false</enableTLS>
<certPath>/opt/deceptifeed/certs/threatfeed-cert.pem</certPath>
<keyPath>/opt/deceptifeed/certs/threatfeed-key.pem</keyPath>
</threatFeed>
<!-- Honeypot Server Configuration -->

View File

@@ -14,6 +14,9 @@
<threatExpiryHours>336</threatExpiryHours>
<includePrivateIPs>false</includePrivateIPs>
<excludeListPath></excludeListPath>
<enableTLS>false</enableTLS>
<certPath>key-threatfeed-public.pem</certPath>
<keyPath>key-threatfeed-private.pem</keyPath>
</threatFeed>
<!-- Honeypot Server Configuration -->

6
go.mod
View File

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

16
go.sum
View File

@@ -1,8 +1,8 @@
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=

View File

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

View File

@@ -145,6 +145,9 @@ type ThreatFeed struct {
ExpiryHours int `xml:"threatExpiryHours"`
IsPrivateIncluded bool `xml:"includePrivateIPs"`
ExcludeListPath string `xml:"excludeListPath"`
EnableTLS bool `xml:"enableTLS"`
CertPath string `xml:"certPath"`
KeyPath string `xml:"keyPath"`
}
// Load reads an optional XML configuration file and unmarshals its contents

View File

@@ -2,17 +2,13 @@ package httpserver
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"io"
"io/fs"
"log"
"log/slog"
"math/big"
"net"
"net/http"
"os"
@@ -20,6 +16,7 @@ import (
"strings"
"time"
"github.com/r-smith/deceptifeed/internal/certutil"
"github.com/r-smith/deceptifeed/internal/config"
"github.com/r-smith/deceptifeed/internal/threatfeed"
)
@@ -112,7 +109,7 @@ func listenHTTP(cfg *config.Server, response *responseConfig) {
}
}
// listenHTTP initializes and starts an HTTPS (encrypted) honeypot server.
// listenHTTPS initializes and starts an HTTPS (encrypted) honeypot server.
func listenHTTPS(cfg *config.Server, response *responseConfig) {
mux := http.NewServeMux()
mux.HandleFunc("/", handleConnection(cfg, parseCustomHeaders(cfg.Headers), response))
@@ -126,10 +123,9 @@ func listenHTTPS(cfg *config.Server, response *responseConfig) {
}
// If the cert and key aren't found, generate a self-signed certificate.
if _, err := os.Stat(cfg.CertPath); os.IsNotExist(err) {
if _, err := os.Stat(cfg.KeyPath); os.IsNotExist(err) {
// Generate a self-signed certificate.
cert, err := generateSelfSignedCert(cfg.CertPath, cfg.KeyPath)
if _, err := os.Stat(cfg.CertPath); errors.Is(err, fs.ErrNotExist) {
if _, err := os.Stat(cfg.KeyPath); errors.Is(err, fs.ErrNotExist) {
cert, err := certutil.GenerateSelfSigned(cfg.CertPath, cfg.KeyPath)
if err != nil {
fmt.Fprintln(os.Stderr, "Failed to generate HTTPS certificate:", err)
return
@@ -223,6 +219,9 @@ func handleConnection(cfg *config.Server, customHeaders map[string]string, respo
case modeDefault:
// Built-in default response.
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
if _, _, ok := r.BasicAuth(); ok {
time.Sleep(2 * time.Second)
}
w.Header()["WWW-Authenticate"] = []string{"Basic"}
w.WriteHeader(http.StatusUnauthorized)
} else {
@@ -357,91 +356,3 @@ func getLocalAddr(r *http.Request) (ip string, port string) {
}
return ip, port
}
// generateSelfSignedCert creates a self-signed TLS certificate and private key
// and returns the resulting tls.Certificate. If file paths are provided, the
// certificate and key are also saved to disk.
func generateSelfSignedCert(certPath string, keyPath string) (tls.Certificate, error) {
// Generate 2048-bit RSA private key.
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return tls.Certificate{}, fmt.Errorf("failed to generate private key: %w", err)
}
// Set the certificate validity period to 10 years.
notBefore := time.Now()
notAfter := notBefore.AddDate(10, 0, 0)
// Generate a random serial number for the certificate.
serialNumber := make([]byte, 16)
_, err = rand.Read(serialNumber)
if err != nil {
return tls.Certificate{}, fmt.Errorf("failed to generate certificate serial number: %w", err)
}
// Set up the template for creating the certificate.
template := x509.Certificate{
SerialNumber: new(big.Int).SetBytes(serialNumber),
Subject: pkix.Name{CommonName: "localhost"},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
}
// Use the template to create a self-signed X.509 certificate.
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return tls.Certificate{}, fmt.Errorf("failed to create certificate: %w", err)
}
certPEM := &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}
keyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}
// Save the certificate and key to disk.
if len(certPath) > 0 && len(keyPath) > 0 {
_ = writeCertAndKey(certPEM, keyPEM, certPath, keyPath)
// If saving fails, ignore the errors and use the in-memory
// certificate.
}
// Parse the public certificate and private key bytes into a tls.Certificate.
cert, err := tls.X509KeyPair(pem.EncodeToMemory(certPEM), pem.EncodeToMemory(keyPEM))
if err != nil {
return tls.Certificate{}, fmt.Errorf("failed to load certificate and private key: %w", err)
}
// Return the tls.Certificate.
return cert, nil
}
// writeCertAndKey saves the public certificate and private key in PEM format
// to the specified file paths.
func writeCertAndKey(cert *pem.Block, key *pem.Block, certPath string, keyPath string) error {
// Save the certificate file to disk.
certFile, err := os.Create(certPath)
if err != nil {
return err
}
defer certFile.Close()
if err := pem.Encode(certFile, cert); err != nil {
return err
}
// Save the private key file to disk.
keyFile, err := os.Create(keyPath)
if err != nil {
return err
}
defer keyFile.Close()
// Limit key access to the owner only.
_ = keyFile.Chmod(0600)
if err := pem.Encode(keyFile, key); err != nil {
return err
}
return nil
}

View File

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

View File

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

View File

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