feat: sdk migration in progress

This commit is contained in:
allanice001
2025-11-02 13:19:30 +00:00
commit 0d10d42442
492 changed files with 71067 additions and 0 deletions

View File

@@ -0,0 +1,161 @@
package httpmiddleware
import (
"net/http"
"strings"
"github.com/glueops/autoglue/internal/auth"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// AuthMiddleware authenticates either a user principal (JWT, user API key, app key/secret)
// or an org principal (org key/secret). If requireOrg is true, the request must have
// an organization resolved; otherwise org is optional.
//
// Org resolution order for user principals (when requireOrg == true):
// 1. X-Org-ID header (UUID)
// 2. chi URL param {id} (useful under /orgs/{id}/... routers)
// 3. single-membership fallback (exactly one membership)
//
// If none resolves, respond with org_required.
func AuthMiddleware(db *gorm.DB, requireOrg bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var user *models.User
var org *models.Organization
var roles []string
// --- 1) Authenticate principal ---
// Prefer org principal if explicit machine access is provided.
if orgKey := r.Header.Get("X-ORG-KEY"); orgKey != "" {
secret := r.Header.Get("X-ORG-SECRET")
org = auth.ValidateOrgKeyPair(orgKey, secret, db)
if org == nil {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "invalid org credentials")
return
}
// org principal implies machine role
roles = []string{"org:machine"}
} else {
// User principals
if ah := r.Header.Get("Authorization"); strings.HasPrefix(ah, "Bearer ") {
user = auth.ValidateJWT(ah[7:], db)
} else if apiKey := r.Header.Get("X-API-KEY"); apiKey != "" {
user = auth.ValidateAPIKey(apiKey, db)
} else if appKey := r.Header.Get("X-APP-KEY"); appKey != "" {
secret := r.Header.Get("X-APP-SECRET")
user = auth.ValidateAppKeyPair(appKey, secret, db)
}
if user == nil {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "invalid credentials")
return
}
// --- 2) Resolve organization (user principal) ---
// A) Try X-Org-ID if present
if s := r.Header.Get("X-Org-ID"); s != "" {
oid, err := uuid.Parse(s)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_org_id", "X-Org-ID must be a UUID")
return
}
var o models.Organization
if err := db.First(&o, "id = ?", oid).Error; err != nil {
// Header provided but org not found
utils.WriteError(w, http.StatusUnauthorized, "org_forbidden", "organization not found")
return
}
// Verify membership
if !userIsMember(db, user.ID, o.ID) {
utils.WriteError(w, http.StatusUnauthorized, "org_forbidden", "user is not a member of specified org")
return
}
org = &o
}
// B) If still no org and requireOrg==true, try chi URL param {id}
if org == nil && requireOrg {
if sid := chi.URLParam(r, "id"); sid != "" {
if oid, err := uuid.Parse(sid); err == nil {
var o models.Organization
if err := db.First(&o, "id = ?", oid).Error; err == nil && userIsMember(db, user.ID, o.ID) {
org = &o
} else {
utils.WriteError(w, http.StatusUnauthorized, "org_forbidden", "user is not a member of specified org")
return
}
}
}
}
// C) Single-membership fallback (only if requireOrg==true and still nil)
if org == nil && requireOrg {
var ms []models.Membership
if err := db.Where("user_id = ?", user.ID).Find(&ms).Error; err == nil && len(ms) == 1 {
var o models.Organization
if err := db.First(&o, "id = ?", ms[0].OrganizationID).Error; err == nil {
org = &o
}
}
}
// D) Final check
if requireOrg && org == nil {
utils.WriteError(w, http.StatusUnauthorized, "org_required", "specify X-Org-ID or use an endpoint that does not require org")
return
}
// Populate roles if an org was resolved (optional for org-optional endpoints)
if org != nil {
roles = userRolesInOrg(db, user.ID, org.ID)
if len(roles) == 0 {
utils.WriteError(w, http.StatusForbidden, "forbidden", "no roles in organization")
return
}
}
}
// --- 3) Attach to context and proceed ---
ctx := r.Context()
if user != nil {
ctx = WithUser(ctx, user)
}
if org != nil {
ctx = WithOrg(ctx, org)
}
if roles != nil {
ctx = WithRoles(ctx, roles)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func userIsMember(db *gorm.DB, userID, orgID uuid.UUID) bool {
var count int64
db.Model(&models.Membership{}).
Where("user_id = ? AND organization_id = ?", userID, orgID).
Count(&count)
return count > 0
}
func userRolesInOrg(db *gorm.DB, userID, orgID uuid.UUID) []string {
var m models.Membership
if err := db.Where("user_id = ? AND organization_id = ?", userID, orgID).First(&m).Error; err == nil {
switch m.Role {
case "owner":
return []string{"role:owner", "role:admin", "role:member"}
case "admin":
return []string{"role:admin", "role:member"}
default:
return []string{"role:member"}
}
}
return nil
}

View File

@@ -0,0 +1,45 @@
package httpmiddleware
import (
"context"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
)
type ctxKey string
const (
ctxUserKey ctxKey = "ctx_user"
ctxOrgKey ctxKey = "ctx_org"
ctxRolesKey ctxKey = "ctx_roles" // []string, user roles in current org
)
func WithUser(ctx context.Context, u *models.User) context.Context {
return context.WithValue(ctx, ctxUserKey, u)
}
func WithOrg(ctx context.Context, o *models.Organization) context.Context {
return context.WithValue(ctx, ctxOrgKey, o)
}
func WithRoles(ctx context.Context, roles []string) context.Context {
return context.WithValue(ctx, ctxRolesKey, roles)
}
func UserFrom(ctx context.Context) (*models.User, bool) {
u, ok := ctx.Value(ctxUserKey).(*models.User)
return u, ok && u != nil
}
func OrgFrom(ctx context.Context) (*models.Organization, bool) {
o, ok := ctx.Value(ctxOrgKey).(*models.Organization)
return o, ok && o != nil
}
func OrgIDFrom(ctx context.Context) (uuid.UUID, bool) {
if o, ok := OrgFrom(ctx); ok {
return o.ID, true
}
return uuid.Nil, false
}
func RolesFrom(ctx context.Context) ([]string, bool) {
r, ok := ctx.Value(ctxRolesKey).([]string)
return r, ok && r != nil
}

View File

@@ -0,0 +1,45 @@
package httpmiddleware
import (
"net/http"
"github.com/glueops/autoglue/internal/utils"
)
func RequireRole(minRole string) func(http.Handler) http.Handler {
// order: owner > admin > member
rank := map[string]int{
"role:member": 1,
"role:admin": 2,
"role:owner": 3,
"org:machine": 2,
"org:machine:ro": 1,
}
need := map[string]bool{
"member": true, "admin": true, "owner": true,
}
if !need[minRole] {
minRole = "member"
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
roles, ok := RolesFrom(r.Context())
if !ok || len(roles) == 0 {
utils.WriteError(w, http.StatusForbidden, "forbidden", "no roles in context")
return
}
max := 0
for _, ro := range roles {
if rank[ro] > max {
max = rank[ro]
}
}
if max < rank["role:"+minRole] {
utils.WriteError(w, http.StatusForbidden, "forbidden", "insufficient role")
return
}
next.ServeHTTP(w, r)
})
}
}

35
internal/api/mw_logger.go Normal file
View File

@@ -0,0 +1,35 @@
package api
import (
"net/http"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/rs/zerolog/log"
)
func zeroLogMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
next.ServeHTTP(ww, r)
dur := time.Since(start)
ev := log.Info()
if ww.Status() >= 500 {
ev = log.Error()
}
ev.
Str("remote_ip", r.RemoteAddr).
Str("request_id", middleware.GetReqID(r.Context())).
Str("method", r.Method).
Str("path", r.URL.Path).
Int("status", ww.Status()).
Int("bytes", ww.BytesWritten()).
Dur("duration", dur).
Msg("http_request")
})
}
}

View File

@@ -0,0 +1,63 @@
package api
import (
"net/http"
"strings"
"github.com/glueops/autoglue/internal/config"
)
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// HSTS (enable only over TLS/behind HTTPS)
// HSTS only when not in dev and over TLS/behind a proxy that terminates TLS
if !config.IsDev() {
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
}
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Permissions-Policy", "geolocation=(), camera=(), microphone=(), interest-cohort=()")
if config.IsDev() {
// --- Relaxed CSP for Vite dev server & Google Fonts ---
// Allows inline/eval for React Refresh preamble, HMR websocket, and fonts.
// Tighten these as you move to prod or self-host fonts.
w.Header().Set("Content-Security-Policy", strings.Join([]string{
"default-src 'self'",
"base-uri 'self'",
"form-action 'self'",
// Vite dev & inline preamble/eval:
"script-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:5173",
// allow dev style + Google Fonts
"style-src 'self' 'unsafe-inline' http://localhost:5173 https://fonts.googleapis.com",
"img-src 'self' data: blob:",
// Google font files
"font-src 'self' data: https://fonts.gstatic.com",
// HMR connections
"connect-src 'self' http://localhost:5173 ws://localhost:5173 ws://localhost:8080",
"frame-ancestors 'none'",
}, "; "))
} else {
// --- Strict CSP for production ---
// If you keep using Google Fonts in prod, add:
// style-src ... https://fonts.googleapis.com
// font-src ... https://fonts.gstatic.com
// Recommended: self-host fonts in prod and keep these tight.
w.Header().Set("Content-Security-Policy", strings.Join([]string{
"default-src 'self'",
"base-uri 'self'",
"form-action 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: blob:",
"font-src 'self' data: https://fonts.gstatic.com",
"connect-src 'self'",
"frame-ancestors 'none'",
}, "; "))
}
next.ServeHTTP(w, r)
})
}

195
internal/api/routes.go Normal file
View File

@@ -0,0 +1,195 @@
package api
import (
"fmt"
"net/http"
httpPprof "net/http/pprof"
"os"
"time"
"github.com/glueops/autoglue/docs"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/handlers"
"github.com/glueops/autoglue/internal/web"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
"gorm.io/gorm"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
httpSwagger "github.com/swaggo/http-swagger/v2"
)
func NewRouter(db *gorm.DB) http.Handler {
zerolog.TimeFieldFormat = time.RFC3339
l := log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05"})
log.Logger = l
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(zeroLogMiddleware())
r.Use(middleware.Recoverer)
r.Use(SecurityHeaders)
r.Use(requestBodyLimit(10 << 20))
r.Use(httprate.LimitByIP(100, 1*time.Minute))
allowed := getAllowedOrigins()
r.Use(cors.Handler(cors.Options{
AllowedOrigins: allowed,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{
"Content-Type",
"Authorization",
"X-Org-ID",
"X-API-KEY",
"X-ORG-KEY",
"X-ORG-SECRET",
},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 600,
}))
r.Use(middleware.AllowContentType("application/json"))
r.Get("/.well-known/jwks.json", handlers.JWKSHandler)
r.Route("/api", func(api chi.Router) {
api.Route("/v1", func(v1 chi.Router) {
authUser := httpmiddleware.AuthMiddleware(db, false)
authOrg := httpmiddleware.AuthMiddleware(db, true)
// Also serving a versioned JWKS for swagger, which uses BasePath
v1.Get("/.well-known/jwks.json", handlers.JWKSHandler)
v1.Route("/auth", func(a chi.Router) {
a.Post("/{provider}/start", handlers.AuthStart(db))
a.Get("/{provider}/callback", handlers.AuthCallback(db))
a.Post("/refresh", handlers.Refresh(db))
a.Post("/logout", handlers.Logout(db))
})
v1.Route("/me", func(me chi.Router) {
me.Use(authUser)
me.Get("/", handlers.GetMe(db))
me.Patch("/", handlers.UpdateMe(db))
me.Get("/api-keys", handlers.ListUserAPIKeys(db))
me.Post("/api-keys", handlers.CreateUserAPIKey(db))
me.Delete("/api-keys/{id}", handlers.DeleteUserAPIKey(db))
})
v1.Route("/orgs", func(o chi.Router) {
o.Use(authUser)
o.Get("/", handlers.ListMyOrgs(db))
o.Post("/", handlers.CreateOrg(db))
o.Group(func(og chi.Router) {
og.Use(authOrg)
og.Get("/{id}", handlers.GetOrg(db))
og.Patch("/{id}", handlers.UpdateOrg(db))
og.Delete("/{id}", handlers.DeleteOrg(db))
// members
og.Get("/{id}/members", handlers.ListMembers(db))
og.Post("/{id}/members", handlers.AddOrUpdateMember(db))
og.Delete("/{id}/members/{user_id}", handlers.RemoveMember(db))
// org-scoped key/secret pair
og.Get("/{id}/api-keys", handlers.ListOrgKeys(db))
og.Post("/{id}/api-keys", handlers.CreateOrgKey(db))
og.Delete("/{id}/api-keys/{key_id}", handlers.DeleteOrgKey(db))
})
})
v1.Route("/ssh", func(s chi.Router) {
s.Use(authOrg)
s.Get("/", handlers.ListPublicSshKeys(db))
s.Post("/", handlers.CreateSSHKey(db))
s.Get("/{id}", handlers.GetSSHKey(db))
s.Delete("/{id}", handlers.DeleteSSHKey(db))
s.Get("/{id}/download", handlers.DownloadSSHKey(db))
})
v1.Route("/servers", func(s chi.Router) {
s.Use(authOrg)
s.Get("/", handlers.ListServers(db))
s.Post("/", handlers.CreateServer(db))
s.Get("/{id}", handlers.GetServer(db))
s.Patch("/{id}", handlers.UpdateServer(db))
s.Delete("/{id}", handlers.DeleteServer(db))
})
v1.Route("/taints", func(s chi.Router) {
s.Use(authOrg)
s.Get("/", handlers.ListTaints(db))
s.Post("/", handlers.CreateTaint(db))
s.Get("/{id}", handlers.GetTaint(db))
s.Patch("/{id}", handlers.UpdateTaint(db))
s.Delete("/{id}", handlers.DeleteTaint(db))
})
})
})
if config.IsDebug() {
r.Route("/debug/pprof", func(pr chi.Router) {
pr.Get("/", httpPprof.Index)
pr.Get("/cmdline", httpPprof.Cmdline)
pr.Get("/profile", httpPprof.Profile)
pr.Get("/symbol", httpPprof.Symbol)
pr.Get("/trace", httpPprof.Trace)
pr.Handle("/allocs", httpPprof.Handler("allocs"))
pr.Handle("/block", httpPprof.Handler("block"))
pr.Handle("/goroutine", httpPprof.Handler("goroutine"))
pr.Handle("/heap", httpPprof.Handler("heap"))
pr.Handle("/mutex", httpPprof.Handler("mutex"))
pr.Handle("/threadcreate", httpPprof.Handler("threadcreate"))
})
}
if config.IsSwaggerEnabled() {
r.Get("/swagger/*", httpSwagger.Handler(
httpSwagger.URL("swagger.json"),
))
r.Get("/swagger/swagger.json", serveSwaggerFromEmbed(docs.SwaggerJSON, "application/json"))
r.Get("/swagger/swagger.yaml", serveSwaggerFromEmbed(docs.SwaggerYAML, "application/x-yaml"))
}
if config.IsUIDev() {
fmt.Println("Running in development mode")
// Dev: isolate proxy from chi middlewares so WS upgrade can hijack.
proxy, err := web.DevProxy("http://localhost:5173")
if err != nil {
log.Error().Err(err).Msg("dev proxy init failed")
return r // fallback
}
mux := http.NewServeMux()
// Send API/Swagger/pprof to chi
mux.Handle("/api/", r)
mux.Handle("/api", r)
mux.Handle("/swagger/", r)
mux.Handle("/debug/pprof/", r)
// Everything else (/, /brand-preview, assets) → proxy (no middlewares)
mux.Handle("/", proxy)
return mux
} else {
fmt.Println("Running in production mode")
if h, err := web.SPAHandler(); err == nil {
r.NotFound(h.ServeHTTP)
} else {
log.Error().Err(err).Msg("spa handler init failed")
}
}
return r
}

45
internal/api/utils.go Normal file
View File

@@ -0,0 +1,45 @@
package api
import (
"net/http"
"os"
"strings"
)
func requestBodyLimit(maxBytes int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
next.ServeHTTP(w, r)
})
}
}
func getAllowedOrigins() []string {
if v := os.Getenv("CORS_ALLOWED_ORIGINS"); v != "" {
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
s := strings.TrimSpace(p)
if s != "" {
out = append(out, s)
}
}
if len(out) > 0 {
return out
}
}
// Defaults (dev)
return []string{
"http://localhost:5173",
"http://localhost:8080",
}
}
func serveSwaggerFromEmbed(data []byte, contentType string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", contentType)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
}

46
internal/app/runtime.go Normal file
View File

@@ -0,0 +1,46 @@
package app
import (
"log"
"github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/models"
"gorm.io/gorm"
)
type Runtime struct {
Cfg config.Config
DB *gorm.DB
}
func NewRuntime() *Runtime {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
d := db.Open(cfg.DbURL)
err = db.Run(d,
&models.MasterKey{},
&models.SigningKey{},
&models.User{},
&models.Organization{},
&models.Account{},
&models.Membership{},
&models.APIKey{},
&models.UserEmail{},
&models.RefreshToken{},
&models.OrganizationKey{},
&models.SshKey{},
&models.Server{},
&models.Taint{},
)
if err != nil {
log.Fatalf("Error initializing database: %v", err)
}
return &Runtime{
Cfg: cfg,
DB: d,
}
}

38
internal/auth/hash.go Normal file
View File

@@ -0,0 +1,38 @@
package auth
import (
"crypto/sha256"
"encoding/hex"
"errors"
"time"
"github.com/alexedwards/argon2id"
)
func SHA256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
var argonParams = &argon2id.Params{
Memory: 64 * 1024, // 64MB
Iterations: 3,
Parallelism: 2,
SaltLength: 16,
KeyLength: 32,
}
func HashSecretArgon2id(plain string) (string, error) {
return argon2id.CreateHash(plain, argonParams)
}
func VerifySecretArgon2id(encodedHash, plain string) (bool, error) {
if encodedHash == "" {
return false, errors.New("empty hash")
}
return argon2id.ComparePasswordAndHash(plain, encodedHash)
}
func NotExpired(expiresAt *time.Time) bool {
return expiresAt == nil || time.Now().Before(*expiresAt)
}

42
internal/auth/issue.go Normal file
View File

@@ -0,0 +1,42 @@
package auth
import (
"crypto/rand"
"encoding/base64"
"time"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
// URL-safe, no padding
return base64.RawURLEncoding.EncodeToString(b), nil
}
// IssueUserAPIKey creates a single-token user API key (X-API-KEY)
func IssueUserAPIKey(db *gorm.DB, userID uuid.UUID, name string, ttl *time.Duration) (plaintext string, rec models.APIKey, err error) {
plaintext, err = randomToken(32)
if err != nil {
return "", models.APIKey{}, err
}
rec = models.APIKey{
Name: name,
Scope: "user",
UserID: &userID,
KeyHash: SHA256Hex(plaintext), // deterministic lookup
}
if ttl != nil {
ex := time.Now().Add(*ttl)
rec.ExpiresAt = &ex
}
if err = db.Create(&rec).Error; err != nil {
return "", models.APIKey{}, err
}
return plaintext, rec, nil
}

View File

@@ -0,0 +1,71 @@
package auth
import (
"crypto/ed25519"
"crypto/rsa"
"encoding/base64"
"fmt"
"math/big"
)
// base64url (no padding)
func b64url(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
// convert small int (RSA exponent) to big-endian bytes
func fromInt(i int) []byte {
var x big.Int
x.SetInt64(int64(i))
return x.Bytes()
}
// --- public accessors for JWKS ---
// KeyMeta is a minimal metadata view exposed for JWKS rendering.
type KeyMeta struct {
Alg string
}
// MetaFor returns minimal metadata (currently the alg) for a given kid.
// If not found, returns zero value (Alg == "").
func MetaFor(kid string) KeyMeta {
kc.mu.RLock()
defer kc.mu.RUnlock()
if m, ok := kc.meta[kid]; ok {
return KeyMeta{Alg: m.Alg}
}
return KeyMeta{}
}
// KcCopy invokes fn with a shallow copy of the public key map (kid -> public key instance).
// Useful to iterate without holding the lock during JSON building.
func KcCopy(fn func(map[string]interface{})) {
kc.mu.RLock()
defer kc.mu.RUnlock()
out := make(map[string]interface{}, len(kc.pub))
for kid, pk := range kc.pub {
out[kid] = pk
}
fmt.Println(out)
fn(out)
}
// PubToJWK converts a parsed public key into bare JWK parameters + kty.
// - RSA: returns n/e (base64url) and kty="RSA"
// - Ed25519: returns x (base64url) and kty="OKP"
func PubToJWK(_kid, _alg string, pub any) (map[string]string, string) {
switch k := pub.(type) {
case *rsa.PublicKey:
return map[string]string{
"n": b64url(k.N.Bytes()),
"e": b64url(fromInt(k.E)),
}, "RSA"
case ed25519.PublicKey:
return map[string]string{
"x": b64url([]byte(k)),
}, "OKP"
default:
return nil, ""
}
}

View File

@@ -0,0 +1,55 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
type IssueOpts struct {
Subject string
Issuer string
Audience string
TTL time.Duration
Claims map[string]any // extra app claims
}
func IssueAccessToken(opts IssueOpts) (string, error) {
kc.mu.RLock()
defer kc.mu.RUnlock()
if kc.selPriv == nil || kc.selKid == "" || kc.selAlg == "" {
return "", errors.New("no active signing key")
}
claims := jwt.MapClaims{
"iss": opts.Issuer,
"aud": opts.Audience,
"sub": opts.Subject,
"iat": time.Now().Unix(),
"exp": time.Now().Add(opts.TTL).Unix(),
}
for k, v := range opts.Claims {
claims[k] = v
}
var method jwt.SigningMethod
switch kc.selAlg {
case "RS256":
method = jwt.SigningMethodRS256
case "RS384":
method = jwt.SigningMethodRS384
case "RS512":
method = jwt.SigningMethodRS512
case "EdDSA":
method = jwt.SigningMethodEdDSA
default:
return "", errors.New("unsupported alg")
}
token := jwt.NewWithClaims(method, claims)
token.Header["kid"] = kc.selKid
return token.SignedString(kc.selPriv)
}

138
internal/auth/jwt_signer.go Normal file
View File

@@ -0,0 +1,138 @@
package auth
import (
"crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"sync"
"time"
"github.com/glueops/autoglue/internal/keys"
"github.com/glueops/autoglue/internal/models"
"gorm.io/gorm"
)
type keyCache struct {
mu sync.RWMutex
pub map[string]interface{} // kid -> public key object
meta map[string]models.SigningKey
selKid string
selAlg string
selPriv any
}
var kc keyCache
// Refresh loads active keys into memory. Call on startup and periodically (ticker/cron).
func Refresh(db *gorm.DB, encKeyB64 string) error {
var rows []models.SigningKey
if err := db.Where("is_active = true AND (expires_at IS NULL OR expires_at > ?)", time.Now()).
Order("created_at desc").Find(&rows).Error; err != nil {
return err
}
pub := make(map[string]interface{}, len(rows))
meta := make(map[string]models.SigningKey, len(rows))
var selKid string
var selAlg string
var selPriv any
for i, r := range rows {
// parse public
block, _ := pem.Decode([]byte(r.PublicPEM))
if block == nil {
continue
}
var pubKey any
switch r.Alg {
case "RS256", "RS384", "RS512":
pubKey, _ = x509.ParsePKCS1PublicKey(block.Bytes)
if pubKey == nil {
// also allow PKIX format
if k, err := x509.ParsePKIXPublicKey(block.Bytes); err == nil {
pubKey = k
}
}
case "EdDSA":
k, err := x509.ParsePKIXPublicKey(block.Bytes)
if err == nil {
if edk, ok := k.(ed25519.PublicKey); ok {
pubKey = edk
}
}
}
if pubKey == nil {
continue
}
pub[r.Kid] = pubKey
meta[r.Kid] = r
// pick first row as current signer (most recent because of order desc)
if i == 0 {
privPEM := r.PrivatePEM
// decrypt if necessary
if len(privPEM) > 10 && privPEM[:10] == "enc:aesgcm" {
pt, err := keysDecrypt(encKeyB64, privPEM)
if err != nil {
continue
}
privPEM = string(pt)
}
blockPriv, _ := pem.Decode([]byte(privPEM))
if blockPriv == nil {
continue
}
switch r.Alg {
case "RS256", "RS384", "RS512":
if k, err := x509.ParsePKCS1PrivateKey(blockPriv.Bytes); err == nil {
selPriv = k
selAlg = r.Alg
selKid = r.Kid
} else if kAny, err := x509.ParsePKCS8PrivateKey(blockPriv.Bytes); err == nil {
if k, ok := kAny.(*rsa.PrivateKey); ok {
selPriv = k
selAlg = r.Alg
selKid = r.Kid
}
}
case "EdDSA":
if kAny, err := x509.ParsePKCS8PrivateKey(blockPriv.Bytes); err == nil {
if k, ok := kAny.(ed25519.PrivateKey); ok {
selPriv = k
selAlg = r.Alg
selKid = r.Kid
}
}
}
}
}
kc.mu.Lock()
defer kc.mu.Unlock()
kc.pub = pub
kc.meta = meta
kc.selKid = selKid
kc.selAlg = selAlg
kc.selPriv = selPriv
return nil
}
func keysDecrypt(encKey, enc string) ([]byte, error) {
return keysDecryptImpl(encKey, enc)
}
// indirection for same package
var keysDecryptImpl = func(encKey, enc string) ([]byte, error) {
return nil, errors.New("not wired")
}
// Wire up from keys package
func init() {
keysDecryptImpl = keysDecryptShim
}
func keysDecryptShim(encKey, enc string) ([]byte, error) {
return keys.Decrypt(encKey, enc)
}

View File

@@ -0,0 +1,56 @@
package auth
import (
"github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/models"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ValidateJWT verifies RS256/RS384/RS512/EdDSA tokens using the in-memory key cache.
// It honors kid when present, and falls back to any active key.
func ValidateJWT(tokenStr string, db *gorm.DB) *models.User {
cfg, _ := config.Load()
parser := jwt.NewParser(
jwt.WithIssuer(cfg.JWTIssuer),
jwt.WithAudience(cfg.JWTAudience),
jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "EdDSA"}),
)
token, err := parser.Parse(tokenStr, func(t *jwt.Token) (any, error) {
// Resolve by kid first
kid, _ := t.Header["kid"].(string)
kc.mu.RLock()
defer kc.mu.RUnlock()
if kid != "" {
if k, ok := kc.pub[kid]; ok {
return k, nil
}
}
// Fallback: try first active key
for _, k := range kc.pub {
return k, nil
}
return nil, jwt.ErrTokenUnverifiable
})
if err != nil || !token.Valid {
return nil
}
claims, _ := token.Claims.(jwt.MapClaims)
sub, _ := claims["sub"].(string)
uid, err := uuid.Parse(sub)
if err != nil {
return nil
}
var u models.User
if err := db.First(&u, "id = ? AND is_disabled = false", uid).Error; err != nil {
return nil
}
return &u
}

105
internal/auth/refresh.go Normal file
View File

@@ -0,0 +1,105 @@
package auth
import (
"crypto/rand"
"encoding/base64"
"errors"
"time"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
// random opaque token (returned to client once)
func generateOpaqueToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
type RefreshPair struct {
Plain string
Record models.RefreshToken
}
// Issue a new refresh token (new family if familyID == nil)
func IssueRefreshToken(db *gorm.DB, userID uuid.UUID, ttl time.Duration, familyID *uuid.UUID) (RefreshPair, error) {
plain, err := generateOpaqueToken(32)
if err != nil {
return RefreshPair{}, err
}
hash, err := HashSecretArgon2id(plain)
if err != nil {
return RefreshPair{}, err
}
fid := uuid.New()
if familyID != nil {
fid = *familyID
}
rec := models.RefreshToken{
UserID: userID,
FamilyID: fid,
TokenHash: hash,
ExpiresAt: time.Now().Add(ttl),
}
if err := db.Create(&rec).Error; err != nil {
return RefreshPair{}, err
}
return RefreshPair{Plain: plain, Record: rec}, nil
}
// ValidateRefreshToken refresh token; returns record if valid & not revoked/expired
func ValidateRefreshToken(db *gorm.DB, plain string) (*models.RefreshToken, error) {
if plain == "" {
return nil, errors.New("empty")
}
// var rec models.RefreshToken
// We can't query by hash w/ Argon; scan candidates by expiry window. Keep small TTL (e.g. 30d).
if err := db.Where("expires_at > ? AND revoked_at IS NULL", time.Now()).
Find(&[]models.RefreshToken{}).Error; err != nil {
return nil, err
}
// Better: add a prefix column to narrow scan; omitted for brevity.
// Pragmatic approach: single SELECT per token:
// Add a TokenHashSHA256 column for deterministic lookup if you want O(1). (Optional)
// Minimal: iterate limited set; for simplicity we fetch by created window:
var recs []models.RefreshToken
if err := db.Where("expires_at > ? AND revoked_at IS NULL", time.Now()).
Order("created_at desc").Limit(500).Find(&recs).Error; err != nil {
return nil, err
}
for _, r := range recs {
ok, _ := VerifySecretArgon2id(r.TokenHash, plain)
if ok {
return &r, nil
}
}
return nil, errors.New("invalid")
}
// RevokeFamily revokes all tokens in a family (logout everywhere)
func RevokeFamily(db *gorm.DB, familyID uuid.UUID) error {
now := time.Now()
return db.Model(&models.RefreshToken{}).
Where("family_id = ? AND revoked_at IS NULL", familyID).
Update("revoked_at", &now).Error
}
// RotateRefreshToken replaces one token with a fresh one within the same family
func RotateRefreshToken(db *gorm.DB, used *models.RefreshToken, ttl time.Duration) (RefreshPair, error) {
// revoke the used token (one-time use)
now := time.Now()
if err := db.Model(&models.RefreshToken{}).
Where("id = ? AND revoked_at IS NULL", used.ID).
Update("revoked_at", &now).Error; err != nil {
return RefreshPair{}, err
}
return IssueRefreshToken(db, used.UserID, ttl, &used.FamilyID)
}

View File

@@ -0,0 +1,88 @@
package auth
import (
"time"
"github.com/glueops/autoglue/internal/models"
"gorm.io/gorm"
)
// ValidateAPIKey validates a single-token user API key sent via X-API-KEY.
func ValidateAPIKey(rawKey string, db *gorm.DB) *models.User {
if rawKey == "" {
return nil
}
digest := SHA256Hex(rawKey)
var k models.APIKey
if err := db.
Where("key_hash = ? AND scope = ? AND (expires_at IS NULL OR expires_at > ?)", digest, "user", time.Now()).
First(&k).Error; err != nil {
return nil
}
if k.UserID == nil {
return nil
}
var u models.User
if err := db.First(&u, "id = ? AND is_disabled = false", *k.UserID).Error; err != nil {
return nil
}
// Optional: touch last_used_at here if you've added it on the model.
return &u
}
// ValidateAppKeyPair validates a user key/secret pair via X-APP-KEY / X-APP-SECRET.
func ValidateAppKeyPair(appKey, secret string, db *gorm.DB) *models.User {
if appKey == "" || secret == "" {
return nil
}
digest := SHA256Hex(appKey)
var k models.APIKey
if err := db.
Where("key_hash = ? AND scope = ? AND (expires_at IS NULL OR expires_at > ?)", digest, "user", time.Now()).
First(&k).Error; err != nil {
return nil
}
ok, _ := VerifySecretArgon2id(zeroIfNil(k.SecretHash), secret)
if !ok || k.UserID == nil {
return nil
}
var u models.User
if err := db.First(&u, "id = ? AND is_disabled = false", *k.UserID).Error; err != nil {
return nil
}
return &u
}
// ValidateOrgKeyPair validates an org key/secret via X-ORG-KEY / X-ORG-SECRET.
func ValidateOrgKeyPair(orgKey, secret string, db *gorm.DB) *models.Organization {
if orgKey == "" || secret == "" {
return nil
}
digest := SHA256Hex(orgKey)
var k models.APIKey
if err := db.
Where("key_hash = ? AND scope = ? AND (expires_at IS NULL OR expires_at > ?)", digest, "org", time.Now()).
First(&k).Error; err != nil {
return nil
}
ok, _ := VerifySecretArgon2id(zeroIfNil(k.SecretHash), secret)
if !ok || k.OrgID == nil {
return nil
}
var o models.Organization
if err := db.First(&o, "id = ?", *k.OrgID).Error; err != nil {
return nil
}
return &o
}
// local helper; avoids nil-deref when comparing secrets
func zeroIfNil(s *string) string {
if s == nil {
return ""
}
return *s
}

194
internal/config/config.go Normal file
View File

@@ -0,0 +1,194 @@
package config
import (
"errors"
"fmt"
"strings"
"sync"
"github.com/joho/godotenv"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
)
type Config struct {
DbURL string
Port string
Host string
JWTIssuer string
JWTAudience string
JWTPrivateEncKey string
OAuthRedirectBase string
GoogleClientID string
GoogleClientSecret string
GithubClientID string
GithubClientSecret string
UIDev bool
Env string
Debug bool
Swagger bool
}
var (
once sync.Once
cached Config
loadErr error
)
func Load() (Config, error) {
once.Do(func() {
_ = godotenv.Load()
// Use a private viper to avoid global mutation/races
v := viper.New()
// Defaults
v.SetDefault("bind.address", "127.0.0.1")
v.SetDefault("bind.port", "8080")
v.SetDefault("database.url", "postgres://user:pass@localhost:5432/db?sslmode=disable")
v.SetDefault("ui.dev", false)
v.SetDefault("env", "development")
v.SetDefault("debug", false)
v.SetDefault("swagger", false)
// Env setup and binding
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
keys := []string{
"bind.address",
"bind.port",
"database.url",
"jwt.issuer",
"jwt.audience",
"jwt.private.enc.key",
"oauth.redirect.base",
"google.client.id",
"google.client.secret",
"github.client.id",
"github.client.secret",
"ui.dev",
"env",
"debug",
"swagger",
}
for _, k := range keys {
_ = v.BindEnv(k)
}
// Build config
cfg := Config{
DbURL: v.GetString("database.url"),
Port: v.GetString("bind.port"),
Host: v.GetString("bind.address"),
JWTIssuer: v.GetString("jwt.issuer"),
JWTAudience: v.GetString("jwt.audience"),
JWTPrivateEncKey: v.GetString("jwt.private.enc.key"),
OAuthRedirectBase: v.GetString("oauth.redirect.base"),
GoogleClientID: v.GetString("google.client.id"),
GoogleClientSecret: v.GetString("google.client.secret"),
GithubClientID: v.GetString("github.client.id"),
GithubClientSecret: v.GetString("github.client.secret"),
UIDev: v.GetBool("ui.dev"),
Env: v.GetString("env"),
Debug: v.GetBool("debug"),
Swagger: v.GetBool("swagger"),
}
// Validate
if err := validateConfig(cfg); err != nil {
loadErr = err
return
}
cached = cfg
})
return cached, loadErr
}
func validateConfig(cfg Config) error {
var errs []string
// Required general settings
req := map[string]string{
"jwt.issuer": cfg.JWTIssuer,
"jwt.audience": cfg.JWTAudience,
"jwt.private.enc.key": cfg.JWTPrivateEncKey,
"oauth.redirect.base": cfg.OAuthRedirectBase,
}
for k, v := range req {
if strings.TrimSpace(v) == "" {
errs = append(errs, fmt.Sprintf("missing required config key %q (env %s)", k, envNameFromKey(k)))
}
}
// OAuth provider requirements:
googleOK := strings.TrimSpace(cfg.GoogleClientID) != "" && strings.TrimSpace(cfg.GoogleClientSecret) != ""
githubOK := strings.TrimSpace(cfg.GithubClientID) != "" && strings.TrimSpace(cfg.GithubClientSecret) != ""
// If partially configured, report what's missing for each
if !googleOK && (cfg.GoogleClientID != "" || cfg.GoogleClientSecret != "") {
if cfg.GoogleClientID == "" {
errs = append(errs, fmt.Sprintf("google.client.id is missing (env %s) while google.client.secret is set", envNameFromKey("google.client.id")))
}
if cfg.GoogleClientSecret == "" {
errs = append(errs, fmt.Sprintf("google.client.secret is missing (env %s) while google.client.id is set", envNameFromKey("google.client.secret")))
}
}
if !githubOK && (cfg.GithubClientID != "" || cfg.GithubClientSecret != "") {
if cfg.GithubClientID == "" {
errs = append(errs, fmt.Sprintf("github.client.id is missing (env %s) while github.client.secret is set", envNameFromKey("github.client.id")))
}
if cfg.GithubClientSecret == "" {
errs = append(errs, fmt.Sprintf("github.client.secret is missing (env %s) while github.client.id is set", envNameFromKey("github.client.secret")))
}
}
// Enforce minimum: at least one full provider
if !googleOK && !githubOK {
errs = append(errs, "at least one OAuth provider must be fully configured: either Google (google.client.id + google.client.secret) or GitHub (github.client.id + github.client.secret)")
}
if len(errs) > 0 {
return errors.New(strings.Join(errs, "; "))
}
return nil
}
func envNameFromKey(key string) string {
return strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
}
func DebugPrintConfig() {
cfg, _ := Load()
b, err := yaml.Marshal(cfg)
if err != nil {
fmt.Println("error marshalling config:", err)
return
}
fmt.Println("Loaded configuration:")
fmt.Println(string(b))
}
func IsUIDev() bool {
cfg, _ := Load()
return cfg.UIDev
}
func IsDev() bool {
cfg, _ := Load()
return strings.EqualFold(cfg.Env, "development")
}
func IsDebug() bool {
cfg, _ := Load()
return cfg.Debug
}
func IsSwaggerEnabled() bool {
cfg, _ := Load()
return cfg.Swagger
}

17
internal/db/db.go Normal file
View File

@@ -0,0 +1,17 @@
package db
import (
"log"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func Open(dsn string) *gorm.DB {
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Warn)})
if err != nil {
log.Fatalf("failed to connect to db: %v", err)
}
return db
}

25
internal/db/migrate.go Normal file
View File

@@ -0,0 +1,25 @@
package db
import (
"fmt"
"gorm.io/gorm"
)
func Run(db *gorm.DB, models ...any) error {
return db.Transaction(func(tx *gorm.DB) error {
// 0) Extensions
if err := tx.Exec(`CREATE EXTENSION IF NOT EXISTS pgcrypto`).Error; err != nil {
return fmt.Errorf("enable pgcrypto: %w", err)
}
if err := tx.Exec(`CREATE EXTENSION IF NOT EXISTS citext`).Error; err != nil {
return fmt.Errorf("enable citext: %w", err)
}
// 1) AutoMigrate (pass parents before children in caller)
if err := tx.AutoMigrate(models...); err != nil {
return fmt.Errorf("automigrate: %w", err)
}
return nil
})
}

477
internal/handlers/auth.go Normal file
View File

@@ -0,0 +1,477 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/glueops/autoglue/internal/auth"
"github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"golang.org/x/oauth2"
"gorm.io/gorm"
)
type oauthProvider struct {
Name string
Issuer string
Scopes []string
ClientID string
Secret string
}
func providerConfig(cfg config.Config, name string) (oauthProvider, bool) {
switch strings.ToLower(name) {
case "google":
return oauthProvider{
Name: "google",
Issuer: "https://accounts.google.com",
Scopes: []string{oidc.ScopeOpenID, "email", "profile"},
ClientID: cfg.GoogleClientID,
Secret: cfg.GoogleClientSecret,
}, true
case "github":
// GitHub is not a pure OIDC provider; we use OAuth2 + user email API
return oauthProvider{
Name: "github",
Issuer: "github",
Scopes: []string{"read:user", "user:email"},
ClientID: cfg.GithubClientID, Secret: cfg.GithubClientSecret,
}, true
}
return oauthProvider{}, false
}
// AuthStart godoc
// @ID AuthStart
// @Summary Begin social login
// @Description Returns provider authorization URL for the frontend to redirect
// @Tags Auth
// @Param provider path string true "google|github"
// @Produce json
// @Success 200 {object} dto.AuthStartResponse
// @Router /auth/{provider}/start [post]
func AuthStart(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cfg, _ := config.Load()
provider := strings.ToLower(chi.URLParam(r, "provider"))
p, ok := providerConfig(cfg, provider)
if !ok || p.ClientID == "" || p.Secret == "" {
utils.WriteError(w, http.StatusBadRequest, "unsupported_provider", "provider not configured")
return
}
redirect := cfg.OAuthRedirectBase + "/api/v1/auth/" + p.Name + "/callback"
// Optional SPA hints to be embedded into state
mode := r.URL.Query().Get("mode") // "spa" enables postMessage callback page
origin := r.URL.Query().Get("origin") // e.g. http://localhost:5173
state := uuid.NewString()
if mode == "spa" && origin != "" {
state = state + "|mode=spa|origin=" + url.QueryEscape(origin)
}
var authURL string
if p.Issuer == "github" {
o := &oauth2.Config{
ClientID: p.ClientID,
ClientSecret: p.Secret,
RedirectURL: redirect,
Scopes: p.Scopes,
Endpoint: oauth2.Endpoint{
AuthURL: "https://github.com/login/oauth/authorize",
TokenURL: "https://github.com/login/oauth/access_token",
},
}
authURL = o.AuthCodeURL(state, oauth2.AccessTypeOffline)
} else {
// Google OIDC
ctx := context.Background()
prov, err := oidc.NewProvider(ctx, p.Issuer)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "oidc_discovery_failed", err.Error())
return
}
o := &oauth2.Config{
ClientID: p.ClientID,
ClientSecret: p.Secret,
RedirectURL: redirect,
Endpoint: prov.Endpoint(),
Scopes: p.Scopes,
}
authURL = o.AuthCodeURL(state, oauth2.AccessTypeOffline)
}
utils.WriteJSON(w, http.StatusOK, dto.AuthStartResponse{AuthURL: authURL})
}
}
// AuthCallback godoc
// @ID AuthCallback
// @Summary Handle social login callback
// @Tags Auth
// @Param provider path string true "google|github"
// @Produce json
// @Success 200 {object} dto.TokenPair
// @Router /auth/{provider}/callback [get]
func AuthCallback(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cfg, _ := config.Load()
provider := strings.ToLower(chi.URLParam(r, "provider"))
p, ok := providerConfig(cfg, provider)
if !ok {
utils.WriteError(w, http.StatusBadRequest, "unsupported_provider", "provider not configured")
return
}
code := r.URL.Query().Get("code")
if code == "" {
utils.WriteError(w, http.StatusBadRequest, "invalid_request", "missing code")
return
}
redirect := cfg.OAuthRedirectBase + "/api/v1/auth/" + p.Name + "/callback"
var email, display, subject string
if p.Issuer == "github" {
// OAuth2 code exchange
o := &oauth2.Config{
ClientID: p.ClientID,
ClientSecret: p.Secret,
RedirectURL: redirect,
Scopes: p.Scopes,
Endpoint: oauth2.Endpoint{
AuthURL: "https://github.com/login/oauth/authorize",
TokenURL: "https://github.com/login/oauth/access_token",
},
}
tok, err := o.Exchange(r.Context(), code)
if err != nil {
utils.WriteError(w, http.StatusUnauthorized, "exchange_failed", err.Error())
return
}
// Fetch user primary email
req, _ := http.NewRequest("GET", "https://api.github.com/user/emails", nil)
req.Header.Set("Authorization", "token "+tok.AccessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil || resp.StatusCode != 200 {
utils.WriteError(w, http.StatusUnauthorized, "email_fetch_failed", "github user/emails")
return
}
defer resp.Body.Close()
var emails []struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
}
if err := json.NewDecoder(resp.Body).Decode(&emails); err != nil || len(emails) == 0 {
utils.WriteError(w, http.StatusUnauthorized, "email_parse_failed", err.Error())
return
}
email = emails[0].Email
for _, e := range emails {
if e.Primary {
email = e.Email
break
}
}
subject = "github:" + email
display = strings.Split(email, "@")[0]
} else {
// Google OIDC
oidcProv, err := oidc.NewProvider(r.Context(), p.Issuer)
if err != nil {
utils.WriteError(w, 500, "oidc_discovery_failed", err.Error())
return
}
o := &oauth2.Config{
ClientID: p.ClientID,
ClientSecret: p.Secret,
RedirectURL: redirect,
Endpoint: oidcProv.Endpoint(),
Scopes: p.Scopes,
}
tok, err := o.Exchange(r.Context(), code)
if err != nil {
utils.WriteError(w, 401, "exchange_failed", err.Error())
return
}
verifier := oidcProv.Verifier(&oidc.Config{ClientID: p.ClientID})
rawIDToken, ok := tok.Extra("id_token").(string)
if !ok {
utils.WriteError(w, 401, "no_id_token", "")
return
}
idt, err := verifier.Verify(r.Context(), rawIDToken)
if err != nil {
utils.WriteError(w, 401, "id_token_invalid", err.Error())
return
}
var claims struct {
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
Sub string `json:"sub"`
}
if err := idt.Claims(&claims); err != nil {
utils.WriteError(w, 401, "claims_parse_error", err.Error())
return
}
email = strings.ToLower(claims.Email)
display = claims.Name
subject = "google:" + claims.Sub
}
// Upsert Account + User; domain auto-join (member)
user, err := upsertAccountAndUser(db, p.Name, subject, email, display)
if err != nil {
utils.WriteError(w, 500, "account_upsert_failed", err.Error())
return
}
// Org auto-join: Organization.Domain == email domain
_ = ensureAutoMembership(db, user.ID, email)
// Issue tokens
accessTTL := 1 * time.Hour
refreshTTL := 30 * 24 * time.Hour
access, err := auth.IssueAccessToken(auth.IssueOpts{
Subject: user.ID.String(),
Issuer: cfg.JWTIssuer,
Audience: cfg.JWTAudience,
TTL: accessTTL,
Claims: map[string]any{
"email": email,
"name": display,
},
})
if err != nil {
utils.WriteError(w, 500, "issue_access_failed", err.Error())
return
}
rp, err := auth.IssueRefreshToken(db, user.ID, refreshTTL, nil)
if err != nil {
utils.WriteError(w, 500, "issue_refresh_failed", err.Error())
return
}
// If the state indicates SPA popup mode, postMessage tokens to the opener and close
state := r.URL.Query().Get("state")
if strings.Contains(state, "mode=spa") {
origin := ""
for _, part := range strings.Split(state, "|") {
if strings.HasPrefix(part, "origin=") {
origin, _ = url.QueryUnescape(strings.TrimPrefix(part, "origin="))
break
}
}
// fallback: restrict to backend origin if none supplied
if origin == "" {
origin = cfg.OAuthRedirectBase
}
payload := dto.TokenPair{
AccessToken: access,
RefreshToken: rp.Plain,
TokenType: "Bearer",
ExpiresIn: int64(accessTTL.Seconds()),
}
writePostMessageHTML(w, origin, payload)
return
}
// Default JSON response
utils.WriteJSON(w, http.StatusOK, dto.TokenPair{
AccessToken: access,
RefreshToken: rp.Plain,
TokenType: "Bearer",
ExpiresIn: int64(accessTTL.Seconds()),
})
}
}
// Refresh godoc
// @ID Refresh
// @Summary Rotate refresh token
// @Tags Auth
// @Accept json
// @Produce json
// @Param body body dto.RefreshRequest true "Refresh token"
// @Success 200 {object} dto.TokenPair
// @Router /auth/refresh [post]
func Refresh(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cfg, _ := config.Load()
var req dto.RefreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
rec, err := auth.ValidateRefreshToken(db, req.RefreshToken)
if err != nil {
utils.WriteError(w, 401, "invalid_refresh", "")
return
}
var u models.User
if err := db.First(&u, "id = ? AND is_disabled = false", rec.UserID).Error; err != nil {
utils.WriteError(w, 401, "user_disabled", "")
return
}
// rotate
newPair, err := auth.RotateRefreshToken(db, rec, 30*24*time.Hour)
if err != nil {
utils.WriteError(w, 500, "rotate_failed", err.Error())
return
}
// new access
access, err := auth.IssueAccessToken(auth.IssueOpts{
Subject: u.ID.String(),
Issuer: cfg.JWTIssuer,
Audience: cfg.JWTAudience,
TTL: 1 * time.Hour,
})
if err != nil {
utils.WriteError(w, 500, "issue_access_failed", err.Error())
return
}
utils.WriteJSON(w, 200, dto.TokenPair{
AccessToken: access,
RefreshToken: newPair.Plain,
TokenType: "Bearer",
ExpiresIn: 3600,
})
}
}
// Logout godoc
// @ID Logout
// @Summary Revoke refresh token family (logout everywhere)
// @Tags Auth
// @Accept json
// @Produce json
// @Param body body dto.LogoutRequest true "Refresh token"
// @Success 204 "No Content"
// @Router /auth/logout [post]
func Logout(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req dto.LogoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
rec, err := auth.ValidateRefreshToken(db, req.RefreshToken)
if err != nil {
w.WriteHeader(204) // already invalid/revoked
return
}
if err := auth.RevokeFamily(db, rec.FamilyID); err != nil {
utils.WriteError(w, 500, "revoke_failed", err.Error())
return
}
w.WriteHeader(204)
}
}
// Helpers
func upsertAccountAndUser(db *gorm.DB, provider, subject, email, display string) (*models.User, error) {
email = strings.ToLower(email)
var acc models.Account
if err := db.Where("provider = ? AND subject = ?", provider, subject).First(&acc).Error; err == nil {
var u models.User
if err := db.First(&u, "id = ?", acc.UserID).Error; err != nil {
return nil, err
}
return &u, nil
}
// Link by email if exists
var ue models.UserEmail
if err := db.Where("LOWER(email) = ?", email).First(&ue).Error; err == nil {
acc = models.Account{
UserID: ue.UserID,
Provider: provider,
Subject: subject,
Email: &email,
EmailVerified: true,
}
if err := db.Create(&acc).Error; err != nil {
return nil, err
}
var u models.User
if err := db.First(&u, "id = ?", ue.UserID).Error; err != nil {
return nil, err
}
return &u, nil
}
// Create user
u := models.User{DisplayName: &display, PrimaryEmail: &email}
if err := db.Create(&u).Error; err != nil {
return nil, err
}
ue = models.UserEmail{UserID: u.ID, Email: email, IsVerified: true, IsPrimary: true}
_ = db.Create(&ue).Error
acc = models.Account{UserID: u.ID, Provider: provider, Subject: subject, Email: &email, EmailVerified: true}
_ = db.Create(&acc).Error
return &u, nil
}
func ensureAutoMembership(db *gorm.DB, userID uuid.UUID, email string) error {
parts := strings.SplitN(strings.ToLower(email), "@", 2)
if len(parts) != 2 {
return nil
}
domain := parts[1]
var org models.Organization
if err := db.Where("LOWER(domain) = ?", domain).First(&org).Error; err != nil {
return nil
}
// if already member, done
var c int64
db.Model(&models.Membership{}).
Where("user_id = ? AND organization_id = ?", userID, org.ID).
Count(&c)
if c > 0 {
return nil
}
return db.Create(&models.Membership{
UserID: userID, OrganizationID: org.ID, Role: "member",
}).Error
}
// writePostMessageHTML sends a tiny HTML page that posts tokens to the SPA and closes the window.
func writePostMessageHTML(w http.ResponseWriter, origin string, payload dto.TokenPair) {
b, _ := json.Marshal(payload)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<!doctype html><html><body><script>
(function(){
try {
var data = ` + string(b) + `;
if (window.opener) {
window.opener.postMessage({ type: 'autoglue:auth', payload: data }, '` + origin + `');
}
} catch (e) {}
window.close();
})();
</script></body></html>`))
}

View File

@@ -0,0 +1,24 @@
package dto
// swagger:model AuthStartResponse
type AuthStartResponse struct {
AuthURL string `json:"auth_url" example:"https://accounts.google.com/o/oauth2/v2/auth?client_id=..."`
}
// swagger:model TokenPair
type TokenPair struct {
AccessToken string `json:"access_token" example:"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ij..."`
RefreshToken string `json:"refresh_token" example:"m0l9o8rT3t0V8d3eFf...."`
TokenType string `json:"token_type" example:"Bearer"`
ExpiresIn int64 `json:"expires_in" example:"3600"`
}
// swagger:model RefreshRequest
type RefreshRequest struct {
RefreshToken string `json:"refresh_token" example:"m0l9o8rT3t0V8d3eFf..."`
}
// swagger:model LogoutRequest
type LogoutRequest struct {
RefreshToken string `json:"refresh_token" example:"m0l9o8rT3t0V8d3eFf..."`
}

View File

@@ -0,0 +1,19 @@
package dto
// JWK represents a single JSON Web Key (public only).
// swagger:model JWK
type JWK struct {
Kty string `json:"kty" example:"RSA" gorm:"-"`
Use string `json:"use,omitempty" example:"sig" gorm:"-"`
Kid string `json:"kid,omitempty" example:"7c6f1d0a-7a98-4e6a-9dbf-6b1af4b9f345" gorm:"-"`
Alg string `json:"alg,omitempty" example:"RS256" gorm:"-"`
N string `json:"n,omitempty" gorm:"-"`
E string `json:"e,omitempty" example:"AQAB" gorm:"-"`
X string `json:"x,omitempty" gorm:"-"`
}
// JWKS is a JSON Web Key Set container.
// swagger:model JWKS
type JWKS struct {
Keys []JWK `json:"keys" gorm:"-"`
}

View File

@@ -0,0 +1,37 @@
package dto
import "github.com/google/uuid"
type CreateServerRequest struct {
Hostname string `json:"hostname,omitempty"`
PublicIPAddress string `json:"public_ip_address,omitempty"`
PrivateIPAddress string `json:"private_ip_address"`
SSHUser string `json:"ssh_user"`
SshKeyID string `json:"ssh_key_id"`
Role string `json:"role" example:"master|worker|bastion"`
Status string `json:"status,omitempty" example:"pending|provisioning|ready|failed"`
}
type UpdateServerRequest struct {
Hostname *string `json:"hostname,omitempty"`
PublicIPAddress *string `json:"public_ip_address,omitempty"`
PrivateIPAddress *string `json:"private_ip_address,omitempty"`
SSHUser *string `json:"ssh_user,omitempty"`
SshKeyID *string `json:"ssh_key_id,omitempty"`
Role *string `json:"role,omitempty" example:"master|worker|bastion"`
Status *string `json:"status,omitempty" example:"pending|provisioning|ready|failed"`
}
type ServerResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Hostname string `json:"hostname"`
PublicIPAddress *string `json:"public_ip_address,omitempty"`
PrivateIPAddress string `json:"private_ip_address"`
SSHUser string `json:"ssh_user"`
SshKeyID uuid.UUID `json:"ssh_key_id"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}

View File

@@ -0,0 +1,38 @@
package dto
import "github.com/google/uuid"
type CreateSSHRequest struct {
Name string `json:"name"`
Comment string `json:"comment,omitempty" example:"deploy@autoglue"`
Bits *int `json:"bits,omitempty"` // Only for RSA
Type *string `json:"type,omitempty"` // "rsa" (default) or "ed25519"
}
type SshResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Name string `json:"name"`
PublicKey string `json:"public_key"`
Fingerprint string `json:"fingerprint"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type SshRevealResponse struct {
SshResponse
PrivateKey string `json:"private_key"`
}
type SshMaterialJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Fingerprint string `json:"fingerprint"`
// Exactly one of the following will be populated for part=public/private.
PublicKey *string `json:"public_key,omitempty"` // OpenSSH authorized_key (string)
PrivatePEM *string `json:"private_pem,omitempty"` // PKCS#1/PEM (string)
// For part=both with mode=json we'll return a base64 zip
ZipBase64 *string `json:"zip_base64,omitempty"` // base64-encoded zip
// Suggested filenames (SDKs can save to disk without inferring names)
Filenames []string `json:"filenames"`
}

View File

@@ -0,0 +1,22 @@
package dto
import "github.com/google/uuid"
type TaintResponse struct {
ID uuid.UUID `json:"id"`
Key string `json:"key"`
Value string `json:"value"`
Effect string `json:"effect"`
}
type CreateTaintRequest struct {
Key string `json:"key"`
Value string `json:"value"`
Effect string `json:"effect"`
}
type UpdateTaintRequest struct {
Key *string `json:"key,omitempty"`
Value *string `json:"value,omitempty"`
Effect *string `json:"effect,omitempty"`
}

56
internal/handlers/jwks.go Normal file
View File

@@ -0,0 +1,56 @@
package handlers
import (
"net/http"
"github.com/glueops/autoglue/internal/auth"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/utils"
)
type jwk struct {
Kty string `json:"kty"`
Use string `json:"use,omitempty"`
Kid string `json:"kid,omitempty"`
Alg string `json:"alg,omitempty"`
N string `json:"n,omitempty"` // RSA modulus (base64url)
E string `json:"e,omitempty"` // RSA exponent (base64url)
X string `json:"x,omitempty"` // Ed25519 public key (base64url)
}
type jwks struct {
Keys []jwk `json:"keys"`
}
// JWKSHandler godoc
// @ID getJWKS
// @Summary Get JWKS
// @Description Returns the JSON Web Key Set for token verification
// @Tags Auth
// @Produce json
// @Success 200 {object} dto.JWKS
// @Router /.well-known/jwks.json [get]
func JWKSHandler(w http.ResponseWriter, _ *http.Request) {
out := dto.JWKS{Keys: make([]dto.JWK, 0)}
auth.KcCopy(func(pub map[string]interface{}) {
for kid, pk := range pub {
meta := auth.MetaFor(kid)
params, kty := auth.PubToJWK(kid, meta.Alg, pk)
if kty == "" {
continue
}
j := dto.JWK{
Kty: kty,
Use: "sig",
Kid: kid,
Alg: meta.Alg,
N: params["n"],
E: params["e"],
X: params["x"],
}
out.Keys = append(out.Keys, j)
}
})
utils.WriteJSON(w, http.StatusOK, out)
}

120
internal/handlers/me.go Normal file
View File

@@ -0,0 +1,120 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"gorm.io/gorm"
)
type meResponse struct {
models.User `json:",inline"`
Emails []models.UserEmail `json:"emails"`
Organizations []models.Organization `json:"organizations"`
}
// GetMe godoc
// @ID GetMe
// @Summary Get current user profile
// @Tags Me
// @Produce json
// @Success 200 {object} meResponse
// @Router /me [get]
// @Security BearerAuth
// @Security ApiKeyAuth
func GetMe(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := httpmiddleware.UserFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
return
}
var user models.User
if err := db.First(&user, "id = ? AND is_disabled = false", u.ID).Error; err != nil {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "user not found/disabled")
return
}
var emails []models.UserEmail
_ = db.Where("user_id = ?", user.ID).Order("is_primary desc, created_at asc").Find(&emails).Error
var orgs []models.Organization
{
var rows []models.Membership
_ = db.Where("user_id = ?", user.ID).Find(&rows).Error
if len(rows) > 0 {
var ids []interface{}
for _, m := range rows {
ids = append(ids, m.OrganizationID)
}
_ = db.Find(&orgs, "id IN ?", ids).Error
}
}
utils.WriteJSON(w, http.StatusOK, meResponse{
User: user,
Emails: emails,
Organizations: orgs,
})
}
}
type updateMeRequest struct {
DisplayName *string `json:"display_name,omitempty"`
// You can add more editable fields here (timezone, avatar, etc)
}
// UpdateMe godoc
// @ID UpdateMe
// @Summary Update current user profile
// @Tags Me
// @Accept json
// @Produce json
// @Param body body updateMeRequest true "Patch profile"
// @Success 200 {object} models.User
// @Router /me [patch]
// @Security BearerAuth
// @Security ApiKeyAuth
func UpdateMe(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := httpmiddleware.UserFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
return
}
var req updateMeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_json", err.Error())
}
updates := map[string]interface{}{}
if req.DisplayName != nil {
updates["display_name"] = req.DisplayName
}
if len(updates) == 0 {
var user models.User
if err := db.First(&user, "id = ?", u.ID).Error; err != nil {
utils.WriteError(w, 404, "not_found", "user")
return
}
utils.WriteJSON(w, 200, user)
return
}
if err := db.Model(&models.User{}).Where("id = ?", u.ID).Updates(updates).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
var out models.User
_ = db.First(&out, "id = ?", u.ID).Error
utils.WriteJSON(w, 200, out)
}
}

View File

@@ -0,0 +1,175 @@
package handlers
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/auth"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
type userAPIKeyOut struct {
ID uuid.UUID `json:"id" format:"uuid"`
Name *string `json:"name,omitempty"`
Scope string `json:"scope"` // "user"
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
Plain *string `json:"plain,omitempty"` // Shown only on create:
}
// ListUserAPIKeys godoc
// @ID ListUserAPIKeys
// @Summary List my API keys
// @Tags Me / API Keys
// @Produce json
// @Success 200 {array} userAPIKeyOut
// @Router /me/api-keys [get]
// @Security BearerAuth
// @Security ApiKeyAuth
func ListUserAPIKeys(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := httpmiddleware.UserFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
return
}
var rows []models.APIKey
if err := db.
Where("scope = ? AND user_id = ?", "user", u.ID).
Order("created_at desc").
Find(&rows).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
out := make([]userAPIKeyOut, 0, len(rows))
for _, k := range rows {
out = append(out, toUserKeyOut(k, nil))
}
utils.WriteJSON(w, 200, out)
}
}
type createUserKeyRequest struct {
Name string `json:"name,omitempty"`
ExpiresInHours *int `json:"expires_in_hours,omitempty"` // optional TTL
}
// CreateUserAPIKey godoc
// @ID CreateUserAPIKey
// @Summary Create a new user API key
// @Description Returns the plaintext key once. Store it securely on the client side.
// @Tags Me / API Keys
// @Accept json
// @Produce json
// @Param body body createUserKeyRequest true "Key options"
// @Success 201 {object} userAPIKeyOut
// @Router /me/api-keys [post]
// @Security BearerAuth
// @Security ApiKeyAuth
func CreateUserAPIKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := httpmiddleware.UserFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
return
}
var req createUserKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
plain, err := generateUserAPIKey()
if err != nil {
utils.WriteError(w, 500, "gen_failed", err.Error())
return
}
hash := auth.SHA256Hex(plain)
var exp *time.Time
if req.ExpiresInHours != nil && *req.ExpiresInHours > 0 {
t := time.Now().Add(time.Duration(*req.ExpiresInHours) * time.Hour)
exp = &t
}
rec := models.APIKey{
Scope: "user",
UserID: &u.ID,
KeyHash: hash,
Name: req.Name, // if field exists
ExpiresAt: exp,
// SecretHash: nil (not used for user keys)
}
if err := db.Create(&rec).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusCreated, toUserKeyOut(rec, &plain))
}
}
// DeleteUserAPIKey godoc
// @ID DeleteUserAPIKey
// @Summary Delete a user API key
// @Tags Me / API Keys
// @Produce json
// @Param id path string true "Key ID (UUID)"
// @Success 204 "No Content"
// @Router /me/api-keys/{id} [delete]
// @Security BearerAuth
func DeleteUserAPIKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := httpmiddleware.UserFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 400, "invalid_id", "must be uuid")
return
}
tx := db.Where("id = ? AND scope = ? AND user_id = ?", id, "user", u.ID).
Delete(&models.APIKey{})
if tx.Error != nil {
utils.WriteError(w, 500, "db_error", tx.Error.Error())
return
}
if tx.RowsAffected == 0 {
utils.WriteError(w, 404, "not_found", "key not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func toUserKeyOut(k models.APIKey, plain *string) userAPIKeyOut {
return userAPIKeyOut{
ID: k.ID,
Name: &k.Name, // if your model has it; else remove
Scope: k.Scope,
CreatedAt: k.CreatedAt,
ExpiresAt: k.ExpiresAt,
LastUsedAt: k.LastUsedAt, // if present; else remove
Plain: plain,
}
}
func generateUserAPIKey() (string, error) {
// 24 random bytes → base64url (no padding), with "u_" prefix
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
s := base64.RawURLEncoding.EncodeToString(b)
return "u_" + s, nil
}

647
internal/handlers/orgs.go Normal file
View File

@@ -0,0 +1,647 @@
package handlers
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/auth"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ---------- Helpers ----------
func mustUser(r *http.Request) (*models.User, bool) {
return httpmiddleware.UserFrom(r.Context())
}
func isOrgRole(db *gorm.DB, userID, orgID uuid.UUID, want ...string) (bool, string) {
var m models.Membership
if err := db.Where("user_id = ? AND organization_id = ?", userID, orgID).First(&m).Error; err != nil {
return false, ""
}
got := strings.ToLower(m.Role)
for _, w := range want {
if got == strings.ToLower(w) {
return true, got
}
}
return false, got
}
func mustMember(db *gorm.DB, userID, orgID uuid.UUID) bool {
ok, _ := isOrgRole(db, userID, orgID, "owner", "admin", "member")
return ok
}
func randomB64URL(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// ---------- Orgs: list/create/get/update/delete ----------
type orgCreateReq struct {
Name string `json:"name" example:"Acme Corp"`
Domain *string `json:"domain,omitempty" example:"acme.com"`
}
// CreateOrg godoc
// @ID CreateOrg
// @Summary Create organization
// @Tags Orgs
// @Accept json
// @Produce json
// @Param body body orgCreateReq true "Org payload"
// @Success 201 {object} models.Organization
// @Failure 400 {object} utils.ErrorResponse
// @Failure 401 {object} utils.ErrorResponse
// @Failure 409 {object} utils.ErrorResponse
// @Router /orgs [post]
// @ID createOrg
// @Security BearerAuth
func CreateOrg(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "")
return
}
var req orgCreateReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
if strings.TrimSpace(req.Name) == "" {
utils.WriteError(w, 400, "validation_error", "name is required")
return
}
org := models.Organization{Name: req.Name}
if req.Domain != nil && strings.TrimSpace(*req.Domain) != "" {
org.Domain = req.Domain
}
if err := db.Create(&org).Error; err != nil {
utils.WriteError(w, 409, "conflict", err.Error())
return
}
// creator is owner
_ = db.Create(&models.Membership{
UserID: u.ID, OrganizationID: org.ID, Role: "owner",
}).Error
utils.WriteJSON(w, 201, org)
}
}
// ListMyOrgs godoc
// @ID ListMyOrgs
// @Summary List organizations I belong to
// @Tags Orgs
// @Produce json
// @Success 200 {array} models.Organization
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs [get]
// @ID listMyOrgs
// @Security BearerAuth
func ListMyOrgs(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "")
return
}
var orgs []models.Organization
if err := db.
Joins("join memberships m on m.organization_id = organizations.id").
Where("m.user_id = ?", u.ID).
Order("organizations.created_at desc").
Find(&orgs).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
utils.WriteJSON(w, 200, orgs)
}
}
// GetOrg godoc
// @ID GetOrg
// @Summary Get organization
// @Tags Orgs
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Success 200 {object} models.Organization
// @Failure 401 {object} utils.ErrorResponse
// @Failure 404 {object} utils.ErrorResponse
// @Router /orgs/{id} [get]
// @ID getOrg
// @Security BearerAuth
func GetOrg(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if !mustMember(db, u.ID, oid) {
utils.WriteError(w, 401, "forbidden", "not a member")
return
}
var org models.Organization
if err := db.First(&org, "id = ?", oid).Error; err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
utils.WriteJSON(w, 200, org)
}
}
type orgUpdateReq struct {
Name *string `json:"name,omitempty"`
Domain *string `json:"domain,omitempty"`
}
// UpdateOrg godoc
// @ID UpdateOrg
// @Summary Update organization (owner/admin)
// @Tags Orgs
// @Accept json
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Param body body orgUpdateReq true "Update payload"
// @Success 200 {object} models.Organization
// @Failure 401 {object} utils.ErrorResponse
// @Failure 404 {object} utils.ErrorResponse
// @Router /orgs/{id} [patch]
// @ID updateOrg
// @Security BearerAuth
func UpdateOrg(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
utils.WriteError(w, 401, "forbidden", "admin or owner required")
return
}
var req orgUpdateReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
changes := map[string]any{}
if req.Name != nil {
changes["name"] = strings.TrimSpace(*req.Name)
}
if req.Domain != nil {
if d := strings.TrimSpace(*req.Domain); d == "" {
changes["domain"] = nil
} else {
changes["domain"] = d
}
}
if len(changes) > 0 {
if err := db.Model(&models.Organization{}).Where("id = ?", oid).Updates(changes).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
}
var out models.Organization
_ = db.First(&out, "id = ?", oid).Error
utils.WriteJSON(w, 200, out)
}
}
// DeleteOrg godoc
// @ID DeleteOrg
// @Summary Delete organization (owner)
// @Tags Orgs
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Success 204 "Deleted"
// @Failure 401 {object} utils.ErrorResponse
// @Failure 404 {object} utils.ErrorResponse
// @Router /orgs/{id} [delete]
// @ID deleteOrg
// @Security BearerAuth
func DeleteOrg(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if ok, _ := isOrgRole(db, u.ID, oid, "owner"); !ok {
utils.WriteError(w, 401, "forbidden", "owner required")
return
}
// Optional safety: deny if members >1 or resources exist; here we just delete.
res := db.Delete(&models.Organization{}, "id = ?", oid)
if res.Error != nil {
utils.WriteError(w, 500, "db_error", res.Error.Error())
return
}
if res.RowsAffected == 0 {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
w.WriteHeader(204)
}
}
// ---------- Members: list/add/update/delete ----------
type memberOut struct {
UserID uuid.UUID `json:"user_id" format:"uuid"`
Email string `json:"email"`
Role string `json:"role"` // owner/admin/member
}
type memberUpsertReq struct {
UserID uuid.UUID `json:"user_id" format:"uuid"`
Role string `json:"role" example:"member"`
}
// ListMembers godoc
// @ID ListMembers
// @Summary List members in org
// @Tags Orgs
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Success 200 {array} memberOut
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs/{id}/members [get]
// @ID listMembers
// @Security BearerAuth
func ListMembers(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil || !mustMember(db, u.ID, oid) {
utils.WriteError(w, 401, "forbidden", "")
return
}
var ms []models.Membership
if err := db.Where("organization_id = ?", oid).Find(&ms).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
// load emails
userIDs := make([]uuid.UUID, 0, len(ms))
for _, m := range ms {
userIDs = append(userIDs, m.UserID)
}
var emails []models.UserEmail
if len(userIDs) > 0 {
_ = db.Where("user_id in ?", userIDs).Where("is_primary = true").Find(&emails).Error
}
emailByUser := map[uuid.UUID]string{}
for _, e := range emails {
emailByUser[e.UserID] = e.Email
}
out := make([]memberOut, 0, len(ms))
for _, m := range ms {
out = append(out, memberOut{
UserID: m.UserID,
Email: emailByUser[m.UserID],
Role: m.Role,
})
}
utils.WriteJSON(w, 200, out)
}
}
// AddOrUpdateMember godoc
// @ID AddOrUpdateMember
// @Summary Add or update a member (owner/admin)
// @Tags Orgs
// @Accept json
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Param body body memberUpsertReq true "User & role"
// @Success 200 {object} memberOut
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs/{id}/members [post]
// @ID addOrUpdateMember
// @Security BearerAuth
func AddOrUpdateMember(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
utils.WriteError(w, 401, "forbidden", "admin or owner required")
return
}
var req memberUpsertReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
role := strings.ToLower(strings.TrimSpace(req.Role))
if role != "owner" && role != "admin" && role != "member" {
utils.WriteError(w, 400, "validation_error", "role must be owner|admin|member")
return
}
var m models.Membership
tx := db.Where("user_id = ? AND organization_id = ?", req.UserID, oid).First(&m)
if tx.Error == nil {
// update
if err := db.Model(&m).Update("role", role).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
} else if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
m = models.Membership{UserID: req.UserID, OrganizationID: oid, Role: role}
if err := db.Create(&m).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
} else {
utils.WriteError(w, 500, "db_error", tx.Error.Error())
return
}
// make response
var ue models.UserEmail
_ = db.Where("user_id = ? AND is_primary = true", req.UserID).First(&ue).Error
utils.WriteJSON(w, 200, memberOut{
UserID: req.UserID, Email: ue.Email, Role: m.Role,
})
}
}
// RemoveMember godoc
// @ID RemoveMember
// @Summary Remove a member (owner/admin)
// @Tags Orgs
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Param user_id path string true "User ID (UUID)"
// @Success 204 "Removed"
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs/{id}/members/{user_id} [delete]
// @ID removeMember
// @Security BearerAuth
func RemoveMember(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
utils.WriteError(w, 401, "forbidden", "admin or owner required")
return
}
uid, err := uuid.Parse(chi.URLParam(r, "user_id"))
if err != nil {
utils.WriteError(w, 400, "invalid_user_id", "")
return
}
res := db.Where("user_id = ? AND organization_id = ?", uid, oid).Delete(&models.Membership{})
if res.Error != nil {
utils.WriteError(w, 500, "db_error", res.Error.Error())
return
}
w.WriteHeader(204)
}
}
// ---------- Org API Keys (key/secret pair) ----------
type orgKeyCreateReq struct {
Name string `json:"name,omitempty" example:"automation-bot"`
ExpiresInHours *int `json:"expires_in_hours,omitempty" example:"720"`
}
type orgKeyCreateResp struct {
ID uuid.UUID `json:"id"`
Name string `json:"name,omitempty"`
Scope string `json:"scope"` // "org"
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
OrgKey string `json:"org_key"` // shown once:
OrgSecret string `json:"org_secret"` // shown once:
}
// ListOrgKeys godoc
// @ID ListOrgKeys
// @Summary List org-scoped API keys (no secrets)
// @Tags Orgs
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Success 200 {array} models.APIKey
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs/{id}/api-keys [get]
// @ID listOrgKeys
// @Security BearerAuth
func ListOrgKeys(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil || !mustMember(db, u.ID, oid) {
utils.WriteError(w, 401, "forbidden", "")
return
}
var keys []models.APIKey
if err := db.Where("org_id = ? AND scope = ?", oid, "org").
Order("created_at desc").
Find(&keys).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
// SecretHash must not be exposed; your json tags likely hide it already.
utils.WriteJSON(w, 200, keys)
}
}
// CreateOrgKey godoc
// @ID CreateOrgKey
// @Summary Create org key/secret pair (owner/admin)
// @Tags Orgs
// @Accept json
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Param body body orgKeyCreateReq true "Key name + optional expiry"
// @Success 201 {object} orgKeyCreateResp
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs/{id}/api-keys [post]
// @ID createOrgKey
// @Security BearerAuth
func CreateOrgKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
utils.WriteError(w, 401, "forbidden", "admin or owner required")
return
}
var req orgKeyCreateReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
// generate
keySuffix, err := randomB64URL(16)
if err != nil {
utils.WriteError(w, 500, "entropy_error", err.Error())
return
}
sec, err := randomB64URL(32)
if err != nil {
utils.WriteError(w, 500, "entropy_error", err.Error())
return
}
orgKey := "org_" + keySuffix
secretPlain := sec
keyHash := auth.SHA256Hex(orgKey)
secretHash, err := auth.HashSecretArgon2id(secretPlain)
if err != nil {
utils.WriteError(w, 500, "hash_error", err.Error())
return
}
var exp *time.Time
if req.ExpiresInHours != nil && *req.ExpiresInHours > 0 {
e := time.Now().Add(time.Duration(*req.ExpiresInHours) * time.Hour)
exp = &e
}
rec := models.APIKey{
OrgID: &oid,
Scope: "org",
Name: req.Name,
KeyHash: keyHash,
SecretHash: &secretHash,
ExpiresAt: exp,
}
if err := db.Create(&rec).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
utils.WriteJSON(w, 201, orgKeyCreateResp{
ID: rec.ID,
Name: rec.Name,
Scope: rec.Scope,
CreatedAt: rec.CreatedAt,
ExpiresAt: rec.ExpiresAt,
OrgKey: orgKey,
OrgSecret: secretPlain,
})
}
}
// DeleteOrgKey godoc
// @ID DeleteOrgKey
// @Summary Delete org key (owner/admin)
// @Tags Orgs
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Param key_id path string true "Key ID (UUID)"
// @Success 204 "Deleted"
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs/{id}/api-keys/{key_id} [delete]
// @ID deleteOrgKey
// @Security BearerAuth
func DeleteOrgKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
utils.WriteError(w, 401, "forbidden", "admin or owner required")
return
}
kid, err := uuid.Parse(chi.URLParam(r, "key_id"))
if err != nil {
utils.WriteError(w, 400, "invalid_key_id", "")
return
}
res := db.Where("id = ? AND org_id = ? AND scope = ?", kid, oid, "org").Delete(&models.APIKey{})
if res.Error != nil {
utils.WriteError(w, 500, "db_error", res.Error.Error())
return
}
if res.RowsAffected == 0 {
utils.WriteError(w, 404, "not_found", "key not found")
return
}
w.WriteHeader(204)
}
}

View File

@@ -0,0 +1,388 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListServers godoc
// @ID ListServers
// @Summary List servers (org scoped)
// @Description Returns servers for the organization in X-Org-ID. Optional filters: status, role.
// @Tags Servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param status query string false "Filter by status (pending|provisioning|ready|failed)"
// @Param role query string false "Filter by role"
// @Success 200 {array} dto.ServerResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list servers"
// @Router /servers [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListServers(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
q := db.Where("organization_id = ?", orgID)
if s := strings.TrimSpace(r.URL.Query().Get("status")); s != "" {
if !validStatus(s) {
utils.WriteError(w, http.StatusBadRequest, "status_invalid", "invalid status")
return
}
q = q.Where("status = ?", strings.ToLower(s))
}
if role := strings.TrimSpace(r.URL.Query().Get("role")); role != "" {
q = q.Where("role = ?", role)
}
var rows []models.Server
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to list servers")
return
}
out := make([]dto.ServerResponse, 0, len(rows))
for _, row := range rows {
out = append(out, dto.ServerResponse{
ID: row.ID,
OrganizationID: row.OrganizationID,
Hostname: row.Hostname,
PublicIPAddress: row.PublicIPAddress,
PrivateIPAddress: row.PrivateIPAddress,
SSHUser: row.SSHUser,
SshKeyID: row.SshKeyID,
Role: row.Role,
Status: row.Status,
CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: row.UpdatedAt.UTC().Format(time.RFC3339),
})
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetServer godoc
// @ID GetServer
// @Summary Get server by ID (org scoped)
// @Description Returns one server in the given organization.
// @Tags Servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Server ID (UUID)"
// @Success 200 {object} dto.ServerResponse
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /servers/{id} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetServer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_invalid", "invalid id")
return
}
var row models.Server
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get server")
return
}
utils.WriteJSON(w, http.StatusOK, row)
}
}
// CreateServer godoc
// @ID CreateServer
// @Summary Create server (org scoped)
// @Description Creates a server bound to the org in X-Org-ID. Validates that ssh_key_id belongs to the org.
// @Tags Servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param body body dto.CreateServerRequest true "Server payload"
// @Success 201 {object} dto.ServerResponse
// @Failure 400 {string} string "invalid json / missing fields / invalid status / invalid ssh_key_id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "create failed"
// @Router /servers [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateServer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var req dto.CreateServerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
req.Role = strings.ToLower(strings.TrimSpace(req.Role))
req.Status = strings.ToLower(strings.TrimSpace(req.Status))
pub := strings.TrimSpace(req.PublicIPAddress)
if req.PrivateIPAddress == "" || req.SSHUser == "" || req.SshKeyID == "" || req.Role == "" {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "private_ip_address, ssh_user, ssh_key_id and role are required")
return
}
if req.Status != "" && !validStatus(req.Status) {
utils.WriteError(w, http.StatusBadRequest, "status_invalid", "invalid status")
return
}
if req.Role == "bastion" && pub == "" {
utils.WriteError(w, http.StatusBadRequest, "public_ip_required", "public_ip_address is required for role=bastion")
return
}
keyID, err := uuid.Parse(req.SshKeyID)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid ssh_key_id")
return
}
if err := ensureKeyBelongsToOrg(orgID, keyID, db); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid or unauthorized ssh_key_id")
return
}
var publicPtr *string
if pub != "" {
publicPtr = &pub
}
s := models.Server{
OrganizationID: orgID,
Hostname: req.Hostname,
PublicIPAddress: publicPtr,
PrivateIPAddress: req.PrivateIPAddress,
SSHUser: req.SSHUser,
SshKeyID: keyID,
Role: req.Role,
Status: "pending",
}
if req.Status != "" {
s.Status = strings.ToLower(req.Status)
}
if err := db.Create(&s).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to create server")
return
}
utils.WriteJSON(w, http.StatusCreated, s)
}
}
// UpdateServer godoc
// @ID UpdateServer
// @Summary Update server (org scoped)
// @Description Partially update fields; changing ssh_key_id validates ownership.
// @Tags Servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Server ID (UUID)"
// @Param body body dto.UpdateServerRequest true "Fields to update"
// @Success 200 {object} dto.ServerResponse
// @Failure 400 {string} string "invalid id / invalid json / invalid status / invalid ssh_key_id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "update failed"
// @Router /servers/{id} [patch]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateServer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_invalid", "invalid id")
return
}
var server models.Server
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&server).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get server")
return
}
var req dto.UpdateServerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
next := server
if req.Hostname != nil {
next.Hostname = *req.Hostname
}
if req.PrivateIPAddress != nil {
next.PrivateIPAddress = *req.PrivateIPAddress
}
if req.PublicIPAddress != nil {
next.PublicIPAddress = req.PublicIPAddress
}
if req.SSHUser != nil {
next.SSHUser = *req.SSHUser
}
if req.Role != nil {
next.Role = *req.Role
}
if req.Status != nil {
st := strings.ToLower(strings.TrimSpace(*req.Status))
if !validStatus(st) {
utils.WriteError(w, http.StatusBadRequest, "status_invalid", "invalid status")
return
}
next.Status = st
}
if req.SshKeyID != nil {
keyID, err := uuid.Parse(*req.SshKeyID)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid ssh_key_id")
return
}
if err := ensureKeyBelongsToOrg(orgID, keyID, db); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid or unauthorized ssh_key_id")
return
}
next.SshKeyID = keyID
}
if strings.EqualFold(next.Role, "bastion") &&
(next.PublicIPAddress == nil || strings.TrimSpace(*next.PublicIPAddress) == "") {
utils.WriteError(w, http.StatusBadRequest, "public_ip_required", "public_ip_address is required for role=bastion")
return
}
if err := db.Save(&next).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to update server")
return
}
utils.WriteJSON(w, http.StatusOK, server)
}
}
// DeleteServer godoc
// @ID DeleteServer
// @Summary Delete server (org scoped)
// @Description Permanently deletes the server.
// @Tags Servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Server ID (UUID)"
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "delete failed"
// @Router /servers/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteServer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_invalid", "invalid id")
return
}
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&models.Server{}).Error; err != nil {
utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found")
return
}
if err := db.Where("id = ? AND organization_id = ?", id, orgID).Delete(&models.Server{}).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to delete server")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// --- Helpers ---
func validStatus(status string) bool {
switch strings.ToLower(status) {
case "pending", "provisioning", "ready", "failed", "":
return true
default:
return false
}
}
func ensureKeyBelongsToOrg(orgID, keyID uuid.UUID, db *gorm.DB) error {
var k models.SshKey
if err := db.Where("id = ? AND organization_id = ?", keyID, orgID).First(&k).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("ssh key not found for this organization")
}
return err
}
return nil
}

553
internal/handlers/ssh.go Normal file
View File

@@ -0,0 +1,553 @@
package handlers
import (
"archive/zip"
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"golang.org/x/crypto/ssh"
"gorm.io/gorm"
)
// ListPublicSshKeys godoc
// @ID ListPublicSshKeys
// @Summary List ssh keys (org scoped)
// @Description Returns ssh keys for the organization in X-Org-ID.
// @Tags Ssh
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Success 200 {array} dto.SshResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list keys"
// @Router /ssh [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListPublicSshKeys(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var rows []models.SshKey
if err := db.Where("organization_id = ?", orgID).Order("created_at DESC").Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to list ssh keys")
return
}
out := make([]dto.SshResponse, 0, len(rows))
for _, row := range rows {
out = append(out, dto.SshResponse{
ID: row.ID,
OrganizationID: row.OrganizationID,
Name: row.Name,
PublicKey: row.PublicKey,
Fingerprint: row.Fingerprint,
CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: row.UpdatedAt.UTC().Format(time.RFC3339),
})
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// CreateSSHKey
// @ID CreateSSHKey
// @Summary Create ssh keypair (org scoped)
// @Description Generates an RSA or ED25519 keypair, saves it, and returns metadata. For RSA you may set bits (2048/3072/4096). Default is 4096. ED25519 ignores bits.
// @Tags Ssh
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param body body dto.CreateSSHRequest true "Key generation options"
// @Success 201 {object} dto.SshResponse
// @Failure 400 {string} string "invalid json / invalid bits"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "generation/create failed"
// @Router /ssh [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateSSHKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var req dto.CreateSSHRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_payload", "invalid JSON payload")
return
}
keyType := "rsa"
if req.Type != nil && strings.TrimSpace(*req.Type) != "" {
keyType = strings.ToLower(strings.TrimSpace(*req.Type))
}
if keyType != "rsa" && keyType != "ed25519" {
utils.WriteError(w, http.StatusBadRequest, "invalid_type", "invalid type (rsa|ed25519)")
return
}
var (
privPEM string
pubAuth string
err error
)
switch keyType {
case "rsa":
bits := 4096
if req.Bits != nil {
if !allowedBits(*req.Bits) {
utils.WriteError(w, http.StatusBadRequest, "invalid_bits", "invalid bits (allowed: 2048, 3072, 4096)")
return
}
bits = *req.Bits
}
privPEM, pubAuth, err = GenerateRSAPEMAndAuthorized(bits, strings.TrimSpace(req.Comment))
case "ed25519":
if req.Bits != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_bits_for_type", "bits is only valid for RSA")
return
}
privPEM, pubAuth, err = GenerateEd25519PEMAndAuthorized(strings.TrimSpace(req.Comment))
}
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "keygen_failure", "key generation failed")
return
}
cipher, iv, tag, err := utils.EncryptForOrg(orgID, []byte(privPEM), db)
if err != nil {
http.Error(w, "encryption failed", http.StatusInternalServerError)
return
}
parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubAuth))
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "ssh_failure", "ssh public key parsing failed")
return
}
fp := ssh.FingerprintSHA256(parsed)
key := models.SshKey{
OrganizationID: orgID,
Name: req.Name,
PublicKey: pubAuth,
EncryptedPrivateKey: cipher,
PrivateIV: iv,
PrivateTag: tag,
Fingerprint: fp,
}
if err := db.Create(&key).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to create ssh key")
return
}
utils.WriteJSON(w, http.StatusCreated, dto.SshResponse{
ID: key.ID,
OrganizationID: key.OrganizationID,
Name: key.Name,
PublicKey: key.PublicKey,
Fingerprint: key.Fingerprint,
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: key.UpdatedAt.UTC().Format(time.RFC3339),
})
}
}
// GetSSHKey godoc
// @ID GetSSHKey
// @Summary Get ssh key by ID (org scoped)
// @Description Returns public key fields. Append `?reveal=true` to include the private key PEM.
// @Tags Ssh
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "SSH Key ID (UUID)"
// @Param reveal query bool false "Reveal private key PEM"
// @Success 200 {object} dto.SshResponse
// @Success 200 {object} dto.SshRevealResponse "When reveal=true"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /ssh/{id} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetSSHKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_key_id", "invalid SSH Key ID")
return
}
var key models.SshKey
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "ssh_key_not_found", "ssh key not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get ssh key")
return
}
if r.URL.Query().Get("reveal") != "true" {
utils.WriteJSON(w, http.StatusOK, dto.SshResponse{
ID: key.ID,
OrganizationID: key.OrganizationID,
Name: key.Name,
PublicKey: key.PublicKey,
Fingerprint: key.Fingerprint,
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: key.UpdatedAt.UTC().Format(time.RFC3339),
})
return
}
plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
return
}
utils.WriteJSON(w, http.StatusOK, dto.SshRevealResponse{
SshResponse: dto.SshResponse{
ID: key.ID,
OrganizationID: key.OrganizationID,
Name: key.Name,
PublicKey: key.PublicKey,
Fingerprint: key.Fingerprint,
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: key.UpdatedAt.UTC().Format(time.RFC3339),
},
PrivateKey: plain,
})
}
}
// DeleteSSHKey godoc
// @ID DeleteSSHKey
// @Summary Delete ssh keypair (org scoped)
// @Description Permanently deletes a keypair.
// @Tags Ssh
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "SSH Key ID (UUID)"
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "delete failed"
// @Router /ssh/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteSSHKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_key_id", "invalid SSH Key ID")
return
}
if err := db.Where("id = ? AND organization_id = ?", id, orgID).
Delete(&models.SshKey{}).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to delete ssh key")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// DownloadSSHKey godoc
// @ID DownloadSSHKey
// @Summary Download ssh key files by ID (org scoped)
// @Description Download `part=public|private|both` of the keypair. `both` returns a zip file.
// @Tags Ssh
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "SSH Key ID (UUID)"
// @Param part query string true "Which part to download" Enums(public,private,both)
// @Success 200 {string} string "file content"
// @Failure 400 {string} string "invalid id / invalid part"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "download failed"
// @Router /ssh/{id}/download [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DownloadSSHKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_key_id", "invalid SSH Key ID")
return
}
var key models.SshKey
if err := db.Where("id = ? AND organization_id = ?", id, orgID).
First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "ssh_key_not_found", "ssh key not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get ssh key")
return
}
part := strings.ToLower(r.URL.Query().Get("part"))
if part == "" {
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_part", "invalid part (public|private|both)")
return
}
mode := strings.ToLower(r.URL.Query().Get("mode"))
if mode != "" && mode != "json" {
utils.WriteError(w, http.StatusBadRequest, "invalid_mode", "invalid mode (json|attachment[default])")
return
}
if mode == "json" {
resp := dto.SshMaterialJSON{
ID: key.ID.String(),
Name: key.Name,
Fingerprint: key.Fingerprint,
}
switch part {
case "public":
pub := key.PublicKey
resp.PublicKey = &pub
resp.Filenames = []string{fmt.Sprintf("id_rsa_%s.pub", key.ID.String())}
utils.WriteJSON(w, http.StatusOK, resp)
return
case "private":
plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
return
}
resp.PrivatePEM = &plain
resp.Filenames = []string{fmt.Sprintf("id_rsa_%s.pem", key.ID.String())}
utils.WriteJSON(w, http.StatusOK, resp)
return
case "both":
plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
return
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
_ = toZipFile(fmt.Sprintf("id_rsa_%s.pem", key.ID.String()), []byte(plain), zw)
_ = toZipFile(fmt.Sprintf("id_rsa_%s.pub", key.ID.String()), []byte(key.PublicKey), zw)
_ = zw.Close()
b64 := utils.EncodeB64(buf.Bytes())
resp.ZipBase64 = &b64
resp.Filenames = []string{
fmt.Sprintf("id_rsa_%s.zip", key.ID.String()),
fmt.Sprintf("id_rsa_%s.pem", key.ID.String()),
fmt.Sprintf("id_rsa_%s.pub", key.ID.String()),
}
utils.WriteJSON(w, http.StatusOK, resp)
return
default:
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_part", "invalid part (public|private|both)")
return
}
}
prefix := keyFilenamePrefix(key.PublicKey)
switch part {
case "public":
filename := fmt.Sprintf("%s_%s.pub", prefix, key.ID.String())
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
_, _ = w.Write([]byte(key.PublicKey))
return
case "private":
plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
return
}
filename := fmt.Sprintf("%s_%s.pem", prefix, key.ID.String())
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
_, _ = w.Write([]byte(plain))
return
case "both":
plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
return
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
_ = toZipFile(fmt.Sprintf("%s_%s.pem", prefix, key.ID.String()), []byte(plain), zw)
_ = toZipFile(fmt.Sprintf("%s_%s.pub", prefix, key.ID.String()), []byte(key.PublicKey), zw)
_ = zw.Close()
filename := fmt.Sprintf("ssh_key_%s.zip", key.ID.String())
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
_, _ = w.Write(buf.Bytes())
return
default:
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_part", "invalid part (public|private|both)")
return
}
}
}
// --- Helpers ---
func allowedBits(b int) bool {
return b == 2048 || b == 3072 || b == 4096
}
func GenerateRSA(bits int) (*rsa.PrivateKey, error) {
return rsa.GenerateKey(rand.Reader, bits)
}
func RSAPrivateToPEMAndAuthorized(priv *rsa.PrivateKey, comment string) (privPEM string, authorized string, err error) {
der := x509.MarshalPKCS1PrivateKey(priv)
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: der}
var buf bytes.Buffer
if err = pem.Encode(&buf, block); err != nil {
return "", "", err
}
pub, err := ssh.NewPublicKey(&priv.PublicKey)
if err != nil {
return "", "", err
}
auth := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))
comment = strings.TrimSpace(comment)
if comment != "" {
auth += " " + comment
}
return buf.String(), auth, nil
}
func GenerateRSAPEMAndAuthorized(bits int, comment string) (string, string, error) {
priv, err := GenerateRSA(bits)
if err != nil {
return "", "", err
}
return RSAPrivateToPEMAndAuthorized(priv, comment)
}
func toZipFile(filename string, content []byte, zw *zip.Writer) error {
f, err := zw.Create(filename)
if err != nil {
return err
}
_, err = f.Write(content)
return err
}
func keyFilenamePrefix(pubAuth string) string {
// OpenSSH authorized keys start with the algorithm name
if strings.HasPrefix(pubAuth, "ssh-ed25519 ") {
return "id_ed25519"
}
// default to RSA
return "id_rsa"
}
func GenerateEd25519PEMAndAuthorized(comment string) (privPEM string, authorized string, err error) {
// Generate ed25519 keypair
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return "", "", err
}
// Private: PKCS#8 PEM
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return "", "", err
}
block := &pem.Block{Type: "PRIVATE KEY", Bytes: der}
var buf bytes.Buffer
if err := pem.Encode(&buf, block); err != nil {
return "", "", err
}
// Public: OpenSSH authorized_key
sshPub, err := ssh.NewPublicKey(ed25519.PublicKey(pub))
if err != nil {
return "", "", err
}
auth := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub)))
comment = strings.TrimSpace(comment)
if comment != "" {
auth += " " + comment
}
return buf.String(), auth, nil
}

335
internal/handlers/taints.go Normal file
View File

@@ -0,0 +1,335 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListTaints godoc
// @ID ListTaints
// @Summary List node pool taints (org scoped)
// @Description Returns node taints for the organization in X-Org-ID. Filters: `key`, `value`, and `q` (key contains). Add `include=node_pools` to include linked node pools.
// @Tags Taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param key query string false "Exact key"
// @Param value query string false "Exact value"
// @Param q query string false "key contains (case-insensitive)"
// @Success 200 {array} dto.TaintResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list node taints"
// @Router /taints [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListTaints(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
q := db.Where("organization_id = ?", orgID)
if key := strings.TrimSpace(r.URL.Query().Get("key")); key != "" {
q = q.Where(`key = ?`, key)
}
if val := strings.TrimSpace(r.URL.Query().Get("value")); val != "" {
q = q.Where(`value = ?`, val)
}
if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" {
q = q.Where(`key ILIKE ?`, "%"+needle+"%")
}
var rows []models.Taint
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.TaintResponse, 0, len(rows))
for _, row := range rows {
out = append(out, dto.TaintResponse{
ID: row.ID,
Key: row.Key,
Value: row.Value,
Effect: row.Effect,
})
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetTaint godoc
// @ID GetTaint
// @Summary Get node taint by ID (org scoped)
// @Tags Taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Taint ID (UUID)"
// @Success 200 {object} dto.TaintResponse
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /taints/{id} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetTaint(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
var row models.Taint
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "not_found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := dto.TaintResponse{
ID: row.ID,
Key: row.Key,
Value: row.Value,
Effect: row.Effect,
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// CreateTaint godoc
// @ID CreateTaint
// @Summary Create node taint (org scoped)
// @Description Creates a taint.
// @Tags Taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param body body dto.CreateTaintRequest true "Taint payload"
// @Success 201 {object} dto.TaintResponse
// @Failure 400 {string} string "invalid json / missing fields / invalid node_pool_ids"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "create failed"
// @Router /taints [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateTaint(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var req dto.CreateTaintRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
req.Key = strings.TrimSpace(req.Key)
req.Value = strings.TrimSpace(req.Value)
req.Effect = strings.TrimSpace(req.Effect)
if req.Key == "" || req.Value == "" || req.Effect == "" {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "missing key/value/effect")
return
}
if _, ok := allowedEffects[req.Effect]; !ok {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid effect")
return
}
t := models.Taint{
OrganizationID: orgID,
Key: req.Key,
Value: req.Value,
Effect: req.Effect,
}
if err := db.Create(&t).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := dto.TaintResponse{
ID: t.ID,
Key: t.Key,
Value: t.Value,
Effect: t.Effect,
}
utils.WriteJSON(w, http.StatusCreated, out)
}
}
// UpdateTaint godoc
// @ID UpdateTaint
// @Summary Update node taint (org scoped)
// @Description Partially update taint fields.
// @Tags Taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Taint ID (UUID)"
// @Param body body dto.UpdateTaintRequest true "Fields to update"
// @Success 200 {object} dto.TaintResponse
// @Failure 400 {string} string "invalid id / invalid json"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "update failed"
// @Router /taints/{id} [patch]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateTaint(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
var t models.Taint
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&t).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "not_found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var req dto.UpdateTaintRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
next := t
if req.Key != nil {
next.Key = strings.TrimSpace(*req.Key)
}
if req.Value != nil {
next.Value = strings.TrimSpace(*req.Value)
}
if req.Effect != nil {
e := strings.TrimSpace(*req.Effect)
if e == "" {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "missing effect")
return
}
if _, ok := allowedEffects[e]; !ok {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid effect")
return
}
next.Effect = e
}
if err := db.Save(&next).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := dto.TaintResponse{
ID: next.ID,
Key: next.Key,
Value: next.Value,
Effect: next.Effect,
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// DeleteTaint godoc
// @ID DeleteTaint
// @Summary Delete taint (org scoped)
// @Description Permanently deletes the taint.
// @Tags Taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Taint ID (UUID)"
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "delete failed"
// @Router /taints/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteTaint(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
var row models.Taint
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "not_found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if err := db.Delete(&row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// --- Helpers ---
var allowedEffects = map[string]struct{}{
"NoSchedule": {},
"PreferNoSchedule": {},
"NoExecute": {},
}

View File

@@ -0,0 +1,35 @@
package keys
import (
"encoding/base64"
"errors"
"strings"
)
func decode32ByteKey(s string) ([]byte, error) {
try := func(enc *base64.Encoding, v string) ([]byte, bool) {
if b, err := enc.DecodeString(v); err == nil && len(b) == 32 {
return b, true
}
return nil, false
}
// Try raw (no padding) variants first
if b, ok := try(base64.RawURLEncoding, s); ok {
return b, nil
}
if b, ok := try(base64.RawStdEncoding, s); ok {
return b, nil
}
// Try padded variants (add padding if missing)
pad := func(v string) string { return v + strings.Repeat("=", (4-len(v)%4)%4) }
if b, ok := try(base64.URLEncoding, pad(s)); ok {
return b, nil
}
if b, ok := try(base64.StdEncoding, pad(s)); ok {
return b, nil
}
return nil, errors.New("key must be 32 bytes in base64/base64url")
}

5
internal/keys/export.go Normal file
View File

@@ -0,0 +1,5 @@
package keys
func Decrypt(encKeyB64, enc string) ([]byte, error) {
return decryptAESGCM(encKeyB64, enc)
}

149
internal/keys/keys.go Normal file
View File

@@ -0,0 +1,149 @@
package keys
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"time"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
type GenOpts struct {
Alg string // "RS256"|"RS384"|"RS512"|"EdDSA"
Bits int // RSA bits (2048/3072/4096). ignored for EdDSA
KID string // optional; if empty we generate one
NBF *time.Time
EXP *time.Time
}
func GenerateAndStore(db *gorm.DB, encKeyB64 string, opts GenOpts) (*models.SigningKey, error) {
if opts.KID == "" {
opts.KID = uuid.NewString()
}
var pubPEM, privPEM []byte
var alg = opts.Alg
switch alg {
case "RS256", "RS384", "RS512":
if opts.Bits == 0 {
opts.Bits = 3072
}
priv, err := rsa.GenerateKey(rand.Reader, opts.Bits)
if err != nil {
return nil, err
}
privDER := x509.MarshalPKCS1PrivateKey(priv)
privPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privDER})
pubDER := x509.MarshalPKCS1PublicKey(&priv.PublicKey)
pubPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: pubDER})
case "EdDSA":
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
privDER, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
privPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})
pubDER, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return nil, err
}
pubPEM = pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})
default:
return nil, fmt.Errorf("unsupported alg: %s", alg)
}
privateOut := string(privPEM)
if encKeyB64 != "" {
enc, err := encryptAESGCM(encKeyB64, privPEM)
if err != nil {
return nil, err
}
privateOut = enc
}
rec := models.SigningKey{
Kid: opts.KID,
Alg: alg,
Use: "sig",
IsActive: true,
PublicPEM: string(pubPEM),
PrivatePEM: privateOut,
NotBefore: opts.NBF,
ExpiresAt: opts.EXP,
}
if err := db.Create(&rec).Error; err != nil {
return nil, err
}
return &rec, nil
}
func encryptAESGCM(b64 string, plaintext []byte) (string, error) {
key, err := decode32ByteKey(b64)
if err != nil {
return "", err
}
if len(key) != 32 {
return "", errors.New("JWT_PRIVATE_ENC_KEY must be 32 bytes (base64url)")
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, aead.NonceSize())
if _, err = rand.Read(nonce); err != nil {
return "", err
}
out := aead.Seal(nonce, nonce, plaintext, nil)
return "enc:aesgcm:" + base64.RawStdEncoding.EncodeToString(out), nil
}
func decryptAESGCM(b64 string, enc string) ([]byte, error) {
if !bytes.HasPrefix([]byte(enc), []byte("enc:aesgcm:")) {
return nil, errors.New("not encrypted")
}
key, err := decode32ByteKey(b64)
if err != nil {
return nil, err
}
blob, err := base64.RawStdEncoding.DecodeString(enc[len("enc:aesgcm:"):])
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := aead.NonceSize()
if len(blob) < nonceSize {
return nil, errors.New("ciphertext too short")
}
nonce, ct := blob[:nonceSize], blob[nonceSize:]
return aead.Open(nil, nonce, ct, nil)
}

View File

@@ -0,0 +1,23 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/datatypes"
)
type Account struct {
// example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
UserID uuid.UUID `gorm:"index;not null" json:"user_id" format:"uuid"`
User User `gorm:"foreignKey:UserID" json:"-"`
Provider string `gorm:"not null" json:"provider"`
Subject string `gorm:"not null" json:"subject"`
Email *string `json:"email,omitempty"`
EmailVerified bool `gorm:"not null;default:false" json:"email_verified"`
Profile datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'" json:"profile"`
SecretHash *string `json:"-"`
CreatedAt time.Time `gorm:"type:timestamptz;column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
}

View File

@@ -0,0 +1,23 @@
package models
import (
"time"
"github.com/google/uuid"
)
type APIKey struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
Name string `gorm:"not null;default:''" json:"name"`
KeyHash string `gorm:"uniqueIndex;not null" json:"-"`
Scope string `gorm:"not null;default:''" json:"scope"`
UserID *uuid.UUID `json:"user_id,omitempty" format:"uuid"`
OrgID *uuid.UUID `json:"org_id,omitempty" format:"uuid"`
SecretHash *string `json:"-"`
ExpiresAt *time.Time `json:"expires_at,omitempty" format:"date-time"`
Revoked bool `gorm:"not null;default:false" json:"revoked"`
Prefix *string `json:"prefix,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty" format:"date-time"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
}

View File

@@ -0,0 +1,15 @@
package models
import (
"time"
"github.com/google/uuid"
)
type MasterKey struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
Key string `gorm:"not null"`
IsActive bool `gorm:"default:true"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at"`
}

View File

@@ -0,0 +1,18 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Membership struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
UserID uuid.UUID `gorm:"index;not null" json:"user_id" format:"uuid"`
User User `gorm:"foreignKey:UserID" json:"-"`
OrganizationID uuid.UUID `gorm:"index;not null" json:"org_id" format:"uuid"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"-"`
Role string `gorm:"not null;default:'member'" json:"role"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
}

View File

@@ -0,0 +1,21 @@
package models
import (
"time"
"github.com/google/uuid"
)
type NodePool struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Name string `gorm:"not null" json:"name"`
Servers []Server `gorm:"many2many:node_servers;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
//Annotations []Annotation `gorm:"many2many:node_annotations;constraint:OnDelete:CASCADE" json:"annotations,omitempty"`
//Labels []Label `gorm:"many2many:node_labels;constraint:OnDelete:CASCADE" json:"labels,omitempty"`
Taints []Taint `gorm:"many2many:node_taints;constraint:OnDelete:CASCADE" json:"taints,omitempty"`
//Clusters []Cluster `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"clusters,omitempty"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
}

View File

@@ -0,0 +1,20 @@
package models
import (
"time"
"github.com/google/uuid"
)
type OrganizationKey struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
MasterKeyID uuid.UUID `gorm:"type:uuid;not null"`
MasterKey MasterKey `gorm:"foreignKey:MasterKeyID;constraint:OnDelete:CASCADE" json:"master_key"`
EncryptedKey string `gorm:"not null"`
IV string `gorm:"not null"`
Tag string `gorm:"not null"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
}

View File

@@ -0,0 +1,16 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Organization struct {
// example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
Name string `gorm:"not null" json:"name"`
Domain *string `gorm:"index" json:"domain"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
}

View File

@@ -0,0 +1,17 @@
package models
import (
"time"
"github.com/google/uuid"
)
type RefreshToken struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
UserID uuid.UUID `gorm:"index;not null" json:"user_id"`
FamilyID uuid.UUID `gorm:"type:uuid;index;not null" json:"family_id"`
TokenHash string `gorm:"uniqueIndex;not null" json:"-"`
ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
}

36
internal/models/server.go Normal file
View File

@@ -0,0 +1,36 @@
package models
import (
"errors"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Server struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Hostname string `json:"hostname"`
PublicIPAddress *string `json:"public_ip_address,omitempty"`
PrivateIPAddress string `gorm:"not null" json:"private_ip_address"`
SSHUser string `gorm:"not null" json:"ssh_user"`
SshKeyID uuid.UUID `gorm:"type:uuid;not null" json:"ssh_key_id"`
SshKey SshKey `gorm:"foreignKey:SshKeyID" json:"ssh_key"`
Role string `gorm:"not null" json:"role"` // e.g., "master", "worker", "bastion"
Status string `gorm:"default:'pending'" json:"status"` // pending, provisioning, ready, failed
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
}
func (s *Server) BeforeSave(tx *gorm.DB) error {
role := strings.ToLower(strings.TrimSpace(s.Role))
if role == "bastion" {
if s.PublicIPAddress == nil || strings.TrimSpace(*s.PublicIPAddress) == "" {
return errors.New("public_ip_address is required for role=bastion")
}
}
return nil
}

View File

@@ -0,0 +1,22 @@
package models
import (
"time"
"github.com/google/uuid"
)
type SigningKey struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
Kid string `gorm:"uniqueIndex;not null" json:"kid"` // key id (header 'kid')
Alg string `gorm:"not null" json:"alg"` // RS256|RS384|RS512|EdDSA
Use string `gorm:"not null;default:'sig'" json:"use"` // "sig"
IsActive bool `gorm:"not null;default:true" json:"is_active"`
PublicPEM string `gorm:"type:text;not null" json:"-"`
PrivatePEM string `gorm:"type:text;not null" json:"-"`
NotBefore *time.Time `json:"-"`
ExpiresAt *time.Time `json:"-"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"`
RotatedFrom *uuid.UUID `json:"-"` // previous key id, if any
}

View File

@@ -0,0 +1,21 @@
package models
import (
"time"
"github.com/google/uuid"
)
type SshKey struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Name string `gorm:"not null" json:"name"`
PublicKey string `gorm:"not null"`
EncryptedPrivateKey string `gorm:"not null"`
PrivateIV string `gorm:"not null"`
PrivateTag string `gorm:"not null"`
Fingerprint string `gorm:"not null;index" json:"fingerprint"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
}

18
internal/models/taint.go Normal file
View File

@@ -0,0 +1,18 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Taint struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Key string `gorm:"not null" json:"key"`
Value string `gorm:"not null" json:"value"`
Effect string `gorm:"not null" json:"effect"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
}

18
internal/models/user.go Normal file
View File

@@ -0,0 +1,18 @@
package models
import (
"time"
"github.com/google/uuid"
)
type User struct {
// example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
DisplayName *string `json:"display_name,omitempty"`
PrimaryEmail *string `json:"primary_email,omitempty"`
AvatarURL *string `json:"avatar_url,omitempty"`
IsDisabled bool `json:"is_disabled"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
}

View File

@@ -0,0 +1,19 @@
package models
import (
"time"
"github.com/google/uuid"
)
type UserEmail struct {
// example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
UserID uuid.UUID `gorm:"index;not null" json:"user_id" format:"uuid"`
User User `gorm:"foreignKey:UserID" json:"user"`
Email string `gorm:"not null" json:"email"`
IsVerified bool `gorm:"not null;default:false" json:"is_verified"`
IsPrimary bool `gorm:"not null;default:false" json:"is_primary"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
}

85
internal/utils/crypto.go Normal file
View File

@@ -0,0 +1,85 @@
package utils
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
)
var (
ErrNoActiveMasterKey = errors.New("no active master key found")
ErrInvalidOrgID = errors.New("invalid organization ID")
ErrCredentialNotFound = errors.New("credential not found")
ErrInvalidMasterKeyLen = errors.New("invalid master key length")
)
func randomBytes(n int) ([]byte, error) {
b := make([]byte, n)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return nil, fmt.Errorf("rand: %w", err)
}
return b, nil
}
func encryptAESGCM(plaintext, key []byte) (cipherNoTag, iv, tag []byte, _ error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, nil, nil, fmt.Errorf("cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, nil, nil, fmt.Errorf("gcm: %w", err)
}
if gcm.NonceSize() != 12 {
return nil, nil, nil, fmt.Errorf("unexpected nonce size: %d", gcm.NonceSize())
}
iv, err = randomBytes(gcm.NonceSize())
if err != nil {
return nil, nil, nil, err
}
// Gos GCM returns ciphertext||tag, with 16-byte tag.
cipherWithTag := gcm.Seal(nil, iv, plaintext, nil)
if len(cipherWithTag) < 16 {
return nil, nil, nil, errors.New("ciphertext too short")
}
tagLen := 16
cipherNoTag = cipherWithTag[:len(cipherWithTag)-tagLen]
tag = cipherWithTag[len(cipherWithTag)-tagLen:]
return cipherNoTag, iv, tag, nil
}
func decryptAESGCM(cipherNoTag, key, iv, tag []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("gcm: %w", err)
}
if gcm.NonceSize() != len(iv) {
return nil, fmt.Errorf("bad nonce size: %d", len(iv))
}
// Reattach tag
cipherWithTag := append(append([]byte{}, cipherNoTag...), tag...)
plain, err := gcm.Open(nil, iv, cipherWithTag, nil)
if err != nil {
return nil, fmt.Errorf("gcm open: %w", err)
}
return plain, nil
}
func EncodeB64(b []byte) string {
return base64.StdEncoding.EncodeToString(b)
}
func DecodeB64(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(s)
}

27
internal/utils/helpers.go Normal file
View File

@@ -0,0 +1,27 @@
package utils
import (
"encoding/json"
"net/http"
)
// ErrorResponse is a simple, reusable error payload.
// swagger:model ErrorResponse
type ErrorResponse struct {
// A machine-readable error code, e.g. "validation_error"
// example: validation_error
Code string `json:"code"`
// Human-readable message
// example: slug is required
Message string `json:"message"`
}
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, code, msg string) {
WriteJSON(w, status, ErrorResponse{Code: code, Message: msg})
}

107
internal/utils/keys.go Normal file
View File

@@ -0,0 +1,107 @@
package utils
import (
"encoding/base64"
"errors"
"fmt"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
func getMasterKey(db *gorm.DB) ([]byte, error) {
var mk models.MasterKey
if err := db.Where("is_active = ?", true).Order("created_at DESC").First(&mk).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNoActiveMasterKey
}
return nil, fmt.Errorf("querying master key: %w", err)
}
keyBytes, err := base64.StdEncoding.DecodeString(mk.Key)
if err != nil {
return nil, fmt.Errorf("decoding master key: %w", err)
}
if len(keyBytes) != 32 {
return nil, fmt.Errorf("%w: got %d, want 32", ErrInvalidMasterKeyLen, len(keyBytes))
}
return keyBytes, nil
}
func getOrCreateTenantKey(orgID string, db *gorm.DB) ([]byte, error) {
var orgKey models.OrganizationKey
err := db.Where("organization_id = ?", orgID).First(&orgKey).Error
if err == nil {
encKeyB64 := orgKey.EncryptedKey
ivB64 := orgKey.IV
tagB64 := orgKey.Tag
encryptedKey, err := DecodeB64(encKeyB64)
if err != nil {
return nil, fmt.Errorf("decode enc key: %w", err)
}
iv, err := DecodeB64(ivB64)
if err != nil {
return nil, fmt.Errorf("decode iv: %w", err)
}
tag, err := DecodeB64(tagB64)
if err != nil {
return nil, fmt.Errorf("decode tag: %w", err)
}
masterKey, err := getMasterKey(db)
if err != nil {
return nil, err
}
return decryptAESGCM(encryptedKey, masterKey, iv, tag)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
// Create new tenant key and wrap with the current master key
orgUUID, err := uuid.Parse(orgID)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidOrgID, err)
}
tenantKey, err := randomBytes(32)
if err != nil {
return nil, fmt.Errorf("tenant key gen: %w", err)
}
masterKey, err := getMasterKey(db)
if err != nil {
return nil, err
}
encrypted, iv, tag, err := encryptAESGCM(tenantKey, masterKey)
if err != nil {
return nil, fmt.Errorf("wrap tenant key: %w", err)
}
var mk models.MasterKey
if err := db.Where("is_active = ?", true).Order("created_at DESC").First(&mk).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNoActiveMasterKey
}
return nil, fmt.Errorf("querying master key: %w", err)
}
orgKey = models.OrganizationKey{
OrganizationID: orgUUID,
MasterKeyID: mk.ID,
EncryptedKey: EncodeB64(encrypted),
IV: EncodeB64(iv),
Tag: EncodeB64(tag),
}
if err := db.Create(&orgKey).Error; err != nil {
return nil, fmt.Errorf("persist org key: %w", err)
}
return tenantKey, nil
}

View File

@@ -0,0 +1,44 @@
package utils
import (
"fmt"
"github.com/google/uuid"
"gorm.io/gorm"
)
func EncryptForOrg(orgID uuid.UUID, plaintext []byte, db *gorm.DB) (cipherB64, ivB64, tagB64 string, err error) {
tenantKey, err := getOrCreateTenantKey(orgID.String(), db)
if err != nil {
return "", "", "", err
}
ct, iv, tag, err := encryptAESGCM(plaintext, tenantKey)
if err != nil {
return "", "", "", err
}
return EncodeB64(ct), EncodeB64(iv), EncodeB64(tag), nil
}
func DecryptForOrg(orgID uuid.UUID, cipherB64, ivB64, tagB64 string, db *gorm.DB) (string, error) {
tenantKey, err := getOrCreateTenantKey(orgID.String(), db)
if err != nil {
return "", err
}
ct, err := DecodeB64(cipherB64)
if err != nil {
return "", fmt.Errorf("decode cipher: %w", err)
}
iv, err := DecodeB64(ivB64)
if err != nil {
return "", fmt.Errorf("decode iv: %w", err)
}
tag, err := DecodeB64(tagB64)
if err != nil {
return "", fmt.Errorf("decode tag: %w", err)
}
plain, err := decryptAESGCM(ct, tenantKey, iv, tag)
if err != nil {
return "", err
}
return string(plain), nil
}

View File

@@ -0,0 +1,36 @@
package version
import (
"fmt"
"runtime"
"runtime/debug"
)
var (
Version = "dev"
Commit = "none"
Date = "unknown"
BuiltBy = "local"
)
func Info() string {
v := fmt.Sprintf("Version: %s\nCommit: %s\nBuilt: %s\nBuiltBy: %s\nGo: %s %s/%s",
Version, Commit, Date, BuiltBy, runtime.Version(), runtime.GOOS, runtime.GOARCH)
// Include VCS info from embedded build metadata (if available)
if bi, ok := debug.ReadBuildInfo(); ok {
for _, s := range bi.Settings {
switch s.Key {
case "vcs":
v += fmt.Sprintf("\nVCS: %s", s.Value)
case "vcs.revision":
v += fmt.Sprintf("\nRevision: %s", s.Value)
case "vcs.time":
v += fmt.Sprintf("\nCommitTime: %s", s.Value)
case "vcs.modified":
v += fmt.Sprintf("\nModified: %s", s.Value)
}
}
}
return v
}

16
internal/web/devproxy.go Normal file
View File

@@ -0,0 +1,16 @@
package web
import (
"net/http"
"net/http/httputil"
"net/url"
)
func DevProxy(target string) (http.Handler, error) {
u, err := url.Parse(target)
if err != nil {
return nil, err
}
p := httputil.NewSingleHostReverseProxy(u)
return p, nil
}

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

15
internal/web/dist/index.html vendored Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ui</title>
<script type="module" crossorigin src="/assets/index-CFwByDWI.js"></script>
<link rel="modulepreload" crossorigin href="/assets/react-BZmgNp9X.js">
<link rel="stylesheet" crossorigin href="/assets/index-BvUUUOIq.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

BIN
internal/web/dist/index.html.br vendored Normal file

Binary file not shown.

BIN
internal/web/dist/index.html.gz vendored Normal file

Binary file not shown.

1
internal/web/dist/vite.svg vendored Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

239
internal/web/static.go Normal file
View File

@@ -0,0 +1,239 @@
package web
import (
"embed"
"io"
"io/fs"
"net/http"
"path"
"path/filepath"
"strings"
"time"
)
// NOTE: Vite outputs to web/dist with assets in dist/assets.
// If you add more nested folders in the future, include them here too.
//go:embed dist
var distFS embed.FS
// spaFileSystem serves embedded dist/ files with SPA fallback to index.html
type spaFileSystem struct {
fs fs.FS
}
func (s spaFileSystem) Open(name string) (fs.File, error) {
// Normalize, strip leading slash
if strings.HasPrefix(name, "/") {
name = name[1:]
}
// Try exact file
f, err := s.fs.Open(name)
if err == nil {
return f, nil
}
// If the requested file doesn't exist, fall back to index.html for SPA routes
// BUT only if it's not obviously a static asset extension
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".js", ".css", ".map", ".json", ".txt", ".ico", ".png", ".jpg", ".jpeg",
".svg", ".webp", ".gif", ".woff", ".woff2", ".ttf", ".otf", ".eot", ".wasm", ".br", ".gz":
return nil, fs.ErrNotExist
}
return s.fs.Open("index.html")
}
func newDistFS() (fs.FS, error) {
return fs.Sub(distFS, "dist")
}
// SPAHandler returns an http.Handler that serves the embedded UI (with caching)
func SPAHandler() (http.Handler, error) {
sub, err := newDistFS()
if err != nil {
return nil, err
}
spa := spaFileSystem{fs: sub}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") ||
r.URL.Path == "/api" ||
strings.HasPrefix(r.URL.Path, "/swagger") ||
strings.HasPrefix(r.URL.Path, "/debug/pprof") {
http.NotFound(w, r)
return
}
filePath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if filePath == "" {
filePath = "index.html"
}
// Try compressed variants for assets and HTML
// NOTE: we only change *Content-Encoding*; Content-Type derives from original ext
// Always vary on Accept-Encoding
w.Header().Add("Vary", "Accept-Encoding")
enc := r.Header.Get("Accept-Encoding")
if tryServeCompressed(w, r, spa, filePath, enc) {
return
}
// Fallback: normal open (or SPA fallback)
f, err := spa.Open(filePath)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
if strings.HasSuffix(filePath, ".html") {
w.Header().Set("Cache-Control", "no-cache")
} else {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
info, _ := f.Stat()
modTime := time.Now()
if info != nil {
modTime = info.ModTime()
}
http.ServeContent(w, r, filePath, modTime, file{f})
}), nil
}
func tryServeCompressed(w http.ResponseWriter, r *http.Request, spa spaFileSystem, filePath, enc string) bool {
wantsBR := strings.Contains(enc, "br")
wantsGZ := strings.Contains(enc, "gzip")
type cand struct {
logical string // MIME/type decision uses this (uncompressed name)
physical string // actual file we open (with .br/.gz)
enc string
}
var cands []cand
// 1) direct compressed variant of requested path (rare for SPA routes, but cheap to try)
if wantsBR {
cands = append(cands, cand{logical: filePath, physical: filePath + ".br", enc: "br"})
}
if wantsGZ {
cands = append(cands, cand{logical: filePath, physical: filePath + ".gz", enc: "gzip"})
}
// 2) SPA route: fall back to compressed index.html
if filepath.Ext(filePath) == "" {
if wantsBR {
cands = append(cands, cand{logical: "index.html", physical: "index.html.br", enc: "br"})
}
if wantsGZ {
cands = append(cands, cand{logical: "index.html", physical: "index.html.gz", enc: "gzip"})
}
}
for _, c := range cands {
f, err := spa.fs.Open(c.physical) // open EXACT path so we don't accidentally get SPA fallback
if err != nil {
continue
}
defer f.Close()
// Cache headers
if strings.HasSuffix(c.logical, ".html") {
w.Header().Set("Cache-Control", "no-cache")
} else {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
if ct := mimeByExt(path.Ext(c.logical)); ct != "" {
w.Header().Set("Content-Type", ct)
}
w.Header().Set("Content-Encoding", c.enc)
w.Header().Add("Vary", "Accept-Encoding")
info, _ := f.Stat()
modTime := time.Now()
if info != nil {
modTime = info.ModTime()
}
// Serve the precompressed bytes
http.ServeContent(w, r, c.physical, modTime, file{f})
return true
}
return false
}
func serveIfExists(w http.ResponseWriter, r *http.Request, spa spaFileSystem, filePath, ext, encoding string) bool {
cf := filePath + ext
f, err := spa.Open(cf)
if err != nil {
return false
}
defer f.Close()
// Set caching headers
if strings.HasSuffix(filePath, ".html") {
w.Header().Set("Cache-Control", "no-cache")
} else {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
// Preserve original content type by extension of *uncompressed* file
if ct := mimeByExt(path.Ext(filePath)); ct != "" {
w.Header().Set("Content-Type", ct)
}
w.Header().Set("Content-Encoding", encoding)
info, _ := f.Stat()
modTime := time.Now()
if info != nil {
modTime = info.ModTime()
}
// Serve the compressed bytes as an io.ReadSeeker if possible
http.ServeContent(w, r, cf, modTime, file{f})
return true
}
func mimeByExt(ext string) string {
switch strings.ToLower(ext) {
case ".html":
return "text/html; charset=utf-8"
case ".js":
return "application/javascript"
case ".css":
return "text/css; charset=utf-8"
case ".json":
return "application/json"
case ".svg":
return "image/svg+xml"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".webp":
return "image/webp"
case ".ico":
return "image/x-icon"
case ".woff2":
return "font/woff2"
case ".woff":
return "font/woff"
default:
return "" // let Go sniff if empty
}
}
// file wraps fs.File to implement io.ReadSeeker if possible (for ServeContent)
type file struct{ fs.File }
func (f file) Seek(offset int64, whence int) (int64, error) {
if s, ok := f.File.(io.Seeker); ok {
return s.Seek(offset, whence)
}
// Fallback: not seekable
return 0, fs.ErrInvalid
}