diff --git a/Makefile b/Makefile index c2663e0..9e6ccef 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,13 @@ all: deps check build -format: +format : find . -iname "*.go" -exec gofmt -s -l -w {} \; -check: +check : go vet ./... -run: +run : go run cmd/HellPot/*.go deps: go mod tidy -v -build: - go build -trimpath -ldflags "-s -w -X main.version=`git tag --sort=-version:refname | head -n 1`" cmd/HellPot/*.go +test : + go test -v ./... +build : + go build -x -trimpath -ldflags "-s -w -X main.version=`git tag --sort=-version:refname | head -n 1`" cmd/HellPot/*.go diff --git a/README.md b/README.md index 443dfba..a70da84 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Clients (hopefully bots) that disregard `robots.txt` and connect to your instanc HellPot will send an infinite stream of data that is _just close enough_ to being a real website that they might just stick around until their soul is ripped apart and they cease to exist. -Under the hood of this eternal suffering is a markov engine that chucks bits and pieces of [The Birth of Tragedy (Hellenism and Pessimism)](https://www.gutenberg.org/files/51356/51356-h/51356-h.htm) by Friedrich Nietzsche at the client using [fasthttp](https://github.com/valyala/fasthttp). +Under the hood of this eternal suffering is a markov engine that chucks bits and pieces of [The Birth of Tragedy (Hellenism and Pessimism)](https://www.gutenberg.org/files/51356/51356-h/51356-h.htm) by Friedrich Nietzsche at the client~~~~ using [fasthttp](https://github.com/valyala/fasthttp), or optionally you may synchronize HellPot with your nightmares by using the `-g`/`--grimoire` flag ## Building From Source diff --git a/cmd/HellPot/HellPot.go b/cmd/HellPot/HellPot.go index b2b3055..1f23794 100644 --- a/cmd/HellPot/HellPot.go +++ b/cmd/HellPot/HellPot.go @@ -2,58 +2,26 @@ package main import ( "os" - "os/signal" - "syscall" - "github.com/rs/zerolog" - - "github.com/yunginnanet/HellPot/internal/config" - "github.com/yunginnanet/HellPot/internal/extra" "github.com/yunginnanet/HellPot/internal/http" ) -var ( - log zerolog.Logger - version string // set by linker -) - -func init() { - if version != "" { - config.Version = version[1:] - } - config.Init() - if config.BannerOnly { - extra.Banner() - os.Exit(0) - } +func main() { + stopChan := make(chan os.Signal, 1) + log, resolvedConf, realConf, err := setup(stopChan) - switch config.DockerLogging { - case true: - config.CurrentLogFile = "/dev/stdout" - config.NoColor = true - log = config.StartLogger(false, os.Stdout) - default: - log = config.StartLogger(true) + if err != nil { + println("failed to start: " + err.Error()) + os.Exit(1) } - extra.Banner() - - log.Info().Str("caller", "config").Str("file", config.Filename).Msg(config.Filename) - log.Info().Str("caller", "logger").Msg(config.CurrentLogFile) - log.Debug().Str("caller", "logger").Msg("debug enabled") - log.Trace().Str("caller", "logger").Msg("trace enabled") - -} - -func main() { - stopChan := make(chan os.Signal, 1) - signal.Notify(stopChan, syscall.SIGINT, syscall.SIGTERM) + printInfo(log, resolvedConf, realConf) go func() { - log.Fatal().Err(http.Serve()).Msg("HTTP error") + log.Fatal().Err(http.Serve(realConf)).Msg("HTTP error") }() - <-stopChan // wait for SIGINT - log.Warn().Msg("Shutting down server...") - + sig := <-stopChan // wait for SIGINT + log.Warn().Interface("signal_received", sig). + Msg("Shutting down server...") } diff --git a/cmd/HellPot/HellPot_test.go b/cmd/HellPot/HellPot_test.go new file mode 100644 index 0000000..86eb303 --- /dev/null +++ b/cmd/HellPot/HellPot_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/yunginnanet/HellPot/internal/config" + "github.com/yunginnanet/HellPot/internal/http" + "github.com/yunginnanet/HellPot/internal/logger" +) + +func testMain(t *testing.T) (string, chan os.Signal, *logger.Log, *config.Parameters, error) { + t.Setenv("HELLPOT_TEST_MODE", "true") + t.Helper() + stopChan := make(chan os.Signal, 1) + + log, resolvedConf, realConfig, err := setup(stopChan) + if err == nil { + printInfo(log, resolvedConf, realConfig) + go func() { + terr := http.Serve(realConfig) + if terr != nil { + t.Error("failed to serve HTTP: " + terr.Error()) + return + } + }() + } + //goland:noinspection GoNilness + return resolvedConf, stopChan, log, realConfig, err +} + +func TestHellPot(t *testing.T) { + tDir := filepath.Join(t.TempDir(), strconv.Itoa(int(time.Now().Unix()))) + logDir := filepath.Join(tDir, "logs") + if err := os.MkdirAll(logDir, 0755); err != nil { + t.Fatal(err) + } + confFile := filepath.Join(tDir, "HellPot_test.toml") + t.Setenv("HELLPOT_LOGGER_DIRECTORY", logDir) + t.Setenv("HELLPOT_LOGGER_RSYSLOG__ADDRESS", "local") + t.Setenv("HELLPOT_LOGGER_DEBUG", "true") + t.Setenv("HELLPOT_CONFIG", confFile) + + resolvedConf, stopChan, log, realConfig, err := testMain(t) + + if err != nil { + t.Fatal(err) + } + if log == nil { + t.Fatal("log is nil") + } + if realConfig == nil { + t.Fatal("realConfig is nil") + } + + logFile := log.Config.ActiveLogFileName + + if stopChan == nil { + t.Fatal("stopChan is nil") + } + if resolvedConf == "" { + t.Fatal("resolvedConf is empty") + } + if logFile == "" { + t.Fatal("logFile is empty") + } + if _, err = os.Stat(logFile); err != nil { + t.Fatal(err) + } + if resolvedConf != confFile { + t.Errorf("expected %s, got %s", confFile, resolvedConf) + } + if logFile != filepath.Join(logDir, "HellPot.log") { + t.Errorf("expected %s, got %s", filepath.Join(logDir, "HellPot.log"), logFile) + } + time.Sleep(25 * time.Millisecond) // sync maybe + logDat, err := os.ReadFile(logFile) + if err != nil { + t.Error(err) + } + if !strings.Contains(string(logDat), "🔥 Starting HellPot 🔥") { + t.Errorf("expected log to contain '🔥 Starting HellPot 🔥', got %s", logDat) + } + if !strings.Contains(string(logDat), logFile) { + t.Errorf("expected log to contain '%s'", logFile) + } + if !strings.Contains(string(logDat), resolvedConf) { + t.Errorf("expected log to contain '%s'", resolvedConf) + } + if !strings.Contains(string(logDat), strconv.Itoa(os.Getpid())+",") { + t.Errorf("expected log to contain 'PID: %d', got %s", os.Getpid(), logDat) + } + t.Log("resolvedConf: ", resolvedConf) + t.Log("logFile: ", logFile) + t.Log("realConfig: ", realConfig) + time.Sleep(100 * time.Millisecond) + stopChan <- os.Interrupt +} diff --git a/cmd/HellPot/boot.go b/cmd/HellPot/boot.go new file mode 100644 index 0000000..968e0f8 --- /dev/null +++ b/cmd/HellPot/boot.go @@ -0,0 +1,254 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "strconv" + "syscall" + "time" + + "github.com/yunginnanet/HellPot/internal/config" + "github.com/yunginnanet/HellPot/internal/extra" + "github.com/yunginnanet/HellPot/internal/logger" +) + +const ( + defaultConfigWarningDelaySecs = 10 + red = "\033[31m" + reset = "\033[0m" +) + +func writeConfig(target string) (*config.Parameters, bool) { + var f *os.File + var err error + f, err = os.Create(target) // #nosec G304 -- go home gosec, you're drunk + if err != nil { + println("failed to create config file: " + err.Error()) + return nil, false + } + if _, err = io.Copy(f, config.Defaults.IO); err != nil { + println("failed to write default config to file: " + err.Error()) + _ = f.Close() + return nil, false + } + if err = f.Sync(); err != nil { + panic(err) + } + println("wrote default config to " + target) + var newConf *config.Parameters + if newConf, err = config.Setup(f); err != nil { + println("failed to setup config with newly written file: " + err.Error()) + _ = f.Close() + return nil, false + } + _ = f.Close() + newConf.UsingDefaults = true + return newConf, true +} + +func searchConfig() string { + var resolvedConf string + uconf, _ := os.UserConfigDir() + if uconf == "" && os.Getenv("HOME") != "" { + uconf = filepath.Join(os.Getenv("HOME"), ".config") + } + + for _, path := range []string{ + "/etc/HellPot/config.toml", + "/usr/local/etc/HellPot/config.toml", + "./config.toml", + filepath.Join(uconf, "HellPot", "config.toml"), + } { + if _, err := os.Stat(path); err == nil { + resolvedConf = path + break + } + } + return resolvedConf +} + +func readConfig(resolvedConf string) (*config.Parameters, error) { + var err error + var setupErr error + var f *os.File + + if resolvedConf == "" { + return nil, fmt.Errorf("%w: provided config file is an empty string", io.EOF) + } + + var runningConfig *config.Parameters + + f, err = os.Open(resolvedConf) // #nosec G304 go home gosec, you're drunk + defer func() { + if f != nil { + _ = f.Close() + } + }() + if err == nil { + runningConfig, setupErr = config.Setup(f) + } + switch { + case setupErr != nil: + println("failed to setup config: " + setupErr.Error()) + err = setupErr + case err != nil: + println("failed to open config file for reading: " + err.Error()) + println("trying to create it....") + newRunningConfig, wroteOK := writeConfig(resolvedConf) + if wroteOK { + return newRunningConfig, nil + } + println("failed to create config file, cannot continue") + return nil, fmt.Errorf("failed to create config file: %w", err) + case runningConfig == nil: + err = errors.New("unknown failure resulting in missing configuration, cannot continue") + return nil, err + } + + return runningConfig, err +} + +type configDetail struct { + runningConfig *config.Parameters + usingDefaults bool + resolvedConf string +} + +func loadCLIConfig() (detail configDetail, err error) { + conf := config.CLIFlags.Lookup("config") + detail = configDetail{} + detail.resolvedConf = conf.Value.String() + detail.runningConfig, err = readConfig(detail.resolvedConf) + detail.usingDefaults = false + return +} + +func loadEnvConfig() (detail configDetail, err error) { + detail = configDetail{} + detail.resolvedConf = os.Getenv("HELLPOT_CONFIG_FILE") + detail.runningConfig, err = readConfig(detail.resolvedConf) + detail.usingDefaults = false + return +} + +func resolveConfig() (runningConfig *config.Parameters, usingDefaults bool, resolvedConf string, err error) { + var cliConfigPath string + if config.CLIFlags != nil && config.CLIFlags.Lookup("config") != nil { + cliConfigPath = config.CLIFlags.Lookup("config").Value.String() + } + + var detail configDetail + +try: + switch { + case cliConfigPath != "": + detail, err = loadCLIConfig() + if err == nil { + runningConfig = detail.runningConfig + usingDefaults = detail.usingDefaults + resolvedConf = detail.resolvedConf + return + } + println("failed to load config from CLI path: " + err.Error()) + cliConfigPath = "" + goto try + case os.Getenv("HELLPOT_CONFIG_FILE") != "": + detail, err = loadEnvConfig() + if err == nil { + runningConfig = detail.runningConfig + usingDefaults = detail.usingDefaults + resolvedConf = detail.resolvedConf + return + } + println("failed to load config from env path: " + err.Error()) + fallthrough + default: + if resolvedConf = searchConfig(); resolvedConf != "" { + usingDefaults = false + } + } + + if runningConfig, err = readConfig(resolvedConf); err != nil && !errors.Is(err, io.EOF) { + return runningConfig, false, "", err + } + + if runningConfig == nil { + if runningConfig, err = config.Setup(nil); err != nil || runningConfig == nil { + if err == nil { + err = errors.New("unknown failure resulting in missing configuration, cannot continue") + } + return runningConfig, false, "", err + } + return runningConfig, true, "", nil + } + + return runningConfig, false, resolvedConf, nil +} + +func initLogger(runningConfig *config.Parameters) (log *logger.Log, err error) { + if log, err = logger.New(&runningConfig.Logger); err != nil { + return + } + + return +} + +func setup(stopChan chan os.Signal) (log *logger.Log, + resolvedConf string, runningConfig *config.Parameters, err error) { + + config.InitCLI() + + var usingDefaults bool + + defer func() { + if runningConfig == nil && err == nil { + err = errors.New("running configuration is nil, cannot continue") + return + } + if (runningConfig.GetLogger() == nil || runningConfig.GetLogger().Config == nil) && err == nil { + err = errors.New("running configuration logger is nil, cannot continue") + return + } + if usingDefaults && os.Getenv("HELLPOT_TEST_MODE") == "" { + log.Warn().Msg("using default configuration!") + print(red + "continuing with default configuration in ") + for i := defaultConfigWarningDelaySecs; i > 0; i-- { + print(strconv.Itoa(i)) + for i := 0; i < 5; i++ { + time.Sleep(200 * time.Millisecond) + print(".") + } + } + print(reset + "\n") + } + signal.Notify(stopChan, syscall.SIGINT, syscall.SIGTERM) + + if //goland:noinspection GoNilness + !runningConfig.GetLogger().Config.NoColor { + extra.Banner() + } + if absResolvedConf, err := filepath.Abs(resolvedConf); err == nil { + resolvedConf = absResolvedConf + } + }() + + switch { + case config.CLIFlags.Lookup("config") == nil, config.CLIFlags.Lookup("genconfig").Value.String() == "": + if runningConfig, usingDefaults, resolvedConf, err = resolveConfig(); err != nil { + return + } + log, err = initLogger(runningConfig) + default: + println("loading configuration file: " + config.CLIFlags.Lookup("config").Value.String()) + if runningConfig, err = readConfig(config.CLIFlags.Lookup("config").Value.String()); err != nil { + return + } + resolvedConf = config.CLIFlags.Lookup("config").Value.String() + log, err = initLogger(runningConfig) + } + return +} diff --git a/cmd/HellPot/util.go b/cmd/HellPot/util.go new file mode 100644 index 0000000..7c123b6 --- /dev/null +++ b/cmd/HellPot/util.go @@ -0,0 +1,42 @@ +package main + +import ( + "os" + + "github.com/rs/zerolog" + + "github.com/yunginnanet/HellPot/internal/config" + "github.com/yunginnanet/HellPot/internal/logger" + "github.com/yunginnanet/HellPot/internal/version" +) + +func printInfo(log *logger.Log, resolvedConf string, realConfig *config.Parameters) { + log.Info(). + Str("version", version.Version). + Int("pid", os.Getpid()). + Str("config_file", resolvedConf). + Str("log_file", log.Config.ActiveLogFileName). + Msg("🔥 Starting HellPot 🔥") + if realConfig.UsingDefaults { + log.Warn().Msg("Using default configuration! Please edit the configuration file to suit your needs.") + } + + var eventer func() *zerolog.Event + + if realConfig.Logger.Debug && !realConfig.Logger.Trace { + eventer = log.Debug + } + if realConfig.Logger.Trace { + eventer = log.Trace + } + + if eventer == nil { + return + } + + eventer().Bool("docker_logging", true). + Bool("debug", true).Bool("trace", false). + Str("rsyslog", realConfig.Logger.RSyslog). + Str("log_file", realConfig.Logger.ActiveLogFileName). + Msg("logging engine started") +} diff --git a/go.mod b/go.mod index 42bd8e5..c477423 100644 --- a/go.mod +++ b/go.mod @@ -6,18 +6,15 @@ require ( git.tcp.direct/kayos/common v0.9.7 github.com/fasthttp/router v1.5.1 github.com/knadh/koanf/parsers/toml v0.1.0 + github.com/knadh/koanf/providers/basicflag v1.0.0 github.com/knadh/koanf/providers/env v0.1.0 - github.com/knadh/koanf/providers/file v1.0.0 github.com/knadh/koanf/v2 v2.1.1 github.com/rs/zerolog v1.33.0 - github.com/spf13/afero v1.11.0 github.com/valyala/fasthttp v1.55.0 - golang.org/x/term v0.21.0 ) require ( github.com/andybalholm/brotli v1.1.0 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect github.com/klauspost/compress v1.17.9 // indirect github.com/knadh/koanf/maps v0.1.1 // indirect @@ -29,5 +26,5 @@ require ( github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect golang.org/x/sys v0.21.0 // indirect - golang.org/x/text v0.16.0 // indirect + nullprogram.com/x/rng v1.1.0 // indirect ) diff --git a/go.sum b/go.sum index e4c7525..0f4f395 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,6 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/fasthttp/router v1.5.1 h1:uViy8UYYhm5npJSKEZ4b/ozM//NGzVCfJbh6VJ0VKr8= github.com/fasthttp/router v1.5.1/go.mod h1:WrmsLo3mrerZP2VEXRV1E8nL8ymJFYCDTr4HmnB8+Zs= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsMBaUOKXq6HSv655U1c= github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -17,10 +15,10 @@ github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NI github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI= github.com/knadh/koanf/parsers/toml v0.1.0/go.mod h1:yUprhq6eo3GbyVXFFMdbfZSo928ksS+uo0FFqNMnO18= +github.com/knadh/koanf/providers/basicflag v1.0.0 h1:qB0es/9fYsLuYnrKazxNCuWtkv3JFX1lI1druUsDDvY= +github.com/knadh/koanf/providers/basicflag v1.0.0/go.mod h1:n0NlnaxXUCER/WIzRroT9q3Np+FiZ9pSjrC6A/OozI8= github.com/knadh/koanf/providers/env v0.1.0 h1:LqKteXqfOWyx5Ab9VfGHmjY9BvRXi+clwyZozgVRiKg= github.com/knadh/koanf/providers/env v0.1.0/go.mod h1:RE8K9GbACJkeEnkl8L/Qcj8p4ZyPXZIQ191HJi44ZaQ= -github.com/knadh/koanf/providers/file v1.0.0 h1:DtPvSQBeF+N0QLPMz0yf2bx0nFSxUcncpqQvzCxfCyk= -github.com/knadh/koanf/providers/file v1.0.0/go.mod h1:/faSBcv2mxPVjFrXck95qeoyoZ5myJ6uxN8OOVNJJCI= github.com/knadh/koanf/v2 v2.1.1 h1:/R8eXqasSTsmDCsAyYj+81Wteg8AqrV9CP6gvsTsOmM= github.com/knadh/koanf/v2 v2.1.1/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -41,8 +39,6 @@ github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 h1:KanIMPX0QdEdB4R3CiimCAbxFrhB3j7h0/OvpYGVQa8= github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= @@ -53,9 +49,6 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= -golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= nullprogram.com/x/rng v1.1.0 h1:SMU7DHaQSWtKJNTpNFIFt8Wd/KSmOuSDPXrMFp/UMro= +nullprogram.com/x/rng v1.1.0/go.mod h1:glGw6V87vyfawxCzqOABL3WfL95G65az9Z2JZCylCkg= diff --git a/heffalump/heffalump.go b/heffalump/heffalump.go index 4e7ce3b..ed97f21 100644 --- a/heffalump/heffalump.go +++ b/heffalump/heffalump.go @@ -6,16 +6,12 @@ package heffalump import ( "bufio" + "fmt" "io" "sync" - - "github.com/yunginnanet/HellPot/internal/config" ) -var log = config.GetLogger() - -// DefaultHeffalump represents a Heffalump type -var DefaultHeffalump *Heffalump +const DefaultBuffSize = 100 * 1 << 10 // Heffalump represents our buffer pool and markov map from Heffalump type Heffalump struct { @@ -36,18 +32,20 @@ func NewHeffalump(mm MarkovMap, buffsize int) *Heffalump { } } +// NewDefaultHeffalump instantiates a new default Heffalump from a MarkovMap created using using the default source text. +func NewDefaultHeffalump() *Heffalump { + return NewHeffalump(NewDefaultMarkovMap(), DefaultBuffSize) +} + // WriteHell writes markov chain heffalump hell to the provided io.Writer func (h *Heffalump) WriteHell(bw *bufio.Writer) (int64, error) { var n int64 var err error - defer func() { - if r := recover(); r != nil { - log.Error().Interface("caller", r).Msg("panic recovered!") - } - }() - - buf := h.pool.Get().([]byte) + buf, ok := h.pool.Get().([]byte) + if !ok { + panic("buffer pool type assertion failed, retrieved type is a " + fmt.Sprintf("%T", buf)) + } if _, err = bw.WriteString("\n\n"); err != nil { h.pool.Put(buf) diff --git a/heffalump/markov.go b/heffalump/markov.go index ca522ab..ef70066 100644 --- a/heffalump/markov.go +++ b/heffalump/markov.go @@ -5,16 +5,21 @@ import ( "io" "math/rand" "strings" + "time" "unicode" "unicode/utf8" + "git.tcp.direct/kayos/common/entropy" "git.tcp.direct/kayos/common/squish" ) -var DefaultMarkovMap MarkovMap +const ( + sp = " " + nl = "\n" +) -func init() { - // DefaultMarkovMap is a Markov chain based on src. +// NewDefaultMarkovMap creates a new MarkovMap from the default source text. +func NewDefaultMarkovMap() MarkovMap { src, err := squish.UnpackStr(srcGz) if err != nil { panic(err) @@ -22,8 +27,8 @@ func init() { if len(src) < 1 { panic("failed to unpack source") } - DefaultMarkovMap = MakeMarkovMap(strings.NewReader(src)) - DefaultHeffalump = NewHeffalump(DefaultMarkovMap, 100*1<<10) + + return MakeMarkovMap(strings.NewReader(src)) } // ScanHTML is a basic split function for a Scanner that returns each @@ -72,11 +77,15 @@ func ScanHTML(data []byte, atEOF bool) (advance int, token []byte, err error) { type tokenPair [2]string // MarkovMap is a map that acts as a Markov chain generator. -type MarkovMap map[tokenPair][]string +type MarkovMap struct { + m map[tokenPair][]string + r *rand.Rand +} // MakeMarkovMap makes an empty MakeMarkov and fills it with r. func MakeMarkovMap(r io.Reader) MarkovMap { - m := MarkovMap{} + m := MarkovMap{m: make(map[tokenPair][]string)} + m.r = rand.New(rand.NewSource(entropy.GetOptimizedRand().Int63())) // #nosec: G404 : speed not security m.Fill(r) return m } @@ -99,13 +108,13 @@ func (mm MarkovMap) Fill(r io.Reader) { // Add adds a three token sequence to the map. func (mm MarkovMap) Add(w1, w2, w3 string) { p := tokenPair{w1, w2} - mm[p] = append(mm[p], w3) + mm.m[p] = append(mm.m[p], w3) } // Get pseudo-randomly chooses a possible suffix to w1 and w2. func (mm MarkovMap) Get(w1, w2 string) string { p := tokenPair{w1, w2} - suffix, ok := mm[p] + suffix, ok := mm.m[p] if !ok { return "" } @@ -121,10 +130,15 @@ func (mm MarkovMap) Read(p []byte) (n int, err error) { for { w3 = mm.Get(w1, w2) if n+len(w3)+1 >= len(p) { + n += copy(p[n:], nl) break } n += copy(p[n:], w3) - n += copy(p[n:], "\n") + if time.Now().UnixNano()%10 == 0 { + n += copy(p[n:], nl) + } else { + n += copy(p[n:], sp) + } w1, w2 = w2, w3 } return diff --git a/internal/config/arguments.go b/internal/config/arguments.go deleted file mode 100644 index 89fe1fd..0000000 --- a/internal/config/arguments.go +++ /dev/null @@ -1,37 +0,0 @@ -package config - -import ( - "os" -) - -var ( - forceDebug = false - forceTrace = false -) - -var argBoolMap = map[string]*bool{ - "--debug": &forceDebug, "-v": &forceDebug, "--trace": &forceTrace, "-vv": &forceTrace, - "--nocolor": &noColorForce, "--banner": &BannerOnly, "--genconfig": &GenConfig, -} - -// TODO: should probably just make a proper CLI with flags or something -func argParse() { - for i, arg := range os.Args { - if t, ok := argBoolMap[arg]; ok { - *t = true - continue - } - switch arg { - case "-h": - CLI.printUsage() - case "-c", "--config": - if len(os.Args) < i+2 { - println("missing config file after -c/--config") - os.Exit(1) - } - loadCustomConfig(os.Args[i+1]) - default: - continue - } - } -} diff --git a/internal/config/client_rules.go b/internal/config/client_rules.go new file mode 100644 index 0000000..7b880c3 --- /dev/null +++ b/internal/config/client_rules.go @@ -0,0 +1,83 @@ +package config + +import ( + "bytes" + "fmt" + "regexp" +) + +type ClientRules struct { + // See: https://github.com/yunginnanet/HellPot/issues/23 + UseragentDisallowStrings []string `koanf:"user_agent_disallow_strings"` + useragentDisallowStrBytes [][]byte + UseragentDisallowRegex []string `koanf:"user_agent_disallow_regex"` + useragentDisallowRegex []*regexp.Regexp +} + +func NewClientRules(strs []string, regex []string) (*ClientRules, error) { + if strs == nil && regex == nil { + return &ClientRules{}, nil + } + if regex == nil { + regex = make([]string, 0) + } + if strs == nil { + strs = make([]string, 0) + } + cr := &ClientRules{ + UseragentDisallowStrings: strs, + UseragentDisallowRegex: regex, + } + return cr, cr.compile() +} + +func (c *ClientRules) compile() error { + dupes := make(map[string]struct{}) + for _, v := range c.UseragentDisallowRegex { + if v == "" { + continue + } + if _, ok := dupes[v]; ok { + continue + } + dupes[v] = struct{}{} + var compd *regexp.Regexp + var err error + if compd, err = regexp.Compile(v); err != nil { + return fmt.Errorf("failed to compile regex '%s': %w", v, err) + } + c.useragentDisallowRegex = append(c.useragentDisallowRegex, compd) + } + + newStrs := make([]string, 0) + for _, v := range c.UseragentDisallowStrings { + if v == "" { + continue + } + if _, ok := dupes[v]; ok { + continue + } + dupes[v] = struct{}{} + newStrs = append(newStrs, v) + } + c.UseragentDisallowStrings = newStrs + c.useragentDisallowStrBytes = make([][]byte, len(c.UseragentDisallowStrings)) + for i, v := range c.UseragentDisallowStrings { + c.useragentDisallowStrBytes[i] = []byte(v) + } + return nil +} + +func (c *ClientRules) MatchUseragent(ua []byte) bool { + for _, v := range c.useragentDisallowRegex { + if v.Match(ua) { + return true + } + } + for _, v := range c.useragentDisallowStrBytes { + if bytes.Contains(ua, v) { + return true + } + } + return false +} diff --git a/internal/config/client_rules_test.go b/internal/config/client_rules_test.go new file mode 100644 index 0000000..c8b080f --- /dev/null +++ b/internal/config/client_rules_test.go @@ -0,0 +1,47 @@ +package config + +import "testing" + +func TestCompileRules(t *testing.T) { + if _, err := NewClientRules(nil, nil); err != nil { + t.Error(err) + } + rules, err := NewClientRules([]string{"test", "test"}, nil) + if err != nil { + t.Error(err) + } + if len(rules.UseragentDisallowStrings) != 1 { + t.Error("expected 1 got", len(rules.UseragentDisallowStrings)) + } + if rules.UseragentDisallowStrings[0] != "test" { + t.Error("expected test got", rules.UseragentDisallowStrings[0]) + } + rules, err = NewClientRules( + []string{"yeeterson", "", "", "", "yeeterson", "mc", "mc", "geeterson"}, + []string{"^y..terson$", "^mc", "^mc", "^g..ters.n$"}, + ) + if err != nil { + t.Error(err) + } + if len(rules.useragentDisallowRegex) != 3 { + t.Error("expected 3 got", len(rules.useragentDisallowRegex)) + } + if len(rules.UseragentDisallowStrings) != 3 { + t.Error("expected 3 got", len(rules.UseragentDisallowStrings)) + } + if !rules.MatchUseragent([]byte("yeeterson")) { + t.Error("expected true got false") + } + if !rules.MatchUseragent([]byte("mc")) { + t.Error("expected true got false") + } + if !rules.MatchUseragent([]byte("yooterson")) { + t.Error("expected true got false") + } + if !rules.MatchUseragent([]byte("gooters%n")) { + t.Error("expected true got false") + } + if rules.MatchUseragent([]byte("yootersongooterson")) { + t.Error("expected false got true") + } +} diff --git a/internal/config/command_line.go b/internal/config/command_line.go new file mode 100644 index 0000000..a74361e --- /dev/null +++ b/internal/config/command_line.go @@ -0,0 +1,142 @@ +package config + +import ( + "flag" + "io" + "os" + "slices" + "strconv" + "strings" + + "github.com/yunginnanet/HellPot/internal/extra" + "github.com/yunginnanet/HellPot/internal/version" +) + +var CLIFlags = flag.NewFlagSet("config", flag.ContinueOnError) + +var ( + sliceDefs = make(map[string][]string) + slicePtrs = make(map[string]*string) +) + +func addCLIFlags() { + parse := func(k string, v interface{}, nestedName string) { + switch casted := v.(type) { + case bool: + CLIFlags.Bool(nestedName, casted, "set "+k) + case string: + CLIFlags.String(nestedName, casted, "set "+k) + case int: + CLIFlags.Int(nestedName, casted, "set "+k) + case float64: + CLIFlags.Float64(nestedName, casted, "set "+k) + case []string: + sliceDefs[nestedName] = casted + joined := strings.Join(sliceDefs[nestedName], ",") + slicePtrs[nestedName] = CLIFlags.String(nestedName, joined, "set "+k) + } + } + + for key, val := range Defaults.val { + if _, ok := val.(map[string]interface{}); !ok { + parse(key, val, key) + continue + } + nested, ok := val.(map[string]interface{}) + if !ok { + // linter was confused by the above check + panic("unreachable, if you see this you have entered a real life HellPot") + } + for k, v := range nested { + nestedName := key + "." + k + parse(k, v, nestedName) + } + } +} + +var replacer = map[string][]string{ + "-h": {"-help"}, + "-v": {"-version"}, + "-c": {"-config"}, + "-g": {"-bespoke.enable_grimoire", "true", "-bespoke.grimoire_file"}, +} + +func InitCLI() { + newArgs := make([]string, 0) + for _, arg := range os.Args { + if repl, ok := replacer[arg]; ok { + newArgs = append(newArgs, repl...) + continue + } + // check for unit test flags + if !strings.HasPrefix(arg, "-test.") { + newArgs = append(newArgs, arg) + } + } + + newArgs = slices.Compact(newArgs) + + CLIFlags.Bool("banner", false, "show banner and version then exit") + CLIFlags.Bool("genconfig", false, "write default config to stdout then exit") + CLIFlags.Bool("help", false, "show this help and exit") + CLIFlags.String("config", "", "specify config file") + CLIFlags.String("version", "", "show version and exit") + + addCLIFlags() + + if err := CLIFlags.Parse(newArgs[1:]); err != nil { + println(err.Error()) + os.Exit(2) + } + + for defaultKey, defaultVal := range Defaults.val { + switch defaultVal.(type) { + case bool: + parsedBool, pErr := strconv.ParseBool(CLIFlags.Lookup(defaultKey).Value.String()) + if pErr != nil { + continue + } + if parsedBool == Defaults.val[defaultKey].(bool) { + fl := CLIFlags.Lookup(defaultKey) + *fl = flag.Flag{} + fl = nil + } + case string: + if CLIFlags.Lookup(defaultKey).Value.String() == Defaults.val[defaultKey].(string) || + CLIFlags.Lookup(defaultKey).Value.String() == "" { + fl := CLIFlags.Lookup(defaultKey) + *fl = flag.Flag{} + fl = nil + } + } + + } + + if os.Getenv("HELLPOT_CONFIG") != "" { + if err := CLIFlags.Set("config", os.Getenv("HELLPOT_CONFIG")); err != nil { + panic(err) + } + } + if CLIFlags.Lookup("help").Value.String() == "true" { + CLIFlags.Usage() + os.Exit(0) + } + if CLIFlags.Lookup("version").Value.String() == "true" { + _, _ = os.Stdout.WriteString("HellPot version: " + version.Version + "\n") + os.Exit(0) + } + if CLIFlags.Lookup("genconfig").Value.String() == "true" { + if n, err := io.Copy(os.Stdout, Defaults.IO); err != nil || n == 0 { + if err == nil { + err = io.EOF + } + panic(err) + } + os.Exit(0) + } + if CLIFlags.Lookup("banner").Value.String() == "true" { + extra.Banner() + os.Exit(0) + } + +} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 925bde3..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,236 +0,0 @@ -package config - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "runtime" - "strconv" - "strings" - - "github.com/knadh/koanf/providers/env" - "github.com/knadh/koanf/providers/file" - "github.com/rs/zerolog" - - "github.com/knadh/koanf/parsers/toml" - viper "github.com/knadh/koanf/v2" -) - -// generic vars -var ( - noColorForce = false - customconfig = false - home string - snek = viper.New(".") -) - -func init() { - home, _ = os.UserHomeDir() - if home == "" { - home = os.Getenv("HOME") - } - if home == "" { - println("WARNING: could not determine home directory") - } -} - -// exported generic vars -var ( - // Trace is the value of our trace (extra verbose) on/off toggle as per the current configuration. - Trace bool - // Debug is the value of our debug (verbose) on/off toggle as per the current configuration. - Debug bool - // Filename returns the current location of our toml config file. - Filename string -) - -func writeConfig() string { - prefConfigLocation, _ := os.UserConfigDir() - if prefConfigLocation != "" { - prefConfigLocation = filepath.Join(prefConfigLocation, Title) - } - - if prefConfigLocation == "" { - home, _ = os.UserHomeDir() - prefConfigLocation = filepath.Join(home, ".config", Title) - } - - if _, err := os.Stat(prefConfigLocation); os.IsNotExist(err) { - if err = os.MkdirAll(prefConfigLocation, 0o750); err != nil && !errors.Is(err, os.ErrExist) { - println("error writing new config: " + err.Error()) - os.Exit(1) - } - } - - Filename = filepath.Join(prefConfigLocation, "config.toml") - - tomld, terr := toml.Parser().Marshal(snek.All()) - if terr != nil { - fmt.Println("Failed to marshal new configuration file: " + terr.Error()) - os.Exit(1) - } - - if err := os.WriteFile(Filename, tomld, 0o600); err != nil { - println("error writing new config: " + err.Error()) - os.Exit(1) - } - - return Filename -} - -// Init will initialize our toml configuration engine and define our default configuration values which can be written to a new configuration file if desired -func Init() { - argParse() - - if customconfig { - associateExportedVariables() - return - } - - setDefaults() - - chosen := "" - - uconf, _ := os.UserConfigDir() - - switch runtime.GOOS { - case "windows": - // - default: - if _, err := os.Stat(filepath.Join("/etc/", Title, "config.toml")); err == nil { - chosen = filepath.Join("/etc/", Title, "config.toml") - } - } - - if chosen == "" && uconf == "" && home != "" { - uconf = filepath.Join(home, ".config") - } - - if chosen == "" && uconf != "" { - _ = os.MkdirAll(filepath.Join(uconf, Title), 0750) - chosen = filepath.Join(uconf, Title, "config.toml") - } - - if chosen == "" { - pwd, _ := os.Getwd() - if _, err := os.Stat("./config.toml"); err == nil { - chosen = "./config.toml" - } else { - if _, err := os.Stat(filepath.Join(pwd, "config.toml")); err == nil { - chosen = filepath.Join(pwd, "config.toml") - } - } - } - - loadErr := snek.Load(file.Provider(chosen), toml.Parser()) - - if chosen == "" || loadErr != nil { - println("No configuration file found, writing new configuration file...") - chosen = writeConfig() - } - Filename = chosen - - if loadErr = snek.Load(file.Provider(chosen), toml.Parser()); loadErr != nil { - fmt.Println("failed to load default config file: ", loadErr.Error()) - os.Exit(1) - } - - /* snek.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) - snek.SetEnvPrefix(Title) - snek.AutomaticEnv() - */ - associateExportedVariables() -} - -func loadCustomConfig(path string) { - Filename, _ = filepath.Abs(path) - - if err := snek.Load(file.Provider(Filename), toml.Parser()); err != nil { - fmt.Println("failed to load specified config file: ", err.Error()) - os.Exit(1) - } - - customconfig = true -} - -func processOpts() { - // string options and their exported variables - stringOpt := map[string]*string{ - "http.bind_addr": &HTTPBind, - "http.bind_port": &HTTPPort, - "http.real_ip_header": &HeaderName, - "logger.directory": &logDir, - "logger.console_time_format": &ConsoleTimeFormat, - "deception.server_name": &FakeServerName, - } - // string slice options and their exported variables - strSliceOpt := map[string]*[]string{ - "http.router.paths": &Paths, - "http.uagent_string_blacklist": &UseragentBlacklistMatchers, - } - // bool options and their exported variables - boolOpt := map[string]*bool{ - "performance.restrict_concurrency": &RestrictConcurrency, - "http.use_unix_socket": &UseUnixSocket, - "logger.debug": &Debug, - "logger.trace": &Trace, - "logger.nocolor": &NoColor, - "logger.docker_logging": &DockerLogging, - "http.router.makerobots": &MakeRobots, - "http.router.catchall": &CatchAll, - } - // integer options and their exported variables - intOpt := map[string]*int{ - "performance.max_workers": &MaxWorkers, - } - - for key, opt := range stringOpt { - *opt = snek.String(key) - } - for key, opt := range strSliceOpt { - *opt = snek.Strings(key) - } - for key, opt := range boolOpt { - *opt = snek.Bool(key) - } - for key, opt := range intOpt { - *opt = snek.Int(key) - } -} - -func associateExportedVariables() { - _ = snek.Load(env.Provider("HELLPOT_", ".", func(s string) string { - s = strings.TrimPrefix(s, "HELLPOT_") - s = strings.ToLower(s) - s = strings.ReplaceAll(s, "__", " ") - s = strings.ReplaceAll(s, "_", ".") - s = strings.ReplaceAll(s, " ", "_") - return s - }), nil) - - processOpts() - - if noColorForce { - NoColor = true - } - - if UseUnixSocket { - UnixSocketPath = snek.String("http.unix_socket_path") - parsedPermissions, err := strconv.ParseUint(snek.String("http.unix_socket_permissions"), 8, 32) - if err == nil { - UnixSocketPermissions = uint32(parsedPermissions) - } - } - - // We set exported variables here so that it tracks when accessed from other packages. - - if Debug || forceDebug { - zerolog.SetGlobalLevel(zerolog.DebugLevel) - Debug = true - } - if Trace || forceTrace { - zerolog.SetGlobalLevel(zerolog.TraceLevel) - Trace = true - } -} diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 0d0241a..4e4c6f7 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -1,35 +1,68 @@ package config import ( - "io" - "os" + "bytes" "runtime" "time" "github.com/knadh/koanf/parsers/toml" - "github.com/spf13/afero" ) -var ( - configSections = []string{"logger", "http", "performance", "deception", "ssh"} - defNoColor = false -) +var Defaults = &Preset{val: defOpts} + +func init() { + Defaults.IO = &PresetIO{p: Defaults} +} + +type Preset struct { + val map[string]interface{} + IO *PresetIO +} + +type PresetIO struct { + p *Preset + buf *bytes.Buffer +} + +func (pre *Preset) ReadBytes() ([]byte, error) { + return toml.Parser().Marshal(pre.val) //nolint:wrapcheck +} + +func (shim *PresetIO) Read(p []byte) (int, error) { + if shim.buf != nil && shim.buf.Len() > 0 { + return shim.buf.Read(p) //nolint:wrapcheck + } + data, err := shim.p.ReadBytes() + if err != nil { + return 0, err + } + if shim.buf == nil { + shim.buf = bytes.NewBuffer(data) + } + return shim.buf.Read(p) //nolint:wrapcheck +} -var defOpts = map[string]map[string]interface{}{ - "logger": { +func (pre *Preset) Read() (map[string]interface{}, error) { + return pre.val, nil +} + +var defOpts = map[string]interface{}{ + "logger": map[string]interface{}{ "debug": true, "trace": false, - "nocolor": defNoColor, - "use_date_filename": true, + "nocolor": runtime.GOOS == "windows", + "noconsole": false, + "use_date_filename": false, "docker_logging": false, + "rsyslog_address": "", "console_time_format": time.Kitchen, }, - "http": { + "http": map[string]interface{}{ "use_unix_socket": false, "unix_socket_path": "/var/run/hellpot", "unix_socket_permissions": "0666", "bind_addr": "127.0.0.1", - "bind_port": "8080", + "bind_port": int64(8080), //nolint:gomnd "real_ip_header": "X-Real-IP", "router": map[string]interface{}{ @@ -44,67 +77,15 @@ var defOpts = map[string]map[string]interface{}{ "Cloudflare-Traffic-Manager", }, }, - "performance": { + "performance": map[string]interface{}{ "restrict_concurrency": false, - "max_workers": 256, + "max_workers": 256, //nolint:gomnd }, - "deception": { + "deception": map[string]interface{}{ "server_name": "nginx", }, -} - -func gen(memfs afero.Fs) { - var ( - dat []byte - err error - f afero.File - ) - if dat, err = snek.Marshal(toml.Parser()); err != nil { - println(err.Error()) - os.Exit(1) - } - if f, err = memfs.Create("config.toml"); err != nil { - println(err.Error()) - os.Exit(1) - } - var n int - if n, err = f.Write(dat); err != nil || n != len(dat) { - if err == nil { - err = io.ErrShortWrite - } - println(err.Error()) - os.Exit(1) - } - println("Default config written to config.toml") - os.Exit(0) -} - -func setDefaults() { - memfs := afero.NewMemMapFs() - //goland:noinspection GoBoolExpressions - if runtime.GOOS == "windows" { - defNoColor = true - } - for _, def := range configSections { - for key, val := range defOpts[def] { - if _, ok := val.(map[string]interface{}); !ok { - if err := snek.Set(def+"."+key, val); err != nil { - println(err.Error()) - os.Exit(1) - } - continue - } - for k, v := range val.(map[string]interface{}) { - if err := snek.Set(def+"."+key+"."+k, v); err != nil { - println(err.Error()) - os.Exit(1) - } - } - continue - } - } - - if GenConfig { - gen(memfs) - } + "bespoke": map[string]interface{}{ + "grimoire_file": "", + "enable_grimoire": false, + }, } diff --git a/internal/config/defaults_test.go b/internal/config/defaults_test.go new file mode 100644 index 0000000..535f8e1 --- /dev/null +++ b/internal/config/defaults_test.go @@ -0,0 +1,58 @@ +package config + +import ( + "bytes" + "testing" +) + +func TestDefaults(t *testing.T) { + t.Run("ReadBytes", func(t *testing.T) { + t.Parallel() + bs, err := Defaults.ReadBytes() + if err != nil { + t.Fatal(err) + } + if len(bs) == 0 { + t.Fatal("expected non-empty byte slice") + } + total := 0 + for _, needle := range []string{ + "logger", + "http", + "performance", + "deception", + "bespoke", + } { + total += bytes.Count(bs, []byte(needle)) + 3 // name plus brackets and newline + if !bytes.Contains(bs, []byte(needle)) { + t.Errorf("expected %q in byte slice", needle) + } + } + if len(bs) <= total { + t.Errorf("default byte slice seems too short to contain any default values") + } + }) + t.Run("Read", func(t *testing.T) { + t.Parallel() + m, err := Defaults.Read() + if err != nil { + t.Fatal(err) + } + if len(m) == 0 { + t.Fatal("expected non-empty map") + } + for _, needle := range []string{ + "logger", + "http", + "performance", + "deception", + } { + if _, ok := m[needle]; !ok { + t.Errorf("expected %q in map", needle) + } + if m[needle] == nil { + t.Errorf("expected non-nil value for %q", needle) + } + } + }) +} diff --git a/internal/config/globals.go b/internal/config/globals.go deleted file mode 100644 index 1c82dfb..0000000 --- a/internal/config/globals.go +++ /dev/null @@ -1,86 +0,0 @@ -package config - -import ( - "runtime/debug" -) - -// Title is the name of the application used throughout the configuration process. -const Title = "HellPot" - -var Version = "dev" - -func init() { - if Version != "dev" { - return - } - binInfo := make(map[string]string) - info, ok := debug.ReadBuildInfo() - if !ok { - return - } - for _, v := range info.Settings { - binInfo[v.Key] = v.Value - } - if gitrev, ok := binInfo["vcs.revision"]; ok { - Version = gitrev[:7] - } -} - -var ( - // BannerOnly when toggled causes HellPot to only print the banner and version then exit. - BannerOnly = false - // GenConfig when toggled causes HellPot to write its default config to the cwd and then exit. - GenConfig = false - // NoColor when true will disable the banner and any colored console output. - NoColor bool - // DockerLogging when true will disable the banner and any colored console output, as well as disable the log file. - // Assumes NoColor == true. - DockerLogging bool - // MakeRobots when false will not respond to requests for robots.txt. - MakeRobots bool - // CatchAll when true will cause HellPot to respond to all paths. - // Note that this will override MakeRobots. - CatchAll bool - // ConsoleTimeFormat sets the time format for the console. The string is passed to time.Format() down the line. - ConsoleTimeFormat string -) - -// "http" -var ( - // HTTPBind is defined via our toml configuration file. It is the address that HellPot listens on. - HTTPBind string - // HTTPPort is defined via our toml configuration file. It is the port that HellPot listens on. - HTTPPort string - // HeaderName is defined via our toml configuration file. It is the HTTP Header containing the original IP of the client, - // in traditional reverse Proxy deployments. - HeaderName string - - // Paths are defined via our toml configuration file. These are the paths that HellPot will present for "robots.txt" - // These are also the paths that HellPot will respond for. Other paths will throw a warning and will serve a 404. - Paths []string - - // UseUnixSocket determines if we will listen for HTTP connections on a unix socket. - UseUnixSocket bool - - // UnixSocketPath is defined via our toml configuration file. It is the path of the socket HellPot listens on - // if UseUnixSocket, also defined via our toml configuration file, is set to true. - UnixSocketPath = "" - UnixSocketPermissions uint32 - - // UseragentBlacklistMatchers contains useragent matches checked for with strings.Contains() that - // prevent HellPot from firing off. - // See: https://github.com/yunginnanet/HellPot/issues/23 - UseragentBlacklistMatchers []string -) - -// "performance" -var ( - RestrictConcurrency bool - MaxWorkers int -) - -// "deception" -var ( - // FakeServerName is our configured value for the "Server: " response header when serving HTTP clients - FakeServerName string -) diff --git a/internal/config/help.go b/internal/config/help.go deleted file mode 100644 index 8c50059..0000000 --- a/internal/config/help.go +++ /dev/null @@ -1,123 +0,0 @@ -package config - -import ( - "io" - "os" - "strings" - - "golang.org/x/term" -) - -type help struct { - title, version string - usage map[int][]string - out io.Writer -} - -var CLI = help{ - title: Title, - version: Version, - usage: map[int][]string{ - 0: {0: "--config", 1: "", 2: "Specify config file"}, - 1: {0: "--nocolor", 1: "disable color and banner"}, - 2: {0: "--banner", 1: "show banner + version and exit"}, - 3: {0: "--genconfig", 1: "write default config to " + Title + ".toml then exit"}, - 4: {0: "--help", 1: "show this help and exit"}, - }, - out: os.Stdout, -} - -func (cli help) secondColStart(index int) (max int) { - l := cli.firstColEnd() + 2 - if len(cli.usage[index]) > 2 && cli.usage[index][2] != "" { - l -= len(cli.usage[index][1]) - } - if l > max { - max = l - } - return max -} - -func (cli help) firstColEnd() (max int) { - for n := range cli.usage { - l := len(cli.usage[n][0]) - if l > max { - max = l - } - } - return max -} - -func (cli help) stdout(s ...string) { - for _, v := range s { - _, _ = cli.out.Write([]byte(v)) - } -} - -func (cli help) lb(n int) { - for n > 0 { - cli.stdout("\n") - n-- - } -} - -func (cli help) printUsage() { - if !term.IsTerminal(int(os.Stdout.Fd())) { - os.Exit(1) - } - cli.header() - - for n := 0; n < len(cli.usage); n++ { - line := &strings.Builder{} - buf := &strings.Builder{} - usageAt := 1 - tlen := cli.secondColStart(n) - switch { - case cli.usage[n][0] == "": - cli.lb(1) - case cli.usage[n][1] == "": - cli.stdout(cli.usage[n][0]) - cli.lb(2) - case len(cli.usage[n]) > 2 && cli.usage[n][2] != "": - tlen = cli.firstColEnd() - len(cli.usage[n][1]) - usageAt = 2 - fallthrough - default: - buf.WriteString(cli.usage[n][0]) - } - if tlen < 0 { - tlen = 2 - } - tab := strings.Repeat(" ", tlen) - line.WriteString(" ") - if buf.Len() < cli.firstColEnd() { - line.WriteString(strings.Repeat(" ", cli.firstColEnd()-buf.Len())) - } - if usageAt == 2 { - buf.WriteString(strings.Repeat(" ", tlen/2)) - buf.WriteString(cli.usage[n][1]) - } - buf.WriteString(tab) - buf.Write([]byte(" (" + cli.usage[n][usageAt] + ")")) - buf.Write([]byte{'\n'}) - line.Write([]byte(buf.String())) - cli.stdout(line.String()) - } - os.Exit(0) - -} - -func (cli help) header() { - cli.stdout("\n") - s := &strings.Builder{} - s.Write([]byte(cli.title)) - s.Write([]byte(" v[")) - s.Write([]byte(cli.version)) - s.Write([]byte("]")) - tab := cli.firstColEnd() - (s.Len() % 2) + 1 - if tab > 0 { - cli.stdout(strings.Repeat(" ", tab)) - } - cli.stdout(s.String()) - cli.lb(2) -} diff --git a/internal/config/logger.go b/internal/config/logger.go deleted file mode 100644 index a9aba64..0000000 --- a/internal/config/logger.go +++ /dev/null @@ -1,71 +0,0 @@ -package config - -import ( - "io" - "os" - "path" - "path/filepath" - "strings" - "time" - - "github.com/rs/zerolog" -) - -var ( - // CurrentLogFile is used for accessing the location of the currently used log file across packages. - CurrentLogFile string - logFile io.Writer - logDir string - logger zerolog.Logger -) - -func prepLogDir() { - logDir = snek.String("logger.directory") - if logDir == "" { - logDir = filepath.Join(home, ".local", "share", Title, "logs") - } - _ = os.MkdirAll(logDir, 0750) -} - -// StartLogger instantiates an instance of our zerolog loggger so we can hook it in our main package. -// While this does return a logger, it should not be used for additional retrievals of the logger. Use GetLogger(). -func StartLogger(pretty bool, targets ...io.Writer) zerolog.Logger { - logFileName := "HellPot" - - if snek.Bool("logger.use_date_filename") { - tn := strings.ReplaceAll(time.Now().Format(time.RFC822), " ", "_") - tn = strings.ReplaceAll(tn, ":", "-") - logFileName = logFileName + "_" + tn - } - - var err error - - switch { - case len(targets) > 0: - logFile = io.MultiWriter(targets...) - default: - prepLogDir() - CurrentLogFile = path.Join(logDir, logFileName+".log") - //nolint:lll - logFile, err = os.OpenFile(CurrentLogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o666) // #nosec G304 G302 -- we are not using user input to create the file - if err != nil { - println("cannot create log file: " + err.Error()) - os.Exit(1) - } - } - - var logWriter = logFile - - if pretty { - logWriter = zerolog.MultiLevelWriter(zerolog.ConsoleWriter{TimeFormat: ConsoleTimeFormat, NoColor: NoColor, Out: os.Stdout}, logFile) - } - - logger = zerolog.New(logWriter).With().Timestamp().Logger() - return logger -} - -// GetLogger retrieves our global logger object. -func GetLogger() *zerolog.Logger { - // future logic here - return &logger -} diff --git a/internal/config/models.go b/internal/config/models.go new file mode 100644 index 0000000..b73801f --- /dev/null +++ b/internal/config/models.go @@ -0,0 +1,89 @@ +package config + +import ( + "sync" + + "github.com/knadh/koanf/v2" + + "github.com/yunginnanet/HellPot/internal/logger" +) + +// Parameters represents the configuration for HellPot. +type Parameters struct { + HTTP HTTP `koanf:"http"` + Logger logger.Configuration `koanf:"logger"` + Bespoke Customization `koanf:"bespoke"` + Perf Performance `koanf:"performance"` + Liar Deception `koanf:"deception"` + + IdleHands DevilsPlaythings `koanf:"experimental"` + + source *koanf.Koanf + logger *logger.Log + UsingDefaults bool +} + +var once = &sync.Once{} + +func (p *Parameters) GetLogger() *logger.Log { + once.Do(func() { + p.logger = logger.GetLoggerOnce() + }) + return p.logger +} + +type Deception struct { + // FakeServerName is our configured value for the "Server: " response header when serving HTTP clients + FakeServerName string `koanf:"fake_server_name"` +} + +type Performance struct { + ConcurrencyCap bool `koanf:"limit_concurrency"` + MaxWorkers int `koanf:"max_workers"` +} + +// UnixSocket represents the configuration for the Unix socket. +type UnixSocket struct { + // UnixSocketPath is the path to the Unix socket that HellPot will listen on if UseUnixSocket is set to true. + UnixSocketPath string `koanf:"unix_socket_path"` + // UseUnixSocket determines if we will listen for HTTP connections on a unix socket. + UseUnixSocket bool `koanf:"use_unix_socket"` + // UnixSocketPermissions are the octal permissions for the Unix socket. + UnixSocketPermissions uint32 `koanf:"unix_socket_permissions"` +} + +// Router represents the configuration for the HTTP router. +type Router struct { + // Paths are defined via our toml configuration file. These are the paths that HellPot will present for "robots.txt" + // These are also the paths that HellPot will respond for. Other paths will throw a warning and will serve a 404. + Paths []string `koanf:"paths"` + CatchAll bool `koanf:"catchall"` + MakeRobots bool `koanf:"makerobots"` + ClientRules ClientRules `koanf:"client_rules"` +} + +// HTTP represents the configuration for the HTTP server. +type HTTP struct { + Bind string `koanf:"bind_addr"` + Port int64 `koanf:"bind_port"` + // ProxiedIPHeader is the HTTP Header containing the original IP of the client + // for usage by traditional reverse Proxy deployments. + ProxiedIPHeader string `koanf:"proxied_ip_header"` + Router Router `koanf:"router"` + UnixSocket UnixSocket `koanf:"unix_socket"` + ClientRules ClientRules `koanf:"client_rules"` + Experimental DevilsPlaythings `koanf:"experimental"` +} + +// DevilsPlaythings - nothing to see here, move along. +type DevilsPlaythings struct { + // POSTMimicry when true will cause HellPot to respond to POST requests to the configured roads to hell + // with the content of the POST request entangled within the response. (Experimental) + POSTMimicry bool `koanf:"post_mimicry"` +} + +// Customization represents the configuration for the customizations. +type Customization struct { + CustomHeffalump bool `koanf:"enable_grimoire"` + Grimoire string `koanf:"grimoire_file"` +} diff --git a/internal/config/setup.go b/internal/config/setup.go new file mode 100644 index 0000000..978ab27 --- /dev/null +++ b/internal/config/setup.go @@ -0,0 +1,202 @@ +package config + +import ( + "fmt" + "io" + "slices" + "strings" + + "github.com/knadh/koanf/parsers/toml" + flags "github.com/knadh/koanf/providers/basicflag" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/v2" + + "github.com/yunginnanet/HellPot/internal/logger" +) + +type readerProvider struct { + source io.Reader +} + +func (r *readerProvider) ReadBytes() ([]byte, error) { + return io.ReadAll(r.source) //nolint:wrapcheck +} + +func (r *readerProvider) Read() (map[string]interface{}, error) { + b, err := r.ReadBytes() + if err != nil { + return nil, err + } + return toml.Parser().Unmarshal(b) //nolint:wrapcheck +} + +func (p *Parameters) merge(ogk *koanf.Koanf, newk *koanf.Koanf, friendlyName string) error { + if ogk == nil { + panic("original koanf is nil") + } + if newk == nil { + return nil + } + dirty := false + + newKeys := newk.All() + ogKeys := ogk.All() + + if len(newk.All()) == 0 || len(newKeys) == 0 { + // println("no configuration overrides found in " + friendlyName) + return nil + } + + valIsEmpty := func(v any) bool { + if v == nil { + return true + } + switch v.(type) { + case string: + return v.(string) == "" + case []string: + return len(v.([]string)) == 0 + default: + return false + } + } + + for k, v := range ogKeys { + // println("checking key " + k + " for overrides from " + friendlyName) + if strings.HasPrefix(k, "logger") && !valIsEmpty(newKeys[k]) { + lg := p.Logger + lgptr := &lg + // println("setting logger config key " + k + " to " + fmt.Sprintf("%v", newKeys[k]) + " from " + friendlyName) + if err := lgptr.Set(k, newKeys[k]); err != nil { + panic(fmt.Sprintf("failed to set logger config: %v", err)) + } + p.Logger = *lgptr + dirty = true + continue + } + if valIsEmpty(v) && !valIsEmpty(newKeys[k]) { + // println("setting newer value for key " + k + " to " + fmt.Sprintf("%v", newKeys[k]) + " from " + friendlyName) + if err := ogk.Set(k, newKeys[k]); err != nil { + panic(fmt.Sprintf("failed to set key %s: %v", k, err)) + } + dirty = true + continue + } + + if _, hasDefault := Defaults.val[k]; !hasDefault { + continue + } + + if v == Defaults.val[k] && newKeys[k] != Defaults.val[k] { + if err := ogk.Set(k, v); err != nil { + panic(fmt.Sprintf("failed to set key %s: %v", k, err)) + } + dirty = true + } + + } + + if !dirty { + return nil + } + + // println("found configuration overrides in " + friendlyName) + + if err := ogk.Merge(newk); err != nil { + return fmt.Errorf("failed to merge env config: %w", err) + } + + return nil +} + +func (p *Parameters) LoadEnv(k *koanf.Koanf) error { + envK := koanf.New(".") + + envErr := envK.Load(env.Provider("HELLPOT_", ".", func(s string) string { + s = strings.TrimPrefix(s, "HELLPOT_") + s = strings.ToLower(s) + s = strings.ReplaceAll(s, "__", " ") + s = strings.ReplaceAll(s, "_", ".") + s = strings.ReplaceAll(s, " ", "_") + return s + }), nil) + + if envErr != nil { + return fmt.Errorf("failed to load env: %w", envErr) + } + + if err := p.merge(k, envK, "environment variables"); err != nil { + return err + } + + return nil +} + +func parseCLISlice(key string, value string) (string, interface{}) { + if _, ok := slicePtrs[key]; !ok { + return key, value + } + split := strings.Split(value, ",") + slices.Sort(split) + return key, split +} + +func (p *Parameters) LoadFlags(k *koanf.Koanf) error { + flagsK := koanf.New(".") + + if err := flagsK.Load(flags.ProviderWithValue(CLIFlags, "-", parseCLISlice, k), nil); err != nil { + return fmt.Errorf("failed to load flags: %w", err) + } + + if err := p.merge(k, flagsK, "cli arguments"); err != nil { + return err + } + + return nil +} + +func Setup(source io.Reader) (*Parameters, error) { + k := koanf.New(".") + + if err := k.Load(Defaults, nil); err != nil { + return nil, fmt.Errorf("failed to load defaults: %w", err) + } + + if source != nil { + if err := k.Load(&readerProvider{source}, toml.Parser()); err != nil { + return nil, fmt.Errorf("failed to read config: %w", err) + } + } + + p := &Parameters{ + source: k, + Logger: logger.Configuration{}, + } + + if source == nil { + p.UsingDefaults = true + } + + if err := p.LoadEnv(k); err != nil { + return nil, err + } + + if err := p.LoadFlags(k); err != nil { + return nil, err + } + + if err := k.Unmarshal("", p); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + for k, v := range k.All() { + if strings.HasPrefix(k, "logger.") { + // println("setting logger config key " + k + " to " + fmt.Sprintf("%v", v)) + if err := p.Logger.Set(k, v); err != nil { + return nil, fmt.Errorf("failed to set logger config: %w", err) + } + } + } + + return p, nil +} diff --git a/internal/config/setup_test.go b/internal/config/setup_test.go new file mode 100644 index 0000000..3f20608 --- /dev/null +++ b/internal/config/setup_test.go @@ -0,0 +1,147 @@ +package config + +import ( + "bytes" + "testing" +) + +func TestSetup(t *testing.T) { + t.Setenv("HELLPOT_TEST_MODE", "true") + t.Run("Success", SetupSuccess) + t.Run("NoFailureOnNilSource", SetupNoFailureOnNilSource) + t.Run("FailureOnReadConfig", SetupFailureOnReadConfig) +} + +func SetupSuccess(t *testing.T) { + source := bytes.NewBufferString(` +[http] +bind_port = 55 +bind_addr = "5.5.5.5" + +[http.router] +catchall = true +makerobots = false + +[logger] +debug = true +rsyslog_address = "local" +`) + + params, err := Setup(source) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if params == nil { + t.Fatal("Expected params to be not nil") + } + + if params.source.Get("http.bind_port") != int64(55) { + t.Errorf("Expected 55, got (%T) %v", params.source.Get("http.port"), params.source.Get("http.port")) + } + if params.HTTP.Port != int64(55) { + t.Errorf("Expected 55, got %v", params.HTTP.Port) + } + if params.source.Get("http.bind_addr") != "5.5.5.5" { + t.Errorf("Expected 5.5.5.5, got %v", params.source.Get("http.bind_addr")) + } + if params.HTTP.Bind != "5.5.5.5" { + t.Errorf("Expected 5.5.5.5, got %v", params.HTTP.Bind) + } + if params.source.Get("http.router.catchall") != true { + t.Errorf("Expected true, got %v", params.source.Get("http.router.catchall")) + } + if params.HTTP.Router.CatchAll != true { + t.Errorf("Expected true, got %v", params.HTTP.Router.CatchAll) + } + if params.source.Get("http.router.makerobots") != false { + t.Errorf("Expected false, got %v", params.source.Get("http.router.makerobots")) + } + if params.HTTP.Router.MakeRobots != false { + t.Errorf("Expected false, got %v", params.HTTP.Router.MakeRobots) + } + if params.source.Get("logger.debug") != true { + t.Errorf("Expected true, got %v", params.source.Get("logger.debug")) + } + if params.Logger.Debug != true { + t.Errorf("Expected true, got %v", params.Logger.Debug) + } + if params.source.Get("logger.rsyslog_address") != "local" { + t.Errorf("Expected local, got %v", params.source.Get("logger.rsyslog_address")) + } + if params.Logger.RSyslog != "local" { + t.Errorf("Expected local, got %v", params.Logger.RSyslog) + } +} + +func SetupNoFailureOnNilSource(t *testing.T) { + params, err := Setup(nil) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if params == nil { + t.Fatal("Expected params to be not nil") + } + + t.Run("DefaultsWithNilSource", func(t *testing.T) { + for _, needle := range []string{ + "logger", + "http", + "performance", + "deception", + "http.router", + "http.router.paths", + } { + if params.source.Get(needle) == nil { + t.Errorf("Expected %q in map", needle) + } + } + + // nolint:forcetypeassert + if params.HTTP.Port != Defaults.val["http"].(map[string]interface{})["bind_port"].(int64) { + t.Errorf("Expected %v, got %v", + // nolint:forcetypeassert + Defaults.val["http"].(map[string]interface{})["port"].(int64), params.HTTP.Port, + ) + } + // nolint:forcetypeassert + if params.HTTP.Bind != Defaults.val["http"].(map[string]interface{})["bind_addr"].(string) { + t.Errorf("Expected %v, got %v", + // nolint:forcetypeassert + Defaults.val["http"].(map[string]interface{})["bind_addr"].(string), params.HTTP.Bind, + ) + } + if params.HTTP.Router.CatchAll != + // nolint:forcetypeassert + Defaults.val["http"].(map[string]interface{})["router"].(map[string]interface{})["catchall"].(bool) { + t.Errorf("Expected %v, got %v", + // nolint:forcetypeassert + Defaults.val["http"].(map[string]interface{})["router"].(map[string]interface{})["catchall"].(bool), + params.HTTP.Router.CatchAll, + ) + } + if len(params.HTTP.Router.Paths) != + // nolint:forcetypeassert + len(Defaults.val["http"].(map[string]interface{})["router"].(map[string]interface{})["paths"].([]string)) { + t.Errorf("Expected %v, got %v", + // nolint:forcetypeassert + Defaults.val["http"].(map[string]interface{})["router"].(map[string]interface{})["paths"].([]string), + params.HTTP.Router.Paths, + ) + } + }) +} + +func SetupFailureOnReadConfig(t *testing.T) { + source := bytes.NewBufferString(`{eeeeeeeeeeeeeeeeeeEE: 1}`) + + params, err := Setup(source) + if err == nil { + t.Error("Expected error, got nil") + } + + if params != nil { + t.Error("Expected params to be nil") + } +} diff --git a/internal/extra/banner.go b/internal/extra/banner.go index 1dd80a0..09e0745 100644 --- a/internal/extra/banner.go +++ b/internal/extra/banner.go @@ -5,13 +5,12 @@ import ( "encoding/binary" "fmt" "os" - "runtime" "strings" "time" "git.tcp.direct/kayos/common/squish" - "github.com/yunginnanet/HellPot/internal/config" + "github.com/yunginnanet/HellPot/internal/version" ) const hellpot = "H4sIAAAAAAACA8VXvW7bQAze9QpZOGQUZNXntBD6Ahm7Gx1cx0jdRnKRKAUCdPDgQavOgB/QTxLZ1P3oRJ5Obo0CtnE5feSR30fylOhmfjv9PEtzwIXIj4dds/xw2jsequNB2gizXd3Mxad2O81PX7AAe+UNGneuR8aUOuTsqQUDXAMv1cJE5Tfbn6GaKz45kpid+lQc3zoNY5zmEUEt+jCGNZUjeYr0StZYmbwtwNavuCaUFWA8MjxVIImjNas6TPQT9Tnq4MnYJF0zkhVU4rLvqflscU/ox0Lg45qKTjoSmiLQPA+ZuTT7BbrckpfWKMkUquTErIPEYbPoKjamy6SjR0feGssPUMYTCDWEnrR8c0m7hJ2B4jekK2KUsBfa7bpTD0ftnmKPE9nN2IzcLc99vxhIUbszlwqrJoklpQWlI6AeQh9nDHXj2ldOvyat/vZdDxVfzZdbSuspRUe/+IKZtxq2GWlbZzS6jnrnDEXGCkXUGnahuTgAA+DY9HU8FUoYH3ji/q84HetDWmT/Y3ml6oX21/eCNzB46+6UuVTSQHXgGmzUTJT/zeNQ3zCvysEBuH3hER9CbhNa6FoLHSBfT2gmK/rFKCj/K1nTfcBduKHVwgjo+Y+HilXBEAqhKg1X6lQzMaIF6ZK6ipVILR0Awh16SWy9KsxvZXWbL34oGpNmMcPNdYFmiE40+qV9cg4Logjm2uXjukzK5a/kYf28WpaTn4u3zcvkfvX09GVTnuFfEYzBNujvr9+S5SafvL0Wj+uiWBSrsov/I6axmMXiLhYf40zE2TTOZnF2F2fNn2n0DpcvBxhQEAAA" @@ -21,23 +20,23 @@ func rc(s []string) string { } func process(in string) (s string) { - var v = strings.Split(config.Version, "") + var v = strings.Split(version.Version, "") var maj, min, smin = "", "", "" - if len(config.Version) > 0 { + if len(version.Version) > 0 { maj = v[0] } - if len(config.Version) > 2 { + if len(version.Version) > 2 { min = v[2] } - if len(config.Version) > 4 { + if len(version.Version) > 4 { smin = v[4] } defl8, _ := squish.UnpackStr(in) sp := strings.Split(defl8, "|") s = sp[0] - if smin == "" || len(config.Version) == 7 || config.Version == "dev" { + if smin == "" || len(version.Version) == 7 || version.Version == "dev" { s = strings.ReplaceAll(s, "$1;40m.", "$1;40m") - if len(config.Version) == 7 || config.Version == "dev" { + if len(version.Version) == 7 || version.Version == "dev" { s = strings.ReplaceAll(s, "$3;40m.", "$3;40m") } } @@ -53,8 +52,8 @@ func process(in string) (s string) { for n := 1; n < 5; n++ { s = cproc(s, fmt.Sprintf("%d", n)) } - if len(config.Version) == 7 || config.Version == "dev" { - maj = "[" + config.Version + "]" + if len(version.Version) == 7 || version.Version == "dev" { + maj = "[" + version.Version + "]" min = "" smin = "" } @@ -72,8 +71,8 @@ func ru() uint32 { return binary.LittleEndian.Uint32(b) } -// printBanner prints our entropic banner -func printBanner() { +// Banner prints our entropic banner +func Banner() { time.Sleep(5 * time.Millisecond) println("\n" + process(hellpot) + "\n\n") time.Sleep(5 * time.Millisecond) @@ -88,13 +87,3 @@ func bannerFail(errs ...error) { } os.Exit(1) } - -// Banner prints out our banner (using spooky magic) -func Banner() { - //goland:noinspection GoBoolExpressions - if runtime.GOOS == "windows" || config.NoColor { - _, _ = os.Stdout.Write([]byte(config.Title + " " + config.Version + "\n\n")) - return - } - printBanner() -} diff --git a/internal/http/robots.go b/internal/http/robots.go index 08039c4..829a64b 100644 --- a/internal/http/robots.go +++ b/internal/http/robots.go @@ -2,32 +2,35 @@ package http import ( "fmt" - "strings" + "git.tcp.direct/kayos/common/pool" "github.com/valyala/fasthttp" - - "github.com/yunginnanet/HellPot/internal/config" ) +var strs = pool.NewStringFactory() + func robotsTXT(ctx *fasthttp.RequestCtx) { + config := runningConfig.HTTP.Router slog := log.With(). Str("USERAGENT", string(ctx.UserAgent())). Str("REMOTE_ADDR", getRealRemote(ctx)). Interface("URL", string(ctx.RequestURI())).Logger() - paths := &strings.Builder{} - paths.WriteString("User-agent: *\r\n") + pathBuf := strs.Get() + pathBuf.MustWriteString("User-agent: *\r\n") for _, p := range config.Paths { - paths.WriteString("Disallow: ") - paths.WriteString(p) - paths.WriteString("\r\n") + pathBuf.MustWriteString("Disallow: ") + pathBuf.MustWriteString(p) + pathBuf.MustWriteString("\r\n") } - paths.WriteString("\r\n") + pathBuf.MustWriteString("\r\n") + paths := pathBuf.String() + strs.MustPut(pathBuf) slog.Debug(). Strs("PATHS", config.Paths). Msg("SERVE_ROBOTS") - if _, err := fmt.Fprintf(ctx, paths.String()); err != nil { + if _, err := fmt.Fprintf(ctx, paths); err != nil { slog.Error().Err(err).Msg("SERVE_ROBOTS_ERROR") } } diff --git a/internal/http/router.go b/internal/http/router.go index a89284c..3636649 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -3,23 +3,30 @@ package http import ( "bufio" "fmt" + "io" "net/http" + "os" "runtime" + "strconv" "strings" "time" "github.com/fasthttp/router" - "github.com/rs/zerolog" "github.com/valyala/fasthttp" "github.com/yunginnanet/HellPot/heffalump" "github.com/yunginnanet/HellPot/internal/config" + "github.com/yunginnanet/HellPot/internal/logger" ) -var log *zerolog.Logger +var ( + log *logger.Log + hellpotHeffalump *heffalump.Heffalump + runningConfig *config.Parameters +) func getRealRemote(ctx *fasthttp.RequestCtx) string { - xrealip := string(ctx.Request.Header.Peek(config.HeaderName)) + xrealip := string(ctx.Request.Header.Peek(runningConfig.HTTP.ProxiedIPHeader)) if len(xrealip) > 0 { return xrealip } @@ -39,15 +46,13 @@ func hellPot(ctx *fasthttp.RequestCtx) { Str("REMOTE_ADDR", remoteAddr). Interface("URL", string(ctx.RequestURI())).Logger() - for _, denied := range config.UseragentBlacklistMatchers { - if strings.Contains(string(ctx.UserAgent()), denied) { - slog.Trace().Msg("Ignoring useragent") - ctx.Error("Not found", http.StatusNotFound) - return - } + if runningConfig.HTTP.ClientRules.MatchUseragent(ctx.UserAgent()) { + slog.Trace().Msg("Ignoring useragent") + ctx.Error("Not found", http.StatusNotFound) + return } - if config.Trace { + if runningConfig.Logger.Trace { slog = slog.With().Str("caller", path).Logger() } @@ -61,7 +66,7 @@ func hellPot(ctx *fasthttp.RequestCtx) { var wn int64 for { - wn, err = heffalump.DefaultHeffalump.WriteHell(bw) + wn, err = hellpotHeffalump.WriteHell(bw) n += wn if err != nil { slog.Trace().Err(err).Msg("END_ON_ERR") @@ -74,35 +79,32 @@ func hellPot(ctx *fasthttp.RequestCtx) { Dur("DURATION", time.Since(s)). Msg("FINISH") }) - } func getSrv(r *router.Router) fasthttp.Server { - if !config.RestrictConcurrency { - config.MaxWorkers = fasthttp.DefaultConcurrency + if !runningConfig.Perf.ConcurrencyCap { + runningConfig.Perf.MaxWorkers = fasthttp.DefaultConcurrency } - log = config.GetLogger() + log = runningConfig.GetLogger() return fasthttp.Server{ // User defined server name // Likely not useful if behind a reverse proxy without additional configuration of the proxy server. - Name: config.FakeServerName, + Name: runningConfig.Liar.FakeServerName, /* from fasthttp docs: "By default request read timeout is unlimited." - My thinking here is avoiding some sort of weird oversized GET query just in case. + Nope. */ ReadTimeout: 5 * time.Second, - MaxRequestBodySize: 1 * 1024 * 1024, + MaxRequestBodySize: 0.5 * 1024 * 1024, - // Help curb abuse of HellPot (we've always needed this badly) - MaxConnsPerIP: 10, + MaxConnsPerIP: 3, MaxRequestsPerConn: 2, - Concurrency: config.MaxWorkers, + Concurrency: runningConfig.Perf.MaxWorkers, - // only accept GET requests - GetOnly: true, + GetOnly: !runningConfig.IdleHands.POSTMimicry, // we don't care if a request ends up being handled by a different handler (in fact it probably will) KeepHijackedConns: true, @@ -117,19 +119,57 @@ func getSrv(r *router.Router) fasthttp.Server { } } +func SetupHeffalump(config *config.Parameters) error { + switch config.Bespoke.CustomHeffalump { + case true: + content, err := os.ReadFile(config.Bespoke.Grimoire) + if err != nil { + return fmt.Errorf("failed to read grimoire file '%s': %w", config.Bespoke.Grimoire, err) + } + // Wasteful, but only done once at startup + src := string(content) + log.Info().Msgf("Using custom grimoire file '%s'", config.Bespoke.Grimoire) + + if len(src) < 1 { + return fmt.Errorf("%w: grimoire file '%s' appears to be empty", io.EOF, config.Bespoke.Grimoire) + } + + if !strings.Contains(src, "<") || !strings.Contains(src, ">") { + return fmt.Errorf("%w: grimoire file '%s' does not appear to be a valid source text, missing brackets", + io.ErrUnexpectedEOF, config.Bespoke.Grimoire) + } + + markovMap := heffalump.MakeMarkovMap(strings.NewReader(src)) + hellpotHeffalump = heffalump.NewHeffalump(markovMap, heffalump.DefaultBuffSize) + default: + log.Info().Msg("Using default source text") + hellpotHeffalump = heffalump.NewDefaultHeffalump() + } + if hellpotHeffalump == nil { + panic("failed to initialize heffalump") + } + return nil +} + // Serve starts our HTTP server and request router -func Serve() error { +func Serve(config *config.Parameters) error { log = config.GetLogger() - l := config.HTTPBind + ":" + config.HTTPPort + runningConfig = config + + if err := SetupHeffalump(config); err != nil { + return fmt.Errorf("failed to setup heffalump: %w", err) + } + + l := config.HTTP.Bind + ":" + strconv.Itoa(int(config.HTTP.Port)) r := router.New() - if config.MakeRobots && !config.CatchAll { + if config.HTTP.Router.MakeRobots && !config.HTTP.Router.CatchAll { r.GET("/robots.txt", robotsTXT) } - if !config.CatchAll { - for _, p := range config.Paths { + if !config.HTTP.Router.CatchAll { + for _, p := range config.HTTP.Router.Paths { log.Trace().Str("caller", "router").Msgf("Add route: %s", p) r.GET(fmt.Sprintf("/%s", p), hellPot) } @@ -141,15 +181,27 @@ func Serve() error { srv := getSrv(r) //goland:noinspection GoBoolExpressions - if !config.UseUnixSocket || runtime.GOOS == "windows" { + if !config.HTTP.UnixSocket.UseUnixSocket || runtime.GOOS == "windows" { log.Info().Str("caller", l).Msg("Listening and serving HTTP...") - return srv.ListenAndServe(l) + err := srv.ListenAndServe(l) + if err != nil { + return fmt.Errorf("failed to start HTTP server: %w", err) + } } - if len(config.UnixSocketPath) < 1 { + if len(config.HTTP.UnixSocket.UnixSocketPath) < 1 { log.Fatal().Msg("unix_socket_path configuration directive appears to be empty") } - log.Info().Str("caller", config.UnixSocketPath).Msg("Listening and serving HTTP...") - return listenOnUnixSocket(config.UnixSocketPath, r) + log.Info().Str("caller", config.HTTP.UnixSocket.UnixSocketPath).Msg("Listening and serving HTTP...") + listener, err := listenOnUnixSocket(config.HTTP.UnixSocket.UnixSocketPath, r) + if err != nil { + return fmt.Errorf("failed to start unix listener: %w", err) + } + + err = srv.Serve(listener) + if err != nil { + err = fmt.Errorf("failed to serve HTTP: %w", err) + } + return err } diff --git a/internal/http/router_unix.go b/internal/http/router_unix.go index 978ad48..011ec5c 100644 --- a/internal/http/router_unix.go +++ b/internal/http/router_unix.go @@ -8,18 +8,16 @@ import ( "syscall" "github.com/fasthttp/router" - "github.com/valyala/fasthttp" - - "github.com/yunginnanet/HellPot/internal/config" ) -func listenOnUnixSocket(addr string, r *router.Router) error { +func listenOnUnixSocket(addr string, r *router.Router) (net.Listener, error) { + config := runningConfig.HTTP.UnixSocket var err error var unixAddr *net.UnixAddr var unixListener *net.UnixListener unixAddr, err = net.ResolveUnixAddr("unix", addr) if err != nil { - return err + return nil, err } // Always unlink sockets before listening on them _ = syscall.Unlink(addr) @@ -29,14 +27,14 @@ func listenOnUnixSocket(addr string, r *router.Router) error { unixListener, err = net.ListenUnix("unix", unixAddr) syscall.Umask(oldmask) if err != nil { - return err + return nil, err } if err = os.Chmod( unixAddr.Name, os.FileMode(config.UnixSocketPermissions), ); err != nil { - return err + return nil, err } - return fasthttp.Serve(unixListener, r.Handler) + return unixListener, nil } diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..a15e51c --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,370 @@ +package logger + +import ( + "errors" + "fmt" + "io" + "log/syslog" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/rs/zerolog" +) + +type Log struct { + zerolog.Logger + Config *Configuration +} + +// Configuration represents the configuration for the logger. +type Configuration struct { + Debug bool `koanf:"debug"` + Trace bool `koanf:"trace"` + NoColor bool `koanf:"nocolor"` + NoConsole bool `koanf:"noconsole"` + DockerLogging bool `koanf:"docker_logging"` + // ConsoleTimeFormat sets the time format for the console. + // The string is passed to time.Format() down the line. + ConsoleTimeFormat string `koanf:"console_time_format"` + + // TimeDateFilename sets the log file name to include the date and time. + TimeDateFilename bool `koanf:"use_date_filename"` + + Directory string `koanf:"directory"` + File string `koanf:"log_file"` + RSyslog string `koanf:"rsyslog_address"` + rsyslogTarget string + + ActiveLogFileName string `koanf:"active_log_file_name"` + + Outputs []io.Writer `koanf:"-"` +} + +func (c *Configuration) Set(k string, v any) error { + k = strings.ToLower(k) + k = strings.TrimPrefix(k, "logger.") + k = strings.ReplaceAll(k, "__", "_") + k = strings.ReplaceAll(k, ".", "_") + + ref := reflect.ValueOf(c) + if ref.Kind() != reflect.Ptr { + panic("not a pointer") + } + + ref = ref.Elem() + + if !ref.IsValid() { + panic("invalid pointer") + } + + if ref.Kind() != reflect.Struct { + panic("not a struct: " + ref.Kind().String()) + } + + var field reflect.Value + + for i := 0; i < ref.NumField(); i++ { + strutTag := ref.Type().Field(i).Tag.Get("koanf") + if strings.ToLower(strutTag) == strings.ToLower(k) { + field = ref.Field(i) + break + } + } + + if field == (reflect.Value{}) { + return fmt.Errorf("field %s does not exist", k) + } + + if !field.CanSet() { + return fmt.Errorf("field %s cannot be set", k) + } + + switch field.Kind() { + case reflect.Bool: + if vstr, vstrok := v.(string); vstrok { + if vb, err := strconv.ParseBool(vstr); err == nil { + field.SetBool(vb) + return nil + } + } + if b, ok := v.(bool); ok { + field.SetBool(b) + return nil + } + return fmt.Errorf("field %s is not a bool", k) + case reflect.String: + if s, ok := v.(string); ok { + field.SetString(s) + return nil + } + return fmt.Errorf("field %s is not a string", k) + case reflect.Slice: + if s, ok := v.([]string); ok { + field.Set(reflect.ValueOf(s)) + return nil + } + return fmt.Errorf("field %s is not a slice", k) + case reflect.Int: + if i, ok := v.(int); ok { + field.SetInt(int64(i)) + return nil + } + return fmt.Errorf("field %s is not an int", k) + default: + return fmt.Errorf("field %s is not a supported type (%T)", k, v) + } +} + +func (c *Configuration) findFallbackDir() error { + locs := []string{"/var/log"} + uconf, err := os.UserHomeDir() + if err == nil { + locs = append(locs, filepath.Join(uconf, ".local", "share")) + } + + var errs []error + + for _, loc := range locs { + if _, err = os.Stat(loc); err == nil { + var locStat os.FileInfo + if locStat, err = os.Stat(filepath.Join(loc, "HellPot")); err == nil { + if locStat.IsDir() { + c.Directory = filepath.Join(loc, "HellPot") + c.File = "HellPot.log" + return nil + } + errs = append(errs, fmt.Errorf("HellPot directory exists but is not a directory")) + } else if !errors.Is(err, os.ErrNotExist) { + errs = append(errs, err) + } + + if err = os.MkdirAll(filepath.Join(loc, "HellPot"), 0750); err == nil { + c.Directory = filepath.Join(loc, "HellPot") + c.File = "HellPot.log" + return nil + } + errs = append(errs, fmt.Errorf("failed to create HellPot directory")) + } + } + + return errors.Join(errs...) +} + +func (c *Configuration) Validate() error { + if c.DockerLogging { + c.Outputs = []io.Writer{os.Stdout} + } + if c.Directory == "" && c.File == "" && c.ActiveLogFileName == "" && c.RSyslog == "" && !c.DockerLogging { + if err := c.findFallbackDir(); err != nil { + return fmt.Errorf("failed to find a fallback log directory: %w", err) + } + } + if len(c.Outputs) == 0 && c.Directory == "" && c.File == "" && c.RSyslog == "" { + return ErrNoOutputs + } + if c.File != "" && c.Directory != "" && filepath.Dir(c.File) != c.Directory && filepath.Base(c.File) != c.File { + return ErrBothFileAndDirectory + } + return nil +} + +func (c *Configuration) setupDirAndFile() (string, error) { + switch { + case c.Directory != "": + stat, err := os.Stat(c.Directory) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("failed to access specified log directory: %w", err) + } + if errors.Is(err, os.ErrNotExist) { + if err = os.MkdirAll(c.Directory, 0750); err != nil { + return "", fmt.Errorf("failed to create specified log directory: %w", err) + } + } + if stat != nil && !stat.IsDir() { + return "", fmt.Errorf("specified log directory is not a directory") + } + if c.File == "" { + c.File = "HellPot.log" + } + case c.Directory == "" && c.File != "": + stat, err := os.Stat(filepath.Dir(c.File)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("failed to access specified log directory: %w", err) + } + if errors.Is(err, os.ErrNotExist) { + if err = os.MkdirAll(filepath.Dir(c.File), 0750); err != nil { + return "", fmt.Errorf("failed to create specified log directory: %w", err) + } + } + if stat != nil && !stat.IsDir() { + panic("specified log directory is not a directory, but it should be...? please report this issue on github") + } + case c.Directory == "" && c.File == "" && c.ActiveLogFileName == "" && c.RSyslog == "" && !c.DockerLogging: + return "", fmt.Errorf("no log directory or file specified") + } + var f *os.File + var err error + if c.TimeDateFilename { + og := filepath.Base(c.File) + ext := filepath.Ext(og) + og = strings.TrimSuffix(og, ext) + c.File = filepath.Join( + filepath.Dir(c.File), + fmt.Sprintf("%s-%s%s", og, time.Now().Format("2006-01-02-15-04-05"), ext), + ) + } + if f, err = os.OpenFile(filepath.Join(c.Directory, c.File), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600); err != nil { + return "", fmt.Errorf("failed to open log file: %w", err) + } + c.Outputs = append(c.Outputs, f) + c.ActiveLogFileName = f.Name() + return f.Name(), nil +} + +func (c *Configuration) setupSyslog() (error, bool) { + if c.RSyslog == "" { + return nil, false + } + var ( + err error + proto string + addr string + conn *syslog.Writer + ) + switch { + case strings.ToLower(c.RSyslog) == "local": + proto = "" + addr = "" + case strings.Contains(c.RSyslog, "://"): + proto = strings.Split(c.RSyslog, "://")[0] + addr = strings.Split(c.RSyslog, "://")[1] + case strings.Contains(c.RSyslog, ":"): + proto = "udp" + addr = c.RSyslog + default: + proto = "udp" + addr = c.RSyslog + ":514" + } + + if proto != "" && addr != "" { + println("dialing syslog server: " + c.rsyslogTarget + "...") + } + + syslogLogLevel := syslog.LOG_INFO + if c.Debug || c.Trace { + syslogLogLevel = syslog.LOG_DEBUG + } + + if conn, err = syslog.Dial(proto, addr, syslogLogLevel, "HellPot"); err != nil { + return fmt.Errorf("failed to dial syslog server: %w", err), false + } + + c.rsyslogTarget = "syslog" + if proto != "" && addr != "" { + c.rsyslogTarget = proto + "://" + addr + } + + c.Outputs = append(c.Outputs, zerolog.SyslogLevelWriter(conn)) + + return nil, true +} + +func (c *Configuration) SetupOutputs() (rsyslogEnabled bool, err error) { + var logPath string + if c.Directory != "" || c.File != "" { + if logPath, err = c.setupDirAndFile(); err != nil { + return false, fmt.Errorf("failed to setup log file: %w", err) + } + c.ActiveLogFileName = logPath + } + + if c.RSyslog != "" { + if err, rsyslogEnabled = c.setupSyslog(); err != nil { + return false, fmt.Errorf("failed to setup syslog: %w", err) + } + } + + consoleSeen := false + + for _, out := range c.Outputs { + if out == nil { + return false, fmt.Errorf("nil output provided") + } + if out == os.Stdout || out == os.Stderr { + consoleSeen = true + break + } + } + + if !consoleSeen && !c.NoConsole { + c.Outputs = append(c.Outputs, os.Stdout) + } + + return rsyslogEnabled, nil +} + +var once = &sync.Once{} + +func GetLoggerOnce() *Log { + var ret *Log + once.Do(func() { + if _log == nil { + panic("early access to logger") + } + ret = _log + }) + if ret == nil { + panic("i said once you fool") + } + return ret +} + +var ( + ErrNoOutputs = errors.New( + "no outputs provided, if console only logging is desired, set docker_logging to true", + ) + ErrBothFileAndDirectory = errors.New( + "cannot specify both file and directory unless file is a child of directory", + ) +) + +var _log *Log + +func New(conf *Configuration) (*Log, error) { + if err := conf.Validate(); err != nil { + return nil, fmt.Errorf("invalid logger configuration: %w", err) + } + var err error + var rsyslogEnabled bool + if rsyslogEnabled, err = conf.SetupOutputs(); err != nil { + return nil, fmt.Errorf("failed to setup logger outputs: %w", err) + } + for i, output := range conf.Outputs { + if output == os.Stdout || output == os.Stderr { + cw := zerolog.ConsoleWriter{Out: output, TimeFormat: conf.ConsoleTimeFormat, NoColor: conf.NoColor} + conf.Outputs[i] = cw + } + } + _log = &Log{Logger: zerolog.New(zerolog.MultiLevelWriter(conf.Outputs...)).With().Timestamp().Logger(), Config: conf} + _log.Logger = _log.Level(zerolog.InfoLevel) + zerolog.SetGlobalLevel(zerolog.InfoLevel) + if conf.Debug { + _log.Logger = _log.Level(zerolog.DebugLevel) + zerolog.SetGlobalLevel(zerolog.DebugLevel) + } + if conf.Trace { + _log.Logger = _log.Level(zerolog.TraceLevel) + zerolog.SetGlobalLevel(zerolog.TraceLevel) + } + + if rsyslogEnabled { + _log.Info().Str("target", conf.rsyslogTarget).Msg("syslog connection established") + } + + return _log, nil +} diff --git a/internal/version/globals.go b/internal/version/globals.go new file mode 100644 index 0000000..ae9837f --- /dev/null +++ b/internal/version/globals.go @@ -0,0 +1,26 @@ +package version + +import ( + "runtime/debug" +) + +const HP = "HellPot" + +var Version = "dev" + +func init() { + if Version != "dev" { + return + } + binInfo := make(map[string]string) + info, ok := debug.ReadBuildInfo() + if !ok { + return + } + for _, v := range info.Settings { + binInfo[v.Key] = v.Value + } + if gitrev, ok := binInfo["vcs.revision"]; ok { + Version = gitrev[:7] + } +}