diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml deleted file mode 100644 index 43e8c65..0000000 --- a/.github/workflows/build.yaml +++ /dev/null @@ -1,77 +0,0 @@ -name: Build, Test, and Release - -on: - push: - branches: - - main - tags: - - "v*" - paths-ignore: - - "**.md" - pull_request: - paths-ignore: - - "**.md" - -jobs: - test: - name: Test on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, macos-latest] - steps: - - uses: actions/checkout@v3 - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: "1.21" - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.x" - - name: Install Caddy - run: | - curl -sS https://webi.sh/caddy | sh - echo "$HOME/.local/bin" >> $GITHUB_PATH - export PATH="$PATH:$HOME/.local/bin" - - name: Start Caddy - run: caddy run & - - name: Build localbase - run: go build -o localbase - - name: Start localbase - run: ./localbase start -d - - name: Create HTTP Server - run: | - echo "

Hello, World!

" > index.html - python3 -m http.server 5000 & - - name: Register Domain with LocalBase - run: ./localbase add webapp --port 5000 - continue-on-error: true - - name: Ping Registered Domain - run: | - curl -H "Host: webapp.local" http://localhost:5000 - - name: Stop LocalBase - run: ./localbase stop - - build-and-release: - name: Build and Release - needs: test - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: "1.21" - - - name: Run GoReleaser - if: startsWith(github.ref, 'refs/tags/') - uses: goreleaser/goreleaser-action@v4 - with: - version: latest - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.SHIP_TOKEN }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..907472a --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,103 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + test: + name: Test + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + go-version: [1.23.x] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.out + fail_ci_if_error: false + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 1.23.x + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + args: --timeout=5m + + build: + name: Build + runs-on: ubuntu-latest + needs: [test, lint] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 1.23.x + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Build (basic test) + run: go build -v . diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..cebf0d3 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,87 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + packages: write + +jobs: + test: + name: Test before release + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 1.23.x + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v -race ./... + + - name: Run linter + uses: golangci/golangci-lint-action@v6 + with: + version: latest + args: --timeout=5m + + release: + name: Release + needs: test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 1.23.x + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Docker secrets removed + + # Homebrew automation removed - manual tap updates are sufficient for local dev tool + + # Release notifications removed - GitHub's built-in notifications are sufficient \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..54ea9e0 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,120 @@ +# Configuration for golangci-lint +# https://golangci-lint.run/usage/configuration/ + +run: + timeout: 5m + go: "1.21" + modules-download-mode: readonly + +# Settings for specific linters +linters-settings: + gofumpt: + # Choose whether to use the extra rules that are disabled by default + extra-rules: true + + gocyclo: + # Minimal code complexity to report + min-complexity: 15 + + govet: + enable: + - shadow + + misspell: + locale: US + + gocritic: + enabled-tags: + - diagnostic + - style + - performance + - experimental + disabled-checks: + - whyNoLint + - wrapperFunc + - dupImport # https://github.com/go-critic/go-critic/issues/845 + - ifElseChain + - octalLiteral + - hugeParam + + revive: + rules: + - name: exported + disabled: false + - name: unexported-return + disabled: false + - name: unused-parameter + disabled: false + +# Enable specific linters +linters: + enable: + - errcheck # Check for unchecked errors + - gosimple # Simplify code + - govet # Vet examines Go source code + - ineffassign # Detect ineffectual assignments + - staticcheck # Go static analysis + - typecheck # Parse and type-check Go code + - unused # Check for unused constants, variables, functions and types + - gofumpt # Stricter gofmt + - misspell # Correct commonly misspelled English words + - gocritic # Comprehensive Go source code linter + - gocyclo # Computes cyclomatic complexity + - unparam # Find unused function parameters + - revive # Replacement for golint + - goimports # Fix imports and format code + - gosec # Security-focused linter + - bodyclose # Check HTTP response body is closed + - nilerr # Check returning nil even if error is not nil + - rowserrcheck # Check SQL rows.Err is checked + - sqlclosecheck # Check SQL database/sql.Rows and sql.Stmt are closed + - unconvert # Remove unnecessary type conversions + - wastedassign # Find assignments to existing variables that are not used + + disable: [] + +# Issues configuration +issues: + # Maximum count of issues with the same text. Set to 0 to disable. + max-same-issues: 50 + + # Maximum issues count per one linter. Set to 0 to disable. + max-issues-per-linter: 0 + + # Exclude following linters from requiring issues to be fixed + exclude-use-default: false + + # List of regexps of issue texts to exclude + exclude: + # Allow shadowing of 'err' variable + - 'shadow: declaration of "err" shadows declaration' + # Allow unused parameters in interface implementations + - 'unused-parameter: parameter .* seems to be unused, consider removing or renaming it as _' + + # Exclude specific issues by file patterns + exclude-rules: + # Exclude lll issues for long lines in test files + - path: _test\.go + linters: + - lll + - funlen + - gocognit + - gocyclo + + # Exclude specific rules for generated files + - path: ".*\\.pb\\.go$" + linters: + - all + + # Allow init functions in main package + - path: main\.go + text: "should not use init function" + linters: + - gochecknoinits + +# Output configuration +output: + formats: + - format: colored-line-number + print-issued-lines: true + print-linter-name: true \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 2f39f7e..1617c08 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,8 +1,12 @@ version: 2 +project_name: localbase + before: hooks: - go mod tidy + - go generate ./... + - go test ./... - sed -i 's/VERSION="v0.0.0"/VERSION="{{.Version}}"/g' install.sh builds: @@ -14,11 +18,13 @@ builds: goarch: - amd64 - arm64 - ignore: - - goos: linux - goarch: arm64 + # Enable all combinations including linux arm64 + ignore: [] ldflags: - - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser + mod_timestamp: '{{ .CommitTimestamp }}' + flags: + - -trimpath archives: - format: tar.gz @@ -29,6 +35,13 @@ archives: {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }} + format_overrides: + - goos: windows + format: zip + files: + - README.md + - LICENSE + - install.sh checksum: name_template: 'checksums.txt' @@ -38,3 +51,58 @@ snapshot: changelog: sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' + - '^ci:' + groups: + - title: Features + regexp: '^.*?feat(\(.+\))??!?:.+$' + order: 0 + - title: 'Bug fixes' + regexp: '^.*?fix(\(.+\))??!?:.+$' + order: 1 + - title: Others + order: 999 + +release: + github: + owner: noelukwa + name: localbase + draft: false + prerelease: auto + mode: replace + header: | + ## LocalBase {{.Tag}} ({{.Date}}) + + Welcome to this new release! + footer: | + ## Installation + + ```bash + curl -sSL https://raw.githubusercontent.com/noelukwa/localbase/main/install.sh | sudo sh\n ```\n \n **Homebrew:**\n ```bash\n brew tap noelukwa/tap && brew install localbase + ``` + + **Full Changelog**: https://github.com/noelukwa/localbase/compare/{{.PreviousTag}}...{{.Tag}} + +brews: + - name: localbase + repository: + owner: noelukwa + name: homebrew-tap + folder: Formula + homepage: https://github.com/noelukwa/localbase + description: "A secure, lightweight tool for provisioning .local domains with automatic HTTPS support" + license: MIT + test: | + system "#{bin}/localbase version" + install: | + bin.install "localbase" + +# Package managers removed - overkill for local dev tool +# Developers can use: brew, go install, or direct binary download + +# Docker removed - LocalBase requires host network access for mDNS/.local domains +# and direct interaction with host's Caddy server, making containerization impractical diff --git a/README.md b/README.md index 0f5f955..76dda13 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,192 @@ +# LocalBase -# localbase +A secure, lightweight tool for provisioning .local domains with automatic HTTPS support. LocalBase simplifies local development by managing Caddy reverse proxy configurations and mDNS service discovery. -localbase is a lightweight tool for provisioning secure .local domains. It simplifies the process of setting up local development environments with HTTPS support. +## Features -## requirements +- 🔒 **Secure by default** - Token-based authentication and TLS encryption +- 🚀 **Zero-config HTTPS** - Automatic certificate generation and management +- 🌐 **mDNS integration** - Automatic `.local` domain resolution +- 🔄 **Hot reloading** - Dynamic domain addition/removal without restarts +- 🎯 **Production ready** - Comprehensive logging, error handling, and monitoring +- ⚡ **Lightweight** - Minimal resource usage with connection pooling -- [caddy](https://caddyserver.com/) -- [go](https://golang.org/) +## Requirements -## installation +- [Caddy](https://caddyserver.com/) - Web server with automatic HTTPS +- [Go](https://golang.org/) 1.21+ - For installation from source -```go -go install github.com/noelukwa/localbase@latest -``` +## Installation -```sh +### 🚀 Quick Install (Recommended) + +```bash curl -sSL https://raw.githubusercontent.com/noelukwa/localbase/main/install.sh | sudo sh ``` -## usage +### 🍺 Homebrew -✨ _ensure caddy is setup and running_ +```bash +brew tap noelukwa/tap +brew install localbase +``` -start the localbase service in foreground: +### 💾 Binary Download -```sh -localbase start +```bash +# Download latest release for your platform +wget https://github.com/noelukwa/localbase/releases/latest/download/localbase_linux_x86_64.tar.gz +tar -xzf localbase_linux_x86_64.tar.gz +sudo mv localbase /usr/local/bin/ ``` -start the localbase service in detached mode: +### 🛠️ Go Install + +```bash +go install github.com/noelukwa/localbase@latest +``` + +### 🔧 Build from Source + +```bash +git clone https://github.com/noelukwa/localbase.git +cd localbase +go build -o localbase . +``` + +## Quick Start + +1. **Start LocalBase service**: + + ```bash + localbase start + ``` + +2. **Add a domain** (in another terminal): -```sh + ```bash + localbase add myapp --port 3000 + ``` + +3. **Start your application** on port 3000 + +4. **Visit** [https://myapp.local](https://myapp.local) 🎉 + +## Usage + +### Service Management + +```bash +# Start in foreground +localbase start + +# Start in daemon mode localbase start -d + +# Stop service +localbase stop + +# Check service status +localbase status ``` -add a new domain: +### Domain Management -```sh +```bash +# Add domain pointing to local service localbase add hello --port 3000 + +# Remove domain +localbase remove hello + +# List all domains +localbase list + +# Health check +localbase ping ``` -✨ now visit [https://hello.local](https://hello.local) +### Configuration -remove a domain: +LocalBase stores configuration in: -```sh -localbase remove hello +- **macOS**: `~/Library/Application Support/localbase/` +- **Linux**: `~/.config/localbase/` +- **Windows**: `%APPDATA%\localbase\` + +Default configuration: + +```json +{ + "caddy_admin": "http://localhost:2019", + "admin_address": "localhost:2025" +} ``` -list all configured domains: +## Development -```sh -localbase list +### Running Tests + +```bash +go test ./... -v ``` -stop the localbase service: +### Running Benchmarks -```sh -localbase stop +```bash +go test -bench=. -benchmem +``` + +### Code Coverage + +```bash +go test -coverprofile=coverage.out ./... +go tool cover -html=coverage.out +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Run tests (`go test ./...`) +4. Commit changes (`git commit -m 'Add amazing feature'`) +5. Push to branch (`git push origin feature/amazing-feature`) +6. Open a Pull Request + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Troubleshooting + +### Common Issues + +**"Caddy not found"** + +```bash +# Install Caddy +brew install caddy # macOS +sudo apt install caddy # Ubuntu/Debian +``` + +**"Permission denied"** + +```bash +# Check file permissions +ls -la ~/.config/localbase/ +``` + +**"Connection refused"** + +```bash +# Check if service is running +localbase status +``` + +### Debug Mode + +Enable debug logging: + +```bash +LOCALBASE_LOG_LEVEL=debug localbase start ``` diff --git a/caddy.go b/caddy.go deleted file mode 100644 index 0f39d2a..0000000 --- a/caddy.go +++ /dev/null @@ -1,157 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" -) - -func getCaddyConfig(caddyAdmin string) (map[string]interface{}, error) { - resp, err := http.Get(fmt.Sprintf("%s/config/", caddyAdmin)) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("failed to get Caddy config: %s", body) - } - - var config map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&config); err != nil { - return nil, err - } - - return config, nil -} - -func addCaddyServerBlock(domains []string, port int, caddyAdmin string) error { - config, err := getCaddyConfig(caddyAdmin) - if err != nil { - return err - } - - // Ensure the config structure is initialized - if config == nil { - config = make(map[string]interface{}) - } - - if _, ok := config["apps"]; !ok { - config["apps"] = make(map[string]interface{}) - } - - apps := config["apps"].(map[string]interface{}) - if _, ok := apps["http"]; !ok { - apps["http"] = make(map[string]interface{}) - } - - httpApp := apps["http"].(map[string]interface{}) - if _, ok := httpApp["servers"]; !ok { - httpApp["servers"] = make(map[string]interface{}) - } - - servers := httpApp["servers"].(map[string]interface{}) - serverName := "default" - if existingServer, ok := servers[serverName]; ok { - server := existingServer.(map[string]interface{}) - routes := server["routes"].([]interface{}) - - for _, domain := range domains { - routes = append(routes, map[string]interface{}{ - "match": []map[string]interface{}{ - {"host": []string{domain}}, - }, - "handle": []map[string]interface{}{ - { - "handler": "reverse_proxy", - "upstreams": []map[string]interface{}{ - {"dial": fmt.Sprintf("localhost:%d", port)}, - }, - }, - }, - }) - } - - server["routes"] = routes - servers[serverName] = server - } else { - newRoutes := []interface{}{} - for _, domain := range domains { - newRoutes = append(newRoutes, map[string]interface{}{ - "match": []map[string]interface{}{ - {"host": []string{domain}}, - }, - "handle": []map[string]interface{}{ - { - "handler": "reverse_proxy", - "upstreams": []map[string]interface{}{ - {"dial": fmt.Sprintf("localhost:%d", port)}, - }, - }, - }, - }) - } - - servers[serverName] = map[string]interface{}{ - "listen": []string{":80", ":443"}, - "routes": newRoutes, - } - } - - jsonData, err := json.Marshal(config) - if err != nil { - return err - } - - url := fmt.Sprintf("%s/config/", caddyAdmin) - req, err := http.NewRequest(http.MethodPatch, url, bytes.NewBuffer(jsonData)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("failed to add Caddy server block: %s", body) - } - - return nil -} - -func isCaddyRunning(caddyAdmin string) (bool, error) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/config/", caddyAdmin), nil) - if err != nil { - return false, err - } - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return false, nil - } - defer resp.Body.Close() - - return resp.StatusCode == http.StatusOK, nil -} - -func ensureCaddyRunning(caddyAdmin string) error { - running, err := isCaddyRunning(caddyAdmin) - if err == nil && running { - return nil - } - return fmt.Errorf("ensure caddy is installed and running") -} diff --git a/caddy_client_test.go b/caddy_client_test.go new file mode 100644 index 0000000..96d1a81 --- /dev/null +++ b/caddy_client_test.go @@ -0,0 +1,322 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestNewCaddyClient(t *testing.T) { + logger := NewLogger(InfoLevel) + client := NewCaddyClient("http://localhost:2019", logger) + + if client == nil { + t.Fatal("NewCaddyClient returned nil") + } + + if client.adminURL != "http://localhost:2019" { + t.Errorf("Expected adminURL http://localhost:2019, got %s", client.adminURL) + } + + if client.logger != logger { + t.Error("Logger not set correctly") + } + + if client.httpClient == nil { + t.Error("HTTP client not initialized") + } + + if client.httpClient.Timeout != 10*time.Second { + t.Errorf("Expected timeout 10s, got %v", client.httpClient.Timeout) + } +} + +func TestCaddyClientGetConfig(t *testing.T) { + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/config/" { + t.Errorf("Expected path /config/, got %s", r.URL.Path) + } + if r.Method != http.MethodGet { + t.Errorf("Expected GET method, got %s", r.Method) + } + + config := map[string]any{ + "apps": map[string]any{ + "http": map[string]any{ + "servers": map[string]any{}, + }, + }, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(config); err != nil { + http.Error(w, "failed to encode response", http.StatusInternalServerError) + return + } + })) + defer server.Close() + + logger := NewLogger(InfoLevel) + client := NewCaddyClient(server.URL, logger) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + config, err := client.GetConfig(ctx) + if err != nil { + t.Fatalf("GetConfig failed: %v", err) + } + + if config == nil { + t.Error("GetConfig returned nil config") + } + + apps, ok := config["apps"].(map[string]any) + if !ok { + t.Error("Expected apps in config") + } + + _, ok = apps["http"].(map[string]any) + if !ok { + t.Error("Expected http app in config") + } +} + +func TestCaddyClientGetConfigError(t *testing.T) { + // Create mock server that returns error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + if _, err := w.Write([]byte("Internal Server Error")); err != nil { + // Can't do much here, just log to prevent compiler warning + _ = err + } + })) + defer server.Close() + + logger := NewLogger(InfoLevel) + client := NewCaddyClient(server.URL, logger) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := client.GetConfig(ctx) + if err == nil { + t.Error("Expected error for server error response") + } + + if !strings.Contains(err.Error(), "500") { + t.Errorf("Expected error to contain status code, got: %v", err) + } +} + +func TestCaddyClientUpdateConfig(t *testing.T) { + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/config/" { + t.Errorf("Expected path /config/, got %s", r.URL.Path) + } + if r.Method != http.MethodPatch { + t.Errorf("Expected PATCH method, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) + } + + // Decode and verify the config + var config map[string]any + if err := json.NewDecoder(r.Body).Decode(&config); err != nil { + t.Errorf("Failed to decode request body: %v", err) + } + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + logger := NewLogger(InfoLevel) + client := NewCaddyClient(server.URL, logger) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + testConfig := map[string]any{ + "test": "value", + } + + err := client.UpdateConfig(ctx, testConfig) + if err != nil { + t.Fatalf("UpdateConfig failed: %v", err) + } +} + +func TestCaddyClientAddServerBlock(t *testing.T) { + // Track requests + requestCount := 0 + + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + + if r.Method == http.MethodGet { + // Return empty config for GET request + config := map[string]any{ + "apps": map[string]any{ + "http": map[string]any{ + "servers": map[string]any{}, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(config); err != nil { + http.Error(w, "failed to encode response", http.StatusInternalServerError) + return + } + } else if r.Method == http.MethodPatch { + // Verify PATCH request + var config map[string]any + if err := json.NewDecoder(r.Body).Decode(&config); err != nil { + t.Errorf("Failed to decode PATCH body: %v", err) + } + + // Verify structure + apps, ok := config["apps"].(map[string]any) + if !ok { + t.Error("Expected apps in config") + } + + httpApp, ok := apps["http"].(map[string]any) + if !ok { + t.Error("Expected http app in config") + } + + servers, ok := httpApp["servers"].(map[string]any) + if !ok { + t.Error("Expected servers in http app") + } + + serverID := "srv_test.local" + defaultServer, ok := servers[serverID].(map[string]any) + if !ok { + t.Errorf("Expected server with ID %s", serverID) + } + + routes, ok := defaultServer["routes"].([]any) + if !ok { + t.Error("Expected routes in default server") + } + + if len(routes) != 1 { + t.Errorf("Expected 1 route, got %d", len(routes)) + } + + w.WriteHeader(http.StatusOK) + } + })) + defer server.Close() + + logger := NewLogger(InfoLevel) + client := NewCaddyClient(server.URL, logger) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := client.AddServerBlock(ctx, []string{"test.local"}, 3000) + if err != nil { + t.Fatalf("AddServerBlock failed: %v", err) + } + + if requestCount != 2 { + t.Errorf("Expected 2 requests (GET + PATCH), got %d", requestCount) + } +} + +func TestCaddyClientIsRunning(t *testing.T) { + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(map[string]any{}); err != nil { + http.Error(w, "failed to encode response", http.StatusInternalServerError) + } + })) + defer server.Close() + + logger := NewLogger(InfoLevel) + client := NewCaddyClient(server.URL, logger) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + running, err := client.IsRunning(ctx) + if err != nil { + t.Fatalf("IsRunning failed: %v", err) + } + + if !running { + t.Error("Expected Caddy to be running") + } +} + +func TestCaddyClientIsRunningFalse(t *testing.T) { + // Use non-existent server + logger := NewLogger(InfoLevel) + client := NewCaddyClient("http://localhost:99999", logger) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + running, err := client.IsRunning(ctx) + if err != nil { + t.Fatalf("IsRunning should not fail for connection error: %v", err) + } + + if running { + t.Error("Expected Caddy to not be running") + } +} + +func TestCaddyClientEnsureRunning(t *testing.T) { + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(map[string]any{}); err != nil { + http.Error(w, "failed to encode response", http.StatusInternalServerError) + } + })) + defer server.Close() + + logger := NewLogger(InfoLevel) + client := NewCaddyClient(server.URL, logger) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := client.EnsureRunning(ctx) + if err != nil { + t.Fatalf("EnsureRunning failed: %v", err) + } +} + +func TestCaddyClientEnsureRunningError(t *testing.T) { + // Use non-existent server to test failure to start Caddy + logger := NewLogger(InfoLevel) + client := NewCaddyClient("http://localhost:99999", logger) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + err := client.EnsureRunning(ctx) + if err == nil { + t.Error("Expected error when Caddy fails to start") + return + } + + // With the new auto-start behavior, we expect an error about failing to start Caddy + // This could be either "failed to start Caddy" or "context deadline exceeded" + if !strings.Contains(err.Error(), "failed to start Caddy") && !strings.Contains(err.Error(), "context deadline exceeded") { + t.Errorf("Expected error message about failing to start Caddy or timeout, got: %v", err) + } +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..9b7d534 --- /dev/null +++ b/client.go @@ -0,0 +1,616 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "os/exec" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Client sends commands to the daemon +type Client struct { + config *Config + logger Logger + tlsManager *TLSManager + authManager *AuthManager +} + +// NewClient creates a new client +func NewClient(logger Logger) (*Client, error) { + configManager := NewConfigManager(logger) + config, err := configManager.Read() + if err != nil { + return nil, fmt.Errorf("failed to read config: %w", err) + } + + // Get config path for TLS certificates and auth tokens + configPath, err := configManager.GetConfigPath() + if err != nil { + return nil, fmt.Errorf("failed to get config path: %w", err) + } + tlsManager := NewTLSManager(configPath, logger) + + // Create authentication manager + authManager, err := NewAuthManager(configPath, logger) + if err != nil { + return nil, fmt.Errorf("failed to create auth manager: %w", err) + } + + return &Client{ + config: config, + logger: logger, + tlsManager: tlsManager, + authManager: authManager, + }, nil +} + +// SendCommand sends a command to the daemon +func (c *Client) SendCommand(method string, params map[string]any) error { + // Build command string + cmdLine := method + if params != nil { + // Order matters for some commands + if domain, ok := params["domain"]; ok { + cmdLine += fmt.Sprintf(" %v", domain) + } + if port, ok := params["port"]; ok { + cmdLine += fmt.Sprintf(" %v", port) + } + } + + // Get TLS configuration + tlsConfig := c.tlsManager.GetClientTLSConfig() + + // Connect with TLS + conn, err := tls.Dial("tcp", c.config.AdminAddress, tlsConfig) + if err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + defer func() { _ = conn.Close() }() + + // Set timeout + _ = conn.SetDeadline(time.Now().Add(10 * time.Second)) + + // Send command + if _, err := fmt.Fprintf(conn, "%s\n", cmdLine); err != nil { + return fmt.Errorf("failed to send command: %w", err) + } + + // Read response + reader := bufio.NewReader(conn) + response, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + response = strings.TrimSpace(response) + + // Handle response + if strings.HasPrefix(response, "ERROR:") { + return fmt.Errorf("%s", strings.TrimPrefix(response, "ERROR: ")) + } + + if strings.HasPrefix(response, "OK:") { + result := strings.TrimPrefix(response, "OK: ") + if result != "" && result != " " { + fmt.Println(result) + } + return nil + } + + // Unexpected response + return fmt.Errorf("unexpected response: %s", response) +} + +// CaddyClientImpl implements the CaddyClient interface +type CaddyClientImpl struct { + adminURL string + httpClient *http.Client + logger Logger + commandValidator *CommandValidator + caddyPath string // Cached secure path to Caddy executable +} + +// NewCaddyClient creates a new Caddy client +func NewCaddyClient(adminURL string, logger Logger) *CaddyClientImpl { + client := &CaddyClientImpl{ + adminURL: adminURL, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + logger: logger, + commandValidator: NewCommandValidator(logger), + } + + // Find and validate Caddy executable on initialization + if path, err := client.commandValidator.ValidateCaddyCommand(); err != nil { + logger.Error("failed to find secure caddy executable", Field{"error", err}) + // Continue without caching the path - will retry on each use + } else { + client.caddyPath = path + logger.Info("caddy executable validated and cached", Field{"path", path}) + } + + return client +} + +// GetConfig retrieves the current Caddy configuration +func (c *CaddyClientImpl) GetConfig(ctx context.Context) (map[string]any, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/config/", c.adminURL), http.NoBody) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to get Caddy config: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) + } + + var config map[string]any + if err := json.NewDecoder(resp.Body).Decode(&config); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return config, nil +} + +// UpdateConfig updates the Caddy configuration +func (c *CaddyClientImpl) UpdateConfig(ctx context.Context, config map[string]any) error { + body, err := json.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, fmt.Sprintf("%s/config/", c.adminURL), bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to update Caddy config: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// IsRunning checks if Caddy is running +func (c *CaddyClientImpl) IsRunning(ctx context.Context) (bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/config/", c.adminURL), http.NoBody) + if err != nil { + return false, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + // Connection error likely means Caddy is not running + return false, nil + } + defer func() { _ = resp.Body.Close() }() + + return resp.StatusCode == http.StatusOK, nil +} + +// AddServerBlock adds a new server block for the given domains +func (c *CaddyClientImpl) AddServerBlock(ctx context.Context, domains []string, port int) error { + // Prepare the server block + serverBlock := createServerBlock(domains, port) + + // Get current config + config, err := c.GetConfig(ctx) + if err != nil { + return fmt.Errorf("failed to get current config: %w", err) + } + + // Navigate to or create the necessary structure + apps, ok := config["apps"].(map[string]any) + if !ok { + apps = make(map[string]any) + config["apps"] = apps + } + + httpApp, ok := apps["http"].(map[string]any) + if !ok { + httpApp = make(map[string]any) + apps["http"] = httpApp + } + + servers, ok := httpApp["servers"].(map[string]any) + if !ok { + servers = make(map[string]any) + httpApp["servers"] = servers + } + + // Add the new server block + serverID := fmt.Sprintf("srv_%s", domains[0]) + servers[serverID] = serverBlock + + // Update the config + return c.UpdateConfig(ctx, config) +} + +// RemoveServerBlock removes server blocks for the given domains +func (c *CaddyClientImpl) RemoveServerBlock(ctx context.Context, domains []string) error { + config, err := c.GetConfig(ctx) + if err != nil { + return fmt.Errorf("failed to get current config: %w", err) + } + + servers := c.getServers(config) + if servers == nil { + return nil // No servers to remove + } + + // Create a set of domains for fast lookup + domainSet := make(map[string]bool) + for _, d := range domains { + domainSet[d] = true + } + + // Find and remove matching server blocks + for serverID, server := range servers { + if c.serverContainsDomain(server, domainSet) { + delete(servers, serverID) + } + } + + return c.UpdateConfig(ctx, config) +} + +// getServers extracts servers from config +func (c *CaddyClientImpl) getServers(config map[string]any) map[string]any { + apps, ok := config["apps"].(map[string]any) + if !ok { + return nil + } + + httpApp, ok := apps["http"].(map[string]any) + if !ok { + return nil + } + + servers, ok := httpApp["servers"].(map[string]any) + if !ok { + return nil + } + + return servers +} + +// serverContainsDomain checks if server contains any of the domains +func (c *CaddyClientImpl) serverContainsDomain(server any, domainSet map[string]bool) bool { + serverConfig, ok := server.(map[string]any) + if !ok { + return false + } + + routes, ok := serverConfig["routes"].([]any) + if !ok || len(routes) == 0 { + return false + } + + for _, route := range routes { + if c.routeContainsDomain(route, domainSet) { + return true + } + } + + return false +} + +// routeContainsDomain checks if route contains any of the domains +func (c *CaddyClientImpl) routeContainsDomain(route any, domainSet map[string]bool) bool { + routeMap, ok := route.(map[string]any) + if !ok { + return false + } + + matchList, ok := routeMap["match"].([]any) + if !ok || len(matchList) == 0 { + return false + } + + for _, match := range matchList { + matchMap, ok := match.(map[string]any) + if !ok { + continue + } + + hosts, ok := matchMap["host"].([]any) + if !ok { + continue + } + + for _, host := range hosts { + if hostStr, ok := host.(string); ok && domainSet[hostStr] { + return true + } + } + } + + return false +} + +// ClearAllServerBlocks removes all server blocks +func (c *CaddyClientImpl) ClearAllServerBlocks(ctx context.Context) error { + config, err := c.GetConfig(ctx) + if err != nil { + return fmt.Errorf("failed to get current config: %w", err) + } + + // Check if there are any apps configured + apps, ok := config["apps"].(map[string]any) + if !ok { + return fmt.Errorf("invalid config structure: apps not found") + } + + // Clear the http app servers + if httpApp, ok := apps["http"].(map[string]any); ok { + httpApp["servers"] = make(map[string]any) + } + + return c.UpdateConfig(ctx, config) +} + +// StartCaddy starts the Caddy server +func (c *CaddyClientImpl) StartCaddy(ctx context.Context) error { + // Check if already running + if running, _ := c.IsRunning(ctx); running { + c.logger.Info("Caddy is already running") + return nil + } + + // Use cached path or find Caddy + caddyPath := c.caddyPath + if caddyPath == "" { + var err error + caddyPath, err = c.commandValidator.ValidateCaddyCommand() + if err != nil { + return fmt.Errorf("failed to find Caddy executable: %w", err) + } + c.caddyPath = caddyPath + } + + // Prepare the command with security in mind + cmd := exec.CommandContext(ctx, caddyPath, "run", "--config", "/dev/null", "--adapter", "json", "--watch") // #nosec G204 + cmd.Env = append(cmd.Env, "HOME="+getHomeDir()) + + // Start Caddy in background + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start Caddy: %w", err) + } + + // Don't wait for the process - let it run in background + go func() { + _ = cmd.Wait() + }() + + // Give Caddy time to start with a nice spinner + return c.waitForCaddyWithSpinner(ctx) +} + +// waitForCaddyWithSpinner waits for Caddy to start with a visual spinner +func (c *CaddyClientImpl) waitForCaddyWithSpinner(ctx context.Context) error { + // Channel to signal when Caddy is ready or timeout/error occurs + done := make(chan error, 1) + + // Start checking Caddy status in background + go func() { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + timeout := time.After(10 * time.Second) + + for { + select { + case <-ctx.Done(): + done <- ctx.Err() + return + case <-timeout: + done <- fmt.Errorf("timeout waiting for Caddy to start") + return + case <-ticker.C: + if running, _ := c.IsRunning(ctx); running { + done <- nil + return + } + } + } + }() + + // Try to run with spinner, fallback to text output if no TTY + model := newSpinnerModel() + model.done = done + program := tea.NewProgram(model) + + if _, err := program.Run(); err != nil { + // Fallback: text output without spinner + c.logger.Info("Starting Caddy server...") + select { + case err := <-done: + if err != nil { + return fmt.Errorf("failed to start Caddy: %w", err) + } + c.logger.Info("Caddy started successfully") + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + + // If we get here, the spinner ran successfully + // Check if there was an error + select { + case err := <-done: + return err + default: + // This shouldn't happen, but handle it gracefully + return fmt.Errorf("Caddy did not start within expected time") + } +} + +// EnsureRunning ensures Caddy is running +func (c *CaddyClientImpl) EnsureRunning(ctx context.Context) error { + running, err := c.IsRunning(ctx) + if err != nil { + return fmt.Errorf("failed to check Caddy status: %w", err) + } + + if !running { + c.logger.Info("Caddy is not running, starting it...") + if err := c.StartCaddy(ctx); err != nil { + return fmt.Errorf("failed to start Caddy: %w", err) + } + } + + return nil +} + +// createServerBlock creates a server block configuration for Caddy +func createServerBlock(domains []string, port int) map[string]any { + // Convert domains to interface slice + hostList := make([]any, len(domains)) + for i, domain := range domains { + hostList[i] = domain + } + + return map[string]any{ + "listen": []any{":443"}, + "routes": []any{ + map[string]any{ + "match": []any{ + map[string]any{ + "host": hostList, + }, + }, + "handle": []any{ + map[string]any{ + "handler": "reverse_proxy", + "upstreams": []any{ + map[string]any{ + "dial": fmt.Sprintf("localhost:%d", port), + }, + }, + }, + }, + }, + }, + "tls_connection_policies": []any{ + map[string]any{ + "match": map[string]any{ + "sni": hostList, + }, + }, + }, + "automatic_https": map[string]any{ + "disable_redirects": false, + }, + } +} + +// Spinner model for Caddy startup +type spinnerModel struct { + spinner int + frames []string + colors []lipgloss.Color + done <-chan error + err error +} + +func newSpinnerModel() *spinnerModel { + return &spinnerModel{ + frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, + colors: []lipgloss.Color{ + lipgloss.Color("#F8B195"), + lipgloss.Color("#F67280"), + lipgloss.Color("#C06C84"), + lipgloss.Color("#6C5B7B"), + lipgloss.Color("#355C7D"), + }, + } +} + +func (m *spinnerModel) Init() tea.Cmd { + return tea.Batch( + m.tick(), + m.waitForDone(), + ) +} + +func (m *spinnerModel) tick() tea.Cmd { + return tea.Tick(80*time.Millisecond, func(time.Time) tea.Msg { + return tickMsg{} + }) +} + +func (m *spinnerModel) waitForDone() tea.Cmd { + return func() tea.Msg { + err := <-m.done + return doneMsg{err: err} + } +} + +func (m *spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tickMsg: + m.spinner++ + cmd := m.tick() + return m, cmd + case doneMsg: + m.err = msg.err + return m, tea.Quit + } + return m, nil +} + +func (m *spinnerModel) View() string { + if m.err != nil { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("✗ Failed to start Caddy: " + m.err.Error() + "\n") + } + + // Check if we're done + select { + case err := <-m.done: + m.err = err + if m.err != nil { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render("✗ Failed to start Caddy: " + m.err.Error() + "\n") + } + return lipgloss.NewStyle().Foreground(lipgloss.Color("#96CEB4")).Render("✓ Caddy started successfully!\n") + default: + // Still waiting + } + + frame := m.frames[m.spinner%len(m.frames)] + color := m.colors[m.spinner%len(m.colors)] + + spinnerStyle := lipgloss.NewStyle().Foreground(color) + return spinnerStyle.Render(frame) + " Starting Caddy server..." +} + +type ( + tickMsg struct{} + doneMsg struct{ err error } +) diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..5b4cc4e --- /dev/null +++ b/config_test.go @@ -0,0 +1,163 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNewConfigManager(t *testing.T) { + logger := NewLogger(InfoLevel) + + cm := NewConfigManager(logger) + if cm == nil { + t.Fatal("NewConfigManager returned nil") + } + if cm.logger != logger { + t.Error("logger not set correctly") + } +} + +func TestGetConfigPath(t *testing.T) { + logger := NewLogger(InfoLevel) + cm := NewConfigManager(logger) + + path, err := cm.GetConfigPath() + if err != nil { + t.Fatalf("GetConfigPath failed: %v", err) + } + + if path == "" { + t.Error("GetConfigPath returned empty path") + } + + // Verify the path contains the expected directory name + if !strings.Contains(path, "localbase") { + t.Errorf("config path should contain 'localbase', got: %s", path) + } + + // Verify directory is created + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("config directory should be created: %s", path) + } +} + +func TestConfigManagerReadWrite(t *testing.T) { + logger := NewLogger(InfoLevel) + cm := NewConfigManager(logger) + + // Create a test config + testConfig := &Config{ + CaddyAdmin: "http://localhost:2019", + AdminAddress: "localhost:2025", + } + + // Write the config + err := cm.Write(testConfig) + if err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + // Read the config back + readConfig, err := cm.Read() + if err != nil { + t.Fatalf("Failed to read config: %v", err) + } + + // Verify config values + if readConfig.CaddyAdmin != testConfig.CaddyAdmin { + t.Errorf("CaddyAdmin mismatch: expected %s, got %s", testConfig.CaddyAdmin, readConfig.CaddyAdmin) + } + + if readConfig.AdminAddress != testConfig.AdminAddress { + t.Errorf("AdminAddress mismatch: expected %s, got %s", testConfig.AdminAddress, readConfig.AdminAddress) + } +} + +func TestConfigManagerDefaultConfig(t *testing.T) { + logger := NewLogger(InfoLevel) + cm := NewConfigManager(logger) + + // Get config path and remove config file if it exists + configPath, err := cm.GetConfigPath() + if err != nil { + t.Fatalf("GetConfigPath failed: %v", err) + } + + configFile := filepath.Join(configPath, "config.json") + _ = os.Remove(configFile) // Ignore error if file doesn't exist + + // Read config (should return default) + config, err := cm.Read() + if err != nil { + t.Fatalf("Failed to read default config: %v", err) + } + + // Verify default values + if config.CaddyAdmin != "http://localhost:2019" { + t.Errorf("Default CaddyAdmin mismatch: expected 'http://localhost:2019', got '%s'", config.CaddyAdmin) + } + + if config.AdminAddress != "localhost:2025" { + t.Errorf("Default AdminAddress mismatch: expected 'localhost:2025', got '%s'", config.AdminAddress) + } +} + +func TestConfigManagerInvalidJSON(t *testing.T) { + logger := NewLogger(InfoLevel) + cm := NewConfigManager(logger) + + // Get config path + configPath, err := cm.GetConfigPath() + if err != nil { + t.Fatalf("GetConfigPath failed: %v", err) + } + + // Write invalid JSON + configFile := filepath.Join(configPath, "config.json") + err = os.WriteFile(configFile, []byte("invalid json content"), 0o600) + if err != nil { + t.Fatalf("Failed to write invalid JSON: %v", err) + } + + // Try to read config (should fail) + _, err = cm.Read() + if err == nil { + t.Error("Expected error when reading invalid JSON config") + } + + // Clean up + _ = os.Remove(configFile) +} + +func TestConfigManagerConfigValidation(t *testing.T) { + logger := NewLogger(InfoLevel) + cm := NewConfigManager(logger) + + // Test config with empty required fields + testConfig := &Config{ + CaddyAdmin: "", + AdminAddress: "", + } + + // Write and read back + err := cm.Write(testConfig) + if err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + readConfig, err := cm.Read() + if err != nil { + t.Fatalf("Failed to read config: %v", err) + } + + // Should have default values filled in + if readConfig.CaddyAdmin == "" { + t.Error("Empty CaddyAdmin should be filled with default") + } + + if readConfig.AdminAddress == "" { + t.Error("Empty AdminAddress should be filled with default") + } +} diff --git a/core.go b/core.go new file mode 100644 index 0000000..df94430 --- /dev/null +++ b/core.go @@ -0,0 +1,572 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "sync" + "time" + + "github.com/hashicorp/mdns" +) + +// ConfigManager handles configuration persistence +type ConfigManager struct { + logger Logger +} + +// NewConfigManager creates a new config manager +func NewConfigManager(logger Logger) *ConfigManager { + return &ConfigManager{logger: logger} +} + +// GetConfigPath returns the OS-specific config directory path +func (c *ConfigManager) GetConfigPath() (string, error) { + var configDir string + + switch runtime.GOOS { + case "darwin": + // macOS: ~/Library/Application Support/localbase + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + configDir = filepath.Join(home, "Library", "Application Support", "localbase") + case "linux": + // Linux: ~/.config/localbase or $XDG_CONFIG_HOME/localbase + if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" { + configDir = filepath.Join(xdgConfig, "localbase") + } else { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + configDir = filepath.Join(home, ".config", "localbase") + } + case "windows": + // Windows: %APPDATA%\localbase + if appData := os.Getenv("APPDATA"); appData != "" { + configDir = filepath.Join(appData, "localbase") + } else { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + configDir = filepath.Join(home, "AppData", "Roaming", "localbase") + } + default: + return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + } + + // Create directory if it doesn't exist + if err := os.MkdirAll(configDir, 0o750); err != nil { + return "", fmt.Errorf("failed to create config directory: %w", err) + } + + return configDir, nil +} + +// GetConfigFile returns the path to the config file +func (c *ConfigManager) GetConfigFile() (string, error) { + configPath, err := c.GetConfigPath() + if err != nil { + return "", err + } + return filepath.Join(configPath, "config.json"), nil +} + +// Read reads the configuration from disk +func (c *ConfigManager) Read() (*Config, error) { + configFile, err := c.GetConfigFile() + if err != nil { + return nil, err + } + + // Default config + config := &Config{ + CaddyAdmin: "http://localhost:2019", + AdminAddress: "localhost:2025", + } + + // Read config file if it exists + data, err := os.ReadFile(configFile) // #nosec G304 - config file path is controlled + if err != nil { + if os.IsNotExist(err) { + // Return default config if file doesn't exist + return config, nil + } + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + // Parse JSON + if err := json.Unmarshal(data, config); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + // Validate required fields + if config.CaddyAdmin == "" { + config.CaddyAdmin = "http://localhost:2019" + } + if config.AdminAddress == "" { + config.AdminAddress = "localhost:2025" + } + + return config, nil +} + +// Write writes the configuration to disk +func (c *ConfigManager) Write(config *Config) error { + configFile, err := c.GetConfigFile() + if err != nil { + return err + } + + // Marshal to JSON with indentation + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + // Write atomically by writing to temp file first + tempFile := configFile + ".tmp" + if err := os.WriteFile(tempFile, data, 0o600); err != nil { + return fmt.Errorf("failed to write temp config file: %w", err) + } + + // Rename temp file to actual config file + if err := os.Rename(tempFile, configFile); err != nil { + // Clean up temp file + _ = os.Remove(tempFile) + return fmt.Errorf("failed to save config file: %w", err) + } + + c.logger.Info("configuration saved", Field{"path", configFile}) + return nil +} + +// LocalBase implements the core domain management functionality +type LocalBase struct { + logger Logger + caddyClient CaddyClient + validator Validator + domainsmu sync.RWMutex + domains map[string]*domainEntry + mdnsServers map[string]*mdns.Server + mdnsMu sync.RWMutex + localIP net.IP + ipMu sync.RWMutex +} + +type domainEntry struct { + port int +} + +// NewLocalBase creates a new LocalBase instance +func NewLocalBase(logger Logger, _ *ConfigManager, caddyClient CaddyClient, validator Validator) (*LocalBase, error) { + localIP, err := getLocalIP() + if err != nil { + return nil, fmt.Errorf("failed to get local IP: %w", err) + } + + return &LocalBase{ + logger: logger, + caddyClient: caddyClient, + validator: validator, + domains: make(map[string]*domainEntry), + mdnsServers: make(map[string]*mdns.Server), + localIP: localIP, + }, nil +} + +// Add registers a new domain +func (l *LocalBase) Add(ctx context.Context, domain string, port int) error { + // Validate inputs + if err := l.validator.ValidateDomain(domain); err != nil { + return fmt.Errorf("invalid domain: %w", err) + } + if err := l.validator.ValidatePort(port); err != nil { + return fmt.Errorf("invalid port: %w", err) + } + + // Ensure domain ends with .local + if !strings.HasSuffix(domain, ".local") { + domain += ".local" + } + + // Check if already registered + l.domainsmu.RLock() + if _, exists := l.domains[domain]; exists { + l.domainsmu.RUnlock() + return fmt.Errorf("domain %s is already registered", domain) + } + l.domainsmu.RUnlock() + + // Register with Caddy + if err := l.caddyClient.AddServerBlock(ctx, []string{domain}, port); err != nil { + return fmt.Errorf("failed to register with Caddy: %w", err) + } + + // Register mDNS + if err := l.registerMDNS(ctx, domain, port); err != nil { + // Rollback Caddy registration + _ = l.caddyClient.RemoveServerBlock(ctx, []string{domain}) + return fmt.Errorf("failed to register mDNS: %w", err) + } + + // Store domain entry + l.domainsmu.Lock() + l.domains[domain] = &domainEntry{port: port} + l.domainsmu.Unlock() + + l.logger.Info("domain registered", Field{"domain", domain}, Field{"port", port}) + return nil +} + +// Remove unregisters a domain +func (l *LocalBase) Remove(ctx context.Context, domain string) error { + // Ensure domain ends with .local + if !strings.HasSuffix(domain, ".local") { + domain += ".local" + } + + // Check if registered + l.domainsmu.RLock() + entry, exists := l.domains[domain] + if !exists { + l.domainsmu.RUnlock() + return fmt.Errorf("domain %s is not registered", domain) + } + l.domainsmu.RUnlock() + + // Unregister from Caddy + if err := l.caddyClient.RemoveServerBlock(ctx, []string{domain}); err != nil { + l.logger.Error("failed to remove from Caddy", Field{"domain", domain}, Field{"error", err}) + // Continue with cleanup + } + + // Unregister mDNS + l.unregisterMDNS(domain) + + // Remove domain entry + l.domainsmu.Lock() + delete(l.domains, domain) + l.domainsmu.Unlock() + + l.logger.Info("domain unregistered", Field{"domain", domain}, Field{"port", entry.port}) + return nil +} + +// List returns all registered domains +func (l *LocalBase) List(ctx context.Context) ([]string, error) { + l.domainsmu.RLock() + defer l.domainsmu.RUnlock() + + domains := make([]string, 0, len(l.domains)) + for domain := range l.domains { + domains = append(domains, domain) + } + + return domains, nil +} + +// Shutdown gracefully shuts down the LocalBase service +func (l *LocalBase) Shutdown(ctx context.Context) error { + l.logger.Info("shutting down LocalBase") + + var errors []string + + // Unregister all mDNS services + l.mdnsMu.Lock() + for domain, server := range l.mdnsServers { + if err := server.Shutdown(); err != nil { + errors = append(errors, fmt.Sprintf("failed to shutdown mDNS for %s: %v", domain, err)) + } + } + l.mdnsServers = make(map[string]*mdns.Server) + l.mdnsMu.Unlock() + + // Clear all Caddy server blocks + if err := l.caddyClient.ClearAllServerBlocks(ctx); err != nil { + errors = append(errors, fmt.Sprintf("failed to clear Caddy server blocks: %v", err)) + } + + // Clear domains + l.domainsmu.Lock() + l.domains = make(map[string]*domainEntry) + l.domainsmu.Unlock() + + if len(errors) > 0 { + return fmt.Errorf("shutdown errors: %v", errors) + } + + return nil +} + +// registerMDNS registers the domain with mDNS +func (l *LocalBase) registerMDNS(_ context.Context, domain string, port int) error { + // Get current IP address + l.ipMu.RLock() + ip := l.localIP + l.ipMu.RUnlock() + + // Remove .local suffix for mDNS + hostname := strings.TrimSuffix(domain, ".local") + + // Create mDNS service + service, err := mdns.NewMDNSService( + hostname, + "_http._tcp", + "", + "", + port, + []net.IP{ip}, + []string{"LocalBase managed domain"}, + ) + if err != nil { + return fmt.Errorf("failed to create mDNS service: %w", err) + } + + // Create mDNS server + server, err := mdns.NewServer(&mdns.Config{Zone: service}) + if err != nil { + return fmt.Errorf("failed to create mDNS server: %w", err) + } + + // Store server reference + l.mdnsMu.Lock() + l.mdnsServers[domain] = server + l.mdnsMu.Unlock() + + l.logger.Info("mDNS service registered", Field{"domain", domain}, Field{"ip", ip.String()}) + return nil +} + +// unregisterMDNS unregisters the domain from mDNS +func (l *LocalBase) unregisterMDNS(domain string) { + l.mdnsMu.Lock() + defer l.mdnsMu.Unlock() + + if server, exists := l.mdnsServers[domain]; exists { + if err := server.Shutdown(); err != nil { + l.logger.Error("failed to shutdown mDNS server", Field{"domain", domain}, Field{"error", err}) + } + delete(l.mdnsServers, domain) + l.logger.Info("mDNS service unregistered", Field{"domain", domain}) + } +} + +// startBroadcast periodically updates the IP address and refreshes mDNS +func (l *LocalBase) startBroadcast(ctx context.Context) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + newIP, err := getLocalIP() + if err != nil { + l.logger.Error("failed to get local IP", Field{"error", err}) + continue + } + + l.ipMu.Lock() + oldIP := l.localIP + if !newIP.Equal(oldIP) { + l.localIP = newIP + l.ipMu.Unlock() + l.logger.Info("IP address changed", Field{"old", oldIP.String()}, Field{"new", newIP.String()}) + l.refreshAllMDNS(ctx) + } else { + l.ipMu.Unlock() + } + } + } +} + +// refreshAllMDNS refreshes all mDNS registrations with the new IP +func (l *LocalBase) refreshAllMDNS(ctx context.Context) { + l.domainsmu.RLock() + domains := make(map[string]int) + for domain, entry := range l.domains { + domains[domain] = entry.port + } + l.domainsmu.RUnlock() + + for domain, port := range domains { + l.unregisterMDNS(domain) + if err := l.registerMDNS(ctx, domain, port); err != nil { + l.logger.Error("failed to refresh mDNS", Field{"domain", domain}, Field{"error", err}) + } + } +} + +// getLocalIP returns the local IP address +func getLocalIP() (net.IP, error) { + // Try to connect to a public DNS server to determine local IP + conn, err := net.Dial("udp", "8.8.8.8:80") + if err != nil { + return nil, fmt.Errorf("failed to determine local IP: %w", err) + } + defer func() { _ = conn.Close() }() + + localAddr := conn.LocalAddr().(*net.UDPAddr) + return localAddr.IP, nil +} + +// DomainValidator validates domain names +type DomainValidator struct { + domainRegex *regexp.Regexp +} + +// NewValidator creates a new validator instance +func NewValidator() *DomainValidator { + // Modified regex to support domain names with dots for local development + domainRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`) + return &DomainValidator{ + domainRegex: domainRegex, + } +} + +// ValidateDomain validates a domain name +func (v *DomainValidator) ValidateDomain(domain string) error { + // Remove .local suffix if present for validation + domain = strings.TrimSuffix(domain, ".local") + + if domain == "" { + return fmt.Errorf("domain cannot be empty") + } + + if len(domain) > 253 { + return fmt.Errorf("domain name too long (max 253 characters)") + } + + // Check for reserved domains + if domain == "localhost" { + return fmt.Errorf("localhost is a reserved domain") + } + + // Split domain into labels and validate each + labels := strings.Split(domain, ".") + for _, label := range labels { + if label == "" { + return fmt.Errorf("domain contains empty label") + } + if len(label) > 63 { + return fmt.Errorf("domain label too long (max 63 characters): %s", label) + } + // Check if label matches the pattern + if !v.domainRegex.MatchString(label) { + return fmt.Errorf("invalid domain label: %s", label) + } + } + + return nil +} + +// ValidatePort validates a port number +func (v *DomainValidator) ValidatePort(port int) error { + if port < 1 || port > 65535 { + return fmt.Errorf("port must be between 1 and 65535") + } + return nil +} + +// CommandValidator validates and secures command execution +type CommandValidator struct { + logger Logger +} + +// NewCommandValidator creates a new command validator +func NewCommandValidator(logger Logger) *CommandValidator { + return &CommandValidator{logger: logger} +} + +// ValidateCaddyCommand finds and validates the Caddy executable +func (cv *CommandValidator) ValidateCaddyCommand() (string, error) { + // Common Caddy installation paths + commonPaths := []string{ + "/usr/local/bin/caddy", + "/usr/bin/caddy", + "/opt/homebrew/bin/caddy", + "/home/linuxbrew/.linuxbrew/bin/caddy", + "C:\\Program Files\\Caddy\\caddy.exe", + "C:\\caddy\\caddy.exe", + } + + // Also check PATH + if pathCmd, err := exec.LookPath("caddy"); err == nil { + commonPaths = append([]string{pathCmd}, commonPaths...) + } + + for _, path := range commonPaths { + if cv.isValidExecutable(path) { + cv.logger.Info("found secure caddy executable", Field{"path", path}) + return path, nil + } + } + + return "", fmt.Errorf("caddy executable not found in common locations or PATH") +} + +// isValidExecutable checks if a path points to a valid executable +func (cv *CommandValidator) isValidExecutable(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + + // Check if it's a regular file + if !info.Mode().IsRegular() { + return false + } + + // On Unix-like systems, check if executable + if runtime.GOOS != "windows" { + return info.Mode()&0o111 != 0 + } + + // On Windows, check for .exe extension + return strings.HasSuffix(strings.ToLower(path), ".exe") +} + +// ValidateDomain validates a domain name for local use +func (cv *CommandValidator) ValidateDomain(domain string) error { + if domain == "" { + return fmt.Errorf("domain cannot be empty") + } + + // Basic domain validation for .local domains + if len(domain) > 253 { + return fmt.Errorf("domain too long") + } + + // Check for dangerous characters + if strings.ContainsAny(domain, " \t\n\r;|&$`\\\"'<>") { + return fmt.Errorf("domain contains invalid characters") + } + + return nil +} + +// ValidatePort validates a port number +func (cv *CommandValidator) ValidatePort(port int) error { + if port < 1 || port > 65535 { + return fmt.Errorf("port must be between 1 and 65535") + } + + // Reserved ports check (optional for local dev) + if port < 1024 { + cv.logger.Debug("using privileged port", Field{"port", port}) + } + + return nil +} diff --git a/go.mod b/go.mod index fa6cdde..2491235 100644 --- a/go.mod +++ b/go.mod @@ -1,22 +1,41 @@ module github.com/noelukwa/localbase -go 1.21.0 +go 1.23.0 -toolchain go1.22.3 +toolchain go1.24.1 require ( + github.com/charmbracelet/bubbletea v1.3.6 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/hashicorp/mdns v1.0.6 github.com/mitchellh/go-homedir v1.1.0 github.com/oleksandr/bonjour v0.0.0-20210301155756-30f43c61b915 github.com/spf13/cobra v1.8.1 ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/miekg/dns v1.1.59 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/mod v0.17.0 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/tools v0.21.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.21.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect ) diff --git a/go.sum b/go.sum index bea60bc..b61d3c4 100644 --- a/go.sum +++ b/go.sum @@ -1,26 +1,137 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/mdns v1.0.6 h1:SV8UcjnQ/+C7KeJ/QeVD/mdN2EmzYfcGfufcuzxfCLQ= +github.com/hashicorp/mdns v1.0.6/go.mod h1:X4+yWh+upFECLOki1doUPaKpgNQII9gy4bUdCYKNhmM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs= github.com/miekg/dns v1.1.59/go.mod h1:nZpewl5p6IvctfgrckopVx2OlSEHPRO/U4SYkRklrEk= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/oleksandr/bonjour v0.0.0-20210301155756-30f43c61b915 h1:d291KOLbN1GthTPA1fLKyWdclX3k1ZP+CzYtun+a5Es= github.com/oleksandr/bonjour v0.0.0-20210301155756-30f43c61b915/go.mod h1:MGuVJ1+5TX1SCoO2Sx0eAnjpdRytYla2uC1YIZfkC9c= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw= -golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..7a1d828 --- /dev/null +++ b/integration_test.go @@ -0,0 +1,254 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// TestBasicIntegration tests the basic flow without requiring Caddy +func TestBasicIntegration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + // Create mock Caddy server + caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/config/": + if r.Method == http.MethodGet { + // Return empty config + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write([]byte(`{"apps":{"http":{"servers":{}}}}}`)); err != nil { + http.Error(w, "failed to write response", http.StatusInternalServerError) + } + } else if r.Method == http.MethodPatch { + // Accept config updates + w.WriteHeader(http.StatusOK) + } + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer caddyServer.Close() + + // Create config with mock Caddy server + config := &Config{ + AdminAddress: "localhost:0", // Use random port + CaddyAdmin: caddyServer.URL, + } + + logger := NewLogger(InfoLevel) + + // Create and start server + server, err := NewServer(config, logger) + if err != nil { + t.Fatalf("Failed to create server: %v", err) + } + + // Start server in background + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serverErrChan := make(chan error, 1) + go func() { + err := server.Start(ctx) + serverErrChan <- err + }() + + // Wait for server to actually start listening + var actualAddr string + for i := 0; i < 50; i++ { // Try for up to 5 seconds + time.Sleep(100 * time.Millisecond) + // Use a safe method to get the address without direct field access + if addr := server.GetListenerAddr(); addr != "" { + actualAddr = addr + break + } + } + + if actualAddr == "" { + t.Fatal("Server failed to start listening") + } + + // Create new config with actual address for client + clientConfig := &Config{ + AdminAddress: actualAddr, + CaddyAdmin: config.CaddyAdmin, + } + configManager := NewConfigManager(logger) + if err := configManager.Write(clientConfig); err != nil { + t.Fatalf("Failed to save config: %v", err) + } + + // Create client + client, err := NewClient(logger) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + // Test ping + err = client.SendCommand("ping", nil) + if err != nil { + t.Errorf("Ping failed: %v", err) + } + + // Test add domain + err = client.SendCommand("add", map[string]any{ + "domain": "testapp", + "port": 3000, + }) + if err != nil { + t.Errorf("Add domain failed: %v", err) + } + + // Test list domains + err = client.SendCommand("list", nil) + if err != nil { + t.Errorf("List domains failed: %v", err) + } + + // Test remove domain + err = client.SendCommand("remove", map[string]any{ + "domain": "testapp.local", + }) + if err != nil { + t.Errorf("Remove domain failed: %v", err) + } + + // Test shutdown + err = client.SendCommand("shutdown", nil) + if err != nil { + t.Errorf("Shutdown failed: %v", err) + } + + // Wait for server to shut down + select { + case err := <-serverErrChan: + if err != nil { + t.Errorf("Server shutdown with error: %v", err) + } + case <-time.After(5 * time.Second): + t.Error("Server did not shut down within timeout") + cancel() // Force shutdown + } +} + +// TestConfigManagerIntegration tests configuration management +func TestConfigManagerIntegration(t *testing.T) { + logger := NewLogger(InfoLevel) + manager := NewConfigManager(logger) + + // Test reading default config + config, err := manager.Read() + if err != nil { + t.Fatalf("Failed to read config: %v", err) + } + + // Should have defaults + if config.CaddyAdmin == "" { + t.Error("Expected default CaddyAdmin") + } + if config.AdminAddress == "" { + t.Error("Expected default AdminAddress") + } + + // Test writing custom config with valid localhost addresses + customConfig := &Config{ + CaddyAdmin: "http://localhost:2020", + AdminAddress: "localhost:2026", + } + + err = manager.Write(customConfig) + if err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + // Test reading custom config back + readConfig, err := manager.Read() + if err != nil { + t.Fatalf("Failed to read custom config: %v", err) + } + + if readConfig.CaddyAdmin != customConfig.CaddyAdmin { + t.Errorf("CaddyAdmin mismatch: expected %s, got %s", customConfig.CaddyAdmin, readConfig.CaddyAdmin) + } + if readConfig.AdminAddress != customConfig.AdminAddress { + t.Errorf("AdminAddress mismatch: expected %s, got %s", customConfig.AdminAddress, readConfig.AdminAddress) + } +} + +// TestValidatorIntegration tests input validation +func TestValidatorIntegration(t *testing.T) { + validator := NewValidator() + + // Test valid inputs + validCases := []struct { + domain string + port int + }{ + {"myapp", 3000}, + {"test-service", 8080}, + {"api-v2", 9000}, + } + + for _, tc := range validCases { + t.Run(fmt.Sprintf("valid_%s_%d", tc.domain, tc.port), func(t *testing.T) { + if err := validator.ValidateDomain(tc.domain); err != nil { + t.Errorf("Domain %s should be valid: %v", tc.domain, err) + } + if err := validator.ValidatePort(tc.port); err != nil { + t.Errorf("Port %d should be valid: %v", tc.port, err) + } + }) + } + + // Test invalid inputs + invalidCases := []struct { + domain string + port int + expectErr bool + }{ + {"", 3000, true}, // empty domain + {"myapp", 0, true}, // invalid port + {"myapp", 70000, true}, // port too high + {"localhost", 3000, true}, // reserved domain + } + + for _, tc := range invalidCases { + t.Run(fmt.Sprintf("invalid_%s_%d", tc.domain, tc.port), func(t *testing.T) { + domainErr := validator.ValidateDomain(tc.domain) + portErr := validator.ValidatePort(tc.port) + + if tc.expectErr && domainErr == nil && portErr == nil { + t.Errorf("Expected validation error for domain=%s port=%d", tc.domain, tc.port) + } + }) + } +} + +// TestLoggerIntegration tests logging functionality +func TestLoggerIntegration(t *testing.T) { + // Test different log levels + levels := []LogLevel{DebugLevel, InfoLevel, ErrorLevel} + + for _, level := range levels { + t.Run(fmt.Sprintf("level_%d", level), func(t *testing.T) { + logger := NewLogger(level) + + // These should not panic + logger.Debug("debug message", Field{"key", "value"}) + logger.Info("info message", Field{"key", "value"}) + logger.Error("error message", Field{"key", "value"}) + + // Test ParseLogLevel + parsedLevel := ParseLogLevel("info") + if parsedLevel != InfoLevel { + t.Errorf("Expected InfoLevel, got %d", parsedLevel) + } + }) + } +} diff --git a/localbase.go b/localbase.go deleted file mode 100644 index be99ac3..0000000 --- a/localbase.go +++ /dev/null @@ -1,166 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - "strings" - "sync" - "time" - - "github.com/oleksandr/bonjour" -) - -type Record struct { - service string - host string - server *bonjour.Server -} - -type LocalBase struct { - records map[string]*Record - mu sync.Mutex -} - -func NewLocalBase() *LocalBase { - return &LocalBase{ - records: make(map[string]*Record), - } -} - -func (lb *LocalBase) List() []string { - lb.mu.Lock() - defer lb.mu.Unlock() - - domains := make([]string, 0, len(lb.records)) - for domain := range lb.records { - domains = append(domains, domain) - } - return domains -} - -func (lb *LocalBase) Add(domain string, port int) error { - lb.mu.Lock() - defer lb.mu.Unlock() - - config, err := readConfig() - if err != nil { - return err - } - - localIP, err := getLocalIP() - if err != nil { - log.Fatalln("Error getting local IP:", err.Error()) - } - log.Println("Local IP:", localIP) - - clean := strings.TrimSpace(domain) - fullDomain := fmt.Sprintf("%s.local", clean) - if _, exists := lb.records[fullDomain]; exists { - return fmt.Errorf("domain %s already registered", fullDomain) - } - fullHost := fmt.Sprintf("%s.", fullDomain) - - service := fmt.Sprintf("_%s._tcp", clean) - // Register nodecrane service - s1, err := bonjour.RegisterProxy( - "localbase", - service, - "", - 80, - fullHost, - localIP, - []string{}, - nil) - - if err != nil { - log.Fatalln("Error registering frontend service:", err.Error()) - } - - lb.records[fullDomain] = &Record{ - service: service, - host: fullHost, - server: s1, - } - - if err := addCaddyServerBlock([]string{fullDomain}, port, config.CaddyAdmin); err != nil { - s1.Shutdown() - delete(lb.records, domain) - return fmt.Errorf("failed to add Caddy server block: %v", err) - } - return nil -} - -func (lb *LocalBase) Remove(domain string) error { - lb.mu.Lock() - defer lb.mu.Unlock() - - record, exists := lb.records[domain] - if !exists { - return fmt.Errorf("domain %s not registered", domain) - } - - record.server.Shutdown() - delete(lb.records, domain) - log.Printf("Removed domain: %s", domain) - return nil -} - -func (lb *LocalBase) Shutdown() { - lb.mu.Lock() - defer lb.mu.Unlock() - - for domain, rec := range lb.records { - rec.server.Shutdown() - log.Printf("Shutting down domain: %s", domain) - } -} - -func (lb *LocalBase) startBroadcast(ctx context.Context) { - ticker := time.NewTicker(15 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - lb.broadcastAll() - case <-ctx.Done(): - return - } - } -} - -func (lb *LocalBase) broadcastAll() { - lb.mu.Lock() - defer lb.mu.Unlock() - - localIP, err := getLocalIP() - if err != nil { - log.Fatalln("Error getting local IP:", err.Error()) - } - - for domain, info := range lb.records { - info.server.Shutdown() - - server, err := bonjour.RegisterProxy( - "localbase", - info.service, - "", - 80, - info.host, - localIP, - []string{}, - nil) - - if err != nil { - log.Fatalln("Error registering frontend service:", err.Error()) - } - - if err != nil { - log.Printf("Error re-registering service for %s: %v", domain, err) - continue - } - - info.server = server - } -} diff --git a/logger_test.go b/logger_test.go new file mode 100644 index 0000000..7b3525a --- /dev/null +++ b/logger_test.go @@ -0,0 +1,212 @@ +package main + +import ( + "bytes" + "log" + "strings" + "testing" +) + +func TestNewLogger(t *testing.T) { + logger := NewLogger(InfoLevel) + if logger == nil { + t.Fatal("NewLogger returned nil") + } + + if logger.level != InfoLevel { + t.Errorf("expected log level %d, got %d", InfoLevel, logger.level) + } +} + +func TestParseLogLevel(t *testing.T) { + tests := []struct { + input string + expected LogLevel + }{ + {"debug", DebugLevel}, + {"DEBUG", DebugLevel}, + {"info", InfoLevel}, + {"INFO", InfoLevel}, + {"error", ErrorLevel}, + {"ERROR", ErrorLevel}, + {"fatal", FatalLevel}, + {"FATAL", FatalLevel}, + {"unknown", InfoLevel}, // default + {"", InfoLevel}, // default + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + result := ParseLogLevel(test.input) + if result != test.expected { + t.Errorf("ParseLogLevel(%s): expected %d, got %d", test.input, test.expected, result) + } + }) + } +} + +func TestLoggerShouldLog(t *testing.T) { + logger := NewLogger(InfoLevel) + + // Should not log debug when level is Info + if logger.shouldLog(DebugLevel) { + t.Error("expected debug to be filtered out at info level") + } + + // Should log info when level is Info + if !logger.shouldLog(InfoLevel) { + t.Error("expected info to be logged at info level") + } + + // Should log error when level is Info + if !logger.shouldLog(ErrorLevel) { + t.Error("expected error to be logged at info level") + } + + // Should log fatal when level is Info + if !logger.shouldLog(FatalLevel) { + t.Error("expected fatal to be logged at info level") + } +} + +func TestLoggerFormatMessage(t *testing.T) { + logger := NewLogger(InfoLevel) + + // Test message without fields + result := logger.formatMessage("INFO", "test message", nil) + expected := "[INFO] test message" + if result != expected { + t.Errorf("expected '%s', got '%s'", expected, result) + } + + // Test message with fields + fields := []Field{ + {"key1", "value1"}, + {"key2", 123}, + } + result = logger.formatMessage("ERROR", "test error", fields) + if !strings.Contains(result, "[ERROR] test error") { + t.Errorf("expected result to contain log level and message, got: %s", result) + } + if !strings.Contains(result, "key1=value1") { + t.Errorf("expected result to contain field key1=value1, got: %s", result) + } + if !strings.Contains(result, "key2=123") { + t.Errorf("expected result to contain field key2=123, got: %s", result) + } +} + +func TestLoggerDebug(t *testing.T) { + // Capture log output + var buf bytes.Buffer + logger := NewLogger(DebugLevel) + logger.logger = log.New(&buf, "", 0) + + logger.Debug("debug message", Field{"key", "value"}) + + output := buf.String() + if !strings.Contains(output, "[DEBUG] debug message") { + t.Errorf("expected debug output to contain message, got: %s", output) + } + if !strings.Contains(output, "key=value") { + t.Errorf("expected debug output to contain field, got: %s", output) + } +} + +func TestLoggerInfo(t *testing.T) { + // Capture log output + var buf bytes.Buffer + logger := NewLogger(InfoLevel) + logger.logger = log.New(&buf, "", 0) + + logger.Info("info message", Field{"key", "value"}) + + output := buf.String() + if !strings.Contains(output, "[INFO] info message") { + t.Errorf("expected info output to contain message, got: %s", output) + } + if !strings.Contains(output, "key=value") { + t.Errorf("expected info output to contain field, got: %s", output) + } +} + +func TestLoggerError(t *testing.T) { + // Capture log output + var buf bytes.Buffer + logger := NewLogger(ErrorLevel) + logger.logger = log.New(&buf, "", 0) + + logger.Error("error message", Field{"key", "value"}) + + output := buf.String() + if !strings.Contains(output, "[ERROR] error message") { + t.Errorf("expected error output to contain message, got: %s", output) + } + if !strings.Contains(output, "key=value") { + t.Errorf("expected error output to contain field, got: %s", output) + } +} + +func TestLoggerFiltering(t *testing.T) { + // Test that lower-level messages are filtered out + var buf bytes.Buffer + logger := NewLogger(ErrorLevel) + logger.logger = log.New(&buf, "", 0) + + // These should be filtered out + logger.Debug("debug message") + logger.Info("info message") + + output := buf.String() + if output != "" { + t.Errorf("expected no output for filtered messages, got: %s", output) + } + + // This should not be filtered + logger.Error("error message") + output = buf.String() + if !strings.Contains(output, "error message") { + t.Errorf("expected error message in output, got: %s", output) + } +} + +func TestLoggerConcurrency(t *testing.T) { + // Test that logger is safe for concurrent use + var buf bytes.Buffer + logger := NewLogger(InfoLevel) + logger.logger = log.New(&buf, "", 0) + + done := make(chan bool, 10) + + // Start 10 goroutines logging concurrently + for i := 0; i < 10; i++ { + go func(id int) { + logger.Info("concurrent message", Field{"id", id}) + done <- true + }(i) + } + + // Wait for all goroutines to complete + for i := 0; i < 10; i++ { + <-done + } + + output := buf.String() + // We should have 10 log messages + messageCount := strings.Count(output, "concurrent message") + if messageCount != 10 { + t.Errorf("expected 10 log messages, got %d", messageCount) + } +} + +func TestField(t *testing.T) { + field := Field{"test_key", "test_value"} + + if field.Key != "test_key" { + t.Errorf("expected field key 'test_key', got '%s'", field.Key) + } + + if field.Value != "test_value" { + t.Errorf("expected field value 'test_value', got '%v'", field.Value) + } +} diff --git a/main.go b/main.go index 02a4f93..f38827d 100644 --- a/main.go +++ b/main.go @@ -1,278 +1,212 @@ package main import ( - "bufio" "context" "fmt" - "log" - "net" "os" "os/exec" "os/signal" - "strconv" - "strings" "syscall" "github.com/spf13/cobra" ) -func run(cfg *Config) { - - if err := ensureCaddyRunning(cfg.CaddyAdmin); err != nil { - log.Fatalf("failed to ensure Caddy is running: %v", err) - } - - lb := NewLocalBase() - - listener, err := net.Listen("tcp", cfg.AdminAddress) - if err != nil { - log.Fatalf("failed to start localbase server: %v", err) - } - defer listener.Close() - - log.Println("localBase server started. listening on", cfg.AdminAddress) - - ctx, cancel := context.WithCancel(context.Background()) - - go lb.startBroadcast(ctx) +var ( + version = "dev" + commit = "unknown" + date = "unknown" + builtBy = "unknown" +) - go func() { - c := make(chan os.Signal, 1) - signal.Notify(c, os.Interrupt, syscall.SIGTERM) - <-c - cancel() - }() +// CLI Commands +var rootCmd = &cobra.Command{ + Use: "localbase", + Short: "localbase is a local domain management tool", + Long: `localbase allows you to manage local domains and their corresponding ports. +It integrates with Caddy server to provide local domain resolution and routing.`, +} - doneChan := make(chan struct{}) - connections := make(chan net.Conn) +var startCmd = &cobra.Command{ + Use: "start", + Short: "Start the localbase daemon", + Long: `Start the localbase daemon, either in the foreground or as a detached process.`, + RunE: func(cmd *cobra.Command, args []string) error { + caddyAdmin, _ := cmd.Flags().GetString("caddy") + adminAddr, _ := cmd.Flags().GetString("addr") + detached, _ := cmd.Flags().GetBool("detached") + logLevel, _ := cmd.Flags().GetString("log-level") - go func() { - for { - conn, err := listener.Accept() - if err != nil { - select { - case <-ctx.Done(): - return - default: - log.Printf("error accepting connection: %v\n", err) - continue - } - } + // Create logger + logger := NewLogger(ParseLogLevel(logLevel)) - select { - case connections <- conn: - case <-ctx.Done(): - return - } + // Create config + cfg := &Config{ + AdminAddress: adminAddr, + CaddyAdmin: caddyAdmin, } - }() - for { - select { - case conn := <-connections: - go handleConnection(doneChan, conn, lb) - case <-doneChan: - cancel() - case <-ctx.Done(): - log.Println("shutting down localbase") - lb.Shutdown() - return + // Save config + configManager := NewConfigManager(logger) + if err := configManager.Write(cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) } - } -} -func handleConnection(ch chan struct{}, conn net.Conn, lb *LocalBase) { - defer conn.Close() - scanner := bufio.NewScanner(conn) - if scanner.Scan() { - parts := strings.Fields(scanner.Text()) - cmd := parts[0] - switch cmd { - case "add": - if len(parts) != 4 || parts[2] != "--port" { - fmt.Fprintln(conn, "Invalid command. Usage: add --port ") - return - } - domain := parts[1] - port, err := strconv.Atoi(parts[3]) - if err != nil { - fmt.Fprintf(conn, "Invalid port number: %v\n", err) - return - } - err = lb.Add(domain, port) - if err != nil { - fmt.Fprintf(conn, "Error: %v\n", err) - } else { - fmt.Fprintf(conn, "Added domain: %s with port: %d\n", domain, port) - } - case "remove": - if len(parts) != 2 { - fmt.Fprintln(conn, "Invalid command. Usage: remove ") - return - } - domain := parts[1] - err := lb.Remove(domain) - if err != nil { - fmt.Fprintf(conn, "Error: %v\n", err) - } else { - fmt.Fprintf(conn, "Removed domain: %s\n", domain) - } - - case "list": - domains := lb.List() - if len(domains) == 0 { - fmt.Fprintln(conn, "No domains registered") - } else { - fmt.Fprintln(conn, "Registered domains:") - for _, domain := range domains { - fmt.Fprintf(conn, "- %s\n", domain) - } + if detached { + // Start in detached mode + cmd := exec.Command(os.Args[0], "start", "--caddy", caddyAdmin, "--addr", adminAddr, "--log-level", logLevel) // #nosec G204 -- using own binary path with validated flags + cmd.Stdout = nil + cmd.Stderr = nil + cmd.Stdin = nil + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start in detached mode: %w", err) } - case "stop": - close(ch) - default: - fmt.Fprintln(conn, "Unknown command") + logger.Info("localbase started in background", Field{"pid", cmd.Process.Pid}) + return nil } - } -} - -func sendCommand(command string) error { - cfg, err := readConfig() - if err != nil { - return err - } - - conn, err := net.Dial("tcp", cfg.AdminAddress) - if err != nil { - return fmt.Errorf("failed to connect to daemon: %v", err) - } - defer conn.Close() - _, err = fmt.Fprintln(conn, command) - if err != nil { - return fmt.Errorf("failed to send command: %v", err) - } - - scanner := bufio.NewScanner(conn) - for scanner.Scan() { - fmt.Println(scanner.Text()) - } - if err := scanner.Err(); err != nil { - return fmt.Errorf("error reading response: %v", err) - } + // Create server + server, err := NewServer(cfg, logger) + if err != nil { + return fmt.Errorf("failed to create server: %w", err) + } - return nil -} + // Setup context with signal handling + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() -var rootCmd = &cobra.Command{ - Use: "localbase", - Short: "localBase is a local domain management tool", - Long: `localBase allows you to manage local domains and their corresponding ports. -It integrates with Caddy server to provide local domain resolution and routing.`, + // Start server + return server.Start(ctx) + }, } var addCmd = &cobra.Command{ Use: "add --port ", - Short: "add a new domain", - Long: `add a new domain to LocalBase with the specified port.`, + Short: "Add a new domain", + Long: `Add a new domain to localbase with the specified port.`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if len(args) != 1 { - return fmt.Errorf("usage: localbase add --port ") - } port, _ := cmd.Flags().GetInt("port") if port == 0 { return fmt.Errorf("port is required") } - return sendCommand(fmt.Sprintf("add %s --port %d", args[0], port)) + + logger := NewLogger(InfoLevel) + client, err := NewClient(logger) + if err != nil { + return err + } + + return client.SendCommand("add", map[string]any{ + "domain": args[0], + "port": port, + }) }, } -var startCmd = &cobra.Command{ - Use: "start", - Short: "start the localbase", - Long: `start the localbase,either in the foreground or as a detached process.`, +var removeCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a domain", + Long: `Remove a domain from localbase.`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - caddyAdmin, _ := cmd.Flags().GetString("caddy") - adminAddr, _ := cmd.Flags().GetInt("addr") - detached, _ := cmd.Flags().GetBool("detached") - - cfg := &Config{ - AdminAddress: fmt.Sprintf(":%d", adminAddr), - CaddyAdmin: caddyAdmin, + logger := NewLogger(InfoLevel) + client, err := NewClient(logger) + if err != nil { + return err } - if err := saveConfig(cfg); err != nil { - return fmt.Errorf("failed to save config: %v", err) + return client.SendCommand("remove", map[string]any{ + "domain": args[0], + }) + }, +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all domains", + Long: `List all domains registered in localbase.`, + RunE: func(cmd *cobra.Command, args []string) error { + logger := NewLogger(InfoLevel) + client, err := NewClient(logger) + if err != nil { + return err } - if detached { - cmd := exec.Command(os.Args[0], "start") - cmd.Stdout = nil - cmd.Stderr = nil - cmd.Stdin = nil - cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} - if err := cmd.Start(); err != nil { - return fmt.Errorf("failed to start in detached mode: %v", err) - } + return client.SendCommand("list", nil) + }, +} - return nil +var stopCmd = &cobra.Command{ + Use: "stop", + Short: "Stop localbase daemon", + Long: `Stop the running localbase daemon.`, + RunE: func(cmd *cobra.Command, args []string) error { + logger := NewLogger(InfoLevel) + client, err := NewClient(logger) + if err != nil { + return fmt.Errorf("failed to connect to daemon: %w", err) } - run(cfg) - return nil + return client.SendCommand("shutdown", nil) }, } -func stopCmd() *cobra.Command { - return &cobra.Command{ - Use: "stop", - Short: "Stop localbase daemon", - Long: `Stop the running localbase daemon.`, - RunE: func(cmd *cobra.Command, args []string) error { - return sendCommand("stop") - }, - } +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version number of localbase", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("LocalBase %s\n", version) + fmt.Printf(" commit: %s\n", commit) + fmt.Printf(" built: %s\n", date) + fmt.Printf(" built by: %s\n", builtBy) + }, } -func removeCmd() *cobra.Command { - return &cobra.Command{ - Use: "remove ", - Short: "Remove a domain", - Long: `Remove a domain from LocalBase.`, - RunE: func(cmd *cobra.Command, args []string) error { - if len(args) != 1 { - return fmt.Errorf("usage: localbase remove ") - } - return sendCommand(fmt.Sprintf("remove %s", args[0])) - }, - } -} +var pingCmd = &cobra.Command{ + Use: "ping", + Short: "Ping the localbase daemon", + Long: `Check if the localbase daemon is running and responsive.`, + RunE: func(cmd *cobra.Command, args []string) error { + logger := NewLogger(ErrorLevel) // Quiet for ping + client, err := NewClient(logger) + if err != nil { + return fmt.Errorf("failed to connect to localbase daemon: %w", err) + } -func listCmd() *cobra.Command { - return &cobra.Command{ - Use: "list", - Short: "List all domains", - Long: `List all domains registered in LocalBase.`, - RunE: func(cmd *cobra.Command, args []string) error { - return sendCommand("list") - }, - } + err = client.SendCommand("ping", nil) + if err != nil { + return fmt.Errorf("ping failed: %w", err) + } + + fmt.Println("pong") + return nil + }, } func init() { - rootCmd.AddCommand(addCmd) - addCmd.Flags().IntP("port", "p", 0, "port for the .local domain") rootCmd.AddCommand(startCmd) - startCmd.Flags().IntP("addr", "a", 2025, "localbase process address") - startCmd.Flags().StringP("caddy", "c", "http://localhost:2019", "local caddy admin address") - startCmd.Flags().BoolP("detached", "d", false, "run localbase in background") - rootCmd.AddCommand(stopCmd()) - rootCmd.AddCommand(removeCmd()) - rootCmd.AddCommand(listCmd()) + startCmd.Flags().StringP("addr", "a", "localhost:2025", "localbase daemon address") + startCmd.Flags().StringP("caddy", "c", "http://localhost:2019", "Caddy admin API address") + startCmd.Flags().BoolP("detached", "d", false, "Run localbase in background") + startCmd.Flags().String("log-level", "info", "Log level (debug, info, error)") + + rootCmd.AddCommand(addCmd) + addCmd.Flags().IntP("port", "p", 0, "Port for the local domain") + if err := addCmd.MarkFlagRequired("port"); err != nil { + panic(fmt.Errorf("failed to mark port flag as required: %w", err)) + } + + rootCmd.AddCommand(removeCmd) + rootCmd.AddCommand(listCmd) + rootCmd.AddCommand(stopCmd) + rootCmd.AddCommand(pingCmd) + rootCmd.AddCommand(versionCmd) } func main() { if err := rootCmd.Execute(); err != nil { - log.Fatalf("[localbase]: %v", err) + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) } } diff --git a/server.go b/server.go new file mode 100644 index 0000000..d8a8d99 --- /dev/null +++ b/server.go @@ -0,0 +1,565 @@ +package main + +import ( + "bufio" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// Server represents the localbase daemon server +type Server struct { + config *Config + logger Logger + localbase DomainService + pool *ConnectionHandler + protocolHandler *ProtocolHandler + tlsManager *TLSManager + authManager *AuthManager + listener net.Listener + shutdownChan chan struct{} + mu sync.RWMutex +} + +// NewServer creates a new server instance +func NewServer(config *Config, logger Logger) (*Server, error) { + // Create dependencies + configManager := NewConfigManager(logger) + caddyClient := NewCaddyClient(config.CaddyAdmin, logger) + validator := NewCommandValidator(logger) + + // Get config path for TLS certificates and auth tokens + configPath, err := configManager.GetConfigPath() + if err != nil { + return nil, fmt.Errorf("failed to get config path: %w", err) + } + tlsManager := NewTLSManager(configPath, logger) + + // Create authentication manager + authManager, err := NewAuthManager(configPath, logger) + if err != nil { + return nil, fmt.Errorf("failed to create auth manager: %w", err) + } + + // Ensure Caddy is running + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := caddyClient.EnsureRunning(ctx); err != nil { + return nil, fmt.Errorf("failed to ensure Caddy is running: %w", err) + } + + // Create LocalBase service + lb, err := NewLocalBase(logger, configManager, caddyClient, validator) + if err != nil { + return nil, fmt.Errorf("failed to create localbase: %w", err) + } + + server := &Server{ + config: config, + logger: logger, + localbase: lb, + tlsManager: tlsManager, + authManager: authManager, + shutdownChan: make(chan struct{}), + } + + // Create protocol handler with server reference for shutdown + server.protocolHandler = NewProtocolHandler(lb, authManager, logger, server.triggerShutdown) + + return server, nil +} + +// GetListenerAddr safely returns the listener address +func (s *Server) GetListenerAddr() string { + s.mu.RLock() + defer s.mu.RUnlock() + if s.listener != nil { + return s.listener.Addr().String() + } + return "" +} + +// Start starts the server +func (s *Server) Start(ctx context.Context) error { + // Create PID file + if err := s.authManager.CreatePIDFile(); err != nil { + return fmt.Errorf("failed to create PID file: %w", err) + } + defer func() { _ = s.authManager.RemovePIDFile() }() + + // Get TLS configuration + tlsConfig, err := s.tlsManager.GetTLSConfig() + if err != nil { + return fmt.Errorf("failed to get TLS config: %w", err) + } + + // Start listening with TLS + listener, err := tls.Listen("tcp", s.config.AdminAddress, tlsConfig) + if err != nil { + return fmt.Errorf("failed to start localbase server: %w", err) + } + + s.mu.Lock() + s.listener = listener + s.mu.Unlock() + + s.logger.Info("localbase server started", Field{"address", s.config.AdminAddress}) + + // Create connection pool + s.pool = NewConnectionPool(ctx, 100, s.protocolHandler.HandleConnection, s.logger) + + // Start broadcast + if lb, ok := s.localbase.(*LocalBase); ok { + go lb.startBroadcast(ctx) + } + + // Accept connections + go s.acceptConnections(ctx) + + // Wait for shutdown signal from either context or shutdown command + select { + case <-ctx.Done(): + s.logger.Info("context canceled, shutting down") + case <-s.shutdownChan: + s.logger.Info("shutdown command received") + } + + return s.stop() +} + +// acceptConnections accepts and handles incoming connections +func (s *Server) acceptConnections(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + default: + // Check if listener is nil (server is shutting down) + s.mu.RLock() + listener := s.listener + s.mu.RUnlock() + + if listener == nil { + return + } + + conn, err := listener.Accept() + if err != nil { + select { + case <-ctx.Done(): + return + default: + s.logger.Error("failed to accept connection", Field{"error", err}) + continue + } + } + + go func() { + if err := s.pool.Accept(conn); err != nil { + s.logger.Error("connection handling error", Field{"error", err}) + } + }() + } + } +} + +// triggerShutdown triggers a graceful shutdown +func (s *Server) triggerShutdown() { + select { + case s.shutdownChan <- struct{}{}: + default: + } +} + +// stop gracefully stops the server +func (s *Server) stop() error { + s.logger.Info("stopping localbase server") + + // Close the listener + s.mu.Lock() + if s.listener != nil { + _ = s.listener.Close() + s.listener = nil + } + s.mu.Unlock() + + // Close connection pool + if s.pool != nil { + _ = s.pool.Close() + } + + // Shutdown LocalBase + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := s.localbase.Shutdown(ctx); err != nil { + s.logger.Error("error shutting down localbase", Field{"error", err}) + return err + } + + return nil +} + +// ProtocolHandler handles protocol communication +type ProtocolHandler struct { + localbase DomainService + auth *AuthManager + logger Logger + shutdown func() +} + +// NewProtocolHandler creates a protocol handler +func NewProtocolHandler(localbase DomainService, auth *AuthManager, logger Logger, shutdown func()) *ProtocolHandler { + return &ProtocolHandler{ + localbase: localbase, + auth: auth, + logger: logger, + shutdown: shutdown, + } +} + +// HandleConnection handles text-based protocol communication +func (h *ProtocolHandler) HandleConnection(ctx context.Context, conn net.Conn) error { + scanner := bufio.NewScanner(conn) + writer := bufio.NewWriter(conn) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + response := h.processCommand(line) + + // Send response + if _, err := writer.WriteString(response + "\n"); err != nil { + return fmt.Errorf("failed to write response: %w", err) + } + if err := writer.Flush(); err != nil { + return fmt.Errorf("failed to flush response: %w", err) + } + } + + return scanner.Err() +} + +// processCommand processes a command +func (h *ProtocolHandler) processCommand(command string) string { + parts := strings.Fields(command) + if len(parts) == 0 { + return "ERROR: empty command" + } + + cmd := parts[0] + args := parts[1:] + + switch cmd { + case "add": + if len(args) < 2 { + return "ERROR: add requires domain and port" + } + domain := args[0] + port := args[1] + + // Convert port to int + var portInt int + if _, err := fmt.Sscanf(port, "%d", &portInt); err != nil { + return "ERROR: invalid port number" + } + + ctx := context.Background() + if err := h.localbase.Add(ctx, domain, portInt); err != nil { + return fmt.Sprintf("ERROR: %v", err) + } + return fmt.Sprintf("OK: added %s:%s", domain, port) + + case "remove": + if len(args) < 1 { + return "ERROR: remove requires domain" + } + domain := args[0] + + ctx := context.Background() + if err := h.localbase.Remove(ctx, domain); err != nil { + return fmt.Sprintf("ERROR: %v", err) + } + return fmt.Sprintf("OK: removed %s", domain) + + case "list": + ctx := context.Background() + domains, err := h.localbase.List(ctx) + if err != nil { + return fmt.Sprintf("ERROR: %v", err) + } + + if len(domains) == 0 { + return "OK: no domains configured" + } + + // Format domains as simple list + var domainList []string + for _, d := range domains { + domainList = append(domainList, fmt.Sprintf("%s -> localhost:%d", d, 0)) // Port info not stored + } + return fmt.Sprintf("OK: %s", strings.Join(domainList, ", ")) + + case "ping": + return "OK: pong" + + case "shutdown": + go h.shutdown() // Shutdown in goroutine to allow response + return "OK: shutting down" + + default: + return fmt.Sprintf("ERROR: unknown command %s", cmd) + } +} + +// ConnectionHandler handles connections directly without pooling +type ConnectionHandler struct { + handler func(context.Context, net.Conn) error + logger Logger + mu sync.RWMutex + active map[net.Conn]struct{} +} + +// NewConnectionPool creates a connection handler +func NewConnectionPool(_ context.Context, _ int, handler func(context.Context, net.Conn) error, logger Logger) *ConnectionHandler { + return &ConnectionHandler{ + handler: handler, + logger: logger, + active: make(map[net.Conn]struct{}), + } +} + +// Accept handles a single connection +func (h *ConnectionHandler) Accept(conn net.Conn) error { + // Track active connection + h.mu.Lock() + h.active[conn] = struct{}{} + h.mu.Unlock() + + // Clean up when done + defer func() { + h.mu.Lock() + delete(h.active, conn) + h.mu.Unlock() + _ = conn.Close() + }() + + // Handle the connection + ctx := context.Background() + if err := h.handler(ctx, conn); err != nil { + h.logger.Error("connection handler error", Field{"error", err}) + return err + } + return nil +} + +// ActiveConnections returns the number of active connections +func (h *ConnectionHandler) ActiveConnections() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.active) +} + +// Close closes all active connections +func (h *ConnectionHandler) Close() error { + h.mu.Lock() + defer h.mu.Unlock() + + for conn := range h.active { + _ = conn.Close() + } + h.active = make(map[net.Conn]struct{}) + return nil +} + +// AuthManager provides basic file-based authentication for local use +type AuthManager struct { + configPath string + logger Logger + pidFile string +} + +// NewAuthManager creates an auth manager +func NewAuthManager(configPath string, logger Logger) (*AuthManager, error) { + auth := &AuthManager{ + configPath: configPath, + logger: logger, + pidFile: filepath.Join(configPath, ".localbase.pid"), + } + + // Ensure config directory exists with proper permissions + if err := os.MkdirAll(configPath, 0o700); err != nil { + return nil, fmt.Errorf("failed to create config directory: %w", err) + } + + return auth, nil +} + +// ValidateToken validates a token (for local use) +func (a *AuthManager) ValidateToken(_ string) bool { + // For local development, just check if daemon is running by same user + _, err := os.Stat(a.pidFile) + return err == nil +} + +// ValidateRequest validates a request +func (a *AuthManager) ValidateRequest(token string) bool { + return a.ValidateToken(token) +} + +// CreatePIDFile creates a PID file when daemon starts +func (a *AuthManager) CreatePIDFile() error { + pid := fmt.Sprintf("%d", os.Getpid()) + return os.WriteFile(a.pidFile, []byte(pid), 0o600) +} + +// RemovePIDFile removes the PID file when daemon stops +func (a *AuthManager) RemovePIDFile() error { + return os.Remove(a.pidFile) +} + +// GetToken returns a token (PID for local use) +func (a *AuthManager) GetToken() (string, error) { + pidBytes, err := os.ReadFile(a.pidFile) + if err != nil { + return "", fmt.Errorf("daemon not running or permission denied") + } + return string(pidBytes), nil +} + +// GetClientToken returns a client token +func (a *AuthManager) GetClientToken() (string, error) { + return a.GetToken() +} + +// RotateToken is a no-op for the auth system +func (a *AuthManager) RotateToken() error { + // For local development, token rotation is not needed + return nil +} + +// TLSManager provides basic TLS for localhost +type TLSManager struct { + configPath string + logger Logger +} + +// NewTLSManager creates a TLS manager +func NewTLSManager(configPath string, logger Logger) *TLSManager { + return &TLSManager{ + configPath: configPath, + logger: logger, + } +} + +// GetTLSConfig returns TLS config for localhost +func (t *TLSManager) GetTLSConfig() (*tls.Config, error) { + certFile := filepath.Join(t.configPath, "cert.pem") + keyFile := filepath.Join(t.configPath, "key.pem") + + // Generate cert if it doesn't exist + if !t.certificateExists(certFile, keyFile) { + if err := t.generateCertificate(certFile, keyFile); err != nil { + return nil, fmt.Errorf("failed to generate certificate: %w", err) + } + } + + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, fmt.Errorf("failed to load certificate: %w", err) + } + + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + ServerName: "localhost", + MinVersion: tls.VersionTLS12, + }, nil +} + +// GetClientTLSConfig returns client TLS config +func (t *TLSManager) GetClientTLSConfig() *tls.Config { + return &tls.Config{ + InsecureSkipVerify: true, // #nosec G402 - localhost self-signed cert + ServerName: "localhost", + MinVersion: tls.VersionTLS12, + } +} + +// certificateExists checks if certificate files exist +func (t *TLSManager) certificateExists(certFile, keyFile string) bool { + _, certErr := os.Stat(certFile) + _, keyErr := os.Stat(keyFile) + return certErr == nil && keyErr == nil +} + +// generateCertificate creates a self-signed certificate +func (t *TLSManager) generateCertificate(certFile, keyFile string) error { + // Generate private key + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return fmt.Errorf("failed to generate private key: %w", err) + } + + // Certificate template for localhost + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"LocalBase"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, + DNSNames: []string{"localhost"}, + } + + // Create certificate + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return fmt.Errorf("failed to create certificate: %w", err) + } + + // Write certificate file + certOut, err := os.OpenFile(certFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) // #nosec G304 + if err != nil { + return fmt.Errorf("failed to create cert file: %w", err) + } + defer func() { _ = certOut.Close() }() + + if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil { + return fmt.Errorf("failed to write certificate: %w", err) + } + + // Write private key file + keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) // #nosec G304 + if err != nil { + return fmt.Errorf("failed to create key file: %w", err) + } + defer func() { _ = keyOut.Close() }() + + privKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey) + + if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: privKeyBytes}); err != nil { + return fmt.Errorf("failed to write private key: %w", err) + } + + t.logger.Info("generated self-signed certificate for localhost") + return nil +} diff --git a/util.go b/util.go index 9ea4979..2638700 100644 --- a/util.go +++ b/util.go @@ -1,106 +1,213 @@ package main import ( - "encoding/json" + "context" "fmt" + "log" "net" "os" - "path/filepath" - "runtime" + "strings" + "sync" +) + +// Logger interface for structured logging +type Logger interface { + Debug(msg string, fields ...Field) + Info(msg string, fields ...Field) + Error(msg string, fields ...Field) + Fatal(msg string, fields ...Field) +} - "github.com/mitchellh/go-homedir" +// Field represents a key-value pair for structured logging +type Field struct { + Key string + Value any +} + +// LogLevel represents the logging level +type LogLevel int + +const ( + // DebugLevel logs all messages + DebugLevel LogLevel = iota + // InfoLevel logs info, error, and fatal messages + InfoLevel + // ErrorLevel logs error and fatal messages + ErrorLevel + // FatalLevel logs only fatal messages + FatalLevel ) -type Config struct { - CaddyAdmin string `json:"caddy_admin"` - AdminAddress string `json:"admin_address"` +// DefaultLogger is the standard implementation of the Logger interface +type DefaultLogger struct { + level LogLevel + mu sync.Mutex + logger *log.Logger } -func defaultConfig() *Config { - return &Config{ - CaddyAdmin: "http://localhost:2019", - AdminAddress: "localhost:2025", +// NewLogger creates a new logger instance +func NewLogger(level LogLevel) *DefaultLogger { + return &DefaultLogger{ + level: level, + logger: log.New(os.Stdout, "", log.LstdFlags), } } -func getConfigDir() (string, error) { - home, err := homedir.Dir() - if err != nil { - return "", err - } +func (l *DefaultLogger) shouldLog(level LogLevel) bool { + return level >= l.level +} - var configDir string - switch runtime.GOOS { - case "windows": - configDir = filepath.Join(home, "AppData", "Roaming", "localbase") - case "darwin": - configDir = filepath.Join(home, "Library", "Application Support", "localbase") - default: - configDir = filepath.Join(home, ".config", "localbase") +func (l *DefaultLogger) formatMessage(level, msg string, fields []Field) string { + var parts []string + parts = append(parts, fmt.Sprintf("[%s] %s", level, msg)) + + for _, field := range fields { + parts = append(parts, fmt.Sprintf("%s=%v", field.Key, field.Value)) } - return configDir, nil + return strings.Join(parts, " ") } -func saveConfig(cfg *Config) error { - configDir, err := getConfigDir() - if err != nil { - return err +// Debug logs a debug message +func (l *DefaultLogger) Debug(msg string, fields ...Field) { + if !l.shouldLog(DebugLevel) { + return } + l.mu.Lock() + defer l.mu.Unlock() + l.logger.Println(l.formatMessage("DEBUG", msg, fields)) +} - if err := os.MkdirAll(configDir, 0755); err != nil { - return err +// Info logs an info message +func (l *DefaultLogger) Info(msg string, fields ...Field) { + if !l.shouldLog(InfoLevel) { + return } + l.mu.Lock() + defer l.mu.Unlock() + l.logger.Println(l.formatMessage("INFO", msg, fields)) +} - configFile := filepath.Join(configDir, "config.json") +func (l *DefaultLogger) Error(msg string, fields ...Field) { + if !l.shouldLog(ErrorLevel) { + return + } + l.mu.Lock() + defer l.mu.Unlock() + l.logger.Println(l.formatMessage("ERROR", msg, fields)) +} - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err +// Fatal logs a fatal error message and exits +func (l *DefaultLogger) Fatal(msg string, fields ...Field) { + l.mu.Lock() + l.logger.Println(l.formatMessage("FATAL", msg, fields)) + l.mu.Unlock() + os.Exit(1) +} + +// ParseLogLevel parses a string log level +func ParseLogLevel(level string) LogLevel { + switch strings.ToLower(level) { + case "debug": + return DebugLevel + case "error": + return ErrorLevel + case "fatal": + return FatalLevel + default: + return InfoLevel } +} - return os.WriteFile(configFile, data, 0644) +// Interfaces + +// DomainService manages domain registrations +type DomainService interface { + Add(ctx context.Context, domain string, port int) error + Remove(ctx context.Context, domain string) error + List(ctx context.Context) ([]string, error) + Shutdown(ctx context.Context) error } -func readConfig() (*Config, error) { - configDir, err := getConfigDir() - if err != nil { - return &Config{}, err +// CaddyClient manages Caddy configurations +type CaddyClient interface { + GetConfig(ctx context.Context) (map[string]any, error) + UpdateConfig(ctx context.Context, config map[string]any) error + AddServerBlock(ctx context.Context, domains []string, port int) error + RemoveServerBlock(ctx context.Context, domains []string) error + ClearAllServerBlocks(ctx context.Context) error + IsRunning(ctx context.Context) (bool, error) + StartCaddy(ctx context.Context) error + EnsureRunning(ctx context.Context) error +} + +// Config represents the application configuration +type Config struct { + CaddyAdmin string `json:"caddy_admin"` + AdminAddress string `json:"admin_address"` +} + +// ConfigManagerInterface handles application configuration +type ConfigManagerInterface interface { + Read() (*Config, error) + Write(config *Config) error + GetConfigPath() (string, error) +} + +// Validator provides input validation +type Validator interface { + ValidateDomain(domain string) error + ValidatePort(port int) error +} + +// Utility functions + +// ParseAddress ensures the address includes localhost binding +func ParseAddress(addr string) (string, error) { + // If no host is specified, default to localhost + if !strings.Contains(addr, ":") { + return "", fmt.Errorf("invalid address format: missing port") } - configFile := filepath.Join(configDir, "config.json") - data, err := os.ReadFile(configFile) + host, port, err := net.SplitHostPort(addr) if err != nil { - if os.IsNotExist(err) { - return defaultConfig(), nil - } - return &Config{}, err + return "", fmt.Errorf("invalid address format: %w", err) } - var cfg Config - if err := json.Unmarshal(data, &cfg); err != nil { - return &Config{}, err + // If no host specified, use localhost + if host == "" { + host = "localhost" } - return &cfg, nil + // Validate host is localhost or loopback + if host != "localhost" && host != "127.0.0.1" && host != "::1" { + return "", fmt.Errorf("admin interface must bind to localhost only") + } + + // Validate port + var portNum int + if _, err := fmt.Sscanf(port, "%d", &portNum); err != nil { + return "", fmt.Errorf("invalid port: %w", err) + } + + if portNum < 1 || portNum > 65535 { + return "", fmt.Errorf("port must be between 1 and 65535") + } + + return net.JoinHostPort(host, port), nil } -func getLocalIP() (string, error) { - addrs, err := net.InterfaceAddrs() - if err != nil { - return "", err +// getHomeDir returns the user's home directory +func getHomeDir() string { + if home, err := os.UserHomeDir(); err == nil { + return home + } + // Fallback to environment variables + if home := os.Getenv("HOME"); home != "" { + return home } - for _, addr := range addrs { - var ip net.IP - switch v := addr.(type) { - case *net.IPNet: - ip = v.IP - case *net.IPAddr: - ip = v.IP - } - if ip != nil && !ip.IsLoopback() && ip.To4() != nil { - return ip.String(), nil - } + if home := os.Getenv("USERPROFILE"); home != "" { + return home } - return "", fmt.Errorf("no suitable local IP address found") + return "" } diff --git a/validator_test.go b/validator_test.go new file mode 100644 index 0000000..81161b3 --- /dev/null +++ b/validator_test.go @@ -0,0 +1,149 @@ +package main + +import ( + "strings" + "testing" +) + +func TestNewCommandValidator(t *testing.T) { + logger := NewLogger(InfoLevel) + + cv := NewCommandValidator(logger) + if cv == nil { + t.Fatal("NewCommandValidator returned nil") + } + if cv.logger != logger { + t.Error("logger not set correctly") + } +} + +func TestCommandValidatorValidateDomain(t *testing.T) { + logger := NewLogger(InfoLevel) + cv := NewCommandValidator(logger) + + // Test valid domains + validDomains := []string{"api", "test-app", "localhost", "myapp.local"} + for _, domain := range validDomains { + err := cv.ValidateDomain(domain) + if err != nil { + t.Errorf("expected domain %s to be valid, got error: %v", domain, err) + } + } + + // Test invalid domain with dangerous characters + err := cv.ValidateDomain("domain;with;semicolons") + if err == nil { + t.Error("ValidateDomain should return error for domain with dangerous characters") + } +} + +func TestCommandValidatorValidatePort(t *testing.T) { + logger := NewLogger(InfoLevel) + cv := NewCommandValidator(logger) + + // Test valid ports + validPorts := []int{1024, 3000, 8080, 8443, 9000, 65535} + for _, port := range validPorts { + err := cv.ValidatePort(port) + if err != nil { + t.Errorf("expected port %d to be valid, got error: %v", port, err) + } + } + + // Test invalid ports + invalidPorts := []int{0, -1, 65536, 100000} + for _, port := range invalidPorts { + err := cv.ValidatePort(port) + if err == nil { + t.Errorf("expected port %d to be invalid", port) + } + } +} + +// Test DomainValidator functionality +func TestNewDomainValidator(t *testing.T) { + validator := NewValidator() + if validator == nil { + t.Fatal("NewValidator returned nil") + } + + if validator.domainRegex == nil { + t.Error("validator domainRegex is nil") + } +} + +func TestDomainValidatorDomain(t *testing.T) { + validator := NewValidator() + + // Test valid domains (for local development) + validDomains := []string{ + "myapp", + "test-app", + "api", + "web-server", + "app123", + "api.suboxo", + "app.example", + "my-app.dev", + } + + for _, domain := range validDomains { + t.Run("valid_"+domain, func(t *testing.T) { + err := validator.ValidateDomain(domain) + if err != nil { + t.Errorf("expected domain %s to be valid, got error: %v", domain, err) + } + }) + } + + // Test invalid domains + invalidDomains := []struct { + domain string + }{ + {""}, + {strings.Repeat("a", 254)}, + } + + for _, testCase := range invalidDomains { + t.Run("invalid_"+testCase.domain, func(t *testing.T) { + err := validator.ValidateDomain(testCase.domain) + if err == nil { + t.Errorf("expected domain %s to be invalid", testCase.domain) + } + }) + } +} + +func TestDomainValidatorPort(t *testing.T) { + validator := NewValidator() + + // Test valid ports + validPorts := []int{1, 1024, 3000, 8080, 8443, 9000, 65535} + + for _, port := range validPorts { + t.Run("valid_port", func(t *testing.T) { + err := validator.ValidatePort(port) + if err != nil { + t.Errorf("expected port %d to be valid, got error: %v", port, err) + } + }) + } + + // Test invalid ports + invalidPorts := []struct { + port int + }{ + {0}, + {-1}, + {65536}, + } + + for _, testCase := range invalidPorts { + t.Run("invalid_port", func(t *testing.T) { + err := validator.ValidatePort(testCase.port) + if err == nil { + t.Errorf("expected port %d to be invalid", testCase.port) + } + }) + } +}