Allow external HTTP root certificates to be configured by a policy file (#805)
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-06-11 11:37:40 +02:00
1 parent 5272895441
commit 0ea63a16c0
4 files changed
+296 -31

No files matched your search

+34 -13
View File
@@ -13,7 +13,13 @@ use crate::runtime_api_token::API_TOKEN;
use crate::app_window::change_location_to;
use crate::runtime_certificate::CERTIFICATE_FINGERPRINT;
use crate::encryption::ENCRYPTION;
use crate::environment::{is_dev, DATA_DIRECTORY};
use crate::environment::{
is_dev, resolve_external_http_custom_root_certificate_policy, DATA_DIRECTORY,
DOTNET_ENV_CUSTOM_ROOT_CERTIFICATE_ALLOWED_HOSTS,
DOTNET_ENV_CUSTOM_ROOT_CERTIFICATE_BUNDLE_PATH,
DOTNET_ENV_CUSTOM_ROOT_CERTIFICATE_POLICY_CONFIGURED,
DOTNET_ENV_CUSTOM_ROOT_CERTIFICATES_ENABLED,
};
use crate::network::get_available_port;
use crate::runtime_api::API_SERVER_PORT;
use crate::stale_process_cleanup::{kill_stale_process, log_potential_stale_process};
@@ -93,6 +99,20 @@ pub async fn dotnet_port(_token: APIToken) -> String {
format!("{dotnet_server_port}")
}
fn external_http_custom_root_certificate_policy_environment() -> Vec<(String, String)> {
let policy = resolve_external_http_custom_root_certificate_policy();
if !policy.is_configured {
return Vec::new();
}
vec![
(String::from(DOTNET_ENV_CUSTOM_ROOT_CERTIFICATE_POLICY_CONFIGURED), String::from("true")),
(String::from(DOTNET_ENV_CUSTOM_ROOT_CERTIFICATES_ENABLED), policy.enabled.to_string()),
(String::from(DOTNET_ENV_CUSTOM_ROOT_CERTIFICATE_BUNDLE_PATH), policy.bundle_path),
(String::from(DOTNET_ENV_CUSTOM_ROOT_CERTIFICATE_ALLOWED_HOSTS), policy.allowed_hosts),
]
}
/// Creates the startup environment file for the .NET server in the development
/// environment. The file is created in the root directory of the repository.
/// Creating that env file on a production environment would be a security
@@ -113,18 +133,18 @@ pub fn create_startup_env_file() {
warn!(Source = "Bootloader .NET"; "Development environment detected; create the startup env file at '../startup.env'.");
let env_file_path = std::path::PathBuf::from("..").join("startup.env");
let mut env_file = std::fs::File::create(env_file_path).unwrap();
let env_file_content = format!(
"AI_STUDIO_SECRET_PASSWORD={secret_password}\n\
AI_STUDIO_SECRET_KEY_SALT={secret_key_salt}\n\
AI_STUDIO_CERTIFICATE_FINGERPRINT={cert_fingerprint}\n\
AI_STUDIO_API_PORT={api_port}\n\
AI_STUDIO_API_TOKEN={api_token}",
let mut env_file_lines = vec![
format!("AI_STUDIO_SECRET_PASSWORD={secret_password}"),
format!("AI_STUDIO_SECRET_KEY_SALT={secret_key_salt}"),
format!("AI_STUDIO_CERTIFICATE_FINGERPRINT={}", CERTIFICATE_FINGERPRINT.get().unwrap()),
format!("AI_STUDIO_API_PORT={api_port}"),
format!("AI_STUDIO_API_TOKEN={}", API_TOKEN.to_hex_text()),
];
for (key, value) in external_http_custom_root_certificate_policy_environment() {
env_file_lines.push(format!("{key}={value}"));
}
cert_fingerprint = CERTIFICATE_FINGERPRINT.get().unwrap(),
api_token = API_TOKEN.to_hex_text()
);
std::io::Write::write_all(&mut env_file, env_file_content.as_bytes()).unwrap();
std::io::Write::write_all(&mut env_file, env_file_lines.join("\n").as_bytes()).unwrap();
info!(Source = "Bootloader .NET"; "The startup env file was created successfully.");
}
@@ -136,13 +156,14 @@ pub fn start_dotnet_server<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>) {
let secret_key_salt = BASE64_STANDARD.encode(ENCRYPTION.secret_key_salt);
let api_port = *API_SERVER_PORT;
let dotnet_server_environment: HashMap<String, String> = HashMap::from_iter([
let mut dotnet_server_environment: HashMap<String, String> = HashMap::from_iter([
(String::from("AI_STUDIO_SECRET_PASSWORD"), secret_password),
(String::from("AI_STUDIO_SECRET_KEY_SALT"), secret_key_salt),
(String::from("AI_STUDIO_CERTIFICATE_FINGERPRINT"), CERTIFICATE_FINGERPRINT.get().unwrap().to_string()),
(String::from("AI_STUDIO_API_PORT"), format!("{api_port}")),
(String::from("AI_STUDIO_API_TOKEN"), API_TOKEN.to_hex_text().to_string()),
]);
dotnet_server_environment.extend(external_http_custom_root_certificate_policy_environment());
info!("Try to start the .NET server...");
let server_spawn_clone = DOTNET_SERVER.clone();
+202
View File
@@ -21,6 +21,12 @@ const ENTERPRISE_CONFIG_SERVER_URL_KEY_PREFIX: &str = "config_server_url";
const ENTERPRISE_REGISTRY_KEY_PATH: &str = r"Software\github\MindWork AI Studio\Enterprise IT";
const ENTERPRISE_POLICY_SECRET_FILE_NAME: &str = "config_encryption_secret.yaml";
const EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATE_POLICY_FILE_NAME: &str = "external_http_custom_root_certificates.yaml";
pub const DOTNET_ENV_CUSTOM_ROOT_CERTIFICATE_POLICY_CONFIGURED: &str = "AI_STUDIO_EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATES_POLICY_CONFIGURED";
pub const DOTNET_ENV_CUSTOM_ROOT_CERTIFICATES_ENABLED: &str = "AI_STUDIO_EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATES_ENABLED";
pub const DOTNET_ENV_CUSTOM_ROOT_CERTIFICATE_BUNDLE_PATH: &str = "AI_STUDIO_EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATE_BUNDLE_PATH";
pub const DOTNET_ENV_CUSTOM_ROOT_CERTIFICATE_ALLOWED_HOSTS: &str = "AI_STUDIO_EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATE_ALLOWED_HOSTS";
#[cfg(any(target_os = "linux", test))]
const FLATPAK_ENTERPRISE_POLICY_DIRECTORY: &str = "/app/etc/MindWorkAI";
@@ -257,6 +263,15 @@ pub struct EnterpriseConfig {
pub slot: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ExternalHttpCustomRootCertificatePolicy {
pub is_configured: bool,
pub enabled: bool,
pub bundle_path: String,
pub allowed_hosts: String,
pub source_detail: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct EnterpriseSourceValue {
value: String,
@@ -337,6 +352,10 @@ pub async fn read_enterprise_configs(_token: APIToken) -> Json<Vec<EnterpriseCon
Json(resolve_effective_enterprise_config_source().configs)
}
pub fn resolve_external_http_custom_root_certificate_policy() -> ExternalHttpCustomRootCertificatePolicy {
load_external_http_custom_root_certificate_policy_from_directories(&enterprise_policy_directories())
}
fn resolve_effective_enterprise_config_source() -> EnterpriseSourceData {
select_effective_enterprise_config_source(gather_enterprise_sources())
}
@@ -646,6 +665,54 @@ fn load_policy_values_from_directories(directories: &[PathBuf]) -> EnterpriseSou
values
}
fn load_external_http_custom_root_certificate_policy_from_directories(directories: &[PathBuf]) -> ExternalHttpCustomRootCertificatePolicy {
for directory in directories {
let path = directory.join(EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATE_POLICY_FILE_NAME);
let Some(values) = read_policy_yaml_mapping(&path) else {
continue;
};
if let Some(policy) = parse_external_http_custom_root_certificate_policy(&path, &values) {
info!("Using external HTTP custom root certificate policy from '{}'.", policy.source_detail);
return policy;
}
}
ExternalHttpCustomRootCertificatePolicy::default()
}
fn parse_external_http_custom_root_certificate_policy(path: &Path, values: &HashMap<String, String>) -> Option<ExternalHttpCustomRootCertificatePolicy> {
let Some(raw_enabled) = values.get("enabled") else {
warn!("Ignoring external HTTP custom root certificate policy '{}': missing 'enabled'.", path.display());
return None;
};
let Some(enabled) = parse_policy_boolean_value(raw_enabled) else {
warn!("Ignoring external HTTP custom root certificate policy '{}': invalid 'enabled' value.", path.display());
return None;
};
let source_detail = path
.canonicalize()
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.into_owned();
Some(ExternalHttpCustomRootCertificatePolicy {
is_configured: true,
enabled,
bundle_path: values
.get("bundle_path")
.and_then(|value| normalize_enterprise_value(value))
.unwrap_or_default(),
allowed_hosts: values
.get("allowed_hosts")
.and_then(|value| normalize_enterprise_value(value))
.unwrap_or_default(),
source_detail,
})
}
fn enterprise_policy_file_slot_suffix(file_name: &str) -> Option<&str> {
let suffix = file_name
.strip_prefix("config")?
@@ -737,6 +804,25 @@ fn parse_policy_yaml_value(raw_value: &str) -> Option<String> {
Some(String::from(trimmed))
}
fn parse_policy_boolean_value(raw_value: &str) -> Option<bool> {
let normalized = raw_value.trim();
if normalized.eq_ignore_ascii_case("true")
|| normalized == "1"
|| normalized.eq_ignore_ascii_case("yes")
|| normalized.eq_ignore_ascii_case("on") {
return Some(true);
}
if normalized.eq_ignore_ascii_case("false")
|| normalized == "0"
|| normalized.eq_ignore_ascii_case("no")
|| normalized.eq_ignore_ascii_case("off") {
return Some(false);
}
None
}
fn insert_first_non_empty_value(values: &mut EnterpriseSourceValues, key: &str, raw_value: &str, source_detail: &str) {
if let Some(value) = normalize_enterprise_value(raw_value) {
values
@@ -963,10 +1049,12 @@ fn normalize_enterprise_config_id(value: &str) -> Option<String> {
mod tests {
use super::{
enterprise_environment_key_name, enterprise_policy_file_slot_suffix,
load_external_http_custom_root_certificate_policy_from_directories,
linux_policy_directories_from_xdg, load_policy_values_from_directories,
normalize_locale_tag, parse_enterprise_source_values,
select_effective_enterprise_config_source, select_effective_enterprise_secret_source,
EnterpriseConfig, EnterpriseSourceData, EnterpriseSourceValue, EnterpriseSourceValues,
ExternalHttpCustomRootCertificatePolicy,
};
use std::collections::HashMap;
use std::fs;
@@ -1490,6 +1578,120 @@ mod tests {
assert_eq!(source.encryption_secret, "POLICY-SECRET");
}
#[test]
fn load_external_http_custom_root_certificate_policy_uses_first_valid_directory() {
let directory_a = tempdir().unwrap();
let directory_b = tempdir().unwrap();
fs::write(
directory_a.path().join("external_http_custom_root_certificates.yaml"),
"enabled: true\nbundle_path: \"/app/etc/MindWorkAI/company-a.pem\"\nallowed_hosts: \"*.a.example.org;eri.a.example.org\"",
)
.unwrap();
fs::write(
directory_b.path().join("external_http_custom_root_certificates.yaml"),
"enabled: true\nbundle_path: \"/app/etc/MindWorkAI/company-b.pem\"\nallowed_hosts: \"*.b.example.org\"",
)
.unwrap();
let policy = load_external_http_custom_root_certificate_policy_from_directories(&[
directory_a.path().to_path_buf(),
directory_b.path().to_path_buf(),
]);
assert_eq!(
policy,
ExternalHttpCustomRootCertificatePolicy {
is_configured: true,
enabled: true,
bundle_path: String::from("/app/etc/MindWorkAI/company-a.pem"),
allowed_hosts: String::from("*.a.example.org;eri.a.example.org"),
source_detail: policy_path(directory_a.path().join("external_http_custom_root_certificates.yaml")),
}
);
}
#[test]
fn load_external_http_custom_root_certificate_policy_allows_disabled_policy_to_win() {
let directory_a = tempdir().unwrap();
let directory_b = tempdir().unwrap();
fs::write(
directory_a.path().join("external_http_custom_root_certificates.yaml"),
"enabled: false",
)
.unwrap();
fs::write(
directory_b.path().join("external_http_custom_root_certificates.yaml"),
"enabled: true\nbundle_path: \"/app/etc/MindWorkAI/company-b.pem\"\nallowed_hosts: \"*.b.example.org\"",
)
.unwrap();
let policy = load_external_http_custom_root_certificate_policy_from_directories(&[
directory_a.path().to_path_buf(),
directory_b.path().to_path_buf(),
]);
assert_eq!(
policy,
ExternalHttpCustomRootCertificatePolicy {
is_configured: true,
enabled: false,
bundle_path: String::new(),
allowed_hosts: String::new(),
source_detail: policy_path(directory_a.path().join("external_http_custom_root_certificates.yaml")),
}
);
}
#[test]
fn load_external_http_custom_root_certificate_policy_skips_invalid_files() {
let directory_a = tempdir().unwrap();
let directory_b = tempdir().unwrap();
fs::write(
directory_a.path().join("external_http_custom_root_certificates.yaml"),
"enabled: maybe\nbundle_path: \"/app/etc/MindWorkAI/ignored.pem\"",
)
.unwrap();
fs::write(
directory_b.path().join("external_http_custom_root_certificates.yaml"),
"enabled: yes\nbundle_path: \"/app/etc/MindWorkAI/company-b.pem\"\nallowed_hosts: \"*.b.example.org,eri.b.example.org\"",
)
.unwrap();
let policy = load_external_http_custom_root_certificate_policy_from_directories(&[
directory_a.path().to_path_buf(),
directory_b.path().to_path_buf(),
]);
assert_eq!(
policy,
ExternalHttpCustomRootCertificatePolicy {
is_configured: true,
enabled: true,
bundle_path: String::from("/app/etc/MindWorkAI/company-b.pem"),
allowed_hosts: String::from("*.b.example.org,eri.b.example.org"),
source_detail: policy_path(directory_b.path().join("external_http_custom_root_certificates.yaml")),
}
);
}
#[test]
fn load_external_http_custom_root_certificate_policy_requires_enabled_key() {
let directory = tempdir().unwrap();
fs::write(
directory.path().join("external_http_custom_root_certificates.yaml"),
"bundle_path: \"/app/etc/MindWorkAI/company.pem\"\nallowed_hosts: \"*.example.org\"",
)
.unwrap();
let policy = load_external_http_custom_root_certificate_policy_from_directories(&[directory.path().to_path_buf()]);
assert_eq!(policy, ExternalHttpCustomRootCertificatePolicy::default());
}
#[test]
fn load_policy_values_from_directories_ignores_invalid_and_incomplete_files() {
let directory = tempdir().unwrap();