mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 04:40:05 +01:00
feat: adding embedded db-studio
Signed-off-by: allanice001 <allanice001@gmail.com>
This commit is contained in:
3
Makefile
3
Makefile
@@ -310,6 +310,9 @@ doctor: ## Print environment diagnostics (shell, versions, generator availabilit
|
|||||||
$(OGC_BIN) version || true; \
|
$(OGC_BIN) version || true; \
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fetch-pgweb: ## Fetch PGWeb Binaries for embedding
|
||||||
|
go run ./tools/pgweb_fetch.go
|
||||||
|
|
||||||
help: ## Show this help
|
help: ## Show this help
|
||||||
@grep -hE '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
|
@grep -hE '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
|
||||||
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|||||||
51
cmd/db.go
Normal file
51
cmd/db.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glueops/autoglue/internal/config"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var dbCmd = &cobra.Command{
|
||||||
|
Use: "db",
|
||||||
|
Short: "Database utilities",
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbPsqlCmd = &cobra.Command{
|
||||||
|
Use: "psql",
|
||||||
|
Short: "Open a psql session to the app database",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg.DbURL == "" {
|
||||||
|
return errors.New("database.url is empty")
|
||||||
|
}
|
||||||
|
psql := "psql"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
psql = "psql.exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 72*time.Hour)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
psqlCmd := exec.CommandContext(ctx, psql, cfg.DbURL)
|
||||||
|
psqlCmd.Stdin, psqlCmd.Stdout, psqlCmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||||
|
fmt.Println("Launching psql…")
|
||||||
|
return psqlCmd.Run()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
dbCmd.AddCommand(dbPsqlCmd)
|
||||||
|
|
||||||
|
rootCmd.AddCommand(dbCmd)
|
||||||
|
}
|
||||||
32
cmd/serve.go
32
cmd/serve.go
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/glueops/autoglue/internal/auth"
|
"github.com/glueops/autoglue/internal/auth"
|
||||||
"github.com/glueops/autoglue/internal/bg"
|
"github.com/glueops/autoglue/internal/bg"
|
||||||
"github.com/glueops/autoglue/internal/config"
|
"github.com/glueops/autoglue/internal/config"
|
||||||
|
"github.com/glueops/autoglue/internal/web"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -33,6 +34,8 @@ var serveCmd = &cobra.Command{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var pgwebInst *web.Pgweb
|
||||||
|
|
||||||
jobs, err := bg.NewJobs(rt.DB, cfg.DbURL)
|
jobs, err := bg.NewJobs(rt.DB, cfg.DbURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to init background jobs: %v", err)
|
log.Fatalf("failed to init background jobs: %v", err)
|
||||||
@@ -119,7 +122,31 @@ var serveCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
r := api.NewRouter(rt.DB, jobs)
|
var studioHandler http.Handler
|
||||||
|
r := api.NewRouter(rt.DB, jobs, nil)
|
||||||
|
|
||||||
|
if cfg.DBStudioEnabled {
|
||||||
|
dbURL := cfg.DbURLRO
|
||||||
|
if dbURL == "" {
|
||||||
|
dbURL = cfg.DbURL
|
||||||
|
}
|
||||||
|
|
||||||
|
pgwebInst, err = web.StartPgweb(
|
||||||
|
dbURL,
|
||||||
|
cfg.DBStudioBind,
|
||||||
|
cfg.DBStudioPort,
|
||||||
|
true,
|
||||||
|
cfg.DBStudioUser,
|
||||||
|
cfg.DBStudioPass,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("pgweb failed to start: %v", err)
|
||||||
|
} else {
|
||||||
|
studioHandler = http.HandlerFunc(pgwebInst.Proxy())
|
||||||
|
r = api.NewRouter(rt.DB, jobs, studioHandler)
|
||||||
|
log.Printf("pgweb running on http://%s:%s (proxied at /db-studio/)", cfg.DBStudioBind, pgwebInst.Port())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port)
|
addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port)
|
||||||
|
|
||||||
@@ -143,6 +170,9 @@ var serveCmd = &cobra.Command{
|
|||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
fmt.Println("\n⏳ Shutting down...")
|
fmt.Println("\n⏳ Shutting down...")
|
||||||
|
if pgwebInst != nil {
|
||||||
|
_ = pgwebInst.Stop(context.Background())
|
||||||
|
}
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return srv.Shutdown(shutdownCtx)
|
return srv.Shutdown(shutdownCtx)
|
||||||
|
|||||||
@@ -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 != "" {
|
} else if appKey := r.Header.Get("X-APP-KEY"); appKey != "" {
|
||||||
secret := r.Header.Get("X-APP-SECRET")
|
secret := r.Header.Get("X-APP-SECRET")
|
||||||
user = auth.ValidateAppKeyPair(appKey, secret, db)
|
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 {
|
if user == nil {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import (
|
|||||||
httpSwagger "github.com/swaggo/http-swagger/v2"
|
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
|
zerolog.TimeFieldFormat = time.RFC3339
|
||||||
|
|
||||||
l := log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05"})
|
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() {
|
if config.IsDebug() {
|
||||||
r.Route("/debug/pprof", func(pr chi.Router) {
|
r.Route("/debug/pprof", func(pr chi.Router) {
|
||||||
pr.Get("/", httpPprof.Index)
|
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("/api", r)
|
mux.Handle("/api", r)
|
||||||
mux.Handle("/swagger/", r)
|
mux.Handle("/swagger/", r)
|
||||||
|
mux.Handle("/db-studio/", r)
|
||||||
mux.Handle("/debug/pprof/", r)
|
mux.Handle("/debug/pprof/", r)
|
||||||
// Everything else (/, /brand-preview, assets) → proxy (no middlewares)
|
// Everything else (/, /brand-preview, assets) → proxy (no middlewares)
|
||||||
mux.Handle("/", proxy)
|
mux.Handle("/", proxy)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DbURL string
|
DbURL string
|
||||||
|
DbURLRO string
|
||||||
Port string
|
Port string
|
||||||
Host string
|
Host string
|
||||||
JWTIssuer string
|
JWTIssuer string
|
||||||
@@ -29,6 +30,12 @@ type Config struct {
|
|||||||
Debug bool
|
Debug bool
|
||||||
Swagger bool
|
Swagger bool
|
||||||
SwaggerHost string
|
SwaggerHost string
|
||||||
|
|
||||||
|
DBStudioEnabled bool
|
||||||
|
DBStudioBind string
|
||||||
|
DBStudioPort string
|
||||||
|
DBStudioUser string
|
||||||
|
DBStudioPass string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -48,6 +55,12 @@ func Load() (Config, error) {
|
|||||||
v.SetDefault("bind.address", "127.0.0.1")
|
v.SetDefault("bind.address", "127.0.0.1")
|
||||||
v.SetDefault("bind.port", "8080")
|
v.SetDefault("bind.port", "8080")
|
||||||
v.SetDefault("database.url", "postgres://user:pass@localhost:5432/db?sslmode=disable")
|
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("ui.dev", false)
|
||||||
v.SetDefault("env", "development")
|
v.SetDefault("env", "development")
|
||||||
@@ -63,6 +76,7 @@ func Load() (Config, error) {
|
|||||||
"bind.address",
|
"bind.address",
|
||||||
"bind.port",
|
"bind.port",
|
||||||
"database.url",
|
"database.url",
|
||||||
|
"database.url_ro",
|
||||||
"jwt.issuer",
|
"jwt.issuer",
|
||||||
"jwt.audience",
|
"jwt.audience",
|
||||||
"jwt.private.enc.key",
|
"jwt.private.enc.key",
|
||||||
@@ -76,6 +90,11 @@ func Load() (Config, error) {
|
|||||||
"debug",
|
"debug",
|
||||||
"swagger",
|
"swagger",
|
||||||
"swagger.host",
|
"swagger.host",
|
||||||
|
"db_studio.enabled",
|
||||||
|
"db_studio.bind",
|
||||||
|
"db_studio.port",
|
||||||
|
"db_studio.user",
|
||||||
|
"db_studio.pass",
|
||||||
}
|
}
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
_ = v.BindEnv(k)
|
_ = v.BindEnv(k)
|
||||||
@@ -84,6 +103,7 @@ func Load() (Config, error) {
|
|||||||
// Build config
|
// Build config
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
DbURL: v.GetString("database.url"),
|
DbURL: v.GetString("database.url"),
|
||||||
|
DbURLRO: v.GetString("database.url_ro"),
|
||||||
Port: v.GetString("bind.port"),
|
Port: v.GetString("bind.port"),
|
||||||
Host: v.GetString("bind.address"),
|
Host: v.GetString("bind.address"),
|
||||||
JWTIssuer: v.GetString("jwt.issuer"),
|
JWTIssuer: v.GetString("jwt.issuer"),
|
||||||
@@ -100,6 +120,12 @@ func Load() (Config, error) {
|
|||||||
Debug: v.GetBool("debug"),
|
Debug: v.GetBool("debug"),
|
||||||
Swagger: v.GetBool("swagger"),
|
Swagger: v.GetBool("swagger"),
|
||||||
SwaggerHost: v.GetString("swagger.host"),
|
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
|
// Validate
|
||||||
|
|||||||
@@ -273,6 +273,21 @@ func AuthCallback(db *gorm.DB) http.HandlerFunc {
|
|||||||
return
|
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
|
// If the state indicates SPA popup mode, postMessage tokens to the opener and close
|
||||||
state := r.URL.Query().Get("state")
|
state := r.URL.Query().Get("state")
|
||||||
if strings.Contains(state, "mode=spa") {
|
if strings.Contains(state, "mode=spa") {
|
||||||
@@ -377,6 +392,7 @@ func Refresh(db *gorm.DB) http.HandlerFunc {
|
|||||||
// @Router /auth/logout [post]
|
// @Router /auth/logout [post]
|
||||||
func Logout(db *gorm.DB) http.HandlerFunc {
|
func Logout(db *gorm.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg, _ := config.Load()
|
||||||
var req dto.LogoutRequest
|
var req dto.LogoutRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
utils.WriteError(w, 400, "invalid_json", err.Error())
|
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)
|
rec, err := auth.ValidateRefreshToken(db, req.RefreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(204) // already invalid/revoked
|
w.WriteHeader(204) // already invalid/revoked
|
||||||
return
|
goto clearCookie
|
||||||
}
|
}
|
||||||
if err := auth.RevokeFamily(db, rec.FamilyID); err != nil {
|
if err := auth.RevokeFamily(db, rec.FamilyID); err != nil {
|
||||||
utils.WriteError(w, 500, "revoke_failed", err.Error())
|
utils.WriteError(w, 500, "revoke_failed", err.Error())
|
||||||
return
|
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)
|
w.WriteHeader(204)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,10 +112,10 @@ func clusterToDTO(c models.Cluster) dto.ClusterResponse {
|
|||||||
Region: c.Region,
|
Region: c.Region,
|
||||||
Status: c.Status,
|
Status: c.Status,
|
||||||
CaptainDomain: c.CaptainDomain,
|
CaptainDomain: c.CaptainDomain,
|
||||||
ClusterLoadBalancer: c.ClusterLoadBalancer,
|
//ClusterLoadBalancer: c.ClusterLoadBalancer,
|
||||||
RandomToken: c.RandomToken,
|
RandomToken: c.RandomToken,
|
||||||
CertificateKey: c.CertificateKey,
|
CertificateKey: c.CertificateKey,
|
||||||
ControlLoadBalancer: c.ControlLoadBalancer,
|
//ControlLoadBalancer: c.ControlLoadBalancer,
|
||||||
NodePools: nps,
|
NodePools: nps,
|
||||||
BastionServer: bastion,
|
BastionServer: bastion,
|
||||||
CreatedAt: c.CreatedAt,
|
CreatedAt: c.CreatedAt,
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ type Cluster struct {
|
|||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Region string `json:"region"`
|
Region string `json:"region"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CaptainDomain string `gorm:"not null" json:"captain_domain"`
|
CaptainDomain string `gorm:"not null" json:"captain_domain"` // nonprod.earth.onglueops.rocks
|
||||||
ClusterLoadBalancer string `json:"cluster_load_balancer"`
|
AppsLoadBalancer string `json:"cluster_load_balancer"` // {public_ip: 1.2.3.4, private_ip: 10.0.30.1, name: apps.CaqptainDomain}
|
||||||
ControlLoadBalancer string `json:"control_load_balancer"`
|
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"`
|
RandomToken string `json:"random_token"`
|
||||||
CertificateKey string `json:"certificate_key"`
|
CertificateKey string `json:"certificate_key"`
|
||||||
EncryptedKubeconfig string `gorm:"type:text" json:"-"`
|
EncryptedKubeconfig string `gorm:"type:text" json:"-"`
|
||||||
|
|||||||
@@ -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
21
internal/models/domain.go
Normal 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()"`
|
||||||
|
}
|
||||||
85
internal/web/pgweb_embed.go
Normal file
85
internal/web/pgweb_embed.go
Normal 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 it’s 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
106
internal/web/pgweb_proxy.go
Normal 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
|
||||||
|
}
|
||||||
BIN
internal/web/pgwebbin/pgweb-darwin-amd64
Executable file
BIN
internal/web/pgwebbin/pgweb-darwin-amd64
Executable file
Binary file not shown.
BIN
internal/web/pgwebbin/pgweb-darwin-arm64
Executable file
BIN
internal/web/pgwebbin/pgweb-darwin-arm64
Executable file
Binary file not shown.
BIN
internal/web/pgwebbin/pgweb-linux-amd64
Executable file
BIN
internal/web/pgwebbin/pgweb-linux-amd64
Executable file
Binary file not shown.
BIN
internal/web/pgwebbin/pgweb-linux-arm64
Executable file
BIN
internal/web/pgwebbin/pgweb-linux-arm64
Executable file
Binary file not shown.
@@ -61,6 +61,7 @@ func SPAHandler() (http.Handler, error) {
|
|||||||
if strings.HasPrefix(r.URL.Path, "/api/") ||
|
if strings.HasPrefix(r.URL.Path, "/api/") ||
|
||||||
r.URL.Path == "/api" ||
|
r.URL.Path == "/api" ||
|
||||||
strings.HasPrefix(r.URL.Path, "/swagger") ||
|
strings.HasPrefix(r.URL.Path, "/swagger") ||
|
||||||
|
strings.HasPrefix(r.URL.Path, "/db-studio") ||
|
||||||
strings.HasPrefix(r.URL.Path, "/debug/pprof") {
|
strings.HasPrefix(r.URL.Path, "/debug/pprof") {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
|
|||||||
171
tools/pgweb_fetch.go
Normal file
171
tools/pgweb_fetch.go
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
//go:build ignore
|
||||||
|
// +build ignore
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Target struct {
|
||||||
|
Name string
|
||||||
|
URL string
|
||||||
|
SHA256 string
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = "0.16.2"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
targets := []Target{
|
||||||
|
{
|
||||||
|
Name: "pgweb-linux-amd64",
|
||||||
|
URL: fmt.Sprintf("https://github.com/sosedoff/pgweb/releases/download/v%s/pgweb_linux_amd64.zip", version),
|
||||||
|
SHA256: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "pgweb-linux-arm64",
|
||||||
|
URL: fmt.Sprintf("https://github.com/sosedoff/pgweb/releases/download/v%s/pgweb_linux_arm64.zip", version),
|
||||||
|
SHA256: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "pgweb-darwin-amd64",
|
||||||
|
URL: fmt.Sprintf("https://github.com/sosedoff/pgweb/releases/download/v%s/pgweb_darwin_amd64.zip", version),
|
||||||
|
SHA256: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "pgweb-darwin-arm64",
|
||||||
|
URL: fmt.Sprintf("https://github.com/sosedoff/pgweb/releases/download/v%s/pgweb_darwin_arm64.zip", version),
|
||||||
|
SHA256: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
outDir := filepath.Join("internal", "web", "pgwebbin")
|
||||||
|
_ = os.MkdirAll(outDir, 0o755)
|
||||||
|
|
||||||
|
for _, t := range targets {
|
||||||
|
destZip := filepath.Join(outDir, t.Name+".zip")
|
||||||
|
fmt.Printf("Downloading %s...\n", t.URL)
|
||||||
|
if err := downloadFile(destZip, t.URL); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
binPath := filepath.Join(outDir, t.Name)
|
||||||
|
if err := unzipSingle(destZip, binPath); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
_ = os.Remove(destZip)
|
||||||
|
|
||||||
|
// Make executable
|
||||||
|
if err := os.Chmod(binPath, 0o755); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Saved %s\n", binPath)
|
||||||
|
|
||||||
|
// Compute checksum
|
||||||
|
sum, _ := fileSHA256(binPath)
|
||||||
|
fmt.Printf(" SHA256: %s\n", sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFile(dest, url string) error {
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("bad status: %s", resp.Status)
|
||||||
|
}
|
||||||
|
out, err := os.Create(dest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
_, err = io.Copy(out, resp.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileSHA256(path string) (string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unzipSingle(zipPath, outPath string) error {
|
||||||
|
// minimal unzip: because pgweb zip has only one binary
|
||||||
|
r, err := os.Open(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
// use archive/zip
|
||||||
|
stat, err := os.Stat(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return unzipFile(zipPath, outPath, stat.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
func unzipFile(zipFile, outFile string, _ int64) error {
|
||||||
|
r, err := os.Open(zipFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
fi, _ := r.Stat()
|
||||||
|
|
||||||
|
// rely on standard zip reader
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpZip := filepath.Join(os.TempDir(), fi.Name())
|
||||||
|
if err := os.WriteFile(tmpZip, data, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpZip)
|
||||||
|
|
||||||
|
zr, err := os.Open(tmpZip)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
// extract using standard lib
|
||||||
|
zr2, err := zip.OpenReader(tmpZip)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer zr2.Close()
|
||||||
|
for _, f := range zr2.File {
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
out, err := os.Create(outFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(out, rc); err != nil {
|
||||||
|
out.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Close()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import tailwindcss from "@tailwindcss/vite"
|
import tailwindcss from "@tailwindcss/vite"
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from "vite"
|
||||||
import react from '@vitejs/plugin-react'
|
import react from "@vitejs/plugin-react"
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -16,6 +16,7 @@ export default defineConfig({
|
|||||||
proxy: {
|
proxy: {
|
||||||
"/api": "http://localhost:8080",
|
"/api": "http://localhost:8080",
|
||||||
"/swagger": "http://localhost:8080",
|
"/swagger": "http://localhost:8080",
|
||||||
|
"/db-studio": "http://localhost:8080",
|
||||||
},
|
},
|
||||||
allowedHosts: ['.getexposed.io']
|
allowedHosts: ['.getexposed.io']
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user