feat: implement SSH honeypot server with auth and config

Add core SSH server with password authentication, per-IP failure
tracking, credential memory with TTL, and static credential support.
Includes TOML config loading with validation, Ed25519 host key
auto-generation, and a Nix package output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 16:36:12 +01:00
parent f657b90357
commit 51fdea0c2f
13 changed files with 1063 additions and 0 deletions

View File

@@ -0,0 +1,138 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestLoadValidConfig(t *testing.T) {
content := `
log_level = "debug"
[ssh]
listen_addr = ":3333"
host_key_path = "/tmp/test_key"
[auth]
accept_after = 5
credential_ttl = "1h"
[[auth.static_credentials]]
username = "root"
password = "toor"
`
path := writeTemp(t, content)
cfg, err := Load(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.LogLevel != "debug" {
t.Errorf("log_level = %q, want %q", cfg.LogLevel, "debug")
}
if cfg.SSH.ListenAddr != ":3333" {
t.Errorf("listen_addr = %q, want %q", cfg.SSH.ListenAddr, ":3333")
}
if cfg.SSH.HostKeyPath != "/tmp/test_key" {
t.Errorf("host_key_path = %q, want %q", cfg.SSH.HostKeyPath, "/tmp/test_key")
}
if cfg.Auth.AcceptAfter != 5 {
t.Errorf("accept_after = %d, want %d", cfg.Auth.AcceptAfter, 5)
}
if cfg.Auth.CredentialTTLDuration != time.Hour {
t.Errorf("credential_ttl_duration = %v, want %v", cfg.Auth.CredentialTTLDuration, time.Hour)
}
if len(cfg.Auth.StaticCredentials) != 1 {
t.Fatalf("static_credentials len = %d, want 1", len(cfg.Auth.StaticCredentials))
}
if cfg.Auth.StaticCredentials[0].Username != "root" {
t.Errorf("username = %q, want %q", cfg.Auth.StaticCredentials[0].Username, "root")
}
}
func TestLoadDefaults(t *testing.T) {
path := writeTemp(t, "")
cfg, err := Load(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.SSH.ListenAddr != ":2222" {
t.Errorf("default listen_addr = %q, want %q", cfg.SSH.ListenAddr, ":2222")
}
if cfg.SSH.HostKeyPath != "oubliette_host_key" {
t.Errorf("default host_key_path = %q, want %q", cfg.SSH.HostKeyPath, "oubliette_host_key")
}
if cfg.Auth.AcceptAfter != 10 {
t.Errorf("default accept_after = %d, want %d", cfg.Auth.AcceptAfter, 10)
}
if cfg.Auth.CredentialTTLDuration != 24*time.Hour {
t.Errorf("default credential_ttl = %v, want %v", cfg.Auth.CredentialTTLDuration, 24*time.Hour)
}
if cfg.LogLevel != "info" {
t.Errorf("default log_level = %q, want %q", cfg.LogLevel, "info")
}
}
func TestLoadInvalidTTL(t *testing.T) {
content := `
[auth]
credential_ttl = "notaduration"
`
path := writeTemp(t, content)
_, err := Load(path)
if err == nil {
t.Fatal("expected error for invalid credential_ttl")
}
}
func TestLoadNegativeTTL(t *testing.T) {
content := `
[auth]
credential_ttl = "-1h"
`
path := writeTemp(t, content)
_, err := Load(path)
if err == nil {
t.Fatal("expected error for negative credential_ttl")
}
}
func TestLoadInvalidStaticCredential(t *testing.T) {
content := `
[[auth.static_credentials]]
username = ""
password = "test"
`
path := writeTemp(t, content)
_, err := Load(path)
if err == nil {
t.Fatal("expected error for empty username")
}
}
func TestLoadMissingFile(t *testing.T) {
_, err := Load("/nonexistent/path/config.toml")
if err == nil {
t.Fatal("expected error for missing file")
}
}
func TestLoadInvalidTOML(t *testing.T) {
path := writeTemp(t, "{{{{invalid toml")
_, err := Load(path)
if err == nil {
t.Fatal("expected error for invalid TOML")
}
}
func writeTemp(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("writing temp config: %v", err)
}
return path
}