Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
cbd93f7
chore(go): cleanup duplicate db code and fix file naming
Flo4604 Sep 10, 2025
761c4ac
feat(ctrl): add inital backend k8s docker fallback
Flo4604 Sep 10, 2025
b2d0c6f
chore(ctrl): cleanup logs and use running vm status
Flo4604 Sep 10, 2025
986d047
chore(ctrl): k8s naming
Flo4604 Sep 10, 2025
a83861f
use random port
Flo4604 Sep 10, 2025
0e9db8d
remove logs
Flo4604 Sep 10, 2025
71806b0
uh sure
Flo4604 Sep 10, 2025
1d84ae6
make the rabbit happy
Flo4604 Sep 11, 2025
7fb8534
Merge branch 'main' into feat/spawn-in-ctrl
Flo4604 Sep 11, 2025
2c1c2f3
add default domain in ctrl plane
Flo4604 Sep 11, 2025
085bb59
Merge branch 'feat/spawn-in-ctrl' of github.com:unkeyed/unkey into fe…
Flo4604 Sep 11, 2025
aebc152
add lowercasing for domain
Flo4604 Sep 11, 2025
b15dbd1
feat(ctrl): cloudflare acme dns provider
Flo4604 Sep 11, 2025
f25c2df
fix: wrong domain for challenge
Flo4604 Sep 11, 2025
1750e1b
fix: more timeout
Flo4604 Sep 11, 2025
555fc4e
Merge branch 'main' into feat/spawn-in-ctrl
Flo4604 Sep 11, 2025
c357fc7
fix: make challenge unique per domain
Flo4604 Sep 11, 2025
3475437
Merge branch 'feat/spawn-in-ctrl' of github.com:unkeyed/unkey into fe…
Flo4604 Sep 11, 2025
1554826
Merge branch 'main' into feat/spawn-in-ctrl
Flo4604 Sep 11, 2025
b81f159
[autofix.ci] apply automated fixes
autofix-ci[bot] Sep 11, 2025
4913d69
fix: update schema
Flo4604 Sep 12, 2025
fb20d25
fix: update schema
Flo4604 Sep 12, 2025
f3c91bd
fix: remove double lkogging
Flo4604 Sep 12, 2025
f9ab3ca
fix: schema
Flo4604 Sep 12, 2025
f3bdd22
[autofix.ci] apply automated fixes
autofix-ci[bot] Sep 12, 2025
6576683
Merge branch 'main' into feat/spawn-in-ctrl
Flo4604 Sep 12, 2025
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
38 changes: 37 additions & 1 deletion go/apps/ctrl/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package ctrl

import (
"fmt"

"github.com/unkeyed/unkey/go/apps/ctrl/services/deployment/backends"
"github.com/unkeyed/unkey/go/pkg/assert"
"github.com/unkeyed/unkey/go/pkg/clock"
"github.com/unkeyed/unkey/go/pkg/tls"
)
Expand All @@ -12,6 +16,22 @@ type S3Config struct {
AccessKeySecret string
}

type CloudflareConfig struct {
// Enables DNS-01 challenges using Cloudflare
Enabled bool

// ApiToken is the Cloudflare API token with Zone:Read, DNS:Edit permissions
ApiToken string
}

type AcmeConfig struct {
// Enables ACME challenges for TLS certificates
Enabled bool

// Enables DNS-01 challenges using Cloudflare
Cloudflare CloudflareConfig
}

type Config struct {
// InstanceID is the unique identifier for this instance of the control plane server
InstanceID string
Expand Down Expand Up @@ -50,6 +70,8 @@ type Config struct {
// MetaldAddress is the full URL of the metald service for VM operations (e.g., "https://metald.example.com:8080")
MetaldAddress string

MetaldBackend string // fallback to either k8's pod or docker, this skips calling metald

// SPIFFESocketPath is the path to the SPIFFE agent socket for mTLS authentication
SPIFFESocketPath string

Expand All @@ -59,10 +81,24 @@ type Config struct {
VaultMasterKeys []string
VaultS3 S3Config

AcmeEnabled bool
// --- ACME/Cloudflare Configuration ---
Acme AcmeConfig

DefaultDomain string
}

func (c Config) Validate() error {
// Validate MetaldBackend field
if err := backends.ValidateBackendType(c.MetaldBackend); err != nil {
return fmt.Errorf("invalid metald backend configuration: %w", err)
}

// Validate Cloudflare configuration if enabled
if c.Acme.Enabled && c.Acme.Cloudflare.Enabled {
if err := assert.NotEmpty(c.Acme.Cloudflare.ApiToken, "cloudflare API token is required when cloudflare is enabled"); err != nil {
return err
}
}

return nil
}
100 changes: 93 additions & 7 deletions go/apps/ctrl/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package ctrl

import (
"context"
"database/sql"
"fmt"
"log/slog"
"net/http"
Expand All @@ -21,6 +22,7 @@ import (
"github.com/unkeyed/unkey/go/pkg/otel"
"github.com/unkeyed/unkey/go/pkg/otel/logging"
"github.com/unkeyed/unkey/go/pkg/shutdown"
"github.com/unkeyed/unkey/go/pkg/uid"
"github.com/unkeyed/unkey/go/pkg/vault"
"github.com/unkeyed/unkey/go/pkg/vault/storage"
pkgversion "github.com/unkeyed/unkey/go/pkg/version"
Expand Down Expand Up @@ -186,7 +188,14 @@ func Run(ctx context.Context, cfg Config) error {
logger.Info("metald client configured", "address", cfg.MetaldAddress, "auth_mode", authMode)

// Register deployment workflow with Hydra worker
deployWorkflow := deployment.NewDeployWorkflow(database, partitionDB, logger, metaldClient)
deployWorkflow := deployment.NewDeployWorkflow(deployment.DeployWorkflowConfig{
Logger: logger,
DB: database,
PartitionDB: partitionDB,
MetaldBackend: cfg.MetaldBackend,
MetalD: metaldClient,
DefaultDomain: cfg.DefaultDomain,
})
err = hydra.RegisterWorkflow(hydraWorker, deployWorkflow)
if err != nil {
return fmt.Errorf("unable to register deployment workflow: %w", err)
Expand Down Expand Up @@ -257,7 +266,7 @@ func Run(ctx context.Context, cfg Config) error {
}
}()

if cfg.AcmeEnabled {
if cfg.Acme.Enabled {
acmeClient, err := acme.GetOrCreateUser(ctx, acme.UserConfig{
DB: database,
Logger: logger,
Expand All @@ -275,10 +284,77 @@ func Run(ctx context.Context, cfg Config) error {
})
err = acmeClient.Challenge.SetHTTP01Provider(httpProvider)
if err != nil {
logger.Error("failed to set HTTP-01 provider", "error", err)
return fmt.Errorf("failed to set HTTP-01 provider: %w", err)
}

// Set up Cloudflare DNS-01 challenge provider if enabled
if cfg.Acme.Cloudflare.Enabled {
cloudflareProvider, err := providers.NewCloudflareProvider(providers.CloudflareProviderConfig{
DB: database,
Logger: logger,
APIToken: cfg.Acme.Cloudflare.ApiToken,
DefaultDomain: cfg.DefaultDomain,
})
if err != nil {
logger.Error("failed to create Cloudflare DNS provider", "error", err)
return fmt.Errorf("failed to create Cloudflare DNS provider: %w", err)
}

err = acmeClient.Challenge.SetDNS01Provider(cloudflareProvider)
if err != nil {
logger.Error("failed to set DNS-01 provider", "error", err)
return fmt.Errorf("failed to set DNS-01 provider: %w", err)
}

logger.Info("Cloudflare DNS-01 challenge provider configured")

if cfg.DefaultDomain != "" {
wildcardDomain := "*." + cfg.DefaultDomain

// Check if we already have a challenge or certificate for the wildcard domain
_, err := db.Query.FindDomainByDomain(ctx, database.RO(), wildcardDomain)
if err != nil && !db.IsNotFound(err) {
logger.Error("Failed to check existing wildcard domain", "error", err, "domain", wildcardDomain)
} else if db.IsNotFound(err) {
now := time.Now().UnixMilli()
domainID := uid.New("domain")

// Insert domain record
err = db.Query.InsertDomain(ctx, database.RW(), db.InsertDomainParams{
ID: domainID,
WorkspaceID: "unkey", // Default workspace for wildcard cert
Domain: wildcardDomain,
CreatedAt: now,
UpdatedAt: sql.NullInt64{Valid: true, Int64: now},
Type: db.DomainsTypeCustom,
})
if err != nil {
logger.Error("Failed to create wildcard domain", "error", err, "domain", wildcardDomain)
} else {
// Insert challenge record
expiresAt := time.Now().Add(90 * 24 * time.Hour).UnixMilli() // 90 days

err = db.Query.InsertAcmeChallenge(ctx, database.RW(), db.InsertAcmeChallengeParams{
WorkspaceID: "unkey",
DomainID: domainID,
Token: "",
Authorization: "",
Status: db.AcmeChallengesStatusWaiting,
Type: db.AcmeChallengesTypeDNS01, // Use DNS-01 for wildcard
CreatedAt: now,
UpdatedAt: sql.NullInt64{Valid: true, Int64: now},
ExpiresAt: expiresAt,
})
if err != nil {
logger.Error("Failed to create wildcard challenge", "error", err, "domain", wildcardDomain)
} else {
logger.Info("Created wildcard domain and challenge", "domain", wildcardDomain)
}
}
}
}
}

// Register deployment workflow with Hydra worker
acmeWorkflows := acme.NewCertificateChallenge(acme.CertificateChallengeConfig{
DB: database,
Expand All @@ -296,19 +372,28 @@ func Run(ctx context.Context, cfg Config) error {
go func() {
logger.Info("Starting cert worker")

// HTTP-01 challenges are always available (we always set up HTTP provider)
supportedTypes := []db.AcmeChallengesType{db.AcmeChallengesTypeHTTP01}

// DNS-01 challenges require Cloudflare to be enabled
if cfg.Acme.Cloudflare.Enabled {
supportedTypes = append(supportedTypes, db.AcmeChallengesTypeDNS01)
}

registerErr := hydraEngine.RegisterCron("*/5 * * * *", "start-certificate-challenges", func(ctx context.Context, payload hydra.CronPayload) error {
challenges, err := db.Query.ListExecutableChallenges(ctx, database.RO())
executableChallenges, err := db.Query.ListExecutableChallenges(ctx, database.RO(), supportedTypes)
if err != nil {
logger.Error("Failed to start workflow", "error", err)
return err
}

logger.Info("Starting certificate challenges", "count", len(challenges))
logger.Info("Starting certificate challenges",
"executable_challenges", len(executableChallenges),
"supported_types", supportedTypes)

for _, challenge := range challenges {
for _, challenge := range executableChallenges {
executionID, err := hydraEngine.StartWorkflow(ctx, "certificate_challenge",
acme.CertificateChallengeRequest{
ID: challenge.ID,
WorkspaceID: challenge.WorkspaceID,
Domain: challenge.Domain,
},
Expand All @@ -333,6 +418,7 @@ func Run(ctx context.Context, cfg Config) error {
}
}()
}

// Start Hydra worker
go func() {
logger.Info("Starting Hydra workflow worker")
Expand Down
29 changes: 11 additions & 18 deletions go/apps/ctrl/services/acme/certificate_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ func (w *CertificateChallenge) Name() string {

// CertificateChallengeRequest defines the input for the certificate challenge workflow
type CertificateChallengeRequest struct {
ID uint64 `json:"id"`
WorkspaceID string `json:"workspace_id"`
Domain string `json:"domain"`
}
Expand All @@ -64,29 +63,27 @@ type EncryptedCertificate struct {

// Run executes the complete build and deployment workflow
func (w *CertificateChallenge) Run(ctx hydra.WorkflowContext, req *CertificateChallengeRequest) error {
w.logger.Info("starting lets-encrypt challenge", "workspace_id", req.WorkspaceID, "domain", req.Domain)
w.logger.Info("starting certificate challenge", "workspace_id", req.WorkspaceID, "domain", req.Domain)

dom, err := hydra.Step(ctx, "find-domain", func(stepCtx context.Context) (db.Domain, error) {
return db.Query.FindDomainByDomain(ctx.Context(), w.db.RO(), req.Domain)
dom, err := hydra.Step(ctx, "resolve-domain", func(stepCtx context.Context) (db.Domain, error) {
return db.Query.FindDomainByDomain(stepCtx, w.db.RO(), req.Domain)
})
if err != nil {
w.logger.Error("failed to find domain", "error", err)
return err
}

err = hydra.StepVoid(ctx, "claim-challenge", func(stepCtx context.Context) error {
err = hydra.StepVoid(ctx, "acquire-challenge", func(stepCtx context.Context) error {
return db.Query.UpdateAcmeChallengeTryClaiming(stepCtx, w.db.RW(), db.UpdateAcmeChallengeTryClaimingParams{
DomainID: dom.ID,
Status: db.AcmeChallengesStatusPending,
UpdatedAt: sql.NullInt64{Int64: time.Now().UnixMilli(), Valid: true},
})
})
if err != nil {
w.logger.Error("failed to claim challenge", "error", err)
return err
}

cert, err := hydra.Step(ctx, "get-and-encrypt-cert", func(stepCtx context.Context) (EncryptedCertificate, error) {
cert, err := hydra.Step(ctx, "obtain-certificate", func(stepCtx context.Context) (EncryptedCertificate, error) {
// A certificate request can be either
// A: We have a new domain WITHOUT a certificate
// B: We have to renew a existing certificate
Expand All @@ -97,7 +94,6 @@ func (w *CertificateChallenge) Run(ctx hydra.WorkflowContext, req *CertificateCh
Status: db.AcmeChallengesStatusFailed,
UpdatedAt: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()},
})
w.logger.Error("failed to obtain certificate", "error", err)
return EncryptedCertificate{}, err
}

Expand Down Expand Up @@ -129,7 +125,6 @@ func (w *CertificateChallenge) Run(ctx hydra.WorkflowContext, req *CertificateCh
})
}
if err != nil {
w.logger.Error("failed to renew/issue certificate", "error", err)
return EncryptedCertificate{}, err
}

Expand All @@ -153,11 +148,10 @@ func (w *CertificateChallenge) Run(ctx hydra.WorkflowContext, req *CertificateCh
}, nil
})
if err != nil {
w.logger.Error("failed to get and store certs in vault", "error", err)
return err
}

err = hydra.StepVoid(ctx, "store-cert", func(stepCtx context.Context) error {
err = hydra.StepVoid(ctx, "persist-certificate", func(stepCtx context.Context) error {
now := time.Now().UnixMilli()
return pdb.Query.InsertCertificate(stepCtx, w.partitionDB.RW(), pdb.InsertCertificateParams{
WorkspaceID: dom.WorkspaceID,
Expand All @@ -169,19 +163,18 @@ func (w *CertificateChallenge) Run(ctx hydra.WorkflowContext, req *CertificateCh
})
})
if err != nil {
w.logger.Error("failed to store cert in vault", "error", err)
return err
}

err = hydra.StepVoid(ctx, "set-expires-at", func(stepCtx context.Context) error {
return db.Query.UpdateAcmeChallengeExpiresAt(stepCtx, w.db.RW(), db.UpdateAcmeChallengeExpiresAtParams{
err = hydra.StepVoid(ctx, "complete-challenge", func(stepCtx context.Context) error {
return db.Query.UpdateAcmeChallengeVerifiedWithExpiry(stepCtx, w.db.RW(), db.UpdateAcmeChallengeVerifiedWithExpiryParams{
Status: db.AcmeChallengesStatusVerified,
ExpiresAt: cert.ExpiresAt,
ID: 0, // "TODO: I need the challenge id"

UpdatedAt: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()},
DomainID: dom.ID,
})
})
if err != nil {
w.logger.Error("failed to store expires at", "error", err)
return err
}

Expand Down
Loading