Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 31 additions & 6 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/cloudflare/cloudflare-go v0.116.0
github.com/creack/pty v1.1.24
github.com/digitalocean/godo v1.171.0
github.com/docker/docker v28.5.2+incompatible
github.com/fsnotify/fsnotify v1.7.0
github.com/gin-gonic/gin v1.9.1
github.com/go-sql-driver/mysql v1.9.3
Expand All @@ -20,13 +21,14 @@ require (
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.22
github.com/robfig/cron/v3 v3.0.1
golang.org/x/crypto v0.32.0
golang.org/x/crypto v0.44.0
golang.org/x/oauth2 v0.34.0
gopkg.in/yaml.v3 v3.0.1
)

require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
Expand All @@ -40,9 +42,19 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
Expand All @@ -52,18 +64,31 @@ require (
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/time v0.9.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gotest.tools/v3 v3.5.2 // indirect
)
117 changes: 95 additions & 22 deletions go.sum

Large diffs are not rendered by default.

147 changes: 147 additions & 0 deletions internal/api/domains_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,4 +381,151 @@ func TestAddDomain(t *testing.T) {
t.Error("expected domain ID to be generated")
}
})

t.Run("rejects duplicate domain", func(t *testing.T) {
createTestDeployment(t, tmpDir, "duplicate-domain", &models.ServiceMetadata{
Name: "duplicate-domain",
Type: "web",
Domains: []models.DomainConfig{
{
ID: "existing-1",
Service: "web",
ContainerPort: 80,
Domain: "existing.example.com",
},
},
})

duplicateDomain := models.DomainConfig{
Service: "web",
ContainerPort: 8080,
Domain: "existing.example.com",
}
body, _ := json.Marshal(duplicateDomain)

w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "name", Value: "duplicate-domain"}}
c.Request = httptest.NewRequest("POST", "/", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")

server.addDomain(c)

if w.Code != http.StatusConflict {
t.Errorf("expected status 409 Conflict, got %d: %s", w.Code, w.Body.String())
}

var response map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}

if response["error"] == "" {
t.Error("expected error message in response")
}
})

t.Run("rejects duplicate domain with same path prefix", func(t *testing.T) {
createTestDeployment(t, tmpDir, "duplicate-path", &models.ServiceMetadata{
Name: "duplicate-path",
Type: "web",
Domains: []models.DomainConfig{
{
ID: "existing-1",
Service: "api",
ContainerPort: 8080,
Domain: "app.example.com",
PathPrefix: "/api",
},
},
})

duplicateDomain := models.DomainConfig{
Service: "web",
ContainerPort: 80,
Domain: "app.example.com",
PathPrefix: "/api",
}
body, _ := json.Marshal(duplicateDomain)

w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "name", Value: "duplicate-path"}}
c.Request = httptest.NewRequest("POST", "/", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")

server.addDomain(c)

if w.Code != http.StatusConflict {
t.Errorf("expected status 409 Conflict, got %d: %s", w.Code, w.Body.String())
}
})

t.Run("allows same domain with different path prefix", func(t *testing.T) {
createTestDeployment(t, tmpDir, "different-path", &models.ServiceMetadata{
Name: "different-path",
Type: "web",
Domains: []models.DomainConfig{
{
ID: "existing-1",
Service: "api",
ContainerPort: 8080,
Domain: "app.example.com",
PathPrefix: "/api",
},
},
})

newDomain := models.DomainConfig{
Service: "web",
ContainerPort: 80,
Domain: "app.example.com",
PathPrefix: "/web",
}
body, _ := json.Marshal(newDomain)

w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "name", Value: "different-path"}}
c.Request = httptest.NewRequest("POST", "/", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")

server.addDomain(c)

// Should succeed (status 201) since path prefixes are different
if w.Code != http.StatusCreated {
t.Errorf("expected status 201 Created, got %d: %s", w.Code, w.Body.String())
}
})

t.Run("rejects duplicate of legacy domain", func(t *testing.T) {
createTestDeployment(t, tmpDir, "duplicate-legacy", &models.ServiceMetadata{
Name: "duplicate-legacy",
Type: "web",
Networking: models.NetworkingConfig{
Expose: true,
Domain: "legacy.example.com",
ContainerPort: 80,
},
})

duplicateDomain := models.DomainConfig{
Service: "web",
ContainerPort: 8080,
Domain: "legacy.example.com",
}
body, _ := json.Marshal(duplicateDomain)

w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "name", Value: "duplicate-legacy"}}
c.Request = httptest.NewRequest("POST", "/", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")

server.addDomain(c)

if w.Code != http.StatusConflict {
t.Errorf("expected status 409 Conflict for duplicate legacy domain, got %d: %s", w.Code, w.Body.String())
}
})
}
57 changes: 54 additions & 3 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/flatrun/agent/internal/proxy"
"github.com/flatrun/agent/internal/scheduler"
"github.com/flatrun/agent/internal/security"
"github.com/flatrun/agent/internal/setup"
"github.com/flatrun/agent/internal/system"
"github.com/flatrun/agent/internal/traffic"
"github.com/flatrun/agent/pkg/config"
Expand Down Expand Up @@ -68,6 +69,8 @@ type Server struct {
auditManager *audit.Manager
auditMiddleware *audit.Middleware
powerDNSManager *dns.PowerDNSManager
setupManager *setup.Manager
setupHandlers *setup.Handlers
}

func New(cfg *config.Config, configPath string) *Server {
Expand All @@ -79,8 +82,15 @@ func New(cfg *config.Config, configPath string) *Server {

router := gin.Default()

var setupManager *setup.Manager
var setupManagerErr error
setupManager, setupManagerErr = setup.NewManager(cfg.DeploymentsPath, cfg, configPath)
if setupManagerErr != nil {
log.Printf("Warning: Failed to initialize setup manager: %v", setupManagerErr)
}

if cfg.API.EnableCORS {
router.Use(corsMiddleware(cfg.API.AllowedOrigins))
router.Use(dynamicCorsMiddleware(cfg, setupManager))
}

manager := docker.NewManager(cfg.DeploymentsPath)
Expand Down Expand Up @@ -170,6 +180,11 @@ func New(cfg *config.Config, configPath string) *Server {

powerDNSManager := dns.NewPowerDNSManager(cfg)

var setupHandlers *setup.Handlers
if setupManager != nil {
setupHandlers = setup.NewHandlers(setupManager, authManager)
}

s := &Server{
config: cfg,
configPath: configPath,
Expand All @@ -192,6 +207,8 @@ func New(cfg *config.Config, configPath string) *Server {
auditManager: auditManager,
auditMiddleware: auditMiddleware,
powerDNSManager: powerDNSManager,
setupManager: setupManager,
setupHandlers: setupHandlers,
}

if backupManager != nil {
Expand All @@ -218,6 +235,26 @@ func (s *Server) setupRoutes() {
api.POST("/auth/login", s.authMiddleware.Login)
api.GET("/auth/validate", s.authMiddleware.ValidateToken)

if s.setupHandlers != nil {
setupGroup := api.Group("/setup")
{
setupGroup.GET("/status", s.setupHandlers.GetStatus)
setupGroup.GET("/verify-dns", s.setupHandlers.VerifyDNS)

setupProtected := setupGroup.Group("")
setupProtected.Use(s.setupHandlers.RequireSetupIncomplete())
{
setupProtected.POST("/initialize", s.setupHandlers.Initialize)
setupProtected.POST("/validate", s.setupHandlers.RunValidation)
setupProtected.POST("/domain", s.setupHandlers.ConfigureDomain)
setupProtected.POST("/cors", s.setupHandlers.ConfigureCORS)
setupProtected.POST("/user", s.setupHandlers.CreateUser)
setupProtected.POST("/install-ui", s.setupHandlers.InstallUI)
setupProtected.POST("/complete", s.setupHandlers.Complete)
}
}
}

// WebSocket endpoint handles its own auth via first-message
api.GET("/containers/:id/exec", s.containerExec)

Expand Down Expand Up @@ -3470,6 +3507,15 @@ func (s *Server) addDomain(c *gin.Context) {
deployment.Metadata.Domains = []models.DomainConfig{existingDomain}
}

for _, existing := range deployment.Metadata.Domains {
if existing.Domain == domain.Domain && existing.PathPrefix == domain.PathPrefix {
c.JSON(http.StatusConflict, gin.H{
"error": fmt.Sprintf("Domain %s%s already exists", domain.Domain, domain.PathPrefix),
})
return
}
}

deployment.Metadata.Domains = append(deployment.Metadata.Domains, domain)

if err := s.manager.SaveMetadata(name, deployment.Metadata); err != nil {
Expand Down Expand Up @@ -4086,10 +4132,15 @@ func toTitleCase(s string) string {
return strings.Join(words, " ")
}

func corsMiddleware(allowedOrigins []string) gin.HandlerFunc {
func dynamicCorsMiddleware(cfg *config.Config, setupMgr *setup.Manager) gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.Request.Header.Get("Origin")

allowedOrigins := cfg.API.AllowedOrigins
if setupMgr != nil {
allowedOrigins = setupMgr.GetAllowedOrigins()
}

for _, allowed := range allowedOrigins {
if origin == allowed || allowed == "*" {
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
Expand All @@ -4098,7 +4149,7 @@ func corsMiddleware(allowedOrigins []string) gin.HandlerFunc {
}

c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, X-API-Key")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")

if c.Request.Method == "OPTIONS" {
Expand Down
Loading
Loading