3087968463
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
1343 lines
37 KiB
Go
1343 lines
37 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"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(redactAccessLists(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
|
|
}
|
|
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(redactAccessList(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(redactAccessList(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
|
|
// 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(redactAccessList(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",
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|