add clearnet functionality to frontend (#2814)

* add clearnet functionality to frontend

* add pattern and add sync db on rpcs

* add domain pattern

* show acme name instead of url if known

* dont blow up if domain not present after delete

* use common name for letsencrypt

* normalize urls

* refactor start-os ui net service

* backend migration and rpcs for serverInfo.host

* fix cors

* implement clearnet for main startos ui

* ability to add and remove tor addresses, including vanity

* add guard to prevent duplicate addresses

* misc bugfixes

* better heuristics for launching UIs

* fix ipv6 mocks

* fix ipv6 display bug

* rewrite url selection for launch ui

---------

Co-authored-by: Aiden McClelland <me@drbonez.dev>
This commit is contained in:
Matt Hill
2025-01-21 20:46:36 -07:00
committed by GitHub
parent 0a9f1d2a27
commit 479797361e
90 changed files with 2838 additions and 1203 deletions

View File

@@ -113,7 +113,6 @@ export class MainLoop {
}))
.find((conf) => conf.internal == internalPort)
await effects.bind({
kind: "multi",
id: interfaceId,
internalPort,
preferredExternalPort: torConf?.external || internalPort,

View File

@@ -401,6 +401,7 @@ export class SystemForEmbassy implements System {
return [
port,
{
protocol: null,
secure: null,
preferredExternalPort: Number.parseInt(
torPort || lanPort || String(port),

327
core/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@ futures = "0.3.28"
lazy_async_pool = "0.3.3"
models = { path = "../models" }
pin-project = "1.1.3"
rpc-toolkit = { git = "https://github.com/Start9Labs/rpc-toolkit.git", branch = "refactor/no-dyn-ctx" }
rpc-toolkit = { git = "https://github.com/Start9Labs/rpc-toolkit.git", branch = "master" }
serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }

View File

@@ -24,7 +24,7 @@ patch-db = { version = "*", path = "../../patch-db/patch-db", features = [
rand = "0.8.5"
regex = "1.10.2"
reqwest = "0.12"
rpc-toolkit = { git = "https://github.com/Start9Labs/rpc-toolkit.git", branch = "refactor/no-dyn-ctx" }
rpc-toolkit = { git = "https://github.com/Start9Labs/rpc-toolkit.git", branch = "master" }
rustls = "0.23"
serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0"

View File

@@ -168,6 +168,6 @@ fn doesnt_reallocate() {
mime: InternedString::intern("png"),
data: Cow::Borrowed(&random[..i]),
};
assert_eq!(dbg!(icon.to_string()).capacity(), icon.data_url_len());
assert_eq!(icon.to_string().capacity(), icon.data_url_len());
}
}

View File

@@ -45,7 +45,7 @@ pub enum ErrorKind {
ConfigGen = 27,
ParseNumber = 28,
Database = 29,
InvalidPackageId = 30,
InvalidId = 30,
InvalidSignature = 31,
Backup = 32,
Restore = 33,
@@ -125,7 +125,7 @@ impl ErrorKind {
ConfigGen => "Config Generation Error",
ParseNumber => "Number Parsing Error",
Database => "Database Error",
InvalidPackageId => "Invalid Package ID",
InvalidId => "Invalid ID",
InvalidSignature => "Invalid Signature",
Backup => "Backup Error",
Restore => "Restore Error",
@@ -226,7 +226,7 @@ impl From<std::convert::Infallible> for Error {
}
impl From<InvalidId> for Error {
fn from(err: InvalidId) -> Self {
Error::new(err, ErrorKind::InvalidPackageId)
Error::new(err, ErrorKind::InvalidId)
}
}
impl From<std::io::Error> for Error {

View File

@@ -1,3 +1,5 @@
use yasi::InternedString;
#[derive(Debug, thiserror::Error)]
#[error("Invalid ID")]
pub struct InvalidId;
#[error("Invalid ID: {0}")]
pub struct InvalidId(pub(super) InternedString);

View File

@@ -43,7 +43,7 @@ impl TryFrom<InternedString> for Id {
if ID_REGEX.is_match(&value) {
Ok(Id(value))
} else {
Err(InvalidId)
Err(InvalidId(value))
}
}
}
@@ -53,7 +53,7 @@ impl TryFrom<String> for Id {
if ID_REGEX.is_match(&value) {
Ok(Id(InternedString::intern(value)))
} else {
Err(InvalidId)
Err(InvalidId(InternedString::intern(value)))
}
}
}
@@ -63,7 +63,7 @@ impl TryFrom<&str> for Id {
if ID_REGEX.is_match(value) {
Ok(Id(InternedString::intern(value)))
} else {
Err(InvalidId)
Err(InvalidId(InternedString::intern(value)))
}
}
}

View File

@@ -14,7 +14,7 @@ keywords = [
name = "start-os"
readme = "README.md"
repository = "https://github.com/Start9Labs/start-os"
version = "0.3.6-alpha.11"
version = "0.3.6-alpha.12"
license = "MIT"
[lib]
@@ -117,7 +117,7 @@ id-pool = { version = "0.2.2", default-features = false, features = [
"u16",
] }
imbl = "2.0.3"
imbl-value = { git = "https://github.com/Start9Labs/imbl-value.git" }
imbl-value = "0.1.2"
include_dir = { version = "0.7.3", features = ["metadata"] }
indexmap = { version = "2.0.2", features = ["serde"] }
indicatif = { version = "0.17.7", features = ["tokio"] }
@@ -172,7 +172,7 @@ regex = "1.10.2"
reqwest = { version = "0.12.4", features = ["stream", "json", "socks"] }
reqwest_cookie_store = "0.8.0"
rpassword = "7.2.0"
rpc-toolkit = { git = "https://github.com/Start9Labs/rpc-toolkit.git", branch = "refactor/no-dyn-ctx" }
rpc-toolkit = { git = "https://github.com/Start9Labs/rpc-toolkit.git", branch = "master" }
rust-argon2 = "2.0.0"
rustyline-async = "0.4.1"
semver = { version = "1.0.20", features = ["serde"] }

View File

@@ -24,7 +24,7 @@ pub struct AccountInfo {
pub server_id: String,
pub hostname: Hostname,
pub password: String,
pub tor_key: TorSecretKeyV3,
pub tor_keys: Vec<TorSecretKeyV3>,
pub root_ca_key: PKey<Private>,
pub root_ca_cert: X509,
pub ssh_key: ssh_key::PrivateKey,
@@ -34,7 +34,7 @@ impl AccountInfo {
pub fn new(password: &str, start_time: SystemTime) -> Result<Self, Error> {
let server_id = generate_id();
let hostname = generate_hostname();
let tor_key = TorSecretKeyV3::generate();
let tor_key = vec![TorSecretKeyV3::generate()];
let root_ca_key = generate_key()?;
let root_ca_cert = make_root_cert(&root_ca_key, &hostname, start_time)?;
let ssh_key = ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::random(
@@ -45,7 +45,7 @@ impl AccountInfo {
server_id,
hostname,
password: hash_password(password)?,
tor_key,
tor_keys: tor_key,
root_ca_key,
root_ca_cert,
ssh_key,
@@ -58,8 +58,11 @@ impl AccountInfo {
let hostname = Hostname(db.as_public().as_server_info().as_hostname().de()?);
let password = db.as_private().as_password().de()?;
let key_store = db.as_private().as_key_store();
let tor_addr = db.as_public().as_server_info().as_onion_address().de()?;
let tor_key = key_store.as_onion().get_key(&tor_addr)?;
let tor_addrs = db.as_public().as_server_info().as_host().as_onions().de()?;
let tor_keys = tor_addrs
.into_iter()
.map(|tor_addr| key_store.as_onion().get_key(&tor_addr))
.collect::<Result<_, _>>()?;
let cert_store = key_store.as_local_certs();
let root_ca_key = cert_store.as_root_key().de()?.0;
let root_ca_cert = cert_store.as_root_cert().de()?.0;
@@ -70,7 +73,7 @@ impl AccountInfo {
server_id,
hostname,
password,
tor_key,
tor_keys,
root_ca_key,
root_ca_cert,
ssh_key,
@@ -82,17 +85,16 @@ impl AccountInfo {
let server_info = db.as_public_mut().as_server_info_mut();
server_info.as_id_mut().ser(&self.server_id)?;
server_info.as_hostname_mut().ser(&self.hostname.0)?;
server_info
.as_lan_address_mut()
.ser(&self.hostname.lan_address().parse()?)?;
server_info
.as_pubkey_mut()
.ser(&self.ssh_key.public_key().to_openssh()?)?;
let onion_address = self.tor_key.public().get_onion_address();
server_info.as_onion_address_mut().ser(&onion_address)?;
server_info
.as_tor_address_mut()
.ser(&format!("https://{onion_address}").parse()?)?;
server_info.as_host_mut().as_onions_mut().ser(
&self
.tor_keys
.iter()
.map(|tor_key| tor_key.public().get_onion_address())
.collect(),
)?;
db.as_private_mut().as_password_mut().ser(&self.password)?;
db.as_private_mut()
.as_ssh_privkey_mut()
@@ -101,7 +103,9 @@ impl AccountInfo {
.as_compat_s9pk_key_mut()
.ser(Pem::new_ref(&self.compat_s9pk_key))?;
let key_store = db.as_private_mut().as_key_store_mut();
key_store.as_onion_mut().insert_key(&self.tor_key)?;
for tor_key in &self.tor_keys {
key_store.as_onion_mut().insert_key(tor_key)?;
}
let cert_store = key_store.as_local_certs_mut();
cert_store
.as_root_key_mut()

View File

@@ -85,7 +85,7 @@ impl OsBackupV0 {
&mut rand::thread_rng(),
ssh_key::Algorithm::Ed25519,
)?,
tor_key: TorSecretKeyV3::from(self.tor_key.0),
tor_keys: vec![TorSecretKeyV3::from(self.tor_key.0)],
compat_s9pk_key: ed25519_dalek::SigningKey::generate(&mut rand::thread_rng()),
},
ui: self.ui,
@@ -114,7 +114,7 @@ impl OsBackupV1 {
root_ca_key: self.root_ca_key.0,
root_ca_cert: self.root_ca_cert.0,
ssh_key: ssh_key::PrivateKey::from(Ed25519Keypair::from_seed(&self.net_key.0)),
tor_key: TorSecretKeyV3::from(ed25519_expand_key(&self.net_key.0)),
tor_keys: vec![TorSecretKeyV3::from(ed25519_expand_key(&self.net_key.0))],
compat_s9pk_key: ed25519_dalek::SigningKey::from_bytes(&self.net_key),
},
ui: self.ui,
@@ -132,7 +132,7 @@ struct OsBackupV2 {
root_ca_key: Pem<PKey<Private>>, // PEM Encoded OpenSSL Key
root_ca_cert: Pem<X509>, // PEM Encoded OpenSSL X509 Certificate
ssh_key: Pem<ssh_key::PrivateKey>, // PEM Encoded OpenSSH Key
tor_key: TorSecretKeyV3, // Base64 Encoded Ed25519 Expanded Secret Key
tor_keys: Vec<TorSecretKeyV3>, // Base64 Encoded Ed25519 Expanded Secret Key
compat_s9pk_key: Pem<ed25519_dalek::SigningKey>, // PEM Encoded ED25519 Key
ui: Value, // JSON Value
}
@@ -146,7 +146,7 @@ impl OsBackupV2 {
root_ca_key: self.root_ca_key.0,
root_ca_cert: self.root_ca_cert.0,
ssh_key: self.ssh_key.0,
tor_key: self.tor_key,
tor_keys: self.tor_keys,
compat_s9pk_key: self.compat_s9pk_key.0,
},
ui: self.ui,
@@ -159,7 +159,7 @@ impl OsBackupV2 {
root_ca_key: Pem(backup.account.root_ca_key.clone()),
root_ca_cert: Pem(backup.account.root_ca_cert.clone()),
ssh_key: Pem(backup.account.ssh_key.clone()),
tor_key: backup.account.tor_key.clone(),
tor_keys: backup.account.tor_keys.clone(),
compat_s9pk_key: Pem(backup.account.compat_s9pk_key.clone()),
ui: backup.ui.clone(),
}

View File

@@ -18,7 +18,7 @@ use crate::db::model::Database;
use crate::disk::mount::backup::BackupMountGuard;
use crate::disk::mount::filesystem::ReadWrite;
use crate::disk::mount::guard::{GenericMountGuard, TmpMountGuard};
use crate::init::{init, InitResult};
use crate::init::init;
use crate::prelude::*;
use crate::s9pk::S9pk;
use crate::service::service_map::DownloadInstallFuture;
@@ -109,13 +109,13 @@ pub async fn recover_full_embassy(
db.put(&ROOT, &Database::init(&os_backup.account)?).await?;
drop(db);
let InitResult { net_ctrl } = init(&ctx.webserver, &ctx.config, init_phases).await?;
let init_result = init(&ctx.webserver, &ctx.config, init_phases).await?;
let rpc_ctx = RpcContext::init(
&ctx.webserver,
&ctx.config,
disk_guid.clone(),
Some(net_ctrl),
Some(init_result),
rpc_ctx_phases,
)
.await?;

View File

@@ -11,7 +11,7 @@ use crate::disk::fsck::RepairStrategy;
use crate::disk::main::DEFAULT_PASSWORD;
use crate::disk::REPAIR_DISK_PATH;
use crate::firmware::{check_for_firmware_update, update_firmware};
use crate::init::{InitPhases, InitResult, STANDBY_MODE_PATH};
use crate::init::{InitPhases, STANDBY_MODE_PATH};
use crate::net::web_server::{UpgradableListener, WebServer};
use crate::prelude::*;
use crate::progress::FullProgressTracker;
@@ -188,14 +188,14 @@ async fn setup_or_init(
}));
}
let InitResult { net_ctrl } =
let init_result =
crate::init::init(&server.acceptor_setter(), config, init_phases).await?;
let rpc_ctx = RpcContext::init(
&server.acceptor_setter(),
config,
disk_guid,
Some(net_ctrl),
Some(init_result),
rpc_ctx_phases,
)
.await?;

View File

@@ -108,8 +108,6 @@ pub struct ServerConfig {
#[arg(long)]
pub tor_socks: Option<SocketAddr>,
#[arg(long)]
pub dns_bind: Option<Vec<SocketAddr>>,
#[arg(long)]
pub revision_cache_size: Option<usize>,
#[arg(long)]
pub disable_encryption: Option<bool>,
@@ -125,7 +123,6 @@ impl ContextConfig for ServerConfig {
self.os_partitions = self.os_partitions.take().or(other.os_partitions);
self.tor_control = self.tor_control.take().or(other.tor_control);
self.tor_socks = self.tor_socks.take().or(other.tor_socks);
self.dns_bind = self.dns_bind.take().or(other.dns_bind);
self.revision_cache_size = self
.revision_cache_size
.take()

View File

@@ -26,9 +26,9 @@ use crate::auth::Sessions;
use crate::context::config::ServerConfig;
use crate::db::model::Database;
use crate::disk::OsPartitionInfo;
use crate::init::check_time_is_synchronized;
use crate::init::{check_time_is_synchronized, InitResult};
use crate::lxc::{ContainerId, LxcContainer, LxcManager};
use crate::net::net_controller::{NetController, PreInitNetController};
use crate::net::net_controller::{NetController, NetService};
use crate::net::utils::{find_eth_iface, find_wifi_iface};
use crate::net::web_server::{UpgradableListener, WebServerAcceptorSetter};
use crate::net::wifi::WpaCli;
@@ -53,6 +53,7 @@ pub struct RpcContextSeed {
pub sync_db: watch::Sender<u64>,
pub account: RwLock<AccountInfo>,
pub net_controller: Arc<NetController>,
pub os_net_service: NetService,
pub s9pk_arch: Option<&'static str>,
pub services: ServiceMap,
pub metrics_cache: RwLock<Option<crate::system::Metrics>>,
@@ -119,7 +120,7 @@ impl RpcContext {
webserver: &WebServerAcceptorSetter<UpgradableListener>,
config: &ServerConfig,
disk_guid: Arc<String>,
net_ctrl: Option<PreInitNetController>,
init_result: Option<InitResult>,
InitRpcContextPhases {
mut load_db,
mut init_net_ctrl,
@@ -133,7 +134,7 @@ impl RpcContext {
let (shutdown, _) = tokio::sync::broadcast::channel(1);
load_db.start();
let db = if let Some(net_ctrl) = &net_ctrl {
let db = if let Some(InitResult { net_ctrl, .. }) = &init_result {
net_ctrl.db.clone()
} else {
TypedPatchDb::<Database>::load(config.db().await?).await?
@@ -144,31 +145,28 @@ impl RpcContext {
tracing::info!("Opened PatchDB");
init_net_ctrl.start();
let net_controller = Arc::new(
NetController::init(
if let Some(net_ctrl) = net_ctrl {
net_ctrl
let (net_controller, os_net_service) = if let Some(InitResult {
net_ctrl,
os_net_service,
}) = init_result
{
(net_ctrl, os_net_service)
} else {
let net_ctrl = PreInitNetController::init(
let net_ctrl = Arc::new(
NetController::init(
db.clone(),
config
.tor_control
.unwrap_or(SocketAddr::from(([127, 0, 0, 1], 9051))),
tor_proxy,
&account.hostname,
account.tor_key.clone(),
)
.await?;
webserver.try_upgrade(|a| net_ctrl.net_iface.upgrade_listener(a))?;
net_ctrl
},
config
.dns_bind
.as_deref()
.unwrap_or(&[SocketAddr::from(([127, 0, 0, 1], 53))]),
)
.await?,
);
webserver.try_upgrade(|a| net_ctrl.net_iface.upgrade_listener(a))?;
let os_net_service = net_ctrl.os_bindings().await?;
(net_ctrl, os_net_service)
};
init_net_ctrl.complete();
tracing::info!("Initialized Net Controller");
@@ -230,6 +228,7 @@ impl RpcContext {
db,
account: RwLock::new(account),
net_controller,
os_net_service,
s9pk_arch: if config.multi_arch_s9pks.unwrap_or(false) {
None
} else {

View File

@@ -40,7 +40,7 @@ lazy_static::lazy_static! {
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct SetupResult {
pub tor_address: String,
pub tor_addresses: Vec<String>,
#[ts(type = "string")]
pub hostname: Hostname,
#[ts(type = "string")]
@@ -51,7 +51,11 @@ impl TryFrom<&AccountInfo> for SetupResult {
type Error = Error;
fn try_from(value: &AccountInfo) -> Result<Self, Self::Error> {
Ok(Self {
tor_address: format!("https://{}", value.tor_key.public().get_onion_address()),
tor_addresses: value
.tor_keys
.iter()
.map(|tor_key| format!("https://{}", tor_key.public().get_onion_address()))
.collect(),
hostname: value.hostname.clone(),
lan_address: value.hostname.lan_address(),
root_ca: String::from_utf8(value.root_ca_cert.to_pem()?)?,

View File

@@ -10,19 +10,22 @@ use itertools::Itertools;
use models::PackageId;
use openssl::hash::MessageDigest;
use patch_db::{HasModel, Value};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use torut::onion::OnionAddressV3;
use ts_rs::TS;
use crate::account::AccountInfo;
use crate::db::model::package::AllPackageData;
use crate::net::acme::AcmeProvider;
use crate::net::host::address::DomainConfig;
use crate::net::host::binding::{AddSslOptions, BindInfo, BindOptions, NetInfo};
use crate::net::host::Host;
use crate::net::vhost::AlpnInfo;
use crate::prelude::*;
use crate::progress::FullProgress;
use crate::system::SmtpValue;
use crate::util::cpupower::Governor;
use crate::util::lshw::LshwDevice;
use crate::util::serde::MaybeUtf8String;
use crate::version::{Current, VersionT};
use crate::{ARCH, PLATFORM};
@@ -38,7 +41,6 @@ pub struct Public {
}
impl Public {
pub fn init(account: &AccountInfo) -> Result<Self, Error> {
let lan_address = account.hostname.lan_address().parse().unwrap();
Ok(Self {
server_info: ServerInfo {
arch: get_arch(),
@@ -46,14 +48,42 @@ impl Public {
id: account.server_id.clone(),
version: Current::default().semver(),
hostname: account.hostname.no_dot_host_name(),
host: Host {
bindings: [(
80,
BindInfo {
enabled: false,
options: BindOptions {
preferred_external_port: 80,
add_ssl: Some(AddSslOptions {
preferred_external_port: 443,
alpn: Some(AlpnInfo::Specified(vec![
MaybeUtf8String("http/1.1".into()),
MaybeUtf8String("h2".into()),
])),
}),
secure: None,
},
net: NetInfo {
assigned_port: None,
assigned_ssl_port: Some(443),
public: false,
},
},
)]
.into_iter()
.collect(),
onions: account
.tor_keys
.iter()
.map(|k| k.public().get_onion_address())
.collect(),
domains: BTreeMap::new(),
hostname_info: BTreeMap::new(),
},
last_backup: None,
package_version_compat: Current::default().compat().clone(),
post_init_migration_todos: BTreeSet::new(),
lan_address,
onion_address: account.tor_key.public().get_onion_address(),
tor_address: format!("https://{}", account.tor_key.public().get_onion_address())
.parse()
.unwrap(),
network_interfaces: BTreeMap::new(),
acme: BTreeMap::new(),
status_info: ServerStatus {
@@ -115,6 +145,7 @@ pub struct ServerInfo {
pub id: String,
#[ts(type = "string")]
pub hostname: InternedString,
pub host: Host,
#[ts(type = "string")]
pub version: Version,
#[ts(type = "string")]
@@ -123,13 +154,6 @@ pub struct ServerInfo {
pub post_init_migration_todos: BTreeSet<Version>,
#[ts(type = "string | null")]
pub last_backup: Option<DateTime<Utc>>,
#[ts(type = "string")]
pub lan_address: Url,
#[ts(type = "string")]
pub onion_address: OnionAddressV3,
/// for backwards compatibility
#[ts(type = "string")]
pub tor_address: Url,
#[ts(as = "BTreeMap::<String, NetworkInterfaceInfo>")]
#[serde(default)]
pub network_interfaces: BTreeMap<InternedString, NetworkInterfaceInfo>,

View File

@@ -3,6 +3,7 @@ use std::io::Cursor;
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use axum::extract::ws::{self};
@@ -25,7 +26,8 @@ use crate::db::model::public::ServerStatus;
use crate::db::model::Database;
use crate::disk::mount::util::unmount;
use crate::middleware::auth::LOCAL_AUTH_COOKIE_PATH;
use crate::net::net_controller::PreInitNetController;
use crate::net::net_controller::{NetController, NetService};
use crate::net::utils::find_wifi_iface;
use crate::net::web_server::{UpgradableListener, WebServerAcceptorSetter};
use crate::prelude::*;
use crate::progress::{
@@ -197,7 +199,8 @@ pub async fn init_postgres(datadir: impl AsRef<Path>) -> Result<(), Error> {
}
pub struct InitResult {
pub net_ctrl: PreInitNetController,
pub net_ctrl: Arc<NetController>,
pub os_net_service: NetService,
}
pub struct InitPhases {
@@ -347,7 +350,8 @@ pub async fn init(
let account = AccountInfo::load(&peek)?;
start_net.start();
let net_ctrl = PreInitNetController::init(
let net_ctrl = Arc::new(
NetController::init(
db.clone(),
cfg.tor_control
.unwrap_or(SocketAddr::from(([127, 0, 0, 1], 9051))),
@@ -356,10 +360,11 @@ pub async fn init(
9050,
))),
&account.hostname,
account.tor_key,
)
.await?;
.await?,
);
webserver.try_upgrade(|a| net_ctrl.net_iface.upgrade_listener(a))?;
let os_net_service = net_ctrl.os_bindings().await?;
start_net.complete();
mount_logs.start();
@@ -394,8 +399,6 @@ pub async fn init(
mount_logs.complete();
tracing::info!("Mounted Logs");
let mut server_info = peek.as_public().as_server_info().de()?;
load_ca_cert.start();
// write to ca cert store
tokio::fs::write(
@@ -423,7 +426,15 @@ pub async fn init(
load_ca_cert.complete();
load_wifi.start();
crate::net::wifi::synchronize_network_manager(MAIN_DATA, &mut server_info.wifi).await?;
let wifi_interface = find_wifi_iface().await?;
let wifi = db
.mutate(|db| {
let wifi = db.as_public_mut().as_server_info_mut().as_wifi_mut();
wifi.as_interface_mut().ser(&wifi_interface)?;
wifi.de()
})
.await?;
crate::net::wifi::synchronize_network_manager(MAIN_DATA, &wifi).await?;
load_wifi.complete();
tracing::info!("Synchronized WiFi");
@@ -448,8 +459,10 @@ pub async fn init(
crate::disk::mount::util::bind(&tmp_docker, CONTAINER_DATADIR, false).await?;
init_tmp.complete();
let server_info = db.peek().await.into_public().into_server_info();
set_governor.start();
let governor = if let Some(governor) = &server_info.governor {
let selected_governor = server_info.as_governor().de()?;
let governor = if let Some(governor) = &selected_governor {
if cpupower::get_available_governors()
.await?
.contains(governor)
@@ -470,11 +483,11 @@ pub async fn init(
set_governor.complete();
sync_clock.start();
server_info.ntp_synced = false;
let mut ntp_synced = false;
let mut not_made_progress = 0u32;
for _ in 0..1800 {
if check_time_is_synchronized().await? {
server_info.ntp_synced = true;
ntp_synced = true;
break;
}
let t = SystemTime::now();
@@ -491,7 +504,7 @@ pub async fn init(
break;
}
}
if !server_info.ntp_synced {
if !ntp_synced {
tracing::warn!("Timed out waiting for system time to synchronize");
} else {
tracing::info!("Syncronized system clock");
@@ -499,15 +512,16 @@ pub async fn init(
sync_clock.complete();
enable_zram.start();
if server_info.zram {
crate::system::enable_zram().await?
if server_info.as_zram().de()? {
crate::system::enable_zram().await?;
tracing::info!("Enabled ZRAM");
}
enable_zram.complete();
update_server_info.start();
server_info.ram = get_mem_info().await?.total.0 as u64 * 1024 * 1024;
server_info.devices = lshw().await?;
server_info.status_info = ServerStatus {
let ram = get_mem_info().await?.total.0 as u64 * 1024 * 1024;
let devices = lshw().await?;
let status_info = ServerStatus {
updated: false,
update_progress: None,
backup_progress: None,
@@ -515,10 +529,15 @@ pub async fn init(
restarting: false,
};
db.mutate(|v| {
v.as_public_mut().as_server_info_mut().ser(&server_info)?;
let server_info = v.as_public_mut().as_server_info_mut();
server_info.as_ntp_synced_mut().ser(&ntp_synced)?;
server_info.as_ram_mut().ser(&ram)?;
server_info.as_devices_mut().ser(&devices)?;
server_info.as_status_info_mut().ser(&status_info)?;
Ok(())
})
.await?;
tracing::info!("Updated server info");
update_server_info.complete();
launch_service_network.start();
@@ -527,6 +546,7 @@ pub async fn init(
.arg("lxc-net.service")
.invoke(ErrorKind::Lxc)
.await?;
tracing::info!("Launched service intranet");
launch_service_network.complete();
validate_db.start();
@@ -535,6 +555,7 @@ pub async fn init(
d.ser(&model)
})
.await?;
tracing::info!("Validated database");
validate_db.complete();
if let Some(progress) = postinit {
@@ -543,7 +564,10 @@ pub async fn init(
tracing::info!("System initialized.");
Ok(InitResult { net_ctrl })
Ok(InitResult {
net_ctrl,
os_net_service,
})
}
pub fn init_api<C: Context>() -> ParentHandler<C> {

View File

@@ -87,6 +87,7 @@ use crate::context::{
CliContext, DiagnosticContext, InitContext, InstallContext, RpcContext, SetupContext,
};
use crate::disk::fsck::RequiresReboot;
use crate::net::net;
use crate::registry::context::{RegistryContext, RegistryUrlParams};
use crate::util::serde::HandlerExtSerde;
@@ -313,7 +314,7 @@ pub fn server<C: Context>() -> ParentHandler<C> {
.no_display()
.with_about("Remove system smtp server and credentials")
.with_call_remote::<CliContext>()
)
).subcommand("host", net::host::server_host_api::<C>().with_about("Commands for modifying the host for the system ui"))
}
pub fn package<C: Context>() -> ParentHandler<C> {
@@ -427,7 +428,7 @@ pub fn package<C: Context>() -> ParentHandler<C> {
.subcommand("attach", from_fn_async(service::cli_attach).no_display())
.subcommand(
"host",
net::host::host::<C>().with_about("Manage network hosts for a package"),
net::host::host_api::<C>().with_about("Manage network hosts for a package"),
)
}

View File

@@ -1,6 +1,7 @@
use axum::body::Body;
use axum::extract::Request;
use axum::response::Response;
use http::{HeaderMap, HeaderValue};
use http::{HeaderMap, HeaderValue, Method};
use rpc_toolkit::{Empty, Middleware};
#[derive(Clone)]
@@ -52,6 +53,13 @@ impl<Context: Send + Sync + 'static> Middleware<Context> for Cors {
request: &mut Request,
) -> Result<(), Response> {
self.get_cors_headers(request);
if request.method() == Method::OPTIONS {
let mut response = Response::new(Body::empty());
response
.headers_mut()
.extend(std::mem::take(&mut self.headers));
return Err(response);
}
Ok(())
}
async fn process_http_response(&mut self, _: &Context, response: &mut Response) {

View File

@@ -173,16 +173,26 @@ impl<'a> async_acme::cache::AcmeCache for AcmeCertCache<'a> {
}
pub fn acme<C: Context>() -> ParentHandler<C> {
ParentHandler::new().subcommand(
ParentHandler::new()
.subcommand(
"init",
from_fn_async(init)
.with_metadata("sync_db", Value::Bool(true))
.no_display()
.with_about("Setup ACME certificate acquisition")
.with_call_remote::<CliContext>(),
)
.subcommand(
"remove",
from_fn_async(remove)
.with_metadata("sync_db", Value::Bool(true))
.no_display()
.with_about("Setup ACME certificate acquisition")
.with_call_remote::<CliContext>(),
)
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, TS)]
#[ts(type = "string")]
pub struct AcmeProvider(pub Url);
impl FromStr for AcmeProvider {
@@ -193,9 +203,31 @@ impl FromStr for AcmeProvider {
"letsencrypt-staging" => async_acme::acme::LETS_ENCRYPT_STAGING_DIRECTORY.parse(),
s => s.parse(),
}
.map(|mut u: Url| {
let path = u
.path_segments()
.into_iter()
.flatten()
.filter(|p| !p.is_empty())
.map(|p| p.to_owned())
.collect::<Vec<_>>();
if let Ok(mut path_mut) = u.path_segments_mut() {
path_mut.clear();
path_mut.extend(path);
}
u
})
.map(Self)
}
}
impl<'de> Deserialize<'de> for AcmeProvider {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
crate::util::serde::deserialize_from_str(deserializer)
}
}
impl AsRef<str> for AcmeProvider {
fn as_ref(&self) -> &str {
self.0.as_str()
@@ -230,3 +262,24 @@ pub async fn init(
.await?;
Ok(())
}
#[derive(Deserialize, Serialize, Parser)]
pub struct RemoveAcmeParams {
#[arg(long)]
pub provider: AcmeProvider,
}
pub async fn remove(
ctx: RpcContext,
RemoveAcmeParams { provider }: RemoveAcmeParams,
) -> Result<(), Error> {
ctx.db
.mutate(|db| {
db.as_public_mut()
.as_server_info_mut()
.as_acme_mut()
.remove(&provider)
})
.await?;
Ok(())
}

View File

@@ -1,6 +1,6 @@
use std::borrow::Borrow;
use std::collections::BTreeMap;
use std::net::{Ipv4Addr, SocketAddr};
use std::net::Ipv4Addr;
use std::sync::{Arc, Weak};
use std::time::Duration;
@@ -19,6 +19,7 @@ use trust_dns_server::server::{Request, RequestHandler, ResponseHandler, Respons
use trust_dns_server::ServerFuture;
use crate::net::forward::START9_BRIDGE_IFACE;
use crate::util::sync::Watch;
use crate::util::Invoke;
use crate::{Error, ErrorKind, ResultExt};
@@ -140,19 +141,27 @@ impl RequestHandler for Resolver {
impl DnsController {
#[instrument(skip_all)]
pub async fn init(bind: &[SocketAddr]) -> Result<Self, Error> {
pub async fn init(mut lxcbr_status: Watch<bool>) -> Result<Self, Error> {
let services = Arc::new(RwLock::new(BTreeMap::new()));
let mut server = ServerFuture::new(Resolver {
services: services.clone(),
});
let dns_server = tokio::spawn(async move {
server.register_listener(
TcpListener::bind(bind)
TcpListener::bind((Ipv4Addr::LOCALHOST, 53))
.await
.with_kind(ErrorKind::Network)?,
Duration::from_secs(30),
);
server.register_socket(UdpSocket::bind(bind).await.with_kind(ErrorKind::Network)?);
server.register_socket(
UdpSocket::bind((Ipv4Addr::LOCALHOST, 53))
.await
.with_kind(ErrorKind::Network)?,
);
lxcbr_status.wait_for(|a| *a).await;
Command::new("resolvectl")
.arg("dns")
@@ -167,11 +176,11 @@ impl DnsController {
.invoke(ErrorKind::Network)
.await?;
let dns_server = tokio::spawn(
server
.block_until_done()
.map_err(|e| Error::new(e, ErrorKind::Network)),
)
.await
.map_err(|e| Error::new(e, ErrorKind::Network))
})
.into();
Ok(Self {

View File

@@ -1,13 +1,16 @@
use std::collections::BTreeSet;
use clap::Parser;
use imbl_value::InternedString;
use models::{HostId, PackageId};
use rpc_toolkit::{from_fn_async, Context, Empty, HandlerArgs, HandlerExt, ParentHandler};
use serde::{Deserialize, Serialize};
use torut::onion::OnionAddressV3;
use ts_rs::TS;
use crate::context::{CliContext, RpcContext};
use crate::db::model::DatabaseModel;
use crate::net::acme::AcmeProvider;
use crate::net::host::{all_hosts, HostApiKind};
use crate::prelude::*;
use crate::util::serde::{display_serializable, HandlerExtSerde};
@@ -35,19 +38,51 @@ pub struct DomainConfig {
pub acme: Option<AcmeProvider>,
}
#[derive(Deserialize, Serialize, Parser)]
pub struct AddressApiParams {
host: HostId,
fn check_duplicates(db: &DatabaseModel) -> Result<(), Error> {
let mut onions = BTreeSet::<OnionAddressV3>::new();
let mut domains = BTreeSet::<InternedString>::new();
let mut check_onion = |onion: OnionAddressV3| {
if onions.contains(&onion) {
return Err(Error::new(
eyre!("onion address {onion} is already in use"),
ErrorKind::InvalidRequest,
));
}
onions.insert(onion);
Ok(())
};
let mut check_domain = |domain: InternedString| {
if domains.contains(&domain) {
return Err(Error::new(
eyre!("domain {domain} is already in use"),
ErrorKind::InvalidRequest,
));
}
domains.insert(domain);
Ok(())
};
for host in all_hosts(db) {
let host = host?;
for onion in host.as_onions().de()? {
check_onion(onion)?;
}
for domain in host.as_domains().keys()? {
check_domain(domain)?;
}
}
Ok(())
}
pub fn address<C: Context>() -> ParentHandler<C, AddressApiParams, PackageId> {
ParentHandler::<C, AddressApiParams, PackageId>::new()
pub fn address_api<C: Context, Kind: HostApiKind>(
) -> ParentHandler<C, Kind::Params, Kind::InheritedParams> {
ParentHandler::<C, Kind::Params, Kind::InheritedParams>::new()
.subcommand(
"domain",
ParentHandler::<C, Empty, (PackageId, HostId)>::new()
ParentHandler::<C, Empty, Kind::Inheritance>::new()
.subcommand(
"add",
from_fn_async(add_domain)
from_fn_async(add_domain::<Kind>)
.with_metadata("sync_db", Value::Bool(true))
.with_inherited(|_, a| a)
.no_display()
.with_about("Add an address to this host")
@@ -55,20 +90,22 @@ pub fn address<C: Context>() -> ParentHandler<C, AddressApiParams, PackageId> {
)
.subcommand(
"remove",
from_fn_async(remove_domain)
from_fn_async(remove_domain::<Kind>)
.with_metadata("sync_db", Value::Bool(true))
.with_inherited(|_, a| a)
.no_display()
.with_about("Remove an address from this host")
.with_call_remote::<CliContext>(),
)
.with_inherited(|AddressApiParams { host }, package| (package, host)),
.with_inherited(Kind::inheritance),
)
.subcommand(
"onion",
ParentHandler::<C, Empty, (PackageId, HostId)>::new()
ParentHandler::<C, Empty, Kind::Inheritance>::new()
.subcommand(
"add",
from_fn_async(add_onion)
from_fn_async(add_onion::<Kind>)
.with_metadata("sync_db", Value::Bool(true))
.with_inherited(|_, a| a)
.no_display()
.with_about("Add an address to this host")
@@ -76,18 +113,19 @@ pub fn address<C: Context>() -> ParentHandler<C, AddressApiParams, PackageId> {
)
.subcommand(
"remove",
from_fn_async(remove_onion)
from_fn_async(remove_onion::<Kind>)
.with_metadata("sync_db", Value::Bool(true))
.with_inherited(|_, a| a)
.no_display()
.with_about("Remove an address from this host")
.with_call_remote::<CliContext>(),
)
.with_inherited(|AddressApiParams { host }, package| (package, host)),
.with_inherited(Kind::inheritance),
)
.subcommand(
"list",
from_fn_async(list_addresses)
.with_inherited(|AddressApiParams { host }, package| (package, host))
from_fn_async(list_addresses::<Kind>)
.with_inherited(Kind::inheritance)
.with_display_serializable()
.with_custom_display_fn(|HandlerArgs { params, .. }, res| {
use prettytable::*;
@@ -136,14 +174,14 @@ pub struct AddDomainParams {
pub acme: Option<AcmeProvider>,
}
pub async fn add_domain(
pub async fn add_domain<Kind: HostApiKind>(
ctx: RpcContext,
AddDomainParams {
domain,
private,
acme,
}: AddDomainParams,
(package, host): (PackageId, HostId),
inheritance: Kind::Inheritance,
) -> Result<(), Error> {
ctx.db
.mutate(|db| {
@@ -153,13 +191,7 @@ pub async fn add_domain(
}
}
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(&package)
.or_not_found(&package)?
.as_hosts_mut()
.as_idx_mut(&host)
.or_not_found(&host)?
Kind::host_for(&inheritance, db)?
.as_domains_mut()
.insert(
&domain,
@@ -167,12 +199,11 @@ pub async fn add_domain(
public: !private,
acme,
},
)
)?;
check_duplicates(db)
})
.await?;
let service = ctx.services.get(&package).await;
let service_ref = service.as_ref().or_not_found(&package)?;
service_ref.update_host(host).await?;
Kind::sync_host(&ctx, inheritance).await?;
Ok(())
}
@@ -182,27 +213,19 @@ pub struct RemoveDomainParams {
pub domain: InternedString,
}
pub async fn remove_domain(
pub async fn remove_domain<Kind: HostApiKind>(
ctx: RpcContext,
RemoveDomainParams { domain }: RemoveDomainParams,
(package, host): (PackageId, HostId),
inheritance: Kind::Inheritance,
) -> Result<(), Error> {
ctx.db
.mutate(|db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(&package)
.or_not_found(&package)?
.as_hosts_mut()
.as_idx_mut(&host)
.or_not_found(&host)?
Kind::host_for(&inheritance, db)?
.as_domains_mut()
.remove(&domain)
})
.await?;
let service = ctx.services.get(&package).await;
let service_ref = service.as_ref().or_not_found(&package)?;
service_ref.update_host(host).await?;
Kind::sync_host(&ctx, inheritance).await?;
Ok(())
}
@@ -212,10 +235,10 @@ pub struct OnionParams {
pub onion: String,
}
pub async fn add_onion(
pub async fn add_onion<Kind: HostApiKind>(
ctx: RpcContext,
OnionParams { onion }: OnionParams,
(package, host): (PackageId, HostId),
inheritance: Kind::Inheritance,
) -> Result<(), Error> {
let onion = onion
.strip_suffix(".onion")
@@ -230,28 +253,22 @@ pub async fn add_onion(
.mutate(|db| {
db.as_private().as_key_store().as_onion().get_key(&onion)?;
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(&package)
.or_not_found(&package)?
.as_hosts_mut()
.as_idx_mut(&host)
.or_not_found(&host)?
Kind::host_for(&inheritance, db)?
.as_onions_mut()
.mutate(|a| Ok(a.insert(onion)))
.mutate(|a| Ok(a.insert(onion)))?;
check_duplicates(db)
})
.await?;
let service = ctx.services.get(&package).await;
let service_ref = service.as_ref().or_not_found(&package)?;
service_ref.update_host(host).await?;
Kind::sync_host(&ctx, inheritance).await?;
Ok(())
}
pub async fn remove_onion(
pub async fn remove_onion<Kind: HostApiKind>(
ctx: RpcContext,
OnionParams { onion }: OnionParams,
(package, host): (PackageId, HostId),
inheritance: Kind::Inheritance,
) -> Result<(), Error> {
let onion = onion
.strip_suffix(".onion")
@@ -264,40 +281,23 @@ pub async fn remove_onion(
.parse::<OnionAddressV3>()?;
ctx.db
.mutate(|db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(&package)
.or_not_found(&package)?
.as_hosts_mut()
.as_idx_mut(&host)
.or_not_found(&host)?
Kind::host_for(&inheritance, db)?
.as_onions_mut()
.mutate(|a| Ok(a.remove(&onion)))
})
.await?;
let service = ctx.services.get(&package).await;
let service_ref = service.as_ref().or_not_found(&package)?;
service_ref.update_host(host).await?;
Kind::sync_host(&ctx, inheritance).await?;
Ok(())
}
pub async fn list_addresses(
pub async fn list_addresses<Kind: HostApiKind>(
ctx: RpcContext,
_: Empty,
(package, host): (PackageId, HostId),
inheritance: Kind::Inheritance,
) -> Result<Vec<HostAddress>, Error> {
Ok(ctx
.db
.peek()
.await
.into_public()
.into_package_data()
.into_idx(&package)
.or_not_found(&package)?
.into_hosts()
.into_idx(&host)
.or_not_found(&host)?
Ok(Kind::host_for(&inheritance, &mut ctx.db.peek().await)?
.de()?
.addresses()
.collect())

View File

@@ -3,13 +3,14 @@ use std::str::FromStr;
use clap::builder::ValueParserFactory;
use clap::Parser;
use models::{FromStrParser, HostId, PackageId};
use models::{FromStrParser, HostId};
use rpc_toolkit::{from_fn_async, Context, Empty, HandlerArgs, HandlerExt, ParentHandler};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::context::{CliContext, RpcContext};
use crate::net::forward::AvailablePorts;
use crate::net::host::HostApiKind;
use crate::net::vhost::AlpnInfo;
use crate::prelude::*;
use crate::util::serde::{display_serializable, HandlerExtSerde};
@@ -146,17 +147,13 @@ pub struct AddSslOptions {
pub alpn: Option<AlpnInfo>,
}
#[derive(Deserialize, Serialize, Parser)]
pub struct BindingApiParams {
host: HostId,
}
pub fn binding<C: Context>() -> ParentHandler<C, BindingApiParams, PackageId> {
ParentHandler::<C, BindingApiParams, PackageId>::new()
pub fn binding<C: Context, Kind: HostApiKind>(
) -> ParentHandler<C, Kind::Params, Kind::InheritedParams> {
ParentHandler::<C, Kind::Params, Kind::InheritedParams>::new()
.subcommand(
"list",
from_fn_async(list_bindings)
.with_inherited(|BindingApiParams { host }, package| (package, host))
from_fn_async(list_bindings::<Kind>)
.with_inherited(Kind::inheritance)
.with_display_serializable()
.with_custom_display_fn(|HandlerArgs { params, .. }, res| {
use prettytable::*;
@@ -194,30 +191,22 @@ pub fn binding<C: Context>() -> ParentHandler<C, BindingApiParams, PackageId> {
)
.subcommand(
"set-public",
from_fn_async(set_public)
.with_inherited(|BindingApiParams { host }, package| (package, host))
from_fn_async(set_public::<Kind>)
.with_metadata("sync_db", Value::Bool(true))
.with_inherited(Kind::inheritance)
.no_display()
.with_about("Add an binding to this host")
.with_call_remote::<CliContext>(),
)
}
pub async fn list_bindings(
pub async fn list_bindings<Kind: HostApiKind>(
ctx: RpcContext,
_: Empty,
(package, host): (PackageId, HostId),
inheritance: Kind::Inheritance,
) -> Result<BTreeMap<u16, BindInfo>, Error> {
ctx.db
.peek()
.await
.into_public()
.into_package_data()
.into_idx(&package)
.or_not_found(&package)?
.into_hosts()
.into_idx(&host)
.or_not_found(&host)?
.into_bindings()
Kind::host_for(&inheritance, &mut ctx.db.peek().await)?
.as_bindings()
.de()
}
@@ -230,23 +219,17 @@ pub struct BindingSetPublicParams {
public: Option<bool>,
}
pub async fn set_public(
pub async fn set_public<Kind: HostApiKind>(
ctx: RpcContext,
BindingSetPublicParams {
internal_port,
public,
}: BindingSetPublicParams,
(package, host): (PackageId, HostId),
inheritance: Kind::Inheritance,
) -> Result<(), Error> {
ctx.db
.mutate(|db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(&package)
.or_not_found(&package)?
.as_hosts_mut()
.as_idx_mut(&host)
.or_not_found(&host)?
Kind::host_for(&inheritance, db)?
.as_bindings_mut()
.mutate(|b| {
b.get_mut(&internal_port)
@@ -257,11 +240,5 @@ pub async fn set_public(
})
})
.await?;
ctx.services
.get(&package)
.await
.as_ref()
.or_not_found(&package)?
.update_host(host)
.await
Kind::sync_host(&ctx, inheritance).await
}

View File

@@ -1,9 +1,12 @@
use std::collections::{BTreeMap, BTreeSet};
use std::future::Future;
use std::panic::RefUnwindSafe;
use clap::Parser;
use imbl_value::InternedString;
use itertools::Itertools;
use models::{HostId, PackageId};
use rpc_toolkit::{from_fn_async, Context, Empty, HandlerExt, ParentHandler};
use rpc_toolkit::{from_fn_async, Context, Empty, HandlerExt, OrEmpty, ParentHandler};
use serde::{Deserialize, Serialize};
use torut::onion::OnionAddressV3;
use ts_rs::TS;
@@ -11,7 +14,7 @@ use ts_rs::TS;
use crate::context::RpcContext;
use crate::db::model::DatabaseModel;
use crate::net::forward::AvailablePorts;
use crate::net::host::address::{address, DomainConfig, HostAddress};
use crate::net::host::address::{address_api, DomainConfig, HostAddress};
use crate::net::host::binding::{binding, BindInfo, BindOptions};
use crate::net::service_interface::HostnameInfo;
use crate::prelude::*;
@@ -19,12 +22,11 @@ use crate::prelude::*;
pub mod address;
pub mod binding;
#[derive(Debug, Deserialize, Serialize, HasModel, TS)]
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
#[serde(rename_all = "camelCase")]
#[model = "Model<Self>"]
#[ts(export)]
pub struct Host {
pub kind: HostKind,
pub bindings: BTreeMap<u16, BindInfo>,
#[ts(type = "string[]")]
pub onions: BTreeSet<OnionAddressV3>,
@@ -39,14 +41,8 @@ impl AsRef<Host> for Host {
}
}
impl Host {
pub fn new(kind: HostKind) -> Self {
Self {
kind,
bindings: BTreeMap::new(),
onions: BTreeSet::new(),
domains: BTreeMap::new(),
hostname_info: BTreeMap::new(),
}
pub fn new() -> Self {
Self::default()
}
pub fn addresses<'a>(&'a self) -> impl Iterator<Item = HostAddress> + 'a {
self.onions
@@ -67,15 +63,6 @@ impl Host {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub enum HostKind {
Multi,
// Single,
// Static,
}
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
#[model = "Model<Self>"]
#[ts(export)]
@@ -94,10 +81,12 @@ impl Map for Hosts {
pub fn host_for<'a>(
db: &'a mut DatabaseModel,
package_id: &PackageId,
package_id: Option<&PackageId>,
host_id: &HostId,
host_kind: HostKind,
) -> Result<&'a mut Model<Host>, Error> {
let Some(package_id) = package_id else {
return Ok(db.as_public_mut().as_server_info_mut().as_host_mut());
};
fn host_info<'a>(
db: &'a mut DatabaseModel,
package_id: &PackageId,
@@ -121,7 +110,7 @@ pub fn host_for<'a>(
None
};
host_info(db, package_id)?.upsert(host_id, || {
let mut h = Host::new(host_kind);
let mut h = Host::new();
h.onions.insert(
tor_key
.or_not_found("generated tor key")?
@@ -132,12 +121,20 @@ pub fn host_for<'a>(
})
}
pub fn all_hosts(db: &DatabaseModel) -> impl Iterator<Item = Result<&Model<Host>, Error>> {
[Ok(db.as_public().as_server_info().as_host())]
.into_iter()
.chain(
[db.as_public().as_package_data().as_entries()]
.into_iter()
.flatten_ok()
.map(|entry| entry.and_then(|(_, v)| v.as_hosts().as_entries()))
.flatten_ok()
.map_ok(|(_, v)| v),
)
}
impl Model<Host> {
pub fn set_kind(&mut self, kind: HostKind) -> Result<(), Error> {
match (self.as_kind().de()?, kind) {
(HostKind::Multi, HostKind::Multi) => Ok(()),
}
}
pub fn add_binding(
&mut self,
available_ports: &mut AvailablePorts,
@@ -157,16 +154,78 @@ impl Model<Host> {
}
#[derive(Deserialize, Serialize, Parser)]
pub struct HostParams {
pub struct RequiresPackageId {
package: PackageId,
}
pub fn host<C: Context>() -> ParentHandler<C, HostParams> {
ParentHandler::<C, HostParams>::new()
#[derive(Deserialize, Serialize, Parser)]
pub struct RequiresHostId {
host: HostId,
}
pub trait HostApiKind: 'static {
type Params: Send + Sync + 'static;
type InheritedParams: Send + Sync + 'static;
type Inheritance: RefUnwindSafe + OrEmpty<Self::Inheritance> + Send + Sync + 'static;
fn inheritance(params: Self::Params, inherited: Self::InheritedParams) -> Self::Inheritance;
fn host_for<'a>(
inheritance: &Self::Inheritance,
db: &'a mut DatabaseModel,
) -> Result<&'a mut Model<Host>, Error>;
fn sync_host(
ctx: &RpcContext,
inheritance: Self::Inheritance,
) -> impl Future<Output = Result<(), Error>> + Send;
}
pub struct ForPackage;
impl HostApiKind for ForPackage {
type Params = RequiresHostId;
type InheritedParams = PackageId;
type Inheritance = (PackageId, HostId);
fn inheritance(
RequiresHostId { host }: Self::Params,
package: Self::InheritedParams,
) -> Self::Inheritance {
(package, host)
}
fn host_for<'a>(
(package, host): &Self::Inheritance,
db: &'a mut DatabaseModel,
) -> Result<&'a mut Model<Host>, Error> {
host_for(db, Some(package), host)
}
async fn sync_host(ctx: &RpcContext, (package, host): Self::Inheritance) -> Result<(), Error> {
let service = ctx.services.get(&package).await;
let service_ref = service.as_ref().or_not_found(&package)?;
service_ref.sync_host(host).await?;
Ok(())
}
}
pub struct ForServer;
impl HostApiKind for ForServer {
type Params = Empty;
type InheritedParams = Empty;
type Inheritance = Empty;
fn inheritance(_: Self::Params, _: Self::InheritedParams) -> Self::Inheritance {
Empty {}
}
fn host_for<'a>(
_: &Self::Inheritance,
db: &'a mut DatabaseModel,
) -> Result<&'a mut Model<Host>, Error> {
host_for(db, None, &HostId::default())
}
async fn sync_host(ctx: &RpcContext, _: Self::Inheritance) -> Result<(), Error> {
ctx.os_net_service.sync_host(HostId::default()).await
}
}
pub fn host_api<C: Context>() -> ParentHandler<C, RequiresPackageId> {
ParentHandler::<C, RequiresPackageId>::new()
.subcommand(
"list",
from_fn_async(list_hosts)
.with_inherited(|HostParams { package }, _| package)
.with_inherited(|RequiresPackageId { package }, _| package)
.with_custom_display_fn(|_, ids| {
for id in ids {
println!("{id}")
@@ -177,14 +236,21 @@ pub fn host<C: Context>() -> ParentHandler<C, HostParams> {
)
.subcommand(
"address",
address::<C>().with_inherited(|HostParams { package }, _| package),
address_api::<C, ForPackage>()
.with_inherited(|RequiresPackageId { package }, _| package),
)
.subcommand(
"binding",
binding::<C>().with_inherited(|HostParams { package }, _| package),
binding::<C, ForPackage>().with_inherited(|RequiresPackageId { package }, _| package),
)
}
pub fn server_host_api<C: Context>() -> ParentHandler<C> {
ParentHandler::<C>::new()
.subcommand("address", address_api::<C, ForServer>())
.subcommand("binding", binding::<C, ForServer>())
}
pub async fn list_hosts(
ctx: RpcContext,
_: Empty,

View File

@@ -21,7 +21,9 @@ impl KeyStore {
local_certs: CertStore::new(account)?,
acme: AcmeCertStore::new(),
};
res.onion.insert(account.tor_key.clone());
for tor_key in account.tor_keys.iter().cloned() {
res.onion.insert(tor_key);
}
Ok(res)
}
}

View File

@@ -7,6 +7,8 @@ use imbl::OrdMap;
use imbl_value::InternedString;
use ipnet::IpNet;
use models::{HostId, OptionExt, PackageId};
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use torut::onion::{OnionAddressV3, TorSecretKeyV3};
use tracing::instrument;
@@ -16,8 +18,8 @@ use crate::hostname::Hostname;
use crate::net::dns::DnsController;
use crate::net::forward::LanPortForwardController;
use crate::net::host::address::HostAddress;
use crate::net::host::binding::{BindId, BindOptions};
use crate::net::host::{host_for, Host, HostKind, Hosts};
use crate::net::host::binding::{AddSslOptions, BindId, BindOptions};
use crate::net::host::{host_for, Host, Hosts};
use crate::net::network_interface::NetworkInterfaceController;
use crate::net::service_interface::{HostnameInfo, IpHostname, OnionHostname};
use crate::net::tor::TorController;
@@ -27,42 +29,32 @@ use crate::prelude::*;
use crate::util::serde::MaybeUtf8String;
use crate::HOST_IP;
pub struct PreInitNetController {
pub db: TypedPatchDb<Database>,
tor: TorController,
vhost: VHostController,
pub net_iface: Arc<NetworkInterfaceController>,
os_bindings: Vec<Arc<()>>,
server_hostnames: Vec<Option<InternedString>>,
pub struct NetController {
pub(crate) db: TypedPatchDb<Database>,
pub(super) tor: TorController,
pub(super) vhost: VHostController,
pub(crate) net_iface: Arc<NetworkInterfaceController>,
pub(super) dns: DnsController,
pub(super) forward: LanPortForwardController,
pub(super) server_hostnames: Vec<Option<InternedString>>,
}
impl PreInitNetController {
#[instrument(skip_all)]
impl NetController {
pub async fn init(
db: TypedPatchDb<Database>,
tor_control: SocketAddr,
tor_socks: SocketAddr,
hostname: &Hostname,
os_tor_key: TorSecretKeyV3,
) -> Result<Self, Error> {
let net_iface = Arc::new(NetworkInterfaceController::new(db.clone()));
let mut res = Self {
Ok(Self {
db: db.clone(),
tor: TorController::new(tor_control, tor_socks),
vhost: VHostController::new(db, net_iface.clone()),
dns: DnsController::init(net_iface.lxcbr_status()).await?,
forward: LanPortForwardController::new(net_iface.subscribe()),
net_iface,
os_bindings: Vec::new(),
server_hostnames: Vec::new(),
};
res.add_os_bindings(hostname, os_tor_key).await?;
Ok(res)
}
async fn add_os_bindings(
&mut self,
hostname: &Hostname,
tor_key: TorSecretKeyV3,
) -> Result<(), Error> {
self.server_hostnames = vec![
server_hostnames: vec![
// LAN IP
None,
// Internal DNS
@@ -73,83 +65,8 @@ impl PreInitNetController {
Some(hostname.no_dot_host_name()),
// LAN mDNS
Some(hostname.local_domain_name()),
];
let vhost_target = TargetInfo {
public: false,
acme: None,
addr: ([127, 0, 0, 1], 80).into(),
connect_ssl: Err(AlpnInfo::Specified(vec![
MaybeUtf8String("http/1.1".into()),
MaybeUtf8String("h2".into()),
])),
};
for hostname in self.server_hostnames.iter().cloned() {
self.os_bindings
.push(self.vhost.add(hostname, 443, vhost_target.clone())?);
}
// Tor
self.os_bindings.push(self.vhost.add(
Some(InternedString::from_display(
&tor_key.public().get_onion_address(),
)),
443,
vhost_target,
)?);
self.os_bindings.extend(
self.tor
.add(
tor_key,
vec![
(80, ([127, 0, 0, 1], 80).into()), // http
(443, ([127, 0, 0, 1], 443).into()), // https
],
)
.await?,
);
Ok(())
}
}
pub struct NetController {
db: TypedPatchDb<Database>,
pub(super) tor: TorController,
pub(super) vhost: VHostController,
pub net_iface: Arc<NetworkInterfaceController>,
pub(super) dns: DnsController,
pub(super) forward: LanPortForwardController,
pub(super) os_bindings: Vec<Arc<()>>,
pub(super) server_hostnames: Vec<Option<InternedString>>,
}
impl NetController {
pub async fn init(
PreInitNetController {
db,
tor,
vhost,
net_iface,
os_bindings,
server_hostnames,
}: PreInitNetController,
dns_bind: &[SocketAddr],
) -> Result<Self, Error> {
let mut res = Self {
db,
tor,
vhost,
dns: DnsController::init(dns_bind).await?,
forward: LanPortForwardController::new(net_iface.subscribe()),
net_iface,
os_bindings,
server_hostnames,
};
res.os_bindings
.push(res.dns.add(None, HOST_IP.into()).await?);
Ok(res)
})
}
#[instrument(skip_all)]
@@ -160,17 +77,48 @@ impl NetController {
) -> Result<NetService, Error> {
let dns = self.dns.add(Some(package.clone()), ip).await?;
let mut res = NetService {
shutdown: false,
id: package,
let res = NetService::new(NetServiceData {
id: Some(package),
ip,
dns,
controller: Arc::downgrade(self),
binds: BTreeMap::new(),
};
})?;
res.clear_bindings(Default::default()).await?;
Ok(res)
}
pub async fn os_bindings(self: &Arc<Self>) -> Result<NetService, Error> {
let dns = self.dns.add(None, HOST_IP.into()).await?;
let service = NetService::new(NetServiceData {
id: None,
ip: [127, 0, 0, 1].into(),
dns,
controller: Arc::downgrade(self),
binds: BTreeMap::new(),
})?;
service.clear_bindings(Default::default()).await?;
service
.bind(
HostId::default(),
80,
BindOptions {
preferred_external_port: 80,
add_ssl: Some(AddSslOptions {
preferred_external_port: 443,
alpn: Some(AlpnInfo::Specified(vec![
MaybeUtf8String("http/1.1".into()),
MaybeUtf8String("h2".into()),
])),
}),
secure: None,
},
)
.await?;
Ok(service)
}
}
#[derive(Default, Debug)]
@@ -180,15 +128,14 @@ struct HostBinds {
tor: BTreeMap<OnionAddressV3, (OrdMap<u16, SocketAddr>, Vec<Arc<()>>)>,
}
pub struct NetService {
shutdown: bool,
id: PackageId,
pub struct NetServiceData {
id: Option<PackageId>,
ip: Ipv4Addr,
dns: Arc<()>,
controller: Weak<NetController>,
binds: BTreeMap<HostId, HostBinds>,
}
impl NetService {
impl NetServiceData {
fn net_controller(&self) -> Result<Arc<NetController>, Error> {
Weak::upgrade(&self.controller).ok_or_else(|| {
Error::new(
@@ -198,34 +145,13 @@ impl NetService {
})
}
pub async fn bind(
async fn clear_bindings(
&mut self,
kind: HostKind,
id: HostId,
internal_port: u16,
options: BindOptions,
ctrl: &NetController,
except: BTreeSet<BindId>,
) -> Result<(), Error> {
crate::dbg!("bind", &kind, &id, internal_port, &options);
let pkg_id = &self.id;
let host = self
.net_controller()?
.db
.mutate(|db| {
let mut ports = db.as_private().as_available_ports().de()?;
let host = host_for(db, pkg_id, &id, kind)?;
host.add_binding(&mut ports, internal_port, options)?;
let host = host.de()?;
db.as_private_mut().as_available_ports_mut().ser(&ports)?;
Ok(host)
})
.await?;
self.update(id, host).await
}
pub async fn clear_bindings(&mut self, except: BTreeSet<BindId>) -> Result<(), Error> {
let pkg_id = &self.id;
let hosts = self
.net_controller()?
if let Some(pkg_id) = &self.id {
let hosts = ctrl
.db
.mutate(|db| {
let mut res = Hosts::default();
@@ -255,13 +181,33 @@ impl NetService {
.await?;
let mut errors = ErrorCollection::new();
for (id, host) in hosts.0 {
errors.handle(self.update(id, host).await);
errors.handle(self.update(ctrl, id, host).await);
}
errors.into_result()
} else {
let host = ctrl
.db
.mutate(|db| {
let host = db.as_public_mut().as_server_info_mut().as_host_mut();
host.as_bindings_mut().mutate(|b| {
for (internal_port, info) in b {
if !except.contains(&BindId {
id: HostId::default(),
internal_port: *internal_port,
}) {
info.disable();
}
}
Ok(())
})?;
host.de()
})
.await?;
self.update(ctrl, HostId::default(), host).await
}
}
pub async fn update(&mut self, id: HostId, host: Host) -> Result<(), Error> {
let ctrl = self.net_controller()?;
async fn update(&mut self, ctrl: &NetController, id: HostId, host: Host) -> Result<(), Error> {
let mut forwards: BTreeMap<u16, (SocketAddr, bool)> = BTreeMap::new();
let mut vhosts: BTreeMap<(Option<InternedString>, u16), TargetInfo> = BTreeMap::new();
let mut tor: BTreeMap<OnionAddressV3, (TorSecretKeyV3, OrdMap<u16, SocketAddr>)> =
@@ -630,7 +576,7 @@ impl NetService {
ctrl.db
.mutate(|db| {
host_for(db, &self.id, &id, host.kind)?
host_for(db, self.id.as_ref(), &id)?
.as_hostname_info_mut()
.ser(&hostname_info)
})
@@ -638,10 +584,129 @@ impl NetService {
Ok(())
}
async fn update_all(&mut self) -> Result<(), Error> {
let ctrl = self.net_controller()?;
if let Some(id) = &self.id {
for (host_id, host) in ctrl
.db
.peek()
.await
.as_public()
.as_package_data()
.as_idx(id)
.or_not_found(id)?
.as_hosts()
.as_entries()?
{
self.update(&*ctrl, host_id, host.de()?).await?;
}
} else {
self.update(
&*ctrl,
HostId::default(),
ctrl.db
.peek()
.await
.as_public()
.as_server_info()
.as_host()
.de()?,
)
.await?;
}
Ok(())
}
}
pub struct NetService {
shutdown: bool,
data: Arc<Mutex<NetServiceData>>,
sync_task: JoinHandle<()>,
}
impl NetService {
fn dummy() -> Self {
Self {
shutdown: true,
data: Arc::new(Mutex::new(NetServiceData {
id: None,
ip: Ipv4Addr::new(0, 0, 0, 0),
dns: Default::default(),
controller: Default::default(),
binds: BTreeMap::new(),
})),
sync_task: tokio::spawn(futures::future::ready(())),
}
}
fn new(data: NetServiceData) -> Result<Self, Error> {
let mut ip_info = data.net_controller()?.net_iface.subscribe();
let data = Arc::new(Mutex::new(data));
let thread_data = data.clone();
let sync_task = tokio::spawn(async move {
loop {
if let Err(e) = thread_data.lock().await.update_all().await {
tracing::error!("Failed to update network info: {e}");
tracing::debug!("{e:?}");
}
ip_info.changed().await;
}
});
Ok(Self {
shutdown: false,
data,
sync_task,
})
}
pub async fn bind(
&self,
id: HostId,
internal_port: u16,
options: BindOptions,
) -> Result<(), Error> {
let mut data = self.data.lock().await;
let pkg_id = &data.id;
let ctrl = data.net_controller()?;
let host = ctrl
.db
.mutate(|db| {
let mut ports = db.as_private().as_available_ports().de()?;
let host = host_for(db, pkg_id.as_ref(), &id)?;
host.add_binding(&mut ports, internal_port, options)?;
let host = host.de()?;
db.as_private_mut().as_available_ports_mut().ser(&ports)?;
Ok(host)
})
.await?;
data.update(&*ctrl, id, host).await
}
pub async fn clear_bindings(&self, except: BTreeSet<BindId>) -> Result<(), Error> {
let mut data = self.data.lock().await;
let ctrl = data.net_controller()?;
data.clear_bindings(&*ctrl, except).await
}
pub async fn update(&self, id: HostId, host: Host) -> Result<(), Error> {
let mut data = self.data.lock().await;
let ctrl = data.net_controller()?;
data.update(&*ctrl, id, host).await
}
pub async fn sync_host(&self, id: HostId) -> Result<(), Error> {
let mut data = self.data.lock().await;
let ctrl = data.net_controller()?;
let host = host_for(&mut ctrl.db.peek().await, data.id.as_ref(), &id)?.de()?;
data.update(&*ctrl, id, host).await
}
pub async fn remove_all(mut self) -> Result<(), Error> {
self.sync_task.abort();
let mut data = self.data.lock().await;
if let Some(ctrl) = Weak::upgrade(&data.controller) {
self.shutdown = true;
if let Some(ctrl) = Weak::upgrade(&self.controller) {
self.clear_bindings(Default::default()).await?;
data.clear_bindings(&*ctrl, Default::default()).await?;
drop(ctrl);
Ok(())
} else {
@@ -653,26 +718,15 @@ impl NetService {
}
}
pub fn get_ip(&self) -> Ipv4Addr {
self.ip
pub async fn get_ip(&self) -> Ipv4Addr {
self.data.lock().await.ip
}
}
impl Drop for NetService {
fn drop(&mut self) {
if !self.shutdown {
tracing::debug!("Dropping NetService for {}", self.id);
let svc = std::mem::replace(
self,
NetService {
shutdown: true,
id: Default::default(),
ip: Ipv4Addr::new(0, 0, 0, 0),
dns: Default::default(),
controller: Default::default(),
binds: BTreeMap::new(),
},
);
let svc = std::mem::replace(self, Self::dummy());
tokio::spawn(async move { svc.remove_all().await.log_err() });
}
}

View File

@@ -27,6 +27,7 @@ use zbus::{proxy, Connection};
use crate::context::{CliContext, RpcContext};
use crate::db::model::public::{IpInfo, NetworkInterfaceInfo, NetworkInterfaceType};
use crate::db::model::Database;
use crate::net::forward::START9_BRIDGE_IFACE;
use crate::net::utils::{ipv6_is_link_local, ipv6_is_local};
use crate::prelude::*;
use crate::util::future::Until;
@@ -319,7 +320,10 @@ impl<'a> StubStream<'a> for SignalStream<'a> {
}
#[instrument(skip_all)]
async fn watcher(write_to: Watch<BTreeMap<InternedString, NetworkInterfaceInfo>>) {
async fn watcher(
write_to: Watch<BTreeMap<InternedString, NetworkInterfaceInfo>>,
lxcbr_status: Watch<bool>,
) {
loop {
let res: Result<(), Error> = async {
let connection = Connection::system().await?;
@@ -357,19 +361,27 @@ async fn watcher(write_to: Watch<BTreeMap<InternedString, NetworkInterfaceInfo>>
let mut ifaces = BTreeSet::new();
let mut jobs = Vec::new();
for device in devices {
use futures::future::Either;
let device_proxy =
device::DeviceProxy::new(&connection, device.clone()).await?;
let iface = InternedString::intern(device_proxy.ip_interface().await?);
if iface.is_empty() {
continue;
} else if &*iface == START9_BRIDGE_IFACE {
jobs.push(Either::Left(watch_activated(
&connection,
device_proxy.clone(),
&lxcbr_status,
)));
}
jobs.push(watch_ip(
jobs.push(Either::Right(watch_ip(
&connection,
device_proxy.clone(),
iface.clone(),
&write_to,
));
)));
ifaces.insert(iface);
}
@@ -588,13 +600,49 @@ async fn watch_ip(
}
}
#[instrument(skip(_connection, device_proxy, write_to))]
async fn watch_activated(
_connection: &Connection,
device_proxy: device::DeviceProxy<'_>,
write_to: &Watch<bool>,
) -> Result<(), Error> {
let mut until = Until::new()
.with_stream(
device_proxy
.receive_active_connection_changed()
.await
.stub(),
)
.with_stream(
device_proxy
.receive_state_changed()
.await?
.into_inner()
.stub(),
);
loop {
until
.run(async {
write_to.send(device_proxy._state().await? == 100);
Ok(())
})
.await?;
}
}
pub struct NetworkInterfaceController {
db: TypedPatchDb<Database>,
lxcbr_status: Watch<bool>,
ip_info: Watch<BTreeMap<InternedString, NetworkInterfaceInfo>>,
_watcher: NonDetachingJoinHandle<()>,
listeners: SyncMutex<BTreeMap<u16, Weak<()>>>,
}
impl NetworkInterfaceController {
pub fn lxcbr_status(&self) -> Watch<bool> {
self.lxcbr_status.clone_unseen()
}
pub fn subscribe(&self) -> Watch<BTreeMap<InternedString, NetworkInterfaceInfo>> {
self.ip_info.clone_unseen()
}
@@ -665,8 +713,10 @@ impl NetworkInterfaceController {
}
pub fn new(db: TypedPatchDb<Database>) -> Self {
let mut ip_info = Watch::new(BTreeMap::new());
let lxcbr_status = Watch::new(false);
Self {
db: db.clone(),
lxcbr_status: lxcbr_status.clone(),
ip_info: ip_info.clone(),
_watcher: tokio::spawn(async move {
match db
@@ -688,7 +738,7 @@ impl NetworkInterfaceController {
tracing::debug!("{e:?}");
}
};
tokio::join!(watcher(ip_info.clone()), async {
tokio::join!(watcher(ip_info.clone(), lxcbr_status), async {
let res: Result<(), Error> = async {
loop {
if let Err(e) = async {

View File

@@ -1,6 +1,5 @@
use std::collections::{BTreeMap, BTreeSet};
use std::net::{IpAddr, SocketAddr};
use std::str::FromStr;
use std::sync::{Arc, Weak};
use std::time::Duration;
@@ -328,17 +327,23 @@ impl VHostServer {
.headers()
.get(http::header::HOST)
.and_then(|host| host.to_str().ok());
if let Some(host) = host {
let uri = Uri::from_parts({
let mut parts = req.uri().to_owned().into_parts();
let mut parts =
req.uri().to_owned().into_parts();
parts.scheme = Some("https".parse()?);
parts.authority =
host.map(FromStr::from_str).transpose()?;
parts.authority = Some(host.parse()?);
parts
})?;
Response::builder()
.status(http::StatusCode::TEMPORARY_REDIRECT)
.header(http::header::LOCATION, uri.to_string())
.body(Body::default())
} else {
Response::builder()
.status(http::StatusCode::BAD_REQUEST)
.body(Body::from("Host header required"))
}
}
.await
{

View File

@@ -899,9 +899,8 @@ impl TypedValueParser for CountryCodeParser {
#[instrument(skip_all)]
pub async fn synchronize_network_manager<P: AsRef<Path>>(
main_datadir: P,
wifi: &mut WifiInfo,
wifi: &WifiInfo,
) -> Result<(), Error> {
wifi.interface = find_wifi_iface().await?;
let persistent = main_datadir.as_ref().join("system-connections");
if tokio::fs::metadata(&persistent).await.is_err() {

View File

@@ -1,14 +1,12 @@
use models::{HostId, PackageId};
use crate::net::host::binding::{BindId, BindOptions, NetInfo};
use crate::net::host::HostKind;
use crate::service::effects::prelude::*;
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct BindParams {
kind: HostKind,
id: HostId,
internal_port: u16,
#[serde(flatten)]
@@ -17,15 +15,18 @@ pub struct BindParams {
pub async fn bind(
context: EffectContext,
BindParams {
kind,
id,
internal_port,
options,
}: BindParams,
) -> Result<(), Error> {
let context = context.deref()?;
let mut svc = context.seed.persistent_container.net_service.lock().await;
svc.bind(kind, id, internal_port, options).await
context
.seed
.persistent_container
.net_service
.bind(id, internal_port, options)
.await
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, Parser)]
@@ -41,8 +42,12 @@ pub async fn clear_bindings(
ClearBindingsParams { except }: ClearBindingsParams,
) -> Result<(), Error> {
let context = context.deref()?;
let mut svc = context.seed.persistent_container.net_service.lock().await;
svc.clear_bindings(except.into_iter().collect()).await?;
context
.seed
.persistent_container
.net_service
.clear_bindings(except.into_iter().collect())
.await?;
Ok(())
}

View File

@@ -4,6 +4,5 @@ use crate::service::effects::prelude::*;
pub async fn get_container_ip(context: EffectContext) -> Result<Ipv4Addr, Error> {
let context = context.deref()?;
let net_service = context.seed.persistent_container.net_service.lock().await;
Ok(net_service.get_ip())
Ok(context.seed.persistent_container.net_service.get_ip().await)
}

View File

@@ -605,23 +605,12 @@ impl Service {
})
}
pub async fn update_host(&self, host_id: HostId) -> Result<(), Error> {
let mut service = self.seed.persistent_container.net_service.lock().await;
let host = self
.seed
.ctx
.db
.peek()
pub async fn sync_host(&self, host_id: HostId) -> Result<(), Error> {
self.seed
.persistent_container
.net_service
.sync_host(host_id)
.await
.as_public()
.as_package_data()
.as_idx(&self.seed.id)
.or_not_found(&self.seed.id)?
.as_hosts()
.as_idx(&host_id)
.or_not_found(&host_id)?
.de()?;
service.update(host_id, host).await
}
}

View File

@@ -110,7 +110,7 @@ pub struct PersistentContainer {
pub(super) images: BTreeMap<ImageId, Arc<MountGuard>>,
pub(super) subcontainers: Arc<Mutex<BTreeMap<Guid, Subcontainer>>>,
pub(super) state: Arc<watch::Sender<ServiceState>>,
pub(super) net_service: Mutex<NetService>,
pub(super) net_service: NetService,
destroyed: bool,
}
@@ -285,7 +285,7 @@ impl PersistentContainer {
images,
subcontainers: Arc::new(Mutex::new(BTreeMap::new())),
state: Arc::new(watch::channel(ServiceState::new(start)).0),
net_service: Mutex::new(net_service),
net_service,
destroyed: false,
})
}

View File

@@ -37,38 +37,6 @@ impl Actor for ServiceActor {
}
}
});
let seed = self.0.clone();
let mut ip_info = seed.ctx.net_controller.net_iface.subscribe();
jobs.add_job(async move {
loop {
if let Err(e) = async {
let mut service = seed.persistent_container.net_service.lock().await;
let hosts = seed
.ctx
.db
.peek()
.await
.as_public()
.as_package_data()
.as_idx(&seed.id)
.or_not_found(&seed.id)?
.as_hosts()
.de()?;
for (host_id, host) in hosts.0 {
service.update(host_id, host).await?;
}
Ok::<_, Error>(())
}
.await
{
tracing::error!("Error syncronizing net host after network change: {e}");
tracing::debug!("{e:?}");
}
ip_info.changed().await;
}
});
}
}

View File

@@ -4,7 +4,8 @@ use std::time::Duration;
use color_eyre::eyre::eyre;
use futures::future::BoxFuture;
use futures::{Future, FutureExt};
use futures::stream::FuturesUnordered;
use futures::{Future, FutureExt, StreamExt};
use helpers::NonDetachingJoinHandle;
use imbl::OrdMap;
use imbl_value::InternedString;
@@ -68,8 +69,12 @@ impl ServiceMap {
progress.start();
let ids = ctx.db.peek().await.as_public().as_package_data().keys()?;
progress.set_total(ids.len() as u64);
for id in ids {
if let Err(e) = self.load(ctx, &id, LoadDisposition::Retry).await {
let mut jobs = FuturesUnordered::new();
for id in &ids {
jobs.push(self.load(ctx, id, LoadDisposition::Retry));
}
while let Some(res) = jobs.next().await {
if let Err(e) = res {
tracing::error!("Error loading installed package as service: {e}");
tracing::debug!("{e:?}");
}

View File

@@ -31,7 +31,7 @@ use crate::disk::mount::guard::{GenericMountGuard, TmpMountGuard};
use crate::disk::util::{pvscan, recovery_info, DiskInfo, StartOsRecoveryInfo};
use crate::disk::REPAIR_DISK_PATH;
use crate::init::{init, InitPhases, InitResult};
use crate::net::net_controller::PreInitNetController;
use crate::net::net_controller::NetController;
use crate::net::ssl::root_ca_start_time;
use crate::prelude::*;
use crate::progress::{FullProgress, PhaseProgressTrackerHandle};
@@ -80,10 +80,11 @@ async fn setup_init(
ctx: &SetupContext,
password: Option<String>,
init_phases: InitPhases,
) -> Result<(AccountInfo, PreInitNetController), Error> {
let InitResult { net_ctrl } = init(&ctx.webserver, &ctx.config, init_phases).await?;
) -> Result<(AccountInfo, InitResult), Error> {
let init_result = init(&ctx.webserver, &ctx.config, init_phases).await?;
let account = net_ctrl
let account = init_result
.net_ctrl
.db
.mutate(|m| {
let mut account = AccountInfo::load(m)?;
@@ -99,7 +100,7 @@ async fn setup_init(
})
.await?;
Ok((account, net_ctrl))
Ok((account, init_result))
}
#[derive(Deserialize, Serialize, TS)]
@@ -452,13 +453,13 @@ async fn fresh_setup(
db.put(&ROOT, &Database::init(&account)?).await?;
drop(db);
let InitResult { net_ctrl } = init(&ctx.webserver, &ctx.config, init_phases).await?;
let init_result = init(&ctx.webserver, &ctx.config, init_phases).await?;
let rpc_ctx = RpcContext::init(
&ctx.webserver,
&ctx.config,
guid,
Some(net_ctrl),
Some(init_result),
rpc_ctx_phases,
)
.await?;

View File

@@ -77,6 +77,14 @@ impl<T> Watch<T> {
pub async fn changed(&mut self) {
futures::future::poll_fn(|cx| self.poll_changed(cx)).await
}
pub async fn wait_for<F: FnMut(&T) -> bool>(&mut self, mut f: F) {
loop {
if self.peek(&mut f) {
break;
}
self.changed().await;
}
}
pub fn send_if_modified<F: FnOnce(&mut T) -> bool>(&self, modify: F) -> bool {
self.shared.mutate(|shared| {
let changed = modify(&mut shared.data);

View File

@@ -30,8 +30,9 @@ mod v0_3_6_alpha_9;
mod v0_3_6_alpha_10;
mod v0_3_6_alpha_11;
mod v0_3_6_alpha_12;
pub type Current = v0_3_6_alpha_11::Version; // VERSION_BUMP
pub type Current = v0_3_6_alpha_12::Version; // VERSION_BUMP
impl Current {
#[instrument(skip(self, db))]
@@ -113,6 +114,7 @@ enum Version {
V0_3_6_alpha_9(Wrapper<v0_3_6_alpha_9::Version>),
V0_3_6_alpha_10(Wrapper<v0_3_6_alpha_10::Version>),
V0_3_6_alpha_11(Wrapper<v0_3_6_alpha_11::Version>),
V0_3_6_alpha_12(Wrapper<v0_3_6_alpha_12::Version>),
Other(exver::Version),
}
@@ -148,6 +150,7 @@ impl Version {
Self::V0_3_6_alpha_9(v) => DynVersion(Box::new(v.0)),
Self::V0_3_6_alpha_10(v) => DynVersion(Box::new(v.0)),
Self::V0_3_6_alpha_11(v) => DynVersion(Box::new(v.0)),
Self::V0_3_6_alpha_12(v) => DynVersion(Box::new(v.0)),
Self::Other(v) => {
return Err(Error::new(
eyre!("unknown version {v}"),
@@ -175,6 +178,7 @@ impl Version {
Version::V0_3_6_alpha_9(Wrapper(x)) => x.semver(),
Version::V0_3_6_alpha_10(Wrapper(x)) => x.semver(),
Version::V0_3_6_alpha_11(Wrapper(x)) => x.semver(),
Version::V0_3_6_alpha_12(Wrapper(x)) => x.semver(),
Version::Other(x) => x.clone(),
}
}

View File

@@ -478,7 +478,7 @@ async fn previous_account_info(pg: &sqlx::Pool<sqlx::Postgres>) -> Result<Accoun
password: account_query
.try_get("password")
.with_ctx(|_| (ErrorKind::Database, "password"))?,
tor_key: TorSecretKeyV3::try_from(
tor_keys: vec![TorSecretKeyV3::try_from(
if let Some(bytes) = account_query
.try_get::<Option<Vec<u8>>, _>("tor_key")
.with_ctx(|_| (ErrorKind::Database, "tor_key"))?
@@ -503,7 +503,7 @@ async fn previous_account_info(pg: &sqlx::Pool<sqlx::Postgres>) -> Result<Accoun
.with_ctx(|_| (ErrorKind::Database, "password.u8 32"))?,
)
},
)?,
)?],
server_id: account_query
.try_get("server_id")
.with_ctx(|_| (ErrorKind::Database, "server_id"))?,

View File

@@ -0,0 +1,68 @@
use std::collections::BTreeMap;
use exver::{PreReleaseSegment, VersionRange};
use imbl_value::json;
use super::v0_3_5::V0_3_0_COMPAT;
use super::{v0_3_6_alpha_11, VersionT};
use crate::prelude::*;
lazy_static::lazy_static! {
static ref V0_3_6_alpha_12: exver::Version = exver::Version::new(
[0, 3, 6],
[PreReleaseSegment::String("alpha".into()), 12.into()]
);
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Version;
impl VersionT for Version {
type Previous = v0_3_6_alpha_11::Version;
type PreUpRes = ();
async fn pre_up(self) -> Result<Self::PreUpRes, Error> {
Ok(())
}
fn semver(self) -> exver::Version {
V0_3_6_alpha_12.clone()
}
fn compat(self) -> &'static VersionRange {
&V0_3_0_COMPAT
}
fn up(self, db: &mut Value, _: Self::PreUpRes) -> Result<(), Error> {
let bindings: BTreeMap<u16, Value> = [(
80,
json!({
"enabled": false,
"options": {
"preferredExternalPort": 80,
"addSsl": {
"preferredExternalPort": 443,
"alpn": { "specified": [ "http/1.1", "h2" ] },
},
"secure": null,
},
"net": {
"assignedPort": null,
"assignedSslPort": 443,
"public": false,
}
}),
)]
.into_iter()
.collect();
let onion = db["public"]["serverInfo"]["onionAddress"].clone();
db["public"]["serverInfo"]["host"] = json!({
"bindings": bindings,
"onions": [onion],
"domains": {},
"hostnameInfo": {},
});
Ok(())
}
fn down(self, _db: &mut Value) -> Result<(), Error> {
Ok(())
}
}

6
package-lock.json generated
View File

@@ -1,6 +0,0 @@
{
"name": "start-os",
"lockfileVersion": 2,
"requires": true,
"packages": {}
}

View File

@@ -77,19 +77,18 @@ type BindOptionsByKnownProtocol =
preferredExternalPort?: number
addSsl?: AddSslOptions
}
export type BindOptionsByProtocol = BindOptionsByKnownProtocol | BindOptions
export type HostKind = BindParams["kind"]
export type BindOptionsByProtocol =
| BindOptionsByKnownProtocol
| (BindOptions & { protocol: null })
const hasStringProtocol = object({
protocol: string,
}).test
export class Host {
export class MultiHost {
constructor(
readonly options: {
effects: Effects
kind: HostKind
id: string
},
) {}
@@ -113,7 +112,7 @@ export class Host {
async bindPort(
internalPort: number,
options: BindOptionsByProtocol,
): Promise<Origin<this>> {
): Promise<Origin> {
if (hasStringProtocol(options)) {
return await this.bindPortForKnown(options, internalPort)
} else {
@@ -130,7 +129,6 @@ export class Host {
},
) {
const binderOptions = {
kind: this.options.kind,
id: this.options.id,
internalPort,
...options,
@@ -163,7 +161,6 @@ export class Host {
const secure: Security | null = !protoInfo.secure ? null : { ssl: false }
await this.options.effects.bind({
kind: this.options.kind,
id: this.options.id,
internalPort,
preferredExternalPort,
@@ -190,21 +187,3 @@ function inObject<Key extends string>(
): obj is { [K in Key]: unknown } {
return key in obj
}
// export class StaticHost extends Host {
// constructor(options: { effects: Effects; id: string }) {
// super({ ...options, kind: "static" })
// }
// }
// export class SingleHost extends Host {
// constructor(options: { effects: Effects; id: string }) {
// super({ ...options, kind: "single" })
// }
// }
export class MultiHost extends Host {
constructor(options: { effects: Effects; id: string }) {
super({ ...options, kind: "multi" })
}
}

View File

@@ -1,11 +1,11 @@
import { AddressInfo } from "../types"
import { AddressReceipt } from "./AddressReceipt"
import { Host, Scheme } from "./Host"
import { MultiHost, Scheme } from "./Host"
import { ServiceInterfaceBuilder } from "./ServiceInterfaceBuilder"
export class Origin<T extends Host> {
export class Origin {
constructor(
readonly host: T,
readonly host: MultiHost,
readonly internalPort: number,
readonly scheme: string | null,
readonly sslScheme: string | null,

View File

@@ -1,11 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AddSslOptions } from "./AddSslOptions"
import type { HostId } from "./HostId"
import type { HostKind } from "./HostKind"
import type { Security } from "./Security"
export type BindParams = {
kind: HostKind
id: HostId
internalPort: number
preferredExternalPort: number

View File

@@ -1,11 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { BindInfo } from "./BindInfo"
import type { DomainConfig } from "./DomainConfig"
import type { HostKind } from "./HostKind"
import type { HostnameInfo } from "./HostnameInfo"
export type Host = {
kind: HostKind
bindings: { [key: number]: BindInfo }
onions: string[]
domains: { [key: string]: DomainConfig }

View File

@@ -1,3 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HostKind = "multi"

View File

@@ -2,6 +2,7 @@
import type { AcmeProvider } from "./AcmeProvider"
import type { AcmeSettings } from "./AcmeSettings"
import type { Governor } from "./Governor"
import type { Host } from "./Host"
import type { LshwDevice } from "./LshwDevice"
import type { NetworkInterfaceInfo } from "./NetworkInterfaceInfo"
import type { ServerStatus } from "./ServerStatus"
@@ -13,16 +14,11 @@ export type ServerInfo = {
platform: string
id: string
hostname: string
host: Host
version: string
packageVersionCompat: string
postInitMigrationTodos: string[]
lastBackup: string | null
lanAddress: string
onionAddress: string
/**
* for backwards compatibility
*/
torAddress: string
networkInterfaces: { [key: string]: NetworkInterfaceInfo }
acme: { [key: AcmeProvider]: AcmeSettings }
statusInfo: ServerStatus

View File

@@ -1,7 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SetupResult = {
torAddress: string
torAddresses: Array<string>
hostname: string
lanAddress: string
rootCa: string

View File

@@ -4,13 +4,13 @@ export { AcmeSettings } from "./AcmeSettings"
export { ActionId } from "./ActionId"
export { ActionInput } from "./ActionInput"
export { ActionMetadata } from "./ActionMetadata"
export { ActionRequest } from "./ActionRequest"
export { ActionRequestCondition } from "./ActionRequestCondition"
export { ActionRequestEntry } from "./ActionRequestEntry"
export { ActionRequestInput } from "./ActionRequestInput"
export { ActionRequestTrigger } from "./ActionRequestTrigger"
export { ActionRequest } from "./ActionRequest"
export { ActionResultMember } from "./ActionResultMember"
export { ActionResult } from "./ActionResult"
export { ActionResultMember } from "./ActionResultMember"
export { ActionResultV0 } from "./ActionResultV0"
export { ActionResultV1 } from "./ActionResultV1"
export { ActionResultValue } from "./ActionResultValue"
@@ -20,13 +20,13 @@ export { AddAdminParams } from "./AddAdminParams"
export { AddAssetParams } from "./AddAssetParams"
export { AddCategoryParams } from "./AddCategoryParams"
export { AddPackageParams } from "./AddPackageParams"
export { AddressInfo } from "./AddressInfo"
export { AddSslOptions } from "./AddSslOptions"
export { AddVersionParams } from "./AddVersionParams"
export { AddressInfo } from "./AddressInfo"
export { Alerts } from "./Alerts"
export { Algorithm } from "./Algorithm"
export { AllowedStatuses } from "./AllowedStatuses"
export { AllPackageData } from "./AllPackageData"
export { AllowedStatuses } from "./AllowedStatuses"
export { AlpnInfo } from "./AlpnInfo"
export { AnySignature } from "./AnySignature"
export { AnySigningKey } from "./AnySigningKey"
@@ -38,9 +38,9 @@ export { BackupTargetFS } from "./BackupTargetFS"
export { Base64 } from "./Base64"
export { BindId } from "./BindId"
export { BindInfo } from "./BindInfo"
export { BindingSetPublicParams } from "./BindingSetPublicParams"
export { BindOptions } from "./BindOptions"
export { BindParams } from "./BindParams"
export { BindingSetPublicParams } from "./BindingSetPublicParams"
export { Blake3Commitment } from "./Blake3Commitment"
export { BlockDev } from "./BlockDev"
export { BuildArg } from "./BuildArg"
@@ -60,11 +60,11 @@ export { CreateSubcontainerFsParams } from "./CreateSubcontainerFsParams"
export { CurrentDependencies } from "./CurrentDependencies"
export { CurrentDependencyInfo } from "./CurrentDependencyInfo"
export { DataUrl } from "./DataUrl"
export { DepInfo } from "./DepInfo"
export { Dependencies } from "./Dependencies"
export { DependencyKind } from "./DependencyKind"
export { DependencyMetadata } from "./DependencyMetadata"
export { DependencyRequirement } from "./DependencyRequirement"
export { DepInfo } from "./DepInfo"
export { Description } from "./Description"
export { DestroySubcontainerFsParams } from "./DestroySubcontainerFsParams"
export { DeviceFilter } from "./DeviceFilter"
@@ -84,8 +84,8 @@ export { GetHostInfoParams } from "./GetHostInfoParams"
export { GetOsAssetParams } from "./GetOsAssetParams"
export { GetOsVersionParams } from "./GetOsVersionParams"
export { GetPackageParams } from "./GetPackageParams"
export { GetPackageResponseFull } from "./GetPackageResponseFull"
export { GetPackageResponse } from "./GetPackageResponse"
export { GetPackageResponseFull } from "./GetPackageResponseFull"
export { GetServiceInterfaceParams } from "./GetServiceInterfaceParams"
export { GetServicePortForwardParams } from "./GetServicePortForwardParams"
export { GetSslCertificateParams } from "./GetSslCertificateParams"
@@ -98,22 +98,21 @@ export { Governor } from "./Governor"
export { Guid } from "./Guid"
export { HardwareRequirements } from "./HardwareRequirements"
export { HealthCheckId } from "./HealthCheckId"
export { Host } from "./Host"
export { HostAddress } from "./HostAddress"
export { HostId } from "./HostId"
export { HostKind } from "./HostKind"
export { HostnameInfo } from "./HostnameInfo"
export { Hosts } from "./Hosts"
export { Host } from "./Host"
export { ImageConfig } from "./ImageConfig"
export { ImageId } from "./ImageId"
export { ImageMetadata } from "./ImageMetadata"
export { ImageSource } from "./ImageSource"
export { InitProgressRes } from "./InitProgressRes"
export { InstallParams } from "./InstallParams"
export { InstalledState } from "./InstalledState"
export { InstalledVersionParams } from "./InstalledVersionParams"
export { InstallingInfo } from "./InstallingInfo"
export { InstallingState } from "./InstallingState"
export { InstallParams } from "./InstallParams"
export { IpHostname } from "./IpHostname"
export { IpInfo } from "./IpInfo"
export { ListPackageSignersParams } from "./ListPackageSignersParams"
@@ -137,14 +136,14 @@ export { NetworkInterfaceSetPublicParams } from "./NetworkInterfaceSetPublicPara
export { NetworkInterfaceType } from "./NetworkInterfaceType"
export { OnionHostname } from "./OnionHostname"
export { OsIndex } from "./OsIndex"
export { OsVersionInfoMap } from "./OsVersionInfoMap"
export { OsVersionInfo } from "./OsVersionInfo"
export { OsVersionInfoMap } from "./OsVersionInfoMap"
export { PackageDataEntry } from "./PackageDataEntry"
export { PackageDetailLevel } from "./PackageDetailLevel"
export { PackageId } from "./PackageId"
export { PackageIndex } from "./PackageIndex"
export { PackageInfoShort } from "./PackageInfoShort"
export { PackageInfo } from "./PackageInfo"
export { PackageInfoShort } from "./PackageInfoShort"
export { PackageSignerParams } from "./PackageSignerParams"
export { PackageState } from "./PackageState"
export { PackageVersionInfo } from "./PackageVersionInfo"
@@ -166,18 +165,18 @@ export { Security } from "./Security"
export { ServerInfo } from "./ServerInfo"
export { ServerSpecs } from "./ServerSpecs"
export { ServerStatus } from "./ServerStatus"
export { ServiceInterfaceId } from "./ServiceInterfaceId"
export { ServiceInterface } from "./ServiceInterface"
export { ServiceInterfaceId } from "./ServiceInterfaceId"
export { ServiceInterfaceType } from "./ServiceInterfaceType"
export { Session } from "./Session"
export { SessionList } from "./SessionList"
export { Sessions } from "./Sessions"
export { Session } from "./Session"
export { SetDataVersionParams } from "./SetDataVersionParams"
export { SetDependenciesParams } from "./SetDependenciesParams"
export { SetHealth } from "./SetHealth"
export { SetIconParams } from "./SetIconParams"
export { SetMainStatusStatus } from "./SetMainStatusStatus"
export { SetMainStatus } from "./SetMainStatus"
export { SetMainStatusStatus } from "./SetMainStatusStatus"
export { SetNameParams } from "./SetNameParams"
export { SetStoreParams } from "./SetStoreParams"
export { SetupExecuteParams } from "./SetupExecuteParams"
@@ -192,7 +191,7 @@ export { TestSmtpParams } from "./TestSmtpParams"
export { UnsetPublicParams } from "./UnsetPublicParams"
export { UpdatingState } from "./UpdatingState"
export { VerifyCifsParams } from "./VerifyCifsParams"
export { VersionSignerParams } from "./VersionSignerParams"
export { Version } from "./Version"
export { VersionSignerParams } from "./VersionSignerParams"
export { VolumeId } from "./VolumeId"
export { WifiInfo } from "./WifiInfo"

View File

@@ -2,63 +2,68 @@ import { Pattern } from "../actions/input/inputSpecTypes"
import * as regexes from "./regexes"
export const ipv6: Pattern = {
regex: regexes.ipv6.source,
regex: regexes.ipv6.matches(),
description: "Must be a valid IPv6 address",
}
export const ipv4: Pattern = {
regex: regexes.ipv4.source,
regex: regexes.ipv4.matches(),
description: "Must be a valid IPv4 address",
}
export const hostname: Pattern = {
regex: regexes.hostname.source,
regex: regexes.hostname.matches(),
description: "Must be a valid hostname",
}
export const localHostname: Pattern = {
regex: regexes.localHostname.source,
regex: regexes.localHostname.matches(),
description: 'Must be a valid ".local" hostname',
}
export const torHostname: Pattern = {
regex: regexes.torHostname.source,
regex: regexes.torHostname.matches(),
description: 'Must be a valid Tor (".onion") hostname',
}
export const url: Pattern = {
regex: regexes.url.source,
regex: regexes.url.matches(),
description: "Must be a valid URL",
}
export const localUrl: Pattern = {
regex: regexes.localUrl.source,
regex: regexes.localUrl.matches(),
description: 'Must be a valid ".local" URL',
}
export const torUrl: Pattern = {
regex: regexes.torUrl.source,
regex: regexes.torUrl.matches(),
description: 'Must be a valid Tor (".onion") URL',
}
export const ascii: Pattern = {
regex: regexes.ascii.source,
regex: regexes.ascii.matches(),
description:
"May only contain ASCII characters. See https://www.w3schools.com/charsets/ref_html_ascii.asp",
}
export const domain: Pattern = {
regex: regexes.domain.matches(),
description: "Must be a valid Fully Qualified Domain Name",
}
export const email: Pattern = {
regex: regexes.email.source,
regex: regexes.email.matches(),
description: "Must be a valid email address",
}
export const emailWithName: Pattern = {
regex: regexes.emailWithName.source,
regex: regexes.emailWithName.matches(),
description: "Must be a valid email address, optionally with a name",
}
export const base64: Pattern = {
regex: regexes.base64.source,
regex: regexes.base64.matches(),
description:
"May only contain base64 characters. See https://base64.guru/learn/base64-characters",
}

View File

@@ -1,38 +1,71 @@
export class ComposableRegex {
readonly regex: RegExp
constructor(regex: RegExp | string) {
if (regex instanceof RegExp) {
this.regex = regex
} else {
this.regex = new RegExp(regex)
}
}
asExpr(): string {
return `(${this.regex.source})`
}
matches(): string {
return `^${this.regex.source}$`
}
contains(): string {
return this.regex.source
}
}
// https://ihateregex.io/expr/ipv6/
export const ipv6 =
/(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/
export const ipv6 = new ComposableRegex(
/(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/,
)
// https://ihateregex.io/expr/ipv4/
export const ipv4 =
/(\b25[0-5]|\b2[0-4][0-9]|\b[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}/
export const ipv4 = new ComposableRegex(
/(\b25[0-5]|\b2[0-4][0-9]|\b[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}/,
)
export const hostname =
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/
export const hostname = new ComposableRegex(
/(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])/,
)
export const localHostname = /[-a-zA-Z0-9@:%._\+~#=]{1,256}\.local/
export const localHostname = new ComposableRegex(
/[-a-zA-Z0-9@:%._\+~#=]{1,256}\.local/,
)
export const torHostname = /[-a-zA-Z0-9@:%._\+~#=]{1,256}\.onion/
export const torHostname = new ComposableRegex(
/[-a-zA-Z0-9@:%._\+~#=]{1,256}\.onion/,
)
// https://ihateregex.io/expr/url/
export const url =
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/
export const url = new ComposableRegex(
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/,
)
export const localUrl =
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.local\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/
export const localUrl = new ComposableRegex(
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.local\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/,
)
export const torUrl =
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.onion\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/
export const torUrl = new ComposableRegex(
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.onion\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/,
)
// https://ihateregex.io/expr/ascii/
export const ascii = /^[ -~]*$/
export const ascii = new ComposableRegex(/[ -~]*/)
export const domain = new ComposableRegex(/[A-Za-z0-9.-]+\.[A-Za-z]{2,}/)
// https://www.regular-expressions.info/email.html
export const email = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/
export const email = new ComposableRegex(`[A-Za-z0-9._%+-]+@${domain.asExpr()}`)
export const emailWithName = new RegExp(
`(${email.source})|([^<]*<(${email.source})>)`,
export const emailWithName = new ComposableRegex(
`${email.asExpr()}|([^<]*<${email.asExpr()}>)`,
)
//https://rgxdb.com/r/1NUN74O6
export const base64 =
/^(?:[a-zA-Z0-9+\/]{4})*(?:|(?:[a-zA-Z0-9+\/]{3}=)|(?:[a-zA-Z0-9+\/]{2}==)|(?:[a-zA-Z0-9+\/]{1}===))$/
export const base64 = new ComposableRegex(
/(?:[a-zA-Z0-9+\/]{4})*(?:|(?:[a-zA-Z0-9+\/]{3}=)|(?:[a-zA-Z0-9+\/]{2}==)|(?:[a-zA-Z0-9+\/]{1}===))/,
)

View File

@@ -17,7 +17,7 @@ emver = { version = "0.1.7", git = "https://github.com/Start9Labs/emver-rs.git",
] }
failure = "0.1.8"
indexmap = { version = "1.6.2", features = ["serde"] }
imbl-value = { git = "https://github.com/Start9Labs/imbl-value.git" }
imbl-value = "0.1.2"
itertools = "0.10.0"
lazy_static = "1.4"
linear-map = { version = "1.2", features = ["serde_impl"] }

6
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "startos-ui",
"version": "0.3.6-alpha.11",
"version": "0.3.6-alpha.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "startos-ui",
"version": "0.3.6-alpha.11",
"version": "0.3.6-alpha.12",
"license": "MIT",
"dependencies": {
"@angular/animations": "^14.1.0",
@@ -126,7 +126,7 @@
"isomorphic-fetch": "^3.0.0",
"lodash.merge": "^4.6.2",
"mime-types": "^2.1.35",
"ts-matches": "^6.1.0",
"ts-matches": "^6.2.1",
"yaml": "^2.2.2"
},
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "startos-ui",
"version": "0.3.6-alpha.11",
"version": "0.3.6-alpha.12",
"author": "Start9 Labs, Inc",
"homepage": "https://start9.com/",
"license": "MIT",

View File

@@ -121,7 +121,7 @@
overflow: auto;
"
>
<code id="tor-addr"></code>
<code id="tor-addr" style="display:block; white-space:pre-wrap"></code>
</p>
</section>
</div>

View File

@@ -15,7 +15,7 @@ export class SuccessPage {
{} as ElementRef<HTMLCanvasElement>
private ctx: CanvasRenderingContext2D = {} as CanvasRenderingContext2D
torAddress?: string
torAddresses?: string[]
lanAddress?: string
cert?: string
@@ -52,7 +52,7 @@ export class SuccessPage {
const torAddress = this.document.getElementById('tor-addr')
const lanAddress = this.document.getElementById('lan-addr')
if (torAddress) torAddress.innerHTML = this.torAddress!
if (torAddress) torAddress.innerHTML = this.torAddresses!.join('\n')
if (lanAddress) lanAddress.innerHTML = this.lanAddress!
this.document
@@ -76,7 +76,9 @@ export class SuccessPage {
try {
const ret = await this.api.complete()
if (!this.isKiosk) {
this.torAddress = ret.torAddress.replace(/^https:/, 'http:')
this.torAddresses = ret.torAddresses.map(a =>
a.replace(/^https:/, 'http:'),
)
this.lanAddress = ret.lanAddress.replace(/^https:/, 'http:')
this.cert = ret.rootCa

View File

@@ -136,7 +136,7 @@ export class MockApiService extends ApiService {
case 3:
return {
status: 'complete',
torAddress: 'https://asdafsadasdasasdasdfasdfasdf.onion',
torAddresses: ['https://asdafsadasdasasdasdfasdfasdf.onion'],
hostname: 'adjective-noun',
lanAddress: 'https://adjective-noun.local',
rootCa: encodeBase64(rootCA),
@@ -283,7 +283,7 @@ export class MockApiService extends ApiService {
async complete(): Promise<T.SetupResult> {
await pauseFor(1000)
return {
torAddress: 'https://asdafsadasdasasdasdfasdfasdf.onion',
torAddresses: ['https://asdafsadasdasasdasdfasdfasdf.onion'],
hostname: 'adjective-noun',
lanAddress: 'https://adjective-noun.local',
rootCa: encodeBase64(rootCA),

View File

@@ -13,7 +13,7 @@ export type WorkspaceConfig = {
community: 'https://community-registry.start9.com/'
}
mocks: {
maskAs: 'tor' | 'local' | 'ip' | 'localhost'
maskAs: 'tor' | 'local' | 'localhost' | 'ipv4' | 'ipv6' | 'clearnet'
// enables local development in secure mode
maskAsHttps: boolean
skipStartupAlerts: boolean

View File

@@ -45,7 +45,7 @@ const ICONS = [
'eye-off-outline',
'eye-outline',
'file-tray-stacked-outline',
'finger-print-outline',
'finger-print',
'flash-outline',
'folder-open-outline',
'globe-outline',

View File

@@ -0,0 +1,62 @@
<ion-item *ngIf="iFace">
<ion-icon
slot="start"
size="large"
[name]="
iFace.type === 'ui'
? 'desktop-outline'
: iFace.type === 'api'
? 'terminal-outline'
: 'people-outline'
"
></ion-icon>
<ion-label>
<h1>{{ iFace.name }}</h1>
<h2>{{ iFace.description }}</h2>
<ion-button style="margin-right: 8px" (click)="presentDomainForm()">
Add Domain
</ion-button>
<ion-button
[color]="iFace.public ? 'danger' : 'success'"
(click)="togglePublic()"
>
Make {{ iFace.public ? 'Private' : 'Public' }}
</ion-button>
</ion-label>
</ion-item>
<div *ngIf="iFace" style="padding-left: 64px">
<ion-item *ngFor="let address of iFace.addresses">
<ion-label>
<h2>{{ address.name }}</h2>
<p>{{ address.url }}</p>
<ion-button
*ngIf="address.isDomain"
color="danger"
(click)="removeStandard(address.url)"
>
Remove
</ion-button>
<ion-button
*ngIf="address.isOnion"
color="danger"
(click)="removeOnion(address.url)"
>
Remove
</ion-button>
</ion-label>
<ion-buttons slot="end">
<ion-button *ngIf="address.isDomain" (click)="showAcme(address.acme)">
<ion-icon name="finger-print"></ion-icon>
</ion-button>
<ion-button *ngIf="iFace.type === 'ui'" (click)="launch(address.url)">
<ion-icon name="open-outline"></ion-icon>
</ion-button>
<ion-button (click)="showQR(address.url)">
<ion-icon name="qr-code-outline"></ion-icon>
</ion-button>
<ion-button (click)="copy(address.url)">
<ion-icon name="copy-outline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-item>
</div>

View File

@@ -0,0 +1,3 @@
p {
font-family: 'Courier New';
}

View File

@@ -0,0 +1,393 @@
import { Component, Inject, Input } from '@angular/core'
import { WINDOW } from '@ng-web-apis/common'
import {
AlertController,
ModalController,
ToastController,
} from '@ionic/angular'
import {
copyToClipboard,
ErrorService,
LoadingService,
} from '@start9labs/shared'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { PatchDB } from 'patch-db-client'
import { QRComponent } from 'src/app/components/qr/qr.component'
import { firstValueFrom } from 'rxjs'
import { ISB, T, utils } from '@start9labs/start-sdk'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { FormComponent } from 'src/app/components/form.component'
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
import { ACME_URL, toAcmeName } from 'src/app/util/acme'
import { ConfigService } from 'src/app/services/config.service'
export type MappedInterface = T.ServiceInterface & {
addresses: MappedAddress[]
public: boolean
}
export type MappedAddress = {
name: string
url: string
isDomain: boolean
isOnion: boolean
acme: string | null
}
@Component({
selector: 'interface-info',
templateUrl: './interface-info.component.html',
styleUrls: ['./interface-info.component.scss'],
})
export class InterfaceInfoComponent {
@Input() pkgId?: string
@Input() iFace!: MappedInterface
constructor(
private readonly toastCtrl: ToastController,
private readonly modalCtrl: ModalController,
private readonly errorService: ErrorService,
private readonly loader: LoadingService,
private readonly api: ApiService,
private readonly formDialog: FormDialogService,
private readonly alertCtrl: AlertController,
private readonly patch: PatchDB<DataModel>,
private readonly config: ConfigService,
@Inject(WINDOW) private readonly windowRef: Window,
) {}
launch(url: string): void {
this.windowRef.open(url, '_blank', 'noreferrer')
}
async togglePublic() {
const loader = this.loader
.open(`Making ${this.iFace.public ? 'private' : 'public'}`)
.subscribe()
const params = {
internalPort: this.iFace.addressInfo.internalPort,
public: !this.iFace.public,
}
try {
if (this.pkgId) {
await this.api.pkgBindingSetPubic({
...params,
host: this.iFace.addressInfo.hostId,
package: this.pkgId,
})
} else {
await this.api.serverBindingSetPubic(params)
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
async presentDomainForm() {
const acme = await firstValueFrom(this.patch.watch$('serverInfo', 'acme'))
const spec = getDomainSpec(Object.keys(acme))
this.formDialog.open(FormComponent, {
label: 'Add Domain',
data: {
spec: await configBuilderToSpec(spec),
buttons: [
{
text: 'Save',
handler: async (val: typeof spec._TYPE) => {
if (val.type.selection === 'standard') {
return this.saveStandard(
val.type.value.domain,
val.type.value.acme,
)
} else {
return this.saveTor(val.type.value.key)
}
},
},
],
},
})
}
async removeStandard(url: string) {
const loader = this.loader.open('Removing').subscribe()
const params = {
domain: new URL(url).hostname,
}
try {
if (this.pkgId) {
await this.api.pkgRemoveDomain({
...params,
package: this.pkgId,
host: this.iFace.addressInfo.hostId,
})
} else {
await this.api.serverRemoveDomain(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
async removeOnion(url: string) {
const loader = this.loader.open('Removing').subscribe()
const params = {
onion: new URL(url).hostname,
}
try {
if (this.pkgId) {
await this.api.pkgRemoveOnion({
...params,
package: this.pkgId,
host: this.iFace.addressInfo.hostId,
})
} else {
await this.api.serverRemoveOnion(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
async showAcme(url: ACME_URL | string | null): Promise<void> {
const alert = await this.alertCtrl.create({
header: 'ACME Provider',
message: toAcmeName(url),
})
await alert.present()
}
async showQR(text: string): Promise<void> {
const modal = await this.modalCtrl.create({
component: QRComponent,
componentProps: {
text,
},
cssClass: 'qr-modal',
})
await modal.present()
}
async copy(address: string): Promise<void> {
let message = ''
await copyToClipboard(address || '').then(success => {
message = success
? 'Copied to clipboard!'
: 'Failed to copy to clipboard.'
})
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 1000,
})
await toast.present()
}
private async saveStandard(domain: string, acme: string) {
const loader = this.loader.open('Saving').subscribe()
const params = {
domain,
acme: acme === 'none' ? null : acme,
private: false,
}
try {
if (this.pkgId) {
await this.api.pkgAddDomain({
...params,
package: this.pkgId,
host: this.iFace.addressInfo.hostId,
})
} else {
await this.api.serverAddDomain(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
private async saveTor(key: string | null) {
const loader = this.loader.open('Creating onion address').subscribe()
try {
let onion = key
? await this.api.addTorKey({ key })
: await this.api.generateTorKey({})
onion = `${onion}.onion`
if (this.pkgId) {
await this.api.pkgAddOnion({
onion,
package: this.pkgId,
host: this.iFace.addressInfo.hostId,
})
} else {
await this.api.serverAddOnion({ onion })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}
function getDomainSpec(acme: string[]) {
return ISB.InputSpec.of({
type: ISB.Value.union(
{ name: 'Type', default: 'standard' },
ISB.Variants.of({
standard: {
name: 'Standard',
spec: ISB.InputSpec.of({
domain: ISB.Value.text({
name: 'Domain',
description: 'The domain or subdomain you want to use',
placeholder: `e.g. 'mydomain.com' or 'sub.mydomain.com'`,
required: true,
default: null,
patterns: [utils.Patterns.domain],
}),
acme: ISB.Value.select({
name: 'ACME Provider',
description:
'Select which ACME provider to use for obtaining your SSL certificate. Add new ACME providers in the System tab. Optionally use your system Root CA. Note: only devices that have trusted your Root CA will be able to access the domain without security warnings.',
values: acme.reduce(
(obj, url) => ({
...obj,
[url]: toAcmeName(url),
}),
{ none: 'None (use system Root CA)' } as Record<string, string>,
),
default: '',
}),
}),
},
onion: {
name: 'Onion',
spec: ISB.InputSpec.of({
key: ISB.Value.text({
name: 'Private Key (optional)',
description:
'Optionally provide a base64-encoded ed25519 private key for generating the Tor V3 (.onion) address. If not provided, a random key will be generated and used.',
required: false,
default: null,
patterns: [utils.Patterns.base64],
}),
}),
},
}),
),
})
}
export function getAddresses(
serviceInterface: T.ServiceInterface,
host: T.Host,
config: ConfigService,
): MappedAddress[] {
const addressInfo = serviceInterface.addressInfo
let hostnames = host.hostnameInfo[addressInfo.internalPort]
hostnames = hostnames.filter(
h =>
config.isLocalhost() ||
h.kind !== 'ip' ||
h.hostname.kind !== 'ipv6' ||
!h.hostname.value.startsWith('fe80::'),
)
if (config.isLocalhost()) {
const local = hostnames.find(
h => h.kind === 'ip' && h.hostname.kind === 'local',
)
if (local) {
hostnames.unshift({
kind: 'ip',
networkInterfaceId: 'lo',
public: false,
hostname: {
kind: 'local',
port: local.hostname.port,
sslPort: local.hostname.sslPort,
value: 'localhost',
},
})
}
}
const mappedAddresses = hostnames.flatMap(h => {
let name = ''
let isDomain = false
let isOnion = false
let acme: string | null = null
if (h.kind === 'onion') {
name = `Tor`
isOnion = true
} else {
const hostnameKind = h.hostname.kind
if (hostnameKind === 'domain') {
name = 'Domain'
isDomain = true
acme = host.domains[h.hostname.domain]?.acme
} else {
name =
hostnameKind === 'local'
? 'Local'
: `${h.networkInterfaceId} (${hostnameKind})`
}
}
const addresses = utils.addressHostToUrl(addressInfo, h)
if (addresses.length > 1) {
return addresses.map(url => ({
name: `${name} (${new URL(url).protocol
.replace(':', '')
.toUpperCase()})`,
url,
isDomain,
isOnion,
acme,
}))
} else {
return addresses.map(url => ({
name,
url,
isDomain,
isOnion,
acme,
}))
}
})
return mappedAddresses.filter(
(value, index, self) => index === self.findIndex(t => t.url === value.url),
)
}

View File

@@ -0,0 +1,11 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { InterfaceInfoComponent } from './interface-info.component'
@NgModule({
declarations: [InterfaceInfoComponent],
imports: [CommonModule, IonicModule],
exports: [InterfaceInfoComponent],
})
export class InterfaceInfoModule {}

View File

@@ -1,44 +0,0 @@
<ion-item *ngIf="iFace">
<ion-icon
slot="start"
size="large"
[name]="
iFace.type === 'ui'
? 'desktop-outline'
: iFace.type === 'api'
? 'terminal-outline'
: 'people-outline'
"
></ion-icon>
<ion-label>
<h1>{{ iFace.name }}</h1>
<h2>{{ iFace.description }}</h2>
</ion-label>
</ion-item>
<div *ngIf="iFace" style="padding-left: 64px">
<ion-item *ngFor="let address of iFace.addresses">
<ion-label>
<h2>{{ address.name }}</h2>
<p>{{ address.url }}</p>
</ion-label>
<ion-buttons slot="end">
<ion-button
*ngIf="iFace.type === 'ui'"
fill="clear"
(click)="launch(address.url)"
>
<ion-icon size="small" slot="icon-only" name="open-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="showQR(address.url)">
<ion-icon
size="small"
slot="icon-only"
name="qr-code-outline"
></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="copy(address.url)">
<ion-icon size="small" slot="icon-only" name="copy-outline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-item>
</div>

View File

@@ -3,11 +3,8 @@ import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { SharedPipesModule } from '@start9labs/shared'
import {
AppInterfacesItemComponent,
AppInterfacesPage,
} from './app-interfaces.page'
import { AppInterfacesPage } from './app-interfaces.page'
import { InterfaceInfoModule } from 'src/app/components/interface-info/interface-info.module'
const routes: Routes = [
{
@@ -22,7 +19,8 @@ const routes: Routes = [
IonicModule,
RouterModule.forChild(routes),
SharedPipesModule,
InterfaceInfoModule,
],
declarations: [AppInterfacesPage, AppInterfacesItemComponent],
declarations: [AppInterfacesPage],
})
export class AppInterfacesPageModule {}

View File

@@ -13,26 +13,29 @@
>
<ng-container *ngIf="serviceInterfaces.ui.length">
<ion-item-divider>User Interfaces</ion-item-divider>
<app-interfaces-item
<interface-info
*ngFor="let ui of serviceInterfaces.ui"
[iFace]="ui"
></app-interfaces-item>
[pkgId]="pkgId"
></interface-info>
</ng-container>
<ng-container *ngIf="serviceInterfaces.api.length">
<ion-item-divider>Application Program Interfaces</ion-item-divider>
<app-interfaces-item
<interface-info
*ngFor="let api of serviceInterfaces.api"
[iFace]="api"
></app-interfaces-item>
[pkgId]="pkgId"
></interface-info>
</ng-container>
<ng-container *ngIf="serviceInterfaces.p2p.length">
<ion-item-divider>Peer-To-Peer Interfaces</ion-item-divider>
<app-interfaces-item
<interface-info
*ngFor="let p2p of serviceInterfaces.p2p"
[iFace]="p2p"
></app-interfaces-item>
[pkgId]="pkgId"
></interface-info>
</ng-container>
</ion-item-group>
</ion-content>

View File

@@ -1,3 +0,0 @@
p {
font-family: 'Courier New';
}

View File

@@ -1,21 +1,11 @@
import { Component, Inject, Input } from '@angular/core'
import { WINDOW } from '@ng-web-apis/common'
import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ModalController, ToastController } from '@ionic/angular'
import { copyToClipboard, getPkgId } from '@start9labs/shared'
import { getPkgId } from '@start9labs/shared'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { PatchDB } from 'patch-db-client'
import { QRComponent } from 'src/app/components/qr/qr.component'
import { combineLatest, map } from 'rxjs'
import { T, utils } from '@start9labs/start-sdk'
type MappedInterface = T.ServiceInterface & {
addresses: MappedAddress[]
}
type MappedAddress = {
name: string
url: string
}
import { getAddresses } from 'src/app/components/interface-info/interface-info.component'
import { ConfigService } from 'src/app/services/config.service'
@Component({
selector: 'app-interfaces',
@@ -46,12 +36,11 @@ export class AppInterfacesPage {
iface.name.toLowerCase() > iface.name.toLowerCase() ? -1 : 1,
)
.map(iface => {
const host = hosts[iface.addressInfo.hostId]
return {
...iface,
addresses: getAddresses(
iface,
hosts[iface.addressInfo.hostId] || {},
),
public: host.bindings[iface.addressInfo.internalPort].net.public,
addresses: getAddresses(iface, host, this.config),
}
})
@@ -66,124 +55,6 @@ export class AppInterfacesPage {
constructor(
private readonly route: ActivatedRoute,
private readonly patch: PatchDB<DataModel>,
private readonly config: ConfigService,
) {}
}
@Component({
selector: 'app-interfaces-item',
templateUrl: './app-interfaces-item.component.html',
styleUrls: ['./app-interfaces.page.scss'],
})
export class AppInterfacesItemComponent {
@Input() iFace!: MappedInterface
constructor(
private readonly toastCtrl: ToastController,
private readonly modalCtrl: ModalController,
@Inject(WINDOW) private readonly windowRef: Window,
) {}
launch(url: string): void {
this.windowRef.open(url, '_blank', 'noreferrer')
}
async showQR(text: string): Promise<void> {
const modal = await this.modalCtrl.create({
component: QRComponent,
componentProps: {
text,
},
cssClass: 'qr-modal',
})
await modal.present()
}
async copy(address: string): Promise<void> {
let message = ''
await copyToClipboard(address || '').then(success => {
message = success
? 'Copied to clipboard!'
: 'Failed to copy to clipboard.'
})
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 1000,
})
await toast.present()
}
}
function getAddresses(
serviceInterface: T.ServiceInterface,
host: T.Host,
): MappedAddress[] {
const addressInfo = serviceInterface.addressInfo
let hostnames =
host.kind === 'multi' ? host.hostnameInfo[addressInfo.internalPort] : []
hostnames = hostnames.filter(
h =>
window.location.host === 'localhost' ||
h.kind !== 'ip' ||
h.hostname.kind !== 'ipv6' ||
!h.hostname.value.startsWith('fe80::'),
)
if (window.location.host === 'localhost') {
const local = hostnames.find(
h => h.kind === 'ip' && h.hostname.kind === 'local',
)
if (local) {
hostnames.unshift({
kind: 'ip',
networkInterfaceId: 'lo',
public: false,
hostname: {
kind: 'local',
port: local.hostname.port,
sslPort: local.hostname.sslPort,
value: 'localhost',
},
})
}
}
const addressesWithNames = hostnames.flatMap(h => {
let name = ''
if (h.kind === 'onion') {
name = `Tor`
} else {
const hostnameKind = h.hostname.kind
if (hostnameKind === 'domain') {
name = 'Domain'
} else {
name =
hostnameKind === 'local'
? 'Local'
: `${h.networkInterfaceId} (${hostnameKind})`
}
}
const addresses = utils.addressHostToUrl(addressInfo, h)
if (addresses.length > 1) {
return addresses.map(url => ({
name: `${name} (${new URL(url).protocol
.replace(':', '')
.toUpperCase()})`,
url,
}))
} else {
return addresses.map(url => ({
name,
url,
}))
}
})
return addressesWithNames.filter(
(value, index, self) => index === self.findIndex(t => t.url === value.url),
)
}

View File

@@ -0,0 +1,18 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { RouterModule, Routes } from '@angular/router'
import { ACMEPage } from './acme.page'
const routes: Routes = [
{
path: '',
component: ACMEPage,
},
]
@NgModule({
imports: [CommonModule, IonicModule, RouterModule.forChild(routes)],
declarations: [ACMEPage],
})
export class ACMEPageModule {}

View File

@@ -0,0 +1,56 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="system"></ion-back-button>
</ion-buttons>
<ion-title>ACME</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top with-widgets">
<ion-item-group>
<!-- always -->
<ion-item>
<ion-label>
<h2>
Register with one or more ACME providers such as Let's Encrypt in
order to generate SSL (https) certificates on-demand for clearnet
hosting
<a [href]="docsUrl" target="_blank" rel="noreferrer">
View instructions
</a>
</h2>
</ion-label>
</ion-item>
<ion-item-divider>Saved Providers</ion-item-divider>
<ion-item button detail="false" (click)="presentFormAcme()">
<ion-icon slot="start" name="add" color="dark"></ion-icon>
<ion-label>
<b>Add Provider</b>
</ion-label>
</ion-item>
<ng-container *ngIf="acme$ | async as acme">
<ion-item *ngFor="let provider of acme | keyvalue">
<ion-icon slot="start" name="finger-print" size="medium"></ion-icon>
<ion-label>
<h2>{{ toAcmeName(provider.key) }}</h2>
<p *ngFor="let contact of provider.value.contact">
Contact: {{ contact }}
</p>
</ion-label>
<ion-button
slot="end"
fill="clear"
color="danger"
(click)="removeAcme(provider.key)"
>
<ion-icon slot="start" name="close"></ion-icon>
Remove
</ion-button>
</ion-item>
</ng-container>
</ion-item-group>
</ion-content>

View File

@@ -0,0 +1,117 @@
import { Component } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from '../../../services/patch-db/data-model'
import { FormDialogService } from '../../../services/form-dialog.service'
import { FormComponent } from '../../../components/form.component'
import { configBuilderToSpec } from '../../../util/configBuilderToSpec'
import { ISB, utils } from '@start9labs/start-sdk'
import { ACME_Name, ACME_URL, knownACME, toAcmeName } from 'src/app/util/acme'
@Component({
selector: 'acme',
templateUrl: 'acme.page.html',
styleUrls: ['acme.page.scss'],
})
export class ACMEPage {
readonly docsUrl = 'https://docs.start9.com/0.3.6/user-manual/acme'
acme$ = this.patch.watch$('serverInfo', 'acme')
toAcmeName = toAcmeName
constructor(
private readonly loader: LoadingService,
private readonly errorService: ErrorService,
private readonly api: ApiService,
private readonly patch: PatchDB<DataModel>,
private readonly formDialog: FormDialogService,
) {}
async presentFormAcme() {
this.formDialog.open(FormComponent, {
label: 'Add ACME Provider',
data: {
spec: await configBuilderToSpec(acmeSpec),
buttons: [
{
text: 'Save',
handler: async (val: typeof acmeSpec._TYPE) => this.saveAcme(val),
},
],
},
})
}
async removeAcme(provider: string) {
const loader = this.loader.open('Removing').subscribe()
try {
await this.api.removeAcme({ provider })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private async saveAcme(val: typeof acmeSpec._TYPE) {
const loader = this.loader.open('Saving').subscribe()
const rawUrl =
val.provider.selection === 'other'
? val.provider.value.url
: val.provider.selection
try {
await this.api.initAcme({
provider: new URL(rawUrl).href,
contact: [`mailto:${val.contact}`],
})
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}
const acmeSpec = ISB.InputSpec.of({
provider: ISB.Value.union(
{ name: 'Provider', default: knownACME['Let\'s Encrypt'] as any },
ISB.Variants.of({
...Object.entries(knownACME).reduce(
(obj, [name, url]) => ({
...obj,
[url]: {
name,
spec: ISB.InputSpec.of({}),
},
}),
{},
),
other: {
name: 'Other',
spec: ISB.InputSpec.of({
url: ISB.Value.text({
name: 'URL',
default: null,
required: true,
inputmode: 'url',
patterns: [utils.Patterns.url],
}),
}),
},
}),
),
contact: ISB.Value.text({
name: 'Contact Email',
default: null,
required: true,
inputmode: 'email',
patterns: [utils.Patterns.email],
}),
})

View File

@@ -85,6 +85,11 @@ const routes: Routes = [
loadChildren: () =>
import('./email/email.module').then(m => m.EmailPageModule),
},
{
path: 'acme',
loadChildren: () =>
import('./acme/acme.module').then(m => m.ACMEPageModule),
},
]
@NgModule({

View File

@@ -463,6 +463,15 @@ export class ServerShowPage {
detail: true,
disabled$: of(false),
},
{
title: 'ACME',
description: `Add ACME providers to create SSL certificates for clearnet access`,
icon: 'finger-print',
action: () =>
this.navCtrl.navigateForward(['acme'], { relativeTo: this.route }),
detail: true,
disabled$: of(false),
},
{
title: 'Email',
description: 'Connect to an external SMTP server for sending emails',

View File

@@ -6,6 +6,7 @@ import { ServerSpecsPage } from './server-specs.page'
import { ExverPipesModule } from '@start9labs/shared'
import { TuiLetModule } from '@taiga-ui/cdk'
import { QRComponentModule } from 'src/app/components/qr/qr.component.module'
import { InterfaceInfoModule } from 'src/app/components/interface-info/interface-info.module'
const routes: Routes = [
{
@@ -22,6 +23,7 @@ const routes: Routes = [
QRComponentModule,
ExverPipesModule,
TuiLetModule,
InterfaceInfoModule,
],
declarations: [ServerSpecsPage],
})

View File

@@ -25,73 +25,6 @@
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
</ion-button>
</ion-item>
<ion-item-divider>Web Addresses</ion-item-divider>
<ion-item>
<ion-label class="break-all">
<h2>Tor</h2>
<p>{{ server.torAddress }}</p>
</ion-label>
<div slot="end">
<ion-button fill="clear" (click)="showQR(server.torAddress)">
<ion-icon
slot="icon-only"
name="qr-code-outline"
size="small"
></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="copy(server.torAddress)">
<ion-icon
slot="icon-only"
name="copy-outline"
size="small"
></ion-icon>
</ion-button>
</div>
</ion-item>
<ion-item>
<ion-label class="break-all">
<h2>LAN</h2>
<p>{{ server.lanAddress }}</p>
</ion-label>
<ion-button slot="end" fill="clear" (click)="copy(server.lanAddress)">
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
</ion-button>
</ion-item>
<ng-container *ngFor="let iface of server.networkInterfaces | keyvalue">
<ng-container *ngIf="(isLocalhost || iface.key !== 'lo') && iface.value.ipInfo">
<ng-container *ngFor="let ipnet of iface.value.ipInfo.subnets">
<ng-container *ngIf="!ipnet.includes('fe80::') || isLocalhost">
<ion-item *tuiLet="ipnet.split('/')[0] as ipAddr">
<ion-label>
<h2>{{ iface.key }} ({{ ipAddr.includes("::") ? "IPv6" : "IPv4" }})</h2>
<p>{{
ipAddr.includes("fe80::")
? "[" + ipAddr + "%" + iface.value.ipInfo.scopeId + "]"
: ipAddr.includes("::")
? "[" + ipAddr + "]"
: ipAddr
}}</p>
</ion-label>
<ion-button slot="end" fill="clear" (click)="copy(ipAddr)">
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
</ion-button>
</ion-item>
</ng-container>
</ng-container>
</ng-container>
</ng-container>
<ion-item-divider>Device Credentials</ion-item-divider>
<!-- <ion-item>
<ion-label>
<h2>Pubkey</h2>
<p>{{ server['pubkey'] }}</p>
</ion-label>
<ion-button slot="end" fill="clear" (click)="copy(server['pubkey'])">
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
</ion-button>
</ion-item> -->
<ion-item>
<ion-label>
<h2>CA fingerprint</h2>
@@ -101,5 +34,9 @@
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
</ion-button>
</ion-item>
<ion-item-divider>Web Addresses</ion-item-divider>
<interface-info *ngIf="ui$ | async as ui" [iFace]="ui"></interface-info>
</ion-item-group>
</ion-content>

View File

@@ -1,10 +1,31 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { ModalController, ToastController } from '@ionic/angular'
import { ToastController } from '@ionic/angular'
import { PatchDB } from 'patch-db-client'
import { ConfigService } from 'src/app/services/config.service'
import { QRComponent } from 'src/app/components/qr/qr.component'
import { copyToClipboard } from '@start9labs/shared'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { map, Observable } from 'rxjs'
import {
getAddresses,
MappedInterface,
} from 'src/app/components/interface-info/interface-info.component'
const iface = {
id: '',
name: 'StartOS User Interface',
description:
'The primary user interface for your StartOS server, accessible from any browser.',
type: 'ui' as const,
masked: false,
addressInfo: {
hostId: '',
internalPort: 80,
scheme: 'http',
sslScheme: 'https',
suffix: '',
username: null,
},
}
@Component({
selector: 'server-specs',
@@ -14,11 +35,17 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
})
export class ServerSpecsPage {
readonly server$ = this.patch.watch$('serverInfo')
readonly isLocalhost = window.location.host === 'localhost'
readonly ui$: Observable<MappedInterface> = this.server$.pipe(
map(server => ({
...iface,
public: server.host.bindings[iface.addressInfo.internalPort].net.public,
addresses: getAddresses(iface, server.host, this.config),
})),
)
constructor(
private readonly toastCtrl: ToastController,
private readonly modalCtrl: ModalController,
private readonly patch: PatchDB<DataModel>,
private readonly config: ConfigService,
) {}
@@ -42,19 +69,4 @@ export class ServerSpecsPage {
})
await toast.present()
}
async showQR(text: string): Promise<void> {
const modal = await this.modalCtrl.create({
component: QRComponent,
componentProps: {
text,
},
cssClass: 'qr-modal',
})
await modal.present()
}
asIsOrder(a: any, b: any) {
return 0
}
}

View File

@@ -1771,8 +1771,21 @@ export module Mock {
currentDependencies: {},
hosts: {
abcdefg: {
kind: 'multi',
bindings: [],
bindings: {
80: {
enabled: true,
net: {
assignedPort: 80,
assignedSslPort: 443,
public: false,
},
options: {
addSsl: null,
preferredExternalPort: 443,
secure: { ssl: true },
},
},
},
onions: [],
domains: {},
hostnameInfo: {
@@ -1857,8 +1870,21 @@ export module Mock {
},
},
bcdefgh: {
kind: 'multi',
bindings: [],
bindings: {
8332: {
enabled: true,
net: {
assignedPort: 8332,
assignedSslPort: null,
public: false,
},
options: {
addSsl: null,
preferredExternalPort: 8332,
secure: { ssl: false },
},
},
},
onions: [],
domains: {},
hostnameInfo: {
@@ -1866,8 +1892,21 @@ export module Mock {
},
},
cdefghi: {
kind: 'multi',
bindings: [],
bindings: {
8333: {
enabled: true,
net: {
assignedPort: 8333,
assignedSslPort: null,
public: false,
},
options: {
addSsl: null,
preferredExternalPort: 8333,
secure: { ssl: false },
},
},
},
onions: [],
domains: {},
hostnameInfo: {

View File

@@ -219,6 +219,80 @@ export module RR {
// package
export type InitAcmeReq = {
provider: 'letsencrypt' | 'letsencrypt-staging' | string
contact: string[]
}
export type InitAcmeRes = null
export type RemoveAcmeReq = {
provider: string
}
export type RemoveAcmeRes = null
export type AddTorKeyReq = {
// net.tor.key.add
key: string
}
export type GenerateTorKeyReq = {} // net.tor.key.generate
export type AddTorKeyRes = string // onion address without .onion suffix
export type ServerBindingSetPublicReq = {
// server.host.binding.set-public
internalPort: number
public: boolean | null // default true
}
export type BindingSetPublicRes = null
export type ServerAddOnionReq = {
// server.host.address.onion.add
onion: string // address *with* .onion suffix
}
export type AddOnionRes = null
export type ServerRemoveOnionReq = ServerAddOnionReq // server.host.address.onion.remove
export type RemoveOnionRes = null
export type ServerAddDomainReq = {
// server.host.address.domain.add
domain: string // FQDN
private: boolean
acme: string | null // "letsencrypt" | "letsencrypt-staging" | Url | null
}
export type AddDomainRes = null
export type ServerRemoveDomainReq = {
// server.host.address.domain.remove
domain: string // FQDN
}
export type RemoveDomainRes = null
export type PkgBindingSetPublicReq = ServerBindingSetPublicReq & {
// package.host.binding.set-public
package: T.PackageId // string
host: T.HostId // string
}
export type PkgAddOnionReq = ServerAddOnionReq & {
// package.host.address.onion.add
package: T.PackageId // string
host: T.HostId // string
}
export type PkgRemoveOnionReq = PkgAddOnionReq // package.host.address.onion.remove
export type PkgAddDomainReq = ServerAddDomainReq & {
// package.host.address.domain.add
package: T.PackageId // string
host: T.HostId // string
}
export type PkgRemoveDomainReq = ServerRemoveDomainReq & {
// package.host.address.domain.remove
package: T.PackageId // string
host: T.HostId // string
}
export type GetPackageLogsReq = ServerLogsReq & { id: string } // package.logs
export type GetPackageLogsRes = LogsRes

View File

@@ -259,4 +259,48 @@ export abstract class ApiService {
): Promise<RR.UninstallPackageRes>
abstract sideloadPackage(): Promise<RR.SideloadPackageRes>
abstract initAcme(params: RR.InitAcmeReq): Promise<RR.InitAcmeRes>
abstract removeAcme(params: RR.RemoveAcmeReq): Promise<RR.RemoveAcmeRes>
abstract addTorKey(params: RR.AddTorKeyReq): Promise<RR.AddTorKeyRes>
abstract generateTorKey(
params: RR.GenerateTorKeyReq,
): Promise<RR.AddTorKeyRes>
abstract serverBindingSetPubic(
params: RR.ServerBindingSetPublicReq,
): Promise<RR.BindingSetPublicRes>
abstract serverAddOnion(params: RR.ServerAddOnionReq): Promise<RR.AddOnionRes>
abstract serverRemoveOnion(
params: RR.ServerRemoveOnionReq,
): Promise<RR.RemoveOnionRes>
abstract serverAddDomain(
params: RR.ServerAddDomainReq,
): Promise<RR.AddDomainRes>
abstract serverRemoveDomain(
params: RR.ServerRemoveDomainReq,
): Promise<RR.RemoveDomainRes>
abstract pkgBindingSetPubic(
params: RR.PkgBindingSetPublicReq,
): Promise<RR.BindingSetPublicRes>
abstract pkgAddOnion(params: RR.PkgAddOnionReq): Promise<RR.AddOnionRes>
abstract pkgRemoveOnion(
params: RR.PkgRemoveOnionReq,
): Promise<RR.RemoveOnionRes>
abstract pkgAddDomain(params: RR.PkgAddDomainReq): Promise<RR.AddDomainRes>
abstract pkgRemoveDomain(
params: RR.PkgRemoveDomainReq,
): Promise<RR.RemoveDomainRes>
}

View File

@@ -516,6 +516,118 @@ export class LiveApiService extends ApiService {
})
}
async removeAcme(params: RR.RemoveAcmeReq): Promise<RR.RemoveAcmeRes> {
return this.rpcRequest({
method: 'net.acme.delete',
params,
})
}
async initAcme(params: RR.InitAcmeReq): Promise<RR.InitAcmeRes> {
return this.rpcRequest({
method: 'net.acme.init',
params,
})
}
async addTorKey(params: RR.AddTorKeyReq): Promise<RR.AddTorKeyRes> {
return this.rpcRequest({
method: 'net.tor.key.add',
params,
})
}
async generateTorKey(params: RR.GenerateTorKeyReq): Promise<RR.AddTorKeyRes> {
return this.rpcRequest({
method: 'net.tor.key.generate',
params,
})
}
async serverBindingSetPubic(
params: RR.ServerBindingSetPublicReq,
): Promise<RR.BindingSetPublicRes> {
return this.rpcRequest({
method: 'server.host.binding.set-public',
params,
})
}
async serverAddOnion(params: RR.ServerAddOnionReq): Promise<RR.AddOnionRes> {
return this.rpcRequest({
method: 'server.host.address.onion.add',
params,
})
}
async serverRemoveOnion(
params: RR.ServerRemoveOnionReq,
): Promise<RR.RemoveOnionRes> {
return this.rpcRequest({
method: 'server.host.address.onion.remove',
params,
})
}
async serverAddDomain(
params: RR.ServerAddDomainReq,
): Promise<RR.AddDomainRes> {
return this.rpcRequest({
method: 'server.host.address.domain.add',
params,
})
}
async serverRemoveDomain(
params: RR.ServerRemoveDomainReq,
): Promise<RR.RemoveDomainRes> {
return this.rpcRequest({
method: 'server.host.address.domain.remove',
params,
})
}
async pkgBindingSetPubic(
params: RR.PkgBindingSetPublicReq,
): Promise<RR.BindingSetPublicRes> {
return this.rpcRequest({
method: 'package.host.binding.set-public',
params,
})
}
async pkgAddOnion(params: RR.PkgAddOnionReq): Promise<RR.AddOnionRes> {
return this.rpcRequest({
method: 'package.host.address.onion.add',
params,
})
}
async pkgRemoveOnion(
params: RR.PkgRemoveOnionReq,
): Promise<RR.RemoveOnionRes> {
return this.rpcRequest({
method: 'package.host.address.onion.remove',
params,
})
}
async pkgAddDomain(params: RR.PkgAddDomainReq): Promise<RR.AddDomainRes> {
return this.rpcRequest({
method: 'package.host.address.domain.add',
params,
})
}
async pkgRemoveDomain(
params: RR.PkgRemoveDomainReq,
): Promise<RR.RemoveDomainRes> {
return this.rpcRequest({
method: 'package.host.address.domain.remove',
params,
})
}
private async rpcRequest<T>(
options: RPCOptions,
urlOverride?: string,

View File

@@ -19,16 +19,7 @@ import {
import { CifsBackupTarget, RR } from './api.types'
import { Mock } from './api.fixures'
import markdown from 'raw-loader!../../../../../shared/assets/markdown/md-sample.md'
import {
from,
interval,
map,
Observable,
shareReplay,
startWith,
Subject,
tap,
} from 'rxjs'
import { from, interval, map, shareReplay, startWith, Subject, tap } from 'rxjs'
import { mockPatchData } from './mock-patch'
import { AuthService } from '../auth.service'
import { T } from '@start9labs/start-sdk'
@@ -38,6 +29,7 @@ import {
MarketplacePkg,
} from '@start9labs/marketplace'
import { WebSocketSubject } from 'rxjs/webSocket'
import { toAcmeUrl } from 'src/app/util/acme'
const PROGRESS: T.FullProgress = {
overall: {
@@ -1064,6 +1056,283 @@ export class MockApiService extends ApiService {
}
}
async initAcme(params: RR.InitAcmeReq): Promise<RR.InitAcmeRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.ADD,
path: `/serverInfo/acme`,
value: {
[toAcmeUrl(params.provider)]: { contact: [params.contact] },
},
},
]
this.mockRevision(patch)
return null
}
async removeAcme(params: RR.RemoveAcmeReq): Promise<RR.RemoveAcmeRes> {
await pauseFor(2000)
const regex = new RegExp('/', 'g')
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/serverInfo/acme/${params.provider.replace(regex, '~1')}`,
},
]
this.mockRevision(patch)
return null
}
async addTorKey(params: RR.AddTorKeyReq): Promise<RR.AddTorKeyRes> {
await pauseFor(2000)
return 'vanityabcdefghijklmnop'
}
async generateTorKey(params: RR.GenerateTorKeyReq): Promise<RR.AddTorKeyRes> {
await pauseFor(2000)
return 'abcdefghijklmnopqrstuv'
}
async serverBindingSetPubic(
params: RR.PkgBindingSetPublicReq,
): Promise<RR.BindingSetPublicRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: `/serverInfo/host/bindings/${params.internalPort}/net/public`,
value: params.public,
},
]
this.mockRevision(patch)
return null
}
async serverAddOnion(params: RR.ServerAddOnionReq): Promise<RR.AddOnionRes> {
await pauseFor(2000)
const patch: Operation<any>[] = [
{
op: PatchOp.ADD,
path: `/serverInfo/host/onions/0`,
value: params.onion,
},
{
op: PatchOp.ADD,
path: `/serverInfo/host/hostnameInfo/80/0`,
value: {
kind: 'onion',
hostname: {
port: 80,
sslPort: 443,
value: params.onion,
},
},
},
]
this.mockRevision(patch)
return null
}
async serverRemoveOnion(
params: RR.ServerRemoveOnionReq,
): Promise<RR.RemoveOnionRes> {
await pauseFor(2000)
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/serverInfo/host/onions/0`,
},
{
op: PatchOp.REMOVE,
path: `/serverInfo/host/hostnameInfo/80/-1`,
},
]
this.mockRevision(patch)
return null
}
async serverAddDomain(params: RR.PkgAddDomainReq): Promise<RR.AddDomainRes> {
await pauseFor(2000)
const patch: Operation<any>[] = [
{
op: PatchOp.ADD,
path: `/serverInfo/host/domains`,
value: {
[params.domain]: { public: !params.private, acme: params.acme },
},
},
{
op: PatchOp.ADD,
path: `/serverInfo/host/hostnameInfo/80/0`,
value: {
kind: 'ip',
networkInterfaceId: 'eth0',
public: false,
hostname: {
kind: 'domain',
domain: params.domain,
subdomain: null,
port: null,
sslPort: 443,
},
},
},
]
this.mockRevision(patch)
return null
}
async serverRemoveDomain(
params: RR.PkgRemoveDomainReq,
): Promise<RR.RemoveDomainRes> {
await pauseFor(2000)
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/serverInfo/host/domains/${params.domain}`,
},
{
op: PatchOp.REMOVE,
path: `/serverInfo/host/hostnameInfo/80/0`,
},
]
this.mockRevision(patch)
return null
}
async pkgBindingSetPubic(
params: RR.PkgBindingSetPublicReq,
): Promise<RR.BindingSetPublicRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: `/packageData/${params.package}/hosts/${params.host}/bindings/${params.internalPort}/net/public`,
value: params.public,
},
]
this.mockRevision(patch)
return null
}
async pkgAddOnion(params: RR.PkgAddOnionReq): Promise<RR.AddOnionRes> {
await pauseFor(2000)
const patch: Operation<any>[] = [
{
op: PatchOp.ADD,
path: `/packageData/${params.package}/hosts/${params.host}/onions/0`,
value: params.onion,
},
{
op: PatchOp.ADD,
path: `/packageData/${params.package}/hosts/${params.host}/hostnameInfo/80/0`,
value: {
kind: 'onion',
hostname: {
port: 80,
sslPort: 443,
value: params.onion,
},
},
},
]
this.mockRevision(patch)
return null
}
async pkgRemoveOnion(
params: RR.PkgRemoveOnionReq,
): Promise<RR.RemoveOnionRes> {
await pauseFor(2000)
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/packageData/${params.package}/hosts/${params.host}/onions/0`,
},
{
op: PatchOp.REMOVE,
path: `/packageData/${params.package}/hosts/${params.host}/hostnameInfo/80/0`,
},
]
this.mockRevision(patch)
return null
}
async pkgAddDomain(params: RR.PkgAddDomainReq): Promise<RR.AddDomainRes> {
await pauseFor(2000)
const patch: Operation<any>[] = [
{
op: PatchOp.ADD,
path: `/packageData/${params.package}/hosts/${params.host}/domains`,
value: {
[params.domain]: { public: !params.private, acme: params.acme },
},
},
{
op: PatchOp.ADD,
path: `/packageData/${params.package}/hosts/${params.host}/hostnameInfo/80/0`,
value: {
kind: 'ip',
networkInterfaceId: 'eth0',
public: false,
hostname: {
kind: 'domain',
domain: params.domain,
subdomain: null,
port: null,
sslPort: 443,
},
},
},
]
this.mockRevision(patch)
return null
}
async pkgRemoveDomain(
params: RR.PkgRemoveDomainReq,
): Promise<RR.RemoveDomainRes> {
await pauseFor(2000)
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/packageData/${params.package}/hosts/${params.host}/domains/${params.domain}`,
},
{
op: PatchOp.REMOVE,
path: `/packageData/${params.package}/hosts/${params.host}/hostnameInfo/80/0`,
},
]
this.mockRevision(patch)
return null
}
private async initProgress(): Promise<T.FullProgress> {
const progress = JSON.parse(JSON.stringify(PROGRESS))

View File

@@ -1,6 +1,7 @@
import { DataModel } from 'src/app/services/patch-db/data-model'
import { Mock } from './api.fixures'
import { BUILT_IN_WIDGETS } from '../../pages/widgets/built-in/widgets'
import { knownACME } from 'src/app/util/acme'
const version = require('../../../../../../package.json').version
export const mockPatchData: DataModel = {
@@ -35,19 +36,16 @@ export const mockPatchData: DataModel = {
},
serverInfo: {
arch: 'x86_64',
onionAddress: 'myveryownspecialtoraddress',
id: 'abcdefgh',
version,
lastBackup: new Date(new Date().valueOf() - 604800001).toISOString(),
lanAddress: 'https://adjective-noun.local',
torAddress: 'https://myveryownspecialtoraddress.onion',
networkInterfaces: {
eth0: {
public: false,
ipInfo: {
scopeId: 1,
deviceType: 'ethernet',
subnets: ['10.0.0.1/24'],
subnets: ['10.0.0.2/24'],
wanIp: null,
ntpServers: [],
},
@@ -59,14 +57,18 @@ export const mockPatchData: DataModel = {
deviceType: 'wireless',
subnets: [
'10.0.90.12/24',
'FE80:CD00:0000:0CDE:1257:0000:211E:729CD/64',
'fe80::cd00:0000:0cde:1257:0000:211e:72cd/64',
],
wanIp: null,
ntpServers: [],
},
},
},
acme: {},
acme: {
[Object.keys(knownACME)[0]]: {
contact: ['mailto:support@start9.com'],
},
},
unreadNotificationCount: 4,
// password is asdfasdf
passwordHash:
@@ -81,6 +83,108 @@ export const mockPatchData: DataModel = {
shuttingDown: false,
},
hostname: 'random-words',
host: {
bindings: {
80: {
enabled: true,
net: {
assignedPort: null,
assignedSslPort: 443,
public: false,
},
options: {
preferredExternalPort: 80,
addSsl: {
preferredExternalPort: 443,
alpn: { specified: ['http/1.1', 'h2'] },
},
secure: null,
},
},
},
domains: {},
onions: ['myveryownspecialtoraddress'],
hostnameInfo: {
80: [
{
kind: 'ip',
networkInterfaceId: 'eth0',
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 443,
},
},
{
kind: 'ip',
networkInterfaceId: 'wlan0',
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 443,
},
},
{
kind: 'ip',
networkInterfaceId: 'eth0',
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.1',
port: null,
sslPort: 443,
},
},
{
kind: 'ip',
networkInterfaceId: 'wlan0',
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.2',
port: null,
sslPort: 443,
},
},
{
kind: 'ip',
networkInterfaceId: 'eth0',
public: false,
hostname: {
kind: 'ipv6',
value: 'fe80::cd00:0000:0cde:1257:0000:211e:72cd',
scopeId: 2,
port: null,
sslPort: 443,
},
},
{
kind: 'ip',
networkInterfaceId: 'wlan0',
public: false,
hostname: {
kind: 'ipv6',
value: 'fe80::cd00:0000:0cde:1257:0000:211e:1234',
scopeId: 3,
port: null,
sslPort: 443,
},
},
{
kind: 'onion',
hostname: {
value: 'myveryownspecialtoraddress.onion',
port: 80,
sslPort: 443,
},
},
],
},
},
pubkey: 'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m',
caFingerprint: 'SHA-256: 63 2B 11 99 44 40 17 DF 37 FC C3 DF 0F 3D 15',
ntpSynced: false,
@@ -201,8 +305,21 @@ export const mockPatchData: DataModel = {
currentDependencies: {},
hosts: {
abcdefg: {
kind: 'multi',
bindings: [],
bindings: {
80: {
enabled: true,
net: {
assignedPort: 80,
assignedSslPort: 443,
public: false,
},
options: {
addSsl: null,
preferredExternalPort: 443,
secure: { ssl: true },
},
},
},
onions: [],
domains: {},
hostnameInfo: {
@@ -257,7 +374,7 @@ export const mockPatchData: DataModel = {
public: false,
hostname: {
kind: 'ipv6',
value: '[fe80:cd00:0000:0cde:1257:0000:211e:72cd]',
value: 'fe80::cd00:0000:0cde:1257:0000:211e:72cd',
scopeId: 2,
port: null,
sslPort: 1234,
@@ -269,7 +386,7 @@ export const mockPatchData: DataModel = {
public: false,
hostname: {
kind: 'ipv6',
value: '[fe80:cd00:0000:0cde:1257:0000:211e:1234]',
value: 'fe80::cd00:0000:0cde:1257:0000:211e:1234',
scopeId: 3,
port: null,
sslPort: 1234,
@@ -287,8 +404,21 @@ export const mockPatchData: DataModel = {
},
},
bcdefgh: {
kind: 'multi',
bindings: [],
bindings: {
8332: {
enabled: true,
net: {
assignedPort: 8332,
assignedSslPort: null,
public: false,
},
options: {
addSsl: null,
preferredExternalPort: 8332,
secure: { ssl: false },
},
},
},
onions: [],
domains: {},
hostnameInfo: {
@@ -296,8 +426,21 @@ export const mockPatchData: DataModel = {
},
},
cdefghi: {
kind: 'multi',
bindings: [],
bindings: {
8333: {
enabled: true,
net: {
assignedPort: 8333,
assignedSslPort: null,
public: false,
},
options: {
addSsl: null,
preferredExternalPort: 8333,
secure: { ssl: false },
},
},
},
onions: [],
domains: {},
hostnameInfo: {

View File

@@ -1,7 +1,7 @@
import { DOCUMENT } from '@angular/common'
import { Inject, Injectable } from '@angular/core'
import { WorkspaceConfig } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { T, utils } from '@start9labs/start-sdk'
import { PackageDataEntry } from './patch-db/data-model'
const {
@@ -28,8 +28,7 @@ export class ConfigService {
api = api
marketplace = marketplace
skipStartupAlerts = useMocks && mocks.skipStartupAlerts
isConsulate = (window as any)['platform'] === 'ios'
supportsWebSockets = !!window.WebSocket || this.isConsulate
supportsWebSockets = !!window.WebSocket
isTor(): boolean {
return useMocks ? mocks.maskAs === 'tor' : this.hostname.endsWith('.onion')
@@ -41,10 +40,55 @@ export class ConfigService {
: this.hostname.endsWith('.local')
}
isLocalhost(): boolean {
return useMocks
? mocks.maskAs === 'localhost'
: this.hostname === 'localhost' || this.hostname === '127.0.0.1'
}
isIpv4(): boolean {
return useMocks
? mocks.maskAs === 'ipv4'
: new RegExp(utils.Patterns.ipv4.regex).test(this.hostname)
}
isLanIpv4(): boolean {
return useMocks
? mocks.maskAs === 'ipv4'
: new RegExp(utils.Patterns.ipv4.regex).test(this.hostname) &&
(this.hostname.startsWith('192.168.') ||
this.hostname.startsWith('10.') ||
(this.hostname.startsWith('172.') &&
!![this.hostname.split('.').map(Number)[1]].filter(
n => n >= 16 && n < 32,
).length))
}
isIpv6(): boolean {
return useMocks
? mocks.maskAs === 'ipv6'
: new RegExp(utils.Patterns.ipv6.regex).test(this.hostname)
}
isLanHttp(): boolean {
return !this.isTor() && !this.isLocalhost() && !this.isHttps()
}
isClearnet(): boolean {
return useMocks
? mocks.maskAs === 'clearnet'
: this.isHttps() &&
!this.isTor() &&
!this.isLocal() &&
!this.isLocalhost() &&
!this.isLanIpv4() &&
!this.isIpv6()
}
isHttps(): boolean {
return useMocks ? mocks.maskAsHttps : this.protocol === 'https:'
}
isSecure(): boolean {
return window.isSecureContext || this.isTor()
}
@@ -59,48 +103,154 @@ export class ConfigService {
/** ${scheme}://${username}@${host}:${externalPort}${suffix} */
launchableAddress(
interfaces: PackageDataEntry['serviceInterfaces'],
hosts: PackageDataEntry['hosts'],
hosts: T.Hosts,
): string {
const ui = Object.values(interfaces).find(
i =>
i.type === 'ui' &&
(i.addressInfo.scheme === 'http' ||
i.addressInfo.sslScheme === 'https'),
) // TODO: select if multiple
)
if (!ui) return ''
const hostnameInfo =
hosts[ui.addressInfo.hostId]?.hostnameInfo[ui.addressInfo.internalPort]
const host = hosts[ui.addressInfo.hostId]
if (!host) return ''
let hostnameInfo = host.hostnameInfo[ui.addressInfo.internalPort]
hostnameInfo = hostnameInfo.filter(
h =>
this.isLocalhost() ||
h.kind !== 'ip' ||
h.hostname.kind !== 'ipv6' ||
!h.hostname.value.startsWith('fe80::'),
)
if (this.isLocalhost()) {
const local = hostnameInfo.find(
h => h.kind === 'ip' && h.hostname.kind === 'local',
)
if (local) {
hostnameInfo.unshift({
kind: 'ip',
networkInterfaceId: 'lo',
public: false,
hostname: {
kind: 'local',
port: local.hostname.port,
sslPort: local.hostname.sslPort,
value: 'localhost',
},
})
}
}
if (!hostnameInfo) return ''
const addressInfo = ui.addressInfo
const scheme = this.isHttps()
? ui.addressInfo.sslScheme === 'https'
? 'https'
: 'http'
: ui.addressInfo.scheme === 'http'
? 'http'
: 'https'
const username = addressInfo.username ? addressInfo.username + '@' : ''
const suffix = addressInfo.suffix || ''
const url = new URL(`${scheme}://${username}placeholder${suffix}`)
const onionHostname = hostnameInfo.find(h => h.kind === 'onion')
?.hostname as T.OnionHostname | undefined
if (this.isTor() && onionHostname) {
url.hostname = onionHostname.value
} else {
const ipHostname = hostnameInfo.find(h => h.kind === 'ip')?.hostname as
| T.IpHostname
const url = new URL(`https://${username}placeholder${suffix}`)
const use = (hostname: {
value: string
port: number | null
sslPort: number | null
}) => {
url.hostname = hostname.value
const useSsl =
hostname.port && hostname.sslPort ? this.isHttps() : !!hostname.sslPort
url.protocol = useSsl
? `${addressInfo.sslScheme || 'https'}:`
: `${addressInfo.scheme || 'http'}:`
const port = useSsl ? hostname.sslPort : hostname.port
const omitPort = useSsl
? ui.addressInfo.sslScheme === 'https' && port === 443
: ui.addressInfo.scheme === 'http' && port === 80
if (!omitPort && port) url.port = String(port)
}
const useFirst = (
hostnames: (
| {
value: string
port: number | null
sslPort: number | null
}
| undefined
)[],
) => {
const first = hostnames.find(h => h)
if (first) {
use(first)
}
return !!first
}
if (!ipHostname) return ''
const ipHostnames = hostnameInfo
.filter(h => h.kind === 'ip')
.map(h => h.hostname) as T.IpHostname[]
const domainHostname = ipHostnames
.filter(h => h.kind === 'domain')
.map(h => h as T.IpHostname & { kind: 'domain' })
.map(h => ({
value: h.domain,
sslPort: h.sslPort,
port: h.port,
}))[0]
const wanIpHostname = hostnameInfo
.filter(h => h.kind === 'ip' && h.public && h.hostname.kind !== 'domain')
.map(h => h.hostname as Exclude<T.IpHostname, { kind: 'domain' }>)
.map(h => ({
value: h.value,
sslPort: h.sslPort,
port: h.port,
}))[0]
const onionHostname = hostnameInfo
.filter(h => h.kind === 'onion')
.map(h => h as T.HostnameInfo & { kind: 'onion' })
.map(h => ({
value: h.hostname.value,
sslPort: h.hostname.sslPort,
port: h.hostname.port,
}))[0]
const localHostname = ipHostnames
.filter(h => h.kind === 'local')
.map(h => h as T.IpHostname & { kind: 'local' })
.map(h => ({ value: h.value, sslPort: h.sslPort, port: h.port }))[0]
url.hostname = this.hostname
url.port = String(ipHostname.sslPort || ipHostname.port)
if (this.isClearnet()) {
if (
!useFirst([domainHostname, wanIpHostname, onionHostname, localHostname])
) {
return ''
}
} else if (this.isTor()) {
if (
!useFirst([onionHostname, domainHostname, wanIpHostname, localHostname])
) {
return ''
}
} else if (this.isIpv6()) {
const ipv6Hostname = ipHostnames.find(h => h.kind === 'ipv6') as {
kind: 'ipv6'
value: string
scopeId: number
port: number | null
sslPort: number | null
}
if (!useFirst([ipv6Hostname, localHostname])) {
return ''
}
} else {
// ipv4 or .local or localhost
if (!localHostname) return ''
use({
value: this.hostname,
port: localHostname.port,
sslPort: localHostname.sslPort,
})
}
return url.href
@@ -109,16 +259,6 @@ export class ConfigService {
getHost(): string {
return this.host
}
private isLocalhost(): boolean {
return useMocks
? mocks.maskAs === 'localhost'
: this.hostname === 'localhost'
}
private isHttps(): boolean {
return useMocks ? mocks.maskAsHttps : this.protocol === 'https:'
}
}
export function hasUi(

View File

@@ -135,7 +135,7 @@ export class FormService {
return this.formBuilder.control(value)
case 'select':
value = currentValue === undefined ? spec.default : currentValue
return this.formBuilder.control(value)
return this.formBuilder.control(value, [Validators.required])
case 'multiselect':
value = currentValue === undefined ? spec.default : currentValue
return this.formBuilder.control(value, multiselectValidators(spec))

View File

@@ -0,0 +1,21 @@
export function toAcmeName(url: ACME_URL | string | null): ACME_Name | string {
return (
Object.entries(knownACME).find(([_, val]) => val === url)?.[0] ||
url ||
'System CA'
)
}
export function toAcmeUrl(name: ACME_Name | string): ACME_URL | string {
return knownACME[name as ACME_Name] || name
}
export const knownACME = {
'Let\'s Encrypt': 'https://acme-v02.api.letsencrypt.org/directory',
'Let\'s Encrypt (Staging)':
'https://acme-staging-v02.api.letsencrypt.org/directory',
}
export type ACME_Name = keyof typeof knownACME
export type ACME_URL = (typeof knownACME)[ACME_Name]