initial commit
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+63
@@ -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
|
||||
+41
@@ -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 '<!doctype html><title>Helix Proxy</title><p>UI placeholder (run make ui-build for the real Svelte SPA)</p>' > 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)
|
||||
@@ -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."
|
||||
@@ -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.).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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": "<b>dead</b>"},
|
||||
}, 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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$"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, `<!doctype html>
|
||||
<html><head><title>Helix Proxy</title></head>
|
||||
<body style="font-family:system-ui;padding:2rem;max-width:720px;margin:0 auto">
|
||||
<h1>Helix Proxy</h1>
|
||||
<p>UI not built yet — run <code>make ui-build</code> then <code>make</code> (or <code>go build</code>).</p>
|
||||
<p>Health: <a href="/api">/api</a> (cwd-relative data at <code>`+config.DataDir()+`</code>)</p>
|
||||
<p>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.</p>
|
||||
</body></html>`)
|
||||
})
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.<email>.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/<id>/ 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/<id>/) 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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": "<h1>custom dead</h1>"}}
|
||||
_, _ = 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("<h1>CUSTOM 404 FILE</h1>"), 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
|
||||
}
|
||||
@@ -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": "<h1>integration dead</h1>"},
|
||||
})
|
||||
wwwDir := config.Resolve("www")
|
||||
_ = os.MkdirAll(wwwDir, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(wwwDir, "404.html"), []byte("<h1>INTEGRATION 404 FILE</h1>"), 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]))
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>404 Not Found</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
line-height: 1.5;
|
||||
max-width: 42rem;
|
||||
margin: 4rem auto;
|
||||
padding: 0 1.5rem;
|
||||
color: #e4e4e7;
|
||||
background: #09090b;
|
||||
}
|
||||
h1 { font-size: 1.75rem; font-weight: 600; margin: 0 0 0.75rem; color: #fafafa; }
|
||||
p { margin: 0.5rem 0; color: #a1a1aa; }
|
||||
.host { color: #f87171; font-weight: 500; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>404 Not Found</h1>
|
||||
<p>{{MESSAGE}}</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Welcome to Helix Proxy</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
line-height: 1.5;
|
||||
max-width: 42rem;
|
||||
margin: 4rem auto;
|
||||
padding: 0 1.5rem;
|
||||
color: #e4e4e7;
|
||||
background: #09090b;
|
||||
}
|
||||
h1 { font-size: 1.75rem; font-weight: 600; margin: 0 0 0.75rem; color: #fafafa; }
|
||||
p { margin: 0.5rem 0; color: #a1a1aa; }
|
||||
code { background: #18181b; padding: 0.15rem 0.4rem; border-radius: 0.25rem; font-size: 0.9em; }
|
||||
.host { color: #34d399; font-weight: 500; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome to Helix Proxy</h1>
|
||||
<p>No virtual host is configured for <span class="host">{{HOST}}</span>.</p>
|
||||
<p>Open the admin UI to add proxy hosts, certificates, and streams.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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("<h1>CUSTOM {{HOST}}</h1>")
|
||||
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) != "<h1>CUSTOM override.example</h1>" {
|
||||
t.Fatalf("override failed: %q", page)
|
||||
}
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
@@ -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-*
|
||||
@@ -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.
|
||||
+15
@@ -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
|
||||
Generated
+1766
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+13
@@ -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 {};
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
<title>Helix Proxy</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
const FLASH_TIMEOUT_MS = 15000;
|
||||
|
||||
let {
|
||||
message = $bindable(''),
|
||||
variant = 'error' as 'error' | 'success',
|
||||
compact = false,
|
||||
class: className = '',
|
||||
timeoutMs = FLASH_TIMEOUT_MS
|
||||
}: {
|
||||
message?: string;
|
||||
variant?: 'error' | 'success';
|
||||
compact?: boolean;
|
||||
class?: string;
|
||||
timeoutMs?: number;
|
||||
} = $props();
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function dismiss() {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
message = '';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!message) {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
message = '';
|
||||
}, timeoutMs);
|
||||
return () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const baseClass = $derived(
|
||||
compact
|
||||
? variant === 'error'
|
||||
? 'text-red-400 text-sm'
|
||||
: 'text-emerald-400 text-sm'
|
||||
: variant === 'error'
|
||||
? 'text-red-400 bg-red-950/50 border border-red-900 px-3 py-1.5 rounded text-sm'
|
||||
: 'text-emerald-400 bg-emerald-950/50 border border-emerald-900 px-3 py-1.5 rounded text-sm'
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if message}
|
||||
<div class="{baseClass} {className} flex items-start justify-between gap-2" role="alert">
|
||||
<span class="flex-1 min-w-0">{message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.preventDefault(); e.stopPropagation(); dismiss(); }}
|
||||
aria-label="Dismiss"
|
||||
class="shrink-0 text-zinc-400 hover:text-white leading-none text-xl w-6 h-6 flex items-center justify-center rounded hover:bg-zinc-800/50"
|
||||
>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import FlashBanner from '$lib/FlashBanner.svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title = '',
|
||||
error = $bindable(''),
|
||||
onClose,
|
||||
children
|
||||
}: {
|
||||
open?: boolean;
|
||||
title?: string;
|
||||
error?: string;
|
||||
onClose?: () => void;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
if (onClose) onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => { if (e.key === 'Escape' && open) close(); }} />
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/60 z-50 flex items-start justify-center pt-10 md:pt-16"
|
||||
onclick={close}
|
||||
role="presentation"
|
||||
>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_interactive_supports_focus -->
|
||||
<div
|
||||
class="bg-zinc-900 border border-zinc-700 rounded-xl w-full max-w-xl mx-3 shadow-2xl text-sm max-h-[calc(100vh-3rem)] flex flex-col overflow-hidden"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between px-4 py-2.5 border-b border-zinc-800">
|
||||
<div id="modal-title" class="font-medium">{title || 'Dialog'}</div>
|
||||
<button type="button" onclick={close} class="text-zinc-400 hover:text-white text-2xl leading-none px-1 -mr-1">×</button>
|
||||
</div>
|
||||
<div class="p-4 overflow-y-auto min-h-0">
|
||||
<FlashBanner bind:message={error} variant="error" class="mb-3" />
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
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<string, string> = {
|
||||
'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, unknown>): 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})`;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import './layout.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||
{@render children()}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
@import 'tailwindcss';
|
||||
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()] });
|
||||
Reference in New Issue
Block a user