Compare commits
24 Commits
1cc94f2c99
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 084d661200 | |||
| 3087968463 | |||
| 61c7b5582c | |||
| 646904ff0a | |||
| 8b334900e8 | |||
| a1433be417 | |||
| ac295ec330 | |||
| 6500d23f07 | |||
| d7b7541dd1 | |||
| 6aa0095059 | |||
| a6e0693409 | |||
| ac98b8e942 | |||
| 83c4f4163e | |||
| 549fdd8171 | |||
| 6615cc218e | |||
| 097906f5b5 | |||
| 13070a275d | |||
| 6aabb76c16 | |||
| 0b9db3a4c0 | |||
| 17c445ec69 | |||
| a43217b459 | |||
| 22844a2a67 | |||
| 466d69c44c | |||
| abb0256f0b |
@@ -36,8 +36,8 @@ jobs:
|
||||
- name: Build UI assets
|
||||
run: npm --prefix ./ui run build
|
||||
|
||||
- name: Run tests
|
||||
run: make test
|
||||
- name: Run Go Tests
|
||||
run: make test-both
|
||||
|
||||
build:
|
||||
name: Build
|
||||
|
||||
@@ -40,6 +40,7 @@ logs/
|
||||
*.sqlite-wal
|
||||
|
||||
# Keys / secrets
|
||||
.jwt_secret
|
||||
keys.json
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Contributing
|
||||
|
||||
## Propose changes
|
||||
|
||||
Open a pull request against `develop`. Keep the default branch for releases and
|
||||
stable tips; land work on `develop` first.
|
||||
|
||||
Point at an existing issue when one fits. Prefer a short issue that states the
|
||||
symptom or request before a large PR.
|
||||
|
||||
## Commits
|
||||
|
||||
Subject form:
|
||||
|
||||
```
|
||||
area: Imperative summary
|
||||
```
|
||||
|
||||
- **Area** is a real package, directory, or subsystem token (`ci:`, `docs:`,
|
||||
Go package name). Not a lone filename.
|
||||
- **Imperative** mood: Fix, Add, Remove — not "Fixed" or "This patch…".
|
||||
- No trailing period. Aim ≤ ~70–75 characters for the whole subject.
|
||||
- Not conventional-commits (`feat:` / `fix:` / `chore:` as types).
|
||||
|
||||
Body explains **why**. Establish the problem, then say what you are doing.
|
||||
One logical change per commit; split fix and cleanup.
|
||||
|
||||
## Pull requests
|
||||
|
||||
Title matches the primary commit subject.
|
||||
|
||||
- **What** changed
|
||||
- **Why** (problem and impact)
|
||||
- **Test** (concrete steps; "CI green" alone is weak)
|
||||
|
||||
## Issues and closing
|
||||
|
||||
Cite leftover issues by **full URL**. Gitea closes issues when `#N` appears in
|
||||
merge text, so do not put `#N` in the merge message unless that issue is actually
|
||||
done. Use `Fixes #N` / `Closes #N` only when the leftover work is finished.
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN if [ -f ui/package.json ]; then \
|
||||
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
|
||||
FROM golang:1.27-alpine AS go-builder
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright © 2025 s1d3sw1ped
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: build ui ui-build run docker docker-build test clean help
|
||||
.PHONY: build ui ui-build run docker docker-build test test-race test-both fmt clean help
|
||||
|
||||
APP_NAME := helix-proxy
|
||||
GO := go
|
||||
@@ -22,6 +22,9 @@ help:
|
||||
@echo " docker Build docker image (helix-proxy:dev)"
|
||||
@echo " docker-build Build docker image"
|
||||
@echo " test Run go tests"
|
||||
@echo " test-race Run go tests with race detector"
|
||||
@echo " test-both Run go tests and race detector"
|
||||
@echo " fmt Format Go sources (gofmt)"
|
||||
@echo " clean Remove build artifacts and ui/dist"
|
||||
@echo ""
|
||||
|
||||
@@ -50,7 +53,24 @@ docker docker-build:
|
||||
docker build -t helix-proxy:dev .
|
||||
|
||||
test:
|
||||
$(GO) test ./... -count=1 -race -coverprofile=coverage.out
|
||||
$(GO) test ./... -count=1
|
||||
|
||||
test-race:
|
||||
$(GO) test ./... -count=1 -race
|
||||
|
||||
test-both: test test-race
|
||||
|
||||
fmt:
|
||||
@echo "Formatting Go sources..."
|
||||
@files="$$(gofmt -l .)"; \
|
||||
if [ -n "$$files" ]; then \
|
||||
count=$$(echo "$$files" | wc -l); \
|
||||
echo "Reformatted $$count file(s):"; \
|
||||
echo "$$files" | sed 's/^/ /'; \
|
||||
echo "$$files" | xargs gofmt -w; \
|
||||
else \
|
||||
echo "All Go files already formatted."; \
|
||||
fi
|
||||
|
||||
clean:
|
||||
rm -f $(APP_NAME)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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).
|
||||
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). Production requires `ADMIN_PASSWORD` on first boot (the well-known default `"password"` is rejected); development may leave it unset until you change it (bootstrap lock).
|
||||
|
||||
**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.
|
||||
@@ -12,23 +12,33 @@ A pure-Go reverse proxy with embedded web UI. Supports proxy hosts, TCP/UDP stre
|
||||
## 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)
|
||||
ADMIN_PASSWORD='choose-a-real-password' ./helix-proxy
|
||||
# Defaults (overridable via env):
|
||||
# Admin UI + API: 127.0.0.1:8081 (ADMIN_HOST / ADMIN_PORT)
|
||||
# Proxy HTTP: :8080 (PROXY_HTTP_PORT)
|
||||
# Proxy HTTPS: :18443 (PROXY_HTTPS_PORT)
|
||||
```
|
||||
|
||||
Visit http://localhost:81
|
||||
Visit http://127.0.0.1:8081
|
||||
|
||||
Data (db, certs, logs, www html) lives in `./data` relative to where you ran the binary.
|
||||
|
||||
## Docker (recommended)
|
||||
```bash
|
||||
docker compose up -d
|
||||
# compose sets ADMIN_PORT=81 PROXY_HTTP_PORT=80 PROXY_HTTPS_PORT=443
|
||||
# proxy :80/:443 published; admin binds 127.0.0.1:81 (not published by default)
|
||||
# or
|
||||
docker build -t helix-proxy:dev .
|
||||
docker run -p 81:81 -v $PWD/data:/app/data --workdir /app helix-proxy:dev
|
||||
docker run --env ADMIN_PASSWORD='choose-a-real-password' \
|
||||
-p 80:80 -p 443:443 \
|
||||
-e ADMIN_PORT=81 -e PROXY_HTTP_PORT=80 -e PROXY_HTTPS_PORT=443 \
|
||||
-v $PWD/data:/app/data --workdir /app helix-proxy:dev
|
||||
```
|
||||
|
||||
Publishing admin (`-p 81:81`) also needs `-e ADMIN_HOST=0.0.0.0`. Do not publish :81 on untrusted networks.
|
||||
Without those env overrides the binary still defaults to admin `127.0.0.1:8081` and proxy `:8080` / `:18443` inside the container.
|
||||
|
||||
PUID/PGID + DISABLE_IPV6 example (see docker-compose.yml for full):
|
||||
```yaml
|
||||
# user: "0:0" # root to allow chown+drop inside
|
||||
@@ -41,12 +51,13 @@ environment:
|
||||
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/).
|
||||
See docker-compose.yml for a full example (ADMIN_PORT=81 / proxy 80/443 via env; publishes 80/443; admin stays on loopback unless you set `ADMIN_HOST` / publish the admin port).
|
||||
|
||||
## 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`)
|
||||
- `data/.jwt_secret` (auto-generated admin API signing key if `JWT_SECRET` is unset)
|
||||
- etc.
|
||||
|
||||
## Status
|
||||
@@ -54,13 +65,13 @@ 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)
|
||||
- single admin (`ADMIN_PASSWORD` required in production; default `"password"` refused; 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).
|
||||
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). Real LE will be used otherwise (requires valid email, port 80 reachable etc).
|
||||
|
||||
## Development
|
||||
- `make ui-build` (once real Svelte UI added to ui/)
|
||||
|
||||
+80
-17
@@ -5,6 +5,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"helix-proxy/internal/auth"
|
||||
"helix-proxy/internal/certificate"
|
||||
@@ -46,6 +47,7 @@ func streamsAPIResponseFrom(st store.Store, eng *proxy.Engine) []streamAPIRespon
|
||||
func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.Manager, jwtMgr *auth.JWTManager) {
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Use(jwtMgr.Middleware)
|
||||
r.Use(bootstrapLock(st))
|
||||
|
||||
// Health (mirrors original /api style)
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -106,7 +108,7 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M
|
||||
}
|
||||
_ = claims
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(st.GetAccessLists())
|
||||
_ = json.NewEncoder(w).Encode(redactAccessLists(st.GetAccessLists()))
|
||||
})
|
||||
r.Post("/access-lists", func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := auth.RequireAuth(w, r)
|
||||
@@ -121,13 +123,17 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M
|
||||
if payload.OwnerUserID == 0 {
|
||||
payload.OwnerUserID = claims.UserID
|
||||
}
|
||||
if err := hashAccessListPasswords(&payload); err != nil {
|
||||
http.Error(w, "password hash failed", 500)
|
||||
return
|
||||
}
|
||||
created, err := st.CreateAccessList(payload)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(created)
|
||||
_ = json.NewEncoder(w).Encode(redactAccessList(created))
|
||||
eng.ReloadFromStore()
|
||||
_, _ = st.AddAuditLog(store.AuditLog{
|
||||
UserID: claims.UserID,
|
||||
@@ -152,7 +158,7 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M
|
||||
return
|
||||
}
|
||||
if al, ok := st.GetAccessList(id); ok {
|
||||
_ = json.NewEncoder(w).Encode(al)
|
||||
_ = json.NewEncoder(w).Encode(redactAccessList(al))
|
||||
return
|
||||
}
|
||||
http.Error(w, "not found", 404)
|
||||
@@ -186,13 +192,29 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M
|
||||
payload.OwnerUserID = existing.OwnerUserID
|
||||
// preserve CreatedOn on update (server-set on create; do not lose on edit)
|
||||
payload.CreatedOn = existing.CreatedOn
|
||||
// Empty password on an item means "keep existing" when username matches.
|
||||
for i := range payload.Items {
|
||||
if payload.Items[i].Password != "" {
|
||||
continue
|
||||
}
|
||||
for _, prev := range existing.Items {
|
||||
if prev.Username == payload.Items[i].Username && prev.Password != "" {
|
||||
payload.Items[i].Password = prev.Password
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := hashAccessListPasswords(&payload); err != nil {
|
||||
http.Error(w, "password hash failed", 500)
|
||||
return
|
||||
}
|
||||
updated, err := st.UpdateAccessList(payload)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
eng.ReloadFromStore()
|
||||
_ = json.NewEncoder(w).Encode(updated)
|
||||
_ = json.NewEncoder(w).Encode(redactAccessList(updated))
|
||||
_, _ = st.AddAuditLog(store.AuditLog{
|
||||
UserID: claims.UserID,
|
||||
ObjectType: "access-list",
|
||||
@@ -1091,12 +1113,10 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M
|
||||
})
|
||||
|
||||
// Login for the single admin account (no registration, no multi-user).
|
||||
// Default password is "password". On first use (when no password has been set in DB yet),
|
||||
// login with "password" succeeds but the response includes mustChangePassword:true
|
||||
// (skipped in PROXY_MODE=development for local dev convenience).
|
||||
// After the admin sets a new password (via /users/me/password), it is bcrypt-hashed and stored in DB.
|
||||
// Subsequent logins use the stored hash. Email is ignored (always the built-in admin).
|
||||
// No 2FA/TOTP.
|
||||
// Until a non-default password is stored, the default password is accepted and the
|
||||
// JWT is a bootstrap token (mutating admin API other than change-password is 403).
|
||||
// After /users/me/password, subsequent logins use the stored bcrypt hash.
|
||||
// Email is ignored (always the built-in admin). No 2FA/TOTP.
|
||||
r.Post("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
Password string `json:"password"`
|
||||
@@ -1105,17 +1125,17 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
u, ok := st.GetUserByEmail("admin@example.com")
|
||||
u, ok := st.GetUserByEmail(adminEmail)
|
||||
if !ok {
|
||||
// fallback to seeded values if lookup fails
|
||||
u = store.User{ID: 1, Email: "admin@example.com", Name: "Admin", Roles: []string{"admin"}}
|
||||
u = store.User{ID: 1, Email: adminEmail, Name: "Admin", Roles: []string{"admin"}}
|
||||
}
|
||||
mustChange := false
|
||||
authed := false
|
||||
bootstrap := false
|
||||
if u.Password == "" {
|
||||
// initial / not yet set: default "password" is accepted
|
||||
if payload.Password == "password" {
|
||||
if payload.Password == defaultAdminPassword {
|
||||
authed = true
|
||||
bootstrap = true
|
||||
mustChange = !config.IsDevelopment()
|
||||
}
|
||||
} else if isBcryptPrefix(u.Password) {
|
||||
@@ -1123,14 +1143,19 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M
|
||||
authed = true
|
||||
}
|
||||
} else if u.Password == payload.Password {
|
||||
// legacy plain (should not happen after first change)
|
||||
authed = true
|
||||
}
|
||||
if !authed {
|
||||
http.Error(w, "invalid credentials", 401)
|
||||
return
|
||||
}
|
||||
token, err := jwtMgr.GenerateToken(u.ID, u.Email, u.Name, u.Roles)
|
||||
var token string
|
||||
var err error
|
||||
if bootstrap {
|
||||
token, err = jwtMgr.GenerateBootstrapToken(u.ID, u.Email, u.Name, u.Roles)
|
||||
} else {
|
||||
token, err = jwtMgr.GenerateToken(u.ID, u.Email, u.Name, u.Roles)
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "token error", 500)
|
||||
return
|
||||
@@ -1277,3 +1302,41 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func hashAccessListPasswords(al *store.AccessList) error {
|
||||
for i := range al.Items {
|
||||
pw := al.Items[i].Password
|
||||
if pw == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(pw, "$2a$") || strings.HasPrefix(pw, "$2b$") || strings.HasPrefix(pw, "$2y$") {
|
||||
continue
|
||||
}
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
al.Items[i].Password = string(h)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func redactAccessList(al store.AccessList) store.AccessList {
|
||||
out := al
|
||||
out.Items = make([]store.AccessItem, len(al.Items))
|
||||
copy(out.Items, al.Items)
|
||||
for i := range out.Items {
|
||||
if out.Items[i].Password != "" {
|
||||
out.Items[i].Password = ""
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func redactAccessLists(list []store.AccessList) []store.AccessList {
|
||||
out := make([]store.AccessList, len(list))
|
||||
for i, al := range list {
|
||||
out[i] = redactAccessList(al)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -118,11 +118,11 @@ func TestAPI_ErrorPaths(t *testing.T) {
|
||||
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,
|
||||
"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))
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
func (e *apiTestEnv) loginToken(t *testing.T) string {
|
||||
t.Helper()
|
||||
resp := apiPost(t, e.BaseURL+"/api/login", map[string]any{"password": "password"}, "")
|
||||
resp := apiPost(t, e.BaseURL+"/api/login", map[string]any{"password": testLoginPassword()}, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login: %d %s", resp.StatusCode, readBody(resp))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"helix-proxy/internal/auth"
|
||||
"helix-proxy/internal/config"
|
||||
"helix-proxy/internal/store"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
adminEmail = "admin@example.com"
|
||||
defaultAdminPassword = "password"
|
||||
testAdminPassword = "helix-test-admin"
|
||||
)
|
||||
|
||||
// applyInitialAdminPassword sets the admin hash from ADMIN_PASSWORD on first boot.
|
||||
// Production refuses to start if the password is still unset. Development may
|
||||
// leave the password empty (bootstrap lock) when ADMIN_PASSWORD is not set.
|
||||
func applyInitialAdminPassword(st store.Store) error {
|
||||
u, ok := st.GetUserByEmail(adminEmail)
|
||||
if !ok {
|
||||
return fmt.Errorf("admin user missing")
|
||||
}
|
||||
if u.Password != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
envPW := os.Getenv("ADMIN_PASSWORD")
|
||||
if envPW != "" {
|
||||
if envPW == defaultAdminPassword {
|
||||
return fmt.Errorf("ADMIN_PASSWORD cannot be the well-known default")
|
||||
}
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(envPW), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash ADMIN_PASSWORD: %w", err)
|
||||
}
|
||||
u.Password = string(h)
|
||||
if err := st.UpdateUser(u); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("admin password set from ADMIN_PASSWORD")
|
||||
return nil
|
||||
}
|
||||
|
||||
if config.IsDevelopment() {
|
||||
slog.Warn("admin password unset; bootstrap lock active until a non-default password is stored")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("ADMIN_PASSWORD is required on first boot; the default password is not permitted")
|
||||
}
|
||||
|
||||
func testLoginPassword() string {
|
||||
if p := os.Getenv("ADMIN_PASSWORD"); p != "" {
|
||||
return p
|
||||
}
|
||||
return defaultAdminPassword
|
||||
}
|
||||
|
||||
func adminPasswordUnset(st store.Store) bool {
|
||||
u, ok := st.GetUserByEmail(adminEmail)
|
||||
return !ok || u.Password == ""
|
||||
}
|
||||
|
||||
func bootstrapPathAllowed(r *http.Request) bool {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return true
|
||||
}
|
||||
p := strings.TrimSuffix(r.URL.Path, "/")
|
||||
if r.Method == http.MethodPost && (p == "/api/login" || p == "/api/users/me/password") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// bootstrapLock rejects mutating admin API (except login + change-password)
|
||||
// while the default password is still in effect, or when the JWT is a bootstrap token.
|
||||
func bootstrapLock(st store.Store) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if bootstrapPathAllowed(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
claims, ok := auth.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if claims.Bootstrap || adminPasswordUnset(st) {
|
||||
http.Error(w, "password change required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"helix-proxy/internal/config"
|
||||
"helix-proxy/internal/store"
|
||||
)
|
||||
|
||||
func newBootstrapAPIServer(t *testing.T) *apiTestEnv {
|
||||
t.Helper()
|
||||
t.Setenv("PROXY_MODE", "development")
|
||||
t.Setenv("ADMIN_PASSWORD", "")
|
||||
return newAPITestServer(t)
|
||||
}
|
||||
|
||||
func TestBootstrapLock_MutatingAdminForbidden(t *testing.T) {
|
||||
env := newBootstrapAPIServer(t)
|
||||
resp := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword}, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("bootstrap login: %d %s", resp.StatusCode, readBody(resp))
|
||||
}
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
decodeJSON(t, resp, &out)
|
||||
if out.Token == "" {
|
||||
t.Fatal("empty bootstrap token")
|
||||
}
|
||||
tok := out.Token
|
||||
|
||||
mutating := []struct {
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}{
|
||||
{http.MethodPost, "/api/proxy-hosts", map[string]any{
|
||||
"domainNames": []string{"boot.example"},
|
||||
"forwardHost": "127.0.0.1",
|
||||
"forwardPort": 8080,
|
||||
"forwardScheme": "http",
|
||||
}},
|
||||
{http.MethodPost, "/api/settings", map[string]any{"default_site": "404"}},
|
||||
{http.MethodPost, "/api/access-lists", map[string]any{"name": "boot"}},
|
||||
{http.MethodPost, "/api/streams", map[string]any{"incomingPort": 9000, "forwardingHost": "127.0.0.1", "forwardingPort": 9001}},
|
||||
}
|
||||
for _, tc := range mutating {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
r := apiRequest(t, tc.method, env.BaseURL+tc.path, tok, tc.body)
|
||||
assertStatus(t, r, http.StatusForbidden)
|
||||
})
|
||||
}
|
||||
|
||||
assertStatus(t, apiGet(t, env.BaseURL+"/api/users/me", tok), http.StatusOK)
|
||||
assertStatus(t, apiGet(t, env.BaseURL+"/api/proxy-hosts", tok), http.StatusOK)
|
||||
}
|
||||
|
||||
func TestBootstrapLock_ChangePasswordThenDefaultFails(t *testing.T) {
|
||||
env := newBootstrapAPIServer(t)
|
||||
resp := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword}, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("bootstrap login: %d %s", resp.StatusCode, readBody(resp))
|
||||
}
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
decodeJSON(t, resp, &out)
|
||||
|
||||
ch := apiPost(t, env.BaseURL+"/api/users/me/password", map[string]any{
|
||||
"currentPassword": defaultAdminPassword,
|
||||
"newPassword": "changed-after-bootstrap",
|
||||
}, out.Token)
|
||||
assertStatus(t, ch, http.StatusOK)
|
||||
|
||||
fail := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword}, "")
|
||||
assertStatus(t, fail, http.StatusUnauthorized)
|
||||
|
||||
okLogin := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": "changed-after-bootstrap"}, "")
|
||||
if okLogin.StatusCode != http.StatusOK {
|
||||
t.Fatalf("new password login: %d %s", okLogin.StatusCode, readBody(okLogin))
|
||||
}
|
||||
var out2 struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
decodeJSON(t, okLogin, &out2)
|
||||
create := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
|
||||
"domainNames": []string{"after-change.example"},
|
||||
"forwardHost": "127.0.0.1",
|
||||
"forwardPort": 8080,
|
||||
"forwardScheme": "http",
|
||||
}, out2.Token)
|
||||
if create.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("full token after change should mutate: %d %s", create.StatusCode, readBody(create))
|
||||
}
|
||||
_ = create.Body.Close()
|
||||
|
||||
locked := apiPost(t, env.BaseURL+"/api/settings", map[string]any{"default_site": "404"}, out.Token)
|
||||
assertStatus(t, locked, http.StatusForbidden)
|
||||
}
|
||||
|
||||
func TestDevTestsBootWithAdminPassword(t *testing.T) {
|
||||
t.Setenv("PROXY_MODE", "development")
|
||||
env := newAPITestServer(t)
|
||||
if adminPasswordUnset(env.Store) {
|
||||
t.Fatal("test env should store ADMIN_PASSWORD hash")
|
||||
}
|
||||
token := env.loginToken(t)
|
||||
create := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
|
||||
"domainNames": []string{"dev-admin.example"},
|
||||
"forwardHost": "127.0.0.1",
|
||||
"forwardPort": 8080,
|
||||
"forwardScheme": "http",
|
||||
}, token)
|
||||
if create.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("dev ADMIN_PASSWORD token should be fully privileged: %d %s", create.StatusCode, readBody(create))
|
||||
}
|
||||
_ = create.Body.Close()
|
||||
|
||||
fail := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword}, "")
|
||||
assertStatus(t, fail, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func TestApplyInitialAdminPassword_RejectsDefault(t *testing.T) {
|
||||
t.Setenv("PROXY_MODE", "development")
|
||||
t.Setenv("ADMIN_PASSWORD", defaultAdminPassword)
|
||||
tmp := t.TempDir()
|
||||
config.ResetForTest()
|
||||
t.Setenv("DATA_DIR", tmp)
|
||||
if err := config.EnsureDataDirs(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := store.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c, ok := st.(interface{ Close() error }); ok {
|
||||
t.Cleanup(func() { _ = c.Close() })
|
||||
}
|
||||
err = applyInitialAdminPassword(st)
|
||||
if err == nil || !strings.Contains(err.Error(), "default") {
|
||||
t.Fatalf("expected default ADMIN_PASSWORD error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyInitialAdminPassword_ProductionRequiresEnv(t *testing.T) {
|
||||
t.Setenv("PROXY_MODE", "production")
|
||||
t.Setenv("ADMIN_PASSWORD", "")
|
||||
tmp := t.TempDir()
|
||||
config.ResetForTest()
|
||||
t.Setenv("DATA_DIR", tmp)
|
||||
if err := config.EnsureDataDirs(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := store.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c, ok := st.(interface{ Close() error }); ok {
|
||||
t.Cleanup(func() { _ = c.Close() })
|
||||
}
|
||||
err = applyInitialAdminPassword(st)
|
||||
if err == nil || !strings.Contains(err.Error(), "ADMIN_PASSWORD") {
|
||||
t.Fatalf("expected refuse without ADMIN_PASSWORD, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -21,15 +21,18 @@ func applyUmaskFromEnv() {
|
||||
}
|
||||
|
||||
// adminListenAddr resolves the admin API/UI listen address from env.
|
||||
// Defaults to loopback so the admin port is not published on all interfaces.
|
||||
// Override host with ADMIN_HOST (e.g. 0.0.0.0) when the UI must be reachable off-box.
|
||||
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
|
||||
host := os.Getenv("ADMIN_HOST")
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
return net.JoinHostPort("", port)
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
// proxyHTTPSListenAddr resolves the TLS proxy listen address from env.
|
||||
|
||||
@@ -11,27 +11,27 @@ func TestAdminListenAddr(t *testing.T) {
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
name: "defaults ipv6",
|
||||
name: "defaults loopback",
|
||||
env: map[string]string{},
|
||||
expect: ":8081",
|
||||
expect: "127.0.0.1:8081",
|
||||
},
|
||||
{
|
||||
name: "custom port",
|
||||
env: map[string]string{"ADMIN_PORT": "9090"},
|
||||
expect: ":9090",
|
||||
expect: "127.0.0.1:9090",
|
||||
},
|
||||
{
|
||||
name: "disable ipv6",
|
||||
name: "disable ipv6 still loopback",
|
||||
env: map[string]string{
|
||||
"DISABLE_IPV6": "1",
|
||||
},
|
||||
expect: "0.0.0.0:8081",
|
||||
expect: "127.0.0.1:8081",
|
||||
},
|
||||
{
|
||||
name: "custom port ipv4 only",
|
||||
name: "ADMIN_HOST override",
|
||||
env: map[string]string{
|
||||
"ADMIN_PORT": "3000",
|
||||
"DISABLE_IPV6": "1",
|
||||
"ADMIN_HOST": "0.0.0.0",
|
||||
"ADMIN_PORT": "3000",
|
||||
},
|
||||
expect: "0.0.0.0:3000",
|
||||
},
|
||||
@@ -39,6 +39,7 @@ func TestAdminListenAddr(t *testing.T) {
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("ADMIN_PORT", "")
|
||||
t.Setenv("ADMIN_HOST", "")
|
||||
t.Setenv("DISABLE_IPV6", "")
|
||||
for k, v := range tc.env {
|
||||
t.Setenv(k, v)
|
||||
|
||||
@@ -60,8 +60,9 @@ func TestIsAdminCanManage(t *testing.T) {
|
||||
|
||||
func TestSingleAdminLoginDevModeSkipsMustChange(t *testing.T) {
|
||||
t.Setenv("PROXY_MODE", "development")
|
||||
t.Setenv("ADMIN_PASSWORD", "")
|
||||
env := newAPITestServer(t)
|
||||
resp := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "password"})
|
||||
resp := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("default login failed: %d", resp.StatusCode)
|
||||
}
|
||||
@@ -76,12 +77,13 @@ func TestSingleAdminLoginDevModeSkipsMustChange(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSingleAdminLogin(t *testing.T) {
|
||||
t.Setenv("PROXY_MODE", "")
|
||||
// Bootstrap path is development-only; production refuses to start without ADMIN_PASSWORD.
|
||||
t.Setenv("PROXY_MODE", "development")
|
||||
t.Setenv("ADMIN_PASSWORD", "")
|
||||
env := newAPITestServer(t)
|
||||
st := env.Store
|
||||
|
||||
// initial default "password" login returns mustChangePassword
|
||||
resp := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "password"})
|
||||
resp := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("default login failed: %d", resp.StatusCode)
|
||||
}
|
||||
@@ -91,9 +93,12 @@ func TestSingleAdminLogin(t *testing.T) {
|
||||
MustChangePassword bool `json:"mustChangePassword"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&lr)
|
||||
if lr.Token == "" || lr.User["id"] != float64(1) || !lr.MustChangePassword {
|
||||
if lr.Token == "" || lr.User["id"] != float64(1) {
|
||||
t.Errorf("default login resp bad: %+v", lr)
|
||||
}
|
||||
if lr.MustChangePassword {
|
||||
t.Error("development bootstrap login should not set mustChangePassword")
|
||||
}
|
||||
|
||||
// wrong default fails
|
||||
respBad := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "wrong"})
|
||||
@@ -104,11 +109,16 @@ func TestSingleAdminLogin(t *testing.T) {
|
||||
// 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)
|
||||
chResp := postJSONAuth(t, env.BaseURL+"/api/users/me/password", map[string]any{"currentPassword": defaultAdminPassword, "newPassword": "newpass123"}, authH)
|
||||
if chResp.StatusCode != 200 {
|
||||
t.Fatalf("initial pw change failed: %d", chResp.StatusCode)
|
||||
}
|
||||
|
||||
respOld := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword})
|
||||
if respOld.StatusCode != 401 {
|
||||
t.Errorf("default password after change should 401: %d", respOld.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 {
|
||||
|
||||
@@ -40,12 +40,19 @@ func run(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyInitialAdminPassword(st); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
eng := proxy.NewEngine(st)
|
||||
eng.ReloadFromStore()
|
||||
|
||||
cm := certificate.NewManager(st)
|
||||
jwtMgr := auth.NewJWTManager(os.Getenv("JWT_SECRET"))
|
||||
jwtSecret, err := auth.LoadOrCreateSecret(config.DataDir())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jwtMgr := auth.NewJWTManager(jwtSecret)
|
||||
|
||||
go startRenewalLoop(ctx, cm, eng)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -70,3 +71,70 @@ func TestRun_EnsureDataDirsFailure(t *testing.T) {
|
||||
t.Fatal("expected error when data dirs cannot be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_ProductionRefusesDefaultPassword(t *testing.T) {
|
||||
t.Setenv("PROXY_MODE", "production")
|
||||
t.Setenv("ADMIN_PASSWORD", "")
|
||||
t.Setenv("DISABLE_IPV6", "1")
|
||||
t.Setenv("RENEWAL_INITIAL_DELAY", "1h")
|
||||
|
||||
tmp := t.TempDir()
|
||||
config.ResetForTest()
|
||||
t.Setenv("DATA_DIR", tmp)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
err := run(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-dev first boot without ADMIN_PASSWORD to refuse to start")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ADMIN_PASSWORD") {
|
||||
t.Fatalf("error should mention ADMIN_PASSWORD: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_ProductionWithAdminPasswordStarts(t *testing.T) {
|
||||
t.Setenv("PROXY_MODE", "production")
|
||||
t.Setenv("ADMIN_PASSWORD", "prod-admin-not-default")
|
||||
t.Setenv("DISABLE_IPV6", "1")
|
||||
t.Setenv("RENEWAL_INITIAL_DELAY", "1h")
|
||||
|
||||
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)
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
type testServerMode int
|
||||
|
||||
const (
|
||||
testServerAPI testServerMode = iota
|
||||
testServerAPI testServerMode = iota
|
||||
testServerAdmin
|
||||
)
|
||||
|
||||
@@ -37,6 +37,9 @@ func newTestServer(t *testing.T, mode testServerMode) *apiTestEnv {
|
||||
if _, ok := os.LookupEnv("PROXY_MODE"); !ok {
|
||||
t.Setenv("PROXY_MODE", "development")
|
||||
}
|
||||
if _, ok := os.LookupEnv("ADMIN_PASSWORD"); !ok {
|
||||
t.Setenv("ADMIN_PASSWORD", testAdminPassword)
|
||||
}
|
||||
tmp := t.TempDir()
|
||||
config.ResetForTest()
|
||||
t.Setenv("DATA_DIR", tmp)
|
||||
@@ -47,6 +50,9 @@ func newTestServer(t *testing.T, mode testServerMode) *apiTestEnv {
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
if err := applyInitialAdminPassword(st); err != nil {
|
||||
t.Fatalf("admin password: %v", err)
|
||||
}
|
||||
eng := proxy.NewEngine(st)
|
||||
eng.ReloadFromStore()
|
||||
cm := certificate.NewManager(st)
|
||||
|
||||
+14
-8
@@ -5,19 +5,25 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "81:81"
|
||||
# Admin binds 127.0.0.1; do not publish :81 on untrusted networks.
|
||||
# - "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)
|
||||
environment:
|
||||
- ADMIN_PORT=81
|
||||
- PROXY_HTTP_PORT=80
|
||||
- PROXY_HTTPS_PORT=443
|
||||
# ADMIN_HOST defaults to 127.0.0.1 (healthcheck hits 127.0.0.1:81)
|
||||
# - JWT_SECRET= # optional; otherwise a random secret is stored in data/.jwt_secret
|
||||
# - 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module helix-proxy
|
||||
|
||||
go 1.25.4
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
github.com/go-acme/lego/v4 v4.35.2
|
||||
|
||||
+72
-11
@@ -2,9 +2,13 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -18,10 +22,11 @@ var (
|
||||
|
||||
// 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"`
|
||||
UserID int `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Roles []string `json:"roles"`
|
||||
Bootstrap bool `json:"bootstrap,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -35,26 +40,82 @@ 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.
|
||||
const jwtSecretFilename = ".jwt_secret"
|
||||
|
||||
// LoadOrCreateSecret returns JWT_SECRET from the environment if set, otherwise
|
||||
// a per-install secret persisted at dir/.jwt_secret (created on first run).
|
||||
func LoadOrCreateSecret(dir string) (string, error) {
|
||||
if s := strings.TrimSpace(os.Getenv("JWT_SECRET")); s != "" {
|
||||
return s, nil
|
||||
}
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return "", fmt.Errorf("jwt secret directory is required when JWT_SECRET is unset")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("create jwt secret dir: %w", err)
|
||||
}
|
||||
path := filepath.Join(dir, jwtSecretFilename)
|
||||
if b, err := os.ReadFile(path); err == nil {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if s != "" {
|
||||
return s, nil
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("read jwt secret: %w", err)
|
||||
}
|
||||
s, err := randomSecret()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(s+"\n"), 0o600); err != nil {
|
||||
return "", fmt.Errorf("write jwt secret: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func randomSecret() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("generate jwt secret: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// NewJWTManager creates a manager. Pass a secret from LoadOrCreateSecret or JWT_SECRET.
|
||||
// An empty secret is replaced with a random in-memory value (tokens will not survive restart).
|
||||
func NewJWTManager(secret string) *JWTManager {
|
||||
if secret == "" {
|
||||
secret = "dev-only-insecure-secret-change-in-prod"
|
||||
s, err := randomSecret()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
secret = s
|
||||
}
|
||||
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) {
|
||||
return m.signToken(userID, email, name, roles, false)
|
||||
}
|
||||
|
||||
// GenerateBootstrapToken issues a JWT that cannot call mutating admin APIs
|
||||
// until the default password has been changed.
|
||||
func (m *JWTManager) GenerateBootstrapToken(userID int, email, name string, roles []string) (string, error) {
|
||||
return m.signToken(userID, email, name, roles, true)
|
||||
}
|
||||
|
||||
func (m *JWTManager) signToken(userID int, email, name string, roles []string, bootstrap bool) (string, error) {
|
||||
now := time.Now()
|
||||
if roles == nil {
|
||||
roles = []string{}
|
||||
}
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Name: name,
|
||||
Roles: roles,
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Name: name,
|
||||
Roles: roles,
|
||||
Bootstrap: bootstrap,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(1 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -30,12 +32,79 @@ func TestJWTManager_GenerateValidate_Roundtrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTManager_DevSecretFallback(t *testing.T) {
|
||||
m := NewJWTManager("") // empty -> dev
|
||||
func TestJWTManager_BootstrapClaim(t *testing.T) {
|
||||
m := NewJWTManager("test-secret-123")
|
||||
token, err := m.GenerateBootstrapToken(1, "admin.com", "Admin", []string{"admin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claims, err := m.ValidateToken(token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !claims.Bootstrap {
|
||||
t.Error("bootstrap token missing claim")
|
||||
}
|
||||
full, err := m.GenerateToken(1, "admin.com", "Admin", []string{"admin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fullClaims, err := m.ValidateToken(full)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fullClaims.Bootstrap {
|
||||
t.Error("normal token should not be bootstrap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTManager_EmptySecretRandomRoundtrip(t *testing.T) {
|
||||
m := NewJWTManager("") // empty -> random in-memory secret
|
||||
token, _ := m.GenerateToken(1, "a@b", "A", nil)
|
||||
_, err := m.ValidateToken(token)
|
||||
if err != nil {
|
||||
t.Error("dev secret should allow roundtrip")
|
||||
t.Error("random secret should allow roundtrip")
|
||||
}
|
||||
m2 := NewJWTManager("")
|
||||
if _, err := m2.ValidateToken(token); err == nil {
|
||||
t.Error("separate empty managers must not share a well-known secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreateSecret_EnvWins(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", "from-env")
|
||||
got, err := LoadOrCreateSecret(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "from-env" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreateSecret_Persists(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", "")
|
||||
dir := t.TempDir()
|
||||
a, err := LoadOrCreateSecret(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a == "" {
|
||||
t.Fatal("empty secret")
|
||||
}
|
||||
b, err := LoadOrCreateSecret(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a != b {
|
||||
t.Fatalf("secret not persisted: %q vs %q", a, b)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, ".jwt_secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) == "" {
|
||||
t.Fatal("secret file empty")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
@@ -95,8 +96,8 @@ func NewEngine(st store.Store) *Engine {
|
||||
return &Engine{
|
||||
hosts: make(map[string]*Host),
|
||||
st: st,
|
||||
streamHandles: make(map[int]*streamHandle),
|
||||
streamRuntime: make(map[int]*streamRuntime),
|
||||
streamHandles: make(map[int]*streamHandle),
|
||||
streamRuntime: make(map[int]*streamRuntime),
|
||||
hostCerts: make(map[string]*tls.Certificate),
|
||||
certByID: make(map[int]*tls.Certificate),
|
||||
redirHosts: make(map[string]store.RedirectionHost),
|
||||
@@ -349,7 +350,7 @@ func (e *Engine) Handler() http.Handler {
|
||||
user, pass := creds[0], creds[1]
|
||||
authed := false
|
||||
for _, item := range hcfg.AccessItems {
|
||||
if item.Username == user && item.Password == pass { // demo: plain compare; real would hash
|
||||
if item.Username == user && accessListPasswordOK(item.Password, pass) {
|
||||
authed = true
|
||||
break
|
||||
}
|
||||
@@ -1250,3 +1251,14 @@ func parseAdvancedConfig(adv string) (reqSets map[string]string, respAdds map[st
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func accessListPasswordOK(stored, provided string) bool {
|
||||
if stored == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(stored, "$2a$") || strings.HasPrefix(stored, "$2b$") || strings.HasPrefix(stored, "$2y$") {
|
||||
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(provided)) == nil
|
||||
}
|
||||
// Legacy plaintext rows until re-saved via admin API.
|
||||
return stored == provided
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
)
|
||||
|
||||
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")
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user