diff --git a/.github/workflows/start-registry.yaml b/.github/workflows/start-registry.yaml index 03dcd95fb..29e462795 100644 --- a/.github/workflows/start-registry.yaml +++ b/.github/workflows/start-registry.yaml @@ -162,7 +162,7 @@ jobs: ADD *.deb . - RUN apt-get install -y ./*_$(uname -m).deb && rm *.deb + RUN apt-get update && apt-get install -y ./*_$(uname -m).deb && rm -rf *.deb /var/lib/apt/lists/* VOLUME /var/lib/startos diff --git a/Makefile b/Makefile index 7ab474909..07aabb16f 100644 --- a/Makefile +++ b/Makefile @@ -155,7 +155,7 @@ results/$(BASENAME).deb: debian/dpkg-build.sh $(call ls-files,debian/startos) $( registry-deb: results/$(REGISTRY_BASENAME).deb results/$(REGISTRY_BASENAME).deb: debian/dpkg-build.sh $(call ls-files,debian/start-registry) $(REGISTRY_TARGETS) - PROJECT=start-registry PLATFORM=$(ARCH) REQUIRES=debian ./build/os-compat/run-compat.sh ./debian/dpkg-build.sh + PROJECT=start-registry PLATFORM=$(ARCH) REQUIRES=debian DEPENDS=ca-certificates ./build/os-compat/run-compat.sh ./debian/dpkg-build.sh tunnel-deb: results/$(TUNNEL_BASENAME).deb diff --git a/build/image-recipe/build.sh b/build/image-recipe/build.sh index 8bd27daf3..787f71844 100755 --- a/build/image-recipe/build.sh +++ b/build/image-recipe/build.sh @@ -209,6 +209,10 @@ cat > config/hooks/normal/9000-install-startos.hook.chroot << EOF set -e +if [ "${IB_TARGET_PLATFORM}" != "raspberrypi" ]; then + /usr/lib/startos/scripts/enable-kiosk +fi + if [ "${NVIDIA}" = "1" ]; then # install a specific NVIDIA driver version @@ -236,7 +240,7 @@ if [ "${NVIDIA}" = "1" ]; then echo "[nvidia-hook] Target kernel version: \${KVER}" >&2 # Ensure kernel headers are present - TEMP_APT_DEPS=(build-essential) + TEMP_APT_DEPS=(build-essential pkg-config) if [ ! -e "/lib/modules/\${KVER}/build" ]; then TEMP_APT_DEPS+=(linux-headers-\${KVER}) fi @@ -279,6 +283,16 @@ if [ "${NVIDIA}" = "1" ]; then echo "[nvidia-hook] NVIDIA \${NVIDIA_DRIVER_VERSION} installation complete for kernel \${KVER}" >&2 + echo "[nvidia-hook] Removing .run installer..." >&2 + rm -f "\${RUN_PATH}" + + echo "[nvidia-hook] Blacklisting nouveau..." >&2 + echo "blacklist nouveau" > /etc/modprobe.d/blacklist-nouveau.conf + echo "options nouveau modeset=0" >> /etc/modprobe.d/blacklist-nouveau.conf + + echo "[nvidia-hook] Rebuilding initramfs..." >&2 + update-initramfs -u -k "\${KVER}" + echo "[nvidia-hook] Removing build dependencies..." >&2 apt-get purge -y nvidia-depends apt-get autoremove -y @@ -310,10 +324,6 @@ usermod -aG systemd-journal start9 echo "start9 ALL=(ALL:ALL) NOPASSWD: ALL" | sudo tee "/etc/sudoers.d/010_start9-nopasswd" -if [ "${IB_TARGET_PLATFORM}" != "raspberrypi" ]; then - /usr/lib/startos/scripts/enable-kiosk -fi - if ! [[ "${IB_OS_ENV}" =~ (^|-)dev($|-) ]]; then passwd -l start9 fi diff --git a/build/lib/scripts/startos-initramfs-module b/build/lib/scripts/startos-initramfs-module index f093328cc..6299edd5b 100755 --- a/build/lib/scripts/startos-initramfs-module +++ b/build/lib/scripts/startos-initramfs-module @@ -104,6 +104,7 @@ local_mount_root() -olowerdir=/startos/config/overlay:/lower,upperdir=/upper/data,workdir=/upper/work \ overlay ${rootmnt} + mkdir -m 750 -p ${rootmnt}/media/startos mkdir -p ${rootmnt}/media/startos/config mount --bind /startos/config ${rootmnt}/media/startos/config mkdir -p ${rootmnt}/media/startos/images diff --git a/build/manage-release.sh b/build/manage-release.sh index c3b71717a..94387d2a6 100755 --- a/build/manage-release.sh +++ b/build/manage-release.sh @@ -198,20 +198,22 @@ cmd_sign() { enter_release_dir resolve_gh_user + mkdir -p signatures + for file in $(release_files); do - gpg -u $START9_GPG_KEY --detach-sign --armor -o "${file}.start9.asc" "$file" + gpg -u $START9_GPG_KEY --detach-sign --armor -o "signatures/${file}.start9.asc" "$file" if [ -n "$GH_USER" ] && [ -n "$GH_GPG_KEY" ]; then - gpg -u "$GH_GPG_KEY" --detach-sign --armor -o "${file}.${GH_USER}.asc" "$file" + gpg -u "$GH_GPG_KEY" --detach-sign --armor -o "signatures/${file}.${GH_USER}.asc" "$file" fi done - gpg --export -a $START9_GPG_KEY > start9.key.asc + gpg --export -a $START9_GPG_KEY > signatures/start9.key.asc if [ -n "$GH_USER" ] && [ -n "$GH_GPG_KEY" ]; then - gpg --export -a "$GH_GPG_KEY" > "${GH_USER}.key.asc" + gpg --export -a "$GH_GPG_KEY" > "signatures/${GH_USER}.key.asc" else >&2 echo 'Warning: could not determine GitHub user or GPG signing key, skipping personal signature' fi - tar -czvf signatures.tar.gz *.asc + tar -czvf signatures.tar.gz -C signatures . gh release upload -R $REPO "v$VERSION" signatures.tar.gz --clobber } @@ -229,17 +231,18 @@ cmd_cosign() { echo "Downloading existing signatures..." gh release download -R $REPO "v$VERSION" -p "signatures.tar.gz" -D "$(pwd)" --clobber - tar -xzf signatures.tar.gz + mkdir -p signatures + tar -xzf signatures.tar.gz -C signatures echo "Adding personal signatures as $GH_USER..." for file in $(release_files); do - gpg -u "$GH_GPG_KEY" --detach-sign --armor -o "${file}.${GH_USER}.asc" "$file" + gpg -u "$GH_GPG_KEY" --detach-sign --armor -o "signatures/${file}.${GH_USER}.asc" "$file" done - gpg --export -a "$GH_GPG_KEY" > "${GH_USER}.key.asc" + gpg --export -a "$GH_GPG_KEY" > "signatures/${GH_USER}.key.asc" echo "Re-packing signatures..." - tar -czvf signatures.tar.gz *.asc + tar -czvf signatures.tar.gz -C signatures . gh release upload -R $REPO "v$VERSION" signatures.tar.gz --clobber echo "Done. Personal signatures for $GH_USER added to v$VERSION." diff --git a/container-runtime/container-runtime.service b/container-runtime/container-runtime.service index ed9d142f7..f04150969 100644 --- a/container-runtime/container-runtime.service +++ b/container-runtime/container-runtime.service @@ -5,7 +5,7 @@ OnFailure=container-runtime-failure.service [Service] Type=simple Environment=RUST_LOG=startos=debug -ExecStart=/usr/bin/node --experimental-detect-module --trace-warnings --unhandled-rejections=warn /usr/lib/startos/init/index.js +ExecStart=/usr/bin/node --experimental-detect-module --trace-warnings /usr/lib/startos/init/index.js Restart=no [Install] diff --git a/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts b/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts index 15a97178d..10b1d7ddc 100644 --- a/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts +++ b/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts @@ -42,6 +42,74 @@ function todo(): never { throw new Error("Not implemented") } +function getStatus( + effects: Effects, + options: Omit[0], "callback"> = {}, +) { + async function* watch(abort?: AbortSignal) { + const resolveCell = { resolve: () => {} } + effects.onLeaveContext(() => { + resolveCell.resolve() + }) + abort?.addEventListener("abort", () => resolveCell.resolve()) + while (effects.isInContext && !abort?.aborted) { + let callback: () => void = () => {} + const waitForNext = new Promise((resolve) => { + callback = resolve + resolveCell.resolve = resolve + }) + yield await effects.getStatus({ ...options, callback }) + await waitForNext + } + } + return { + const: () => + effects.getStatus({ + ...options, + callback: + effects.constRetry && + (() => effects.constRetry && effects.constRetry()), + }), + once: () => effects.getStatus(options), + watch: (abort?: AbortSignal) => { + const ctrl = new AbortController() + abort?.addEventListener("abort", () => ctrl.abort()) + return watch(ctrl.signal) + }, + onChange: ( + callback: ( + value: T.StatusInfo | null, + error?: Error, + ) => { cancel: boolean } | Promise<{ cancel: boolean }>, + ) => { + ;(async () => { + const ctrl = new AbortController() + for await (const value of watch(ctrl.signal)) { + try { + const res = await callback(value) + if (res.cancel) { + ctrl.abort() + break + } + } catch (e) { + console.error( + "callback function threw an error @ getStatus.onChange", + e, + ) + } + } + })() + .catch((e) => callback(null, e as Error)) + .catch((e) => + console.error( + "callback function threw an error @ getStatus.onChange", + e, + ), + ) + }, + } +} + /** * Local type for procedure values from the manifest. * The manifest's zod schemas use ZodTypeAny casts that produce `unknown` in zod v4. @@ -1046,16 +1114,26 @@ export class SystemForEmbassy implements System { timeoutMs: number | null, ): Promise { // TODO: docker - await effects.mount({ - location: `/media/embassy/${id}`, - target: { - packageId: id, - volumeId: "embassy", - subpath: null, - readonly: true, - idmap: [], - }, - }) + const status = await getStatus(effects, { packageId: id }).const() + if (!status) return + try { + await effects.mount({ + location: `/media/embassy/${id}`, + target: { + packageId: id, + volumeId: "embassy", + subpath: null, + readonly: true, + idmap: [], + }, + }) + } catch (e) { + console.error( + `Failed to mount dependency volume for ${id}, skipping autoconfig:`, + e, + ) + return + } configFile .withPath(`/media/embassy/${id}/config.json`) .read() @@ -1204,6 +1282,11 @@ async function updateConfig( if (specValue.target === "config") { const jp = require("jsonpath") const depId = specValue["package-id"] + const depStatus = await getStatus(effects, { packageId: depId }).const() + if (!depStatus) { + mutConfigValue[key] = null + continue + } await effects.mount({ location: `/media/embassy/${depId}`, target: { diff --git a/core/Cargo.lock b/core/Cargo.lock index 739b886cf..10caba090 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -4355,7 +4355,7 @@ dependencies = [ "nix 0.30.1", "patch-db-macro", "serde", - "serde_cbor 0.11.1", + "serde_cbor_2", "thiserror 2.0.18", "tokio", "tracing", @@ -5377,7 +5377,7 @@ dependencies = [ "pin-project", "reqwest", "serde", - "serde_cbor 0.11.2", + "serde_cbor", "serde_json", "thiserror 2.0.18", "tokio", @@ -5785,19 +5785,20 @@ dependencies = [ [[package]] name = "serde_cbor" -version = "0.11.1" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" dependencies = [ "half 1.8.3", "serde", ] [[package]] -name = "serde_cbor" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +name = "serde_cbor_2" +version = "0.13.0" +source = "git+https://github.com/dr-bonez/cbor.git#2ce7fe5a5ca5700aa095668b5ba67154b7f213a4" dependencies = [ - "half 1.8.3", + "half 2.7.1", "serde", ] diff --git a/core/Cargo.toml b/core/Cargo.toml index 9937dfaa1..cb1eb3c51 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -170,9 +170,7 @@ once_cell = "1.19.0" openssh-keys = "0.6.2" openssl = { version = "0.10.57", features = ["vendored"] } p256 = { version = "0.13.2", features = ["pem"] } -patch-db = { version = "*", path = "../patch-db/patch-db", features = [ - "trace", -] } +patch-db = { version = "*", path = "../patch-db/core", features = ["trace"] } pbkdf2 = "0.12.2" pin-project = "1.1.3" pkcs8 = { version = "0.10.2", features = ["std"] } diff --git a/core/locales/i18n.yaml b/core/locales/i18n.yaml index 45a75fdc9..d0f92d305 100644 --- a/core/locales/i18n.yaml +++ b/core/locales/i18n.yaml @@ -857,6 +857,13 @@ error.set-sys-info: fr_FR: "Erreur de Définition des Infos Système" pl_PL: "Błąd Ustawiania Informacji o Systemie" +error.bios: + en_US: "BIOS/UEFI Error" + de_DE: "BIOS/UEFI-Fehler" + es_ES: "Error de BIOS/UEFI" + fr_FR: "Erreur BIOS/UEFI" + pl_PL: "Błąd BIOS/UEFI" + # disk/main.rs disk.main.disk-not-found: en_US: "StartOS disk not found." @@ -2914,6 +2921,13 @@ help.arg.log-limit: fr_FR: "Nombre maximum d'entrées de journal" pl_PL: "Maksymalna liczba wpisów logu" +help.arg.merge: + en_US: "Merge with existing version range instead of replacing" + de_DE: "Mit vorhandenem Versionsbereich zusammenführen statt ersetzen" + es_ES: "Combinar con el rango de versiones existente en lugar de reemplazar" + fr_FR: "Fusionner avec la plage de versions existante au lieu de remplacer" + pl_PL: "Połącz z istniejącym zakresem wersji zamiast zastępować" + help.arg.mirror-url: en_US: "URL of the mirror" de_DE: "URL des Spiegels" @@ -5204,12 +5218,12 @@ about.reset-user-interface-password: fr_FR: "Réinitialiser le mot de passe de l'interface utilisateur" pl_PL: "Zresetuj hasło interfejsu użytkownika" -about.reset-webserver: - en_US: "Reset the webserver" - de_DE: "Den Webserver zurücksetzen" - es_ES: "Restablecer el servidor web" - fr_FR: "Réinitialiser le serveur web" - pl_PL: "Zresetuj serwer internetowy" +about.uninitialize-webserver: + en_US: "Uninitialize the webserver" + de_DE: "Den Webserver deinitialisieren" + es_ES: "Desinicializar el servidor web" + fr_FR: "Désinitialiser le serveur web" + pl_PL: "Zdezinicjalizuj serwer internetowy" about.restart-server: en_US: "Restart the server" diff --git a/core/src/backup/backup_bulk.rs b/core/src/backup/backup_bulk.rs index 722498f3c..4c95bcad9 100644 --- a/core/src/backup/backup_bulk.rs +++ b/core/src/backup/backup_bulk.rs @@ -323,9 +323,7 @@ async fn perform_backup( os_backup_file.save().await?; let luks_folder_old = backup_guard.path().join("luks.old"); - if tokio::fs::metadata(&luks_folder_old).await.is_ok() { - tokio::fs::remove_dir_all(&luks_folder_old).await?; - } + crate::util::io::delete_dir(&luks_folder_old).await?; let luks_folder_bak = backup_guard.path().join("luks"); if tokio::fs::metadata(&luks_folder_bak).await.is_ok() { tokio::fs::rename(&luks_folder_bak, &luks_folder_old).await?; diff --git a/core/src/bins/container_cli.rs b/core/src/bins/container_cli.rs index a03204107..0f5c65226 100644 --- a/core/src/bins/container_cli.rs +++ b/core/src/bins/container_cli.rs @@ -7,10 +7,6 @@ use crate::service::cli::{ContainerCliContext, ContainerClientConfig}; use crate::util::logger::LOGGER; use crate::version::{Current, VersionT}; -lazy_static::lazy_static! { - static ref VERSION_STRING: String = Current::default().semver().to_string(); -} - pub fn main(args: impl IntoIterator) { LOGGER.enable(); if let Err(e) = CliApp::new( @@ -18,6 +14,10 @@ pub fn main(args: impl IntoIterator) { crate::service::effects::handler(), ) .mutate_command(super::translate_cli) + .mutate_command(|cmd| { + cmd.name("start-container") + .version(Current::default().semver().to_string()) + }) .run(args) { match e.data { diff --git a/core/src/bins/registry.rs b/core/src/bins/registry.rs index 13d0c54c2..49892247c 100644 --- a/core/src/bins/registry.rs +++ b/core/src/bins/registry.rs @@ -8,6 +8,7 @@ use tokio::signal::unix::signal; use tracing::instrument; use crate::context::CliContext; +use crate::version::{Current, VersionT}; use crate::context::config::ClientConfig; use crate::net::web_server::{Acceptor, WebServer}; use crate::prelude::*; @@ -101,6 +102,10 @@ pub fn cli(args: impl IntoIterator) { crate::registry::registry_api(), ) .mutate_command(super::translate_cli) + .mutate_command(|cmd| { + cmd.name("start-registry") + .version(Current::default().semver().to_string()) + }) .run(args) { match e.data { diff --git a/core/src/bins/start_cli.rs b/core/src/bins/start_cli.rs index e1d737be4..85847f110 100644 --- a/core/src/bins/start_cli.rs +++ b/core/src/bins/start_cli.rs @@ -8,10 +8,6 @@ use crate::context::config::ClientConfig; use crate::util::logger::LOGGER; use crate::version::{Current, VersionT}; -lazy_static::lazy_static! { - static ref VERSION_STRING: String = Current::default().semver().to_string(); -} - pub fn main(args: impl IntoIterator) { LOGGER.enable(); @@ -20,6 +16,10 @@ pub fn main(args: impl IntoIterator) { crate::main_api(), ) .mutate_command(super::translate_cli) + .mutate_command(|cmd| { + cmd.name("start-cli") + .version(Current::default().semver().to_string()) + }) .run(args) { match e.data { diff --git a/core/src/bins/tunnel.rs b/core/src/bins/tunnel.rs index 07db8f671..3c72e556a 100644 --- a/core/src/bins/tunnel.rs +++ b/core/src/bins/tunnel.rs @@ -13,6 +13,7 @@ use tracing::instrument; use visit_rs::Visit; use crate::context::CliContext; +use crate::version::{Current, VersionT}; use crate::context::config::ClientConfig; use crate::net::tls::TlsListener; use crate::net::web_server::{Accept, Acceptor, MetadataVisitor, WebServer}; @@ -186,6 +187,10 @@ pub fn cli(args: impl IntoIterator) { crate::tunnel::api::tunnel_api(), ) .mutate_command(super::translate_cli) + .mutate_command(|cmd| { + cmd.name("start-tunnel") + .version(Current::default().semver().to_string()) + }) .run(args) { match e.data { diff --git a/core/src/disk/mount/backup.rs b/core/src/disk/mount/backup.rs index 2c89981dc..f2b232ca0 100644 --- a/core/src/disk/mount/backup.rs +++ b/core/src/disk/mount/backup.rs @@ -53,9 +53,7 @@ impl BackupMountGuard { })?, )? } else { - if tokio::fs::metadata(&crypt_path).await.is_ok() { - tokio::fs::remove_dir_all(&crypt_path).await?; - } + crate::util::io::delete_dir(&crypt_path).await?; Default::default() }; let enc_key = if let (Some(hash), Some(wrapped_key)) = ( diff --git a/core/src/disk/mount/util.rs b/core/src/disk/mount/util.rs index 327bb2169..30b6a5435 100644 --- a/core/src/disk/mount/util.rs +++ b/core/src/disk/mount/util.rs @@ -52,13 +52,19 @@ pub async fn bind, P1: AsRef>( pub async fn unmount>(mountpoint: P, lazy: bool) -> Result<(), Error> { tracing::debug!("Unmounting {}.", mountpoint.as_ref().display()); let mut cmd = tokio::process::Command::new("umount"); + cmd.env("LANG", "C.UTF-8"); if lazy { cmd.arg("-l"); } - cmd.arg(mountpoint.as_ref()) + match cmd + .arg(mountpoint.as_ref()) .invoke(crate::ErrorKind::Filesystem) - .await?; - Ok(()) + .await + { + Ok(_) => Ok(()), + Err(e) if e.to_string().contains("not mounted") => Ok(()), + Err(e) => Err(e), + } } /// Unmounts all mountpoints under (and including) the given path, in reverse diff --git a/core/src/init.rs b/core/src/init.rs index 8b6a91625..e5792adea 100644 --- a/core/src/init.rs +++ b/core/src/init.rs @@ -291,21 +291,15 @@ pub async fn init( init_tmp.start(); let tmp_dir = Path::new(PACKAGE_DATA).join("tmp"); - if tokio::fs::metadata(&tmp_dir).await.is_ok() { - tokio::fs::remove_dir_all(&tmp_dir).await?; - } + crate::util::io::delete_dir(&tmp_dir).await?; if tokio::fs::metadata(&tmp_dir).await.is_err() { tokio::fs::create_dir_all(&tmp_dir).await?; } let tmp_var = Path::new(PACKAGE_DATA).join("tmp/var"); - if tokio::fs::metadata(&tmp_var).await.is_ok() { - tokio::fs::remove_dir_all(&tmp_var).await?; - } + crate::util::io::delete_dir(&tmp_var).await?; crate::disk::mount::util::bind(&tmp_var, "/var/tmp", false).await?; let downloading = Path::new(PACKAGE_DATA).join("archive/downloading"); - if tokio::fs::metadata(&downloading).await.is_ok() { - tokio::fs::remove_dir_all(&downloading).await?; - } + crate::util::io::delete_dir(&downloading).await?; let tmp_docker = Path::new(PACKAGE_DATA).join("tmp").join(*CONTAINER_TOOL); crate::disk::mount::util::bind(&tmp_docker, *CONTAINER_DATADIR, false).await?; init_tmp.complete(); diff --git a/core/src/net/host/mod.rs b/core/src/net/host/mod.rs index c77b4aa26..941cc15f3 100644 --- a/core/src/net/host/mod.rs +++ b/core/src/net/host/mod.rs @@ -283,7 +283,7 @@ impl Model { }; available.insert(HostnameInfo { ssl: opt.secure.map_or(false, |s| s.ssl), - public: true, + public: false, hostname: domain.clone(), port: Some(port), metadata: HostnameMetadata::PrivateDomain { gateways }, @@ -300,7 +300,7 @@ impl Model { } available.insert(HostnameInfo { ssl: true, - public: true, + public: false, hostname: domain, port: Some(port), metadata: HostnameMetadata::PrivateDomain { @@ -314,7 +314,7 @@ impl Model { { available.insert(HostnameInfo { ssl: true, - public: true, + public: false, hostname: domain, port: Some(opt.preferred_external_port), metadata: HostnameMetadata::PrivateDomain { diff --git a/core/src/registry/package/signer.rs b/core/src/registry/package/signer.rs index 47ec7b13d..ee1cbc47a 100644 --- a/core/src/registry/package/signer.rs +++ b/core/src/registry/package/signer.rs @@ -59,8 +59,7 @@ pub struct AddPackageSignerParams { #[ts(type = "string | null")] pub versions: Option, #[arg(long, help = "help.arg.merge")] - #[ts(optional)] - pub merge: Option, + pub merge: bool, } pub async fn add_package_signer( @@ -89,7 +88,7 @@ pub async fn add_package_signer( .as_authorized_mut() .upsert(&signer, || Ok(VersionRange::None))? .mutate(|existing| { - *existing = if merge.unwrap_or(false) { + *existing = if merge { VersionRange::or(existing.clone(), versions) } else { versions diff --git a/core/src/s9pk/rpc.rs b/core/src/s9pk/rpc.rs index 2fafd7e0c..e84be8406 100644 --- a/core/src/s9pk/rpc.rs +++ b/core/src/s9pk/rpc.rs @@ -17,6 +17,7 @@ use crate::s9pk::manifest::{HardwareRequirements, Manifest}; use crate::s9pk::merkle_archive::source::multi_cursor_file::MultiCursorFile; use crate::s9pk::v2::SIG_CONTEXT; use crate::s9pk::v2::pack::ImageConfig; +use crate::sign::commitment::merkle_archive::MerkleArchiveCommitment; use crate::util::io::{TmpDir, create_file, open_file}; use crate::util::serde::{HandlerExtSerde, apply_expr}; use crate::util::{Apply, Invoke}; @@ -131,6 +132,13 @@ fn inspect() -> ParentHandler { .with_display_serializable() .with_about("about.display-s9pk-manifest"), ) + .subcommand( + "commitment", + from_fn_async(inspect_commitment) + .with_inherited(only_parent) + .with_display_serializable() + .with_about("about.display-s9pk-root-sighash-and-maxsize"), + ) } #[derive(Deserialize, Serialize, Parser, TS)] @@ -262,6 +270,15 @@ async fn inspect_manifest( Ok(s9pk.as_manifest().clone()) } +async fn inspect_commitment( + _: CliContext, + _: Empty, + S9pkPath { s9pk: s9pk_path }: S9pkPath, +) -> Result { + let s9pk = super::S9pk::open(&s9pk_path, None).await?; + s9pk.as_archive().commitment().await +} + async fn convert(ctx: CliContext, S9pkPath { s9pk: s9pk_path }: S9pkPath) -> Result<(), Error> { let mut s9pk = super::load( MultiCursorFile::from(open_file(&s9pk_path).await?), diff --git a/core/src/service/effects/callbacks.rs b/core/src/service/effects/callbacks.rs index d30665c96..b50c526f8 100644 --- a/core/src/service/effects/callbacks.rs +++ b/core/src/service/effects/callbacks.rs @@ -1,6 +1,6 @@ use std::cmp::min; use std::collections::{BTreeMap, BTreeSet}; -use std::sync::{Arc, Mutex, Weak}; +use std::sync::{Arc, Weak}; use std::time::{Duration, SystemTime}; use clap::Parser; @@ -8,13 +8,12 @@ use futures::future::join_all; use imbl::{OrdMap, Vector, vector}; use imbl_value::InternedString; use patch_db::TypedDbWatch; -use patch_db::json_ptr::JsonPointer; use serde::{Deserialize, Serialize}; use tracing::warn; use ts_rs::TS; -use crate::db::model::Database; use crate::db::model::public::NetworkInterfaceInfo; +use crate::net::host::Host; use crate::net::ssl::FullchainCertData; use crate::prelude::*; use crate::service::effects::context::EffectContext; @@ -23,23 +22,104 @@ use crate::service::rpc::{CallbackHandle, CallbackId}; use crate::service::{Service, ServiceActorSeed}; use crate::util::collections::EqMap; use crate::util::future::NonDetachingJoinHandle; +use crate::util::sync::SyncMutex; +use crate::status::StatusInfo; use crate::{GatewayId, HostId, PackageId, ServiceInterfaceId}; -#[derive(Default)] -pub struct ServiceCallbacks(Mutex); +/// Abstraction for callbacks that are triggered by patchdb subscriptions. +/// +/// Handles the subscribe-wait-fire-remove pattern: when a callback is first +/// registered for a key, a patchdb subscription is spawned. When the subscription +/// fires, all handlers are consumed and invoked, then the subscription stops. +/// A new subscription is created if a handler is registered again. +pub struct DbWatchedCallbacks { + label: &'static str, + inner: SyncMutex, Vec)>>, +} + +impl DbWatchedCallbacks { + pub fn new(label: &'static str) -> Self { + Self { + label, + inner: SyncMutex::new(BTreeMap::new()), + } + } + + pub fn add( + self: &Arc, + key: K, + watch: TypedDbWatch, + handler: CallbackHandler, + ) { + self.inner.mutate(|map| { + map.entry(key.clone()) + .or_insert_with(|| { + let this = Arc::clone(self); + let k = key; + let label = self.label; + ( + tokio::spawn(async move { + let mut watch = watch.untyped(); + if watch.changed().await.is_ok() { + if let Some(cbs) = this.inner.mutate(|map| { + map.remove(&k) + .map(|(_, handlers)| CallbackHandlers(handlers)) + .filter(|cb| !cb.0.is_empty()) + }) { + let value = watch + .peek_and_mark_seen() + .unwrap_or_default(); + if let Err(e) = cbs.call(vector![value]).await { + tracing::error!("Error in {label} callback: {e}"); + tracing::debug!("{e:?}"); + } + } + } + }) + .into(), + Vec::new(), + ) + }) + .1 + .push(handler); + }) + } + + pub fn gc(&self) { + self.inner.mutate(|map| { + map.retain(|_, (_, v)| { + v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0); + !v.is_empty() + }); + }) + } +} + +pub struct ServiceCallbacks { + inner: SyncMutex, + get_host_info: Arc>, + get_status: Arc>, +} + +impl Default for ServiceCallbacks { + fn default() -> Self { + Self { + inner: SyncMutex::new(ServiceCallbackMap::default()), + get_host_info: Arc::new(DbWatchedCallbacks::new("host info")), + get_status: Arc::new(DbWatchedCallbacks::new("get_status")), + } + } +} #[derive(Default)] struct ServiceCallbackMap { get_service_interface: BTreeMap<(PackageId, ServiceInterfaceId), Vec>, list_service_interfaces: BTreeMap>, get_system_smtp: Vec, - get_host_info: - BTreeMap<(PackageId, HostId), (NonDetachingJoinHandle<()>, Vec)>, get_ssl_certificate: EqMap< (BTreeSet, FullchainCertData, Algorithm), (NonDetachingJoinHandle<()>, Vec), >, - get_status: BTreeMap>, get_container_ip: BTreeMap>, get_service_manifest: BTreeMap>, get_outbound_gateway: BTreeMap, Vec)>, @@ -47,8 +127,7 @@ struct ServiceCallbackMap { impl ServiceCallbacks { fn mutate(&self, f: impl FnOnce(&mut ServiceCallbackMap) -> T) -> T { - let mut this = self.0.lock().unwrap(); - f(&mut *this) + self.inner.mutate(f) } pub fn gc(&self) { @@ -63,18 +142,10 @@ impl ServiceCallbacks { }); this.get_system_smtp .retain(|h| h.handle.is_active() && h.seed.strong_count() > 0); - this.get_host_info.retain(|_, (_, v)| { - v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0); - !v.is_empty() - }); this.get_ssl_certificate.retain(|_, (_, v)| { v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0); !v.is_empty() }); - this.get_status.retain(|_, v| { - v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0); - !v.is_empty() - }); this.get_service_manifest.retain(|_, v| { v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0); !v.is_empty() @@ -83,7 +154,9 @@ impl ServiceCallbacks { v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0); !v.is_empty() }); - }) + }); + self.get_host_info.gc(); + self.get_status.gc(); } pub(super) fn add_get_service_interface( @@ -151,51 +224,14 @@ impl ServiceCallbacks { } pub(super) fn add_get_host_info( - self: &Arc, - db: &TypedPatchDb, + &self, package_id: PackageId, host_id: HostId, + watch: TypedDbWatch, handler: CallbackHandler, ) { - self.mutate(|this| { - this.get_host_info - .entry((package_id.clone(), host_id.clone())) - .or_insert_with(|| { - let ptr: JsonPointer = - format!("/public/packageData/{}/hosts/{}", package_id, host_id) - .parse() - .expect("valid json pointer"); - let db = db.clone(); - let callbacks = Arc::clone(self); - let key = (package_id, host_id); - ( - tokio::spawn(async move { - let mut sub = db.subscribe(ptr).await; - while sub.recv().await.is_some() { - if let Some(cbs) = callbacks.mutate(|this| { - this.get_host_info - .remove(&key) - .map(|(_, handlers)| CallbackHandlers(handlers)) - .filter(|cb| !cb.0.is_empty()) - }) { - if let Err(e) = cbs.call(vector![]).await { - tracing::error!("Error in host info callback: {e}"); - tracing::debug!("{e:?}"); - } - } - // entry was removed when we consumed handlers, - // so stop watching — a new subscription will be - // created if the service re-registers - break; - } - }) - .into(), - Vec::new(), - ) - }) - .1 - .push(handler); - }) + self.get_host_info + .add((package_id, host_id), watch, handler); } pub(super) fn add_get_ssl_certificate( @@ -256,19 +292,14 @@ impl ServiceCallbacks { .push(handler); }) } - pub(super) fn add_get_status(&self, package_id: PackageId, handler: CallbackHandler) { - self.mutate(|this| this.get_status.entry(package_id).or_default().push(handler)) - } - #[must_use] - pub fn get_status(&self, package_id: &PackageId) -> Option { - self.mutate(|this| { - if let Some(watched) = this.get_status.remove(package_id) { - Some(CallbackHandlers(watched)) - } else { - None - } - .filter(|cb| !cb.0.is_empty()) - }) + + pub(super) fn add_get_status( + &self, + package_id: PackageId, + watch: TypedDbWatch, + handler: CallbackHandler, + ) { + self.get_status.add(package_id, watch, handler); } pub(super) fn add_get_container_ip(&self, package_id: PackageId, handler: CallbackHandler) { diff --git a/core/src/service/effects/control.rs b/core/src/service/effects/control.rs index 88931812f..edaf998e8 100644 --- a/core/src/service/effects/control.rs +++ b/core/src/service/effects/control.rs @@ -80,27 +80,32 @@ pub async fn get_status( package_id, callback, }: GetStatusParams, -) -> Result { +) -> Result, Error> { let context = context.deref()?; let id = package_id.unwrap_or_else(|| context.seed.id.clone()); - let db = context.seed.ctx.db.peek().await; + + let ptr = format!("/public/packageData/{}/statusInfo", id) + .parse() + .expect("valid json pointer"); + let mut watch = context + .seed + .ctx + .db + .watch(ptr) + .await + .typed::(); + + let status = watch.peek_and_mark_seen()?.de().ok(); if let Some(callback) = callback { let callback = callback.register(&context.seed.persistent_container); context.seed.ctx.callbacks.add_get_status( id.clone(), + watch, super::callbacks::CallbackHandler::new(&context, callback), ); } - let status = db - .as_public() - .as_package_data() - .as_idx(&id) - .or_not_found(&id)? - .as_status_info() - .de()?; - Ok(status) } diff --git a/core/src/service/effects/net/host.rs b/core/src/service/effects/net/host.rs index a20fcf189..193826aac 100644 --- a/core/src/service/effects/net/host.rs +++ b/core/src/service/effects/net/host.rs @@ -23,26 +23,30 @@ pub async fn get_host_info( }: GetHostInfoParams, ) -> Result, Error> { let context = context.deref()?; - let db = context.seed.ctx.db.peek().await; let package_id = package_id.unwrap_or_else(|| context.seed.id.clone()); + let ptr = format!("/public/packageData/{}/hosts/{}", package_id, host_id) + .parse() + .expect("valid json pointer"); + let mut watch = context + .seed + .ctx + .db + .watch(ptr) + .await + .typed::(); + + let res = watch.peek_and_mark_seen()?.de().ok(); + if let Some(callback) = callback { let callback = callback.register(&context.seed.persistent_container); context.seed.ctx.callbacks.add_get_host_info( - &context.seed.ctx.db, package_id.clone(), host_id.clone(), + watch, CallbackHandler::new(&context, callback), ); } - let res = db - .as_public() - .as_package_data() - .as_idx(&package_id) - .and_then(|m| m.as_hosts().as_idx(&host_id)) - .map(|m| m.de()) - .transpose()?; - Ok(res) } diff --git a/core/src/service/effects/version.rs b/core/src/service/effects/version.rs index 185e1f629..7b82e060c 100644 --- a/core/src/service/effects/version.rs +++ b/core/src/service/effects/version.rs @@ -2,7 +2,7 @@ use std::path::Path; use crate::DATA_DIR; use crate::service::effects::prelude::*; -use crate::util::io::{delete_file, maybe_read_file_to_string, write_file_atomic}; +use crate::util::io::{delete_file, write_file_atomic}; use crate::volume::PKG_VOLUME_DIR; #[derive(Debug, Clone, Serialize, Deserialize, TS, Parser)] @@ -36,11 +36,5 @@ pub async fn set_data_version( #[instrument(skip_all)] pub async fn get_data_version(context: EffectContext) -> Result, Error> { let context = context.deref()?; - let package_id = &context.seed.id; - let path = Path::new(DATA_DIR) - .join(PKG_VOLUME_DIR) - .join(package_id) - .join("data") - .join(".version"); - maybe_read_file_to_string(path).await + crate::service::get_data_version(&context.seed.id).await } diff --git a/core/src/service/mod.rs b/core/src/service/mod.rs index f17f2d266..d904e77c9 100644 --- a/core/src/service/mod.rs +++ b/core/src/service/mod.rs @@ -46,12 +46,14 @@ use crate::service::uninstall::cleanup; use crate::util::Never; use crate::util::actor::concurrent::ConcurrentActor; use crate::util::future::NonDetachingJoinHandle; -use crate::util::io::{AsyncReadStream, AtomicFile, TermSize, delete_file}; +use crate::util::io::{ + AsyncReadStream, AtomicFile, TermSize, delete_file, maybe_read_file_to_string, +}; use crate::util::net::WebSocket; use crate::util::serde::Pem; use crate::util::sync::SyncMutex; use crate::util::tui::choose; -use crate::volume::data_dir; +use crate::volume::{PKG_VOLUME_DIR, data_dir}; use crate::{ActionId, CAP_1_KiB, DATA_DIR, ImageId, PackageId}; pub mod action; @@ -81,6 +83,17 @@ pub enum LoadDisposition { Undo, } +/// Read the data version file for a service from disk. +/// Returns `Ok(None)` if the file does not exist (fresh install). +pub async fn get_data_version(id: &PackageId) -> Result, Error> { + let path = Path::new(DATA_DIR) + .join(PKG_VOLUME_DIR) + .join(id) + .join("data") + .join(".version"); + maybe_read_file_to_string(&path).await +} + struct RootCommand(pub String); #[derive(Clone, Debug, Serialize, Deserialize, Default, TS)] @@ -390,12 +403,17 @@ impl Service { tracing::error!("Error opening s9pk for install: {e}"); tracing::debug!("{e:?}") }) { + let init_kind = if get_data_version(id).await.ok().flatten().is_some() { + InitKind::Update + } else { + InitKind::Install + }; if let Ok(service) = Self::install( ctx.clone(), s9pk, &s9pk_path, &None, - InitKind::Install, + init_kind, None::, None, ) @@ -424,12 +442,17 @@ impl Service { tracing::error!("Error opening s9pk for update: {e}"); tracing::debug!("{e:?}") }) { + let init_kind = if get_data_version(id).await.ok().flatten().is_some() { + InitKind::Update + } else { + InitKind::Install + }; if let Ok(service) = Self::install( ctx.clone(), s9pk, &s9pk_path, &None, - InitKind::Update, + init_kind, None::, None, ) diff --git a/core/src/service/rpc.rs b/core/src/service/rpc.rs index b5c8ed01c..94c15f42f 100644 --- a/core/src/service/rpc.rs +++ b/core/src/service/rpc.rs @@ -107,6 +107,12 @@ impl ExitParams { target: Some(InternedString::from_display(range)), } } + pub fn target_str(s: &str) -> Self { + Self { + id: Guid::new(), + target: Some(InternedString::intern(s)), + } + } pub fn uninstall() -> Self { Self { id: Guid::new(), diff --git a/core/src/service/service_actor.rs b/core/src/service/service_actor.rs index 4fec11a08..ed8feafdf 100644 --- a/core/src/service/service_actor.rs +++ b/core/src/service/service_actor.rs @@ -1,7 +1,6 @@ use std::sync::Arc; use std::time::Duration; -use imbl::vector; use patch_db::TypedDbWatch; use super::ServiceActorSeed; @@ -99,16 +98,9 @@ async fn service_actor_loop<'a>( seed: &'a Arc, transition: &mut Option>, ) -> Result<(), Error> { - let id = &seed.id; let status_model = watch.peek_and_mark_seen()?; let status = status_model.de()?; - if let Some(callbacks) = seed.ctx.callbacks.get_status(id) { - callbacks - .call(vector![patch_db::ModelExt::into_value(status_model)]) - .await?; - } - match status { StatusInfo { desired: DesiredStatus::Running | DesiredStatus::Restarting, diff --git a/core/src/service/service_map.rs b/core/src/service/service_map.rs index 697578a7c..5c657d562 100644 --- a/core/src/service/service_map.rs +++ b/core/src/service/service_map.rs @@ -28,7 +28,7 @@ use crate::s9pk::S9pk; use crate::s9pk::manifest::PackageId; use crate::s9pk::merkle_archive::source::FileSource; use crate::service::rpc::{ExitParams, InitKind}; -use crate::service::{LoadDisposition, Service, ServiceRef}; +use crate::service::{LoadDisposition, Service, ServiceRef, get_data_version}; use crate::sign::commitment::merkle_archive::MerkleArchiveCommitment; use crate::status::{DesiredStatus, StatusInfo}; use crate::util::future::NonDetachingJoinHandle; @@ -299,10 +299,11 @@ impl ServiceMap { s9pk.serialize(&mut progress_writer, true).await?; let (file, mut unpack_progress) = progress_writer.into_inner(); file.sync_all().await?; - unpack_progress.complete(); crate::util::io::rename(&download_path, &installed_path).await?; + unpack_progress.complete(); + Ok::<_, Error>(sync_progress_task) }) .await?; @@ -310,36 +311,50 @@ impl ServiceMap { .handle_last(async move { finalization_progress.start(); let s9pk = S9pk::open(&installed_path, Some(&id)).await?; + let data_version = get_data_version(&id).await?; let prev = if let Some(service) = service.take() { ensure_code!( recovery_source.is_none(), ErrorKind::InvalidRequest, "cannot restore over existing package" ); - let prev_version = service - .seed - .persistent_container - .s9pk - .as_manifest() - .version - .clone(); - let prev_can_migrate_to = &service - .seed - .persistent_container - .s9pk - .as_manifest() - .can_migrate_to; - let next_version = &s9pk.as_manifest().version; - let next_can_migrate_from = &s9pk.as_manifest().can_migrate_from; - let uninit = if prev_version.satisfies(next_can_migrate_from) { - ExitParams::target_version(&*prev_version) - } else if next_version.satisfies(prev_can_migrate_to) { - ExitParams::target_version(&s9pk.as_manifest().version) + let uninit = if let Some(ref data_ver) = data_version { + let prev_can_migrate_to = &service + .seed + .persistent_container + .s9pk + .as_manifest() + .can_migrate_to; + let next_version = &s9pk.as_manifest().version; + let next_can_migrate_from = &s9pk.as_manifest().can_migrate_from; + if let Ok(data_ver_ev) = data_ver.parse::() { + if data_ver_ev.satisfies(next_can_migrate_from) { + ExitParams::target_str(data_ver) + } else if next_version.satisfies(prev_can_migrate_to) { + ExitParams::target_version(&s9pk.as_manifest().version) + } else { + ExitParams::target_range(&VersionRange::and( + prev_can_migrate_to.clone(), + next_can_migrate_from.clone(), + )) + } + } else if let Ok(data_ver_range) = data_ver.parse::() { + ExitParams::target_range(&VersionRange::and( + data_ver_range, + next_can_migrate_from.clone(), + )) + } else if next_version.satisfies(prev_can_migrate_to) { + ExitParams::target_version(&s9pk.as_manifest().version) + } else { + ExitParams::target_range(&VersionRange::and( + prev_can_migrate_to.clone(), + next_can_migrate_from.clone(), + )) + } } else { - ExitParams::target_range(&VersionRange::and( - prev_can_migrate_to.clone(), - next_can_migrate_from.clone(), - )) + ExitParams::target_version( + &*service.seed.persistent_container.s9pk.as_manifest().version, + ) }; let cleanup = service.uninstall(uninit, false, false).await?; progress.complete(); @@ -354,7 +369,7 @@ impl ServiceMap { ®istry, if recovery_source.is_some() { InitKind::Restore - } else if prev.is_some() { + } else if data_version.is_some() { InitKind::Update } else { InitKind::Install diff --git a/core/src/service/uninstall.rs b/core/src/service/uninstall.rs index 2f6515024..ec8b92468 100644 --- a/core/src/service/uninstall.rs +++ b/core/src/service/uninstall.rs @@ -101,13 +101,11 @@ pub async fn cleanup(ctx: &RpcContext, id: &PackageId, soft: bool) -> Result<(), if !soft { let path = Path::new(DATA_DIR).join(PKG_VOLUME_DIR).join(&manifest.id); - if tokio::fs::metadata(&path).await.is_ok() { - tokio::fs::remove_dir_all(&path).await?; - } - let logs_dir = Path::new(PACKAGE_DATA).join("logs").join(&manifest.id); - if tokio::fs::metadata(&logs_dir).await.is_ok() { - #[cfg(not(feature = "dev"))] - tokio::fs::remove_dir_all(&logs_dir).await?; + crate::util::io::delete_dir(&path).await?; + #[cfg(not(feature = "dev"))] + { + let logs_dir = Path::new(PACKAGE_DATA).join("logs").join(&manifest.id); + crate::util::io::delete_dir(&logs_dir).await?; } } }, diff --git a/core/src/setup.rs b/core/src/setup.rs index 2166cfc08..850647752 100644 --- a/core/src/setup.rs +++ b/core/src/setup.rs @@ -738,9 +738,7 @@ async fn migrate( ); let tmpdir = Path::new(package_data_transfer_args.0).join("tmp"); - if tokio::fs::metadata(&tmpdir).await.is_ok() { - tokio::fs::remove_dir_all(&tmpdir).await?; - } + crate::util::io::delete_dir(&tmpdir).await?; let ordering = std::sync::atomic::Ordering::Relaxed; diff --git a/core/src/tunnel/web.rs b/core/src/tunnel/web.rs index 04e7f84c0..7e3eec70d 100644 --- a/core/src/tunnel/web.rs +++ b/core/src/tunnel/web.rs @@ -168,10 +168,10 @@ pub fn web_api() -> ParentHandler { .with_call_remote::(), ) .subcommand( - "reset", + "uninit", from_fn_async(reset_web) .no_display() - .with_about("about.reset-webserver") + .with_about("about.uninitialize-webserver") .with_call_remote::(), ) } diff --git a/core/src/util/io.rs b/core/src/util/io.rs index 99940c373..f1478e8b0 100644 --- a/core/src/util/io.rs +++ b/core/src/util/io.rs @@ -1047,6 +1047,20 @@ pub async fn delete_file(path: impl AsRef) -> Result<(), Error> { .with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("delete {path:?}"))) } +pub async fn delete_dir(path: impl AsRef) -> Result<(), Error> { + let path = path.as_ref(); + tokio::fs::remove_dir_all(path) + .await + .or_else(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + Ok(()) + } else { + Err(e) + } + }) + .with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("delete dir {path:?}"))) +} + #[instrument(skip_all)] pub async fn rename(src: impl AsRef, dst: impl AsRef) -> Result<(), Error> { let src = src.as_ref(); diff --git a/core/start-tunneld.service b/core/start-tunneld.service index b0d0a2043..402326614 100644 --- a/core/start-tunneld.service +++ b/core/start-tunneld.service @@ -1,5 +1,7 @@ [Unit] Description=StartTunnel +After=network-online.target +Wants=network-online.target [Service] Type=simple diff --git a/debian/startos/postinst b/debian/startos/postinst index 246589f57..24a433c2b 100755 --- a/debian/startos/postinst +++ b/debian/startos/postinst @@ -28,13 +28,12 @@ if [ -f /etc/default/grub ]; then sed -i '/\(^\|#\)'"$1"'=/d' /etc/default/grub printf '%s="%s"\n' "$1" "$2" >> /etc/default/grub } - # Enable both graphical and serial terminal output - grub_set GRUB_TERMINAL_INPUT 'console serial' - grub_set GRUB_TERMINAL_OUTPUT 'gfxterm serial' - # Remove GRUB_TERMINAL if present (replaced by INPUT/OUTPUT above) + # Graphical terminal (serial added conditionally via /etc/grub.d/01_serial) + grub_set GRUB_TERMINAL_INPUT 'console' + grub_set GRUB_TERMINAL_OUTPUT 'gfxterm' + # Remove GRUB_TERMINAL and GRUB_SERIAL_COMMAND if present sed -i '/^\(#\|\)GRUB_TERMINAL=/d' /etc/default/grub - # Serial console settings - grub_set GRUB_SERIAL_COMMAND 'serial --unit=0 --speed=115200 --word=8 --parity=no --stop=1' + sed -i '/^\(#\|\)GRUB_SERIAL_COMMAND=/d' /etc/default/grub # Graphics mode and splash background grub_set GRUB_GFXMODE 800x600 grub_set GRUB_GFXPAYLOAD_LINUX keep @@ -49,6 +48,24 @@ if [ -f /etc/default/grub ]; then mkdir -p /boot/grub/startos-theme cp -r /usr/lib/startos/grub-theme/* /boot/grub/startos-theme/ fi + # Copy font to boot partition so GRUB can load it without accessing rootfs + if [ -f /usr/share/grub/unicode.pf2 ]; then + mkdir -p /boot/grub/fonts + cp /usr/share/grub/unicode.pf2 /boot/grub/fonts/unicode.pf2 + fi + # Install conditional serial console script for GRUB + cat > /etc/grub.d/01_serial << 'GRUBEOF' +#!/bin/sh +cat << 'EOF' +# Conditionally enable serial console (avoids breaking gfxterm on EFI +# systems where the serial port is unavailable) +if serial --unit=0 --speed=115200 --word=8 --parity=no --stop=1; then + terminal_input console serial + terminal_output gfxterm serial +fi +EOF +GRUBEOF + chmod +x /etc/grub.d/01_serial fi VERSION="$(cat /usr/lib/startos/VERSION.txt)" diff --git a/patch-db b/patch-db index 05c93290c..12227eab1 160000 --- a/patch-db +++ b/patch-db @@ -1 +1 @@ -Subproject commit 05c93290c759bdf5e7308a24cf0d4a440ed287a0 +Subproject commit 12227eab18ec2f56b66fa16f3e49411a6eaae6f2 diff --git a/sdk/base/lib/Effects.ts b/sdk/base/lib/Effects.ts index d3d0b8923..554390654 100644 --- a/sdk/base/lib/Effects.ts +++ b/sdk/base/lib/Effects.ts @@ -69,7 +69,7 @@ export type Effects = { getStatus(options: { packageId?: PackageId callback?: () => void - }): Promise + }): Promise /** DEPRECATED: indicate to the host os what runstate the service is in */ setMainStatus(options: SetMainStatus): Promise diff --git a/sdk/base/lib/util/GetContainerIp.ts b/sdk/base/lib/util/GetContainerIp.ts new file mode 100644 index 000000000..dfec071ad --- /dev/null +++ b/sdk/base/lib/util/GetContainerIp.ts @@ -0,0 +1,18 @@ +import { Effects } from '../Effects' +import { PackageId } from '../osBindings' +import { Watchable } from './Watchable' + +export class GetContainerIp extends Watchable { + protected readonly label = 'GetContainerIp' + + constructor( + effects: Effects, + readonly opts: { packageId?: PackageId } = {}, + ) { + super(effects) + } + + protected call(callback?: () => void) { + return this.effects.getContainerIp({ ...this.opts, callback }) + } +} diff --git a/sdk/base/lib/util/GetHostInfo.ts b/sdk/base/lib/util/GetHostInfo.ts new file mode 100644 index 000000000..ef67f03fc --- /dev/null +++ b/sdk/base/lib/util/GetHostInfo.ts @@ -0,0 +1,18 @@ +import { Effects } from '../Effects' +import { Host, HostId, PackageId } from '../osBindings' +import { Watchable } from './Watchable' + +export class GetHostInfo extends Watchable { + protected readonly label = 'GetHostInfo' + + constructor( + effects: Effects, + readonly opts: { hostId: HostId; packageId?: PackageId }, + ) { + super(effects) + } + + protected call(callback?: () => void) { + return this.effects.getHostInfo({ ...this.opts, callback }) + } +} diff --git a/sdk/base/lib/util/GetOutboundGateway.ts b/sdk/base/lib/util/GetOutboundGateway.ts index 460bb8b90..5a4cecb50 100644 --- a/sdk/base/lib/util/GetOutboundGateway.ts +++ b/sdk/base/lib/util/GetOutboundGateway.ts @@ -1,106 +1,14 @@ import { Effects } from '../Effects' -import { AbortedError } from './AbortedError' -import { DropGenerator, DropPromise } from './Drop' +import { Watchable } from './Watchable' -export class GetOutboundGateway { - constructor(readonly effects: Effects) {} +export class GetOutboundGateway extends Watchable { + protected readonly label = 'GetOutboundGateway' - /** - * Returns the effective outbound gateway. Reruns the context from which it has been called if the underlying value changes - */ - const() { - return this.effects.getOutboundGateway({ - callback: - this.effects.constRetry && - (() => this.effects.constRetry && this.effects.constRetry()), - }) - } - /** - * Returns the effective outbound gateway. Does nothing if the value changes - */ - once() { - return this.effects.getOutboundGateway({}) - } - - private async *watchGen(abort?: AbortSignal) { - const resolveCell = { resolve: () => {} } - this.effects.onLeaveContext(() => { - resolveCell.resolve() - }) - abort?.addEventListener('abort', () => resolveCell.resolve()) - while (this.effects.isInContext && !abort?.aborted) { - let callback: () => void = () => {} - const waitForNext = new Promise((resolve) => { - callback = resolve - resolveCell.resolve = resolve - }) - yield await this.effects.getOutboundGateway({ - callback: () => callback(), - }) - await waitForNext - } - return new Promise((_, rej) => rej(new AbortedError())) - } - - /** - * Watches the effective outbound gateway. Returns an async iterator that yields whenever the value changes - */ - watch(abort?: AbortSignal): AsyncGenerator { - const ctrl = new AbortController() - abort?.addEventListener('abort', () => ctrl.abort()) - return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) - } - - /** - * Watches the effective outbound gateway. Takes a custom callback function to run whenever the value changes - */ - onChange( - callback: ( - value: string, - error?: Error, - ) => { cancel: boolean } | Promise<{ cancel: boolean }>, - ) { - ;(async () => { - const ctrl = new AbortController() - for await (const value of this.watch(ctrl.signal)) { - try { - const res = await callback(value) - if (res.cancel) { - ctrl.abort() - break - } - } catch (e) { - console.error( - 'callback function threw an error @ GetOutboundGateway.onChange', - e, - ) - } - } - })() - .catch((e) => callback('', e)) - .catch((e) => - console.error( - 'callback function threw an error @ GetOutboundGateway.onChange', - e, - ), - ) + constructor(effects: Effects) { + super(effects) } - /** - * Watches the effective outbound gateway. Returns when the predicate is true - */ - waitFor(pred: (value: string) => boolean): Promise { - const ctrl = new AbortController() - return DropPromise.of( - Promise.resolve().then(async () => { - for await (const next of this.watchGen(ctrl.signal)) { - if (pred(next)) { - return next - } - } - return '' - }), - () => ctrl.abort(), - ) + protected call(callback?: () => void) { + return this.effects.getOutboundGateway({ callback }) } } diff --git a/sdk/base/lib/util/GetServiceManifest.ts b/sdk/base/lib/util/GetServiceManifest.ts new file mode 100644 index 000000000..7a99e5aa0 --- /dev/null +++ b/sdk/base/lib/util/GetServiceManifest.ts @@ -0,0 +1,18 @@ +import { Effects } from '../Effects' +import { Manifest, PackageId } from '../osBindings' +import { Watchable } from './Watchable' + +export class GetServiceManifest extends Watchable { + protected readonly label = 'GetServiceManifest' + + constructor( + effects: Effects, + readonly opts: { packageId: PackageId }, + ) { + super(effects) + } + + protected call(callback?: () => void) { + return this.effects.getServiceManifest({ ...this.opts, callback }) + } +} diff --git a/sdk/base/lib/util/GetSslCertificate.ts b/sdk/base/lib/util/GetSslCertificate.ts new file mode 100644 index 000000000..08d5b10c0 --- /dev/null +++ b/sdk/base/lib/util/GetSslCertificate.ts @@ -0,0 +1,20 @@ +import { Effects } from '../Effects' +import { Watchable } from './Watchable' + +export class GetSslCertificate extends Watchable<[string, string, string]> { + protected readonly label = 'GetSslCertificate' + + constructor( + effects: Effects, + readonly opts: { + hostnames: string[] + algorithm?: 'ecdsa' | 'ed25519' + }, + ) { + super(effects) + } + + protected call(callback?: () => void) { + return this.effects.getSslCertificate({ ...this.opts, callback }) + } +} diff --git a/sdk/base/lib/util/GetStatus.ts b/sdk/base/lib/util/GetStatus.ts new file mode 100644 index 000000000..365217977 --- /dev/null +++ b/sdk/base/lib/util/GetStatus.ts @@ -0,0 +1,18 @@ +import { Effects } from '../Effects' +import { PackageId, StatusInfo } from '../osBindings' +import { Watchable } from './Watchable' + +export class GetStatus extends Watchable { + protected readonly label = 'GetStatus' + + constructor( + effects: Effects, + readonly opts: { packageId?: PackageId } = {}, + ) { + super(effects) + } + + protected call(callback?: () => void) { + return this.effects.getStatus({ ...this.opts, callback }) + } +} diff --git a/sdk/base/lib/util/GetSystemSmtp.ts b/sdk/base/lib/util/GetSystemSmtp.ts index 03cedba6f..b45263549 100644 --- a/sdk/base/lib/util/GetSystemSmtp.ts +++ b/sdk/base/lib/util/GetSystemSmtp.ts @@ -1,111 +1,15 @@ import { Effects } from '../Effects' import * as T from '../types' -import { AbortedError } from './AbortedError' -import { DropGenerator, DropPromise } from './Drop' +import { Watchable } from './Watchable' -export class GetSystemSmtp { - constructor(readonly effects: Effects) {} +export class GetSystemSmtp extends Watchable { + protected readonly label = 'GetSystemSmtp' - /** - * Returns the system SMTP credentials. Reruns the context from which it has been called if the underlying value changes - */ - const() { - return this.effects.getSystemSmtp({ - callback: - this.effects.constRetry && - (() => this.effects.constRetry && this.effects.constRetry()), - }) - } - /** - * Returns the system SMTP credentials. Does nothing if the credentials change - */ - once() { - return this.effects.getSystemSmtp({}) - } - - private async *watchGen(abort?: AbortSignal) { - const resolveCell = { resolve: () => {} } - this.effects.onLeaveContext(() => { - resolveCell.resolve() - }) - abort?.addEventListener('abort', () => resolveCell.resolve()) - while (this.effects.isInContext && !abort?.aborted) { - let callback: () => void = () => {} - const waitForNext = new Promise((resolve) => { - callback = resolve - resolveCell.resolve = resolve - }) - yield await this.effects.getSystemSmtp({ - callback: () => callback(), - }) - await waitForNext - } - return new Promise((_, rej) => rej(new AbortedError())) - } - - /** - * Watches the system SMTP credentials. Returns an async iterator that yields whenever the value changes - */ - watch( - abort?: AbortSignal, - ): AsyncGenerator { - const ctrl = new AbortController() - abort?.addEventListener('abort', () => ctrl.abort()) - return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) - } - - /** - * Watches the system SMTP credentials. Takes a custom callback function to run whenever the credentials change - */ - onChange( - callback: ( - value: T.SmtpValue | null, - error?: Error, - ) => { cancel: boolean } | Promise<{ cancel: boolean }>, - ) { - ;(async () => { - const ctrl = new AbortController() - for await (const value of this.watch(ctrl.signal)) { - try { - const res = await callback(value) - if (res.cancel) { - ctrl.abort() - break - } - } catch (e) { - console.error( - 'callback function threw an error @ GetSystemSmtp.onChange', - e, - ) - } - } - })() - .catch((e) => callback(null, e)) - .catch((e) => - console.error( - 'callback function threw an error @ GetSystemSmtp.onChange', - e, - ), - ) + constructor(effects: Effects) { + super(effects) } - /** - * Watches the system SMTP credentials. Returns when the predicate is true - */ - waitFor( - pred: (value: T.SmtpValue | null) => boolean, - ): Promise { - const ctrl = new AbortController() - return DropPromise.of( - Promise.resolve().then(async () => { - for await (const next of this.watchGen(ctrl.signal)) { - if (pred(next)) { - return next - } - } - return null - }), - () => ctrl.abort(), - ) + protected call(callback?: () => void) { + return this.effects.getSystemSmtp({ callback }) } } diff --git a/sdk/base/lib/util/Watchable.ts b/sdk/base/lib/util/Watchable.ts new file mode 100644 index 000000000..e6e5fc427 --- /dev/null +++ b/sdk/base/lib/util/Watchable.ts @@ -0,0 +1,107 @@ +import { Effects } from '../Effects' +import { AbortedError } from './AbortedError' +import { DropGenerator, DropPromise } from './Drop' + +export abstract class Watchable { + constructor(readonly effects: Effects) {} + + protected abstract call(callback?: () => void): Promise + protected abstract readonly label: string + + /** + * Returns the value. Reruns the context from which it has been called if the underlying value changes + */ + const(): Promise { + return this.call( + this.effects.constRetry && + (() => this.effects.constRetry && this.effects.constRetry()), + ) + } + + /** + * Returns the value. Does nothing if the value changes + */ + once(): Promise { + return this.call() + } + + private async *watchGen(abort?: AbortSignal) { + const resolveCell = { resolve: () => {} } + this.effects.onLeaveContext(() => { + resolveCell.resolve() + }) + abort?.addEventListener('abort', () => resolveCell.resolve()) + while (this.effects.isInContext && !abort?.aborted) { + let callback: () => void = () => {} + const waitForNext = new Promise((resolve) => { + callback = resolve + resolveCell.resolve = resolve + }) + yield await this.call(() => callback()) + await waitForNext + } + return new Promise((_, rej) => rej(new AbortedError())) + } + + /** + * Watches the value. Returns an async iterator that yields whenever the value changes + */ + watch(abort?: AbortSignal): AsyncGenerator { + const ctrl = new AbortController() + abort?.addEventListener('abort', () => ctrl.abort()) + return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) + } + + /** + * Watches the value. Takes a custom callback function to run whenever the value changes + */ + onChange( + callback: ( + value: T | undefined, + error?: Error, + ) => { cancel: boolean } | Promise<{ cancel: boolean }>, + ) { + ;(async () => { + const ctrl = new AbortController() + for await (const value of this.watch(ctrl.signal)) { + try { + const res = await callback(value) + if (res.cancel) { + ctrl.abort() + break + } + } catch (e) { + console.error( + `callback function threw an error @ ${this.label}.onChange`, + e, + ) + } + } + })() + .catch((e) => callback(undefined, e)) + .catch((e) => + console.error( + `callback function threw an error @ ${this.label}.onChange`, + e, + ), + ) + } + + /** + * Watches the value. Returns when the predicate is true + */ + waitFor(pred: (value: T) => boolean): Promise { + const ctrl = new AbortController() + return DropPromise.of( + Promise.resolve().then(async () => { + for await (const next of this.watchGen(ctrl.signal)) { + if (pred(next)) { + return next + } + } + throw new AbortedError() + }), + () => ctrl.abort(), + ) + } +} diff --git a/sdk/base/lib/util/getServiceInterface.ts b/sdk/base/lib/util/getServiceInterface.ts index e0cedc529..944e1c6b6 100644 --- a/sdk/base/lib/util/getServiceInterface.ts +++ b/sdk/base/lib/util/getServiceInterface.ts @@ -36,6 +36,7 @@ export const getHostname = (url: string): Hostname | null => { * - `'ipv6'` — IPv6 addresses only * - `'localhost'` — loopback addresses (`localhost`, `127.0.0.1`, `::1`) * - `'link-local'` — IPv6 link-local addresses (fe80::/10) + * - `'bridge'` — The LXC bridge interface * - `'plugin'` — hostnames provided by a plugin package */ type FilterKinds = @@ -46,6 +47,7 @@ type FilterKinds = | 'ipv6' | 'localhost' | 'link-local' + | 'bridge' | 'plugin' /** @@ -120,7 +122,11 @@ type FilterReturnTy = F extends { const nonLocalFilter = { exclude: { - kind: ['localhost', 'link-local'] as ('localhost' | 'link-local')[], + kind: ['localhost', 'link-local', 'bridge'] as ( + | 'localhost' + | 'link-local' + | 'bridge' + )[], }, } as const const publicFilter = { @@ -284,6 +290,9 @@ function filterRec( (kind.has('link-local') && h.metadata.kind === 'ipv6' && IPV6_LINK_LOCAL.contains(IpAddress.parse(h.hostname))) || + (kind.has('bridge') && + h.metadata.kind === 'ipv4' && + h.metadata.gateway === 'lxcbr0') || (kind.has('plugin') && h.metadata.kind === 'plugin')), ) } diff --git a/sdk/base/lib/util/index.ts b/sdk/base/lib/util/index.ts index e156cb97b..3c4606f14 100644 --- a/sdk/base/lib/util/index.ts +++ b/sdk/base/lib/util/index.ts @@ -15,7 +15,13 @@ export { once } from './once' export { asError } from './asError' export * as Patterns from './patterns' export * from './typeHelpers' +export { Watchable } from './Watchable' +export { GetContainerIp } from './GetContainerIp' +export { GetHostInfo } from './GetHostInfo' export { GetOutboundGateway } from './GetOutboundGateway' +export { GetServiceManifest } from './GetServiceManifest' +export { GetSslCertificate } from './GetSslCertificate' +export { GetStatus } from './GetStatus' export { GetSystemSmtp } from './GetSystemSmtp' export { Graph, Vertex } from './graph' export { inMs } from './inMs' diff --git a/sdk/package/lib/version/VersionGraph.ts b/sdk/package/lib/version/VersionGraph.ts index 84d24269e..2b82c67c3 100644 --- a/sdk/package/lib/version/VersionGraph.ts +++ b/sdk/package/lib/version/VersionGraph.ts @@ -331,6 +331,9 @@ export class VersionGraph target: VersionRange | ExtendedVersion | null, ): Promise { if (target) { + if (isRange(target) && !target.satisfiable()) { + return + } const from = await getDataVersion(effects) if (from) { target = await this.migrate({