Compare commits

...

3 Commits

Author SHA1 Message Date
Matt Hill
9eb26db050 ensure correct locale on 035 update 2026-03-26 21:13:10 -06:00
Matt Hill
6cefc27c5f build: use org-hosted large runners for fast CI builds
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 19:24:19 -06:00
Matt Hill
2b676808a9 feat: generate certificates signed by the root CA (#3144)
Co-authored-by: Aiden McClelland <me@drbonez.dev>
2026-03-26 18:57:11 -06:00
8 changed files with 241 additions and 13 deletions

View File

@@ -89,9 +89,9 @@ jobs:
"riscv64": "ubuntu-latest"
}')[matrix.arch],
fromJson('{
"x86_64": "ubuntu-24.04-32-cores",
"aarch64": "ubuntu-24.04-arm-32-cores",
"riscv64": "ubuntu-24.04-32-cores"
"x86_64": "amd64-fast",
"aarch64": "aarch64-fast",
"riscv64": "amd64-fast"
}')[matrix.arch]
)
)[github.event.inputs.runner == 'fast']
@@ -153,15 +153,15 @@ jobs:
"riscv64-nonfree": "ubuntu-24.04-arm",
}')[matrix.platform],
fromJson('{
"x86_64": "ubuntu-24.04-8-cores",
"x86_64-nonfree": "ubuntu-24.04-8-cores",
"x86_64-nvidia": "ubuntu-24.04-8-cores",
"aarch64": "ubuntu-24.04-arm-8-cores",
"aarch64-nonfree": "ubuntu-24.04-arm-8-cores",
"aarch64-nvidia": "ubuntu-24.04-arm-8-cores",
"raspberrypi": "ubuntu-24.04-arm-8-cores",
"riscv64": "ubuntu-24.04-8-cores",
"riscv64-nonfree": "ubuntu-24.04-8-cores",
"x86_64": "amd64-fast",
"x86_64-nonfree": "amd64-fast",
"x86_64-nvidia": "amd64-fast",
"aarch64": "aarch64-fast",
"aarch64-nonfree": "aarch64-fast",
"aarch64-nvidia": "aarch64-fast",
"raspberrypi": "aarch64-fast",
"riscv64": "amd64-fast",
"riscv64-nonfree": "amd64-fast",
}')[matrix.platform]
)
)[github.event.inputs.runner == 'fast']

View File

@@ -2818,6 +2818,13 @@ help.arg.echoip-urls:
fr_FR: "URLs du service Echo IP pour la détection d'IP externe"
pl_PL: "Adresy URL usługi Echo IP do wykrywania zewnętrznego IP"
help.arg.ed25519:
en_US: "Use Ed25519 instead of NIST P-256"
de_DE: "Ed25519 anstelle von NIST P-256 verwenden"
es_ES: "Usar Ed25519 en lugar de NIST P-256"
fr_FR: "Utiliser Ed25519 au lieu de NIST P-256"
pl_PL: "Użyj Ed25519 zamiast NIST P-256"
help.arg.emulate-missing-arch:
en_US: "Emulate missing architecture using this one"
de_DE: "Fehlende Architektur mit dieser emulieren"
@@ -2902,6 +2909,13 @@ help.arg.host-url:
fr_FR: "URL du serveur StartOS"
pl_PL: "URL serwera StartOS"
help.arg.hostnames:
en_US: "Hostnames to include in the certificate"
de_DE: "Hostnamen, die in das Zertifikat aufgenommen werden sollen"
es_ES: "Nombres de host para incluir en el certificado"
fr_FR: "Noms d'hôtes à inclure dans le certificat"
pl_PL: "Nazwy hostów do uwzględnienia w certyfikacie"
help.arg.icon-path:
en_US: "Path to service icon file"
de_DE: "Pfad zur Service-Icon-Datei"
@@ -5150,6 +5164,13 @@ about.manage-query-dns:
fr_FR: "Gérer et interroger le DNS"
pl_PL: "Zarządzaj i odpytuj DNS"
about.manage-ssl-certificates:
en_US: "Manage SSL certificates"
de_DE: "SSL-Zertifikate verwalten"
es_ES: "Gestionar certificados SSL"
fr_FR: "Gérer les certificats SSL"
pl_PL: "Zarządzaj certyfikatami SSL"
about.manage-ssl-vhost-proxy:
en_US: "Manage SSL vhost proxy"
de_DE: "SSL-vhost-Proxy verwalten"
@@ -5654,6 +5675,13 @@ about.stop-service:
fr_FR: "Arrêter un service"
pl_PL: "Zatrzymaj usługę"
about.ssl-generate-certificate:
en_US: "Generate an SSL certificate from the system root CA"
de_DE: "SSL-Zertifikat von der System-Root-CA generieren"
es_ES: "Generar un certificado SSL desde la CA raíz del sistema"
fr_FR: "Générer un certificat SSL depuis l'autorité racine du système"
pl_PL: "Wygeneruj certyfikat SSL z głównego CA systemu"
about.teardown-rebuild-containers:
en_US: "Teardown and rebuild containers"
de_DE: "Container abbauen und neu erstellen"

View File

@@ -42,6 +42,10 @@ pub fn net_api<C: Context>() -> ParentHandler<C> {
"tunnel",
tunnel::tunnel_api::<C>().with_about("about.manage-tunnels"),
)
.subcommand(
"ssl",
ssl::ssl_api::<C>().with_about("about.manage-ssl-certificates"),
)
.subcommand(
"vhost",
vhost::vhost_api::<C>().with_about("about.manage-ssl-vhost-proxy"),

View File

@@ -5,6 +5,7 @@ use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use clap::Parser;
use futures::FutureExt;
use imbl_value::InternedString;
use libc::time_t;
@@ -21,16 +22,19 @@ use openssl::x509::extension::{
use openssl::x509::{X509, X509Builder, X509NameBuilder, X509Ref};
use openssl::*;
use patch_db::HasModel;
use rpc_toolkit::{Context, HandlerExt, ParentHandler, from_fn_async};
use serde::{Deserialize, Serialize};
use tokio_rustls::rustls::ServerConfig;
use tokio_rustls::rustls::crypto::CryptoProvider;
use tokio_rustls::rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer};
use tokio_rustls::rustls::server::ClientHello;
use tracing::instrument;
use ts_rs::TS;
use visit_rs::Visit;
use crate::SOURCE_DATE;
use crate::account::AccountInfo;
use crate::context::{CliContext, RpcContext};
use crate::db::model::Database;
use crate::db::{DbAccess, DbAccessMut};
use crate::hostname::ServerHostname;
@@ -39,7 +43,7 @@ use crate::net::gateway::GatewayInfo;
use crate::net::tls::{TlsHandler, TlsHandlerAction};
use crate::net::web_server::{Accept, ExtractVisitor, TcpMetadata, extract};
use crate::prelude::*;
use crate::util::serde::Pem;
use crate::util::serde::{HandlerExtSerde, Pem};
pub fn should_use_cert(cert: &X509Ref) -> Result<bool, ErrorStack> {
Ok(cert
@@ -592,6 +596,85 @@ pub fn make_self_signed(applicant: (&PKey<Private>, &SANInfo)) -> Result<X509, E
Ok(cert)
}
pub fn ssl_api<C: Context>() -> ParentHandler<C> {
ParentHandler::new().subcommand(
"generate-certificate",
from_fn_async(generate_certificate)
.with_display_serializable()
.with_custom_display_fn(|_, res: GenerateCertificateResponse| {
println!("Private Key:");
print!("{}", res.key);
println!("\nCertificate Chain:");
print!("{}", res.fullchain);
Ok(())
})
.with_about("about.ssl-generate-certificate")
.with_call_remote::<CliContext>(),
)
}
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
#[command(rename_all = "kebab-case")]
#[group(skip)]
#[ts(export)]
pub struct GenerateCertificateParams {
#[arg(help = "help.arg.hostnames")]
pub hostnames: Vec<String>,
#[arg(long, help = "help.arg.ed25519")]
#[serde(default)]
pub ed25519: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct GenerateCertificateResponse {
pub key: String,
pub fullchain: String,
}
pub async fn generate_certificate(
ctx: RpcContext,
GenerateCertificateParams { hostnames, ed25519 }: GenerateCertificateParams,
) -> Result<GenerateCertificateResponse, Error> {
let peek = ctx.db.peek().await;
let cert_store = peek.as_private().as_key_store().as_local_certs();
let int_key = cert_store.as_int_key().de()?.0;
let int_cert = cert_store.as_int_cert().de()?.0;
let root_cert = cert_store.as_root_cert().de()?.0;
drop(peek);
let hostnames: BTreeSet<InternedString> = hostnames.into_iter().map(InternedString::from).collect();
let san_info = SANInfo::new(&hostnames);
let (key, cert) = if ed25519 {
let key = PKey::generate_ed25519()?;
let cert = make_leaf_cert((&int_key, &int_cert), (&key, &san_info))?;
(key, cert)
} else {
let key = gen_nistp256()?;
let cert = make_leaf_cert((&int_key, &int_cert), (&key, &san_info))?;
(key, cert)
};
let key_pem =
String::from_utf8(key.private_key_to_pem_pkcs8()?).with_kind(ErrorKind::Utf8)?;
let fullchain_pem = String::from_utf8(
[&cert, &int_cert, &root_cert]
.into_iter()
.map(|c| c.to_pem())
.collect::<Result<Vec<_>, _>>()?
.concat(),
)
.with_kind(ErrorKind::Utf8)?;
Ok(GenerateCertificateResponse {
key: key_pem,
fullchain: fullchain_pem,
})
}
pub struct RootCaTlsHandler<M: HasModel> {
pub db: TypedPatchDb<M>,
pub crypto_provider: Arc<CryptoProvider>,

View File

@@ -40,6 +40,102 @@ lazy_static::lazy_static! {
);
}
/// Detect the LC_COLLATE / LC_CTYPE the cluster was created with and generate
/// those locales if they are missing from the running system. Older installs
/// may have been initialized with a locale (e.g. en_GB.UTF-8) that the current
/// image does not ship. Without it PostgreSQL starts but refuses
/// connections, breaking the migration.
async fn ensure_cluster_locale(pg_version: u32) -> Result<(), Error> {
let cluster_dir = format!("/var/lib/postgresql/{pg_version}/main");
let pg_controldata = format!("/usr/lib/postgresql/{pg_version}/bin/pg_controldata");
let output = Command::new(&pg_controldata)
.arg(&cluster_dir)
.kill_on_drop(true)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.await
.with_kind(crate::ErrorKind::Database)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!("pg_controldata failed, skipping locale check: {stderr}");
return Ok(());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut locales_needed = Vec::new();
for line in stdout.lines() {
let locale = if let Some(rest) = line.strip_prefix("LC_COLLATE:") {
rest.trim()
} else if let Some(rest) = line.strip_prefix("LC_CTYPE:") {
rest.trim()
} else {
continue;
};
if !locale.is_empty() && locale != "C" && locale != "POSIX" {
locales_needed.push(locale.to_owned());
}
}
locales_needed.sort();
locales_needed.dedup();
if locales_needed.is_empty() {
return Ok(());
}
// Check which locales are already available.
let available = Command::new("locale")
.arg("-a")
.kill_on_drop(true)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.await
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
let mut need_gen = false;
for locale in &locales_needed {
// locale -a normalizes e.g. "en_GB.UTF-8" → "en_GB.utf8"
let normalized = locale.replace("-", "").to_lowercase();
if available.lines().any(|l| l.replace("-", "").to_lowercase() == normalized) {
continue;
}
// Debian's locale-gen ignores positional args — the locale must be
// uncommented in /etc/locale.gen or appended to it.
tracing::info!("Enabling missing locale for PostgreSQL cluster: {locale}");
let locale_gen_path = Path::new("/etc/locale.gen");
let contents = tokio::fs::read_to_string(locale_gen_path)
.await
.unwrap_or_default();
// Try to uncomment an existing entry first, otherwise append.
let entry = format!("{locale} UTF-8");
let commented = format!("# {entry}");
if contents.contains(&commented) {
let updated = contents.replace(&commented, &entry);
tokio::fs::write(locale_gen_path, updated).await?;
} else if !contents.contains(&entry) {
use tokio::io::AsyncWriteExt;
let mut f = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(locale_gen_path)
.await?;
f.write_all(format!("\n{entry}\n").as_bytes()).await?;
}
need_gen = true;
}
if need_gen {
Command::new("locale-gen")
.invoke(crate::ErrorKind::Database)
.await?;
}
Ok(())
}
#[tracing::instrument(skip_all)]
async fn init_postgres(datadir: impl AsRef<Path>) -> Result<PgPool, Error> {
let db_dir = datadir.as_ref().join("main/postgresql");
@@ -91,6 +187,12 @@ async fn init_postgres(datadir: impl AsRef<Path>) -> Result<PgPool, Error> {
crate::disk::mount::util::bind(&db_dir, "/var/lib/postgresql", false).await?;
// The cluster may have been created with a locale not present on the
// current image (e.g. en_GB.UTF-8 on a server that predates the trixie
// image). Detect and generate it before starting PostgreSQL, otherwise
// PG will start but refuse connections.
ensure_cluster_locale(pg_version).await?;
Command::new("systemctl")
.arg("start")
.arg(format!("postgresql@{pg_version}-main.service"))

View File

@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type GenerateCertificateParams = {
hostnames: Array<string>
ed25519: boolean
}

View File

@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type GenerateCertificateResponse = { key: string; fullchain: string }

View File

@@ -106,6 +106,8 @@ export { FullProgress } from './FullProgress'
export { GatewayId } from './GatewayId'
export { GatewayInfo } from './GatewayInfo'
export { GatewayType } from './GatewayType'
export { GenerateCertificateParams } from './GenerateCertificateParams'
export { GenerateCertificateResponse } from './GenerateCertificateResponse'
export { GetActionInputParams } from './GetActionInputParams'
export { GetContainerIpParams } from './GetContainerIpParams'
export { GetHostInfoParams } from './GetHostInfoParams'