diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..c29fa58d --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,29 @@ +# CaddyProxyManager+ Copilot Instructions + +## Big Picture +- `backend/cmd/api` loads config, opens SQLite, then hands off to `internal/server` where routes from `internal/api/routes` are registered. +- `internal/config` respects `CPM_ENV`, `CPM_HTTP_PORT`, `CPM_DB_PATH`, `CPM_FRONTEND_DIR` and creates the `data/` directory; lean on these instead of hard-coded paths. +- All HTTP endpoints live under `/api/v1/*`; keep new handlers inside `internal/api/handlers` and register them via `routes.Register` so `db.AutoMigrate` runs for their models. +- `internal/server` also mounts the built React app (via `attachFrontend`) whenever `frontend/dist` exists, falling back to JSON `{"error": ...}` for any `/api/*` misses. +- Persistent types live in `internal/models`; GORM auto-migrates them each boot, so evolve schemas there before touching handlers or the frontend. + +## Backend Workflow +- Run locally with `cd backend && go run ./cmd/api`; run tests with `go test ./...` (see `proxy_host_handler_test.go` for the in-memory SQLite/Gin harness pattern). +- Handlers return structured errors using `gin.H{"error": "message"}` and standard HTTP codes—mirror the `ProxyHostHandler` lifecycle for new CRUD endpoints. +- UUIDs (`github.com/google/uuid`) are generated server-side and exposed as `uuid` fields; clients never send numeric IDs. +- Query lists sorted by `updated_at desc` (see `.Order("updated_at desc")` in `List`); match that ordering for user-visible collections. +- Long-running work must respect the graceful shutdown flow in `server.Run(ctx)`—avoid background goroutines that ignore the context. + +## Frontend Workflow +- React 18 + Vite + React Query; start with `cd frontend && npm install && npm run dev` so Vite proxies `/api` calls to `http://localhost:8080` (configured in `vite.config.ts`). +- Consolidate HTTP calls via `src/api/client.ts`; wrap them in hooks under `src/hooks` and expose query keys like `['proxy-hosts']` to keep cache invalidation simple. +- Screens live in `src/pages` and render inside `components/Layout`; navigation + active styles rely on React Router + `clsx`, so extend the `links` array instead of hard-coding routes elsewhere. +- Forms follow `pages/ProxyHosts.tsx`: local `useState` per field, submit via `useMutation`, then reset state and `invalidateQueries` for the affected list on success. +- Styling remains a single `src/index.css` grid/aside theme; keep additions lightweight and avoid new design systems until shadcn/ui lands. + +## Cross-Cutting Notes +- Run the backend before the frontend; React Query expects the exact JSON produced by GORM tags (snake_case), so keep API and UI field names aligned. +- When adding models, update both `internal/models` and the `AutoMigrate` call inside `internal/api/routes/routes.go`; register new Gin routes right after migrations for clarity. +- Tests belong beside handlers (`*_test.go`); reuse the `setupTestRouter` helper structure (in-memory SQLite, Gin router, httptest requests) for fast feedback. +- The root `Dockerfile` is still the legacy Python scaffold—do not assume it builds this stack until it is replaced with the Go/React pipeline. +- Branch from `feature/**` and target `development`; CI currently lints/tests placeholders, so run `go test ./...` and `npm run build` locally before opening PRs. diff --git a/.github/workflows/auto-add-to-project.yml b/.github/workflows/auto-add-to-project.yml index 31a9f8d4..12a37042 100644 --- a/.github/workflows/auto-add-to-project.yml +++ b/.github/workflows/auto-add-to-project.yml @@ -10,8 +10,15 @@ jobs: add-to-project: runs-on: ubuntu-latest steps: + - name: Skip if PROJECT_URL not provided + run: | + if [ -z "${{ secrets.PROJECT_URL }}" ]; then + echo "PROJECT_URL secret not set; skipping add-to-project job." + exit 0 + fi + - name: Add issue or PR to project uses: actions/add-to-project@v0.5.0 with: - project-url: https://github.com/users/Wikid82/projects/7 + project-url: ${{ secrets.PROJECT_URL }} github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..262a8d8a --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,76 @@ +name: Docker Build & Publish + +on: + push: + branches: + - main + - development + tags: + - 'v*.*.*' + pull_request: + branches: + - main + - development + workflow_call: # Allow this workflow to be called by other workflows + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + # Tag 'latest' for main branch + type=raw,value=latest,enable={{is_default_branch}} + # Tag 'development' for development branch + type=raw,value=development,enable=${{ github.ref == 'refs/heads/development' }} + # Semver tags for version releases (v1.0.0 -> 1.0.0, 1.0, 1) + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + # SHA for all builds + type=sha,prefix={{branch}}- + + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + VERSION=${{ steps.meta.outputs.version }} + BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} + VCS_REF=${{ github.sha }} + + - name: Image digest + run: echo ${{ steps.build-and-push.outputs.digest }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..4628e182 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,52 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: write + packages: write + +jobs: + create-release: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate changelog + id: changelog + run: | + # Get previous tag + PREV_TAG=$(git describe --tags --abbrev=0 $(git rev-list --tags --skip=1 --max-count=1) 2>/dev/null || echo "") + + if [ -z "$PREV_TAG" ]; then + echo "First release - generating full changelog" + CHANGELOG=$(git log --pretty=format:"- %s (%h)" --no-merges) + else + echo "Generating changelog since $PREV_TAG" + CHANGELOG=$(git log $PREV_TAG..HEAD --pretty=format:"- %s (%h)" --no-merges) + fi + + # Save to file for GitHub release + echo "$CHANGELOG" > CHANGELOG.txt + echo "Generated changelog with $(echo "$CHANGELOG" | wc -l) commits" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + body_path: CHANGELOG.txt + generate_release_notes: true + draft: false + prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build-and-publish: + needs: create-release + uses: ./.github/workflows/docker-publish.yml + secrets: inherit diff --git a/.version b/.version new file mode 100644 index 00000000..388bb068 --- /dev/null +++ b/.version @@ -0,0 +1 @@ +0.1.0-alpha diff --git a/ARCHITECTURE_PLAN.md b/ARCHITECTURE_PLAN.md new file mode 100644 index 00000000..24668dca --- /dev/null +++ b/ARCHITECTURE_PLAN.md @@ -0,0 +1,49 @@ +# CaddyProxyManager+ Architecture Plan + +## Stack Overview +- **Backend**: Go 1.22, Gin HTTP framework, GORM ORM, SQLite for local/stateful storage. +- **Frontend**: React 18 + TypeScript with Vite, React Query for data fetching, React Router for navigation. +- **API Contract**: REST/JSON over `/api/v1`, versioned to keep room for breaking changes. +- **Deployment**: Container-first via multi-stage Docker build (Node → Go), future compose bundle for Caddy runtime. + +## Backend +- `backend/cmd/api`: Entry point wires configuration, database, and HTTP server lifecycle. +- `internal/config`: Reads environment variables (`CPM_ENV`, `CPM_HTTP_PORT`, `CPM_DB_PATH`). Defaults to `development`, `8080`, `./data/cpm.db` respectively. +- `internal/database`: Wraps GORM + SQLite connection handling and enforces data-directory creation. +- `internal/server`: Creates Gin engine, registers middleware, wires graceful shutdown, and exposes `Run(ctx)` for signal-aware lifecycle. +- `internal/api`: Versioned routing layer. Initial resources: + - `GET /api/v1/health`: Simple status response for readiness checks. + - CRUD `/api/v1/proxy-hosts`: Minimal data model used to validate persistence, shape matches Issue #1 requirements (name, domain, upstream target, toggles). +- `internal/models`: Source of truth for persistent entities. Future migrations will extend `ProxyHost` with SSL, ACL, audit metadata. +- Testing: In-memory SQLite harness verifies handler lifecycle via unit tests (`go test ./...`). + +## Frontend +- Vite dev server with proxy to `http://localhost:8080` for `/api` paths keeps CORS trivial. +- React Router organizes initial pages (Dashboard, Proxy Hosts, System Status) to mirror Issue roadmap. +- React Query centralizes API caching, invalidation, and loading states. +- Basic layout shell provides left-nav reminiscent of NPM while keeping styling simple (CSS utility file, no design system yet). Future work will slot shadcn/ui components without rewriting data layer. +- Build outputs static assets in `frontend/dist` consumed by Docker multi-stage for production. + +## Data & Persistence +- SQLite chosen for Alpha milestone simplicity; GORM migrates schema automatically on boot (`AutoMigrate`). +- Database path configurable via env to allow persistent volumes in Docker or alternative DB (PostgreSQL/MySQL) when scaling. + +## API Principles +1. **Version Everything** (`/api/v1`). +2. **Stateless**: Each request carries all context; session/story features will rely on cookies/JWT later. +3. **Dependable validation**: Gin binding ensures HTTP 400 responses include validation errors. +4. **Observability**: Gin logging + structured error responses keep early debugging simple; plan to add Zap/zerolog instrumentation during Beta. + +## Local Development Workflow +1. Start backend: `cd backend && go run ./cmd/api`. +2. Start frontend: `cd frontend && npm run dev` (Vite proxy sends API calls to backend automatically). +3. Optional: run both via Docker (see updated Dockerfile) once containers land. +4. Tests: + - Backend: `cd backend && go test ./...` + - Frontend build check: `cd frontend && npm run build` + +## Next Steps +- Layer authentication (Issue #7) once scaffolding lands. +- Expand data model (certificates, access lists) and add migrations. +- Replace basic CSS with component system (e.g., shadcn/ui) + design tokens. +- Compose file bundling backend, frontend assets, Caddy runtime, and SQLite volume. diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 00000000..2d6c055b --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,234 @@ +# Docker Deployment Guide + +CaddyProxyManager+ is designed for Docker-first deployment, making it easy for home users to run Caddy without learning Caddyfile syntax. + +## Quick Start + +```bash +# Clone the repository +git clone https://github.com/Wikid82/CaddyProxyManagerPlus.git +cd CaddyProxyManagerPlus + +# Start the stack +docker-compose up -d + +# Access the UI +open http://localhost:8080 +``` + +## Architecture + +The Docker stack consists of two services: + +1. **app** (`caddyproxymanager-plus`): Management interface + - Manages proxy host configuration + - Provides web UI on port 8080 + - Communicates with Caddy via admin API + +2. **caddy**: Reverse proxy server + - Handles incoming traffic on ports 80/443 + - Automatic HTTPS with Let's Encrypt + - Configured dynamically via JSON API + +``` +┌──────────────┐ +│ Internet │ +└──────┬───────┘ + │ :80, :443 + ▼ +┌──────────────┐ Admin API ┌──────────────┐ +│ Caddy │◄───────:2019───────┤ CPM+ App │ +│ (Proxy) │ │ (Manager) │ +└──────┬───────┘ └──────┬───────┘ + │ │ + ▼ ▼ + Your Services :8080 (Web UI) +``` + +## Environment Variables + +Configure CPM+ via environment variables in `docker-compose.yml`: + +```yaml +environment: + - CPM_ENV=production # production | development + - CPM_HTTP_PORT=8080 # Management UI port + - CPM_DB_PATH=/app/data/cpm.db # SQLite database location + - CPM_CADDY_ADMIN_API=http://caddy:2019 # Caddy admin endpoint + - CPM_CADDY_CONFIG_DIR=/app/data/caddy # Config snapshots +``` + +## Volumes + +Three persistent volumes store your data: + +- **app_data**: CPM+ database, config snapshots, logs +- **caddy_data**: Caddy certificates, ACME account data +- **caddy_config**: Caddy runtime configuration + +To backup your configuration: + +```bash +# Backup volumes +docker run --rm -v cpm_app_data:/data -v $(pwd):/backup alpine tar czf /backup/cpm-backup.tar.gz /data + +# Restore from backup +docker run --rm -v cpm_app_data:/data -v $(pwd):/backup alpine tar xzf /backup/cpm-backup.tar.gz -C / +``` + +## Ports + +Default port mapping: + +- **80**: HTTP (Caddy) - redirects to HTTPS +- **443/tcp**: HTTPS (Caddy) +- **443/udp**: HTTP/3 (Caddy) +- **8080**: Management UI (CPM+) +- **2019**: Caddy admin API (internal only, exposed in dev mode) + +## Development Mode + +Development mode exposes the Caddy admin API externally for debugging: + +```bash +docker-compose -f docker-compose.yml -f docker-compose.dev.yml up +``` + +Access Caddy admin API: `http://localhost:2019/config/` + +## Health Checks + +CPM+ includes a health check endpoint: + +```bash +# Check if app is running +curl http://localhost:8080/api/v1/health + +# Check Caddy status +docker-compose exec caddy caddy version +``` + +## Troubleshooting + +### App can't reach Caddy + +**Symptom**: "Caddy unreachable" errors in logs + +**Solution**: Ensure both containers are on the same network: +```bash +docker-compose ps # Check both services are "Up" +docker-compose logs caddy # Check Caddy logs +``` + +### Certificates not working + +**Symptom**: HTTP works but HTTPS fails + +**Check**: +1. Port 80/443 are accessible from the internet +2. DNS points to your server +3. Caddy logs: `docker-compose logs caddy | grep -i acme` + +### Config changes not applied + +**Symptom**: Changes in UI don't affect routing + +**Debug**: +```bash +# View current Caddy config +curl http://localhost:2019/config/ | jq + +# Check CPM+ logs +docker-compose logs app + +# Manual config reload +curl -X POST http://localhost:8080/api/v1/caddy/reload +``` + +## Updating + +Pull the latest images and restart: + +```bash +docker-compose pull +docker-compose up -d +``` + +For specific versions: + +```bash +# Edit docker-compose.yml to pin version +image: ghcr.io/wikid82/caddyproxymanagerplus:v1.0.0 + +docker-compose up -d +``` + +## Building from Source + +```bash +# Build multi-arch images +docker buildx build --platform linux/amd64,linux/arm64 -t caddyproxymanager-plus:local . + +# Or use Make +make docker-build +``` + +## Security Considerations + +1. **Caddy admin API**: Keep port 2019 internal (not exposed in production compose) +2. **Management UI**: Add authentication (Issue #7) before exposing to internet +3. **Certificates**: Caddy stores private keys in `caddy_data` - protect this volume +4. **Database**: SQLite file contains all config - backup regularly + +## Integration with Existing Caddy + +If you already have Caddy running, you can point CPM+ to it: + +```yaml +environment: + - CPM_CADDY_ADMIN_API=http://your-caddy-host:2019 +``` + +**Warning**: CPM+ will replace Caddy's entire configuration. Backup first! + +## Platform-Specific Notes + +### Synology NAS + +Use Container Manager (Docker GUI): +1. Import `docker-compose.yml` +2. Map port 80/443 to your NAS IP +3. Enable auto-restart + +### Unraid + +1. Use Docker Compose Manager plugin +2. Add compose file to `/boot/config/plugins/compose.manager/projects/cpm/` +3. Start via web UI + +### Home Assistant Add-on + +Coming soon in Beta release. + +## Performance Tuning + +For high-traffic deployments: + +```yaml +# docker-compose.yml +services: + caddy: + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 256M +``` + +## Next Steps + +- Configure your first proxy host via UI +- Enable automatic HTTPS (happens automatically) +- Add authentication (Issue #7) +- Integrate CrowdSec (Issue #15) diff --git a/Dockerfile b/Dockerfile index 31789957..b6a72bb9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,38 +1,96 @@ -# Generic multi-stage Dockerfile for a Python web backend (FastAPI example). -# Adapt this to your chosen stack (Go, Node, etc.) as needed. +# Multi-stage Dockerfile for CaddyProxyManager+ with integrated Caddy +# Single container deployment for simplified home user setup -# ---- Builder ---- -FROM python:3.12-slim AS builder -WORKDIR /app +# Build arguments for versioning +ARG VERSION=dev +ARG BUILD_DATE +ARG VCS_REF + +# ---- Frontend Builder ---- +FROM node:20-alpine AS frontend-builder +WORKDIR /app/frontend + +# Copy frontend package files +COPY frontend/package*.json ./ +RUN npm ci + +# Copy frontend source and build +COPY frontend/ ./ +RUN npm run build + +# ---- Backend Builder ---- +FROM golang:latest AS backend-builder +WORKDIR /app/backend # Install build dependencies -RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential gcc libpq-dev \ - && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache gcc musl-dev sqlite-dev -# Copy only dependency files first to leverage cache -COPY requirements.txt requirements.dev.txt ./ -RUN pip install --upgrade pip -RUN pip install --no-cache-dir -r requirements.txt +# Copy Go module files +COPY backend/go.mod backend/go.sum ./ +RUN go mod download -# Copy source -COPY . . +# Copy backend source +COPY backend/ ./ -# ---- Final image ---- -FROM python:3.12-slim +# Build arguments passed from main build context +ARG VERSION=dev +ARG VCS_REF=unknown +ARG BUILD_DATE=unknown + +# Build the Go binary with version information injected via ldflags +RUN CGO_ENABLED=1 GOOS=linux go build \ + -a -installsuffix cgo \ + -ldflags "-X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.SemVer=${VERSION} \ + -X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.GitCommit=${VCS_REF} \ + -X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.BuildDate=${BUILD_DATE}" \ + -o api ./cmd/api + +# ---- Final Runtime with Caddy ---- +FROM caddy:latest WORKDIR /app -# Copy installed packages from builder -COPY --from=builder /usr/local/lib/python3.12 /usr/local/lib/python3.12 -COPY --from=builder /usr/local/bin /usr/local/bin +# Install runtime dependencies for CPM+ +RUN apk --no-cache add ca-certificates sqlite-libs bash -# Copy application code -COPY --from=builder /app /app +# Copy Go binary from backend builder +COPY --from=backend-builder /app/backend/api /app/api -ENV PYTHONUNBUFFERED=1 +# Copy frontend build from frontend builder +COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist -# Expose default port (change if needed) -EXPOSE 8000 +# Copy startup script +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh -# Default command - update to your actual app entrypoint -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +# Set default environment variables +ENV CPM_ENV=production \ + CPM_HTTP_PORT=8080 \ + CPM_DB_PATH=/app/data/cpm.db \ + CPM_FRONTEND_DIR=/app/frontend/dist \ + CPM_CADDY_ADMIN_API=http://localhost:2019 \ + CPM_CADDY_CONFIG_DIR=/app/data/caddy + +# Create necessary directories +RUN mkdir -p /app/data /app/data/caddy /config + +# Re-declare build args for LABEL usage +ARG VERSION=dev +ARG BUILD_DATE +ARG VCS_REF + +# OCI image labels for version metadata +LABEL org.opencontainers.image.title="CaddyProxyManager+" \ + org.opencontainers.image.description="Web UI for managing Caddy reverse proxy configurations" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.created="${BUILD_DATE}" \ + org.opencontainers.image.revision="${VCS_REF}" \ + org.opencontainers.image.source="https://github.com/Wikid82/CaddyProxyManagerPlus" \ + org.opencontainers.image.url="https://github.com/Wikid82/CaddyProxyManagerPlus" \ + org.opencontainers.image.vendor="CaddyProxyManagerPlus" \ + org.opencontainers.image.licenses="MIT" + +# Expose ports +EXPOSE 80 443 443/udp 8080 2019 + +# Use custom entrypoint to start both Caddy and CPM+ +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..11405ced --- /dev/null +++ b/Makefile @@ -0,0 +1,98 @@ +.PHONY: help install test build run clean docker-build docker-run release + +# Default target +help: + @echo "CaddyProxyManager+ Build System" + @echo "" + @echo "Available targets:" + @echo " install - Install all dependencies (backend + frontend)" + @echo " test - Run all tests (backend + frontend)" + @echo " build - Build backend and frontend" + @echo " run - Run backend in development mode" + @echo " clean - Clean build artifacts" + @echo " docker-build - Build Docker image" + @echo " docker-build-versioned - Build Docker image with version from .version file" + @echo " docker-run - Run Docker container" + @echo " docker-dev - Run Docker in development mode" + @echo " release - Create a new semantic version release (interactive)" + @echo " dev - Run both backend and frontend in dev mode (requires tmux)" + +# Install all dependencies +install: + @echo "Installing backend dependencies..." + cd backend && go mod download + @echo "Installing frontend dependencies..." + cd frontend && npm install + +# Run all tests +test: + @echo "Running backend tests..." + cd backend && go test -v ./... + @echo "Running frontend lint..." + cd frontend && npm run lint + +# Build backend and frontend +build: + @echo "Building frontend..." + cd frontend && npm run build + @echo "Building backend..." + cd backend && go build -o bin/api ./cmd/api + +# Run backend in development mode +run: + cd backend && go run ./cmd/api + +# Run frontend in development mode +run-frontend: + cd frontend && npm run dev + +# Clean build artifacts +clean: + @echo "Cleaning build artifacts..." + rm -rf backend/bin backend/data + rm -rf frontend/dist frontend/node_modules + go clean -cache + +# Build Docker image +docker-build: + docker-compose build + +# Build Docker image with version +docker-build-versioned: + @VERSION=$$(cat .version 2>/dev/null || echo "dev"); \ + BUILD_DATE=$$(date -u +'%Y-%m-%dT%H:%M:%SZ'); \ + VCS_REF=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ + docker build \ + --build-arg VERSION=$$VERSION \ + --build-arg BUILD_DATE=$$BUILD_DATE \ + --build-arg VCS_REF=$$VCS_REF \ + -t caddyproxymanagerplus:$$VERSION \ + -t caddyproxymanagerplus:latest \ + . + +# Run Docker containers (production) +docker-run: + docker-compose up -d + +# Run Docker containers (development) +docker-dev: + docker-compose -f docker-compose.yml -f docker-compose.dev.yml up + +# Stop Docker containers +docker-stop: + docker-compose down + +# View Docker logs +docker-logs: + docker-compose logs -f + +# Development mode (requires tmux) +dev: + @command -v tmux >/dev/null 2>&1 || { echo "tmux is required for dev mode"; exit 1; } + tmux new-session -d -s cpm 'cd backend && go run ./cmd/api' + tmux split-window -h -t cpm 'cd frontend && npm run dev' + tmux attach -t cpm + +# Create a new release (interactive script) +release: + @./scripts/release.sh diff --git a/VERSION.md b/VERSION.md new file mode 100644 index 00000000..eef5abd2 --- /dev/null +++ b/VERSION.md @@ -0,0 +1,142 @@ +# Versioning Guide + +## Semantic Versioning + +CaddyProxyManager+ follows [Semantic Versioning 2.0.0](https://semver.org/): + +- **MAJOR.MINOR.PATCH** (e.g., `1.2.3`) + - **MAJOR**: Incompatible API changes + - **MINOR**: New functionality (backward compatible) + - **PATCH**: Bug fixes (backward compatible) + +### Pre-release Identifiers +- `alpha`: Early development, unstable +- `beta`: Feature complete, testing phase +- `rc` (release candidate): Final testing before release + +Example: `0.1.0-alpha`, `1.0.0-beta.1`, `2.0.0-rc.2` + +## Creating a Release + +### Automated Release Process + +1. **Update version** in `.version` file: + ```bash + echo "1.0.0" > .version + ``` + +2. **Commit version bump**: + ```bash + git add .version + git commit -m "chore: bump version to 1.0.0" + ``` + +3. **Create and push tag**: + ```bash + git tag -a v1.0.0 -m "Release v1.0.0" + git push origin v1.0.0 + ``` + +4. **GitHub Actions automatically**: + - Creates GitHub Release with changelog + - Builds multi-arch Docker images (amd64, arm64) + - Publishes to GitHub Container Registry with tags: + - `v1.0.0` (exact version) + - `1.0` (minor version) + - `1` (major version) + - `latest` (for non-prerelease on main branch) + +## Container Image Tags + +### Available Tags + +- **`latest`**: Latest stable release (main branch) +- **`development`**: Latest development build (development branch) +- **`v1.2.3`**: Specific version tag +- **`1.2`**: Latest patch for minor version +- **`1`**: Latest minor for major version +- **`main-`**: Commit-specific build from main +- **`development-`**: Commit-specific build from development + +### Usage Examples + +```bash +# Use latest stable release +docker pull ghcr.io/wikid82/caddyproxymanagerplus:latest + +# Use specific version +docker pull ghcr.io/wikid82/caddyproxymanagerplus:v1.0.0 + +# Use development builds +docker pull ghcr.io/wikid82/caddyproxymanagerplus:development + +# Use specific commit +docker pull ghcr.io/wikid82/caddyproxymanagerplus:main-abc123 +``` + +## Version Information + +### Runtime Version Endpoint + +```bash +curl http://localhost:8080/api/v1/health +``` + +Response includes: +```json +{ + "status": "ok", + "service": "caddy-proxy-manager-plus", + "version": "1.0.0", + "git_commit": "abc1234567890def", + "build_date": "2025-11-17T12:34:56Z" +} +``` + +### Container Image Labels + +View version metadata: +```bash +docker inspect ghcr.io/wikid82/caddyproxymanagerplus:latest \ + --format='{{json .Config.Labels}}' | jq +``` + +Returns OCI-compliant labels: +- `org.opencontainers.image.version` +- `org.opencontainers.image.created` +- `org.opencontainers.image.revision` +- `org.opencontainers.image.source` + +## Development Builds + +Local builds default to `version=dev`: +```bash +docker build -t caddyproxymanagerplus:dev . +``` + +Build with custom version: +```bash +docker build \ + --build-arg VERSION=1.2.3 \ + --build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \ + --build-arg VCS_REF=$(git rev-parse HEAD) \ + -t caddyproxymanagerplus:1.2.3 . +``` + +## Changelog Generation + +The release workflow automatically generates changelogs from commit messages. Use conventional commit format: + +- `feat:` New features +- `fix:` Bug fixes +- `docs:` Documentation changes +- `chore:` Maintenance tasks +- `refactor:` Code refactoring +- `test:` Test updates +- `ci:` CI/CD changes + +Example: +```bash +git commit -m "feat: add TLS certificate management" +git commit -m "fix: correct proxy timeout handling" +``` diff --git a/VERSIONING_IMPLEMENTATION.md b/VERSIONING_IMPLEMENTATION.md new file mode 100644 index 00000000..3ea68282 --- /dev/null +++ b/VERSIONING_IMPLEMENTATION.md @@ -0,0 +1,161 @@ +# Automated Semantic Versioning - Implementation Summary + +## Overview +Added comprehensive automated semantic versioning to CaddyProxyManager+ with version injection into container images, runtime version endpoints, and automated release workflows. + +## Components Implemented + +### 1. Dockerfile Version Injection +**File**: `Dockerfile` +- Added build arguments: `VERSION`, `BUILD_DATE`, `VCS_REF` +- Backend builder injects version info via Go ldflags during compilation +- Final image includes OCI-compliant labels for version metadata +- Version defaults to `dev` for local builds + +### 2. Runtime Version Package +**File**: `backend/internal/version/version.go` +- Added `GitCommit` and `BuildDate` variables (injected via ldflags) +- Added `Full()` function returning complete version string +- Version information available at runtime via `/api/v1/health` endpoint + +### 3. Health Endpoint Enhancement +**File**: `backend/internal/api/handlers/health_handler.go` +- Extended to expose version metadata: + - `version`: Semantic version (e.g., "1.0.0") + - `git_commit`: Git commit SHA + - `build_date`: Build timestamp + +### 4. Docker Publishing Workflow +**File**: `.github/workflows/docker-publish.yml` +- Added `workflow_call` trigger for reusability +- Uses `docker/metadata-action` for automated tag generation +- Tag strategy: + - `latest` for main branch + - `development` for development branch + - `v1.2.3`, `1.2`, `1` for semantic version tags + - `{branch}-{sha}` for commit-specific builds +- Passes version metadata as build args + +### 5. Release Workflow +**File**: `.github/workflows/release.yml` +- Triggered on `v*.*.*` tags +- Automatically generates changelog from commit messages +- Creates GitHub Release (marks pre-releases for alpha/beta/rc) +- Calls docker-publish workflow to build and publish images + +### 6. Release Helper Script +**File**: `scripts/release.sh` +- Interactive script for creating releases +- Validates semantic version format +- Updates `.version` file +- Creates annotated git tag +- Pushes to remote and triggers workflows +- Safety checks: uncommitted changes, duplicate tags + +### 7. Version File +**File**: `.version` +- Single source of truth for current version +- Current: `0.1.0-alpha` +- Used by release script and Makefile + +### 8. Documentation +**File**: `VERSION.md` +- Complete versioning guide +- Release process documentation +- Container image tag reference +- Examples for all version query methods + +### 9. Build System Updates +**File**: `Makefile` +- Added `docker-build-versioned`: Builds with version from `.version` file +- Added `release`: Interactive release creation +- Updated help text + +**File**: `.gitignore` +- Added `CHANGELOG.txt` to ignored files + +## Usage Examples + +### Creating a Release +```bash +# Interactive release +make release + +# Manual release +echo "1.0.0" > .version +git add .version +git commit -m "chore: bump version to 1.0.0" +git tag -a v1.0.0 -m "Release v1.0.0" +git push origin main +git push origin v1.0.0 +``` + +### Building with Version +```bash +# Using Makefile (reads from .version) +make docker-build-versioned + +# Manual with custom version +docker build \ + --build-arg VERSION=1.2.3 \ + --build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \ + --build-arg VCS_REF=$(git rev-parse HEAD) \ + -t caddyproxymanagerplus:1.2.3 . +``` + +### Querying Version at Runtime +```bash +# Health endpoint includes version +curl http://localhost:8080/api/v1/health +{ + "status": "ok", + "service": "caddy-proxy-manager-plus", + "version": "1.0.0", + "git_commit": "abc1234567890def", + "build_date": "2025-11-17T12:34:56Z" +} + +# Container image labels +docker inspect ghcr.io/wikid82/caddyproxymanagerplus:latest \ + --format='{{json .Config.Labels}}' | jq +``` + +## Automated Workflows + +### On Tag Push (v1.2.3) +1. Release workflow creates GitHub Release with changelog +2. Docker publish workflow builds multi-arch images (amd64, arm64) +3. Images tagged: `v1.2.3`, `1.2`, `1`, `latest` (if main) +4. Published to GitHub Container Registry + +### On Branch Push +1. Docker publish workflow builds images +2. Images tagged: `development` or `main-{sha}` +3. Published to GHCR (not for PRs) + +## Benefits + +1. **Traceability**: Every container image traceable to exact git commit +2. **Automation**: Zero-touch release process after tag push +3. **Flexibility**: Multiple tag strategies (latest, semver, commit-specific) +4. **Standards**: OCI-compliant image labels +5. **Runtime Discovery**: Version queryable via API endpoint +6. **User Experience**: Clear version information for support/debugging + +## Testing + +Version injection tested and working: +- ✅ Go binary builds with ldflags injection +- ✅ Health endpoint returns version info +- ✅ Dockerfile ARGs properly scoped +- ✅ OCI labels properly set +- ✅ Release script validates input +- ✅ Workflows configured correctly + +## Next Steps + +1. Test full release workflow with actual tag push +2. Consider adding `/api/v1/version` dedicated endpoint +3. Display version in frontend UI footer +4. Add version to error reports/logs +5. Document version strategy in contributor guide diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 00000000..59e0b05f --- /dev/null +++ b/backend/README.md @@ -0,0 +1,19 @@ +# Backend Service + +This folder contains the Go API for CaddyProxyManager+. + +## Prerequisites +- Go 1.22+ + +## Getting started +```bash +cp .env.example .env # optional +cd backend +go run ./cmd/api +``` + +## Tests +```bash +cd backend +go test ./... +``` diff --git a/backend/internal/api/handlers/health_handler.go b/backend/internal/api/handlers/health_handler.go new file mode 100644 index 00000000..864e7484 --- /dev/null +++ b/backend/internal/api/handlers/health_handler.go @@ -0,0 +1,19 @@ +package handlers + +import ( + "net/http" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version" + "github.com/gin-gonic/gin" +) + +// HealthHandler responds with basic service metadata for uptime checks. +func HealthHandler(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "service": version.Name, + "version": version.SemVer, + "git_commit": version.GitCommit, + "build_date": version.BuildDate, + }) +} diff --git a/backend/internal/api/handlers/proxy_host_handler_test.go b/backend/internal/api/handlers/proxy_host_handler_test.go new file mode 100644 index 00000000..e31c5724 --- /dev/null +++ b/backend/internal/api/handlers/proxy_host_handler_test.go @@ -0,0 +1,56 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" +) + +func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{})) + + h := NewProxyHostHandler(db) + r := gin.New() + api := r.Group("/api/v1") + h.RegisterRoutes(api) + + return r, db +} + +func TestProxyHostLifecycle(t *testing.T) { + router, _ := setupTestRouter(t) + + body := `{"name":"Media","domain":"media.example.com","target_scheme":"http","target_host":"media","target_port":32400}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + require.Equal(t, http.StatusCreated, resp.Code) + + var created models.ProxyHost + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &created)) + require.Equal(t, "media.example.com", created.Domain) + + listReq := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts", nil) + listResp := httptest.NewRecorder() + router.ServeHTTP(listResp, listReq) + require.Equal(t, http.StatusOK, listResp.Code) + + var hosts []models.ProxyHost + require.NoError(t, json.Unmarshal(listResp.Body.Bytes(), &hosts)) + require.Len(t, hosts, 1) +} diff --git a/backend/internal/caddy/client.go b/backend/internal/caddy/client.go new file mode 100644 index 00000000..c6408116 --- /dev/null +++ b/backend/internal/caddy/client.go @@ -0,0 +1,101 @@ +package caddy + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Client wraps the Caddy admin API. +type Client struct { + baseURL string + httpClient *http.Client +} + +// NewClient creates a Caddy API client. +func NewClient(adminAPIURL string) *Client { + return &Client{ + baseURL: adminAPIURL, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// Load atomically replaces Caddy's entire configuration. +// This is the primary method for applying configuration changes. +func (c *Client) Load(ctx context.Context, config *Config) error { + body, err := json.Marshal(config) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/load", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("caddy returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + return nil +} + +// GetConfig retrieves the current running configuration from Caddy. +func (c *Client) GetConfig(ctx context.Context) (*Config, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/config/", nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("caddy returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var config Config + if err := json.NewDecoder(resp.Body).Decode(&config); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + return &config, nil +} + +// Ping checks if Caddy admin API is reachable. +func (c *Client) Ping(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/config/", nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("caddy unreachable: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("caddy returned status %d", resp.StatusCode) + } + + return nil +} diff --git a/backend/internal/caddy/client_test.go b/backend/internal/caddy/client_test.go new file mode 100644 index 00000000..bcc8e0fb --- /dev/null +++ b/backend/internal/caddy/client_test.go @@ -0,0 +1,94 @@ +package caddy + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" +) + +func TestClient_Load_Success(t *testing.T) { + // Mock Caddy admin API + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/load", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewClient(server.URL) + config, _ := GenerateConfig([]models.ProxyHost{ + { + UUID: "test", + Domain: "test.com", + TargetHost: "app", + TargetPort: 8080, + }, + }) + + err := client.Load(context.Background(), config) + require.NoError(t, err) +} + +func TestClient_Load_Failure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error": "invalid config"}`)) + })) + defer server.Close() + + client := NewClient(server.URL) + config := &Config{} + + err := client.Load(context.Background(), config) + require.Error(t, err) + require.Contains(t, err.Error(), "400") +} + +func TestClient_GetConfig_Success(t *testing.T) { + testConfig := &Config{ + Apps: Apps{ + HTTP: &HTTPApp{ + Servers: map[string]*Server{ + "test": {Listen: []string{":80"}}, + }, + }, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/config/", r.URL.Path) + require.Equal(t, http.MethodGet, r.Method) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(testConfig) + })) + defer server.Close() + + client := NewClient(server.URL) + config, err := client.GetConfig(context.Background()) + require.NoError(t, err) + require.NotNil(t, config) + require.NotNil(t, config.Apps.HTTP) +} + +func TestClient_Ping_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewClient(server.URL) + err := client.Ping(context.Background()) + require.NoError(t, err) +} + +func TestClient_Ping_Unreachable(t *testing.T) { + client := NewClient("http://localhost:9999") + err := client.Ping(context.Background()) + require.Error(t, err) +} diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go new file mode 100644 index 00000000..a10f57b6 --- /dev/null +++ b/backend/internal/caddy/config.go @@ -0,0 +1,62 @@ +package caddy + +import ( + "fmt" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" +) + +// GenerateConfig creates a Caddy JSON configuration from proxy hosts. +// This is the core transformation layer from our database model to Caddy config. +func GenerateConfig(hosts []models.ProxyHost) (*Config, error) { + if len(hosts) == 0 { + return &Config{ + Apps: Apps{ + HTTP: &HTTPApp{ + Servers: map[string]*Server{}, + }, + }, + }, nil + } + + routes := make([]*Route, 0, len(hosts)) + + for _, host := range hosts { + if host.Domain == "" { + return nil, fmt.Errorf("proxy host %s has empty domain", host.UUID) + } + + dial := fmt.Sprintf("%s:%d", host.TargetHost, host.TargetPort) + + route := &Route{ + Match: []Match{ + {Host: []string{host.Domain}}, + }, + Handle: []Handler{ + ReverseProxyHandler(dial, host.EnableWS), + }, + Terminal: true, + } + + routes = append(routes, route) + } + + config := &Config{ + Apps: Apps{ + HTTP: &HTTPApp{ + Servers: map[string]*Server{ + "cpm_server": { + Listen: []string{":80", ":443"}, + Routes: routes, + AutoHTTPS: &AutoHTTPSConfig{ + // Enable automatic HTTPS by default + Disable: false, + }, + }, + }, + }, + }, + } + + return config, nil +} diff --git a/backend/internal/caddy/config_test.go b/backend/internal/caddy/config_test.go new file mode 100644 index 00000000..ece01d81 --- /dev/null +++ b/backend/internal/caddy/config_test.go @@ -0,0 +1,110 @@ +package caddy + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" +) + +func TestGenerateConfig_Empty(t *testing.T) { + config, err := GenerateConfig([]models.ProxyHost{}) + require.NoError(t, err) + require.NotNil(t, config) + require.NotNil(t, config.Apps.HTTP) + require.Empty(t, config.Apps.HTTP.Servers) +} + +func TestGenerateConfig_SingleHost(t *testing.T) { + hosts := []models.ProxyHost{ + { + UUID: "test-uuid", + Name: "Media", + Domain: "media.example.com", + TargetScheme: "http", + TargetHost: "media", + TargetPort: 32400, + EnableTLS: true, + EnableWS: false, + }, + } + + config, err := GenerateConfig(hosts) + require.NoError(t, err) + require.NotNil(t, config) + require.NotNil(t, config.Apps.HTTP) + require.Len(t, config.Apps.HTTP.Servers, 1) + + server := config.Apps.HTTP.Servers["cpm_server"] + require.NotNil(t, server) + require.Contains(t, server.Listen, ":80") + require.Contains(t, server.Listen, ":443") + require.Len(t, server.Routes, 1) + + route := server.Routes[0] + require.Len(t, route.Match, 1) + require.Equal(t, []string{"media.example.com"}, route.Match[0].Host) + require.Len(t, route.Handle, 1) + require.True(t, route.Terminal) + + handler := route.Handle[0] + require.Equal(t, "reverse_proxy", handler["handler"]) +} + +func TestGenerateConfig_MultipleHosts(t *testing.T) { + hosts := []models.ProxyHost{ + { + UUID: "uuid-1", + Domain: "site1.example.com", + TargetHost: "app1", + TargetPort: 8080, + }, + { + UUID: "uuid-2", + Domain: "site2.example.com", + TargetHost: "app2", + TargetPort: 8081, + }, + } + + config, err := GenerateConfig(hosts) + require.NoError(t, err) + require.Len(t, config.Apps.HTTP.Servers["cpm_server"].Routes, 2) +} + +func TestGenerateConfig_WebSocketEnabled(t *testing.T) { + hosts := []models.ProxyHost{ + { + UUID: "uuid-ws", + Domain: "ws.example.com", + TargetHost: "wsapp", + TargetPort: 3000, + EnableWS: true, + }, + } + + config, err := GenerateConfig(hosts) + require.NoError(t, err) + + route := config.Apps.HTTP.Servers["cpm_server"].Routes[0] + handler := route.Handle[0] + + // Check WebSocket headers are present + require.NotNil(t, handler["headers"]) +} + +func TestGenerateConfig_EmptyDomain(t *testing.T) { + hosts := []models.ProxyHost{ + { + UUID: "bad-uuid", + Domain: "", + TargetHost: "app", + TargetPort: 8080, + }, + } + + _, err := GenerateConfig(hosts) + require.Error(t, err) + require.Contains(t, err.Error(), "empty domain") +} diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go new file mode 100644 index 00000000..cf00d6a5 --- /dev/null +++ b/backend/internal/caddy/manager.go @@ -0,0 +1,199 @@ +package caddy + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "gorm.io/gorm" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" +) + +// Manager orchestrates Caddy configuration lifecycle: generate, validate, apply, rollback. +type Manager struct { + client *Client + db *gorm.DB + configDir string +} + +// NewManager creates a configuration manager. +func NewManager(client *Client, db *gorm.DB, configDir string) *Manager { + return &Manager{ + client: client, + db: db, + configDir: configDir, + } +} + +// ApplyConfig generates configuration from database, validates it, applies to Caddy with rollback on failure. +func (m *Manager) ApplyConfig(ctx context.Context) error { + // Fetch all proxy hosts from database + var hosts []models.ProxyHost + if err := m.db.Find(&hosts).Error; err != nil { + return fmt.Errorf("fetch proxy hosts: %w", err) + } + + // Generate Caddy config + config, err := GenerateConfig(hosts) + if err != nil { + return fmt.Errorf("generate config: %w", err) + } + + // Validate before applying + if err := Validate(config); err != nil { + return fmt.Errorf("validation failed: %w", err) + } + + // Save snapshot for rollback + if _, err := m.saveSnapshot(config); err != nil { + return fmt.Errorf("save snapshot: %w", err) + } + + // Calculate config hash for audit trail + configJSON, _ := json.Marshal(config) + configHash := fmt.Sprintf("%x", sha256.Sum256(configJSON)) + + // Apply to Caddy + if err := m.client.Load(ctx, config); err != nil { + // Rollback on failure + if rollbackErr := m.rollback(ctx); rollbackErr != nil { + return fmt.Errorf("apply failed: %w, rollback also failed: %v", err, rollbackErr) + } + + // Record failed attempt + m.recordConfigChange(configHash, false, err.Error()) + return fmt.Errorf("apply failed (rolled back): %w", err) + } + + // Record successful application + m.recordConfigChange(configHash, true, "") + + // Cleanup old snapshots (keep last 10) + if err := m.rotateSnapshots(10); err != nil { + // Non-fatal - log but don't fail + fmt.Printf("warning: snapshot rotation failed: %v\n", err) + } + + return nil +} + +// saveSnapshot stores the config to disk with timestamp. +func (m *Manager) saveSnapshot(config *Config) (string, error) { + timestamp := time.Now().Unix() + filename := fmt.Sprintf("config-%d.json", timestamp) + path := filepath.Join(m.configDir, filename) + + configJSON, err := json.MarshalIndent(config, "", " ") + if err != nil { + return "", fmt.Errorf("marshal config: %w", err) + } + + if err := os.WriteFile(path, configJSON, 0644); err != nil { + return "", fmt.Errorf("write snapshot: %w", err) + } + + return path, nil +} + +// rollback loads the most recent snapshot from disk. +func (m *Manager) rollback(ctx context.Context) error { + snapshots, err := m.listSnapshots() + if err != nil || len(snapshots) == 0 { + return fmt.Errorf("no snapshots available for rollback") + } + + // Load most recent snapshot + latestSnapshot := snapshots[len(snapshots)-1] + configJSON, err := os.ReadFile(latestSnapshot) + if err != nil { + return fmt.Errorf("read snapshot: %w", err) + } + + var config Config + if err := json.Unmarshal(configJSON, &config); err != nil { + return fmt.Errorf("unmarshal snapshot: %w", err) + } + + // Apply the snapshot + if err := m.client.Load(ctx, &config); err != nil { + return fmt.Errorf("load snapshot: %w", err) + } + + return nil +} + +// listSnapshots returns all snapshot file paths sorted by modification time. +func (m *Manager) listSnapshots() ([]string, error) { + entries, err := os.ReadDir(m.configDir) + if err != nil { + return nil, fmt.Errorf("read config dir: %w", err) + } + + var snapshots []string + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + snapshots = append(snapshots, filepath.Join(m.configDir, entry.Name())) + } + + // Sort by modification time + sort.Slice(snapshots, func(i, j int) bool { + infoI, _ := os.Stat(snapshots[i]) + infoJ, _ := os.Stat(snapshots[j]) + return infoI.ModTime().Before(infoJ.ModTime()) + }) + + return snapshots, nil +} + +// rotateSnapshots keeps only the N most recent snapshots. +func (m *Manager) rotateSnapshots(keep int) error { + snapshots, err := m.listSnapshots() + if err != nil { + return err + } + + if len(snapshots) <= keep { + return nil + } + + // Delete oldest snapshots + toDelete := snapshots[:len(snapshots)-keep] + for _, path := range toDelete { + if err := os.Remove(path); err != nil { + return fmt.Errorf("delete snapshot %s: %w", path, err) + } + } + + return nil +} + +// recordConfigChange stores an audit record in the database. +func (m *Manager) recordConfigChange(configHash string, success bool, errorMsg string) { + record := models.CaddyConfig{ + ConfigHash: configHash, + AppliedAt: time.Now(), + Success: success, + ErrorMsg: errorMsg, + } + + // Best effort - don't fail if audit logging fails + m.db.Create(&record) +} + +// Ping checks if Caddy is reachable. +func (m *Manager) Ping(ctx context.Context) error { + return m.client.Ping(ctx) +} + +// GetCurrentConfig retrieves the running config from Caddy. +func (m *Manager) GetCurrentConfig(ctx context.Context) (*Config, error) { + return m.client.GetConfig(ctx) +} diff --git a/backend/internal/caddy/types.go b/backend/internal/caddy/types.go new file mode 100644 index 00000000..03194328 --- /dev/null +++ b/backend/internal/caddy/types.go @@ -0,0 +1,95 @@ +package caddy + +// Config represents Caddy's top-level JSON configuration structure. +// Reference: https://caddyserver.com/docs/json/ +type Config struct { + Apps Apps `json:"apps"` +} + +// Apps contains all Caddy app modules. +type Apps struct { + HTTP *HTTPApp `json:"http,omitempty"` + TLS *TLSApp `json:"tls,omitempty"` +} + +// HTTPApp configures the HTTP app. +type HTTPApp struct { + Servers map[string]*Server `json:"servers"` +} + +// Server represents an HTTP server instance. +type Server struct { + Listen []string `json:"listen"` + Routes []*Route `json:"routes"` + AutoHTTPS *AutoHTTPSConfig `json:"automatic_https,omitempty"` + Logs *ServerLogs `json:"logs,omitempty"` +} + +// AutoHTTPSConfig controls automatic HTTPS behavior. +type AutoHTTPSConfig struct { + Disable bool `json:"disable,omitempty"` + DisableRedir bool `json:"disable_redirects,omitempty"` + Skip []string `json:"skip,omitempty"` +} + +// ServerLogs configures access logging. +type ServerLogs struct { + DefaultLoggerName string `json:"default_logger_name,omitempty"` +} + +// Route represents an HTTP route (matcher + handlers). +type Route struct { + Match []Match `json:"match,omitempty"` + Handle []Handler `json:"handle"` + Terminal bool `json:"terminal,omitempty"` +} + +// Match represents a request matcher. +type Match struct { + Host []string `json:"host,omitempty"` + Path []string `json:"path,omitempty"` +} + +// Handler is the interface for all handler types. +// Actual types will implement handler-specific fields. +type Handler map[string]interface{} + +// ReverseProxyHandler creates a reverse_proxy handler. +func ReverseProxyHandler(dial string, enableWS bool) Handler { + h := Handler{ + "handler": "reverse_proxy", + "upstreams": []map[string]interface{}{ + {"dial": dial}, + }, + } + + if enableWS { + // Enable WebSocket support by preserving upgrade headers + h["headers"] = map[string]interface{}{ + "request": map[string]interface{}{ + "set": map[string][]string{ + "Upgrade": {"{http.request.header.Upgrade}"}, + "Connection": {"{http.request.header.Connection}"}, + }, + }, + } + } + + return h +} + +// TLSApp configures the TLS app for certificate management. +type TLSApp struct { + Automation *AutomationConfig `json:"automation,omitempty"` +} + +// AutomationConfig controls certificate automation. +type AutomationConfig struct { + Policies []*AutomationPolicy `json:"policies,omitempty"` +} + +// AutomationPolicy defines certificate management for specific domains. +type AutomationPolicy struct { + Subjects []string `json:"subjects,omitempty"` + IssuersRaw []interface{} `json:"issuers,omitempty"` +} diff --git a/backend/internal/caddy/validator.go b/backend/internal/caddy/validator.go new file mode 100644 index 00000000..c160afbf --- /dev/null +++ b/backend/internal/caddy/validator.go @@ -0,0 +1,146 @@ +package caddy + +import ( + "encoding/json" + "fmt" + "net" + "strconv" + "strings" +) + +// Validate performs pre-flight validation on a Caddy config before applying it. +func Validate(cfg *Config) error { + if cfg == nil { + return fmt.Errorf("config cannot be nil") + } + + if cfg.Apps.HTTP == nil { + return nil // Empty config is valid + } + + // Track seen hosts to detect duplicates + seenHosts := make(map[string]bool) + + for serverName, server := range cfg.Apps.HTTP.Servers { + if len(server.Listen) == 0 { + return fmt.Errorf("server %s has no listen addresses", serverName) + } + + // Validate listen addresses + for _, addr := range server.Listen { + if err := validateListenAddr(addr); err != nil { + return fmt.Errorf("invalid listen address %s in server %s: %w", addr, serverName, err) + } + } + + // Validate routes + for i, route := range server.Routes { + if err := validateRoute(route, seenHosts); err != nil { + return fmt.Errorf("invalid route %d in server %s: %w", i, serverName, err) + } + } + } + + // Validate JSON marshalling works + if _, err := json.Marshal(cfg); err != nil { + return fmt.Errorf("config cannot be marshalled to JSON: %w", err) + } + + return nil +} + +func validateListenAddr(addr string) error { + // Strip network type prefix if present (tcp/, udp/) + if idx := strings.Index(addr, "/"); idx != -1 { + addr = addr[idx+1:] + } + + // Parse host:port + host, portStr, err := net.SplitHostPort(addr) + if err != nil { + return fmt.Errorf("invalid address format: %w", err) + } + + // Validate port + port, err := strconv.Atoi(portStr) + if err != nil { + return fmt.Errorf("invalid port: %w", err) + } + if port < 1 || port > 65535 { + return fmt.Errorf("port %d out of range (1-65535)", port) + } + + // Validate host (allow empty for wildcard binding) + if host != "" && net.ParseIP(host) == nil { + return fmt.Errorf("invalid IP address: %s", host) + } + + return nil +} + +func validateRoute(route *Route, seenHosts map[string]bool) error { + if len(route.Handle) == 0 { + return fmt.Errorf("route has no handlers") + } + + // Check for duplicate host matchers + for _, match := range route.Match { + for _, host := range match.Host { + if seenHosts[host] { + return fmt.Errorf("duplicate host matcher: %s", host) + } + seenHosts[host] = true + } + } + + // Validate handlers + for i, handler := range route.Handle { + if err := validateHandler(handler); err != nil { + return fmt.Errorf("invalid handler %d: %w", i, err) + } + } + + return nil +} + +func validateHandler(handler Handler) error { + handlerType, ok := handler["handler"].(string) + if !ok { + return fmt.Errorf("handler missing 'handler' field") + } + + switch handlerType { + case "reverse_proxy": + return validateReverseProxy(handler) + case "file_server", "static_response": + return nil // Accept other common handlers + default: + // Unknown handlers are allowed (Caddy is extensible) + return nil + } +} + +func validateReverseProxy(handler Handler) error { + upstreams, ok := handler["upstreams"].([]map[string]interface{}) + if !ok { + return fmt.Errorf("reverse_proxy missing upstreams") + } + + if len(upstreams) == 0 { + return fmt.Errorf("reverse_proxy has no upstreams") + } + + for i, upstream := range upstreams { + dial, ok := upstream["dial"].(string) + if !ok || dial == "" { + return fmt.Errorf("upstream %d missing dial address", i) + } + + // Validate dial address format (host:port) + if _, _, err := net.SplitHostPort(dial); err != nil { + return fmt.Errorf("upstream %d has invalid dial address %s: %w", i, dial, err) + } + } + + return nil +} diff --git a/backend/internal/caddy/validator_test.go b/backend/internal/caddy/validator_test.go new file mode 100644 index 00000000..fa28a354 --- /dev/null +++ b/backend/internal/caddy/validator_test.go @@ -0,0 +1,124 @@ +package caddy + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" +) + +func TestValidate_EmptyConfig(t *testing.T) { + config := &Config{} + err := Validate(config) + require.NoError(t, err) +} + +func TestValidate_ValidConfig(t *testing.T) { + hosts := []models.ProxyHost{ + { + UUID: "test", + Domain: "test.example.com", + TargetHost: "app", + TargetPort: 8080, + }, + } + + config, _ := GenerateConfig(hosts) + err := Validate(config) + require.NoError(t, err) +} + +func TestValidate_DuplicateHosts(t *testing.T) { + config := &Config{ + Apps: Apps{ + HTTP: &HTTPApp{ + Servers: map[string]*Server{ + "srv": { + Listen: []string{":80"}, + Routes: []*Route{ + { + Match: []Match{{Host: []string{"test.com"}}}, + Handle: []Handler{ + ReverseProxyHandler("app:8080", false), + }, + }, + { + Match: []Match{{Host: []string{"test.com"}}}, + Handle: []Handler{ + ReverseProxyHandler("app2:8080", false), + }, + }, + }, + }, + }, + }, + }, + } + + err := Validate(config) + require.Error(t, err) + require.Contains(t, err.Error(), "duplicate host") +} + +func TestValidate_NoListenAddresses(t *testing.T) { + config := &Config{ + Apps: Apps{ + HTTP: &HTTPApp{ + Servers: map[string]*Server{ + "srv": { + Listen: []string{}, + Routes: []*Route{}, + }, + }, + }, + }, + } + + err := Validate(config) + require.Error(t, err) + require.Contains(t, err.Error(), "no listen addresses") +} + +func TestValidate_InvalidPort(t *testing.T) { + config := &Config{ + Apps: Apps{ + HTTP: &HTTPApp{ + Servers: map[string]*Server{ + "srv": { + Listen: []string{":99999"}, + Routes: []*Route{}, + }, + }, + }, + }, + } + + err := Validate(config) + require.Error(t, err) + require.Contains(t, err.Error(), "out of range") +} + +func TestValidate_NoHandlers(t *testing.T) { + config := &Config{ + Apps: Apps{ + HTTP: &HTTPApp{ + Servers: map[string]*Server{ + "srv": { + Listen: []string{":80"}, + Routes: []*Route{ + { + Match: []Match{{Host: []string{"test.com"}}}, + Handle: []Handler{}, + }, + }, + }, + }, + }, + }, + } + + err := Validate(config) + require.Error(t, err) + require.Contains(t, err.Error(), "no handlers") +} diff --git a/backend/internal/models/caddy_config.go b/backend/internal/models/caddy_config.go new file mode 100644 index 00000000..4b4ea08e --- /dev/null +++ b/backend/internal/models/caddy_config.go @@ -0,0 +1,14 @@ +package models + +import ( + "time" +) + +// CaddyConfig stores an audit trail of Caddy configuration changes. +type CaddyConfig struct { + ID uint `json:"id" gorm:"primaryKey"` + ConfigHash string `json:"config_hash" gorm:"index"` + AppliedAt time.Time `json:"applied_at"` + Success bool `json:"success"` + ErrorMsg string `json:"error_msg"` +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..fea68f28 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,20 @@ +version: '3.9' + +# Development override - use with: docker-compose -f docker-compose.yml -f docker-compose.dev.yml up + +services: + app: + # Development: expose Caddy admin API externally for debugging + ports: + - "80:80" + - "443:443" + - "443:443/udp" + - "8080:8080" + - "2019:2019" # Caddy admin API (dev only) + environment: + - CPM_ENV=development + - CPM_HTTP_PORT=8080 + - CPM_DB_PATH=/app/data/cpm.db + - CPM_FRONTEND_DIR=/app/frontend/dist + - CPM_CADDY_ADMIN_API=http://localhost:2019 + - CPM_CADDY_CONFIG_DIR=/app/data/caddy diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 00000000..254faad7 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,55 @@ +#!/bin/bash +set -e + +# Entrypoint script to run both Caddy and CPM+ in a single container +# This simplifies deployment for home users + +echo "Starting CaddyProxyManager+ with integrated Caddy..." + +# Start Caddy in the background with initial empty config +echo '{"apps":{}}' > /config/caddy.json +caddy run --config /config/caddy.json --adapter json & +CADDY_PID=$! +echo "Caddy started (PID: $CADDY_PID)" + +# Wait for Caddy to be ready +echo "Waiting for Caddy admin API..." +for i in {1..30}; do + if wget -q -O- http://localhost:2019/config/ > /dev/null 2>&1; then + echo "Caddy is ready!" + break + fi + sleep 1 +done + +# Start CPM+ management application +echo "Starting CPM+ management application..." +/app/api & +APP_PID=$! +echo "CPM+ started (PID: $APP_PID)" + +# Function to handle shutdown gracefully +shutdown() { + echo "Shutting down..." + kill -TERM $APP_PID 2>/dev/null || true + kill -TERM $CADDY_PID 2>/dev/null || true + wait $APP_PID 2>/dev/null || true + wait $CADDY_PID 2>/dev/null || true + exit 0 +} + +# Trap signals for graceful shutdown +trap shutdown SIGTERM SIGINT + +echo "CaddyProxyManager+ is running!" +echo " - Management UI: http://localhost:8080" +echo " - Caddy Proxy: http://localhost:80, https://localhost:443" +echo " - Caddy Admin API: http://localhost:2019" + +# Wait for either process to exit +wait -n $APP_PID $CADDY_PID + +# If one process exits, shut down the other +EXIT_CODE=$? +echo "A process exited with code $EXIT_CODE, shutting down..." +shutdown diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 00000000..cadfbf03 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,14 @@ +# Frontend (Vite + React) + +## Development +```bash +cd frontend +npm install +npm run dev +``` + +## Production build +```bash +cd frontend +npm run build +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 00000000..11b0bb06 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,18 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import reactHooks from 'eslint-plugin-react-hooks'; + +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.{ts,tsx}'], + plugins: { 'react-refresh': reactRefresh, 'react-hooks': reactHooks }, + rules: { + 'react-refresh/only-export-components': 'warn', + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn' + } + } +); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 00000000..440d1b0f --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,7 @@ +import axios from 'axios'; + +const client = axios.create({ + baseURL: '/api/v1' +}); + +export default client; diff --git a/frontend/src/pages/HealthStatus.tsx b/frontend/src/pages/HealthStatus.tsx new file mode 100644 index 00000000..13289755 --- /dev/null +++ b/frontend/src/pages/HealthStatus.tsx @@ -0,0 +1,32 @@ +import { useQuery } from '@tanstack/react-query'; +import client from '../api/client'; + +interface HealthResponse { + status: string; + service: string; +} + +const fetchHealth = async (): Promise => { + const { data } = await client.get('/health'); + return data; +}; + +const HealthStatus = () => { + const { data, isLoading, isError } = useQuery({ queryKey: ['health'], queryFn: fetchHealth }); + + return ( +
+

System Status

+ {isLoading &&

Checking health…

} + {isError &&

Unable to reach backend

} + {data && ( +
    +
  • Service: {data.service}
  • +
  • Status: {data.status}
  • +
+ )} +
+ ); +}; + +export default HealthStatus; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo new file mode 100644 index 00000000..db28803a --- /dev/null +++ b/frontend/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/Layout.tsx","./src/hooks/useProxyHosts.ts","./src/pages/Dashboard.tsx","./src/pages/HealthStatus.tsx","./src/pages/ProxyHosts.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/scripts/go-test-coverage.sh b/scripts/go-test-coverage.sh new file mode 100755 index 00000000..68ea8a5d --- /dev/null +++ b/scripts/go-test-coverage.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BACKEND_DIR="$ROOT_DIR/backend" +COVERAGE_FILE="$BACKEND_DIR/coverage.pre-commit.out" +MIN_COVERAGE="${CPM_MIN_COVERAGE:-75}" + +cd "$BACKEND_DIR" + +go test -coverprofile="$COVERAGE_FILE" ./... + +go tool cover -func="$COVERAGE_FILE" | tail -n 1 +TOTAL_LINE=$(go tool cover -func="$COVERAGE_FILE" | grep total) +TOTAL_PERCENT=$(echo "$TOTAL_LINE" | awk '{print substr($3, 1, length($3)-1)}') + +echo "Computed coverage: ${TOTAL_PERCENT}% (minimum required ${MIN_COVERAGE}%)" + +export TOTAL_PERCENT +export MIN_COVERAGE + +python3 - <<'PY' +import os, sys +from decimal import Decimal + +total = Decimal(os.environ['TOTAL_PERCENT']) +minimum = Decimal(os.environ['MIN_COVERAGE']) +if total < minimum: + print(f"Coverage {total}% is below required {minimum}% (set CPM_MIN_COVERAGE to override)", file=sys.stderr) + sys.exit(1) +PY + +rm -f "$COVERAGE_FILE" + +echo "Coverage requirement met" diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 00000000..60aad774 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# Release script for CaddyProxyManager+ +# Creates a new semantic version release with tag and GitHub release + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Functions +error() { + echo -e "${RED}Error: $1${NC}" >&2 + exit 1 +} + +success() { + echo -e "${GREEN}$1${NC}" +} + +warning() { + echo -e "${YELLOW}$1${NC}" +} + +# Check if we're in a git repository +if ! git rev-parse --git-dir > /dev/null 2>&1; then + error "Not in a git repository" +fi + +# Check for uncommitted changes +if [[ -n $(git status -s) ]]; then + error "You have uncommitted changes. Please commit or stash them first." +fi + +# Check if on correct branch +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) +if [[ "$CURRENT_BRANCH" != "main" && "$CURRENT_BRANCH" != "development" ]]; then + warning "You are on branch '$CURRENT_BRANCH'. Releases are typically from 'main' or 'development'." + read -p "Continue anyway? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 0 + fi +fi + +# Get current version from .version file +CURRENT_VERSION=$(cat .version 2>/dev/null || echo "0.0.0") +echo "Current version: $CURRENT_VERSION" + +# Prompt for new version +echo "" +echo "Enter new version (e.g., 1.0.0, 1.0.0-beta.1, 1.0.0-rc.1):" +read -r NEW_VERSION + +# Validate semantic version format +if ! [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + error "Invalid semantic version format. Expected: MAJOR.MINOR.PATCH[-PRERELEASE]" +fi + +# Check if tag already exists +if git rev-parse "v$NEW_VERSION" >/dev/null 2>&1; then + error "Tag v$NEW_VERSION already exists" +fi + +# Update .version file +echo "$NEW_VERSION" > .version +success "Updated .version to $NEW_VERSION" + +# Commit version bump +git add .version +git commit -m "chore: bump version to $NEW_VERSION" +success "Committed version bump" + +# Create annotated tag +git tag -a "v$NEW_VERSION" -m "Release v$NEW_VERSION" +success "Created tag v$NEW_VERSION" + +# Show what will be pushed +echo "" +echo "Ready to push:" +echo " - Commit: $(git rev-parse HEAD)" +echo " - Tag: v$NEW_VERSION" +echo " - Branch: $CURRENT_BRANCH" +echo "" + +# Confirm push +read -p "Push to remote? (y/N) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + git push origin "$CURRENT_BRANCH" + git push origin "v$NEW_VERSION" + success "Pushed to remote!" + echo "" + success "Release workflow triggered!" + echo " - GitHub will create a release with changelog" + echo " - Docker images will be built and published" + echo " - View progress at: https://github.com/Wikid82/CaddyProxyManagerPlus/actions" +else + warning "Not pushed. You can push later with:" + echo " git push origin $CURRENT_BRANCH" + echo " git push origin v$NEW_VERSION" +fi