commit 1cc94f2c9966965b2dce7985c512e363a78fb703 Author: Justin Harms Date: Sat Jun 6 07:54:44 2026 -0500 initial commit diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..92a7557 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,69 @@ +name: CI + +on: + push: + branches: + - "**" + pull_request: + +jobs: + test: + name: Go Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Verify Go modules + run: go mod verify + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: ui/package-lock.json + + - name: Install UI deps + run: npm --prefix ./ui ci + + - name: Build UI assets + run: npm --prefix ./ui run build + + - name: Run tests + run: make test + + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Verify Go modules + run: go mod verify + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: ui/package-lock.json + + - name: Install UI deps + run: npm --prefix ./ui ci + + - name: Build + run: make build diff --git a/.gitea/workflows/fmt.yaml b/.gitea/workflows/fmt.yaml new file mode 100644 index 0000000..7681f2b --- /dev/null +++ b/.gitea/workflows/fmt.yaml @@ -0,0 +1,31 @@ +name: Format + +on: + push: + branches: + - "**" + pull_request: + +jobs: + gofmt: + name: gofmt + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Verify formatting + shell: bash + run: | + files="$(gofmt -l .)" + if [[ -n "${files}" ]]; then + echo "These files need formatting:" + echo "${files}" + exit 1 + fi diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..5fb0d3e --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,199 @@ +name: Release Artifacts + +on: + push: + tags: + - "*" + workflow_dispatch: + +jobs: + validate-tag: + name: Validate release tag + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.meta.outputs.tag }} + prerelease: ${{ steps.meta.outputs.prerelease }} + steps: + - name: Validate tag and determine prerelease flag + id: meta + shell: bash + run: | + set -eu + tag="${{ gitea.ref_name }}" + echo "tag=${tag}" >> "${GITHUB_OUTPUT}" + + # Allowed tags: + # - Stable: 1.2.3 + # - Prerelease: 1.2.3-alpha1 | 1.2.3-beta1 | 1.2.3-rc1 + if [[ "${tag}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "prerelease=false" >> "${GITHUB_OUTPUT}" + elif [[ "${tag}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)[0-9]+$ ]]; then + echo "prerelease=true" >> "${GITHUB_OUTPUT}" + else + echo "Invalid tag format: ${tag}" + echo "Expected one of:" + echo " - 1.2.3" + echo " - 1.2.3-alpha1" + echo " - 1.2.3-beta1" + echo " - 1.2.3-rc1" + exit 1 + fi + + build-and-release-executables: + name: Build and release executables + needs: validate-tag + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Verify Go modules + run: go mod verify + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: ui/package-lock.json + + - name: Install UI deps + run: npm --prefix ./ui ci + + - name: Build UI assets + run: npm --prefix ./ui run build + + - name: Run tests + run: make test + + - name: Build release binaries + shell: bash + run: | + set -eu + tag="${{ needs.validate-tag.outputs.tag }}" + commit="$(git rev-parse --short HEAD)" + mkdir -p dist + + for arch in amd64 arm64; do + out="dist/helix-proxy-linux-${arch}" + + CGO_ENABLED=0 GOOS=linux GOARCH="${arch}" \ + go build -trimpath \ + -ldflags "-s -w -X helix-proxy/internal/version.Version=${tag} -X helix-proxy/internal/version.Commit=${commit}" \ + -o "${out}" ./cmd/helix-proxy + + file "${out}" | grep -q "statically linked" + done + + - name: Package release archives + shell: bash + run: | + set -eu + rm -rf release-packages + mkdir -p release-packages + + for bin in dist/helix-proxy-linux-*; do + name="$(basename "${bin}")" + archive="helix-proxy-${{ needs.validate-tag.outputs.tag }}-${name#helix-proxy-}.tar.gz" + stage="release-packages/${name}" + + rm -rf "${stage}" + mkdir -p "${stage}" + cp README.md "${stage}/" + cp "${bin}" "${stage}/helix-proxy" + tar -C "${stage}" -czf "${archive}" . + done + + - name: Generate release notes + shell: bash + run: | + set -eu + current_tag="${{ needs.validate-tag.outputs.tag }}" + prev_tag="$(git tag --sort=-creatordate | awk -v cur="${current_tag}" '$0 != cur { print; exit }')" + + { + echo "## Release ${current_tag}" + echo + if [ -n "${prev_tag}" ]; then + echo "Changes since \`${prev_tag}\`:" + echo + git log --pretty='- %h %s' "${prev_tag}..${current_tag}" + else + echo "Changes in this release:" + echo + git log --pretty='- %h %s' "${current_tag}" + fi + } > RELEASE_NOTES.md + + - name: Create release and upload archives + uses: https://gitea.com/actions/gitea-release-action@v1 + with: + tag_name: ${{ needs.validate-tag.outputs.tag }} + name: ${{ needs.validate-tag.outputs.tag }} + prerelease: ${{ needs.validate-tag.outputs.prerelease }} + body_path: RELEASE_NOTES.md + files: |- + helix-proxy-${{ needs.validate-tag.outputs.tag }}-linux-*.tar.gz + + build-and-release-docker-image: + name: Build and release Docker image + needs: [validate-tag, build-and-release-executables] + runs-on: ubuntu-latest + permissions: + code: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve registry and image names + id: image + shell: bash + run: | + set -eu + host="${{ gitea.server_url }}" + host="${host#http://}" + host="${host#https://}" + host="${host%%/*}" + repo="$(printf '%s' "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')" + image="${host}/${repo}" + echo "registry_host=${host}" >> "${GITHUB_OUTPUT}" + echo "repository_lower=${repo}" >> "${GITHUB_OUTPUT}" + echo "image=${image}" >> "${GITHUB_OUTPUT}" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.GIT_REGISTRY || steps.image.outputs.registry_host }} + username: ${{ secrets.GIT_USER || gitea.actor }} + password: ${{ secrets.GIT_PACKAGES_TOKEN || secrets.GITEA_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.GIT_REGISTRY || steps.image.outputs.registry_host }}/${{ steps.image.outputs.repository_lower }} + tags: | + type=raw,value=${{ needs.validate-tag.outputs.tag }} + type=raw,value=latest,enable=${{ needs.validate-tag.outputs.prerelease == 'false' }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03b43da --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Binaries +helix-proxy +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Explicitly include the cmd/helix-proxy folder +!cmd/helix-proxy + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out + +# Dependency directories +vendor/ + +# Go workspace file +go.work +go.work.sum + +# Env files +.env +.env.* + +# Build / dist +dist/ +ui/dist/ +ui/node_modules/ +node_modules/ + +# Data (user runtime, volumes) +data/ +logs/ +*.sqlite +*.sqlite-shm +*.sqlite-wal + +# Keys / secrets +keys.json +*.pem +*.key + +# IDE / editor +.idea/ +.vscode/ +.playwright-mcp/ +*.swp +*~ + +# Screenshots +*.png + +# OS +.DS_Store +Thumbs.db + +# Misc +tmp/ +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..30b4119 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# Multi-stage for true single binary with embedded Svelte UI +# 1. Build Svelte UI (if ui/ present) +FROM node:20-alpine AS ui-builder +WORKDIR /src +# Copy the entire context (cheap for this project size at this stage) +COPY . . +# Always ensure a dist exists +RUN mkdir -p ui/dist && printf 'Helix Proxy

UI placeholder (run make ui-build for the real Svelte SPA)

' > ui/dist/index.html +# If a real ui/ with package.json was copied, build it (overwrites the placeholder) +RUN if [ -f ui/package.json ]; then \ + cd ui && npm ci && npm run build; \ + fi +# Put the final dist where the Go embed step (ui/dist from module root) will find it +RUN mkdir -p /out-ui/dist && cp -r ui/dist/* /out-ui/dist/ 2>/dev/null || cp -r ui/dist /out-ui/ 2>/dev/null || true + +# 2. Build Go binary (embeds the dist produced above or the placeholder) +FROM golang:1.25-alpine AS go-builder +WORKDIR /src +RUN apk add --no-cache git ca-certificates +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +# Copy the UI dist produced by previous stage into the location the embed expects +COPY --from=ui-builder /out-ui/dist ./ui/dist +RUN TAG="$$(git describe --tags --exact-match HEAD 2>/dev/null || true)" && \ + COMMIT="$$(git rev-parse --short HEAD 2>/dev/null || true)" && \ + CGO_ENABLED=0 go build -ldflags="-s -w -X helix-proxy/internal/version.Version=$$TAG -X helix-proxy/internal/version.Commit=$$COMMIT" -o /out/helix-proxy ./cmd/helix-proxy + +# Final: minimal image, single binary +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata wget && \ + adduser -D -u 1000 -g 1000 appuser +WORKDIR /app +COPY --from=go-builder /out/helix-proxy /app/helix-proxy +# The binary contains the UI via embed. It will create ./data (and data/www) relative to CWD on first run. +USER appuser +EXPOSE 80 81 443 +ENTRYPOINT ["/app/helix-proxy"] +# In practice run with: +# docker run -p 80:80 -p 81:81 -p 443:443 -v $PWD/data:/app/data --workdir /app helix-proxy:dev +# (or set DATA_DIR etc. to move things) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..32d96dc --- /dev/null +++ b/Makefile @@ -0,0 +1,59 @@ +.PHONY: build ui ui-build run docker docker-build test clean help + +APP_NAME := helix-proxy +GO := go +UI_DIR := ui +DIST_DIR := $(UI_DIR)/dist +DATA_DIR := data + +# Inject git tag and commit separately; version package picks tag → commit → dev. +GIT_TAG := $(shell git describe --tags --exact-match HEAD 2>/dev/null) +GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null) +LDFLAGS := -X helix-proxy/internal/version.Version=$(GIT_TAG) -X helix-proxy/internal/version.Commit=$(GIT_COMMIT) + +# Default target +help: + @echo "Helix Proxy Makefile" + @echo "" + @echo " build Build the Go binary (embeds UI if dist/ present)" + @echo " ui Install UI deps (npm ci in ui/)" + @echo " ui-build Build Svelte SPA to $(DIST_DIR)" + @echo " run Build + run (uses current dir as cwd)" + @echo " docker Build docker image (helix-proxy:dev)" + @echo " docker-build Build docker image" + @echo " test Run go tests" + @echo " clean Remove build artifacts and ui/dist" + @echo "" + +build: ui-build + @echo "Building $(APP_NAME) (tag=$(if $(GIT_TAG),$(GIT_TAG),none), commit=$(if $(GIT_COMMIT),$(GIT_COMMIT),none))..." + $(GO) build -ldflags "$(LDFLAGS)" -o $(APP_NAME) ./cmd/helix-proxy + +ui: + @echo "Installing UI dependencies..." + cd $(UI_DIR) && npm ci + +ui-build: clean + @if [ -d "$(UI_DIR)" ]; then \ + echo "Building Svelte UI..."; \ + cd $(UI_DIR) && npm run build; \ + else \ + echo "No $(UI_DIR)/ dir, skipping UI build (will use empty embed or stub)"; \ + fi + +run: build + @echo "Running $(APP_NAME) from cwd (data/ will be created relative)..." + PROXY_MODE=development ./$(APP_NAME) + +docker docker-build: + @echo "Building docker image helix-proxy:dev..." + docker build -t helix-proxy:dev . + +test: + $(GO) test ./... -count=1 -race -coverprofile=coverage.out + +clean: + rm -f $(APP_NAME) + rm -rf $(DIST_DIR) + rm -rf $(DATA_DIR) + @echo "Cleaned binaries and UI dist." diff --git a/README.md b/README.md new file mode 100644 index 0000000..770d1d3 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# Helix Proxy + +A pure-Go reverse proxy with embedded web UI. Supports proxy hosts, TCP/UDP streams, redirections, dead hosts, certificates (Let's Encrypt + custom), access lists, audit, and more. Single static binary. Single built-in admin (no multi-user registration; defaults to "password", first login forces a change which is then bcrypt-hashed and stored in the DB). + +**Key features / differences from traditional setups**: +- **Entirely replaces nginx**: pure Go reverse proxy (http) + TCP/UDP stream proxy engine. No nginx binary, no config files on disk for routing, live updates. +- **Single binary**: the modern Svelte SPA UI is built and **embedded** (`//go:embed`) into the Go executable. +- **Storage**: default is a single `data/db.bolt` (cwd-relative, bbolt embedded). +- **No magic paths**: everything defaults to paths relative to the process CWD (`data/db.bolt`, `data/www/`, `data/certs/`, ...). All overridable with env vars (`DATA_DIR`, `WWW_DIR`, etc.). +- **www/html lives in data/** by default (single volume tree). + +## Quick start (binary) +```bash +make # builds UI (placeholder) + Go binary with embed +./helix-proxy +# Admin UI + API on :81 +# Proxy on :8080 (or 80/443 when you have perms / run in docker) +``` + +Visit http://localhost:81 + +Data (db, certs, logs, www html) lives in `./data` relative to where you ran the binary. + +## Docker (recommended) +```bash +docker compose up -d +# or +docker build -t helix-proxy:dev . +docker run -p 81:81 -v $PWD/data:/app/data --workdir /app helix-proxy:dev +``` + +PUID/PGID + DISABLE_IPV6 example (see docker-compose.yml for full): +```yaml +# user: "0:0" # root to allow chown+drop inside +environment: + - PUID=1000 + - PGID=1000 + - DISABLE_IPV6=1 + # PUID_NO_DROP=1 # for low ports (80/443) + PUID: do chown as root but do not drop (stay root to bind low ports; default with PUID drops to non-root after chown, so use high ports or NET_BIND_SERVICE cap or run without PUID for low ports) +``` +Binary auto-chowns data tree (if started root) then drops privs (unless PUID_NO_DROP); umask support via UMASK env. Files 0600, dirs 0755. +Note: privilege drop happens early (before listeners); low-port binds require either root (with PUID_NO_DROP), capabilities, high ports in config, or external setuid wrapper. + +See docker-compose.yml for full example (exposes 80/81/443, volume for data/). + +## Paths (all overridable) +- `data/db.bolt` (or `DATA_DIR`) +- `data/certs/`, `data/logs/`, `data/letsencrypt-acme-challenge/` +- `data/www/` (default site / custom html; `WWW_DIR` or `HTML_DIR`) +- etc. + +## Status +Core features implemented and verified: +- cwd-relative + env-overridable paths/storage (single `data/db.bolt` primary via bbolt) +- pure-Go engine: proxy hosts (full: locations, advanced_config parser, ssl_forced, block_exploits, websocket, hsts, caching w/ HIT/MISS, access lists, custom certs, LE), streams (tcp/udp +ssl term), redirection hosts (full forward_http_code/preservePath/scheme + CRUD), dead hosts (per-dead custom content + CRUD) +- certificates: custom PEM (meta keys compat) + full Let's Encrypt issuance/renewal via lego (http-01). In PROXY_MODE=development *all* LE certs are self-signed test certs (domain-based sim tricks removed). +- single admin (defaults to "password"; first login forces change; hashed + stored in DB; no registration or multi-user) +- audit (userId=1), settings (default_site + letsencrypt_email etc) +- live reload on all CRUD, dual https/http + SNI, embedded Svelte SPA (full tabs, pickers, edits) +- single binary (go build embeds UI after make ui-build), docker multi-stage + full PUID/PGID/umask +- no nginx, no .conf files, no external processes for proxying + +LE note: for real certs use a public DNS domain pointing at your server (port 80/http reachable). By default (production), real LE http-01 is used. Set PROXY_MODE=development and *all* letsencrypt cert requests will produce self-signed test certs instead (for dev/testing; see TESTING.md). Real LE will be used otherwise (requires valid email, port 80 reachable etc). + +## Development +- `make ui-build` (once real Svelte UI added to ui/) +- `make` +- `make docker` + +Contributions / v2 features: open an issue or just ask to implement the next piece (advanced config, full certs with lego, streams, full Svelte UI, SQL backend, etc.). diff --git a/cmd/helix-proxy/api.go b/cmd/helix-proxy/api.go new file mode 100644 index 0000000..781856b --- /dev/null +++ b/cmd/helix-proxy/api.go @@ -0,0 +1,1279 @@ +package main + +import ( + "encoding/json" + "log/slog" + "net/http" + "strconv" + + "helix-proxy/internal/auth" + "helix-proxy/internal/certificate" + "helix-proxy/internal/config" + "helix-proxy/internal/proxy" + "helix-proxy/internal/store" + "helix-proxy/internal/version" + + "github.com/go-chi/chi/v5" + "golang.org/x/crypto/bcrypt" +) + +type streamAPIResponse struct { + store.Stream + Listening bool `json:"listening"` + ListenError string `json:"listenError,omitempty"` +} + +func streamAPIResponseFrom(st store.Store, eng *proxy.Engine, s store.Stream) streamAPIResponse { + out := streamAPIResponse{Stream: s} + if s.Enabled { + st := eng.StreamStatus(s.IncomingPort) + out.Listening = st.Listening + out.ListenError = st.ListenError + } + return out +} + +func streamsAPIResponseFrom(st store.Store, eng *proxy.Engine) []streamAPIResponse { + streams := st.GetStreams() + out := make([]streamAPIResponse, len(streams)) + for i, s := range streams { + out[i] = streamAPIResponseFrom(st, eng, s) + } + return out +} + +// mountAPI registers all /api routes on r. +func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.Manager, jwtMgr *auth.JWTManager) { + r.Route("/api", func(r chi.Router) { + r.Use(jwtMgr.Middleware) + + // Health (mirrors original /api style) + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + major, minor, revision := version.Parts() + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "OK", + "setup": st.IsSetup(), + "version": map[string]int{"major": major, "minor": minor, "revision": revision}, + }) + }) + r.Get("/proxy-hosts", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + _ = claims + w.Header().Set("Content-Type", "application/json") + hosts := st.GetProxyHosts() + _ = json.NewEncoder(w).Encode(hosts) + }) + + r.Post("/proxy-hosts", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + var payload store.ProxyHost + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, err.Error(), 400) + return + } + if payload.OwnerUserID == 0 { + payload.OwnerUserID = claims.UserID + } + created, err := st.CreateProxyHost(payload) + if err != nil { + writeStoreError(w, err) + return + } + eng.ReloadFromStore() + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(created) + // audit + _, _ = st.AddAuditLog(store.AuditLog{ + UserID: claims.UserID, + ObjectType: "proxy-host", + ObjectID: created.ID, + Action: "created", + Meta: map[string]any{"domain_names": created.DomainNames}, + }) + }) + + r.Get("/access-lists", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + _ = claims + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(st.GetAccessLists()) + }) + r.Post("/access-lists", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + var payload store.AccessList + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, err.Error(), 400) + return + } + if payload.OwnerUserID == 0 { + payload.OwnerUserID = claims.UserID + } + created, err := st.CreateAccessList(payload) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(created) + eng.ReloadFromStore() + _, _ = st.AddAuditLog(store.AuditLog{ + UserID: claims.UserID, + ObjectType: "access-list", + ObjectID: created.ID, + Action: "created", + Meta: map[string]any{"name": created.Name}, + }) + }) + + // full access list lifecycle (update/delete for complete parity) + r.Get("/access-lists/{id}", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + _ = claims // read is authed; no owner gate for GET (consistent with list GETs) + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + if al, ok := st.GetAccessList(id); ok { + _ = json.NewEncoder(w).Encode(al) + return + } + http.Error(w, "not found", 404) + }) + r.Put("/access-lists/{id}", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + existing, ok := st.GetAccessList(id) + if !ok { + http.Error(w, "not found", 404) + return + } + if !canManage(claims, existing.OwnerUserID) { + http.Error(w, "forbidden: not owner or admin", 403) + return + } + var payload store.AccessList + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, err.Error(), 400) + return + } + payload.ID = id + payload.OwnerUserID = existing.OwnerUserID + // preserve CreatedOn on update (server-set on create; do not lose on edit) + payload.CreatedOn = existing.CreatedOn + updated, err := st.UpdateAccessList(payload) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + eng.ReloadFromStore() + _ = json.NewEncoder(w).Encode(updated) + _, _ = st.AddAuditLog(store.AuditLog{ + UserID: claims.UserID, + ObjectType: "access-list", + ObjectID: updated.ID, + Action: "updated", + Meta: map[string]any{"name": updated.Name}, + }) + }) + r.Delete("/access-lists/{id}", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + existing, ok := st.GetAccessList(id) + if !ok { + http.Error(w, "not found", 404) + return + } + if !canManage(claims, existing.OwnerUserID) { + http.Error(w, "forbidden: not owner or admin", 403) + return + } + if err := st.DeleteAccessList(id); err != nil { + http.Error(w, err.Error(), 500) + return + } + eng.ReloadFromStore() + w.WriteHeader(http.StatusNoContent) + _, _ = st.AddAuditLog(store.AuditLog{ + UserID: claims.UserID, + ObjectType: "access-list", + ObjectID: id, + Action: "deleted", + Meta: map[string]any{}, + }) + }) + + r.Get("/streams", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + _ = claims + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(streamsAPIResponseFrom(st, eng)) + }) + r.Post("/streams", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + var payload store.Stream + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, err.Error(), 400) + return + } + if payload.OwnerUserID == 0 { + payload.OwnerUserID = claims.UserID + } + created, err := st.CreateStream(payload) + if err != nil { + writeStoreError(w, err) + return + } + eng.ReloadFromStore() // will handle streams too + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(streamAPIResponseFrom(st, eng, created)) + _, _ = st.AddAuditLog(store.AuditLog{ + UserID: claims.UserID, + ObjectType: "stream", + ObjectID: created.ID, + Action: "created", + Meta: map[string]any{"incoming_port": created.IncomingPort}, + }) + }) + + // full stream lifecycle + r.Put("/streams/{id}", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + existing, ok := st.GetStream(id) + if !ok { + http.Error(w, "not found", 404) + return + } + if !canManage(claims, existing.OwnerUserID) { + http.Error(w, "forbidden: not owner or admin", 403) + return + } + var payload store.Stream + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, err.Error(), 400) + return + } + payload.ID = id + payload.OwnerUserID = existing.OwnerUserID + // preserve CreatedOn on update (server-set on create; do not lose on edit) + payload.CreatedOn = existing.CreatedOn + updated, err := st.UpdateStream(payload) + if err != nil { + writeStoreError(w, err) + return + } + eng.ReloadFromStore() + _ = json.NewEncoder(w).Encode(streamAPIResponseFrom(st, eng, updated)) + _, _ = st.AddAuditLog(store.AuditLog{ + UserID: claims.UserID, + ObjectType: "stream", + ObjectID: updated.ID, + Action: "updated", + Meta: map[string]any{"incoming_port": updated.IncomingPort}, + }) + }) + r.Delete("/streams/{id}", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + existing, ok := st.GetStream(id) + if !ok { + http.Error(w, "not found", 404) + return + } + if !canManage(claims, existing.OwnerUserID) { + http.Error(w, "forbidden: not owner or admin", 403) + return + } + if err := st.DeleteStream(id); err != nil { + http.Error(w, err.Error(), 500) + return + } + eng.ReloadFromStore() + w.WriteHeader(http.StatusNoContent) + _, _ = st.AddAuditLog(store.AuditLog{ + UserID: claims.UserID, + ObjectType: "stream", + ObjectID: id, + Action: "deleted", + Meta: map[string]any{}, + }) + }) + + r.Get("/certificates", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + _ = claims + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(st.GetCertificates()) + }) + r.Post("/certificates", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + var payload store.Certificate + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, err.Error(), 400) + return + } + if payload.OwnerUserID == 0 { + payload.OwnerUserID = claims.UserID + } + created, err := st.CreateCertificate(payload) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + // If LE, perform issuance now. + // This will populate meta["cert"]/meta["key"] + expires_on and write files. + // Challenge tokens will be served automatically by the proxy Handler. + if created.Provider == "letsencrypt" { + email := cm.GetEmailForCert(created) + if ierr := cm.Issue(created, email); ierr != nil { + // mimic original: on failure remove the partial cert record + _ = st.DeleteCertificate(created.ID) + http.Error(w, "letsencrypt issuance failed: "+ierr.Error(), 500) + return + } + if c2, ok := st.GetCertificate(created.ID); ok { + created = c2 + } + eng.ReloadFromStore() + } else { + // custom/other cert: load PEMs into engine for immediate SNI/https use + eng.ReloadFromStore() + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(created) + _, _ = st.AddAuditLog(store.AuditLog{ + UserID: claims.UserID, + ObjectType: "certificate", + ObjectID: created.ID, + Action: "created", + Meta: map[string]any{"provider": created.Provider, "domain_names": created.DomainNames}, + }) + }) + + // Delete a certificate (removes from store; attached hosts will fall back) + r.Delete("/certificates/{id}", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + existing, ok := st.GetCertificate(id) + if !ok { + http.Error(w, "not found", 404) + return + } + if !canManage(claims, existing.OwnerUserID) { + http.Error(w, "forbidden: not owner or admin", 403) + return + } + if err := st.DeleteCertificate(id); err != nil { + http.Error(w, err.Error(), 500) + return + } + eng.ReloadFromStore() + w.WriteHeader(http.StatusNoContent) + _, _ = st.AddAuditLog(store.AuditLog{ + UserID: claims.UserID, + ObjectType: "certificate", + ObjectID: id, + Action: "deleted", + Meta: map[string]any{}, + }) + }) + + // Renew (re-obtain) a certificate. Useful for manual trigger or after domain change. + r.Post("/certificates/{id}/renew", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + c, ok := st.GetCertificate(id) + if !ok { + http.Error(w, "not found", 404) + return + } + if !canManage(claims, c.OwnerUserID) { + http.Error(w, "forbidden: not owner or admin", 403) + return + } + email := cm.GetEmailForCert(c) + if err := cm.Issue(c, email); err != nil { + http.Error(w, "renewal failed: "+err.Error(), 500) + return + } + if c2, ok := st.GetCertificate(id); ok { + c = c2 + } + eng.ReloadFromStore() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(c) + }) + + r.Get("/redirection-hosts", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + _ = claims + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(st.GetRedirectionHosts()) + }) + r.Post("/redirection-hosts", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + var payload store.RedirectionHost + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, err.Error(), 400) + return + } + if payload.OwnerUserID == 0 { + payload.OwnerUserID = claims.UserID + } + created, err := st.CreateRedirectionHost(payload) + if err != nil { + writeStoreError(w, err) + return + } + eng.ReloadFromStore() + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(created) + _, _ = st.AddAuditLog(store.AuditLog{ + UserID: claims.UserID, + ObjectType: "redirection-host", + ObjectID: created.ID, + Action: "created", + Meta: map[string]any{"domain_names": created.DomainNames}, + }) + }) + + // full redir lifecycle + r.Put("/redirection-hosts/{id}", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + existing, ok := st.GetRedirectionHost(id) + if !ok { + http.Error(w, "not found", 404) + return + } + if !canManage(claims, existing.OwnerUserID) { + http.Error(w, "forbidden: not owner or admin", 403) + return + } + var payload store.RedirectionHost + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, err.Error(), 400) + return + } + payload.ID = id + payload.OwnerUserID = existing.OwnerUserID + // preserve CreatedOn on update (server-set on create; do not lose on edit) + payload.CreatedOn = existing.CreatedOn + updated, err := st.UpdateRedirectionHost(payload) + if err != nil { + writeStoreError(w, err) + return + } + eng.ReloadFromStore() + _ = json.NewEncoder(w).Encode(updated) + _, _ = st.AddAuditLog(store.AuditLog{ + UserID: claims.UserID, + ObjectType: "redirection-host", + ObjectID: updated.ID, + Action: "updated", + Meta: map[string]any{"domain_names": updated.DomainNames}, + }) + }) + r.Delete("/redirection-hosts/{id}", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + existing, ok := st.GetRedirectionHost(id) + if !ok { + http.Error(w, "not found", 404) + return + } + if !canManage(claims, existing.OwnerUserID) { + http.Error(w, "forbidden: not owner or admin", 403) + return + } + if err := st.DeleteRedirectionHost(id); err != nil { + http.Error(w, err.Error(), 500) + return + } + eng.ReloadFromStore() + w.WriteHeader(http.StatusNoContent) + _, _ = st.AddAuditLog(store.AuditLog{ + UserID: claims.UserID, + ObjectType: "redirection-host", + ObjectID: id, + Action: "deleted", + Meta: map[string]any{}, + }) + }) + + r.Get("/dead-hosts", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + _ = claims + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(st.GetDeadHosts()) + }) + r.Post("/dead-hosts", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + var payload store.DeadHost + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, err.Error(), 400) + return + } + if payload.OwnerUserID == 0 { + payload.OwnerUserID = claims.UserID + } + created, err := st.CreateDeadHost(payload) + if err != nil { + writeStoreError(w, err) + return + } + eng.ReloadFromStore() + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(created) + _, _ = st.AddAuditLog(store.AuditLog{ + UserID: claims.UserID, + ObjectType: "dead-host", + ObjectID: created.ID, + Action: "created", + Meta: map[string]any{"domain_names": created.DomainNames}, + }) + }) + + // full dead lifecycle + r.Put("/dead-hosts/{id}", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + existing, ok := st.GetDeadHost(id) + if !ok { + http.Error(w, "not found", 404) + return + } + if !canManage(claims, existing.OwnerUserID) { + http.Error(w, "forbidden: not owner or admin", 403) + return + } + var payload store.DeadHost + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, err.Error(), 400) + return + } + payload.ID = id + payload.OwnerUserID = existing.OwnerUserID + // preserve CreatedOn on update (server-set on create; do not lose on edit) + payload.CreatedOn = existing.CreatedOn + updated, err := st.UpdateDeadHost(payload) + if err != nil { + writeStoreError(w, err) + return + } + eng.ReloadFromStore() + _ = json.NewEncoder(w).Encode(updated) + _, _ = st.AddAuditLog(store.AuditLog{ + UserID: claims.UserID, + ObjectType: "dead-host", + ObjectID: updated.ID, + Action: "updated", + Meta: map[string]any{"domain_names": updated.DomainNames}, + }) + }) + r.Delete("/dead-hosts/{id}", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + existing, ok := st.GetDeadHost(id) + if !ok { + http.Error(w, "not found", 404) + return + } + if !canManage(claims, existing.OwnerUserID) { + http.Error(w, "forbidden: not owner or admin", 403) + return + } + if err := st.DeleteDeadHost(id); err != nil { + http.Error(w, err.Error(), 500) + return + } + eng.ReloadFromStore() + w.WriteHeader(http.StatusNoContent) + _, _ = st.AddAuditLog(store.AuditLog{ + UserID: claims.UserID, + ObjectType: "dead-host", + ObjectID: id, + Action: "deleted", + Meta: map[string]any{}, + }) + }) + + // enable/disable for redirection and dead hosts (engine only loads enabled) + r.Post("/redirection-hosts/{id}/enable", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + existing, ok := st.GetRedirectionHost(id) + if !ok { + http.Error(w, "not found", 404) + return + } + if !canManage(claims, existing.OwnerUserID) { + http.Error(w, "forbidden: not owner or admin", 403) + return + } + if err := st.SetRedirectionHostEnabled(id, true); err != nil { + http.Error(w, err.Error(), 404) + return + } + eng.ReloadFromStore() + w.WriteHeader(200) + }) + r.Post("/redirection-hosts/{id}/disable", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + existing, ok := st.GetRedirectionHost(id) + if !ok { + http.Error(w, "not found", 404) + return + } + if !canManage(claims, existing.OwnerUserID) { + http.Error(w, "forbidden: not owner or admin", 403) + return + } + if err := st.SetRedirectionHostEnabled(id, false); err != nil { + http.Error(w, err.Error(), 404) + return + } + eng.ReloadFromStore() + w.WriteHeader(200) + }) + + r.Post("/dead-hosts/{id}/enable", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + existing, ok := st.GetDeadHost(id) + if !ok { + http.Error(w, "not found", 404) + return + } + if !canManage(claims, existing.OwnerUserID) { + http.Error(w, "forbidden: not owner or admin", 403) + return + } + if err := st.SetDeadHostEnabled(id, true); err != nil { + http.Error(w, err.Error(), 404) + return + } + eng.ReloadFromStore() + w.WriteHeader(200) + }) + r.Post("/dead-hosts/{id}/disable", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + existing, ok := st.GetDeadHost(id) + if !ok { + http.Error(w, "not found", 404) + return + } + if !canManage(claims, existing.OwnerUserID) { + http.Error(w, "forbidden: not owner or admin", 403) + return + } + if err := st.SetDeadHostEnabled(id, false); err != nil { + http.Error(w, err.Error(), 404) + return + } + eng.ReloadFromStore() + w.WriteHeader(200) + }) + + // streams enable/disable + r.Post("/streams/{id}/enable", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + existing, ok := st.GetStream(id) + if !ok { + http.Error(w, "not found", 404) + return + } + if !canManage(claims, existing.OwnerUserID) { + http.Error(w, "forbidden: not owner or admin", 403) + return + } + if err := st.SetStreamEnabled(id, true); err != nil { + if err.Error() == "not found" { + http.Error(w, err.Error(), 404) + return + } + writeStoreError(w, err) + return + } + eng.ReloadFromStore() + w.WriteHeader(200) + }) + r.Post("/streams/{id}/disable", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + existing, ok := st.GetStream(id) + if !ok { + http.Error(w, "not found", 404) + return + } + if !canManage(claims, existing.OwnerUserID) { + http.Error(w, "forbidden: not owner or admin", 403) + return + } + if err := st.SetStreamEnabled(id, false); err != nil { + http.Error(w, err.Error(), 404) + return + } + eng.ReloadFromStore() + w.WriteHeader(200) + }) + + // Full proxy-host lifecycle (list/create above; get/put/delete + enable/disable here) + r.Route("/proxy-hosts/{id}", func(r chi.Router) { + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + _ = claims // read authed (consistent); no owner gate for GET + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + if h, ok := st.GetProxyHost(id); ok { + _ = json.NewEncoder(w).Encode(h) + return + } + http.Error(w, "not found", 404) + }) + r.Put("/", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + existing, ok := st.GetProxyHost(id) + if !ok { + http.Error(w, "not found", 404) + return + } + if !canManage(claims, existing.OwnerUserID) { + http.Error(w, "forbidden: not owner or admin", 403) + return + } + var payload store.ProxyHost + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, err.Error(), 400) + return + } + payload.ID = id + // preserve owner on update + payload.OwnerUserID = existing.OwnerUserID + // preserve CreatedOn on update (server-set on create; do not lose on edit) + payload.CreatedOn = existing.CreatedOn + updated, err := st.UpdateProxyHost(payload) + if err != nil { + writeStoreError(w, err) + return + } + eng.ReloadFromStore() + _ = json.NewEncoder(w).Encode(updated) + _, _ = st.AddAuditLog(store.AuditLog{ + UserID: claims.UserID, + ObjectType: "proxy-host", + ObjectID: updated.ID, + Action: "updated", + Meta: map[string]any{"domain_names": updated.DomainNames}, + }) + }) + r.Delete("/", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + existing, ok := st.GetProxyHost(id) + if !ok { + http.Error(w, "not found", 404) + return + } + if !canManage(claims, existing.OwnerUserID) { + http.Error(w, "forbidden: not owner or admin", 403) + return + } + if err := st.DeleteProxyHost(id); err != nil { + http.Error(w, err.Error(), 500) + return + } + eng.ReloadFromStore() + w.WriteHeader(http.StatusNoContent) + _, _ = st.AddAuditLog(store.AuditLog{ + UserID: claims.UserID, + ObjectType: "proxy-host", + ObjectID: id, + Action: "deleted", + Meta: map[string]any{}, + }) + }) + }) + + // enable / disable actions + r.Post("/proxy-hosts/{id}/enable", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + existing, ok := st.GetProxyHost(id) + if !ok { + http.Error(w, "not found", 404) + return + } + if !canManage(claims, existing.OwnerUserID) { + http.Error(w, "forbidden: not owner or admin", 403) + return + } + if err := st.SetProxyHostEnabled(id, true); err != nil { + http.Error(w, err.Error(), 404) + return + } + eng.ReloadFromStore() + w.WriteHeader(200) + }) + r.Post("/proxy-hosts/{id}/disable", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "bad id", 400) + return + } + existing, ok := st.GetProxyHost(id) + if !ok { + http.Error(w, "not found", 404) + return + } + if !canManage(claims, existing.OwnerUserID) { + http.Error(w, "forbidden: not owner or admin", 403) + return + } + if err := st.SetProxyHostEnabled(id, false); err != nil { + http.Error(w, err.Error(), 404) + return + } + eng.ReloadFromStore() + w.WriteHeader(200) + }) + + // Settings (global for now; default_site drives unmatched host behavior in engine) + r.Get("/settings", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + _ = claims + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(st.GetSettings()) + }) + r.Post("/settings", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + _ = claims + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, err.Error(), 400) + return + } + for k, v := range payload { + if err := st.SetSetting(k, v); err != nil { + http.Error(w, err.Error(), 500) + return + } + } + w.WriteHeader(200) + _ = json.NewEncoder(w).Encode(st.GetSettings()) + }) + + // Audit logs (basic) + r.Get("/audit-logs", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + _ = claims + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(st.GetAuditLogs()) + }) + + // Login for the single admin account (no registration, no multi-user). + // Default password is "password". On first use (when no password has been set in DB yet), + // login with "password" succeeds but the response includes mustChangePassword:true + // (skipped in PROXY_MODE=development for local dev convenience). + // After the admin sets a new password (via /users/me/password), it is bcrypt-hashed and stored in DB. + // Subsequent logins use the stored hash. Email is ignored (always the built-in admin). + // No 2FA/TOTP. + r.Post("/login", func(w http.ResponseWriter, r *http.Request) { + var payload struct { + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, err.Error(), 400) + return + } + u, ok := st.GetUserByEmail("admin@example.com") + if !ok { + // fallback to seeded values if lookup fails + u = store.User{ID: 1, Email: "admin@example.com", Name: "Admin", Roles: []string{"admin"}} + } + mustChange := false + authed := false + if u.Password == "" { + // initial / not yet set: default "password" is accepted + if payload.Password == "password" { + authed = true + mustChange = !config.IsDevelopment() + } + } else if isBcryptPrefix(u.Password) { + if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(payload.Password)); err == nil { + authed = true + } + } else if u.Password == payload.Password { + // legacy plain (should not happen after first change) + authed = true + } + if !authed { + http.Error(w, "invalid credentials", 401) + return + } + token, err := jwtMgr.GenerateToken(u.ID, u.Email, u.Name, u.Roles) + if err != nil { + http.Error(w, "token error", 500) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "token": token, + "user": map[string]any{ + "id": u.ID, + "email": u.Email, + "name": u.Name, + "roles": u.Roles, + "totpEnabled": false, + }, + "mustChangePassword": mustChange, + }) + }) + + // Users: single admin only (no registration, no CRUD, no 2FA). List and /me kept for UI compatibility. + r.Get("/users", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + _ = claims + // Return the single admin (store may have legacy, but we present the fixed one) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "id": 1, "email": "admin@example.com", "name": "Admin", "roles": []string{"admin"}, "totpEnabled": false, + }}) + }) + r.Get("/users/me", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": claims.UserID, + "email": claims.Email, + "name": claims.Name, + "roles": claims.Roles, + "totpEnabled": false, + }) + }) + + // Change the single admin's password. Works for the initial "password" (when DB has empty pw) + // as well as normal changes (requires current password to match stored hash). + // New password is bcrypt-hashed and persisted. "password" is not allowed as the new value. + r.Post("/users/me/password", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + var payload struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, err.Error(), 400) + return + } + if payload.NewPassword == "" || payload.NewPassword == "password" { + http.Error(w, "new password required and cannot be the default", 400) + return + } + u, ok := st.GetUserByEmail(claims.Email) + if !ok { + http.Error(w, "not found", 404) + return + } + // verify current + if u.Password == "" { + // initial state: current must be the default (or empty to be lenient for first-login flow) + if payload.CurrentPassword != "" && payload.CurrentPassword != "password" { + http.Error(w, "invalid current password", 401) + return + } + } else if isBcryptPrefix(u.Password) { + if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(payload.CurrentPassword)); err != nil { + http.Error(w, "invalid current password", 401) + return + } + } else if u.Password != payload.CurrentPassword { + http.Error(w, "invalid current password", 401) + return + } + h, err := bcrypt.GenerateFromPassword([]byte(payload.NewPassword), bcrypt.DefaultCost) + if err != nil { + slog.Error("password hash failed", "err", err) + http.Error(w, "password hash failed", 500) + return + } + u.Password = string(h) + if err := st.UpdateUser(u); err != nil { + http.Error(w, err.Error(), 500) + return + } + _, _ = st.AddAuditLog(store.AuditLog{ + UserID: claims.UserID, + ObjectType: "user", + ObjectID: u.ID, + Action: "password-changed", + Meta: map[string]any{}, + }) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + }) + + // Dashboard (counts, like original) + r.Get("/dashboard", func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.RequireAuth(w, r) + if !ok { + return + } + _ = claims + data := map[string]any{ + "proxy_hosts": len(st.GetProxyHosts()), + "redirection_hosts": len(st.GetRedirectionHosts()), + "dead_hosts": len(st.GetDeadHosts()), + "streams": len(st.GetStreams()), + "certificates": len(st.GetCertificates()), + "access_lists": len(st.GetAccessLists()), + "audit_logs": len(st.GetAuditLogs()), + "users": len(st.GetUsers()), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(data) + }) + + // Version (like original) + r.Get("/version", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + major, minor, revision := version.Parts() + _ = json.NewEncoder(w).Encode(map[string]any{ + "version": version.String(), + "tag": version.Version, + "commit": version.Commit, + "major": major, + "minor": minor, + "revision": revision, + "api": "v1", + }) + }) + }) +} diff --git a/cmd/helix-proxy/api_branches_test.go b/cmd/helix-proxy/api_branches_test.go new file mode 100644 index 0000000..1cbd1f5 --- /dev/null +++ b/cmd/helix-proxy/api_branches_test.go @@ -0,0 +1,453 @@ +package main + +import ( + "bytes" + "net/http" + "testing" + + "helix-proxy/internal/store" +) + +func apiPutRaw(t *testing.T, url, rawBody, token string) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader([]byte(rawBody))) + if err != nil { + t.Fatalf("request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + return resp +} + +func TestAPI_UnauthorizedEndpoints(t *testing.T) { + env := newAPITestServer(t) + paths := []string{ + "/api/proxy-hosts", + "/api/access-lists", + "/api/streams", + "/api/certificates", + "/api/redirection-hosts", + "/api/dead-hosts", + "/api/settings", + "/api/audit-logs", + "/api/dashboard", + "/api/users", + "/api/users/me", + } + for _, path := range paths { + t.Run(path, func(t *testing.T) { + assertStatus(t, apiGet(t, env.BaseURL+path, ""), http.StatusUnauthorized) + }) + } +} + +func TestAPI_BadIDBranches(t *testing.T) { + env := newAPITestServer(t) + token := env.loginToken(t) + + cases := []struct { + name string + method string + path string + want int + }{ + {"proxy-host get", http.MethodGet, "/api/proxy-hosts/nope", http.StatusBadRequest}, + {"proxy-host put", http.MethodPut, "/api/proxy-hosts/nope", http.StatusBadRequest}, + {"proxy-host delete", http.MethodDelete, "/api/proxy-hosts/bad", http.StatusBadRequest}, + {"proxy-host enable", http.MethodPost, "/api/proxy-hosts/x/enable", http.StatusBadRequest}, + {"proxy-host disable", http.MethodPost, "/api/proxy-hosts/x/disable", http.StatusBadRequest}, + + {"access-list put", http.MethodPut, "/api/access-lists/abc", http.StatusBadRequest}, + {"access-list delete", http.MethodDelete, "/api/access-lists/abc", http.StatusBadRequest}, + + {"stream put", http.MethodPut, "/api/streams/nope", http.StatusBadRequest}, + {"stream delete", http.MethodDelete, "/api/streams/bad", http.StatusBadRequest}, + {"stream enable", http.MethodPost, "/api/streams/x/enable", http.StatusBadRequest}, + {"stream disable", http.MethodPost, "/api/streams/x/disable", http.StatusBadRequest}, + + {"cert delete", http.MethodDelete, "/api/certificates/bad", http.StatusBadRequest}, + + {"redir put", http.MethodPut, "/api/redirection-hosts/nope", http.StatusBadRequest}, + {"redir delete", http.MethodDelete, "/api/redirection-hosts/bad", http.StatusBadRequest}, + {"redir disable", http.MethodPost, "/api/redirection-hosts/x/disable", http.StatusBadRequest}, + + {"dead put", http.MethodPut, "/api/dead-hosts/nope", http.StatusBadRequest}, + {"dead delete", http.MethodDelete, "/api/dead-hosts/bad", http.StatusBadRequest}, + {"dead disable", http.MethodPost, "/api/dead-hosts/x/disable", http.StatusBadRequest}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var resp *http.Response + switch tc.method { + case http.MethodGet: + resp = apiGet(t, env.BaseURL+tc.path, token) + case http.MethodPut: + resp = apiPut(t, env.BaseURL+tc.path, map[string]any{"name": "x"}, token) + case http.MethodDelete: + resp = apiDelete(t, env.BaseURL+tc.path, token) + default: + resp = apiPost(t, env.BaseURL+tc.path, nil, token) + } + assertStatus(t, resp, tc.want) + }) + } +} + +func TestAPI_NotFoundEnableDisable(t *testing.T) { + env := newAPITestServer(t) + token := env.loginToken(t) + + cases := []string{ + "/api/proxy-hosts/9999/disable", + "/api/streams/9999/enable", + "/api/streams/9999/disable", + "/api/redirection-hosts/9999/enable", + "/api/dead-hosts/9999/disable", + "/api/dead-hosts/9999/enable", + } + for _, path := range cases { + t.Run(path, func(t *testing.T) { + assertStatus(t, apiPost(t, env.BaseURL+path, nil, token), http.StatusNotFound) + }) + } +} + +func TestAPI_MalformedJSONOnCreateAndUpdate(t *testing.T) { + env := newAPITestServer(t) + token := env.loginToken(t) + + createPaths := []string{ + "/api/proxy-hosts", + "/api/access-lists", + "/api/streams", + "/api/redirection-hosts", + "/api/dead-hosts", + } + for _, path := range createPaths { + t.Run("post "+path, func(t *testing.T) { + assertStatus(t, apiPostRaw(t, env.BaseURL+path, `{`, token), http.StatusBadRequest) + }) + } + + ph := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{ + "domainNames": []string{"malformed-ph.example"}, + "forwardHost": "127.0.0.1", + "forwardPort": 1, + "forwardScheme": "http", + }, token) + if ph.StatusCode != http.StatusCreated { + t.Fatalf("proxy host: %d", ph.StatusCode) + } + var proxyHost store.ProxyHost + decodeJSON(t, ph, &proxyHost) + + al := apiPost(t, env.BaseURL+"/api/access-lists", map[string]any{ + "name": "malformed-acl", + "clients": []map[string]string{{"address": "all", "directive": "allow"}}, + }, token) + if al.StatusCode != http.StatusCreated { + t.Fatalf("acl: %d", al.StatusCode) + } + var accessList store.AccessList + decodeJSON(t, al, &accessList) + + strm := apiPost(t, env.BaseURL+"/api/streams", map[string]any{ + "incomingPort": pickFreePort(t), + "forwardingHost": "127.0.0.1", + "forwardingPort": 9, + "tcpForwarding": true, + "enabled": false, + }, token) + if strm.StatusCode != http.StatusCreated { + t.Fatalf("stream: %d", strm.StatusCode) + } + var stream store.Stream + decodeJSON(t, strm, &stream) + + redir := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{ + "domainNames": []string{"malformed-redir.example"}, + "forwardDomainName": "target.example", + "enabled": true, + }, token) + if redir.StatusCode != http.StatusCreated { + t.Fatalf("redir: %d", redir.StatusCode) + } + var redirection store.RedirectionHost + decodeJSON(t, redir, &redirection) + + dead := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{ + "domainNames": []string{"malformed-dead.example"}, + "enabled": true, + }, token) + if dead.StatusCode != http.StatusCreated { + t.Fatalf("dead: %d", dead.StatusCode) + } + var deadHost store.DeadHost + decodeJSON(t, dead, &deadHost) + + putCases := []struct { + name string + url string + }{ + {"proxy-host", env.BaseURL + "/api/proxy-hosts/" + itoa(proxyHost.ID)}, + {"access-list", env.BaseURL + "/api/access-lists/" + itoa(accessList.ID)}, + {"stream", env.BaseURL + "/api/streams/" + itoa(stream.ID)}, + {"redirection", env.BaseURL + "/api/redirection-hosts/" + itoa(redirection.ID)}, + {"dead-host", env.BaseURL + "/api/dead-hosts/" + itoa(deadHost.ID)}, + } + for _, tc := range putCases { + t.Run("put "+tc.name, func(t *testing.T) { + assertStatus(t, apiPutRaw(t, tc.url, `{`, token), http.StatusBadRequest) + }) + } +} + +func TestAPI_UpdateConflict409(t *testing.T) { + env := newAPITestServer(t) + token := env.loginToken(t) + + t.Run("proxy-hosts", func(t *testing.T) { + a := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{ + "domainNames": []string{"conflict-a.example"}, + "forwardHost": "127.0.0.1", + "forwardPort": 1, + "forwardScheme": "http", + }, token) + b := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{ + "domainNames": []string{"conflict-b.example"}, + "forwardHost": "127.0.0.1", + "forwardPort": 2, + "forwardScheme": "http", + }, token) + if a.StatusCode != http.StatusCreated || b.StatusCode != http.StatusCreated { + t.Fatalf("create: %d %d", a.StatusCode, b.StatusCode) + } + var hostB store.ProxyHost + decodeJSON(t, b, &hostB) + assertStatus(t, apiPut(t, env.BaseURL+"/api/proxy-hosts/"+itoa(hostB.ID), map[string]any{ + "domainNames": []string{"conflict-a.example"}, + "forwardHost": "127.0.0.1", + "forwardPort": 2, + "forwardScheme": "http", + }, token), http.StatusConflict) + }) + + t.Run("redirection-hosts", func(t *testing.T) { + a := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{ + "domainNames": []string{"conflict-redir-a.example"}, + "forwardDomainName": "a.example", + "enabled": true, + }, token) + b := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{ + "domainNames": []string{"conflict-redir-b.example"}, + "forwardDomainName": "b.example", + "enabled": true, + }, token) + if a.StatusCode != http.StatusCreated || b.StatusCode != http.StatusCreated { + t.Fatalf("create: %d %d", a.StatusCode, b.StatusCode) + } + var hostB store.RedirectionHost + decodeJSON(t, b, &hostB) + assertStatus(t, apiPut(t, env.BaseURL+"/api/redirection-hosts/"+itoa(hostB.ID), map[string]any{ + "domainNames": []string{"conflict-redir-a.example"}, + "forwardDomainName": "b.example", + "enabled": true, + }, token), http.StatusConflict) + }) + + t.Run("dead-hosts", func(t *testing.T) { + a := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{ + "domainNames": []string{"conflict-dead-a.example"}, + "enabled": true, + }, token) + b := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{ + "domainNames": []string{"conflict-dead-b.example"}, + "enabled": true, + }, token) + if a.StatusCode != http.StatusCreated || b.StatusCode != http.StatusCreated { + t.Fatalf("create: %d %d", a.StatusCode, b.StatusCode) + } + var hostB store.DeadHost + decodeJSON(t, b, &hostB) + assertStatus(t, apiPut(t, env.BaseURL+"/api/dead-hosts/"+itoa(hostB.ID), map[string]any{ + "domainNames": []string{"conflict-dead-a.example"}, + "enabled": true, + }, token), http.StatusConflict) + }) + + t.Run("streams", func(t *testing.T) { + port := pickFreePort(t) + a := apiPost(t, env.BaseURL+"/api/streams", map[string]any{ + "incomingPort": port, + "forwardingHost": "127.0.0.1", + "forwardingPort": 9, + "tcpForwarding": true, + "enabled": false, + }, token) + otherPort := pickFreePort(t) + b := apiPost(t, env.BaseURL+"/api/streams", map[string]any{ + "incomingPort": otherPort, + "forwardingHost": "127.0.0.1", + "forwardingPort": 10, + "tcpForwarding": true, + "enabled": false, + }, token) + if a.StatusCode != http.StatusCreated || b.StatusCode != http.StatusCreated { + t.Fatalf("create: %d %d", a.StatusCode, b.StatusCode) + } + var streamB store.Stream + decodeJSON(t, b, &streamB) + assertStatus(t, apiPut(t, env.BaseURL+"/api/streams/"+itoa(streamB.ID), map[string]any{ + "incomingPort": port, + "forwardingHost": "127.0.0.1", + "forwardingPort": 10, + "tcpForwarding": true, + "enabled": false, + }, token), http.StatusConflict) + }) +} + +func TestAPI_ForbiddenEnableDisableBranches(t *testing.T) { + env := newAPITestServer(t) + adminTok := env.loginToken(t) + userTok := env.userToken(t, 2) + + ph := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{ + "domainNames": []string{"forbid-enable-ph.example"}, + "forwardHost": "127.0.0.1", + "forwardPort": 1, + "forwardScheme": "http", + "enabled": true, + }, adminTok) + var proxyHost store.ProxyHost + decodeJSON(t, ph, &proxyHost) + assertStatus(t, apiPost(t, env.BaseURL+"/api/proxy-hosts/"+itoa(proxyHost.ID)+"/enable", nil, userTok), http.StatusForbidden) + + strm := apiPost(t, env.BaseURL+"/api/streams", map[string]any{ + "incomingPort": pickFreePort(t), + "forwardingHost": "127.0.0.1", + "forwardingPort": 9, + "tcpForwarding": true, + "enabled": false, + }, adminTok) + var stream store.Stream + decodeJSON(t, strm, &stream) + assertStatus(t, apiPost(t, env.BaseURL+"/api/streams/"+itoa(stream.ID)+"/disable", nil, userTok), http.StatusForbidden) + + redir := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{ + "domainNames": []string{"forbid-disable-redir.example"}, + "forwardDomainName": "x.example", + "enabled": true, + }, adminTok) + var redirection store.RedirectionHost + decodeJSON(t, redir, &redirection) + assertStatus(t, apiPost(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redirection.ID)+"/disable", nil, userTok), http.StatusForbidden) + + dead := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{ + "domainNames": []string{"forbid-enable-dead.example"}, + "enabled": true, + }, adminTok) + var deadHost store.DeadHost + decodeJSON(t, dead, &deadHost) + assertStatus(t, apiPost(t, env.BaseURL+"/api/dead-hosts/"+itoa(deadHost.ID)+"/enable", nil, userTok), http.StatusForbidden) +} + +func TestAPI_LoginLegacyPlainPassword(t *testing.T) { + env := newAPITestServer(t) + u, ok := env.Store.GetUserByEmail("admin@example.com") + if !ok { + t.Fatal("admin user missing") + } + u.Password = "legacyplain" + if err := env.Store.UpdateUser(u); err != nil { + t.Fatalf("update user: %v", err) + } + + resp := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": "legacyplain"}, "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("legacy login: %d %s", resp.StatusCode, readBody(resp)) + } + var loginOut struct { + Token string `json:"token"` + } + decodeJSON(t, resp, &loginOut) + if loginOut.Token == "" { + t.Fatal("empty token for legacy login") + } + + assertStatus(t, apiPost(t, env.BaseURL+"/api/users/me/password", map[string]any{ + "currentPassword": "legacyplain", + "newPassword": "newsecure123", + }, loginOut.Token), http.StatusOK) +} + +func TestAPI_PasswordChangeEmptyNew(t *testing.T) { + env := newAPITestServer(t) + token := env.loginToken(t) + assertStatus(t, apiPost(t, env.BaseURL+"/api/users/me/password", map[string]any{ + "currentPassword": "password", + "newPassword": "", + }, token), http.StatusBadRequest) +} + +func TestAPI_ListEndpointsOK(t *testing.T) { + env := newAPITestServer(t) + token := env.loginToken(t) + + lists := []string{ + "/api/access-lists", + "/api/streams", + "/api/certificates", + "/api/redirection-hosts", + "/api/dead-hosts", + "/api/settings", + } + for _, path := range lists { + t.Run(path, func(t *testing.T) { + assertStatus(t, apiGet(t, env.BaseURL+path, token), http.StatusOK) + }) + } +} + +func TestAPI_DeleteNotFoundBranches(t *testing.T) { + env := newAPITestServer(t) + token := env.loginToken(t) + + cases := []string{ + "/api/redirection-hosts/9999", + "/api/dead-hosts/9999", + "/api/streams/9999", + } + for _, path := range cases { + t.Run(path, func(t *testing.T) { + assertStatus(t, apiDelete(t, env.BaseURL+path, token), http.StatusNotFound) + }) + } +} + +func TestAPI_PutNotFoundBranches(t *testing.T) { + env := newAPITestServer(t) + token := env.loginToken(t) + + cases := []struct { + path string + body map[string]any + }{ + {"/api/streams/9999", map[string]any{ + "incomingPort": 1, "forwardingHost": "127.0.0.1", "forwardingPort": 1, "tcpForwarding": true, + }}, + {"/api/redirection-hosts/9999", map[string]any{"domainNames": []string{"x.example"}}}, + {"/api/dead-hosts/9999", map[string]any{"domainNames": []string{"x.example"}}}, + } + for _, tc := range cases { + t.Run(tc.path, func(t *testing.T) { + assertStatus(t, apiPut(t, env.BaseURL+tc.path, tc.body, token), http.StatusNotFound) + }) + } +} diff --git a/cmd/helix-proxy/api_errors_test.go b/cmd/helix-proxy/api_errors_test.go new file mode 100644 index 0000000..477abb6 --- /dev/null +++ b/cmd/helix-proxy/api_errors_test.go @@ -0,0 +1,345 @@ +package main + +import ( + "bytes" + "net/http" + "strings" + "testing" + + "helix-proxy/internal/store" +) + +func assertStatus(t *testing.T, resp *http.Response, want int) { + t.Helper() + if resp.StatusCode != want { + t.Errorf("status: got %d want %d body=%s", resp.StatusCode, want, readBody(resp)) + return + } + _ = resp.Body.Close() +} + +func apiPostRaw(t *testing.T, url, rawBody, token string) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader([]byte(rawBody))) + if err != nil { + t.Fatalf("request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + return resp +} + +func TestAPI_ErrorPaths(t *testing.T) { + env := newAPITestServer(t) + token := env.loginToken(t) + + t.Run("auth", func(t *testing.T) { + assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/login", `{`, ""), http.StatusBadRequest) + assertStatus(t, apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": "wrong"}, ""), http.StatusUnauthorized) + assertStatus(t, apiGet(t, env.BaseURL+"/api/proxy-hosts", "not-a-jwt"), http.StatusUnauthorized) + assertStatus(t, apiGet(t, env.BaseURL+"/api/proxy-hosts", "Bearer"), http.StatusUnauthorized) + assertStatus(t, apiGet(t, env.BaseURL+"/api/proxy-hosts", "Basic dXNlcjpwYXNz"), http.StatusUnauthorized) + + assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/users/me/password", `not-json`, token), http.StatusBadRequest) + assertStatus(t, apiPost(t, env.BaseURL+"/api/users/me/password", map[string]any{ + "currentPassword": "password", + "newPassword": "password", + }, token), http.StatusBadRequest) + assertStatus(t, apiPost(t, env.BaseURL+"/api/users/me/password", map[string]any{ + "currentPassword": "wrong", + "newPassword": "newpass123", + }, token), http.StatusUnauthorized) + }) + + t.Run("proxy-hosts", func(t *testing.T) { + assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/proxy-hosts", `{`, token), http.StatusBadRequest) + assertStatus(t, apiGet(t, env.BaseURL+"/api/proxy-hosts/bad-id", token), http.StatusBadRequest) + assertStatus(t, apiGet(t, env.BaseURL+"/api/proxy-hosts/9999", token), http.StatusNotFound) + assertStatus(t, apiPut(t, env.BaseURL+"/api/proxy-hosts/9999", map[string]any{ + "domainNames": []string{"x.example"}, + "forwardHost": "127.0.0.1", + "forwardPort": 1, + }, token), http.StatusNotFound) + assertStatus(t, apiDelete(t, env.BaseURL+"/api/proxy-hosts/9999", token), http.StatusNotFound) + assertStatus(t, apiPost(t, env.BaseURL+"/api/proxy-hosts/9999/enable", nil, token), http.StatusNotFound) + }) + + t.Run("access-lists", func(t *testing.T) { + assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/access-lists", `{`, token), http.StatusBadRequest) + assertStatus(t, apiGet(t, env.BaseURL+"/api/access-lists/nope", token), http.StatusBadRequest) + assertStatus(t, apiGet(t, env.BaseURL+"/api/access-lists/9999", token), http.StatusNotFound) + assertStatus(t, apiPut(t, env.BaseURL+"/api/access-lists/9999", map[string]any{"name": "x"}, token), http.StatusNotFound) + assertStatus(t, apiDelete(t, env.BaseURL+"/api/access-lists/9999", token), http.StatusNotFound) + }) + + t.Run("streams", func(t *testing.T) { + assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/streams", `{`, token), http.StatusBadRequest) + assertStatus(t, apiPut(t, env.BaseURL+"/api/streams/bad", map[string]any{}, token), http.StatusBadRequest) + assertStatus(t, apiDelete(t, env.BaseURL+"/api/streams/9999", token), http.StatusNotFound) + + first := apiPost(t, env.BaseURL+"/api/streams", map[string]any{ + "incomingPort": 29100, + "forwardingHost": "127.0.0.1", + "forwardingPort": 9, + "tcpForwarding": true, + "enabled": false, + }, token) + if first.StatusCode != http.StatusCreated { + t.Fatalf("stream create: %d %s", first.StatusCode, readBody(first)) + } + dup := apiPost(t, env.BaseURL+"/api/streams", map[string]any{ + "incomingPort": 29100, + "forwardingHost": "127.0.0.1", + "forwardingPort": 10, + "tcpForwarding": true, + "enabled": false, + }, token) + assertStatus(t, dup, http.StatusConflict) + }) + + t.Run("certificates", func(t *testing.T) { + assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/certificates", `{`, token), http.StatusBadRequest) + assertStatus(t, apiDelete(t, env.BaseURL+"/api/certificates/9999", token), http.StatusNotFound) + assertStatus(t, apiPost(t, env.BaseURL+"/api/certificates/9999/renew", nil, token), http.StatusNotFound) + assertStatus(t, apiPost(t, env.BaseURL+"/api/certificates/bad/renew", nil, token), http.StatusBadRequest) + }) + + t.Run("redirection-hosts", func(t *testing.T) { + assertStatus(t, apiPut(t, env.BaseURL+"/api/redirection-hosts/9999", map[string]any{ + "domainNames": []string{"x.example"}, + }, token), http.StatusNotFound) + assertStatus(t, apiPost(t, env.BaseURL+"/api/redirection-hosts/9999/disable", nil, token), http.StatusNotFound) + assertStatus(t, apiPost(t, env.BaseURL+"/api/redirection-hosts/bad/enable", nil, token), http.StatusBadRequest) + + first := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{ + "domainNames": []string{"dup-redir.example"}, + "forwardDomainName": "target.example", + "forwardScheme": "https", + "preservePath": true, + "enabled": true, + }, token) + if first.StatusCode != http.StatusCreated { + t.Fatalf("redir create: %d %s", first.StatusCode, readBody(first)) + } + dup := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{ + "domainNames": []string{"dup-redir.example"}, + "forwardDomainName": "other.example", + "enabled": true, + }, token) + assertStatus(t, dup, http.StatusConflict) + }) + + t.Run("dead-hosts", func(t *testing.T) { + assertStatus(t, apiDelete(t, env.BaseURL+"/api/dead-hosts/9999", token), http.StatusNotFound) + assertStatus(t, apiPost(t, env.BaseURL+"/api/dead-hosts/bad/enable", nil, token), http.StatusBadRequest) + + first := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{ + "domainNames": []string{"dup-dead.example"}, + "enabled": true, + }, token) + if first.StatusCode != http.StatusCreated { + t.Fatalf("dead create: %d %s", first.StatusCode, readBody(first)) + } + dup := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{ + "domainNames": []string{"dup-dead.example"}, + "enabled": true, + }, token) + assertStatus(t, dup, http.StatusConflict) + }) + + t.Run("settings", func(t *testing.T) { + assertStatus(t, apiPostRaw(t, env.BaseURL+"/api/settings", `{`, token), http.StatusBadRequest) + }) +} + +func TestAPI_ForbiddenNonOwner(t *testing.T) { + env := newAPITestServer(t) + adminTok := env.loginToken(t) + userTok := env.userToken(t, 2) + + al := apiPost(t, env.BaseURL+"/api/access-lists", map[string]any{ + "name": "owned-by-admin", + "clients": []map[string]string{{"address": "all", "directive": "allow"}}, + }, adminTok) + if al.StatusCode != http.StatusCreated { + t.Fatalf("acl create: %d", al.StatusCode) + } + var created store.AccessList + decodeJSON(t, al, &created) + + assertStatus(t, apiPut(t, env.BaseURL+"/api/access-lists/"+itoa(created.ID), map[string]any{ + "name": "hijack", + }, userTok), http.StatusForbidden) + assertStatus(t, apiDelete(t, env.BaseURL+"/api/access-lists/"+itoa(created.ID), userTok), http.StatusForbidden) + + ph := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{ + "domainNames": []string{"forbidden-ph.example"}, + "forwardHost": "127.0.0.1", + "forwardPort": 1, + "forwardScheme": "http", + }, adminTok) + if ph.StatusCode != http.StatusCreated { + t.Fatalf("proxy host: %d", ph.StatusCode) + } + var proxyHost store.ProxyHost + decodeJSON(t, ph, &proxyHost) + assertStatus(t, apiDelete(t, env.BaseURL+"/api/proxy-hosts/"+itoa(proxyHost.ID), userTok), http.StatusForbidden) + assertStatus(t, apiPost(t, env.BaseURL+"/api/proxy-hosts/"+itoa(proxyHost.ID)+"/disable", nil, userTok), http.StatusForbidden) + + strm := apiPost(t, env.BaseURL+"/api/streams", map[string]any{ + "incomingPort": pickFreePort(t), + "forwardingHost": "127.0.0.1", + "forwardingPort": 9, + "tcpForwarding": true, + "enabled": false, + }, adminTok) + if strm.StatusCode != http.StatusCreated { + t.Fatalf("stream: %d %s", strm.StatusCode, readBody(strm)) + } + var stream store.Stream + decodeJSON(t, strm, &stream) + assertStatus(t, apiPut(t, env.BaseURL+"/api/streams/"+itoa(stream.ID), map[string]any{ + "incomingPort": stream.IncomingPort, + "forwardingHost": "127.0.0.1", + "forwardingPort": 10, + "tcpForwarding": true, + "enabled": false, + }, userTok), http.StatusForbidden) + assertStatus(t, apiDelete(t, env.BaseURL+"/api/streams/"+itoa(stream.ID), userTok), http.StatusForbidden) + assertStatus(t, apiPost(t, env.BaseURL+"/api/streams/"+itoa(stream.ID)+"/enable", nil, userTok), http.StatusForbidden) + + redir := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{ + "domainNames": []string{"forbidden-redir.example"}, + "forwardDomainName": "elsewhere.example", + "enabled": true, + }, adminTok) + if redir.StatusCode != http.StatusCreated { + t.Fatalf("redir: %d", redir.StatusCode) + } + var redirection store.RedirectionHost + decodeJSON(t, redir, &redirection) + assertStatus(t, apiPut(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redirection.ID), map[string]any{ + "domainNames": []string{"hijack-redir.example"}, + }, userTok), http.StatusForbidden) + assertStatus(t, apiDelete(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redirection.ID), userTok), http.StatusForbidden) + assertStatus(t, apiPost(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redirection.ID)+"/enable", nil, userTok), http.StatusForbidden) + + dead := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{ + "domainNames": []string{"forbidden-dead.example"}, + "enabled": true, + }, adminTok) + if dead.StatusCode != http.StatusCreated { + t.Fatalf("dead: %d", dead.StatusCode) + } + var deadHost store.DeadHost + decodeJSON(t, dead, &deadHost) + assertStatus(t, apiPut(t, env.BaseURL+"/api/dead-hosts/"+itoa(deadHost.ID), map[string]any{ + "domainNames": []string{"hijack-dead.example"}, + }, userTok), http.StatusForbidden) + assertStatus(t, apiDelete(t, env.BaseURL+"/api/dead-hosts/"+itoa(deadHost.ID), userTok), http.StatusForbidden) + assertStatus(t, apiPost(t, env.BaseURL+"/api/dead-hosts/"+itoa(deadHost.ID)+"/disable", nil, userTok), http.StatusForbidden) + + cert := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{ + "provider": "other", + "domainNames": []string{"forbidden-cert.example"}, + }, adminTok) + if cert.StatusCode != http.StatusCreated { + t.Fatalf("cert: %d", cert.StatusCode) + } + var certificate store.Certificate + decodeJSON(t, cert, &certificate) + assertStatus(t, apiDelete(t, env.BaseURL+"/api/certificates/"+itoa(certificate.ID), userTok), http.StatusForbidden) +} + +func TestAPI_CertificateFailures(t *testing.T) { + t.Run("other provider create succeeds without issuance", func(t *testing.T) { + env := newAPITestServer(t) + token := env.loginToken(t) + resp := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{ + "provider": "other", + "domainNames": []string{"custom.example"}, + }, token) + assertStatus(t, resp, http.StatusCreated) + }) + + t.Run("LE empty domains rolls back in production", func(t *testing.T) { + t.Setenv("PROXY_MODE", "") + env := newAPITestServer(t) + token := env.loginToken(t) + resp := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{ + "provider": "letsencrypt", + "domainNames": []string{}, + }, token) + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("empty domains: %d %s", resp.StatusCode, readBody(resp)) + } + body := readBody(resp) + if !strings.Contains(body, "letsencrypt issuance failed") { + t.Errorf("body: %q", body) + } + if certs := env.Store.GetCertificates(); len(certs) != 0 { + t.Errorf("failed issue should roll back cert, got %d", len(certs)) + } + }) + + t.Run("renew non-LE fails", func(t *testing.T) { + env := newAPITestServer(t) + token := env.loginToken(t) + create := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{ + "provider": "other", + "domainNames": []string{"renew-fail.example"}, + }, token) + if create.StatusCode != http.StatusCreated { + t.Fatalf("create: %d %s", create.StatusCode, readBody(create)) + } + var cert store.Certificate + decodeJSON(t, create, &cert) + + renew := apiPost(t, env.BaseURL+"/api/certificates/"+itoa(cert.ID)+"/renew", nil, token) + body := readBody(renew) + if renew.StatusCode != http.StatusInternalServerError { + t.Fatalf("renew: %d %s", renew.StatusCode, body) + } + if !strings.Contains(body, "renewal failed") { + t.Errorf("expected renewal failed message, got %q", body) + } + }) + + t.Run("renew forbidden for non-owner", func(t *testing.T) { + env := newAPITestServer(t) + adminTok := env.loginToken(t) + userTok := env.userToken(t, 2) + + create := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{ + "provider": "other", + "domainNames": []string{"owned.example"}, + }, adminTok) + if create.StatusCode != http.StatusCreated { + t.Fatalf("create: %d", create.StatusCode) + } + var cert store.Certificate + decodeJSON(t, create, &cert) + + assertStatus(t, apiPost(t, env.BaseURL+"/api/certificates/"+itoa(cert.ID)+"/renew", nil, userTok), http.StatusForbidden) + }) +} + +func TestAPI_UsersList(t *testing.T) { + env := newAPITestServer(t) + token := env.loginToken(t) + resp := apiGet(t, env.BaseURL+"/api/users", token) + if resp.StatusCode != http.StatusOK { + t.Fatalf("users: %d", resp.StatusCode) + } + var users []map[string]any + decodeJSON(t, resp, &users) + if len(users) != 1 || users[0]["email"] != "admin@example.com" { + t.Errorf("users: %v", users) + } +} diff --git a/cmd/helix-proxy/api_test.go b/cmd/helix-proxy/api_test.go new file mode 100644 index 0000000..f971a79 --- /dev/null +++ b/cmd/helix-proxy/api_test.go @@ -0,0 +1,467 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "helix-proxy/internal/store" +) + +func (e *apiTestEnv) loginToken(t *testing.T) string { + t.Helper() + resp := apiPost(t, e.BaseURL+"/api/login", map[string]any{"password": "password"}, "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("login: %d %s", resp.StatusCode, readBody(resp)) + } + var out struct { + Token string `json:"token"` + } + decodeJSON(t, resp, &out) + if out.Token == "" { + t.Fatal("empty login token") + } + return out.Token +} + +func (e *apiTestEnv) userToken(t *testing.T, userID int) string { + t.Helper() + tok, err := e.JWT.GenerateToken(userID, "user@example.com", "User", []string{"user"}) + if err != nil { + t.Fatalf("user token: %v", err) + } + return tok +} + +func apiRequest(t *testing.T, method, url, token string, body any) *http.Response { + t.Helper() + var r io.Reader + if body != nil { + b, _ := json.Marshal(body) + r = bytes.NewReader(b) + } + req, err := http.NewRequest(method, url, r) + if err != nil { + t.Fatalf("request: %v", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + return resp +} + +func apiGet(t *testing.T, url, token string) *http.Response { + return apiRequest(t, http.MethodGet, url, token, nil) +} + +func apiPost(t *testing.T, url string, body any, token string) *http.Response { + return apiRequest(t, http.MethodPost, url, token, body) +} + +func apiPut(t *testing.T, url string, body any, token string) *http.Response { + return apiRequest(t, http.MethodPut, url, token, body) +} + +func apiDelete(t *testing.T, url, token string) *http.Response { + return apiRequest(t, http.MethodDelete, url, token, nil) +} + +func readBody(resp *http.Response) string { + b, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + return string(b) +} + +func decodeJSON(t *testing.T, resp *http.Response, dst any) { + t.Helper() + defer resp.Body.Close() + if err := json.NewDecoder(resp.Body).Decode(dst); err != nil { + t.Fatalf("decode: %v body=%s", err, readBody(resp)) + } +} + +func TestAPI_PublicAndAuth(t *testing.T) { + env := newAPITestServer(t) + + // health (no token) + resp := apiGet(t, env.BaseURL+"/api/", "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("health: %d", resp.StatusCode) + } + var health map[string]any + decodeJSON(t, resp, &health) + if health["status"] != "OK" { + t.Errorf("health status: %v", health["status"]) + } + + // version (no RequireAuth in handler) + resp = apiGet(t, env.BaseURL+"/api/version", "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("version: %d", resp.StatusCode) + } + + // protected list without token -> 401 + resp = apiGet(t, env.BaseURL+"/api/proxy-hosts", "") + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("proxy-hosts no auth: %d", resp.StatusCode) + } + + token := env.loginToken(t) + + // me + resp = apiGet(t, env.BaseURL+"/api/users/me", token) + if resp.StatusCode != http.StatusOK { + t.Fatalf("users/me: %d", resp.StatusCode) + } + var me map[string]any + decodeJSON(t, resp, &me) + if me["email"] != "admin@example.com" { + t.Errorf("me email: %v", me["email"]) + } +} + +func TestAPI_ProxyHostCRUD(t *testing.T) { + env := newAPITestServer(t) + token := env.loginToken(t) + + create := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{ + "domainNames": []string{"api-proxy.example"}, + "forwardHost": "127.0.0.1", + "forwardPort": 8080, + "forwardScheme": "http", + "enabled": true, + }, token) + if create.StatusCode != http.StatusCreated { + t.Fatalf("create: %d %s", create.StatusCode, readBody(create)) + } + var ph store.ProxyHost + decodeJSON(t, create, &ph) + if ph.ID == 0 || ph.OwnerUserID != 1 { + t.Fatalf("created host: %+v", ph) + } + + list := apiGet(t, env.BaseURL+"/api/proxy-hosts", token) + if list.StatusCode != http.StatusOK { + t.Fatalf("list: %d", list.StatusCode) + } + var hosts []store.ProxyHost + decodeJSON(t, list, &hosts) + if len(hosts) != 1 { + t.Fatalf("list len: %d", len(hosts)) + } + + get := apiGet(t, env.BaseURL+"/api/proxy-hosts/"+itoa(ph.ID), token) + if get.StatusCode != http.StatusOK { + t.Fatalf("get: %d", get.StatusCode) + } + + up := apiPut(t, env.BaseURL+"/api/proxy-hosts/"+itoa(ph.ID), map[string]any{ + "domainNames": []string{"api-proxy-updated.example"}, + "forwardHost": "127.0.0.1", + "forwardPort": 9090, + "forwardScheme": "http", + "enabled": true, + }, token) + if up.StatusCode != http.StatusOK { + t.Fatalf("update: %d %s", up.StatusCode, readBody(up)) + } + + dis := apiPost(t, env.BaseURL+"/api/proxy-hosts/"+itoa(ph.ID)+"/disable", nil, token) + if dis.StatusCode != http.StatusOK { + t.Fatalf("disable: %d", dis.StatusCode) + } + en := apiPost(t, env.BaseURL+"/api/proxy-hosts/"+itoa(ph.ID)+"/enable", nil, token) + if en.StatusCode != http.StatusOK { + t.Fatalf("enable: %d", en.StatusCode) + } + + // duplicate domain -> 409 + dup := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{ + "domainNames": []string{"api-proxy-updated.example"}, + "forwardHost": "127.0.0.1", + "forwardPort": 1, + "forwardScheme": "http", + }, token) + if dup.StatusCode != http.StatusConflict { + t.Errorf("duplicate domain: %d %s", dup.StatusCode, readBody(dup)) + } + + // non-owner forbidden + userTok := env.userToken(t, 2) + forbid := apiPut(t, env.BaseURL+"/api/proxy-hosts/"+itoa(ph.ID), map[string]any{ + "domainNames": []string{"hijack.example"}, + "forwardHost": "127.0.0.1", + "forwardPort": 1, + }, userTok) + if forbid.StatusCode != http.StatusForbidden { + t.Errorf("non-owner update: %d", forbid.StatusCode) + } + + del := apiDelete(t, env.BaseURL+"/api/proxy-hosts/"+itoa(ph.ID), token) + if del.StatusCode != http.StatusNoContent { + t.Fatalf("delete: %d %s", del.StatusCode, readBody(del)) + } + + logs := apiGet(t, env.BaseURL+"/api/audit-logs", token) + if logs.StatusCode != http.StatusOK { + t.Fatalf("audit: %d", logs.StatusCode) + } + var audit []store.AuditLog + decodeJSON(t, logs, &audit) + if len(audit) < 3 { + t.Errorf("expected audit entries, got %d", len(audit)) + } +} + +func TestAPI_AccessListCRUD(t *testing.T) { + env := newAPITestServer(t) + token := env.loginToken(t) + + create := apiPost(t, env.BaseURL+"/api/access-lists", map[string]any{ + "name": "api-acl", + "clients": []map[string]string{ + {"address": "all", "directive": "allow"}, + }, + "items": []map[string]string{ + {"username": "u", "password": "p"}, + }, + }, token) + if create.StatusCode != http.StatusCreated { + t.Fatalf("create: %d %s", create.StatusCode, readBody(create)) + } + var al store.AccessList + decodeJSON(t, create, &al) + + get := apiGet(t, env.BaseURL+"/api/access-lists/"+itoa(al.ID), token) + if get.StatusCode != http.StatusOK { + t.Fatalf("get: %d", get.StatusCode) + } + + up := apiPut(t, env.BaseURL+"/api/access-lists/"+itoa(al.ID), map[string]any{ + "name": "api-acl-updated", + "passAuth": true, + "clients": []map[string]string{{"address": "all", "directive": "allow"}}, + "items": []map[string]string{{"username": "u2", "password": "p2"}}, + }, token) + if up.StatusCode != http.StatusOK { + t.Fatalf("update: %d %s", up.StatusCode, readBody(up)) + } + + del := apiDelete(t, env.BaseURL+"/api/access-lists/"+itoa(al.ID), token) + if del.StatusCode != http.StatusNoContent { + t.Fatalf("delete: %d", del.StatusCode) + } +} + +func TestAPI_RedirectionAndDeadHosts(t *testing.T) { + env := newAPITestServer(t) + token := env.loginToken(t) + + rh := apiPost(t, env.BaseURL+"/api/redirection-hosts", map[string]any{ + "domainNames": []string{"api-redir.example"}, + "forwardHttpCode": 302, + "forwardScheme": "https", + "forwardDomainName": "target.example", + "preservePath": true, + "enabled": true, + }, token) + if rh.StatusCode != http.StatusCreated { + t.Fatalf("redir create: %d %s", rh.StatusCode, readBody(rh)) + } + var redir store.RedirectionHost + decodeJSON(t, rh, &redir) + + up := apiPut(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redir.ID), map[string]any{ + "domainNames": []string{"api-redir.example"}, + "forwardHttpCode": 301, + "forwardScheme": "https", + "forwardDomainName": "other.example", + "enabled": true, + }, token) + if up.StatusCode != http.StatusOK { + t.Fatalf("redir update: %d", up.StatusCode) + } + if apiPost(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redir.ID)+"/disable", nil, token).StatusCode != http.StatusOK { + t.Error("redir disable failed") + } + if apiPost(t, env.BaseURL+"/api/redirection-hosts/"+itoa(redir.ID)+"/enable", nil, token).StatusCode != http.StatusOK { + t.Error("redir enable failed") + } + + dh := apiPost(t, env.BaseURL+"/api/dead-hosts", map[string]any{ + "domainNames": []string{"api-dead.example"}, + "enabled": true, + "meta": map[string]any{"html": "dead"}, + }, token) + if dh.StatusCode != http.StatusCreated { + t.Fatalf("dead create: %d %s", dh.StatusCode, readBody(dh)) + } + var dead store.DeadHost + decodeJSON(t, dh, &dead) + + if apiPut(t, env.BaseURL+"/api/dead-hosts/"+itoa(dead.ID), map[string]any{ + "domainNames": []string{"api-dead.example"}, + "enabled": true, + }, token).StatusCode != http.StatusOK { + t.Error("dead update failed") + } + if apiPost(t, env.BaseURL+"/api/dead-hosts/"+itoa(dead.ID)+"/disable", nil, token).StatusCode != http.StatusOK { + t.Error("dead disable failed") + } + + list := apiGet(t, env.BaseURL+"/api/redirection-hosts", token) + if list.StatusCode != http.StatusOK { + t.Fatalf("redir list: %d", list.StatusCode) + } +} + +func TestAPI_StreamCRUD(t *testing.T) { + env := newAPITestServer(t) + token := env.loginToken(t) + + create := apiPost(t, env.BaseURL+"/api/streams", map[string]any{ + "incomingPort": 29001, + "forwardingHost": "127.0.0.1", + "forwardingPort": 9, + "tcpForwarding": true, + "enabled": false, + }, token) + if create.StatusCode != http.StatusCreated { + t.Fatalf("create: %d %s", create.StatusCode, readBody(create)) + } + var strm store.Stream + decodeJSON(t, create, &strm) + + up := apiPut(t, env.BaseURL+"/api/streams/"+itoa(strm.ID), map[string]any{ + "incomingPort": 29001, + "forwardingHost": "127.0.0.1", + "forwardingPort": 10, + "tcpForwarding": true, + "enabled": false, + }, token) + if up.StatusCode != http.StatusOK { + t.Fatalf("update: %d %s", up.StatusCode, readBody(up)) + } + + if apiPost(t, env.BaseURL+"/api/streams/"+itoa(strm.ID)+"/enable", nil, token).StatusCode != http.StatusOK { + t.Error("enable failed") + } + + enabledUp := apiPut(t, env.BaseURL+"/api/streams/"+itoa(strm.ID), map[string]any{ + "incomingPort": 29001, + "forwardingHost": "127.0.0.1", + "forwardingPort": 11, + "tcpForwarding": true, + "enabled": true, + }, token) + if enabledUp.StatusCode != http.StatusOK { + t.Fatalf("update enabled same port: %d %s", enabledUp.StatusCode, readBody(enabledUp)) + } + var enabledResp struct { + store.Stream + Listening bool `json:"listening"` + ListenError string `json:"listenError"` + } + decodeJSON(t, enabledUp, &enabledResp) + if !enabledResp.Listening { + t.Fatalf("expected enabled stream listening after edit, got listenError=%q", enabledResp.ListenError) + } + + if apiPost(t, env.BaseURL+"/api/streams/"+itoa(strm.ID)+"/disable", nil, token).StatusCode != http.StatusOK { + t.Error("disable failed") + } + + del := apiDelete(t, env.BaseURL+"/api/streams/"+itoa(strm.ID), token) + if del.StatusCode != http.StatusNoContent { + t.Fatalf("delete: %d", del.StatusCode) + } +} + +func TestAPI_CertificateCRUD(t *testing.T) { + t.Setenv("PROXY_MODE", "development") + env := newAPITestServer(t) + token := env.loginToken(t) + + create := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{ + "provider": "letsencrypt", + "domainNames": []string{"api-cert.example"}, + }, token) + if create.StatusCode != http.StatusCreated { + t.Fatalf("create: %d %s", create.StatusCode, readBody(create)) + } + var cert store.Certificate + decodeJSON(t, create, &cert) + if cert.ID == 0 { + t.Fatal("cert id missing") + } + + list := apiGet(t, env.BaseURL+"/api/certificates", token) + if list.StatusCode != http.StatusOK { + t.Fatalf("list: %d", list.StatusCode) + } + + renew := apiPost(t, env.BaseURL+"/api/certificates/"+itoa(cert.ID)+"/renew", nil, token) + if renew.StatusCode != http.StatusOK { + t.Fatalf("renew: %d %s", renew.StatusCode, readBody(renew)) + } + + del := apiDelete(t, env.BaseURL+"/api/certificates/"+itoa(cert.ID), token) + if del.StatusCode != http.StatusNoContent { + t.Fatalf("delete: %d", del.StatusCode) + } +} + +func TestAPI_SettingsDashboard(t *testing.T) { + env := newAPITestServer(t) + token := env.loginToken(t) + + set := apiPost(t, env.BaseURL+"/api/settings", map[string]any{ + "default_site": "404", + }, token) + if set.StatusCode != http.StatusOK { + t.Fatalf("settings post: %d %s", set.StatusCode, readBody(set)) + } + var settings map[string]any + decodeJSON(t, set, &settings) + if settings["default_site"] != "404" { + t.Errorf("default_site: %v", settings["default_site"]) + } + + dash := apiGet(t, env.BaseURL+"/api/dashboard", token) + if dash.StatusCode != http.StatusOK { + t.Fatalf("dashboard: %d", dash.StatusCode) + } + var counts map[string]any + decodeJSON(t, dash, &counts) + if _, ok := counts["proxy_hosts"]; !ok { + t.Error("dashboard missing proxy_hosts") + } +} + +func TestAPI_WriteStoreError(t *testing.T) { + w := httptest.NewRecorder() + writeStoreError(w, store.ErrDuplicateStreamPort) + if w.Code != http.StatusConflict { + t.Errorf("conflict: %d", w.Code) + } + w2 := httptest.NewRecorder() + writeStoreError(w2, io.ErrUnexpectedEOF) + if w2.Code != http.StatusInternalServerError { + t.Errorf("generic: %d", w2.Code) + } +} + +func itoa(n int) string { + return strconv.Itoa(n) +} diff --git a/cmd/helix-proxy/https.go b/cmd/helix-proxy/https.go new file mode 100644 index 0000000..3d0cbcf --- /dev/null +++ b/cmd/helix-proxy/https.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + "crypto/tls" + "net/http" + "time" + + "helix-proxy/internal/proxy" +) + +// serveHTTPS listens with TLS and serves the proxy engine handler (SNI via eng.TLSConfig). +// Shuts down when ctx is cancelled. +func serveHTTPS(ctx context.Context, eng *proxy.Engine, addr string) error { + ln, err := tls.Listen("tcp", addr, eng.TLSConfig()) + if err != nil { + return err + } + srv := &http.Server{ + Handler: eng.Handler(), + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + } + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + _ = ln.Close() + }() + return srv.Serve(ln) +} diff --git a/cmd/helix-proxy/listen_config.go b/cmd/helix-proxy/listen_config.go new file mode 100644 index 0000000..5864389 --- /dev/null +++ b/cmd/helix-proxy/listen_config.go @@ -0,0 +1,45 @@ +package main + +import ( + "log/slog" + "net" + "os" + "strconv" + "syscall" +) + +// applyUmaskFromEnv sets the process umask from UMASK env (octal) or defaults to 022. +func applyUmaskFromEnv() { + if u := os.Getenv("UMASK"); u != "" { + if mode, err := strconv.ParseInt(u, 8, 32); err == nil { + syscall.Umask(int(mode)) + return + } + slog.Warn("invalid UMASK env (ignored)", "val", u) + } + syscall.Umask(0o022) +} + +// adminListenAddr resolves the admin API/UI listen address from env. +func adminListenAddr() string { + port := "8081" + if p := os.Getenv("ADMIN_PORT"); p != "" { + port = p + } + if os.Getenv("DISABLE_IPV6") == "1" { + return "0.0.0.0:" + port + } + return net.JoinHostPort("", port) +} + +// proxyHTTPSListenAddr resolves the TLS proxy listen address from env. +func proxyHTTPSListenAddr() string { + port := "18443" + if p := os.Getenv("PROXY_HTTPS_PORT"); p != "" { + port = p + } + if os.Getenv("DISABLE_IPV6") == "1" { + return "0.0.0.0:" + port + } + return net.JoinHostPort("", port) +} diff --git a/cmd/helix-proxy/listen_config_test.go b/cmd/helix-proxy/listen_config_test.go new file mode 100644 index 0000000..c094009 --- /dev/null +++ b/cmd/helix-proxy/listen_config_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "testing" +) + +func TestAdminListenAddr(t *testing.T) { + tests := []struct { + name string + env map[string]string + expect string + }{ + { + name: "defaults ipv6", + env: map[string]string{}, + expect: ":8081", + }, + { + name: "custom port", + env: map[string]string{"ADMIN_PORT": "9090"}, + expect: ":9090", + }, + { + name: "disable ipv6", + env: map[string]string{ + "DISABLE_IPV6": "1", + }, + expect: "0.0.0.0:8081", + }, + { + name: "custom port ipv4 only", + env: map[string]string{ + "ADMIN_PORT": "3000", + "DISABLE_IPV6": "1", + }, + expect: "0.0.0.0:3000", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("ADMIN_PORT", "") + t.Setenv("DISABLE_IPV6", "") + for k, v := range tc.env { + t.Setenv(k, v) + } + if got := adminListenAddr(); got != tc.expect { + t.Errorf("adminListenAddr() = %q want %q", got, tc.expect) + } + }) + } +} + +func TestProxyHTTPSListenAddr(t *testing.T) { + tests := []struct { + name string + env map[string]string + expect string + }{ + { + name: "defaults", + env: map[string]string{}, + expect: ":18443", + }, + { + name: "custom port", + env: map[string]string{"PROXY_HTTPS_PORT": "8443"}, + expect: ":8443", + }, + { + name: "ipv4 only", + env: map[string]string{ + "DISABLE_IPV6": "1", + "PROXY_HTTPS_PORT": "9443", + }, + expect: "0.0.0.0:9443", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("PROXY_HTTPS_PORT", "") + t.Setenv("DISABLE_IPV6", "") + for k, v := range tc.env { + t.Setenv(k, v) + } + if got := proxyHTTPSListenAddr(); got != tc.expect { + t.Errorf("proxyHTTPSListenAddr() = %q want %q", got, tc.expect) + } + }) + } +} + +func TestApplyUmaskFromEnv(t *testing.T) { + t.Run("invalid ignored", func(t *testing.T) { + t.Setenv("UMASK", "not-octal") + applyUmaskFromEnv() // should not panic + }) + t.Run("valid octal", func(t *testing.T) { + t.Setenv("UMASK", "0077") + applyUmaskFromEnv() + }) + t.Run("default when unset", func(t *testing.T) { + t.Setenv("UMASK", "") + applyUmaskFromEnv() + }) +} diff --git a/cmd/helix-proxy/main.go b/cmd/helix-proxy/main.go new file mode 100644 index 0000000..e656625 --- /dev/null +++ b/cmd/helix-proxy/main.go @@ -0,0 +1,134 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + + "helix-proxy/internal/auth" + "helix-proxy/internal/config" + "helix-proxy/internal/store" +) + +func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + if err := run(ctx); err != nil { + slog.Error("run failed", "err", err) + os.Exit(1) + } +} + +// --- permission helpers for granular object ownership + role checks (admin can do all) --- + +func isAdmin(c *auth.Claims) bool { + if c == nil { + return false + } + for _, r := range c.Roles { + if r == "admin" { + return true + } + } + return false +} + +func writeStoreError(w http.ResponseWriter, err error) { + if store.IsConflictError(err) { + http.Error(w, err.Error(), http.StatusConflict) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) +} + +func canManage(c *auth.Claims, ownerUserID int) bool { + if c == nil { + return false + } + if isAdmin(c) { + return true + } + return c.UserID == ownerUserID +} + +// applyPUIDPGID chowns the data tree (and key subdirs) if PUID/PGID envs are set +// and the process is running as root (0). This replicates the spirit of original +// entrypoint scripts for Docker non-root runs. After chown, if root, drops privs +// via Setgid+Setuid so process runs as target user (enables low ports etc only if +// needed before drop; matches summary/README "full" PUID claims). +// umask handled early in main() to affect creates. +// To support low ports (80/443) + PUID (which requires root at Listen time), set PUID_NO_DROP=1 +// (chown still done as root, but no drop -- process stays root; or use high ports/caps/entrypoint drop). +func applyPUIDPGID() error { + puidStr := os.Getenv("PUID") + pgidStr := os.Getenv("PGID") + if puidStr == "" || pgidStr == "" { + return nil + } + uid, err := strconv.Atoi(puidStr) + if err != nil { + return err + } + gid, err := strconv.Atoi(pgidStr) + if err != nil { + return err + } + if os.Getuid() != 0 { + return nil // not root, skip + } + dataDir := config.DataDir() + dirs := []string{ + dataDir, + config.Resolve("certs"), + config.Resolve("logs"), + config.Resolve("letsencrypt-acme-challenge"), + config.Resolve("www"), + } + for _, d := range dirs { + if err := os.Chown(d, uid, gid); err != nil { + slog.Warn("chown skipped", "dir", d, "err", err) + // continue for other dirs + } + // recursive for subfiles/dirs (use WalkDir for modern Go) + filepath.WalkDir(d, func(path string, de os.DirEntry, err error) error { + if err != nil { + return nil + } + if err := os.Chown(path, uid, gid); err != nil { + // ignore some + } + return nil + }) + } + slog.Info("applied PUID/PGID chown", "uid", uid, "gid", gid, "data", dataDir) + + if os.Getenv("PUID_NO_DROP") != "" { + slog.Info("PUID_NO_DROP set; skipping privilege drop after chown (remains root for low-port binds if needed)") + return nil + } + + // drop privileges (setgid before setuid; only while still root) + if err := syscall.Setgid(gid); err != nil { + slog.Warn("Setgid after chown failed (still root)", "gid", gid, "err", err) + } else if err := syscall.Setuid(uid); err != nil { + slog.Warn("Setuid after chown failed", "uid", uid, "err", err) + } else { + slog.Info("dropped to PUID/PGID (non-root)", "uid", uid, "gid", gid) + } + return nil +} + +// isBcryptPrefix detects bcrypt-hashed passwords for strict compare (no plain fallback). +// Supports common variants; used in login to prevent hash-str itself being usable as pw. +func isBcryptPrefix(pw string) bool { + return len(pw) > 0 && (strings.HasPrefix(pw, "$2a$") || strings.HasPrefix(pw, "$2b$") || strings.HasPrefix(pw, "$2y$")) +} diff --git a/cmd/helix-proxy/main_test.go b/cmd/helix-proxy/main_test.go new file mode 100644 index 0000000..7e66e2a --- /dev/null +++ b/cmd/helix-proxy/main_test.go @@ -0,0 +1,183 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "testing" + + "helix-proxy/internal/auth" +) + +func TestIsBcryptPrefix(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"", false}, + {"plain", false}, + {"$2a$10$abcdefghijklmnopqrstuv", true}, + {"$2b$12$hash", true}, + {"$2y$05$hash", true}, + {"$2x$10$hash", false}, + } + for _, tc := range cases { + if got := isBcryptPrefix(tc.in); got != tc.want { + t.Errorf("isBcryptPrefix(%q) = %v want %v", tc.in, got, tc.want) + } + } +} + +func TestIsAdminCanManage(t *testing.T) { + adminClaims := &auth.Claims{UserID: 1, Roles: []string{"admin"}} + userClaims := &auth.Claims{UserID: 2, Roles: []string{"user"}} + nilClaims := (*auth.Claims)(nil) + + if !isAdmin(adminClaims) { + t.Error("admin should be admin") + } + if isAdmin(userClaims) { + t.Error("user not admin") + } + if isAdmin(nilClaims) { + t.Error("nil not admin") + } + + if !canManage(adminClaims, 99) { + t.Error("admin can manage any") + } + if !canManage(userClaims, 2) { + t.Error("owner can manage self") + } + if canManage(userClaims, 3) { + t.Error("user cannot manage other") + } + if canManage(nilClaims, 1) { + t.Error("nil cannot") + } +} + +func TestSingleAdminLoginDevModeSkipsMustChange(t *testing.T) { + t.Setenv("PROXY_MODE", "development") + env := newAPITestServer(t) + resp := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "password"}) + if resp.StatusCode != 200 { + t.Fatalf("default login failed: %d", resp.StatusCode) + } + var lr struct { + Token string `json:"token"` + MustChangePassword bool `json:"mustChangePassword"` + } + json.NewDecoder(resp.Body).Decode(&lr) + if lr.Token == "" || lr.MustChangePassword { + t.Errorf("dev mode default login should not require password change: %+v", lr) + } +} + +func TestSingleAdminLogin(t *testing.T) { + t.Setenv("PROXY_MODE", "") + env := newAPITestServer(t) + st := env.Store + + // initial default "password" login returns mustChangePassword + resp := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "password"}) + if resp.StatusCode != 200 { + t.Fatalf("default login failed: %d", resp.StatusCode) + } + var lr struct { + Token string `json:"token"` + User map[string]any `json:"user"` + MustChangePassword bool `json:"mustChangePassword"` + } + json.NewDecoder(resp.Body).Decode(&lr) + if lr.Token == "" || lr.User["id"] != float64(1) || !lr.MustChangePassword { + t.Errorf("default login resp bad: %+v", lr) + } + + // wrong default fails + respBad := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "wrong"}) + if respBad.StatusCode != 401 { + t.Errorf("wrong default should 401: %d", respBad.StatusCode) + } + + // use the returned token to change password (initial case) + token := lr.Token + authH := func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+token) } + chResp := postJSONAuth(t, env.BaseURL+"/api/users/me/password", map[string]any{"currentPassword": "password", "newPassword": "newpass123"}, authH) + if chResp.StatusCode != 200 { + t.Fatalf("initial pw change failed: %d", chResp.StatusCode) + } + + // after change, login with new pw succeeds, no mustChange + resp2 := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "newpass123"}) + if resp2.StatusCode != 200 { + t.Fatalf("new pw login failed: %d", resp2.StatusCode) + } + var lr2 struct { + Token string `json:"token"` + MustChangePassword bool `json:"mustChangePassword"` + } + json.NewDecoder(resp2.Body).Decode(&lr2) + if lr2.MustChangePassword { + t.Error("after change, mustChange should be false") + } + + // confirm stored as bcrypt in the test store + uu, _ := st.GetUserByEmail("admin@example.com") + if uu.Password == "" || !isBcryptPrefix(uu.Password) { + t.Error("password not stored as bcrypt after change") + } + + // changing again requires correct current + token2 := lr2.Token + authH2 := func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+token2) } + badCur := postJSONAuth(t, env.BaseURL+"/api/users/me/password", map[string]any{"currentPassword": "wrong", "newPassword": "another"}, authH2) + if badCur.StatusCode != 401 { + t.Errorf("bad current on change should 401: %d", badCur.StatusCode) + } + goodCh := postJSONAuth(t, env.BaseURL+"/api/users/me/password", map[string]any{"currentPassword": "newpass123", "newPassword": "finalpass"}, authH2) + if goodCh.StatusCode != 200 { + t.Errorf("subsequent change failed: %d", goodCh.StatusCode) + } +} + +func postJSON(t *testing.T, url string, body any) *http.Response { + t.Helper() + b, _ := json.Marshal(body) + req, _ := http.NewRequest("POST", url, bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + return doReqClient(req) +} + +func postJSONAuth(t *testing.T, url string, body any, authSetter func(*http.Request)) *http.Response { + t.Helper() + b, _ := json.Marshal(body) + req, _ := http.NewRequest("POST", url, bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + authSetter(req) + return doReqClient(req) +} + +func doReqAuth(method, url string, body any, authSetter func(*http.Request)) *http.Response { + var br io.Reader + if body != nil { + bb, _ := json.Marshal(body) + br = bytes.NewReader(bb) + } + req, _ := http.NewRequest(method, url, br) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + authSetter(req) + return doReqClient(req) +} + +func doReqClient(req *http.Request) *http.Response { + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + panic(err) + } + return resp +} diff --git a/cmd/helix-proxy/router.go b/cmd/helix-proxy/router.go new file mode 100644 index 0000000..c26fa94 --- /dev/null +++ b/cmd/helix-proxy/router.go @@ -0,0 +1,65 @@ +package main + +import ( + "errors" + "fmt" + "io/fs" + "net/http" + + "helix-proxy/internal/auth" + "helix-proxy/internal/certificate" + "helix-proxy/internal/config" + "helix-proxy/internal/proxy" + "helix-proxy/internal/store" + "helix-proxy/ui" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +// newAdminRouter builds the admin UI + API chi router (no listeners started). +func newAdminRouter(st store.Store, eng *proxy.Engine, cm *certificate.Manager, jwtMgr *auth.JWTManager) chi.Router { + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + mountAPI(r, st, eng, cm, jwtMgr) + mountSPA(r) + return r +} + +// mountSPA serves the embedded Svelte SPA or a build placeholder. +func mountSPA(r chi.Router) { + spaSub, err := fs.Sub(ui.Dist, "dist") + if err != nil { + r.Get("/*", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, ` +Helix Proxy + +

Helix Proxy

+

UI not built yet — run make ui-build then make (or go build).

+

Health: /api (cwd-relative data at `+config.DataDir()+`)

+

All paths (data, www/html, certs, logs...) default to ./data/... and ./data/www/ relative to the process CWD. Override with DATA_DIR, WWW_DIR etc.

+`) + }) + return + } + + fileServer := http.FileServer(http.FS(spaSub)) + r.Get("/*", func(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + if p != "" && p[0] == '/' { + p = p[1:] + } + if p == "" { + p = "index.html" + } + if _, statErr := fs.Stat(spaSub, p); errors.Is(statErr, fs.ErrNotExist) { + r.URL.Path = "/" + } + fileServer.ServeHTTP(w, r) + }) +} diff --git a/cmd/helix-proxy/router_test.go b/cmd/helix-proxy/router_test.go new file mode 100644 index 0000000..a8842be --- /dev/null +++ b/cmd/helix-proxy/router_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "net/http" + "strings" + "testing" +) + +func TestAdminRouter_SPAFallback(t *testing.T) { + env := newAdminTestServer(t) + + resp := apiGet(t, env.BaseURL+"/api/", "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("api health: %d", resp.StatusCode) + } + + resp = apiGet(t, env.BaseURL+"/", "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("spa root: %d", resp.StatusCode) + } + body := readBody(resp) + if !strings.Contains(body, "Helix Proxy") { + t.Errorf("spa body missing title: %q", body[:min(120, len(body))]) + } + + resp = apiGet(t, env.BaseURL+"/some/spa/route", "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("spa route: %d", resp.StatusCode) + } +} + +func TestApplyPUIDPGID(t *testing.T) { + t.Run("no env", func(t *testing.T) { + t.Setenv("PUID", "") + t.Setenv("PGID", "") + if err := applyPUIDPGID(); err != nil { + t.Errorf("empty env: %v", err) + } + }) + + t.Run("invalid PUID", func(t *testing.T) { + t.Setenv("PUID", "not-a-number") + t.Setenv("PGID", "1000") + if err := applyPUIDPGID(); err == nil { + t.Error("expected error for invalid PUID") + } + }) + + t.Run("invalid PGID", func(t *testing.T) { + t.Setenv("PUID", "1000") + t.Setenv("PGID", "bad") + if err := applyPUIDPGID(); err == nil { + t.Error("expected error for invalid PGID") + } + }) + + t.Run("non-root skips chown", func(t *testing.T) { + t.Setenv("PUID", "1000") + t.Setenv("PGID", "1000") + // tests run unprivileged; Getuid() != 0 => no chown, no error + if err := applyPUIDPGID(); err != nil { + t.Errorf("non-root should skip: %v", err) + } + }) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/cmd/helix-proxy/run.go b/cmd/helix-proxy/run.go new file mode 100644 index 0000000..ae9473b --- /dev/null +++ b/cmd/helix-proxy/run.go @@ -0,0 +1,132 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "time" + + "helix-proxy/internal/auth" + "helix-proxy/internal/certificate" + "helix-proxy/internal/config" + "helix-proxy/internal/proxy" + "helix-proxy/internal/store" +) + +// run boots the admin API, proxy engine, HTTPS listener, and renewal loop until ctx is cancelled. +func run(ctx context.Context) error { + applyUmaskFromEnv() + + if err := config.EnsureDataDirs(); err != nil { + return err + } + + if err := applyPUIDPGID(); err != nil { + slog.Warn("PUID/PGID apply partial", "err", err) + } + + dbPath := config.Resolve("db.bolt") + slog.Info("using data dir", "data", config.DataDir(), "db", dbPath) + + if config.IsDevelopment() { + slog.Warn("PROXY_MODE=development (all letsencrypt certs will be self-signed test certs; not for production use)") + } else { + slog.Info("production mode (real LE only; no test cert simulation)") + } + + st, err := store.New() + if err != nil { + return err + } + + eng := proxy.NewEngine(st) + eng.ReloadFromStore() + + cm := certificate.NewManager(st) + jwtMgr := auth.NewJWTManager(os.Getenv("JWT_SECRET")) + + go startRenewalLoop(ctx, cm, eng) + + r := newAdminRouter(st, eng, cm, jwtMgr) + + adminAddr := adminListenAddr() + srv := &http.Server{ + Addr: adminAddr, + Handler: r, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + slog.Info("admin UI + API listening", "addr", adminAddr, "note", "use ADMIN_PORT=81 in privileged env or docker for real port") + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("server error", "err", err) + } + }() + + proxyCtx, proxyCancel := context.WithCancel(ctx) + defer proxyCancel() + + go func() { + if err := eng.Start(proxyCtx); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("proxy engine stopped", "err", err) + } + }() + + go func() { + httpsAddr := proxyHTTPSListenAddr() + slog.Info("starting HTTPS listener for cert-enabled hosts (pure Go tls termination)", "addr", httpsAddr) + if err := serveHTTPS(proxyCtx, eng, httpsAddr); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("https server error", "err", err) + } + }() + + <-ctx.Done() + + slog.Info("shutting down...") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + proxyCancel() + _ = srv.Shutdown(shutdownCtx) + slog.Info("stopped") + return nil +} + +// startRenewalLoop periodically renews LE certs until ctx is cancelled. +func startRenewalLoop(ctx context.Context, cm *certificate.Manager, eng *proxy.Engine) { + initialDelay := 5 * time.Second + if d := os.Getenv("RENEWAL_INITIAL_DELAY"); d != "" { + if parsed, err := time.ParseDuration(d); err == nil { + initialDelay = parsed + } + } + + renew := func() { + cm.ProcessRenewals() + eng.ReloadFromStore() + } + + go func() { + select { + case <-ctx.Done(): + return + case <-time.After(initialDelay): + renew() + } + }() + + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + renew() + } + } +} diff --git a/cmd/helix-proxy/run_test.go b/cmd/helix-proxy/run_test.go new file mode 100644 index 0000000..7686311 --- /dev/null +++ b/cmd/helix-proxy/run_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "net/http" + "strconv" + "testing" + "time" + + "helix-proxy/internal/config" +) + +func TestRun_BootstrapAndShutdown(t *testing.T) { + t.Setenv("PROXY_MODE", "development") + t.Setenv("DISABLE_IPV6", "1") + t.Setenv("RENEWAL_INITIAL_DELAY", "1h") // avoid renewal work during short test + + adminPort := pickFreePort(t) + httpPort := pickFreePort(t) + httpsPort := pickFreePort(t) + t.Setenv("ADMIN_PORT", strconv.Itoa(adminPort)) + t.Setenv("PROXY_HTTP_PORT", strconv.Itoa(httpPort)) + t.Setenv("PROXY_HTTPS_PORT", strconv.Itoa(httpsPort)) + + tmp := t.TempDir() + config.ResetForTest() + t.Setenv("DATA_DIR", tmp) + + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { + errCh <- run(ctx) + }() + + adminAddr := "127.0.0.1:" + strconv.Itoa(adminPort) + waitForTCP(t, adminAddr, 5*time.Second) + waitForTCP(t, "127.0.0.1:"+strconv.Itoa(httpPort), 5*time.Second) + waitForTCP(t, "127.0.0.1:"+strconv.Itoa(httpsPort), 5*time.Second) + + resp, err := http.Get("http://" + adminAddr + "/api/") + if err != nil { + t.Fatalf("admin health: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("admin health status: %d", resp.StatusCode) + } + _ = resp.Body.Close() + + cancel() + + select { + case err := <-errCh: + if err != nil { + t.Fatalf("run returned error: %v", err) + } + case <-time.After(15 * time.Second): + t.Fatal("run did not exit after context cancel") + } +} + +func TestRun_EnsureDataDirsFailure(t *testing.T) { + config.ResetForTest() + t.Setenv("DATA_DIR", "/nonexistent-root/helix-proxy-test-"+strconv.Itoa(pickFreePort(t))) + // Parent does not exist and cannot be created under /nonexistent-root. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := run(ctx) + if err == nil { + t.Fatal("expected error when data dirs cannot be created") + } +} diff --git a/cmd/helix-proxy/startup_test.go b/cmd/helix-proxy/startup_test.go new file mode 100644 index 0000000..659e5f3 --- /dev/null +++ b/cmd/helix-proxy/startup_test.go @@ -0,0 +1,146 @@ +package main + +import ( + "context" + "crypto/tls" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + "time" + + "helix-proxy/internal/store" +) + +func pickFreePort(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + port := ln.Addr().(*net.TCPAddr).Port + _ = ln.Close() + return port +} + +func waitForTCP(t *testing.T, addr string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) + if err == nil { + _ = conn.Close() + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("tcp %s not ready within %s", addr, timeout) +} + +func TestEngineStartup_HTTPAndHTTPS(t *testing.T) { + t.Setenv("DISABLE_IPV6", "1") + httpPort := pickFreePort(t) + httpsPort := pickFreePort(t) + t.Setenv("PROXY_HTTP_PORT", strconv.Itoa(httpPort)) + t.Setenv("PROXY_HTTPS_PORT", strconv.Itoa(httpsPort)) + + env := newAPITestServer(t) + token := env.loginToken(t) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Upstream", "ok") + _, _ = io.WriteString(w, "upstream-body") + })) + t.Cleanup(upstream.Close) + + u, err := url.Parse(upstream.URL) + if err != nil { + t.Fatalf("parse upstream: %v", err) + } + host, portStr, _ := net.SplitHostPort(u.Host) + port, _ := strconv.Atoi(portStr) + + domain := "startup-tls.example" + certResp := apiPost(t, env.BaseURL+"/api/certificates", map[string]any{ + "provider": "letsencrypt", + "domainNames": []string{domain}, + }, token) + if certResp.StatusCode != http.StatusCreated { + t.Fatalf("cert create: %d %s", certResp.StatusCode, readBody(certResp)) + } + var cert store.Certificate + decodeJSON(t, certResp, &cert) + + phResp := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{ + "domainNames": []string{domain}, + "forwardHost": host, + "forwardPort": port, + "forwardScheme": "http", + "certificateId": cert.ID, + "enabled": true, + }, token) + if phResp.StatusCode != http.StatusCreated { + t.Fatalf("proxy host: %d %s", phResp.StatusCode, readBody(phResp)) + } + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + httpAddr := "127.0.0.1:" + strconv.Itoa(httpPort) + httpsAddr := proxyHTTPSListenAddr() + + go func() { + _ = env.Engine.Start(ctx) + }() + go func() { + _ = serveHTTPS(ctx, env.Engine, httpsAddr) + }() + + waitForTCP(t, httpAddr, 5*time.Second) + waitForTCP(t, "127.0.0.1:"+strconv.Itoa(httpsPort), 5*time.Second) + + req, err := http.NewRequest(http.MethodGet, "http://"+httpAddr+"/", nil) + if err != nil { + t.Fatalf("request: %v", err) + } + req.Host = domain + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("http proxy: %v", err) + } + body := readBody(resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("http status: %d body=%s", resp.StatusCode, body) + } + if body != "upstream-body" { + t.Errorf("http body: %q", body) + } + + httpsClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + ServerName: domain, + InsecureSkipVerify: true, + }, + }, + } + httpsReq, err := http.NewRequest(http.MethodGet, "https://127.0.0.1:"+strconv.Itoa(httpsPort)+"/", nil) + if err != nil { + t.Fatalf("https request: %v", err) + } + httpsReq.Host = domain + httpsResp, err := httpsClient.Do(httpsReq) + if err != nil { + t.Fatalf("https proxy: %v", err) + } + httpsBody := readBody(httpsResp) + if httpsResp.StatusCode != http.StatusOK { + t.Fatalf("https status: %d body=%s", httpsResp.StatusCode, httpsBody) + } + if httpsBody != "upstream-body" { + t.Errorf("https body: %q", httpsBody) + } +} diff --git a/cmd/helix-proxy/test_server_test.go b/cmd/helix-proxy/test_server_test.go new file mode 100644 index 0000000..cd25e77 --- /dev/null +++ b/cmd/helix-proxy/test_server_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "helix-proxy/internal/auth" + "helix-proxy/internal/certificate" + "helix-proxy/internal/config" + "helix-proxy/internal/proxy" + "helix-proxy/internal/store" + + "github.com/go-chi/chi/v5" +) + +// testServerMode selects API-only vs full admin router (API + SPA). +type testServerMode int + +const ( + testServerAPI testServerMode = iota + testServerAdmin +) + +type apiTestEnv struct { + BaseURL string + Store store.Store + Engine *proxy.Engine + Certs *certificate.Manager + JWT *auth.JWTManager +} + +// newTestServer boots a temp store/engine and httptest server with the chosen router. +func newTestServer(t *testing.T, mode testServerMode) *apiTestEnv { + t.Helper() + if _, ok := os.LookupEnv("PROXY_MODE"); !ok { + t.Setenv("PROXY_MODE", "development") + } + tmp := t.TempDir() + config.ResetForTest() + t.Setenv("DATA_DIR", tmp) + if err := config.EnsureDataDirs(); err != nil { + t.Fatalf("ensure dirs: %v", err) + } + st, err := store.New() + if err != nil { + t.Fatalf("store: %v", err) + } + eng := proxy.NewEngine(st) + eng.ReloadFromStore() + cm := certificate.NewManager(st) + jwtMgr := auth.NewJWTManager("test-jwt-secret") + + var handler http.Handler + switch mode { + case testServerAdmin: + handler = newAdminRouter(st, eng, cm, jwtMgr) + default: + r := chi.NewRouter() + mountAPI(r, st, eng, cm, jwtMgr) + handler = r + } + + srv := httptest.NewServer(handler) + t.Cleanup(func() { + srv.Close() + if c, ok := st.(interface{ Close() error }); ok { + _ = c.Close() + } + }) + return &apiTestEnv{ + BaseURL: srv.URL, + Store: st, + Engine: eng, + Certs: cm, + JWT: jwtMgr, + } +} + +func newAPITestServer(t *testing.T) *apiTestEnv { + return newTestServer(t, testServerAPI) +} + +func newAdminTestServer(t *testing.T) *apiTestEnv { + return newTestServer(t, testServerAdmin) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b18a144 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + app: + build: . + image: helix-proxy:dev + restart: unless-stopped + ports: + - "80:80" + - "81:81" + - "443:443" + volumes: + - ./data:/app/data + # user: "0:0" # required when using PUID/PGID != built-in to allow binary to chown+drop + # working_dir: /app # binary uses CWD for relative data/ + data/www/ + # environment: + # - DATA_DIR=/app/data + # - WWW_DIR=/app/data/www + # - PUID=1000 + # - PGID=1000 + # - DISABLE_IPV6=1 + # # PUID_NO_DROP=1 # if using low ports (80/443) + PUID: chown as root but skip drop (stay root for bind; default drop runs as PUID after, requires high ports or NET_BIND_SERVICE cap) + # To use optional SQL backend instead of default yaml: + # - DB_TYPE=postgres + # - DB_POSTGRES_HOST=db + # ... and add a db service + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:81/api"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..95502f4 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module helix-proxy + +go 1.25.4 + +require ( + github.com/go-acme/lego/v4 v4.35.2 + github.com/go-chi/chi/v5 v5.3.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + go.etcd.io/bbolt v1.3.10 + golang.org/x/crypto v0.50.0 + golang.org/x/net v0.53.0 +) + +require ( + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/miekg/dns v1.1.72 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.44.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ec4ddff --- /dev/null +++ b/go.sum @@ -0,0 +1,38 @@ +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU= +github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0= +go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..676e2b6 --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,131 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var ( + ErrInvalidToken = errors.New("invalid or expired token") + ErrNoToken = errors.New("no authorization token") +) + +// Claims for our JWT (minimal, matching original style). +type Claims struct { + UserID int `json:"user_id"` + Email string `json:"email"` + Name string `json:"name"` + Roles []string `json:"roles"` + jwt.RegisteredClaims +} + +// Context key for user info. +type contextKey string + +const UserContextKey contextKey = "user" + +// JWTManager handles signing and validation. +type JWTManager struct { + secret []byte +} + +// NewJWTManager creates a manager. In real use, load secret from secure store or env (never commit real secret). +// For demo we accept a secret; in production rotate and use env/JWT_SECRET or db meta. +func NewJWTManager(secret string) *JWTManager { + if secret == "" { + secret = "dev-only-insecure-secret-change-in-prod" + } + return &JWTManager{secret: []byte(secret)} +} + +// GenerateToken creates a JWT for the given user (1 hour expiry like typical). +func (m *JWTManager) GenerateToken(userID int, email, name string, roles []string) (string, error) { + now := time.Now() + if roles == nil { + roles = []string{} + } + claims := Claims{ + UserID: userID, + Email: email, + Name: name, + Roles: roles, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(1 * time.Hour)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + Issuer: "helix-proxy", + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(m.secret) +} + +// ValidateToken parses and validates the token, returns claims. +func (m *JWTManager) ValidateToken(tokenStr string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return m.secret, nil + }) + if err != nil { + return nil, ErrInvalidToken + } + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + return nil, ErrInvalidToken +} + +// Middleware returns a chi/http middleware that validates Bearer token and injects user into context. +// Skips if no token (for public endpoints like /login we handle separately). +// On failure returns 401. +func (m *JWTManager) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + // No token: let handler decide (some endpoints public) + next.ServeHTTP(w, r) + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + http.Error(w, "invalid authorization header", http.StatusUnauthorized) + return + } + + claims, err := m.ValidateToken(parts[1]) + if err != nil { + http.Error(w, "invalid token", http.StatusUnauthorized) + return + } + + // Inject into context + ctx := context.WithValue(r.Context(), UserContextKey, claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// GetUserFromContext extracts claims if present (for handlers that require auth). +func GetUserFromContext(ctx context.Context) (*Claims, bool) { + claims, ok := ctx.Value(UserContextKey).(*Claims) + return claims, ok +} + +// RequireAuth is a simple helper that can be used inside handlers for protected routes +// (alternative to middleware if you want per-route). +func RequireAuth(w http.ResponseWriter, r *http.Request) (*Claims, bool) { + claims, ok := GetUserFromContext(r.Context()) + if !ok { + http.Error(w, "authentication required", http.StatusUnauthorized) + return nil, false + } + return claims, true +} diff --git a/internal/auth/jwt_test.go b/internal/auth/jwt_test.go new file mode 100644 index 0000000..faf6b68 --- /dev/null +++ b/internal/auth/jwt_test.go @@ -0,0 +1,128 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestJWTManager_GenerateValidate_Roundtrip(t *testing.T) { + m := NewJWTManager("test-secret-123") + token, err := m.GenerateToken(42, "user@example.com", "User", []string{"admin", "user"}) + if err != nil { + t.Fatal(err) + } + if token == "" { + t.Error("empty token") + } + + claims, err := m.ValidateToken(token) + if err != nil { + t.Fatalf("validate: %v", err) + } + if claims.UserID != 42 || claims.Email != "user@example.com" || len(claims.Roles) != 2 { + t.Errorf("claims: %+v", claims) + } + if claims.ExpiresAt == nil || time.Until(claims.ExpiresAt.Time) > time.Hour { + t.Error("expiry not ~1h") + } +} + +func TestJWTManager_DevSecretFallback(t *testing.T) { + m := NewJWTManager("") // empty -> dev + token, _ := m.GenerateToken(1, "a@b", "A", nil) + _, err := m.ValidateToken(token) + if err != nil { + t.Error("dev secret should allow roundtrip") + } +} + +func TestJWTManager_BadToken(t *testing.T) { + m := NewJWTManager("s") + if _, err := m.ValidateToken("not.a.jwt"); err == nil { + t.Error("bad token should err") + } + // wrong sig + m2 := NewJWTManager("other") + tok, _ := m2.GenerateToken(1, "e", "n", nil) + if _, err := m.ValidateToken(tok); err == nil { + t.Error("wrong sig should invalid") + } +} + +func TestJWTManager_Middleware(t *testing.T) { + m := NewJWTManager("sec") + token, _ := m.GenerateToken(7, "m@e", "M", []string{"admin"}) + + // with valid bearer + h := m.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, ok := GetUserFromContext(r.Context()) + if !ok || c.UserID != 7 { + t.Error("claims not in ctx") + } + w.WriteHeader(200) + })) + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != 200 { + t.Errorf("valid bearer: %d", rr.Code) + } + + // no token: passes through (no 401) + h2 := m.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(204) + })) + req2 := httptest.NewRequest("GET", "/", nil) + rr2 := httptest.NewRecorder() + h2.ServeHTTP(rr2, req2) + if rr2.Code != 204 { + t.Error("no token should pass") + } + + // bad token: 401 + req3 := httptest.NewRequest("GET", "/", nil) + req3.Header.Set("Authorization", "Bearer bad") + rr3 := httptest.NewRecorder() + h.ServeHTTP(rr3, req3) + if rr3.Code != 401 { + t.Errorf("bad token 401: %d", rr3.Code) + } + + // wrong scheme + req4 := httptest.NewRequest("GET", "/", nil) + req4.Header.Set("Authorization", "Basic foo") + rr4 := httptest.NewRecorder() + h.ServeHTTP(rr4, req4) + if rr4.Code != 401 { + t.Errorf("bad scheme: %d", rr4.Code) + } +} + +func TestRequireAuth(t *testing.T) { + // direct, needs ctx with claims + m := NewJWTManager("s") + tok, _ := m.GenerateToken(99, "r@a", "R", nil) + claims, _ := m.ValidateToken(tok) + + req := httptest.NewRequest("GET", "/", nil) + // without ctx + rr := httptest.NewRecorder() + c, ok := RequireAuth(rr, req) + if ok || c != nil || rr.Code != 401 { + t.Error("require without claims should 401") + } + + // with ctx set via context value + req2 := httptest.NewRequest("GET", "/", nil) + ctx := context.WithValue(req2.Context(), UserContextKey, claims) + req2 = req2.WithContext(ctx) + rr2 := httptest.NewRecorder() + c2, ok2 := RequireAuth(rr2, req2) + if !ok2 || c2.UserID != 99 { + t.Error("require with claims failed") + } +} diff --git a/internal/certificate/manager.go b/internal/certificate/manager.go new file mode 100644 index 0000000..f192b41 --- /dev/null +++ b/internal/certificate/manager.go @@ -0,0 +1,365 @@ +package certificate + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "log/slog" + "math/big" + "os" + "path/filepath" + "strings" + "time" + + "github.com/go-acme/lego/v4/certcrypto" + "github.com/go-acme/lego/v4/certificate" + "github.com/go-acme/lego/v4/lego" + "github.com/go-acme/lego/v4/providers/http/webroot" + "github.com/go-acme/lego/v4/registration" + + "helix-proxy/internal/config" + "helix-proxy/internal/store" +) + +// Manager handles Let's Encrypt (and future other) certificate issuance and renewal using lego. +// It is the pure-Go certificate issuance manager (using lego for LE, no external certbot). +type Manager struct { + st store.Store +} + +// NewManager returns a cert manager bound to the store (for read/update of Certificate records). +func NewManager(st store.Store) *Manager { + return &Manager{st: st} +} + +// account represents a lego user/account. We generate ephemeral keys for v1 (new LE account per issue). +// For production persistence of account key, we could save to data/certs/lego-account..key . +type account struct { + Email string + Registration *registration.Resource + key crypto.PrivateKey +} + +func (a *account) GetEmail() string { + return a.Email +} +func (a *account) GetRegistration() *registration.Resource { + return a.Registration +} +func (a *account) GetPrivateKey() crypto.PrivateKey { + return a.key +} + +// Issue obtains (or renews) a certificate for the given store.Certificate record (must have provider=="letsencrypt"). +// It writes PEMs into the cert's Meta["cert"] and Meta["key"] (for engine loadCerts compatibility with custom certs), +// updates expires_on, and persists via store.SetCertificate. +// It also optionally writes PEM files under data/certs// for inspection/backup. +// email is the contact for LE registration (required for LE); falls back to "admin@example.com" if empty. +// Respects meta["staging"]=true or env LEGO_STAGING=1 to use staging (recommended for testing to avoid rate limits). +// Respects meta["key_type"] = "rsa" | "ecdsa" (default rsa2048). +// If PROXY_MODE=development, uses self-signed test cert for *all* LE certs (no domain tricks). +func (m *Manager) Issue(sc store.Certificate, email string) error { + if sc.Provider != "letsencrypt" { + return fmt.Errorf("not a letsencrypt certificate (provider=%s)", sc.Provider) + } + if len(sc.DomainNames) == 0 { + return fmt.Errorf("no domain_names for certificate %d", sc.ID) + } + if email == "" { + email = "admin@example.com" + } + + // In development mode (PROXY_MODE=development), *all* letsencrypt certs use the + // self-signed test path (no real LE). This is for dev/testing only. + // In production (default), real LE http-01 is always attempted. + if config.IsDevelopment() { + slog.Info("using self-signed test cert (LE simulation; all certs in dev mode)", "domains", sc.DomainNames) + return m.issueTestCert(sc) + } + + staging := false + if sc.Meta != nil { + if v, ok := sc.Meta["staging"]; ok { + switch t := v.(type) { + case bool: + staging = t + case string: + staging = strings.ToLower(t) == "true" || t == "1" + } + } + } + if os.Getenv("LEGO_STAGING") != "" || os.Getenv("LE_STAGING") != "" { + staging = true + } + + keyType := certcrypto.RSA2048 + if sc.Meta != nil { + if kt, ok := sc.Meta["key_type"].(string); ok { + switch strings.ToLower(kt) { + case "ecdsa", "ec256", "p256": + keyType = certcrypto.EC256 + case "rsa4096": + keyType = certcrypto.RSA4096 + } + } + } + + // Create lego account (ephemeral key; see note above) + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("generate account key: %w", err) + } + acc := &account{ + Email: email, + key: privateKey, + } + + cfg := lego.NewConfig(acc) + if staging { + cfg.CADirURL = lego.LEDirectoryStaging + slog.Info("using Let's Encrypt staging for certificate issue", "certID", sc.ID, "domains", sc.DomainNames) + } else { + cfg.CADirURL = lego.LEDirectoryProduction + } + cfg.Certificate.KeyType = keyType + + client, err := lego.NewClient(cfg) + if err != nil { + return fmt.Errorf("lego client: %w", err) + } + + // http-01 via our served challenge dir (lego writes token files, our proxy serves them) + webrootPath := config.Resolve("letsencrypt-acme-challenge") + // ensure layout exists + _ = os.MkdirAll(filepath.Join(webrootPath, ".well-known", "acme-challenge"), 0o755) + + provider, err := webroot.NewHTTPProvider(webrootPath) + if err != nil { + return fmt.Errorf("webroot provider: %w", err) + } + if err := client.Challenge.SetHTTP01Provider(provider); err != nil { + return fmt.Errorf("set http01 provider: %w", err) + } + + // Register account (or resolve). For ephemeral this always creates a fresh LE account. + reg, err := client.Registration.Register(registration.RegisterOptions{ + TermsOfServiceAgreed: true, + }) + if err != nil { + // If already registered for key (rare here), try resolve; otherwise fail + if reg2, err2 := client.Registration.ResolveAccountByKey(); err2 == nil { + reg = reg2 + } else { + return fmt.Errorf("le register: %w", err) + } + } + acc.Registration = reg + + slog.Info("requesting LE certificate", "certID", sc.ID, "domains", sc.DomainNames, "staging", staging, "email", email) + + request := certificate.ObtainRequest{ + Domains: sc.DomainNames, + Bundle: true, + } + certs, err := client.Certificate.Obtain(request) + if err != nil { + return fmt.Errorf("obtain cert: %w", err) + } + + // Populate PEMs in meta (used by engine for tls.Certificate and by custom cert path) + // Use compat keys "certificate"/"certificate_key" + "cert"/"key" (for data format compatibility) + if sc.Meta == nil { + sc.Meta = map[string]any{} + } + sc.Meta["certificate"] = string(certs.Certificate) + sc.Meta["certificate_key"] = string(certs.PrivateKey) + sc.Meta["cert"] = string(certs.Certificate) + sc.Meta["key"] = string(certs.PrivateKey) + // keep original meta bits like dns_challenge etc. + sc.Meta["letsencrypt"] = map[string]any{ + "account": email, + "staging": staging, + "issued_at": time.Now().UTC().Format(time.RFC3339), + } + + // Compute real expiry from the returned leaf cert (more reliable than LE response) + expires := "" + if block, _ := pem.Decode(certs.Certificate); block != nil { + if xc, err := x509.ParseCertificate(block.Bytes); err == nil { + expires = xc.NotAfter.UTC().Format("2006-01-02 15:04:05") + sc.Meta["cert_info"] = map[string]any{ + "not_after": xc.NotAfter.UTC().Format(time.RFC3339), + "issuer": xc.Issuer.String(), + "subject": xc.Subject.String(), + } + } + } + if expires == "" { + expires = time.Now().Add(90 * 24 * time.Hour).Format("2006-01-02 15:04:05") // fallback ~90d + } + sc.ExpiresOn = expires + + // Persist files too (under data/certs//) for serving live certs + certDir := filepath.Join(config.Resolve("certs"), fmt.Sprintf("%d", sc.ID)) + if err := os.MkdirAll(certDir, 0o755); err == nil { + _ = os.WriteFile(filepath.Join(certDir, "fullchain.pem"), certs.Certificate, 0o600) + _ = os.WriteFile(filepath.Join(certDir, "privkey.pem"), certs.PrivateKey, 0o600) + } + + // Save back to store (updates meta + expires) + if err := m.st.SetCertificate(sc); err != nil { + return fmt.Errorf("store set cert after issue: %w", err) + } + + slog.Info("LE certificate obtained and stored", "certID", sc.ID, "domains", sc.DomainNames, "expires", sc.ExpiresOn) + return nil +} + +// GetEmailForCert tries to find a reasonable contact email for LE from the cert meta or first admin user. +func (m *Manager) GetEmailForCert(sc store.Certificate) string { + if sc.Meta != nil { + if e, ok := sc.Meta["email"].(string); ok && e != "" { + return e + } + if e, ok := sc.Meta["letsencrypt_email"].(string); ok && e != "" { + return e + } + } + // fallback to global setting or seeded admin + if settings := m.st.GetSettings(); settings != nil { + if e, ok := settings["letsencrypt_email"].(string); ok && e != "" { + return e + } + } + users := m.st.GetUsers() + for _, u := range users { + if u.Email != "" { + return u.Email + } + } + return "admin@example.com" +} + +// ShouldRenew reports whether a LE cert is within the renewal window (30 days before expiry). +func (m *Manager) ShouldRenew(sc store.Certificate) bool { + if sc.Provider != "letsencrypt" { + return false + } + if sc.ExpiresOn == "" { + return true + } + t, err := time.Parse("2006-01-02 15:04:05", sc.ExpiresOn) + if err != nil { + // try RFC3339 etc + if t2, err2 := time.Parse(time.RFC3339, sc.ExpiresOn); err2 == nil { + t = t2 + } else { + return true + } + } + renewBefore := 30 * 24 * time.Hour + return time.Until(t) < renewBefore +} + +// ProcessRenewals finds LE certs due for renewal and re-issues them (sequentially). +// Called by timer and on demand. +func (m *Manager) ProcessRenewals() { + certs := m.st.GetCertificates() + for _, c := range certs { + if c.Provider == "letsencrypt" && m.ShouldRenew(c) { + slog.Info("renewing expiring LE cert", "id", c.ID, "domains", c.DomainNames, "expires", c.ExpiresOn) + email := m.GetEmailForCert(c) + if err := m.Issue(c, email); err != nil { + slog.Error("renewal failed (will retry later)", "id", c.ID, "err", err) + continue + } + } + } +} + +// --- dev-mode test helpers (all letsencrypt certs become self-signed test certs when PROXY_MODE=development) --- + +func (m *Manager) issueTestCert(sc store.Certificate) error { + certPEM, keyPEM, err := generateSelfSignedTestCert(sc.DomainNames) + if err != nil { + return err + } + + if sc.Meta == nil { + sc.Meta = map[string]any{} + } + sc.Meta["certificate"] = string(certPEM) + sc.Meta["certificate_key"] = string(keyPEM) + sc.Meta["cert"] = string(certPEM) + sc.Meta["key"] = string(keyPEM) + sc.Meta["test_cert"] = true // marker that this was dev sim, not real LE + sc.Meta["letsencrypt"] = map[string]any{ + "account": "test@localhost", + "staging": true, + "issued_at": time.Now().UTC().Format(time.RFC3339), + "note": "self-signed simulation (only in PROXY_MODE=development)", + } + + // parse for expires + expires := "" + if block, _ := pem.Decode(certPEM); block != nil { + if xc, err := x509.ParseCertificate(block.Bytes); err == nil { + expires = xc.NotAfter.UTC().Format("2006-01-02 15:04:05") + sc.Meta["cert_info"] = map[string]any{ + "not_after": xc.NotAfter.UTC().Format(time.RFC3339), + "issuer": xc.Issuer.String(), + "subject": xc.Subject.String(), + } + } + } + if expires == "" { + expires = time.Now().Add(90 * 24 * time.Hour).Format("2006-01-02 15:04:05") + } + sc.ExpiresOn = expires + + // write files + certDir := filepath.Join(config.Resolve("certs"), fmt.Sprintf("%d", sc.ID)) + _ = os.MkdirAll(certDir, 0o755) + _ = os.WriteFile(filepath.Join(certDir, "fullchain.pem"), certPEM, 0o600) + _ = os.WriteFile(filepath.Join(certDir, "privkey.pem"), keyPEM, 0o600) + + if err := m.st.SetCertificate(sc); err != nil { + return err + } + slog.Info("test self-signed cert stored (LE simulation, dev-only)", "certID", sc.ID, "domains", sc.DomainNames) + return nil +} + +func generateSelfSignedTestCert(domains []string) (certPEM, keyPEM []byte, err error) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, nil, err + } + + serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + template := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: domains[0], Organization: []string{"Helix Proxy test"}}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(90 * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: domains, + } + + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return nil, nil, err + } + certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + + keyDER := x509.MarshalPKCS1PrivateKey(priv) + keyPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyDER}) + return certPEM, keyPEM, nil +} diff --git a/internal/certificate/manager_test.go b/internal/certificate/manager_test.go new file mode 100644 index 0000000..52c904b --- /dev/null +++ b/internal/certificate/manager_test.go @@ -0,0 +1,230 @@ +package certificate + +import ( + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "strings" + "testing" + "time" + + "helix-proxy/internal/config" + "helix-proxy/internal/store" +) + +func TestGenerateSelfSignedTestCert(t *testing.T) { + domains := []string{"foo.example.com", "bar.example.com"} + certPEM, keyPEM, err := generateSelfSignedTestCert(domains) + if err != nil { + t.Fatalf("generate: %v", err) + } + if !strings.Contains(string(certPEM), "BEGIN CERTIFICATE") { + t.Error("no cert pem marker") + } + if !strings.Contains(string(keyPEM), "BEGIN RSA PRIVATE KEY") { + t.Error("no key pem marker") + } + // parse and check + block, _ := pem.Decode(certPEM) + if block == nil { + t.Fatal("decode failed") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("parse: %v", err) + } + if cert.NotAfter.Sub(cert.NotBefore) < 89*24*time.Hour { + t.Error("validity too short") + } + found := false + for _, d := range cert.DNSNames { + if d == "foo.example.com" { + found = true + } + } + if !found { + t.Error("dns names not in cert") + } +} + +func TestIssueTestCertPath(t *testing.T) { + // use temp data dir, reset config + config.ResetForTest() + tmp := t.TempDir() + t.Setenv("DATA_DIR", tmp) + // ensure dirs so writes don't fail + _ = config.EnsureDataDirs() + + // minimal store: use New which is bbolt in the tmp (via reset+env) + st, err := store.New() + if err != nil { + t.Fatalf("new store: %v", err) + } + if c, ok := st.(interface{ Close() error }); ok { + defer c.Close() + } + + cm := NewManager(st) + + // create a cert record (simulating what API would do) + cert := store.Certificate{ + Provider: "letsencrypt", + NiceName: "test local", + DomainNames: []string{"mytest.example.com"}, // any domain; in dev mode all LE become test certs + Meta: map[string]any{}, + } + created, err := st.CreateCertificate(cert) + if err != nil { + t.Fatalf("create cert: %v", err) + } + + // dev mode => all letsencrypt certs are test/self-signed + t.Setenv("PROXY_MODE", "development") + email := cm.GetEmailForCert(created) + if err := cm.Issue(created, email); err != nil { + t.Fatalf("Issue test cert: %v", err) + } + + // reload from store + got, ok := st.GetCertificate(created.ID) + if !ok { + t.Fatal("cert not found after issue") + } + if got.ExpiresOn == "" { + t.Error("no expires set") + } + if v, _ := got.Meta["test_cert"].(bool); !v { + t.Error("test_cert marker not set") + } + if _, ok := got.Meta["certificate"]; !ok { + t.Error("certificate pem not in meta") + } + if _, ok := got.Meta["key"]; !ok { + t.Error("key not in meta") + } + + // file written? + if _, err := os.Stat(config.Resolve("certs")); err != nil { + t.Error("certs dir missing") + } + // the per-id dir + perID := fmt.Sprintf("%s/%d", config.Resolve("certs"), got.ID) + if _, err := os.Stat(perID); err != nil { + t.Logf("per-id cert dir %s not present (may be ok): %v", perID, err) + } + + // exercise non-local LE path meta parsing (staging/key_type from meta) in *production* mode + // (real LE path; will fail on obtain due to no DNS/reach, but pre-LE branches + meta covered) + t.Setenv("PROXY_MODE", "") + c2 := store.Certificate{ + Provider: "letsencrypt", + DomainNames: []string{"nonlocal.example.com"}, + Meta: map[string]any{"staging": "true", "key_type": "rsa4096"}, + } + created2, _ := st.CreateCertificate(c2) + email2 := cm.GetEmailForCert(created2) + if err2 := cm.Issue(created2, email2); err2 == nil || strings.Contains(err2.Error(), "not a letsencrypt") { + t.Logf("non-local LE Issue err (expected without public reach/DNS; meta parse covered): %v", err2) + } +} + +func TestIssueRejectsNonLE(t *testing.T) { + config.ResetForTest() + tmp := t.TempDir() + t.Setenv("DATA_DIR", tmp) + _ = config.EnsureDataDirs() + st, err := store.New() + if err != nil { + t.Fatalf("store: %v", err) + } + if c, ok := st.(interface{ Close() error }); ok { + defer c.Close() + } + cm := NewManager(st) + c := store.Certificate{Provider: "other", DomainNames: []string{"ex.com"}} + created, _ := st.CreateCertificate(c) + err = cm.Issue(created, "e@e.com") + if err == nil || !strings.Contains(err.Error(), "not a letsencrypt") { + t.Errorf("expected reject non-letsencrypt: got %v", err) + } +} + +func TestShouldRenew(t *testing.T) { + config.ResetForTest() + tmp := t.TempDir() + t.Setenv("DATA_DIR", tmp) + _ = config.EnsureDataDirs() + st, err := store.New() + if err != nil { + t.Fatalf("store: %v", err) + } + if c, ok := st.(interface{ Close() error }); ok { + defer c.Close() + } + cm := NewManager(st) + now := time.Now().UTC() + tests := []struct { + name string + exp string + want bool + }{ + {"empty", "", true}, + {"past", now.Add(-40 * 24 * time.Hour).Format("2006-01-02 15:04:05"), true}, + {"near 29d", now.Add(29 * 24 * time.Hour).Format("2006-01-02 15:04:05"), true}, + {"far 31d", now.Add(31 * 24 * time.Hour).Format("2006-01-02 15:04:05"), false}, + {"rfc3339 near", now.Add(10 * 24 * time.Hour).Format(time.RFC3339), true}, + {"bad format", "not-a-date", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := store.Certificate{Provider: "letsencrypt", ExpiresOn: tt.exp} + if got := cm.ShouldRenew(c); got != tt.want { + t.Errorf("ShouldRenew(%s) = %v want %v", tt.exp, got, tt.want) + } + }) + } +} + +func TestProcessRenewals(t *testing.T) { + config.ResetForTest() + tmp := t.TempDir() + t.Setenv("DATA_DIR", tmp) + _ = config.EnsureDataDirs() + st, err := store.New() + if err != nil { + t.Fatalf("store: %v", err) + } + if c, ok := st.(interface{ Close() error }); ok { + defer c.Close() + } + cm := NewManager(st) + + // dev mode: *all* letsencrypt certs (any domain) use test/self-signed path in Issue/renew + t.Setenv("PROXY_MODE", "development") + + // LE due soon + dueExp := time.Now().Add(5 * 24 * time.Hour).Format("2006-01-02 15:04:05") + leDue := store.Certificate{Provider: "letsencrypt", DomainNames: []string{"due.example.com"}, ExpiresOn: dueExp} + createdDue, _ := st.CreateCertificate(leDue) + + // LE far future (no renew) + farExp := time.Now().Add(40 * 24 * time.Hour).Format("2006-01-02 15:04:05") + leFar := store.Certificate{Provider: "letsencrypt", DomainNames: []string{"far.example.com"}, ExpiresOn: farExp} + createdFar, _ := st.CreateCertificate(leFar) + + // non-LE (ignored even if "due") + nonLE := store.Certificate{Provider: "other", DomainNames: []string{"x.com"}, ExpiresOn: time.Now().Add(-1 * time.Hour).Format("2006-01-02 15:04:05")} + st.CreateCertificate(nonLE) + + cm.ProcessRenewals() + + gDue, _ := st.GetCertificate(createdDue.ID) + if tDue, _ := time.Parse("2006-01-02 15:04:05", gDue.ExpiresOn); time.Until(tDue) < 80*24*time.Hour { + t.Error("due LE cert was not renewed (expires not extended)") + } + gFar, _ := st.GetCertificate(createdFar.ID) + if gFar.ExpiresOn != farExp { + t.Error("far LE should not have been touched") + } +} diff --git a/internal/config/paths.go b/internal/config/paths.go new file mode 100644 index 0000000..e7c81df --- /dev/null +++ b/internal/config/paths.go @@ -0,0 +1,110 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "sync" +) + +var ( + once sync.Once + base string // resolved data base dir +) + +// DataDir returns the base directory for data (db.bolt, certs, logs, www, etc). +// Respects DATA_DIR env (absolute or relative). Falls back to "data" relative to CWD. +func DataDir() string { + once.Do(func() { + if d := os.Getenv("DATA_DIR"); d != "" { + base = d + return + } + // default relative to cwd + wd, err := os.Getwd() + if err != nil { + wd = "." + } + base = filepath.Join(wd, "data") + }) + return base +} + +// Resolve returns an absolute or relative path under the data dir for the given subpath (e.g. "db.bolt", "certs", "www"). +// If the corresponding *_DIR env is set it is used as base instead. +func Resolve(sub string) string { + // Allow per-subdir override e.g. CERTS_DIR, WWW_DIR, LOGS_DIR, CHALLENGE_DIR + key := "" + switch sub { + case "db.bolt": + key = "DATA_DIR" + case "certs", "custom_ssl": + key = "CERTS_DIR" + case "logs": + key = "LOGS_DIR" + case "letsencrypt-acme-challenge", "challenge": + key = "CHALLENGE_DIR" + case "www": + if d := os.Getenv("WWW_DIR"); d != "" { + return filepath.Join(d, "") // allow override to point directly + } + key = "DATA_DIR" + case "html": + if d := os.Getenv("HTML_DIR"); d != "" { + return filepath.Join(d, "") + } + key = "DATA_DIR" + default: + key = "DATA_DIR" + } + + dir := DataDir() + if key != "" { + if override := os.Getenv(key); override != "" { + dir = override + } + } + return filepath.Join(dir, sub) +} + +// EnsureDataDirs creates the expected subdirs under the resolved data location (and www inside data by default). +// Also pre-creates the acme-challenge subdir layout that lego webroot + our handler expect. +func EnsureDataDirs() error { + dirs := []string{ + Resolve(""), + Resolve("certs"), + Resolve("logs"), + Resolve("letsencrypt-acme-challenge"), + Resolve("www"), // data/www by default + } + for _, d := range dirs { + if err := os.MkdirAll(d, 0o755); err != nil { + return err + } + } + // full layout for http-01 challenges written by lego + acme := filepath.Join(Resolve("letsencrypt-acme-challenge"), ".well-known", "acme-challenge") + if err := os.MkdirAll(acme, 0o755); err != nil { + return err + } + return nil +} + +// ResetForTest clears the cached data dir (sync.Once) so tests can change +// DATA_DIR / *_DIR envs via t.Setenv and observe fresh resolution. +// Call at the start of such tests. This is the supported way to make +// config testable without globals leaking across tests. +func ResetForTest() { + once = sync.Once{} + base = "" +} + +// IsDevelopment reports whether PROXY_MODE=development (or "dev"). +// Defaults to false (production mode). +// When true, all letsencrypt certs use self-signed test certs (via the cert manager's +// issueTestCert path). No domain tricks (.localtest etc.) are used. In production +// (default) real LE issuance is always attempted. +func IsDevelopment() bool { + mode := strings.ToLower(os.Getenv("PROXY_MODE")) + return mode == "development" || mode == "dev" +} diff --git a/internal/config/paths_test.go b/internal/config/paths_test.go new file mode 100644 index 0000000..79bd77f --- /dev/null +++ b/internal/config/paths_test.go @@ -0,0 +1,158 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDataDir(t *testing.T) { + ResetForTest() + t.Setenv("DATA_DIR", "") + // default should be data under cwd + d := DataDir() + if d == "" { + t.Fatal("DataDir empty") + } + // once behavior: change env shouldn't affect without reset + t.Setenv("DATA_DIR", "/tmp/should-not-affect") + d2 := DataDir() + if d != d2 { + t.Error("DataDir ignored Once") + } + + ResetForTest() + t.Setenv("DATA_DIR", "/tmp/testdata123") + d3 := DataDir() + if d3 != "/tmp/testdata123" { + t.Errorf("DATA_DIR override failed: got %s", d3) + } +} + +func TestResolve(t *testing.T) { + ResetForTest() + tmp := t.TempDir() + t.Setenv("DATA_DIR", tmp) + + tests := []struct { + sub string + wantBase string // suffix check + }{ + {"db.bolt", filepath.Join(tmp, "db.bolt")}, + {"certs", filepath.Join(tmp, "certs")}, + {"logs", filepath.Join(tmp, "logs")}, + {"letsencrypt-acme-challenge", filepath.Join(tmp, "letsencrypt-acme-challenge")}, + {"www", filepath.Join(tmp, "www")}, + {"unknown", filepath.Join(tmp, "unknown")}, + } + for _, tt := range tests { + got := Resolve(tt.sub) + if got != tt.wantBase { + t.Errorf("Resolve(%q) = %q want %q", tt.sub, got, tt.wantBase) + } + } + + // override CERTS_DIR (uses as base + join sub per current impl) + ResetForTest() + certsBase := filepath.Join(tmp, "mycerts-base") + t.Setenv("CERTS_DIR", certsBase) + t.Setenv("DATA_DIR", tmp) + wantCerts := filepath.Join(certsBase, "certs") + if got := Resolve("certs"); got != wantCerts { + t.Errorf("CERTS_DIR override: got %s want %s", got, wantCerts) + } + + // WWW_DIR / HTML_DIR (special case returns dir directly; overrides conditioned on exact sub to avoid cross-contamination) + ResetForTest() + wwwDir := filepath.Join(tmp, "mywww") + t.Setenv("WWW_DIR", wwwDir) + if got := Resolve("www"); got != wwwDir { + t.Errorf("WWW_DIR: got %s", got) + } + ResetForTest() + htmlDir := filepath.Join(tmp, "myhtml") + t.Setenv("HTML_DIR", htmlDir) + if got := Resolve("html"); got != htmlDir { + t.Errorf("HTML_DIR: got %s", got) + } + + // mixed overrides: only HTML set should not affect Resolve("www") (falls to DATA_DIR/www); vice-versa + // (explicitly clear the other *_DIR to counter t.Setenv lingering within one TestResolve func) + ResetForTest() + t.Setenv("WWW_DIR", "") + t.Setenv("HTML_DIR", htmlDir) + t.Setenv("DATA_DIR", tmp) + if got := Resolve("www"); got != filepath.Join(tmp, "www") { + t.Errorf("mixed: only HTML should not affect www: got %s", got) + } + ResetForTest() + t.Setenv("HTML_DIR", "") + t.Setenv("WWW_DIR", wwwDir) + t.Setenv("DATA_DIR", tmp) + if got := Resolve("html"); got != filepath.Join(tmp, "html") { + t.Errorf("mixed: only WWW should not affect html: got %s", got) + } + + // CHALLENGE_DIR (uses as base + join per impl) + ResetForTest() + chBase := filepath.Join(tmp, "ch-base") + t.Setenv("CHALLENGE_DIR", chBase) + wantCh := filepath.Join(chBase, "letsencrypt-acme-challenge") + if got := Resolve("letsencrypt-acme-challenge"); got != wantCh { + t.Errorf("CHALLENGE: got %s want %s", got, wantCh) + } +} + +func TestEnsureDataDirs(t *testing.T) { + ResetForTest() + tmp := t.TempDir() + t.Setenv("DATA_DIR", tmp) + + if err := EnsureDataDirs(); err != nil { + t.Fatalf("EnsureDataDirs: %v", err) + } + + // verify layout + dirs := []string{ + tmp, + filepath.Join(tmp, "certs"), + filepath.Join(tmp, "logs"), + filepath.Join(tmp, "letsencrypt-acme-challenge"), + filepath.Join(tmp, "www"), + filepath.Join(tmp, "letsencrypt-acme-challenge", ".well-known", "acme-challenge"), + } + for _, d := range dirs { + if fi, err := os.Stat(d); err != nil || !fi.IsDir() { + t.Errorf("expected dir %s: %v", d, err) + } + } +} + +func TestIsDevelopment(t *testing.T) { + ResetForTest() + t.Setenv("PROXY_MODE", "") + if IsDevelopment() { + t.Error("default should be production (not dev)") + } + + t.Setenv("PROXY_MODE", "development") + if !IsDevelopment() { + t.Error("PROXY_MODE=development should be dev") + } + + t.Setenv("PROXY_MODE", "dev") + if !IsDevelopment() { + t.Error("PROXY_MODE=dev should be dev") + } + + t.Setenv("PROXY_MODE", "production") + if IsDevelopment() { + t.Error("PROXY_MODE=production should not be dev") + } + + // case insensitive + t.Setenv("PROXY_MODE", "DEVELOPMENT") + if !IsDevelopment() { + t.Error("case insensitive") + } +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..56c3486 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,155 @@ +package models + +// These types are the core domain models for Helix Proxy. +// They are used by the Store interface (any backend), the API, the proxy engine, +// and the certificate manager. + +type Root struct { + Version int + + Users []User + ProxyHosts []ProxyHost + AccessLists []AccessList + Streams []Stream + Certificates []Certificate + RedirectionHosts []RedirectionHost + DeadHosts []DeadHost + Settings map[string]any + AuditLogs []AuditLog + + // meta for internal use by store impls + Meta map[string]any +} + +type User struct { + ID int `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Password string `json:"password,omitempty"` // bcrypt hash; empty for demo seeded admin. (API responses manually omit the value) + Roles []string `json:"roles"` + TotpSecret string `json:"totpSecret,omitempty"` // include for bbolt persistence (API never returns the secret value) + TotpEnabled bool `json:"totpEnabled"` +} + +// HeaderRule is a name/value pair for request or response header overrides. +type HeaderRule struct { + Name string `json:"name"` + Value string `json:"value,omitempty"` +} + +type ProxyHost struct { + ID int `json:"id"` + DomainNames []string `json:"domainNames"` + ForwardScheme string `json:"forwardScheme"` + ForwardHost string `json:"forwardHost"` + ForwardPort int `json:"forwardPort"` + AccessListID int `json:"accessListId,omitempty"` + CertificateID int `json:"certificateId,omitempty"` + SslForced bool `json:"sslForced"` + CachingEnabled bool `json:"cachingEnabled"` + BlockExploits bool `json:"blockExploits"` + AllowWebsocketUpgrade bool `json:"allowWebsocketUpgrade"` + Http2Support bool `json:"http2Support"` + HstsEnabled bool `json:"hstsEnabled"` + HstsSubdomains bool `json:"hstsSubdomains"` + TrustForwardedProto bool `json:"trustForwardedProto"` + RequestHeaders []HeaderRule `json:"requestHeaders,omitempty"` + ResponseHeaders []HeaderRule `json:"responseHeaders,omitempty"` + HideHeaders []string `json:"hideHeaders,omitempty"` + AdvancedConfig string `json:"advancedConfig,omitempty"` // legacy; engine falls back when structured headers are empty + Enabled bool `json:"enabled"` + Locations []Location `json:"locations,omitempty"` + Meta map[string]any `json:"meta,omitempty"` + OwnerUserID int `json:"ownerUserId,omitempty"` + CreatedOn string `json:"createdOn,omitempty"` // server-set UTC on create (via store); preserved on update; empty for legacy DB records +} + +type Location struct { + ID int `json:"id,omitempty"` + Path string `json:"path"` + ForwardScheme string `json:"forwardScheme"` + ForwardHost string `json:"forwardHost"` + ForwardPort int `json:"forwardPort"` + ForwardPath string `json:"forwardPath,omitempty"` + AdvancedConfig string `json:"advancedConfig,omitempty"` +} + +type AccessList struct { + ID int `json:"id"` + Name string `json:"name"` + SatisfyAny bool `json:"satisfyAny"` + Clients []AccessClient `json:"clients"` + Items []AccessItem `json:"items"` + PassAuth bool `json:"passAuth"` + OwnerUserID int `json:"ownerUserId,omitempty"` + CreatedOn string `json:"createdOn,omitempty"` // server-set UTC on create (via store); preserved on update; empty for legacy DB records +} + +type AccessClient struct { + Address string `json:"address"` + Directive string `json:"directive"` // "allow" or "deny" +} + +type AccessItem struct { + Username string `json:"username"` + Password string `json:"password"` // plain for demo access lists (separate from User bcrypt) +} + +type Stream struct { + ID int `json:"id"` + IncomingPort int `json:"incomingPort"` + ForwardingHost string `json:"forwardingHost"` + ForwardingPort int `json:"forwardingPort"` + TCPForwarding bool `json:"tcpForwarding"` + UDPForwarding bool `json:"udpForwarding"` + Enabled bool `json:"enabled"` + CertificateID int `json:"certificateId"` + Meta map[string]any `json:"meta,omitempty"` + OwnerUserID int `json:"ownerUserId,omitempty"` + CreatedOn string `json:"createdOn,omitempty"` // server-set UTC on create (via store); preserved on update; empty for legacy DB records +} + +type Certificate struct { + ID int `json:"id"` + Provider string `json:"provider"` // "letsencrypt" or "other" + NiceName string `json:"niceName"` + DomainNames []string `json:"domainNames"` + ExpiresOn string `json:"expiresOn"` + Meta map[string]any `json:"meta"` + OwnerUserID int `json:"ownerUserId,omitempty"` + CreatedOn string `json:"createdOn,omitempty"` // server-set UTC on create (via store); preserved on update; empty for legacy DB records +} + +type RedirectionHost struct { + ID int `json:"id"` + DomainNames []string `json:"domainNames"` + ForwardHTTPCode int `json:"forwardHttpCode"` + ForwardScheme string `json:"forwardScheme"` + ForwardDomainName string `json:"forwardDomainName"` + PreservePath bool `json:"preservePath"` + Enabled bool `json:"enabled"` + AdvancedConfig string `json:"advancedConfig,omitempty"` + Meta map[string]any `json:"meta,omitempty"` + OwnerUserID int `json:"ownerUserId,omitempty"` + CreatedOn string `json:"createdOn,omitempty"` // server-set UTC on create (via store); preserved on update; empty for legacy DB records +} + +type DeadHost struct { + ID int `json:"id"` + DomainNames []string `json:"domainNames"` + Enabled bool `json:"enabled"` + AdvancedConfig string `json:"advancedConfig,omitempty"` + Meta map[string]any `json:"meta,omitempty"` + OwnerUserID int `json:"ownerUserId,omitempty"` + CreatedOn string `json:"createdOn,omitempty"` // server-set UTC on create (via store); preserved on update; empty for legacy DB records +} + +type AuditLog struct { + ID int `json:"id"` + UserID int `json:"userId"` + ObjectType string `json:"objectType"` + ObjectID int `json:"objectId"` + Action string `json:"action"` + Meta map[string]any `json:"meta"` + CreatedOn string `json:"createdOn"` +} diff --git a/internal/proxy/engine.go b/internal/proxy/engine.go new file mode 100644 index 0000000..d0e184d --- /dev/null +++ b/internal/proxy/engine.go @@ -0,0 +1,1252 @@ +package proxy + +import ( + "context" + "crypto/tls" + "encoding/base64" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "helix-proxy/internal/config" + "helix-proxy/internal/store" + "helix-proxy/internal/www" +) + +// Engine manages the live HTTP reverse proxy (and later streams) based on the store. +// It replaces all nginx config generation / reload. +type Engine struct { + mu sync.RWMutex + hosts map[string]*Host // domain (or wildcard key) -> config + handler + st store.Store + srv *http.Server // the :80 / :443 listener(s) + started bool + + // streams + streamMu sync.Mutex + streamHandles map[int]*streamHandle // stream id -> live listeners + streamRuntimeMu sync.RWMutex + streamRuntime map[int]*streamRuntime // incoming port -> live listener state + + // TLS certs per host (from certificates) + certMu sync.RWMutex + hostCerts map[string]*tls.Certificate + certByID map[int]*tls.Certificate // for streams SSL termination etc. + + // redirection hosts + redirMu sync.RWMutex + redirHosts map[string]store.RedirectionHost + + // dead hosts + deadMu sync.RWMutex + deadHosts map[string]store.DeadHost + + // simple response cache for cachingEnabled + cacheMu sync.Mutex + cache map[string]struct { + body []byte + headers http.Header + status int + expires time.Time + } +} + +// Host holds the live config for one (or more) domain(s). +type Host struct { + ID int + Domains []string + ForwardURL string // default backend + Enabled bool + ForwardScheme string + SslForced bool + BlockExploits bool + Locations []store.Location + + // Access list (resolved at reload) + AccessClients []store.AccessClient + AccessSatisfyAny bool + AccessItems []store.AccessItem + AccessPassAuth bool + + // Additional flags from ProxyHost (applied in Handler) + HstsEnabled bool + HstsSubdomains bool + AllowWebsocketUpgrade bool + TrustForwardedProto bool + ReqHeaderSets map[string]string + RespHeaderAdds map[string]string + RespHeaderHides []string + CachingEnabled bool + Http2Support bool // noted for future http2 backend config +} + +func NewEngine(st store.Store) *Engine { + return &Engine{ + hosts: make(map[string]*Host), + st: st, + streamHandles: make(map[int]*streamHandle), + streamRuntime: make(map[int]*streamRuntime), + hostCerts: make(map[string]*tls.Certificate), + certByID: make(map[int]*tls.Certificate), + redirHosts: make(map[string]store.RedirectionHost), + deadHosts: make(map[string]store.DeadHost), + cache: make(map[string]struct { + body []byte + headers http.Header + status int + expires time.Time + }), + } +} + +// ReloadFromStore rebuilds the in-memory routing table from the store. +// Called on startup and after any host mutation. +func (e *Engine) ReloadFromStore() { + e.mu.Lock() + defer e.mu.Unlock() + + e.hosts = make(map[string]*Host) + + proxyHosts := e.st.GetProxyHosts() + slog.Info("store root for reload", "proxyHosts", len(proxyHosts), "accessLists", len(e.st.GetAccessLists())) + for _, ph := range proxyHosts { + if !ph.Enabled || len(ph.DomainNames) == 0 { + continue + } + scheme := ph.ForwardScheme + if scheme == "" { + scheme = "http" + } + u := &url.URL{Scheme: scheme, Host: net.JoinHostPort(ph.ForwardHost, "80")} + if ph.ForwardPort > 0 { + u.Host = net.JoinHostPort(ph.ForwardHost, itoa(ph.ForwardPort)) + } + h := &Host{ + ID: ph.ID, + Domains: append([]string(nil), ph.DomainNames...), + ForwardURL: u.String(), + Enabled: ph.Enabled, + ForwardScheme: scheme, + SslForced: ph.SslForced, + BlockExploits: ph.BlockExploits, + Locations: append([]store.Location(nil), ph.Locations...), + + // access list + AccessSatisfyAny: false, + + // additional flags + HstsEnabled: ph.HstsEnabled, + HstsSubdomains: ph.HstsSubdomains, + AllowWebsocketUpgrade: ph.AllowWebsocketUpgrade, + TrustForwardedProto: ph.TrustForwardedProto, + CachingEnabled: ph.CachingEnabled, + Http2Support: ph.Http2Support, + } + h.ReqHeaderSets, h.RespHeaderAdds, h.RespHeaderHides = resolveProxyHeaders(ph) + if ph.AccessListID > 0 { + if al, ok := e.st.GetAccessList(ph.AccessListID); ok { + h.AccessClients = append([]store.AccessClient(nil), al.Clients...) + h.AccessSatisfyAny = al.SatisfyAny + h.AccessItems = append([]store.AccessItem(nil), al.Items...) + h.AccessPassAuth = al.PassAuth + slog.Info("loaded access list for host", "hostID", ph.ID, "listID", ph.AccessListID, "clients", len(h.AccessClients), "authItems", len(h.AccessItems)) + } else { + slog.Warn("access list not found for host", "hostID", ph.ID, "listID", ph.AccessListID) + } + } + for _, d := range h.Domains { + e.hosts[strings.ToLower(d)] = h // later: support *.example.com wildcard matching + } + } + slog.Info("proxy engine reloaded", "hosts", len(e.hosts)) + e.reloadStreams() + e.loadCerts() + e.loadRedirs() + e.loadDeads() + e.cacheMu.Lock() + e.cache = make(map[string]struct { + body []byte + headers http.Header + status int + expires time.Time + }) + e.cacheMu.Unlock() +} + +func itoa(i int) string { return strconv.Itoa(i) } + +// Handler returns the http.Handler for the main proxy listener(s). +// It does SNI/Host based lookup and reverse proxy. +func (e *Engine) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Serve ACME challenges for LE (before host lookup) + // lego webroot provider writes tokens to /.well-known/acme-challenge/ + // so our serve must look there (standard layout). + if strings.HasPrefix(r.URL.Path, "/.well-known/acme-challenge/") { + challengeDir := config.Resolve("letsencrypt-acme-challenge") + acmeDir := filepath.Join(challengeDir, ".well-known", "acme-challenge") + file := filepath.Join(acmeDir, filepath.Base(r.URL.Path)) + if data, err := os.ReadFile(file); err == nil { + w.Header().Set("Content-Type", "text/plain") + w.Write(data) + return + } + http.NotFound(w, r) + return + } + + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + host = strings.ToLower(host) + + // Check redirection hosts first (independent) + e.redirMu.RLock() + rh, isRedir := e.redirHosts[host] + e.redirMu.RUnlock() + slog.Debug("redir check", "host", host, "isRedir", isRedir, "numRedirs", len(e.redirHosts)) + if isRedir { + code := rh.ForwardHTTPCode + if code == 0 { + code = 302 + } + target := rh.ForwardDomainName + scheme := rh.ForwardScheme + if scheme == "" || scheme == "auto" { + scheme = "http" + } + target = scheme + "://" + target + if rh.PreservePath { + target += r.URL.RequestURI() + } + http.Redirect(w, r, target, code) + return + } + + // Check dead hosts (blocks access, serves 404 or custom) + e.deadMu.RLock() + dh, isDead := e.deadHosts[host] + e.deadMu.RUnlock() + if isDead { + // per-dead custom html via meta.html (for full parity/custom content) + if dh.Meta != nil { + if html, ok := dh.Meta["html"].(string); ok && html != "" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(html)) + return + } + } + // Serve 404 page (data/www/404.html overrides embedded default). + if data := www.LoadPage("404.html", map[string]string{ + "MESSAGE": fmt.Sprintf("This host (%s) is configured as dead/blocked.", host), + }); data != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + w.Write(data) + return + } + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("404 Not Found (this host is configured as dead/blocked)")) + return + } + + e.mu.RLock() + hcfg, ok := e.hosts[host] + e.mu.RUnlock() + + if !ok || !hcfg.Enabled { + // default site behavior driven by settings + ds := "congratulations" + if settings := e.st.GetSettings(); settings != nil { + if s, ok := settings["default_site"].(string); ok && s != "" { + ds = s + } + } + switch ds { + case "404": + if data := www.LoadPage("404.html", map[string]string{ + "MESSAGE": fmt.Sprintf("No virtual host configured for %s.", host), + }); data != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + w.Write(data) + return + } + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("404 Not Found")) + return + case "444": + w.WriteHeader(444) // close like nginx + return + case "redirect": + tgt := "https://example.com" + if settings := e.st.GetSettings(); settings != nil { + if r, ok := settings["default_site_redirect"].(string); ok && r != "" { + tgt = r + } + } + http.Redirect(w, r, tgt, http.StatusFound) + return + default: // congratulations + if data := www.LoadPage("index.html", map[string]string{"HOST": host}); data != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(data) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `

Welcome to Helix Proxy

No virtual host for %s. Use the admin UI on :81 to configure hosts.

`, host) + return + } + } + + slog.Debug("host matched", "host", host, "hasAccessClients", len(hcfg.AccessClients)) + + // Access list IP check (before other processing) + // Reuses getRealClientIP (defined below) with TrustForwardedProto gating to avoid dupe of XFF-trust logic (see also injection path at ~431). + if len(hcfg.AccessClients) > 0 { + clientIP := getRealClientIP(r, hcfg.TrustForwardedProto) + slog.Debug("access check", "host", host, "clients", len(hcfg.AccessClients), "satisfyAny", hcfg.AccessSatisfyAny, "ip", clientIP) + if !checkAccess(hcfg.AccessClients, hcfg.AccessSatisfyAny, clientIP) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("access denied by access list")) + return + } + } + + // Basic auth if access list has items + if len(hcfg.AccessItems) > 0 { + authHeader := r.Header.Get("Authorization") + if authHeader == "" || !strings.HasPrefix(authHeader, "Basic ") { + w.Header().Set("WWW-Authenticate", `Basic realm="Authorization required"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + payload, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Basic ")) + if err != nil { + w.Header().Set("WWW-Authenticate", `Basic realm="Authorization required"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + creds := strings.SplitN(string(payload), ":", 2) + if len(creds) != 2 { + w.Header().Set("WWW-Authenticate", `Basic realm="Authorization required"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + user, pass := creds[0], creds[1] + authed := false + for _, item := range hcfg.AccessItems { + if item.Username == user && item.Password == pass { // demo: plain compare; real would hash + authed = true + break + } + } + if !authed { + w.Header().Set("WWW-Authenticate", `Basic realm="Authorization required"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + // if not pass_auth, strip the header so backend doesn't see the creds + if !hcfg.AccessPassAuth { + r.Header.Del("Authorization") + } + } + + // Basic ssl_forced: if configured and request looks http, redirect to https + if hcfg.SslForced { + proto := r.Header.Get("X-Forwarded-Proto") + if proto == "http" || r.TLS == nil { + httpsURL := "https://" + r.Host + r.URL.RequestURI() + http.Redirect(w, r, httpsURL, http.StatusMovedPermanently) + return + } + } + + // Basic block_exploits + if hcfg.BlockExploits { + badPaths := []string{".env", "wp-admin", ".git/", "config.php", "phpinfo", "shell.php"} + for _, bad := range badPaths { + if strings.Contains(r.URL.Path, bad) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("blocked by exploits rule")) + return + } + } + } + + // HSTS (set on responses for this vhost if enabled; typically observed on https) + if hcfg.HstsEnabled { + hsts := "max-age=31536000" + if hcfg.HstsSubdomains { + hsts += "; includeSubDomains" + } + w.Header().Set("Strict-Transport-Security", hsts) + } + + // Support custom locations (path prefix routing to different backends) + targetURL := hcfg.ForwardURL + reqPath := r.URL.Path + for _, loc := range hcfg.Locations { + if loc.Path != "" && strings.HasPrefix(reqPath, loc.Path) { + scheme := loc.ForwardScheme + if scheme == "" { + scheme = hcfg.ForwardScheme + if scheme == "" { + scheme = "http" + } + } + port := loc.ForwardPort + if port == 0 { + port = 80 + } + u := &url.URL{ + Scheme: scheme, + Host: net.JoinHostPort(loc.ForwardHost, strconv.Itoa(port)), + } + if loc.ForwardPath != "" { + // simple path rewrite (mutate request path only; target URL stays host-level + // so ReverseProxy joinURLPath does not duplicate the prefix) + rest := strings.TrimPrefix(reqPath, loc.Path) + r.URL.Path = loc.ForwardPath + rest + } + targetURL = u.String() + break + } + } + + // Websocket upgrade support (if enabled for this host) + if hcfg.AllowWebsocketUpgrade && isWebsocketRequest(r) { + r.Header.Set("Connection", "Upgrade") + r.Header.Set("Upgrade", "websocket") + // ReverseProxy will handle hijacking the conn for the upgrade + } + + // Compute real client info (respect trust-forwarded if configured) + clientIP := getRealClientIP(r, hcfg.TrustForwardedProto) + forwardedProto := "http" + if r.TLS != nil { + forwardedProto = "https" + } else if hcfg.TrustForwardedProto { + if p := r.Header.Get("X-Forwarded-Proto"); p != "" { + forwardedProto = strings.ToLower(strings.Split(p, ",")[0]) + } + } + + // Apply configured request/response header overrides. + for k, v := range hcfg.ReqHeaderSets { + r.Header.Set(k, v) + } + + target, _ := url.Parse(targetURL) + proxy := httputil.NewSingleHostReverseProxy(target) + + // Director to set X-Forwarded* and any request header overrides (after default director) + origDirector := proxy.Director + proxy.Director = func(req *http.Request) { + origDirector(req) + req.Header.Set("X-Forwarded-Host", r.Host) + req.Header.Set("X-Forwarded-Proto", forwardedProto) + req.Header.Set("X-Forwarded-For", clientIP) + req.Header.Set("X-Real-IP", clientIP) + for k, v := range hcfg.ReqHeaderSets { + req.Header.Set(k, v) + } + } + + // ModifyResponse to apply response header adds/hides + proxy.ModifyResponse = func(resp *http.Response) error { + for k, v := range hcfg.RespHeaderAdds { + resp.Header.Set(k, v) + } + for _, k := range hcfg.RespHeaderHides { + resp.Header.Del(k) + } + return nil + } + + // Basic caching support if enabled (for GET; simple in-mem with 1h TTL + HIT/MISS + no-store skip) + if hcfg.CachingEnabled && r.Method == "GET" { + key := host + r.URL.RequestURI() + e.cacheMu.Lock() + if c, ok := e.cache[key]; ok && time.Now().Before(c.expires) { + for k, vs := range c.headers { + for _, v := range vs { + w.Header().Add(k, v) + } + } + w.Header().Set("X-Proxy-Cache", "HIT") + status := c.status + if status == 0 { + status = 200 + } + w.WriteHeader(status) + w.Write(c.body) + e.cacheMu.Unlock() + return + } + e.cacheMu.Unlock() + + // capture response for cache (note: buffers full body in RAM; no size cap yet, see review) + rec := httptest.NewRecorder() + proxy.ServeHTTP(rec, r) + + // decide to cache: skip if no-store/private in resp + doCache := true + if cc := rec.Header().Get("Cache-Control"); cc != "" { + lcc := strings.ToLower(cc) + if strings.Contains(lcc, "no-store") || strings.Contains(lcc, "private") { + doCache = false + } + } + if doCache { + e.cacheMu.Lock() + e.cache[key] = struct { + body []byte + headers http.Header + status int + expires time.Time + }{ + body: rec.Body.Bytes(), + headers: rec.Header(), + status: rec.Code, + expires: time.Now().Add(1 * time.Hour), + } + e.cacheMu.Unlock() + } + // write to client (MISS only set for actual cacheable paths; omitted/BYPASS for no-store) + for k, vs := range rec.Header() { + for _, v := range vs { + w.Header().Add(k, v) + } + } + if doCache { + w.Header().Set("X-Proxy-Cache", "MISS") + } + w.WriteHeader(rec.Code) + w.Write(rec.Body.Bytes()) + return + } + + proxy.ServeHTTP(w, r) + }) +} + +// Start begins listening on the proxy ports (80/443 in real; for dev we can use high ports or require root). +// In production docker this will be able to bind because of how the image runs. +// Respects PROXY_HTTP_PORT env (e.g. 80 or 8080). +func (e *Engine) Start(ctx context.Context) error { + e.ReloadFromStore() + + port := "8080" + if p := os.Getenv("PROXY_HTTP_PORT"); p != "" { + port = p + } + addr := net.JoinHostPort("", port) + if os.Getenv("DISABLE_IPV6") == "1" { + addr = "0.0.0.0:" + port + } + + e.srv = &http.Server{ + Addr: addr, + Handler: e.Handler(), + } + + ln, err := net.Listen("tcp", e.srv.Addr) + if err != nil { + return err + } + slog.Info("pure-Go proxy HTTP listening (no nginx). Use PROXY_HTTP_PORT=80 (and privileges) or in docker for real low port", "addr", e.srv.Addr) + + go func() { + <-ctx.Done() + _ = e.srv.Shutdown(context.Background()) + _ = ln.Close() + }() + + e.started = true + return e.srv.Serve(ln) +} + +// StreamStatus is the live listener state for an enabled stream (not persisted). +type StreamStatus struct { + Listening bool `json:"listening"` + ListenError string `json:"listenError,omitempty"` +} + +type streamRuntime struct { + wantTCP bool + wantUDP bool + tcpOK bool + udpOK bool + tcpErr string + udpErr string + tcpDone bool + udpDone bool +} + +func (r *streamRuntime) status() StreamStatus { + if r.wantTCP && !r.tcpDone { + return StreamStatus{} + } + if r.wantUDP && !r.udpDone { + return StreamStatus{} + } + var errs []string + if r.wantTCP && !r.tcpOK && r.tcpErr != "" { + errs = append(errs, r.tcpErr) + } + if r.wantUDP && !r.udpOK && r.udpErr != "" { + errs = append(errs, r.udpErr) + } + if len(errs) > 0 { + return StreamStatus{ListenError: strings.Join(errs, "; ")} + } + return StreamStatus{Listening: r.wantTCP || r.wantUDP} +} + +func (e *Engine) StreamStatus(port int) StreamStatus { + e.streamRuntimeMu.RLock() + rt := e.streamRuntime[port] + e.streamRuntimeMu.RUnlock() + if rt == nil { + return StreamStatus{} + } + return rt.status() +} + +func (e *Engine) initStreamRuntime(s store.Stream) { + e.streamRuntimeMu.Lock() + defer e.streamRuntimeMu.Unlock() + e.streamRuntime[s.IncomingPort] = &streamRuntime{ + wantTCP: s.TCPForwarding, + wantUDP: s.UDPForwarding, + } +} + +func (e *Engine) markStreamTCPResult(port int, ok bool, err error) { + e.streamRuntimeMu.Lock() + defer e.streamRuntimeMu.Unlock() + rt := e.streamRuntime[port] + if rt == nil { + return + } + rt.tcpDone = true + rt.tcpOK = ok + if err != nil { + rt.tcpErr = err.Error() + } +} + +func (e *Engine) markStreamUDPResult(port int, ok bool, err error) { + e.streamRuntimeMu.Lock() + defer e.streamRuntimeMu.Unlock() + rt := e.streamRuntime[port] + if rt == nil { + return + } + rt.udpDone = true + rt.udpOK = ok + if err != nil { + rt.udpErr = err.Error() + } +} + +type streamHandle struct { + cancel context.CancelFunc + running store.Stream + tcpLn net.Listener + udpConn *net.UDPConn + wg sync.WaitGroup +} + +func streamListenerConfigEqual(a, b store.Stream) bool { + return a.IncomingPort == b.IncomingPort && + a.ForwardingHost == b.ForwardingHost && + a.ForwardingPort == b.ForwardingPort && + a.TCPForwarding == b.TCPForwarding && + a.UDPForwarding == b.UDPForwarding && + a.CertificateID == b.CertificateID +} + +func duplicateStreamListenPorts(streams map[int]store.Stream) map[int]string { + byPort := make(map[int]int, len(streams)) + errs := make(map[int]string) + for id, s := range streams { + if owner, ok := byPort[s.IncomingPort]; ok { + msg := fmt.Sprintf("incoming port %d already assigned to stream %d", s.IncomingPort, owner) + if _, has := errs[id]; !has { + errs[id] = msg + } + if _, has := errs[owner]; !has { + errs[owner] = fmt.Sprintf("incoming port %d conflict with stream %d", s.IncomingPort, id) + } + continue + } + byPort[s.IncomingPort] = id + } + return errs +} + +func (e *Engine) markStreamFailed(s store.Stream, err error) { + if s.TCPForwarding { + e.markStreamTCPResult(s.IncomingPort, false, err) + } + if s.UDPForwarding { + e.markStreamUDPResult(s.IncomingPort, false, err) + } +} + +func (e *Engine) streamPortHeldByOther(streamID, port int) (int, bool) { + e.streamMu.Lock() + defer e.streamMu.Unlock() + for id, h := range e.streamHandles { + if id != streamID && h.running.IncomingPort == port { + return id, true + } + } + return 0, false +} + +func (e *Engine) clearStreamRuntime(port int) { + e.streamRuntimeMu.Lock() + delete(e.streamRuntime, port) + e.streamRuntimeMu.Unlock() +} + +func (e *Engine) stopStreamByID(id int) { + e.streamMu.Lock() + h := e.streamHandles[id] + delete(e.streamHandles, id) + e.streamMu.Unlock() + if h == nil { + return + } + if h.tcpLn != nil { + _ = h.tcpLn.Close() + } + if h.udpConn != nil { + _ = h.udpConn.Close() + } + h.cancel() + h.wg.Wait() + e.clearStreamRuntime(h.running.IncomingPort) +} + +func (e *Engine) reloadStreams() { + all := e.st.GetStreams() + desired := make(map[int]store.Stream, len(all)) + for _, s := range all { + if s.Enabled && (s.TCPForwarding || s.UDPForwarding) { + desired[s.ID] = s + } + } + + e.streamMu.Lock() + running := make(map[int]*streamHandle, len(e.streamHandles)) + for id, h := range e.streamHandles { + running[id] = h + } + e.streamMu.Unlock() + + portConflicts := duplicateStreamListenPorts(desired) + for id, msg := range portConflicts { + s := desired[id] + e.initStreamRuntime(s) + e.markStreamFailed(s, fmt.Errorf("%s", msg)) + delete(desired, id) + slog.Error("stream port conflict", "streamID", id, "port", s.IncomingPort, "err", msg) + } + + stopIDs := make(map[int]struct{}) + for id, h := range running { + want, ok := desired[id] + if !ok || !streamListenerConfigEqual(want, h.running) { + stopIDs[id] = struct{}{} + } + } + + for id := range stopIDs { + e.stopStreamByID(id) + } + + for id, want := range desired { + e.streamMu.Lock() + h := e.streamHandles[id] + e.streamMu.Unlock() + if h != nil && streamListenerConfigEqual(want, h.running) { + continue + } + e.startStream(want) + } + + e.streamRuntimeMu.Lock() + activePorts := make(map[int]struct{}) + e.streamMu.Lock() + for _, h := range e.streamHandles { + activePorts[h.running.IncomingPort] = struct{}{} + } + e.streamMu.Unlock() + for port := range e.streamRuntime { + if _, ok := activePorts[port]; ok { + continue + } + keep := false + for _, s := range desired { + if s.IncomingPort == port { + keep = true + break + } + } + if !keep { + delete(e.streamRuntime, port) + } + } + e.streamRuntimeMu.Unlock() +} + +func (e *Engine) startStream(s store.Stream) { + if !s.TCPForwarding && !s.UDPForwarding { + return + } + ctx, cancel := context.WithCancel(context.Background()) + h := &streamHandle{cancel: cancel, running: s} + + e.streamMu.Lock() + e.streamHandles[s.ID] = h + e.streamMu.Unlock() + + e.initStreamRuntime(s) + + if holderID, held := e.streamPortHeldByOther(s.ID, s.IncomingPort); held { + err := fmt.Errorf("incoming port %d already used by stream %d", s.IncomingPort, holderID) + slog.Error("stream listen blocked", "streamID", s.ID, "port", s.IncomingPort, "holder", holderID) + e.markStreamFailed(s, err) + return + } + + if s.TCPForwarding { + listenAddr := ":" + itoa(s.IncomingPort) + if os.Getenv("DISABLE_IPV6") == "1" { + listenAddr = "0.0.0.0:" + itoa(s.IncomingPort) + } + if s.CertificateID > 0 { + cert := e.getStreamCert(s.CertificateID) + if cert == nil { + err := fmt.Errorf("no certificate for stream TLS (cert id %d)", s.CertificateID) + slog.Error("tcp stream tls listen failed", "port", s.IncomingPort, "err", err) + e.markStreamTCPResult(s.IncomingPort, false, err) + } else { + tlsCfg := &tls.Config{Certificates: []tls.Certificate{*cert}} + ln, err := tls.Listen("tcp", listenAddr, tlsCfg) + if err != nil { + slog.Error("tcp stream tls listen failed", "port", s.IncomingPort, "err", err) + e.markStreamTCPResult(s.IncomingPort, false, err) + } else { + h.tcpLn = ln + e.markStreamTCPResult(s.IncomingPort, true, nil) + h.wg.Add(1) + go func() { + defer h.wg.Done() + e.serveTCPStream(ctx, s, ln) + }() + } + } + } else { + ln, err := net.Listen("tcp", listenAddr) + if err != nil { + slog.Error("tcp stream listen failed", "port", s.IncomingPort, "err", err) + e.markStreamTCPResult(s.IncomingPort, false, err) + } else { + h.tcpLn = ln + e.markStreamTCPResult(s.IncomingPort, true, nil) + h.wg.Add(1) + go func() { + defer h.wg.Done() + e.serveTCPStream(ctx, s, ln) + }() + } + } + } + if s.UDPForwarding { + udpAddr := ":" + itoa(s.IncomingPort) + if os.Getenv("DISABLE_IPV6") == "1" { + udpAddr = "0.0.0.0:" + itoa(s.IncomingPort) + } + addr, _ := net.ResolveUDPAddr("udp", udpAddr) + conn, err := net.ListenUDP("udp", addr) + if err != nil { + slog.Error("udp stream listen failed", "port", s.IncomingPort, "err", err) + e.markStreamUDPResult(s.IncomingPort, false, err) + } else { + h.udpConn = conn + e.markStreamUDPResult(s.IncomingPort, true, nil) + h.wg.Add(1) + go func() { + defer h.wg.Done() + e.serveUDPStream(ctx, s, conn) + }() + } + } + st := e.StreamStatus(s.IncomingPort) + if st.Listening { + slog.Info("started stream listener", "port", s.IncomingPort, "tcp", s.TCPForwarding, "udp", s.UDPForwarding, "ssl", s.CertificateID > 0) + } else if st.ListenError != "" { + slog.Warn("stream listener failed to start", "port", s.IncomingPort, "tcp", s.TCPForwarding, "udp", s.UDPForwarding, "ssl", s.CertificateID > 0, "err", st.ListenError) + if h.tcpLn == nil && h.udpConn == nil { + e.streamMu.Lock() + delete(e.streamHandles, s.ID) + e.streamMu.Unlock() + h.cancel() + } + } +} + +func (e *Engine) serveTCPStream(ctx context.Context, s store.Stream, ln net.Listener) { + for { + conn, err := ln.Accept() + if err != nil { + if ctx.Err() != nil { + return + } + continue + } + go func(c net.Conn) { + defer c.Close() + target, err := net.Dial("tcp", net.JoinHostPort(s.ForwardingHost, itoa(s.ForwardingPort))) + if err != nil { + return + } + defer target.Close() + done := make(chan struct{}, 2) + go func() { io.Copy(target, c); done <- struct{}{} }() + go func() { io.Copy(c, target); done <- struct{}{} }() + <-done + }(conn) + } +} + +func (e *Engine) getStreamCert(id int) *tls.Certificate { + e.certMu.RLock() + defer e.certMu.RUnlock() + return e.certByID[id] +} + +func (e *Engine) serveUDPStream(ctx context.Context, s store.Stream, conn *net.UDPConn) { + buf := make([]byte, 65535) + for { + n, clientAddr, err := conn.ReadFromUDP(buf) + if err != nil { + if ctx.Err() != nil { + return + } + continue + } + go func(data []byte, caddr *net.UDPAddr) { + taddr, _ := net.ResolveUDPAddr("udp", net.JoinHostPort(s.ForwardingHost, itoa(s.ForwardingPort))) + tconn, err := net.DialUDP("udp", nil, taddr) + if err != nil { + return + } + defer tconn.Close() + tconn.Write(data) + tconn.SetReadDeadline(time.Now().Add(5 * time.Second)) + resp := make([]byte, 65535) + m, _, _ := tconn.ReadFromUDP(resp) + if m > 0 { + conn.WriteToUDP(resp[:m], caddr) + } + }(append([]byte{}, buf[:n]...), clientAddr) + } +} + +func (e *Engine) loadCerts() { + e.certMu.Lock() + defer e.certMu.Unlock() + e.hostCerts = make(map[string]*tls.Certificate) + e.certByID = make(map[int]*tls.Certificate) + + certs := e.st.GetCertificates() + certByID := map[int]store.Certificate{} + for _, c := range certs { + certByID[c.ID] = c + } + + for id, c := range certByID { + certPEM, _ := c.Meta["certificate"].(string) + if certPEM == "" { + certPEM, _ = c.Meta["cert"].(string) // compat our old + UI + } + keyPEM, _ := c.Meta["certificate_key"].(string) + if keyPEM == "" { + keyPEM, _ = c.Meta["key"].(string) + } + if certPEM != "" && keyPEM != "" { + tcert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)) + if err == nil { + e.certByID[id] = &tcert + // also index by domains for http proxy hosts + for _, d := range c.DomainNames { + e.hostCerts[strings.ToLower(d)] = &tcert + } + } + } + } + + // legacy per-host for proxy (now covered above, but keep for compat) + // re-attaches using ph.CertificateID even if cert's DomainNames differ; supports hosts referencing cert by id only + proxyHosts := e.st.GetProxyHosts() + for _, ph := range proxyHosts { + if ph.CertificateID > 0 { + if tcert, ok := e.certByID[ph.CertificateID]; ok { + for _, d := range ph.DomainNames { + e.hostCerts[strings.ToLower(d)] = tcert + } + } + } + } +} + +func (e *Engine) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + e.certMu.RLock() + defer e.certMu.RUnlock() + if cert, ok := e.hostCerts[hello.ServerName]; ok { + return cert, nil + } + // fallback to any + for _, c := range e.hostCerts { + return c, nil + } + return nil, fmt.Errorf("no certificate for %s", hello.ServerName) +} + +func (e *Engine) loadRedirs() { + e.redirMu.Lock() + defer e.redirMu.Unlock() + e.redirHosts = make(map[string]store.RedirectionHost) + + redirs := e.st.GetRedirectionHosts() + keys := []string{} + for _, rh := range redirs { + if rh.Enabled { + for _, d := range rh.DomainNames { + lower := strings.ToLower(d) + e.redirHosts[lower] = rh + keys = append(keys, lower) + } + } + } + slog.Info("loadRedirs done", "num", len(e.redirHosts), "keys", keys) +} + +func (e *Engine) loadDeads() { + e.deadMu.Lock() + defer e.deadMu.Unlock() + e.deadHosts = make(map[string]store.DeadHost) + + deads := e.st.GetDeadHosts() + keys := []string{} + for _, dh := range deads { + if dh.Enabled { + for _, d := range dh.DomainNames { + e.deadHosts[strings.ToLower(d)] = dh + keys = append(keys, d) + } + } + } + slog.Info("loadDeads done", "num", len(e.deadHosts), "keys", keys) +} + +func (e *Engine) TLSConfig() *tls.Config { + return &tls.Config{ + GetCertificate: e.GetCertificate, + } +} + +// TLS + streams: https listener (with SNI via GetCertificate) and dynamic stream mgmt +// (reload + per-port listeners/cancels) are implemented in cmd/helix-proxy/main.go +// (using eng.TLSConfig()/Handler() and reloadStreams on every ReloadFromStore). + +// checkAccess implements basic allow/deny from access list clients. +// satisfyAny means any allow is enough (after denies). +func checkAccess(clients []store.AccessClient, satisfyAny bool, ipStr string) bool { + if len(clients) == 0 { + return true + } + ip := ipStr + if h, _, err := net.SplitHostPort(ipStr); err == nil { + ip = h + } + parsed := net.ParseIP(ip) + if parsed == nil { + slog.Info("access check bad ip", "ip", ipStr) + return false + } + + hasAllow := false + for _, c := range clients { + addr := c.Address + if addr == "all" { + if c.Directive == "deny" { + slog.Info("access deny all", "ip", ipStr) + return false + } + hasAllow = true + continue + } + // try CIDR + if _, cidr, err := net.ParseCIDR(addr); err == nil { + if cidr.Contains(parsed) { + if c.Directive == "deny" { + slog.Info("access deny cidr", "ip", ipStr, "cidr", addr) + return false + } + hasAllow = true + } + continue + } + // exact IP + if net.ParseIP(addr).Equal(parsed) { + if c.Directive == "deny" { + slog.Info("access deny exact", "ip", ipStr, "addr", addr) + return false + } + hasAllow = true + } + } + + if satisfyAny { + slog.Info("access satisfyAny result", "ip", ipStr, "hasAllow", hasAllow) + return hasAllow + } + // satisfy all: if we didn't hit any deny, and there were allows or no rules, allow + slog.Info("access satisfyAll result", "ip", ipStr, "hasAllow", hasAllow) + return true +} + +// (no more config ref needed here) + +// --- helpers for new engine features (hsts/ws/headers/advanced) --- + +func isWebsocketRequest(r *http.Request) bool { + return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") && + strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") +} + +func getRealClientIP(r *http.Request, trustForwarded bool) string { + // X-Forwarded-For may be comma list; first is original client (when trusted) + // gated on host's TrustForwardedProto (also controls proto; used for IP trust per security review) + if trustForwarded { + if xf := r.Header.Get("X-Forwarded-For"); xf != "" { + parts := strings.Split(xf, ",") + return strings.TrimSpace(parts[0]) + } + } + ip := r.RemoteAddr + if h, _, err := net.SplitHostPort(ip); err == nil { + return h + } + return ip +} + +// resolveProxyHeaders returns header overrides from structured fields, falling back to legacy advancedConfig. +func resolveProxyHeaders(ph store.ProxyHost) (reqSets map[string]string, respAdds map[string]string, respHides []string) { + hasStructured := len(ph.RequestHeaders) > 0 || len(ph.ResponseHeaders) > 0 || len(ph.HideHeaders) > 0 + if hasStructured { + reqSets = map[string]string{} + for _, h := range ph.RequestHeaders { + name := strings.TrimSpace(h.Name) + if name != "" { + reqSets[name] = h.Value + } + } + respAdds = map[string]string{} + for _, h := range ph.ResponseHeaders { + name := strings.TrimSpace(h.Name) + if name != "" { + respAdds[name] = h.Value + } + } + for _, name := range ph.HideHeaders { + name = strings.TrimSpace(name) + if name != "" { + respHides = append(respHides, name) + } + } + return reqSets, respAdds, respHides + } + if ph.AdvancedConfig != "" { + return parseAdvancedConfig(ph.AdvancedConfig) + } + return map[string]string{}, map[string]string{}, nil +} + +// parseAdvancedConfig does a fuller (but still best-effort) parse of the advanced_config string +// (in the spirit of advanced config / snippets) to support common cases: +// - proxy_set_header Foo Bar +// - add_header X-Resp baz; +// - proxy_hide_header X-Foo +// - proxy_redirect (parsed, no-op for now as complex) +// - comments, ; or \n sep, set $var ignored. +// Returns maps for application via Director/ModifyResponse so headers survive proxy. +func parseAdvancedConfig(adv string) (reqSets map[string]string, respAdds map[string]string, respHides []string) { + reqSets = map[string]string{} + respAdds = map[string]string{} + respHides = []string{} + if adv == "" { + return + } + // normalize separators + adv = strings.ReplaceAll(adv, ";", "\n") + lines := strings.Split(adv, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + lower := strings.ToLower(line) + switch { + case strings.HasPrefix(lower, "proxy_set_header "): + // proxy_set_header X-Foo bar baz + parts := strings.Fields(line) + if len(parts) >= 3 { + name := parts[1] + val := strings.Join(parts[2:], " ") + reqSets[name] = val + } + case strings.HasPrefix(lower, "add_header "): + // add_header X-Response-Header "value" always; + parts := strings.Fields(line) + if len(parts) >= 3 { + name := parts[1] + // take until ; or end, strip quotes + val := strings.Join(parts[2:], " ") + val = strings.Trim(val, `"; `) + respAdds[name] = val + } + case strings.HasPrefix(lower, "proxy_hide_header "): + parts := strings.Fields(line) + if len(parts) >= 2 { + name := parts[1] + respHides = append(respHides, name) + } + case strings.HasPrefix(lower, "proxy_redirect "): + // best-effort: no-op here (would require Location rewrite in ModifyResponse for full) + default: + // ignore set $foo, etc. + } + } + return +} diff --git a/internal/proxy/engine_test.go b/internal/proxy/engine_test.go new file mode 100644 index 0000000..ae613f4 --- /dev/null +++ b/internal/proxy/engine_test.go @@ -0,0 +1,825 @@ +package proxy + +import ( + "bytes" + "crypto/tls" + "encoding/base64" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "helix-proxy/internal/certificate" + "helix-proxy/internal/config" + "helix-proxy/internal/store" +) + +// --- pure helper tests (table driven as recommended) --- + +func TestItoa(t *testing.T) { + if itoa(80) != "80" || itoa(0) != "0" || itoa(443) != "443" { + t.Error("itoa wrong") + } +} + +func TestIsWebsocketRequest(t *testing.T) { + tests := []struct { + name string + h http.Header + want bool + }{ + {"none", http.Header{}, false}, + {"upgrade ws conn upgrade", http.Header{"Upgrade": []string{"websocket"}, "Connection": []string{"Upgrade"}}, true}, + {"case insen", http.Header{"Upgrade": []string{"WebSocket"}, "Connection": []string{"keep-alive, Upgrade"}}, true}, + {"no upgrade header", http.Header{"Connection": []string{"Upgrade"}}, false}, + {"no conn upgrade", http.Header{"Upgrade": []string{"websocket"}}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &http.Request{Header: tt.h} + if got := isWebsocketRequest(r); got != tt.want { + t.Errorf("isWebsocketRequest() = %v want %v", got, tt.want) + } + }) + } +} + +func TestGetRealClientIP(t *testing.T) { + tests := []struct { + name string + r *http.Request + trust bool + want string + }{ + {"xff first (trust)", &http.Request{Header: http.Header{"X-Forwarded-For": []string{"1.2.3.4, 5.6.7.8"}}, RemoteAddr: "9.9.9.9:1"}, true, "1.2.3.4"}, + {"xff ignored when !trust", &http.Request{Header: http.Header{"X-Forwarded-For": []string{"1.2.3.4, 5.6.7.8"}}, RemoteAddr: "9.9.9.9:1"}, false, "9.9.9.9"}, + {"no xff use remote", &http.Request{RemoteAddr: "10.0.0.1:1234"}, true, "10.0.0.1"}, + {"remote no port", &http.Request{RemoteAddr: "10.0.0.2"}, false, "10.0.0.2"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getRealClientIP(tt.r, tt.trust); got != tt.want { + t.Errorf("getRealClientIP() = %v want %v", got, tt.want) + } + }) + } +} + +func TestParseAdvancedConfig(t *testing.T) { + tests := []struct { + name string + adv string + wantSets map[string]string + wantAdds map[string]string + wantHides []string + }{ + {"empty", "", map[string]string{}, map[string]string{}, []string{}}, + {"comment and blank", "# foo\n\n ", map[string]string{}, map[string]string{}, []string{}}, + {"set_header", "proxy_set_header X-Foo bar baz", map[string]string{"X-Foo": "bar baz"}, map[string]string{}, []string{}}, + {"add_header quoted", `add_header X-Bar "val with space";`, map[string]string{}, map[string]string{"X-Bar": "val with space"}, []string{}}, + {"hide", "proxy_hide_header X-Baz\nproxy_hide_header X-Qux", map[string]string{}, map[string]string{}, []string{"X-Baz", "X-Qux"}}, + {"mixed sep ; and nl", "proxy_set_header A 1; add_header B 2; proxy_hide_header C", map[string]string{"A": "1"}, map[string]string{"B": "2"}, []string{"C"}}, + {"unknown ignored", "set $foo bar\nproxy_redirect / /", map[string]string{}, map[string]string{}, []string{}}, + {"trim quotes in add", `add_header X "v";`, map[string]string{}, map[string]string{"X": "v"}, []string{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sets, adds, hides := parseAdvancedConfig(tt.adv) + if !mapsEqual(sets, tt.wantSets) { + t.Errorf("reqSets = %v want %v", sets, tt.wantSets) + } + if !mapsEqual(adds, tt.wantAdds) { + t.Errorf("respAdds = %v want %v", adds, tt.wantAdds) + } + if !slicesEqual(hides, tt.wantHides) { + t.Errorf("respHides = %v want %v", hides, tt.wantHides) + } + }) + } +} + +func TestResolveProxyHeaders(t *testing.T) { + structured := store.ProxyHost{ + RequestHeaders: []store.HeaderRule{{Name: "X-Req", Value: "1"}}, + ResponseHeaders: []store.HeaderRule{{Name: "X-Resp", Value: "2"}}, + HideHeaders: []string{"X-Hide"}, + } + sets, adds, hides := resolveProxyHeaders(structured) + if !mapsEqual(sets, map[string]string{"X-Req": "1"}) { + t.Errorf("structured reqSets = %v", sets) + } + if !mapsEqual(adds, map[string]string{"X-Resp": "2"}) { + t.Errorf("structured respAdds = %v", adds) + } + if !slicesEqual(hides, []string{"X-Hide"}) { + t.Errorf("structured respHides = %v", hides) + } + + legacy := store.ProxyHost{AdvancedConfig: "proxy_set_header A 1\nadd_header B 2;\nproxy_hide_header C"} + sets, adds, hides = resolveProxyHeaders(legacy) + if !mapsEqual(sets, map[string]string{"A": "1"}) || !mapsEqual(adds, map[string]string{"B": "2"}) || !slicesEqual(hides, []string{"C"}) { + t.Errorf("legacy fallback failed: sets=%v adds=%v hides=%v", sets, adds, hides) + } + + // structured fields take precedence over legacy advancedConfig + both := store.ProxyHost{ + RequestHeaders: []store.HeaderRule{{Name: "New", Value: "v"}}, + AdvancedConfig: "proxy_set_header Old 1", + } + sets, _, _ = resolveProxyHeaders(both) + if !mapsEqual(sets, map[string]string{"New": "v"}) { + t.Errorf("structured should win over legacy: %v", sets) + } +} + +func mapsEqual(a, b map[string]string) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if b[k] != v { + return false + } + } + return true +} + +func slicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestCheckAccess(t *testing.T) { + tests := []struct { + name string + clients []store.AccessClient + satisfyAny bool + ip string + want bool + }{ + {"no clients allow", nil, false, "1.2.3.4", true}, + {"empty allow", []store.AccessClient{}, false, "1.2.3.4", true}, + {"deny all", []store.AccessClient{{Address: "all", Directive: "deny"}}, false, "9.9.9.9", false}, + {"allow all", []store.AccessClient{{Address: "all", Directive: "allow"}}, false, "1.1.1.1", true}, + {"exact allow", []store.AccessClient{{Address: "10.0.0.1", Directive: "allow"}}, false, "10.0.0.1:123", true}, + {"exact deny", []store.AccessClient{{Address: "10.0.0.1", Directive: "deny"}}, false, "10.0.0.1", false}, + {"cidr allow", []store.AccessClient{{Address: "192.168.0.0/16", Directive: "allow"}}, false, "192.168.1.2", true}, + {"cidr deny", []store.AccessClient{{Address: "192.168.0.0/16", Directive: "deny"}}, false, "192.168.1.2", false}, + {"satisfyAny: one allow sufficient", []store.AccessClient{{Address: "1.1.1.1", Directive: "allow"}, {Address: "2.2.2.2", Directive: "allow"}}, true, "1.1.1.1", true}, + {"satisfyAny: no allow", []store.AccessClient{{Address: "1.1.1.1", Directive: "deny"}}, true, "9.9.9.9", false}, + {"satisfy all (default): denies block even if allow present", []store.AccessClient{{Address: "1.1.1.1", Directive: "allow"}, {Address: "1.1.1.1", Directive: "deny"}}, false, "1.1.1.1", false}, + {"bad ip", []store.AccessClient{{Address: "all", Directive: "allow"}}, false, "not-an-ip", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := checkAccess(tt.clients, tt.satisfyAny, tt.ip) + if got != tt.want { + t.Errorf("checkAccess(%v, %v, %q) = %v want %v", tt.clients, tt.satisfyAny, tt.ip, got, tt.want) + } + }) + } +} + +func TestEngineReloadClearsCache(t *testing.T) { + eng, st := newTestEngine(t) + defer closeStore(st) + + eng.cacheMu.Lock() + eng.cache["k"] = struct { + body []byte + headers http.Header + status int + expires time.Time + }{body: []byte("x")} + eng.cacheMu.Unlock() + + // use public ReloadFromStore (exercises the cache clear inside it) + eng.ReloadFromStore() + + eng.cacheMu.Lock() + if len(eng.cache) != 0 { + t.Error("reload did not clear cache") + } + eng.cacheMu.Unlock() +} + +// --- helpers for handler tests --- +func newTestEngine(t *testing.T) (*Engine, store.Store) { + t.Helper() + tmp := t.TempDir() + config.ResetForTest() + t.Setenv("DATA_DIR", tmp) + _ = config.EnsureDataDirs() + st, err := store.New() + if err != nil { + t.Fatalf("test store: %v", err) + } + eng := NewEngine(st) + eng.ReloadFromStore() + return eng, st +} + +func doReq(h http.Handler, method, host, path string, headers http.Header, body []byte) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, bytes.NewReader(body)) + req.Host = host + if headers != nil { + req.Header = headers.Clone() + } + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + return rr +} + +// TestEngineHandler_HostMatchingRedirsDeadsDefaults exercises core routing branches. +func TestEngineHandler_HostMatchingRedirsDeadsDefaults(t *testing.T) { + eng, st := newTestEngine(t) + defer func() { + if c, ok := st.(interface{ Close() error }); ok { + c.Close() + } + }() + + h := eng.Handler() + + // no hosts: default congratulations (or index if present) + rr := doReq(h, "GET", "nohost.local", "/", nil, nil) + if rr.Code != 200 || !strings.Contains(rr.Body.String(), "Welcome to Helix Proxy") { + t.Errorf("default congrats: code=%d body=%s", rr.Code, rr.Body.String()[:min(100, rr.Body.Len())]) + } + + // set default 404 + st.SetSetting("default_site", "404") + eng.ReloadFromStore() + rr = doReq(h, "GET", "nohost.local", "/", nil, nil) + if rr.Code != 404 { + t.Errorf("default 404: %d", rr.Code) + } + + // 444 + st.SetSetting("default_site", "444") + eng.ReloadFromStore() + rr = doReq(h, "GET", "nohost.local", "/", nil, nil) + if rr.Code != 444 { + t.Errorf("default 444: %d", rr.Code) + } + + // redirect default + st.SetSetting("default_site", "redirect") + st.SetSetting("default_site_redirect", "https://def.example") + eng.ReloadFromStore() + rr = doReq(h, "GET", "nohost.local", "/", nil, nil) + if rr.Code != 302 || !strings.Contains(rr.Header().Get("Location"), "def.example") { + t.Errorf("default redirect: %d %s", rr.Code, rr.Header().Get("Location")) + } + + // create a redir host + rh := store.RedirectionHost{ + DomainNames: []string{"redir.example"}, + ForwardHTTPCode: 301, + ForwardScheme: "https", + ForwardDomainName: "target.example", + PreservePath: true, + Enabled: true, + } + _, _ = st.CreateRedirectionHost(rh) + eng.ReloadFromStore() + rr = doReq(h, "GET", "redir.example", "/foo?x=1", nil, nil) + if rr.Code != 301 || !strings.Contains(rr.Header().Get("Location"), "https://target.example/foo?x=1") { + t.Errorf("redir preserve: %d loc=%s", rr.Code, rr.Header().Get("Location")) + } + + // dead host plain + dh := store.DeadHost{DomainNames: []string{"dead.example"}, Enabled: true} + _, _ = st.CreateDeadHost(dh) + eng.ReloadFromStore() + rr = doReq(h, "GET", "dead.example", "/", nil, nil) + if rr.Code != 404 || !strings.Contains(rr.Body.String(), "dead/blocked") { + t.Errorf("dead plain: %d %s", rr.Code, rr.Body.String()) + } + + // dead with meta.html + dh2 := store.DeadHost{DomainNames: []string{"deadhtml.example"}, Enabled: true, Meta: map[string]any{"html": "

custom dead

"}} + _, _ = st.CreateDeadHost(dh2) + eng.ReloadFromStore() + rr = doReq(h, "GET", "deadhtml.example", "/", nil, nil) + if rr.Code != 404 || !strings.Contains(rr.Body.String(), "custom dead") { + t.Errorf("dead meta html: %d %s", rr.Code, rr.Body.String()) + } + + // custom 404.html file fallback (not just meta) + wwwDir := config.Resolve("www") + _ = os.MkdirAll(wwwDir, 0o755) + _ = os.WriteFile(filepath.Join(wwwDir, "404.html"), []byte("

CUSTOM 404 FILE

"), 0o644) + dh3 := store.DeadHost{DomainNames: []string{"deadfile.example"}, Enabled: true} + _, _ = st.CreateDeadHost(dh3) + eng.ReloadFromStore() + rr = doReq(h, "GET", "deadfile.example", "/", nil, nil) + if rr.Code != 404 || !strings.Contains(rr.Body.String(), "CUSTOM 404 FILE") { + t.Errorf("dead custom 404.html: %d %s", rr.Code, rr.Body.String()) + } +} + +// TestEngineHandler_ProxyHostBasic + locations + ssl + block + hsts + ws + xforward +func TestEngineHandler_ProxyBasics(t *testing.T) { + eng, st := newTestEngine(t) + defer closeStore(st) + + // create a simple proxy host (no backend will 502 but we can check early behaviors before proxy) + ph := store.ProxyHost{ + DomainNames: []string{"proxy.example"}, + ForwardScheme: "http", + ForwardHost: "127.0.0.1", + ForwardPort: 9, // invalid to not actually connect in test + Enabled: true, + SslForced: true, + BlockExploits: true, + HstsEnabled: true, + HstsSubdomains: true, + TrustForwardedProto: true, + RequestHeaders: []store.HeaderRule{{Name: "X-Custom", Value: "reqval"}}, + ResponseHeaders: []store.HeaderRule{{Name: "X-Resp", Value: "respval"}}, + HideHeaders: []string{"X-HideMe"}, + } + created, _ := st.CreateProxyHost(ph) + _ = created + eng.ReloadFromStore() + + h := eng.Handler() + + // ssl forced on plain + rr := doReq(h, "GET", "proxy.example", "/path", http.Header{"X-Forwarded-Proto": []string{"http"}}, nil) + if rr.Code != 301 || !strings.HasPrefix(rr.Header().Get("Location"), "https://") { + t.Errorf("ssl_forced: %d %s", rr.Code, rr.Header().Get("Location")) + } + + // block exploits - use TLS set on req to bypass ssl_forced redirect (which is before block check) + req := httptest.NewRequest("GET", "/.env", nil) + req.Host = "proxy.example" + req.TLS = &tls.ConnectionState{} // simulate https so no ssl redirect + rr = httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != 403 || !strings.Contains(rr.Body.String(), "exploits") { + t.Errorf("block_exploits: %d %s", rr.Code, rr.Body.String()) + } + + // HSTS set on response (simulate https to reach hsts code) + req = httptest.NewRequest("GET", "/ok", nil) + req.Host = "proxy.example" + req.TLS = &tls.ConnectionState{} + rr = httptest.NewRecorder() + h.ServeHTTP(rr, req) + if hs := rr.Header().Get("Strict-Transport-Security"); !strings.Contains(hs, "includeSubDomains") { + t.Errorf("hsts not set or missing sub: %q", hs) + } + + // X-Forwarded injection happens in director, but since we hit error backend we may not see, test via other means later + // for now check advanced req set happens early? but applied in director too. +} + +// Test locations forwardPath rewrite +func TestEngineHandler_Locations(t *testing.T) { + eng, st := newTestEngine(t) + defer closeStore(st) + + ph := store.ProxyHost{ + DomainNames: []string{"loc.example"}, + ForwardScheme: "http", + ForwardHost: "127.0.0.1", + ForwardPort: 80, + Enabled: true, + Locations: []store.Location{ + {Path: "/api", ForwardScheme: "http", ForwardHost: "backend", ForwardPort: 9000, ForwardPath: "/v2"}, + }, + } + _, _ = st.CreateProxyHost(ph) + eng.ReloadFromStore() + + h := eng.Handler() + // we can't easily assert the rewritten without real backend or spying, but at least no panic and host match + rr := doReq(h, "GET", "loc.example", "/api/foo", nil, nil) + // expect 502 from revproxy to bad host, but routing happened + if rr.Code == 404 { + t.Error("location host not matched, got 404 default") + } +} + +// Test access lists IP + basic auth + passauth +func TestEngineHandler_AccessLists(t *testing.T) { + eng, st := newTestEngine(t) + defer closeStore(st) + + al := store.AccessList{ + Name: "acl1", + SatisfyAny: false, + Clients: []store.AccessClient{{Address: "10.0.0.0/8", Directive: "allow"}, {Address: "1.2.3.4", Directive: "deny"}}, + Items: []store.AccessItem{{Username: "user", Password: "pass"}}, + PassAuth: false, + } + alCreated, _ := st.CreateAccessList(al) + + ph := store.ProxyHost{ + DomainNames: []string{"acl.example"}, + ForwardHost: "127.0.0.1", + ForwardPort: 80, + Enabled: true, + AccessListID: alCreated.ID, + TrustForwardedProto: true, // enable XFF for spoof tests in access list (default false would ignore XFF) + } + _, _ = st.CreateProxyHost(ph) + + // ph with default TrustForwardedProto=false for testing XFF spoof ignored in access + clientIP + phNoTrust := store.ProxyHost{ + DomainNames: []string{"acl-notrust.example"}, + ForwardHost: "127.0.0.1", + ForwardPort: 80, + Enabled: true, + AccessListID: alCreated.ID, + // TrustForwardedProto: false (default) => XFF ignored, use RemoteAddr for access decisions/injection + } + _, _ = st.CreateProxyHost(phNoTrust) + eng.ReloadFromStore() + + h := eng.Handler() + + // deny via explicit deny client (note: 11.x would be allowed under !satisfyAny if no deny hit; use the deny addr) + rr := doReq(h, "GET", "acl.example", "/", http.Header{"X-Forwarded-For": []string{"1.2.3.4"}}, nil) + if rr.Code != 403 { + t.Errorf("access deny exact: %d", rr.Code) + } + + // trust=false + spoof XFF (deny addr): should ignore XFF (use Remote which doesn't match deny) => no 403, reaches auth + rr = doReq(h, "GET", "acl-notrust.example", "/", http.Header{"X-Forwarded-For": []string{"1.2.3.4"}}, nil) + if rr.Code != 401 { + t.Errorf("TrustForwardedProto=false should ignore XFF spoof (reaches auth 401); got %d", rr.Code) + } + + // allow by cidr via xff + rr = doReq(h, "GET", "acl.example", "/", http.Header{"X-Forwarded-For": []string{"10.1.2.3"}}, nil) + // will 401 because basic auth required next + if rr.Code != 401 { + t.Errorf("access allow but no auth expected 401: %d", rr.Code) + } + + // basic auth fail + rr = doReq(h, "GET", "acl.example", "/", http.Header{ + "X-Forwarded-For": []string{"10.1.2.3"}, + "Authorization": []string{"Basic " + basic("bad", "bad")}, + }, nil) + if rr.Code != 401 { + t.Errorf("basic bad: %d", rr.Code) + } + + // basic good, passauth false so stripped + rr = doReq(h, "GET", "acl.example", "/", http.Header{ + "X-Forwarded-For": []string{"10.1.2.3"}, + "Authorization": []string{"Basic " + basic("user", "pass")}, + }, nil) + if rr.Code == 401 { + t.Error("basic good should not 401") + } + // (502 later ok) +} + +func basic(u, p string) string { + return base64.StdEncoding.EncodeToString([]byte(u + ":" + p)) +} + +func TestEngineHandler_Caching(t *testing.T) { + eng, st := newTestEngine(t) + defer closeStore(st) + + upstream := NewWhoami(t, WhoamiConfig{ + DefaultHeaders: map[string]string{ + "Cache-Control": "max-age=3600", + "X-Upstream": "yes", + }, + Routes: map[string]WhoamiRoute{ + "/cpath": {Body: "CACHED-BODY-FOR-/cpath"}, + }, + }) + _, _ = st.CreateProxyHost(store.ProxyHost{ + DomainNames: []string{"cache.example"}, + ForwardScheme: "http", + ForwardHost: upstream.Hostname, + ForwardPort: upstream.Port, + Enabled: true, + CachingEnabled: true, + }) + eng.ReloadFromStore() + h := eng.Handler() + + rr1 := doReq(h, "GET", "cache.example", "/cpath", nil, nil) + if rr1.Code != 200 || !strings.Contains(rr1.Body.String(), "CACHED-BODY") || rr1.Header().Get("X-Proxy-Cache") != "MISS" { + t.Errorf("first MISS: code=%d cache=%s body=%s", rr1.Code, rr1.Header().Get("X-Proxy-Cache"), rr1.Body.String()) + } + + rr2 := doReq(h, "GET", "cache.example", "/cpath", nil, nil) + if rr2.Header().Get("X-Proxy-Cache") != "HIT" || rr2.Body.String() != rr1.Body.String() { + t.Errorf("second HIT: cache=%s body=%s", rr2.Header().Get("X-Proxy-Cache"), rr2.Body.String()) + } + + noStore := NewWhoami(t, WhoamiConfig{ + DefaultHeaders: map[string]string{"Cache-Control": "no-store"}, + Routes: map[string]WhoamiRoute{"/p": {Body: "NO-STORE-BODY"}}, + }) + _, _ = st.CreateProxyHost(store.ProxyHost{ + DomainNames: []string{"cache-nostore.example"}, + ForwardScheme: "http", + ForwardHost: noStore.Hostname, + ForwardPort: noStore.Port, + Enabled: true, + CachingEnabled: true, + }) + eng.ReloadFromStore() + h = eng.Handler() + + rr3 := doReq(h, "GET", "cache-nostore.example", "/p", nil, nil) + if rr3.Header().Get("X-Proxy-Cache") != "" { + t.Error("no-store should not set X-Proxy-Cache") + } + rr4 := doReq(h, "GET", "cache-nostore.example", "/p", nil, nil) + if rr4.Header().Get("X-Proxy-Cache") == "HIT" { + t.Error("no-store response should not have been cached for HIT") + } + + eng.ReloadFromStore() +} + +// Test X-Forwarded* injection (respects trust), ws header munging via whoami upstream. +func TestEngineHandler_ForwardedAndWS(t *testing.T) { + eng, st := newTestEngine(t) + defer closeStore(st) + + up := NewWhoami(t, WhoamiConfig{}) + _, _ = st.CreateProxyHost(store.ProxyHost{ + DomainNames: []string{"fwd.example"}, + ForwardScheme: "http", + ForwardHost: up.Hostname, + ForwardPort: up.Port, + Enabled: true, + TrustForwardedProto: true, + AllowWebsocketUpgrade: true, + }) + eng.ReloadFromStore() + h := eng.Handler() + + hdrs := http.Header{ + "X-Forwarded-For": []string{"10.9.8.7"}, + "X-Forwarded-Proto": []string{"https"}, + "Connection": []string{"Upgrade"}, + "Upgrade": []string{"websocket"}, + } + rr := doReq(h, "GET", "fwd.example", "/ws", hdrs, nil) + if rr.Code != 200 { + t.Fatalf("forwarded/ws proxy: code=%d", rr.Code) + } + got, ok := up.LastRequest() + if !ok { + t.Fatal("upstream saw no request") + } + if !strings.Contains(got.HeaderValue("X-Forwarded-For"), "10.9.8.7") || got.HeaderValue("X-Forwarded-Proto") != "https" { + t.Errorf("X-Forwarded* injection: ff=%s proto=%s", got.HeaderValue("X-Forwarded-For"), got.HeaderValue("X-Forwarded-Proto")) + } + if got.HeaderValue("Connection") != "Upgrade" || got.HeaderValue("Upgrade") != "websocket" { + t.Errorf("ws munging: conn=%s up=%s", got.HeaderValue("Connection"), got.HeaderValue("Upgrade")) + } +} + +// Test ACME challenge serving (before host) +func TestEngineHandler_ACME(t *testing.T) { + eng, st := newTestEngine(t) + defer closeStore(st) + + h := eng.Handler() + + chDir := config.Resolve("letsencrypt-acme-challenge") + acmeFileDir := filepath.Join(chDir, ".well-known", "acme-challenge") + _ = os.MkdirAll(acmeFileDir, 0o755) + token := "testtoken123" + content := "challenge-content-xyz" + if err := os.WriteFile(filepath.Join(acmeFileDir, token), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + rr := doReq(h, "GET", "anyhost", "/.well-known/acme-challenge/"+token, nil, nil) + if rr.Code != 200 || rr.Body.String() != content { + t.Errorf("acme serve: code=%d body=%q", rr.Code, rr.Body.String()) + } + + rr = doReq(h, "GET", "anyhost", "/.well-known/acme-challenge/missing", nil, nil) + if rr.Code != 404 { + t.Errorf("acme missing: %d", rr.Code) + } +} + +// Test loadCerts / GetCertificate (SNI, compat keys) +func TestEngine_LoadCertsGetCertificate(t *testing.T) { + eng, st := newTestEngine(t) + defer closeStore(st) + + // create via cert manager's test path (dev mode makes *all* LE certs self-signed test certs) + config.ResetForTest() + t.Setenv("DATA_DIR", config.DataDir()) // already set in newTest + t.Setenv("PROXY_MODE", "development") + cm := certificate.NewManager(st) + certRec := store.Certificate{ + Provider: "letsencrypt", + DomainNames: []string{"sni.example"}, + Meta: map[string]any{}, + } + created, _ := st.CreateCertificate(certRec) + email := cm.GetEmailForCert(created) + if err := cm.Issue(created, email); err != nil { + t.Fatalf("issue test cert for load: %v", err) + } + // now attach to host + ph := store.ProxyHost{ + DomainNames: []string{"sni.example"}, + ForwardHost: "127.0.0.1", + ForwardPort: 80, + Enabled: true, + CertificateID: created.ID, + } + _, _ = st.CreateProxyHost(ph) + + eng.ReloadFromStore() + + // loadCerts called in reload; should succeed with real PEMs + cert, err := eng.GetCertificate(&tls.ClientHelloInfo{ServerName: "sni.example"}) + if err != nil { + t.Fatalf("GetCertificate: %v", err) + } + if cert == nil { + t.Error("nil cert") + } + // fallback any (there is one now) + _, _ = eng.GetCertificate(&tls.ClientHelloInfo{ServerName: "unknown"}) +} + +// Test streams reload (at least lifecycle no panic) +func TestEngine_StreamsReload(t *testing.T) { + eng, st := newTestEngine(t) + defer closeStore(st) + + strm := store.Stream{ + IncomingPort: 0, // use high? but test may bind fail; use invalid port to just test start path without real listen success + ForwardingHost: "127.0.0.1", + ForwardingPort: 9, + TCPForwarding: true, + Enabled: true, + } + // port 0 not good; pick a likely free high port for test? but to avoid bind in CI, just create disabled first + strm.IncomingPort = 19999 + (int(time.Now().UnixNano()) % 1000) // somewhat unique + _, _ = st.CreateStream(strm) + eng.ReloadFromStore() + runtime := eng.StreamStatus(strm.IncomingPort) + if !runtime.Listening { + t.Fatalf("expected stream listening on port %d: %+v", strm.IncomingPort, runtime) + } + // now disable and reload to exercise cancel path + strm.Enabled = false + _, _ = st.UpdateStream(strm) + eng.ReloadFromStore() + // ok if no crash +} + +func TestEngine_StreamListenFailure(t *testing.T) { + eng, st := newTestEngine(t) + defer closeStore(st) + + port := 19800 + (int(time.Now().UnixNano()) % 1000) + ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) + if err != nil { + t.Fatalf("bind probe port: %v", err) + } + defer ln.Close() + + strm := store.Stream{ + IncomingPort: port, + ForwardingHost: "127.0.0.1", + ForwardingPort: 9, + TCPForwarding: true, + Enabled: true, + } + _, _ = st.CreateStream(strm) + eng.ReloadFromStore() + + runtime := eng.StreamStatus(port) + if runtime.Listening { + t.Fatal("expected stream not listening when port is taken") + } + if runtime.ListenError == "" { + t.Fatal("expected listen error when port is taken") + } +} + +func TestEngine_StreamReloadSamePort(t *testing.T) { + eng, st := newTestEngine(t) + defer closeStore(st) + + port := 19700 + (int(time.Now().UnixNano()) % 1000) + strm := store.Stream{ + IncomingPort: port, + ForwardingHost: "127.0.0.1", + ForwardingPort: 9, + TCPForwarding: true, + Enabled: true, + } + created, _ := st.CreateStream(strm) + eng.ReloadFromStore() + + runtime := eng.StreamStatus(port) + if !runtime.Listening { + t.Fatalf("expected stream listening after initial reload: %+v", runtime) + } + + created.ForwardingPort = 10 + _, _ = st.UpdateStream(created) + eng.ReloadFromStore() + + runtime = eng.StreamStatus(port) + if !runtime.Listening { + t.Fatalf("expected stream still listening after edit reload on same port: %+v", runtime) + } +} + +func TestEngine_StreamReloadLeavesOthersRunning(t *testing.T) { + eng, st := newTestEngine(t) + defer closeStore(st) + + base := 19600 + (int(time.Now().UnixNano()) % 1000) + portA := base + portB := base + 1 + + a, _ := st.CreateStream(store.Stream{ + IncomingPort: portA, ForwardingHost: "127.0.0.1", ForwardingPort: 9, + TCPForwarding: true, Enabled: true, + }) + b, _ := st.CreateStream(store.Stream{ + IncomingPort: portB, ForwardingHost: "127.0.0.1", ForwardingPort: 9, + TCPForwarding: true, Enabled: true, + }) + eng.ReloadFromStore() + + if st := eng.StreamStatus(portA); !st.Listening { + t.Fatalf("stream A not listening: %+v", st) + } + if st := eng.StreamStatus(portB); !st.Listening { + t.Fatalf("stream B not listening: %+v", st) + } + + a.ForwardingPort = 10 + _, _ = st.UpdateStream(a) + eng.ReloadFromStore() + + if st := eng.StreamStatus(portA); !st.Listening { + t.Fatalf("stream A not listening after edit: %+v", st) + } + if st := eng.StreamStatus(portB); !st.Listening { + t.Fatalf("stream B should still be listening after editing A: %+v", st) + } + _ = b +} + +func TestDuplicateStreamListenPorts(t *testing.T) { + desired := map[int]store.Stream{ + 1: {ID: 1, IncomingPort: 9000, TCPForwarding: true}, + 2: {ID: 2, IncomingPort: 9000, TCPForwarding: true}, + 3: {ID: 3, IncomingPort: 9001, TCPForwarding: true}, + } + conflicts := duplicateStreamListenPorts(desired) + if len(conflicts) != 2 { + t.Fatalf("expected 2 conflicts, got %d: %v", len(conflicts), conflicts) + } + if conflicts[1] == "" || conflicts[2] == "" { + t.Fatalf("expected errors for streams 1 and 2, got %v", conflicts) + } + if _, ok := conflicts[3]; ok { + t.Fatalf("stream 3 should not conflict: %v", conflicts) + } +} + +func closeStore(s store.Store) { + if c, ok := s.(interface{ Close() error }); ok { + c.Close() + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/proxy/integration_test.go b/internal/proxy/integration_test.go new file mode 100644 index 0000000..b3b9553 --- /dev/null +++ b/internal/proxy/integration_test.go @@ -0,0 +1,546 @@ +package proxy + +import ( + "crypto/tls" + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "helix-proxy/internal/certificate" + "helix-proxy/internal/config" + "helix-proxy/internal/store" + + "golang.org/x/net/websocket" +) + +// TestEngine_Integration_FullSetup wires proxy hosts, redirections, dead hosts, +// access lists, certificates, locations, and streams against configurable whoami upstreams. +func TestEngine_Integration_FullSetup(t *testing.T) { + eng, st := newTestEngine(t) + defer closeStore(st) + + // --- upstream mocks --- + mainUpstream := NewWhoami(t, WhoamiConfig{ + Routes: map[string]WhoamiRoute{ + "/health": {Status: 200, Body: `{"status":"ok"}`, Headers: map[string]string{"X-Secret": "hidden"}}, + }, + }) + apiUpstream := NewWhoami(t, WhoamiConfig{ + ValidPrefixes: []string{"/v2"}, + }) + authUpstream := NewWhoami(t, WhoamiConfig{}) + tcpEcho := NewTCPEcho(t, "STREAM-OK\n") + + certRec := issueDevCert(t, st, "secure.example", "stream.example") + + // --- access list --- + al, _ := st.CreateAccessList(store.AccessList{ + Name: "integration-acl", + SatisfyAny: false, + Clients: []store.AccessClient{{Address: "all", Directive: "allow"}}, + Items: []store.AccessItem{{Username: "testuser", Password: "secret"}}, + PassAuth: false, + }) + + // --- proxy host: headers, location rewrite, access, cert --- + _, _ = st.CreateProxyHost(store.ProxyHost{ + DomainNames: []string{"proxy-int.example"}, + ForwardScheme: "http", + ForwardHost: mainUpstream.Hostname, + ForwardPort: mainUpstream.Port, + CertificateID: certRec.ID, + AccessListID: al.ID, + TrustForwardedProto: true, + RequestHeaders: []store.HeaderRule{{Name: "X-Injected-Req", Value: "yes"}}, + ResponseHeaders: []store.HeaderRule{{Name: "X-Injected-Resp", Value: "yes"}}, + HideHeaders: []string{"X-Secret"}, + Enabled: true, + Locations: []store.Location{{ + Path: "/api", + ForwardScheme: "http", + ForwardHost: apiUpstream.Hostname, + ForwardPort: apiUpstream.Port, + ForwardPath: "/v2", + }}, + }) + + // proxy with passAuth=true to verify Authorization reaches upstream + alPass, _ := st.CreateAccessList(store.AccessList{ + Name: "pass-auth-acl", + Clients: []store.AccessClient{{Address: "all", Directive: "allow"}}, + Items: []store.AccessItem{{Username: "u", Password: "p"}}, + PassAuth: true, + }) + _, _ = st.CreateProxyHost(store.ProxyHost{ + DomainNames: []string{"passauth.example"}, + ForwardScheme: "http", + ForwardHost: authUpstream.Hostname, + ForwardPort: authUpstream.Port, + AccessListID: alPass.ID, + Enabled: true, + }) + + // --- redirection host --- + _, _ = st.CreateRedirectionHost(store.RedirectionHost{ + DomainNames: []string{"redir-int.example"}, + ForwardHTTPCode: 302, + ForwardScheme: "https", + ForwardDomainName: "target-int.example", + PreservePath: true, + Enabled: true, + }) + + // --- dead hosts --- + _, _ = st.CreateDeadHost(store.DeadHost{ + DomainNames: []string{"dead-int.example"}, + Enabled: true, + Meta: map[string]any{"html": "

integration dead

"}, + }) + wwwDir := config.Resolve("www") + _ = os.MkdirAll(wwwDir, 0o755) + _ = os.WriteFile(filepath.Join(wwwDir, "404.html"), []byte("

INTEGRATION 404 FILE

"), 0o644) + _, _ = st.CreateDeadHost(store.DeadHost{DomainNames: []string{"deadfile-int.example"}, Enabled: true}) + + // --- default site modes --- + _ = st.SetSetting("default_site", "404") + + // --- stream (TCP) --- + t.Setenv("DISABLE_IPV6", "1") + streamPort := pickFreePort(t) + _, _ = st.CreateStream(store.Stream{ + IncomingPort: streamPort, + ForwardingHost: tcpEcho.Host, + ForwardingPort: tcpEcho.Port, + TCPForwarding: true, + Enabled: true, + }) + cleanupStreams(t, eng, st) + + eng.ReloadFromStore() + h := eng.Handler() + + // --- redirection --- + rr := doReq(h, "GET", "redir-int.example", "/foo?bar=1", nil, nil) + if rr.Code != 302 || !strings.Contains(rr.Header().Get("Location"), "https://target-int.example/foo?bar=1") { + t.Errorf("redirection: code=%d loc=%q", rr.Code, rr.Header().Get("Location")) + } + + // --- dead hosts --- + rr = doReq(h, "GET", "dead-int.example", "/", nil, nil) + if rr.Code != 404 || !strings.Contains(rr.Body.String(), "integration dead") { + t.Errorf("dead meta: code=%d body=%q", rr.Code, rr.Body.String()) + } + rr = doReq(h, "GET", "deadfile-int.example", "/", nil, nil) + if rr.Code != 404 || !strings.Contains(rr.Body.String(), "INTEGRATION 404 FILE") { + t.Errorf("dead file: code=%d body=%q", rr.Code, rr.Body.String()) + } + + // --- default 404 --- + rr = doReq(h, "GET", "unknown-int.example", "/", nil, nil) + if rr.Code != 404 { + t.Errorf("default 404: %d", rr.Code) + } + + // --- certificate SNI --- + cert, err := eng.GetCertificate(&tls.ClientHelloInfo{ServerName: "secure.example"}) + if err != nil || cert == nil { + t.Errorf("GetCertificate secure.example: err=%v cert=%v", err, cert) + } + + // --- proxy: main path with auth + injected headers --- + authHdr := http.Header{ + "Authorization": []string{"Basic " + basic("testuser", "secret")}, + "X-Forwarded-For": []string{"10.0.0.5"}, + "X-Forwarded-Proto": []string{"https"}, + } + rr = doReq(h, "GET", "proxy-int.example", "/health", authHdr, nil) + if rr.Code != 200 { + t.Fatalf("proxy /health: code=%d body=%s", rr.Code, rr.Body.String()) + } + if rr.Header().Get("X-Injected-Resp") != "yes" { + t.Error("response header injection missing") + } + if rr.Header().Get("X-Secret") != "" { + t.Error("hide header failed: X-Secret leaked") + } + if got, ok := mainUpstream.LastRequest(); !ok { + t.Fatal("main upstream saw no request") + } else { + if got.Path != "/health" { + t.Errorf("main path: got %q want /health", got.Path) + } + if got.HeaderValue("X-Injected-Req") != "yes" { + t.Errorf("request header injection: got %q", got.HeaderValue("X-Injected-Req")) + } + if got.HeaderValue("Authorization") != "" { + t.Error("passAuth=false should strip Authorization before upstream") + } + if got.HeaderValue("X-Forwarded-Proto") != "https" { + t.Errorf("X-Forwarded-Proto: got %q", got.HeaderValue("X-Forwarded-Proto")) + } + } + + // --- location path rewrite --- + apiUpstream.ClearRequests() + rr = doReq(h, "GET", "proxy-int.example", "/api/foo", authHdr, nil) + if rr.Code != 200 { + t.Fatalf("location /api/foo: code=%d", rr.Code) + } + got, ok := apiUpstream.LastRequest() + if !ok { + t.Fatal("api upstream saw no request for location") + } + if got.Path != "/v2/foo" { + t.Errorf("location rewrite: upstream path=%q want /v2/foo", got.Path) + } + if rr.Header().Get("X-Whoami-Path") != "/v2/foo" { + t.Errorf("X-Whoami-Path header: %q", rr.Header().Get("X-Whoami-Path")) + } + + // --- passAuth=true keeps Authorization --- + authUpstream.ClearRequests() + rr = doReq(h, "GET", "passauth.example", "/", http.Header{ + "Authorization": []string{"Basic " + basic("u", "p")}, + }, nil) + if rr.Code != 200 { + t.Fatalf("passauth proxy: code=%d", rr.Code) + } + got, ok = authUpstream.LastRequest() + if !ok { + t.Fatal("auth upstream saw no request") + } + if got.HeaderValue("Authorization") == "" { + t.Error("passAuth=true should forward Authorization to upstream") + } + + // --- whoami JSON echo on default route --- + mainUpstream.ClearRequests() + rr = doReq(h, "GET", "proxy-int.example", "/echo-test?q=1", authHdr, nil) + if rr.Code != 200 { + t.Fatalf("echo route: code=%d", rr.Code) + } + var echoed WhoamiRequest + if err := json.Unmarshal(rr.Body.Bytes(), &echoed); err != nil { + t.Fatalf("parse whoami json: %v body=%s", err, rr.Body.String()) + } + if echoed.Path != "/echo-test" || echoed.Query != "q=1" { + t.Errorf("whoami json echo: path=%q query=%q", echoed.Path, echoed.Query) + } + + // --- stream TCP forwarding --- + waitForTCP(t, net.JoinHostPort("127.0.0.1", itoa(streamPort)), 5*time.Second) + conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", itoa(streamPort)), 2*time.Second) + if err != nil { + t.Fatalf("dial stream port %d: %v", streamPort, err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) + if _, err := io.WriteString(conn, "ping\n"); err != nil { + t.Fatalf("stream write: %v", err) + } + buf := make([]byte, 64) + n, err := conn.Read(buf) + if err != nil { + t.Fatalf("stream read: %v", err) + } + if !strings.Contains(string(buf[:n]), "STREAM-OK") { + t.Errorf("stream echo: got %q", string(buf[:n])) + } + + // reload lifecycle: cleanupStreams t.Cleanup also exercises disable path + strms := st.GetStreams() + if len(strms) == 0 { + t.Fatal("no streams in store") + } +} + +// TestEngine_Integration_Websocket verifies end-to-end websocket upgrade and message echo. +func TestEngine_Integration_Websocket(t *testing.T) { + eng, st := newTestEngine(t) + defer closeStore(st) + + wsUp := NewWhoamiWS(t, "/ws") + _, _ = st.CreateProxyHost(store.ProxyHost{ + DomainNames: []string{"ws-int.example"}, + ForwardScheme: "http", + ForwardHost: wsUp.Hostname, + ForwardPort: wsUp.Port, + Enabled: true, + AllowWebsocketUpgrade: true, + TrustForwardedProto: true, + }) + eng.ReloadFromStore() + + proxy := newProxyServer(t, eng) + ws := dialWhoamiWS(t, proxy.URL, "ws-int.example", "/ws", http.Header{ + "X-Forwarded-For": []string{"10.9.8.7"}, + "X-Forwarded-Proto": []string{"https"}, + }) + + if err := websocket.Message.Send(ws, "hello"); err != nil { + t.Fatalf("ws send: %v", err) + } + var reply string + if err := websocket.Message.Receive(ws, &reply); err != nil { + t.Fatalf("ws recv: %v", err) + } + if reply != "echo:hello" { + t.Errorf("ws echo: got %q", reply) + } + + got, ok := wsUp.UpgradeRequest() + if !ok { + t.Fatal("upstream missed websocket upgrade request") + } + if got.Path != "/ws" { + t.Errorf("upgrade path: %q", got.Path) + } + if !strings.Contains(got.HeaderValue("X-Forwarded-For"), "10.9.8.7") { + t.Errorf("upgrade X-Forwarded-For: %q", got.HeaderValue("X-Forwarded-For")) + } + if got.HeaderValue("X-Forwarded-Proto") != "https" { + t.Errorf("upgrade X-Forwarded-Proto: %q", got.HeaderValue("X-Forwarded-Proto")) + } + if got.HeaderValue("Connection") != "Upgrade" || got.HeaderValue("Upgrade") != "websocket" { + t.Errorf("upgrade headers: conn=%q up=%q", got.HeaderValue("Connection"), got.HeaderValue("Upgrade")) + } + msgs := wsUp.Messages() + if len(msgs) != 1 || msgs[0] != "hello" { + t.Errorf("upstream messages: %v", msgs) + } +} + +// TestEngine_Integration_Caching exercises cache HIT/MISS through whoami upstreams. +func TestEngine_Integration_Caching(t *testing.T) { + eng, st := newTestEngine(t) + defer closeStore(st) + + upstream := NewWhoami(t, WhoamiConfig{ + DefaultHeaders: map[string]string{"Cache-Control": "max-age=3600"}, + Routes: map[string]WhoamiRoute{ + "/asset": {Body: "CACHED-ASSET"}, + }, + }) + _, _ = st.CreateProxyHost(store.ProxyHost{ + DomainNames: []string{"cache-int.example"}, + ForwardScheme: "http", + ForwardHost: upstream.Hostname, + ForwardPort: upstream.Port, + Enabled: true, + CachingEnabled: true, + }) + eng.ReloadFromStore() + h := eng.Handler() + + rr1 := doReq(h, "GET", "cache-int.example", "/asset", nil, nil) + if rr1.Code != 200 || rr1.Header().Get("X-Proxy-Cache") != "MISS" || rr1.Body.String() != "CACHED-ASSET" { + t.Fatalf("cache MISS: code=%d cache=%s body=%q", rr1.Code, rr1.Header().Get("X-Proxy-Cache"), rr1.Body.String()) + } + if reqs := upstream.Requests(); len(reqs) != 1 { + t.Fatalf("upstream requests after MISS: %d", len(reqs)) + } + + rr2 := doReq(h, "GET", "cache-int.example", "/asset", nil, nil) + if rr2.Header().Get("X-Proxy-Cache") != "HIT" || rr2.Body.String() != "CACHED-ASSET" { + t.Errorf("cache HIT: cache=%s body=%q", rr2.Header().Get("X-Proxy-Cache"), rr2.Body.String()) + } + if reqs := upstream.Requests(); len(reqs) != 1 { + t.Errorf("upstream should not be hit again on HIT; requests=%d", len(reqs)) + } +} + +// TestEngine_Integration_ProxyMiddleware exercises ssl_forced, block_exploits, and HSTS +// against a real whoami upstream. +func TestEngine_Integration_ProxyMiddleware(t *testing.T) { + eng, st := newTestEngine(t) + defer closeStore(st) + + upstream := NewWhoami(t, WhoamiConfig{}) + _, _ = st.CreateProxyHost(store.ProxyHost{ + DomainNames: []string{"middleware.example"}, + ForwardScheme: "http", + ForwardHost: upstream.Hostname, + ForwardPort: upstream.Port, + Enabled: true, + SslForced: true, + BlockExploits: true, + HstsEnabled: true, + HstsSubdomains: true, + TrustForwardedProto: true, + }) + eng.ReloadFromStore() + h := eng.Handler() + + // ssl_forced redirects plain http + rr := doReq(h, "GET", "middleware.example", "/ok", http.Header{ + "X-Forwarded-Proto": []string{"http"}, + }, nil) + if rr.Code != 301 || !strings.HasPrefix(rr.Header().Get("Location"), "https://") { + t.Errorf("ssl_forced: code=%d loc=%q", rr.Code, rr.Header().Get("Location")) + } + + // block_exploits on https path + req := httptest.NewRequest("GET", "/.env", nil) + req.Host = "middleware.example" + req.TLS = &tls.ConnectionState{} + rr = httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != 403 || !strings.Contains(rr.Body.String(), "exploits") { + t.Errorf("block_exploits: code=%d body=%q", rr.Code, rr.Body.String()) + } + + // HSTS + successful whoami proxy on https + req = httptest.NewRequest("GET", "/ok", nil) + req.Host = "middleware.example" + req.TLS = &tls.ConnectionState{} + rr = httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != 200 { + t.Fatalf("proxy ok: code=%d body=%s", rr.Code, rr.Body.String()) + } + if hs := rr.Header().Get("Strict-Transport-Security"); !strings.Contains(hs, "includeSubDomains") { + t.Errorf("hsts: %q", hs) + } + got, ok := upstream.LastRequest() + if !ok || got.Path != "/ok" { + t.Errorf("upstream path: got=%v ok=%v", got.Path, ok) + } +} + +// issueDevCert creates and issues a self-signed certificate in development mode. +func issueDevCert(t *testing.T, st store.Store, domains ...string) store.Certificate { + t.Helper() + t.Setenv("PROXY_MODE", "development") + cm := certificate.NewManager(st) + rec, _ := st.CreateCertificate(store.Certificate{ + Provider: "letsencrypt", + DomainNames: domains, + Meta: map[string]any{}, + }) + if err := cm.Issue(rec, cm.GetEmailForCert(rec)); err != nil { + t.Fatalf("issue cert: %v", err) + } + rec, _ = st.GetCertificate(rec.ID) + return rec +} + +// TestEngine_Integration_TLSStream verifies TLS-terminated TCP stream forwarding. +func TestEngine_Integration_TLSStream(t *testing.T) { + eng, st := newTestEngine(t) + defer closeStore(st) + t.Setenv("DISABLE_IPV6", "1") + + certRec := issueDevCert(t, st, "stream-tls.example") + tcpEcho := NewTCPEcho(t, "STREAM-TLS-OK\n") + streamPort := 20000 + int(time.Now().UnixNano()%10000) + + _, _ = st.CreateStream(store.Stream{ + IncomingPort: streamPort, + ForwardingHost: tcpEcho.Host, + ForwardingPort: tcpEcho.Port, + TCPForwarding: true, + Enabled: true, + CertificateID: certRec.ID, + }) + cleanupStreams(t, eng, st) + eng.ReloadFromStore() + + if eng.getStreamCert(certRec.ID) == nil { + t.Fatalf("stream cert %d not loaded after reload", certRec.ID) + } + + trust, err := eng.GetCertificate(&tls.ClientHelloInfo{ServerName: "stream-tls.example"}) + if err != nil { + t.Fatalf("GetCertificate: %v", err) + } + + addr := net.JoinHostPort("127.0.0.1", itoa(streamPort)) + var waitErr error + for attempt := 0; attempt < 3; attempt++ { + waitErr = tryWaitForTLSStream(addr, "stream-tls.example", trust, 2*time.Second) + if waitErr == nil { + break + } + eng.ReloadFromStore() + } + if waitErr != nil { + t.Fatalf("tls stream listener on %s: %v", addr, waitErr) + } + + conn := DialTLSStream(t, addr, "stream-tls.example", trust) + if _, err := io.WriteString(conn, "ping\n"); err != nil { + t.Fatalf("tls stream write: %v", err) + } + got := ReadStreamGreeting(t, conn, 64, 2*time.Second) + if !strings.Contains(got, "STREAM-TLS-OK") { + t.Errorf("tls stream echo: got %q", got) + } + + // plain TCP to a TLS listener should fail handshake quickly + raw, err := net.DialTimeout("tcp", addr, time.Second) + if err != nil { + t.Fatalf("raw dial: %v", err) + } + defer raw.Close() + _ = raw.SetDeadline(time.Now().Add(time.Second)) + if _, err := io.WriteString(raw, "not-tls\n"); err != nil { + t.Fatalf("raw write: %v", err) + } + buf := make([]byte, 16) + if _, err := raw.Read(buf); err == nil { + t.Error("expected plain TCP to TLS stream to fail read, got data") + } +} + +// TestEngine_Integration_UDPStream verifies UDP stream forwarding. +func TestEngine_Integration_UDPStream(t *testing.T) { + eng, st := newTestEngine(t) + defer closeStore(st) + t.Setenv("DISABLE_IPV6", "1") // bind 0.0.0.0 so 127.0.0.1 dials reach the listener + + udpEcho := NewUDPEcho(t, "UDP-STREAM-OK\n") + streamPort := pickFreePort(t) + + _, _ = st.CreateStream(store.Stream{ + IncomingPort: streamPort, + ForwardingHost: udpEcho.Host, + ForwardingPort: udpEcho.Port, + UDPForwarding: true, + Enabled: true, + }) + cleanupStreams(t, eng, st) + eng.ReloadFromStore() + + addr := net.JoinHostPort("127.0.0.1", itoa(streamPort)) + waitForUDP(t, addr, 2*time.Second) + + target, err := net.ResolveUDPAddr("udp", addr) + if err != nil { + t.Fatalf("resolve stream udp: %v", err) + } + conn, err := net.DialUDP("udp", nil, target) + if err != nil { + t.Fatalf("dial stream udp: %v", err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) + + if _, err := conn.Write([]byte("probe")); err != nil { + t.Fatalf("udp write: %v", err) + } + buf := make([]byte, 64) + n, err := conn.Read(buf) + if err != nil { + t.Fatalf("udp read: %v", err) + } + if !strings.Contains(string(buf[:n]), "UDP-STREAM-OK") { + t.Errorf("udp stream echo: got %q", string(buf[:n])) + } +} diff --git a/internal/proxy/whoami_test.go b/internal/proxy/whoami_test.go new file mode 100644 index 0000000..60db90d --- /dev/null +++ b/internal/proxy/whoami_test.go @@ -0,0 +1,618 @@ +package proxy + +import ( + "crypto/tls" + "crypto/x509" + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync" + "testing" + "time" + + "helix-proxy/internal/store" + + "golang.org/x/net/websocket" +) + +// WhoamiRequest captures what the upstream mock received. +type WhoamiRequest struct { + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"query,omitempty"` + Host string `json:"host"` + Headers map[string][]string `json:"headers"` + Body string `json:"body,omitempty"` + RemoteAddr string `json:"remoteAddr"` +} + +// WhoamiRoute configures a response for a specific path (and optionally method). +type WhoamiRoute struct { + Method string + Status int + Body string + Headers map[string]string +} + +// WhoamiConfig configures whoami upstream behavior. +type WhoamiConfig struct { + DefaultStatus int + DefaultBody string + DefaultHeaders map[string]string + Routes map[string]WhoamiRoute // key: path or "METHOD path" + ValidPaths []string // exact paths allowed; empty = all + ValidPrefixes []string // path prefixes allowed (checked after ValidPaths) +} + +// WhoamiServer is a test upstream that records requests and returns configurable responses. +type WhoamiServer struct { + Server *httptest.Server + Hostname string + Port int + + cfg WhoamiConfig + + mu sync.Mutex + requests []WhoamiRequest +} + +// NewWhoami starts a whoami upstream and registers cleanup on t. +func NewWhoami(t *testing.T, cfg WhoamiConfig) *WhoamiServer { + t.Helper() + if cfg.DefaultStatus == 0 { + cfg.DefaultStatus = http.StatusOK + } + w := &WhoamiServer{cfg: cfg} + w.Server = httptest.NewServer(http.HandlerFunc(w.serve)) + t.Cleanup(w.Server.Close) + + u, err := url.Parse(w.Server.URL) + if err != nil { + t.Fatalf("whoami url: %v", err) + } + w.Hostname = u.Hostname() + w.Port, err = strconv.Atoi(u.Port()) + if err != nil { + t.Fatalf("whoami port: %v", err) + } + return w +} + +func (w *WhoamiServer) serve(rw http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + + req := WhoamiRequest{ + Method: r.Method, + Path: r.URL.Path, + Query: r.URL.RawQuery, + Host: r.Host, + Headers: cloneHeaderMap(r.Header), + Body: string(body), + RemoteAddr: r.RemoteAddr, + } + + w.mu.Lock() + w.requests = append(w.requests, req) + w.mu.Unlock() + + if !w.pathAllowed(r.URL.Path) { + http.Error(rw, "whoami: path not allowed", http.StatusNotFound) + return + } + + route, ok := w.matchRoute(r.Method, r.URL.Path) + status := w.cfg.DefaultStatus + respBody := w.cfg.DefaultBody + respHeaders := map[string]string{} + for k, v := range w.cfg.DefaultHeaders { + respHeaders[k] = v + } + + if ok { + if route.Status != 0 { + status = route.Status + } + if route.Body != "" { + respBody = route.Body + } + for k, v := range route.Headers { + respHeaders[k] = v + } + } + + // If no custom body, echo the captured request as JSON (whoami-style). + if respBody == "" { + b, err := json.Marshal(req) + if err != nil { + http.Error(rw, "whoami: marshal", http.StatusInternalServerError) + return + } + respBody = string(b) + } + + rw.Header().Set("Content-Type", "application/json") + rw.Header().Set("X-Whoami-Method", req.Method) + rw.Header().Set("X-Whoami-Path", req.Path) + rw.Header().Set("X-Whoami-Host", req.Host) + rw.Header().Set("X-Whoami-Query", req.Query) + for name, vals := range req.Headers { + rw.Header().Set("X-Whoami-H-"+name, strings.Join(vals, ", ")) + } + for k, v := range respHeaders { + rw.Header().Set(k, v) + } + rw.WriteHeader(status) + _, _ = io.WriteString(rw, respBody) +} + +func (w *WhoamiServer) pathAllowed(path string) bool { + if len(w.cfg.ValidPaths) == 0 && len(w.cfg.ValidPrefixes) == 0 { + return true + } + for _, p := range w.cfg.ValidPaths { + if path == p { + return true + } + } + for _, p := range w.cfg.ValidPrefixes { + if strings.HasPrefix(path, p) { + return true + } + } + return false +} + +func (w *WhoamiServer) matchRoute(method, path string) (WhoamiRoute, bool) { + if len(w.cfg.Routes) == 0 { + return WhoamiRoute{}, false + } + key := strings.ToUpper(method) + " " + path + if r, ok := w.cfg.Routes[key]; ok { + return r, true + } + if r, ok := w.cfg.Routes[path]; ok { + return r, true + } + return WhoamiRoute{}, false +} + +// Requests returns a copy of all recorded upstream requests. +func (w *WhoamiServer) Requests() []WhoamiRequest { + w.mu.Lock() + defer w.mu.Unlock() + out := make([]WhoamiRequest, len(w.requests)) + copy(out, w.requests) + return out +} + +// LastRequest returns the most recent upstream request, if any. +func (w *WhoamiServer) LastRequest() (WhoamiRequest, bool) { + w.mu.Lock() + defer w.mu.Unlock() + if len(w.requests) == 0 { + return WhoamiRequest{}, false + } + return w.requests[len(w.requests)-1], true +} + +// ClearRequests resets the recorded request history. +func (w *WhoamiServer) ClearRequests() { + w.mu.Lock() + defer w.mu.Unlock() + w.requests = nil +} + +// ParseWhoamiBody decodes a JSON whoami echo body from a proxied response. +func ParseWhoamiBody(body []byte) (WhoamiRequest, error) { + var req WhoamiRequest + err := json.Unmarshal(body, &req) + return req, err +} + +// HeaderValue returns the first value for a header name (case-insensitive). +func (r WhoamiRequest) HeaderValue(name string) string { + for k, vs := range r.Headers { + if strings.EqualFold(k, name) && len(vs) > 0 { + return vs[0] + } + } + return "" +} + +func cloneHeaderMap(h http.Header) map[string][]string { + out := make(map[string][]string, len(h)) + for k, vs := range h { + cp := make([]string, len(vs)) + copy(cp, vs) + out[k] = cp + } + return out +} + +// TCPEchoServer accepts connections and echoes a fixed greeting. +type TCPEchoServer struct { + Listener net.Listener + Host string + Port int + Greeting string +} + +// NewTCPEcho starts a TCP server that writes Greeting on each accepted connection. +func NewTCPEcho(t *testing.T, greeting string) *TCPEchoServer { + t.Helper() + if greeting == "" { + greeting = "STREAM-ECHO\n" + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("tcp echo listen: %v", err) + } + _, portStr, _ := net.SplitHostPort(ln.Addr().String()) + port, _ := strconv.Atoi(portStr) + s := &TCPEchoServer{Listener: ln, Host: "127.0.0.1", Port: port, Greeting: greeting} + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + _, _ = io.WriteString(c, s.Greeting) + }(conn) + } + }() + + t.Cleanup(func() { _ = ln.Close() }) + return s +} + +// pickFreePort returns a likely-free TCP port on 127.0.0.1. +func pickFreePort(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("pick port: %v", err) + } + _, portStr, _ := net.SplitHostPort(ln.Addr().String()) + _ = ln.Close() + port, _ := strconv.Atoi(portStr) + return port +} + +// UDPEchoServer responds to each datagram with a fixed payload. +type UDPEchoServer struct { + Conn *net.UDPConn + Host string + Port int + Response []byte +} + +// NewUDPEcho starts a UDP server that writes Response for every datagram received. +func NewUDPEcho(t *testing.T, response string) *UDPEchoServer { + t.Helper() + if response == "" { + response = "UDP-ECHO\n" + } + addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("udp resolve: %v", err) + } + conn, err := net.ListenUDP("udp", addr) + if err != nil { + t.Fatalf("udp listen: %v", err) + } + _, portStr, _ := net.SplitHostPort(conn.LocalAddr().String()) + port, _ := strconv.Atoi(portStr) + s := &UDPEchoServer{ + Conn: conn, + Host: "127.0.0.1", + Port: port, + Response: []byte(response), + } + + go func() { + buf := make([]byte, 4096) + for { + n, client, err := conn.ReadFromUDP(buf) + if err != nil { + return + } + _, _ = conn.WriteToUDP(s.Response, client) + _ = n + } + }() + + t.Cleanup(func() { _ = conn.Close() }) + return s +} + +// tryWaitForTLSStream returns nil once a TLS stream listener accepts connections. +func tryWaitForTLSStream(addr, serverName string, trust *tls.Certificate, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + var lastErr error + pool := x509.NewCertPool() + if len(trust.Certificate) > 0 { + if leaf, err := x509.ParseCertificate(trust.Certificate[0]); err == nil { + pool.AddCert(leaf) + } + } + cfg := &tls.Config{ServerName: serverName, RootCAs: pool, MinVersion: tls.VersionTLS12} + for time.Now().Before(deadline) { + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 200 * time.Millisecond}, "tcp", addr, cfg) + if err == nil { + _ = conn.Close() + return nil + } + lastErr = err + time.Sleep(20 * time.Millisecond) + } + return lastErr +} + +// waitForTLSStream dials addr with TLS until success or timeout. +func waitForTLSStream(t *testing.T, addr, serverName string, trust *tls.Certificate, timeout time.Duration) { + t.Helper() + if err := tryWaitForTLSStream(addr, serverName, trust, timeout); err != nil { + t.Fatalf("wait for tls stream %s: %v", addr, err) + } +} + +// tryWaitForTCP returns nil once a TCP listener accepts connections. +func tryWaitForTCP(addr string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + var lastErr error + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) + if err == nil { + _ = conn.Close() + return nil + } + lastErr = err + time.Sleep(20 * time.Millisecond) + } + return lastErr +} + +// waitForTCP dials addr until success or timeout (stream listeners start async). +func waitForTCP(t *testing.T, addr string, timeout time.Duration) { + t.Helper() + if err := tryWaitForTCP(addr, timeout); err != nil { + t.Fatalf("wait for tcp %s: %v", addr, err) + } +} + +// tlsConfigForCert builds a client TLS config that trusts the given server certificate. +func tlsConfigForCert(t *testing.T, serverName string, cert *tls.Certificate) *tls.Config { + t.Helper() + if len(cert.Certificate) == 0 { + t.Fatal("tls cert has no certificate chain") + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(cert.Certificate[0]) { + // X509KeyPair stores DER; re-parse for the pool + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + t.Fatalf("parse leaf cert: %v", err) + } + pool.AddCert(leaf) + } + return &tls.Config{ + ServerName: serverName, + RootCAs: pool, + MinVersion: tls.VersionTLS12, + } +} + +// DialTLSStream connects to a TLS-terminated stream listener. +func DialTLSStream(t *testing.T, addr, serverName string, trust *tls.Certificate) net.Conn { + t.Helper() + var cfg *tls.Config + if trust != nil { + cfg = tlsConfigForCert(t, serverName, trust) + } else { + cfg = &tls.Config{ + ServerName: serverName, + InsecureSkipVerify: true, //nolint:gosec // test-only self-signed dev certs + MinVersion: tls.VersionTLS12, + } + } + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 2 * time.Second}, "tcp", addr, cfg) + if err != nil { + t.Fatalf("tls dial %s: %v", addr, err) + } + t.Cleanup(func() { _ = conn.Close() }) + return conn +} + +// waitForUDP sends a probe datagram until a response arrives or timeout. +func waitForUDP(t *testing.T, addr string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + target, err := net.ResolveUDPAddr("udp", addr) + if err != nil { + t.Fatalf("resolve udp %s: %v", addr, err) + } + var lastErr error + for time.Now().Before(deadline) { + conn, err := net.DialUDP("udp", nil, target) + if err != nil { + lastErr = err + time.Sleep(20 * time.Millisecond) + continue + } + _ = conn.SetDeadline(time.Now().Add(200 * time.Millisecond)) + if _, err := conn.Write([]byte("probe")); err != nil { + lastErr = err + _ = conn.Close() + time.Sleep(20 * time.Millisecond) + continue + } + buf := make([]byte, 8) + _, err = conn.Read(buf) + _ = conn.Close() + if err == nil { + return + } + lastErr = err + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("wait for udp %s: %v", addr, lastErr) +} + +// WhoamiWSServer is a websocket upstream that records the upgrade request and echoes messages. +type WhoamiWSServer struct { + Server *httptest.Server + Hostname string + Port int + Path string + + mu sync.Mutex + upgradeReq WhoamiRequest + messages []string +} + +// NewWhoamiWS starts a websocket echo upstream on path (default /ws). +func NewWhoamiWS(t *testing.T, path string) *WhoamiWSServer { + t.Helper() + if path == "" { + path = "/ws" + } + w := &WhoamiWSServer{Path: path} + mux := http.NewServeMux() + mux.Handle(path, websocket.Handler(w.handleWS)) + w.Server = httptest.NewServer(mux) + t.Cleanup(w.Server.Close) + + u, err := url.Parse(w.Server.URL) + if err != nil { + t.Fatalf("whoami ws url: %v", err) + } + w.Hostname = u.Hostname() + w.Port, err = strconv.Atoi(u.Port()) + if err != nil { + t.Fatalf("whoami ws port: %v", err) + } + return w +} + +func (w *WhoamiWSServer) handleWS(ws *websocket.Conn) { + req := ws.Request() + body, _ := io.ReadAll(req.Body) + _ = req.Body.Close() + captured := WhoamiRequest{ + Method: req.Method, + Path: req.URL.Path, + Query: req.URL.RawQuery, + Host: req.Host, + Headers: cloneHeaderMap(req.Header), + Body: string(body), + RemoteAddr: req.RemoteAddr, + } + w.mu.Lock() + w.upgradeReq = captured + w.mu.Unlock() + + for { + var msg string + if err := websocket.Message.Receive(ws, &msg); err != nil { + return + } + w.mu.Lock() + w.messages = append(w.messages, msg) + w.mu.Unlock() + if err := websocket.Message.Send(ws, "echo:"+msg); err != nil { + return + } + } +} + +// UpgradeRequest returns the HTTP request observed at websocket upgrade time. +func (w *WhoamiWSServer) UpgradeRequest() (WhoamiRequest, bool) { + w.mu.Lock() + defer w.mu.Unlock() + if w.upgradeReq.Method == "" { + return WhoamiRequest{}, false + } + return w.upgradeReq, true +} + +// Messages returns client messages received after upgrade. +func (w *WhoamiWSServer) Messages() []string { + w.mu.Lock() + defer w.mu.Unlock() + out := make([]string, len(w.messages)) + copy(out, w.messages) + return out +} + +// cleanupStreams disables all streams and reloads the engine on test end. +func cleanupStreams(t *testing.T, eng *Engine, st store.Store) { + t.Helper() + t.Cleanup(func() { + for _, s := range st.GetStreams() { + if !s.Enabled { + continue + } + s.Enabled = false + _, _ = st.UpdateStream(s) + } + eng.ReloadFromStore() + }) +} + +// newProxyServer exposes eng.Handler() on an httptest server (needed for websocket hijack). +func newProxyServer(t *testing.T, eng *Engine) *httptest.Server { + t.Helper() + srv := httptest.NewServer(eng.Handler()) + t.Cleanup(srv.Close) + return srv +} + +// dialWhoamiWS connects through a proxy front to the websocket upstream path. +// The vhost is embedded in the handshake URL (Host header); TCP dials proxyURL directly. +func dialWhoamiWS(t *testing.T, proxyURL, vhost, wsPath string, hdrs http.Header) *websocket.Conn { + t.Helper() + proxyU, err := url.Parse(proxyURL) + if err != nil { + t.Fatalf("proxy url: %v", err) + } + wsURL := "ws://" + vhost + wsPath + cfg, err := websocket.NewConfig(wsURL, "http://"+vhost+"/") + if err != nil { + t.Fatalf("ws config: %v", err) + } + if hdrs != nil { + cfg.Header = hdrs.Clone() + } + conn, err := net.DialTimeout("tcp", proxyU.Host, 2*time.Second) + if err != nil { + t.Fatalf("tcp dial %s: %v", proxyU.Host, err) + } + ws, err := websocket.NewClient(cfg, conn) + if err != nil { + _ = conn.Close() + t.Fatalf("ws handshake %s via %s: %v", wsURL, proxyU.Host, err) + } + t.Cleanup(func() { _ = ws.Close() }) + return ws +} + +// ReadStreamGreeting reads up to n bytes from conn before deadline. +func ReadStreamGreeting(t *testing.T, conn net.Conn, n int, timeout time.Duration) string { + t.Helper() + _ = conn.SetReadDeadline(time.Now().Add(timeout)) + buf := make([]byte, n) + got, err := io.ReadAtLeast(conn, buf, 1) + if err != nil { + t.Fatalf("stream read: %v", err) + } + return string(buf[:got]) +} diff --git a/internal/store/bbolt.go b/internal/store/bbolt.go new file mode 100644 index 0000000..f3626be --- /dev/null +++ b/internal/store/bbolt.go @@ -0,0 +1,854 @@ +package store + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "helix-proxy/internal/config" + "helix-proxy/internal/models" + + "go.etcd.io/bbolt" +) + +const ( + bucketProxyHosts = "proxy_hosts" + bucketAccessLists = "access_lists" + bucketStreams = "streams" + bucketCertificates = "certificates" + bucketRedirectionHosts = "redirection_hosts" + bucketDeadHosts = "dead_hosts" + bucketUsers = "users" + bucketAuditLogs = "audit_logs" + bucketSettings = "settings" + bucketMeta = "meta" +) + +type bboltStore struct { + db *bbolt.DB + path string +} + +// NewBboltStore opens (or creates) the bbolt embedded database at the standard +// location (data/db.bolt by default, overridable via DATA_DIR etc). +func NewBboltStore() (Store, error) { + p := config.Resolve("db.bolt") + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return nil, err + } + + db, err := bbolt.Open(p, 0o600, &bbolt.Options{Timeout: 0}) + if err != nil { + return nil, fmt.Errorf("open bbolt db: %w", err) + } + + s := &bboltStore{db: db, path: p} + + // Ensure all buckets exist + if err := s.createBuckets(); err != nil { + db.Close() + return nil, err + } + + // First-run seed (admin user + default settings) if nothing exists + if !s.hasAnyUsers() { + if err := s.seedDefaults(); err != nil { + db.Close() + return nil, err + } + } + + return s, nil +} + +func (s *bboltStore) Close() error { + if s.db != nil { + return s.db.Close() + } + return nil +} + +func (s *bboltStore) createBuckets() error { + return s.db.Update(func(tx *bbolt.Tx) error { + buckets := []string{ + bucketProxyHosts, bucketAccessLists, bucketStreams, bucketCertificates, + bucketRedirectionHosts, bucketDeadHosts, bucketUsers, bucketAuditLogs, + bucketSettings, bucketMeta, + } + for _, name := range buckets { + if _, err := tx.CreateBucketIfNotExists([]byte(name)); err != nil { + return err + } + } + return nil + }) +} + +func (s *bboltStore) seedDefaults() error { + return s.db.Update(func(tx *bbolt.Tx) error { + // Seed admin user + default settings on first run (empty data dir) + admin := models.User{ + ID: 1, + Email: "admin@example.com", + Name: "Admin", + Roles: []string{"admin"}, + } + if err := s.putJSON(tx, bucketUsers, admin.ID, admin); err != nil { + return err + } + + // Default settings + b := tx.Bucket([]byte(bucketSettings)) + defaults := map[string]any{"default_site": "congratulations"} + jb, _ := json.Marshal(defaults) + b.Put([]byte("data"), jb) + + // Advance user sequence + bu := tx.Bucket([]byte(bucketUsers)) + bu.NextSequence() // advance past 1 + + return nil + }) +} + +func (s *bboltStore) hasAnyUsers() bool { + var count int + s.db.View(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte(bucketUsers)) + if b != nil { + count = b.Stats().KeyN + } + return nil + }) + return count > 0 +} + +// --- helpers --- + +func itob(i int) []byte { + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, uint64(i)) + return b +} + +func btoi(b []byte) int { + if len(b) < 8 { + return 0 + } + return int(binary.BigEndian.Uint64(b)) +} + +func (s *bboltStore) putJSON(tx *bbolt.Tx, bucket string, id int, v any) error { + b := tx.Bucket([]byte(bucket)) + jb, err := json.Marshal(v) + if err != nil { + return err + } + return b.Put(itob(id), jb) +} + +func (s *bboltStore) getJSON(tx *bbolt.Tx, bucket string, id int, out any) bool { + b := tx.Bucket([]byte(bucket)) + if b == nil { + return false + } + data := b.Get(itob(id)) + if data == nil { + return false + } + return json.Unmarshal(data, out) == nil +} + +// --- Store interface implementation --- + +func (s *bboltStore) IsSetup() bool { + var has bool + s.db.View(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte(bucketUsers)) + if b != nil { + has = b.Stats().KeyN > 0 + } + return nil + }) + return has +} + +func (s *bboltStore) GetUsers() []models.User { + var out []models.User + s.db.View(func(tx *bbolt.Tx) error { + out = s.loadUsers(tx) + return nil + }) + return out +} + +// --- ProxyHost --- + +func (s *bboltStore) GetProxyHosts() []models.ProxyHost { + var out []models.ProxyHost + s.db.View(func(tx *bbolt.Tx) error { + out = s.loadProxyHosts(tx) + return nil + }) + return out +} + +func (s *bboltStore) GetProxyHost(id int) (models.ProxyHost, bool) { + var h models.ProxyHost + s.db.View(func(tx *bbolt.Tx) error { + s.getJSON(tx, bucketProxyHosts, id, &h) + return nil + }) + return h, h.ID != 0 +} + +func (s *bboltStore) CreateProxyHost(h models.ProxyHost) (models.ProxyHost, error) { + err := s.db.Update(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte(bucketProxyHosts)) + if h.ID == 0 { + seq, _ := b.NextSequence() + h.ID = int(seq) + } + if h.DomainNames == nil { + h.DomainNames = []string{} + } + if h.Meta == nil { + h.Meta = map[string]any{} + } + if h.CreatedOn == "" { + h.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05") // centralized server-set (no longer duplicated in main.go POST handlers) + } + if err := validateProxyHostSources(s.loadProxyHosts(tx), h); err != nil { + return err + } + return s.putJSON(tx, bucketProxyHosts, h.ID, h) + }) + return h, err +} + +func (s *bboltStore) UpdateProxyHost(h models.ProxyHost) (models.ProxyHost, error) { + err := s.db.Update(func(tx *bbolt.Tx) error { + if !s.getJSON(tx, bucketProxyHosts, h.ID, &models.ProxyHost{}) { + return errors.New("not found") + } + if err := validateProxyHostSources(s.loadProxyHosts(tx), h); err != nil { + return err + } + return s.putJSON(tx, bucketProxyHosts, h.ID, h) + }) + return h, err +} + +func (s *bboltStore) DeleteProxyHost(id int) error { + return s.db.Update(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte(bucketProxyHosts)) + return b.Delete(itob(id)) + }) +} + +func (s *bboltStore) SetProxyHostEnabled(id int, enabled bool) error { + return s.db.Update(func(tx *bbolt.Tx) error { + var h models.ProxyHost + if !s.getJSON(tx, bucketProxyHosts, id, &h) { + return errors.New("not found") + } + h.Enabled = enabled + return s.putJSON(tx, bucketProxyHosts, id, h) + }) +} + +// --- AccessList --- + +func (s *bboltStore) GetAccessLists() []models.AccessList { + var out []models.AccessList + s.db.View(func(tx *bbolt.Tx) error { + out = s.loadAccessLists(tx) + return nil + }) + return out +} + +func (s *bboltStore) GetAccessList(id int) (models.AccessList, bool) { + var al models.AccessList + s.db.View(func(tx *bbolt.Tx) error { + s.getJSON(tx, bucketAccessLists, id, &al) + return nil + }) + return al, al.ID != 0 +} + +func (s *bboltStore) CreateAccessList(al models.AccessList) (models.AccessList, error) { + err := s.db.Update(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte(bucketAccessLists)) + if al.ID == 0 { + seq, _ := b.NextSequence() + al.ID = int(seq) + } + if al.Clients == nil { + al.Clients = []models.AccessClient{} + } + if al.Items == nil { + al.Items = []models.AccessItem{} + } + if al.CreatedOn == "" { + al.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05") // centralized server-set (no longer duplicated in main.go POST handlers) + } + return s.putJSON(tx, bucketAccessLists, al.ID, al) + }) + return al, err +} + +func (s *bboltStore) UpdateAccessList(al models.AccessList) (models.AccessList, error) { + err := s.db.Update(func(tx *bbolt.Tx) error { + return s.putJSON(tx, bucketAccessLists, al.ID, al) + }) + return al, err +} + +func (s *bboltStore) DeleteAccessList(id int) error { + return s.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket([]byte(bucketAccessLists)).Delete(itob(id)) + }) +} + +// --- Stream --- + +func (s *bboltStore) GetStreams() []models.Stream { + var out []models.Stream + s.db.View(func(tx *bbolt.Tx) error { + out = s.loadStreams(tx) + return nil + }) + return out +} + +func (s *bboltStore) GetStream(id int) (models.Stream, bool) { + var st models.Stream + s.db.View(func(tx *bbolt.Tx) error { + s.getJSON(tx, bucketStreams, id, &st) + return nil + }) + return st, st.ID != 0 +} + +func (s *bboltStore) CreateStream(st models.Stream) (models.Stream, error) { + err := s.db.Update(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte(bucketStreams)) + if st.ID == 0 { + seq, _ := b.NextSequence() + st.ID = int(seq) + } + if st.Meta == nil { + st.Meta = map[string]any{} + } + if st.CreatedOn == "" { + st.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05") // centralized server-set (no longer duplicated in main.go POST handlers) + } + if err := validateStreamIncomingPort(st, s.loadStreams(tx)); err != nil { + return err + } + return s.putJSON(tx, bucketStreams, st.ID, st) + }) + return st, err +} + +func (s *bboltStore) UpdateStream(st models.Stream) (models.Stream, error) { + err := s.db.Update(func(tx *bbolt.Tx) error { + if !s.getJSON(tx, bucketStreams, st.ID, &models.Stream{}) { + return errors.New("not found") + } + if err := validateStreamIncomingPort(st, s.loadStreams(tx)); err != nil { + return err + } + return s.putJSON(tx, bucketStreams, st.ID, st) + }) + return st, err +} + +func (s *bboltStore) DeleteStream(id int) error { + return s.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket([]byte(bucketStreams)).Delete(itob(id)) + }) +} + +func (s *bboltStore) SetStreamEnabled(id int, enabled bool) error { + return s.db.Update(func(tx *bbolt.Tx) error { + var st models.Stream + if !s.getJSON(tx, bucketStreams, id, &st) { + return errors.New("not found") + } + st.Enabled = enabled + if enabled { + if err := validateStreamIncomingPort(st, s.loadStreams(tx)); err != nil { + return err + } + } + return s.putJSON(tx, bucketStreams, id, st) + }) +} + +// --- Certificate --- + +func (s *bboltStore) GetCertificates() []models.Certificate { + var out []models.Certificate + s.db.View(func(tx *bbolt.Tx) error { + out = s.loadCertificates(tx) + return nil + }) + return out +} + +func (s *bboltStore) GetCertificate(id int) (models.Certificate, bool) { + var c models.Certificate + s.db.View(func(tx *bbolt.Tx) error { + s.getJSON(tx, bucketCertificates, id, &c) + return nil + }) + return c, c.ID != 0 +} + +func (s *bboltStore) CreateCertificate(c models.Certificate) (models.Certificate, error) { + err := s.db.Update(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte(bucketCertificates)) + if c.ID == 0 { + seq, _ := b.NextSequence() + c.ID = int(seq) + } + if c.DomainNames == nil { + c.DomainNames = []string{} + } + if c.Meta == nil { + c.Meta = map[string]any{} + } + if c.CreatedOn == "" { + c.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05") // centralized server-set (no longer duplicated in main.go POST handlers) + } + return s.putJSON(tx, bucketCertificates, c.ID, c) + }) + return c, err +} + +func (s *bboltStore) SetCertificate(c models.Certificate) error { + return s.db.Update(func(tx *bbolt.Tx) error { + var existing models.Certificate + if s.getJSON(tx, bucketCertificates, c.ID, &existing) && c.CreatedOn == "" { + c.CreatedOn = existing.CreatedOn + } + return s.putJSON(tx, bucketCertificates, c.ID, c) + }) +} + +func (s *bboltStore) DeleteCertificate(id int) error { + return s.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket([]byte(bucketCertificates)).Delete(itob(id)) + }) +} + +// --- RedirectionHost --- + +func (s *bboltStore) GetRedirectionHosts() []models.RedirectionHost { + var out []models.RedirectionHost + s.db.View(func(tx *bbolt.Tx) error { + out = s.loadRedirectionHosts(tx) + return nil + }) + return out +} + +func (s *bboltStore) GetRedirectionHost(id int) (models.RedirectionHost, bool) { + var rh models.RedirectionHost + s.db.View(func(tx *bbolt.Tx) error { + s.getJSON(tx, bucketRedirectionHosts, id, &rh) + return nil + }) + return rh, rh.ID != 0 +} + +func (s *bboltStore) CreateRedirectionHost(h models.RedirectionHost) (models.RedirectionHost, error) { + err := s.db.Update(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte(bucketRedirectionHosts)) + if h.ID == 0 { + seq, _ := b.NextSequence() + h.ID = int(seq) + } + if h.DomainNames == nil { + h.DomainNames = []string{} + } + if h.Meta == nil { + h.Meta = map[string]any{} + } + if h.CreatedOn == "" { + h.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05") // centralized server-set (no longer duplicated in main.go POST handlers) + } + if err := validateRedirectionHostSources(s.loadRedirectionHosts(tx), h); err != nil { + return err + } + return s.putJSON(tx, bucketRedirectionHosts, h.ID, h) + }) + return h, err +} + +func (s *bboltStore) UpdateRedirectionHost(h models.RedirectionHost) (models.RedirectionHost, error) { + err := s.db.Update(func(tx *bbolt.Tx) error { + if !s.getJSON(tx, bucketRedirectionHosts, h.ID, &models.RedirectionHost{}) { + return errors.New("not found") + } + if err := validateRedirectionHostSources(s.loadRedirectionHosts(tx), h); err != nil { + return err + } + return s.putJSON(tx, bucketRedirectionHosts, h.ID, h) + }) + return h, err +} + +func (s *bboltStore) DeleteRedirectionHost(id int) error { + return s.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket([]byte(bucketRedirectionHosts)).Delete(itob(id)) + }) +} + +func (s *bboltStore) SetRedirectionHostEnabled(id int, enabled bool) error { + return s.db.Update(func(tx *bbolt.Tx) error { + var rh models.RedirectionHost + if !s.getJSON(tx, bucketRedirectionHosts, id, &rh) { + return errors.New("not found") + } + rh.Enabled = enabled + return s.putJSON(tx, bucketRedirectionHosts, id, rh) + }) +} + +// --- DeadHost --- + +func (s *bboltStore) GetDeadHosts() []models.DeadHost { + var out []models.DeadHost + s.db.View(func(tx *bbolt.Tx) error { + out = s.loadDeadHosts(tx) + return nil + }) + return out +} + +func (s *bboltStore) GetDeadHost(id int) (models.DeadHost, bool) { + var dh models.DeadHost + s.db.View(func(tx *bbolt.Tx) error { + s.getJSON(tx, bucketDeadHosts, id, &dh) + return nil + }) + return dh, dh.ID != 0 +} + +func (s *bboltStore) CreateDeadHost(h models.DeadHost) (models.DeadHost, error) { + err := s.db.Update(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte(bucketDeadHosts)) + if h.ID == 0 { + seq, _ := b.NextSequence() + h.ID = int(seq) + } + if h.DomainNames == nil { + h.DomainNames = []string{} + } + if h.Meta == nil { + h.Meta = map[string]any{} + } + if h.CreatedOn == "" { + h.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05") // centralized server-set (no longer duplicated in main.go POST handlers) + } + if err := validateDeadHostSources(s.loadDeadHosts(tx), h); err != nil { + return err + } + return s.putJSON(tx, bucketDeadHosts, h.ID, h) + }) + return h, err +} + +func (s *bboltStore) UpdateDeadHost(h models.DeadHost) (models.DeadHost, error) { + err := s.db.Update(func(tx *bbolt.Tx) error { + if !s.getJSON(tx, bucketDeadHosts, h.ID, &models.DeadHost{}) { + return errors.New("not found") + } + if err := validateDeadHostSources(s.loadDeadHosts(tx), h); err != nil { + return err + } + return s.putJSON(tx, bucketDeadHosts, h.ID, h) + }) + return h, err +} + +func (s *bboltStore) DeleteDeadHost(id int) error { + return s.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket([]byte(bucketDeadHosts)).Delete(itob(id)) + }) +} + +func (s *bboltStore) SetDeadHostEnabled(id int, enabled bool) error { + return s.db.Update(func(tx *bbolt.Tx) error { + var dh models.DeadHost + if !s.getJSON(tx, bucketDeadHosts, id, &dh) { + return errors.New("not found") + } + dh.Enabled = enabled + return s.putJSON(tx, bucketDeadHosts, id, dh) + }) +} + +// --- Settings --- + +func (s *bboltStore) GetSettings() map[string]any { + var m map[string]any + s.db.View(func(tx *bbolt.Tx) error { + m = s.loadSettings(tx) + return nil + }) + return m +} + +func (s *bboltStore) SetSetting(name string, value any) error { + return s.db.Update(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte(bucketSettings)) + var current map[string]any + if data := b.Get([]byte("data")); data != nil { + json.Unmarshal(data, ¤t) + } + if current == nil { + current = map[string]any{} + } + current[name] = value + jb, _ := json.Marshal(current) + return b.Put([]byte("data"), jb) + }) +} + +// --- AuditLog --- + +func (s *bboltStore) GetAuditLogs() []models.AuditLog { + var out []models.AuditLog + s.db.View(func(tx *bbolt.Tx) error { + out = s.loadAuditLogs(tx) + return nil + }) + return out +} + +func (s *bboltStore) AddAuditLog(log models.AuditLog) (models.AuditLog, error) { + err := s.db.Update(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte(bucketAuditLogs)) + if log.ID == 0 { + seq, _ := b.NextSequence() + log.ID = int(seq) + } + if log.Meta == nil { + log.Meta = map[string]any{} + } + if log.CreatedOn == "" { + // set now if caller did not provide (callers in main rarely do) + log.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05") + } + return s.putJSON(tx, bucketAuditLogs, log.ID, log) + }) + return log, err +} + +// --- Users --- + +func (s *bboltStore) GetUserByEmail(email string) (models.User, bool) { + var found models.User + s.db.View(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte(bucketUsers)) + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + var u models.User + if json.Unmarshal(v, &u) == nil && u.Email == email { + found = u + return nil + } + } + return nil + }) + return found, found.ID != 0 +} + +func (s *bboltStore) CreateUser(u models.User) (models.User, error) { + err := s.db.Update(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte(bucketUsers)) + if u.ID == 0 { + seq, _ := b.NextSequence() + u.ID = int(seq) + } + if u.Roles == nil { + u.Roles = []string{} + } + return s.putJSON(tx, bucketUsers, u.ID, u) + }) + return u, err +} + +func (s *bboltStore) UpdateUser(u models.User) error { + return s.db.Update(func(tx *bbolt.Tx) error { + return s.putJSON(tx, bucketUsers, u.ID, u) + }) +} + +func (s *bboltStore) UpdateUserFull(u models.User) error { + return s.UpdateUser(u) +} + +func (s *bboltStore) DeleteUser(id int) error { + return s.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket([]byte(bucketUsers)).Delete(itob(id)) + }) +} + +// --- load helpers (used by Root and lists) --- + +func (s *bboltStore) loadUsers(tx *bbolt.Tx) []models.User { + var out []models.User + b := tx.Bucket([]byte(bucketUsers)) + if b == nil { + return out + } + b.ForEach(func(_, v []byte) error { + var u models.User + if json.Unmarshal(v, &u) == nil { + out = append(out, u) + } + return nil + }) + return out +} + +func (s *bboltStore) loadProxyHosts(tx *bbolt.Tx) []models.ProxyHost { + var out []models.ProxyHost + b := tx.Bucket([]byte(bucketProxyHosts)) + if b == nil { + return out + } + b.ForEach(func(_, v []byte) error { + var h models.ProxyHost + if json.Unmarshal(v, &h) == nil { + out = append(out, h) + } + return nil + }) + return out +} + +func (s *bboltStore) loadAccessLists(tx *bbolt.Tx) []models.AccessList { + var out []models.AccessList + b := tx.Bucket([]byte(bucketAccessLists)) + if b == nil { + return out + } + b.ForEach(func(_, v []byte) error { + var al models.AccessList + if json.Unmarshal(v, &al) == nil { + out = append(out, al) + } + return nil + }) + return out +} + +func (s *bboltStore) loadStreams(tx *bbolt.Tx) []models.Stream { + var out []models.Stream + b := tx.Bucket([]byte(bucketStreams)) + if b == nil { + return out + } + b.ForEach(func(_, v []byte) error { + var st models.Stream + if json.Unmarshal(v, &st) == nil { + out = append(out, st) + } + return nil + }) + return out +} + +func (s *bboltStore) loadCertificates(tx *bbolt.Tx) []models.Certificate { + var out []models.Certificate + b := tx.Bucket([]byte(bucketCertificates)) + if b == nil { + return out + } + b.ForEach(func(_, v []byte) error { + var c models.Certificate + if json.Unmarshal(v, &c) == nil { + out = append(out, c) + } + return nil + }) + return out +} + +func (s *bboltStore) loadRedirectionHosts(tx *bbolt.Tx) []models.RedirectionHost { + var out []models.RedirectionHost + b := tx.Bucket([]byte(bucketRedirectionHosts)) + if b == nil { + return out + } + b.ForEach(func(_, v []byte) error { + var rh models.RedirectionHost + if json.Unmarshal(v, &rh) == nil { + out = append(out, rh) + } + return nil + }) + return out +} + +func (s *bboltStore) loadDeadHosts(tx *bbolt.Tx) []models.DeadHost { + var out []models.DeadHost + b := tx.Bucket([]byte(bucketDeadHosts)) + if b == nil { + return out + } + b.ForEach(func(_, v []byte) error { + var dh models.DeadHost + if json.Unmarshal(v, &dh) == nil { + out = append(out, dh) + } + return nil + }) + return out +} + +func (s *bboltStore) loadAuditLogs(tx *bbolt.Tx) []models.AuditLog { + var out []models.AuditLog + b := tx.Bucket([]byte(bucketAuditLogs)) + if b == nil { + return out + } + b.ForEach(func(_, v []byte) error { + var a models.AuditLog + if json.Unmarshal(v, &a) == nil { + out = append(out, a) + } + return nil + }) + return out +} + +func (s *bboltStore) loadSettings(tx *bbolt.Tx) map[string]any { + b := tx.Bucket([]byte(bucketSettings)) + if b == nil { + return map[string]any{} + } + data := b.Get([]byte("data")) + if data == nil { + return map[string]any{} + } + var m map[string]any + json.Unmarshal(data, &m) + if m == nil { + return map[string]any{} + } + return m +} + +// Compile-time check +var _ Store = (*bboltStore)(nil) diff --git a/internal/store/source_validate.go b/internal/store/source_validate.go new file mode 100644 index 0000000..8d9b3db --- /dev/null +++ b/internal/store/source_validate.go @@ -0,0 +1,110 @@ +package store + +import ( + "errors" + "fmt" + "strings" + + "helix-proxy/internal/models" +) + +var ( + ErrDuplicateProxySource = errors.New("duplicate proxy host source domain") + ErrDuplicateRedirectionSource = errors.New("duplicate redirection host source domain") + ErrDuplicateDeadSource = errors.New("duplicate dead host source domain") + ErrDuplicateStreamPort = errors.New("duplicate stream incoming port") +) + +func IsConflictError(err error) bool { + return errors.Is(err, ErrDuplicateProxySource) || + errors.Is(err, ErrDuplicateRedirectionSource) || + errors.Is(err, ErrDuplicateDeadSource) || + errors.Is(err, ErrDuplicateStreamPort) +} + +type domainRecord struct { + ID int + DomainNames []string +} + +func normalizeSourceDomain(d string) (normalized, display string, ok bool) { + display = strings.TrimSpace(d) + if display == "" { + return "", "", false + } + return strings.ToLower(display), display, true +} + +func validateUniqueDomains(domainNames []string, selfID int, existing []domainRecord, errSentinel error) error { + own := make(map[string]string) + for _, d := range domainNames { + norm, display, ok := normalizeSourceDomain(d) + if !ok { + continue + } + if prev, dup := own[norm]; dup { + return fmt.Errorf("%w: duplicate source domain: %s", errSentinel, prev) + } + own[norm] = display + } + + used := make(map[string]struct{}) + for _, rec := range existing { + if rec.ID == selfID { + continue + } + for _, d := range rec.DomainNames { + norm, _, ok := normalizeSourceDomain(d) + if !ok { + continue + } + used[norm] = struct{}{} + } + } + + for norm, display := range own { + if _, conflict := used[norm]; conflict { + return fmt.Errorf("%w: source domain already in use: %s", errSentinel, display) + } + } + return nil +} + +func validateProxyHostSources(hosts []models.ProxyHost, h models.ProxyHost) error { + existing := make([]domainRecord, len(hosts)) + for i, host := range hosts { + existing[i] = domainRecord{ID: host.ID, DomainNames: host.DomainNames} + } + return validateUniqueDomains(h.DomainNames, h.ID, existing, ErrDuplicateProxySource) +} + +func validateRedirectionHostSources(hosts []models.RedirectionHost, h models.RedirectionHost) error { + existing := make([]domainRecord, len(hosts)) + for i, host := range hosts { + existing[i] = domainRecord{ID: host.ID, DomainNames: host.DomainNames} + } + return validateUniqueDomains(h.DomainNames, h.ID, existing, ErrDuplicateRedirectionSource) +} + +func validateDeadHostSources(hosts []models.DeadHost, h models.DeadHost) error { + existing := make([]domainRecord, len(hosts)) + for i, host := range hosts { + existing[i] = domainRecord{ID: host.ID, DomainNames: host.DomainNames} + } + return validateUniqueDomains(h.DomainNames, h.ID, existing, ErrDuplicateDeadSource) +} + +func validateStreamIncomingPort(st models.Stream, streams []models.Stream) error { + if st.IncomingPort <= 0 { + return nil + } + for _, other := range streams { + if other.ID == st.ID { + continue + } + if other.IncomingPort == st.IncomingPort { + return fmt.Errorf("%w: incoming port already in use: %d", ErrDuplicateStreamPort, st.IncomingPort) + } + } + return nil +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..342c42e --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,111 @@ +package store + +import "helix-proxy/internal/models" + +// Convenience type aliases so that code importing "helix-proxy/internal/store" +// can refer to the domain models as e.g. store.ProxyHost (historical API surface). +type ( + Root = models.Root + User = models.User + ProxyHost = models.ProxyHost + HeaderRule = models.HeaderRule + Location = models.Location + AccessList = models.AccessList + AccessClient = models.AccessClient + AccessItem = models.AccessItem + Stream = models.Stream + Certificate = models.Certificate + RedirectionHost = models.RedirectionHost + DeadHost = models.DeadHost + AuditLog = models.AuditLog +) + +// New returns the default Store implementation (bbolt, a pure-Go +// embedded transactional database). This is the recommended constructor for +// normal operation. +// +// It provides real ACID transactions and good concurrency while remaining a +// single static binary with no external database process. +func New() (Store, error) { + return NewBboltStore() +} + +// Store is the interface for all persistent state in Helix Proxy. +// +// We use bbolt (pure-Go embedded transactional key/value store) as the one and only +// implementation. The interface exists primarily to: +// - Make the rest of the code (engine, cert manager, API handlers) easy to test with fakes. +// - Keep a clean boundary (in case we ever need to evolve the storage layer). +// +// All methods are safe for concurrent use. +// +// Models live in internal/models. + +type Store interface { + // --- meta / bootstrap --- + IsSetup() bool + GetUsers() []models.User + + // --- ProxyHost --- + GetProxyHosts() []models.ProxyHost + GetProxyHost(id int) (models.ProxyHost, bool) + CreateProxyHost(h models.ProxyHost) (models.ProxyHost, error) + UpdateProxyHost(h models.ProxyHost) (models.ProxyHost, error) + DeleteProxyHost(id int) error + SetProxyHostEnabled(id int, enabled bool) error + + // --- AccessList --- + GetAccessLists() []models.AccessList + GetAccessList(id int) (models.AccessList, bool) + CreateAccessList(al models.AccessList) (models.AccessList, error) + UpdateAccessList(al models.AccessList) (models.AccessList, error) + DeleteAccessList(id int) error + + // --- Stream --- + GetStreams() []models.Stream + GetStream(id int) (models.Stream, bool) + CreateStream(st models.Stream) (models.Stream, error) + UpdateStream(st models.Stream) (models.Stream, error) + DeleteStream(id int) error + SetStreamEnabled(id int, enabled bool) error + + // --- Certificate --- + GetCertificates() []models.Certificate + GetCertificate(id int) (models.Certificate, bool) + CreateCertificate(c models.Certificate) (models.Certificate, error) + SetCertificate(c models.Certificate) error // used after LE issuance/renewal mutates meta+expires + DeleteCertificate(id int) error + + // --- RedirectionHost --- + GetRedirectionHosts() []models.RedirectionHost + GetRedirectionHost(id int) (models.RedirectionHost, bool) + CreateRedirectionHost(h models.RedirectionHost) (models.RedirectionHost, error) + UpdateRedirectionHost(h models.RedirectionHost) (models.RedirectionHost, error) + DeleteRedirectionHost(id int) error + SetRedirectionHostEnabled(id int, enabled bool) error + + // --- DeadHost --- + GetDeadHosts() []models.DeadHost + GetDeadHost(id int) (models.DeadHost, bool) + CreateDeadHost(h models.DeadHost) (models.DeadHost, error) + UpdateDeadHost(h models.DeadHost) (models.DeadHost, error) + DeleteDeadHost(id int) error + SetDeadHostEnabled(id int, enabled bool) error + + // --- Settings (global) --- + GetSettings() map[string]any + SetSetting(name string, value any) error + + // --- Audit --- + GetAuditLogs() []models.AuditLog + AddAuditLog(log models.AuditLog) (models.AuditLog, error) + + // --- Users + 2FA --- + GetUserByEmail(email string) (models.User, bool) + CreateUser(u models.User) (models.User, error) + UpdateUser(u models.User) error + UpdateUserFull(u models.User) error // used when updating without changing password + DeleteUser(id int) error + + // GetUsers avoids needing to fetch the entire root for user lists. +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..9bf4f52 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,513 @@ +package store + +import ( + "errors" + "reflect" + "strconv" + "sync" + "testing" + + "helix-proxy/internal/config" +) + +// --- test helpers --- + +func newTestBbolt(t *testing.T) Store { + t.Helper() + tmp := t.TempDir() + config.ResetForTest() + t.Setenv("DATA_DIR", tmp) + s, err := NewBboltStore() + if err != nil { + t.Fatalf("NewBboltStore: %v", err) + } + return s +} + +// closeIfBbolt helps cleanup for bbolt (interface doesn't have Close but impl does) +func closeIfBbolt(s Store) { + if c, ok := s.(interface{ Close() error }); ok { + c.Close() + } +} + +func TestNew_SeedsAdminAndSettings(t *testing.T) { + s := newTestBbolt(t) + defer closeIfBbolt(s) + + if !s.IsSetup() { + t.Error("IsSetup false after new") + } + users := s.GetUsers() + if len(users) != 1 || users[0].Email != "admin@example.com" || users[0].ID != 1 { + t.Errorf("seeded admin wrong: %+v", users) + } + settings := s.GetSettings() + if settings["default_site"] != "congratulations" { + t.Errorf("default settings wrong: %v", settings) + } +} + +func TestIDGeneration(t *testing.T) { + s := newTestBbolt(t) + defer closeIfBbolt(s) + + h1, _ := s.CreateProxyHost(ProxyHost{DomainNames: []string{"a.com"}}) + h2, _ := s.CreateProxyHost(ProxyHost{DomainNames: []string{"b.com"}}) + if h1.ID == 0 || h2.ID == 0 || h2.ID <= h1.ID { + t.Errorf("ids not increasing: %d %d", h1.ID, h2.ID) + } + if h1.CreatedOn == "" { + t.Error("CreateProxyHost did not populate CreatedOn") + } + al1, _ := s.CreateAccessList(AccessList{Name: "t1"}) + if al1.CreatedOn == "" { + t.Error("CreateAccessList did not populate CreatedOn") + } + st1, _ := s.CreateStream(Stream{IncomingPort: 1}) + if st1.CreatedOn == "" { + t.Error("CreateStream did not populate CreatedOn") + } + // explicit id should be honored? (current impls assign only if 0) + h3, _ := s.CreateProxyHost(ProxyHost{ID: 99, DomainNames: []string{"c.com"}}) + if h3.ID != 99 { + t.Errorf("explicit id not kept: %d", h3.ID) + } +} + +func TestProxyHostCRUD(t *testing.T) { + s := newTestBbolt(t) + defer closeIfBbolt(s) + + // create + h := ProxyHost{ + DomainNames: []string{"example.com", "www.example.com"}, + ForwardScheme: "https", + ForwardHost: "10.0.0.1", + ForwardPort: 8443, + Enabled: true, + Meta: map[string]any{"foo": "bar"}, + } + created, err := s.CreateProxyHost(h) + if err != nil { + t.Fatal(err) + } + if created.ID == 0 { + t.Fatal("no id assigned") + } + if !reflect.DeepEqual(created.DomainNames, h.DomainNames) || created.Meta["foo"] != "bar" { + t.Error("roundtrip fidelity lost on create") + } + + // get list / get + list := s.GetProxyHosts() + if len(list) != 1 { + t.Errorf("list len %d", len(list)) + } + got, ok := s.GetProxyHost(created.ID) + if !ok || got.ID != created.ID { + t.Error("get by id failed") + } + + // update + created.ForwardHost = "10.0.0.2" + created.Meta["new"] = 42 + up, err := s.UpdateProxyHost(created) + if err != nil { + t.Fatal(err) + } + if up.ForwardHost != "10.0.0.2" || up.Meta["new"] != 42 { + t.Error("update not persisted") + } + if up.CreatedOn == "" { + t.Error("UpdateProxyHost did not preserve CreatedOn") + } + + // set enabled + if err := s.SetProxyHostEnabled(created.ID, false); err != nil { + t.Fatal(err) + } + g2, _ := s.GetProxyHost(created.ID) + if g2.Enabled { + t.Error("set enabled false failed") + } + + // delete + if err := s.DeleteProxyHost(created.ID); err != nil { + t.Fatal(err) + } + if _, ok := s.GetProxyHost(created.ID); ok { + t.Error("delete did not remove") + } +} + +func TestProxyHostDuplicateSources(t *testing.T) { + s := newTestBbolt(t) + defer closeIfBbolt(s) + + h1, err := s.CreateProxyHost(ProxyHost{DomainNames: []string{"a.example.com"}}) + if err != nil { + t.Fatal(err) + } + + _, err = s.CreateProxyHost(ProxyHost{DomainNames: []string{"a.example.com"}}) + if !errors.Is(err, ErrDuplicateProxySource) { + t.Fatalf("expected duplicate error on cross-host conflict, got %v", err) + } + + _, err = s.CreateProxyHost(ProxyHost{DomainNames: []string{"A.EXAMPLE.COM"}}) + if !errors.Is(err, ErrDuplicateProxySource) { + t.Fatalf("expected duplicate error on case-insensitive conflict, got %v", err) + } + + _, err = s.CreateProxyHost(ProxyHost{DomainNames: []string{"b.example.com", "b.example.com"}}) + if !errors.Is(err, ErrDuplicateProxySource) { + t.Fatalf("expected duplicate error within same host, got %v", err) + } + + h1.DomainNames = []string{"a.example.com", "c.example.com"} + if _, err = s.UpdateProxyHost(h1); err != nil { + t.Fatalf("update own domains: %v", err) + } + + h2, err := s.CreateProxyHost(ProxyHost{DomainNames: []string{"d.example.com"}}) + if err != nil { + t.Fatal(err) + } + h2.DomainNames = []string{"c.example.com"} + _, err = s.UpdateProxyHost(h2) + if !errors.Is(err, ErrDuplicateProxySource) { + t.Fatalf("expected duplicate error on update conflict, got %v", err) + } +} + +func TestRedirectionHostDuplicateSources(t *testing.T) { + s := newTestBbolt(t) + defer closeIfBbolt(s) + + _, err := s.CreateRedirectionHost(RedirectionHost{DomainNames: []string{"redir.example.com"}}) + if err != nil { + t.Fatal(err) + } + _, err = s.CreateRedirectionHost(RedirectionHost{DomainNames: []string{"redir.example.com"}}) + if !errors.Is(err, ErrDuplicateRedirectionSource) { + t.Fatalf("expected duplicate error, got %v", err) + } +} + +func TestDeadHostDuplicateSources(t *testing.T) { + s := newTestBbolt(t) + defer closeIfBbolt(s) + + _, err := s.CreateDeadHost(DeadHost{DomainNames: []string{"dead.example.com"}}) + if err != nil { + t.Fatal(err) + } + _, err = s.CreateDeadHost(DeadHost{DomainNames: []string{"DEAD.example.com"}}) + if !errors.Is(err, ErrDuplicateDeadSource) { + t.Fatalf("expected duplicate error, got %v", err) + } +} + +func TestStreamDuplicateIncomingPort(t *testing.T) { + s := newTestBbolt(t) + defer closeIfBbolt(s) + + _, err := s.CreateStream(Stream{IncomingPort: 9001, ForwardingHost: "127.0.0.1", ForwardingPort: 80}) + if err != nil { + t.Fatal(err) + } + _, err = s.CreateStream(Stream{IncomingPort: 9001, ForwardingHost: "127.0.0.1", ForwardingPort: 8080}) + if !errors.Is(err, ErrDuplicateStreamPort) { + t.Fatalf("expected duplicate port error, got %v", err) + } +} + +func TestStreamUpdateDuplicateIncomingPort(t *testing.T) { + s := newTestBbolt(t) + defer closeIfBbolt(s) + + a, err := s.CreateStream(Stream{IncomingPort: 9001, ForwardingHost: "127.0.0.1", ForwardingPort: 80}) + if err != nil { + t.Fatal(err) + } + b, err := s.CreateStream(Stream{IncomingPort: 9002, ForwardingHost: "127.0.0.1", ForwardingPort: 80}) + if err != nil { + t.Fatal(err) + } + b.IncomingPort = 9001 + _, err = s.UpdateStream(b) + if !errors.Is(err, ErrDuplicateStreamPort) { + t.Fatalf("expected duplicate port error on update, got %v", err) + } + _ = a +} + +func TestAccessListCRUD(t *testing.T) { + s := newTestBbolt(t) + defer closeIfBbolt(s) + + al := AccessList{ + Name: "test al", + SatisfyAny: true, + Clients: []AccessClient{{Address: "1.2.3.4", Directive: "allow"}}, + Items: []AccessItem{{Username: "u", Password: "p"}}, + PassAuth: true, + } + created, err := s.CreateAccessList(al) + if err != nil || created.ID == 0 { + t.Fatalf("create: %v", err) + } + if len(created.Clients) != 1 || len(created.Items) != 1 { + t.Error("clients/items nilled?") + } + + // update + created.Name = "renamed" + up, err := s.UpdateAccessList(created) + if err != nil || up.Name != "renamed" { + t.Fatalf("update: %v", err) + } + if up.CreatedOn == "" { + t.Error("UpdateAccessList did not preserve CreatedOn") + } + + if err := s.DeleteAccessList(created.ID); err != nil { + t.Fatal(err) + } + if _, ok := s.GetAccessList(created.ID); ok { + t.Error("not deleted") + } +} + +func TestStreamCRUD(t *testing.T) { + s := newTestBbolt(t) + defer closeIfBbolt(s) + + st := Stream{ + IncomingPort: 1234, + ForwardingHost: "127.0.0.1", + ForwardingPort: 5678, + TCPForwarding: true, + Enabled: true, + Meta: map[string]any{"k": 1}, + } + c, err := s.CreateStream(st) + if err != nil { + t.Fatal(err) + } + if c.ID == 0 || c.Meta == nil { + t.Error("id or meta") + } + c.Enabled = false + up, _ := s.UpdateStream(c) + if up.CreatedOn == "" { + t.Error("UpdateStream did not preserve CreatedOn") + } + if err := s.SetStreamEnabled(c.ID, true); err != nil { + t.Fatal(err) + } + g, _ := s.GetStream(c.ID) + if !g.Enabled { + t.Error("set enabled") + } + _ = s.DeleteStream(c.ID) +} + +func TestCertificateCRUD(t *testing.T) { + s := newTestBbolt(t) + defer closeIfBbolt(s) + + c := Certificate{ + Provider: "letsencrypt", + DomainNames: []string{"ex.com"}, + Meta: map[string]any{"cert": "pem"}, + } + created, err := s.CreateCertificate(c) + if err != nil || len(created.DomainNames) == 0 || created.Meta == nil { + t.Fatal(err) + } + created.ExpiresOn = "2099-01-01" + origCreated := created.CreatedOn + if err := s.SetCertificate(created); err != nil { + t.Fatal(err) + } + g, _ := s.GetCertificate(created.ID) + if g.ExpiresOn != "2099-01-01" { + t.Error("set cert") + } + if g.CreatedOn != origCreated { + t.Error("SetCertificate did not preserve CreatedOn") + } + _ = s.DeleteCertificate(created.ID) +} + +func TestRedirectionDeadHostCRUD(t *testing.T) { + s := newTestBbolt(t) + defer closeIfBbolt(s) + + rh := RedirectionHost{DomainNames: []string{"r.com"}, ForwardDomainName: "t.com", Enabled: true, Meta: map[string]any{}} + crh, _ := s.CreateRedirectionHost(rh) + crh.ForwardHTTPCode = 301 + rup, _ := s.UpdateRedirectionHost(crh) + if rup.CreatedOn == "" { + t.Error("UpdateRedirectionHost did not preserve CreatedOn") + } + _ = s.SetRedirectionHostEnabled(crh.ID, false) + _ = s.DeleteRedirectionHost(crh.ID) + + dh := DeadHost{DomainNames: []string{"d.com"}, Enabled: true, Meta: map[string]any{}} + cdh, _ := s.CreateDeadHost(dh) + dup, _ := s.UpdateDeadHost(cdh) + if dup.CreatedOn == "" { + t.Error("UpdateDeadHost did not preserve CreatedOn") + } + _ = s.SetDeadHostEnabled(cdh.ID, false) + _ = s.DeleteDeadHost(cdh.ID) +} + +func TestSettingsAudit(t *testing.T) { + s := newTestBbolt(t) + defer closeIfBbolt(s) + + if err := s.SetSetting("foo", "bar"); err != nil { + t.Fatal(err) + } + m := s.GetSettings() + if m["foo"] != "bar" { + t.Errorf("set/get setting: %v", m) + } + + log := AuditLog{UserID: 1, ObjectType: "test", ObjectID: 99, Action: "create", Meta: map[string]any{"x": 1}} + added, err := s.AddAuditLog(log) + if err != nil || added.ID == 0 { + t.Fatalf("add audit: %v", err) + } + if added.CreatedOn == "" { + t.Error("AddAuditLog did not populate CreatedOn") + } + logs := s.GetAuditLogs() + if len(logs) < 1 || logs[len(logs)-1].CreatedOn == "" { + t.Error("no audit logs or missing CreatedOn") + } +} + +func TestUsersCRUD(t *testing.T) { + s := newTestBbolt(t) + defer closeIfBbolt(s) + + u := User{Email: "new@example.com", Name: "New", Roles: []string{"user"}, Password: "pw"} + created, err := s.CreateUser(u) + if err != nil || created.ID == 0 { + t.Fatal(err) + } + created.Name = "Renamed" + if err := s.UpdateUser(created); err != nil { + t.Fatal(err) + } + if err := s.UpdateUserFull(created); err != nil { + t.Fatal(err) + } + by, ok := s.GetUserByEmail("new@example.com") + if !ok || by.Name != "Renamed" { + t.Error("get by email or update") + } + if err := s.DeleteUser(created.ID); err != nil { + t.Fatal(err) + } +} + +func TestGetUsersAndIsSetup(t *testing.T) { + s := newTestBbolt(t) + defer closeIfBbolt(s) + users := s.GetUsers() + if len(users) == 0 { + t.Error("no users") + } + if !s.IsSetup() { + t.Error("not setup") + } +} + +func TestFidelityMetaSlices(t *testing.T) { + s := newTestBbolt(t) + defer closeIfBbolt(s) + + ph := ProxyHost{DomainNames: nil, Meta: nil} + c, _ := s.CreateProxyHost(ph) + // impls normalize to non-nil + if c.DomainNames == nil || c.Meta == nil { + t.Error("should have non-nil slices/maps after create") + } + g, _ := s.GetProxyHost(c.ID) + if g.DomainNames == nil { + t.Error("get returned nil domains") + } + + // audit fidelity (CreatedOn + Meta) + alog := AuditLog{UserID: 1, ObjectType: "t", ObjectID: 1, Action: "a", Meta: map[string]any{"m": 2}} + added, _ := s.AddAuditLog(alog) + if added.CreatedOn == "" || added.Meta["m"] != 2 { + t.Error("audit CreatedOn/Meta fidelity") + } + gots := s.GetAuditLogs() + if len(gots) == 0 || gots[len(gots)-1].Meta == nil { + t.Error("get audit meta nil") + } + + // access list nested slices fidelity + al := AccessList{Name: "al", Clients: []AccessClient{{Address: "1.2.3.4", Directive: "allow"}}, Items: []AccessItem{{Username: "u", Password: "p"}}} + ca, _ := s.CreateAccessList(al) + if len(ca.Clients) == 0 || len(ca.Items) == 0 { + t.Error("access nested create") + } + ga, _ := s.GetAccessList(ca.ID) + if len(ga.Items) == 0 || len(ga.Clients) == 0 { + t.Error("access nested get") + } +} + +func TestConcurrency(t *testing.T) { + s := newTestBbolt(t) + defer closeIfBbolt(s) + + var wg sync.WaitGroup + n := 20 + var mu sync.Mutex + ids := []int{} + errs := []error{} + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + h, err := s.CreateProxyHost(ProxyHost{DomainNames: []string{"c" + strconv.Itoa(i) + ".com"}}) + _ = s.SetSetting("k", i) + _, _ = s.AddAuditLog(AuditLog{Action: "cc"}) + mu.Lock() + ids = append(ids, h.ID) + if err != nil { + errs = append(errs, err) + } + mu.Unlock() + }(i) + } + wg.Wait() + if len(errs) > 0 { + t.Errorf("conc errs: %v", errs) + } + if len(ids) != n { + t.Errorf("expected %d creates, got %d", n, len(ids)) + } + seen := map[int]bool{} + for _, id := range ids { + if seen[id] { + t.Errorf("dup ID %d in conc creates", id) + } + seen[id] = true + } + // final fidelity post-conc + final := s.GetProxyHosts() + if len(final) != n { + t.Errorf("post-conc hosts count %d != %d", len(final), n) + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..30ee19d --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,82 @@ +package version + +import ( + "strconv" + "strings" +) + +// Version and Commit are injected separately at link time via -ldflags (see Makefile / Dockerfile). +// Version is the exact git tag on HEAD (empty if untagged). Commit is the short git hash (empty if unavailable). +var ( + Version = "" + Commit = "" +) + +func resolve() string { + if isSemverTag(Version) { + return Version + } + if Commit != "" { + return Commit + } + return "dev" +} + +func String() string { + return resolve() +} + +func Display() string { + return resolve() +} + +// isSemverTag reports whether tag looks like semver (optional "v" + major.minor.patch, pre-release suffix allowed). +func isSemverTag(tag string) bool { + if tag == "" { + return false + } + core := tag + if i := strings.IndexAny(core, "-+"); i >= 0 { + core = core[:i] + } + core = strings.TrimPrefix(core, "v") + parts := strings.Split(core, ".") + if len(parts) != 3 { + return false + } + for _, p := range parts { + if p == "" { + return false + } + for _, c := range p { + if c < '0' || c > '9' { + return false + } + } + } + return true +} + +// Parts splits the git tag (Version) into major/minor/revision for NPM-style API responses. +// Returns zeros when there is no semver tag (commit-only or dev builds). +func Parts() (major, minor, revision int) { + if !isSemverTag(Version) { + return 0, 0, 0 + } + core := Version + if i := strings.IndexAny(core, "-+"); i >= 0 { + core = core[:i] + } + core = strings.TrimPrefix(core, "v") + parts := strings.Split(core, ".") + if len(parts) != 3 { + return 0, 0, 0 + } + major, err0 := strconv.Atoi(parts[0]) + minor, err1 := strconv.Atoi(parts[1]) + revision, err2 := strconv.Atoi(parts[2]) + if err0 != nil || err1 != nil || err2 != nil { + return 0, 0, 0 + } + return major, minor, revision +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..b5d1134 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,61 @@ +package version + +import "testing" + +func TestResolve(t *testing.T) { + tests := []struct { + tag, commit, want string + }{ + {"v1.0.0", "abc1234", "v1.0.0"}, + {"1.2.3", "abc1234", "1.2.3"}, + {"v1.0.0-rc.1", "abc1234", "v1.0.0-rc.1"}, + {"release-candidate", "abc1234", "abc1234"}, + {"latest", "abc1234", "abc1234"}, + {"v1.2", "abc1234", "abc1234"}, + {"", "abc1234", "abc1234"}, + {"", "", "dev"}, + } + for _, tc := range tests { + Version = tc.tag + Commit = tc.commit + if got := String(); got != tc.want { + t.Errorf("resolve(tag=%q, commit=%q) = %q; want %q", tc.tag, tc.commit, got, tc.want) + } + } +} + +func TestIsSemverTag(t *testing.T) { + ok := []string{"v1.0.0", "1.2.3", "v0.0.1", "2.10.99", "v1.0.0-rc.1"} + bad := []string{"", "latest", "release-1", "v1.2", "1.2", "abc1234", "v1.2.3.4"} + for _, tag := range ok { + if !isSemverTag(tag) { + t.Errorf("isSemverTag(%q) = false; want true", tag) + } + } + for _, tag := range bad { + if isSemverTag(tag) { + t.Errorf("isSemverTag(%q) = true; want false", tag) + } + } +} + +func TestParts(t *testing.T) { + tests := []struct { + tag string + major, minor, rev int + }{ + {"v1.2.3", 1, 2, 3}, + {"1.2.3", 1, 2, 3}, + {"v1.2.3-rc1", 1, 2, 3}, + {"", 0, 0, 0}, + {"abc1234", 0, 0, 0}, + {"1.2", 0, 0, 0}, + } + for _, tc := range tests { + Version = tc.tag + major, minor, rev := Parts() + if major != tc.major || minor != tc.minor || rev != tc.rev { + t.Errorf("Parts(%q) = %d,%d,%d; want %d,%d,%d", tc.tag, major, minor, rev, tc.major, tc.minor, tc.rev) + } + } +} diff --git a/internal/www/404.html b/internal/www/404.html new file mode 100644 index 0000000..3da5121 --- /dev/null +++ b/internal/www/404.html @@ -0,0 +1,27 @@ + + + + + + 404 Not Found + + + +

404 Not Found

+

{{MESSAGE}}

+ + diff --git a/internal/www/embed.go b/internal/www/embed.go new file mode 100644 index 0000000..ee0eeb6 --- /dev/null +++ b/internal/www/embed.go @@ -0,0 +1,9 @@ +package www + +import "embed" + +// Default pages for unmatched hosts and shared error responses. +// Override by placing the same filename under WWW_DIR (default: data/www/). +// +//go:embed index.html 404.html +var defaults embed.FS diff --git a/internal/www/index.html b/internal/www/index.html new file mode 100644 index 0000000..d737671 --- /dev/null +++ b/internal/www/index.html @@ -0,0 +1,29 @@ + + + + + + Welcome to Helix Proxy + + + +

Welcome to Helix Proxy

+

No virtual host is configured for {{HOST}}.

+

Open the admin UI to add proxy hosts, certificates, and streams.

+ + diff --git a/internal/www/page.go b/internal/www/page.go new file mode 100644 index 0000000..d14fb47 --- /dev/null +++ b/internal/www/page.go @@ -0,0 +1,34 @@ +package www + +import ( + "os" + "path/filepath" + "strings" + + "helix-proxy/internal/config" +) + +// LoadPage returns HTML for name (e.g. "index.html", "404.html"). +// A file in WWW_DIR with the same name overrides the embedded default. +// Placeholders use the form {{KEY}} and are replaced from vars. +func LoadPage(name string, vars map[string]string) []byte { + wwwDir := config.Resolve("www") + customPath := filepath.Join(wwwDir, name) + + data, err := os.ReadFile(customPath) + if err != nil { + data, err = defaults.ReadFile(name) + if err != nil { + return nil + } + } + + if len(vars) == 0 { + return data + } + s := string(data) + for k, v := range vars { + s = strings.ReplaceAll(s, "{{"+k+"}}", v) + } + return []byte(s) +} diff --git a/internal/www/page_test.go b/internal/www/page_test.go new file mode 100644 index 0000000..13e79b7 --- /dev/null +++ b/internal/www/page_test.go @@ -0,0 +1,39 @@ +package www + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "helix-proxy/internal/config" +) + +func TestLoadPageEmbeddedDefault(t *testing.T) { + t.Setenv("DATA_DIR", t.TempDir()) + page := LoadPage("index.html", map[string]string{"HOST": "test.example"}) + if page == nil { + t.Fatal("expected embedded index.html") + } + body := string(page) + if !strings.Contains(body, "test.example") || !strings.Contains(body, "Welcome to Helix Proxy") { + t.Fatalf("unexpected page: %s", page) + } +} + +func TestLoadPageWWWOverride(t *testing.T) { + dir := t.TempDir() + t.Setenv("DATA_DIR", dir) + wwwDir := config.Resolve("www") + if err := os.MkdirAll(wwwDir, 0o755); err != nil { + t.Fatal(err) + } + custom := []byte("

CUSTOM {{HOST}}

") + if err := os.WriteFile(filepath.Join(wwwDir, "index.html"), custom, 0o644); err != nil { + t.Fatal(err) + } + page := LoadPage("index.html", map[string]string{"HOST": "override.example"}) + if string(page) != "

CUSTOM override.example

" { + t.Fatalf("override failed: %q", page) + } +} diff --git a/proxy-manager b/proxy-manager new file mode 100755 index 0000000..f2fb4d5 Binary files /dev/null and b/proxy-manager differ diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/ui/.npmrc b/ui/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/ui/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..2cc4d7f --- /dev/null +++ b/ui/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by `sv`. + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +npx sv@0.15.4 create --template minimal --types ts --add tailwindcss="plugins:none" --install npm ui +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/ui/embed.go b/ui/embed.go new file mode 100644 index 0000000..b8a373a --- /dev/null +++ b/ui/embed.go @@ -0,0 +1,15 @@ +package ui + +import "embed" + +// Dist contains the built Svelte SPA assets (index.html + js/css/...). +// Built by `make ui-build` (npm run build in ui/) and embedded at compile time. +// Served by the Go binary for the admin UI on :81. +// +// Note: SvelteKit outputs assets under dist/_app/... (leading underscore). +// Go's //go:embed skips dirs/files starting with _ by default in patterns, +// so we explicitly list dist/_app to ensure the client JS/CSS are embedded. +// See https://pkg.go.dev/embed and SvelteKit adapter-static output. +// +//go:embed dist dist/_app +var Dist embed.FS diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..6cc3a9c --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,1766 @@ +{ + "name": "ui", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ui", + "version": "0.0.1", + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.2.2", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "tailwindcss": "^4.2.2", + "typescript": "^6.0.2", + "vite": "^8.0.7" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", + "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.63.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.63.0.tgz", + "integrity": "sha512-1DrR7vQ9brXLrNE2sLtFXApwr7AUXPfpbIFYc+CQRf2+iURaZbXGU+7TG/RLr+9fdFkoRdyCAVUOHCChw11LFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.1.1.tgz", + "integrity": "sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", + "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.22.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.2.tgz", + "integrity": "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.11.tgz", + "integrity": "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.1.tgz", + "integrity": "sha512-eArsJmvl3xZVuTYD852PzIEdg2wgDdIZ1NEsIPbzAukHwi284B18No4nK2rCO9AwsWUDza4Cjvmoa4HaojTl5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.9", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.6.0.tgz", + "integrity": "sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "0.1.1", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..686d23e --- /dev/null +++ b/ui/package.json @@ -0,0 +1,26 @@ +{ + "name": "ui", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.2.2", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "tailwindcss": "^4.2.2", + "typescript": "^6.0.2", + "vite": "^8.0.7" + } +} diff --git a/ui/src/app.d.ts b/ui/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/ui/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/ui/src/app.html b/ui/src/app.html new file mode 100644 index 0000000..3c53763 --- /dev/null +++ b/ui/src/app.html @@ -0,0 +1,13 @@ + + + + + + + Helix Proxy + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/ui/src/lib/FlashBanner.svelte b/ui/src/lib/FlashBanner.svelte new file mode 100644 index 0000000..cd6de2e --- /dev/null +++ b/ui/src/lib/FlashBanner.svelte @@ -0,0 +1,70 @@ + + +{#if message} + +{/if} diff --git a/ui/src/lib/Modal.svelte b/ui/src/lib/Modal.svelte new file mode 100644 index 0000000..64914fe --- /dev/null +++ b/ui/src/lib/Modal.svelte @@ -0,0 +1,53 @@ + + + { if (e.key === 'Escape' && open) close(); }} /> + +{#if open} + + +{/if} diff --git a/ui/src/lib/assets/favicon.svg b/ui/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/ui/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/ui/src/lib/index.ts b/ui/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/ui/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/ui/src/lib/labels.ts b/ui/src/lib/labels.ts new file mode 100644 index 0000000..f6c104a --- /dev/null +++ b/ui/src/lib/labels.ts @@ -0,0 +1,130 @@ +type CertLike = { id?: number; niceName?: string; domainNames?: string[]; provider?: string }; +type AccessLike = { id?: number; name?: string }; +type AuditLogLike = { + userId?: number; + objectType?: string; + objectId?: number; + action?: string; + meta?: Record; +}; +type UserLike = { id?: number; name?: string; email?: string }; + +export function certLabel(c: CertLike | null | undefined): string { + if (!c) return ''; + const domains = (c.domainNames || []).join(', '); + return c.niceName || domains || (c.id ? `Certificate #${c.id}` : 'Certificate'); +} + +export function certLabelById(certId: number, certs: CertLike[]): string { + if (!certId) return ''; + const c = certs.find((x) => x.id === certId); + return c ? certLabel(c) : `Certificate #${certId}`; +} + +export function accessLabelById(accessListId: number, lists: AccessLike[]): string { + if (!accessListId) return ''; + const al = lists.find((x) => x.id === accessListId); + return al?.name || `Access list #${accessListId}`; +} + +export function providerLabel(provider: string | undefined): string { + if (provider === 'letsencrypt') return "Let's Encrypt"; + if (provider === 'custom') return 'Custom'; + return provider || '-'; +} + +export function sslCertLabel(certId: number, certs: CertLike[], sslForced = false): string { + if (!certId) return sslForced ? 'Forced' : 'None'; + return certLabelById(certId, certs); +} + +const OBJECT_TYPE_LABELS: Record = { + 'proxy-host': 'proxy host', + certificate: 'certificate', + 'access-list': 'access list', + stream: 'stream', + 'redirection-host': 'redirection host', + 'dead-host': '404 host', + user: 'user', +}; + +function metaDomains(meta: Record): string { + const raw = meta.domain_names ?? meta.domainNames; + if (Array.isArray(raw) && raw.length) return raw.map(String).join(', '); + return ''; +} + +export function auditObjectLabel(log: AuditLogLike): string { + const meta = log.meta || {}; + const domains = metaDomains(meta); + if (domains) return domains; + if (meta.name) return String(meta.name); + if (meta.incoming_port != null) return `port ${meta.incoming_port}`; + const type = log.objectType || 'object'; + return `${OBJECT_TYPE_LABELS[type] || type} #${log.objectId ?? '?'}`; +} + +export function auditUserLabel(userId: number | undefined, user: UserLike | null): string { + if (user && userId === user.id) return user.name || user.email || `User #${userId}`; + if (userId) return `User #${userId}`; + return 'System'; +} + +export function auditLogSummary(log: AuditLogLike, user: UserLike | null): string { + const who = auditUserLabel(log.userId, user); + const action = log.action || 'changed'; + const type = OBJECT_TYPE_LABELS[log.objectType || ''] || log.objectType || 'object'; + const what = auditObjectLabel(log); + return `${who} ${action} ${type}: ${what}`; +} + +export function ownerDisplay(user: UserLike | null): { label: string; initial: string } { + const label = user?.name || user?.email || 'admin'; + return { label, initial: (label[0] || 'A').toUpperCase() }; +} + +export function streamLabel(s: { + incomingPort?: number; + forwardingHost?: string; + forwardingPort?: number; +}): string { + const port = s.incomingPort ?? '?'; + const target = s.forwardingHost ? `${s.forwardingHost}:${s.forwardingPort ?? '?'}` : ''; + return target ? `:${port} → ${target}` : `Stream :${port}`; +} + +export function streamStatusDisplay(s: { + enabled?: boolean; + listening?: boolean; + listenError?: string; +}): { label: string; className: string; title?: string } { + if (!s.enabled) { + return { label: 'Offline', className: 'bg-zinc-700 text-zinc-400' }; + } + if (s.listenError) { + return { label: 'Error', className: 'bg-red-900 text-red-400', title: s.listenError }; + } + if (s.listening) { + return { label: 'Online', className: 'bg-emerald-900 text-emerald-400' }; + } + return { label: 'Starting', className: 'bg-amber-900 text-amber-400' }; +} + +export function domainListLabel(domainNames: string[] | string | undefined, fallback = ''): string { + const list = Array.isArray(domainNames) + ? domainNames + : (domainNames || '').split(',').map((s) => s.trim()).filter(Boolean); + const label = list.join(', '); + return label || fallback; +} + +export function redirLabel(r: { + domainNames?: string[] | string; + forwardDomainName?: string; + forwardHttpCode?: number; +}): string { + const from = domainListLabel(r.domainNames, 'redirection'); + const to = r.forwardDomainName || '?'; + const code = r.forwardHttpCode ?? 301; + return `${from} → ${to} (${code})`; +} diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte new file mode 100644 index 0000000..0d8eb03 --- /dev/null +++ b/ui/src/routes/+layout.svelte @@ -0,0 +1,9 @@ + + + +{@render children()} diff --git a/ui/src/routes/+page.svelte b/ui/src/routes/+page.svelte new file mode 100644 index 0000000..d0fc187 --- /dev/null +++ b/ui/src/routes/+page.svelte @@ -0,0 +1,1776 @@ + + +
+ +
+
+
+
HP
+
Helix Proxy
+
+ {#if token} +
+ + + + +
+ + {#if showUserMenu} +
+
{user?.email || 'admin'}
Administrator
+ + +
+ {/if} +
+
+ {/if} +
+
+ + {#if !token} + +
+

Sign in

+ +
+
+ + +
+ +
+ + + +
+ {:else} + +
+ {#if !mustChangePassword} + +
+ + +
+ + {#if showHostsMenu} +
+ + + + +
+ {/if} +
+ + + + +
+ {/if} + + {#if loading}
Loading...
{/if} + + + + {#if mustChangePassword || showChangePw} +
+
{mustChangePassword ? 'First login: set a new admin password' : 'Change Admin Password'}
+

{mustChangePassword ? 'The default "password" must be changed before using the manager.' : 'Enter your current password and choose a new one.'}

+
+ {#if !mustChangePassword} + + {/if} + + +
+ + {#if !mustChangePassword} + + {/if} +
+
+
+ {/if} + + + {#if currentTab === 'dashboard'} +
Dashboard
+
+ + + + +
+ + {/if} + + + {#if currentTab === 'proxy'} + +
+
Proxy Hosts
+
+
+ + +
+ + +
+
+ +
+ + + + + + + + + + + + + + {#each filteredProxy as h} + + + + + + + + + + {:else} + + {/each} + +
OwnerSource ↓DestinationSSLAccessStatus
+
+
{owner.initial}
+ {owner.label} +
+
+ +
Created: {h.createdOn || '—'}
+
{h.forwardScheme}://{h.forwardHost}:{h.forwardPort} + {sslCertLabel(h.certificateId, certificates, h.sslForced)} + {h.accessListId ? accessLabelById(h.accessListId, accessLists) : 'Public'}{h.enabled ? 'Online' : 'Offline'} + + + +
+
There are no Proxy Hosts
+
Why don't you create one?
+ +
+
+ {/if} + + + {#if currentTab === 'certs'} +
+
Certificates
+
+
+ + +
+ + + +
+
+
+ + + + {#each filteredCerts as c} + + + + + + + + + {:else} + + {/each} + +
OwnerName ↓ProviderExpiresStatus
+
+
{owner.initial}
+ {owner.label} +
+
{certLabel(c)}
Created: {c.createdOn || '—'}
{providerLabel(c.provider)}{c.expiresOn || '-'}{isCertInUse(c.id) ? 'In Use' : 'Available'} + + +
+
There are no Certificates
+
Why don't you create one?
+ +
+
+ + {/if} + + + {#if currentTab === 'access'} +
+
Access Lists
+
+
+ + +
+ + +
+
+
+ + + + {#each filteredAccess as al} + + + + + + + + + + {:else} + + {/each} + +
OwnerName ↓AuthorizationAccessSatisfyProxy Hosts
+
+
{owner.initial}
+ {owner.label} +
+
{al.name || '-'}
Created: {al.createdOn || '—'}
{(al.items || []).length} User{(al.clients || []).length} Rule{al.satisfyAny ? 'Any' : 'All'}{proxyHosts.filter((p:any)=>p.accessListId === al.id).length} + + +
+
There are no Access Lists
+
Why don't you create one?
+ +
+
+ {/if} + + + {#if currentTab === 'streams'} +
+
Streams
+
+
+ + +
+ + +
+
+
+ + + + + + + + + + + + + {#each filteredStreams as s} + {@const status = streamStatusDisplay(s)} + + + + + + + + + {:else} + + {/each} + +
OwnerIncoming ↓ForwardSSLStatus
+
+
{owner.initial}
+ {owner.label} +
+
{s.incomingPort}
Created: {s.createdOn || '—'}
{s.forwardingHost}:{s.forwardingPort}{sslCertLabel(s.certificateId, certificates)} {s.tcpForwarding ? 'TCP' : ''}{s.udpForwarding ? ' UDP' : ''}{status.label} + + + +
+
There are no Streams
+
Why don't you create one?
+ +
+
+ {/if} + + + {#if currentTab === 'redir'} +
+
Redirection Hosts
+
+
+ + +
+ + +
+
+
+ + + + + + + + + + + + {#each filteredRedirs as r} + + + + + + + + {:else} + + {/each} + +
OwnerSource ↓DestinationStatus
+
+
{owner.initial}
+ {owner.label} +
+
+ +
Created: {r.createdOn || '—'}
+
{r.forwardDomainName} ({r.forwardHttpCode} {r.forwardScheme}){r.enabled ? 'Online' : 'Offline'} + + + +
+
There are no Redirection Hosts
+
Why don't you create one?
+ +
+
+ {/if} + + + {#if currentTab === 'dead'} +
+
404 Hosts
+
+
+ + +
+ + +
+
+
+ + + + + + + + + + + + {#each filteredDeads as d} + + + + + + + + {:else} + + {/each} + +
OwnerSource ↓Custom HTMLStatus
+
+
{owner.initial}
+ {owner.label} +
+
+ +
Created: {d.createdOn || '—'}
+
{d.meta && d.meta.html ? 'yes (per-host)' : 'default 404'}{d.enabled ? 'Online' : 'Offline'} + + + +
+
There are no 404 Hosts
+
Why don't you create one?
+ +
+
+ {/if} + + + {#if currentTab === 'audit'} +
+
Audit Logs (recent first)
+
+ + +
+
+
+ {#each filteredAudit.slice().reverse() as log} +
{log.createdOn} {auditLogSummary(log, user)}
+ {:else} +
No logs yet
+ {/each} +
+ {/if} + + + {#if currentTab === 'settings'} +
Settings
+
+
Default site
+ + {#if settingsData.default_site === 'redirect'} + + {/if} +
+ + +
+ +
+ {/if} + + + closeForm()}> + {#if formModalType === 'stream'} +
{ await createStream(e); if (!formError) closeForm(); }} class="space-y-3 text-sm"> +
+ + + +
+
+ + + + +
+
+ + +
+
+ {:else if formModalType === 'redir'} +
{ await createRedir(e); if (!formError) closeForm(); }} class="space-y-3 text-sm"> + + +
+ + + +
+
+ +
+
+ + +
+
+ {:else if formModalType === 'dead'} +
{ await createDead(e); if (!formError) closeForm(); }} class="space-y-3 text-sm"> + + +
+ +
+
+ + +
+
+ {:else if formModalType === 'proxy'} +
{ await createProxy(e); if (!formError) closeForm(); }} class="space-y-3 text-sm"> + +
+ + + + +
+ + {#if proxyFormTab === 'details'} + +
+ + + +
+
+ +
+
+ +
+
+
Options
+
+ + + + + +
+
+ {:else if proxyFormTab === 'locations'} +
+
+ Custom locations + +
+ {#each (newProxy.locations as any[]) as loc, i} +
+ +
+ + + +
+
+ + +
+
+ {/each} +
+ {:else if proxyFormTab === 'ssl'} + +
+ + + +
+ {:else if proxyFormTab === 'headers'} +
+
+
+ Request headers + +
+ {#each (newProxy.requestHeaders || []) as hdr, i} +
+ + + +
+ {/each} +
+
+
+ Response headers + +
+ {#each (newProxy.responseHeaders || []) as hdr, i} +
+ + + +
+ {/each} +
+
+
+ Hide headers + +
+ {#each (newProxy.hideHeaders || []) as name, i} +
+ + +
+ {/each} +
+
+ {/if} + +
+ + +
+
+ {:else if formModalType === 'cert'} +
{ await createCert(e); if (!formError) closeForm(); }} class="space-y-3 text-sm"> + +
+ + +
+
+ {:else if formModalType === 'customCert'} +
{ await createCustomCert(e); if (!formError) closeForm(); }} class="space-y-3 text-sm"> + + + + +
+ + +
+
+ {:else if formModalType === 'access'} +
{ await createAccess(e); if (!formError) closeForm(); }} class="space-y-3 text-sm"> + + + +
+ + +
+
+ + +
+
+ {:else} +
No form selected.
+ {/if} +
+ + { confirmState.message = ''; confirmState.onConfirm = null; }}> +

{confirmState.message}

+
+ + +
+
+ + +
+ GitHub + © 2026 + {appVersion || 'dev'} +
+
+ {/if} +
+ + diff --git a/ui/src/routes/layout.css b/ui/src/routes/layout.css new file mode 100644 index 0000000..d4b5078 --- /dev/null +++ b/ui/src/routes/layout.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/ui/static/robots.txt b/ui/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/ui/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/ui/svelte.config.js b/ui/svelte.config.js new file mode 100644 index 0000000..08a8dd2 --- /dev/null +++ b/ui/svelte.config.js @@ -0,0 +1,20 @@ +import adapter from '@sveltejs/adapter-static'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + compilerOptions: { + // Force runes mode for the project, except for libraries. Can be removed in svelte 6. + runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true) + }, + kit: { + adapter: adapter({ + pages: 'dist', + assets: 'dist', + fallback: 'index.html', + precompress: false, + strict: true + }) + } +}; + +export default config; diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..56f40c7 --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,5 @@ +import tailwindcss from '@tailwindcss/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });