feat: adding embedded db-studio

Signed-off-by: allanice001 <allanice001@gmail.com>
This commit is contained in:
allanice001
2025-11-11 03:19:09 +00:00
parent 9108ee8f8f
commit 3a1ce33bca
20 changed files with 580 additions and 52 deletions

View File

@@ -49,6 +49,14 @@ func AuthMiddleware(db *gorm.DB, requireOrg bool) func(http.Handler) http.Handle
} else if appKey := r.Header.Get("X-APP-KEY"); appKey != "" {
secret := r.Header.Get("X-APP-SECRET")
user = auth.ValidateAppKeyPair(appKey, secret, db)
} else if c, err := r.Cookie("ag_jwt"); err == nil {
tok := strings.TrimSpace(c.Value)
if strings.HasPrefix(strings.ToLower(tok), "bearer ") {
tok = tok[7:]
}
if tok != "" {
user = auth.ValidateJWT(tok, db)
}
}
if user == nil {

View File

@@ -27,7 +27,7 @@ import (
httpSwagger "github.com/swaggo/http-swagger/v2"
)
func NewRouter(db *gorm.DB, jobs *bg.Jobs) http.Handler {
func NewRouter(db *gorm.DB, jobs *bg.Jobs, studio http.Handler) http.Handler {
zerolog.TimeFieldFormat = time.RFC3339
l := log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05"})
@@ -212,6 +212,17 @@ func NewRouter(db *gorm.DB, jobs *bg.Jobs) http.Handler {
})
})
})
if studio != nil {
r.Group(func(gr chi.Router) {
authUser := httpmiddleware.AuthMiddleware(db, false)
adminOnly := httpmiddleware.RequirePlatformAdmin()
gr.Use(authUser)
gr.Use(adminOnly)
gr.Mount("/db-studio", http.StripPrefix("/db-studio", studio))
})
}
if config.IsDebug() {
r.Route("/debug/pprof", func(pr chi.Router) {
pr.Get("/", httpPprof.Index)
@@ -251,6 +262,7 @@ func NewRouter(db *gorm.DB, jobs *bg.Jobs) http.Handler {
mux.Handle("/api/", r)
mux.Handle("/api", r)
mux.Handle("/swagger/", r)
mux.Handle("/db-studio/", r)
mux.Handle("/debug/pprof/", r)
// Everything else (/, /brand-preview, assets) → proxy (no middlewares)
mux.Handle("/", proxy)

View File

@@ -13,6 +13,7 @@ import (
type Config struct {
DbURL string
DbURLRO string
Port string
Host string
JWTIssuer string
@@ -29,6 +30,12 @@ type Config struct {
Debug bool
Swagger bool
SwaggerHost string
DBStudioEnabled bool
DBStudioBind string
DBStudioPort string
DBStudioUser string
DBStudioPass string
}
var (
@@ -48,6 +55,12 @@ func Load() (Config, error) {
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("database.url_ro", "")
v.SetDefault("db_studio.enabled", false)
v.SetDefault("db_studio.bind", "127.0.0.1")
v.SetDefault("db_studio.port", "0") // 0 = random
v.SetDefault("db_studio.user", "")
v.SetDefault("db_studio.pass", "")
v.SetDefault("ui.dev", false)
v.SetDefault("env", "development")
@@ -63,6 +76,7 @@ func Load() (Config, error) {
"bind.address",
"bind.port",
"database.url",
"database.url_ro",
"jwt.issuer",
"jwt.audience",
"jwt.private.enc.key",
@@ -76,6 +90,11 @@ func Load() (Config, error) {
"debug",
"swagger",
"swagger.host",
"db_studio.enabled",
"db_studio.bind",
"db_studio.port",
"db_studio.user",
"db_studio.pass",
}
for _, k := range keys {
_ = v.BindEnv(k)
@@ -84,6 +103,7 @@ func Load() (Config, error) {
// Build config
cfg := Config{
DbURL: v.GetString("database.url"),
DbURLRO: v.GetString("database.url_ro"),
Port: v.GetString("bind.port"),
Host: v.GetString("bind.address"),
JWTIssuer: v.GetString("jwt.issuer"),
@@ -100,6 +120,12 @@ func Load() (Config, error) {
Debug: v.GetBool("debug"),
Swagger: v.GetBool("swagger"),
SwaggerHost: v.GetString("swagger.host"),
DBStudioEnabled: v.GetBool("db_studio.enabled"),
DBStudioBind: v.GetString("db_studio.bind"),
DBStudioPort: v.GetString("db_studio.port"),
DBStudioUser: v.GetString("db_studio.user"),
DBStudioPass: v.GetString("db_studio.pass"),
}
// Validate

View File

@@ -273,6 +273,21 @@ func AuthCallback(db *gorm.DB) http.HandlerFunc {
return
}
secure := strings.HasPrefix(cfg.OAuthRedirectBase, "https://")
if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" {
secure = strings.EqualFold(xf, "https")
}
http.SetCookie(w, &http.Cookie{
Name: "ag_jwt",
Value: "Bearer " + access,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure,
MaxAge: int((time.Hour * 8).Seconds()),
})
// 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") {
@@ -377,6 +392,7 @@ func Refresh(db *gorm.DB) http.HandlerFunc {
// @Router /auth/logout [post]
func Logout(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cfg, _ := config.Load()
var req dto.LogoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
@@ -385,13 +401,27 @@ func Logout(db *gorm.DB) http.HandlerFunc {
rec, err := auth.ValidateRefreshToken(db, req.RefreshToken)
if err != nil {
w.WriteHeader(204) // already invalid/revoked
return
goto clearCookie
}
if err := auth.RevokeFamily(db, rec.FamilyID); err != nil {
utils.WriteError(w, 500, "revoke_failed", err.Error())
return
}
clearCookie:
http.SetCookie(w, &http.Cookie{
Name: "ag_jwt",
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode,
Secure: strings.HasPrefix(cfg.OAuthRedirectBase, "https"),
})
w.WriteHeader(204)
}
}

View File

@@ -106,20 +106,20 @@ func clusterToDTO(c models.Cluster) dto.ClusterResponse {
}
return dto.ClusterResponse{
ID: c.ID,
Name: c.Name,
Provider: c.Provider,
Region: c.Region,
Status: c.Status,
CaptainDomain: c.CaptainDomain,
ClusterLoadBalancer: c.ClusterLoadBalancer,
RandomToken: c.RandomToken,
CertificateKey: c.CertificateKey,
ControlLoadBalancer: c.ControlLoadBalancer,
NodePools: nps,
BastionServer: bastion,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
ID: c.ID,
Name: c.Name,
Provider: c.Provider,
Region: c.Region,
Status: c.Status,
CaptainDomain: c.CaptainDomain,
//ClusterLoadBalancer: c.ClusterLoadBalancer,
RandomToken: c.RandomToken,
CertificateKey: c.CertificateKey,
//ControlLoadBalancer: c.ControlLoadBalancer,
NodePools: nps,
BastionServer: bastion,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
}

View File

@@ -14,17 +14,20 @@ type Cluster struct {
Provider string `json:"provider"`
Region string `json:"region"`
Status string `json:"status"`
CaptainDomain string `gorm:"not null" json:"captain_domain"`
ClusterLoadBalancer string `json:"cluster_load_balancer"`
ControlLoadBalancer string `json:"control_load_balancer"`
RandomToken string `json:"random_token"`
CertificateKey string `json:"certificate_key"`
EncryptedKubeconfig string `gorm:"type:text" json:"-"`
KubeIV string `json:"-"`
KubeTag string `json:"-"`
NodePools []NodePool `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"`
BastionServerID *uuid.UUID `gorm:"type:uuid" json:"bastion_server_id,omitempty"`
BastionServer *Server `gorm:"foreignKey:BastionServerID" json:"bastion_server,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
CaptainDomain string `gorm:"not null" json:"captain_domain"` // nonprod.earth.onglueops.rocks
AppsLoadBalancer string `json:"cluster_load_balancer"` // {public_ip: 1.2.3.4, private_ip: 10.0.30.1, name: apps.CaqptainDomain}
GlueOpsLoadBalancer string `json:"control_load_balancer"` // {public_ip: 5.6.7.8, private_ip: 10.0.22.1, name: CaptainDomain}
ControlPlane string `json:"control_plane"` // <- dns cntlpn
RandomToken string `json:"random_token"`
CertificateKey string `json:"certificate_key"`
EncryptedKubeconfig string `gorm:"type:text" json:"-"`
KubeIV string `json:"-"`
KubeTag string `json:"-"`
NodePools []NodePool `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"`
BastionServerID *uuid.UUID `gorm:"type:uuid" json:"bastion_server_id,omitempty"`
BastionServer *Server `gorm:"foreignKey:BastionServerID" json:"bastion_server,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
}

View File

@@ -1,20 +0,0 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Dns struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_credentials_org_provider" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
ClusterID *uuid.UUID `gorm:"type:uuid" json:"cluster_id,omitempty"`
Cluster *Cluster `gorm:"foreignKey:ClusterID" json:"cluster,omitempty"`
Type string `gorm:"not null" json:"type,omitempty"`
Name string `gorm:"not null" json:"name,omitempty"`
Content string `gorm:"not null" json:"content,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
}

21
internal/models/domain.go Normal file
View File

@@ -0,0 +1,21 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Domain struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_credentials_org_provider" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
ClusterID *uuid.UUID `gorm:"type:uuid" json:"cluster_id,omitempty"`
Cluster *Cluster `gorm:"foreignKey:ClusterID" json:"cluster,omitempty"`
DomainName string `gorm:"not null;index" json:"domain_name,omitempty"`
DomainID string
CredentialID uuid.UUID `gorm:"type:uuid;not null" json:"credential_id"`
Credential Credential `gorm:"foreignKey:CredentialID" json:"credential,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
}

View File

@@ -0,0 +1,85 @@
package web
import (
"crypto/sha256"
"embed"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
)
//go:embed pgwebbin/*
var pgwebFS embed.FS
type pgwebAsset struct {
Path string
SHA256 string
}
var pgwebIndex = map[string]pgwebAsset{
"linux/amd64": {Path: "pgwebbin/pgweb-linux-amd64", SHA256: ""},
"linux/arm64": {Path: "pgwebbin/pgweb-linux-arm64", SHA256: ""},
"darwin/amd64": {Path: "pgwebbin/pgweb-darwin-amd64", SHA256: ""},
"darwin/arm64": {Path: "pgwebbin/pgweb-darwin-arm64", SHA256: ""},
}
func ExtractPgweb() (string, error) {
key := runtime.GOOS + "/" + runtime.GOARCH
as, ok := pgwebIndex[key]
if !ok {
return "", fmt.Errorf("pgweb not embedded for %s", key)
}
f, err := pgwebFS.Open(as.Path)
if err != nil {
return "", fmt.Errorf("embedded pgweb missing: %w", err)
}
defer f.Close()
tmpDir, err := os.MkdirTemp("", "pgweb-*")
if err != nil {
return "", err
}
filename := "pgweb"
if runtime.GOOS == "windows" {
filename += ".exe"
}
outPath := filepath.Join(tmpDir, filename)
out, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o700)
if err != nil {
return "", err
}
defer out.Close()
h := sha256.New()
if _, err = io.Copy(io.MultiWriter(out, h), f); err != nil {
return "", err
}
if as.SHA256 != "" {
got := hex.EncodeToString(h.Sum(nil))
if got != as.SHA256 {
return "", fmt.Errorf("pgweb checksum mismatch: got=%s want=%s", got, as.SHA256)
}
}
// Make sure its executable on Unix; Windows ignores this.
_ = os.Chmod(outPath, 0o700)
return outPath, nil
}
func CleanupPgweb(pgwebPath string) error {
if pgwebPath == "" {
return nil
}
dir := filepath.Dir(pgwebPath)
if dir == "" || dir == "/" || dir == "." {
return errors.New("refusing to remove suspicious directory")
}
return os.RemoveAll(dir)
}

106
internal/web/pgweb_proxy.go Normal file
View File

@@ -0,0 +1,106 @@
package web
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/exec"
"time"
)
type Pgweb struct {
cmd *exec.Cmd
host string
port string
bin string
}
func StartPgweb(dbURL, host, port string, readonly bool, user, pass string) (*Pgweb, error) {
// pick random port if 0/empty
if port == "" || port == "0" {
l, err := net.Listen("tcp", net.JoinHostPort(host, "0"))
if err != nil {
return nil, err
}
defer l.Close()
_, p, _ := net.SplitHostPort(l.Addr().String())
port = p
}
args := []string{
"--url", dbURL,
"--bind", host,
"--listen", port,
"--skip-open",
}
if readonly {
args = append(args, "--readonly")
}
if user != "" && pass != "" {
args = append(args, "--auth-user", user, "--auth-pass", pass)
}
pgwebBinary, err := ExtractPgweb()
if err != nil {
return nil, fmt.Errorf("pgweb extract: %w", err)
}
cmd := exec.Command(pgwebBinary, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return nil, err
}
// wait for port to be ready
deadline := time.Now().Add(4 * time.Second)
for time.Now().Before(deadline) {
c, err := net.DialTimeout("tcp", net.JoinHostPort(host, port), 200*time.Millisecond)
if err == nil {
_ = c.Close()
return &Pgweb{cmd: cmd, host: host, port: port}, nil
}
time.Sleep(120 * time.Millisecond)
}
// still return object so caller can Stop()
//return &Pgweb{cmd: cmd, host: host, port: port, bin: pgwebBinary}, nil
return nil, fmt.Errorf("pgweb did not become ready on %s:%s", host, port)
}
func (p *Pgweb) Proxy() http.HandlerFunc {
target, _ := url.Parse("http://" + net.JoinHostPort(p.host, p.port))
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.FlushInterval = 100 * time.Millisecond
return func(w http.ResponseWriter, r *http.Request) {
r.Host = target.Host
// Let pgweb handle its paths; we mount it at a prefix.
proxy.ServeHTTP(w, r)
}
}
func (p *Pgweb) Stop(ctx context.Context) error {
if p == nil || p.cmd == nil || p.cmd.Process == nil {
return nil
}
_ = p.cmd.Process.Kill()
done := make(chan struct{})
go func() { _, _ = p.cmd.Process.Wait(); close(done) }()
select {
case <-done:
if p.bin != "" {
_ = CleanupPgweb(p.bin)
}
case <-ctx.Done():
return ctx.Err()
}
return nil
}
func (p *Pgweb) Port() string {
return p.port
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -61,6 +61,7 @@ func SPAHandler() (http.Handler, error) {
if strings.HasPrefix(r.URL.Path, "/api/") ||
r.URL.Path == "/api" ||
strings.HasPrefix(r.URL.Path, "/swagger") ||
strings.HasPrefix(r.URL.Path, "/db-studio") ||
strings.HasPrefix(r.URL.Path, "/debug/pprof") {
http.NotFound(w, r)
return