Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,18 @@ jobs:
run: deno task build
working-directory: frontend

- name: Install Rust
uses: dsherret/rust-toolchain-file@v1

- name: Add wasm target
run: rustup target add wasm32-unknown-unknown

- name: Build API Cloudflare Worker
run: |
cargo install worker-build --locked
worker-build --release
working-directory: workers-rs

- name: terraform plan
run: |
touch terraform/staging.secret.tfvars
Expand Down Expand Up @@ -335,6 +347,18 @@ jobs:
run: deno task build
working-directory: frontend

- name: Install Rust
uses: dsherret/rust-toolchain-file@v1

- name: Add wasm target
run: rustup target add wasm32-unknown-unknown

- name: Build API Cloudflare Worker
run: |
cargo install worker-build --locked
worker-build --release
working-directory: workers-rs

- name: terraform plan
run: |
touch terraform/prod.secret.tfvars
Expand Down
15 changes: 15 additions & 0 deletions api/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,16 @@ pub struct Config {
#[clap(long = "database_pool_size", default_value = "3")]
/// The size of the database connection pool.
pub database_pool_size: u32,

#[clap(long = "db_client_cert", env = "DB_CLIENT_CERT")]
/// PEM client certificate presented when connecting to the database over
/// TLS. Required once the DB enforces `TRUSTED_CLIENT_CERTIFICATE_REQUIRED`;
/// all three of cert/key/root must be set together to take effect.
pub db_client_cert: Option<String>,

#[clap(long = "db_client_key", env = "DB_CLIENT_KEY")]
/// PEM private key matching `db_client_cert`.
pub db_client_key: Option<String>,
}

impl std::fmt::Debug for Config {
Expand Down Expand Up @@ -247,6 +257,11 @@ impl std::fmt::Debug for Config {
)
.field("email_from", &self.email_from)
.field("email_from_name", &self.email_from_name)
.field(
"db_client_cert",
&self.db_client_cert.as_ref().map(|_| "***"),
)
.field("db_client_key", &self.db_client_key.as_ref().map(|_| "***"))
.finish()
}
}
31 changes: 30 additions & 1 deletion api/src/db/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ use sqlx::FromRow;
use sqlx::Result;
use sqlx::Row;
use sqlx::migrate;
use sqlx::postgres::PgConnectOptions;
use sqlx::postgres::PgPoolOptions;
use sqlx::postgres::PgSslMode;
use std::str::FromStr;
use tracing::instrument;
use uuid::Uuid;

Expand Down Expand Up @@ -54,16 +57,42 @@ pub struct Database {
pool: sqlx::PgPool,
}

/// Client-certificate TLS material for connecting to the database. Supplied
/// when the database requires a client certificate (`ssl_mode =
/// TRUSTED_CLIENT_CERTIFICATE_REQUIRED`); the same cert is also presented by
/// the Hyperdrive-backed `api` Worker so both reach Cloud SQL over mTLS.
pub struct DbTls {
pub client_cert: String,
pub client_key: String,
}

impl Database {
pub async fn connect(
database_url: &str,
pool_size: u32,
acquire_timeout: std::time::Duration,
tls: Option<DbTls>,
) -> anyhow::Result<Self> {
let mut opts = PgConnectOptions::from_str(database_url)?;
if let Some(tls) = tls {
// Present our client cert (the DB requires one) and encrypt, but don't
// verify the server cert. We use `Require`, not `VerifyCa`: Cloud Run
// connects to Cloud SQL by private IP, yet the server cert is only valid
// for the instance's `*.sql.goog` DNS name. `VerifyCa` is meant to skip
// that hostname check, but sqlx 0.8's `NoHostnameTlsVerifier` only
// swallows rustls's legacy `NotValidForName` error, not 0.23's
// `NotValidForNameContext`, so verification fails and the connection is
// refused. The client certificate (mTLS) is the access boundary and the
// link stays inside the VPC.
opts = opts
.ssl_mode(PgSslMode::Require)
.ssl_client_cert_from_pem(tls.client_cert.into_bytes())
.ssl_client_key_from_pem(tls.client_key.into_bytes());
}
let pool = PgPoolOptions::new()
.max_connections(pool_size)
.acquire_timeout(acquire_timeout)
.connect(database_url)
.connect_with(opts)
.await?;
if std::env::var("DATABASE_DISABLE_MIGRATIONS").is_err() {
migrate!("./migrations")
Expand Down
7 changes: 4 additions & 3 deletions api/src/db/ephemeral_database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,10 @@ impl EphemeralDatabase {

pg_execute(format!("CREATE DATABASE \"{database_name}\""));

let database = Database::connect(&database_url, 1, Duration::from_secs(5))
.await
.unwrap();
let database =
Database::connect(&database_url, 1, Duration::from_secs(5), None)
.await
.unwrap();

Self {
database: Some(database),
Expand Down
9 changes: 9 additions & 0 deletions api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,19 @@ async fn main() {
};
setup_tracing("api", export_target, config.deployment_environment).await;

let db_tls = match (config.db_client_cert, config.db_client_key) {
(Some(client_cert), Some(client_key)) => Some(crate::db::DbTls {
client_cert,
client_key,
}),
_ => None,
};

let database = Database::connect(
&config.database_url,
config.database_pool_size,
Duration::from_secs(15),
db_tls,
)
.await
.unwrap();
Expand Down
21 changes: 20 additions & 1 deletion lb/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,28 @@ const frontendShim: Fetcher = {
// deno-lint-ignore no-explicit-any
} as any;

// Local-dev shim for the API service binding: forwards to the API server's
// HTTP URL on localhost. In prod this binding points at the `api` Worker; for
// local dev we forward straight to the compute server so the harness keeps
// working without running the Worker separately.
const apiShim: Fetcher = {
fetch(req: Request | string | URL, init?: RequestInit) {
if (typeof req === "string" || req instanceof URL) {
return fetch(
new URL(new URL(req).pathname + new URL(req).search, REGISTRY_API_URL),
init,
);
}
const url = new URL(req.url);
const target = new URL(url.pathname + url.search, REGISTRY_API_URL);
return fetch(new Request(target, req));
},
// deno-lint-ignore no-explicit-any
} as any;

function handler(req: Request): Promise<Response> {
return main.fetch(req, {
REGISTRY_API_URL,
API: apiShim,
FRONTEND: frontendShim,
MODULES_BUCKET: new R2BucketShim(MODULES_BUCKET),
NPM_BUCKET: new R2BucketShim(NPM_BUCKET),
Expand Down
2 changes: 1 addition & 1 deletion lb/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export async function handleAPIRequest(

const response = await proxyToBackend(
request,
env.REGISTRY_API_URL,
env.API,
rewritePath ? (path) => `/api${path}` : undefined,
);

Expand Down
6 changes: 5 additions & 1 deletion lb/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ declare global {
export type PartialBucket = Pick<R2Bucket, "get" | "head">;

export interface WorkerEnv {
REGISTRY_API_URL: string;
// The API server is a sibling Cloudflare Worker (workers-rs), bound via a
// service binding rather than an HTTP URL so traffic stays inside Cloudflare.
// It serves the lightweight CRUD/DB/auth surface and itself proxies the
// compute-only paths to the Cloud Run compute service.
API: Fetcher;

// The frontend is a sibling Cloudflare Worker, wired up via a service
// binding rather than an HTTP URL so traffic stays inside Cloudflare.
Expand Down
9 changes: 9 additions & 0 deletions terraform/cloud_run_api.tf
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ locals {
"CLOUDFLARE_ACCOUNT_ID" = var.cloudflare_account_id
"CLOUDFLARE_ZONE_ID" = var.cloudflare_zone_id
"CLOUDFLARE_ANALYTICS_DATASET" = local.worker_download_analytics_dataset

# Client certificate for the DB connection. The DB requires a client cert
# (ssl_mode = TRUSTED_CLIENT_CERTIFICATE_REQUIRED, see db.tf), so both Cloud
# Run services present it over the private VPC IP; the same cert is handed to
# the Hyperdrive config fronting the `api` Worker. The connection uses
# sslmode=require (encrypt + client auth, no server verification — we connect
# by IP), so no server CA is needed here. Plain env, like DATABASE_URL.
"DB_CLIENT_CERT" = google_sql_ssl_cert.api.cert
"DB_CLIENT_KEY" = google_sql_ssl_cert.api.private_key
})
}

Expand Down
127 changes: 127 additions & 0 deletions terraform/cloudflare_api.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright 2024 the JSR authors. All rights reserved. MIT license.

// The `api` Cloudflare Worker (workers-rs, wasm32) that fronts `api.jsr.io`.
// It serves the lightweight CRUD/DB/auth surface directly — reaching the
// existing Postgres through Cloudflare Hyperdrive (no sqlx) — and proxies the
// compute-only paths (publish, docs, source, diff, graph, /tasks/*) to the
// Cloud Run compute service. The `lb` Worker service-binds this Worker for the
// `api.jsr.io` backend (see lb.tf), exactly as it already does the frontend.
//
// Deployed as the same worker/version/deployment triple as the frontend (see
// cloudflare_frontend.tf): an immutable version holding the built wasm bundle,
// and a deployment pinning 100% of traffic to it.

# Hyperdrive carries the client certificate the Worker presents to Cloud SQL,
# uploaded as an account mTLS certificate. Same cert/key as google_sql_ssl_cert
# .api, which both Cloud Run services also present (see db.tf, cloud_run_api.tf).
resource "cloudflare_mtls_certificate" "api_db_client" {
account_id = var.cloudflare_account_id
name = "${var.gcp_project}-jsr-api-db-client"
certificates = google_sql_ssl_cert.api.cert
private_key = google_sql_ssl_cert.api.private_key
ca = false
}

# Cloud SQL's server CA, uploaded so Hyperdrive can verify the origin (verify-ca:
# we connect by IP, so the hostname isn't checked, but the CA is).
resource "cloudflare_mtls_certificate" "api_db_ca" {
account_id = var.cloudflare_account_id
name = "${var.gcp_project}-jsr-api-db-ca"
certificates = google_sql_ssl_cert.api.server_ca_cert
ca = true
}

# Hyperdrive connection to the existing Postgres over the public IP, with the
# client certificate (mTLS) as the access boundary — the DB requires it
# (ssl_mode = TRUSTED_CLIENT_CERTIFICATE_REQUIRED, see db.tf).
resource "cloudflare_hyperdrive_config" "api" {
account_id = var.cloudflare_account_id
name = "${var.gcp_project}-jsr-api"

origin = {
scheme = "postgres"
database = google_sql_database.database.name
host = google_sql_database_instance.main_pg15.public_ip_address
port = 5432
user = google_sql_user.api.name
password = google_sql_user.api.password
}

mtls = {
sslmode = "verify-ca"
ca_certificate_id = cloudflare_mtls_certificate.api_db_ca.id
mtls_certificate_id = cloudflare_mtls_certificate.api_db_client.id
}
}

resource "cloudflare_worker" "jsr_api" {
account_id = var.cloudflare_account_id
name = "${var.gcp_project}-jsr-api"

observability = {
enabled = true
logs = {
enabled = true
invocation_logs = false
head_sampling_rate = 0.01
persist = false
destinations = [var.cloudflare_otlp_logs_destination]
}
traces = {
enabled = true
head_sampling_rate = 0.01
persist = false
destinations = [var.cloudflare_otlp_traces_destination]
}
}
}

resource "cloudflare_worker_version" "jsr_api" {
account_id = var.cloudflare_account_id
worker_id = cloudflare_worker.jsr_api.id
main_module = "index.js"
compatibility_date = "2026-05-19"
compatibility_flags = ["nodejs_compat"]

# `worker-build --release` (run in CI before terraform) emits the bundle into
# workers-rs/build: the esbuild entrypoint `index.js` and the wasm it imports
# as `./index_bg.wasm` (build/worker/shim.mjs is only a back-compat re-export
# of ../index.js, used by `wrangler dev`).
modules = [
{
name = "index.js"
content_file = "${path.module}/../workers-rs/build/index.js"
content_type = "application/javascript+module"
},
{
name = "index_bg.wasm"
content_file = "${path.module}/../workers-rs/build/index_bg.wasm"
content_type = "application/wasm"
},
]

bindings = [
{
type = "hyperdrive"
name = "HYPERDRIVE"
id = cloudflare_hyperdrive_config.api.id
}, {
# The Cloud Run compute service the Worker proxies compute-only paths to.
# Public Cloud Run URL (the same value lb used for REGISTRY_API_URL before
# the cutover); reached over `fetch` (see workers-rs proxy_to_compute).
type = "plain_text"
name = "COMPUTE_API_URL"
text = google_cloud_run_v2_service.registry_api.uri
}
]
}

resource "cloudflare_workers_deployment" "jsr_api" {
account_id = var.cloudflare_account_id
script_name = cloudflare_worker.jsr_api.name
strategy = "percentage"
versions = [{
percentage = 100
version_id = cloudflare_worker_version.jsr_api.id
}]
}
25 changes: 24 additions & 1 deletion terraform/db.tf
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,20 @@ resource "google_sql_database_instance" "main_pg15" {
ip_configuration {
ipv4_enabled = true
private_network = google_compute_network.main.self_link
ssl_mode = "ENCRYPTED_ONLY"

# Cloudflare Hyperdrive (fronting the `api` Worker) reaches Cloud SQL over
# the public IP — Hyperdrive's egress isn't a pinnable range, so it can't
# be an allowlist entry. The public IP is left open and a required client
# certificate (mTLS) is the access boundary instead of a network ACL: the
# client key is secret, so only cert holders connect. Cloud Run still
# reaches the DB over the private VPC and presents the same cert (see
# google_sql_ssl_cert.api + cloud_run_api.tf).
ssl_mode = "TRUSTED_CLIENT_CERTIFICATE_REQUIRED"

authorized_networks {
name = "hyperdrive-public-egress"
value = "0.0.0.0/0"
}
}

backup_configuration {
Expand All @@ -49,6 +62,16 @@ resource "google_sql_database" "database" {
instance = google_sql_database_instance.main_pg15.name
}

# Client certificate the API presents when connecting to Cloud SQL over TLS.
# Delivered to both Cloud Run services as env (see cloud_run_api.tf) and, later,
# to the Hyperdrive config that fronts the `api` Worker. It is presented now
# (harmless under the current ssl_mode) so it is already in place before the DB
# is flipped to TRUSTED_CLIENT_CERTIFICATE_REQUIRED in a follow-up.
resource "google_sql_ssl_cert" "api" {
common_name = "api-client"
instance = google_sql_database_instance.main_pg15.name
}

resource "google_sql_user" "api" {
name = "api"
instance = google_sql_database_instance.main_pg15.name
Expand Down
Loading
Loading