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:
98
internal/config/config.go
Normal file
98
internal/config/config.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
SSH SSHConfig `toml:"ssh"`
|
||||
Auth AuthConfig `toml:"auth"`
|
||||
LogLevel string `toml:"log_level"`
|
||||
}
|
||||
|
||||
type SSHConfig struct {
|
||||
ListenAddr string `toml:"listen_addr"`
|
||||
HostKeyPath string `toml:"host_key_path"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
AcceptAfter int `toml:"accept_after"`
|
||||
CredentialTTL string `toml:"credential_ttl"`
|
||||
StaticCredentials []Credential `toml:"static_credentials"`
|
||||
|
||||
// Parsed duration, not from TOML directly.
|
||||
CredentialTTLDuration time.Duration `toml:"-"`
|
||||
}
|
||||
|
||||
type Credential struct {
|
||||
Username string `toml:"username"`
|
||||
Password string `toml:"password"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading config: %w", err)
|
||||
}
|
||||
|
||||
cfg := &Config{}
|
||||
if err := toml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing config: %w", err)
|
||||
}
|
||||
|
||||
applyDefaults(cfg)
|
||||
|
||||
if err := validate(cfg); err != nil {
|
||||
return nil, fmt.Errorf("validating config: %w", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func applyDefaults(cfg *Config) {
|
||||
if cfg.SSH.ListenAddr == "" {
|
||||
cfg.SSH.ListenAddr = ":2222"
|
||||
}
|
||||
if cfg.SSH.HostKeyPath == "" {
|
||||
cfg.SSH.HostKeyPath = "oubliette_host_key"
|
||||
}
|
||||
if cfg.Auth.AcceptAfter == 0 {
|
||||
cfg.Auth.AcceptAfter = 10
|
||||
}
|
||||
if cfg.Auth.CredentialTTL == "" {
|
||||
cfg.Auth.CredentialTTL = "24h"
|
||||
}
|
||||
if cfg.LogLevel == "" {
|
||||
cfg.LogLevel = "info"
|
||||
}
|
||||
}
|
||||
|
||||
func validate(cfg *Config) error {
|
||||
d, err := time.ParseDuration(cfg.Auth.CredentialTTL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid credential_ttl %q: %w", cfg.Auth.CredentialTTL, err)
|
||||
}
|
||||
if d <= 0 {
|
||||
return fmt.Errorf("credential_ttl must be positive, got %s", d)
|
||||
}
|
||||
cfg.Auth.CredentialTTLDuration = d
|
||||
|
||||
if cfg.Auth.AcceptAfter < 1 {
|
||||
return fmt.Errorf("accept_after must be at least 1, got %d", cfg.Auth.AcceptAfter)
|
||||
}
|
||||
|
||||
for i, cred := range cfg.Auth.StaticCredentials {
|
||||
if cred.Username == "" {
|
||||
return fmt.Errorf("static_credentials[%d]: username must not be empty", i)
|
||||
}
|
||||
if cred.Password == "" {
|
||||
return fmt.Errorf("static_credentials[%d]: password must not be empty", i)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
138
internal/config/config_test.go
Normal file
138
internal/config/config_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user