Initial vibeproxy implementation
Self-contained HTTP/HTTPS/TCP/UDP reverse proxy manager written in Go, with SQLite storage, a Bootstrap 5 admin UI (Go templates, no JS framework), and a REST API under /api/v1. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QTXFrNP1imCW7jtfL5SZrw
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/owen/vibeproxy/internal/database"
|
||||
"github.com/owen/vibeproxy/internal/models"
|
||||
)
|
||||
|
||||
func (a *API) listAccessLists(w http.ResponseWriter, r *http.Request) {
|
||||
lists, err := database.ListAccessLists(a.db)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, lists)
|
||||
}
|
||||
|
||||
func (a *API) getAccessList(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
l, err := database.GetAccessList(a.db, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "access list not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, l)
|
||||
}
|
||||
|
||||
func (a *API) createAccessList(w http.ResponseWriter, r *http.Request) {
|
||||
var l models.AccessList
|
||||
if !decodeJSON(w, r, &l) {
|
||||
return
|
||||
}
|
||||
if err := validateAccessList(&l); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
l.ID = 0
|
||||
id, err := database.SaveAccessList(a.db, &l)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.reload(r)
|
||||
audit(a.db, r, "access_list_changed", l.Name, fmt.Sprintf("id=%d created", id))
|
||||
writeJSON(w, http.StatusCreated, l)
|
||||
}
|
||||
|
||||
func (a *API) updateAccessList(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var l models.AccessList
|
||||
if !decodeJSON(w, r, &l) {
|
||||
return
|
||||
}
|
||||
l.ID = id
|
||||
if err := validateAccessList(&l); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, err := database.SaveAccessList(a.db, &l); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.reload(r)
|
||||
audit(a.db, r, "access_list_changed", l.Name, fmt.Sprintf("id=%d updated", id))
|
||||
writeJSON(w, http.StatusOK, l)
|
||||
}
|
||||
|
||||
func (a *API) deleteAccessList(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := database.DeleteAccessList(a.db, id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.reload(r)
|
||||
audit(a.db, r, "access_list_changed", fmt.Sprintf("id=%d", id), "deleted")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func validateAccessList(l *models.AccessList) error {
|
||||
if l.Name == "" {
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
for i, rule := range l.Rules {
|
||||
if rule.Action != models.AccessAllow && rule.Action != models.AccessDeny {
|
||||
return fmt.Errorf("rule %d: action must be allow or deny", i)
|
||||
}
|
||||
if rule.CIDR == "" {
|
||||
return fmt.Errorf("rule %d: CIDR is required", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/owen/vibeproxy/internal/certificates"
|
||||
"github.com/owen/vibeproxy/internal/database"
|
||||
"github.com/owen/vibeproxy/internal/models"
|
||||
)
|
||||
|
||||
func (a *API) listCertificates(w http.ResponseWriter, r *http.Request) {
|
||||
certs, err := database.ListCertificates(a.db)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, certs)
|
||||
}
|
||||
|
||||
func (a *API) getCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
c, err := database.GetCertificate(a.db, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "certificate not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, c)
|
||||
}
|
||||
|
||||
type issueCertRequest struct {
|
||||
Name string `json:"name"`
|
||||
Domains []string `json:"domains"`
|
||||
Email string `json:"email"`
|
||||
Staging bool `json:"staging"`
|
||||
ChallengeType models.ChallengeType `json:"challenge_type"`
|
||||
DNSProvider string `json:"dns_provider"`
|
||||
}
|
||||
|
||||
func (a *API) issueCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
var req issueCertRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Name == "" || len(req.Domains) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "name and domains are required")
|
||||
return
|
||||
}
|
||||
if req.ChallengeType == "" {
|
||||
req.ChallengeType = models.ChallengeHTTP01
|
||||
}
|
||||
settings := a.store.Current().Settings
|
||||
email := req.Email
|
||||
if email == "" {
|
||||
email = settings.ACMEEmail
|
||||
}
|
||||
cert, err := a.certs.IssueLetsEncrypt(r.Context(), certOpts(req, email))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
audit(a.db, r, "certificate_issued", cert.Name, fmt.Sprintf("domains=%v challenge=%s", cert.Domains, cert.ChallengeType))
|
||||
writeJSON(w, http.StatusCreated, cert)
|
||||
}
|
||||
|
||||
func certOpts(req issueCertRequest, email string) certificates.IssueLetsEncryptOptions {
|
||||
return certificates.IssueLetsEncryptOptions{
|
||||
Name: req.Name,
|
||||
Domains: req.Domains,
|
||||
Email: email,
|
||||
Staging: req.Staging,
|
||||
ChallengeType: req.ChallengeType,
|
||||
DNSProvider: req.DNSProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) uploadCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Domains []string `json:"domains"`
|
||||
CertPEM string `json:"cert_pem"`
|
||||
KeyPEM string `json:"key_pem"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.CertPEM == "" || req.KeyPEM == "" {
|
||||
writeError(w, http.StatusBadRequest, "name, cert_pem, and key_pem are required")
|
||||
return
|
||||
}
|
||||
cert, err := a.certs.UploadCustom(req.Name, req.Domains, []byte(req.CertPEM), []byte(req.KeyPEM))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
audit(a.db, r, "certificate_issued", cert.Name, "custom certificate uploaded")
|
||||
writeJSON(w, http.StatusCreated, cert)
|
||||
}
|
||||
|
||||
func (a *API) renewCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
c, err := database.GetCertificate(a.db, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "certificate not found")
|
||||
return
|
||||
}
|
||||
if c.Type != models.CertTypeLetsEncrypt {
|
||||
writeError(w, http.StatusBadRequest, "only Let's Encrypt certificates can be renewed")
|
||||
return
|
||||
}
|
||||
settings := a.store.Current().Settings
|
||||
updated, err := a.certs.IssueLetsEncrypt(r.Context(), certOptsFromModel(*c, settings.ACMEEmail, settings.ACMEStaging))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
audit(a.db, r, "certificate_renewed", updated.Name, "")
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func certOptsFromModel(c models.Certificate, email string, staging bool) certificates.IssueLetsEncryptOptions {
|
||||
return certificates.IssueLetsEncryptOptions{
|
||||
Name: c.Name,
|
||||
Domains: c.Domains,
|
||||
Email: email,
|
||||
Staging: staging,
|
||||
ChallengeType: c.ChallengeType,
|
||||
DNSProvider: c.DNSProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) deleteCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := database.DeleteCertificate(a.db, id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.certs.LoadAll()
|
||||
audit(a.db, r, "certificate_changed", fmt.Sprintf("id=%d", id), "deleted")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Package api implements the REST API under /api/v1, authenticated with
|
||||
// bearer API tokens (separate from the web UI's Basic Auth).
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/owen/vibeproxy/internal/auth"
|
||||
"github.com/owen/vibeproxy/internal/logging"
|
||||
)
|
||||
|
||||
const maxJSONBody = 10 << 20 // 10 MiB is plenty for config payloads
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func pathID(r *http.Request) (int64, error) {
|
||||
return strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
}
|
||||
|
||||
func audit(db *sql.DB, r *http.Request, action, target, details string) {
|
||||
logging.Audit(db, auth.ActorFromContext(r.Context()), action, target, details)
|
||||
}
|
||||
|
||||
func queryInt(r *http.Request, key string, def int) int {
|
||||
v := r.URL.Query().Get(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func nilIfEmptyID(id int64) *int64 {
|
||||
if id == 0 {
|
||||
return nil
|
||||
}
|
||||
return &id
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/owen/vibeproxy/internal/database"
|
||||
)
|
||||
|
||||
func (a *API) listRequestLogs(w http.ResponseWriter, r *http.Request) {
|
||||
f := database.LogFilter{
|
||||
Status: queryInt(r, "status", 0),
|
||||
Search: r.URL.Query().Get("q"),
|
||||
Limit: queryInt(r, "limit", 50),
|
||||
Offset: queryInt(r, "offset", 0),
|
||||
}
|
||||
if id := queryInt(r, "proxy_host_id", 0); id > 0 {
|
||||
id64 := int64(id)
|
||||
f.ProxyHostID = &id64
|
||||
}
|
||||
logs, total, err := database.QueryRequestLogs(a.db, f)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"logs": logs, "total": total})
|
||||
}
|
||||
|
||||
func (a *API) listAuditLogs(w http.ResponseWriter, r *http.Request) {
|
||||
limit := queryInt(r, "limit", 50)
|
||||
offset := queryInt(r, "offset", 0)
|
||||
logs, total, err := database.ListAuditLogs(a.db, limit, offset)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"logs": logs, "total": total})
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/owen/vibeproxy/internal/database"
|
||||
"github.com/owen/vibeproxy/internal/models"
|
||||
)
|
||||
|
||||
func (a *API) listProxyHosts(w http.ResponseWriter, r *http.Request) {
|
||||
hosts, err := database.ListProxyHosts(a.db)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, hosts)
|
||||
}
|
||||
|
||||
func (a *API) getProxyHost(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
h, err := database.GetProxyHost(a.db, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "proxy host not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h)
|
||||
}
|
||||
|
||||
func (a *API) createProxyHost(w http.ResponseWriter, r *http.Request) {
|
||||
var h models.ProxyHost
|
||||
if !decodeJSON(w, r, &h) {
|
||||
return
|
||||
}
|
||||
if err := validateProxyHost(&h); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
h.ID = 0
|
||||
id, err := database.SaveProxyHost(a.db, &h)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.reload(r)
|
||||
audit(a.db, r, "proxy_created", h.Name, fmt.Sprintf("id=%d domains=%v", id, h.Domains))
|
||||
writeJSON(w, http.StatusCreated, h)
|
||||
}
|
||||
|
||||
func (a *API) updateProxyHost(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var h models.ProxyHost
|
||||
if !decodeJSON(w, r, &h) {
|
||||
return
|
||||
}
|
||||
h.ID = id
|
||||
if err := validateProxyHost(&h); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, err := database.SaveProxyHost(a.db, &h); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.reload(r)
|
||||
audit(a.db, r, "proxy_changed", h.Name, fmt.Sprintf("id=%d", id))
|
||||
writeJSON(w, http.StatusOK, h)
|
||||
}
|
||||
|
||||
func (a *API) deleteProxyHost(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := database.DeleteProxyHost(a.db, id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.reload(r)
|
||||
audit(a.db, r, "proxy_deleted", fmt.Sprintf("id=%d", id), "")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (a *API) enableProxyHost(w http.ResponseWriter, r *http.Request) {
|
||||
a.setProxyHostEnabled(w, r, true)
|
||||
}
|
||||
|
||||
func (a *API) disableProxyHost(w http.ResponseWriter, r *http.Request) {
|
||||
a.setProxyHostEnabled(w, r, false)
|
||||
}
|
||||
|
||||
func (a *API) setProxyHostEnabled(w http.ResponseWriter, r *http.Request, enabled bool) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := database.SetProxyHostEnabled(a.db, id, enabled); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.reload(r)
|
||||
audit(a.db, r, "proxy_changed", fmt.Sprintf("id=%d", id), fmt.Sprintf("enabled=%v", enabled))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (a *API) reload(r *http.Request) {
|
||||
if err := a.store.Reload(r.Context()); err != nil {
|
||||
a.log.Error("config reload failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func validateProxyHost(h *models.ProxyHost) error {
|
||||
if h.Name == "" {
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
if h.Type == "" {
|
||||
h.Type = models.ProxyHostTypeProxy
|
||||
}
|
||||
if len(h.Domains) == 0 {
|
||||
return fmt.Errorf("at least one domain is required")
|
||||
}
|
||||
if h.Type == models.ProxyHostTypeProxy {
|
||||
if len(h.Backends) == 0 {
|
||||
return fmt.Errorf("at least one backend is required for a proxy host")
|
||||
}
|
||||
for i, b := range h.Backends {
|
||||
if b.Host == "" {
|
||||
return fmt.Errorf("backend %d: host is required", i)
|
||||
}
|
||||
if b.Port <= 0 || b.Port > 65535 {
|
||||
return fmt.Errorf("backend %d: invalid port", i)
|
||||
}
|
||||
if b.Weight <= 0 {
|
||||
h.Backends[i].Weight = 1
|
||||
}
|
||||
if b.HealthCheckType == "" {
|
||||
h.Backends[i].HealthCheckType = models.HealthCheckTCP
|
||||
}
|
||||
if b.IntervalSeconds <= 0 {
|
||||
h.Backends[i].IntervalSeconds = 10
|
||||
}
|
||||
if b.FailureThreshold <= 0 {
|
||||
h.Backends[i].FailureThreshold = 3
|
||||
}
|
||||
if b.RecoveryThreshold <= 0 {
|
||||
h.Backends[i].RecoveryThreshold = 2
|
||||
}
|
||||
}
|
||||
if h.ForwardScheme == "" {
|
||||
h.ForwardScheme = "http"
|
||||
}
|
||||
if h.LoadBalancingMethod == "" {
|
||||
h.LoadBalancingMethod = "round_robin"
|
||||
}
|
||||
} else if h.Type == models.ProxyHostTypeRedirect {
|
||||
if h.RedirectTargetURL == "" {
|
||||
return fmt.Errorf("redirect target URL is required")
|
||||
}
|
||||
switch h.RedirectCode {
|
||||
case 301, 302, 307, 308:
|
||||
default:
|
||||
h.RedirectCode = 301
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("invalid proxy host type %q", h.Type)
|
||||
}
|
||||
if h.ConnectTimeoutMS <= 0 {
|
||||
h.ConnectTimeoutMS = 5000
|
||||
}
|
||||
if h.ReadTimeoutMS <= 0 {
|
||||
h.ReadTimeoutMS = 60000
|
||||
}
|
||||
if h.WriteTimeoutMS <= 0 {
|
||||
h.WriteTimeoutMS = 60000
|
||||
}
|
||||
if h.IdleTimeoutMS <= 0 {
|
||||
h.IdleTimeoutMS = 120000
|
||||
}
|
||||
for _, hd := range h.CustomHeaders {
|
||||
if hd.Name == "" {
|
||||
return fmt.Errorf("custom header name is required")
|
||||
}
|
||||
if hd.Direction != models.HeaderDirectionRequest && hd.Direction != models.HeaderDirectionResponse {
|
||||
return fmt.Errorf("custom header direction must be request or response")
|
||||
}
|
||||
if hd.Action != models.HeaderActionSet && hd.Action != models.HeaderActionRemove {
|
||||
return fmt.Errorf("custom header action must be set or remove")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/owen/vibeproxy/internal/auth"
|
||||
"github.com/owen/vibeproxy/internal/certificates"
|
||||
"github.com/owen/vibeproxy/internal/runtime"
|
||||
"github.com/owen/vibeproxy/internal/streams"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
db *sql.DB
|
||||
store *runtime.Store
|
||||
certs *certificates.Manager
|
||||
streams *streams.Manager
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewRouter builds the full /api/v1 handler tree, protected by API token
|
||||
// bearer auth.
|
||||
func NewRouter(db *sql.DB, store *runtime.Store, certs *certificates.Manager, streamMgr *streams.Manager, log *slog.Logger) http.Handler {
|
||||
a := &API{db: db, store: store, certs: certs, streams: streamMgr, log: log}
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /api/v1/proxy-hosts", a.listProxyHosts)
|
||||
mux.HandleFunc("POST /api/v1/proxy-hosts", a.createProxyHost)
|
||||
mux.HandleFunc("GET /api/v1/proxy-hosts/{id}", a.getProxyHost)
|
||||
mux.HandleFunc("PUT /api/v1/proxy-hosts/{id}", a.updateProxyHost)
|
||||
mux.HandleFunc("DELETE /api/v1/proxy-hosts/{id}", a.deleteProxyHost)
|
||||
mux.HandleFunc("POST /api/v1/proxy-hosts/{id}/enable", a.enableProxyHost)
|
||||
mux.HandleFunc("POST /api/v1/proxy-hosts/{id}/disable", a.disableProxyHost)
|
||||
|
||||
mux.HandleFunc("GET /api/v1/streams", a.listStreams)
|
||||
mux.HandleFunc("POST /api/v1/streams", a.createStream)
|
||||
mux.HandleFunc("GET /api/v1/streams/{id}", a.getStream)
|
||||
mux.HandleFunc("PUT /api/v1/streams/{id}", a.updateStream)
|
||||
mux.HandleFunc("DELETE /api/v1/streams/{id}", a.deleteStream)
|
||||
mux.HandleFunc("POST /api/v1/streams/{id}/enable", a.enableStream)
|
||||
mux.HandleFunc("POST /api/v1/streams/{id}/disable", a.disableStream)
|
||||
|
||||
mux.HandleFunc("GET /api/v1/certificates", a.listCertificates)
|
||||
mux.HandleFunc("POST /api/v1/certificates/letsencrypt", a.issueCertificate)
|
||||
mux.HandleFunc("POST /api/v1/certificates/custom", a.uploadCertificate)
|
||||
mux.HandleFunc("GET /api/v1/certificates/{id}", a.getCertificate)
|
||||
mux.HandleFunc("DELETE /api/v1/certificates/{id}", a.deleteCertificate)
|
||||
mux.HandleFunc("POST /api/v1/certificates/{id}/renew", a.renewCertificate)
|
||||
|
||||
mux.HandleFunc("GET /api/v1/access-lists", a.listAccessLists)
|
||||
mux.HandleFunc("POST /api/v1/access-lists", a.createAccessList)
|
||||
mux.HandleFunc("GET /api/v1/access-lists/{id}", a.getAccessList)
|
||||
mux.HandleFunc("PUT /api/v1/access-lists/{id}", a.updateAccessList)
|
||||
mux.HandleFunc("DELETE /api/v1/access-lists/{id}", a.deleteAccessList)
|
||||
|
||||
mux.HandleFunc("GET /api/v1/settings", a.getSettings)
|
||||
mux.HandleFunc("PUT /api/v1/settings", a.updateSettings)
|
||||
|
||||
mux.HandleFunc("GET /api/v1/tokens", a.listTokens)
|
||||
mux.HandleFunc("POST /api/v1/tokens", a.createToken)
|
||||
mux.HandleFunc("DELETE /api/v1/tokens/{id}", a.revokeToken)
|
||||
|
||||
mux.HandleFunc("GET /api/v1/logs/requests", a.listRequestLogs)
|
||||
mux.HandleFunc("GET /api/v1/logs/audit", a.listAuditLogs)
|
||||
|
||||
mux.HandleFunc("GET /api/v1/stats", a.getStats)
|
||||
|
||||
return auth.APIToken(db)(mux)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/owen/vibeproxy/internal/database"
|
||||
)
|
||||
|
||||
type settingsDTO struct {
|
||||
ACMEEmail string `json:"acme_email"`
|
||||
ACMEStaging bool `json:"acme_staging"`
|
||||
ACMERenewalWindowDays int `json:"acme_renewal_window_days"`
|
||||
CertStoragePath string `json:"cert_storage_path"`
|
||||
LogRetentionDays int `json:"log_retention_days"`
|
||||
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs"`
|
||||
BackupEnabled bool `json:"backup_enabled"`
|
||||
BackupIntervalHours int `json:"backup_interval_hours"`
|
||||
BackupRetentionCount int `json:"backup_retention_count"`
|
||||
BackupPath string `json:"backup_path"`
|
||||
}
|
||||
|
||||
func (a *API) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
s := a.store.Current().Settings
|
||||
writeJSON(w, http.StatusOK, settingsDTO{
|
||||
ACMEEmail: s.ACMEEmail,
|
||||
ACMEStaging: s.ACMEStaging,
|
||||
ACMERenewalWindowDays: s.ACMERenewalWindowDays,
|
||||
CertStoragePath: s.CertStoragePath,
|
||||
LogRetentionDays: s.LogRetentionDays,
|
||||
TrustedProxyCIDRs: s.TrustedProxyCIDRs,
|
||||
BackupEnabled: s.BackupEnabled,
|
||||
BackupIntervalHours: s.BackupIntervalHours,
|
||||
BackupRetentionCount: s.BackupRetentionCount,
|
||||
BackupPath: s.BackupPath,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var dto settingsDTO
|
||||
if !decodeJSON(w, r, &dto) {
|
||||
return
|
||||
}
|
||||
kv := map[string]string{
|
||||
"acme_email": dto.ACMEEmail,
|
||||
"acme_staging": strconv.FormatBool(dto.ACMEStaging),
|
||||
"acme_renewal_window_days": strconv.Itoa(dto.ACMERenewalWindowDays),
|
||||
"cert_storage_path": dto.CertStoragePath,
|
||||
"log_retention_days": strconv.Itoa(dto.LogRetentionDays),
|
||||
"trusted_proxy_cidrs": strings.Join(dto.TrustedProxyCIDRs, ","),
|
||||
"backup_enabled": strconv.FormatBool(dto.BackupEnabled),
|
||||
"backup_interval_hours": strconv.Itoa(dto.BackupIntervalHours),
|
||||
"backup_retention_count": strconv.Itoa(dto.BackupRetentionCount),
|
||||
"backup_path": dto.BackupPath,
|
||||
}
|
||||
for k, v := range kv {
|
||||
if err := database.SetSetting(a.db, k, v); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
a.reload(r)
|
||||
audit(a.db, r, "settings_changed", "settings", "")
|
||||
writeJSON(w, http.StatusOK, dto)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/owen/vibeproxy/internal/stats"
|
||||
)
|
||||
|
||||
func (a *API) getStats(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := stats.Compute(a.db, a.store.Current())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, d)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/owen/vibeproxy/internal/database"
|
||||
"github.com/owen/vibeproxy/internal/models"
|
||||
)
|
||||
|
||||
func (a *API) listStreams(w http.ResponseWriter, r *http.Request) {
|
||||
s, err := database.ListStreams(a.db)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s)
|
||||
}
|
||||
|
||||
func (a *API) getStream(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
s, err := database.GetStream(a.db, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "stream not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s)
|
||||
}
|
||||
|
||||
func (a *API) createStream(w http.ResponseWriter, r *http.Request) {
|
||||
var s models.Stream
|
||||
if !decodeJSON(w, r, &s) {
|
||||
return
|
||||
}
|
||||
if err := validateStream(&s); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.ID = 0
|
||||
id, err := database.SaveStream(a.db, &s)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.reloadStreams(r)
|
||||
audit(a.db, r, "stream_changed", fmt.Sprintf("id=%d", id), fmt.Sprintf("%s :%d -> %s:%d", s.Protocol, s.ListenPort, s.DestinationHost, s.DestinationPort))
|
||||
writeJSON(w, http.StatusCreated, s)
|
||||
}
|
||||
|
||||
func (a *API) updateStream(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var s models.Stream
|
||||
if !decodeJSON(w, r, &s) {
|
||||
return
|
||||
}
|
||||
s.ID = id
|
||||
if err := validateStream(&s); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, err := database.SaveStream(a.db, &s); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.reloadStreams(r)
|
||||
audit(a.db, r, "stream_changed", fmt.Sprintf("id=%d", id), "")
|
||||
writeJSON(w, http.StatusOK, s)
|
||||
}
|
||||
|
||||
func (a *API) deleteStream(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := database.DeleteStream(a.db, id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.reloadStreams(r)
|
||||
audit(a.db, r, "stream_changed", fmt.Sprintf("id=%d", id), "deleted")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (a *API) enableStream(w http.ResponseWriter, r *http.Request) { a.setStreamEnabled(w, r, true) }
|
||||
func (a *API) disableStream(w http.ResponseWriter, r *http.Request) { a.setStreamEnabled(w, r, false) }
|
||||
|
||||
func (a *API) setStreamEnabled(w http.ResponseWriter, r *http.Request, enabled bool) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := database.SetStreamEnabled(a.db, id, enabled); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.reloadStreams(r)
|
||||
audit(a.db, r, "stream_changed", fmt.Sprintf("id=%d", id), fmt.Sprintf("enabled=%v", enabled))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (a *API) reloadStreams(r *http.Request) {
|
||||
if err := a.streams.Reload(r.Context()); err != nil {
|
||||
a.log.Error("stream reload failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func validateStream(s *models.Stream) error {
|
||||
if s.Protocol != models.StreamTCP && s.Protocol != models.StreamUDP {
|
||||
return fmt.Errorf("protocol must be tcp or udp")
|
||||
}
|
||||
if s.ListenPort <= 0 || s.ListenPort > 65535 {
|
||||
return fmt.Errorf("invalid listen port")
|
||||
}
|
||||
if s.DestinationHost == "" {
|
||||
return fmt.Errorf("destination host is required")
|
||||
}
|
||||
if s.DestinationPort <= 0 || s.DestinationPort > 65535 {
|
||||
return fmt.Errorf("invalid destination port")
|
||||
}
|
||||
if s.ConnectTimeoutMS <= 0 {
|
||||
s.ConnectTimeoutMS = 5000
|
||||
}
|
||||
if s.IdleTimeoutMS <= 0 {
|
||||
s.IdleTimeoutMS = 300000
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/owen/vibeproxy/internal/auth"
|
||||
"github.com/owen/vibeproxy/internal/database"
|
||||
)
|
||||
|
||||
func (a *API) listTokens(w http.ResponseWriter, r *http.Request) {
|
||||
tokens, err := database.ListAPITokens(a.db)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Never expose the stored hash.
|
||||
type dto struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Prefix string `json:"prefix"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsedAt string `json:"last_used_at,omitempty"`
|
||||
Revoked bool `json:"revoked"`
|
||||
}
|
||||
out := make([]dto, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
d := dto{ID: t.ID, Name: t.Name, Prefix: t.Prefix, CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"), Revoked: t.RevokedAt != nil}
|
||||
if t.LastUsedAt != nil {
|
||||
d.LastUsedAt = t.LastUsedAt.Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (a *API) createToken(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
plaintext, hash, prefix, err := auth.GenerateAPIToken()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
id, err := database.CreateAPIToken(a.db, req.Name, hash, prefix)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
audit(a.db, r, "token_created", req.Name, "")
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"id": id,
|
||||
"name": req.Name,
|
||||
"token": plaintext, // shown once; never retrievable again
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) revokeToken(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := database.RevokeAPIToken(a.db, id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
audit(a.db, r, "token_revoked", "", "")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
Reference in New Issue
Block a user