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
+34
View File
@@ -0,0 +1,34 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
)
// GenerateAPIToken returns a full plaintext token (shown once to the user),
// its sha256 hash (stored in the DB), and a short prefix (stored alongside
// the hash so the UI can display "vp_ab12..." without ever re-showing the
// secret).
func GenerateAPIToken() (plaintext, hash, prefix string, err error) {
b := make([]byte, 32)
if _, err = rand.Read(b); err != nil {
return
}
secret := base64.RawURLEncoding.EncodeToString(b)
plaintext = "vp_" + secret
hash = HashAPIToken(plaintext)
prefix = plaintext[:8]
return
}
func HashAPIToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func FormatTokenPreview(prefix string) string {
return fmt.Sprintf("%s%s", prefix, "…")
}
+151
View File
@@ -0,0 +1,151 @@
package auth
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/base64"
"net/http"
"time"
"github.com/owen/vibeproxy/internal/database"
)
type ctxKey string
const actorCtxKey ctxKey = "actor"
func ActorFromContext(ctx context.Context) string {
if v, ok := ctx.Value(actorCtxKey).(string); ok {
return v
}
return "unknown"
}
// BasicAuthAdmin protects the web UI with HTTP Basic Auth against the
// single stored admin account.
func BasicAuthAdmin(db *sql.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth()
if !ok {
unauthorized(w)
return
}
user, err := database.GetUserByUsername(db, username)
if err != nil {
unauthorized(w)
return
}
valid, err := VerifyPassword(password, user.PasswordHash)
if err != nil || !valid {
unauthorized(w)
return
}
ctx := context.WithValue(r.Context(), actorCtxKey, username)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func unauthorized(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="vibeproxy"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
// APIToken protects the REST API with a bearer token checked against
// hashed tokens stored in the database.
func APIToken(db *sql.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok := bearerToken(r)
if tok == "" {
http.Error(w, `{"error":"missing bearer token"}`, http.StatusUnauthorized)
return
}
hash := HashAPIToken(tok)
rec, err := database.GetAPITokenByHash(db, hash)
if err != nil || rec.RevokedAt != nil {
http.Error(w, `{"error":"invalid or revoked token"}`, http.StatusUnauthorized)
return
}
go database.TouchAPIToken(db, rec.ID)
ctx := context.WithValue(r.Context(), actorCtxKey, "token:"+rec.Name)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
const p = "Bearer "
if len(h) > len(p) && h[:len(p)] == p {
return h[len(p):]
}
return ""
}
// CSRF implements a double-submit-cookie style CSRF protection for the
// server-rendered web UI (which authenticates via Basic Auth and so has no
// session to bind a per-session token to).
type CSRF struct {
secret []byte
}
func NewCSRF() *CSRF {
s := make([]byte, 32)
rand.Read(s)
return &CSRF{secret: s}
}
const csrfCookieName = "vp_csrf"
func (c *CSRF) sign(nonce string) string {
mac := hmac.New(sha256.New, c.secret)
mac.Write([]byte(nonce))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// Token issues (and sets a cookie for) a fresh CSRF token for rendering
// into a form's hidden field.
func (c *CSRF) Token(w http.ResponseWriter) string {
nonceB := make([]byte, 16)
rand.Read(nonceB)
nonce := base64.RawURLEncoding.EncodeToString(nonceB)
sig := c.sign(nonce)
value := nonce + "." + sig
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: value,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Expires: time.Now().Add(2 * time.Hour),
})
return value
}
// Protect rejects unsafe requests whose form value doesn't match the
// double-submitted cookie.
func (c *CSRF) Protect(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
next.ServeHTTP(w, r)
return
}
cookie, err := r.Cookie(csrfCookieName)
if err != nil {
http.Error(w, "CSRF token missing", http.StatusForbidden)
return
}
formTok := r.FormValue("csrf_token")
if formTok == "" || subtle.ConstantTimeCompare([]byte(formTok), []byte(cookie.Value)) != 1 {
http.Error(w, "CSRF token invalid", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
+78
View File
@@ -0,0 +1,78 @@
// Package auth handles admin credential hashing, HTTP Basic Auth, API
// tokens, and CSRF protection for the web UI.
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// Argon2id parameters. Tuned for a low-traffic single-admin login, not a
// high-throughput multi-tenant service.
const (
argonTime = 1
argonMemory = 64 * 1024 // KiB
argonThreads = 4
argonKeyLen = 32
saltLen = 16
)
var ErrInvalidHash = errors.New("invalid password hash format")
// HashPassword returns an encoded argon2id hash in the standard
// $argon2id$v=19$m=...,t=...,p=...$salt$hash format.
func HashPassword(password string) (string, error) {
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemory, argonTime, argonThreads, b64Salt, b64Hash), nil
}
// VerifyPassword checks password against an encoded hash produced by
// HashPassword, using constant-time comparison.
func VerifyPassword(password, encoded string) (bool, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, ErrInvalidHash
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return false, ErrInvalidHash
}
var memory uint32
var time uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
return false, ErrInvalidHash
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, ErrInvalidHash
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, ErrInvalidHash
}
got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
// GenerateRandomPassword returns a strong, human-typeable temporary
// password (24 base64url characters, no padding).
func GenerateRandomPassword() (string, error) {
b := make([]byte, 18)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
+54
View File
@@ -0,0 +1,54 @@
package auth
import "testing"
func TestHashAndVerifyPassword(t *testing.T) {
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatal(err)
}
ok, err := VerifyPassword("correct horse battery staple", hash)
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("expected password to verify")
}
ok, err = VerifyPassword("wrong password", hash)
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("expected wrong password to fail verification")
}
}
func TestGenerateRandomPassword(t *testing.T) {
p1, err := GenerateRandomPassword()
if err != nil {
t.Fatal(err)
}
p2, err := GenerateRandomPassword()
if err != nil {
t.Fatal(err)
}
if p1 == p2 {
t.Fatal("expected distinct random passwords")
}
if len(p1) < 20 {
t.Fatalf("password too short: %q", p1)
}
}
func TestAPITokenRoundTrip(t *testing.T) {
plaintext, hash, prefix, err := GenerateAPIToken()
if err != nil {
t.Fatal(err)
}
if HashAPIToken(plaintext) != hash {
t.Fatal("hash mismatch")
}
if len(prefix) != 8 {
t.Fatalf("expected 8-char prefix, got %q", prefix)
}
}