diff --git a/.env.example b/.env.example index 538e155..94416e2 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,8 @@ PORT=8080 +LISTEN_NETWORK=tcp +LISTEN_ADDRESS=:8080 +# Only used when LISTEN_NETWORK=unix +UNIX_SOCKET_PERM=0660 DATABASE_URL=postgres://postgres:postgres@localhost:5432/skills_market?sslmode=disable STORAGE_DIR=uploads DEV_MODE=true diff --git a/README.md b/README.md index 293df90..5ddc5e4 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,22 @@ Go + Postgres marketplace where authenticated users can upload LLM skill files, ``` 5. Open `http://localhost:8080`. +## Listener configuration +- `LISTEN_NETWORK`: `tcp` (default) or `unix` +- `LISTEN_ADDRESS`: + - for `tcp`: address like `:8080` or `127.0.0.1:8080` + - for `unix`: socket path like `/tmp/skills2.sock` +- `UNIX_SOCKET_PERM`: octal file mode for unix socket path (default `0660`) + +Examples: +- TCP (default): + - `LISTEN_NETWORK=tcp` + - `LISTEN_ADDRESS=:8080` +- Unix socket: + - `LISTEN_NETWORK=unix` + - `LISTEN_ADDRESS=/tmp/skills2.sock` + - `UNIX_SOCKET_PERM=0660` + ## Local dev mode (no auth setup) Set `DEV_MODE=true` in `.env`. In this mode, protected routes always authenticate as: diff --git a/config.go b/config.go index 18b74ad..ccbfa71 100644 --- a/config.go +++ b/config.go @@ -3,31 +3,53 @@ package main import ( "fmt" "os" + "strconv" "strings" "github.com/joho/godotenv" ) type Config struct { - Port string - DatabaseURL string - StorageDir string - DevMode bool + Port string + ListenNetwork string + ListenAddress string + UnixSocketPerm os.FileMode + DatabaseURL string + StorageDir string + DevMode bool } func LoadConfig() (Config, error) { _ = godotenv.Load() cfg := Config{ - Port: envOrDefault("PORT", "8080"), - DatabaseURL: os.Getenv("DATABASE_URL"), - StorageDir: envOrDefault("STORAGE_DIR", "uploads"), - DevMode: strings.EqualFold(envOrDefault("DEV_MODE", "false"), "true"), + Port: envOrDefault("PORT", "8080"), + ListenNetwork: strings.ToLower(envOrDefault("LISTEN_NETWORK", "tcp")), + ListenAddress: strings.TrimSpace(os.Getenv("LISTEN_ADDRESS")), + DatabaseURL: os.Getenv("DATABASE_URL"), + StorageDir: envOrDefault("STORAGE_DIR", "uploads"), + DevMode: strings.EqualFold(envOrDefault("DEV_MODE", "false"), "true"), } + if cfg.ListenAddress == "" { + cfg.ListenAddress = ":" + cfg.Port + } + socketPerm, err := parseFileMode(envOrDefault("UNIX_SOCKET_PERM", "0660")) + if err != nil { + return Config{}, fmt.Errorf("invalid UNIX_SOCKET_PERM: %w", err) + } + cfg.UnixSocketPerm = socketPerm if cfg.DatabaseURL == "" { return Config{}, fmt.Errorf("DATABASE_URL is required") } + switch cfg.ListenNetwork { + case "tcp", "unix": + default: + return Config{}, fmt.Errorf("LISTEN_NETWORK must be tcp or unix") + } + if cfg.ListenNetwork == "unix" && cfg.ListenAddress == "" { + return Config{}, fmt.Errorf("LISTEN_ADDRESS is required when LISTEN_NETWORK=unix") + } return cfg, nil } @@ -38,3 +60,12 @@ func envOrDefault(key, fallback string) string { } return value } + +func parseFileMode(value string) (os.FileMode, error) { + trimmed := strings.TrimSpace(value) + perm, err := strconv.ParseUint(trimmed, 8, 32) + if err != nil { + return 0, err + } + return os.FileMode(perm), nil +} diff --git a/main.go b/main.go index b7fcb37..6edfefe 100644 --- a/main.go +++ b/main.go @@ -103,13 +103,52 @@ func main() { mux.Handle("/api/purchase", app.auth.RequireAuth(http.HandlerFunc(app.handlePurchaseAPI))) mux.Handle("/api/my-skills", app.auth.RequireAuth(http.HandlerFunc(app.handleMySkillsAPI))) - addr := ":" + cfg.Port - log.Printf("server listening on %s", addr) - if err := http.ListenAndServe(addr, loggingMiddleware(mux)); err != nil { + listener, cleanup, err := createListener(cfg) + if err != nil { + log.Fatalf("create listener: %v", err) + } + defer cleanup() + + log.Printf("server listening network=%s address=%s", cfg.ListenNetwork, cfg.ListenAddress) + if err := http.Serve(listener, loggingMiddleware(mux)); err != nil { log.Fatalf("server failed: %v", err) } } +func createListener(cfg Config) (net.Listener, func(), error) { + if cfg.ListenNetwork == "unix" { + if err := os.MkdirAll(filepath.Dir(cfg.ListenAddress), 0o755); err != nil { + return nil, nil, fmt.Errorf("create socket dir: %w", err) + } + if _, err := os.Stat(cfg.ListenAddress); err == nil { + if err := os.Remove(cfg.ListenAddress); err != nil { + return nil, nil, fmt.Errorf("remove stale socket: %w", err) + } + } else if !os.IsNotExist(err) { + return nil, nil, fmt.Errorf("check socket path: %w", err) + } + l, err := net.Listen("unix", cfg.ListenAddress) + if err != nil { + return nil, nil, err + } + if err := os.Chmod(cfg.ListenAddress, cfg.UnixSocketPerm); err != nil { + _ = l.Close() + return nil, nil, fmt.Errorf("chmod socket: %w", err) + } + cleanup := func() { + _ = l.Close() + _ = os.Remove(cfg.ListenAddress) + } + return l, cleanup, nil + } + + l, err := net.Listen("tcp", cfg.ListenAddress) + if err != nil { + return nil, nil, err + } + return l, func() { _ = l.Close() }, nil +} + func runMigrations(db *sql.DB) error { schema, err := os.ReadFile("db/schema.sql") if err != nil { diff --git a/skills2 b/skills2 deleted file mode 100755 index d3efa2a..0000000 Binary files a/skills2 and /dev/null differ