Compare commits

..

1 Commits

Author SHA1 Message Date
clawbot
bbf47e61a7 fix: clean up orphan resources on deploy cancellation (closes #89) 2026-02-19 13:47:07 -08:00
13 changed files with 97 additions and 226 deletions

View File

@@ -51,8 +51,7 @@ type Config struct {
MaintenanceMode bool MaintenanceMode bool
MetricsUsername string MetricsUsername string
MetricsPassword string MetricsPassword string
SessionSecret string `json:"-"` SessionSecret string
CORSOrigins string
params *Params params *Params
log *slog.Logger log *slog.Logger
} }
@@ -103,7 +102,6 @@ func setupViper(name string) {
viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("SESSION_SECRET", "") viper.SetDefault("SESSION_SECRET", "")
viper.SetDefault("CORS_ORIGINS", "")
} }
func buildConfig(log *slog.Logger, params *Params) (*Config, error) { func buildConfig(log *slog.Logger, params *Params) (*Config, error) {
@@ -138,7 +136,6 @@ func buildConfig(log *slog.Logger, params *Params) (*Config, error) {
MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"), MetricsPassword: viper.GetString("METRICS_PASSWORD"),
SessionSecret: viper.GetString("SESSION_SECRET"), SessionSecret: viper.GetString("SESSION_SECRET"),
CORSOrigins: viper.GetString("CORS_ORIGINS"),
params: params, params: params,
log: log, log: log,
} }

View File

@@ -480,20 +480,6 @@ func (c *Client) CloneRepo(
return c.performClone(ctx, cfg) return c.performClone(ctx, cfg)
} }
// RemoveImage removes a Docker image by ID or tag.
// It returns nil if the image was successfully removed or does not exist.
func (c *Client) RemoveImage(ctx context.Context, imageID string) error {
_, err := c.docker.ImageRemove(ctx, imageID, image.RemoveOptions{
Force: true,
PruneChildren: true,
})
if err != nil && !client.IsErrNotFound(err) {
return fmt.Errorf("failed to remove image %s: %w", imageID, err)
}
return nil
}
func (c *Client) performBuild( func (c *Client) performBuild(
ctx context.Context, ctx context.Context,
opts BuildImageOptions, opts BuildImageOptions,
@@ -754,6 +740,20 @@ func (c *Client) connect(ctx context.Context) error {
return nil return nil
} }
// RemoveImage removes a Docker image by ID or tag.
// It returns nil if the image was successfully removed or does not exist.
func (c *Client) RemoveImage(ctx context.Context, imageID string) error {
_, err := c.docker.ImageRemove(ctx, imageID, image.RemoveOptions{
Force: true,
PruneChildren: true,
})
if err != nil && !client.IsErrNotFound(err) {
return fmt.Errorf("failed to remove image %s: %w", imageID, err)
}
return nil
}
func (c *Client) close() error { func (c *Client) close() error {
if c.docker != nil { if c.docker != nil {
err := c.docker.Close() err := c.docker.Close()

View File

@@ -74,13 +74,18 @@ func deploymentToAPI(d *models.Deployment) apiDeploymentResponse {
// HandleAPILoginPOST returns a handler that authenticates via JSON credentials // HandleAPILoginPOST returns a handler that authenticates via JSON credentials
// and sets a session cookie. // and sets a session cookie.
func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc { func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type loginResponse struct { type loginResponse struct {
UserID int64 `json:"userId"` UserID int64 `json:"userId"`
Username string `json:"username"` Username string `json:"username"`
} }
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
var req map[string]string var req loginRequest
decodeErr := json.NewDecoder(request.Body).Decode(&req) decodeErr := json.NewDecoder(request.Body).Decode(&req)
if decodeErr != nil { if decodeErr != nil {
@@ -91,10 +96,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
return return
} }
username := req["username"] if req.Username == "" || req.Password == "" {
credential := req["password"]
if username == "" || credential == "" {
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{"error": "username and password are required"}, map[string]string{"error": "username and password are required"},
http.StatusBadRequest) http.StatusBadRequest)
@@ -102,7 +104,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
return return
} }
user, authErr := h.auth.Authenticate(request.Context(), username, credential) user, authErr := h.auth.Authenticate(request.Context(), req.Username, req.Password)
if authErr != nil { if authErr != nil {
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{"error": "invalid credentials"}, map[string]string{"error": "invalid credentials"},

View File

@@ -499,7 +499,7 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
return return
} }
_, _ = writer.Write([]byte(logs)) // #nosec G705 -- Content-Type is text/plain, no XSS risk _, _ = writer.Write([]byte(logs))
} }
} }
@@ -581,8 +581,8 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
return return
} }
// Check if file exists — logPath is constructed internally, not from user input // Check if file exists
_, err := os.Stat(logPath) // #nosec G703 -- path from internal GetLogFilePath, not user input _, err := os.Stat(logPath)
if os.IsNotExist(err) { if os.IsNotExist(err) {
http.NotFound(writer, request) http.NotFound(writer, request)

View File

@@ -1,81 +0,0 @@
package middleware //nolint:testpackage // tests internal CORS behavior
import (
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"git.eeqj.de/sneak/upaas/internal/config"
)
//nolint:gosec // test credentials
func newCORSTestMiddleware(corsOrigins string) *Middleware {
return &Middleware{
log: slog.Default(),
params: &Params{
Config: &config.Config{
CORSOrigins: corsOrigins,
SessionSecret: "test-secret-32-bytes-long-enough",
},
},
}
}
func TestCORS_NoOriginsConfigured_NoCORSHeaders(t *testing.T) {
t.Parallel()
m := newCORSTestMiddleware("")
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://evil.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"),
"expected no CORS headers when no origins configured")
}
func TestCORS_OriginsConfigured_AllowsMatchingOrigin(t *testing.T) {
t.Parallel()
m := newCORSTestMiddleware("https://app.example.com,https://other.example.com")
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://app.example.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, "https://app.example.com",
rec.Header().Get("Access-Control-Allow-Origin"))
assert.Equal(t, "true",
rec.Header().Get("Access-Control-Allow-Credentials"))
}
func TestCORS_OriginsConfigured_RejectsNonMatchingOrigin(t *testing.T) {
t.Parallel()
m := newCORSTestMiddleware("https://app.example.com")
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://evil.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"),
"expected no CORS headers for non-matching origin")
}

View File

@@ -177,48 +177,17 @@ func realIP(r *http.Request) string {
} }
// CORS returns CORS middleware. // CORS returns CORS middleware.
// When UPAAS_CORS_ORIGINS is empty (default), no CORS headers are sent
// (same-origin only). When configured, only the specified origins are
// allowed and credentials (cookies) are permitted.
func (m *Middleware) CORS() func(http.Handler) http.Handler { func (m *Middleware) CORS() func(http.Handler) http.Handler {
origins := parseCORSOrigins(m.params.Config.CORSOrigins)
// No origins configured — no CORS headers (same-origin policy).
if len(origins) == 0 {
return func(next http.Handler) http.Handler {
return next
}
}
return cors.Handler(cors.Options{ return cors.Handler(cors.Options{
AllowedOrigins: origins, AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"}, ExposedHeaders: []string{"Link"},
AllowCredentials: true, AllowCredentials: false,
MaxAge: corsMaxAge, MaxAge: corsMaxAge,
}) })
} }
// parseCORSOrigins splits a comma-separated origin string into a slice,
// trimming whitespace. Returns nil if the input is empty.
func parseCORSOrigins(raw string) []string {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
origins := make([]string, 0, len(parts))
for _, p := range parts {
if o := strings.TrimSpace(p); o != "" {
origins = append(origins, o)
}
}
return origins
}
// MetricsAuth returns basic auth middleware for metrics endpoint. // MetricsAuth returns basic auth middleware for metrics endpoint.
func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler { func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler {
if m.params.Config.MetricsUsername == "" { if m.params.Config.MetricsUsername == "" {
@@ -266,9 +235,9 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler {
// loginRateLimit configures the login rate limiter. // loginRateLimit configures the login rate limiter.
const ( const (
loginRateLimit = rate.Limit(5.0 / 60.0) // 5 requests per 60 seconds loginRateLimit = rate.Limit(5.0 / 60.0) // 5 requests per 60 seconds
loginBurst = 5 // allow burst of 5 loginBurst = 5 // allow burst of 5
limiterExpiry = 10 * time.Minute // evict entries not seen in 10 minutes limiterExpiry = 10 * time.Minute // evict entries not seen in 10 minutes
limiterCleanupEvery = 1 * time.Minute // sweep interval limiterCleanupEvery = 1 * time.Minute // sweep interval
) )
// ipLimiterEntry stores a rate limiter with its last-seen timestamp. // ipLimiterEntry stores a rate limiter with its last-seen timestamp.
@@ -280,8 +249,8 @@ type ipLimiterEntry struct {
// ipLimiter tracks per-IP rate limiters for login attempts with automatic // ipLimiter tracks per-IP rate limiters for login attempts with automatic
// eviction of stale entries to prevent unbounded memory growth. // eviction of stale entries to prevent unbounded memory growth.
type ipLimiter struct { type ipLimiter struct {
mu sync.Mutex mu sync.Mutex
limiters map[string]*ipLimiterEntry limiters map[string]*ipLimiterEntry
lastSweep time.Time lastSweep time.Time
} }

View File

@@ -32,23 +32,23 @@ const (
type App struct { type App struct {
db *database.Database db *database.Database
ID string ID string
Name string Name string
RepoURL string RepoURL string
Branch string Branch string
DockerfilePath string DockerfilePath string
WebhookSecret string WebhookSecret string
WebhookSecretHash string WebhookSecretHash string
SSHPrivateKey string SSHPrivateKey string
SSHPublicKey string SSHPublicKey string
ImageID sql.NullString ImageID sql.NullString
PreviousImageID sql.NullString PreviousImageID sql.NullString
Status AppStatus Status AppStatus
DockerNetwork sql.NullString DockerNetwork sql.NullString
NtfyTopic sql.NullString NtfyTopic sql.NullString
SlackWebhook sql.NullString SlackWebhook sql.NullString
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
// NewApp creates a new App with a database reference. // NewApp creates a new App with a database reference.

View File

@@ -54,51 +54,51 @@ func (s *Server) SetupRoutes() {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(s.mw.SessionAuth()) r.Use(s.mw.SessionAuth())
// Dashboard // Dashboard
r.Get("/", s.handlers.HandleDashboard()) r.Get("/", s.handlers.HandleDashboard())
// Logout // Logout
r.Post("/logout", s.handlers.HandleLogout()) r.Post("/logout", s.handlers.HandleLogout())
// App routes // App routes
r.Get("/apps/new", s.handlers.HandleAppNew()) r.Get("/apps/new", s.handlers.HandleAppNew())
r.Post("/apps", s.handlers.HandleAppCreate()) r.Post("/apps", s.handlers.HandleAppCreate())
r.Get("/apps/{id}", s.handlers.HandleAppDetail()) r.Get("/apps/{id}", s.handlers.HandleAppDetail())
r.Get("/apps/{id}/edit", s.handlers.HandleAppEdit()) r.Get("/apps/{id}/edit", s.handlers.HandleAppEdit())
r.Post("/apps/{id}", s.handlers.HandleAppUpdate()) r.Post("/apps/{id}", s.handlers.HandleAppUpdate())
r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete()) r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete())
r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy()) r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy())
r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy()) r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy())
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments()) r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI()) r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI())
r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload()) r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload())
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs()) r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())
r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI()) r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI())
r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI()) r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI())
r.Get("/apps/{id}/recent-deployments", s.handlers.HandleRecentDeploymentsAPI()) r.Get("/apps/{id}/recent-deployments", s.handlers.HandleRecentDeploymentsAPI())
r.Post("/apps/{id}/rollback", s.handlers.HandleAppRollback()) r.Post("/apps/{id}/rollback", s.handlers.HandleAppRollback())
r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart()) r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart())
r.Post("/apps/{id}/stop", s.handlers.HandleAppStop()) r.Post("/apps/{id}/stop", s.handlers.HandleAppStop())
r.Post("/apps/{id}/start", s.handlers.HandleAppStart()) r.Post("/apps/{id}/start", s.handlers.HandleAppStart())
// Environment variables // Environment variables
r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd()) r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd())
r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit()) r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit())
r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete()) r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete())
// Labels // Labels
r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd()) r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd())
r.Post("/apps/{id}/labels/{labelID}/edit", s.handlers.HandleLabelEdit()) r.Post("/apps/{id}/labels/{labelID}/edit", s.handlers.HandleLabelEdit())
r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete()) r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete())
// Volumes // Volumes
r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd()) r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd())
r.Post("/apps/{id}/volumes/{volumeID}/edit", s.handlers.HandleVolumeEdit()) r.Post("/apps/{id}/volumes/{volumeID}/edit", s.handlers.HandleVolumeEdit())
r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete()) r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete())
// Ports // Ports
r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd()) r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd())
r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete()) r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete())
}) })
}) })

View File

@@ -11,7 +11,6 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"time" "time"
@@ -716,7 +715,7 @@ func (svc *Service) cleanupCancelledDeploy(
prefix := fmt.Sprintf("%d-", deployment.ID) prefix := fmt.Sprintf("%d-", deployment.ID)
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) { if entry.IsDir() && len(entry.Name()) > len(prefix) && entry.Name()[:len(prefix)] == prefix {
dirPath := filepath.Join(buildDir, entry.Name()) dirPath := filepath.Join(buildDir, entry.Name())
removeErr := os.RemoveAll(dirPath) removeErr := os.RemoveAll(dirPath)
@@ -726,7 +725,6 @@ func (svc *Service) cleanupCancelledDeploy(
} else { } else {
svc.log.Info("cleaned up build dir from cancelled deploy", svc.log.Info("cleaned up build dir from cancelled deploy",
"app", app.Name, "path", dirPath) "app", app.Name, "path", dirPath)
_ = deployment.AppendLog(ctx, "Cleaned up build directory") _ = deployment.AppendLog(ctx, "Cleaned up build directory")
} }
} }

View File

@@ -32,7 +32,7 @@ func TestCleanupCancelledDeploy_RemovesBuildDir(t *testing.T) {
require.NoError(t, os.MkdirAll(deployDir, 0o750)) require.NoError(t, os.MkdirAll(deployDir, 0o750))
// Create a file inside to verify full removal // Create a file inside to verify full removal
require.NoError(t, os.WriteFile(filepath.Join(deployDir, "work"), []byte("test"), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(deployDir, "work"), []byte("test"), 0o640))
// Also create a dir for a different deployment (should NOT be removed) // Also create a dir for a different deployment (should NOT be removed)
otherDir := filepath.Join(buildDir, "99-xyz789") otherDir := filepath.Join(buildDir, "99-xyz789")

View File

@@ -6,7 +6,6 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"git.eeqj.de/sneak/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/docker" "git.eeqj.de/sneak/upaas/internal/docker"
@@ -48,14 +47,12 @@ func NewTestServiceWithConfig(log *slog.Logger, cfg *config.Config, dockerClient
} }
} }
// CleanupCancelledDeploy exposes the build directory cleanup portion of // CleanupCancelledDeploy exposes cleanupCancelledDeploy for testing.
// cleanupCancelledDeploy for testing. It removes build directories matching
// the deployment ID prefix.
func (svc *Service) CleanupCancelledDeploy( func (svc *Service) CleanupCancelledDeploy(
_ context.Context, ctx context.Context,
appName string, appName string,
deploymentID int64, deploymentID int64,
_ string, imageID string,
) { ) {
// We can't create real models.App/Deployment in tests easily, // We can't create real models.App/Deployment in tests easily,
// so we test the build dir cleanup portion directly. // so we test the build dir cleanup portion directly.
@@ -69,7 +66,7 @@ func (svc *Service) CleanupCancelledDeploy(
prefix := fmt.Sprintf("%d-", deploymentID) prefix := fmt.Sprintf("%d-", deploymentID)
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) { if entry.IsDir() && len(entry.Name()) > len(prefix) && entry.Name()[:len(prefix)] == prefix {
dirPath := filepath.Join(buildDir, entry.Name()) dirPath := filepath.Join(buildDir, entry.Name())
_ = os.RemoveAll(dirPath) _ = os.RemoveAll(dirPath)
} }

View File

@@ -10,7 +10,6 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
@@ -248,15 +247,10 @@ func (svc *Service) sendNtfy(
) error { ) error {
svc.log.Debug("sending ntfy notification", "topic", topic, "title", title) svc.log.Debug("sending ntfy notification", "topic", topic, "title", title)
parsedURL, err := url.ParseRequestURI(topic)
if err != nil {
return fmt.Errorf("invalid ntfy topic URL: %w", err)
}
request, err := http.NewRequestWithContext( request, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
parsedURL.String(), topic,
bytes.NewBufferString(message), bytes.NewBufferString(message),
) )
if err != nil { if err != nil {
@@ -266,7 +260,7 @@ func (svc *Service) sendNtfy(
request.Header.Set("Title", title) request.Header.Set("Title", title)
request.Header.Set("Priority", svc.ntfyPriority(priority)) request.Header.Set("Priority", svc.ntfyPriority(priority))
resp, err := svc.client.Do(request) // #nosec G704 -- URL from validated config, not user input resp, err := svc.client.Do(request)
if err != nil { if err != nil {
return fmt.Errorf("failed to send ntfy request: %w", err) return fmt.Errorf("failed to send ntfy request: %w", err)
} }
@@ -346,15 +340,10 @@ func (svc *Service) sendSlack(
return fmt.Errorf("failed to marshal slack payload: %w", err) return fmt.Errorf("failed to marshal slack payload: %w", err)
} }
parsedWebhookURL, err := url.ParseRequestURI(webhookURL)
if err != nil {
return fmt.Errorf("invalid slack webhook URL: %w", err)
}
request, err := http.NewRequestWithContext( request, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
parsedWebhookURL.String(), webhookURL,
bytes.NewBuffer(body), bytes.NewBuffer(body),
) )
if err != nil { if err != nil {
@@ -363,7 +352,7 @@ func (svc *Service) sendSlack(
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
resp, err := svc.client.Do(request) // #nosec G704 -- URL from validated config, not user input resp, err := svc.client.Do(request)
if err != nil { if err != nil {
return fmt.Errorf("failed to send slack request: %w", err) return fmt.Errorf("failed to send slack request: %w", err)
} }

View File

@@ -12,7 +12,7 @@ import (
// KeyPair contains an SSH key pair. // KeyPair contains an SSH key pair.
type KeyPair struct { type KeyPair struct {
PrivateKey string `json:"-"` PrivateKey string
PublicKey string PublicKey string
} }