feat: OTA updates for start-tunnel via apt repository (untested)

- Add apt repo publish script (build/apt/publish-deb.sh) for S3-hosted repo
- Add apt source config and GPG key placeholder (apt/)
- Add tunnel.update.check and tunnel.update.apply RPC endpoints
- Wire up update API in tunnel frontend (api service + mock)
- Uses systemd-run --scope to survive service restart during update
This commit is contained in:
Aiden McClelland
2026-02-19 22:38:39 -07:00
parent 9af5b87c92
commit 35f3274f29
12 changed files with 333 additions and 0 deletions

View File

@@ -3972,6 +3972,13 @@ about.allow-gateway-infer-inbound-access-from-wan:
fr_FR: "Permettre à cette passerelle de déduire si elle a un accès entrant depuis le WAN en fonction de son adresse IPv4"
pl_PL: "Pozwól tej bramce wywnioskować, czy ma dostęp przychodzący z WAN na podstawie adresu IPv4"
about.apply-available-update:
en_US: "Apply available update"
de_DE: "Verfügbares Update anwenden"
es_ES: "Aplicar actualización disponible"
fr_FR: "Appliquer la mise à jour disponible"
pl_PL: "Zastosuj dostępną aktualizację"
about.calculate-blake3-hash-for-file:
en_US: "Calculate blake3 hash for a file"
de_DE: "Blake3-Hash für eine Datei berechnen"
@@ -3993,6 +4000,13 @@ about.check-dns-configuration:
fr_FR: "Vérifier la configuration DNS d'une passerelle"
pl_PL: "Sprawdź konfigurację DNS bramy"
about.check-for-updates:
en_US: "Check for available updates"
de_DE: "Nach verfügbaren Updates suchen"
es_ES: "Buscar actualizaciones disponibles"
fr_FR: "Vérifier les mises à jour disponibles"
pl_PL: "Sprawdź dostępne aktualizacje"
about.check-update-startos:
en_US: "Check a given registry for StartOS updates and update if available"
de_DE: "Ein bestimmtes Registry auf StartOS-Updates prüfen und bei Verfügbarkeit aktualisieren"

View File

@@ -53,6 +53,24 @@ pub fn tunnel_api<C: Context>() -> ParentHandler<C> {
.with_call_remote::<CliContext>(),
),
)
.subcommand(
"update",
ParentHandler::<C>::new()
.subcommand(
"check",
from_fn_async(super::update::check_update)
.with_display_serializable()
.with_about("about.check-for-updates")
.with_call_remote::<CliContext>(),
)
.subcommand(
"apply",
from_fn_async(super::update::apply_update)
.with_display_serializable()
.with_about("about.apply-available-update")
.with_call_remote::<CliContext>(),
),
)
}
#[derive(Deserialize, Serialize, Parser)]

View File

@@ -9,6 +9,7 @@ pub mod api;
pub mod auth;
pub mod context;
pub mod db;
pub mod update;
pub mod web;
pub mod wg;

109
core/src/tunnel/update.rs Normal file
View File

@@ -0,0 +1,109 @@
use std::process::Stdio;
use rpc_toolkit::Empty;
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use tracing::instrument;
use ts_rs::TS;
use crate::prelude::*;
use crate::tunnel::context::TunnelContext;
use crate::util::Invoke;
#[derive(Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct TunnelUpdateResult {
/// "up-to-date", "update-available", or "updating"
pub status: String,
/// Currently installed version
pub installed: String,
/// Available candidate version
pub candidate: String,
}
#[instrument(skip_all)]
pub async fn check_update(_ctx: TunnelContext, _: Empty) -> Result<TunnelUpdateResult, Error> {
Command::new("apt-get")
.arg("update")
.invoke(ErrorKind::UpdateFailed)
.await?;
let policy_output = Command::new("apt-cache")
.arg("policy")
.arg("start-tunnel")
.invoke(ErrorKind::UpdateFailed)
.await?;
let policy_str = String::from_utf8_lossy(&policy_output).to_string();
let installed = parse_version_field(&policy_str, "Installed:");
let candidate = parse_version_field(&policy_str, "Candidate:");
let status = if installed == candidate {
"up-to-date"
} else {
"update-available"
};
Ok(TunnelUpdateResult {
status: status.to_string(),
installed: installed.unwrap_or_default(),
candidate: candidate.unwrap_or_default(),
})
}
#[instrument(skip_all)]
pub async fn apply_update(_ctx: TunnelContext, _: Empty) -> Result<TunnelUpdateResult, Error> {
let policy_output = Command::new("apt-cache")
.arg("policy")
.arg("start-tunnel")
.invoke(ErrorKind::UpdateFailed)
.await?;
let policy_str = String::from_utf8_lossy(&policy_output).to_string();
let installed = parse_version_field(&policy_str, "Installed:");
let candidate = parse_version_field(&policy_str, "Candidate:");
if installed == candidate {
return Ok(TunnelUpdateResult {
status: "up-to-date".to_string(),
installed: installed.unwrap_or_default(),
candidate: candidate.unwrap_or_default(),
});
}
// Spawn in a separate cgroup via systemd-run so the process survives
// when the postinst script restarts start-tunneld.service.
// After the install completes, reboot the system.
Command::new("systemd-run")
.arg("--scope")
.arg("--")
.arg("sh")
.arg("-c")
.arg("apt-get install --only-upgrade -y start-tunnel && reboot")
.env("DEBIAN_FRONTEND", "noninteractive")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.with_kind(ErrorKind::UpdateFailed)?;
Ok(TunnelUpdateResult {
status: "updating".to_string(),
installed: installed.unwrap_or_default(),
candidate: candidate.unwrap_or_default(),
})
}
fn parse_version_field(policy: &str, field: &str) -> Option<String> {
policy
.lines()
.find(|l| l.trim().starts_with(field))
.and_then(|l| l.split_whitespace().nth(1))
.filter(|v| *v != "(none)")
.map(|s| s.to_string())
}
#[test]
fn export_bindings_tunnel_update() {
TunnelUpdateResult::export_all_to("bindings/tunnel").unwrap();
}