Lots more changes
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"scratchbox/internal/config"
|
||||
)
|
||||
|
||||
func newAccessLogWriter(cfg config.Config) (io.Writer, func() error, error) {
|
||||
if !cfg.Server.AccessLog.Enabled {
|
||||
return io.Discard, func() error { return nil }, nil
|
||||
}
|
||||
if cfg.Server.AccessLog.FilePath == "" {
|
||||
return os.Stdout, func() error { return nil }, nil
|
||||
}
|
||||
|
||||
writer, err := newRollingFileWriter(
|
||||
cfg.Server.AccessLog.FilePath,
|
||||
cfg.Server.AccessLog.MaxSizeBytes,
|
||||
cfg.Server.AccessLog.MaxBackups,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return io.MultiWriter(os.Stdout, writer), writer.Close, nil
|
||||
}
|
||||
|
||||
type rollingFileWriter struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
maxSize int64
|
||||
maxBackups int
|
||||
file *os.File
|
||||
size int64
|
||||
}
|
||||
|
||||
func newRollingFileWriter(path string, maxSize int64, maxBackups int) (*rollingFileWriter, error) {
|
||||
if maxSize <= 0 {
|
||||
return nil, fmt.Errorf("max size must be > 0")
|
||||
}
|
||||
if maxBackups <= 0 {
|
||||
return nil, fmt.Errorf("max backups must be > 0")
|
||||
}
|
||||
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create log directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open log file: %w", err)
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, fmt.Errorf("stat log file: %w", err)
|
||||
}
|
||||
|
||||
return &rollingFileWriter{
|
||||
path: path,
|
||||
maxSize: maxSize,
|
||||
maxBackups: maxBackups,
|
||||
file: f,
|
||||
size: info.Size(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *rollingFileWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if w.size > 0 && w.size+int64(len(p)) > w.maxSize {
|
||||
if err := w.rotateLocked(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
n, err := w.file.Write(p)
|
||||
w.size += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (w *rollingFileWriter) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.file == nil {
|
||||
return nil
|
||||
}
|
||||
err := w.file.Close()
|
||||
w.file = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *rollingFileWriter) rotateLocked() error {
|
||||
if err := w.file.Close(); err != nil {
|
||||
return fmt.Errorf("close before rotate: %w", err)
|
||||
}
|
||||
|
||||
for i := w.maxBackups - 1; i >= 1; i-- {
|
||||
from := rotatedLogPath(w.path, i)
|
||||
to := rotatedLogPath(w.path, i+1)
|
||||
if _, err := os.Stat(from); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("stat rotated file %s: %w", from, err)
|
||||
}
|
||||
_ = os.Remove(to)
|
||||
if err := os.Rename(from, to); err != nil {
|
||||
return fmt.Errorf("rotate backup %s -> %s: %w", from, to, err)
|
||||
}
|
||||
}
|
||||
|
||||
firstBackup := rotatedLogPath(w.path, 1)
|
||||
_ = os.Remove(firstBackup)
|
||||
if _, err := os.Stat(w.path); err == nil {
|
||||
if err := os.Rename(w.path, firstBackup); err != nil {
|
||||
return fmt.Errorf("rotate current log to %s: %w", firstBackup, err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("stat current log file: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(w.path, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open rotated log file: %w", err)
|
||||
}
|
||||
w.file = f
|
||||
w.size = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
func rotatedLogPath(path string, index int) string {
|
||||
return fmt.Sprintf("%s.%d", path, index)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"scratchbox/internal/config"
|
||||
)
|
||||
|
||||
func TestNewAccessLogWriterDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.Server.AccessLog.Enabled = false
|
||||
writer, closeFn, err := newAccessLogWriter(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("newAccessLogWriter() error = %v", err)
|
||||
}
|
||||
if writer == nil {
|
||||
t.Fatalf("newAccessLogWriter() writer = nil")
|
||||
}
|
||||
if err := closeFn(); err != nil {
|
||||
t.Fatalf("closeFn() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollingFileWriterRotatesAndKeepsBackups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logPath := filepath.Join(t.TempDir(), "access.log")
|
||||
w, err := newRollingFileWriter(logPath, 10, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("newRollingFileWriter() error = %v", err)
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
_, _ = w.Write([]byte("12345"))
|
||||
_, _ = w.Write([]byte("67890"))
|
||||
_, _ = w.Write([]byte("abc"))
|
||||
_, _ = w.Write([]byte("defghijkl"))
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
|
||||
current, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(current) error = %v", err)
|
||||
}
|
||||
backup1, err := os.ReadFile(logPath + ".1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(backup1) error = %v", err)
|
||||
}
|
||||
backup2, err := os.ReadFile(logPath + ".2")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(backup2) error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(current, []byte("defghijkl")) {
|
||||
t.Fatalf("current log = %q, want %q", string(current), "defghijkl")
|
||||
}
|
||||
if !bytes.Equal(backup1, []byte("abc")) {
|
||||
t.Fatalf("backup1 log = %q, want %q", string(backup1), "abc")
|
||||
}
|
||||
if !bytes.Equal(backup2, []byte("1234567890")) {
|
||||
t.Fatalf("backup2 log = %q, want %q", string(backup2), "1234567890")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"scratchbox/internal/config"
|
||||
)
|
||||
|
||||
func newGenerateKeyCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "generate-key",
|
||||
Short: "Print a random base64 encryption key to stdout",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return writeEncryptionKey(cmd)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func writeEncryptionKey(cmd *cobra.Command) error {
|
||||
key, err := config.GenerateEncryptionKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate encryption key: %w", err)
|
||||
}
|
||||
if _, err := fmt.Fprintln(cmd.OutOrStdout(), key); err != nil {
|
||||
return fmt.Errorf("write encryption key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -27,5 +27,6 @@ func newRootCmd() *cobra.Command {
|
||||
}
|
||||
cmd.AddCommand(newServerCmd())
|
||||
cmd.AddCommand(newGenerateConfigCmd())
|
||||
cmd.AddCommand(newGenerateKeyCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
+104
-7
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
@@ -31,6 +32,18 @@ func repoRoot(t *testing.T) string {
|
||||
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
|
||||
}
|
||||
|
||||
func writeConfigKeyFile(t *testing.T, configPath string) {
|
||||
t.Helper()
|
||||
keyPath := filepath.Join(filepath.Dir(configPath), "config.key")
|
||||
key, err := config.GenerateEncryptionKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEncryptionKey() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, []byte(key+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(config.key) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainProcessSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -48,7 +61,15 @@ storage:
|
||||
security:
|
||||
allowed_ips: []
|
||||
trust_proxy_headers: false
|
||||
rate_limit:
|
||||
rate_limit_ui:
|
||||
enabled: true
|
||||
requests_per_minute: 10
|
||||
burst: 1
|
||||
rate_limit_api_read:
|
||||
enabled: true
|
||||
requests_per_minute: 10
|
||||
burst: 1
|
||||
rate_limit_api_write:
|
||||
enabled: true
|
||||
requests_per_minute: 10
|
||||
burst: 1
|
||||
@@ -56,6 +77,7 @@ security:
|
||||
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
writeConfigKeyFile(t, cfgPath)
|
||||
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestMainHelperProcess")
|
||||
cmd.Dir = repoRoot(t)
|
||||
@@ -104,7 +126,15 @@ storage:
|
||||
security:
|
||||
allowed_ips: []
|
||||
trust_proxy_headers: false
|
||||
rate_limit:
|
||||
rate_limit_ui:
|
||||
enabled: true
|
||||
requests_per_minute: 10
|
||||
burst: 1
|
||||
rate_limit_api_read:
|
||||
enabled: true
|
||||
requests_per_minute: 10
|
||||
burst: 1
|
||||
rate_limit_api_write:
|
||||
enabled: true
|
||||
requests_per_minute: 10
|
||||
burst: 1
|
||||
@@ -112,6 +142,7 @@ security:
|
||||
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
writeConfigKeyFile(t, cfgPath)
|
||||
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestMainHelperProcess")
|
||||
cmd.Dir = repoRoot(t)
|
||||
@@ -198,8 +229,26 @@ func TestGenerateConfigCommandWritesDefaultYAML(t *testing.T) {
|
||||
if cfg.Storage.DataDir != "./data" {
|
||||
t.Fatalf("storage.data_dir = %q, want %q", cfg.Storage.DataDir, "./data")
|
||||
}
|
||||
if cfg.Security.RateLimit.RequestsPerMinute != 30 {
|
||||
t.Fatalf("security.rate_limit.requests_per_minute = %d, want %d", cfg.Security.RateLimit.RequestsPerMinute, 30)
|
||||
if cfg.Storage.EncryptionKeyFile != "config.key" {
|
||||
t.Fatalf("storage.encryption_key_file = %q, want %q", cfg.Storage.EncryptionKeyFile, "config.key")
|
||||
}
|
||||
if cfg.Security.RateLimitUI.RequestsPerMinute != 360 {
|
||||
t.Fatalf("security.rate_limit_ui.requests_per_minute = %d, want %d", cfg.Security.RateLimitUI.RequestsPerMinute, 360)
|
||||
}
|
||||
if cfg.Security.RateLimitUI.Enabled {
|
||||
t.Fatalf("security.rate_limit_ui.enabled = %v, want false", cfg.Security.RateLimitUI.Enabled)
|
||||
}
|
||||
if cfg.Security.RateLimitAPIRead.RequestsPerMinute != 300 {
|
||||
t.Fatalf("security.rate_limit_api_read.requests_per_minute = %d, want %d", cfg.Security.RateLimitAPIRead.RequestsPerMinute, 300)
|
||||
}
|
||||
if cfg.Security.RateLimitAPIRead.Enabled {
|
||||
t.Fatalf("security.rate_limit_api_read.enabled = %v, want false", cfg.Security.RateLimitAPIRead.Enabled)
|
||||
}
|
||||
if cfg.Security.RateLimitAPIWrite.RequestsPerMinute != 30 {
|
||||
t.Fatalf("security.rate_limit_api_write.requests_per_minute = %d, want %d", cfg.Security.RateLimitAPIWrite.RequestsPerMinute, 30)
|
||||
}
|
||||
if !cfg.Security.RateLimitAPIWrite.Enabled {
|
||||
t.Fatalf("security.rate_limit_api_write.enabled = %v, want true", cfg.Security.RateLimitAPIWrite.Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +265,45 @@ func TestGenerateConfigCommandRejectsUnexpectedArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateKeyCommandWritesKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newRootCmd()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"generate-key"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
raw := strings.TrimSpace(out.String())
|
||||
if raw == "" {
|
||||
t.Fatalf("generate-key output is empty")
|
||||
}
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("generate-key output is not valid base64: %v", err)
|
||||
}
|
||||
if len(keyBytes) != 32 {
|
||||
t.Fatalf("decoded key length = %d, want 32", len(keyBytes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateKeyCommandRejectsUnexpectedArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newRootCmd()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"generate-key", "extra"})
|
||||
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatalf("expected error for unexpected args")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveServerEndToEndHTTP(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
cfgPath := filepath.Join(tmp, "config.yaml")
|
||||
@@ -231,7 +319,15 @@ storage:
|
||||
security:
|
||||
allowed_ips: []
|
||||
trust_proxy_headers: false
|
||||
rate_limit:
|
||||
rate_limit_ui:
|
||||
enabled: true
|
||||
requests_per_minute: 30
|
||||
burst: 10
|
||||
rate_limit_api_read:
|
||||
enabled: true
|
||||
requests_per_minute: 30
|
||||
burst: 10
|
||||
rate_limit_api_write:
|
||||
enabled: true
|
||||
requests_per_minute: 30
|
||||
burst: 10
|
||||
@@ -239,13 +335,14 @@ security:
|
||||
if err := os.WriteFile(cfgPath, []byte(cfgRaw), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
writeConfigKeyFile(t, cfgPath)
|
||||
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
store, err := storage.NewFilesystemStore(cfg.Storage.DataDir)
|
||||
store, err := storage.NewFilesystemStore(cfg.Storage.DataDir, cfg.Storage.EncryptionKeyBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||
}
|
||||
@@ -261,7 +358,7 @@ security:
|
||||
_ = os.Chdir(wd)
|
||||
}()
|
||||
|
||||
srv, err := httpapi.NewServer(cfg, store, logger)
|
||||
srv, err := httpapi.NewServer(cfg, store, logger, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("httpapi.NewServer() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -40,13 +40,30 @@ func run(configPath string) error {
|
||||
}
|
||||
|
||||
logger := log.New(os.Stdout, "[scratchbox] ", log.LstdFlags|log.LUTC)
|
||||
accessWriter, closeAccessWriter, err := newAccessLogWriter(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("access log init failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := closeAccessWriter(); closeErr != nil {
|
||||
logger.Printf("access log writer close failed: %v", closeErr)
|
||||
}
|
||||
}()
|
||||
var accessLogger *log.Logger
|
||||
if cfg.Server.AccessLog.Enabled {
|
||||
accessLogger = log.New(accessWriter, "[access] ", log.LstdFlags|log.LUTC)
|
||||
}
|
||||
|
||||
store, err := storage.NewFilesystemStore(cfg.Storage.DataDir)
|
||||
store, err := storage.NewFilesystemStoreWithDefaultTTL(
|
||||
cfg.Storage.DataDir,
|
||||
cfg.Limits.DefaultTTLDuration,
|
||||
cfg.Storage.EncryptionKeyBytes,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage init failed: %w", err)
|
||||
}
|
||||
|
||||
server, err := httpapi.NewServer(cfg, store, logger)
|
||||
server, err := httpapi.NewServer(cfg, store, logger, accessLogger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http init failed: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user