mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-27 02:41:53 +00:00
Compare commits
15 Commits
next/major
...
fix/a20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f6f3dc72d | ||
|
|
59155c2e34 | ||
|
|
7693b0febc | ||
|
|
3901d38d65 | ||
|
|
8fdeeab5bb | ||
|
|
fd1ccc0c8c | ||
|
|
d31f762d5a | ||
|
|
5a0cd302de | ||
|
|
e71023a3a7 | ||
|
|
e077b5425b | ||
|
|
d982ffa722 | ||
|
|
4005365239 | ||
|
|
0f8a66b357 | ||
|
|
2ed8402edd | ||
|
|
f7f87a4e6a |
@@ -105,7 +105,7 @@ lb config \
|
|||||||
--iso-preparer "START9 LABS; HTTPS://START9.COM" \
|
--iso-preparer "START9 LABS; HTTPS://START9.COM" \
|
||||||
--iso-publisher "START9 LABS; HTTPS://START9.COM" \
|
--iso-publisher "START9 LABS; HTTPS://START9.COM" \
|
||||||
--backports true \
|
--backports true \
|
||||||
--bootappend-live "boot=live noautologin" \
|
--bootappend-live "boot=live noautologin console=tty0" \
|
||||||
--bootloaders $BOOTLOADERS \
|
--bootloaders $BOOTLOADERS \
|
||||||
--cache false \
|
--cache false \
|
||||||
--mirror-bootstrap "https://deb.debian.org/debian/" \
|
--mirror-bootstrap "https://deb.debian.org/debian/" \
|
||||||
|
|||||||
@@ -62,12 +62,27 @@ fi
|
|||||||
chroot /media/startos/next bash -e << "EOF"
|
chroot /media/startos/next bash -e << "EOF"
|
||||||
|
|
||||||
if [ -f /boot/grub/grub.cfg ]; then
|
if [ -f /boot/grub/grub.cfg ]; then
|
||||||
grub-install --no-nvram /dev/$(eval $(lsblk -o MOUNTPOINT,PKNAME -P | grep 'MOUNTPOINT="/media/startos/root"') && echo $PKNAME)
|
grub-install /dev/$(eval $(lsblk -o MOUNTPOINT,PKNAME -P | grep 'MOUNTPOINT="/media/startos/root"') && echo $PKNAME)
|
||||||
update-grub
|
update-grub
|
||||||
fi
|
fi
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
# Promote the USB installer boot entry back to first in EFI boot order.
|
||||||
|
# The entry number was saved during initial OS install.
|
||||||
|
if [ -d /sys/firmware/efi ] && [ -f /media/startos/config/efi-installer-entry ]; then
|
||||||
|
USB_ENTRY=$(cat /media/startos/config/efi-installer-entry)
|
||||||
|
if [ -n "$USB_ENTRY" ]; then
|
||||||
|
CURRENT_ORDER=$(efibootmgr | grep BootOrder | sed 's/BootOrder: //')
|
||||||
|
OTHER_ENTRIES=$(echo "$CURRENT_ORDER" | tr ',' '\n' | grep -v "$USB_ENTRY" | tr '\n' ',' | sed 's/,$//')
|
||||||
|
if [ -n "$OTHER_ENTRIES" ]; then
|
||||||
|
efibootmgr -o "$USB_ENTRY,$OTHER_ENTRIES"
|
||||||
|
else
|
||||||
|
efibootmgr -o "$USB_ENTRY"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
sync
|
sync
|
||||||
|
|
||||||
umount -Rl /media/startos/next
|
umount -Rl /media/startos/next
|
||||||
|
|||||||
@@ -11,10 +11,22 @@ START9_GPG_KEY="2D63C217"
|
|||||||
ARCHES="aarch64 aarch64-nonfree aarch64-nvidia riscv64 riscv64-nonfree x86_64 x86_64-nonfree x86_64-nvidia"
|
ARCHES="aarch64 aarch64-nonfree aarch64-nvidia riscv64 riscv64-nonfree x86_64 x86_64-nonfree x86_64-nvidia"
|
||||||
CLI_ARCHES="aarch64 riscv64 x86_64"
|
CLI_ARCHES="aarch64 riscv64 x86_64"
|
||||||
|
|
||||||
|
parse_run_id() {
|
||||||
|
local val="$1"
|
||||||
|
if [[ "$val" =~ /actions/runs/([0-9]+) ]]; then
|
||||||
|
echo "${BASH_REMATCH[1]}"
|
||||||
|
else
|
||||||
|
echo "$val"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
require_version() {
|
require_version() {
|
||||||
if [ -z "$VERSION" ]; then
|
if [ -z "${VERSION:-}" ]; then
|
||||||
>&2 echo '$VERSION required'
|
read -rp "VERSION: " VERSION
|
||||||
exit 2
|
if [ -z "$VERSION" ]; then
|
||||||
|
>&2 echo '$VERSION required'
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +87,22 @@ resolve_gh_user() {
|
|||||||
|
|
||||||
cmd_download() {
|
cmd_download() {
|
||||||
require_version
|
require_version
|
||||||
|
|
||||||
|
if [ -z "${RUN_ID:-}" ]; then
|
||||||
|
read -rp "RUN_ID (OS images, leave blank to skip): " RUN_ID
|
||||||
|
fi
|
||||||
|
RUN_ID=$(parse_run_id "${RUN_ID:-}")
|
||||||
|
|
||||||
|
if [ -z "${ST_RUN_ID:-}" ]; then
|
||||||
|
read -rp "ST_RUN_ID (start-tunnel, leave blank to skip): " ST_RUN_ID
|
||||||
|
fi
|
||||||
|
ST_RUN_ID=$(parse_run_id "${ST_RUN_ID:-}")
|
||||||
|
|
||||||
|
if [ -z "${CLI_RUN_ID:-}" ]; then
|
||||||
|
read -rp "CLI_RUN_ID (start-cli, leave blank to skip): " CLI_RUN_ID
|
||||||
|
fi
|
||||||
|
CLI_RUN_ID=$(parse_run_id "${CLI_RUN_ID:-}")
|
||||||
|
|
||||||
ensure_release_dir
|
ensure_release_dir
|
||||||
|
|
||||||
if [ -n "$RUN_ID" ]; then
|
if [ -n "$RUN_ID" ]; then
|
||||||
@@ -143,10 +171,14 @@ cmd_upload() {
|
|||||||
enter_release_dir
|
enter_release_dir
|
||||||
|
|
||||||
for file in $(release_files); do
|
for file in $(release_files); do
|
||||||
gh release upload -R $REPO "v$VERSION" "$file"
|
case "$file" in
|
||||||
done
|
*.iso|*.squashfs)
|
||||||
for file in *.iso *.squashfs; do
|
s3cmd put -P "$file" "$S3_BUCKET/v$VERSION/$file"
|
||||||
s3cmd put -P "$file" "$S3_BUCKET/v$VERSION/$file"
|
;;
|
||||||
|
*)
|
||||||
|
gh release upload -R $REPO "v$VERSION" "$file"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
container-runtime/package-lock.json
generated
2
container-runtime/package-lock.json
generated
@@ -37,7 +37,7 @@
|
|||||||
},
|
},
|
||||||
"../sdk/dist": {
|
"../sdk/dist": {
|
||||||
"name": "@start9labs/start-sdk",
|
"name": "@start9labs/start-sdk",
|
||||||
"version": "0.4.0-beta.55",
|
"version": "0.4.0-beta.58",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@iarna/toml": "^3.0.0",
|
"@iarna/toml": "^3.0.0",
|
||||||
|
|||||||
@@ -25,3 +25,4 @@ cd sdk && make baseDist dist # Rebuild SDK after ts-bindings
|
|||||||
- When adding i18n keys, add all 5 locales in `core/locales/i18n.yaml` (see [i18n-patterns.md](i18n-patterns.md))
|
- When adding i18n keys, add all 5 locales in `core/locales/i18n.yaml` (see [i18n-patterns.md](i18n-patterns.md))
|
||||||
- When using DB watches, follow the `TypedDbWatch<T>` patterns in [patchdb.md](patchdb.md)
|
- When using DB watches, follow the `TypedDbWatch<T>` patterns in [patchdb.md](patchdb.md)
|
||||||
- **Always use `.invoke(ErrorKind::...)` instead of `.status()` when running CLI commands** via `tokio::process::Command`. The `Invoke` trait (from `crate::util::Invoke`) captures stdout/stderr and checks exit codes properly. Using `.status()` leaks stderr directly to system logs, creating noise. For check-then-act patterns (e.g. `iptables -C`), use `.invoke(...).await.is_ok()` / `.is_err()` instead of `.status().await.map_or(false, |s| s.success())`.
|
- **Always use `.invoke(ErrorKind::...)` instead of `.status()` when running CLI commands** via `tokio::process::Command`. The `Invoke` trait (from `crate::util::Invoke`) captures stdout/stderr and checks exit codes properly. Using `.status()` leaks stderr directly to system logs, creating noise. For check-then-act patterns (e.g. `iptables -C`), use `.invoke(...).await.is_ok()` / `.is_err()` instead of `.status().await.map_or(false, |s| s.success())`.
|
||||||
|
- Always use file utils in util::io instead of tokio::fs when available
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use crate::net::host::Host;
|
|||||||
use crate::net::host::binding::{
|
use crate::net::host::binding::{
|
||||||
AddSslOptions, BindInfo, BindOptions, Bindings, DerivedAddressInfo, NetInfo,
|
AddSslOptions, BindInfo, BindOptions, Bindings, DerivedAddressInfo, NetInfo,
|
||||||
};
|
};
|
||||||
use crate::net::vhost::AlpnInfo;
|
use crate::net::vhost::{AlpnInfo, PassthroughInfo};
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use crate::progress::FullProgress;
|
use crate::progress::FullProgress;
|
||||||
use crate::system::{KeyboardOptions, SmtpValue};
|
use crate::system::{KeyboardOptions, SmtpValue};
|
||||||
@@ -121,6 +121,7 @@ impl Public {
|
|||||||
},
|
},
|
||||||
dns: Default::default(),
|
dns: Default::default(),
|
||||||
default_outbound: None,
|
default_outbound: None,
|
||||||
|
passthroughs: Vec::new(),
|
||||||
},
|
},
|
||||||
status_info: ServerStatus {
|
status_info: ServerStatus {
|
||||||
backup_progress: None,
|
backup_progress: None,
|
||||||
@@ -233,6 +234,8 @@ pub struct NetworkInfo {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[ts(type = "string | null")]
|
#[ts(type = "string | null")]
|
||||||
pub default_outbound: Option<GatewayId>,
|
pub default_outbound: Option<GatewayId>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub passthroughs: Vec<PassthroughInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
|
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ use crate::db::model::public::AcmeSettings;
|
|||||||
use crate::db::{DbAccess, DbAccessByKey, DbAccessMut};
|
use crate::db::{DbAccess, DbAccessByKey, DbAccessMut};
|
||||||
use crate::error::ErrorData;
|
use crate::error::ErrorData;
|
||||||
use crate::net::ssl::should_use_cert;
|
use crate::net::ssl::should_use_cert;
|
||||||
use crate::net::tls::{SingleCertResolver, TlsHandler};
|
use crate::net::tls::{SingleCertResolver, TlsHandler, TlsHandlerAction};
|
||||||
use crate::net::web_server::Accept;
|
use crate::net::web_server::Accept;
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use crate::util::FromStrParser;
|
use crate::util::FromStrParser;
|
||||||
@@ -173,7 +173,7 @@ where
|
|||||||
&'a mut self,
|
&'a mut self,
|
||||||
hello: &'a ClientHello<'a>,
|
hello: &'a ClientHello<'a>,
|
||||||
_: &'a <A as Accept>::Metadata,
|
_: &'a <A as Accept>::Metadata,
|
||||||
) -> Option<ServerConfig> {
|
) -> Option<TlsHandlerAction> {
|
||||||
let domain = hello.server_name()?;
|
let domain = hello.server_name()?;
|
||||||
if hello
|
if hello
|
||||||
.alpn()
|
.alpn()
|
||||||
@@ -207,20 +207,20 @@ where
|
|||||||
cfg.alpn_protocols = vec![ACME_TLS_ALPN_NAME.to_vec()];
|
cfg.alpn_protocols = vec![ACME_TLS_ALPN_NAME.to_vec()];
|
||||||
tracing::info!("performing ACME auth challenge");
|
tracing::info!("performing ACME auth challenge");
|
||||||
|
|
||||||
return Some(cfg);
|
return Some(TlsHandlerAction::Tls(cfg));
|
||||||
}
|
}
|
||||||
|
|
||||||
let domains: BTreeSet<InternedString> = [domain.into()].into_iter().collect();
|
let domains: BTreeSet<InternedString> = [domain.into()].into_iter().collect();
|
||||||
|
|
||||||
let crypto_provider = self.crypto_provider.clone();
|
let crypto_provider = self.crypto_provider.clone();
|
||||||
if let Some(cert) = self.get_cert(&domains).await {
|
if let Some(cert) = self.get_cert(&domains).await {
|
||||||
return Some(
|
return Some(TlsHandlerAction::Tls(
|
||||||
ServerConfig::builder_with_provider(crypto_provider)
|
ServerConfig::builder_with_provider(crypto_provider)
|
||||||
.with_safe_default_protocol_versions()
|
.with_safe_default_protocol_versions()
|
||||||
.log_err()?
|
.log_err()?
|
||||||
.with_no_client_auth()
|
.with_no_client_auth()
|
||||||
.with_cert_resolver(Arc::new(SingleCertResolver(Arc::new(cert)))),
|
.with_cert_resolver(Arc::new(SingleCertResolver(Arc::new(cert)))),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -174,11 +174,11 @@ async fn set_name(
|
|||||||
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
|
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
struct CheckPortParams {
|
pub struct CheckPortParams {
|
||||||
#[arg(help = "help.arg.port")]
|
#[arg(help = "help.arg.port")]
|
||||||
port: u16,
|
pub port: u16,
|
||||||
#[arg(help = "help.arg.gateway-id")]
|
#[arg(help = "help.arg.gateway-id")]
|
||||||
gateway: GatewayId,
|
pub gateway: GatewayId,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||||
@@ -200,7 +200,7 @@ pub struct IfconfigPortRes {
|
|||||||
pub reachable: bool,
|
pub reachable: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_port(
|
pub async fn check_port(
|
||||||
ctx: RpcContext,
|
ctx: RpcContext,
|
||||||
CheckPortParams { port, gateway }: CheckPortParams,
|
CheckPortParams { port, gateway }: CheckPortParams,
|
||||||
) -> Result<CheckPortRes, Error> {
|
) -> Result<CheckPortRes, Error> {
|
||||||
@@ -276,12 +276,12 @@ async fn check_port(
|
|||||||
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
|
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
struct CheckDnsParams {
|
pub struct CheckDnsParams {
|
||||||
#[arg(help = "help.arg.gateway-id")]
|
#[arg(help = "help.arg.gateway-id")]
|
||||||
gateway: GatewayId,
|
pub gateway: GatewayId,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_dns(
|
pub async fn check_dns(
|
||||||
ctx: RpcContext,
|
ctx: RpcContext,
|
||||||
CheckDnsParams { gateway }: CheckDnsParams,
|
CheckDnsParams { gateway }: CheckDnsParams,
|
||||||
) -> Result<bool, Error> {
|
) -> Result<bool, Error> {
|
||||||
@@ -1238,8 +1238,7 @@ async fn poll_ip_info(
|
|||||||
device_type,
|
device_type,
|
||||||
Some(NetworkInterfaceType::Bridge | NetworkInterfaceType::Loopback)
|
Some(NetworkInterfaceType::Bridge | NetworkInterfaceType::Loopback)
|
||||||
) {
|
) {
|
||||||
*prev_attempt = Some(Instant::now());
|
let res = match get_wan_ipv4(iface.as_str(), &ifconfig_url).await {
|
||||||
match get_wan_ipv4(iface.as_str(), &ifconfig_url).await {
|
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
@@ -1253,7 +1252,9 @@ async fn poll_ip_info(
|
|||||||
tracing::debug!("{e:?}");
|
tracing::debug!("{e:?}");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
*prev_attempt = Some(Instant::now());
|
||||||
|
res
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use crate::context::{CliContext, RpcContext};
|
|||||||
use crate::db::model::DatabaseModel;
|
use crate::db::model::DatabaseModel;
|
||||||
use crate::hostname::ServerHostname;
|
use crate::hostname::ServerHostname;
|
||||||
use crate::net::acme::AcmeProvider;
|
use crate::net::acme::AcmeProvider;
|
||||||
|
use crate::net::gateway::{CheckDnsParams, CheckPortParams, CheckPortRes, check_dns, check_port};
|
||||||
use crate::net::host::{HostApiKind, all_hosts};
|
use crate::net::host::{HostApiKind, all_hosts};
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use crate::util::serde::{HandlerExtSerde, display_serializable};
|
use crate::util::serde::{HandlerExtSerde, display_serializable};
|
||||||
@@ -160,6 +161,7 @@ pub fn address_api<C: Context, Kind: HostApiKind>()
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Parser, TS)]
|
#[derive(Deserialize, Serialize, Parser, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
pub struct AddPublicDomainParams {
|
pub struct AddPublicDomainParams {
|
||||||
#[arg(help = "help.arg.fqdn")]
|
#[arg(help = "help.arg.fqdn")]
|
||||||
@@ -168,6 +170,17 @@ pub struct AddPublicDomainParams {
|
|||||||
pub acme: Option<AcmeProvider>,
|
pub acme: Option<AcmeProvider>,
|
||||||
#[arg(help = "help.arg.gateway-id")]
|
#[arg(help = "help.arg.gateway-id")]
|
||||||
pub gateway: GatewayId,
|
pub gateway: GatewayId,
|
||||||
|
#[arg(help = "help.arg.internal-port")]
|
||||||
|
pub internal_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct AddPublicDomainRes {
|
||||||
|
#[ts(type = "string | null")]
|
||||||
|
pub dns: Option<Ipv4Addr>,
|
||||||
|
pub port: CheckPortRes,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add_public_domain<Kind: HostApiKind>(
|
pub async fn add_public_domain<Kind: HostApiKind>(
|
||||||
@@ -176,10 +189,12 @@ pub async fn add_public_domain<Kind: HostApiKind>(
|
|||||||
fqdn,
|
fqdn,
|
||||||
acme,
|
acme,
|
||||||
gateway,
|
gateway,
|
||||||
|
internal_port,
|
||||||
}: AddPublicDomainParams,
|
}: AddPublicDomainParams,
|
||||||
inheritance: Kind::Inheritance,
|
inheritance: Kind::Inheritance,
|
||||||
) -> Result<Option<Ipv4Addr>, Error> {
|
) -> Result<AddPublicDomainRes, Error> {
|
||||||
ctx.db
|
let ext_port = ctx
|
||||||
|
.db
|
||||||
.mutate(|db| {
|
.mutate(|db| {
|
||||||
if let Some(acme) = &acme {
|
if let Some(acme) = &acme {
|
||||||
if !db
|
if !db
|
||||||
@@ -195,21 +210,92 @@ pub async fn add_public_domain<Kind: HostApiKind>(
|
|||||||
|
|
||||||
Kind::host_for(&inheritance, db)?
|
Kind::host_for(&inheritance, db)?
|
||||||
.as_public_domains_mut()
|
.as_public_domains_mut()
|
||||||
.insert(&fqdn, &PublicDomainConfig { acme, gateway })?;
|
.insert(
|
||||||
|
&fqdn,
|
||||||
|
&PublicDomainConfig {
|
||||||
|
acme,
|
||||||
|
gateway: gateway.clone(),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
handle_duplicates(db)?;
|
handle_duplicates(db)?;
|
||||||
let hostname = ServerHostname::load(db.as_public().as_server_info())?;
|
let hostname = ServerHostname::load(db.as_public().as_server_info())?;
|
||||||
let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?;
|
let gateways = db
|
||||||
let ports = db.as_private().as_available_ports().de()?;
|
.as_public()
|
||||||
Kind::host_for(&inheritance, db)?.update_addresses(&hostname, &gateways, &ports)
|
.as_server_info()
|
||||||
|
.as_network()
|
||||||
|
.as_gateways()
|
||||||
|
.de()?;
|
||||||
|
let available_ports = db.as_private().as_available_ports().de()?;
|
||||||
|
let host = Kind::host_for(&inheritance, db)?;
|
||||||
|
host.update_addresses(&hostname, &gateways, &available_ports)?;
|
||||||
|
|
||||||
|
// Find the external port for the target binding
|
||||||
|
let bindings = host.as_bindings().de()?;
|
||||||
|
let target_bind = bindings
|
||||||
|
.get(&internal_port)
|
||||||
|
.ok_or_else(|| Error::new(eyre!("binding not found for internal port {internal_port}"), ErrorKind::NotFound))?;
|
||||||
|
let ext_port = target_bind
|
||||||
|
.addresses
|
||||||
|
.available
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.public && a.hostname == fqdn)
|
||||||
|
.and_then(|a| a.port)
|
||||||
|
.ok_or_else(|| Error::new(eyre!("no public address found for {fqdn} on port {internal_port}"), ErrorKind::NotFound))?;
|
||||||
|
|
||||||
|
// Disable the domain on all other bindings
|
||||||
|
host.as_bindings_mut().mutate(|b| {
|
||||||
|
for (&port, bind) in b.iter_mut() {
|
||||||
|
if port == internal_port {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let has_addr = bind
|
||||||
|
.addresses
|
||||||
|
.available
|
||||||
|
.iter()
|
||||||
|
.any(|a| a.public && a.hostname == fqdn);
|
||||||
|
if has_addr {
|
||||||
|
let other_ext = bind
|
||||||
|
.addresses
|
||||||
|
.available
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.public && a.hostname == fqdn)
|
||||||
|
.and_then(|a| a.port)
|
||||||
|
.unwrap_or(ext_port);
|
||||||
|
bind.addresses.disabled.insert((fqdn.clone(), other_ext));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(ext_port)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.result?;
|
.result?;
|
||||||
|
|
||||||
tokio::task::spawn_blocking(|| {
|
let ctx2 = ctx.clone();
|
||||||
crate::net::dns::query_dns(ctx, crate::net::dns::QueryDnsParams { fqdn })
|
let fqdn2 = fqdn.clone();
|
||||||
|
|
||||||
|
let (dns_result, port_result) = tokio::join!(
|
||||||
|
async {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
crate::net::dns::query_dns(ctx2, crate::net::dns::QueryDnsParams { fqdn: fqdn2 })
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.with_kind(ErrorKind::Unknown)?
|
||||||
|
},
|
||||||
|
check_port(
|
||||||
|
ctx.clone(),
|
||||||
|
CheckPortParams {
|
||||||
|
port: ext_port,
|
||||||
|
gateway: gateway.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(AddPublicDomainRes {
|
||||||
|
dns: dns_result?,
|
||||||
|
port: port_result?,
|
||||||
})
|
})
|
||||||
.await
|
|
||||||
.with_kind(ErrorKind::Unknown)?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Parser, TS)]
|
#[derive(Deserialize, Serialize, Parser, TS)]
|
||||||
@@ -257,13 +343,13 @@ pub async fn add_private_domain<Kind: HostApiKind>(
|
|||||||
ctx: RpcContext,
|
ctx: RpcContext,
|
||||||
AddPrivateDomainParams { fqdn, gateway }: AddPrivateDomainParams,
|
AddPrivateDomainParams { fqdn, gateway }: AddPrivateDomainParams,
|
||||||
inheritance: Kind::Inheritance,
|
inheritance: Kind::Inheritance,
|
||||||
) -> Result<(), Error> {
|
) -> Result<bool, Error> {
|
||||||
ctx.db
|
ctx.db
|
||||||
.mutate(|db| {
|
.mutate(|db| {
|
||||||
Kind::host_for(&inheritance, db)?
|
Kind::host_for(&inheritance, db)?
|
||||||
.as_private_domains_mut()
|
.as_private_domains_mut()
|
||||||
.upsert(&fqdn, || Ok(BTreeSet::new()))?
|
.upsert(&fqdn, || Ok(BTreeSet::new()))?
|
||||||
.mutate(|d| Ok(d.insert(gateway)))?;
|
.mutate(|d| Ok(d.insert(gateway.clone())))?;
|
||||||
handle_duplicates(db)?;
|
handle_duplicates(db)?;
|
||||||
let hostname = ServerHostname::load(db.as_public().as_server_info())?;
|
let hostname = ServerHostname::load(db.as_public().as_server_info())?;
|
||||||
let gateways = db
|
let gateways = db
|
||||||
@@ -278,7 +364,7 @@ pub async fn add_private_domain<Kind: HostApiKind>(
|
|||||||
.await
|
.await
|
||||||
.result?;
|
.result?;
|
||||||
|
|
||||||
Ok(())
|
check_dns(ctx, CheckDnsParams { gateway }).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn remove_private_domain<Kind: HostApiKind>(
|
pub async fn remove_private_domain<Kind: HostApiKind>(
|
||||||
|
|||||||
@@ -249,6 +249,20 @@ impl Model<Host> {
|
|||||||
port: Some(port),
|
port: Some(port),
|
||||||
metadata,
|
metadata,
|
||||||
});
|
});
|
||||||
|
} else if opt.secure.map_or(false, |s| s.ssl)
|
||||||
|
&& opt.add_ssl.is_none()
|
||||||
|
&& available_ports.is_ssl(opt.preferred_external_port)
|
||||||
|
&& net.assigned_port != Some(opt.preferred_external_port)
|
||||||
|
{
|
||||||
|
// Service handles its own TLS and the preferred port is
|
||||||
|
// allocated as SSL — add an address for passthrough vhost.
|
||||||
|
available.insert(HostnameInfo {
|
||||||
|
ssl: true,
|
||||||
|
public: true,
|
||||||
|
hostname: domain,
|
||||||
|
port: Some(opt.preferred_external_port),
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +307,20 @@ impl Model<Host> {
|
|||||||
gateways: domain_gateways,
|
gateways: domain_gateways,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} else if opt.secure.map_or(false, |s| s.ssl)
|
||||||
|
&& opt.add_ssl.is_none()
|
||||||
|
&& available_ports.is_ssl(opt.preferred_external_port)
|
||||||
|
&& net.assigned_port != Some(opt.preferred_external_port)
|
||||||
|
{
|
||||||
|
available.insert(HostnameInfo {
|
||||||
|
ssl: true,
|
||||||
|
public: true,
|
||||||
|
hostname: domain,
|
||||||
|
port: Some(opt.preferred_external_port),
|
||||||
|
metadata: HostnameMetadata::PrivateDomain {
|
||||||
|
gateways: domain_gateways,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bind.as_addresses_mut().as_available_mut().ser(&available)?;
|
bind.as_addresses_mut().as_available_mut().ser(&available)?;
|
||||||
|
|||||||
@@ -76,9 +76,22 @@ impl NetController {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
let passthroughs = db
|
||||||
|
.peek()
|
||||||
|
.await
|
||||||
|
.as_public()
|
||||||
|
.as_server_info()
|
||||||
|
.as_network()
|
||||||
|
.as_passthroughs()
|
||||||
|
.de()?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
db: db.clone(),
|
db: db.clone(),
|
||||||
vhost: VHostController::new(db.clone(), net_iface.clone(), crypto_provider),
|
vhost: VHostController::new(
|
||||||
|
db.clone(),
|
||||||
|
net_iface.clone(),
|
||||||
|
crypto_provider,
|
||||||
|
passthroughs,
|
||||||
|
),
|
||||||
tls_client_config,
|
tls_client_config,
|
||||||
dns: DnsController::init(db, &net_iface.watcher).await?,
|
dns: DnsController::init(db, &net_iface.watcher).await?,
|
||||||
forward: InterfacePortForwardController::new(net_iface.watcher.subscribe()),
|
forward: InterfacePortForwardController::new(net_iface.watcher.subscribe()),
|
||||||
@@ -237,6 +250,7 @@ impl NetServiceData {
|
|||||||
connect_ssl: connect_ssl
|
connect_ssl: connect_ssl
|
||||||
.clone()
|
.clone()
|
||||||
.map(|_| ctrl.tls_client_config.clone()),
|
.map(|_| ctrl.tls_client_config.clone()),
|
||||||
|
passthrough: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -253,7 +267,9 @@ impl NetServiceData {
|
|||||||
_ => continue,
|
_ => continue,
|
||||||
}
|
}
|
||||||
let domain = &addr_info.hostname;
|
let domain = &addr_info.hostname;
|
||||||
let domain_ssl_port = addr_info.port.unwrap_or(443);
|
let Some(domain_ssl_port) = addr_info.port else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let key = (Some(domain.clone()), domain_ssl_port);
|
let key = (Some(domain.clone()), domain_ssl_port);
|
||||||
let target = vhosts.entry(key).or_insert_with(|| ProxyTarget {
|
let target = vhosts.entry(key).or_insert_with(|| ProxyTarget {
|
||||||
public: BTreeSet::new(),
|
public: BTreeSet::new(),
|
||||||
@@ -266,6 +282,7 @@ impl NetServiceData {
|
|||||||
addr,
|
addr,
|
||||||
add_x_forwarded_headers: ssl.add_x_forwarded_headers,
|
add_x_forwarded_headers: ssl.add_x_forwarded_headers,
|
||||||
connect_ssl: connect_ssl.clone().map(|_| ctrl.tls_client_config.clone()),
|
connect_ssl: connect_ssl.clone().map(|_| ctrl.tls_client_config.clone()),
|
||||||
|
passthrough: false,
|
||||||
});
|
});
|
||||||
if addr_info.public {
|
if addr_info.public {
|
||||||
for gw in addr_info.metadata.gateways() {
|
for gw in addr_info.metadata.gateways() {
|
||||||
@@ -317,6 +334,53 @@ impl NetServiceData {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Passthrough vhosts: if the service handles its own TLS
|
||||||
|
// (secure.ssl && no add_ssl) and a domain address is enabled on
|
||||||
|
// an SSL port different from assigned_port, add a passthrough
|
||||||
|
// vhost so the service's TLS endpoint is reachable on that port.
|
||||||
|
if bind.options.secure.map_or(false, |s| s.ssl) && bind.options.add_ssl.is_none() {
|
||||||
|
let assigned = bind.net.assigned_port;
|
||||||
|
for addr_info in &enabled_addresses {
|
||||||
|
if !addr_info.ssl {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(pt_port) = addr_info.port.filter(|p| assigned != Some(*p)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match &addr_info.metadata {
|
||||||
|
HostnameMetadata::PublicDomain { .. }
|
||||||
|
| HostnameMetadata::PrivateDomain { .. } => {}
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
let domain = &addr_info.hostname;
|
||||||
|
let key = (Some(domain.clone()), pt_port);
|
||||||
|
let target = vhosts.entry(key).or_insert_with(|| ProxyTarget {
|
||||||
|
public: BTreeSet::new(),
|
||||||
|
private: BTreeSet::new(),
|
||||||
|
acme: None,
|
||||||
|
addr,
|
||||||
|
add_x_forwarded_headers: false,
|
||||||
|
connect_ssl: Err(AlpnInfo::Reflect),
|
||||||
|
passthrough: true,
|
||||||
|
});
|
||||||
|
if addr_info.public {
|
||||||
|
for gw in addr_info.metadata.gateways() {
|
||||||
|
target.public.insert(gw.clone());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for gw in addr_info.metadata.gateways() {
|
||||||
|
if let Some(info) = net_ifaces.get(gw) {
|
||||||
|
if let Some(ip_info) = &info.ip_info {
|
||||||
|
for subnet in &ip_info.subnets {
|
||||||
|
target.private.insert(subnet.addr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase 3: Reconcile ──
|
// ── Phase 3: Reconcile ──
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ use crate::db::{DbAccess, DbAccessMut};
|
|||||||
use crate::hostname::ServerHostname;
|
use crate::hostname::ServerHostname;
|
||||||
use crate::init::check_time_is_synchronized;
|
use crate::init::check_time_is_synchronized;
|
||||||
use crate::net::gateway::GatewayInfo;
|
use crate::net::gateway::GatewayInfo;
|
||||||
use crate::net::tls::TlsHandler;
|
use crate::net::tls::{TlsHandler, TlsHandlerAction};
|
||||||
use crate::net::web_server::{Accept, ExtractVisitor, TcpMetadata, extract};
|
use crate::net::web_server::{Accept, ExtractVisitor, TcpMetadata, extract};
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use crate::util::serde::Pem;
|
use crate::util::serde::Pem;
|
||||||
@@ -620,7 +620,7 @@ where
|
|||||||
&mut self,
|
&mut self,
|
||||||
hello: &ClientHello<'_>,
|
hello: &ClientHello<'_>,
|
||||||
metadata: &<A as Accept>::Metadata,
|
metadata: &<A as Accept>::Metadata,
|
||||||
) -> Option<ServerConfig> {
|
) -> Option<TlsHandlerAction> {
|
||||||
let hostnames: BTreeSet<InternedString> = hello
|
let hostnames: BTreeSet<InternedString> = hello
|
||||||
.server_name()
|
.server_name()
|
||||||
.map(InternedString::from)
|
.map(InternedString::from)
|
||||||
@@ -684,5 +684,6 @@ where
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.log_err()
|
.log_err()
|
||||||
|
.map(TlsHandlerAction::Tls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ use tokio_rustls::rustls::sign::CertifiedKey;
|
|||||||
use tokio_rustls::rustls::{ClientConfig, RootCertStore, ServerConfig};
|
use tokio_rustls::rustls::{ClientConfig, RootCertStore, ServerConfig};
|
||||||
use visit_rs::{Visit, VisitFields};
|
use visit_rs::{Visit, VisitFields};
|
||||||
|
|
||||||
|
/// Result of a TLS handler's decision about how to handle a connection.
|
||||||
|
pub enum TlsHandlerAction {
|
||||||
|
/// Complete the TLS handshake with this ServerConfig.
|
||||||
|
Tls(ServerConfig),
|
||||||
|
/// Don't complete TLS — rewind the BackTrackingIO and return the raw stream.
|
||||||
|
Passthrough,
|
||||||
|
}
|
||||||
|
|
||||||
use crate::net::http::handle_http_on_https;
|
use crate::net::http::handle_http_on_https;
|
||||||
use crate::net::web_server::{Accept, AcceptStream, MetadataVisitor};
|
use crate::net::web_server::{Accept, AcceptStream, MetadataVisitor};
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
@@ -50,7 +58,7 @@ pub trait TlsHandler<'a, A: Accept> {
|
|||||||
&'a mut self,
|
&'a mut self,
|
||||||
hello: &'a ClientHello<'a>,
|
hello: &'a ClientHello<'a>,
|
||||||
metadata: &'a A::Metadata,
|
metadata: &'a A::Metadata,
|
||||||
) -> impl Future<Output = Option<ServerConfig>> + Send + 'a;
|
) -> impl Future<Output = Option<TlsHandlerAction>> + Send + 'a;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -66,7 +74,7 @@ where
|
|||||||
&'a mut self,
|
&'a mut self,
|
||||||
hello: &'a ClientHello<'a>,
|
hello: &'a ClientHello<'a>,
|
||||||
metadata: &'a <A as Accept>::Metadata,
|
metadata: &'a <A as Accept>::Metadata,
|
||||||
) -> Option<ServerConfig> {
|
) -> Option<TlsHandlerAction> {
|
||||||
if let Some(config) = self.0.get_config(hello, metadata).await {
|
if let Some(config) = self.0.get_config(hello, metadata).await {
|
||||||
return Some(config);
|
return Some(config);
|
||||||
}
|
}
|
||||||
@@ -86,7 +94,7 @@ pub trait WrapTlsHandler<A: Accept> {
|
|||||||
prev: ServerConfig,
|
prev: ServerConfig,
|
||||||
hello: &'a ClientHello<'a>,
|
hello: &'a ClientHello<'a>,
|
||||||
metadata: &'a <A as Accept>::Metadata,
|
metadata: &'a <A as Accept>::Metadata,
|
||||||
) -> impl Future<Output = Option<ServerConfig>> + Send + 'a
|
) -> impl Future<Output = Option<TlsHandlerAction>> + Send + 'a
|
||||||
where
|
where
|
||||||
Self: 'a;
|
Self: 'a;
|
||||||
}
|
}
|
||||||
@@ -102,9 +110,12 @@ where
|
|||||||
&'a mut self,
|
&'a mut self,
|
||||||
hello: &'a ClientHello<'a>,
|
hello: &'a ClientHello<'a>,
|
||||||
metadata: &'a <A as Accept>::Metadata,
|
metadata: &'a <A as Accept>::Metadata,
|
||||||
) -> Option<ServerConfig> {
|
) -> Option<TlsHandlerAction> {
|
||||||
let prev = self.inner.get_config(hello, metadata).await?;
|
let action = self.inner.get_config(hello, metadata).await?;
|
||||||
self.wrapper.wrap(prev, hello, metadata).await
|
match action {
|
||||||
|
TlsHandlerAction::Tls(cfg) => self.wrapper.wrap(cfg, hello, metadata).await,
|
||||||
|
other => Some(other),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,34 +214,56 @@ where
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let hello = mid.client_hello();
|
let hello = mid.client_hello();
|
||||||
if let Some(cfg) = tls_handler.get_config(&hello, &metadata).await {
|
let sni = hello.server_name().map(InternedString::intern);
|
||||||
let buffered = mid.io.stop_buffering();
|
match tls_handler.get_config(&hello, &metadata).await {
|
||||||
mid.io
|
Some(TlsHandlerAction::Tls(cfg)) => {
|
||||||
.write_all(&buffered)
|
let buffered = mid.io.stop_buffering();
|
||||||
.await
|
mid.io
|
||||||
.with_kind(ErrorKind::Network)?;
|
.write_all(&buffered)
|
||||||
return Ok(match mid.into_stream(Arc::new(cfg)).await {
|
.await
|
||||||
Ok(stream) => {
|
.with_kind(ErrorKind::Network)?;
|
||||||
let s = stream.get_ref().1;
|
return Ok(match mid.into_stream(Arc::new(cfg)).await {
|
||||||
Some((
|
Ok(stream) => {
|
||||||
TlsMetadata {
|
let s = stream.get_ref().1;
|
||||||
inner: metadata,
|
Some((
|
||||||
tls_info: TlsHandshakeInfo {
|
TlsMetadata {
|
||||||
sni: s.server_name().map(InternedString::intern),
|
inner: metadata,
|
||||||
alpn: s
|
tls_info: TlsHandshakeInfo {
|
||||||
.alpn_protocol()
|
sni: s
|
||||||
.map(|a| MaybeUtf8String(a.to_vec())),
|
.server_name()
|
||||||
|
.map(InternedString::intern),
|
||||||
|
alpn: s
|
||||||
|
.alpn_protocol()
|
||||||
|
.map(|a| MaybeUtf8String(a.to_vec())),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
Box::pin(stream) as AcceptStream,
|
||||||
Box::pin(stream) as AcceptStream,
|
))
|
||||||
))
|
}
|
||||||
}
|
Err(e) => {
|
||||||
Err(e) => {
|
tracing::trace!("Error completing TLS handshake: {e}");
|
||||||
tracing::trace!("Error completing TLS handshake: {e}");
|
tracing::trace!("{e:?}");
|
||||||
tracing::trace!("{e:?}");
|
None
|
||||||
None
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
Some(TlsHandlerAction::Passthrough) => {
|
||||||
|
let (dummy, _drop) = tokio::io::duplex(1);
|
||||||
|
let mut bt = std::mem::replace(
|
||||||
|
&mut mid.io,
|
||||||
|
BackTrackingIO::new(Box::pin(dummy) as AcceptStream),
|
||||||
|
);
|
||||||
|
drop(mid);
|
||||||
|
bt.rewind();
|
||||||
|
return Ok(Some((
|
||||||
|
TlsMetadata {
|
||||||
|
inner: metadata,
|
||||||
|
tls_info: TlsHandshakeInfo { sni, alpn: None },
|
||||||
|
},
|
||||||
|
Box::pin(bt) as AcceptStream,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ use std::sync::{Arc, Weak};
|
|||||||
use std::task::{Poll, ready};
|
use std::task::{Poll, ready};
|
||||||
|
|
||||||
use async_acme::acme::ACME_TLS_ALPN_NAME;
|
use async_acme::acme::ACME_TLS_ALPN_NAME;
|
||||||
|
use clap::Parser;
|
||||||
use color_eyre::eyre::eyre;
|
use color_eyre::eyre::eyre;
|
||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
use futures::future::BoxFuture;
|
use futures::future::BoxFuture;
|
||||||
use imbl::OrdMap;
|
use imbl::OrdMap;
|
||||||
use imbl_value::{InOMap, InternedString};
|
use imbl_value::{InOMap, InternedString};
|
||||||
use rpc_toolkit::{Context, HandlerArgs, HandlerExt, ParentHandler, from_fn};
|
use rpc_toolkit::{Context, HandlerArgs, HandlerExt, ParentHandler, from_fn, from_fn_async};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::net::{TcpListener, TcpStream};
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
use tokio_rustls::TlsConnector;
|
use tokio_rustls::TlsConnector;
|
||||||
@@ -35,7 +36,7 @@ use crate::net::gateway::{
|
|||||||
};
|
};
|
||||||
use crate::net::ssl::{CertStore, RootCaTlsHandler};
|
use crate::net::ssl::{CertStore, RootCaTlsHandler};
|
||||||
use crate::net::tls::{
|
use crate::net::tls::{
|
||||||
ChainedHandler, TlsHandlerWrapper, TlsListener, TlsMetadata, WrapTlsHandler,
|
ChainedHandler, TlsHandlerAction, TlsHandlerWrapper, TlsListener, TlsMetadata, WrapTlsHandler,
|
||||||
};
|
};
|
||||||
use crate::net::utils::ipv6_is_link_local;
|
use crate::net::utils::ipv6_is_link_local;
|
||||||
use crate::net::web_server::{Accept, AcceptStream, ExtractVisitor, TcpMetadata, extract};
|
use crate::net::web_server::{Accept, AcceptStream, ExtractVisitor, TcpMetadata, extract};
|
||||||
@@ -46,68 +47,228 @@ use crate::util::serde::{HandlerExtSerde, MaybeUtf8String, display_serializable}
|
|||||||
use crate::util::sync::{SyncMutex, Watch};
|
use crate::util::sync::{SyncMutex, Watch};
|
||||||
use crate::{GatewayId, ResultExt};
|
use crate::{GatewayId, ResultExt};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, HasModel, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[model = "Model<Self>"]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct PassthroughInfo {
|
||||||
|
#[ts(type = "string")]
|
||||||
|
pub hostname: InternedString,
|
||||||
|
pub listen_port: u16,
|
||||||
|
#[ts(type = "string")]
|
||||||
|
pub backend: SocketAddr,
|
||||||
|
#[ts(type = "string[]")]
|
||||||
|
pub public_gateways: BTreeSet<GatewayId>,
|
||||||
|
#[ts(type = "string[]")]
|
||||||
|
pub private_ips: BTreeSet<IpAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, Parser)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
struct AddPassthroughParams {
|
||||||
|
#[arg(long)]
|
||||||
|
pub hostname: InternedString,
|
||||||
|
#[arg(long)]
|
||||||
|
pub listen_port: u16,
|
||||||
|
#[arg(long)]
|
||||||
|
pub backend: SocketAddr,
|
||||||
|
#[arg(long)]
|
||||||
|
pub public_gateway: Vec<GatewayId>,
|
||||||
|
#[arg(long)]
|
||||||
|
pub private_ip: Vec<IpAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, Parser)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
struct RemovePassthroughParams {
|
||||||
|
#[arg(long)]
|
||||||
|
pub hostname: InternedString,
|
||||||
|
#[arg(long)]
|
||||||
|
pub listen_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn vhost_api<C: Context>() -> ParentHandler<C> {
|
pub fn vhost_api<C: Context>() -> ParentHandler<C> {
|
||||||
ParentHandler::new().subcommand(
|
ParentHandler::new()
|
||||||
"dump-table",
|
.subcommand(
|
||||||
from_fn(|ctx: RpcContext| Ok(ctx.net_controller.vhost.dump_table()))
|
"dump-table",
|
||||||
.with_display_serializable()
|
from_fn(dump_table)
|
||||||
.with_custom_display_fn(|HandlerArgs { params, .. }, res| {
|
.with_display_serializable()
|
||||||
use prettytable::*;
|
.with_custom_display_fn(|HandlerArgs { params, .. }, res| {
|
||||||
|
use prettytable::*;
|
||||||
|
|
||||||
if let Some(format) = params.format {
|
if let Some(format) = params.format {
|
||||||
display_serializable(format, res)?;
|
display_serializable(format, res)?;
|
||||||
return Ok::<_, Error>(());
|
return Ok::<_, Error>(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut table = Table::new();
|
let mut table = Table::new();
|
||||||
table.add_row(row![bc => "FROM", "TO", "ACTIVE"]);
|
table.add_row(row![bc => "FROM", "TO", "ACTIVE"]);
|
||||||
|
|
||||||
for (external, targets) in res {
|
for (external, targets) in res {
|
||||||
for (host, targets) in targets {
|
for (host, targets) in targets {
|
||||||
for (idx, target) in targets.into_iter().enumerate() {
|
for (idx, target) in targets.into_iter().enumerate() {
|
||||||
table.add_row(row![
|
table.add_row(row![
|
||||||
format!(
|
format!(
|
||||||
"{}:{}",
|
"{}:{}",
|
||||||
host.as_ref().map(|s| &**s).unwrap_or("*"),
|
host.as_ref().map(|s| &**s).unwrap_or("*"),
|
||||||
external.0
|
external.0
|
||||||
),
|
),
|
||||||
target,
|
target,
|
||||||
idx == 0
|
idx == 0
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
table.print_tty(false)?;
|
table.print_tty(false)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.with_call_remote::<CliContext>(),
|
.with_call_remote::<CliContext>(),
|
||||||
)
|
)
|
||||||
|
.subcommand(
|
||||||
|
"add-passthrough",
|
||||||
|
from_fn_async(add_passthrough)
|
||||||
|
.no_display()
|
||||||
|
.with_call_remote::<CliContext>(),
|
||||||
|
)
|
||||||
|
.subcommand(
|
||||||
|
"remove-passthrough",
|
||||||
|
from_fn_async(remove_passthrough)
|
||||||
|
.no_display()
|
||||||
|
.with_call_remote::<CliContext>(),
|
||||||
|
)
|
||||||
|
.subcommand(
|
||||||
|
"list-passthrough",
|
||||||
|
from_fn(list_passthrough)
|
||||||
|
.with_display_serializable()
|
||||||
|
.with_call_remote::<CliContext>(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dump_table(
|
||||||
|
ctx: RpcContext,
|
||||||
|
) -> Result<BTreeMap<JsonKey<u16>, BTreeMap<JsonKey<Option<InternedString>>, EqSet<String>>>, Error>
|
||||||
|
{
|
||||||
|
Ok(ctx.net_controller.vhost.dump_table())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_passthrough(
|
||||||
|
ctx: RpcContext,
|
||||||
|
AddPassthroughParams {
|
||||||
|
hostname,
|
||||||
|
listen_port,
|
||||||
|
backend,
|
||||||
|
public_gateway,
|
||||||
|
private_ip,
|
||||||
|
}: AddPassthroughParams,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let public_gateways: BTreeSet<GatewayId> = public_gateway.into_iter().collect();
|
||||||
|
let private_ips: BTreeSet<IpAddr> = private_ip.into_iter().collect();
|
||||||
|
ctx.net_controller.vhost.add_passthrough(
|
||||||
|
hostname.clone(),
|
||||||
|
listen_port,
|
||||||
|
backend,
|
||||||
|
public_gateways.clone(),
|
||||||
|
private_ips.clone(),
|
||||||
|
)?;
|
||||||
|
ctx.db
|
||||||
|
.mutate(|db| {
|
||||||
|
let pts = db
|
||||||
|
.as_public_mut()
|
||||||
|
.as_server_info_mut()
|
||||||
|
.as_network_mut()
|
||||||
|
.as_passthroughs_mut();
|
||||||
|
let mut vec: Vec<PassthroughInfo> = pts.de()?;
|
||||||
|
vec.retain(|p| !(p.hostname == hostname && p.listen_port == listen_port));
|
||||||
|
vec.push(PassthroughInfo {
|
||||||
|
hostname,
|
||||||
|
listen_port,
|
||||||
|
backend,
|
||||||
|
public_gateways,
|
||||||
|
private_ips,
|
||||||
|
});
|
||||||
|
pts.ser(&vec)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.result?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_passthrough(
|
||||||
|
ctx: RpcContext,
|
||||||
|
RemovePassthroughParams {
|
||||||
|
hostname,
|
||||||
|
listen_port,
|
||||||
|
}: RemovePassthroughParams,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
ctx.net_controller
|
||||||
|
.vhost
|
||||||
|
.remove_passthrough(&hostname, listen_port);
|
||||||
|
ctx.db
|
||||||
|
.mutate(|db| {
|
||||||
|
let pts = db
|
||||||
|
.as_public_mut()
|
||||||
|
.as_server_info_mut()
|
||||||
|
.as_network_mut()
|
||||||
|
.as_passthroughs_mut();
|
||||||
|
let mut vec: Vec<PassthroughInfo> = pts.de()?;
|
||||||
|
vec.retain(|p| !(p.hostname == hostname && p.listen_port == listen_port));
|
||||||
|
pts.ser(&vec)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.result?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_passthrough(ctx: RpcContext) -> Result<Vec<PassthroughInfo>, Error> {
|
||||||
|
Ok(ctx.net_controller.vhost.list_passthrough())
|
||||||
}
|
}
|
||||||
|
|
||||||
// not allowed: <=1024, >=32768, 5355, 5432, 9050, 6010, 9051, 5353
|
// not allowed: <=1024, >=32768, 5355, 5432, 9050, 6010, 9051, 5353
|
||||||
|
|
||||||
|
struct PassthroughHandle {
|
||||||
|
_rc: Arc<()>,
|
||||||
|
backend: SocketAddr,
|
||||||
|
public: BTreeSet<GatewayId>,
|
||||||
|
private: BTreeSet<IpAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct VHostController {
|
pub struct VHostController {
|
||||||
db: TypedPatchDb<Database>,
|
db: TypedPatchDb<Database>,
|
||||||
interfaces: Arc<NetworkInterfaceController>,
|
interfaces: Arc<NetworkInterfaceController>,
|
||||||
crypto_provider: Arc<CryptoProvider>,
|
crypto_provider: Arc<CryptoProvider>,
|
||||||
acme_cache: AcmeTlsAlpnCache,
|
acme_cache: AcmeTlsAlpnCache,
|
||||||
servers: SyncMutex<BTreeMap<u16, VHostServer<VHostBindListener>>>,
|
servers: SyncMutex<BTreeMap<u16, VHostServer<VHostBindListener>>>,
|
||||||
|
passthrough_handles: SyncMutex<BTreeMap<(InternedString, u16), PassthroughHandle>>,
|
||||||
}
|
}
|
||||||
impl VHostController {
|
impl VHostController {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
db: TypedPatchDb<Database>,
|
db: TypedPatchDb<Database>,
|
||||||
interfaces: Arc<NetworkInterfaceController>,
|
interfaces: Arc<NetworkInterfaceController>,
|
||||||
crypto_provider: Arc<CryptoProvider>,
|
crypto_provider: Arc<CryptoProvider>,
|
||||||
|
passthroughs: Vec<PassthroughInfo>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
let controller = Self {
|
||||||
db,
|
db,
|
||||||
interfaces,
|
interfaces,
|
||||||
crypto_provider,
|
crypto_provider,
|
||||||
acme_cache: Arc::new(SyncMutex::new(BTreeMap::new())),
|
acme_cache: Arc::new(SyncMutex::new(BTreeMap::new())),
|
||||||
servers: SyncMutex::new(BTreeMap::new()),
|
servers: SyncMutex::new(BTreeMap::new()),
|
||||||
|
passthrough_handles: SyncMutex::new(BTreeMap::new()),
|
||||||
|
};
|
||||||
|
for pt in passthroughs {
|
||||||
|
if let Err(e) = controller.add_passthrough(
|
||||||
|
pt.hostname,
|
||||||
|
pt.listen_port,
|
||||||
|
pt.backend,
|
||||||
|
pt.public_gateways,
|
||||||
|
pt.private_ips,
|
||||||
|
) {
|
||||||
|
tracing::warn!("failed to restore passthrough: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
controller
|
||||||
}
|
}
|
||||||
#[instrument(skip_all)]
|
#[instrument(skip_all)]
|
||||||
pub fn add(
|
pub fn add(
|
||||||
@@ -120,20 +281,7 @@ impl VHostController {
|
|||||||
let server = if let Some(server) = writable.remove(&external) {
|
let server = if let Some(server) = writable.remove(&external) {
|
||||||
server
|
server
|
||||||
} else {
|
} else {
|
||||||
let bind_reqs = Watch::new(VHostBindRequirements::default());
|
self.create_server(external)
|
||||||
let listener = VHostBindListener {
|
|
||||||
ip_info: self.interfaces.watcher.subscribe(),
|
|
||||||
port: external,
|
|
||||||
bind_reqs: bind_reqs.clone_unseen(),
|
|
||||||
listeners: BTreeMap::new(),
|
|
||||||
};
|
|
||||||
VHostServer::new(
|
|
||||||
listener,
|
|
||||||
bind_reqs,
|
|
||||||
self.db.clone(),
|
|
||||||
self.crypto_provider.clone(),
|
|
||||||
self.acme_cache.clone(),
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
let rc = server.add(hostname, target);
|
let rc = server.add(hostname, target);
|
||||||
writable.insert(external, server);
|
writable.insert(external, server);
|
||||||
@@ -141,6 +289,75 @@ impl VHostController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_server(&self, port: u16) -> VHostServer<VHostBindListener> {
|
||||||
|
let bind_reqs = Watch::new(VHostBindRequirements::default());
|
||||||
|
let listener = VHostBindListener {
|
||||||
|
ip_info: self.interfaces.watcher.subscribe(),
|
||||||
|
port,
|
||||||
|
bind_reqs: bind_reqs.clone_unseen(),
|
||||||
|
listeners: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
VHostServer::new(
|
||||||
|
listener,
|
||||||
|
bind_reqs,
|
||||||
|
self.db.clone(),
|
||||||
|
self.crypto_provider.clone(),
|
||||||
|
self.acme_cache.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_passthrough(
|
||||||
|
&self,
|
||||||
|
hostname: InternedString,
|
||||||
|
port: u16,
|
||||||
|
backend: SocketAddr,
|
||||||
|
public: BTreeSet<GatewayId>,
|
||||||
|
private: BTreeSet<IpAddr>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let target = ProxyTarget {
|
||||||
|
public: public.clone(),
|
||||||
|
private: private.clone(),
|
||||||
|
acme: None,
|
||||||
|
addr: backend,
|
||||||
|
add_x_forwarded_headers: false,
|
||||||
|
connect_ssl: Err(AlpnInfo::Reflect),
|
||||||
|
passthrough: true,
|
||||||
|
};
|
||||||
|
let rc = self.add(Some(hostname.clone()), port, DynVHostTarget::new(target))?;
|
||||||
|
self.passthrough_handles.mutate(|h| {
|
||||||
|
h.insert(
|
||||||
|
(hostname, port),
|
||||||
|
PassthroughHandle {
|
||||||
|
_rc: rc,
|
||||||
|
backend,
|
||||||
|
public,
|
||||||
|
private,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_passthrough(&self, hostname: &InternedString, port: u16) {
|
||||||
|
self.passthrough_handles
|
||||||
|
.mutate(|h| h.remove(&(hostname.clone(), port)));
|
||||||
|
self.gc(Some(hostname.clone()), port);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_passthrough(&self) -> Vec<PassthroughInfo> {
|
||||||
|
self.passthrough_handles.peek(|h| {
|
||||||
|
h.iter()
|
||||||
|
.map(|((hostname, port), handle)| PassthroughInfo {
|
||||||
|
hostname: hostname.clone(),
|
||||||
|
listen_port: *port,
|
||||||
|
backend: handle.backend,
|
||||||
|
public_gateways: handle.public.clone(),
|
||||||
|
private_ips: handle.private.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn dump_table(
|
pub fn dump_table(
|
||||||
&self,
|
&self,
|
||||||
) -> BTreeMap<JsonKey<u16>, BTreeMap<JsonKey<Option<InternedString>>, EqSet<String>>> {
|
) -> BTreeMap<JsonKey<u16>, BTreeMap<JsonKey<Option<InternedString>>, EqSet<String>>> {
|
||||||
@@ -330,6 +547,9 @@ pub trait VHostTarget<A: Accept>: std::fmt::Debug + Eq {
|
|||||||
fn bind_requirements(&self) -> (BTreeSet<GatewayId>, BTreeSet<IpAddr>) {
|
fn bind_requirements(&self) -> (BTreeSet<GatewayId>, BTreeSet<IpAddr>) {
|
||||||
(BTreeSet::new(), BTreeSet::new())
|
(BTreeSet::new(), BTreeSet::new())
|
||||||
}
|
}
|
||||||
|
fn is_passthrough(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
fn preprocess<'a>(
|
fn preprocess<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
prev: ServerConfig,
|
prev: ServerConfig,
|
||||||
@@ -349,6 +569,7 @@ pub trait DynVHostTargetT<A: Accept>: std::fmt::Debug + Any {
|
|||||||
fn filter(&self, metadata: &<A as Accept>::Metadata) -> bool;
|
fn filter(&self, metadata: &<A as Accept>::Metadata) -> bool;
|
||||||
fn acme(&self) -> Option<&AcmeProvider>;
|
fn acme(&self) -> Option<&AcmeProvider>;
|
||||||
fn bind_requirements(&self) -> (BTreeSet<GatewayId>, BTreeSet<IpAddr>);
|
fn bind_requirements(&self) -> (BTreeSet<GatewayId>, BTreeSet<IpAddr>);
|
||||||
|
fn is_passthrough(&self) -> bool;
|
||||||
fn preprocess<'a>(
|
fn preprocess<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
prev: ServerConfig,
|
prev: ServerConfig,
|
||||||
@@ -373,6 +594,9 @@ impl<A: Accept, T: VHostTarget<A> + 'static> DynVHostTargetT<A> for T {
|
|||||||
fn acme(&self) -> Option<&AcmeProvider> {
|
fn acme(&self) -> Option<&AcmeProvider> {
|
||||||
VHostTarget::acme(self)
|
VHostTarget::acme(self)
|
||||||
}
|
}
|
||||||
|
fn is_passthrough(&self) -> bool {
|
||||||
|
VHostTarget::is_passthrough(self)
|
||||||
|
}
|
||||||
fn bind_requirements(&self) -> (BTreeSet<GatewayId>, BTreeSet<IpAddr>) {
|
fn bind_requirements(&self) -> (BTreeSet<GatewayId>, BTreeSet<IpAddr>) {
|
||||||
VHostTarget::bind_requirements(self)
|
VHostTarget::bind_requirements(self)
|
||||||
}
|
}
|
||||||
@@ -459,6 +683,7 @@ pub struct ProxyTarget {
|
|||||||
pub addr: SocketAddr,
|
pub addr: SocketAddr,
|
||||||
pub add_x_forwarded_headers: bool,
|
pub add_x_forwarded_headers: bool,
|
||||||
pub connect_ssl: Result<Arc<ClientConfig>, AlpnInfo>, // Ok: yes, connect using ssl, pass through alpn; Err: connect tcp, use provided strategy for alpn
|
pub connect_ssl: Result<Arc<ClientConfig>, AlpnInfo>, // Ok: yes, connect using ssl, pass through alpn; Err: connect tcp, use provided strategy for alpn
|
||||||
|
pub passthrough: bool,
|
||||||
}
|
}
|
||||||
impl PartialEq for ProxyTarget {
|
impl PartialEq for ProxyTarget {
|
||||||
fn eq(&self, other: &Self) -> bool {
|
fn eq(&self, other: &Self) -> bool {
|
||||||
@@ -466,6 +691,7 @@ impl PartialEq for ProxyTarget {
|
|||||||
&& self.private == other.private
|
&& self.private == other.private
|
||||||
&& self.acme == other.acme
|
&& self.acme == other.acme
|
||||||
&& self.addr == other.addr
|
&& self.addr == other.addr
|
||||||
|
&& self.passthrough == other.passthrough
|
||||||
&& self.connect_ssl.as_ref().map(Arc::as_ptr)
|
&& self.connect_ssl.as_ref().map(Arc::as_ptr)
|
||||||
== other.connect_ssl.as_ref().map(Arc::as_ptr)
|
== other.connect_ssl.as_ref().map(Arc::as_ptr)
|
||||||
}
|
}
|
||||||
@@ -480,6 +706,7 @@ impl fmt::Debug for ProxyTarget {
|
|||||||
.field("addr", &self.addr)
|
.field("addr", &self.addr)
|
||||||
.field("add_x_forwarded_headers", &self.add_x_forwarded_headers)
|
.field("add_x_forwarded_headers", &self.add_x_forwarded_headers)
|
||||||
.field("connect_ssl", &self.connect_ssl.as_ref().map(|_| ()))
|
.field("connect_ssl", &self.connect_ssl.as_ref().map(|_| ()))
|
||||||
|
.field("passthrough", &self.passthrough)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -524,6 +751,9 @@ where
|
|||||||
fn bind_requirements(&self) -> (BTreeSet<GatewayId>, BTreeSet<IpAddr>) {
|
fn bind_requirements(&self) -> (BTreeSet<GatewayId>, BTreeSet<IpAddr>) {
|
||||||
(self.public.clone(), self.private.clone())
|
(self.public.clone(), self.private.clone())
|
||||||
}
|
}
|
||||||
|
fn is_passthrough(&self) -> bool {
|
||||||
|
self.passthrough
|
||||||
|
}
|
||||||
async fn preprocess<'a>(
|
async fn preprocess<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
mut prev: ServerConfig,
|
mut prev: ServerConfig,
|
||||||
@@ -677,7 +907,7 @@ where
|
|||||||
prev: ServerConfig,
|
prev: ServerConfig,
|
||||||
hello: &'a ClientHello<'a>,
|
hello: &'a ClientHello<'a>,
|
||||||
metadata: &'a <A as Accept>::Metadata,
|
metadata: &'a <A as Accept>::Metadata,
|
||||||
) -> Option<ServerConfig>
|
) -> Option<TlsHandlerAction>
|
||||||
where
|
where
|
||||||
Self: 'a,
|
Self: 'a,
|
||||||
{
|
{
|
||||||
@@ -687,7 +917,7 @@ where
|
|||||||
.flatten()
|
.flatten()
|
||||||
.any(|a| a == ACME_TLS_ALPN_NAME)
|
.any(|a| a == ACME_TLS_ALPN_NAME)
|
||||||
{
|
{
|
||||||
return Some(prev);
|
return Some(TlsHandlerAction::Tls(prev));
|
||||||
}
|
}
|
||||||
|
|
||||||
let (target, rc) = self.0.peek(|m| {
|
let (target, rc) = self.0.peek(|m| {
|
||||||
@@ -700,11 +930,16 @@ where
|
|||||||
.map(|(t, rc)| (t.clone(), rc.clone()))
|
.map(|(t, rc)| (t.clone(), rc.clone()))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
let is_pt = target.0.is_passthrough();
|
||||||
let (prev, store) = target.into_preprocessed(rc, prev, hello, metadata).await?;
|
let (prev, store) = target.into_preprocessed(rc, prev, hello, metadata).await?;
|
||||||
|
|
||||||
self.1 = Some(store);
|
self.1 = Some(store);
|
||||||
|
|
||||||
Some(prev)
|
if is_pt {
|
||||||
|
Some(TlsHandlerAction::Passthrough)
|
||||||
|
} else {
|
||||||
|
Some(TlsHandlerAction::Tls(prev))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,63 @@ use crate::util::serde::IoFormat;
|
|||||||
mod gpt;
|
mod gpt;
|
||||||
mod mbr;
|
mod mbr;
|
||||||
|
|
||||||
|
/// Get the EFI BootCurrent entry number (the entry firmware used to boot).
|
||||||
|
/// Returns None on non-EFI systems or if BootCurrent is not set.
|
||||||
|
async fn get_efi_boot_current() -> Result<Option<String>, Error> {
|
||||||
|
let efi_output = String::from_utf8(
|
||||||
|
Command::new("efibootmgr")
|
||||||
|
.invoke(ErrorKind::Grub)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
.map_err(|e| Error::new(eyre!("efibootmgr output not valid UTF-8: {e}"), ErrorKind::Grub))?;
|
||||||
|
|
||||||
|
Ok(efi_output
|
||||||
|
.lines()
|
||||||
|
.find(|line| line.starts_with("BootCurrent:"))
|
||||||
|
.and_then(|line| line.strip_prefix("BootCurrent:"))
|
||||||
|
.map(|s| s.trim().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Promote a specific boot entry to first in the EFI boot order.
|
||||||
|
async fn promote_efi_entry(entry: &str) -> Result<(), Error> {
|
||||||
|
let efi_output = String::from_utf8(
|
||||||
|
Command::new("efibootmgr")
|
||||||
|
.invoke(ErrorKind::Grub)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
.map_err(|e| Error::new(eyre!("efibootmgr output not valid UTF-8: {e}"), ErrorKind::Grub))?;
|
||||||
|
|
||||||
|
let current_order = efi_output
|
||||||
|
.lines()
|
||||||
|
.find(|line| line.starts_with("BootOrder:"))
|
||||||
|
.and_then(|line| line.strip_prefix("BootOrder:"))
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
if current_order.is_empty() || current_order.starts_with(entry) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let other_entries: Vec<&str> = current_order
|
||||||
|
.split(',')
|
||||||
|
.filter(|e| e.trim() != entry)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let new_order = if other_entries.is_empty() {
|
||||||
|
entry.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{},{}", entry, other_entries.join(","))
|
||||||
|
};
|
||||||
|
|
||||||
|
Command::new("efibootmgr")
|
||||||
|
.arg("-o")
|
||||||
|
.arg(&new_order)
|
||||||
|
.invoke(ErrorKind::Grub)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Probe a squashfs image to determine its target architecture
|
/// Probe a squashfs image to determine its target architecture
|
||||||
async fn probe_squashfs_arch(squashfs_path: &Path) -> Result<InternedString, Error> {
|
async fn probe_squashfs_arch(squashfs_path: &Path) -> Result<InternedString, Error> {
|
||||||
let output = String::from_utf8(
|
let output = String::from_utf8(
|
||||||
@@ -359,7 +416,6 @@ pub async fn install_os_to(
|
|||||||
"riscv64" => install.arg("--target=riscv64-efi"),
|
"riscv64" => install.arg("--target=riscv64-efi"),
|
||||||
_ => &mut install,
|
_ => &mut install,
|
||||||
};
|
};
|
||||||
install.arg("--no-nvram");
|
|
||||||
}
|
}
|
||||||
install
|
install
|
||||||
.arg(disk_path)
|
.arg(disk_path)
|
||||||
@@ -429,6 +485,21 @@ pub async fn install_os(
|
|||||||
});
|
});
|
||||||
|
|
||||||
let use_efi = tokio::fs::metadata("/sys/firmware/efi").await.is_ok();
|
let use_efi = tokio::fs::metadata("/sys/firmware/efi").await.is_ok();
|
||||||
|
|
||||||
|
// Save the boot entry we booted from (the USB installer) before grub-install
|
||||||
|
// overwrites the boot order.
|
||||||
|
let boot_current = if use_efi {
|
||||||
|
match get_efi_boot_current().await {
|
||||||
|
Ok(entry) => entry,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to get EFI BootCurrent: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let InstallOsResult { part_info, rootfs } = install_os_to(
|
let InstallOsResult { part_info, rootfs } = install_os_to(
|
||||||
"/run/live/medium/live/filesystem.squashfs",
|
"/run/live/medium/live/filesystem.squashfs",
|
||||||
&disk.logicalname,
|
&disk.logicalname,
|
||||||
@@ -440,6 +511,20 @@ pub async fn install_os(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// grub-install prepends its new entry to the EFI boot order, overriding the
|
||||||
|
// USB-first priority. Promote the USB entry (identified by BootCurrent from
|
||||||
|
// when we booted the installer) back to first, and persist the entry number
|
||||||
|
// so the upgrade script can do the same.
|
||||||
|
if let Some(ref entry) = boot_current {
|
||||||
|
if let Err(e) = promote_efi_entry(entry).await {
|
||||||
|
tracing::warn!("Failed to restore EFI boot order: {e}");
|
||||||
|
}
|
||||||
|
let efi_entry_path = rootfs.path().join("config/efi-installer-entry");
|
||||||
|
if let Err(e) = tokio::fs::write(&efi_entry_path, entry).await {
|
||||||
|
tracing::warn!("Failed to save EFI installer entry number: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx.config
|
ctx.config
|
||||||
.mutate(|c| c.os_partitions = Some(part_info.clone()));
|
.mutate(|c| c.os_partitions = Some(part_info.clone()));
|
||||||
|
|
||||||
|
|||||||
@@ -1238,19 +1238,13 @@ pub async fn test_smtp(
|
|||||||
.body("This is a test email sent from your StartOS Server".to_owned())?;
|
.body("This is a test email sent from your StartOS Server".to_owned())?;
|
||||||
|
|
||||||
let transport = match security {
|
let transport = match security {
|
||||||
SmtpSecurity::Starttls => AsyncSmtpTransport::<Tokio1Executor>::relay(&host)?
|
SmtpSecurity::Starttls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&host)?,
|
||||||
.port(port)
|
SmtpSecurity::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&host)?,
|
||||||
.credentials(creds)
|
}
|
||||||
.build(),
|
.port(port)
|
||||||
SmtpSecurity::Tls => {
|
.tls(Tls::Wrapper(TlsParameters::new(host.clone())?))
|
||||||
let tls = TlsParameters::new(host.clone())?;
|
.credentials(creds)
|
||||||
AsyncSmtpTransport::<Tokio1Executor>::relay(&host)?
|
.build();
|
||||||
.port(port)
|
|
||||||
.tls(Tls::Wrapper(tls))
|
|
||||||
.credentials(creds)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
transport.send(message).await?;
|
transport.send(message).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use ts_rs::TS;
|
|||||||
use crate::context::CliContext;
|
use crate::context::CliContext;
|
||||||
use crate::hostname::ServerHostname;
|
use crate::hostname::ServerHostname;
|
||||||
use crate::net::ssl::{SANInfo, root_ca_start_time};
|
use crate::net::ssl::{SANInfo, root_ca_start_time};
|
||||||
use crate::net::tls::TlsHandler;
|
use crate::net::tls::{TlsHandler, TlsHandlerAction};
|
||||||
use crate::net::web_server::Accept;
|
use crate::net::web_server::Accept;
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use crate::tunnel::auth::SetPasswordParams;
|
use crate::tunnel::auth::SetPasswordParams;
|
||||||
@@ -59,7 +59,7 @@ where
|
|||||||
&'a mut self,
|
&'a mut self,
|
||||||
_: &'a ClientHello<'a>,
|
_: &'a ClientHello<'a>,
|
||||||
_: &'a <A as Accept>::Metadata,
|
_: &'a <A as Accept>::Metadata,
|
||||||
) -> Option<ServerConfig> {
|
) -> Option<TlsHandlerAction> {
|
||||||
let cert_info = self
|
let cert_info = self
|
||||||
.db
|
.db
|
||||||
.peek()
|
.peek()
|
||||||
@@ -88,7 +88,7 @@ where
|
|||||||
.log_err()?;
|
.log_err()?;
|
||||||
cfg.alpn_protocols
|
cfg.alpn_protocols
|
||||||
.extend([b"http/1.1".into(), b"h2".into()]);
|
.extend([b"http/1.1".into(), b"h2".into()]);
|
||||||
Some(cfg)
|
Some(TlsHandlerAction::Tls(cfg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,57 @@
|
|||||||
import { SmtpValue } from '../../types'
|
|
||||||
import { GetSystemSmtp, Patterns } from '../../util'
|
import { GetSystemSmtp, Patterns } from '../../util'
|
||||||
import { InputSpec, InputSpecOf } from './builder/inputSpec'
|
import { InputSpec } from './builder/inputSpec'
|
||||||
import { Value } from './builder/value'
|
import { Value } from './builder/value'
|
||||||
import { Variants } from './builder/variants'
|
import { Variants } from './builder/variants'
|
||||||
|
|
||||||
|
const securityVariants = Variants.of({
|
||||||
|
tls: {
|
||||||
|
name: 'TLS',
|
||||||
|
spec: InputSpec.of({
|
||||||
|
port: Value.dynamicText(async () => ({
|
||||||
|
name: 'Port',
|
||||||
|
required: true,
|
||||||
|
default: '465',
|
||||||
|
disabled: 'Fixed for TLS',
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
starttls: {
|
||||||
|
name: 'STARTTLS',
|
||||||
|
spec: InputSpec.of({
|
||||||
|
port: Value.select({
|
||||||
|
name: 'Port',
|
||||||
|
default: '587',
|
||||||
|
values: { '25': '25', '587': '587', '2525': '2525' },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an SMTP field spec with provider-specific defaults pre-filled.
|
* Creates an SMTP field spec with provider-specific defaults pre-filled.
|
||||||
*/
|
*/
|
||||||
function smtpFields(
|
function smtpFields(
|
||||||
defaults: {
|
defaults: {
|
||||||
host?: string
|
host?: string
|
||||||
port?: number
|
|
||||||
security?: 'starttls' | 'tls'
|
security?: 'starttls' | 'tls'
|
||||||
|
hostDisabled?: boolean
|
||||||
} = {},
|
} = {},
|
||||||
): InputSpec<SmtpValue> {
|
) {
|
||||||
return InputSpec.of<InputSpecOf<SmtpValue>>({
|
const hostSpec = Value.text({
|
||||||
host: Value.text({
|
name: 'Host',
|
||||||
name: 'Host',
|
required: true,
|
||||||
required: true,
|
default: defaults.host ?? null,
|
||||||
default: defaults.host ?? null,
|
placeholder: 'smtp.example.com',
|
||||||
placeholder: 'smtp.example.com',
|
})
|
||||||
}),
|
|
||||||
port: Value.number({
|
return InputSpec.of({
|
||||||
name: 'Port',
|
host: defaults.hostDisabled
|
||||||
required: true,
|
? hostSpec.withDisabled('Fixed for this provider')
|
||||||
default: defaults.port ?? 587,
|
: hostSpec,
|
||||||
min: 1,
|
security: Value.union({
|
||||||
max: 65535,
|
|
||||||
integer: true,
|
|
||||||
}),
|
|
||||||
security: Value.select({
|
|
||||||
name: 'Connection Security',
|
name: 'Connection Security',
|
||||||
default: defaults.security ?? 'starttls',
|
default: defaults.security ?? 'tls',
|
||||||
values: {
|
variants: securityVariants,
|
||||||
starttls: 'STARTTLS',
|
|
||||||
tls: 'TLS',
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
from: Value.text({
|
from: Value.text({
|
||||||
name: 'From Address',
|
name: 'From Address',
|
||||||
@@ -72,40 +88,39 @@ export const smtpProviderVariants = Variants.of({
|
|||||||
name: 'Gmail',
|
name: 'Gmail',
|
||||||
spec: smtpFields({
|
spec: smtpFields({
|
||||||
host: 'smtp.gmail.com',
|
host: 'smtp.gmail.com',
|
||||||
port: 587,
|
security: 'tls',
|
||||||
security: 'starttls',
|
hostDisabled: true,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
ses: {
|
ses: {
|
||||||
name: 'Amazon SES',
|
name: 'Amazon SES',
|
||||||
spec: smtpFields({
|
spec: smtpFields({
|
||||||
host: 'email-smtp.us-east-1.amazonaws.com',
|
host: 'email-smtp.us-east-1.amazonaws.com',
|
||||||
port: 587,
|
security: 'tls',
|
||||||
security: 'starttls',
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
sendgrid: {
|
sendgrid: {
|
||||||
name: 'SendGrid',
|
name: 'SendGrid',
|
||||||
spec: smtpFields({
|
spec: smtpFields({
|
||||||
host: 'smtp.sendgrid.net',
|
host: 'smtp.sendgrid.net',
|
||||||
port: 587,
|
security: 'tls',
|
||||||
security: 'starttls',
|
hostDisabled: true,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
mailgun: {
|
mailgun: {
|
||||||
name: 'Mailgun',
|
name: 'Mailgun',
|
||||||
spec: smtpFields({
|
spec: smtpFields({
|
||||||
host: 'smtp.mailgun.org',
|
host: 'smtp.mailgun.org',
|
||||||
port: 587,
|
security: 'tls',
|
||||||
security: 'starttls',
|
hostDisabled: true,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
protonmail: {
|
protonmail: {
|
||||||
name: 'Proton Mail',
|
name: 'Proton Mail',
|
||||||
spec: smtpFields({
|
spec: smtpFields({
|
||||||
host: 'smtp.protonmail.ch',
|
host: 'smtp.protonmail.ch',
|
||||||
port: 587,
|
security: 'tls',
|
||||||
security: 'starttls',
|
hostDisabled: true,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
other: {
|
other: {
|
||||||
@@ -121,7 +136,7 @@ export const smtpProviderVariants = Variants.of({
|
|||||||
export const systemSmtpSpec = InputSpec.of({
|
export const systemSmtpSpec = InputSpec.of({
|
||||||
provider: Value.union({
|
provider: Value.union({
|
||||||
name: 'Provider',
|
name: 'Provider',
|
||||||
default: null as any,
|
default: 'gmail',
|
||||||
variants: smtpProviderVariants,
|
variants: smtpProviderVariants,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,28 +14,34 @@ export const knownProtocols = {
|
|||||||
defaultPort: 80,
|
defaultPort: 80,
|
||||||
withSsl: 'https',
|
withSsl: 'https',
|
||||||
alpn: { specified: ['http/1.1'] } as AlpnInfo,
|
alpn: { specified: ['http/1.1'] } as AlpnInfo,
|
||||||
|
addXForwardedHeaders: true,
|
||||||
},
|
},
|
||||||
https: {
|
https: {
|
||||||
secure: { ssl: true },
|
secure: { ssl: true },
|
||||||
defaultPort: 443,
|
defaultPort: 443,
|
||||||
|
addXForwardedHeaders: true,
|
||||||
},
|
},
|
||||||
ws: {
|
ws: {
|
||||||
secure: null,
|
secure: null,
|
||||||
defaultPort: 80,
|
defaultPort: 80,
|
||||||
withSsl: 'wss',
|
withSsl: 'wss',
|
||||||
alpn: { specified: ['http/1.1'] } as AlpnInfo,
|
alpn: { specified: ['http/1.1'] } as AlpnInfo,
|
||||||
|
addXForwardedHeaders: true,
|
||||||
},
|
},
|
||||||
wss: {
|
wss: {
|
||||||
secure: { ssl: true },
|
secure: { ssl: true },
|
||||||
defaultPort: 443,
|
defaultPort: 443,
|
||||||
|
addXForwardedHeaders: true,
|
||||||
},
|
},
|
||||||
ssh: {
|
ssh: {
|
||||||
secure: { ssl: false },
|
secure: { ssl: false },
|
||||||
defaultPort: 22,
|
defaultPort: 22,
|
||||||
|
addXForwardedHeaders: false,
|
||||||
},
|
},
|
||||||
dns: {
|
dns: {
|
||||||
secure: { ssl: false },
|
secure: { ssl: false },
|
||||||
defaultPort: 53,
|
defaultPort: 53,
|
||||||
|
addXForwardedHeaders: false,
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -136,7 +142,7 @@ export class MultiHost {
|
|||||||
const sslProto = this.getSslProto(options)
|
const sslProto = this.getSslProto(options)
|
||||||
const addSsl = sslProto
|
const addSsl = sslProto
|
||||||
? {
|
? {
|
||||||
addXForwardedHeaders: false,
|
addXForwardedHeaders: knownProtocols[sslProto].addXForwardedHeaders,
|
||||||
preferredExternalPort: knownProtocols[sslProto].defaultPort,
|
preferredExternalPort: knownProtocols[sslProto].defaultPort,
|
||||||
scheme: sslProto,
|
scheme: sslProto,
|
||||||
alpn: 'alpn' in protoInfo ? protoInfo.alpn : null,
|
alpn: 'alpn' in protoInfo ? protoInfo.alpn : null,
|
||||||
@@ -148,7 +154,7 @@ export class MultiHost {
|
|||||||
preferredExternalPort: 443,
|
preferredExternalPort: 443,
|
||||||
scheme: sslProto,
|
scheme: sslProto,
|
||||||
alpn: null,
|
alpn: null,
|
||||||
...('addSsl' in options ? options.addSsl : null),
|
...options.addSsl,
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ export type AddPublicDomainParams = {
|
|||||||
fqdn: string
|
fqdn: string
|
||||||
acme: AcmeProvider | null
|
acme: AcmeProvider | null
|
||||||
gateway: GatewayId
|
gateway: GatewayId
|
||||||
|
internalPort: number
|
||||||
}
|
}
|
||||||
|
|||||||
4
sdk/base/lib/osBindings/AddPublicDomainRes.ts
Normal file
4
sdk/base/lib/osBindings/AddPublicDomainRes.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { CheckPortRes } from './CheckPortRes'
|
||||||
|
|
||||||
|
export type AddPublicDomainRes = { dns: string | null; port: CheckPortRes }
|
||||||
@@ -5,6 +5,7 @@ import type { DnsSettings } from './DnsSettings'
|
|||||||
import type { GatewayId } from './GatewayId'
|
import type { GatewayId } from './GatewayId'
|
||||||
import type { Host } from './Host'
|
import type { Host } from './Host'
|
||||||
import type { NetworkInterfaceInfo } from './NetworkInterfaceInfo'
|
import type { NetworkInterfaceInfo } from './NetworkInterfaceInfo'
|
||||||
|
import type { PassthroughInfo } from './PassthroughInfo'
|
||||||
import type { WifiInfo } from './WifiInfo'
|
import type { WifiInfo } from './WifiInfo'
|
||||||
|
|
||||||
export type NetworkInfo = {
|
export type NetworkInfo = {
|
||||||
@@ -14,4 +15,5 @@ export type NetworkInfo = {
|
|||||||
acme: { [key: AcmeProvider]: AcmeSettings }
|
acme: { [key: AcmeProvider]: AcmeSettings }
|
||||||
dns: DnsSettings
|
dns: DnsSettings
|
||||||
defaultOutbound: string | null
|
defaultOutbound: string | null
|
||||||
|
passthroughs: Array<PassthroughInfo>
|
||||||
}
|
}
|
||||||
|
|||||||
9
sdk/base/lib/osBindings/PassthroughInfo.ts
Normal file
9
sdk/base/lib/osBindings/PassthroughInfo.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type PassthroughInfo = {
|
||||||
|
hostname: string
|
||||||
|
listenPort: number
|
||||||
|
backend: string
|
||||||
|
publicGateways: string[]
|
||||||
|
privateIps: string[]
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export { AddPackageSignerParams } from './AddPackageSignerParams'
|
|||||||
export { AddPackageToCategoryParams } from './AddPackageToCategoryParams'
|
export { AddPackageToCategoryParams } from './AddPackageToCategoryParams'
|
||||||
export { AddPrivateDomainParams } from './AddPrivateDomainParams'
|
export { AddPrivateDomainParams } from './AddPrivateDomainParams'
|
||||||
export { AddPublicDomainParams } from './AddPublicDomainParams'
|
export { AddPublicDomainParams } from './AddPublicDomainParams'
|
||||||
|
export { AddPublicDomainRes } from './AddPublicDomainRes'
|
||||||
export { AddressInfo } from './AddressInfo'
|
export { AddressInfo } from './AddressInfo'
|
||||||
export { AddSslOptions } from './AddSslOptions'
|
export { AddSslOptions } from './AddSslOptions'
|
||||||
export { AddTunnelParams } from './AddTunnelParams'
|
export { AddTunnelParams } from './AddTunnelParams'
|
||||||
@@ -201,6 +202,7 @@ export { PackagePlugin } from './PackagePlugin'
|
|||||||
export { PackageState } from './PackageState'
|
export { PackageState } from './PackageState'
|
||||||
export { PackageVersionInfo } from './PackageVersionInfo'
|
export { PackageVersionInfo } from './PackageVersionInfo'
|
||||||
export { PartitionInfo } from './PartitionInfo'
|
export { PartitionInfo } from './PartitionInfo'
|
||||||
|
export { PassthroughInfo } from './PassthroughInfo'
|
||||||
export { PasswordType } from './PasswordType'
|
export { PasswordType } from './PasswordType'
|
||||||
export { PathOrUrl } from './PathOrUrl'
|
export { PathOrUrl } from './PathOrUrl'
|
||||||
export { Pem } from './Pem'
|
export { Pem } from './Pem'
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
export * as inputSpecTypes from './actions/input/inputSpecTypes'
|
export * as inputSpecTypes from './actions/input/inputSpecTypes'
|
||||||
|
export {
|
||||||
|
CurrentDependenciesResult,
|
||||||
|
OptionalDependenciesOf as OptionalDependencies,
|
||||||
|
RequiredDependenciesOf as RequiredDependencies,
|
||||||
|
} from './dependencies/setupDependencies'
|
||||||
|
export * from './osBindings'
|
||||||
|
export { SDKManifest } from './types/ManifestTypes'
|
||||||
|
export { Effects }
|
||||||
import { InputSpec as InputSpecClass } from './actions/input/builder/inputSpec'
|
import { InputSpec as InputSpecClass } from './actions/input/builder/inputSpec'
|
||||||
|
|
||||||
import {
|
|
||||||
DependencyRequirement,
|
|
||||||
NamedHealthCheckResult,
|
|
||||||
Manifest,
|
|
||||||
ServiceInterface,
|
|
||||||
ActionId,
|
|
||||||
} from './osBindings'
|
|
||||||
import { Affine, StringObject, ToKebab } from './util'
|
|
||||||
import { Action, Actions } from './actions/setupActions'
|
import { Action, Actions } from './actions/setupActions'
|
||||||
import { Effects } from './Effects'
|
import { Effects } from './Effects'
|
||||||
import { ExtendedVersion, VersionRange } from './exver'
|
import { ExtendedVersion, VersionRange } from './exver'
|
||||||
export { Effects }
|
import {
|
||||||
export * from './osBindings'
|
ActionId,
|
||||||
export { SDKManifest } from './types/ManifestTypes'
|
DependencyRequirement,
|
||||||
export {
|
Manifest,
|
||||||
RequiredDependenciesOf as RequiredDependencies,
|
NamedHealthCheckResult,
|
||||||
OptionalDependenciesOf as OptionalDependencies,
|
ServiceInterface,
|
||||||
CurrentDependenciesResult,
|
} from './osBindings'
|
||||||
} from './dependencies/setupDependencies'
|
import { StringObject, ToKebab } from './util'
|
||||||
|
|
||||||
/** An object that can be built into a terminable daemon process. */
|
/** An object that can be built into a terminable daemon process. */
|
||||||
export type DaemonBuildable = {
|
export type DaemonBuildable = {
|
||||||
|
|||||||
@@ -26,6 +26,18 @@ export const getHostname = (url: string): Hostname | null => {
|
|||||||
return last
|
return last
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kinds of hostnames that can be filtered on.
|
||||||
|
*
|
||||||
|
* - `'mdns'` — mDNS / Bonjour `.local` hostnames
|
||||||
|
* - `'domain'` — any os-managed domain name (matches both `'private-domain'` and `'public-domain'` metadata kinds)
|
||||||
|
* - `'ip'` — shorthand for both `'ipv4'` and `'ipv6'`
|
||||||
|
* - `'ipv4'` — IPv4 addresses only
|
||||||
|
* - `'ipv6'` — IPv6 addresses only
|
||||||
|
* - `'localhost'` — loopback addresses (`localhost`, `127.0.0.1`, `::1`)
|
||||||
|
* - `'link-local'` — IPv6 link-local addresses (fe80::/10)
|
||||||
|
* - `'plugin'` — hostnames provided by a plugin package
|
||||||
|
*/
|
||||||
type FilterKinds =
|
type FilterKinds =
|
||||||
| 'mdns'
|
| 'mdns'
|
||||||
| 'domain'
|
| 'domain'
|
||||||
@@ -34,10 +46,25 @@ type FilterKinds =
|
|||||||
| 'ipv6'
|
| 'ipv6'
|
||||||
| 'localhost'
|
| 'localhost'
|
||||||
| 'link-local'
|
| 'link-local'
|
||||||
|
| 'plugin'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes which hostnames to include (or exclude) when filtering a `Filled` address.
|
||||||
|
*
|
||||||
|
* Every field is optional — omitted fields impose no constraint.
|
||||||
|
* Filters are composable: the `.filter()` method intersects successive filters,
|
||||||
|
* and the `exclude` field inverts a nested filter.
|
||||||
|
*/
|
||||||
export type Filter = {
|
export type Filter = {
|
||||||
|
/** Keep only hostnames with the given visibility. `'public'` = externally reachable, `'private'` = LAN-only. */
|
||||||
visibility?: 'public' | 'private'
|
visibility?: 'public' | 'private'
|
||||||
|
/** Keep only hostnames whose metadata kind matches. A single kind or array of kinds. `'ip'` expands to `['ipv4','ipv6']`, `'domain'` matches both `'private-domain'` and `'public-domain'`. */
|
||||||
kind?: FilterKinds | FilterKinds[]
|
kind?: FilterKinds | FilterKinds[]
|
||||||
|
/** Arbitrary predicate — hostnames for which this returns `false` are excluded. */
|
||||||
predicate?: (h: HostnameInfo) => boolean
|
predicate?: (h: HostnameInfo) => boolean
|
||||||
|
/** Keep only plugin hostnames provided by this package. Implies `kind: 'plugin'`. */
|
||||||
|
pluginId?: PackageId
|
||||||
|
/** A nested filter whose matches are *removed* from the result (logical NOT). */
|
||||||
exclude?: Filter
|
exclude?: Filter
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,9 +92,13 @@ type KindFilter<K extends FilterKinds> = K extends 'mdns'
|
|||||||
?
|
?
|
||||||
| (HostnameInfo & { metadata: { kind: 'ipv6' } })
|
| (HostnameInfo & { metadata: { kind: 'ipv6' } })
|
||||||
| KindFilter<Exclude<K, 'ipv6'>>
|
| KindFilter<Exclude<K, 'ipv6'>>
|
||||||
: K extends 'ip'
|
: K extends 'plugin'
|
||||||
? KindFilter<Exclude<K, 'ip'> | 'ipv4' | 'ipv6'>
|
?
|
||||||
: never
|
| (HostnameInfo & { metadata: { kind: 'plugin' } })
|
||||||
|
| KindFilter<Exclude<K, 'plugin'>>
|
||||||
|
: K extends 'ip'
|
||||||
|
? KindFilter<Exclude<K, 'ip'> | 'ipv4' | 'ipv6'>
|
||||||
|
: never
|
||||||
|
|
||||||
type FilterReturnTy<F extends Filter> = F extends {
|
type FilterReturnTy<F extends Filter> = F extends {
|
||||||
visibility: infer V extends 'public' | 'private'
|
visibility: infer V extends 'public' | 'private'
|
||||||
@@ -107,20 +138,62 @@ type FormatReturnTy<
|
|||||||
? UrlString | FormatReturnTy<F, Exclude<Format, 'urlstring'>>
|
? UrlString | FormatReturnTy<F, Exclude<Format, 'urlstring'>>
|
||||||
: never
|
: never
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A resolved address with its hostnames already populated, plus helpers
|
||||||
|
* for filtering, formatting, and converting hostnames to URLs.
|
||||||
|
*
|
||||||
|
* Filters are chainable and each call returns a new `Filled` narrowed to the
|
||||||
|
* matching subset of hostnames:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* addresses.nonLocal // exclude localhost & link-local
|
||||||
|
* addresses.public // only publicly-reachable hostnames
|
||||||
|
* addresses.filter({ kind: 'domain' }) // only domain-name hostnames
|
||||||
|
* addresses.filter({ visibility: 'private' }) // only LAN-reachable hostnames
|
||||||
|
* addresses.nonLocal.filter({ kind: 'ip' }) // chainable — non-local IPs only
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
export type Filled<F extends Filter = {}> = {
|
export type Filled<F extends Filter = {}> = {
|
||||||
|
/** The hostnames that survived all applied filters. */
|
||||||
hostnames: HostnameInfo[]
|
hostnames: HostnameInfo[]
|
||||||
|
|
||||||
|
/** Convert a single hostname into a fully-formed URL string, applying the address's scheme, username, and suffix. */
|
||||||
toUrl: (h: HostnameInfo) => UrlString
|
toUrl: (h: HostnameInfo) => UrlString
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return every hostname in the requested format.
|
||||||
|
*
|
||||||
|
* - `'urlstring'` (default) — formatted URL strings
|
||||||
|
* - `'url'` — `URL` objects
|
||||||
|
* - `'hostname-info'` — raw `HostnameInfo` objects
|
||||||
|
*/
|
||||||
format: <Format extends Formats = 'urlstring'>(
|
format: <Format extends Formats = 'urlstring'>(
|
||||||
format?: Format,
|
format?: Format,
|
||||||
) => FormatReturnTy<{}, Format>[]
|
) => FormatReturnTy<{}, Format>[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply an arbitrary {@link Filter} and return a new `Filled` containing only
|
||||||
|
* the hostnames that match. Filters compose: calling `.filter()` on an
|
||||||
|
* already-filtered `Filled` intersects the constraints.
|
||||||
|
*/
|
||||||
filter: <NewFilter extends Filter>(
|
filter: <NewFilter extends Filter>(
|
||||||
filter: NewFilter,
|
filter: NewFilter,
|
||||||
) => Filled<NewFilter & Filter>
|
) => Filled<NewFilter & Filter>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply multiple filters and return hostnames that match **any** of them (union / OR).
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* addresses.matchesAny([{ kind: 'domain' }, { kind: 'mdns' }])
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
matchesAny: <NewFilters extends Filter[]>(
|
||||||
|
filters: [...NewFilters],
|
||||||
|
) => Filled<NewFilters[number] & F>
|
||||||
|
|
||||||
|
/** Shorthand filter that excludes `localhost` and IPv6 link-local addresses — keeps only network-reachable hostnames. */
|
||||||
nonLocal: Filled<typeof nonLocalFilter & Filter>
|
nonLocal: Filled<typeof nonLocalFilter & Filter>
|
||||||
|
/** Shorthand filter that keeps only publicly-reachable hostnames (those with `public: true`). */
|
||||||
public: Filled<typeof publicFilter & Filter>
|
public: Filled<typeof publicFilter & Filter>
|
||||||
}
|
}
|
||||||
export type FilledAddressInfo = AddressInfo & Filled
|
export type FilledAddressInfo = AddressInfo & Filled
|
||||||
@@ -210,7 +283,16 @@ function filterRec(
|
|||||||
['localhost', '127.0.0.1', '::1'].includes(h.hostname)) ||
|
['localhost', '127.0.0.1', '::1'].includes(h.hostname)) ||
|
||||||
(kind.has('link-local') &&
|
(kind.has('link-local') &&
|
||||||
h.metadata.kind === 'ipv6' &&
|
h.metadata.kind === 'ipv6' &&
|
||||||
IPV6_LINK_LOCAL.contains(IpAddress.parse(h.hostname)))),
|
IPV6_LINK_LOCAL.contains(IpAddress.parse(h.hostname))) ||
|
||||||
|
(kind.has('plugin') && h.metadata.kind === 'plugin')),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (filter.pluginId) {
|
||||||
|
const id = filter.pluginId
|
||||||
|
hostnames = hostnames.filter(
|
||||||
|
(h) =>
|
||||||
|
invert !==
|
||||||
|
(h.metadata.kind === 'plugin' && h.metadata.packageId === id),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +324,14 @@ function enabledAddresses(addr: DerivedAddressInfo): HostnameInfo[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters out localhost and IPv6 link-local hostnames from a list.
|
||||||
|
* Equivalent to the `nonLocal` filter on `Filled` addresses.
|
||||||
|
*/
|
||||||
|
export function filterNonLocal(hostnames: HostnameInfo[]): HostnameInfo[] {
|
||||||
|
return filterRec(hostnames, nonLocalFilter, false)
|
||||||
|
}
|
||||||
|
|
||||||
export const filledAddress = (
|
export const filledAddress = (
|
||||||
host: Host,
|
host: Host,
|
||||||
addressInfo: AddressInfo,
|
addressInfo: AddressInfo,
|
||||||
@@ -280,6 +370,19 @@ export const filledAddress = (
|
|||||||
filterRec(hostnames, filter, false),
|
filterRec(hostnames, filter, false),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
matchesAny: <NewFilters extends Filter[]>(filters: [...NewFilters]) => {
|
||||||
|
const seen = new Set<HostnameInfo>()
|
||||||
|
const union: HostnameInfo[] = []
|
||||||
|
for (const f of filters) {
|
||||||
|
for (const h of filterRec(hostnames, f, false)) {
|
||||||
|
if (!seen.has(h)) {
|
||||||
|
seen.add(h)
|
||||||
|
union.push(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filledAddressFromHostnames<NewFilters[number] & F>(union)
|
||||||
|
},
|
||||||
get nonLocal(): Filled<typeof nonLocalFilter & F> {
|
get nonLocal(): Filled<typeof nonLocalFilter & F> {
|
||||||
return getNonLocal()
|
return getNonLocal()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export {
|
|||||||
GetServiceInterface,
|
GetServiceInterface,
|
||||||
getServiceInterface,
|
getServiceInterface,
|
||||||
filledAddress,
|
filledAddress,
|
||||||
|
filterNonLocal,
|
||||||
} from './getServiceInterface'
|
} from './getServiceInterface'
|
||||||
export { getServiceInterfaces } from './getServiceInterfaces'
|
export { getServiceInterfaces } from './getServiceInterfaces'
|
||||||
export { once } from './once'
|
export { once } from './once'
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
|||||||
| 'getSystemSmtp'
|
| 'getSystemSmtp'
|
||||||
| 'getOutboundGateway'
|
| 'getOutboundGateway'
|
||||||
| 'getContainerIp'
|
| 'getContainerIp'
|
||||||
|
| 'getStatus'
|
||||||
| 'getDataVersion'
|
| 'getDataVersion'
|
||||||
| 'setDataVersion'
|
| 'setDataVersion'
|
||||||
| 'getServiceManifest'
|
| 'getServiceManifest'
|
||||||
@@ -164,7 +165,6 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
|||||||
getSslKey: (effects, ...args) => effects.getSslKey(...args),
|
getSslKey: (effects, ...args) => effects.getSslKey(...args),
|
||||||
shutdown: (effects, ...args) => effects.shutdown(...args),
|
shutdown: (effects, ...args) => effects.shutdown(...args),
|
||||||
getDependencies: (effects, ...args) => effects.getDependencies(...args),
|
getDependencies: (effects, ...args) => effects.getDependencies(...args),
|
||||||
getStatus: (effects, ...args) => effects.getStatus(...args),
|
|
||||||
setHealth: (effects, ...args) => effects.setHealth(...args),
|
setHealth: (effects, ...args) => effects.setHealth(...args),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,6 +342,104 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the service's current status with reactive subscription support.
|
||||||
|
*
|
||||||
|
* Returns an object with multiple read strategies: `const()` for a value
|
||||||
|
* that retries on change, `once()` for a single read, `watch()` for an async
|
||||||
|
* generator, `onChange()` for a callback, and `waitFor()` to block until a predicate is met.
|
||||||
|
*
|
||||||
|
* @param effects - The effects context
|
||||||
|
* @param options - Optional filtering options (e.g. `packageId`)
|
||||||
|
*/
|
||||||
|
getStatus: (
|
||||||
|
effects: T.Effects,
|
||||||
|
options: Omit<Parameters<T.Effects['getStatus']>[0], 'callback'> = {},
|
||||||
|
) => {
|
||||||
|
async function* watch(abort?: AbortSignal) {
|
||||||
|
const resolveCell = { resolve: () => {} }
|
||||||
|
effects.onLeaveContext(() => {
|
||||||
|
resolveCell.resolve()
|
||||||
|
})
|
||||||
|
abort?.addEventListener('abort', () => resolveCell.resolve())
|
||||||
|
while (effects.isInContext && !abort?.aborted) {
|
||||||
|
let callback: () => void = () => {}
|
||||||
|
const waitForNext = new Promise<void>((resolve) => {
|
||||||
|
callback = resolve
|
||||||
|
resolveCell.resolve = resolve
|
||||||
|
})
|
||||||
|
yield await effects.getStatus({ ...options, callback })
|
||||||
|
await waitForNext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
const: () =>
|
||||||
|
effects.getStatus({
|
||||||
|
...options,
|
||||||
|
callback:
|
||||||
|
effects.constRetry &&
|
||||||
|
(() => effects.constRetry && effects.constRetry()),
|
||||||
|
}),
|
||||||
|
once: () => effects.getStatus(options),
|
||||||
|
watch: (abort?: AbortSignal) => {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
abort?.addEventListener('abort', () => ctrl.abort())
|
||||||
|
return DropGenerator.of(watch(ctrl.signal), () => ctrl.abort())
|
||||||
|
},
|
||||||
|
onChange: (
|
||||||
|
callback: (
|
||||||
|
value: T.StatusInfo | null,
|
||||||
|
error?: Error,
|
||||||
|
) => { cancel: boolean } | Promise<{ cancel: boolean }>,
|
||||||
|
) => {
|
||||||
|
;(async () => {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
for await (const value of watch(ctrl.signal)) {
|
||||||
|
try {
|
||||||
|
const res = await callback(value)
|
||||||
|
if (res.cancel) {
|
||||||
|
ctrl.abort()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
'callback function threw an error @ getStatus.onChange',
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.catch((e) => callback(null, e))
|
||||||
|
.catch((e) =>
|
||||||
|
console.error(
|
||||||
|
'callback function threw an error @ getStatus.onChange',
|
||||||
|
e,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
waitFor: async (pred: (value: T.StatusInfo | null) => boolean) => {
|
||||||
|
const resolveCell = { resolve: () => {} }
|
||||||
|
effects.onLeaveContext(() => {
|
||||||
|
resolveCell.resolve()
|
||||||
|
})
|
||||||
|
while (effects.isInContext) {
|
||||||
|
let callback: () => void = () => {}
|
||||||
|
const waitForNext = new Promise<void>((resolve) => {
|
||||||
|
callback = resolve
|
||||||
|
resolveCell.resolve = resolve
|
||||||
|
})
|
||||||
|
const res = await effects.getStatus({ ...options, callback })
|
||||||
|
if (pred(res)) {
|
||||||
|
resolveCell.resolve()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
await waitForNext
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
MultiHost: {
|
MultiHost: {
|
||||||
/**
|
/**
|
||||||
* Create a new MultiHost instance for binding ports and exporting interfaces.
|
* Create a new MultiHost instance for binding ports and exporting interfaces.
|
||||||
|
|||||||
4
sdk/package/package-lock.json
generated
4
sdk/package/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@start9labs/start-sdk",
|
"name": "@start9labs/start-sdk",
|
||||||
"version": "0.4.0-beta.55",
|
"version": "0.4.0-beta.58",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@start9labs/start-sdk",
|
"name": "@start9labs/start-sdk",
|
||||||
"version": "0.4.0-beta.55",
|
"version": "0.4.0-beta.58",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@iarna/toml": "^3.0.0",
|
"@iarna/toml": "^3.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@start9labs/start-sdk",
|
"name": "@start9labs/start-sdk",
|
||||||
"version": "0.4.0-beta.55",
|
"version": "0.4.0-beta.58",
|
||||||
"description": "Software development kit to facilitate packaging services for StartOS",
|
"description": "Software development kit to facilitate packaging services for StartOS",
|
||||||
"main": "./package/lib/index.js",
|
"main": "./package/lib/index.js",
|
||||||
"types": "./package/lib/index.d.ts",
|
"types": "./package/lib/index.d.ts",
|
||||||
|
|||||||
@@ -34,110 +34,121 @@ import { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog
|
|||||||
@Component({
|
@Component({
|
||||||
template: `
|
template: `
|
||||||
@if (!shuttingDown) {
|
@if (!shuttingDown) {
|
||||||
<section tuiCardLarge="compact">
|
<section tuiCardLarge="compact">
|
||||||
<header tuiHeader>
|
<header tuiHeader>
|
||||||
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
|
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@if (loading) {
|
@if (loading) {
|
||||||
<tui-loader />
|
<tui-loader />
|
||||||
} @else if (drives.length === 0) {
|
} @else if (drives.length === 0) {
|
||||||
<p class="no-drives">
|
<p class="no-drives">
|
||||||
{{
|
{{
|
||||||
'No drives found. Please connect a drive and click Refresh.' | i18n
|
'No drives found. Please connect a drive and click Refresh.'
|
||||||
}}
|
| i18n
|
||||||
</p>
|
}}
|
||||||
} @else {
|
</p>
|
||||||
<tui-textfield [stringify]="stringify">
|
|
||||||
<label tuiLabel>{{ 'OS Drive' | i18n }}</label>
|
|
||||||
@if (mobile) {
|
|
||||||
<select
|
|
||||||
tuiSelect
|
|
||||||
[(ngModel)]="selectedOsDrive"
|
|
||||||
[items]="drives"
|
|
||||||
></select>
|
|
||||||
} @else {
|
|
||||||
<input tuiSelect [(ngModel)]="selectedOsDrive" />
|
|
||||||
}
|
|
||||||
@if (!mobile) {
|
|
||||||
<tui-data-list-wrapper
|
|
||||||
new
|
|
||||||
*tuiTextfieldDropdown
|
|
||||||
[items]="drives"
|
|
||||||
[itemContent]="driveContent"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
<tui-icon [tuiTooltip]="osDriveTooltip" />
|
|
||||||
</tui-textfield>
|
|
||||||
|
|
||||||
<tui-textfield [stringify]="stringify">
|
|
||||||
<label tuiLabel>{{ 'Data Drive' | i18n }}</label>
|
|
||||||
@if (mobile) {
|
|
||||||
<select
|
|
||||||
tuiSelect
|
|
||||||
[(ngModel)]="selectedDataDrive"
|
|
||||||
(ngModelChange)="onDataDriveChange($event)"
|
|
||||||
[items]="drives"
|
|
||||||
></select>
|
|
||||||
} @else {
|
|
||||||
<input
|
|
||||||
tuiSelect
|
|
||||||
[(ngModel)]="selectedDataDrive"
|
|
||||||
(ngModelChange)="onDataDriveChange($event)"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
@if (!mobile) {
|
|
||||||
<tui-data-list-wrapper
|
|
||||||
new
|
|
||||||
*tuiTextfieldDropdown
|
|
||||||
[items]="drives"
|
|
||||||
[itemContent]="driveContent"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
@if (preserveData === true) {
|
|
||||||
<tui-icon
|
|
||||||
icon="@tui.database"
|
|
||||||
style="color: var(--tui-status-positive); pointer-events: none"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
@if (preserveData === false) {
|
|
||||||
<tui-icon
|
|
||||||
icon="@tui.database-zap"
|
|
||||||
style="color: var(--tui-status-negative); pointer-events: none"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
<tui-icon [tuiTooltip]="dataDriveTooltip" />
|
|
||||||
</tui-textfield>
|
|
||||||
|
|
||||||
<ng-template #driveContent let-drive>
|
|
||||||
<div class="drive-item">
|
|
||||||
<span class="drive-name">
|
|
||||||
{{ drive.vendor || ('Unknown' | i18n) }}
|
|
||||||
{{ drive.model || ('Drive' | i18n) }}
|
|
||||||
</span>
|
|
||||||
<small>
|
|
||||||
{{ formatCapacity(drive.capacity) }} · {{ drive.logicalname }}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
}
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
@if (drives.length === 0) {
|
|
||||||
<button tuiButton appearance="secondary" (click)="refresh()">
|
|
||||||
{{ 'Refresh' | i18n }}
|
|
||||||
</button>
|
|
||||||
} @else {
|
} @else {
|
||||||
<button
|
<tui-textfield
|
||||||
tuiButton
|
[stringify]="stringify"
|
||||||
[disabled]="!selectedOsDrive || !selectedDataDrive"
|
[disabledItemHandler]="osDisabled"
|
||||||
(click)="continue()"
|
|
||||||
>
|
>
|
||||||
{{ 'Continue' | i18n }}
|
<label tuiLabel>{{ 'OS Drive' | i18n }}</label>
|
||||||
</button>
|
@if (mobile) {
|
||||||
|
<select
|
||||||
|
tuiSelect
|
||||||
|
[ngModel]="selectedOsDrive"
|
||||||
|
(ngModelChange)="onOsDriveChange($event)"
|
||||||
|
[items]="drives"
|
||||||
|
></select>
|
||||||
|
} @else {
|
||||||
|
<input
|
||||||
|
tuiSelect
|
||||||
|
[ngModel]="selectedOsDrive"
|
||||||
|
(ngModelChange)="onOsDriveChange($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@if (!mobile) {
|
||||||
|
<tui-data-list-wrapper
|
||||||
|
new
|
||||||
|
*tuiTextfieldDropdown
|
||||||
|
[items]="drives"
|
||||||
|
[itemContent]="driveContent"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<tui-icon [tuiTooltip]="osDriveTooltip" />
|
||||||
|
</tui-textfield>
|
||||||
|
|
||||||
|
<tui-textfield
|
||||||
|
[stringify]="stringify"
|
||||||
|
[disabledItemHandler]="dataDisabled"
|
||||||
|
>
|
||||||
|
<label tuiLabel>{{ 'Data Drive' | i18n }}</label>
|
||||||
|
@if (mobile) {
|
||||||
|
<select
|
||||||
|
tuiSelect
|
||||||
|
[(ngModel)]="selectedDataDrive"
|
||||||
|
(ngModelChange)="onDataDriveChange($event)"
|
||||||
|
[items]="drives"
|
||||||
|
></select>
|
||||||
|
} @else {
|
||||||
|
<input
|
||||||
|
tuiSelect
|
||||||
|
[(ngModel)]="selectedDataDrive"
|
||||||
|
(ngModelChange)="onDataDriveChange($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@if (!mobile) {
|
||||||
|
<tui-data-list-wrapper
|
||||||
|
new
|
||||||
|
*tuiTextfieldDropdown
|
||||||
|
[items]="drives"
|
||||||
|
[itemContent]="driveContent"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@if (preserveData === true) {
|
||||||
|
<tui-icon
|
||||||
|
icon="@tui.database"
|
||||||
|
style="color: var(--tui-status-positive); pointer-events: none"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@if (preserveData === false) {
|
||||||
|
<tui-icon
|
||||||
|
icon="@tui.database-zap"
|
||||||
|
style="color: var(--tui-status-negative); pointer-events: none"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<tui-icon [tuiTooltip]="dataDriveTooltip" />
|
||||||
|
</tui-textfield>
|
||||||
|
|
||||||
|
<ng-template #driveContent let-drive>
|
||||||
|
<div class="drive-item">
|
||||||
|
<span class="drive-name">
|
||||||
|
{{ driveName(drive) }}
|
||||||
|
</span>
|
||||||
|
<small>
|
||||||
|
{{ formatCapacity(drive.capacity) }} · {{ drive.logicalname }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
}
|
}
|
||||||
</footer>
|
|
||||||
</section>
|
<footer>
|
||||||
|
@if (drives.length === 0) {
|
||||||
|
<button tuiButton appearance="secondary" (click)="refresh()">
|
||||||
|
{{ 'Refresh' | i18n }}
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
[disabled]="!selectedOsDrive || !selectedDataDrive"
|
||||||
|
(click)="continue()"
|
||||||
|
>
|
||||||
|
{{ 'Continue' | i18n }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
styles: `
|
styles: `
|
||||||
@@ -198,6 +209,10 @@ export default class DrivesPage {
|
|||||||
'The drive where your StartOS data (services, settings, etc.) will be stored. This can be the same as the OS drive or a separate drive.',
|
'The drive where your StartOS data (services, settings, etc.) will be stored. This can be the same as the OS drive or a separate drive.',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private readonly MIN_OS = 18 * 2 ** 30 // 18 GiB
|
||||||
|
private readonly MIN_DATA = 20 * 2 ** 30 // 20 GiB
|
||||||
|
private readonly MIN_BOTH = 38 * 2 ** 30 // 38 GiB
|
||||||
|
|
||||||
drives: DiskInfo[] = []
|
drives: DiskInfo[] = []
|
||||||
loading = true
|
loading = true
|
||||||
shuttingDown = false
|
shuttingDown = false
|
||||||
@@ -206,10 +221,17 @@ export default class DrivesPage {
|
|||||||
selectedDataDrive: DiskInfo | null = null
|
selectedDataDrive: DiskInfo | null = null
|
||||||
preserveData: boolean | null = null
|
preserveData: boolean | null = null
|
||||||
|
|
||||||
|
readonly osDisabled = (drive: DiskInfo): boolean =>
|
||||||
|
drive.capacity < this.MIN_OS
|
||||||
|
|
||||||
|
dataDisabled = (drive: DiskInfo): boolean => drive.capacity < this.MIN_DATA
|
||||||
|
|
||||||
|
readonly driveName = (drive: DiskInfo): string =>
|
||||||
|
[drive.vendor, drive.model].filter(Boolean).join(' ') ||
|
||||||
|
this.i18n.transform('Unknown Drive')
|
||||||
|
|
||||||
readonly stringify = (drive: DiskInfo | null) =>
|
readonly stringify = (drive: DiskInfo | null) =>
|
||||||
drive
|
drive ? this.driveName(drive) : ''
|
||||||
? `${drive.vendor || this.i18n.transform('Unknown')} ${drive.model || this.i18n.transform('Drive')}`
|
|
||||||
: ''
|
|
||||||
|
|
||||||
formatCapacity(bytes: number): string {
|
formatCapacity(bytes: number): string {
|
||||||
const gb = bytes / 1e9
|
const gb = bytes / 1e9
|
||||||
@@ -231,6 +253,22 @@ export default class DrivesPage {
|
|||||||
await this.loadDrives()
|
await this.loadDrives()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onOsDriveChange(osDrive: DiskInfo | null) {
|
||||||
|
this.selectedOsDrive = osDrive
|
||||||
|
this.dataDisabled = (drive: DiskInfo) => {
|
||||||
|
if (osDrive && drive.logicalname === osDrive.logicalname) {
|
||||||
|
return drive.capacity < this.MIN_BOTH
|
||||||
|
}
|
||||||
|
return drive.capacity < this.MIN_DATA
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear data drive if it's now invalid
|
||||||
|
if (this.selectedDataDrive && this.dataDisabled(this.selectedDataDrive)) {
|
||||||
|
this.selectedDataDrive = null
|
||||||
|
this.preserveData = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onDataDriveChange(drive: DiskInfo | null) {
|
onDataDriveChange(drive: DiskInfo | null) {
|
||||||
this.preserveData = null
|
this.preserveData = null
|
||||||
|
|
||||||
@@ -400,7 +438,7 @@ export default class DrivesPage {
|
|||||||
|
|
||||||
private async loadDrives() {
|
private async loadDrives() {
|
||||||
try {
|
try {
|
||||||
this.drives = await this.api.getDisks()
|
this.drives = (await this.api.getDisks()).filter(d => d.capacity > 0)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.errorService.handleError(e)
|
this.errorService.handleError(e)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Component, inject, signal } from '@angular/core'
|
import { Component, inject, signal } from '@angular/core'
|
||||||
import { Router } from '@angular/router'
|
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import {
|
import {
|
||||||
getAllKeyboardsSorted,
|
getAllKeyboardsSorted,
|
||||||
@@ -72,7 +71,6 @@ import { StateService } from '../services/state.service'
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export default class KeyboardPage {
|
export default class KeyboardPage {
|
||||||
private readonly router = inject(Router)
|
|
||||||
private readonly api = inject(ApiService)
|
private readonly api = inject(ApiService)
|
||||||
private readonly stateService = inject(StateService)
|
private readonly stateService = inject(StateService)
|
||||||
|
|
||||||
@@ -103,22 +101,9 @@ export default class KeyboardPage {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.stateService.keyboard = this.selected.layout
|
this.stateService.keyboard = this.selected.layout
|
||||||
await this.navigateToNextStep()
|
await this.stateService.navigateAfterLocale()
|
||||||
} finally {
|
} finally {
|
||||||
this.saving.set(false)
|
this.saving.set(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async navigateToNextStep() {
|
|
||||||
if (this.stateService.dataDriveGuid) {
|
|
||||||
if (this.stateService.attach) {
|
|
||||||
this.stateService.setupType = 'attach'
|
|
||||||
await this.router.navigate(['/password'])
|
|
||||||
} else {
|
|
||||||
await this.router.navigate(['/home'])
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await this.router.navigate(['/drives'])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,8 +141,12 @@ export default class LanguagePage {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this.api.setLanguage({ language: this.selected.name })
|
await this.api.setLanguage({ language: this.selected.name })
|
||||||
// Always go to keyboard selection
|
|
||||||
await this.router.navigate(['/keyboard'])
|
if (this.stateService.kiosk) {
|
||||||
|
await this.router.navigate(['/keyboard'])
|
||||||
|
} else {
|
||||||
|
await this.stateService.navigateAfterLocale()
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.saving.set(false)
|
this.saving.set(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,7 +191,118 @@ export class MockApiService extends ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GiB = 2 ** 30
|
||||||
|
|
||||||
const MOCK_DISKS: DiskInfo[] = [
|
const MOCK_DISKS: DiskInfo[] = [
|
||||||
|
// 0 capacity - should be hidden entirely
|
||||||
|
{
|
||||||
|
logicalname: '/dev/sdd',
|
||||||
|
vendor: 'Generic',
|
||||||
|
model: 'Card Reader',
|
||||||
|
partitions: [],
|
||||||
|
capacity: 0,
|
||||||
|
guid: null,
|
||||||
|
},
|
||||||
|
// 10 GiB - too small for OS and data; also tests both vendor+model null
|
||||||
|
{
|
||||||
|
logicalname: '/dev/sde',
|
||||||
|
vendor: null,
|
||||||
|
model: null,
|
||||||
|
partitions: [
|
||||||
|
{
|
||||||
|
logicalname: '/dev/sde1',
|
||||||
|
label: null,
|
||||||
|
capacity: 10 * GiB,
|
||||||
|
used: null,
|
||||||
|
startOs: {},
|
||||||
|
guid: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
capacity: 10 * GiB,
|
||||||
|
guid: null,
|
||||||
|
},
|
||||||
|
// 18 GiB - exact OS boundary; tests vendor null with model present
|
||||||
|
{
|
||||||
|
logicalname: '/dev/sdf',
|
||||||
|
vendor: null,
|
||||||
|
model: 'SATA Flash Drive',
|
||||||
|
partitions: [
|
||||||
|
{
|
||||||
|
logicalname: '/dev/sdf1',
|
||||||
|
label: null,
|
||||||
|
capacity: 18 * GiB,
|
||||||
|
used: null,
|
||||||
|
startOs: {},
|
||||||
|
guid: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
capacity: 18 * GiB,
|
||||||
|
guid: null,
|
||||||
|
},
|
||||||
|
// 20 GiB - exact data boundary; tests vendor present with model null
|
||||||
|
{
|
||||||
|
logicalname: '/dev/sdg',
|
||||||
|
vendor: 'PNY',
|
||||||
|
model: null,
|
||||||
|
partitions: [
|
||||||
|
{
|
||||||
|
logicalname: '/dev/sdg1',
|
||||||
|
label: null,
|
||||||
|
capacity: 20 * GiB,
|
||||||
|
used: null,
|
||||||
|
startOs: {},
|
||||||
|
guid: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
capacity: 20 * GiB,
|
||||||
|
guid: null,
|
||||||
|
},
|
||||||
|
// 30 GiB - OK for OS or data alone, too small for both (< 38 GiB)
|
||||||
|
{
|
||||||
|
logicalname: '/dev/sdh',
|
||||||
|
vendor: 'SanDisk',
|
||||||
|
model: 'Ultra',
|
||||||
|
partitions: [
|
||||||
|
{
|
||||||
|
logicalname: '/dev/sdh1',
|
||||||
|
label: null,
|
||||||
|
capacity: 30 * GiB,
|
||||||
|
used: null,
|
||||||
|
startOs: {},
|
||||||
|
guid: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
capacity: 30 * GiB,
|
||||||
|
guid: null,
|
||||||
|
},
|
||||||
|
// 30 GiB with existing StartOS data - tests preserve/overwrite + capacity constraint
|
||||||
|
{
|
||||||
|
logicalname: '/dev/sdi',
|
||||||
|
vendor: 'Kingston',
|
||||||
|
model: 'A400',
|
||||||
|
partitions: [
|
||||||
|
{
|
||||||
|
logicalname: '/dev/sdi1',
|
||||||
|
label: null,
|
||||||
|
capacity: 30 * GiB,
|
||||||
|
used: null,
|
||||||
|
startOs: {
|
||||||
|
'small-server-id': {
|
||||||
|
hostname: 'small-server',
|
||||||
|
version: '0.3.6',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
passwordHash:
|
||||||
|
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||||
|
wrappedKey: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
guid: 'small-existing-guid',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
capacity: 30 * GiB,
|
||||||
|
guid: 'small-existing-guid',
|
||||||
|
},
|
||||||
|
// 500 GB - large, always OK
|
||||||
{
|
{
|
||||||
logicalname: '/dev/sda',
|
logicalname: '/dev/sda',
|
||||||
vendor: 'Samsung',
|
vendor: 'Samsung',
|
||||||
@@ -209,6 +320,7 @@ const MOCK_DISKS: DiskInfo[] = [
|
|||||||
capacity: 500000000000,
|
capacity: 500000000000,
|
||||||
guid: null,
|
guid: null,
|
||||||
},
|
},
|
||||||
|
// 1 TB with existing StartOS data
|
||||||
{
|
{
|
||||||
logicalname: '/dev/sdb',
|
logicalname: '/dev/sdb',
|
||||||
vendor: 'Crucial',
|
vendor: 'Crucial',
|
||||||
@@ -235,6 +347,7 @@ const MOCK_DISKS: DiskInfo[] = [
|
|||||||
capacity: 1000000000000,
|
capacity: 1000000000000,
|
||||||
guid: 'existing-guid',
|
guid: 'existing-guid',
|
||||||
},
|
},
|
||||||
|
// 2 TB
|
||||||
{
|
{
|
||||||
logicalname: '/dev/sdc',
|
logicalname: '/dev/sdc',
|
||||||
vendor: 'WD',
|
vendor: 'WD',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { inject, Injectable } from '@angular/core'
|
import { inject, Injectable } from '@angular/core'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
import { T } from '@start9labs/start-sdk'
|
import { T } from '@start9labs/start-sdk'
|
||||||
import { ApiService } from './api.service'
|
import { ApiService } from './api.service'
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export type RecoverySource =
|
|||||||
})
|
})
|
||||||
export class StateService {
|
export class StateService {
|
||||||
private readonly api = inject(ApiService)
|
private readonly api = inject(ApiService)
|
||||||
|
private readonly router = inject(Router)
|
||||||
|
|
||||||
// Determined at app init
|
// Determined at app init
|
||||||
kiosk = false
|
kiosk = false
|
||||||
@@ -45,6 +47,23 @@ export class StateService {
|
|||||||
setupType?: SetupType
|
setupType?: SetupType
|
||||||
recoverySource?: RecoverySource
|
recoverySource?: RecoverySource
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the appropriate step after language/keyboard selection.
|
||||||
|
* Keyboard selection is only needed in kiosk mode.
|
||||||
|
*/
|
||||||
|
async navigateAfterLocale(): Promise<void> {
|
||||||
|
if (this.dataDriveGuid) {
|
||||||
|
if (this.attach) {
|
||||||
|
this.setupType = 'attach'
|
||||||
|
await this.router.navigate(['/password'])
|
||||||
|
} else {
|
||||||
|
await this.router.navigate(['/home'])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.router.navigate(['/drives'])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called for attach flow (existing data drive)
|
* Called for attach flow (existing data drive)
|
||||||
*/
|
*/
|
||||||
|
|||||||
1
web/projects/shared/assets/icons/letsencrypt.svg
Normal file
1
web/projects/shared/assets/icons/letsencrypt.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M22.7 17.21h-3.83v-1.975c0-1.572-1.3-2.86-2.86-2.86s-2.86 1.3-2.86 2.86v1.975H9.33v-1.975c0-3.708 3.023-6.7 6.7-6.7 3.708 0 6.7 3.023 6.7 6.7z" fill="#ffa400"/><path d="M24.282 17.21H7.758a1.27 1.27 0 0 0-1.29 1.29V30.7A1.27 1.27 0 0 0 7.758 32h16.524a1.27 1.27 0 0 0 1.29-1.29V18.5c-.04-.725-.605-1.3-1.3-1.3zm-7.456 8.02v1.652c0 .443-.363.846-.846.846-.443 0-.846-.363-.846-.846V25.23c-.524-.282-.846-.846-.846-1.49 0-.927.766-1.693 1.693-1.693s1.693.766 1.693 1.693c.04.645-.322 1.21-.846 1.49z" fill="#003a70"/><path d="M6.066 15.395h-4a1.17 1.17 0 0 1-1.169-1.169 1.17 1.17 0 0 1 1.169-1.169h4a1.17 1.17 0 0 1 1.169 1.169 1.17 1.17 0 0 1-1.169 1.169zm2.82-6.287a1.03 1.03 0 0 1-.725-.282l-3.144-2.58c-.484-.403-.564-1.128-.16-1.652.403-.484 1.128-.564 1.652-.16l3.144 2.58c.484.403.564 1.128.16 1.652-.282.282-.605.443-.927.443zm7.134-2.74a1.17 1.17 0 0 1-1.169-1.169V1.17A1.17 1.17 0 0 1 16.02 0a1.17 1.17 0 0 1 1.169 1.169V5.2a1.17 1.17 0 0 1-1.169 1.169zm7.093 2.74c-.322 0-.685-.16-.887-.443-.403-.484-.322-1.25.16-1.652l3.144-2.58c.484-.403 1.25-.322 1.652.16s.322 1.25-.16 1.652l-3.144 2.58a1.13 1.13 0 0 1-.766.282zm6.81 6.287h-4.03a1.17 1.17 0 0 1-1.169-1.169 1.17 1.17 0 0 1 1.169-1.169h4.03a1.17 1.17 0 0 1 1.169 1.169 1.17 1.17 0 0 1-1.169 1.169z" fill="#ffa400"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -16,7 +16,7 @@ export const VERSION = new InjectionToken<string>('VERSION')
|
|||||||
host: {
|
host: {
|
||||||
target: '_blank',
|
target: '_blank',
|
||||||
rel: 'noreferrer',
|
rel: 'noreferrer',
|
||||||
'[href]': 'url()',
|
'[attr.href]': 'url()',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class DocsLinkDirective {
|
export class DocsLinkDirective {
|
||||||
|
|||||||
@@ -360,7 +360,6 @@ export default {
|
|||||||
377: 'StartOS-Sicherungen erkannt',
|
377: 'StartOS-Sicherungen erkannt',
|
||||||
378: 'Keine StartOS-Sicherungen erkannt',
|
378: 'Keine StartOS-Sicherungen erkannt',
|
||||||
379: 'StartOS-Version',
|
379: 'StartOS-Version',
|
||||||
381: 'SMTP-Zugangsdaten',
|
|
||||||
382: 'Test-E-Mail senden',
|
382: 'Test-E-Mail senden',
|
||||||
383: 'Senden',
|
383: 'Senden',
|
||||||
384: 'E-Mail wird gesendet',
|
384: 'E-Mail wird gesendet',
|
||||||
@@ -644,7 +643,6 @@ export default {
|
|||||||
706: 'Beibehalten',
|
706: 'Beibehalten',
|
||||||
707: 'Überschreiben',
|
707: 'Überschreiben',
|
||||||
708: 'Entsperren',
|
708: 'Entsperren',
|
||||||
709: 'Laufwerk',
|
|
||||||
710: 'Übertragen',
|
710: 'Übertragen',
|
||||||
711: 'Die Liste ist leer',
|
711: 'Die Liste ist leer',
|
||||||
712: 'Jetzt neu starten',
|
712: 'Jetzt neu starten',
|
||||||
@@ -659,8 +657,6 @@ export default {
|
|||||||
721: 'Gateway für ausgehenden Datenverkehr auswählen',
|
721: 'Gateway für ausgehenden Datenverkehr auswählen',
|
||||||
722: 'Der Typ des Gateways',
|
722: 'Der Typ des Gateways',
|
||||||
723: 'Nur ausgehend',
|
723: 'Nur ausgehend',
|
||||||
724: 'Als Standard für ausgehenden Verkehr festlegen',
|
|
||||||
725: 'Gesamten ausgehenden Datenverkehr über dieses Gateway leiten',
|
|
||||||
726: 'WireGuard-Konfigurationsdatei',
|
726: 'WireGuard-Konfigurationsdatei',
|
||||||
727: 'Eingehend/Ausgehend',
|
727: 'Eingehend/Ausgehend',
|
||||||
728: 'StartTunnel (Eingehend/Ausgehend)',
|
728: 'StartTunnel (Eingehend/Ausgehend)',
|
||||||
@@ -669,7 +665,6 @@ export default {
|
|||||||
731: 'Öffentliche Domain',
|
731: 'Öffentliche Domain',
|
||||||
732: 'Private Domain',
|
732: 'Private Domain',
|
||||||
733: 'Ausblenden',
|
733: 'Ausblenden',
|
||||||
734: 'Standard ausgehend',
|
|
||||||
735: 'Zertifikat',
|
735: 'Zertifikat',
|
||||||
736: 'Selbstsigniert',
|
736: 'Selbstsigniert',
|
||||||
737: 'Portweiterleitung',
|
737: 'Portweiterleitung',
|
||||||
@@ -704,4 +699,14 @@ export default {
|
|||||||
774: 'Der Portstatus kann nicht ermittelt werden, solange der Dienst nicht läuft',
|
774: 'Der Portstatus kann nicht ermittelt werden, solange der Dienst nicht läuft',
|
||||||
775: 'Diese Adresse funktioniert nicht aus Ihrem lokalen Netzwerk aufgrund einer Router-Hairpinning-Einschränkung',
|
775: 'Diese Adresse funktioniert nicht aus Ihrem lokalen Netzwerk aufgrund einer Router-Hairpinning-Einschränkung',
|
||||||
776: 'Aktion nicht gefunden',
|
776: 'Aktion nicht gefunden',
|
||||||
|
777: 'Diese Domain wird auch gelten für',
|
||||||
|
778: 'Plugin',
|
||||||
|
779: 'Öffentlich',
|
||||||
|
780: 'Privat',
|
||||||
|
781: 'Lokal',
|
||||||
|
782: 'Unbekanntes Laufwerk',
|
||||||
|
783: 'Muss eine gültige E-Mail-Adresse sein',
|
||||||
|
786: 'Automatisch',
|
||||||
|
787: 'Ausgehender Datenverkehr',
|
||||||
|
788: 'Gateway verwenden',
|
||||||
} satisfies i18n
|
} satisfies i18n
|
||||||
|
|||||||
@@ -359,7 +359,6 @@ export const ENGLISH: Record<string, number> = {
|
|||||||
'StartOS backups detected': 377,
|
'StartOS backups detected': 377,
|
||||||
'No StartOS backups detected': 378,
|
'No StartOS backups detected': 378,
|
||||||
'StartOS Version': 379,
|
'StartOS Version': 379,
|
||||||
'SMTP Credentials': 381,
|
|
||||||
'Send test email': 382,
|
'Send test email': 382,
|
||||||
'Send': 383,
|
'Send': 383,
|
||||||
'Sending email': 384,
|
'Sending email': 384,
|
||||||
@@ -644,7 +643,6 @@ export const ENGLISH: Record<string, number> = {
|
|||||||
'Preserve': 706,
|
'Preserve': 706,
|
||||||
'Overwrite': 707,
|
'Overwrite': 707,
|
||||||
'Unlock': 708,
|
'Unlock': 708,
|
||||||
'Drive': 709, // the noun, a storage device
|
|
||||||
'Transfer': 710, // the verb
|
'Transfer': 710, // the verb
|
||||||
'The list is empty': 711,
|
'The list is empty': 711,
|
||||||
'Restart now': 712,
|
'Restart now': 712,
|
||||||
@@ -659,8 +657,6 @@ export const ENGLISH: Record<string, number> = {
|
|||||||
'Select the gateway for outbound traffic': 721,
|
'Select the gateway for outbound traffic': 721,
|
||||||
'The type of gateway': 722,
|
'The type of gateway': 722,
|
||||||
'Outbound Only': 723,
|
'Outbound Only': 723,
|
||||||
'Set as default outbound': 724,
|
|
||||||
'Route all outbound traffic through this gateway': 725,
|
|
||||||
'WireGuard Config File': 726,
|
'WireGuard Config File': 726,
|
||||||
'Inbound/Outbound': 727,
|
'Inbound/Outbound': 727,
|
||||||
'StartTunnel (Inbound/Outbound)': 728,
|
'StartTunnel (Inbound/Outbound)': 728,
|
||||||
@@ -669,7 +665,6 @@ export const ENGLISH: Record<string, number> = {
|
|||||||
'Public Domain': 731,
|
'Public Domain': 731,
|
||||||
'Private Domain': 732,
|
'Private Domain': 732,
|
||||||
'Hide': 733,
|
'Hide': 733,
|
||||||
'default outbound': 734,
|
|
||||||
'Certificate': 735,
|
'Certificate': 735,
|
||||||
'Self signed': 736,
|
'Self signed': 736,
|
||||||
'Port Forwarding': 737,
|
'Port Forwarding': 737,
|
||||||
@@ -704,4 +699,14 @@ export const ENGLISH: Record<string, number> = {
|
|||||||
'Port status cannot be determined while service is not running': 774,
|
'Port status cannot be determined while service is not running': 774,
|
||||||
'This address will not work from your local network due to a router hairpinning limitation': 775,
|
'This address will not work from your local network due to a router hairpinning limitation': 775,
|
||||||
'Action not found': 776,
|
'Action not found': 776,
|
||||||
|
'This domain will also apply to': 777,
|
||||||
|
'Plugin': 778,
|
||||||
|
'Public': 779, // as in, publicly accessible
|
||||||
|
'Private': 780, // as in, privately accessible
|
||||||
|
'Local': 781, // as in, locally accessible
|
||||||
|
'Unknown Drive': 782,
|
||||||
|
'Must be a valid email address': 783,
|
||||||
|
'Auto': 786,
|
||||||
|
'Outbound Traffic': 787,
|
||||||
|
'Use gateway': 788,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,7 +360,6 @@ export default {
|
|||||||
377: 'Copias de seguridad de StartOS detectadas',
|
377: 'Copias de seguridad de StartOS detectadas',
|
||||||
378: 'No se detectaron copias de seguridad de StartOS',
|
378: 'No se detectaron copias de seguridad de StartOS',
|
||||||
379: 'Versión de StartOS',
|
379: 'Versión de StartOS',
|
||||||
381: 'Credenciales SMTP',
|
|
||||||
382: 'Enviar correo de prueba',
|
382: 'Enviar correo de prueba',
|
||||||
383: 'Enviar',
|
383: 'Enviar',
|
||||||
384: 'Enviando correo',
|
384: 'Enviando correo',
|
||||||
@@ -644,7 +643,6 @@ export default {
|
|||||||
706: 'Conservar',
|
706: 'Conservar',
|
||||||
707: 'Sobrescribir',
|
707: 'Sobrescribir',
|
||||||
708: 'Desbloquear',
|
708: 'Desbloquear',
|
||||||
709: 'Unidad',
|
|
||||||
710: 'Transferir',
|
710: 'Transferir',
|
||||||
711: 'La lista está vacía',
|
711: 'La lista está vacía',
|
||||||
712: 'Reiniciar ahora',
|
712: 'Reiniciar ahora',
|
||||||
@@ -659,8 +657,6 @@ export default {
|
|||||||
721: 'Selecciona la puerta de enlace para el tráfico saliente',
|
721: 'Selecciona la puerta de enlace para el tráfico saliente',
|
||||||
722: 'El tipo de puerta de enlace',
|
722: 'El tipo de puerta de enlace',
|
||||||
723: 'Solo saliente',
|
723: 'Solo saliente',
|
||||||
724: 'Establecer como saliente predeterminado',
|
|
||||||
725: 'Enrutar todo el tráfico saliente a través de esta puerta de enlace',
|
|
||||||
726: 'Archivo de configuración WireGuard',
|
726: 'Archivo de configuración WireGuard',
|
||||||
727: 'Entrante/Saliente',
|
727: 'Entrante/Saliente',
|
||||||
728: 'StartTunnel (Entrante/Saliente)',
|
728: 'StartTunnel (Entrante/Saliente)',
|
||||||
@@ -669,7 +665,6 @@ export default {
|
|||||||
731: 'Dominio público',
|
731: 'Dominio público',
|
||||||
732: 'Dominio privado',
|
732: 'Dominio privado',
|
||||||
733: 'Ocultar',
|
733: 'Ocultar',
|
||||||
734: 'saliente predeterminado',
|
|
||||||
735: 'Certificado',
|
735: 'Certificado',
|
||||||
736: 'Autofirmado',
|
736: 'Autofirmado',
|
||||||
737: 'Reenvío de puertos',
|
737: 'Reenvío de puertos',
|
||||||
@@ -704,4 +699,14 @@ export default {
|
|||||||
774: 'El estado del puerto no se puede determinar mientras el servicio no está en ejecución',
|
774: 'El estado del puerto no se puede determinar mientras el servicio no está en ejecución',
|
||||||
775: 'Esta dirección no funcionará desde tu red local debido a una limitación de hairpinning del router',
|
775: 'Esta dirección no funcionará desde tu red local debido a una limitación de hairpinning del router',
|
||||||
776: 'Acción no encontrada',
|
776: 'Acción no encontrada',
|
||||||
|
777: 'Este dominio también se aplicará a',
|
||||||
|
778: 'Plugin',
|
||||||
|
779: 'Público',
|
||||||
|
780: 'Privado',
|
||||||
|
781: 'Local',
|
||||||
|
782: 'Unidad desconocida',
|
||||||
|
783: 'Debe ser una dirección de correo electrónico válida',
|
||||||
|
786: 'Automático',
|
||||||
|
787: 'Tráfico saliente',
|
||||||
|
788: 'Usar gateway',
|
||||||
} satisfies i18n
|
} satisfies i18n
|
||||||
|
|||||||
@@ -360,7 +360,6 @@ export default {
|
|||||||
377: 'Sauvegardes StartOS détectées',
|
377: 'Sauvegardes StartOS détectées',
|
||||||
378: 'Aucune sauvegarde StartOS détectée',
|
378: 'Aucune sauvegarde StartOS détectée',
|
||||||
379: 'Version de StartOS',
|
379: 'Version de StartOS',
|
||||||
381: 'Identifiants SMTP',
|
|
||||||
382: 'Envoyer un email de test',
|
382: 'Envoyer un email de test',
|
||||||
383: 'Envoyer',
|
383: 'Envoyer',
|
||||||
384: 'Envoi de l’email',
|
384: 'Envoi de l’email',
|
||||||
@@ -644,7 +643,6 @@ export default {
|
|||||||
706: 'Conserver',
|
706: 'Conserver',
|
||||||
707: 'Écraser',
|
707: 'Écraser',
|
||||||
708: 'Déverrouiller',
|
708: 'Déverrouiller',
|
||||||
709: 'Disque',
|
|
||||||
710: 'Transférer',
|
710: 'Transférer',
|
||||||
711: 'La liste est vide',
|
711: 'La liste est vide',
|
||||||
712: 'Redémarrer maintenant',
|
712: 'Redémarrer maintenant',
|
||||||
@@ -659,8 +657,6 @@ export default {
|
|||||||
721: 'Sélectionnez la passerelle pour le trafic sortant',
|
721: 'Sélectionnez la passerelle pour le trafic sortant',
|
||||||
722: 'Le type de passerelle',
|
722: 'Le type de passerelle',
|
||||||
723: 'Sortant uniquement',
|
723: 'Sortant uniquement',
|
||||||
724: 'Définir comme sortant par défaut',
|
|
||||||
725: 'Acheminer tout le trafic sortant via cette passerelle',
|
|
||||||
726: 'Fichier de configuration WireGuard',
|
726: 'Fichier de configuration WireGuard',
|
||||||
727: 'Entrant/Sortant',
|
727: 'Entrant/Sortant',
|
||||||
728: 'StartTunnel (Entrant/Sortant)',
|
728: 'StartTunnel (Entrant/Sortant)',
|
||||||
@@ -669,7 +665,6 @@ export default {
|
|||||||
731: 'Domaine public',
|
731: 'Domaine public',
|
||||||
732: 'Domaine privé',
|
732: 'Domaine privé',
|
||||||
733: 'Masquer',
|
733: 'Masquer',
|
||||||
734: 'sortant par défaut',
|
|
||||||
735: 'Certificat',
|
735: 'Certificat',
|
||||||
736: 'Auto-signé',
|
736: 'Auto-signé',
|
||||||
737: 'Redirection de ports',
|
737: 'Redirection de ports',
|
||||||
@@ -704,4 +699,14 @@ export default {
|
|||||||
774: "L'état du port ne peut pas être déterminé tant que le service n'est pas en cours d'exécution",
|
774: "L'état du port ne peut pas être déterminé tant que le service n'est pas en cours d'exécution",
|
||||||
775: "Cette adresse ne fonctionnera pas depuis votre réseau local en raison d'une limitation de hairpinning du routeur",
|
775: "Cette adresse ne fonctionnera pas depuis votre réseau local en raison d'une limitation de hairpinning du routeur",
|
||||||
776: 'Action introuvable',
|
776: 'Action introuvable',
|
||||||
|
777: "Ce domaine s'appliquera également à",
|
||||||
|
778: 'Plugin',
|
||||||
|
779: 'Public',
|
||||||
|
780: 'Privé',
|
||||||
|
781: 'Local',
|
||||||
|
782: 'Lecteur inconnu',
|
||||||
|
783: 'Doit être une adresse e-mail valide',
|
||||||
|
786: 'Automatique',
|
||||||
|
787: 'Trafic sortant',
|
||||||
|
788: 'Utiliser la passerelle',
|
||||||
} satisfies i18n
|
} satisfies i18n
|
||||||
|
|||||||
@@ -360,7 +360,6 @@ export default {
|
|||||||
377: 'Wykryto kopie zapasowe StartOS',
|
377: 'Wykryto kopie zapasowe StartOS',
|
||||||
378: 'Nie wykryto kopii zapasowych StartOS',
|
378: 'Nie wykryto kopii zapasowych StartOS',
|
||||||
379: 'Wersja StartOS',
|
379: 'Wersja StartOS',
|
||||||
381: 'Dane logowania SMTP',
|
|
||||||
382: 'Wyślij e-mail testowy',
|
382: 'Wyślij e-mail testowy',
|
||||||
383: 'Wyślij',
|
383: 'Wyślij',
|
||||||
384: 'Wysyłanie e-maila',
|
384: 'Wysyłanie e-maila',
|
||||||
@@ -644,7 +643,6 @@ export default {
|
|||||||
706: 'Zachowaj',
|
706: 'Zachowaj',
|
||||||
707: 'Nadpisz',
|
707: 'Nadpisz',
|
||||||
708: 'Odblokuj',
|
708: 'Odblokuj',
|
||||||
709: 'Dysk',
|
|
||||||
710: 'Przenieś',
|
710: 'Przenieś',
|
||||||
711: 'Lista jest pusta',
|
711: 'Lista jest pusta',
|
||||||
712: 'Uruchom ponownie teraz',
|
712: 'Uruchom ponownie teraz',
|
||||||
@@ -659,8 +657,6 @@ export default {
|
|||||||
721: 'Wybierz bramę dla ruchu wychodzącego',
|
721: 'Wybierz bramę dla ruchu wychodzącego',
|
||||||
722: 'Typ bramy',
|
722: 'Typ bramy',
|
||||||
723: 'Tylko wychodzący',
|
723: 'Tylko wychodzący',
|
||||||
724: 'Ustaw jako domyślne wychodzące',
|
|
||||||
725: 'Kieruj cały ruch wychodzący przez tę bramę',
|
|
||||||
726: 'Plik konfiguracyjny WireGuard',
|
726: 'Plik konfiguracyjny WireGuard',
|
||||||
727: 'Przychodzący/Wychodzący',
|
727: 'Przychodzący/Wychodzący',
|
||||||
728: 'StartTunnel (Przychodzący/Wychodzący)',
|
728: 'StartTunnel (Przychodzący/Wychodzący)',
|
||||||
@@ -669,7 +665,6 @@ export default {
|
|||||||
731: 'Domena publiczna',
|
731: 'Domena publiczna',
|
||||||
732: 'Domena prywatna',
|
732: 'Domena prywatna',
|
||||||
733: 'Ukryj',
|
733: 'Ukryj',
|
||||||
734: 'domyślne wychodzące',
|
|
||||||
735: 'Certyfikat',
|
735: 'Certyfikat',
|
||||||
736: 'Samopodpisany',
|
736: 'Samopodpisany',
|
||||||
737: 'Przekierowanie portów',
|
737: 'Przekierowanie portów',
|
||||||
@@ -704,4 +699,14 @@ export default {
|
|||||||
774: 'Status portu nie może być określony, gdy usługa nie jest uruchomiona',
|
774: 'Status portu nie może być określony, gdy usługa nie jest uruchomiona',
|
||||||
775: 'Ten adres nie będzie działać z Twojej sieci lokalnej z powodu ograniczenia hairpinning routera',
|
775: 'Ten adres nie będzie działać z Twojej sieci lokalnej z powodu ograniczenia hairpinning routera',
|
||||||
776: 'Nie znaleziono akcji',
|
776: 'Nie znaleziono akcji',
|
||||||
|
777: 'Ta domena będzie również dotyczyć',
|
||||||
|
778: 'Wtyczka',
|
||||||
|
779: 'Publiczny',
|
||||||
|
780: 'Prywatny',
|
||||||
|
781: 'Lokalny',
|
||||||
|
782: 'Nieznany dysk',
|
||||||
|
783: 'Musi być prawidłowy adres e-mail',
|
||||||
|
786: 'Automatycznie',
|
||||||
|
787: 'Ruch wychodzący',
|
||||||
|
788: 'Użyj bramy',
|
||||||
} satisfies i18n
|
} satisfies i18n
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface FormContext<T> {
|
|||||||
buttons: ActionButton<T>[]
|
buttons: ActionButton<T>[]
|
||||||
value?: T
|
value?: T
|
||||||
operations?: Operation[]
|
operations?: Operation[]
|
||||||
|
note?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -43,6 +44,9 @@ export interface FormContext<T> {
|
|||||||
(tuiValueChanges)="markAsDirty()"
|
(tuiValueChanges)="markAsDirty()"
|
||||||
>
|
>
|
||||||
<form-group [spec]="spec" />
|
<form-group [spec]="spec" />
|
||||||
|
@if (note) {
|
||||||
|
<p class="note">{{ note }}</p>
|
||||||
|
}
|
||||||
<footer>
|
<footer>
|
||||||
<ng-content />
|
<ng-content />
|
||||||
@for (button of buttons; track $index) {
|
@for (button of buttons; track $index) {
|
||||||
@@ -70,6 +74,12 @@ export interface FormContext<T> {
|
|||||||
</form>
|
</form>
|
||||||
`,
|
`,
|
||||||
styles: `
|
styles: `
|
||||||
|
.note {
|
||||||
|
color: var(--tui-text-secondary);
|
||||||
|
font: var(--tui-font-text-s);
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@@ -106,6 +116,7 @@ export class FormComponent<T extends Record<string, any>> implements OnInit {
|
|||||||
@Input() buttons = this.context?.data.buttons || []
|
@Input() buttons = this.context?.data.buttons || []
|
||||||
@Input() operations = this.context?.data.operations || []
|
@Input() operations = this.context?.data.operations || []
|
||||||
@Input() value?: T = this.context?.data.value
|
@Input() value?: T = this.context?.data.value
|
||||||
|
@Input() note = this.context?.data.note || ''
|
||||||
|
|
||||||
form = new FormGroup({})
|
form = new FormGroup({})
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import { ABOUT } from './about.component'
|
|||||||
}
|
}
|
||||||
<tui-data-list [style.width.rem]="13">
|
<tui-data-list [style.width.rem]="13">
|
||||||
<tui-opt-group>
|
<tui-opt-group>
|
||||||
<button tuiOption iconStart="@tui.info" (click)="about()">
|
<button tuiOption iconStart="@tui.info" new (click)="about()">
|
||||||
{{ 'About this server' | i18n }}
|
{{ 'About this server' | i18n }}
|
||||||
</button>
|
</button>
|
||||||
</tui-opt-group>
|
</tui-opt-group>
|
||||||
@@ -53,13 +53,15 @@ import { ABOUT } from './about.component'
|
|||||||
<a
|
<a
|
||||||
tuiOption
|
tuiOption
|
||||||
docsLink
|
docsLink
|
||||||
iconStart="@tui.book-open"
|
new
|
||||||
path="/start-os/user-manual/index.html"
|
iconStart="@tui.book-open-text"
|
||||||
|
path="/start-os/user-manual"
|
||||||
>
|
>
|
||||||
{{ 'User manual' | i18n }}
|
{{ 'User manual' | i18n }}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
tuiOption
|
tuiOption
|
||||||
|
new
|
||||||
iconStart="@tui.headphones"
|
iconStart="@tui.headphones"
|
||||||
href="https://start9.com/contact"
|
href="https://start9.com/contact"
|
||||||
>
|
>
|
||||||
@@ -67,6 +69,7 @@ import { ABOUT } from './about.component'
|
|||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
tuiOption
|
tuiOption
|
||||||
|
new
|
||||||
iconStart="@tui.dollar-sign"
|
iconStart="@tui.dollar-sign"
|
||||||
href="https://donate.start9.com"
|
href="https://donate.start9.com"
|
||||||
>
|
>
|
||||||
@@ -76,6 +79,7 @@ import { ABOUT } from './about.component'
|
|||||||
<tui-opt-group label="">
|
<tui-opt-group label="">
|
||||||
<a
|
<a
|
||||||
tuiOption
|
tuiOption
|
||||||
|
new
|
||||||
iconStart="@tui.settings"
|
iconStart="@tui.settings"
|
||||||
routerLink="/system"
|
routerLink="/system"
|
||||||
(click)="open = false"
|
(click)="open = false"
|
||||||
@@ -86,6 +90,7 @@ import { ABOUT } from './about.component'
|
|||||||
<tui-opt-group label="">
|
<tui-opt-group label="">
|
||||||
<button
|
<button
|
||||||
tuiOption
|
tuiOption
|
||||||
|
new
|
||||||
iconStart="@tui.refresh-cw"
|
iconStart="@tui.refresh-cw"
|
||||||
(click)="promptPower('restart')"
|
(click)="promptPower('restart')"
|
||||||
>
|
>
|
||||||
@@ -93,12 +98,13 @@ import { ABOUT } from './about.component'
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
tuiOption
|
tuiOption
|
||||||
|
new
|
||||||
iconStart="@tui.power"
|
iconStart="@tui.power"
|
||||||
(click)="promptPower('shutdown')"
|
(click)="promptPower('shutdown')"
|
||||||
>
|
>
|
||||||
{{ 'Shutdown' | i18n }}
|
{{ 'Shutdown' | i18n }}
|
||||||
</button>
|
</button>
|
||||||
<button tuiOption iconStart="@tui.log-out" (click)="logout()">
|
<button tuiOption new iconStart="@tui.log-out" (click)="logout()">
|
||||||
{{ 'Logout' | i18n }}
|
{{ 'Logout' | i18n }}
|
||||||
</button>
|
</button>
|
||||||
</tui-opt-group>
|
</tui-opt-group>
|
||||||
|
|||||||
@@ -30,19 +30,6 @@ import { DomainHealthService } from './domain-health.service'
|
|||||||
selector: 'td[actions]',
|
selector: 'td[actions]',
|
||||||
template: `
|
template: `
|
||||||
<div class="desktop">
|
<div class="desktop">
|
||||||
@if (address().ui) {
|
|
||||||
<a
|
|
||||||
tuiIconButton
|
|
||||||
appearance="flat-grayscale"
|
|
||||||
iconStart="@tui.external-link"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
[attr.href]="address().enabled ? address().url : null"
|
|
||||||
[class.disabled]="!address().enabled"
|
|
||||||
>
|
|
||||||
{{ 'Open UI' | i18n }}
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
@if (address().deletable) {
|
@if (address().deletable) {
|
||||||
<button
|
<button
|
||||||
tuiIconButton
|
tuiIconButton
|
||||||
@@ -87,6 +74,19 @@ import { DomainHealthService } from './domain-health.service'
|
|||||||
{{ 'Address Requirements' | i18n }}
|
{{ 'Address Requirements' | i18n }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
@if (address().ui) {
|
||||||
|
<a
|
||||||
|
tuiIconButton
|
||||||
|
appearance="flat-grayscale"
|
||||||
|
iconStart="@tui.external-link"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
[attr.href]="address().enabled ? address().url : null"
|
||||||
|
[class.disabled]="!address().enabled"
|
||||||
|
>
|
||||||
|
{{ 'Open UI' | i18n }}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
<button
|
<button
|
||||||
tuiIconButton
|
tuiIconButton
|
||||||
appearance="flat-grayscale"
|
appearance="flat-grayscale"
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import { InterfaceAddressItemComponent } from './item.component'
|
|||||||
selector: 'section[gatewayGroup]',
|
selector: 'section[gatewayGroup]',
|
||||||
template: `
|
template: `
|
||||||
<header>
|
<header>
|
||||||
{{ gatewayGroup().gatewayName }}
|
{{ 'Gateway' | i18n }}: {{ gatewayGroup().gatewayName }}
|
||||||
<button
|
<button
|
||||||
tuiDropdown
|
tuiDropdown
|
||||||
tuiButton
|
tuiButton
|
||||||
@@ -57,7 +57,14 @@ import { InterfaceAddressItemComponent } from './item.component'
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<table
|
<table
|
||||||
[appTable]="['Enabled', 'Type', 'Certificate Authority', 'URL', null]"
|
[appTable]="[
|
||||||
|
null,
|
||||||
|
'Access',
|
||||||
|
'Type',
|
||||||
|
'Certificate Authority',
|
||||||
|
'URL',
|
||||||
|
null,
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
@for (address of gatewayGroup().addresses; track $index) {
|
@for (address of gatewayGroup().addresses; track $index) {
|
||||||
<tr
|
<tr
|
||||||
@@ -69,7 +76,7 @@ import { InterfaceAddressItemComponent } from './item.component'
|
|||||||
></tr>
|
></tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5">
|
<td colspan="6">
|
||||||
<app-placeholder icon="@tui.list-x">
|
<app-placeholder icon="@tui.list-x">
|
||||||
{{ 'No addresses' | i18n }}
|
{{ 'No addresses' | i18n }}
|
||||||
</app-placeholder>
|
</app-placeholder>
|
||||||
@@ -132,6 +139,7 @@ export class InterfaceAddressesComponent {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
note: this.getSharedHostNote(),
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
text: this.i18n.transform('Save')!,
|
text: this.i18n.transform('Save')!,
|
||||||
@@ -190,6 +198,7 @@ export class InterfaceAddressesComponent {
|
|||||||
size: 's',
|
size: 's',
|
||||||
data: {
|
data: {
|
||||||
spec: await configBuilderToSpec(addSpec),
|
spec: await configBuilderToSpec(addSpec),
|
||||||
|
note: this.getSharedHostNote(),
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
text: this.i18n.transform('Save')!,
|
text: this.i18n.transform('Save')!,
|
||||||
@@ -207,18 +216,22 @@ export class InterfaceAddressesComponent {
|
|||||||
const loader = this.loader.open('Saving').subscribe()
|
const loader = this.loader.open('Saving').subscribe()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let configured: boolean
|
||||||
if (this.packageId()) {
|
if (this.packageId()) {
|
||||||
await this.api.pkgAddPrivateDomain({
|
configured = await this.api.pkgAddPrivateDomain({
|
||||||
fqdn,
|
fqdn,
|
||||||
gateway: gatewayId,
|
gateway: gatewayId,
|
||||||
package: this.packageId(),
|
package: this.packageId(),
|
||||||
host: iface?.addressInfo.hostId || '',
|
host: iface?.addressInfo.hostId || '',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
await this.api.osUiAddPrivateDomain({ fqdn, gateway: gatewayId })
|
configured = await this.api.osUiAddPrivateDomain({
|
||||||
|
fqdn,
|
||||||
|
gateway: gatewayId,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.domainHealth.checkPrivateDomain(gatewayId)
|
await this.domainHealth.checkPrivateDomain(gatewayId, configured)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -229,6 +242,13 @@ export class InterfaceAddressesComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getSharedHostNote(): string {
|
||||||
|
const names = this.value()?.sharedHostNames
|
||||||
|
if (!names?.length) return ''
|
||||||
|
|
||||||
|
return `${this.i18n.transform('This domain will also apply to')} ${names.join(', ')}`
|
||||||
|
}
|
||||||
|
|
||||||
private async savePublicDomain(
|
private async savePublicDomain(
|
||||||
fqdn: string,
|
fqdn: string,
|
||||||
authority?: 'local' | string,
|
authority?: 'local' | string,
|
||||||
@@ -241,26 +261,22 @@ export class InterfaceAddressesComponent {
|
|||||||
fqdn,
|
fqdn,
|
||||||
gateway: gatewayId,
|
gateway: gatewayId,
|
||||||
acme: !authority || authority === 'local' ? null : authority,
|
acme: !authority || authority === 'local' ? null : authority,
|
||||||
|
internalPort: iface?.addressInfo.internalPort || 80,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let res
|
||||||
if (this.packageId()) {
|
if (this.packageId()) {
|
||||||
await this.api.pkgAddPublicDomain({
|
res = await this.api.pkgAddPublicDomain({
|
||||||
...params,
|
...params,
|
||||||
package: this.packageId(),
|
package: this.packageId(),
|
||||||
host: iface?.addressInfo.hostId || '',
|
host: iface?.addressInfo.hostId || '',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
await this.api.osUiAddPublicDomain(params)
|
res = await this.api.osUiAddPublicDomain(params)
|
||||||
}
|
}
|
||||||
|
|
||||||
const port = this.gatewayGroup().addresses.find(
|
await this.domainHealth.checkPublicDomain(fqdn, gatewayId, res)
|
||||||
a => a.access === 'public' && a.hostnameInfo.port !== null,
|
|
||||||
)?.hostnameInfo.port
|
|
||||||
|
|
||||||
if (port !== undefined && port !== null) {
|
|
||||||
await this.domainHealth.checkPublicDomain(fqdn, gatewayId, port)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -19,21 +19,34 @@ export class DomainHealthService {
|
|||||||
async checkPublicDomain(
|
async checkPublicDomain(
|
||||||
fqdn: string,
|
fqdn: string,
|
||||||
gatewayId: string,
|
gatewayId: string,
|
||||||
port: number,
|
portOrRes: number | T.AddPublicDomainRes,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const gateway = await this.getGatewayData(gatewayId)
|
const gateway = await this.getGatewayData(gatewayId)
|
||||||
if (!gateway) return
|
if (!gateway) return
|
||||||
|
|
||||||
const [dnsPass, portResult] = await Promise.all([
|
let dnsPass: boolean
|
||||||
this.api
|
let port: number
|
||||||
.queryDns({ fqdn })
|
let portResult: T.CheckPortRes | null
|
||||||
.then(ip => ip === gateway.ipInfo.wanIp)
|
|
||||||
.catch(() => false),
|
if (typeof portOrRes === 'number') {
|
||||||
this.api
|
port = portOrRes
|
||||||
.checkPort({ gateway: gatewayId, port })
|
const [dns, portRes] = await Promise.all([
|
||||||
.catch((): null => null),
|
this.api
|
||||||
])
|
.queryDns({ fqdn })
|
||||||
|
.then(ip => ip === gateway.ipInfo.wanIp)
|
||||||
|
.catch(() => false),
|
||||||
|
this.api
|
||||||
|
.checkPort({ gateway: gatewayId, port: portOrRes })
|
||||||
|
.catch((): null => null),
|
||||||
|
])
|
||||||
|
dnsPass = dns
|
||||||
|
portResult = portRes
|
||||||
|
} else {
|
||||||
|
dnsPass = portOrRes.dns === gateway.ipInfo.wanIp
|
||||||
|
port = portOrRes.port.port
|
||||||
|
portResult = portOrRes.port
|
||||||
|
}
|
||||||
|
|
||||||
const portOk =
|
const portOk =
|
||||||
!!portResult?.openInternally &&
|
!!portResult?.openInternally &&
|
||||||
@@ -55,14 +68,17 @@ export class DomainHealthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkPrivateDomain(gatewayId: string): Promise<void> {
|
async checkPrivateDomain(
|
||||||
|
gatewayId: string,
|
||||||
|
prefetchedConfigured?: boolean,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const gateway = await this.getGatewayData(gatewayId)
|
const gateway = await this.getGatewayData(gatewayId)
|
||||||
if (!gateway) return
|
if (!gateway) return
|
||||||
|
|
||||||
const configured = await this.api
|
const configured =
|
||||||
.checkDns({ gateway: gatewayId })
|
prefetchedConfigured ??
|
||||||
.catch(() => false)
|
(await this.api.checkDns({ gateway: gatewayId }).catch(() => false))
|
||||||
|
|
||||||
if (!configured) {
|
if (!configured) {
|
||||||
setTimeout(
|
setTimeout(
|
||||||
@@ -150,7 +166,10 @@ export class DomainHealthService {
|
|||||||
fqdn: string,
|
fqdn: string,
|
||||||
gateway: DnsGateway,
|
gateway: DnsGateway,
|
||||||
port: number,
|
port: number,
|
||||||
initialResults?: { dnsPass: boolean; portResult: T.CheckPortRes | null },
|
initialResults?: {
|
||||||
|
dnsPass: boolean
|
||||||
|
portResult: T.CheckPortRes | null
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
this.dialog
|
this.dialog
|
||||||
.openComponent(DOMAIN_VALIDATION, {
|
.openComponent(DOMAIN_VALIDATION, {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
|
|||||||
import { TuiObfuscatePipe } from '@taiga-ui/cdk'
|
import { TuiObfuscatePipe } from '@taiga-ui/cdk'
|
||||||
import { TuiButton, TuiIcon } from '@taiga-ui/core'
|
import { TuiButton, TuiIcon } from '@taiga-ui/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { TuiSwitch } from '@taiga-ui/kit'
|
import { TuiBadge, TuiSwitch } from '@taiga-ui/kit'
|
||||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
import { GatewayAddress, MappedServiceInterface } from '../interface.service'
|
import { GatewayAddress, MappedServiceInterface } from '../interface.service'
|
||||||
import { AddressActionsComponent } from './actions.component'
|
import { AddressActionsComponent } from './actions.component'
|
||||||
@@ -36,22 +36,51 @@ import { DomainHealthService } from './domain-health.service'
|
|||||||
(ngModelChange)="onToggleEnabled()"
|
(ngModelChange)="onToggleEnabled()"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="type">
|
<td class="access">
|
||||||
<tui-icon
|
<tui-icon
|
||||||
[icon]="address.access === 'public' ? '@tui.globe' : '@tui.house'"
|
[icon]="address.access === 'public' ? '@tui.globe' : '@tui.house'"
|
||||||
/>
|
/>
|
||||||
{{ address.type }}
|
<span>
|
||||||
|
{{ (address.access === 'public' ? 'Public' : 'Local') | i18n }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="type">
|
||||||
|
<tui-badge
|
||||||
|
size="s"
|
||||||
|
[appearance]="typeAppearance(address.hostnameInfo.metadata.kind)"
|
||||||
|
>
|
||||||
|
{{ address.type }}
|
||||||
|
</tui-badge>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ address.certificate }}
|
<div class="cert">
|
||||||
|
@if (address.certificate === 'Root CA') {
|
||||||
|
<img src="assets/icons/favicon.svg" alt="" class="cert-icon" />
|
||||||
|
} @else if (address.certificate.startsWith("Let's Encrypt")) {
|
||||||
|
<img src="assets/icons/letsencrypt.svg" alt="" class="cert-icon" />
|
||||||
|
} @else if (
|
||||||
|
address.certificate !== '-' && address.certificate !== 'Self signed'
|
||||||
|
) {
|
||||||
|
<tui-icon icon="@tui.shield" class="cert-icon" />
|
||||||
|
}
|
||||||
|
{{ address.certificate }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="url">
|
<div class="url">
|
||||||
<span
|
@if (address.masked && currentlyMasked()) {
|
||||||
[title]="address.masked && currentlyMasked() ? '' : address.url"
|
<span>{{ address.url | tuiObfuscate: 'mask' }}</span>
|
||||||
>
|
} @else {
|
||||||
{{ address.url | tuiObfuscate: recipe() }}
|
<span [title]="address.url">
|
||||||
</span>
|
@if (urlParts(); as parts) {
|
||||||
|
{{ parts.prefix }}
|
||||||
|
<b>{{ parts.hostname }}</b>
|
||||||
|
{{ parts.suffix }}
|
||||||
|
} @else {
|
||||||
|
{{ address.url }}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
@if (address.masked) {
|
@if (address.masked) {
|
||||||
<button
|
<button
|
||||||
tuiIconButton
|
tuiIconButton
|
||||||
@@ -81,12 +110,28 @@ import { DomainHealthService } from './domain-health.service'
|
|||||||
grid-template-columns: fit-content(10rem) 1fr 2rem 2rem;
|
grid-template-columns: fit-content(10rem) 1fr 2rem 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.type tui-icon {
|
.access tui-icon {
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
margin-right: 0.7rem;
|
margin-right: 0.7rem;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cert {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-icon {
|
||||||
|
height: 1.25rem;
|
||||||
|
width: 1.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
tui-icon.cert-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.url {
|
.url {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -104,6 +149,7 @@ import { DomainHealthService } from './domain-health.service'
|
|||||||
|
|
||||||
:host-context(tui-root._mobile) {
|
:host-context(tui-root._mobile) {
|
||||||
padding-inline-start: 0.75rem !important;
|
padding-inline-start: 0.75rem !important;
|
||||||
|
row-gap: 0.25rem;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
@@ -129,18 +175,32 @@ import { DomainHealthService } from './domain-health.service'
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
td:nth-child(2) {
|
.access {
|
||||||
|
padding-right: 0;
|
||||||
|
font: var(--tui-font-text-m);
|
||||||
|
font-weight: bold;
|
||||||
|
|
||||||
|
tui-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.type {
|
||||||
font: var(--tui-font-text-m);
|
font: var(--tui-font-text-m);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--tui-text-primary);
|
color: var(--tui-text-primary);
|
||||||
padding-inline-end: 0.5rem;
|
padding-inline-end: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
td:nth-child(3) {
|
td:nth-child(4) {
|
||||||
grid-area: 2 / 1 / 2 / 3;
|
grid-area: 2 / 1 / 2 / 3;
|
||||||
|
|
||||||
|
.cert-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
td:nth-child(4) {
|
td:nth-child(5) {
|
||||||
grid-area: 3 / 1 / 3 / 3;
|
grid-area: 3 / 1 / 3 / 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +214,7 @@ import { DomainHealthService } from './domain-health.service'
|
|||||||
imports: [
|
imports: [
|
||||||
i18nPipe,
|
i18nPipe,
|
||||||
AddressActionsComponent,
|
AddressActionsComponent,
|
||||||
|
TuiBadge,
|
||||||
TuiButton,
|
TuiButton,
|
||||||
TuiIcon,
|
TuiIcon,
|
||||||
TuiObfuscatePipe,
|
TuiObfuscatePipe,
|
||||||
@@ -180,6 +241,33 @@ export class InterfaceAddressItemComponent {
|
|||||||
this.address()?.masked && this.currentlyMasked() ? 'mask' : 'none',
|
this.address()?.masked && this.currentlyMasked() ? 'mask' : 'none',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
readonly urlParts = computed(() => {
|
||||||
|
const { url, hostnameInfo } = this.address()
|
||||||
|
const idx = url.indexOf(hostnameInfo.hostname)
|
||||||
|
if (idx === -1) return null
|
||||||
|
return {
|
||||||
|
prefix: url.slice(0, idx),
|
||||||
|
hostname: hostnameInfo.hostname,
|
||||||
|
suffix: url.slice(idx + hostnameInfo.hostname.length),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
typeAppearance(kind: string): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'public-domain':
|
||||||
|
case 'private-domain':
|
||||||
|
return 'info'
|
||||||
|
case 'mdns':
|
||||||
|
return 'positive'
|
||||||
|
case 'ipv4':
|
||||||
|
return 'warning'
|
||||||
|
case 'ipv6':
|
||||||
|
return 'neutral'
|
||||||
|
default:
|
||||||
|
return 'neutral'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async onToggleEnabled() {
|
async onToggleEnabled() {
|
||||||
const addr = this.address()
|
const addr = this.address()
|
||||||
const iface = this.value()
|
const iface = this.value()
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
@if (pluginGroup().pluginPkgInfo; as pkgInfo) {
|
@if (pluginGroup().pluginPkgInfo; as pkgInfo) {
|
||||||
<img [src]="pkgInfo.icon" alt="" class="plugin-icon" />
|
<img [src]="pkgInfo.icon" alt="" class="plugin-icon" />
|
||||||
}
|
}
|
||||||
{{ pluginGroup().pluginName }}
|
{{ 'Plugin' | i18n }}: {{ pluginGroup().pluginName }}
|
||||||
@if (pluginGroup().tableAction; as action) {
|
@if (pluginGroup().tableAction; as action) {
|
||||||
<button
|
<button
|
||||||
tuiButton
|
tuiButton
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ function getAddressType(h: T.HostnameInfo): string {
|
|||||||
return 'IPv6'
|
return 'IPv6'
|
||||||
case 'public-domain':
|
case 'public-domain':
|
||||||
case 'private-domain':
|
case 'private-domain':
|
||||||
return h.hostname
|
return 'Domain'
|
||||||
case 'mdns':
|
case 'mdns':
|
||||||
return 'mDNS'
|
return 'mDNS'
|
||||||
case 'plugin':
|
case 'plugin':
|
||||||
@@ -116,7 +116,12 @@ export class InterfaceService {
|
|||||||
gatewayMap.set(gateway.id, gateway)
|
gatewayMap.set(gateway.id, gateway)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const h of addr.available) {
|
const available =
|
||||||
|
this.config.accessType === 'localhost'
|
||||||
|
? addr.available
|
||||||
|
: utils.filterNonLocal(addr.available)
|
||||||
|
|
||||||
|
for (const h of available) {
|
||||||
const gatewayIds = getGatewayIds(h)
|
const gatewayIds = getGatewayIds(h)
|
||||||
for (const gid of gatewayIds) {
|
for (const gid of gatewayIds) {
|
||||||
const list = groupMap.get(gid)
|
const list = groupMap.get(gid)
|
||||||
@@ -337,4 +342,5 @@ export type MappedServiceInterface = T.ServiceInterface & {
|
|||||||
gatewayGroups: GatewayAddressGroup[]
|
gatewayGroups: GatewayAddressGroup[]
|
||||||
pluginGroups: PluginAddressGroup[]
|
pluginGroups: PluginAddressGroup[]
|
||||||
addSsl: boolean
|
addSsl: boolean
|
||||||
|
sharedHostNames: string[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ interface ActionItem {
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [TuiTitle],
|
imports: [TuiTitle],
|
||||||
host: {
|
host: {
|
||||||
'[disabled]': '!!disabled() || inactive()',
|
'[attr.disabled]': '(!!disabled() || inactive()) || null',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class ServiceActionComponent {
|
export class ServiceActionComponent {
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
ALLOWED_STATUSES,
|
ALLOWED_STATUSES,
|
||||||
getInstalledBaseStatus,
|
getInstalledBaseStatus,
|
||||||
INACTIVE_STATUSES,
|
INACTIVE_STATUSES,
|
||||||
renderPkgStatus,
|
|
||||||
} from 'src/app/services/pkg-status-rendering.service'
|
} from 'src/app/services/pkg-status-rendering.service'
|
||||||
import { getManifest } from 'src/app/utils/get-package-data'
|
import { getManifest } from 'src/app/utils/get-package-data'
|
||||||
|
|
||||||
@@ -153,7 +152,7 @@ export class ServiceTaskComponent {
|
|||||||
const action = pkg.actions[this.task().actionId]
|
const action = pkg.actions[this.task().actionId]
|
||||||
if (!action) return this.i18n.transform('Action not found')!
|
if (!action) return this.i18n.transform('Action not found')!
|
||||||
|
|
||||||
const status = renderPkgStatus(pkg).primary
|
const status = getInstalledBaseStatus(pkg.statusInfo)
|
||||||
|
|
||||||
if (INACTIVE_STATUSES.includes(status)) return status as string
|
if (INACTIVE_STATUSES.includes(status)) return status as string
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
|||||||
import { ServiceActionComponent } from '../components/action.component'
|
import { ServiceActionComponent } from '../components/action.component'
|
||||||
import {
|
import {
|
||||||
ALLOWED_STATUSES,
|
ALLOWED_STATUSES,
|
||||||
|
BaseStatus,
|
||||||
|
getInstalledBaseStatus,
|
||||||
INACTIVE_STATUSES,
|
INACTIVE_STATUSES,
|
||||||
PrimaryStatus,
|
|
||||||
renderPkgStatus,
|
|
||||||
} from 'src/app/services/pkg-status-rendering.service'
|
} from 'src/app/services/pkg-status-rendering.service'
|
||||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||||
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
||||||
@@ -108,7 +108,7 @@ export default class ServiceActionsRoute {
|
|||||||
const specialGroup = Object.values(pkg.actions).some(a => !!a.group)
|
const specialGroup = Object.values(pkg.actions).some(a => !!a.group)
|
||||||
? 'Other'
|
? 'Other'
|
||||||
: 'General'
|
: 'General'
|
||||||
const status = renderPkgStatus(pkg).primary
|
const status = getInstalledBaseStatus(pkg.statusInfo)
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
icon: pkg.icon,
|
icon: pkg.icon,
|
||||||
@@ -187,7 +187,7 @@ export default class ServiceActionsRoute {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handle(
|
handle(
|
||||||
status: PrimaryStatus,
|
status: BaseStatus,
|
||||||
icon: string,
|
icon: string,
|
||||||
{ id, title }: T.Manifest,
|
{ id, title }: T.Manifest,
|
||||||
action: T.ActionMetadata & { id: string },
|
action: T.ActionMetadata & { id: string },
|
||||||
|
|||||||
@@ -125,6 +125,10 @@ export default class ServiceInterfaceRoute {
|
|||||||
const binding = host.bindings[port]
|
const binding = host.bindings[port]
|
||||||
const gateways = this.gatewayService.gateways() || []
|
const gateways = this.gatewayService.gateways() || []
|
||||||
|
|
||||||
|
const sharedHostNames = Object.values(serviceInterfaces)
|
||||||
|
.filter(si => si.addressInfo.hostId === key && si.id !== iFace.id)
|
||||||
|
.map(si => si.name)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...iFace,
|
...iFace,
|
||||||
gatewayGroups: this.interfaceService.getGatewayGroups(
|
gatewayGroups: this.interfaceService.getGatewayGroups(
|
||||||
@@ -132,8 +136,13 @@ export default class ServiceInterfaceRoute {
|
|||||||
host,
|
host,
|
||||||
gateways,
|
gateways,
|
||||||
),
|
),
|
||||||
pluginGroups: this.interfaceService.getPluginGroups(iFace, host, this.allPackageData()),
|
pluginGroups: this.interfaceService.getPluginGroups(
|
||||||
|
iFace,
|
||||||
|
host,
|
||||||
|
this.allPackageData(),
|
||||||
|
),
|
||||||
addSsl: !!binding?.options.addSsl,
|
addSsl: !!binding?.options.addSsl,
|
||||||
|
sharedHostNames,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { AuthoritiesTableComponent } from './table.component'
|
|||||||
docsLink
|
docsLink
|
||||||
path="/start-os/user-manual/trust-ca.html"
|
path="/start-os/user-manual/trust-ca.html"
|
||||||
appearance="icon"
|
appearance="icon"
|
||||||
iconStart="@tui.external-link"
|
iconStart="@tui.book-open-text"
|
||||||
>
|
>
|
||||||
{{ 'Documentation' | i18n }}
|
{{ 'Documentation' | i18n }}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const ipv6 =
|
|||||||
docsLink
|
docsLink
|
||||||
path="/start-os/user-manual/dns.html"
|
path="/start-os/user-manual/dns.html"
|
||||||
appearance="icon"
|
appearance="icon"
|
||||||
iconStart="@tui.external-link"
|
iconStart="@tui.book-open-text"
|
||||||
>
|
>
|
||||||
{{ 'Documentation' | i18n }}
|
{{ 'Documentation' | i18n }}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
import { CommonModule } from '@angular/common'
|
|
||||||
import {
|
|
||||||
ChangeDetectionStrategy,
|
|
||||||
Component,
|
|
||||||
inject,
|
|
||||||
signal,
|
|
||||||
} from '@angular/core'
|
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
|
||||||
import { RouterLink } from '@angular/router'
|
|
||||||
import {
|
|
||||||
DialogService,
|
|
||||||
DocsLinkDirective,
|
|
||||||
ErrorService,
|
|
||||||
i18nKey,
|
|
||||||
i18nPipe,
|
|
||||||
LoadingService,
|
|
||||||
} from '@start9labs/shared'
|
|
||||||
import { inputSpec } from '@start9labs/start-sdk'
|
|
||||||
import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core'
|
|
||||||
import { TuiHeader } from '@taiga-ui/layout'
|
|
||||||
import { PatchDB } from 'patch-db-client'
|
|
||||||
import { Subscription, switchMap, tap } from 'rxjs'
|
|
||||||
import { FormGroupComponent } from 'src/app/routes/portal/components/form/containers/group.component'
|
|
||||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
|
||||||
import { FormService } from 'src/app/services/form.service'
|
|
||||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
|
||||||
import { TitleDirective } from 'src/app/services/title.service'
|
|
||||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
|
||||||
|
|
||||||
const PROVIDER_HINTS: Record<string, string> = {
|
|
||||||
gmail:
|
|
||||||
'Requires an App Password. Enable 2FA in your Google account, then generate an App Password.',
|
|
||||||
ses: 'Use SMTP credentials (not IAM credentials). Update the host to match your SES region.',
|
|
||||||
sendgrid:
|
|
||||||
"Username is 'apikey' (literal). Password is your SendGrid API key.",
|
|
||||||
mailgun: 'Use SMTP credentials from your Mailgun domain settings.',
|
|
||||||
protonmail:
|
|
||||||
'Requires a Proton for Business account. Use your Proton email as username.',
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectProviderKey(host: string | undefined): string {
|
|
||||||
if (!host) return 'other'
|
|
||||||
const providers: Record<string, string> = {
|
|
||||||
'smtp.gmail.com': 'gmail',
|
|
||||||
'smtp.sendgrid.net': 'sendgrid',
|
|
||||||
'smtp.mailgun.org': 'mailgun',
|
|
||||||
'smtp.protonmail.ch': 'protonmail',
|
|
||||||
}
|
|
||||||
for (const [h, key] of Object.entries(providers)) {
|
|
||||||
if (host === h) return key
|
|
||||||
}
|
|
||||||
if (host.endsWith('.amazonaws.com')) return 'ses'
|
|
||||||
return 'other'
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
template: `
|
|
||||||
<ng-container *title>
|
|
||||||
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
|
|
||||||
{{ 'Back' | i18n }}
|
|
||||||
</a>
|
|
||||||
{{ 'SMTP' | i18n }}
|
|
||||||
</ng-container>
|
|
||||||
@if (form$ | async; as form) {
|
|
||||||
<form [formGroup]="form">
|
|
||||||
<header tuiHeader="body-l">
|
|
||||||
<h3 tuiTitle>
|
|
||||||
<b>
|
|
||||||
{{ 'SMTP Credentials' | i18n }}
|
|
||||||
<a
|
|
||||||
tuiIconButton
|
|
||||||
size="xs"
|
|
||||||
docsLink
|
|
||||||
path="/start-os/user-manual/smtp.html"
|
|
||||||
appearance="icon"
|
|
||||||
iconStart="@tui.external-link"
|
|
||||||
>
|
|
||||||
{{ 'Documentation' | i18n }}
|
|
||||||
</a>
|
|
||||||
</b>
|
|
||||||
</h3>
|
|
||||||
</header>
|
|
||||||
@if (spec | async; as resolved) {
|
|
||||||
<form-group [spec]="resolved" />
|
|
||||||
}
|
|
||||||
@if (providerHint()) {
|
|
||||||
<p class="provider-hint">{{ providerHint() }}</p>
|
|
||||||
}
|
|
||||||
<footer>
|
|
||||||
@if (isSaved) {
|
|
||||||
<button
|
|
||||||
tuiButton
|
|
||||||
size="l"
|
|
||||||
appearance="secondary-destructive"
|
|
||||||
(click)="save(null)"
|
|
||||||
>
|
|
||||||
{{ 'Delete' | i18n }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<button
|
|
||||||
tuiButton
|
|
||||||
size="l"
|
|
||||||
[disabled]="form.invalid || form.pristine"
|
|
||||||
(click)="save(form.value)"
|
|
||||||
>
|
|
||||||
{{ 'Save' | i18n }}
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</form>
|
|
||||||
<form>
|
|
||||||
<header tuiHeader="body-l">
|
|
||||||
<h3 tuiTitle>
|
|
||||||
<b>{{ 'Send test email' | i18n }}</b>
|
|
||||||
</h3>
|
|
||||||
</header>
|
|
||||||
<tui-textfield>
|
|
||||||
<label tuiLabel>Name Lastname <email@example.com></label>
|
|
||||||
<input
|
|
||||||
tuiTextfield
|
|
||||||
inputmode="email"
|
|
||||||
[(ngModel)]="testAddress"
|
|
||||||
[ngModelOptions]="{ standalone: true }"
|
|
||||||
/>
|
|
||||||
</tui-textfield>
|
|
||||||
<footer>
|
|
||||||
<button
|
|
||||||
tuiButton
|
|
||||||
size="l"
|
|
||||||
[disabled]="!testAddress || form.invalid"
|
|
||||||
(click)="sendTestEmail(form.value)"
|
|
||||||
>
|
|
||||||
{{ 'Send' | i18n }}
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
styles: `
|
|
||||||
:host {
|
|
||||||
max-width: 36rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
form header,
|
|
||||||
form footer {
|
|
||||||
margin: 1rem 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.provider-hint {
|
|
||||||
margin: 0.5rem 0 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
ReactiveFormsModule,
|
|
||||||
FormGroupComponent,
|
|
||||||
TuiButton,
|
|
||||||
TuiTextfield,
|
|
||||||
TuiHeader,
|
|
||||||
TuiTitle,
|
|
||||||
RouterLink,
|
|
||||||
TitleDirective,
|
|
||||||
i18nPipe,
|
|
||||||
DocsLinkDirective,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export default class SystemEmailComponent {
|
|
||||||
private readonly dialog = inject(DialogService)
|
|
||||||
private readonly loader = inject(LoadingService)
|
|
||||||
private readonly errorService = inject(ErrorService)
|
|
||||||
private readonly formService = inject(FormService)
|
|
||||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
|
||||||
private readonly api = inject(ApiService)
|
|
||||||
private readonly i18n = inject(i18nPipe)
|
|
||||||
|
|
||||||
readonly providerHint = signal('')
|
|
||||||
private providerSub: Subscription | null = null
|
|
||||||
|
|
||||||
testAddress = ''
|
|
||||||
isSaved = false
|
|
||||||
|
|
||||||
readonly spec = configBuilderToSpec(inputSpec.constants.systemSmtpSpec)
|
|
||||||
|
|
||||||
readonly form$ = this.patch.watch$('serverInfo', 'smtp').pipe(
|
|
||||||
tap(value => {
|
|
||||||
this.isSaved = !!value
|
|
||||||
}),
|
|
||||||
switchMap(async value => {
|
|
||||||
const spec = await this.spec
|
|
||||||
const formData = value
|
|
||||||
? { provider: { selection: detectProviderKey(value.host), value } }
|
|
||||||
: undefined
|
|
||||||
const form = this.formService.createForm(spec, formData)
|
|
||||||
|
|
||||||
// Watch provider selection for hints
|
|
||||||
this.providerSub?.unsubscribe()
|
|
||||||
const selectionCtrl = form.get('provider.selection')
|
|
||||||
if (selectionCtrl) {
|
|
||||||
this.providerHint.set(PROVIDER_HINTS[selectionCtrl.value] || '')
|
|
||||||
this.providerSub = selectionCtrl.valueChanges.subscribe(key => {
|
|
||||||
this.providerHint.set(PROVIDER_HINTS[key] || '')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return form
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
async save(formValue: Record<string, any> | null): Promise<void> {
|
|
||||||
const loader = this.loader.open('Saving').subscribe()
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (formValue) {
|
|
||||||
await this.api.setSmtp(formValue['provider'].value)
|
|
||||||
this.isSaved = true
|
|
||||||
} else {
|
|
||||||
await this.api.clearSmtp({})
|
|
||||||
this.isSaved = false
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
this.errorService.handleError(e)
|
|
||||||
} finally {
|
|
||||||
loader.unsubscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendTestEmail(formValue: Record<string, any>) {
|
|
||||||
const smtpValue = formValue['provider'].value
|
|
||||||
const loader = this.loader.open('Sending email').subscribe()
|
|
||||||
const success =
|
|
||||||
`${this.i18n.transform('A test email has been sent to')} ${this.testAddress}. <i>${this.i18n.transform('Check your spam folder and mark as not spam.')}</i>` as i18nKey
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.api.testSmtp({
|
|
||||||
...smtpValue,
|
|
||||||
password: smtpValue.password || '',
|
|
||||||
to: this.testAddress,
|
|
||||||
})
|
|
||||||
this.dialog
|
|
||||||
.openAlert(success, { label: 'Success', size: 's' })
|
|
||||||
.subscribe()
|
|
||||||
this.testAddress = ''
|
|
||||||
} catch (e: any) {
|
|
||||||
this.errorService.handleError(e)
|
|
||||||
} finally {
|
|
||||||
loader.unsubscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { CommonModule } from '@angular/common'
|
import { CommonModule } from '@angular/common'
|
||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
linkedSignal,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
import { RouterLink } from '@angular/router'
|
import { RouterLink } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
DocsLinkDirective,
|
DocsLinkDirective,
|
||||||
@@ -7,14 +14,18 @@ import {
|
|||||||
i18nPipe,
|
i18nPipe,
|
||||||
LoadingService,
|
LoadingService,
|
||||||
} from '@start9labs/shared'
|
} from '@start9labs/shared'
|
||||||
import { TuiButton } from '@taiga-ui/core'
|
|
||||||
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
|
||||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
|
||||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
|
||||||
import { GatewaysTableComponent } from './table.component'
|
|
||||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
|
||||||
import { TitleDirective } from 'src/app/services/title.service'
|
|
||||||
import { ISB } from '@start9labs/start-sdk'
|
import { ISB } from '@start9labs/start-sdk'
|
||||||
|
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
|
||||||
|
import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core'
|
||||||
|
import { TuiChevron, TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit'
|
||||||
|
import { TuiHeader } from '@taiga-ui/layout'
|
||||||
|
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
||||||
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
|
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||||
|
import { GatewayService } from 'src/app/services/gateway.service'
|
||||||
|
import { TitleDirective } from 'src/app/services/title.service'
|
||||||
|
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||||
|
import { GatewaysTableComponent } from './table.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: `
|
template: `
|
||||||
@@ -34,7 +45,7 @@ import { ISB } from '@start9labs/start-sdk'
|
|||||||
docsLink
|
docsLink
|
||||||
path="/start-os/user-manual/gateways.html"
|
path="/start-os/user-manual/gateways.html"
|
||||||
appearance="icon"
|
appearance="icon"
|
||||||
iconStart="@tui.external-link"
|
iconStart="@tui.book-open-text"
|
||||||
>
|
>
|
||||||
{{ 'Documentation' | i18n }}
|
{{ 'Documentation' | i18n }}
|
||||||
</a>
|
</a>
|
||||||
@@ -50,12 +61,99 @@ import { ISB } from '@start9labs/start-sdk'
|
|||||||
</header>
|
</header>
|
||||||
<gateways-table />
|
<gateways-table />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@if (outboundOptions(); as options) {
|
||||||
|
<section class="outbound">
|
||||||
|
<header tuiHeader="body-l">
|
||||||
|
<h3 tuiTitle>
|
||||||
|
<b>
|
||||||
|
{{ 'Outbound Traffic' | i18n }}
|
||||||
|
<a
|
||||||
|
tuiIconButton
|
||||||
|
size="xs"
|
||||||
|
docsLink
|
||||||
|
path="/start-os/user-manual/gateways.html"
|
||||||
|
fragment="#outbound-traffic"
|
||||||
|
appearance="icon"
|
||||||
|
iconStart="@tui.book-open-text"
|
||||||
|
>
|
||||||
|
{{ 'Documentation' | i18n }}
|
||||||
|
</a>
|
||||||
|
</b>
|
||||||
|
</h3>
|
||||||
|
</header>
|
||||||
|
<tui-textfield
|
||||||
|
tuiChevron
|
||||||
|
[stringify]="stringifyOutbound"
|
||||||
|
[tuiTextfieldCleaner]="false"
|
||||||
|
>
|
||||||
|
<label tuiLabel>{{ 'Use gateway' | i18n }}</label>
|
||||||
|
@if (mobile) {
|
||||||
|
<select
|
||||||
|
tuiSelect
|
||||||
|
[ngModel]="selectedOutbound()"
|
||||||
|
(ngModelChange)="selectedOutbound.set($event)"
|
||||||
|
[items]="options"
|
||||||
|
></select>
|
||||||
|
} @else {
|
||||||
|
<input
|
||||||
|
tuiSelect
|
||||||
|
[ngModel]="selectedOutbound()"
|
||||||
|
(ngModelChange)="selectedOutbound.set($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@if (!mobile) {
|
||||||
|
<tui-data-list-wrapper
|
||||||
|
new
|
||||||
|
*tuiTextfieldDropdown
|
||||||
|
[items]="options"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</tui-textfield>
|
||||||
|
<footer>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
[disabled]="
|
||||||
|
selectedOutbound()?.id ===
|
||||||
|
(gatewayService.defaultOutbound() ?? null)
|
||||||
|
"
|
||||||
|
(click)="saveOutbound()"
|
||||||
|
>
|
||||||
|
{{ 'Save' | i18n }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
.outbound {
|
||||||
|
max-width: 24rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outbound header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outbound footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
providers: [GatewayService],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
TuiButton,
|
TuiButton,
|
||||||
|
TuiTextfield,
|
||||||
|
TuiTitle,
|
||||||
|
TuiChevron,
|
||||||
|
TuiSelect,
|
||||||
|
TuiDataListWrapper,
|
||||||
|
TuiHeader,
|
||||||
GatewaysTableComponent,
|
GatewaysTableComponent,
|
||||||
TitleDirective,
|
TitleDirective,
|
||||||
i18nPipe,
|
i18nPipe,
|
||||||
@@ -68,6 +166,48 @@ export default class GatewaysComponent {
|
|||||||
private readonly api = inject(ApiService)
|
private readonly api = inject(ApiService)
|
||||||
private readonly formDialog = inject(FormDialogService)
|
private readonly formDialog = inject(FormDialogService)
|
||||||
private readonly i18n = inject(i18nPipe)
|
private readonly i18n = inject(i18nPipe)
|
||||||
|
readonly gatewayService = inject(GatewayService)
|
||||||
|
readonly mobile = inject(TUI_IS_MOBILE)
|
||||||
|
|
||||||
|
private readonly autoOption = {
|
||||||
|
id: null,
|
||||||
|
name: this.i18n.transform('Auto') ?? 'Auto',
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly outboundOptions = computed(() => {
|
||||||
|
const gateways = this.gatewayService.gateways()
|
||||||
|
if (!gateways) return null
|
||||||
|
return [
|
||||||
|
this.autoOption,
|
||||||
|
...gateways.map(g => ({ id: g.id as string | null, name: g.name })),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
readonly selectedOutbound = linkedSignal(() => {
|
||||||
|
const options = this.outboundOptions()
|
||||||
|
const defaultId = this.gatewayService.defaultOutbound() ?? null
|
||||||
|
if (options) {
|
||||||
|
return options.find(o => o.id === defaultId) ?? options[0]
|
||||||
|
}
|
||||||
|
return this.autoOption
|
||||||
|
})
|
||||||
|
|
||||||
|
readonly stringifyOutbound = (opt: { id: string | null; name: string }) =>
|
||||||
|
opt.name
|
||||||
|
|
||||||
|
async saveOutbound() {
|
||||||
|
const loader = this.loader.open('Saving').subscribe()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.api.setDefaultOutbound({
|
||||||
|
gateway: this.selectedOutbound()?.id ?? null,
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async add() {
|
async add() {
|
||||||
const spec = ISB.InputSpec.of({
|
const spec = ISB.InputSpec.of({
|
||||||
@@ -108,13 +248,6 @@ export default class GatewaysComponent {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
setAsDefaultOutbound: ISB.Value.toggle({
|
|
||||||
name: this.i18n.transform('Set as default outbound'),
|
|
||||||
description: this.i18n.transform(
|
|
||||||
'Route all outbound traffic through this gateway',
|
|
||||||
),
|
|
||||||
default: false,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.formDialog.open(FormComponent, {
|
this.formDialog.open(FormComponent, {
|
||||||
@@ -135,7 +268,7 @@ export default class GatewaysComponent {
|
|||||||
? input.config.value.file
|
? input.config.value.file
|
||||||
: await (input.config.value.file as any as File).text(),
|
: await (input.config.value.file as any as File).text(),
|
||||||
type: null, // @TODO Aiden why is attr here?
|
type: null, // @TODO Aiden why is attr here?
|
||||||
setAsDefaultOutbound: input.setAsDefaultOutbound,
|
setAsDefaultOutbound: false,
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -23,9 +23,8 @@ import { filter } from 'rxjs'
|
|||||||
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
||||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
|
||||||
import { GatewayPlus } from 'src/app/services/gateway.service'
|
import { GatewayPlus } from 'src/app/services/gateway.service'
|
||||||
import { TuiBadge } from '@taiga-ui/kit'
|
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||||
import { PORT_FORWARDS_MODAL } from './port-forwards.component'
|
import { PORT_FORWARDS_MODAL } from './port-forwards.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -45,11 +44,6 @@ import { PORT_FORWARDS_MODAL } from './port-forwards.component'
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
{{ gateway.name }}
|
{{ gateway.name }}
|
||||||
@if (gateway.isDefaultOutbound) {
|
|
||||||
<tui-badge appearance="primary-success">
|
|
||||||
{{ 'default outbound' | i18n }}
|
|
||||||
</tui-badge>
|
|
||||||
}
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@if (gateway.type === 'outbound-only') {
|
@if (gateway.type === 'outbound-only') {
|
||||||
@@ -91,13 +85,6 @@ import { PORT_FORWARDS_MODAL } from './port-forwards.component'
|
|||||||
</button>
|
</button>
|
||||||
</tui-opt-group>
|
</tui-opt-group>
|
||||||
}
|
}
|
||||||
@if (!gateway.isDefaultOutbound) {
|
|
||||||
<tui-opt-group>
|
|
||||||
<button tuiOption new (click)="setDefaultOutbound()">
|
|
||||||
{{ 'Set as default outbound' | i18n }}
|
|
||||||
</button>
|
|
||||||
</tui-opt-group>
|
|
||||||
}
|
|
||||||
@if (gateway.ipInfo.deviceType === 'wireguard') {
|
@if (gateway.ipInfo.deviceType === 'wireguard') {
|
||||||
<tui-opt-group>
|
<tui-opt-group>
|
||||||
<button tuiOption new class="g-negative" (click)="remove()">
|
<button tuiOption new class="g-negative" (click)="remove()">
|
||||||
@@ -116,8 +103,8 @@ import { PORT_FORWARDS_MODAL } from './port-forwards.component'
|
|||||||
margin-right: 0.7rem;
|
margin-right: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
tui-badge {
|
td:first-child {
|
||||||
margin-left: 1rem;
|
width: 24rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
td:last-child {
|
td:last-child {
|
||||||
@@ -171,7 +158,6 @@ import { PORT_FORWARDS_MODAL } from './port-forwards.component'
|
|||||||
TuiOptGroup,
|
TuiOptGroup,
|
||||||
TuiTextfield,
|
TuiTextfield,
|
||||||
i18nPipe,
|
i18nPipe,
|
||||||
TuiBadge,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class GatewaysItemComponent {
|
export class GatewaysItemComponent {
|
||||||
@@ -214,18 +200,6 @@ export class GatewaysItemComponent {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async setDefaultOutbound() {
|
|
||||||
const loader = this.loader.open().subscribe()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.api.setDefaultOutbound({ gateway: this.gateway().id })
|
|
||||||
} catch (e: any) {
|
|
||||||
this.errorService.handleError(e)
|
|
||||||
} finally {
|
|
||||||
loader.unsubscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async rename() {
|
async rename() {
|
||||||
const { id, name } = this.gateway()
|
const { id, name } = this.gateway()
|
||||||
const renameSpec = ISB.InputSpec.of({
|
const renameSpec = ISB.InputSpec.of({
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { GatewayService } from 'src/app/services/gateway.service'
|
|||||||
</table>
|
</table>
|
||||||
`,
|
`,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
providers: [GatewayService],
|
|
||||||
imports: [TuiSkeleton, i18nPipe, TableComponent, GatewaysItemComponent],
|
imports: [TuiSkeleton, i18nPipe, TableComponent, GatewaysItemComponent],
|
||||||
})
|
})
|
||||||
export class GatewaysTableComponent {
|
export class GatewaysTableComponent {
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
|
import { FormControl, ReactiveFormsModule } from '@angular/forms'
|
||||||
|
import { RouterLink } from '@angular/router'
|
||||||
|
import {
|
||||||
|
DialogService,
|
||||||
|
DocsLinkDirective,
|
||||||
|
ErrorService,
|
||||||
|
i18nKey,
|
||||||
|
i18nPipe,
|
||||||
|
LoadingService,
|
||||||
|
} from '@start9labs/shared'
|
||||||
|
import { inputSpec, ISB, utils } from '@start9labs/start-sdk'
|
||||||
|
import { TuiButton, TuiError, TuiTextfield, TuiTitle } from '@taiga-ui/core'
|
||||||
|
import { TuiHeader } from '@taiga-ui/layout'
|
||||||
|
import { PatchDB } from 'patch-db-client'
|
||||||
|
import { switchMap } from 'rxjs'
|
||||||
|
import { FormGroupComponent } from 'src/app/routes/portal/components/form/containers/group.component'
|
||||||
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
|
import { FormService } from 'src/app/services/form.service'
|
||||||
|
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||||
|
import { TitleDirective } from 'src/app/services/title.service'
|
||||||
|
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||||
|
|
||||||
|
function detectProviderKey(host: string | undefined): string {
|
||||||
|
if (!host) return 'other'
|
||||||
|
const providers: Record<string, string> = {
|
||||||
|
'smtp.gmail.com': 'gmail',
|
||||||
|
'smtp.sendgrid.net': 'sendgrid',
|
||||||
|
'smtp.mailgun.org': 'mailgun',
|
||||||
|
'smtp.protonmail.ch': 'protonmail',
|
||||||
|
}
|
||||||
|
for (const [h, key] of Object.entries(providers)) {
|
||||||
|
if (host === h) return key
|
||||||
|
}
|
||||||
|
if (host.endsWith('.amazonaws.com')) return 'ses'
|
||||||
|
return 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<ng-container *title>
|
||||||
|
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
|
||||||
|
{{ 'Back' | i18n }}
|
||||||
|
</a>
|
||||||
|
SMTP
|
||||||
|
</ng-container>
|
||||||
|
@if (form$ | async; as data) {
|
||||||
|
<form [formGroup]="data.form">
|
||||||
|
<header tuiHeader="body-l">
|
||||||
|
<h3 tuiTitle>
|
||||||
|
<b>
|
||||||
|
SMTP
|
||||||
|
<a
|
||||||
|
tuiIconButton
|
||||||
|
size="xs"
|
||||||
|
docsLink
|
||||||
|
path="/start-os/user-manual/smtp.html"
|
||||||
|
appearance="icon"
|
||||||
|
iconStart="@tui.book-open-text"
|
||||||
|
>
|
||||||
|
{{ 'Documentation' | i18n }}
|
||||||
|
</a>
|
||||||
|
</b>
|
||||||
|
</h3>
|
||||||
|
</header>
|
||||||
|
<form-group [spec]="data.spec" />
|
||||||
|
<footer>
|
||||||
|
@if (!data.form.pristine) {
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
size="l"
|
||||||
|
appearance="secondary"
|
||||||
|
(click)="cancel(data)"
|
||||||
|
>
|
||||||
|
{{ 'Cancel' | i18n }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
size="l"
|
||||||
|
[disabled]="data.form.invalid || data.form.pristine"
|
||||||
|
(click)="save(data.form.value)"
|
||||||
|
>
|
||||||
|
{{ 'Save' | i18n }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
@if (data.form.value.smtp?.selection === 'enabled') {
|
||||||
|
<form>
|
||||||
|
<header tuiHeader="body-l">
|
||||||
|
<h3 tuiTitle>
|
||||||
|
<b>{{ 'Send test email' | i18n }}</b>
|
||||||
|
</h3>
|
||||||
|
</header>
|
||||||
|
<tui-textfield>
|
||||||
|
<label tuiLabel>email@example.com</label>
|
||||||
|
<input
|
||||||
|
tuiTextfield
|
||||||
|
inputmode="email"
|
||||||
|
[formControl]="testEmailControl"
|
||||||
|
/>
|
||||||
|
</tui-textfield>
|
||||||
|
<tui-error
|
||||||
|
[error]="
|
||||||
|
!testEmailControl.pristine && isEmailInvalid
|
||||||
|
? ('Must be a valid email address' | i18n)
|
||||||
|
: null
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<footer>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
size="l"
|
||||||
|
[disabled]="
|
||||||
|
!testEmailControl.value || isEmailInvalid || data.form.invalid
|
||||||
|
"
|
||||||
|
(click)="sendTestEmail(data.form.value)"
|
||||||
|
>
|
||||||
|
{{ 'Send' | i18n }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host {
|
||||||
|
max-width: 36rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
form header,
|
||||||
|
form footer {
|
||||||
|
margin: 1rem 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
FormGroupComponent,
|
||||||
|
TuiButton,
|
||||||
|
TuiError,
|
||||||
|
TuiTextfield,
|
||||||
|
TuiHeader,
|
||||||
|
TuiTitle,
|
||||||
|
RouterLink,
|
||||||
|
TitleDirective,
|
||||||
|
i18nPipe,
|
||||||
|
DocsLinkDirective,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export default class SystemEmailComponent {
|
||||||
|
private readonly dialog = inject(DialogService)
|
||||||
|
private readonly loader = inject(LoadingService)
|
||||||
|
private readonly errorService = inject(ErrorService)
|
||||||
|
private readonly formService = inject(FormService)
|
||||||
|
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
private readonly i18n = inject(i18nPipe)
|
||||||
|
|
||||||
|
private readonly emailRegex = new RegExp(utils.Patterns.email.regex)
|
||||||
|
readonly testEmailControl = new FormControl('')
|
||||||
|
|
||||||
|
get isEmailInvalid(): boolean {
|
||||||
|
const value = this.testEmailControl.value
|
||||||
|
return !!value && !this.emailRegex.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly smtpSpec = ISB.InputSpec.of({
|
||||||
|
smtp: ISB.Value.union({
|
||||||
|
name: this.i18n.transform('SMTP'),
|
||||||
|
default: 'disabled',
|
||||||
|
variants: ISB.Variants.of({
|
||||||
|
disabled: {
|
||||||
|
name: this.i18n.transform('Disabled'),
|
||||||
|
spec: ISB.InputSpec.of({}),
|
||||||
|
},
|
||||||
|
enabled: {
|
||||||
|
name: this.i18n.transform('Enabled'),
|
||||||
|
spec: inputSpec.constants.systemSmtpSpec,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
readonly form$ = this.patch.watch$('serverInfo', 'smtp').pipe(
|
||||||
|
switchMap(async value => {
|
||||||
|
const spec = await configBuilderToSpec(this.smtpSpec)
|
||||||
|
|
||||||
|
const formData = value
|
||||||
|
? {
|
||||||
|
smtp: {
|
||||||
|
selection: 'enabled' as const,
|
||||||
|
value: {
|
||||||
|
provider: {
|
||||||
|
selection: detectProviderKey(value.host),
|
||||||
|
value: {
|
||||||
|
host: value.host,
|
||||||
|
security: {
|
||||||
|
selection: value.security,
|
||||||
|
value: { port: String(value.port) },
|
||||||
|
},
|
||||||
|
from: value.from,
|
||||||
|
username: value.username,
|
||||||
|
password: value.password,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
const form = this.formService.createForm(spec, formData)
|
||||||
|
|
||||||
|
return { form, spec, formData }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
private getSmtpValue(formValue: Record<string, any>) {
|
||||||
|
const { security, ...rest } = formValue['smtp'].value.provider.value
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
security: security.selection,
|
||||||
|
port: Number(security.value.port),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(formValue: Record<string, any>): Promise<void> {
|
||||||
|
const loader = this.loader.open('Saving').subscribe()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (formValue['smtp'].selection === 'disabled') {
|
||||||
|
await this.api.clearSmtp({})
|
||||||
|
} else {
|
||||||
|
await this.api.setSmtp(this.getSmtpValue(formValue))
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(data: {
|
||||||
|
form: ReturnType<FormService['createForm']>
|
||||||
|
formData: Record<string, any> | undefined
|
||||||
|
}) {
|
||||||
|
data.form.reset(data.formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendTestEmail(formValue: Record<string, any>) {
|
||||||
|
const smtpValue = this.getSmtpValue(formValue)
|
||||||
|
const address = this.testEmailControl.value!
|
||||||
|
const loader = this.loader.open('Sending email').subscribe()
|
||||||
|
const success =
|
||||||
|
`${this.i18n.transform('A test email has been sent to')} ${address}. <i>${this.i18n.transform('Check your spam folder and mark as not spam.')}</i>` as i18nKey
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.api.testSmtp({
|
||||||
|
...smtpValue,
|
||||||
|
password: smtpValue.password || '',
|
||||||
|
to: address,
|
||||||
|
})
|
||||||
|
this.dialog
|
||||||
|
.openAlert(success, { label: 'Success', size: 's' })
|
||||||
|
.subscribe()
|
||||||
|
this.testEmailControl.reset()
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ import { SSHTableComponent } from './table.component'
|
|||||||
docsLink
|
docsLink
|
||||||
path="/start-os/user-manual/ssh.html"
|
path="/start-os/user-manual/ssh.html"
|
||||||
appearance="icon"
|
appearance="icon"
|
||||||
iconStart="@tui.external-link"
|
iconStart="@tui.book-open-text"
|
||||||
>
|
>
|
||||||
{{ 'Documentation' | i18n }}
|
{{ 'Documentation' | i18n }}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -73,9 +73,7 @@ export default class StartOsUiComponent {
|
|||||||
|
|
||||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||||
|
|
||||||
readonly network = toSignal(
|
readonly network = toSignal(this.patch.watch$('serverInfo', 'network'))
|
||||||
this.patch.watch$('serverInfo', 'network'),
|
|
||||||
)
|
|
||||||
|
|
||||||
readonly allPackageData = toSignal(this.patch.watch$('packageData'))
|
readonly allPackageData = toSignal(this.patch.watch$('packageData'))
|
||||||
|
|
||||||
@@ -98,6 +96,7 @@ export default class StartOsUiComponent {
|
|||||||
this.allPackageData(),
|
this.allPackageData(),
|
||||||
),
|
),
|
||||||
addSsl: true,
|
addSsl: true,
|
||||||
|
sharedHostNames: [],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ import { wifiSpec } from './wifi.const'
|
|||||||
docsLink
|
docsLink
|
||||||
path="/start-os/user-manual/wifi.html"
|
path="/start-os/user-manual/wifi.html"
|
||||||
appearance="icon"
|
appearance="icon"
|
||||||
iconStart="@tui.external-link"
|
iconStart="@tui.book-open-text"
|
||||||
>
|
>
|
||||||
{{ 'Documentation' | i18n }}
|
{{ 'Documentation' | i18n }}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default [
|
|||||||
{
|
{
|
||||||
path: 'email',
|
path: 'email',
|
||||||
title: titleResolver,
|
title: titleResolver,
|
||||||
loadComponent: () => import('./routes/email/email.component'),
|
loadComponent: () => import('./routes/smtp/smtp.component'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'backup',
|
path: 'backup',
|
||||||
|
|||||||
@@ -340,11 +340,13 @@ export abstract class ApiService {
|
|||||||
|
|
||||||
abstract osUiAddPublicDomain(
|
abstract osUiAddPublicDomain(
|
||||||
params: T.AddPublicDomainParams,
|
params: T.AddPublicDomainParams,
|
||||||
): Promise<string | null>
|
): Promise<T.AddPublicDomainRes>
|
||||||
|
|
||||||
abstract osUiRemovePublicDomain(params: T.RemoveDomainParams): Promise<null>
|
abstract osUiRemovePublicDomain(params: T.RemoveDomainParams): Promise<null>
|
||||||
|
|
||||||
abstract osUiAddPrivateDomain(params: T.AddPrivateDomainParams): Promise<null>
|
abstract osUiAddPrivateDomain(
|
||||||
|
params: T.AddPrivateDomainParams,
|
||||||
|
): Promise<boolean>
|
||||||
|
|
||||||
abstract osUiRemovePrivateDomain(params: T.RemoveDomainParams): Promise<null>
|
abstract osUiRemovePrivateDomain(params: T.RemoveDomainParams): Promise<null>
|
||||||
|
|
||||||
@@ -354,13 +356,15 @@ export abstract class ApiService {
|
|||||||
|
|
||||||
abstract pkgAddPublicDomain(
|
abstract pkgAddPublicDomain(
|
||||||
params: PkgAddPublicDomainReq,
|
params: PkgAddPublicDomainReq,
|
||||||
): Promise<string | null>
|
): Promise<T.AddPublicDomainRes>
|
||||||
|
|
||||||
abstract pkgRemovePublicDomain(
|
abstract pkgRemovePublicDomain(
|
||||||
params: PkgRemovePublicDomainReq,
|
params: PkgRemovePublicDomainReq,
|
||||||
): Promise<null>
|
): Promise<null>
|
||||||
|
|
||||||
abstract pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise<null>
|
abstract pkgAddPrivateDomain(
|
||||||
|
params: PkgAddPrivateDomainReq,
|
||||||
|
): Promise<boolean>
|
||||||
|
|
||||||
abstract pkgRemovePrivateDomain(
|
abstract pkgRemovePrivateDomain(
|
||||||
params: PkgRemovePrivateDomainReq,
|
params: PkgRemovePrivateDomainReq,
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ export class LiveApiService extends ApiService {
|
|||||||
// wifi
|
// wifi
|
||||||
|
|
||||||
async enableWifi(params: T.SetWifiEnabledParams): Promise<null> {
|
async enableWifi(params: T.SetWifiEnabledParams): Promise<null> {
|
||||||
return this.rpcRequest({ method: 'wifi.enable', params })
|
return this.rpcRequest({ method: 'wifi.set-enabled', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
async getWifi(params: {}, timeout?: number): Promise<T.WifiListInfo> {
|
async getWifi(params: {}, timeout?: number): Promise<T.WifiListInfo> {
|
||||||
@@ -630,7 +630,7 @@ export class LiveApiService extends ApiService {
|
|||||||
|
|
||||||
async osUiAddPublicDomain(
|
async osUiAddPublicDomain(
|
||||||
params: T.AddPublicDomainParams,
|
params: T.AddPublicDomainParams,
|
||||||
): Promise<string | null> {
|
): Promise<T.AddPublicDomainRes> {
|
||||||
return this.rpcRequest({
|
return this.rpcRequest({
|
||||||
method: 'server.host.address.domain.public.add',
|
method: 'server.host.address.domain.public.add',
|
||||||
params,
|
params,
|
||||||
@@ -644,7 +644,9 @@ export class LiveApiService extends ApiService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async osUiAddPrivateDomain(params: T.AddPrivateDomainParams): Promise<null> {
|
async osUiAddPrivateDomain(
|
||||||
|
params: T.AddPrivateDomainParams,
|
||||||
|
): Promise<boolean> {
|
||||||
return this.rpcRequest({
|
return this.rpcRequest({
|
||||||
method: 'server.host.address.domain.private.add',
|
method: 'server.host.address.domain.private.add',
|
||||||
params,
|
params,
|
||||||
@@ -669,7 +671,7 @@ export class LiveApiService extends ApiService {
|
|||||||
|
|
||||||
async pkgAddPublicDomain(
|
async pkgAddPublicDomain(
|
||||||
params: PkgAddPublicDomainReq,
|
params: PkgAddPublicDomainReq,
|
||||||
): Promise<string | null> {
|
): Promise<T.AddPublicDomainRes> {
|
||||||
return this.rpcRequest({
|
return this.rpcRequest({
|
||||||
method: 'package.host.address.domain.public.add',
|
method: 'package.host.address.domain.public.add',
|
||||||
params,
|
params,
|
||||||
@@ -683,7 +685,7 @@ export class LiveApiService extends ApiService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise<null> {
|
async pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise<boolean> {
|
||||||
return this.rpcRequest({
|
return this.rpcRequest({
|
||||||
method: 'package.host.address.domain.private.add',
|
method: 'package.host.address.domain.private.add',
|
||||||
params,
|
params,
|
||||||
|
|||||||
@@ -1440,7 +1440,7 @@ export class MockApiService extends ApiService {
|
|||||||
|
|
||||||
async osUiAddPublicDomain(
|
async osUiAddPublicDomain(
|
||||||
params: T.AddPublicDomainParams,
|
params: T.AddPublicDomainParams,
|
||||||
): Promise<string | null> {
|
): Promise<T.AddPublicDomainRes> {
|
||||||
await pauseFor(2000)
|
await pauseFor(2000)
|
||||||
|
|
||||||
const patch: Operation<any>[] = [
|
const patch: Operation<any>[] = [
|
||||||
@@ -1465,7 +1465,16 @@ export class MockApiService extends ApiService {
|
|||||||
]
|
]
|
||||||
this.mockRevision(patch)
|
this.mockRevision(patch)
|
||||||
|
|
||||||
return null
|
return {
|
||||||
|
dns: null,
|
||||||
|
port: {
|
||||||
|
ip: '0.0.0.0',
|
||||||
|
port: 443,
|
||||||
|
openExternally: false,
|
||||||
|
openInternally: false,
|
||||||
|
hairpinning: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async osUiRemovePublicDomain(params: T.RemoveDomainParams): Promise<null> {
|
async osUiRemovePublicDomain(params: T.RemoveDomainParams): Promise<null> {
|
||||||
@@ -1482,7 +1491,9 @@ export class MockApiService extends ApiService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
async osUiAddPrivateDomain(params: T.AddPrivateDomainParams): Promise<null> {
|
async osUiAddPrivateDomain(
|
||||||
|
params: T.AddPrivateDomainParams,
|
||||||
|
): Promise<boolean> {
|
||||||
await pauseFor(2000)
|
await pauseFor(2000)
|
||||||
|
|
||||||
const patch: Operation<any>[] = [
|
const patch: Operation<any>[] = [
|
||||||
@@ -1505,7 +1516,7 @@ export class MockApiService extends ApiService {
|
|||||||
]
|
]
|
||||||
this.mockRevision(patch)
|
this.mockRevision(patch)
|
||||||
|
|
||||||
return null
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
async osUiRemovePrivateDomain(params: T.RemoveDomainParams): Promise<null> {
|
async osUiRemovePrivateDomain(params: T.RemoveDomainParams): Promise<null> {
|
||||||
@@ -1535,7 +1546,7 @@ export class MockApiService extends ApiService {
|
|||||||
|
|
||||||
async pkgAddPublicDomain(
|
async pkgAddPublicDomain(
|
||||||
params: PkgAddPublicDomainReq,
|
params: PkgAddPublicDomainReq,
|
||||||
): Promise<string | null> {
|
): Promise<T.AddPublicDomainRes> {
|
||||||
await pauseFor(2000)
|
await pauseFor(2000)
|
||||||
|
|
||||||
const patch: Operation<any>[] = [
|
const patch: Operation<any>[] = [
|
||||||
@@ -1560,7 +1571,16 @@ export class MockApiService extends ApiService {
|
|||||||
]
|
]
|
||||||
this.mockRevision(patch)
|
this.mockRevision(patch)
|
||||||
|
|
||||||
return null
|
return {
|
||||||
|
dns: null,
|
||||||
|
port: {
|
||||||
|
ip: '0.0.0.0',
|
||||||
|
port: 443,
|
||||||
|
openExternally: false,
|
||||||
|
openInternally: false,
|
||||||
|
hairpinning: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async pkgRemovePublicDomain(params: PkgRemovePublicDomainReq): Promise<null> {
|
async pkgRemovePublicDomain(params: PkgRemovePublicDomainReq): Promise<null> {
|
||||||
@@ -1577,7 +1597,9 @@ export class MockApiService extends ApiService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
async pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise<null> {
|
async pkgAddPrivateDomain(
|
||||||
|
params: PkgAddPrivateDomainReq,
|
||||||
|
): Promise<boolean> {
|
||||||
await pauseFor(2000)
|
await pauseFor(2000)
|
||||||
|
|
||||||
const patch: Operation<any>[] = [
|
const patch: Operation<any>[] = [
|
||||||
@@ -1600,7 +1622,7 @@ export class MockApiService extends ApiService {
|
|||||||
]
|
]
|
||||||
this.mockRevision(patch)
|
this.mockRevision(patch)
|
||||||
|
|
||||||
return null
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
async pkgRemovePrivateDomain(
|
async pkgRemovePrivateDomain(
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ export const mockPatchData: DataModel = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
passthroughs: [],
|
||||||
defaultOutbound: 'eth0',
|
defaultOutbound: 'eth0',
|
||||||
dns: {
|
dns: {
|
||||||
dhcpServers: ['1.1.1.1', '8.8.8.8'],
|
dhcpServers: ['1.1.1.1', '8.8.8.8'],
|
||||||
@@ -651,7 +652,7 @@ export const mockPatchData: DataModel = {
|
|||||||
publicDomains: {
|
publicDomains: {
|
||||||
'bitcoin.example.com': {
|
'bitcoin.example.com': {
|
||||||
gateway: 'eth0',
|
gateway: 'eth0',
|
||||||
acme: null,
|
acme: 'https://acme-v02.api.letsencrypt.org/directory',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
privateDomains: {
|
privateDomains: {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export type GatewayPlus = T.NetworkInterfaceInfo & {
|
|||||||
subnets: utils.IpNet[]
|
subnets: utils.IpNet[]
|
||||||
lanIpv4: string[]
|
lanIpv4: string[]
|
||||||
wanIp?: utils.IpAddress
|
wanIp?: utils.IpAddress
|
||||||
isDefaultOutbound: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -29,7 +28,6 @@ export class GatewayService {
|
|||||||
this.network$.pipe(
|
this.network$.pipe(
|
||||||
map(network => {
|
map(network => {
|
||||||
const gateways = network.gateways
|
const gateways = network.gateways
|
||||||
const defaultOutbound = network.defaultOutbound
|
|
||||||
return Object.entries(gateways)
|
return Object.entries(gateways)
|
||||||
.filter(([_, val]) => !!val?.ipInfo)
|
.filter(([_, val]) => !!val?.ipInfo)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -49,7 +47,6 @@ export class GatewayService {
|
|||||||
lanIpv4: subnets.filter(s => s.isIpv4()).map(s => s.address),
|
lanIpv4: subnets.filter(s => s.isIpv4()).map(s => s.address),
|
||||||
wanIp:
|
wanIp:
|
||||||
val.ipInfo?.wanIp && utils.IpAddress.parse(val.ipInfo?.wanIp),
|
val.ipInfo?.wanIp && utils.IpAddress.parse(val.ipInfo?.wanIp),
|
||||||
isDefaultOutbound: id === defaultOutbound,
|
|
||||||
} as GatewayPlus
|
} as GatewayPlus
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -161,7 +161,6 @@ export class MarketplaceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fetchRegistry$(url: string): Observable<StoreDataWithUrl | null> {
|
private fetchRegistry$(url: string): Observable<StoreDataWithUrl | null> {
|
||||||
console.log('FETCHING REGISTRY: ', url)
|
|
||||||
return combineLatest([this.fetchInfo$(url), this.fetchPackages$(url)]).pipe(
|
return combineLatest([this.fetchInfo$(url), this.fetchPackages$(url)]).pipe(
|
||||||
map(([info, packages]) => ({ info, packages, url })),
|
map(([info, packages]) => ({ info, packages, url })),
|
||||||
catchError(e => {
|
catchError(e => {
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ hr {
|
|||||||
min-height: fit-content;
|
min-height: fit-content;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.g-aside {
|
.g-aside {
|
||||||
|
|||||||
Reference in New Issue
Block a user