Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m16s
Build Container / build-container (pull_request) Successful in 10m15s
Build App / build-linux (pull_request) Successful in 6m35s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Behind a TLS-terminating corporate proxy every HTTPS call inside a container fails — npm, pip, git, curl, the browser-view pane, and Claude Code's own API requests. There was no mechanism at all: installing the certificate by hand inside a container is lost on Reset and had to be repeated per project. A global CA path in AppSettings with a per-project override on Project, taking either a single certificate file or a directory. It is bind-mounted read-only at /tmp/.host-ca (mirroring /tmp/.host-ssh and /tmp/.host-aws) and applied by entrypoint.sh on every start, so it survives recreation, migration and Reset. Four things this gets right that are easy to get wrong: * update-ca-certificates globs *.crt case-sensitively, so a .pem that is merely copied in is ignored in silence. Certificates are renamed, by container_cert_name() in Rust and a mirrored few lines of shell. * The system store only serves curl/git/apt. Node — and so Claude Code itself — needs NODE_EXTRA_CA_CERTS, Python needs REQUESTS_CA_BUNDLE/SSL_CERT_FILE, and Chromium reads neither: it wants ~/.pki/nssdb, seeded with certutil (libnss3-tools, added to the image). * Those vars are set from Rust at creation, never exported by the entrypoint — a terminal is a docker exec and sees nothing the entrypoint exported. They are emitted empty when no CA is configured, since docker commit bakes env into the snapshot image. * triple-c.ca-fingerprint hashes the certificate bytes as well as the path, so a CA rotated in at the same location still forces a recreation. Verified end to end against a real container and a self-signed CA: curl, node, python and git all complete a TLS handshake against a server signed by it and all three fail in the same container without it; the env vars are visible from a docker exec session; the store is cleaned when the setting is cleared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
371 lines
12 KiB
Rust
371 lines
12 KiB
Rust
use tauri::State;
|
|
|
|
use crate::docker;
|
|
use crate::models::gateway_settings::GatewaySettings;
|
|
use crate::models::AppSettings;
|
|
use crate::AppState;
|
|
|
|
#[tauri::command]
|
|
pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, String> {
|
|
Ok(state.settings_store.get())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn update_settings(
|
|
settings: AppSettings,
|
|
state: State<'_, AppState>,
|
|
) -> Result<AppSettings, String> {
|
|
let before = state.settings_store.get();
|
|
let saved = state.settings_store.update(settings)?;
|
|
|
|
// Persisting a setting is not the same as applying it. The gateway is the
|
|
// one settings block that owns a *container*, so a saved change that the
|
|
// running container doesn't reflect is a live desync, not a preference.
|
|
reconcile_gateway(&before.gateway, &saved.gateway).await;
|
|
|
|
Ok(saved)
|
|
}
|
|
|
|
/// What a settings save has to do to the gateway container to stay honest.
|
|
///
|
|
/// Kept separate from the IPC command and expressed over plain settings so the
|
|
/// decision is testable without Docker.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum GatewayAction {
|
|
/// Nothing to do.
|
|
None,
|
|
/// The gateway is off — a container left running must be stopped.
|
|
StopIfRunning,
|
|
/// The published shape moved. A *running* container is now serving on the
|
|
/// old binding while status reports the new one, so it has to be recreated.
|
|
RestartIfRunning,
|
|
}
|
|
|
|
/// Whether the container's published shape (as opposed to a purely cosmetic
|
|
/// field) changed. Provider, models and base URL all change the rendered
|
|
/// LiteLLM config, which is only read at boot.
|
|
fn gateway_shape_changed(before: &GatewaySettings, after: &GatewaySettings) -> bool {
|
|
before.port != after.port
|
|
|| before.provider.trim() != after.provider.trim()
|
|
|| before.api_base.as_deref().unwrap_or("").trim()
|
|
!= after.api_base.as_deref().unwrap_or("").trim()
|
|
|| before.valid_models() != after.valid_models()
|
|
}
|
|
|
|
fn gateway_action(before: &GatewaySettings, after: &GatewaySettings) -> GatewayAction {
|
|
if !after.enabled {
|
|
// Includes the case where it was already disabled: a container found
|
|
// running while the feature is off should not stay up.
|
|
return GatewayAction::StopIfRunning;
|
|
}
|
|
if gateway_shape_changed(before, after) {
|
|
return GatewayAction::RestartIfRunning;
|
|
}
|
|
GatewayAction::None
|
|
}
|
|
|
|
/// Apply [`gateway_action`]. Never fails the settings save: the settings *are*
|
|
/// saved by this point, and a Docker hiccup must not make the UI think they
|
|
/// weren't. Both paths are no-ops when no container exists, so this stays cheap
|
|
/// on the overwhelmingly common "gateway not in use" save.
|
|
async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
|
|
let action = gateway_action(before, after);
|
|
if action == GatewayAction::None {
|
|
return;
|
|
}
|
|
|
|
let (exists, running) = match docker::gateway::gateway_container_presence().await {
|
|
Ok(presence) => presence,
|
|
// Docker down: there is nothing running to desync from.
|
|
Err(e) => {
|
|
log::debug!("Gateway reconcile skipped ({})", e);
|
|
return;
|
|
}
|
|
};
|
|
if !exists || !running {
|
|
return;
|
|
}
|
|
|
|
match action {
|
|
GatewayAction::StopIfRunning => {
|
|
log::info!("Model gateway disabled in settings — stopping the container");
|
|
if let Err(e) = docker::gateway::stop_gateway_container().await {
|
|
log::error!("Failed to stop the model gateway after it was disabled: {}", e);
|
|
}
|
|
}
|
|
GatewayAction::RestartIfRunning => {
|
|
log::info!("Model gateway settings changed — recreating the container");
|
|
// The fingerprint no longer matches, so this stops, removes and
|
|
// recreates with the new port/config in one step.
|
|
if let Err(e) = docker::gateway::ensure_gateway_running(after).await {
|
|
log::error!("Failed to apply the new model gateway settings: {}", e);
|
|
}
|
|
}
|
|
GatewayAction::None => unreachable!(),
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn pull_image(
|
|
image_name: String,
|
|
app_handle: tauri::AppHandle,
|
|
) -> Result<(), String> {
|
|
use tauri::Emitter;
|
|
docker::pull_image(&image_name, move |msg| {
|
|
let _ = app_handle.emit("image-pull-progress", msg);
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn detect_host_timezone() -> Result<String, String> {
|
|
// Try the iana-time-zone crate first (cross-platform)
|
|
match iana_time_zone::get_timezone() {
|
|
Ok(tz) => return Ok(tz),
|
|
Err(e) => log::debug!("iana_time_zone::get_timezone() failed: {}", e),
|
|
}
|
|
|
|
// Fallback: check TZ env var
|
|
if let Ok(tz) = std::env::var("TZ") {
|
|
if !tz.is_empty() {
|
|
return Ok(tz);
|
|
}
|
|
}
|
|
|
|
// Fallback: read /etc/timezone (Linux)
|
|
if let Ok(tz) = std::fs::read_to_string("/etc/timezone") {
|
|
let tz = tz.trim().to_string();
|
|
if !tz.is_empty() {
|
|
return Ok(tz);
|
|
}
|
|
}
|
|
|
|
// Default to UTC if detection fails
|
|
Ok("UTC".to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn detect_aws_config() -> Result<Option<String>, String> {
|
|
if let Some(home) = dirs::home_dir() {
|
|
let aws_dir = home.join(".aws");
|
|
if aws_dir.exists() {
|
|
return Ok(Some(aws_dir.to_string_lossy().to_string()));
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
/// What the UI shows next to a corporate CA certificate path.
|
|
///
|
|
/// Errors are returned *inside* the payload rather than as `Err` so the field
|
|
/// can render its own inline message while the user is still typing — a toast
|
|
/// per keystroke would be unusable. The same check runs again, as a hard error,
|
|
/// when the container is created.
|
|
#[derive(Debug, serde::Serialize)]
|
|
pub struct CaCertInfo {
|
|
pub exists: bool,
|
|
pub is_directory: bool,
|
|
/// How many certificate files were found.
|
|
pub cert_count: usize,
|
|
/// The names they will be installed as inside the container. Surfacing
|
|
/// these makes the silent `.pem` → `.crt` rename visible, which is the one
|
|
/// step users most often do by hand and get wrong.
|
|
pub installed_names: Vec<String>,
|
|
/// Why the path is unusable, if it is.
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn inspect_ca_cert_path(path: String) -> Result<CaCertInfo, String> {
|
|
use crate::docker::ca_certs;
|
|
|
|
let trimmed = path.trim();
|
|
if trimmed.is_empty() {
|
|
return Ok(CaCertInfo {
|
|
exists: false,
|
|
is_directory: false,
|
|
cert_count: 0,
|
|
installed_names: Vec::new(),
|
|
error: None,
|
|
});
|
|
}
|
|
|
|
let p = std::path::Path::new(trimmed);
|
|
let exists = p.exists();
|
|
let is_directory = p.is_dir();
|
|
|
|
match ca_certs::resolve(Some(trimmed)) {
|
|
Ok(Some(resolved)) => Ok(CaCertInfo {
|
|
exists,
|
|
is_directory,
|
|
cert_count: resolved.cert_files.len(),
|
|
installed_names: resolved
|
|
.cert_files
|
|
.iter()
|
|
.map(|f| {
|
|
ca_certs::container_cert_name(
|
|
&f.file_name().unwrap_or_default().to_string_lossy(),
|
|
)
|
|
})
|
|
.collect(),
|
|
error: None,
|
|
}),
|
|
Ok(None) => Ok(CaCertInfo {
|
|
exists,
|
|
is_directory,
|
|
cert_count: 0,
|
|
installed_names: Vec::new(),
|
|
error: None,
|
|
}),
|
|
Err(e) => Ok(CaCertInfo {
|
|
exists,
|
|
is_directory,
|
|
cert_count: 0,
|
|
installed_names: Vec::new(),
|
|
error: Some(e),
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_aws_profiles() -> Result<Vec<String>, String> {
|
|
let mut profiles = Vec::new();
|
|
|
|
let home = match dirs::home_dir() {
|
|
Some(h) => h,
|
|
None => return Ok(profiles),
|
|
};
|
|
|
|
// Parse ~/.aws/credentials
|
|
let credentials_path = home.join(".aws").join("credentials");
|
|
if credentials_path.exists() {
|
|
if let Ok(contents) = std::fs::read_to_string(&credentials_path) {
|
|
for line in contents.lines() {
|
|
let trimmed = line.trim();
|
|
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
|
let profile = trimmed[1..trimmed.len() - 1].to_string();
|
|
if !profiles.contains(&profile) {
|
|
profiles.push(profile);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse ~/.aws/config (profiles are prefixed with "profile ")
|
|
let config_path = home.join(".aws").join("config");
|
|
if config_path.exists() {
|
|
if let Ok(contents) = std::fs::read_to_string(&config_path) {
|
|
for line in contents.lines() {
|
|
let trimmed = line.trim();
|
|
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
|
let section = &trimmed[1..trimmed.len() - 1];
|
|
let profile = if let Some(name) = section.strip_prefix("profile ") {
|
|
name.to_string()
|
|
} else {
|
|
section.to_string()
|
|
};
|
|
if !profiles.contains(&profile) {
|
|
profiles.push(profile);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(profiles)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::models::gateway_settings::GatewayModel;
|
|
|
|
fn enabled_gateway() -> GatewaySettings {
|
|
GatewaySettings {
|
|
enabled: true,
|
|
port: 4000,
|
|
provider: "openai".to_string(),
|
|
api_base: None,
|
|
models: vec![GatewayModel {
|
|
name: "gpt-5.1".to_string(),
|
|
model_id: "gpt-5.1".to_string(),
|
|
}],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn disabling_the_gateway_stops_it() {
|
|
// The bug: turning the toggle off only persisted `enabled: false` and
|
|
// hid the Stop button, leaving a container serving with no way to stop
|
|
// it.
|
|
let before = enabled_gateway();
|
|
let mut after = before.clone();
|
|
after.enabled = false;
|
|
assert_eq!(gateway_action(&before, &after), GatewayAction::StopIfRunning);
|
|
// Still true when it was already off — a stray running container is
|
|
// still a container that shouldn't be up.
|
|
assert_eq!(gateway_action(&after, &after), GatewayAction::StopIfRunning);
|
|
}
|
|
|
|
#[test]
|
|
fn changing_the_port_reconciles_the_container() {
|
|
// Otherwise status reports the new port while the container keeps the
|
|
// old binding, and every project gets a broken ANTHROPIC_BASE_URL.
|
|
let before = enabled_gateway();
|
|
let mut after = before.clone();
|
|
after.port = 4100;
|
|
assert_eq!(
|
|
gateway_action(&before, &after),
|
|
GatewayAction::RestartIfRunning
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn config_changes_that_only_take_effect_at_boot_reconcile_too() {
|
|
let before = enabled_gateway();
|
|
|
|
let mut provider = before.clone();
|
|
provider.provider = "groq".to_string();
|
|
assert_eq!(
|
|
gateway_action(&before, &provider),
|
|
GatewayAction::RestartIfRunning
|
|
);
|
|
|
|
let mut api_base = before.clone();
|
|
api_base.api_base = Some("https://example.test/v1".to_string());
|
|
assert_eq!(
|
|
gateway_action(&before, &api_base),
|
|
GatewayAction::RestartIfRunning
|
|
);
|
|
|
|
let mut models = before.clone();
|
|
models.models[0].model_id = "gpt-4.1".to_string();
|
|
assert_eq!(
|
|
gateway_action(&before, &models),
|
|
GatewayAction::RestartIfRunning
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn saving_an_unchanged_or_half_typed_gateway_touches_nothing() {
|
|
let before = enabled_gateway();
|
|
assert_eq!(gateway_action(&before, &before), GatewayAction::None);
|
|
|
|
// Whitespace-only edits don't reach the rendered config.
|
|
let mut trimmed = before.clone();
|
|
trimmed.provider = " openai ".to_string();
|
|
trimmed.api_base = Some(" ".to_string());
|
|
assert_eq!(gateway_action(&before, &trimmed), GatewayAction::None);
|
|
|
|
// A half-filled model row is skipped when rendering, so it must not
|
|
// bounce a live container either.
|
|
let mut half_typed = before.clone();
|
|
half_typed.models.push(GatewayModel {
|
|
name: "gpt".to_string(),
|
|
model_id: String::new(),
|
|
});
|
|
assert_eq!(gateway_action(&before, &half_typed), GatewayAction::None);
|
|
}
|
|
}
|