Skip to content

Auto-issue wildcard TLS for port shares (ACME phase 1) - #26

Open
abhishek-anand wants to merge 25 commits into
mainfrom
feat/acme-wildcard-tls
Open

Auto-issue wildcard TLS for port shares (ACME phase 1)#26
abhishek-anand wants to merge 25 commits into
mainfrom
feat/acme-wildcard-tls

Conversation

@abhishek-anand

Copy link
Copy Markdown
Contributor

Auto-issue and renew a wildcard TLS certificate (*.<share-domain>) so port
shares serve over full HTTPS at https://<slug>.<domain>.

What this adds

  • ACME DNS-01 order state machine for wildcard issuance (Cloudflare and Route53
    providers, additive RRset writes, injectable API base for tests).
  • Envelope encryption (KEK) for ACME account and cert private keys at rest.
  • Fleet tables for certificates, ACME accounts, and fenced renewal jobs, with a
    reconcile and renewal worker using a fenced lease for single-writer safety.
  • In-process TLS termination with a dynamic SNI cert resolver; shares terminate
    over HTTP/1.1 and enforce Host == SNI.
  • Config and docs for the new TARIT_ACME_* and share TLS settings.

Design notes

  • Share TLS offers http/1.1 ALPN only. The share pipeline is HTTP/1.1 (Host
    routing, origin-form URIs, WebSocket upgrades), so h2 would break it.
  • Requires TARIT_SHARE_TOKEN_KEY whenever any share listener (plaintext or TLS)
    is set, so HTTPS-only deployments cannot start with unusable private shares.
  • KEK is exactly 64 hex characters.

Validation

  • orch/tests/e2e_acme.sh: hermetic Pebble DNS-01 gate. Layer A covers issuance,
    fleet distribution, unknown-SNI rejection, and fenced-lease failover. Layer B
    optionally provisions a real KVM share. Verified ACME_PASS on a c8i host.
  • cargo fmt --check, cargo clippy -D warnings, and the taritd/fleet test
    suites pass.

Real-VM two-node validation on bare metal remains a separate migration gate.

Copilot AI review requested due to automatic review settings July 14, 2026 21:52
@cursor

cursor Bot commented Jul 14, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds in-process wildcard TLS termination via the ACME DNS-01 challenge, introducing database schemas for certificates, accounts, and jobs, as well as an ACME worker and TLS server configuration. The review feedback identifies a potential infinite failure loop during ACME order retries due to uncleared order URLs, unnecessary string allocations in the hot path of CertStore::select, and a failure to reset the job state to 'pending' when reclaiming ACME jobs in the database.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +314 to +324
async fn record_failure(&self, job: &AcmeJob, error: ManagerError) {
let mut updated = job.clone();
updated.state = AcmeJobState::Failed;
updated.attempt = updated.attempt.saturating_add(1);
let attempt = u32::try_from(job.attempt).unwrap_or(u32::MAX);
let retry_seconds = i64::try_from(backoff_after(attempt).as_secs()).unwrap_or(3_600);
updated.next_attempt_at = Some(Utc::now() + chrono::Duration::seconds(retry_seconds));
updated.last_error = Some(error.to_string());
updated.updated_at = Utc::now();

match self.fleet.save_acme_job(&updated, job.fence).await {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Infinite Failure Loop on Unresumable ACME Orders

When an ACME order fails (e.g., due to a DNS propagation timeout, or entering an Invalid/Processing/Valid state where the private key is lost), run_dns01_order will reject resuming it on subsequent attempts with an UnexpectedOrderState error.

Because record_failure clones the job and persists the same order_url back to the database, the state machine gets stuck in an infinite loop of immediate failures trying to resume the unresumable order.

Fix: Clear the order_url on failure so that the next retry starts a fresh ACME order.

Suggested change
async fn record_failure(&self, job: &AcmeJob, error: ManagerError) {
let mut updated = job.clone();
updated.state = AcmeJobState::Failed;
updated.attempt = updated.attempt.saturating_add(1);
let attempt = u32::try_from(job.attempt).unwrap_or(u32::MAX);
let retry_seconds = i64::try_from(backoff_after(attempt).as_secs()).unwrap_or(3_600);
updated.next_attempt_at = Some(Utc::now() + chrono::Duration::seconds(retry_seconds));
updated.last_error = Some(error.to_string());
updated.updated_at = Utc::now();
match self.fleet.save_acme_job(&updated, job.fence).await {
async fn record_failure(&self, job: &AcmeJob, error: ManagerError) {
let mut updated = job.clone();
updated.state = AcmeJobState::Failed;
updated.attempt = updated.attempt.saturating_add(1);
updated.order_url = None;
let attempt = u32::try_from(job.attempt).unwrap_or(u32::MAX);
let retry_seconds = i64::try_from(backoff_after(attempt).as_secs()).unwrap_or(3_600);
updated.next_attempt_at = Some(Utc::now() + chrono::Duration::seconds(retry_seconds));
updated.last_error = Some(error.to_string());
updated.updated_at = Utc::now();
match self.fleet.save_acme_job(&updated, job.fence).await {

Comment on lines +35 to +46
pub fn select(&self, sni: Option<&str>) -> Option<Arc<CertifiedKey>> {
let sni = normalize_sni(sni?)?;

if let Some(key) = self.exact.get(&sni) {
return Some(Arc::clone(key));
}

let (base_domain, key) = self.wildcard.as_ref()?;
let label = sni.strip_suffix(&format!(".{base_domain}"))?;

(!label.is_empty() && !label.contains('.')).then(|| Arc::clone(key))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Avoid String Allocation in Hot Path

CertStore::select is called on every incoming TLS connection during the ClientHello phase. Using format!(\".{base_domain}\") allocates a new String on every single handshake, which can severely degrade performance under high load.

Fix: Use string slicing and basic suffix checks to avoid any dynamic allocation.

Suggested change
pub fn select(&self, sni: Option<&str>) -> Option<Arc<CertifiedKey>> {
let sni = normalize_sni(sni?)?;
if let Some(key) = self.exact.get(&sni) {
return Some(Arc::clone(key));
}
let (base_domain, key) = self.wildcard.as_ref()?;
let label = sni.strip_suffix(&format!(".{base_domain}"))?;
(!label.is_empty() && !label.contains('.')).then(|| Arc::clone(key))
}
pub fn select(&self, sni: Option<&str>) -> Option<Arc<CertifiedKey>> {
let sni = normalize_sni(sni?)?;
if let Some(key) = self.exact.get(&sni) {
return Some(Arc::clone(key));
}
let (base_domain, key) = self.wildcard.as_ref()?;
if sni.len() > base_domain.len() + 1
&& sni.ends_with(base_domain)
&& sni.as_bytes()[sni.len() - base_domain.len() - 1] == b'.'
{
let label = &sni[..sni.len() - base_domain.len() - 1];
if !label.is_empty() && !label.contains('.') {
return Some(Arc::clone(key));
}
}
None
}

Comment on lines +593 to +603
let row = client
.query_opt(
"INSERT INTO fleet_acme_jobs (
id, identifier, state, fence, lease_holder, lease_expires_at,
attempt, next_attempt_at, updated_at
) VALUES ($1,$2,'pending',1,$3,now() + $4::text::interval,0,NULL,now())
ON CONFLICT (identifier) DO UPDATE SET
fence = fleet_acme_jobs.fence + 1,
lease_holder = EXCLUDED.lease_holder,
lease_expires_at = now() + $4::text::interval,
updated_at = now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Reset Job State to Pending on Reclaim

When a previously failed or active ACME job is reclaimed by a node, the DO UPDATE SET clause does not update the state column. As a result, the job will continue to show as Failed or Active in the database while the node is actively working on it.

Fix: Set state = EXCLUDED.state (which is 'pending') in the DO UPDATE clause so that the database accurately reflects the job's active status.

        let row = client
            .query_opt(
                "INSERT INTO fleet_acme_jobs (
                   id, identifier, state, fence, lease_holder, lease_expires_at,
                   attempt, next_attempt_at, updated_at
                 ) VALUES ($1,$2,'pending',1,$3,now() + $4::text::interval,0,NULL,now())
                 ON CONFLICT (identifier) DO UPDATE SET
                   fence = fleet_acme_jobs.fence + 1,
                   state = EXCLUDED.state,
                   lease_holder = EXCLUDED.lease_holder,
                   lease_expires_at = now() + $4::text::interval,
                   updated_at = now()"

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces phase-1 support for in-process wildcard TLS for port shares by adding an ACME DNS-01 issuance/renewal worker, storing encrypted key material in the fleet database, and serving HTTPS shares via a dynamic SNI cert resolver.

Changes:

  • Add ACME DNS-01 issuance + renewal worker with Cloudflare/Route53 DNS providers and envelope-encrypted key storage.
  • Add an in-process TLS listener and share-gateway logic to enforce TLS routing expectations.
  • Add fleet schema + APIs for certificates/ACME accounts/jobs, plus a new hermetic e2e gate.

Reviewed changes

Copilot reviewed 22 out of 23 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
orch/tests/e2e_acme.sh New hermetic Pebble DNS-01 + wildcard TLS end-to-end gate.
orch/docs/CONFIGURATION.md Documents new ACME + TLS configuration and operational requirements.
orch/docs/API.md Notes in-process HTTPS share support and links to configuration.
orch/crates/taritd/src/tls.rs New TLS server accept loop and per-connection TLS metadata injection.
orch/crates/taritd/src/supervisor.rs Updates test config scaffolding for new ACME/TLS config fields.
orch/crates/taritd/src/share_gateway.rs Enforces TLS scheme/SNI behavior and adds MISDIRECTED response.
orch/crates/taritd/src/ops.rs Updates test config scaffolding for new ACME/TLS config fields.
orch/crates/taritd/src/metrics.rs Updates test config scaffolding for new ACME/TLS config fields.
orch/crates/taritd/src/main.rs Wires ACME worker + TLS listener into taritd startup.
orch/crates/taritd/src/gateway.rs Updates test config scaffolding for new ACME/TLS config fields.
orch/crates/taritd/src/config.rs Adds ACME/TLS env parsing + validation and share-token-key requirement.
orch/crates/taritd/src/api.rs Updates test config scaffolding for new ACME/TLS config fields.
orch/crates/taritd/src/acme/mod.rs New ACME module entry points.
orch/crates/taritd/src/acme/resolver.rs Dynamic cert store + rustls SNI resolver.
orch/crates/taritd/src/acme/order.rs DNS-01 order driver and cert parsing utilities.
orch/crates/taritd/src/acme/manager.rs Fleet-backed reconcile/renew loop with fenced lease coordination.
orch/crates/taritd/src/acme/dns.rs Cloudflare/Route53 TXT upsert/delete + propagation checks.
orch/crates/taritd/src/acme/crypto.rs Envelope encryption helper for sealing key material.
orch/crates/taritd/Cargo.toml Adds ACME/TLS/DNS/encryption dependencies.
orch/crates/tarit-fleet/src/lib.rs Adds schema + APIs for certificates, ACME accounts, and job leasing.
orch/crates/tarit-fleet/Cargo.toml Adds tokio dependency for new listener wiring.
orch/Cargo.toml Adds workspace dependencies for ACME/TLS/DNS/encryption stack.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +675 to +679
let tls = request.extensions().get::<crate::tls::TlsInfo>();
let host = format!("{slug}.{domain}");
if !host_matches_sni(&host, tls.and_then(|info| info.sni.as_deref())) {
return Err(GatewayError::Misdirected);
}
Comment on lines +450 to +456
pub async fn publish_certificate(
&self,
certificate: &CertRecord,
identifier: &str,
fence: i64,
) -> Result<bool, FleetError> {
let sans = serialize_json(&certificate.sans, "sans")?;
Comment on lines +1120 to +1124
if (plaintext_listen.is_some() || tls_listen.is_some()) && token_key.is_none() {
bail!(
"TARIT_SHARE_TOKEN_KEY must decode to exactly 32 bytes when a share listener is enabled"
);
}
Comment thread orch/docs/CONFIGURATION.md Outdated
Comment on lines +176 to +177
the fleet and refresh when notified. Renewal is automatic, ARI-aware when the CA
advertises it, and otherwise begins at one third of the certificate lifetime.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants