// Package tlscerts implements CRUD against the tls_certs table — // the operator-visible inventory of certificates EdgeGuard manages, // covering both Let's-Encrypt-issued and operator-uploaded PEMs. package tlscerts import ( "context" "errors" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "git.netcell-it.de/projekte/edgeguard-native/internal/models" ) var ErrNotFound = errors.New("tls cert not found") type Repo struct { Pool *pgxpool.Pool } func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } const baseSelect = ` SELECT id, domain, issuer, status, cert_path, key_path, not_before, not_after, last_renewed_at, last_error, created_at, updated_at FROM tls_certs ` func (r *Repo) List(ctx context.Context) ([]models.TLSCert, error) { rows, err := r.Pool.Query(ctx, baseSelect+" ORDER BY domain ASC") if err != nil { return nil, err } defer rows.Close() out := make([]models.TLSCert, 0, 4) for rows.Next() { c, err := scan(rows) if err != nil { return nil, err } out = append(out, *c) } return out, rows.Err() } func (r *Repo) Get(ctx context.Context, id int64) (*models.TLSCert, error) { row := r.Pool.QueryRow(ctx, baseSelect+" WHERE id = $1", id) c, err := scan(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return nil, err } return c, nil } func (r *Repo) GetByDomain(ctx context.Context, domain string) (*models.TLSCert, error) { row := r.Pool.QueryRow(ctx, baseSelect+" WHERE domain = $1", domain) c, err := scan(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return nil, err } return c, nil } // Upsert persists a cert by (domain). issued/renewed by certbot or // uploaded → same code path. Sets last_renewed_at = NOW() so the // renewal cron knows when to come back. func (r *Repo) Upsert(ctx context.Context, c models.TLSCert) (*models.TLSCert, error) { now := time.Now().UTC() row := r.Pool.QueryRow(ctx, ` INSERT INTO tls_certs (domain, issuer, status, cert_path, key_path, not_before, not_after, last_renewed_at, last_error) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (domain) DO UPDATE SET issuer = EXCLUDED.issuer, status = EXCLUDED.status, cert_path = EXCLUDED.cert_path, key_path = EXCLUDED.key_path, not_before = EXCLUDED.not_before, not_after = EXCLUDED.not_after, last_renewed_at = EXCLUDED.last_renewed_at, last_error = EXCLUDED.last_error, updated_at = NOW() RETURNING id, domain, issuer, status, cert_path, key_path, not_before, not_after, last_renewed_at, last_error, created_at, updated_at`, c.Domain, c.Issuer, c.Status, c.CertPath, c.KeyPath, c.NotBefore, c.NotAfter, &now, c.LastError) return scan(row) } func (r *Repo) MarkError(ctx context.Context, domain, msg string) error { _, err := r.Pool.Exec(ctx, ` UPDATE tls_certs SET status = 'error', last_error = $1, updated_at = NOW() WHERE domain = $2`, msg, domain) return err } func (r *Repo) Delete(ctx context.Context, id int64) error { tag, err := r.Pool.Exec(ctx, `DELETE FROM tls_certs WHERE id = $1`, id) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // ListExpiringSoon returns certs whose not_after is within the next // `within` (typically 30 days) and aren't already in error state — // the renewal scheduler picks these up. func (r *Repo) ListExpiringSoon(ctx context.Context, within time.Duration) ([]models.TLSCert, error) { cutoff := time.Now().UTC().Add(within) rows, err := r.Pool.Query(ctx, baseSelect+ " WHERE not_after IS NOT NULL AND not_after <= $1 AND status <> 'error'", cutoff) if err != nil { return nil, err } defer rows.Close() out := make([]models.TLSCert, 0, 4) for rows.Next() { c, err := scan(rows) if err != nil { return nil, err } out = append(out, *c) } return out, rows.Err() } func scan(row interface{ Scan(...any) error }) (*models.TLSCert, error) { var c models.TLSCert if err := row.Scan( &c.ID, &c.Domain, &c.Issuer, &c.Status, &c.CertPath, &c.KeyPath, &c.NotBefore, &c.NotAfter, &c.LastRenewedAt, &c.LastError, &c.CreatedAt, &c.UpdatedAt, ); err != nil { return nil, err } return &c, nil }