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)
+ }
+ })
+ }
+}