Compare commits

...

24 Commits

Author SHA1 Message Date
eva 084d661200 access-list: Hash basic-auth passwords and redact API reads (#18)
Format / gofmt (push) Successful in 20s
CI / Go Tests (push) Successful in 1m16s
CI / Build (push) Successful in 34s
Closes #17.

- bcrypt-hash access-list item passwords on create/update
- redact password fields on API reads
- legacy plaintext still authenticates until re-saved
- gofmt api.go and engine.go
2026-09-14 08:41:00 -05:00
blake 3087968463 format: gofmt api.go and engine.go
Format / gofmt (pull_request) Successful in 19s
Format / gofmt (push) Successful in 20s
CI / Build (push) Successful in 29s
CI / Build (pull_request) Successful in 31s
CI / Go Tests (push) Successful in 1m12s
CI / Go Tests (pull_request) Successful in 1m16s
2026-09-14 13:38:52 +00:00
Blake 61c7b5582c access-list: Hash basic-auth passwords and redact API reads
Format / gofmt (pull_request) Failing after 15s
Format / gofmt (push) Failing after 15s
CI / Build (push) Successful in 36s
CI / Build (pull_request) Successful in 37s
CI / Go Tests (pull_request) Successful in 1m20s
CI / Go Tests (push) Failing after 1m20s
Access-list item passwords were stored and returned in plaintext.
Hash on write with bcrypt, compare hashes in the proxy engine (with
legacy plaintext fallback), and omit passwords from admin GET JSON.
2026-09-14 13:32:31 +00:00
eva 646904ff0a helix-proxy: Promote develop to master
Format / gofmt (pull_request) Successful in 16s
CI / Build (pull_request) Successful in 24s
Format / gofmt (push) Successful in 14s
CI / Go Tests (pull_request) Successful in 1m0s
CI / Build (push) Successful in 25s
CI / Go Tests (push) Successful in 1m0s
helix-proxy: Promote develop to master
2026-09-02 13:28:38 -05:00
eva 8b334900e8 merge: Sync develop with master after compose promote
Format / gofmt (push) Successful in 13s
CI / Build (push) Successful in 23s
CI / Go Tests (push) Successful in 59s
Format / gofmt (pull_request) Successful in 15s
CI / Build (pull_request) Successful in 23s
CI / Go Tests (pull_request) Successful in 57s
Trees already matched; histories diverged after dual promote.
Unifies tips so dual-branch layout is coherent again.
2026-09-02 18:17:39 +00:00
s1d3sw1ped_bot a1433be417 docker: Promote compose port align to master
Format / gofmt (push) Successful in 13s
CI / Build (push) Successful in 22s
CI / Go Tests (push) Successful in 57s
Promote develop compose/README port align (ADMIN_PORT=81) to master.

Resolved README conflict: kept develop port/env docs and master ADMIN_PASSWORD usage bits. Compose Option A env retained.

Do not delete develop.

Co-authored-by: s1d3sw1ped_bot <s1d3sw1ped+giteabot@gmail.com>
Co-committed-by: s1d3sw1ped_bot <s1d3sw1ped+giteabot@gmail.com>
2026-09-02 11:41:19 -05:00
s1d3sw1ped_bot ac295ec330 docker: Promote compose port align to master
Format / gofmt (push) Successful in 14s
CI / Build (push) Successful in 25s
CI / Go Tests (push) Successful in 58s
Land develop compose/README port align (PR #11 / a6e0693) onto master.

Resolves README divergence vs master ADMIN_PASSWORD docs; keeps ADMIN_PORT=81 healthcheck align. Temporary head branch only — develop retained.

Co-authored-by: s1d3sw1ped_bot <s1d3sw1ped+giteabot@gmail.com>
Co-committed-by: s1d3sw1ped_bot <s1d3sw1ped+giteabot@gmail.com>
2026-09-02 11:39:36 -05:00
s1d3sw1ped_bot 6500d23f07 docs: Keep ADMIN_PASSWORD in docker run example
Format / gofmt (push) Successful in 14s
CI / Build (push) Successful in 27s
Format / gofmt (pull_request) Successful in 15s
CI / Build (pull_request) Successful in 25s
CI / Go Tests (push) Successful in 1m0s
CI / Go Tests (pull_request) Successful in 1m1s
Preserve master boot-rule guidance alongside compose port align.
2026-09-02 16:37:20 +00:00
s1d3sw1ped_bot d7b7541dd1 docs: Finish README conflict resolution for promote
Keep master ADMIN_PASSWORD / binary defaults; adopt develop compose
port align (ADMIN_PORT=81, admin unpublished by default).
2026-09-02 16:37:06 +00:00
s1d3sw1ped_bot 6aa0095059 merge: Resolve develop→master compose port align
Bring docker-compose ADMIN_PORT=81 healthcheck align from develop
while keeping master README ADMIN_PASSWORD / boot rules and not
publishing admin :81 by default.
2026-09-02 16:36:40 +00:00
s1d3sw1ped_bot a6e0693409 docker: Align compose healthcheck with admin port
Format / gofmt (push) Successful in 14s
CI / Build (push) Successful in 22s
CI / Go Tests (push) Successful in 57s
Format / gofmt (pull_request) Successful in 14s
CI / Build (pull_request) Successful in 26s
CI / Go Tests (pull_request) Successful in 58s
Set compose ADMIN_PORT/PROXY_* to 81/80/443 so healthcheck and EXPOSE match;
document binary defaults vs Docker ports in README.

Fixes #10
2026-09-02 11:31:39 -05:00
s1d3sw1ped_bot ac98b8e942 README: Fix default ports and ADMIN_PASSWORD boot rules
Format / gofmt (push) Successful in 15s
CI / Build (push) Successful in 30s
CI / Go Tests (push) Successful in 1m4s
Docs sync: align README with current ports/password rules.

Co-authored-by: s1d3sw1ped_bot <s1d3sw1ped+giteabot@gmail.com>
Co-committed-by: s1d3sw1ped_bot <s1d3sw1ped+giteabot@gmail.com>
2026-09-02 10:44:13 -05:00
s1d3sw1ped_bot 83c4f4163e docs: Add CONTRIBUTING.md
Format / gofmt (push) Successful in 16s
CI / Build (push) Successful in 28s
CI / Go Tests (push) Successful in 1m3s
Add short contribution guide on the default branch.

Prefer develop for future PRs; keep commit subjects in area: Imperative form.
2026-09-01 13:55:52 -05:00
s1d3sw1ped_bot 549fdd8171 docs: Add CONTRIBUTING.md
Format / gofmt (push) Successful in 19s
Format / gofmt (pull_request) Successful in 20s
CI / Build (pull_request) Successful in 29s
CI / Build (push) Successful in 30s
CI / Go Tests (pull_request) Successful in 1m3s
CI / Go Tests (push) Successful in 1m5s
Point new contributors at develop for PRs, kernel-style commit
subjects, What/Why/Test PR bodies, and safe issue citations under
Gitea merge-text closing rules.
2026-09-01 18:54:34 +00:00
s1d3sw1ped_bot 6615cc218e docs: Add CONTRIBUTING.md
Format / gofmt (push) Successful in 16s
CI / Build (push) Successful in 23s
CI / Go Tests (push) Successful in 59s
Contributors need a short guide for develop-targeted PRs, commit
subject form, and Gitea issue-closing rules.
2026-09-01 13:37:24 -05:00
s1d3sw1ped_bot 097906f5b5 Lock bootstrap admin token until password is changed (#6)
Format / gofmt (push) Successful in 17s
CI / Build (push) Successful in 28s
CI / Go Tests (push) Successful in 1m2s
Closes #2.
2026-08-31 22:57:37 -05:00
s1d3sw1ped_bot 13070a275d Lock bootstrap admin token until password is changed.
Format / gofmt (push) Successful in 7s
CI / Build (push) Successful in 14s
Format / gofmt (pull_request) Successful in 7s
CI / Build (pull_request) Successful in 13s
CI / Go Tests (push) Successful in 50s
CI / Go Tests (pull_request) Successful in 48s
Closes #2: bootstrap JWTs cannot mutate admin APIs except change-password, production requires ADMIN_PASSWORD on first boot, admin binds loopback.
2026-09-01 03:54:09 +00:00
s1d3sw1ped_bot 6aabb76c16 Bump Go to 1.27.0 (#5)
Format / gofmt (push) Successful in 7s
CI / Build (push) Successful in 15s
CI / Go Tests (push) Successful in 21s
2026-08-31 20:08:49 -05:00
s1d3sw1ped_bot 0b9db3a4c0 Add MIT LICENSE (#4)
Format / gofmt (push) Successful in 8s
CI / Build (push) Successful in 14s
CI / Go Tests (push) Successful in 29s
Copied from steamcache2.
2026-08-31 19:06:03 -05:00
s1d3sw1ped_bot 17c445ec69 Add MIT LICENSE.
Format / gofmt (push) Successful in 10s
Format / gofmt (pull_request) Successful in 10s
CI / Build (push) Successful in 15s
CI / Go Tests (push) Successful in 32s
CI / Build (pull_request) Successful in 14s
CI / Go Tests (pull_request) Successful in 27s
2026-09-01 00:03:28 +00:00
s1d3sw1ped_bot a43217b459 Persist per-install JWT signing secret (#3)
Format / gofmt (push) Successful in 8s
CI / Build (push) Successful in 15s
CI / Go Tests (push) Successful in 28s
Drop compiled-in JWT fallback. Use JWT_SECRET or data/.jwt_secret.
2026-08-31 18:57:55 -05:00
s1d3sw1ped_bot 22844a2a67 Persist a per-install JWT signing secret instead of a compiled-in default.
Format / gofmt (push) Successful in 8s
Format / gofmt (pull_request) Successful in 9s
CI / Build (push) Successful in 24s
CI / Build (pull_request) Successful in 25s
CI / Go Tests (pull_request) Successful in 39s
CI / Go Tests (push) Successful in 40s
Admin tokens were forgeable whenever JWT_SECRET was unset. Prefer the env var, otherwise write a random key to data/.jwt_secret.
2026-08-31 23:55:34 +00:00
s1d3sw1ped 466d69c44c make fmt
Format / gofmt (push) Successful in 7s
CI / Build (push) Successful in 14s
CI / Go Tests (push) Successful in 28s
2026-06-08 00:39:41 -05:00
s1d3sw1ped abb0256f0b add fmt and test-both to makefile to simplify tests and formatting 2026-06-08 00:39:28 -05:00
24 changed files with 756 additions and 86 deletions
+2 -2
View File
@@ -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
+1
View File
@@ -40,6 +40,7 @@ logs/
*.sqlite-wal
# Keys / secrets
.jwt_secret
keys.json
*.pem
*.key
+40
View File
@@ -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 ≤ ~7075 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
View File
@@ -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 ./
+21
View File
@@ -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.
+22 -2
View File
@@ -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)
+20 -9
View File
@@ -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
View File
@@ -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
}
+5 -5
View File
@@ -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))
+1 -1
View File
@@ -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))
}
+104
View File
@@ -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)
})
}
}
+167
View File
@@ -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)
}
}
+6 -3
View File
@@ -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.
+9 -8
View File
@@ -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)
+16 -6
View File
@@ -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 {
+8 -1
View File
@@ -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)
+68
View File
@@ -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")
}
}
+7 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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),
+72 -3
View File
@@ -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")
}
}
+15 -3
View File
@@ -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
}
+4 -4
View File
@@ -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 {