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:
2026-08-17 10:28:59 -05:00
co-authored by Claude Sonnet 5
commit ba1f7c4c03
105 changed files with 9142 additions and 0 deletions
+106
View File
@@ -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
}
+153
View File
@@ -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)
}
+63
View File
@@ -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
}
+37
View File
@@ -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})
}
+201
View File
@@ -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
}
+70
View File
@@ -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)
}
+66
View File
@@ -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)
}
+16
View File
@@ -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)
}
+137
View File
@@ -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
}
+77
View File
@@ -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)
}