Store a SHA-256 hash of the webhook secret in a new webhook_secret_hash column. FindAppByWebhookSecret now hashes the incoming secret and queries by hash, eliminating the SQL string comparison timing side-channel. - Add migration 005_add_webhook_secret_hash.sql - Add database.HashWebhookSecret() helper - Backfill existing secrets on startup - Update App model to include WebhookSecretHash in all queries - Update app creation to compute hash at insert time - Add TestHashWebhookSecret unit test - Update all test fixtures to set WebhookSecretHash Closes #13
332 lines
7.4 KiB
Go
332 lines
7.4 KiB
Go
// Package app provides application management services.
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/oklog/ulid/v2"
|
|
|
|
"go.uber.org/fx"
|
|
|
|
"git.eeqj.de/sneak/upaas/internal/database"
|
|
"git.eeqj.de/sneak/upaas/internal/logger"
|
|
"git.eeqj.de/sneak/upaas/internal/models"
|
|
"git.eeqj.de/sneak/upaas/internal/ssh"
|
|
)
|
|
|
|
// ServiceParams contains dependencies for Service.
|
|
type ServiceParams struct {
|
|
fx.In
|
|
|
|
Logger *logger.Logger
|
|
Database *database.Database
|
|
}
|
|
|
|
// Service provides app management functionality.
|
|
type Service struct {
|
|
log *slog.Logger
|
|
db *database.Database
|
|
params *ServiceParams
|
|
}
|
|
|
|
// New creates a new app Service.
|
|
func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
|
|
return &Service{
|
|
log: params.Logger.Get(),
|
|
db: params.Database,
|
|
params: ¶ms,
|
|
}, nil
|
|
}
|
|
|
|
// CreateAppInput contains the input for creating an app.
|
|
type CreateAppInput struct {
|
|
Name string
|
|
RepoURL string
|
|
Branch string
|
|
DockerfilePath string
|
|
DockerNetwork string
|
|
NtfyTopic string
|
|
SlackWebhook string
|
|
}
|
|
|
|
// CreateApp creates a new application with generated SSH keys and webhook secret.
|
|
func (svc *Service) CreateApp(
|
|
ctx context.Context,
|
|
input CreateAppInput,
|
|
) (*models.App, error) {
|
|
// Generate SSH key pair
|
|
keyPair, err := ssh.GenerateKeyPair()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate SSH key pair: %w", err)
|
|
}
|
|
|
|
// Create app with ULID
|
|
app := models.NewApp(svc.db)
|
|
app.ID = ulid.MustNew(ulid.Timestamp(time.Now()), rand.Reader).String()
|
|
app.Name = input.Name
|
|
app.RepoURL = input.RepoURL
|
|
|
|
app.Branch = input.Branch
|
|
if app.Branch == "" {
|
|
app.Branch = "main"
|
|
}
|
|
|
|
app.DockerfilePath = input.DockerfilePath
|
|
if app.DockerfilePath == "" {
|
|
app.DockerfilePath = "Dockerfile"
|
|
}
|
|
|
|
app.WebhookSecret = uuid.New().String()
|
|
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
|
|
app.SSHPrivateKey = keyPair.PrivateKey
|
|
app.SSHPublicKey = keyPair.PublicKey
|
|
app.Status = models.AppStatusPending
|
|
|
|
if input.DockerNetwork != "" {
|
|
app.DockerNetwork = sql.NullString{String: input.DockerNetwork, Valid: true}
|
|
}
|
|
|
|
if input.NtfyTopic != "" {
|
|
app.NtfyTopic = sql.NullString{String: input.NtfyTopic, Valid: true}
|
|
}
|
|
|
|
if input.SlackWebhook != "" {
|
|
app.SlackWebhook = sql.NullString{String: input.SlackWebhook, Valid: true}
|
|
}
|
|
|
|
saveErr := app.Save(ctx)
|
|
if saveErr != nil {
|
|
return nil, fmt.Errorf("failed to save app: %w", saveErr)
|
|
}
|
|
|
|
svc.log.Info("app created", "id", app.ID, "name", app.Name)
|
|
|
|
return app, nil
|
|
}
|
|
|
|
// UpdateAppInput contains the input for updating an app.
|
|
type UpdateAppInput struct {
|
|
Name string
|
|
RepoURL string
|
|
Branch string
|
|
DockerfilePath string
|
|
DockerNetwork string
|
|
NtfyTopic string
|
|
SlackWebhook string
|
|
}
|
|
|
|
// UpdateApp updates an existing application.
|
|
func (svc *Service) UpdateApp(
|
|
ctx context.Context,
|
|
app *models.App,
|
|
input UpdateAppInput,
|
|
) error {
|
|
app.Name = input.Name
|
|
app.RepoURL = input.RepoURL
|
|
app.Branch = input.Branch
|
|
app.DockerfilePath = input.DockerfilePath
|
|
|
|
app.DockerNetwork = sql.NullString{
|
|
String: input.DockerNetwork,
|
|
Valid: input.DockerNetwork != "",
|
|
}
|
|
app.NtfyTopic = sql.NullString{
|
|
String: input.NtfyTopic,
|
|
Valid: input.NtfyTopic != "",
|
|
}
|
|
app.SlackWebhook = sql.NullString{
|
|
String: input.SlackWebhook,
|
|
Valid: input.SlackWebhook != "",
|
|
}
|
|
|
|
saveErr := app.Save(ctx)
|
|
if saveErr != nil {
|
|
return fmt.Errorf("failed to save app: %w", saveErr)
|
|
}
|
|
|
|
svc.log.Info("app updated", "id", app.ID, "name", app.Name)
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteApp deletes an application and its related data.
|
|
func (svc *Service) DeleteApp(ctx context.Context, app *models.App) error {
|
|
// Related data is deleted by CASCADE
|
|
deleteErr := app.Delete(ctx)
|
|
if deleteErr != nil {
|
|
return fmt.Errorf("failed to delete app: %w", deleteErr)
|
|
}
|
|
|
|
svc.log.Info("app deleted", "id", app.ID, "name", app.Name)
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetApp retrieves an app by ID.
|
|
func (svc *Service) GetApp(ctx context.Context, appID string) (*models.App, error) {
|
|
app, err := models.FindApp(ctx, svc.db, appID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to find app: %w", err)
|
|
}
|
|
|
|
return app, nil
|
|
}
|
|
|
|
// GetAppByWebhookSecret retrieves an app by webhook secret.
|
|
func (svc *Service) GetAppByWebhookSecret(
|
|
ctx context.Context,
|
|
secret string,
|
|
) (*models.App, error) {
|
|
app, err := models.FindAppByWebhookSecret(ctx, svc.db, secret)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to find app by webhook secret: %w", err)
|
|
}
|
|
|
|
return app, nil
|
|
}
|
|
|
|
// ListApps returns all apps.
|
|
func (svc *Service) ListApps(ctx context.Context) ([]*models.App, error) {
|
|
apps, err := models.AllApps(ctx, svc.db)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list apps: %w", err)
|
|
}
|
|
|
|
return apps, nil
|
|
}
|
|
|
|
// AddEnvVar adds an environment variable to an app.
|
|
func (svc *Service) AddEnvVar(
|
|
ctx context.Context,
|
|
appID, key, value string,
|
|
) error {
|
|
envVar := models.NewEnvVar(svc.db)
|
|
envVar.AppID = appID
|
|
envVar.Key = key
|
|
envVar.Value = value
|
|
|
|
saveErr := envVar.Save(ctx)
|
|
if saveErr != nil {
|
|
return fmt.Errorf("failed to save env var: %w", saveErr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteEnvVar deletes an environment variable.
|
|
func (svc *Service) DeleteEnvVar(ctx context.Context, envVarID int64) error {
|
|
envVar, err := models.FindEnvVar(ctx, svc.db, envVarID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find env var: %w", err)
|
|
}
|
|
|
|
if envVar == nil {
|
|
return nil
|
|
}
|
|
|
|
deleteErr := envVar.Delete(ctx)
|
|
if deleteErr != nil {
|
|
return fmt.Errorf("failed to delete env var: %w", deleteErr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AddLabel adds a label to an app.
|
|
func (svc *Service) AddLabel(
|
|
ctx context.Context,
|
|
appID, key, value string,
|
|
) error {
|
|
label := models.NewLabel(svc.db)
|
|
label.AppID = appID
|
|
label.Key = key
|
|
label.Value = value
|
|
|
|
saveErr := label.Save(ctx)
|
|
if saveErr != nil {
|
|
return fmt.Errorf("failed to save label: %w", saveErr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteLabel deletes a label.
|
|
func (svc *Service) DeleteLabel(ctx context.Context, labelID int64) error {
|
|
label, err := models.FindLabel(ctx, svc.db, labelID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find label: %w", err)
|
|
}
|
|
|
|
if label == nil {
|
|
return nil
|
|
}
|
|
|
|
deleteErr := label.Delete(ctx)
|
|
if deleteErr != nil {
|
|
return fmt.Errorf("failed to delete label: %w", deleteErr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AddVolume adds a volume mount to an app.
|
|
func (svc *Service) AddVolume(
|
|
ctx context.Context,
|
|
appID, hostPath, containerPath string,
|
|
readonly bool,
|
|
) error {
|
|
volume := models.NewVolume(svc.db)
|
|
volume.AppID = appID
|
|
volume.HostPath = hostPath
|
|
volume.ContainerPath = containerPath
|
|
volume.ReadOnly = readonly
|
|
|
|
saveErr := volume.Save(ctx)
|
|
if saveErr != nil {
|
|
return fmt.Errorf("failed to save volume: %w", saveErr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteVolume deletes a volume mount.
|
|
func (svc *Service) DeleteVolume(ctx context.Context, volumeID int64) error {
|
|
volume, err := models.FindVolume(ctx, svc.db, volumeID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find volume: %w", err)
|
|
}
|
|
|
|
if volume == nil {
|
|
return nil
|
|
}
|
|
|
|
deleteErr := volume.Delete(ctx)
|
|
if deleteErr != nil {
|
|
return fmt.Errorf("failed to delete volume: %w", deleteErr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateAppStatus updates the status of an app.
|
|
func (svc *Service) UpdateAppStatus(
|
|
ctx context.Context,
|
|
app *models.App,
|
|
status models.AppStatus,
|
|
) error {
|
|
app.Status = status
|
|
|
|
saveErr := app.Save(ctx)
|
|
if saveErr != nil {
|
|
return fmt.Errorf("failed to save app status: %w", saveErr)
|
|
}
|
|
|
|
return nil
|
|
}
|