initial commit
Format / gofmt (push) Failing after 27s
CI / Build (push) Successful in 51s
CI / Go Tests (push) Failing after 21s

This commit is contained in:
2026-06-06 07:54:44 -05:00
commit 1cc94f2c99
68 changed files with 14615 additions and 0 deletions
+69
View File
@@ -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
+31
View File
@@ -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
+199
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+59
View File
@@ -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."
+70
View File
@@ -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
+453
View File
@@ -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)
})
}
}
+345
View File
@@ -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)
}
}
+467
View File
@@ -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)
}
+32
View File
@@ -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)
}
+45
View File
@@ -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)
}
+105
View File
@@ -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()
})
}
+134
View File
@@ -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$"))
}
+183
View File
@@ -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
}
+65
View File
@@ -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)
})
}
+72
View File
@@ -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
}
+132
View File
@@ -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()
}
}
}
+72
View File
@@ -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")
}
}
+146
View File
@@ -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)
}
}
+87
View File
@@ -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)
}
+30
View File
@@ -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
+23
View File
@@ -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
)
+38
View File
@@ -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=
+131
View File
@@ -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
}
+128
View File
@@ -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")
}
}
+365
View File
@@ -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
}
+230
View File
@@ -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")
}
}
+110
View File
@@ -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"
}
+158
View File
@@ -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")
}
}
+155
View File
@@ -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
+825
View File
@@ -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
}
+546
View File
@@ -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]))
}
}
+618
View File
@@ -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])
}
+854
View File
@@ -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, &current)
}
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)
+110
View File
@@ -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
}
+111
View File
@@ -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.
}
+513
View File
@@ -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)
}
}
+82
View File
@@ -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
}
+61
View File
@@ -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)
}
}
}
+27
View File
@@ -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>
+9
View File
@@ -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
+29
View File
@@ -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>
+34
View File
@@ -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)
}
+39
View File
@@ -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
View File
Binary file not shown.
+23
View File
@@ -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-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+42
View File
@@ -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
View File
@@ -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
+1766
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
}
}
+13
View File
@@ -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 {};
+13
View File
@@ -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>
+70
View File
@@ -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}
+53
View File
@@ -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}
+1
View File
@@ -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

+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+130
View File
@@ -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})`;
}
+9
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
@import 'tailwindcss';
+3
View File
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+20
View File
@@ -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;
+20
View File
@@ -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
}
+5
View File
@@ -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()] });