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"`
|
CertificateChain *string `db:"certificate_chain"`
|
||||||
ExpiresAt time.Time `db:"expires_at"`
|
ExpiresAt time.Time `db:"expires_at"`
|
||||||
CachedAt time.Time `db:"cached_at"`
|
CachedAt time.Time `db:"cached_at"`
|
||||||
CustomDomainID gid.GID `db:"custom_domain_id"`
|
CertificateID gid.GID `db:"certificate_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
CachedCertificates []*CachedCertificate
|
CachedCertificates []*CachedCertificate
|
||||||
@@ -54,7 +54,7 @@ SELECT
|
|||||||
certificate_chain,
|
certificate_chain,
|
||||||
expires_at,
|
expires_at,
|
||||||
cached_at,
|
cached_at,
|
||||||
custom_domain_id
|
certificate_id
|
||||||
FROM
|
FROM
|
||||||
cached_certificates
|
cached_certificates
|
||||||
WHERE
|
WHERE
|
||||||
@@ -91,7 +91,7 @@ INSERT INTO cached_certificates (
|
|||||||
certificate_chain,
|
certificate_chain,
|
||||||
expires_at,
|
expires_at,
|
||||||
cached_at,
|
cached_at,
|
||||||
custom_domain_id
|
certificate_id
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@domain,
|
@domain,
|
||||||
@certificate_pem,
|
@certificate_pem,
|
||||||
@@ -99,7 +99,7 @@ INSERT INTO cached_certificates (
|
|||||||
@certificate_chain,
|
@certificate_chain,
|
||||||
@expires_at,
|
@expires_at,
|
||||||
@cached_at,
|
@cached_at,
|
||||||
@custom_domain_id
|
@certificate_id
|
||||||
)
|
)
|
||||||
ON CONFLICT (domain) DO UPDATE SET
|
ON CONFLICT (domain) DO UPDATE SET
|
||||||
certificate_pem = EXCLUDED.certificate_pem,
|
certificate_pem = EXCLUDED.certificate_pem,
|
||||||
@@ -107,7 +107,7 @@ ON CONFLICT (domain) DO UPDATE SET
|
|||||||
certificate_chain = EXCLUDED.certificate_chain,
|
certificate_chain = EXCLUDED.certificate_chain,
|
||||||
expires_at = EXCLUDED.expires_at,
|
expires_at = EXCLUDED.expires_at,
|
||||||
cached_at = NOW(),
|
cached_at = NOW(),
|
||||||
custom_domain_id = EXCLUDED.custom_domain_id
|
certificate_id = EXCLUDED.certificate_id
|
||||||
`
|
`
|
||||||
|
|
||||||
args := pgx.NamedArgs{
|
args := pgx.NamedArgs{
|
||||||
@@ -117,7 +117,7 @@ ON CONFLICT (domain) DO UPDATE SET
|
|||||||
"certificate_chain": cc.CertificateChain,
|
"certificate_chain": cc.CertificateChain,
|
||||||
"expires_at": cc.ExpiresAt,
|
"expires_at": cc.ExpiresAt,
|
||||||
"cached_at": cc.CachedAt,
|
"cached_at": cc.CachedAt,
|
||||||
"custom_domain_id": cc.CustomDomainID,
|
"certificate_id": cc.CertificateID,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := conn.Exec(ctx, q, args)
|
_, err := conn.Exec(ctx, q, args)
|
||||||
@@ -170,36 +170,36 @@ WHERE
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cc *CachedCertificate) RefreshFromDomain(ctx context.Context, conn pg.Querier, domain *CustomDomain, encryptionKey cipher.EncryptionKey) error {
|
func (cc *CachedCertificate) RefreshFromCertificate(ctx context.Context, conn pg.Querier, certificate *Certificate, encryptionKey cipher.EncryptionKey) error {
|
||||||
if domain.SSLCertificate == nil {
|
if certificate.SSLCertificate == nil {
|
||||||
return fmt.Errorf("domain has no parsed certificate")
|
return fmt.Errorf("certificate has no parsed certificate")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(domain.SSLCertificatePEM) == 0 {
|
if len(certificate.SSLCertificatePEM) == 0 {
|
||||||
return fmt.Errorf("domain has no certificate PEM")
|
return fmt.Errorf("certificate has no certificate PEM")
|
||||||
}
|
}
|
||||||
|
|
||||||
privateKeyPEM, err := domain.DecryptPrivateKey(encryptionKey)
|
privateKeyPEM, err := certificate.DecryptPrivateKey(encryptionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot decrypt private key: %w", err)
|
return fmt.Errorf("cannot decrypt private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(privateKeyPEM) == 0 {
|
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 {
|
if certificate.SSLExpiresAt == nil {
|
||||||
return fmt.Errorf("domain certificate has no expiry date")
|
return fmt.Errorf("certificate has no expiry date")
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := &CachedCertificate{
|
cache := &CachedCertificate{
|
||||||
Domain: domain.Domain,
|
Domain: certificate.Hostname,
|
||||||
CertificatePEM: string(domain.SSLCertificatePEM),
|
CertificatePEM: string(certificate.SSLCertificatePEM),
|
||||||
PrivateKeyPEM: string(privateKeyPEM),
|
PrivateKeyPEM: string(privateKeyPEM),
|
||||||
CertificateChain: domain.SSLCertificateChain,
|
CertificateChain: certificate.SSLCertificateChain,
|
||||||
ExpiresAt: *domain.SSLExpiresAt,
|
ExpiresAt: *certificate.SSLExpiresAt,
|
||||||
CachedAt: time.Now(),
|
CachedAt: time.Now(),
|
||||||
CustomDomainID: domain.ID,
|
CertificateID: certificate.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
return cache.Upsert(ctx, conn)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"go.gearno.de/kit/pg"
|
"go.gearno.de/kit/pg"
|
||||||
"go.probo.inc/probo/pkg/crypto/cipher"
|
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/iam/policy"
|
"go.probo.inc/probo/pkg/iam/policy"
|
||||||
"go.probo.inc/probo/pkg/page"
|
"go.probo.inc/probo/pkg/page"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
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 {
|
CustomDomain struct {
|
||||||
ID gid.GID `db:"id"`
|
ID gid.GID `db:"id"`
|
||||||
OrganizationID gid.GID `db:"organization_id"`
|
OrganizationID gid.GID `db:"organization_id"`
|
||||||
Domain string `db:"domain"`
|
Domain string `db:"domain"`
|
||||||
HTTPChallengeToken *string `db:"http_challenge_token"`
|
Managed bool `db:"managed"`
|
||||||
HTTPChallengeKeyAuth *string `db:"http_challenge_key_auth"`
|
CertificateID *gid.GID `db:"certificate_id"`
|
||||||
HTTPChallengeURL *string `db:"http_challenge_url"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
HTTPOrderURL *string `db:"http_order_url"`
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CustomDomains []*CustomDomain
|
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()
|
now := time.Now()
|
||||||
|
|
||||||
return &CustomDomain{
|
return &CustomDomain{
|
||||||
ID: gid.New(tenantID, CustomDomainEntityType),
|
ID: gid.New(tenantID, CustomDomainEntityType),
|
||||||
SSLStatus: CustomDomainSSLStatusPending,
|
OrganizationID: organizationID,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
CreatedAt: now,
|
Managed: managed,
|
||||||
UpdatedAt: now,
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +77,7 @@ func (cd *CustomDomain) AuthorizationAttributes(
|
|||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
resourceIDs []gid.GID,
|
resourceIDs []gid.GID,
|
||||||
) (policy.AttributesByID, error) {
|
) (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{
|
args := pgx.StrictNamedArgs{
|
||||||
"resource_ids": resourceIDs,
|
"resource_ids": resourceIDs,
|
||||||
@@ -96,14 +93,18 @@ func (cd *CustomDomain) AuthorizationAttributes(
|
|||||||
attrsByID := make(policy.AttributesByID)
|
attrsByID := make(policy.AttributesByID)
|
||||||
|
|
||||||
for rows.Next() {
|
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)
|
return nil, fmt.Errorf("cannot scan authorization attributes: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
attrsByID[id] = policy.Attributes{
|
attrsByID[id] = policy.Attributes{
|
||||||
"organization_id": organizationID.String(),
|
"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))
|
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(
|
func (cd *CustomDomain) LoadByID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
@@ -196,18 +139,8 @@ SELECT
|
|||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
domain,
|
domain,
|
||||||
http_challenge_token,
|
managed,
|
||||||
http_challenge_key_auth,
|
certificate_id,
|
||||||
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,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -242,63 +175,6 @@ LIMIT 1
|
|||||||
return nil
|
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(
|
func (cd *CustomDomain) LoadByDomain(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
@@ -310,18 +186,8 @@ SELECT
|
|||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
domain,
|
domain,
|
||||||
http_challenge_token,
|
managed,
|
||||||
http_challenge_key_auth,
|
certificate_id,
|
||||||
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,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -356,59 +222,44 @@ LIMIT 1
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cd *CustomDomain) LoadByOrganizationID(
|
func (domains *CustomDomains) LoadByIDs(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
scope Scoper,
|
scope Scoper,
|
||||||
organizationID gid.GID,
|
ids []gid.GID,
|
||||||
) error {
|
) error {
|
||||||
q := `
|
q := `
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
domain,
|
domain,
|
||||||
http_challenge_token,
|
managed,
|
||||||
http_challenge_key_auth,
|
certificate_id,
|
||||||
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,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
custom_domains
|
custom_domains
|
||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND organization_id = @organization_id
|
AND id = ANY(@ids)
|
||||||
LIMIT 1
|
|
||||||
`
|
`
|
||||||
|
|
||||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
args := pgx.NamedArgs{"organization_id": organizationID}
|
args := pgx.NamedArgs{"ids": ids}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
rows, err := conn.Query(ctx, q, args)
|
rows, err := conn.Query(ctx, q, args)
|
||||||
if err != nil {
|
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 err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
return fmt.Errorf("cannot collect custom domains: %w", err)
|
||||||
return ErrResourceNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("cannot collect custom domain: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
*cd = customDomain
|
*domains = result
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -417,31 +268,15 @@ func (cd *CustomDomain) Insert(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Tx,
|
conn pg.Tx,
|
||||||
scope Scoper,
|
scope Scoper,
|
||||||
encryptionKey cipher.EncryptionKey,
|
|
||||||
) error {
|
) error {
|
||||||
var encryptedKey []byte
|
|
||||||
if len(cd.EncryptedSSLPrivateKey) > 0 {
|
|
||||||
encryptedKey = cd.EncryptedSSLPrivateKey
|
|
||||||
}
|
|
||||||
|
|
||||||
q := `
|
q := `
|
||||||
INSERT INTO custom_domains (
|
INSERT INTO custom_domains (
|
||||||
id,
|
id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
organization_id,
|
organization_id,
|
||||||
domain,
|
domain,
|
||||||
http_challenge_token,
|
managed,
|
||||||
http_challenge_key_auth,
|
certificate_id,
|
||||||
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,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@@ -449,42 +284,22 @@ INSERT INTO custom_domains (
|
|||||||
@tenant_id,
|
@tenant_id,
|
||||||
@organization_id,
|
@organization_id,
|
||||||
@domain,
|
@domain,
|
||||||
@http_challenge_token,
|
@managed,
|
||||||
@http_challenge_key_auth,
|
@certificate_id,
|
||||||
@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,
|
@created_at,
|
||||||
@updated_at
|
@updated_at
|
||||||
)
|
)
|
||||||
`
|
`
|
||||||
|
|
||||||
args := pgx.NamedArgs{
|
args := pgx.NamedArgs{
|
||||||
"id": cd.ID,
|
"id": cd.ID,
|
||||||
"tenant_id": scope.GetTenantID(),
|
"tenant_id": scope.GetTenantID(),
|
||||||
"organization_id": cd.OrganizationID,
|
"organization_id": cd.OrganizationID,
|
||||||
"domain": cd.Domain,
|
"domain": cd.Domain,
|
||||||
"http_challenge_token": cd.HTTPChallengeToken,
|
"managed": cd.Managed,
|
||||||
"http_challenge_key_auth": cd.HTTPChallengeKeyAuth,
|
"certificate_id": cd.CertificateID,
|
||||||
"http_challenge_url": cd.HTTPChallengeURL,
|
"created_at": cd.CreatedAt,
|
||||||
"http_order_url": cd.HTTPOrderURL,
|
"updated_at": cd.UpdatedAt,
|
||||||
"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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := conn.Exec(ctx, q, args)
|
_, err := conn.Exec(ctx, q, args)
|
||||||
@@ -498,8 +313,6 @@ INSERT INTO custom_domains (
|
|||||||
return fmt.Errorf("cannot insert custom domain: %w", err)
|
return fmt.Errorf("cannot insert custom domain: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cd.EncryptedSSLPrivateKey = encryptedKey
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,27 +321,13 @@ func (cd *CustomDomain) Update(
|
|||||||
conn pg.Tx,
|
conn pg.Tx,
|
||||||
scope Scoper,
|
scope Scoper,
|
||||||
) error {
|
) error {
|
||||||
var encryptedKey []byte
|
|
||||||
if len(cd.EncryptedSSLPrivateKey) > 0 {
|
|
||||||
encryptedKey = cd.EncryptedSSLPrivateKey
|
|
||||||
}
|
|
||||||
|
|
||||||
q := `
|
q := `
|
||||||
UPDATE
|
UPDATE
|
||||||
custom_domains
|
custom_domains
|
||||||
SET
|
SET
|
||||||
http_challenge_token = @http_challenge_token,
|
domain = @domain,
|
||||||
http_challenge_key_auth = @http_challenge_key_auth,
|
managed = @managed,
|
||||||
http_challenge_url = @http_challenge_url,
|
certificate_id = @certificate_id,
|
||||||
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,
|
|
||||||
updated_at = @updated_at
|
updated_at = @updated_at
|
||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
@@ -538,20 +337,11 @@ WHERE
|
|||||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
args := pgx.NamedArgs{
|
args := pgx.NamedArgs{
|
||||||
"id": cd.ID,
|
"id": cd.ID,
|
||||||
"http_challenge_token": cd.HTTPChallengeToken,
|
"domain": cd.Domain,
|
||||||
"http_challenge_key_auth": cd.HTTPChallengeKeyAuth,
|
"managed": cd.Managed,
|
||||||
"http_challenge_url": cd.HTTPChallengeURL,
|
"certificate_id": cd.CertificateID,
|
||||||
"http_order_url": cd.HTTPOrderURL,
|
"updated_at": time.Now(),
|
||||||
"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(),
|
|
||||||
}
|
}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
@@ -560,8 +350,6 @@ WHERE
|
|||||||
return fmt.Errorf("cannot update custom domain: %w", err)
|
return fmt.Errorf("cannot update custom domain: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cd.EncryptedSSLPrivateKey = encryptedKey
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,281 +377,3 @@ WHERE
|
|||||||
|
|
||||||
return nil
|
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
|
ElectronicSignatureEventEntityType uint16 = 60
|
||||||
EmailAttachmentEntityType uint16 = 61
|
EmailAttachmentEntityType uint16 = 61
|
||||||
ComplianceFrameworkEntityType uint16 = 62
|
ComplianceFrameworkEntityType uint16 = 62
|
||||||
ComplianceExternalURLEntityType uint16 = 63
|
ComplianceCustomLinkEntityType uint16 = 63
|
||||||
MailingListEntityType uint16 = 64
|
MailingListEntityType uint16 = 64
|
||||||
MailingListSubscriberEntityType uint16 = 65
|
MailingListSubscriberEntityType uint16 = 65
|
||||||
MailingListUpdateEntityType uint16 = 66
|
MailingListUpdateEntityType uint16 = 66
|
||||||
@@ -135,6 +135,7 @@ const (
|
|||||||
AccessReviewCampaignSourceFetchAttemptEntityType uint16 = 103
|
AccessReviewCampaignSourceFetchAttemptEntityType uint16 = 103
|
||||||
CompliancePortalCommitmentGroupEntityType uint16 = 104
|
CompliancePortalCommitmentGroupEntityType uint16 = 104
|
||||||
CompliancePortalCommitmentEntityType uint16 = 105
|
CompliancePortalCommitmentEntityType uint16 = 105
|
||||||
|
CertificateEntityType uint16 = 106
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewEntityFromID(id gid.GID) (any, bool) {
|
func NewEntityFromID(id gid.GID) (any, bool) {
|
||||||
@@ -253,8 +254,8 @@ func NewEntityFromID(id gid.GID) (any, bool) {
|
|||||||
return &EmailAttachment{ID: id}, true
|
return &EmailAttachment{ID: id}, true
|
||||||
case ComplianceFrameworkEntityType:
|
case ComplianceFrameworkEntityType:
|
||||||
return &ComplianceFramework{ID: id}, true
|
return &ComplianceFramework{ID: id}, true
|
||||||
case ComplianceExternalURLEntityType:
|
case ComplianceCustomLinkEntityType:
|
||||||
return &ComplianceExternalURL{ID: id}, true
|
return &ComplianceCustomLink{ID: id}, true
|
||||||
case MailingListEntityType:
|
case MailingListEntityType:
|
||||||
return &MailingList{ID: id}, true
|
return &MailingList{ID: id}, true
|
||||||
case MailingListSubscriberEntityType:
|
case MailingListSubscriberEntityType:
|
||||||
@@ -333,6 +334,8 @@ func NewEntityFromID(id gid.GID) (any, bool) {
|
|||||||
return &CompliancePortalCommitmentGroup{ID: id}, true
|
return &CompliancePortalCommitmentGroup{ID: id}, true
|
||||||
case CompliancePortalCommitmentEntityType:
|
case CompliancePortalCommitmentEntityType:
|
||||||
return &CompliancePortalCommitment{ID: id}, true
|
return &CompliancePortalCommitment{ID: id}, true
|
||||||
|
case CertificateEntityType:
|
||||||
|
return &Certificate{ID: id}, true
|
||||||
default:
|
default:
|
||||||
return nil, false
|
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