Files
helix-proxy/cmd/helix-proxy/api.go
T
s1d3sw1ped_bot 13070a275d
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
Lock bootstrap admin token until password is changed.
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

1284 lines
35 KiB
Go

package main
import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
"helix-proxy/internal/auth"
"helix-proxy/internal/certificate"
"helix-proxy/internal/config"
"helix-proxy/internal/proxy"
"helix-proxy/internal/store"
"helix-proxy/internal/version"
"github.com/go-chi/chi/v5"
"golang.org/x/crypto/bcrypt"
)
type streamAPIResponse struct {
store.Stream
Listening bool `json:"listening"`
ListenError string `json:"listenError,omitempty"`
}
func streamAPIResponseFrom(st store.Store, eng *proxy.Engine, s store.Stream) streamAPIResponse {
out := streamAPIResponse{Stream: s}
if s.Enabled {
st := eng.StreamStatus(s.IncomingPort)
out.Listening = st.Listening
out.ListenError = st.ListenError
}
return out
}
func streamsAPIResponseFrom(st store.Store, eng *proxy.Engine) []streamAPIResponse {
streams := st.GetStreams()
out := make([]streamAPIResponse, len(streams))
for i, s := range streams {
out[i] = streamAPIResponseFrom(st, eng, s)
}
return out
}
// mountAPI registers all /api routes on r.
func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.Manager, jwtMgr *auth.JWTManager) {
r.Route("/api", func(r chi.Router) {
r.Use(jwtMgr.Middleware)
r.Use(bootstrapLock(st))
// Health (mirrors original /api style)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
major, minor, revision := version.Parts()
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "OK",
"setup": st.IsSetup(),
"version": map[string]int{"major": major, "minor": minor, "revision": revision},
})
})
r.Get("/proxy-hosts", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
_ = claims
w.Header().Set("Content-Type", "application/json")
hosts := st.GetProxyHosts()
_ = json.NewEncoder(w).Encode(hosts)
})
r.Post("/proxy-hosts", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
var payload store.ProxyHost
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), 400)
return
}
if payload.OwnerUserID == 0 {
payload.OwnerUserID = claims.UserID
}
created, err := st.CreateProxyHost(payload)
if err != nil {
writeStoreError(w, err)
return
}
eng.ReloadFromStore()
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(created)
// audit
_, _ = st.AddAuditLog(store.AuditLog{
UserID: claims.UserID,
ObjectType: "proxy-host",
ObjectID: created.ID,
Action: "created",
Meta: map[string]any{"domain_names": created.DomainNames},
})
})
r.Get("/access-lists", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
_ = claims
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(st.GetAccessLists())
})
r.Post("/access-lists", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
var payload store.AccessList
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), 400)
return
}
if payload.OwnerUserID == 0 {
payload.OwnerUserID = claims.UserID
}
created, err := st.CreateAccessList(payload)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(created)
eng.ReloadFromStore()
_, _ = st.AddAuditLog(store.AuditLog{
UserID: claims.UserID,
ObjectType: "access-list",
ObjectID: created.ID,
Action: "created",
Meta: map[string]any{"name": created.Name},
})
})
// full access list lifecycle (update/delete for complete parity)
r.Get("/access-lists/{id}", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
_ = claims // read is authed; no owner gate for GET (consistent with list GETs)
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
if al, ok := st.GetAccessList(id); ok {
_ = json.NewEncoder(w).Encode(al)
return
}
http.Error(w, "not found", 404)
})
r.Put("/access-lists/{id}", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
existing, ok := st.GetAccessList(id)
if !ok {
http.Error(w, "not found", 404)
return
}
if !canManage(claims, existing.OwnerUserID) {
http.Error(w, "forbidden: not owner or admin", 403)
return
}
var payload store.AccessList
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), 400)
return
}
payload.ID = id
payload.OwnerUserID = existing.OwnerUserID
// preserve CreatedOn on update (server-set on create; do not lose on edit)
payload.CreatedOn = existing.CreatedOn
updated, err := st.UpdateAccessList(payload)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
eng.ReloadFromStore()
_ = json.NewEncoder(w).Encode(updated)
_, _ = st.AddAuditLog(store.AuditLog{
UserID: claims.UserID,
ObjectType: "access-list",
ObjectID: updated.ID,
Action: "updated",
Meta: map[string]any{"name": updated.Name},
})
})
r.Delete("/access-lists/{id}", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
existing, ok := st.GetAccessList(id)
if !ok {
http.Error(w, "not found", 404)
return
}
if !canManage(claims, existing.OwnerUserID) {
http.Error(w, "forbidden: not owner or admin", 403)
return
}
if err := st.DeleteAccessList(id); err != nil {
http.Error(w, err.Error(), 500)
return
}
eng.ReloadFromStore()
w.WriteHeader(http.StatusNoContent)
_, _ = st.AddAuditLog(store.AuditLog{
UserID: claims.UserID,
ObjectType: "access-list",
ObjectID: id,
Action: "deleted",
Meta: map[string]any{},
})
})
r.Get("/streams", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
_ = claims
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(streamsAPIResponseFrom(st, eng))
})
r.Post("/streams", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
var payload store.Stream
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), 400)
return
}
if payload.OwnerUserID == 0 {
payload.OwnerUserID = claims.UserID
}
created, err := st.CreateStream(payload)
if err != nil {
writeStoreError(w, err)
return
}
eng.ReloadFromStore() // will handle streams too
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(streamAPIResponseFrom(st, eng, created))
_, _ = st.AddAuditLog(store.AuditLog{
UserID: claims.UserID,
ObjectType: "stream",
ObjectID: created.ID,
Action: "created",
Meta: map[string]any{"incoming_port": created.IncomingPort},
})
})
// full stream lifecycle
r.Put("/streams/{id}", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
existing, ok := st.GetStream(id)
if !ok {
http.Error(w, "not found", 404)
return
}
if !canManage(claims, existing.OwnerUserID) {
http.Error(w, "forbidden: not owner or admin", 403)
return
}
var payload store.Stream
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), 400)
return
}
payload.ID = id
payload.OwnerUserID = existing.OwnerUserID
// preserve CreatedOn on update (server-set on create; do not lose on edit)
payload.CreatedOn = existing.CreatedOn
updated, err := st.UpdateStream(payload)
if err != nil {
writeStoreError(w, err)
return
}
eng.ReloadFromStore()
_ = json.NewEncoder(w).Encode(streamAPIResponseFrom(st, eng, updated))
_, _ = st.AddAuditLog(store.AuditLog{
UserID: claims.UserID,
ObjectType: "stream",
ObjectID: updated.ID,
Action: "updated",
Meta: map[string]any{"incoming_port": updated.IncomingPort},
})
})
r.Delete("/streams/{id}", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
existing, ok := st.GetStream(id)
if !ok {
http.Error(w, "not found", 404)
return
}
if !canManage(claims, existing.OwnerUserID) {
http.Error(w, "forbidden: not owner or admin", 403)
return
}
if err := st.DeleteStream(id); err != nil {
http.Error(w, err.Error(), 500)
return
}
eng.ReloadFromStore()
w.WriteHeader(http.StatusNoContent)
_, _ = st.AddAuditLog(store.AuditLog{
UserID: claims.UserID,
ObjectType: "stream",
ObjectID: id,
Action: "deleted",
Meta: map[string]any{},
})
})
r.Get("/certificates", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
_ = claims
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(st.GetCertificates())
})
r.Post("/certificates", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
var payload store.Certificate
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), 400)
return
}
if payload.OwnerUserID == 0 {
payload.OwnerUserID = claims.UserID
}
created, err := st.CreateCertificate(payload)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
// If LE, perform issuance now.
// This will populate meta["cert"]/meta["key"] + expires_on and write files.
// Challenge tokens will be served automatically by the proxy Handler.
if created.Provider == "letsencrypt" {
email := cm.GetEmailForCert(created)
if ierr := cm.Issue(created, email); ierr != nil {
// mimic original: on failure remove the partial cert record
_ = st.DeleteCertificate(created.ID)
http.Error(w, "letsencrypt issuance failed: "+ierr.Error(), 500)
return
}
if c2, ok := st.GetCertificate(created.ID); ok {
created = c2
}
eng.ReloadFromStore()
} else {
// custom/other cert: load PEMs into engine for immediate SNI/https use
eng.ReloadFromStore()
}
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(created)
_, _ = st.AddAuditLog(store.AuditLog{
UserID: claims.UserID,
ObjectType: "certificate",
ObjectID: created.ID,
Action: "created",
Meta: map[string]any{"provider": created.Provider, "domain_names": created.DomainNames},
})
})
// Delete a certificate (removes from store; attached hosts will fall back)
r.Delete("/certificates/{id}", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
existing, ok := st.GetCertificate(id)
if !ok {
http.Error(w, "not found", 404)
return
}
if !canManage(claims, existing.OwnerUserID) {
http.Error(w, "forbidden: not owner or admin", 403)
return
}
if err := st.DeleteCertificate(id); err != nil {
http.Error(w, err.Error(), 500)
return
}
eng.ReloadFromStore()
w.WriteHeader(http.StatusNoContent)
_, _ = st.AddAuditLog(store.AuditLog{
UserID: claims.UserID,
ObjectType: "certificate",
ObjectID: id,
Action: "deleted",
Meta: map[string]any{},
})
})
// Renew (re-obtain) a certificate. Useful for manual trigger or after domain change.
r.Post("/certificates/{id}/renew", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
c, ok := st.GetCertificate(id)
if !ok {
http.Error(w, "not found", 404)
return
}
if !canManage(claims, c.OwnerUserID) {
http.Error(w, "forbidden: not owner or admin", 403)
return
}
email := cm.GetEmailForCert(c)
if err := cm.Issue(c, email); err != nil {
http.Error(w, "renewal failed: "+err.Error(), 500)
return
}
if c2, ok := st.GetCertificate(id); ok {
c = c2
}
eng.ReloadFromStore()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(c)
})
r.Get("/redirection-hosts", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
_ = claims
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(st.GetRedirectionHosts())
})
r.Post("/redirection-hosts", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
var payload store.RedirectionHost
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), 400)
return
}
if payload.OwnerUserID == 0 {
payload.OwnerUserID = claims.UserID
}
created, err := st.CreateRedirectionHost(payload)
if err != nil {
writeStoreError(w, err)
return
}
eng.ReloadFromStore()
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(created)
_, _ = st.AddAuditLog(store.AuditLog{
UserID: claims.UserID,
ObjectType: "redirection-host",
ObjectID: created.ID,
Action: "created",
Meta: map[string]any{"domain_names": created.DomainNames},
})
})
// full redir lifecycle
r.Put("/redirection-hosts/{id}", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
existing, ok := st.GetRedirectionHost(id)
if !ok {
http.Error(w, "not found", 404)
return
}
if !canManage(claims, existing.OwnerUserID) {
http.Error(w, "forbidden: not owner or admin", 403)
return
}
var payload store.RedirectionHost
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), 400)
return
}
payload.ID = id
payload.OwnerUserID = existing.OwnerUserID
// preserve CreatedOn on update (server-set on create; do not lose on edit)
payload.CreatedOn = existing.CreatedOn
updated, err := st.UpdateRedirectionHost(payload)
if err != nil {
writeStoreError(w, err)
return
}
eng.ReloadFromStore()
_ = json.NewEncoder(w).Encode(updated)
_, _ = st.AddAuditLog(store.AuditLog{
UserID: claims.UserID,
ObjectType: "redirection-host",
ObjectID: updated.ID,
Action: "updated",
Meta: map[string]any{"domain_names": updated.DomainNames},
})
})
r.Delete("/redirection-hosts/{id}", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
existing, ok := st.GetRedirectionHost(id)
if !ok {
http.Error(w, "not found", 404)
return
}
if !canManage(claims, existing.OwnerUserID) {
http.Error(w, "forbidden: not owner or admin", 403)
return
}
if err := st.DeleteRedirectionHost(id); err != nil {
http.Error(w, err.Error(), 500)
return
}
eng.ReloadFromStore()
w.WriteHeader(http.StatusNoContent)
_, _ = st.AddAuditLog(store.AuditLog{
UserID: claims.UserID,
ObjectType: "redirection-host",
ObjectID: id,
Action: "deleted",
Meta: map[string]any{},
})
})
r.Get("/dead-hosts", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
_ = claims
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(st.GetDeadHosts())
})
r.Post("/dead-hosts", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
var payload store.DeadHost
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), 400)
return
}
if payload.OwnerUserID == 0 {
payload.OwnerUserID = claims.UserID
}
created, err := st.CreateDeadHost(payload)
if err != nil {
writeStoreError(w, err)
return
}
eng.ReloadFromStore()
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(created)
_, _ = st.AddAuditLog(store.AuditLog{
UserID: claims.UserID,
ObjectType: "dead-host",
ObjectID: created.ID,
Action: "created",
Meta: map[string]any{"domain_names": created.DomainNames},
})
})
// full dead lifecycle
r.Put("/dead-hosts/{id}", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
existing, ok := st.GetDeadHost(id)
if !ok {
http.Error(w, "not found", 404)
return
}
if !canManage(claims, existing.OwnerUserID) {
http.Error(w, "forbidden: not owner or admin", 403)
return
}
var payload store.DeadHost
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), 400)
return
}
payload.ID = id
payload.OwnerUserID = existing.OwnerUserID
// preserve CreatedOn on update (server-set on create; do not lose on edit)
payload.CreatedOn = existing.CreatedOn
updated, err := st.UpdateDeadHost(payload)
if err != nil {
writeStoreError(w, err)
return
}
eng.ReloadFromStore()
_ = json.NewEncoder(w).Encode(updated)
_, _ = st.AddAuditLog(store.AuditLog{
UserID: claims.UserID,
ObjectType: "dead-host",
ObjectID: updated.ID,
Action: "updated",
Meta: map[string]any{"domain_names": updated.DomainNames},
})
})
r.Delete("/dead-hosts/{id}", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
existing, ok := st.GetDeadHost(id)
if !ok {
http.Error(w, "not found", 404)
return
}
if !canManage(claims, existing.OwnerUserID) {
http.Error(w, "forbidden: not owner or admin", 403)
return
}
if err := st.DeleteDeadHost(id); err != nil {
http.Error(w, err.Error(), 500)
return
}
eng.ReloadFromStore()
w.WriteHeader(http.StatusNoContent)
_, _ = st.AddAuditLog(store.AuditLog{
UserID: claims.UserID,
ObjectType: "dead-host",
ObjectID: id,
Action: "deleted",
Meta: map[string]any{},
})
})
// enable/disable for redirection and dead hosts (engine only loads enabled)
r.Post("/redirection-hosts/{id}/enable", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
existing, ok := st.GetRedirectionHost(id)
if !ok {
http.Error(w, "not found", 404)
return
}
if !canManage(claims, existing.OwnerUserID) {
http.Error(w, "forbidden: not owner or admin", 403)
return
}
if err := st.SetRedirectionHostEnabled(id, true); err != nil {
http.Error(w, err.Error(), 404)
return
}
eng.ReloadFromStore()
w.WriteHeader(200)
})
r.Post("/redirection-hosts/{id}/disable", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
existing, ok := st.GetRedirectionHost(id)
if !ok {
http.Error(w, "not found", 404)
return
}
if !canManage(claims, existing.OwnerUserID) {
http.Error(w, "forbidden: not owner or admin", 403)
return
}
if err := st.SetRedirectionHostEnabled(id, false); err != nil {
http.Error(w, err.Error(), 404)
return
}
eng.ReloadFromStore()
w.WriteHeader(200)
})
r.Post("/dead-hosts/{id}/enable", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
existing, ok := st.GetDeadHost(id)
if !ok {
http.Error(w, "not found", 404)
return
}
if !canManage(claims, existing.OwnerUserID) {
http.Error(w, "forbidden: not owner or admin", 403)
return
}
if err := st.SetDeadHostEnabled(id, true); err != nil {
http.Error(w, err.Error(), 404)
return
}
eng.ReloadFromStore()
w.WriteHeader(200)
})
r.Post("/dead-hosts/{id}/disable", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
existing, ok := st.GetDeadHost(id)
if !ok {
http.Error(w, "not found", 404)
return
}
if !canManage(claims, existing.OwnerUserID) {
http.Error(w, "forbidden: not owner or admin", 403)
return
}
if err := st.SetDeadHostEnabled(id, false); err != nil {
http.Error(w, err.Error(), 404)
return
}
eng.ReloadFromStore()
w.WriteHeader(200)
})
// streams enable/disable
r.Post("/streams/{id}/enable", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
existing, ok := st.GetStream(id)
if !ok {
http.Error(w, "not found", 404)
return
}
if !canManage(claims, existing.OwnerUserID) {
http.Error(w, "forbidden: not owner or admin", 403)
return
}
if err := st.SetStreamEnabled(id, true); err != nil {
if err.Error() == "not found" {
http.Error(w, err.Error(), 404)
return
}
writeStoreError(w, err)
return
}
eng.ReloadFromStore()
w.WriteHeader(200)
})
r.Post("/streams/{id}/disable", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
existing, ok := st.GetStream(id)
if !ok {
http.Error(w, "not found", 404)
return
}
if !canManage(claims, existing.OwnerUserID) {
http.Error(w, "forbidden: not owner or admin", 403)
return
}
if err := st.SetStreamEnabled(id, false); err != nil {
http.Error(w, err.Error(), 404)
return
}
eng.ReloadFromStore()
w.WriteHeader(200)
})
// Full proxy-host lifecycle (list/create above; get/put/delete + enable/disable here)
r.Route("/proxy-hosts/{id}", func(r chi.Router) {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
_ = claims // read authed (consistent); no owner gate for GET
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
if h, ok := st.GetProxyHost(id); ok {
_ = json.NewEncoder(w).Encode(h)
return
}
http.Error(w, "not found", 404)
})
r.Put("/", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
existing, ok := st.GetProxyHost(id)
if !ok {
http.Error(w, "not found", 404)
return
}
if !canManage(claims, existing.OwnerUserID) {
http.Error(w, "forbidden: not owner or admin", 403)
return
}
var payload store.ProxyHost
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), 400)
return
}
payload.ID = id
// preserve owner on update
payload.OwnerUserID = existing.OwnerUserID
// preserve CreatedOn on update (server-set on create; do not lose on edit)
payload.CreatedOn = existing.CreatedOn
updated, err := st.UpdateProxyHost(payload)
if err != nil {
writeStoreError(w, err)
return
}
eng.ReloadFromStore()
_ = json.NewEncoder(w).Encode(updated)
_, _ = st.AddAuditLog(store.AuditLog{
UserID: claims.UserID,
ObjectType: "proxy-host",
ObjectID: updated.ID,
Action: "updated",
Meta: map[string]any{"domain_names": updated.DomainNames},
})
})
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
existing, ok := st.GetProxyHost(id)
if !ok {
http.Error(w, "not found", 404)
return
}
if !canManage(claims, existing.OwnerUserID) {
http.Error(w, "forbidden: not owner or admin", 403)
return
}
if err := st.DeleteProxyHost(id); err != nil {
http.Error(w, err.Error(), 500)
return
}
eng.ReloadFromStore()
w.WriteHeader(http.StatusNoContent)
_, _ = st.AddAuditLog(store.AuditLog{
UserID: claims.UserID,
ObjectType: "proxy-host",
ObjectID: id,
Action: "deleted",
Meta: map[string]any{},
})
})
})
// enable / disable actions
r.Post("/proxy-hosts/{id}/enable", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
existing, ok := st.GetProxyHost(id)
if !ok {
http.Error(w, "not found", 404)
return
}
if !canManage(claims, existing.OwnerUserID) {
http.Error(w, "forbidden: not owner or admin", 403)
return
}
if err := st.SetProxyHostEnabled(id, true); err != nil {
http.Error(w, err.Error(), 404)
return
}
eng.ReloadFromStore()
w.WriteHeader(200)
})
r.Post("/proxy-hosts/{id}/disable", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "bad id", 400)
return
}
existing, ok := st.GetProxyHost(id)
if !ok {
http.Error(w, "not found", 404)
return
}
if !canManage(claims, existing.OwnerUserID) {
http.Error(w, "forbidden: not owner or admin", 403)
return
}
if err := st.SetProxyHostEnabled(id, false); err != nil {
http.Error(w, err.Error(), 404)
return
}
eng.ReloadFromStore()
w.WriteHeader(200)
})
// Settings (global for now; default_site drives unmatched host behavior in engine)
r.Get("/settings", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
_ = claims
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(st.GetSettings())
})
r.Post("/settings", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
_ = claims
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), 400)
return
}
for k, v := range payload {
if err := st.SetSetting(k, v); err != nil {
http.Error(w, err.Error(), 500)
return
}
}
w.WriteHeader(200)
_ = json.NewEncoder(w).Encode(st.GetSettings())
})
// Audit logs (basic)
r.Get("/audit-logs", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
_ = claims
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(st.GetAuditLogs())
})
// Login for the single admin account (no registration, no multi-user).
// 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"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), 400)
return
}
u, ok := st.GetUserByEmail(adminEmail)
if !ok {
u = store.User{ID: 1, Email: adminEmail, Name: "Admin", Roles: []string{"admin"}}
}
mustChange := false
authed := false
bootstrap := false
if u.Password == "" {
if payload.Password == defaultAdminPassword {
authed = true
bootstrap = true
mustChange = !config.IsDevelopment()
}
} else if isBcryptPrefix(u.Password) {
if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(payload.Password)); err == nil {
authed = true
}
} else if u.Password == payload.Password {
authed = true
}
if !authed {
http.Error(w, "invalid credentials", 401)
return
}
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
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"token": token,
"user": map[string]any{
"id": u.ID,
"email": u.Email,
"name": u.Name,
"roles": u.Roles,
"totpEnabled": false,
},
"mustChangePassword": mustChange,
})
})
// Users: single admin only (no registration, no CRUD, no 2FA). List and /me kept for UI compatibility.
r.Get("/users", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
_ = claims
// Return the single admin (store may have legacy, but we present the fixed one)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]map[string]any{{
"id": 1, "email": "admin@example.com", "name": "Admin", "roles": []string{"admin"}, "totpEnabled": false,
}})
})
r.Get("/users/me", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": claims.UserID,
"email": claims.Email,
"name": claims.Name,
"roles": claims.Roles,
"totpEnabled": false,
})
})
// Change the single admin's password. Works for the initial "password" (when DB has empty pw)
// as well as normal changes (requires current password to match stored hash).
// New password is bcrypt-hashed and persisted. "password" is not allowed as the new value.
r.Post("/users/me/password", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
var payload struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), 400)
return
}
if payload.NewPassword == "" || payload.NewPassword == "password" {
http.Error(w, "new password required and cannot be the default", 400)
return
}
u, ok := st.GetUserByEmail(claims.Email)
if !ok {
http.Error(w, "not found", 404)
return
}
// verify current
if u.Password == "" {
// initial state: current must be the default (or empty to be lenient for first-login flow)
if payload.CurrentPassword != "" && payload.CurrentPassword != "password" {
http.Error(w, "invalid current password", 401)
return
}
} else if isBcryptPrefix(u.Password) {
if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(payload.CurrentPassword)); err != nil {
http.Error(w, "invalid current password", 401)
return
}
} else if u.Password != payload.CurrentPassword {
http.Error(w, "invalid current password", 401)
return
}
h, err := bcrypt.GenerateFromPassword([]byte(payload.NewPassword), bcrypt.DefaultCost)
if err != nil {
slog.Error("password hash failed", "err", err)
http.Error(w, "password hash failed", 500)
return
}
u.Password = string(h)
if err := st.UpdateUser(u); err != nil {
http.Error(w, err.Error(), 500)
return
}
_, _ = st.AddAuditLog(store.AuditLog{
UserID: claims.UserID,
ObjectType: "user",
ObjectID: u.ID,
Action: "password-changed",
Meta: map[string]any{},
})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
})
// Dashboard (counts, like original)
r.Get("/dashboard", func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.RequireAuth(w, r)
if !ok {
return
}
_ = claims
data := map[string]any{
"proxy_hosts": len(st.GetProxyHosts()),
"redirection_hosts": len(st.GetRedirectionHosts()),
"dead_hosts": len(st.GetDeadHosts()),
"streams": len(st.GetStreams()),
"certificates": len(st.GetCertificates()),
"access_lists": len(st.GetAccessLists()),
"audit_logs": len(st.GetAuditLogs()),
"users": len(st.GetUsers()),
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(data)
})
// Version (like original)
r.Get("/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
major, minor, revision := version.Parts()
_ = json.NewEncoder(w).Encode(map[string]any{
"version": version.String(),
"tag": version.Version,
"commit": version.Commit,
"major": major,
"minor": minor,
"revision": revision,
"api": "v1",
})
})
})
}