fix: comprehensive security, performance, and quality hardening

Security: apply AdminOnly middleware to mutating routes, require
ENCRYPTION_KEY and ADMIN_PASSWORD (no insecure defaults), restrict
CORS to same-origin, fix OIDC token delivery via cookie instead of
URL query param, add rate limiting on login, add MaxBytesReader,
validate volume paths against traversal, add security headers,
validate user roles, add Secure flag to OIDC cookie.

Performance: set SQLite MaxOpenConns(1) to prevent SQLITE_BUSY,
add FK indexes on 8 columns, track notifier goroutines with
WaitGroup for graceful shutdown, use GetRegistryByName instead of
GetAllRegistries in deployer, pass basePath param to avoid redundant
settings query, return empty slices from store to remove reflection.

Quality: refactor TriggerDeploy to delegate to runDeploy (~100 lines
removed), consolidate duplicated utilities (extractPort, boolToInt,
now, isTerminalStatus) into shared exports, migrate all log.Printf
to slog structured logging, use consistent webhook response envelope,
remove dead code (parseEnvVars, duplicate auth types).

UX: clean up NPM proxy on instance removal via API, add README with
quickstart guide, add .env.example, require ADMIN_PASSWORD in
docker-compose, document staging-net prerequisite.
This commit is contained in:
2026-03-29 12:49:24 +03:00
parent c5bfc586c1
commit be6ad15efc
32 changed files with 519 additions and 392 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ func (d *Deployer) blueGreenDeploy(
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
portStr := fmt.Sprintf("%d/tcp", project.Port)
envVars := d.mergeEnvVars(project, stage.ID)
mounts := d.computeVolumeMounts(project.ID, stage.Name, imageTag)
mounts := d.computeVolumeMounts(project.ID, stage.Name, imageTag, settings.BaseVolumePath)
containerCfg := docker.ContainerConfig{
Name: containerName,
+12 -113
View File
@@ -192,8 +192,7 @@ func (d *Deployer) runDeploy(ctx context.Context, project store.Project, stage s
}
// TriggerDeploy is the synchronous entry point for deployments (used by poller and webhook).
// It orchestrates the full flow: pull image -> create container -> start -> configure proxy -> health check.
// On failure, it rolls back (removes container, deletes proxy host, updates status).
// It validates inputs, creates a deploy record, and delegates to runDeploy.
func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error {
if d.shuttingDown.Load() {
return fmt.Errorf("deployer is shutting down, rejecting new deploy")
@@ -202,7 +201,6 @@ func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageT
d.activeWg.Add(1)
defer d.activeWg.Done()
// Load project and stage from store.
project, err := d.store.GetProjectByID(projectID)
if err != nil {
return fmt.Errorf("get project: %w", err)
@@ -213,17 +211,10 @@ func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageT
return fmt.Errorf("get stage: %w", err)
}
// Validate promote_from constraint.
if err := d.validatePromoteFrom(stage, imageTag); err != nil {
return fmt.Errorf("promote validation: %w", err)
}
settings, err := d.store.GetSettings()
if err != nil {
return fmt.Errorf("get settings: %w", err)
}
// Create deploy record.
deploy, err := d.store.CreateDeploy(store.Deploy{
ProjectID: projectID,
StageID: stageID,
@@ -234,69 +225,9 @@ func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageT
return fmt.Errorf("create deploy record: %w", err)
}
slog.Info("starting deploy",
"deploy_id", deploy.ID,
"project", project.Name,
"stage", stage.Name,
"tag", imageTag,
)
d.logDeploy(deploy.ID, fmt.Sprintf("Starting deploy of %s:%s for project %s, stage %s", project.Image, imageTag, project.Name, stage.Name), "info")
// Enforce max_instances before deploying.
if err := d.enforceMaxInstances(ctx, stage, deploy.ID, settings); err != nil {
d.logDeploy(deploy.ID, fmt.Sprintf("Failed to enforce max instances: %v", err), "error")
// Non-fatal: continue with deploy.
if err := d.runDeploy(ctx, project, stage, deploy.ID, imageTag); err != nil {
return err
}
// Choose deploy strategy: blue-green if stage has max_instances=1 and an existing instance.
var containerID string
var npmProxyID int
var instanceID string
var deployErr error
if stage.MaxInstances == 1 {
containerID, npmProxyID, instanceID, deployErr = d.blueGreenDeploy(ctx, project, stage, settings, deploy.ID, imageTag)
} else {
// Execute the standard deploy pipeline. Track state for rollback.
containerID, npmProxyID, instanceID, deployErr = d.executeDeploy(ctx, project, stage, settings, deploy.ID, imageTag)
}
if deployErr != nil {
d.logDeploy(deploy.ID, fmt.Sprintf("Deploy failed: %v", deployErr), "error")
d.publishDeployStatus(deploy.ID, projectID, stageID, imageTag, "failed", deployErr.Error())
d.rollback(ctx, deploy.ID, containerID, npmProxyID, instanceID)
d.notifier.Send(settings.NotificationURL, notify.Event{
Type: "deploy_failure",
Project: project.Name,
Stage: stage.Name,
ImageTag: imageTag,
Error: deployErr.Error(),
})
return fmt.Errorf("deploy failed: %w", deployErr)
}
// Mark deploy as successful.
if err := d.store.UpdateDeployStatus(deploy.ID, "success", ""); err != nil {
slog.Warn("update deploy status to success", "error", err)
}
d.publishDeployStatus(deploy.ID, projectID, stageID, imageTag, "success", "")
subdomain := d.buildSubdomain(project, stage, settings, imageTag)
fullURL := fmt.Sprintf("https://%s.%s", subdomain, settings.Domain)
d.logDeploy(deploy.ID, fmt.Sprintf("Deploy successful: %s", fullURL), "info")
d.notifier.Send(settings.NotificationURL, notify.Event{
Type: "deploy_success",
Project: project.Name,
Stage: stage.Name,
ImageTag: imageTag,
Subdomain: subdomain,
URL: fullURL,
})
return nil
}
@@ -351,7 +282,7 @@ func (d *Deployer) executeDeploy(
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
portStr := fmt.Sprintf("%d/tcp", project.Port)
envVars := d.mergeEnvVars(project, stage.ID)
mounts := d.computeVolumeMounts(project.ID, stage.Name, imageTag)
mounts := d.computeVolumeMounts(project.ID, stage.Name, imageTag, settings.BaseVolumePath)
containerCfg := docker.ContainerConfig{
Name: containerName,
@@ -597,25 +528,18 @@ func (d *Deployer) buildRegistryAuth(project store.Project) (string, error) {
return "", nil
}
registries, err := d.store.GetAllRegistries()
reg, err := d.store.GetRegistryByName(project.Registry)
if err != nil {
return "", fmt.Errorf("get registries: %w", err)
return "", fmt.Errorf("get registry %s: %w", project.Registry, err)
}
for _, reg := range registries {
if reg.Name == project.Registry {
token := reg.Token
if token != "" {
decrypted, err := crypto.Decrypt(d.encKey, token)
if err != nil {
return "", fmt.Errorf("decrypt registry token: %w", err)
}
return docker.EncodeRegistryAuth(decrypted, decrypted, reg.URL)
}
return "", nil
if reg.Token != "" {
decrypted, err := crypto.Decrypt(d.encKey, reg.Token)
if err != nil {
return "", fmt.Errorf("decrypt registry token: %w", err)
}
return docker.EncodeRegistryAuth(decrypted, decrypted, reg.URL)
}
return "", nil
}
@@ -628,25 +552,6 @@ func (d *Deployer) decryptNpmPassword(encryptedPassword string) (string, error)
return crypto.Decrypt(d.encKey, encryptedPassword)
}
// parseEnvVars parses a JSON-encoded map into KEY=VALUE environment variable strings.
func (d *Deployer) parseEnvVars(envJSON string) []string {
if envJSON == "" || envJSON == "{}" {
return nil
}
var envMap map[string]string
if err := json.Unmarshal([]byte(envJSON), &envMap); err != nil {
slog.Warn("parse env vars", "error", err)
return nil
}
vars := make([]string, 0, len(envMap))
for k, v := range envMap {
vars = append(vars, k+"="+v)
}
return vars
}
// mergeEnvVars builds the final environment variable list for a container:
// 1. Parse project-level env JSON
// 2. Overlay with stage-level env overrides (stage wins on key conflict)
@@ -696,7 +601,7 @@ func (d *Deployer) mergeEnvVars(project store.Project, stageID string) []string
// computeVolumeMounts builds Docker mount specifications from the project's volume config.
// For shared mode, source is used as-is.
// For isolated mode, source gets /{stage}-{tag}/ appended.
func (d *Deployer) computeVolumeMounts(projectID, stageName, imageTag string) []mount.Mount {
func (d *Deployer) computeVolumeMounts(projectID, stageName, imageTag, basePath string) []mount.Mount {
vols, err := d.store.GetVolumesByProjectID(projectID)
if err != nil {
slog.Warn("get project volumes", "project_id", projectID, "error", err)
@@ -707,12 +612,6 @@ func (d *Deployer) computeVolumeMounts(projectID, stageName, imageTag string) []
return nil
}
// Get base volume path from settings.
basePath := ""
if settings, err := d.store.GetSettings(); err == nil {
basePath = settings.BaseVolumePath
}
mounts := make([]mount.Mount, 0, len(vols))
for _, vol := range vols {
source := vol.Source