Extract certificate lifecycle into certificates
Pull SSL/ACME state out of custom_domains into a dedicated hostname-keyed certificates table, and repoint cached certificates from the domain to the certificate. Custom domains now reference a certificate id, keeping certificate provisioning decoupled from domain business logic. Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
@@ -39,7 +39,7 @@ type (
|
||||
CertificateChain *string `db:"certificate_chain"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
CachedAt time.Time `db:"cached_at"`
|
||||
CustomDomainID gid.GID `db:"custom_domain_id"`
|
||||
CertificateID gid.GID `db:"certificate_id"`
|
||||
}
|
||||
|
||||
CachedCertificates []*CachedCertificate
|
||||
@@ -54,7 +54,7 @@ SELECT
|
||||
certificate_chain,
|
||||
expires_at,
|
||||
cached_at,
|
||||
custom_domain_id
|
||||
certificate_id
|
||||
FROM
|
||||
cached_certificates
|
||||
WHERE
|
||||
@@ -91,7 +91,7 @@ INSERT INTO cached_certificates (
|
||||
certificate_chain,
|
||||
expires_at,
|
||||
cached_at,
|
||||
custom_domain_id
|
||||
certificate_id
|
||||
) VALUES (
|
||||
@domain,
|
||||
@certificate_pem,
|
||||
@@ -99,7 +99,7 @@ INSERT INTO cached_certificates (
|
||||
@certificate_chain,
|
||||
@expires_at,
|
||||
@cached_at,
|
||||
@custom_domain_id
|
||||
@certificate_id
|
||||
)
|
||||
ON CONFLICT (domain) DO UPDATE SET
|
||||
certificate_pem = EXCLUDED.certificate_pem,
|
||||
@@ -107,7 +107,7 @@ ON CONFLICT (domain) DO UPDATE SET
|
||||
certificate_chain = EXCLUDED.certificate_chain,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
cached_at = NOW(),
|
||||
custom_domain_id = EXCLUDED.custom_domain_id
|
||||
certificate_id = EXCLUDED.certificate_id
|
||||
`
|
||||
|
||||
args := pgx.NamedArgs{
|
||||
@@ -117,7 +117,7 @@ ON CONFLICT (domain) DO UPDATE SET
|
||||
"certificate_chain": cc.CertificateChain,
|
||||
"expires_at": cc.ExpiresAt,
|
||||
"cached_at": cc.CachedAt,
|
||||
"custom_domain_id": cc.CustomDomainID,
|
||||
"certificate_id": cc.CertificateID,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
@@ -170,36 +170,36 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cc *CachedCertificate) RefreshFromDomain(ctx context.Context, conn pg.Querier, domain *CustomDomain, encryptionKey cipher.EncryptionKey) error {
|
||||
if domain.SSLCertificate == nil {
|
||||
return fmt.Errorf("domain has no parsed certificate")
|
||||
func (cc *CachedCertificate) RefreshFromCertificate(ctx context.Context, conn pg.Querier, certificate *Certificate, encryptionKey cipher.EncryptionKey) error {
|
||||
if certificate.SSLCertificate == nil {
|
||||
return fmt.Errorf("certificate has no parsed certificate")
|
||||
}
|
||||
|
||||
if len(domain.SSLCertificatePEM) == 0 {
|
||||
return fmt.Errorf("domain has no certificate PEM")
|
||||
if len(certificate.SSLCertificatePEM) == 0 {
|
||||
return fmt.Errorf("certificate has no certificate PEM")
|
||||
}
|
||||
|
||||
privateKeyPEM, err := domain.DecryptPrivateKey(encryptionKey)
|
||||
privateKeyPEM, err := certificate.DecryptPrivateKey(encryptionKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot decrypt private key: %w", err)
|
||||
}
|
||||
|
||||
if len(privateKeyPEM) == 0 {
|
||||
return fmt.Errorf("domain has no private key PEM")
|
||||
return fmt.Errorf("certificate has no private key PEM")
|
||||
}
|
||||
|
||||
if domain.SSLExpiresAt == nil {
|
||||
return fmt.Errorf("domain certificate has no expiry date")
|
||||
if certificate.SSLExpiresAt == nil {
|
||||
return fmt.Errorf("certificate has no expiry date")
|
||||
}
|
||||
|
||||
cache := &CachedCertificate{
|
||||
Domain: domain.Domain,
|
||||
CertificatePEM: string(domain.SSLCertificatePEM),
|
||||
Domain: certificate.Hostname,
|
||||
CertificatePEM: string(certificate.SSLCertificatePEM),
|
||||
PrivateKeyPEM: string(privateKeyPEM),
|
||||
CertificateChain: domain.SSLCertificateChain,
|
||||
ExpiresAt: *domain.SSLExpiresAt,
|
||||
CertificateChain: certificate.SSLCertificateChain,
|
||||
ExpiresAt: *certificate.SSLExpiresAt,
|
||||
CachedAt: time.Now(),
|
||||
CustomDomainID: domain.ID,
|
||||
CertificateID: certificate.ID,
|
||||
}
|
||||
|
||||
return cache.Upsert(ctx, conn)
|
||||
|
||||
915
pkg/coredata/certificate.go
Normal file
915
pkg/coredata/certificate.go
Normal file
@@ -0,0 +1,915 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/crypto/cipher"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
// Certificate is a generic TLS certificate together with its ACME
|
||||
// provisioning lifecycle for a single hostname. It carries no knowledge of
|
||||
// the resource it protects; the hostname is globally unique.
|
||||
Certificate struct {
|
||||
ID gid.GID `db:"id"`
|
||||
Hostname string `db:"hostname"`
|
||||
HTTPChallengeToken *string `db:"http_challenge_token"`
|
||||
HTTPChallengeKeyAuth *string `db:"http_challenge_key_auth"`
|
||||
HTTPChallengeURL *string `db:"http_challenge_url"`
|
||||
HTTPOrderURL *string `db:"http_order_url"`
|
||||
SSLCertificate *tls.Certificate `db:"-"`
|
||||
SSLCertificatePEM []byte `db:"ssl_certificate"`
|
||||
EncryptedSSLPrivateKey []byte `db:"encrypted_ssl_private_key"`
|
||||
SSLCertificateChain *string `db:"ssl_certificate_chain"`
|
||||
Status CertificateStatus `db:"status"`
|
||||
SSLExpiresAt *time.Time `db:"ssl_expires_at"`
|
||||
SSLRetryCount int `db:"ssl_retry_count"`
|
||||
SSLLastAttemptAt *time.Time `db:"ssl_last_attempt_at"`
|
||||
ProvisioningError *string `db:"provisioning_error"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
Certificates []*Certificate
|
||||
)
|
||||
|
||||
func NewCertificate(
|
||||
tenantID gid.TenantID,
|
||||
hostname string,
|
||||
) *Certificate {
|
||||
now := time.Now()
|
||||
|
||||
return &Certificate{
|
||||
ID: gid.New(tenantID, CertificateEntityType),
|
||||
Hostname: hostname,
|
||||
Status: CertificateStatusPending,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Certificate) DecryptPrivateKey(encryptionKey cipher.EncryptionKey) ([]byte, error) {
|
||||
if len(c.EncryptedSSLPrivateKey) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
decrypted, err := cipher.Decrypt(c.EncryptedSSLPrivateKey, encryptionKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot decrypt SSL private key: %w", err)
|
||||
}
|
||||
|
||||
return decrypted, nil
|
||||
}
|
||||
|
||||
func (c *Certificate) EncryptPrivateKey(privateKeyPEM []byte, encryptionKey cipher.EncryptionKey) error {
|
||||
if len(privateKeyPEM) == 0 {
|
||||
c.EncryptedSSLPrivateKey = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
encrypted, err := cipher.Encrypt(privateKeyPEM, encryptionKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot encrypt SSL private key: %w", err)
|
||||
}
|
||||
|
||||
c.EncryptedSSLPrivateKey = encrypted
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Certificate) ParseCertificate(encryptionKey cipher.EncryptionKey) error {
|
||||
if len(c.SSLCertificatePEM) == 0 {
|
||||
return fmt.Errorf("no certificate PEM data")
|
||||
}
|
||||
|
||||
privateKeyPEM, err := c.DecryptPrivateKey(encryptionKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot decrypt private key: %w", err)
|
||||
}
|
||||
|
||||
if len(privateKeyPEM) == 0 {
|
||||
return fmt.Errorf("no private key data")
|
||||
}
|
||||
|
||||
fullCertPEM := string(c.SSLCertificatePEM)
|
||||
if c.SSLCertificateChain != nil && *c.SSLCertificateChain != "" {
|
||||
fullCertPEM += "\n" + *c.SSLCertificateChain
|
||||
}
|
||||
|
||||
tlsCert, err := tls.X509KeyPair([]byte(fullCertPEM), privateKeyPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse certificate and key: %w", err)
|
||||
}
|
||||
|
||||
c.SSLCertificate = &tlsCert
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Certificate) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
certificateID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
hostname,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
certificates
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"id": certificateID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query certificate: %w", err)
|
||||
}
|
||||
|
||||
certificate, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Certificate])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect certificate: %w", err)
|
||||
}
|
||||
|
||||
*c = certificate
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Certificate) LoadByIDForUpdateSkipLocked(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
certificateID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
hostname,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
certificates
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"id": certificateID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query certificate for update: %w", err)
|
||||
}
|
||||
|
||||
certificate, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Certificate])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect certificate: %w", err)
|
||||
}
|
||||
|
||||
*c = certificate
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Certificate) LoadByHostname(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
hostname string,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
hostname,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
certificates
|
||||
WHERE
|
||||
%s
|
||||
AND hostname = @hostname
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"hostname": hostname}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query certificate: %w", err)
|
||||
}
|
||||
|
||||
certificate, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Certificate])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect certificate: %w", err)
|
||||
}
|
||||
|
||||
*c = certificate
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Certificate) LoadByHTTPChallengeToken(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
token string,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
hostname,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
certificates
|
||||
WHERE
|
||||
%s
|
||||
AND http_challenge_token = @token
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"token": token}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query certificate: %w", err)
|
||||
}
|
||||
|
||||
certificate, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Certificate])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect certificate: %w", err)
|
||||
}
|
||||
|
||||
*c = certificate
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (certificates *Certificates) LoadByIDs(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
ids []gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
hostname,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
certificates
|
||||
WHERE
|
||||
%s
|
||||
AND id = ANY(@ids)
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"ids": ids}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query certificates: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Certificate])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect certificates: %w", err)
|
||||
}
|
||||
|
||||
*certificates = result
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Certificate) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
var encryptedKey []byte
|
||||
if len(c.EncryptedSSLPrivateKey) > 0 {
|
||||
encryptedKey = c.EncryptedSSLPrivateKey
|
||||
}
|
||||
|
||||
q := `
|
||||
INSERT INTO certificates (
|
||||
id,
|
||||
tenant_id,
|
||||
hostname,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@hostname,
|
||||
@http_challenge_token,
|
||||
@http_challenge_key_auth,
|
||||
@http_challenge_url,
|
||||
@http_order_url,
|
||||
@ssl_certificate,
|
||||
@encrypted_ssl_private_key,
|
||||
@ssl_certificate_chain,
|
||||
@status,
|
||||
@ssl_expires_at,
|
||||
@ssl_retry_count,
|
||||
@ssl_last_attempt_at,
|
||||
@provisioning_error,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.NamedArgs{
|
||||
"id": c.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"hostname": c.Hostname,
|
||||
"http_challenge_token": c.HTTPChallengeToken,
|
||||
"http_challenge_key_auth": c.HTTPChallengeKeyAuth,
|
||||
"http_challenge_url": c.HTTPChallengeURL,
|
||||
"http_order_url": c.HTTPOrderURL,
|
||||
"ssl_certificate": c.SSLCertificatePEM,
|
||||
"encrypted_ssl_private_key": encryptedKey,
|
||||
"ssl_certificate_chain": c.SSLCertificateChain,
|
||||
"status": c.Status,
|
||||
"ssl_expires_at": c.SSLExpiresAt,
|
||||
"ssl_retry_count": c.SSLRetryCount,
|
||||
"ssl_last_attempt_at": c.SSLLastAttemptAt,
|
||||
"provisioning_error": c.ProvisioningError,
|
||||
"created_at": c.CreatedAt,
|
||||
"updated_at": c.UpdatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok {
|
||||
if pgErr.Code == "23505" && pgErr.ConstraintName == "certificates_hostname_key" {
|
||||
return ErrResourceAlreadyExists
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot insert certificate: %w", err)
|
||||
}
|
||||
|
||||
c.EncryptedSSLPrivateKey = encryptedKey
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Certificate) Update(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
var encryptedKey []byte
|
||||
if len(c.EncryptedSSLPrivateKey) > 0 {
|
||||
encryptedKey = c.EncryptedSSLPrivateKey
|
||||
}
|
||||
|
||||
q := `
|
||||
UPDATE
|
||||
certificates
|
||||
SET
|
||||
hostname = @hostname,
|
||||
http_challenge_token = @http_challenge_token,
|
||||
http_challenge_key_auth = @http_challenge_key_auth,
|
||||
http_challenge_url = @http_challenge_url,
|
||||
http_order_url = @http_order_url,
|
||||
ssl_certificate = @ssl_certificate,
|
||||
encrypted_ssl_private_key = @encrypted_ssl_private_key,
|
||||
ssl_certificate_chain = @ssl_certificate_chain,
|
||||
status = @status,
|
||||
ssl_expires_at = @ssl_expires_at,
|
||||
ssl_retry_count = @ssl_retry_count,
|
||||
ssl_last_attempt_at = @ssl_last_attempt_at,
|
||||
provisioning_error = @provisioning_error,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{
|
||||
"id": c.ID,
|
||||
"hostname": c.Hostname,
|
||||
"http_challenge_token": c.HTTPChallengeToken,
|
||||
"http_challenge_key_auth": c.HTTPChallengeKeyAuth,
|
||||
"http_challenge_url": c.HTTPChallengeURL,
|
||||
"http_order_url": c.HTTPOrderURL,
|
||||
"ssl_certificate": c.SSLCertificatePEM,
|
||||
"encrypted_ssl_private_key": encryptedKey,
|
||||
"ssl_certificate_chain": c.SSLCertificateChain,
|
||||
"status": c.Status,
|
||||
"ssl_expires_at": c.SSLExpiresAt,
|
||||
"ssl_retry_count": c.SSLRetryCount,
|
||||
"ssl_last_attempt_at": c.SSLLastAttemptAt,
|
||||
"provisioning_error": c.ProvisioningError,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update certificate: %w", err)
|
||||
}
|
||||
|
||||
c.EncryptedSSLPrivateKey = encryptedKey
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Certificate) Delete(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
DELETE FROM
|
||||
certificates
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"id": c.ID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete certificate: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (certificates *Certificates) ListForRenewal(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
hostname,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
certificates
|
||||
WHERE
|
||||
%s
|
||||
AND status = @status
|
||||
AND ssl_expires_at <= CURRENT_TIMESTAMP + INTERVAL '30 days'
|
||||
ORDER BY
|
||||
ssl_expires_at ASC
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"status": string(CertificateStatusActive)}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query certificates for renewal: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Certificate])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect certificates: %w", err)
|
||||
}
|
||||
|
||||
*certificates = result
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (certificates *Certificates) ListWithPendingHTTPChallenges(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
hostname,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
certificates
|
||||
WHERE
|
||||
%s
|
||||
AND status = ANY(@statuses)
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{
|
||||
"statuses": []string{
|
||||
string(CertificateStatusPending),
|
||||
string(CertificateStatusProvisioning),
|
||||
string(CertificateStatusRenewing),
|
||||
},
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query certificates with pending challenges: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Certificate])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect certificates: %w", err)
|
||||
}
|
||||
|
||||
*certificates = result
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (certificates *Certificates) LoadActive(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
hostname,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
certificates
|
||||
WHERE
|
||||
%s
|
||||
AND status = @status
|
||||
AND ssl_certificate IS NOT NULL
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"status": string(CertificateStatusActive)}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query active certificates: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Certificate])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect certificates: %w", err)
|
||||
}
|
||||
|
||||
*certificates = result
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (certificates *Certificates) ListStaleProvisioning(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
hostname,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
certificates
|
||||
WHERE
|
||||
%s
|
||||
AND (
|
||||
(status IN (@provisioning_status, @renewing_status) AND updated_at < CURRENT_TIMESTAMP - INTERVAL '4 hours')
|
||||
OR
|
||||
(ssl_retry_count > 0 AND ssl_last_attempt_at < CURRENT_TIMESTAMP - INTERVAL '24 hours')
|
||||
)
|
||||
AND status != @failed_status
|
||||
AND status != @active_status
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{
|
||||
"provisioning_status": string(CertificateStatusProvisioning),
|
||||
"renewing_status": string(CertificateStatusRenewing),
|
||||
"failed_status": string(CertificateStatusFailed),
|
||||
"active_status": string(CertificateStatusActive),
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query stale provisioning certificates: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Certificate])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect stale provisioning certificates: %w", err)
|
||||
}
|
||||
|
||||
*certificates = result
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Certificate) LoadNextForProvisioningForUpdateSkipLocked(
|
||||
ctx context.Context,
|
||||
tx pg.Tx,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
hostname,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
certificates
|
||||
WHERE
|
||||
status = ANY(@statuses)
|
||||
ORDER BY
|
||||
updated_at ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`
|
||||
|
||||
rows, err := tx.Query(
|
||||
ctx,
|
||||
q,
|
||||
pgx.StrictNamedArgs{
|
||||
"statuses": []string{
|
||||
string(CertificateStatusPending),
|
||||
string(CertificateStatusProvisioning),
|
||||
string(CertificateStatusRenewing),
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query certificate provisioning queue: %w", err)
|
||||
}
|
||||
|
||||
certificate, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Certificate])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect certificate: %w", err)
|
||||
}
|
||||
|
||||
*c = certificate
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Certificate) LoadNextForRenewalForUpdateSkipLocked(
|
||||
ctx context.Context,
|
||||
tx pg.Tx,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
hostname,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
certificates
|
||||
WHERE
|
||||
status = @status
|
||||
AND ssl_expires_at <= CURRENT_TIMESTAMP + INTERVAL '30 days'
|
||||
ORDER BY
|
||||
ssl_expires_at ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`
|
||||
|
||||
rows, err := tx.Query(
|
||||
ctx,
|
||||
q,
|
||||
pgx.StrictNamedArgs{"status": string(CertificateStatusActive)},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query certificate renewal queue: %w", err)
|
||||
}
|
||||
|
||||
certificate, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Certificate])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect certificate: %w", err)
|
||||
}
|
||||
|
||||
*c = certificate
|
||||
|
||||
return nil
|
||||
}
|
||||
82
pkg/coredata/certificate_status.go
Normal file
82
pkg/coredata/certificate_status.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type CertificateStatus string
|
||||
|
||||
const (
|
||||
CertificateStatusPending CertificateStatus = "PENDING"
|
||||
CertificateStatusProvisioning CertificateStatus = "PROVISIONING"
|
||||
CertificateStatusActive CertificateStatus = "ACTIVE"
|
||||
CertificateStatusRenewing CertificateStatus = "RENEWING"
|
||||
CertificateStatusExpired CertificateStatus = "EXPIRED"
|
||||
CertificateStatusFailed CertificateStatus = "FAILED"
|
||||
)
|
||||
|
||||
var (
|
||||
_ fmt.Stringer = CertificateStatus("")
|
||||
_ encoding.TextMarshaler = CertificateStatus("")
|
||||
_ encoding.TextUnmarshaler = (*CertificateStatus)(nil)
|
||||
)
|
||||
|
||||
func CertificateStatuses() []CertificateStatus {
|
||||
return []CertificateStatus{
|
||||
CertificateStatusPending,
|
||||
CertificateStatusProvisioning,
|
||||
CertificateStatusActive,
|
||||
CertificateStatusRenewing,
|
||||
CertificateStatusExpired,
|
||||
CertificateStatusFailed,
|
||||
}
|
||||
}
|
||||
|
||||
func (v CertificateStatus) IsValid() bool {
|
||||
switch v {
|
||||
case
|
||||
CertificateStatusPending,
|
||||
CertificateStatusProvisioning,
|
||||
CertificateStatusActive,
|
||||
CertificateStatusRenewing,
|
||||
CertificateStatusExpired,
|
||||
CertificateStatusFailed:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (v CertificateStatus) String() string {
|
||||
return string(v)
|
||||
}
|
||||
|
||||
func (v CertificateStatus) MarshalText() ([]byte, error) {
|
||||
return []byte(v.String()), nil
|
||||
}
|
||||
|
||||
func (v *CertificateStatus) UnmarshalText(text []byte) error {
|
||||
val := CertificateStatus(text)
|
||||
if !val.IsValid() {
|
||||
return fmt.Errorf("invalid CertificateStatus value: %q", string(text))
|
||||
}
|
||||
|
||||
*v = val
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -22,55 +22,52 @@ package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/crypto/cipher"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/iam/policy"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
// CustomDomain is a domain owned by an organization used to serve its
|
||||
// compliance portal. Its TLS certificate lifecycle is owned by the generic
|
||||
// certificates table, referenced through CertificateID.
|
||||
CustomDomain struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
Domain string `db:"domain"`
|
||||
HTTPChallengeToken *string `db:"http_challenge_token"`
|
||||
HTTPChallengeKeyAuth *string `db:"http_challenge_key_auth"`
|
||||
HTTPChallengeURL *string `db:"http_challenge_url"`
|
||||
HTTPOrderURL *string `db:"http_order_url"`
|
||||
SSLCertificate *tls.Certificate `db:"-"`
|
||||
SSLCertificatePEM []byte `db:"ssl_certificate"`
|
||||
EncryptedSSLPrivateKey []byte `db:"encrypted_ssl_private_key"`
|
||||
SSLCertificateChain *string `db:"ssl_certificate_chain"`
|
||||
SSLStatus CustomDomainSSLStatus `db:"ssl_status"`
|
||||
SSLExpiresAt *time.Time `db:"ssl_expires_at"`
|
||||
SSLRetryCount int `db:"ssl_retry_count"`
|
||||
SSLLastAttemptAt *time.Time `db:"ssl_last_attempt_at"`
|
||||
ProvisioningError *string `db:"provisioning_error"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
Domain string `db:"domain"`
|
||||
Managed bool `db:"managed"`
|
||||
CertificateID *gid.GID `db:"certificate_id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
CustomDomains []*CustomDomain
|
||||
)
|
||||
|
||||
func NewCustomDomain(tenantID gid.TenantID, domain string) *CustomDomain {
|
||||
func NewCustomDomain(
|
||||
tenantID gid.TenantID,
|
||||
organizationID gid.GID,
|
||||
domain string,
|
||||
managed bool,
|
||||
) *CustomDomain {
|
||||
now := time.Now()
|
||||
|
||||
return &CustomDomain{
|
||||
ID: gid.New(tenantID, CustomDomainEntityType),
|
||||
SSLStatus: CustomDomainSSLStatusPending,
|
||||
Domain: domain,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ID: gid.New(tenantID, CustomDomainEntityType),
|
||||
OrganizationID: organizationID,
|
||||
Domain: domain,
|
||||
Managed: managed,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +77,7 @@ func (cd *CustomDomain) AuthorizationAttributes(
|
||||
conn pg.Querier,
|
||||
resourceIDs []gid.GID,
|
||||
) (policy.AttributesByID, error) {
|
||||
q := `SELECT id, organization_id FROM custom_domains WHERE id = ANY(@resource_ids::text[])`
|
||||
q := `SELECT id, organization_id, managed FROM custom_domains WHERE id = ANY(@resource_ids::text[])`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"resource_ids": resourceIDs,
|
||||
@@ -96,14 +93,18 @@ func (cd *CustomDomain) AuthorizationAttributes(
|
||||
attrsByID := make(policy.AttributesByID)
|
||||
|
||||
for rows.Next() {
|
||||
var id, organizationID gid.GID
|
||||
var (
|
||||
id, organizationID gid.GID
|
||||
managed bool
|
||||
)
|
||||
|
||||
if err := rows.Scan(&id, &organizationID); err != nil {
|
||||
if err := rows.Scan(&id, &organizationID, &managed); err != nil {
|
||||
return nil, fmt.Errorf("cannot scan authorization attributes: %w", err)
|
||||
}
|
||||
|
||||
attrsByID[id] = policy.Attributes{
|
||||
"organization_id": organizationID.String(),
|
||||
"managed": strconv.FormatBool(managed),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,64 +128,6 @@ func (cd *CustomDomain) CursorKey(field CustomDomainOrderField) page.CursorKey {
|
||||
panic(fmt.Sprintf("unsupported order by: %s", field))
|
||||
}
|
||||
|
||||
func (cd *CustomDomain) DecryptPrivateKey(encryptionKey cipher.EncryptionKey) ([]byte, error) {
|
||||
if len(cd.EncryptedSSLPrivateKey) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
decrypted, err := cipher.Decrypt(cd.EncryptedSSLPrivateKey, encryptionKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot decrypt SSL private key: %w", err)
|
||||
}
|
||||
|
||||
return decrypted, nil
|
||||
}
|
||||
|
||||
func (cd *CustomDomain) EncryptPrivateKey(privateKeyPEM []byte, encryptionKey cipher.EncryptionKey) error {
|
||||
if len(privateKeyPEM) == 0 {
|
||||
cd.EncryptedSSLPrivateKey = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
encrypted, err := cipher.Encrypt(privateKeyPEM, encryptionKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot encrypt SSL private key: %w", err)
|
||||
}
|
||||
|
||||
cd.EncryptedSSLPrivateKey = encrypted
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cd *CustomDomain) ParseCertificate(encryptionKey cipher.EncryptionKey) error {
|
||||
if len(cd.SSLCertificatePEM) == 0 {
|
||||
return fmt.Errorf("no certificate PEM data")
|
||||
}
|
||||
|
||||
privateKeyPEM, err := cd.DecryptPrivateKey(encryptionKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot decrypt private key: %w", err)
|
||||
}
|
||||
|
||||
if len(privateKeyPEM) == 0 {
|
||||
return fmt.Errorf("no private key data")
|
||||
}
|
||||
|
||||
fullCertPEM := string(cd.SSLCertificatePEM)
|
||||
if cd.SSLCertificateChain != nil && *cd.SSLCertificateChain != "" {
|
||||
fullCertPEM += "\n" + *cd.SSLCertificateChain
|
||||
}
|
||||
|
||||
tlsCert, err := tls.X509KeyPair([]byte(fullCertPEM), privateKeyPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse certificate and key: %w", err)
|
||||
}
|
||||
|
||||
cd.SSLCertificate = &tlsCert
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cd *CustomDomain) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
@@ -196,18 +139,8 @@ SELECT
|
||||
id,
|
||||
organization_id,
|
||||
domain,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
ssl_status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
managed,
|
||||
certificate_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -242,63 +175,6 @@ LIMIT 1
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cd *CustomDomain) LoadByIDForUpdateSkipLocked(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
domainID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
domain,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
ssl_status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
custom_domains
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"id": domainID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query custom domain for update: %w", err)
|
||||
}
|
||||
|
||||
customDomain, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CustomDomain])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect custom domain: %w", err)
|
||||
}
|
||||
|
||||
*cd = customDomain
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cd *CustomDomain) LoadByDomain(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
@@ -310,18 +186,8 @@ SELECT
|
||||
id,
|
||||
organization_id,
|
||||
domain,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
ssl_status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
managed,
|
||||
certificate_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -356,59 +222,44 @@ LIMIT 1
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cd *CustomDomain) LoadByOrganizationID(
|
||||
func (domains *CustomDomains) LoadByIDs(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
ids []gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
domain,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
ssl_status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
managed,
|
||||
certificate_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
custom_domains
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
LIMIT 1
|
||||
AND id = ANY(@ids)
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"organization_id": organizationID}
|
||||
args := pgx.NamedArgs{"ids": ids}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query custom domain: %w", err)
|
||||
return fmt.Errorf("cannot query custom domains: %w", err)
|
||||
}
|
||||
|
||||
customDomain, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CustomDomain])
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CustomDomain])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect custom domain: %w", err)
|
||||
return fmt.Errorf("cannot collect custom domains: %w", err)
|
||||
}
|
||||
|
||||
*cd = customDomain
|
||||
*domains = result
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -417,31 +268,15 @@ func (cd *CustomDomain) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
encryptionKey cipher.EncryptionKey,
|
||||
) error {
|
||||
var encryptedKey []byte
|
||||
if len(cd.EncryptedSSLPrivateKey) > 0 {
|
||||
encryptedKey = cd.EncryptedSSLPrivateKey
|
||||
}
|
||||
|
||||
q := `
|
||||
INSERT INTO custom_domains (
|
||||
id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
domain,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
ssl_status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
managed,
|
||||
certificate_id,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
@@ -449,42 +284,22 @@ INSERT INTO custom_domains (
|
||||
@tenant_id,
|
||||
@organization_id,
|
||||
@domain,
|
||||
@http_challenge_token,
|
||||
@http_challenge_key_auth,
|
||||
@http_challenge_url,
|
||||
@http_order_url,
|
||||
@ssl_certificate,
|
||||
@encrypted_ssl_private_key,
|
||||
@ssl_certificate_chain,
|
||||
@ssl_status,
|
||||
@ssl_expires_at,
|
||||
@ssl_retry_count,
|
||||
@ssl_last_attempt_at,
|
||||
@provisioning_error,
|
||||
@managed,
|
||||
@certificate_id,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.NamedArgs{
|
||||
"id": cd.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": cd.OrganizationID,
|
||||
"domain": cd.Domain,
|
||||
"http_challenge_token": cd.HTTPChallengeToken,
|
||||
"http_challenge_key_auth": cd.HTTPChallengeKeyAuth,
|
||||
"http_challenge_url": cd.HTTPChallengeURL,
|
||||
"http_order_url": cd.HTTPOrderURL,
|
||||
"ssl_certificate": cd.SSLCertificatePEM,
|
||||
"encrypted_ssl_private_key": encryptedKey,
|
||||
"ssl_certificate_chain": cd.SSLCertificateChain,
|
||||
"ssl_status": cd.SSLStatus,
|
||||
"ssl_expires_at": cd.SSLExpiresAt,
|
||||
"ssl_retry_count": cd.SSLRetryCount,
|
||||
"ssl_last_attempt_at": cd.SSLLastAttemptAt,
|
||||
"provisioning_error": cd.ProvisioningError,
|
||||
"created_at": cd.CreatedAt,
|
||||
"updated_at": cd.UpdatedAt,
|
||||
"id": cd.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": cd.OrganizationID,
|
||||
"domain": cd.Domain,
|
||||
"managed": cd.Managed,
|
||||
"certificate_id": cd.CertificateID,
|
||||
"created_at": cd.CreatedAt,
|
||||
"updated_at": cd.UpdatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
@@ -498,8 +313,6 @@ INSERT INTO custom_domains (
|
||||
return fmt.Errorf("cannot insert custom domain: %w", err)
|
||||
}
|
||||
|
||||
cd.EncryptedSSLPrivateKey = encryptedKey
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -508,27 +321,13 @@ func (cd *CustomDomain) Update(
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
var encryptedKey []byte
|
||||
if len(cd.EncryptedSSLPrivateKey) > 0 {
|
||||
encryptedKey = cd.EncryptedSSLPrivateKey
|
||||
}
|
||||
|
||||
q := `
|
||||
UPDATE
|
||||
custom_domains
|
||||
SET
|
||||
http_challenge_token = @http_challenge_token,
|
||||
http_challenge_key_auth = @http_challenge_key_auth,
|
||||
http_challenge_url = @http_challenge_url,
|
||||
http_order_url = @http_order_url,
|
||||
ssl_certificate = @ssl_certificate,
|
||||
encrypted_ssl_private_key = @encrypted_ssl_private_key,
|
||||
ssl_certificate_chain = @ssl_certificate_chain,
|
||||
ssl_status = @ssl_status,
|
||||
ssl_expires_at = @ssl_expires_at,
|
||||
ssl_retry_count = @ssl_retry_count,
|
||||
ssl_last_attempt_at = @ssl_last_attempt_at,
|
||||
provisioning_error = @provisioning_error,
|
||||
domain = @domain,
|
||||
managed = @managed,
|
||||
certificate_id = @certificate_id,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
%s
|
||||
@@ -538,20 +337,11 @@ WHERE
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{
|
||||
"id": cd.ID,
|
||||
"http_challenge_token": cd.HTTPChallengeToken,
|
||||
"http_challenge_key_auth": cd.HTTPChallengeKeyAuth,
|
||||
"http_challenge_url": cd.HTTPChallengeURL,
|
||||
"http_order_url": cd.HTTPOrderURL,
|
||||
"ssl_certificate": cd.SSLCertificatePEM,
|
||||
"encrypted_ssl_private_key": encryptedKey,
|
||||
"ssl_certificate_chain": cd.SSLCertificateChain,
|
||||
"ssl_status": cd.SSLStatus,
|
||||
"ssl_expires_at": cd.SSLExpiresAt,
|
||||
"ssl_retry_count": cd.SSLRetryCount,
|
||||
"ssl_last_attempt_at": cd.SSLLastAttemptAt,
|
||||
"provisioning_error": cd.ProvisioningError,
|
||||
"updated_at": time.Now(),
|
||||
"id": cd.ID,
|
||||
"domain": cd.Domain,
|
||||
"managed": cd.Managed,
|
||||
"certificate_id": cd.CertificateID,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
@@ -560,8 +350,6 @@ WHERE
|
||||
return fmt.Errorf("cannot update custom domain: %w", err)
|
||||
}
|
||||
|
||||
cd.EncryptedSSLPrivateKey = encryptedKey
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -589,281 +377,3 @@ WHERE
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cd *CustomDomain) LoadByHTTPChallengeToken(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
token string,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
domain,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
ssl_status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
custom_domains
|
||||
WHERE
|
||||
%s
|
||||
AND http_challenge_token = @token
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"token": token}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query custom domain: %w", err)
|
||||
}
|
||||
|
||||
customDomain, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CustomDomain])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect custom domain: %w", err)
|
||||
}
|
||||
|
||||
*cd = customDomain
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (domains *CustomDomains) ListDomainsForRenewal(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
domain,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
ssl_status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
custom_domains
|
||||
WHERE
|
||||
%s
|
||||
AND ssl_status = @status
|
||||
AND ssl_expires_at <= CURRENT_TIMESTAMP + INTERVAL '30 days'
|
||||
ORDER BY
|
||||
ssl_expires_at ASC
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"status": string(CustomDomainSSLStatusActive)}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query custom domains for renewal: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CustomDomain])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect custom domains: %w", err)
|
||||
}
|
||||
|
||||
*domains = result
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (domains *CustomDomains) ListDomainsWithPendingHTTPChallenges(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
domain,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
ssl_status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
custom_domains
|
||||
WHERE
|
||||
%s
|
||||
AND ssl_status = ANY(@statuses)
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{
|
||||
"statuses": []string{
|
||||
string(CustomDomainSSLStatusPending),
|
||||
string(CustomDomainSSLStatusProvisioning),
|
||||
string(CustomDomainSSLStatusRenewing),
|
||||
},
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query custom domains with pending challenges: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CustomDomain])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect custom domains: %w", err)
|
||||
}
|
||||
|
||||
*domains = result
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (domains *CustomDomains) LoadActiveCertificates(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
domain,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
ssl_status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
custom_domains
|
||||
WHERE
|
||||
%s
|
||||
AND ssl_status = @status
|
||||
AND ssl_certificate IS NOT NULL
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"status": string(CustomDomainSSLStatusActive)}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query active certificates: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CustomDomain])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect custom domains: %w", err)
|
||||
}
|
||||
|
||||
*domains = result
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (domains *CustomDomains) ListStaleProvisioningDomains(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
domain,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
ssl_status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
provisioning_error,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
custom_domains
|
||||
WHERE
|
||||
%s
|
||||
AND (
|
||||
(ssl_status IN (@provisioning_status, @renewing_status) AND updated_at < CURRENT_TIMESTAMP - INTERVAL '4 hours')
|
||||
OR
|
||||
(ssl_retry_count > 0 AND ssl_last_attempt_at < CURRENT_TIMESTAMP - INTERVAL '24 hours')
|
||||
)
|
||||
AND ssl_status != @failed_status
|
||||
AND ssl_status != @active_status
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{
|
||||
"provisioning_status": string(CustomDomainSSLStatusProvisioning),
|
||||
"renewing_status": string(CustomDomainSSLStatusRenewing),
|
||||
"failed_status": string(CustomDomainSSLStatusFailed),
|
||||
"active_status": string(CustomDomainSSLStatusActive),
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query stale provisioning domains: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CustomDomain])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect stale provisioning domains: %w", err)
|
||||
}
|
||||
|
||||
*domains = result
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ const (
|
||||
ElectronicSignatureEventEntityType uint16 = 60
|
||||
EmailAttachmentEntityType uint16 = 61
|
||||
ComplianceFrameworkEntityType uint16 = 62
|
||||
ComplianceExternalURLEntityType uint16 = 63
|
||||
ComplianceCustomLinkEntityType uint16 = 63
|
||||
MailingListEntityType uint16 = 64
|
||||
MailingListSubscriberEntityType uint16 = 65
|
||||
MailingListUpdateEntityType uint16 = 66
|
||||
@@ -135,6 +135,7 @@ const (
|
||||
AccessReviewCampaignSourceFetchAttemptEntityType uint16 = 103
|
||||
CompliancePortalCommitmentGroupEntityType uint16 = 104
|
||||
CompliancePortalCommitmentEntityType uint16 = 105
|
||||
CertificateEntityType uint16 = 106
|
||||
)
|
||||
|
||||
func NewEntityFromID(id gid.GID) (any, bool) {
|
||||
@@ -253,8 +254,8 @@ func NewEntityFromID(id gid.GID) (any, bool) {
|
||||
return &EmailAttachment{ID: id}, true
|
||||
case ComplianceFrameworkEntityType:
|
||||
return &ComplianceFramework{ID: id}, true
|
||||
case ComplianceExternalURLEntityType:
|
||||
return &ComplianceExternalURL{ID: id}, true
|
||||
case ComplianceCustomLinkEntityType:
|
||||
return &ComplianceCustomLink{ID: id}, true
|
||||
case MailingListEntityType:
|
||||
return &MailingList{ID: id}, true
|
||||
case MailingListSubscriberEntityType:
|
||||
@@ -333,6 +334,8 @@ func NewEntityFromID(id gid.GID) (any, bool) {
|
||||
return &CompliancePortalCommitmentGroup{ID: id}, true
|
||||
case CompliancePortalCommitmentEntityType:
|
||||
return &CompliancePortalCommitment{ID: id}, true
|
||||
case CertificateEntityType:
|
||||
return &Certificate{ID: id}, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
|
||||
131
pkg/coredata/migrations/20260709T090905Z.sql
Normal file
131
pkg/coredata/migrations/20260709T090905Z.sql
Normal file
@@ -0,0 +1,131 @@
|
||||
-- Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
--
|
||||
-- Permission to use, copy, modify, and/or distribute this software for any
|
||||
-- purpose with or without fee is hereby granted, provided that the above
|
||||
-- copyright notice and this permission notice appear in all copies.
|
||||
--
|
||||
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
-- PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
-- Extract the SSL/ACME certificate lifecycle out of custom_domains into a
|
||||
-- generic certificates table keyed on a globally-unique hostname. A custom
|
||||
-- domain now references its certificate through certificate_id.
|
||||
|
||||
CREATE TABLE certificates (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
hostname CITEXT NOT NULL UNIQUE,
|
||||
ssl_certificate BYTEA,
|
||||
encrypted_ssl_private_key BYTEA,
|
||||
ssl_certificate_chain TEXT,
|
||||
status custom_domain_ssl_status NOT NULL,
|
||||
ssl_expires_at TIMESTAMP WITH TIME ZONE,
|
||||
ssl_retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
ssl_last_attempt_at TIMESTAMP WITH TIME ZONE,
|
||||
http_challenge_token TEXT,
|
||||
http_challenge_key_auth TEXT,
|
||||
http_challenge_url TEXT,
|
||||
http_order_url TEXT,
|
||||
provisioning_error TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_certificates_ssl_expires ON certificates(ssl_expires_at)
|
||||
WHERE status = 'ACTIVE';
|
||||
CREATE INDEX idx_certificates_http_challenge_token ON certificates(http_challenge_token)
|
||||
WHERE http_challenge_token IS NOT NULL;
|
||||
|
||||
-- Backfill one certificate per existing custom domain. A fresh GID is minted
|
||||
-- for each row: the source domain's tenant (first 8 bytes of its GID) is kept,
|
||||
-- the entity type is set to 104 (CertificateEntityType), and a millisecond
|
||||
-- timestamp plus random suffix guarantee uniqueness. The certificate hostname
|
||||
-- equals the custom domain name, which lets us link the two afterwards.
|
||||
INSERT INTO certificates (
|
||||
id,
|
||||
tenant_id,
|
||||
hostname,
|
||||
ssl_certificate,
|
||||
encrypted_ssl_private_key,
|
||||
ssl_certificate_chain,
|
||||
status,
|
||||
ssl_expires_at,
|
||||
ssl_retry_count,
|
||||
ssl_last_attempt_at,
|
||||
http_challenge_token,
|
||||
http_challenge_key_auth,
|
||||
http_challenge_url,
|
||||
http_order_url,
|
||||
provisioning_error,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
translate(
|
||||
encode(
|
||||
substring(decode(translate(cd.id, '-_', '+/'), 'base64') FROM 1 FOR 8)
|
||||
|| int2send(104::smallint)
|
||||
|| int8send((floor(extract(epoch FROM clock_timestamp()) * 1000))::bigint)
|
||||
|| substring(decode(md5(random()::text || cd.id), 'hex') FROM 1 FOR 6),
|
||||
'base64'
|
||||
),
|
||||
'+/',
|
||||
'-_'
|
||||
),
|
||||
cd.tenant_id,
|
||||
cd.domain,
|
||||
cd.ssl_certificate,
|
||||
cd.encrypted_ssl_private_key,
|
||||
cd.ssl_certificate_chain,
|
||||
COALESCE(cd.ssl_status, 'PENDING'),
|
||||
cd.ssl_expires_at,
|
||||
COALESCE(cd.ssl_retry_count, 0),
|
||||
cd.ssl_last_attempt_at,
|
||||
cd.http_challenge_token,
|
||||
cd.http_challenge_key_auth,
|
||||
cd.http_challenge_url,
|
||||
cd.http_order_url,
|
||||
cd.provisioning_error,
|
||||
cd.created_at,
|
||||
cd.updated_at
|
||||
FROM custom_domains cd;
|
||||
|
||||
ALTER TABLE custom_domains ADD COLUMN certificate_id TEXT REFERENCES certificates(id) ON DELETE SET NULL;
|
||||
|
||||
UPDATE custom_domains cd
|
||||
SET certificate_id = c.id
|
||||
FROM certificates c
|
||||
WHERE c.hostname = cd.domain;
|
||||
|
||||
-- Repoint the certificate cache from the custom domain to the certificate.
|
||||
ALTER TABLE cached_certificates ADD COLUMN certificate_id TEXT REFERENCES certificates(id) ON DELETE CASCADE;
|
||||
|
||||
UPDATE cached_certificates cc
|
||||
SET certificate_id = cd.certificate_id
|
||||
FROM custom_domains cd
|
||||
WHERE cd.id = cc.custom_domain_id;
|
||||
|
||||
ALTER TABLE cached_certificates DROP COLUMN custom_domain_id;
|
||||
|
||||
-- Drop the certificate lifecycle columns now living on certificates.
|
||||
DROP INDEX IF EXISTS idx_custom_domains_ssl_expires;
|
||||
DROP INDEX IF EXISTS idx_custom_domains_http_challenge_token;
|
||||
|
||||
ALTER TABLE custom_domains
|
||||
DROP COLUMN ssl_certificate,
|
||||
DROP COLUMN encrypted_ssl_private_key,
|
||||
DROP COLUMN ssl_certificate_chain,
|
||||
DROP COLUMN ssl_status,
|
||||
DROP COLUMN ssl_expires_at,
|
||||
DROP COLUMN ssl_retry_count,
|
||||
DROP COLUMN ssl_last_attempt_at,
|
||||
DROP COLUMN http_challenge_token,
|
||||
DROP COLUMN http_challenge_key_auth,
|
||||
DROP COLUMN http_challenge_url,
|
||||
DROP COLUMN http_order_url,
|
||||
DROP COLUMN provisioning_error;
|
||||
Reference in New Issue
Block a user