diff --git a/cmd/certificate/main.go b/cmd/certificate/main.go new file mode 100644 index 0000000..9cf977a --- /dev/null +++ b/cmd/certificate/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "log" + "math/big" + "net" + "os" + "time" +) + +func main() { + // создаём шаблон сертификата + cert := &x509.Certificate{ + // указываем уникальный номер сертификата + SerialNumber: big.NewInt(1658), + // заполняем базовую информацию о владельце сертификата + Subject: pkix.Name{ + Organization: []string{"Yandex.Praktikum"}, + Country: []string{"RU"}, + }, + // разрешаем использование сертификата для 127.0.0.1 и ::1 + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, + // сертификат верен, начиная со времени создания + NotBefore: time.Now(), + // время жизни сертификата — 10 лет + NotAfter: time.Now().AddDate(10, 0, 0), + SubjectKeyId: []byte{1, 2, 3, 4, 6}, + // устанавливаем использование ключа для цифровой подписи, + // а также клиентской и серверной авторизации + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + KeyUsage: x509.KeyUsageDigitalSignature, + } + + // создаём новый приватный RSA-ключ длиной 4096 бит + // обратите внимание, что для генерации ключа и сертификата + // используется rand.Reader в качестве источника случайных данных + privateKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + log.Fatal(err) + } + + // создаём сертификат x.509 + certBytes, err := x509.CreateCertificate(rand.Reader, cert, cert, &privateKey.PublicKey, privateKey) + if err != nil { + log.Fatal(err) + } + + // кодируем сертификат и ключ в формате PEM, который + // используется для хранения и обмена криптографическими ключами + var certPEM bytes.Buffer + err = pem.Encode(&certPEM, &pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }) + if err != nil { + log.Fatal(err) + } + + var privateKeyPEM bytes.Buffer + err = pem.Encode(&privateKeyPEM, &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }) + if err != nil { + log.Fatal(err) + } + + // Сохраняем сертификат и приватный ключ в файлы cert.pem и private.pem + if err = os.WriteFile("cert.pem", certPEM.Bytes(), 0644); err != nil { + log.Fatal(err) + } + + if err = os.WriteFile("key.pem", privateKeyPEM.Bytes(), 0644); err != nil { + log.Fatal(err) + } +} diff --git a/internal/app/app.go b/internal/app/app.go index d6df4e5..d77b8ba 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -30,6 +30,7 @@ type App struct { router *gin.Engine urlHandler *handler.URLHandler deleteWorker *worker.DeleteWorker + enableTLS bool } // New создаёт и настраивает новое приложение: @@ -152,6 +153,7 @@ func New() (*App, error) { router: router, urlHandler: urlHandler, deleteWorker: deleteWorker, + enableTLS: cfg.Enable_HTTPS, }, nil } @@ -201,6 +203,9 @@ func (a *App) SetupRoutes() { // Run запускает HTTP-сервер. func (a *App) Run() error { + if a.enableTLS { + return a.server.ListenAndServeTLS("cert.pem", "key.pem") + } return a.server.ListenAndServe() } diff --git a/internal/config/flag/config.go b/internal/config/flag/config.go index 99101bf..5845f7a 100644 --- a/internal/config/flag/config.go +++ b/internal/config/flag/config.go @@ -1,8 +1,10 @@ -// Package config обеспечивает загрузку конфигурации из флагов командной строки -// и переменных окружения. Переменные окружения имеют приоритет. +// Package config обеспечивает загрузку конфигурации из флагов командной строки, +// переменных окружения и JSON-файла конфигурации. +// Приоритет: переменные окружения > флаги > JSON-файл. package config import ( + "encoding/json" "flag" "os" "strconv" @@ -21,6 +23,17 @@ type ServerConfig struct { SecretKey string `env:"SECRET_KEY"` AuditFile string `env:"AUDIT_FILE"` AuditURL string `env:"AUDIT_URL"` + Enable_HTTPS bool `env:"ENABLE_HTTPS"` + ConfigJSON string `env:"CONFIG"` +} + +// JSONFileConfig — структура JSON-файла конфигурации. +type JSONFileConfig struct { + ServerAddress string `json:"server_address"` + BaseURL string `json:"base_url"` + FileStoragePath string `json:"file_storage_path"` + DatabaseDSN string `json:"database_dsn"` + EnableHTTPS *bool `json:"enable_https"` } var ( @@ -34,7 +47,10 @@ var ( secretKey *string auditFile *string auditURL *string + enableHTTPS *bool flagsOnce sync.Once + jsonConfig *string + jsonConfigLong *string ) func initFlags() { @@ -43,14 +59,31 @@ func initFlags() { envValue = flag.String("e", "dev", "environment") fileValue = flag.String("f", "data.json", "file to store data") dsnValue = flag.String("d", "", "database DSN") - deleteWorkerCount = flag.Int("w", 2, "number of delete worker channels") - deleteChannelSize = flag.Int("c", 1000, "size of each delete channel buffer") - secretKey = flag.String("s", "my-super-secret-key-change-in-production", "secret key for signing cookies") + deleteWorkerCount = flag.Int("wc", 2, "number of delete worker channels") + deleteChannelSize = flag.Int("cs", 1000, "size of each delete channel buffer") + secretKey = flag.String("sk", "my-super-secret-key-change-in-production", "secret key for signing cookies") auditFile = flag.String("audit-file", "", "path to audit log file") auditURL = flag.String("audit-url", "", "URL of remote audit server") + enableHTTPS = flag.Bool("s", false, "enable HTTPS") + jsonConfig = flag.String("c", "", "path to JSON config file") + jsonConfigLong = flag.String("config", "", "path to JSON config file") +} + +// loadJSONConfig загружает конфигурацию из JSON-файла. +func loadJSONConfig(path string) (*JSONFileConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg JSONFileConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil } -// NewServerConfig создаёт конфигурацию, читая флаги и переменные окружения. +// NewServerConfig создаёт конфигурацию, читая флаги, JSON-файл и переменные окружения. +// Приоритет: переменная окружения > явно переданный флаг > JSON-конфиг > значение флага по умолчанию. func NewServerConfig() *ServerConfig { flagsOnce.Do(initFlags) @@ -58,40 +91,108 @@ func NewServerConfig() *ServerConfig { flag.Parse() } - cfg := &ServerConfig{} + // Определяем, какие флаги были явно переданы + flagSet := make(map[string]bool) + flag.Visit(func(f *flag.Flag) { + flagSet[f.Name] = true + }) + + // Определяем путь к JSON-конфигурации: ENV CONFIG > флаг -c/-config + configPath := "" + if v, ok := os.LookupEnv("CONFIG"); ok { + configPath = v + } else if *jsonConfig != "" { + configPath = *jsonConfig + } else if *jsonConfigLong != "" { + configPath = *jsonConfigLong + } - if envAddr, ok := os.LookupEnv("SERVER_ADDRESS"); ok { - cfg.AddrServer = envAddr - } else { + // Загружаем JSON-конфиг, если путь задан + var jcfg *JSONFileConfig + if configPath != "" { + if loaded, err := loadJSONConfig(configPath); err == nil { + jcfg = loaded + } + } + + cfg := &ServerConfig{ + ConfigJSON: configPath, + } + + // --- Поля с поддержкой JSON-конфига --- + + // AddrServer + cfg.AddrServer = *addrServer + if jcfg != nil && jcfg.ServerAddress != "" { + cfg.AddrServer = jcfg.ServerAddress + } + if flagSet["a"] { cfg.AddrServer = *addrServer } + if v, ok := os.LookupEnv("SERVER_ADDRESS"); ok { + cfg.AddrServer = v + } - if envURL, ok := os.LookupEnv("BASE_URL"); ok { - cfg.AddrURL = envURL - } else { + // AddrURL + cfg.AddrURL = *addrURL + if jcfg != nil && jcfg.BaseURL != "" { + cfg.AddrURL = jcfg.BaseURL + } + if flagSet["b"] { cfg.AddrURL = *addrURL } - - if envEnv, ok := os.LookupEnv("ENV"); ok { - cfg.Env = envEnv - } else { - cfg.Env = *envValue + if v, ok := os.LookupEnv("BASE_URL"); ok { + cfg.AddrURL = v } - if envFile, ok := os.LookupEnv("FILE_STORAGE_PATH"); ok { - cfg.File = envFile - } else { + // File + cfg.File = *fileValue + if jcfg != nil && jcfg.FileStoragePath != "" { + cfg.File = jcfg.FileStoragePath + } + if flagSet["f"] { cfg.File = *fileValue } + if v, ok := os.LookupEnv("FILE_STORAGE_PATH"); ok { + cfg.File = v + } - if envDSN, ok := os.LookupEnv("DATABASE_DSN"); ok { - cfg.DSN = envDSN - } else { + // DSN + cfg.DSN = *dsnValue + if jcfg != nil && jcfg.DatabaseDSN != "" { + cfg.DSN = jcfg.DatabaseDSN + } + if flagSet["d"] { cfg.DSN = *dsnValue } + if v, ok := os.LookupEnv("DATABASE_DSN"); ok { + cfg.DSN = v + } + + // Enable_HTTPS + cfg.Enable_HTTPS = *enableHTTPS + if jcfg != nil && jcfg.EnableHTTPS != nil { + cfg.Enable_HTTPS = *jcfg.EnableHTTPS + } + if flagSet["s"] { + cfg.Enable_HTTPS = *enableHTTPS + } + if v, ok := os.LookupEnv("ENABLE_HTTPS"); ok { + if val, err := strconv.ParseBool(v); err == nil { + cfg.Enable_HTTPS = val + } + } + + // --- Поля без JSON-конфига --- + + if v, ok := os.LookupEnv("ENV"); ok { + cfg.Env = v + } else { + cfg.Env = *envValue + } - if envWorkerCount, ok := os.LookupEnv("DELETE_WORKER_COUNT"); ok { - if count, err := parseIntFromEnv(envWorkerCount); err == nil { + if v, ok := os.LookupEnv("DELETE_WORKER_COUNT"); ok { + if count, err := parseIntFromEnv(v); err == nil { cfg.DeleteWorkerCount = count } else { cfg.DeleteWorkerCount = *deleteWorkerCount @@ -100,8 +201,8 @@ func NewServerConfig() *ServerConfig { cfg.DeleteWorkerCount = *deleteWorkerCount } - if envChannelSize, ok := os.LookupEnv("DELETE_CHANNEL_SIZE"); ok { - if size, err := parseIntFromEnv(envChannelSize); err == nil { + if v, ok := os.LookupEnv("DELETE_CHANNEL_SIZE"); ok { + if size, err := parseIntFromEnv(v); err == nil { cfg.DeleteChannelSize = size } else { cfg.DeleteChannelSize = *deleteChannelSize @@ -110,20 +211,20 @@ func NewServerConfig() *ServerConfig { cfg.DeleteChannelSize = *deleteChannelSize } - if envSecretKey, ok := os.LookupEnv("SECRET_KEY"); ok { - cfg.SecretKey = envSecretKey + if v, ok := os.LookupEnv("SECRET_KEY"); ok { + cfg.SecretKey = v } else { cfg.SecretKey = *secretKey } - if envAuditFile, ok := os.LookupEnv("AUDIT_FILE"); ok { - cfg.AuditFile = envAuditFile + if v, ok := os.LookupEnv("AUDIT_FILE"); ok { + cfg.AuditFile = v } else { cfg.AuditFile = *auditFile } - if envAuditURL, ok := os.LookupEnv("AUDIT_URL"); ok { - cfg.AuditURL = envAuditURL + if v, ok := os.LookupEnv("AUDIT_URL"); ok { + cfg.AuditURL = v } else { cfg.AuditURL = *auditURL } diff --git a/internal/config/flag/config_test.go b/internal/config/flag/config_test.go index 7e01afc..638159b 100644 --- a/internal/config/flag/config_test.go +++ b/internal/config/flag/config_test.go @@ -1,6 +1,8 @@ package config import ( + "os" + "path/filepath" "testing" ) @@ -104,3 +106,151 @@ func TestNewServerConfig(t *testing.T) { }) } } + +func TestLoadJSONConfig(t *testing.T) { + t.Run("valid JSON file", func(t *testing.T) { + content := `{"server_address":"localhost:9090","base_url":"http://example.com","file_storage_path":"/tmp/test.db","database_dsn":"postgres://test","enable_https":true}` + tmpFile := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := loadJSONConfig(tmpFile) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.ServerAddress != "localhost:9090" { + t.Errorf("ServerAddress = %v, want localhost:9090", cfg.ServerAddress) + } + if cfg.BaseURL != "http://example.com" { + t.Errorf("BaseURL = %v, want http://example.com", cfg.BaseURL) + } + if cfg.FileStoragePath != "/tmp/test.db" { + t.Errorf("FileStoragePath = %v, want /tmp/test.db", cfg.FileStoragePath) + } + if cfg.DatabaseDSN != "postgres://test" { + t.Errorf("DatabaseDSN = %v, want postgres://test", cfg.DatabaseDSN) + } + if cfg.EnableHTTPS == nil || !*cfg.EnableHTTPS { + t.Errorf("EnableHTTPS = %v, want true", cfg.EnableHTTPS) + } + }) + + t.Run("file not found", func(t *testing.T) { + _, err := loadJSONConfig("/nonexistent/config.json") + if err == nil { + t.Error("expected error for missing file") + } + }) + + t.Run("invalid JSON", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "bad.json") + if err := os.WriteFile(tmpFile, []byte("{invalid"), 0644); err != nil { + t.Fatal(err) + } + _, err := loadJSONConfig(tmpFile) + if err == nil { + t.Error("expected error for invalid JSON") + } + }) +} + +func TestParseIntFromEnv(t *testing.T) { + if v, err := parseIntFromEnv("42"); err != nil || v != 42 { + t.Errorf("parseIntFromEnv(\"42\") = %v, %v; want 42, nil", v, err) + } + if _, err := parseIntFromEnv("abc"); err == nil { + t.Error("expected error for non-numeric string") + } +} + +func TestNewServerConfigWithJSONFile(t *testing.T) { + content := `{"server_address":"json-host:3000","base_url":"http://json-url","file_storage_path":"/json/path.db","database_dsn":"json-dsn","enable_https":true}` + tmpFile := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + t.Run("JSON config used as base", func(t *testing.T) { + t.Setenv("CONFIG", tmpFile) + + got := NewServerConfig() + + if got.AddrServer != "json-host:3000" { + t.Errorf("AddrServer = %v, want json-host:3000", got.AddrServer) + } + if got.AddrURL != "http://json-url" { + t.Errorf("AddrURL = %v, want http://json-url", got.AddrURL) + } + if got.File != "/json/path.db" { + t.Errorf("File = %v, want /json/path.db", got.File) + } + if got.DSN != "json-dsn" { + t.Errorf("DSN = %v, want json-dsn", got.DSN) + } + if !got.Enable_HTTPS { + t.Errorf("Enable_HTTPS = %v, want true", got.Enable_HTTPS) + } + }) + + t.Run("env vars override JSON config", func(t *testing.T) { + t.Setenv("CONFIG", tmpFile) + t.Setenv("SERVER_ADDRESS", ":9999") + t.Setenv("BASE_URL", "http://env-url") + t.Setenv("FILE_STORAGE_PATH", "/env/path.db") + t.Setenv("DATABASE_DSN", "env-dsn") + t.Setenv("ENABLE_HTTPS", "false") + t.Setenv("DELETE_WORKER_COUNT", "5") + t.Setenv("DELETE_CHANNEL_SIZE", "500") + t.Setenv("SECRET_KEY", "env-secret") + t.Setenv("AUDIT_FILE", "/env/audit.log") + t.Setenv("AUDIT_URL", "http://env-audit") + + got := NewServerConfig() + + if got.AddrServer != ":9999" { + t.Errorf("AddrServer = %v, want :9999", got.AddrServer) + } + if got.AddrURL != "http://env-url" { + t.Errorf("AddrURL = %v, want http://env-url", got.AddrURL) + } + if got.File != "/env/path.db" { + t.Errorf("File = %v, want /env/path.db", got.File) + } + if got.DSN != "env-dsn" { + t.Errorf("DSN = %v, want env-dsn", got.DSN) + } + if got.Enable_HTTPS { + t.Errorf("Enable_HTTPS = %v, want false", got.Enable_HTTPS) + } + if got.DeleteWorkerCount != 5 { + t.Errorf("DeleteWorkerCount = %v, want 5", got.DeleteWorkerCount) + } + if got.DeleteChannelSize != 500 { + t.Errorf("DeleteChannelSize = %v, want 500", got.DeleteChannelSize) + } + if got.SecretKey != "env-secret" { + t.Errorf("SecretKey = %v, want env-secret", got.SecretKey) + } + if got.AuditFile != "/env/audit.log" { + t.Errorf("AuditFile = %v, want /env/audit.log", got.AuditFile) + } + if got.AuditURL != "http://env-audit" { + t.Errorf("AuditURL = %v, want http://env-audit", got.AuditURL) + } + }) + + t.Run("invalid env ints fallback to flag defaults", func(t *testing.T) { + t.Setenv("DELETE_WORKER_COUNT", "not-a-number") + t.Setenv("DELETE_CHANNEL_SIZE", "not-a-number") + + got := NewServerConfig() + + if got.DeleteWorkerCount != 2 { + t.Errorf("DeleteWorkerCount = %v, want 2 (flag default)", got.DeleteWorkerCount) + } + if got.DeleteChannelSize != 1000 { + t.Errorf("DeleteChannelSize = %v, want 1000 (flag default)", got.DeleteChannelSize) + } + }) +}