Compare commits

..

1 Commits

Author SHA1 Message Date
Shadowy Super Coder
4e7d33b07f Fix mount propagation so container-internal mounts are visible to dependent services
Volume bind mounts into LXC containers inherited private propagation
  from the host source path, which prevented mounts made inside a
  container (e.g. NAS mounts via postinit.sh) from propagating back to
  the host. Dependent services bind-mounting the same volume from the
  host side would never see these internal mounts.

  Self-bind each host volume directory and mark it rshared so that
  container-internal mounts propagate back to the host path. Mark
  dependency mounts as rslave so they receive propagated mounts but
  cannot propagate mounts back to the source service.

  Because rshared propagation means mounts can survive container
  teardown, add defense-in-depth to uninstall cleanup: unmount any
  remaining mounts under the package volume path, then refuse to
  delete if any persist, preventing remove_dir_all from traversing
  into a live NFS/NAS mount and destroying data.
2026-02-11 09:20:16 -07:00
15 changed files with 176 additions and 154 deletions

View File

@@ -61,6 +61,24 @@ pub async fn unmount<P: AsRef<Path>>(mountpoint: P, lazy: bool) -> Result<(), Er
Ok(())
}
/// Returns true if any mountpoints exist under (or at) the given path.
pub async fn has_mounts_under<P: AsRef<Path>>(path: P) -> Result<bool, Error> {
let path = path.as_ref();
let canonical_path = tokio::fs::canonicalize(path)
.await
.with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("canonicalize {path:?}")))?;
let mounts_content = tokio::fs::read_to_string("/proc/mounts")
.await
.with_ctx(|_| (ErrorKind::Filesystem, "read /proc/mounts"))?;
Ok(mounts_content.lines().any(|line| {
line.split_whitespace()
.nth(1)
.map_or(false, |mp| Path::new(mp).starts_with(&canonical_path))
}))
}
/// Unmounts all mountpoints under (and including) the given path, in reverse
/// depth order so that nested mounts are unmounted before their parents.
#[instrument(skip_all)]

View File

@@ -6,6 +6,8 @@ use clap::builder::ValueParserFactory;
use exver::VersionRange;
use rust_i18n::t;
use tokio::process::Command;
use crate::db::model::package::{
CurrentDependencies, CurrentDependencyInfo, CurrentDependencyKind, ManifestPreference,
TaskEntry,
@@ -19,7 +21,7 @@ use crate::service::effects::callbacks::CallbackHandler;
use crate::service::effects::prelude::*;
use crate::service::rpc::CallbackId;
use crate::status::health_check::NamedHealthCheckResult;
use crate::util::{FromStrParser, VersionString};
use crate::util::{FromStrParser, Invoke, VersionString};
use crate::volume::data_dir;
use crate::{DATA_DIR, HealthCheckId, PackageId, ReplayId, VolumeId};
@@ -90,7 +92,7 @@ pub async fn mount(
),
)
.mount(
mountpoint,
&mountpoint,
if readonly {
MountType::ReadOnly
} else {
@@ -99,6 +101,15 @@ pub async fn mount(
)
.await?;
// Make the dependency mount a slave so it receives propagated mounts
// (e.g. NAS mounts from the source service) but cannot propagate
// mounts back to the source service's volume.
Command::new("mount")
.arg("--make-rslave")
.arg(&mountpoint)
.invoke(ErrorKind::Filesystem)
.await?;
Ok(())
}

View File

@@ -20,6 +20,7 @@ use crate::disk::mount::filesystem::loop_dev::LoopDev;
use crate::disk::mount::filesystem::overlayfs::OverlayGuard;
use crate::disk::mount::filesystem::{MountType, ReadOnly};
use crate::disk::mount::guard::{GenericMountGuard, MountGuard};
use crate::disk::mount::util::{is_mountpoint, unmount};
use crate::lxc::{HOST_RPC_SERVER_SOCKET, LxcConfig, LxcContainer};
use crate::net::net_controller::NetService;
use crate::prelude::*;
@@ -76,6 +77,7 @@ pub struct PersistentContainer {
pub(super) rpc_client: UnixRpcClient,
pub(super) rpc_server: watch::Sender<Option<(NonDetachingJoinHandle<()>, ShutdownHandle)>>,
js_mount: MountGuard,
host_volume_binds: BTreeMap<VolumeId, MountGuard>,
volumes: BTreeMap<VolumeId, MountGuard>,
assets: Vec<MountGuard>,
pub(super) images: BTreeMap<ImageId, Arc<MountGuard>>,
@@ -120,6 +122,7 @@ impl PersistentContainer {
.is_ok();
let mut volumes = BTreeMap::new();
let mut host_volume_binds = BTreeMap::new();
// TODO: remove once packages are reconverted
let added = if is_compat {
@@ -128,13 +131,35 @@ impl PersistentContainer {
BTreeSet::default()
};
for volume in s9pk.as_manifest().volumes.union(&added) {
let host_volume_dir = data_dir(DATA_DIR, &s9pk.as_manifest().id, volume);
// Self-bind the host volume directory and mark it rshared so that
// mounts created inside the container (e.g. NAS mounts from
// postinit.sh) propagate back to the host path and are visible to
// dependent services that bind-mount the same volume.
if is_mountpoint(&host_volume_dir).await? {
unmount(&host_volume_dir, true).await?;
}
let host_bind = MountGuard::mount(
&Bind::new(&host_volume_dir),
&host_volume_dir,
MountType::ReadWrite,
)
.await?;
Command::new("mount")
.arg("--make-rshared")
.arg(&host_volume_dir)
.invoke(ErrorKind::Filesystem)
.await?;
host_volume_binds.insert(volume.clone(), host_bind);
let mountpoint = lxc_container
.rootfs_dir()
.join("media/startos/volumes")
.join(volume);
let mount = MountGuard::mount(
&IdMapped::new(
Bind::new(data_dir(DATA_DIR, &s9pk.as_manifest().id, volume)),
Bind::new(&host_volume_dir),
vec![IdMap {
from_id: 0,
to_id: 100000,
@@ -296,6 +321,7 @@ impl PersistentContainer {
rpc_server: watch::channel(None).0,
// procedures: Default::default(),
js_mount,
host_volume_binds,
volumes,
assets,
images,
@@ -439,6 +465,7 @@ impl PersistentContainer {
let rpc_server = self.rpc_server.send_replace(None);
let js_mount = self.js_mount.take();
let volumes = std::mem::take(&mut self.volumes);
let host_volume_binds = std::mem::take(&mut self.host_volume_binds);
let assets = std::mem::take(&mut self.assets);
let images = std::mem::take(&mut self.images);
let subcontainers = self.subcontainers.clone();
@@ -461,6 +488,11 @@ impl PersistentContainer {
for (_, volume) in volumes {
errs.handle(volume.unmount(true).await);
}
// Unmount host-side shared binds after the rootfs-side volume
// mounts. Use delete_mountpoint=false to preserve the data dirs.
for (_, host_bind) in host_volume_binds {
errs.handle(host_bind.unmount(false).await);
}
for assets in assets {
errs.handle(assets.unmount(true).await);
}

View File

@@ -4,6 +4,7 @@ use imbl::vector;
use crate::context::RpcContext;
use crate::db::model::package::{InstalledState, InstallingInfo, InstallingState, PackageState};
use crate::disk::mount::util::{has_mounts_under, unmount_all_under};
use crate::prelude::*;
use crate::volume::PKG_VOLUME_DIR;
use crate::{DATA_DIR, PACKAGE_DATA, PackageId};
@@ -81,6 +82,22 @@ pub async fn cleanup(ctx: &RpcContext, id: &PackageId, soft: bool) -> Result<(),
if !soft {
let path = Path::new(DATA_DIR).join(PKG_VOLUME_DIR).join(&manifest.id);
if tokio::fs::metadata(&path).await.is_ok() {
// Best-effort cleanup of any propagated mounts (e.g. NAS)
// that survived container destroy or were never cleaned up
// (force-uninstall skips destroy entirely).
unmount_all_under(&path, true).await.log_err();
// Hard check: refuse to delete if mounts are still active,
// to avoid traversing into a live NFS/NAS mount.
if has_mounts_under(&path).await? {
return Err(Error::new(
eyre!(
"Refusing to remove {}: active mounts remain under this path. \
Unmount them manually and retry.",
path.display()
),
ErrorKind::Filesystem,
));
}
tokio::fs::remove_dir_all(&path).await?;
}
let logs_dir = Path::new(PACKAGE_DATA).join("logs").join(&manifest.id);

View File

@@ -5,7 +5,7 @@ export default {
2: 'Aktualisieren',
4: 'System',
5: 'Allgemein',
6: 'SMTP',
6: 'E-Mail',
7: 'Sicherung erstellen',
8: 'Sicherung wiederherstellen',
9: 'Zum Login gehen',
@@ -385,8 +385,8 @@ export default {
405: 'Verbunden',
406: 'Vergessen',
407: 'WiFi-Zugangsdaten',
408: 'Mit verstecktem Netzwerk verbinden',
409: 'Verbinden mit',
408: 'Veraltet',
409: 'Die WLAN-Unterstützung wird in StartOS v0.4.1 entfernt. Wenn Sie keinen Zugriff auf Ethernet haben, können Sie einen WLAN-Extender verwenden, um sich mit dem lokalen Netzwerk zu verbinden und dann Ihren Server über Ethernet an den Extender anschließen. Bitte wenden Sie sich bei Fragen an den Start9-Support.',
410: 'Bekannte Netzwerke',
411: 'Weitere Netzwerke',
412: 'WiFi ist deaktiviert',

View File

@@ -4,7 +4,7 @@ export const ENGLISH: Record<string, number> = {
'Update': 2, // verb
'System': 4, // as in, system preferences
'General': 5, // as in, general settings
'SMTP': 6,
'Email': 6,
'Create Backup': 7, // create a backup
'Restore Backup': 8, // restore from backup
'Go to login': 9,
@@ -384,8 +384,8 @@ export const ENGLISH: Record<string, number> = {
'Connected': 405,
'Forget': 406, // as in, delete or remove
'WiFi Credentials': 407,
'Connect to hidden network': 408,
'Connect to': 409, // followed by a network name, e.g. "Connect to MyWiFi?"
'Deprecated': 408,
'WiFi support will be removed in StartOS v0.4.1. If you do not have access to Ethernet, you can use a WiFi extender to connect to the local network, then connect your server to the extender via Ethernet. Please contact Start9 support with any questions or concerns.': 409,
'Known Networks': 410,
'Other Networks': 411,
'WiFi is disabled': 412,

View File

@@ -5,7 +5,7 @@ export default {
2: 'Actualizar',
4: 'Sistema',
5: 'General',
6: 'SMTP',
6: 'Correo electrónico',
7: 'Crear copia de seguridad',
8: 'Restaurar copia de seguridad',
9: 'Ir a inicio de sesión',
@@ -385,8 +385,8 @@ export default {
405: 'Conectado',
406: 'Olvidar',
407: 'Credenciales WiFi',
408: 'Conectar a red oculta',
409: 'Conectar a',
408: 'Obsoleto',
409: 'El soporte para WiFi será eliminado en StartOS v0.4.1. Si no tienes acceso a Ethernet, puedes usar un extensor WiFi para conectarte a la red local y luego conectar tu servidor al extensor por Ethernet. Por favor, contacta al soporte de Start9 si tienes dudas o inquietudes.',
410: 'Redes conocidas',
411: 'Otras redes',
412: 'WiFi está deshabilitado',

View File

@@ -5,7 +5,7 @@ export default {
2: 'Mettre à jour',
4: 'Système',
5: 'Général',
6: 'SMTP',
6: 'Email',
7: 'Créer une sauvegarde',
8: 'Restaurer une sauvegarde',
9: 'Se connecter',
@@ -385,8 +385,8 @@ export default {
405: 'Connecté',
406: 'Oublier',
407: 'Identifiants WiFi',
408: 'Se connecter à un réseau masqué',
409: 'Se connecter à',
408: 'Obsolète',
409: 'Le support WiFi sera supprimé dans StartOS v0.4.1. Si vous navez pas accès à internet via Ethernet, vous pouvez utiliser un répéteur WiFi pour vous connecter au réseau local, puis brancher votre serveur sur le répéteur en Ethernet. Contactez le support Start9 pour toute question.',
410: 'Réseaux connus',
411: 'Autres réseaux',
412: 'Le WiFi est désactivé',

View File

@@ -5,7 +5,7 @@ export default {
2: 'Aktualizuj',
4: 'Ustawienia',
5: 'Ogólne',
6: 'SMTP',
6: 'E-mail',
7: 'Utwórz kopię zapasową',
8: 'Przywróć z kopii zapasowej',
9: 'Przejdź do logowania',
@@ -385,8 +385,8 @@ export default {
405: 'Połączono',
406: 'Zapomnij',
407: 'Dane logowania WiFi',
408: 'Połącz z ukrytą siecią',
409: 'Połącz z',
408: 'Przestarzałe',
409: 'Obsługa WiFi zostanie usunięta w StartOS v0.4.1. Jeśli nie masz dostępu do sieci Ethernet, możesz użyć wzmacniacza WiFi do połączenia z siecią lokalną, a następnie podłączyć serwer do wzmacniacza przez Ethernet. W razie pytań lub wątpliwości skontaktuj się z pomocą techniczną Start9.',
410: 'Znane sieci',
411: 'Inne sieci',
412: 'WiFi jest wyłączone',

View File

@@ -28,7 +28,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
{{ 'Back' | i18n }}
</a>
{{ 'SMTP' | i18n }}
{{ 'Email' | i18n }}
</ng-container>
@if (form$ | async; as form) {
<form [formGroup]="form">

View File

@@ -160,11 +160,7 @@ export default class SystemSSHComponent {
const loader = this.loader.open('Deleting').subscribe()
try {
await Promise.all(
fingerprints.map(fingerprint =>
this.api.deleteSshKey({ fingerprint }),
),
)
await this.api.deleteSshKey({ fingerprint: '' })
this.local$.next(
all.filter(s => !fingerprints.includes(s.fingerprint)),
)

View File

@@ -5,23 +5,8 @@ import {
inject,
Input,
} from '@angular/core'
import { NgTemplateOutlet } from '@angular/common'
import {
DialogService,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { filter } from 'rxjs'
import { IST } from '@start9labs/start-sdk'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiIcon,
TuiTextfield,
TuiTitle,
} from '@taiga-ui/core'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { TuiButton, TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiBadge, TuiFade } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import {
@@ -37,78 +22,50 @@ import { wifiSpec } from './wifi.const'
@Component({
selector: '[wifi]',
template: `
<ng-template #row let-network>
@if (getSignal(network.strength); as signal) {
<tui-icon
background="@tui.wifi"
[icon]="signal.icon"
[style.background]="'var(--tui-background-neutral-2)'"
[style.color]="signal.color"
/>
} @else {
<tui-icon icon="@tui.wifi-off" />
}
<tui-icon
[icon]="network.security.length ? '@tui.lock' : '@tui.lock-open'"
/>
<div tuiTitle>
<strong tuiFade>
{{ network.ssid }}
</strong>
</div>
@if (network.connected) {
<tui-badge appearance="positive">
{{ 'Connected' | i18n }}
</tui-badge>
}
@if (network.connected === false) {
@for (network of wifi; track $index) {
@if (network.ssid) {
<button
tuiIconButton
tuiDropdown
size="s"
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
[(tuiDropdownOpen)]="open"
tuiCell
[disabled]="network.connected"
(click)="prompt(network)"
>
{{ 'More' | i18n }}
<tui-data-list *tuiTextfieldDropdown>
<div tuiTitle>
<strong tuiFade>
{{ network.ssid }}
@if (network.connected) {
<tui-badge appearance="positive">
{{ 'Connected' | i18n }}
</tui-badge>
}
</strong>
</div>
@if (network.connected !== undefined) {
<button
tuiOption
new
iconStart="@tui.wifi"
(click)="prompt(network)"
>
{{ 'Connect' | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="forget(network)"
tuiIconButton
size="s"
appearance="icon"
iconStart="@tui.trash-2"
(click.stop)="forget(network)"
>
{{ 'Forget' | i18n }}
</button>
</tui-data-list>
} @else {
<tui-icon
[icon]="network.security.length ? '@tui.lock' : '@tui.lock-open'"
/>
}
@if (getSignal(network.strength); as signal) {
<tui-icon
background="@tui.wifi"
[icon]="signal.icon"
[style.background]="'var(--tui-background-neutral-2)'"
[style.color]="signal.color"
/>
} @else {
<tui-icon icon="@tui.wifi-off" />
}
</button>
}
</ng-template>
@for (network of wifi; track $index) {
@if (network.ssid) {
@if (network.connected === undefined) {
<button tuiCell (click)="prompt(network)">
<ng-container
*ngTemplateOutlet="row; context: { $implicit: network }"
/>
</button>
} @else {
<div tuiCell>
<ng-container
*ngTemplateOutlet="row; context: { $implicit: network }"
/>
</div>
}
}
}
`,
styles: `
@@ -118,6 +75,8 @@ import { wifiSpec } from './wifi.const'
}
[tuiCell] {
padding-inline: 1rem !important;
&:disabled > * {
opacity: 1;
}
@@ -129,24 +88,11 @@ import { wifiSpec } from './wifi.const'
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
NgTemplateOutlet,
TuiCell,
TuiTitle,
TuiBadge,
TuiButton,
TuiIcon,
TuiFade,
TuiDropdown,
TuiDataList,
TuiTextfield,
i18nPipe,
],
imports: [TuiCell, TuiTitle, TuiBadge, TuiButton, TuiIcon, TuiFade, i18nPipe],
})
export class WifiTableComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly dialogs = inject(DialogService)
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
private readonly component = inject(SystemWifiComponent)
@@ -156,8 +102,6 @@ export class WifiTableComponent {
@Input()
wifi: readonly Wifi[] = []
open = false
getSignal(signal: number) {
if (signal < 5) {
return null
@@ -197,30 +141,17 @@ export class WifiTableComponent {
async prompt(network: Wifi): Promise<void> {
if (!network.security.length) {
this.dialogs
.openConfirm({
label: `${this.i18n.transform('Connect to')} ${network.ssid}?`,
size: 's',
})
.pipe(filter(Boolean))
.subscribe(() => this.component.saveAndConnect(network.ssid))
await this.component.saveAndConnect(network.ssid)
} else {
const ssid = wifiSpec.spec['ssid'] as IST.ValueSpecText
const spec: IST.InputSpec = {
...wifiSpec.spec,
ssid: { ...ssid, disabled: 'ssid', default: network.ssid },
}
this.formDialog.open<FormContext<WiFiForm>>(FormComponent, {
label: 'Password needed',
data: {
spec,
value: { ssid: network.ssid, password: '' },
spec: wifiSpec.spec,
buttons: [
{
text: this.i18n.transform('Connect')!,
handler: async ({ password }) =>
this.component.saveAndConnect(network.ssid, password),
handler: async ({ ssid, password }) =>
this.component.saveAndConnect(ssid, password),
},
],
},

View File

@@ -8,7 +8,6 @@ import { toSignal } from '@angular/core/rxjs-interop'
import { FormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import {
DocsLinkDirective,
ErrorService,
i18nKey,
i18nPipe,
@@ -20,9 +19,11 @@ import {
TuiAppearance,
TuiButton,
TuiLoader,
TuiNotification,
TuiTitle,
} from '@taiga-ui/core'
import { TuiSwitch } from '@taiga-ui/kit'
import { TuiCardLarge } from '@taiga-ui/layout'
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { catchError, defer, map, merge, Observable, of, Subject } from 'rxjs'
import {
@@ -46,20 +47,23 @@ import { wifiSpec } from './wifi.const'
</a>
WiFi
</ng-container>
<header tuiHeader>
<tui-notification appearance="negative">
<div tuiTitle>
{{ 'Deprecated' | i18n }}
<div tuiSubtitle>
{{
'WiFi support will be removed in StartOS v0.4.1. If you do not have access to Ethernet, you can use a WiFi extender to connect to the local network, then connect your server to the extender via Ethernet. Please contact Start9 support with any questions or concerns.'
| i18n
}}
</div>
</div>
</tui-notification>
</header>
@if (status()?.interface) {
<section class="g-card">
<header>
Wi-Fi
<a
tuiIconButton
size="xs"
docsLink
path="/user-manual/wifi.html"
appearance="icon"
iconStart="@tui.external-link"
>
{{ 'Documentation' | i18n }}
</a>
<input
type="checkbox"
tuiSwitch
@@ -88,8 +92,8 @@ import { wifiSpec } from './wifi.const'
></div>
}
<p>
<button tuiButton (click)="other(data)" appearance="flat">
+ {{ 'Connect to hidden network' | i18n }}
<button tuiButton (click)="other(data)">
{{ 'Add' | i18n }}
</button>
</p>
} @else {
@@ -124,8 +128,10 @@ import { wifiSpec } from './wifi.const'
TitleDirective,
RouterLink,
PlaceholderComponent,
TuiHeader,
TuiTitle,
TuiNotification,
i18nPipe,
DocsLinkDirective,
],
})
export default class SystemWifiComponent {

View File

@@ -1,3 +1,4 @@
import { AsyncPipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { RouterModule } from '@angular/router'
@@ -5,9 +6,12 @@ import { i18nPipe } from '@start9labs/shared'
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiBadgeNotification } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { BadgeService } from 'src/app/services/badge.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import { SYSTEM_MENU } from './system.const'
import { map } from 'rxjs'
@Component({
template: `
@@ -22,6 +26,9 @@ import { SYSTEM_MENU } from './system.const'
tuiCell="s"
routerLinkActive="active"
[routerLink]="page.link"
[style.display]="
!(wifiEnabled$ | async) && page.item === 'WiFi' ? 'none' : null
"
>
<tui-icon [icon]="page.icon" />
<span tuiTitle>
@@ -109,9 +116,13 @@ import { SYSTEM_MENU } from './system.const'
TitleDirective,
TuiBadgeNotification,
i18nPipe,
AsyncPipe,
],
})
export class SystemComponent {
readonly menu = SYSTEM_MENU
readonly badge = toSignal(inject(BadgeService).getCount('system'))
readonly wifiEnabled$ = inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'network', 'wifi')
.pipe(map(wifi => !!wifi.interface && wifi.enabled))
}

View File

@@ -28,7 +28,7 @@ export const SYSTEM_MENU = [
},
{
icon: '@tui.mail',
item: 'SMTP',
item: 'Email',
link: 'email',
},
{