Files
start-os/core/startos/src/install/mod.rs
Matt Hill add01ebc68 Gateways, domains, and new service interface (#3001)
* add support for inbound proxies

* backend changes

* fix file type

* proxy -> tunnel, implement backend apis

* wip start-tunneld

* add domains and gateways, remove routers, fix docs links

* dont show hidden actions

* show and test dns

* edit instead of chnage acme and change gateway

* refactor: domains page

* refactor: gateways page

* domains and acme refactor

* certificate authorities

* refactor public/private gateways

* fix fe types

* domains mostly finished

* refactor: add file control to form service

* add ip util to sdk

* domains api + migration

* start service interface page, WIP

* different options for clearnet domains

* refactor: styles for interfaces page

* minor

* better placeholder for no addresses

* start sorting addresses

* best address logic

* comments

* fix unnecessary export

* MVP of service interface page

* domains preferred

* fix: address comments

* only translations left

* wip: start-tunnel & fix build

* forms for adding domain, rework things based on new ideas

* fix: dns testing

* public domain, max width, descriptions for dns

* nix StartOS domains, implement public and private domains at interface scope

* restart tor instead of reset

* better icon for restart tor

* dns

* fix sort functions for public and private domains

* with todos

* update types

* clean up tech debt, bump dependencies

* revert to ts-rs v9

* fix all types

* fix dns form

* add missing translations

* it builds

* fix: comments (#3009)

* fix: comments

* undo default

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>

* fix: refactor legacy components (#3010)

* fix: comments

* fix: refactor legacy components

* remove default again

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>

* more translations

* wip

* fix deadlock

* coukd work

* simple renaming

* placeholder for empty service interfaces table

* honor hidden form values

* remove logs

* reason instead of description

* fix dns

* misc fixes

* implement toggling gateways for service interface

* fix showing dns records

* move status column in service list

* remove unnecessary truthy check

* refactor: refactor forms components and remove legacy Taiga UI package (#3012)

* handle wh file uploads

* wip: debugging tor

* socks5 proxy working

* refactor: fix multiple comments (#3013)

* refactor: fix multiple comments

* styling changes, add documentation to sidebar

* translations for dns page

* refactor: subtle colors

* rearrange service page

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>

* fix file_stream and remove non-terminating test

* clean  up logs

* support for sccache

* fix gha sccache

* more marketplace translations

* install wizard clarity

* stub hostnameInfo in migration

* fix address info after setup, fix styling on SI page, new 040 release notes

* remove tor logs from os

* misc fixes

* reset tor still not functioning...

* update ts

* minor styling and wording

* chore: some fixes (#3015)

* fix gateway renames

* different handling for public domains

* styling fixes

* whole navbar should not be clickable on service show page

* timeout getState request

* remove links from changelog

* misc fixes from pairing

* use custom name for gateway in more places

* fix dns parsing

* closes #3003

* closes #2999

* chore: some fixes (#3017)

* small copy change

* revert hardcoded error for testing

* dont require port forward if gateway is public

* use old wan ip when not available

* fix .const hanging on undefined

* fix test

* fix doc test

* fix renames

* update deps

* allow specifying dependency metadata directly

* temporarily make dependencies not cliackable in marketplace listings

* fix socks bind

* fix test

---------

Co-authored-by: Aiden McClelland <me@drbonez.dev>
Co-authored-by: waterplea <alexander@inkin.ru>
2025-09-10 03:43:51 +00:00

573 lines
19 KiB
Rust

use std::ops::Deref;
use std::path::PathBuf;
use std::time::Duration;
use axum::extract::ws;
use clap::builder::ValueParserFactory;
use clap::{CommandFactory, FromArgMatches, Parser, value_parser};
use color_eyre::eyre::eyre;
use exver::VersionRange;
use futures::{AsyncWriteExt, StreamExt};
use imbl_value::{InternedString, json};
use itertools::Itertools;
use models::{FromStrParser, VersionString};
use reqwest::Url;
use reqwest::header::{CONTENT_LENGTH, HeaderMap};
use rpc_toolkit::HandlerArgs;
use rpc_toolkit::yajrc::RpcError;
use rustyline_async::ReadlineEvent;
use serde::{Deserialize, Serialize};
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
use tracing::instrument;
use ts_rs::TS;
use crate::context::{CliContext, RpcContext};
use crate::db::model::package::{ManifestPreference, PackageStateMatchModelRef};
use crate::prelude::*;
use crate::progress::{FullProgress, FullProgressTracker, PhasedProgressBar};
use crate::registry::context::{RegistryContext, RegistryUrlParams};
use crate::registry::package::get::GetPackageResponse;
use crate::rpc_continuations::{Guid, RpcContinuation};
use crate::s9pk::manifest::PackageId;
use crate::upload::upload;
use crate::util::Never;
use crate::util::io::open_file;
use crate::util::net::WebSocketExt;
pub const PKG_ARCHIVE_DIR: &str = "package-data/archive";
pub const PKG_PUBLIC_DIR: &str = "package-data/public";
pub const PKG_WASM_DIR: &str = "package-data/wasm";
// #[command(display(display_serializable))]
pub async fn list(ctx: RpcContext) -> Result<Vec<Value>, Error> {
Ok(ctx
.db
.peek()
.await
.as_public()
.as_package_data()
.as_entries()?
.iter()
.filter_map(|(id, pde)| {
let status = match pde.as_state_info().as_match() {
PackageStateMatchModelRef::Installed(_) => "installed",
PackageStateMatchModelRef::Installing(_) => "installing",
PackageStateMatchModelRef::Updating(_) => "updating",
PackageStateMatchModelRef::Restoring(_) => "restoring",
PackageStateMatchModelRef::Removing(_) => "removing",
PackageStateMatchModelRef::Error(_) => "error",
};
Some(json!({
"status": status,
"id": id.clone(),
"version": pde.as_state_info()
.as_manifest(ManifestPreference::Old)
.as_version()
.de()
.ok()?
}))
})
.collect())
}
#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub enum MinMax {
Min,
Max,
}
impl Default for MinMax {
fn default() -> Self {
MinMax::Max
}
}
impl std::str::FromStr for MinMax {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"min" => Ok(MinMax::Min),
"max" => Ok(MinMax::Max),
_ => Err(Error::new(
eyre!("Must be one of \"min\", \"max\"."),
crate::ErrorKind::ParseVersion,
)),
}
}
}
impl ValueParserFactory for MinMax {
type Parser = FromStrParser<Self>;
fn value_parser() -> Self::Parser {
FromStrParser::new()
}
}
impl std::fmt::Display for MinMax {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MinMax::Min => write!(f, "min"),
MinMax::Max => write!(f, "max"),
}
}
}
#[derive(Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct InstallParams {
#[ts(type = "string")]
registry: Url,
id: PackageId,
version: VersionString,
}
#[instrument(skip_all)]
pub async fn install(
ctx: RpcContext,
InstallParams {
registry,
id,
version,
}: InstallParams,
) -> Result<(), Error> {
let package: GetPackageResponse = from_value(
ctx.call_remote_with::<RegistryContext, _>(
"package.get",
json!({
"id": id,
"targetVersion": VersionRange::exactly(version.deref().clone()),
}),
RegistryUrlParams {
registry: registry.clone(),
},
)
.await?,
)?;
let asset = &package
.best
.get(&version)
.ok_or_else(|| {
Error::new(
eyre!("{id}@{version} not found on {registry}"),
ErrorKind::NotFound,
)
})?
.s9pk;
let progress_tracker = FullProgressTracker::new();
let download_progress = progress_tracker.add_phase("Downloading".into(), Some(100));
let download = ctx
.services
.install(
ctx.clone(),
|| asset.deserialize_s9pk_buffered(ctx.client.clone(), download_progress),
Some(registry),
None::<Never>,
Some(progress_tracker),
)
.await?;
tokio::spawn(async move { download.await?.await });
Ok(())
}
#[derive(Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct SideloadParams {
#[ts(skip)]
#[serde(rename = "__auth_session")]
session: Option<InternedString>,
}
#[derive(Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct SideloadResponse {
pub upload: Guid,
pub progress: Guid,
}
#[instrument(skip_all)]
pub async fn sideload(
ctx: RpcContext,
SideloadParams { session }: SideloadParams,
) -> Result<SideloadResponse, Error> {
let (err_send, mut err_recv) = oneshot::channel::<Error>();
let progress = Guid::new();
let progress_tracker = FullProgressTracker::new();
let (upload, file) = upload(
&ctx,
session.clone(),
progress_tracker.add_phase("Uploading".into(), Some(100)),
)
.await?;
let mut progress_listener = progress_tracker.stream(Some(Duration::from_millis(200)));
ctx.rpc_continuations
.add(
progress.clone(),
RpcContinuation::ws_authed(
&ctx,
session,
|mut ws| async move {
if let Err(e) = async {
loop {
tokio::select! {
progress = progress_listener.next() => {
if let Some(progress) = progress {
ws.send(ws::Message::Text(
serde_json::to_string(&progress)
.with_kind(ErrorKind::Serialization)?
.into(),
))
.await
.with_kind(ErrorKind::Network)?;
if progress.overall.is_complete() {
return ws.normal_close("complete").await;
}
} else {
return ws.normal_close("complete").await;
}
}
msg = ws.recv() => {
if msg.transpose().with_kind(ErrorKind::Network)?.is_none() {
return Ok(())
}
}
err = (&mut err_recv) => {
if let Ok(e) = err {
ws.close_result(Err::<&str, _>(e.clone_output())).await?;
return Err(e)
}
}
}
}
}
.await
{
tracing::error!("Error tracking sideload progress: {e}");
tracing::debug!("{e:?}");
}
},
Duration::from_secs(600),
),
)
.await;
tokio::spawn(async move {
if let Err(e) = async {
let key = ctx.db.peek().await.into_private().into_developer_key();
ctx.services
.install(
ctx.clone(),
|| crate::s9pk::load(file.clone(), || Ok(key.de()?.0), Some(&progress_tracker)),
None,
None::<Never>,
Some(progress_tracker.clone()),
)
.await?
.await?
.await?;
file.delete().await
}
.await
{
tracing::error!("Error sideloading package: {e}");
tracing::debug!("{e:?}");
let _ = err_send.send(e);
}
});
Ok(SideloadResponse { upload, progress })
}
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
#[command(rename_all = "kebab-case")]
pub struct CancelInstallParams {
pub id: PackageId,
}
#[instrument(skip_all)]
pub fn cancel_install(
ctx: RpcContext,
CancelInstallParams { id }: CancelInstallParams,
) -> Result<(), Error> {
if let Some(cancel) = ctx.cancellable_installs.mutate(|c| c.remove(&id)) {
cancel.send(()).ok();
}
Ok(())
}
#[derive(Deserialize, Serialize, Parser)]
pub struct QueryPackageParams {
id: PackageId,
version: Option<VersionRange>,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum CliInstallParams {
Marketplace(QueryPackageParams),
Sideload(PathBuf),
}
impl CommandFactory for CliInstallParams {
fn command() -> clap::Command {
use clap::{Arg, Command};
Command::new("install")
.arg(
Arg::new("sideload")
.long("sideload")
.short('s')
.required_unless_present("id")
.value_parser(value_parser!(PathBuf)),
)
.args(
QueryPackageParams::command()
.get_arguments()
.cloned()
.map(|a| {
if a.get_id() == "id" {
a.required(false).required_unless_present("sideload")
} else {
a
}
.conflicts_with("sideload")
}),
)
}
fn command_for_update() -> clap::Command {
Self::command()
}
}
impl FromArgMatches for CliInstallParams {
fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error> {
if let Some(sideload) = matches.get_one::<PathBuf>("sideload") {
Ok(Self::Sideload(sideload.clone()))
} else {
Ok(Self::Marketplace(QueryPackageParams::from_arg_matches(
matches,
)?))
}
}
fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> {
*self = Self::from_arg_matches(matches)?;
Ok(())
}
}
#[derive(Deserialize, Serialize, Parser, TS)]
#[ts(export)]
pub struct InstalledVersionParams {
id: PackageId,
}
pub async fn installed_version(
ctx: RpcContext,
InstalledVersionParams { id }: InstalledVersionParams,
) -> Result<Option<VersionString>, Error> {
if let Some(pde) = ctx
.db
.peek()
.await
.into_public()
.into_package_data()
.into_idx(&id)
{
Ok(Some(
pde.into_state_info()
.as_manifest(ManifestPreference::Old)
.as_version()
.de()?,
))
} else {
Ok(None)
}
}
#[instrument(skip_all)]
pub async fn cli_install(
HandlerArgs {
context: ctx,
parent_method,
method,
params,
..
}: HandlerArgs<CliContext, CliInstallParams>,
) -> Result<(), RpcError> {
let method = parent_method.into_iter().chain(method).collect_vec();
match params {
CliInstallParams::Sideload(path) => {
let file = open_file(path).await?;
// rpc call remote sideload
let SideloadResponse { upload, progress } = from_value::<SideloadResponse>(
ctx.call_remote::<RpcContext>(
&method[..method.len() - 1]
.into_iter()
.chain(std::iter::once(&"sideload"))
.join("."),
imbl_value::json!({}),
)
.await?,
)?;
let upload = async {
let content_length = file.metadata().await?.len();
ctx.rest_continuation(
upload,
reqwest::Body::wrap_stream(tokio_util::io::ReaderStream::new(file)),
{
let mut map = HeaderMap::new();
map.insert(CONTENT_LENGTH, content_length.into());
map
},
)
.await?
.error_for_status()
.with_kind(ErrorKind::Network)?;
Ok::<_, Error>(())
};
let progress = async {
use tokio_tungstenite::tungstenite::Message;
let mut bar = PhasedProgressBar::new("Sideloading");
let mut ws = ctx.ws_continuation(progress).await?;
let mut progress = FullProgress::new();
loop {
tokio::select! {
msg = ws.next() => {
if let Some(msg) = msg {
match msg.with_kind(ErrorKind::Network)? {
Message::Text(t) => {
progress =
serde_json::from_str::<FullProgress>(&t)
.with_kind(ErrorKind::Deserialization)?;
bar.update(&progress);
}
Message::Close(Some(c)) if c.code != CloseCode::Normal => {
return Err(Error::new(eyre!("{}", c.reason), ErrorKind::Network))
}
_ => (),
}
} else {
break;
}
}
_ = tokio::time::sleep(Duration::from_millis(100)) => {
bar.update(&progress);
},
}
}
Ok::<_, Error>(())
};
let (upload, progress) = tokio::join!(upload, progress);
progress?;
upload?;
}
CliInstallParams::Marketplace(QueryPackageParams { id, version }) => {
let source_version: Option<VersionString> = from_value(
ctx.call_remote::<RpcContext>("package.installed-version", json!({ "id": &id }))
.await?,
)?;
let mut packages: GetPackageResponse = from_value(
ctx.call_remote::<RegistryContext>(
"package.get",
json!({ "id": &id, "targetVersion": version, "sourceVersion": source_version }),
)
.await?,
)?;
let version = if packages.best.len() == 1 {
packages.best.pop_first().map(|(k, _)| k).unwrap()
} else {
println!(
"Multiple flavors of {id} found. Please select one of the following versions to install:"
);
let version;
loop {
let (mut read, mut output) = rustyline_async::Readline::new("> ".into())
.with_kind(ErrorKind::Filesystem)?;
for (idx, version) in packages.best.keys().enumerate() {
output
.write_all(format!(" {}) {}\n", idx + 1, version).as_bytes())
.await?;
read.add_history_entry(version.to_string());
}
if let ReadlineEvent::Line(line) = read.readline().await? {
let trimmed = line.trim();
match trimmed.parse() {
Ok(v) => {
if let Some((k, _)) = packages.best.remove_entry(&v) {
version = k;
break;
}
}
Err(_) => match trimmed.parse::<usize>() {
Ok(i) if (1..=packages.best.len()).contains(&i) => {
version = packages.best.keys().nth(i - 1).unwrap().clone();
break;
}
_ => (),
},
}
eprintln!("invalid selection: {trimmed}");
println!("Please select one of the following versions to install:");
} else {
return Err(Error::new(
eyre!("Could not determine precise version to install"),
ErrorKind::InvalidRequest,
)
.into());
}
}
version
};
ctx.call_remote::<RpcContext>(
&method.join("."),
to_value(&InstallParams {
id,
registry: ctx.registry_url.clone().or_not_found("--registry")?,
version,
})?,
)
.await?;
}
}
Ok(())
}
#[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
#[command(rename_all = "kebab-case")]
pub struct UninstallParams {
id: PackageId,
#[arg(long, help = "Do not delete the service data")]
#[serde(default)]
soft: bool,
#[arg(long, help = "Ignore errors in service uninit script")]
#[serde(default)]
force: bool,
}
pub async fn uninstall(
ctx: RpcContext,
UninstallParams { id, soft, force }: UninstallParams,
) -> Result<(), Error> {
let fut = ctx
.services
.uninstall(ctx.clone(), id.clone(), soft, force)
.await?;
tokio::spawn(async move {
if let Err(e) = fut.await {
tracing::error!("Error uninstalling service {id}: {e}");
tracing::debug!("{e:?}");
}
});
Ok(())
}