* proxies

* OS outbound proxy. ugly, needs work

* abstract interface address management

* clearnet and outbound proxies for services

* clean up

* router tab

* smart launching of UIs

* update sdk types

* display outbound proxy on service show and rework menu
This commit is contained in:
Matt Hill
2023-08-07 15:14:03 -06:00
committed by GitHub
parent 0d079f0d89
commit c3ae146580
101 changed files with 2135 additions and 1161 deletions

View File

@@ -22,6 +22,7 @@ import {
import { AppComponent } from './app.component'
import { RoutingModule } from './routing.module'
import { OSWelcomePageModule } from './common/os-welcome/os-welcome.module'
import { QRComponentModule } from './common/qr/qr.module'
import { PreloaderModule } from './app/preloader/preloader.module'
import { FooterModule } from './app/footer/footer.module'
import { MenuModule } from './app/menu/menu.module'
@@ -70,6 +71,7 @@ import { environment } from '../environments/environment'
registrationStrategy: 'registerWhenStable:30000',
}),
LoadingModule,
QRComponentModule,
],
providers: APP_PROVIDERS,
bootstrap: [AppComponent],

View File

@@ -69,6 +69,7 @@ const ICONS = [
'pulse',
'push-outline',
'qr-code-outline',
'radio-outline',
'receipt-outline',
'refresh',
'reload',
@@ -81,7 +82,8 @@ const ICONS = [
'save-outline',
'server-outline',
'settings-outline',
'shield-checkmark-outline',
'shield-outline',
'shuffle-outline',
'stop-outline',
'stopwatch-outline',
'storefront-outline',

View File

@@ -74,6 +74,6 @@ export class TargetSelectPage {
styleUrls: ['./target-select.page.scss'],
})
export class TargetStatusComponent {
@Input() type!: BackupType
@Input() target!: BackupTarget
@Input({ required: true }) type!: BackupType
@Input({ required: true }) target!: BackupTarget
}

View File

@@ -40,7 +40,7 @@ export const googleDriveSpec = Config.of({
name: 'Private Key File',
description:
'Your Google Drive service account private key file (.json file)',
required: true,
required: { default: null },
extensions: ['json'],
}),
})

View File

@@ -41,7 +41,7 @@ export class MarketplaceShowControlsComponent {
@Input()
url?: string
@Input()
@Input({ required: true })
pkg!: MarketplacePkg
@Input()

View File

@@ -15,7 +15,7 @@ import { DependentInfo } from 'src/app/types/dependent-info'
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MarketplaceShowDependentComponent {
@Input()
@Input({ required: true })
pkg!: MarketplacePkg
readonly dependentInfo?: DependentInfo =

View File

@@ -10,8 +10,7 @@ import {
styleUrls: ['marketplace-status.component.scss'],
})
export class MarketplaceStatusComponent {
@Input() version!: string
@Input({ required: true }) version!: string
@Input() localPkg?: PackageDataEntry
PackageState = PackageState

View File

@@ -188,7 +188,7 @@ interface LocalAction {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppActionsItemComponent {
@Input() action!: LocalAction
@Input({ required: true }) action!: LocalAction
}
@Pipe({

View File

@@ -0,0 +1,24 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppInterfacePage } from './app-interface.page'
import { InterfaceAddressesComponentModule } from 'src/app/common/interface-addresses/interface-addresses.module'
const routes: Routes = [
{
path: '',
component: AppInterfacePage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
InterfaceAddressesComponentModule,
],
declarations: [AppInterfacePage],
})
export class AppInterfacePageModule {}

View File

@@ -0,0 +1,20 @@
<ng-container *ngIf="interfaceInfo$ | async as interfaceInfo">
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [defaultHref]="'/services/' + pkgId"></ion-back-button>
</ion-buttons>
<ion-title>{{ interfaceInfo.name }}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="with-widgets">
<div class="cap-width">
<interface-addresses
[packageContext]="{ packageId: pkgId, interfaceId }"
[addressInfo]="interfaceInfo.addressInfo"
[isUi]="interfaceInfo.type === 'ui'"
></interface-addresses>
</div>
</ion-content>
</ng-container>

View File

@@ -0,0 +1,29 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { getPkgId } from '@start9labs/shared'
@Component({
selector: 'app-interface',
templateUrl: './app-interface.page.html',
styleUrls: ['./app-interface.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppInterfacePage {
readonly pkgId = getPkgId(this.route)
readonly interfaceId = this.route.snapshot.paramMap.get('interfaceId')!
readonly interfaceInfo$ = this.patch.watch$(
'package-data',
this.pkgId,
'installed',
'interfaceInfo',
this.interfaceId,
)
constructor(
private readonly route: ActivatedRoute,
private readonly patch: PatchDB<DataModel>,
) {}
}

View File

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

View File

@@ -1,32 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { SharedPipesModule } from '@start9labs/shared'
import { QrCodeModule } from 'ng-qrcode'
import {
AppInterfacesItemComponent,
AppInterfacesPage,
} from './app-interfaces.page'
import { UiPipesModule } from '../ui-pipes/ui.module'
import { QRComponent } from './qr.component'
const routes: Routes = [
{
path: '',
component: AppInterfacesPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SharedPipesModule,
UiPipesModule,
QrCodeModule,
],
declarations: [AppInterfacesPage, AppInterfacesItemComponent, QRComponent],
})
export class AppInterfacesPageModule {}

View File

@@ -1,19 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [defaultHref]="'/services/' + pkgId"></ion-back-button>
</ion-buttons>
<ion-title>Interfaces</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top with-widgets">
<ion-item-group>
<div
*ngFor="let addressInfo of (addressInfo$ | async)"
style="margin-bottom: 30px"
>
<app-interfaces-item [addressInfo]="addressInfo"></app-interfaces-item>
</div>
</ion-item-group>
</ion-content>

View File

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

View File

@@ -1,59 +0,0 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { getPkgId, CopyService } from '@start9labs/shared'
import { AddressInfo, DataModel } from 'src/app/services/patch-db/data-model'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { QRComponent } from './qr.component'
import { TuiDialogService } from '@taiga-ui/core'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
@Component({
selector: 'app-interfaces',
templateUrl: './app-interfaces.page.html',
styleUrls: ['./app-interfaces.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppInterfacesPage {
readonly pkgId = getPkgId(this.route)
readonly addressInfo$ = this.patch
.watch$('package-data', this.pkgId, 'installed', 'address-info')
.pipe(
map(addressInfo =>
Object.values(addressInfo).sort((a, b) => a.name.localeCompare(b.name)),
),
)
constructor(
private readonly route: ActivatedRoute,
private readonly patch: PatchDB<DataModel>,
) {}
}
@Component({
selector: 'app-interfaces-item',
templateUrl: './app-interfaces-item.component.html',
styleUrls: ['./app-interfaces.page.scss'],
})
export class AppInterfacesItemComponent {
@Input()
addressInfo!: AddressInfo
constructor(
private readonly dialogs: TuiDialogService,
readonly copyService: CopyService,
) {}
launch(url: string): void {
window.open(url, '_blank', 'noreferrer')
}
showQR(data: string) {
this.dialogs
.open(new PolymorpheusComponent(QRComponent), {
size: 'auto',
data,
})
.subscribe()
}
}

View File

@@ -9,7 +9,7 @@ import { PkgInfo } from 'src/app/types/pkg-info'
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppListIconComponent {
@Input()
@Input({ required: true })
pkg!: PkgInfo
readonly connected$ = this.connectionService.connected$

View File

@@ -20,19 +20,27 @@
></status>
</ion-label>
<ng-container *ngIf="pkg.entry.installed as installed">
<ion-button
*ngIf="installed['address-info'] | hasUi"
slot="end"
fill="clear"
color="primary"
(click)="openPopover($event)"
[disabled]="status !== 'running'"
<ng-container
*ngIf="installed['interfaceInfo'] | launchableInterfaces as launchable"
>
<ion-icon slot="icon-only" name="open-outline"></ion-icon>
</ion-button>
<launch-menu
#launchMenu
[addressInfo]="installed['address-info']"
></launch-menu>
<ion-button
*ngIf="launchable.length"
slot="end"
fill="clear"
color="primary"
(click)="
launchable.length > 1
? openPopover($event)
: launchUI(launchable[0].address, $event)
"
[disabled]="status !== 'running'"
>
<ion-icon slot="icon-only" name="open-outline"></ion-icon>
</ion-button>
<launch-menu
#launchMenu
[launchableInterfaces]="launchable"
></launch-menu>
</ng-container>
</ng-container>
</ion-item>

View File

@@ -1,12 +1,14 @@
import {
ChangeDetectionStrategy,
Component,
Inject,
Input,
ViewChild,
} from '@angular/core'
import { LaunchMenuComponent } from '../../launch-menu/launch-menu.component'
import { LaunchMenuComponent } from './launch-menu/launch-menu.component'
import { PackageMainStatus } from 'src/app/services/patch-db/data-model'
import { PkgInfo } from 'src/app/types/pkg-info'
import { DOCUMENT } from '@angular/common'
@Component({
selector: 'app-list-pkg',
@@ -17,7 +19,7 @@ import { PkgInfo } from 'src/app/types/pkg-info'
export class AppListPkgComponent {
@ViewChild('launchMenu') launchMenu!: LaunchMenuComponent
@Input()
@Input({ required: true })
pkg!: PkgInfo
get status(): PackageMainStatus {
@@ -26,10 +28,18 @@ export class AppListPkgComponent {
)
}
constructor(@Inject(DOCUMENT) private readonly document: Document) {}
openPopover(e: Event): void {
e.stopPropagation()
e.preventDefault()
this.launchMenu.event = e
this.launchMenu.isOpen = true
}
launchUI(address: string, e: Event) {
e.stopPropagation()
e.preventDefault()
this.document.defaultView?.open(address, '_blank', 'noreferrer')
}
}

View File

@@ -0,0 +1,25 @@
<ion-popover
#popover
(didDismiss)="popover.isOpen = false"
mode="ios"
type="event"
>
<ng-template>
<ion-content>
<ion-item-group>
<ion-item
button
*ngFor="let iface of launchableInterfaces"
detail="false"
(click)="launchUI(iface.address)"
>
<ion-label>
<h2>{{ iface.name }}</h2>
<p>{{ iface.address }}</p>
</ion-label>
<ion-icon slot="end" name="open-outline" size="small"></ion-icon>
</ion-item>
</ion-item-group>
</ion-content>
</ng-template>
</ion-popover>

View File

@@ -7,6 +7,7 @@ import {
ViewChild,
} from '@angular/core'
import { InstalledPackageInfo } from 'src/app/services/patch-db/data-model'
import { LaunchableInterface } from '../launchable-interfaces.pipe'
@Component({
selector: 'launch-menu',
@@ -17,8 +18,8 @@ import { InstalledPackageInfo } from 'src/app/services/patch-db/data-model'
export class LaunchMenuComponent {
@ViewChild('popover') popover!: HTMLIonPopoverElement
@Input()
addressInfo!: InstalledPackageInfo['address-info']
@Input({ required: true })
launchableInterfaces!: LaunchableInterface[]
set isOpen(open: boolean) {
this.popover.isOpen = open

View File

@@ -1,12 +1,11 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { UiPipesModule } from '../ui-pipes/ui.module'
import { LaunchMenuComponent } from './launch-menu.component'
@NgModule({
declarations: [LaunchMenuComponent],
imports: [CommonModule, IonicModule, UiPipesModule],
imports: [CommonModule, IonicModule],
exports: [LaunchMenuComponent],
})
export class LaunchMenuComponentModule {}

View File

@@ -0,0 +1,26 @@
import { Pipe, PipeTransform } from '@angular/core'
import { ConfigService } from 'src/app/services/config.service'
import { InstalledPackageInfo } from 'src/app/services/patch-db/data-model'
@Pipe({
name: 'launchableInterfaces',
})
export class LaunchableInterfacesPipe implements PipeTransform {
constructor(private readonly config: ConfigService) {}
transform(
interfaceInfo: InstalledPackageInfo['interfaceInfo'],
): LaunchableInterface[] {
return Object.values(interfaceInfo)
.filter(info => info.type === 'ui')
.map(info => ({
name: info.name,
address: this.config.launchableAddress(info),
}))
}
}
export type LaunchableInterface = {
name: string
address: string
}

View File

@@ -12,11 +12,11 @@ import {
import { BadgeMenuComponentModule } from 'src/app/common/badge-menu-button/badge-menu.component.module'
import { WidgetListComponentModule } from 'src/app/common/widget-list/widget-list.component.module'
import { StatusComponentModule } from '../status/status.component.module'
import { UiPipesModule } from '../ui-pipes/ui.module'
import { AppListIconComponent } from './app-list-icon/app-list-icon.component'
import { AppListPkgComponent } from './app-list-pkg/app-list-pkg.component'
import { PackageInfoPipe } from './package-info.pipe'
import { LaunchMenuComponentModule } from '../launch-menu/launch-menu.module'
import { LaunchMenuComponentModule } from './app-list-pkg/launch-menu/launch-menu.module'
import { LaunchableInterfacesPipe } from './app-list-pkg/launchable-interfaces.pipe'
const routes: Routes = [
{
@@ -31,7 +31,6 @@ const routes: Routes = [
StatusComponentModule,
EmverPipesModule,
TextSpinnerComponentModule,
UiPipesModule,
IonicModule,
RouterModule.forChild(routes),
BadgeMenuComponentModule,
@@ -45,6 +44,7 @@ const routes: Routes = [
AppListIconComponent,
AppListPkgComponent,
PackageInfoPipe,
LaunchableInterfacesPipe,
],
})
export class AppListPageModule {}

View File

@@ -10,21 +10,23 @@ import {
} from '@start9labs/shared'
import { StatusComponentModule } from '../status/status.component.module'
import { AppConfigPageModule } from './modals/app-config/app-config.module'
import { UiPipesModule } from '../ui-pipes/ui.module'
import { AppShowHeaderComponent } from './components/app-show-header/app-show-header.component'
import { AppShowProgressComponent } from './components/app-show-progress/app-show-progress.component'
import { AppShowStatusComponent } from './components/app-show-status/app-show-status.component'
import { AppShowDependenciesComponent } from './components/app-show-dependencies/app-show-dependencies.component'
import { AppShowMenuComponent } from './components/app-show-menu/app-show-menu.component'
import {
AppShowInterfacesComponent,
InterfaceInfoPipe,
} from './components/app-show-interfaces/app-show-interfaces.component'
import { AppShowHealthChecksComponent } from './components/app-show-health-checks/app-show-health-checks.component'
import { AppShowAdditionalComponent } from './components/app-show-additional/app-show-additional.component'
import { HealthColorPipe } from './pipes/health-color.pipe'
import { ToButtonsPipe } from './pipes/to-buttons.pipe'
import { ToDependenciesPipe } from './pipes/to-dependencies.pipe'
import { ToStatusPipe } from './pipes/to-status.pipe'
import { ProgressDataPipe } from './pipes/progress-data.pipe'
import { InsecureWarningComponentModule } from 'src/app/common/insecure-warning/insecure-warning.module'
import { LaunchMenuComponentModule } from '../launch-menu/launch-menu.module'
import { LaunchMenuComponentModule } from '../app-list/app-list-pkg/launch-menu/launch-menu.module'
const routes: Routes = [
{
@@ -38,7 +40,6 @@ const routes: Routes = [
AppShowPage,
HealthColorPipe,
ProgressDataPipe,
ToButtonsPipe,
ToDependenciesPipe,
ToStatusPipe,
AppShowHeaderComponent,
@@ -46,8 +47,10 @@ const routes: Routes = [
AppShowStatusComponent,
AppShowDependenciesComponent,
AppShowMenuComponent,
AppShowInterfacesComponent,
AppShowHealthChecksComponent,
AppShowAdditionalComponent,
InterfaceInfoPipe,
],
imports: [
CommonModule,
@@ -56,7 +59,6 @@ const routes: Routes = [
RouterModule.forChild(routes),
AppConfigPageModule,
EmverPipesModule,
UiPipesModule,
ResponsiveColModule,
SharedPipesModule,
InsecureWarningComponentModule,

View File

@@ -27,6 +27,8 @@
></app-show-status>
<!-- ** installed && !backing-up ** -->
<ng-container *ngIf="isInstalled(pkg) && !isBackingUp(status)">
<!-- ** interfaces ** -->
<app-show-interfaces [pkg]="pkg.installed!"></app-show-interfaces>
<!-- ** health checks ** -->
<app-show-health-checks
*ngIf="isRunning(status)"
@@ -38,7 +40,7 @@
[dependencies]="dependencies"
></app-show-dependencies>
<!-- ** menu ** -->
<app-show-menu [buttons]="pkg | toButtons"></app-show-menu>
<app-show-menu [pkg]="pkg"></app-show-menu>
<!-- ** additional ** -->
<app-show-additional [pkg]="pkg"></app-show-additional>
</ng-container>

View File

@@ -12,7 +12,7 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowAdditionalComponent {
@Input()
@Input({ required: true })
pkg!: PackageDataEntry
constructor(

View File

@@ -8,6 +8,6 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowHeaderComponent {
@Input()
@Input({ required: true })
pkg!: PackageDataEntry
}

View File

@@ -12,7 +12,7 @@ import { isEmptyObject } from '@start9labs/shared'
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowHealthChecksComponent {
@Input() pkgId!: string
@Input({ required: true }) pkgId!: string
readonly connected$ = this.connectionService.connected$

View File

@@ -0,0 +1,25 @@
<ion-item-divider>Interfaces</ion-item-divider>
<ion-item
button
*ngFor="let info of pkg.interfaceInfo | interfaceInfo"
[routerLink]="['interfaces', info.id]"
>
<ion-icon slot="start" [name]="info.icon" [color]="info.color"></ion-icon>
<ion-label>
<h2>{{ info.name }}</h2>
<p>{{ info.description }}</p>
<p>
<ion-text [color]="info.color">{{ info.typeDetail }}</ion-text>
</p>
</ion-label>
<ion-button
*ngIf="info.type === 'ui'"
slot="end"
fill="clear"
color="primary"
(click)="launchUI(info, $event)"
[disabled]="pkg.status.main.status !== 'running'"
>
<ion-icon slot="icon-only" name="open-outline"></ion-icon>
</ion-button>
</ion-item>

View File

@@ -0,0 +1,83 @@
import { DOCUMENT } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
Inject,
Input,
} from '@angular/core'
import { ConfigService } from 'src/app/services/config.service'
import {
InstalledPackageInfo,
InterfaceInfo,
} from 'src/app/services/patch-db/data-model'
import { Pipe, PipeTransform } from '@angular/core'
@Component({
selector: 'app-show-interfaces',
templateUrl: './app-show-interfaces.component.html',
styleUrls: ['./app-show-interfaces.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowInterfacesComponent {
@Input({ required: true })
pkg!: InstalledPackageInfo
constructor(
private readonly config: ConfigService,
@Inject(DOCUMENT) private readonly document: Document,
) {}
launchUI(info: InterfaceInfo, e: Event) {
e.stopPropagation()
e.preventDefault()
this.document.defaultView?.open(
this.config.launchableAddress(info),
'_blank',
'noreferrer',
)
}
}
@Pipe({
name: 'interfaceInfo',
})
export class InterfaceInfoPipe implements PipeTransform {
transform(info: InstalledPackageInfo['interfaceInfo']) {
return Object.entries(info).map(([id, val]) => {
let color: string
let icon: string
let typeDetail: string
switch (val.type) {
case 'ui':
color = 'primary'
icon = 'desktop-outline'
typeDetail = 'User Interface (UI)'
break
case 'p2p':
color = 'secondary'
icon = 'people-outline'
typeDetail = 'Peer-To-Peer Interface (P2P)'
break
case 'api':
color = 'tertiary'
icon = 'terminal-outline'
typeDetail = 'Application Program Interface (API)'
break
case 'other':
color = 'dark'
icon = 'cube-outline'
typeDetail = 'Unknown Interface Type'
break
}
return {
...val,
id,
color,
icon,
typeDetail,
}
})
}
}

View File

@@ -1,15 +1,96 @@
<ion-item-divider>Menu</ion-item-divider>
<!-- instructions -->
<ion-item
*ngFor="let button of buttons"
button
detail
(click)="button.action()"
[disabled]="button.disabled"
[ngClass]="{ highlighted: button.highlighted$ | async }"
(click)="presentModalInstructions()"
[ngClass]="{ highlighted: highlighted$ | async }"
>
<ion-icon slot="start" [name]="button.icon"></ion-icon>
<ion-icon slot="start" name="list-outline"></ion-icon>
<ion-label>
<h2>{{ button.title }}</h2>
<p *ngIf="button.description">{{ button.description }}</p>
<h2>Instructions</h2>
<p>Understand how to use {{ pkg.manifest.title }}</p>
</ion-label>
</ion-item>
<!-- config -->
<ion-item button detail (click)="openConfig()">
<ion-icon slot="start" name="options-outline"></ion-icon>
<ion-label>
<h2>Config</h2>
<p>Customize {{ pkg.manifest.title }}</p>
</ion-label>
</ion-item>
<!-- credentials -->
<ion-item button detail (click)="navigate('credentials')">
<ion-icon slot="start" name="key-outline"></ion-icon>
<ion-label>
<h2>Credentials</h2>
<p>Password, keys, or other credentials of interest</p>
</ion-label>
</ion-item>
<!-- actions -->
<ion-item button detail (click)="navigate('actions')">
<ion-icon slot="start" name="flash-outline"></ion-icon>
<ion-label>
<h2>Actions</h2>
<p>Uninstall and other commands specific to {{ pkg.manifest.title }}</p>
</ion-label>
</ion-item>
<!-- outbound proxy -->
<ion-item button detail (click)="setOutboundProxy()">
<ion-icon slot="start" name="shield-outline"></ion-icon>
<ion-label>
<h2>Outbound Proxy</h2>
<p>Proxy all outbound traffic from {{ pkg.manifest.title }}</p>
<p *ngIf="{ value: pkg.installed?.outboundProxy } as proxy">
<ion-text [color]="proxy.value ? 'success' : 'warning'">
{{
!proxy.value
? 'None'
: proxy.value === 'primary'
? 'System Primary'
: proxy.value === 'mirror'
? 'Mirror P2P'
: proxy.value.proxyId
}}
</ion-text>
</p>
</ion-label>
</ion-item>
<!-- logs -->
<ion-item button detail (click)="navigate('logs')">
<ion-icon slot="start" name="receipt-outline"></ion-icon>
<ion-label>
<h2>Logs</h2>
<p>Raw, unfiltered logs</p>
</ion-label>
</ion-item>
<!-- marketplace -->
<ion-item
*ngIf="pkg.installed?.['marketplace-url'] as url; else sideloaded"
button
detail
(click)="navigate('/marketplace/' + pkg.manifest.id, { url })"
>
<ion-icon slot="start" name="storefront-outline"></ion-icon>
<ion-label>
<h2>Marketplace Listing</h2>
<p>View service in the marketplace</p>
</ion-label>
</ion-item>
<ng-template #sideloaded>
<ion-item button detail disabled (click)="({})">
<ion-icon slot="start" name="storefront-outline"></ion-icon>
<ion-label>
<h2>Marketplace Listing</h2>
<p>This package was not installed from the marketplace</p>
</ion-label>
</ion-item>
</ng-template>

View File

@@ -1,5 +1,22 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { Button } from '../../pipes/to-buttons.pipe'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { TuiDialogService } from '@taiga-ui/core'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { MarkdownComponent } from '@start9labs/shared'
import { from, map } from 'rxjs'
import { PatchDB } from 'patch-db-client'
import { NavController } from '@ionic/angular'
import { ActivatedRoute, Params } from '@angular/router'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { ProxyService } from 'src/app/services/proxy.service'
import {
AppConfigPage,
PackageConfigData,
} from '../../modals/app-config/app-config.page'
@Component({
selector: 'app-show-menu',
@@ -8,6 +25,68 @@ import { Button } from '../../pipes/to-buttons.pipe'
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowMenuComponent {
@Input()
buttons: Button[] = []
@Input({ required: true })
pkg!: PackageDataEntry
get highlighted$() {
return this.patch
.watch$('ui', 'ack-instructions', this.pkg.manifest.id)
.pipe(map(seen => !seen))
}
constructor(
private readonly route: ActivatedRoute,
private readonly navCtrl: NavController,
private readonly dialogs: TuiDialogService,
private readonly formDialog: FormDialogService,
private readonly api: ApiService,
readonly patch: PatchDB<DataModel>,
private readonly proxyService: ProxyService,
) {}
async presentModalInstructions() {
const { id, version } = this.pkg.manifest
this.api
.setDbValue<boolean>(['ack-instructions', id], true)
.catch(e => console.error('Failed to mark instructions as seen', e))
this.dialogs
.open(new PolymorpheusComponent(MarkdownComponent), {
label: 'Instructions',
size: 'l',
data: {
content: from(
this.api.getStatic(
`/public/package-data/${id}/${version}/INSTRUCTIONS.md`,
),
),
},
})
.subscribe()
}
openConfig() {
this.formDialog.open<PackageConfigData>(AppConfigPage, {
label: `${this.pkg.manifest.title} configuration`,
data: { pkgId: this.pkg.manifest.id },
})
}
setOutboundProxy() {
this.proxyService.presentModalSetOutboundProxy({
packageId: this.pkg.manifest.id,
outboundProxy: this.pkg.installed!.outboundProxy,
hasP2P: Object.values(this.pkg.installed!.interfaceInfo).some(
i => i.type === 'p2p',
),
})
}
navigate(path: string, qp?: Params) {
return this.navCtrl.navigateForward([path], {
relativeTo: this.route,
queryParams: qp,
})
}
}

View File

@@ -12,10 +12,10 @@ import { ProgressData } from 'src/app/types/progress-data'
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowProgressComponent {
@Input()
@Input({ required: true })
pkg!: PackageDataEntry
@Input()
@Input({ required: true })
progressData!: ProgressData
get unpackingBuffer(): number {

View File

@@ -48,19 +48,6 @@
<ion-icon slot="start" name="construct-outline"></ion-icon>
Configure
</ion-button>
<ion-button
*ngIf="addressInfo | hasUi"
class="action-button"
color="primary"
[disabled]="status.primary !== 'running'"
(click)="openPopover($event)"
>
<ion-icon slot="start" name="open-outline"></ion-icon>
Open UI
</ion-button>
<launch-menu #launchMenu [addressInfo]="addressInfo"></launch-menu>
</ion-col>
</ion-row>
</ion-grid>

View File

@@ -16,8 +16,8 @@ import {
StatusRendering,
} from 'src/app/services/pkg-status-rendering.service'
import {
AddressInfo,
DataModel,
InterfaceInfo,
PackageDataEntry,
PackageState,
} from 'src/app/services/patch-db/data-model'
@@ -30,7 +30,7 @@ import {
import { DependencyInfo } from '../../pipes/to-dependencies.pipe'
import { hasCurrentDeps } from 'src/app/util/has-deps'
import { ConnectionService } from 'src/app/services/connection.service'
import { LaunchMenuComponent } from '../../../launch-menu/launch-menu.component'
import { LaunchMenuComponent } from '../../../app-list/app-list-pkg/launch-menu/launch-menu.component'
@Component({
selector: 'app-show-status',
@@ -41,10 +41,10 @@ import { LaunchMenuComponent } from '../../../launch-menu/launch-menu.component'
export class AppShowStatusComponent {
@ViewChild('launchMenu') launchMenu!: LaunchMenuComponent
@Input()
@Input({ required: true })
pkg!: PackageDataEntry
@Input()
@Input({ required: true })
status!: PackageStatus
@Input()
@@ -66,8 +66,8 @@ export class AppShowStatusComponent {
return this.pkg.manifest.id
}
get addressInfo(): Record<string, AddressInfo> {
return this.pkg.installed!['address-info']
get interfaceInfo(): Record<string, InterfaceInfo> {
return this.pkg.installed!['interfaceInfo']
}
get isConfigured(): boolean {
@@ -90,11 +90,6 @@ export class AppShowStatusComponent {
return PrimaryRendering[this.status.primary]
}
openPopover(e: Event): void {
this.launchMenu.event = e
this.launchMenu.isOpen = true
}
presentModalConfig(): void {
this.formDialog.open<PackageConfigData>(AppConfigPage, {
label: `${this.pkg.manifest.title} configuration`,

View File

@@ -1,153 +0,0 @@
import { Pipe, PipeTransform } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { NavController } from '@ionic/angular'
import { MarkdownComponent } from '@start9labs/shared'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import {
AppConfigPage,
PackageConfigData,
} from '../modals/app-config/app-config.page'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { from, map, Observable } from 'rxjs'
import { PatchDB } from 'patch-db-client'
import { TuiDialogService } from '@taiga-ui/core'
export interface Button {
title: string
description: string
icon: string
action: Function
highlighted$?: Observable<boolean>
disabled?: boolean
}
@Pipe({
name: 'toButtons',
})
export class ToButtonsPipe implements PipeTransform {
constructor(
private readonly route: ActivatedRoute,
private readonly navCtrl: NavController,
private readonly dialogs: TuiDialogService,
private readonly formDialog: FormDialogService,
private readonly apiService: ApiService,
private readonly patch: PatchDB<DataModel>,
) {}
transform(pkg: PackageDataEntry): Button[] {
const pkgTitle = pkg.manifest.title
return [
// instructions
{
action: () => this.presentModalInstructions(pkg),
title: 'Instructions',
description: `Understand how to use ${pkgTitle}`,
icon: 'list-outline',
highlighted$: this.patch
.watch$('ui', 'ack-instructions', pkg.manifest.id)
.pipe(map(seen => !seen)),
},
// config
{
action: () =>
this.formDialog.open<PackageConfigData>(AppConfigPage, {
label: `${pkg.manifest.title} configuration`,
data: { pkgId: pkg.manifest.id },
}),
title: 'Config',
description: `Customize ${pkgTitle}`,
icon: 'options-outline',
},
// credentials
{
action: () =>
this.navCtrl.navigateForward(['credentials'], {
relativeTo: this.route,
}),
title: 'Credentials',
description: 'Password, keys, or other credentials of interest',
icon: 'key-outline',
},
// actions
{
action: () =>
this.navCtrl.navigateForward(['actions'], { relativeTo: this.route }),
title: 'Actions',
description: `Uninstall and other commands specific to ${pkgTitle}`,
icon: 'flash-outline',
},
// interfaces
{
action: () =>
this.navCtrl.navigateForward(['interfaces'], {
relativeTo: this.route,
}),
title: 'Interfaces',
description: 'User and machine access points',
icon: 'desktop-outline',
},
// logs
{
action: () =>
this.navCtrl.navigateForward(['logs'], { relativeTo: this.route }),
title: 'Logs',
description: 'Raw, unfiltered service logs',
icon: 'receipt-outline',
},
// view in marketplace
this.viewInMarketplaceButton(pkg),
]
}
private async presentModalInstructions(pkg: PackageDataEntry) {
const { id, version } = pkg.manifest
this.apiService
.setDbValue<boolean>(['ack-instructions', id], true)
.catch(e => console.error('Failed to mark instructions as seen', e))
this.dialogs
.open(new PolymorpheusComponent(MarkdownComponent), {
label: 'Instructions',
size: 'l',
data: {
content: from(
this.apiService.getStatic(
`/public/package-data/${id}/${version}/INSTRUCTIONS.md`,
),
),
},
})
.subscribe()
}
private viewInMarketplaceButton(pkg: PackageDataEntry): Button {
const url = pkg.installed?.['marketplace-url']
const queryParams = url ? { url } : {}
let button: Button = {
title: 'Marketplace Listing',
icon: 'storefront-outline',
action: () =>
this.navCtrl.navigateForward([`marketplace/${pkg.manifest.id}`], {
queryParams,
}),
disabled: false,
description: 'View service in the marketplace',
}
if (!url) {
button.disabled = true
button.description = 'This package was not installed from the marketplace'
button.action = () => {}
}
return button
}
}

View File

@@ -1,28 +0,0 @@
<ion-popover
#popover
(didDismiss)="popover.isOpen = false"
mode="ios"
type="event"
>
<ng-template>
<ion-content>
<ion-item-group>
<ng-container *ngFor="let address of addressInfo | uiAddresses">
<ion-item-divider>{{ address.name }}</ion-item-divider>
<ion-item
button
detail="false"
*ngFor="let address of address.addresses"
(click)="launchUI(address)"
>
<ion-label>
<h2>{{ address | addressType }}</h2>
<p>{{ address }}</p>
</ion-label>
<ion-icon slot="end" name="open-outline" size="small"></ion-icon>
</ion-item>
</ng-container>
</ion-item-group>
</ion-content>
</ng-template>
</ion-popover>

View File

@@ -24,13 +24,6 @@ const routes: Routes = [
m => m.AppActionsPageModule,
),
},
{
path: ':pkgId/interfaces',
loadChildren: () =>
import('./app-interfaces/app-interfaces.module').then(
m => m.AppInterfacesPageModule,
),
},
{
path: ':pkgId/logs',
loadChildren: () =>
@@ -43,6 +36,13 @@ const routes: Routes = [
m => m.AppCredentialsPageModule,
),
},
{
path: ':pkgId/interfaces/:interfaceId',
loadChildren: () =>
import('./app-interface/app-interface.module').then(
m => m.AppInterfacePageModule,
),
},
]
@NgModule({

View File

@@ -16,7 +16,7 @@ export class StatusComponent {
PS = PrimaryStatus
PR = PrimaryRendering
@Input() rendering!: StatusRendering
@Input({ required: true }) rendering!: StatusRendering
@Input() size?: string
@Input() style?: string = 'regular'
@Input() weight?: string = 'normal'

View File

@@ -1,8 +0,0 @@
import { NgModule } from '@angular/core'
import { UiPipe, UiAddressesPipe, AddressTypePipe } from './ui.pipe'
@NgModule({
declarations: [UiPipe, UiAddressesPipe, AddressTypePipe],
exports: [UiPipe, UiAddressesPipe, AddressTypePipe],
})
export class UiPipesModule {}

View File

@@ -1,56 +0,0 @@
import { Pipe, PipeTransform } from '@angular/core'
import { InstalledPackageInfo } from 'src/app/services/patch-db/data-model'
import { hasUi } from 'src/app/services/config.service'
@Pipe({
name: 'hasUi',
})
export class UiPipe implements PipeTransform {
transform(addressInfo: InstalledPackageInfo['address-info']): boolean {
return hasUi(addressInfo)
}
}
@Pipe({
name: 'uiAddresses',
})
export class UiAddressesPipe implements PipeTransform {
transform(
addressInfo: InstalledPackageInfo['address-info'],
): { name: string; addresses: string[] }[] {
return Object.values(addressInfo)
.filter(info => info.ui)
.map(info => ({
name: info.name,
addresses: info.addresses,
}))
}
}
@Pipe({
name: 'addressType',
})
export class AddressTypePipe implements PipeTransform {
transform(address: string): string {
if (isValidIpv4(address)) return 'IPv4'
if (isValidIpv6(address)) return 'IPv6'
const hostname = new URL(address).hostname
if (hostname.endsWith('.onion')) return 'Tor'
if (hostname.endsWith('.local')) return 'Local'
return 'Custom'
}
}
function isValidIpv4(address: string): boolean {
const regexExp =
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
return regexExp.test(address)
}
function isValidIpv6(address: string): boolean {
const regexExp =
/(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/gi
return regexExp.test(address)
}

View File

@@ -1,6 +1,8 @@
import { Config } from '@start9labs/start-sdk/lib/config/builder/config'
import { Value } from '@start9labs/start-sdk/lib/config/builder/value'
import { Variants } from '@start9labs/start-sdk/lib/config/builder/variants'
import { Proxy } from 'src/app/services/patch-db/data-model'
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
const auth = Config.of({
username: Value.text({
@@ -14,89 +16,137 @@ const auth = Config.of({
}),
})
const strategyUnion = Value.union(
{
name: 'Networking Strategy',
required: { default: 'router' },
},
Variants.of({
router: {
name: 'Router',
spec: Config.of({
ip: Value.select({
name: 'IP Strategy',
description: `
<h5>IPv6 Only</h5><b>Pros</b>: Ready for IPv6 Internet. Enhanced privacy, as IPv6 addresses are less correlated with geographic area
<b>Cons</b>: Your website is only accessible to people who's ISP supports IPv6
<h5>IPv6 and IPv4</h5><b>Pros</b>: Ready for IPv6 Internet. Anyone can access your website
<b>Cons</b>: IPv4 addresses are closely correlated with geographic areas
<h5>IPv4 Only</h5><b>Pros</b>: Anyone can access your website
<b>Cons</b>: IPv4 addresses are closely correlated with geographic areas
`,
required: { default: 'ipv6' },
values: {
ipv6: 'IPv6 Only',
both: 'IPv6 and IPv4',
ipv4: 'IPv4 Only',
},
}),
}),
},
reverseProxy: {
name: 'Reverse Proxy',
spec: Config.of({}),
},
}),
)
function getStrategyUnion(proxies: Proxy[]) {
const inboundProxies = proxies
.filter(p => p.type === 'inbound-outbound')
.reduce((prev, curr) => {
return {
[curr.id]: curr.name,
...prev,
}
}, {})
export const start9MeSpec = Config.of({
strategy: strategyUnion,
})
export const customSpec = Config.of({
hostname: Value.text({
name: 'Hostname',
required: { default: null },
placeholder: 'yourdomain.com',
}),
provider: Value.union(
return Value.union(
{
name: 'Dynamic DNS Provider',
required: { default: 'start9' },
name: 'Networking Strategy',
required: { default: null },
description: `<h5>Local</h5>Select this option if you do not mind exposing your home/business IP address to the Internet. This option requires configuring router settings, which StartOS can do automatically if you have an OpenWRT router
<h5>Proxy</h5>Select this option is you prefer to hide your home/business IP address from the Internet. This option requires running your own Virtual Private Server (VPS) <i>or</i> paying service provider such as Static Wire
`,
},
Variants.of({
start9: {
name: 'Start9',
spec: Config.of({}),
local: {
name: 'Local',
spec: Config.of({
ipStrategy: Value.select({
name: 'IP Strategy',
description: `<h5>IPv6 Only (recommended)</h5><b>Requirements</b>:<ol><li>ISP IPv6 support</li><li>OpenWRT (recommended) or Linksys router</li></ol><b>Pros</b>: Ready for IPv6 Internet. Enhanced privacy. Run multiple clearnet servers from the same network
<b>Cons</b>: Interfaces using this domain will only be accessible to people whose ISP supports IPv6
<h5>IPv6 and IPv4</h5><b>Pros</b>: Ready for IPv6 Internet. Accessible by anyone
<b>Cons</b>: Less private, as IPv4 addresses are closely correlated with geographic areas. Cannot run multiple clearnet servers from the same network
<h5>IPv4 Only</h5><b>Pros</b>: Accessible by anyone
<b>Cons</b>: Less private, as IPv4 addresses are closely correlated with geographic areas. Cannot run multiple clearnet servers from the same network
`,
required: { default: 'ipv6' },
values: {
ipv6: 'IPv6 Only',
ipv4: 'IPv4 Only',
dualstack: 'IPv6 and IPv4',
},
}),
}),
},
duckdns: {
name: 'Duck DNS',
spec: auth,
},
dyn: {
name: 'DynDNS',
spec: auth,
},
easydns: {
name: 'easyDNS',
spec: auth,
},
googledomains: {
name: 'Google Domains',
spec: auth,
},
namecheap: {
name: 'Namecheap (IPv4 only)',
spec: auth,
},
zoneedit: {
name: 'Zoneedit',
spec: auth,
proxy: {
name: 'Proxy',
spec: Config.of({
proxyStrategy: Value.union(
{
name: 'Proxy Strategy',
required: { default: 'primary' },
description: `<h5>Primary</h5>Use the <i>Primary Inbound</i> proxy from your proxy settings. If you do not have any inbound proxies, no proxy will be used
<h5>Other</h5>Use a specific proxy from your proxy settings
`,
},
Variants.of({
primary: {
name: 'Primary',
spec: Config.of({}),
},
other: {
name: 'Specific',
spec: Config.of({
proxyId: Value.select({
name: 'Select Proxy',
required: { default: null },
values: inboundProxies,
}),
}),
},
}),
),
}),
},
}),
),
strategy: strategyUnion,
})
)
}
export type Start9MeSpec = typeof start9MeSpec.validator._TYPE
export type CustomSpec = typeof customSpec.validator._TYPE
export async function getStart9ToSpec(proxies: Proxy[]) {
return configBuilderToSpec(
Config.of({
strategy: getStrategyUnion(proxies),
}),
)
}
export async function getCustomSpec(proxies: Proxy[]) {
return configBuilderToSpec(
Config.of({
hostname: Value.text({
name: 'Hostname',
required: { default: null },
placeholder: 'yourdomain.com',
}),
provider: Value.union(
{
name: 'Dynamic DNS Provider',
required: { default: 'start9' },
},
Variants.of({
start9: {
name: 'Start9',
spec: Config.of({}),
},
njalla: {
name: 'Njalla',
spec: auth,
},
duckdns: {
name: 'Duck DNS',
spec: auth,
},
dyn: {
name: 'DynDNS',
spec: auth,
},
easydns: {
name: 'easyDNS',
spec: auth,
},
zoneedit: {
name: 'Zoneedit',
spec: auth,
},
googledomains: {
name: 'Google Domains (IPv4 or IPv6)',
spec: auth,
},
namecheap: {
name: 'Namecheap (IPv4 only)',
spec: auth,
},
}),
),
strategy: getStrategyUnion(proxies),
}),
)
}

View File

@@ -10,21 +10,20 @@
<ion-content class="ion-padding-top with-widgets">
<div class="ion-padding-start ion-padding-end">
<tui-notification>
Adding domains to StartOS enables you to access your server and service
interfaces over clearnet.
Adding domains permits accessing your server and services over clearnet.
<a [href]="docsUrl" target="_blank" rel="noreferrer">View instructions</a>
</tui-notification>
</div>
<ion-item-group *ngIf="domains$ | async as domains">
<ion-item-divider>
Start9.me
Start9.to
<ion-button
*ngIf="!domains.start9Me"
*ngIf="!domains.start9To"
class="ion-padding-start"
strong
size="small"
(click)="presentModalClaimStart9Me()"
(click)="presentModalClaimStart9To()"
>
<ion-icon slot="start" name="add-outline"></ion-icon>
Claim
@@ -35,26 +34,27 @@
<ion-grid class="ion-padding">
<ion-row class="grid-headings">
<ion-col size="2">Domain</ion-col>
<ion-col size="2">Added</ion-col>
<ion-col size="2.5">Added</ion-col>
<ion-col size="2">DDNS Provider</ion-col>
<ion-col size="1.5">Network Strategy</ion-col>
<ion-col size="2">IP Strategy</ion-col>
<ion-col size="1.5">In Use</ion-col>
<ion-col size="1"></ion-col>
<ion-col size="2">Network Strategy</ion-col>
<ion-col size="1.5">Used By</ion-col>
<ion-col size="2"></ion-col>
</ion-row>
<ion-row
*ngIf="domains.start9Me as start9Me"
*ngIf="domains.start9To as start9To"
class="ion-align-items-center grid-row-border"
>
<ion-col size="2">{{ start9Me.value }}</ion-col>
<ion-col size="2">{{ start9Me.createdAt| date: 'short' }}</ion-col>
<ion-col size="2">{{ start9To.value }}</ion-col>
<ion-col size="2.5">{{ start9To.createdAt| date: 'short' }}</ion-col>
<ion-col size="2">Start9</ion-col>
<ion-col size="1.5">{{ start9Me.networkStrategy }}</ion-col>
<ion-col size="2">{{ start9Me.ipStrategy || 'N/A' }}</ion-col>
<ion-col size="1.5" *ngIf="start9Me.usedBy as usedBy">
<ion-col size="2">
{{ $any(start9To.networkStrategy).ipStrategy ||
$any(start9To.networkStrategy).proxyId || 'Primary Proxy' }}
</ion-col>
<ion-col size="1.5" *ngIf="start9To.usedBy as usedBy">
<a
*ngIf="usedBy.length as qty; else unused"
(click)="presentAlertUsedBy(start9Me.value, usedBy)"
(click)="presentAlertUsedBy(start9To.value, usedBy)"
>
{{ qty }} Interfaces
</a>
@@ -62,9 +62,9 @@
<span>N/A</span>
</ng-template>
</ion-col>
<ion-col size="1">
<ion-col size="2">
<ion-buttons style="float: right">
<ion-button size="small" (click)="presentAlertDeleteStart9Me()">
<ion-button size="small" (click)="presentAlertDeleteStart9To()">
<ion-icon name="trash"></ion-icon>
</ion-button>
</ion-buttons>
@@ -90,22 +90,23 @@
<ion-grid class="ion-padding">
<ion-row class="grid-headings">
<ion-col size="2">Domain</ion-col>
<ion-col size="2">Added</ion-col>
<ion-col size="2.5">Added</ion-col>
<ion-col size="2">DDNS Provider</ion-col>
<ion-col size="1.5">Network Strategy</ion-col>
<ion-col size="2">IP Strategy</ion-col>
<ion-col size="1.5">In Use</ion-col>
<ion-col size="1"></ion-col>
<ion-col size="2">Network Strategy</ion-col>
<ion-col size="1.5">Used By</ion-col>
<ion-col size="2"></ion-col>
</ion-row>
<ion-row
*ngFor="let domain of domains.custom"
class="ion-align-items-center grid-row-border"
>
<ion-col size="2">{{ domain.value }}</ion-col>
<ion-col size="2">{{ domain.createdAt| date: 'short' }}</ion-col>
<ion-col size="2.5">{{ domain.createdAt| date: 'short' }}</ion-col>
<ion-col size="2">{{ domain.provider }}</ion-col>
<ion-col size="1.5">{{ domain.networkStrategy }}</ion-col>
<ion-col size="2">{{ domain.ipStrategy || 'N/A' }}</ion-col>
<ion-col size="2">
{{ $any(domain.networkStrategy).ipStrategy ||
$any(domain.networkStrategy).proxyId || 'Primary Proxy' }}
</ion-col>
<ion-col size="1.5" *ngIf="domain.usedBy as usedBy">
<a
*ngIf="usedBy.length as qty; else unused"
@@ -117,7 +118,7 @@
<span>N/A</span>
</ng-template>
</ion-col>
<ion-col size="1">
<ion-col size="2">
<ion-buttons style="float: right">
<ion-button
size="small"

View File

@@ -2,20 +2,13 @@ import { Component } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { combineLatest, filter, first, map, switchMap } from 'rxjs'
import { filter, firstValueFrom, map } from 'rxjs'
import { PatchDB } from 'patch-db-client'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { DataModel, Domain } from 'src/app/services/patch-db/data-model'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import {
start9MeSpec,
Start9MeSpec,
customSpec,
CustomSpec,
} from './domain.const'
import { ConnectionService } from 'src/app/services/connection.service'
import { FormContext, FormPage } from '../../../modals/form/form.page'
import { getClearnetAddress } from 'src/app/util/clearnetAddress'
import { getCustomSpec, getStart9ToSpec } from './domain.const'
@Component({
selector: 'domains',
@@ -25,46 +18,19 @@ import { getClearnetAddress } from 'src/app/util/clearnetAddress'
export class DomainsPage {
readonly docsUrl = 'https://docs.start9.com/latest/user-manual/domains'
readonly server$ = this.patch.watch$('server-info')
readonly pkgs$ = this.patch.watch$('package-data').pipe(first())
readonly domains$ = this.patch.watch$('server-info', 'network').pipe(
map(network => {
const start9ToSubdomain = network.start9ToSubdomain
const start9To = !start9ToSubdomain
? null
: {
...start9ToSubdomain,
value: `${start9ToSubdomain.value}.start9.to`,
provider: 'Start9',
}
readonly domains$ = this.connectionService.websocketConnected$.pipe(
filter(Boolean),
switchMap(() =>
combineLatest([this.server$, this.pkgs$]).pipe(
map(([{ ui, network }, packageData]) => {
const start9MeSubdomain = network.start9MeSubdomain
const start9Me = !start9MeSubdomain
? null
: {
value: `${start9MeSubdomain.value}.start9.me`,
createdAt: start9MeSubdomain.createdAt,
provider: 'Start9',
networkStrategy: start9MeSubdomain.networkStrategy,
ipStrategy: start9MeSubdomain.ipStrategy,
usedBy: usedBy(
start9MeSubdomain.value,
getClearnetAddress('https', ui.domainInfo),
packageData,
),
}
const custom = network.domains.map(domain => ({
value: domain.value,
createdAt: domain.createdAt,
provider: domain.provider,
networkStrategy: domain.networkStrategy,
ipStrategy: domain.ipStrategy,
usedBy: usedBy(
domain.value,
getClearnetAddress('https', ui.domainInfo),
packageData,
),
}))
return { start9Me, custom }
}),
),
),
return { start9To, custom: network.domains }
}),
)
constructor(
@@ -73,16 +39,23 @@ export class DomainsPage {
private readonly api: ApiService,
private readonly loader: LoadingService,
private readonly formDialog: FormDialogService,
private readonly connectionService: ConnectionService,
private readonly patch: PatchDB<DataModel>,
) {}
async presentModalAdd() {
const options: Partial<TuiDialogOptions<FormContext<CustomSpec>>> = {
const proxies = await firstValueFrom(
this.patch.watch$('server-info', 'network', 'proxies'),
)
const options: Partial<TuiDialogOptions<FormContext<any>>> = {
label: 'Custom Domain',
data: {
spec: await customSpec.build({} as any),
spec: await getCustomSpec(proxies),
buttons: [
{
text: 'Manage proxies',
link: '/system/proxies',
},
{
text: 'Save',
handler: async value => this.save(value),
@@ -93,15 +66,23 @@ export class DomainsPage {
this.formDialog.open(FormPage, options)
}
async presentModalClaimStart9Me() {
const options: Partial<TuiDialogOptions<FormContext<Start9MeSpec>>> = {
label: 'start9.me',
async presentModalClaimStart9To() {
const proxies = await firstValueFrom(
this.patch.watch$('server-info', 'network', 'proxies'),
)
const options: Partial<TuiDialogOptions<FormContext<any>>> = {
label: 'start9.to',
data: {
spec: await start9MeSpec.build({} as any),
spec: await getStart9ToSpec(proxies),
buttons: [
{
text: 'Manage proxies',
link: '/system/proxies',
},
{
text: 'Save',
handler: async value => this.claimStart9MeDomain(value),
handler: async value => this.claimStart9ToDomain(value),
},
],
},
@@ -124,26 +105,26 @@ export class DomainsPage {
.subscribe(() => this.delete(hostname))
}
presentAlertDeleteStart9Me() {
presentAlertDeleteStart9To() {
this.dialogs
.open(TUI_PROMPT, {
label: 'Confirm',
size: 's',
data: {
content: 'Delete start9.me domain?',
content: 'Delete start9.to domain?',
yes: 'Delete',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.deleteStart9MeDomain())
.subscribe(() => this.deleteStart9ToDomain())
}
presentAlertUsedBy(domain: string, usedBy: string[]) {
presentAlertUsedBy(domain: string, usedBy: Domain['usedBy']) {
this.dialogs
.open(
`${domain} is currently being used by:<ul>${usedBy.map(
u => `<li>${u}</li>`,
`${domain} is currently being used by:<ul>${usedBy.map(u =>
u.interfaces.map(i => `<li>${u.service.title} - ${i.title}</li>`),
)}</ul>`,
{
label: 'Used by',
@@ -153,17 +134,23 @@ export class DomainsPage {
.subscribe()
}
private async claimStart9MeDomain(value: Start9MeSpec): Promise<boolean> {
private async claimStart9ToDomain(value: any): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
const networkStrategy = value.strategy.unionSelectKey
const strategy = value.strategy.unionValueKey
const networkStrategy =
value.strategy.unionSelectKey === 'local'
? { ipStrategy: strategy.ipStrategy }
: {
proxyId:
strategy.proxyStrategy.unionSelectKey === 'primary'
? null
: strategy.proxyStrategy.unionValueKey.proxyId,
}
try {
await this.api.claimStart9MeDomain({
networkStrategy,
ipStrategy:
networkStrategy === 'router' ? value.strategy.unionValueKey.ip : null,
})
await this.api.claimStart9ToDomain({ networkStrategy })
return true
} catch (e: any) {
this.errorService.handleError(e)
@@ -173,12 +160,23 @@ export class DomainsPage {
}
}
private async save(value: CustomSpec): Promise<boolean> {
private async save(value: any): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
const networkStrategy = value.strategy.unionSelectKey
const providerName = value.provider.unionSelectKey
const strategy = value.strategy.unionValueKey
const networkStrategy =
value.strategy.unionSelectKey === 'local'
? { ipStrategy: strategy.ipStrategy }
: {
proxyId:
strategy.proxyStrategy.unionSelectKey === 'primary'
? null
: strategy.proxyStrategy.unionValueKey.proxyId,
}
try {
await this.api.addDomain({
hostname: value.hostname,
@@ -194,8 +192,6 @@ export class DomainsPage {
: value.provider.unionValueKey.password,
},
networkStrategy,
ipStrategy:
networkStrategy === 'router' ? value.strategy.unionValueKey.ip : null,
})
return true
} catch (e: any) {
@@ -218,11 +214,11 @@ export class DomainsPage {
}
}
private async deleteStart9MeDomain(): Promise<void> {
private async deleteStart9ToDomain(): Promise<void> {
const loader = this.loader.open('Deleting...').subscribe()
try {
await this.api.deleteStart9MeDomain({})
await this.api.deleteStart9ToDomain({})
} catch (e: any) {
this.errorService.handleError(e)
} finally {
@@ -230,21 +226,3 @@ export class DomainsPage {
}
}
}
function usedBy(
domain: string,
serverUi: string | null,
pkgs: DataModel['package-data'],
): string[] {
const list = []
if (serverUi && serverUi.includes(domain)) list.push('StartOS Web Interface')
return list.concat(
Object.values(pkgs)
.filter(pkg =>
Object.values(pkg.installed?.['address-info'] || {}).some(ai =>
ai.addresses.some(a => a.includes(domain)),
),
)
.map(pkg => pkg.manifest.title),
)
}

View File

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

View File

@@ -1,228 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="system"></ion-back-button>
</ion-buttons>
<ion-title>StartOS Web Interface</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="with-widgets">
<div *ngIf="server$ | async as server" class="cap-width">
<!-- clearnet -->
<ion-item-divider style="--padding-top: 0">Clearnet</ion-item-divider>
<ion-item-group>
<ion-item>
<ion-label>
<h2>
Clearnet provides a fast and convenient experience. It not not
provide anonymity, and the addresses can be discovered and accessed
by anyone.
<a
href="https://docs.start9.com/latest/user-manual/os-addresses#clearnet"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</h2>
</ion-label>
</ion-item>
<ng-container *ngIf="server.ui.domainInfo as domainInfo; else noClearnet">
<ion-item *ngIf="domainInfo | osClearnetPipe as clearnetAddress">
<ion-label>
<h2>Clearnet</h2>
<p>{{ clearnetAddress }}</p>
<div class="ion-padding-top">
<ion-button (click)="presentModalAddClearnet(server)">
Update
</ion-button>
<ion-button
class="ion-padding-start"
(click)="presentAlertRemoveClearnet()"
color="danger"
>
Remove
</ion-button>
</div>
</ion-label>
<div slot="end">
<ion-button fill="clear" (click)="launch(clearnetAddress)">
<ion-icon
slot="icon-only"
size="small"
name="open-outline"
></ion-icon>
</ion-button>
<ion-button
fill="clear"
(click)="copyService.copy(clearnetAddress)"
>
<ion-icon
slot="icon-only"
size="small"
name="copy-outline"
></ion-icon>
</ion-button>
</div>
</ion-item>
</ng-container>
<ng-template #noClearnet>
<div class="ion-padding">
<ion-button strong (click)="presentModalAddClearnet(server)">
<ion-icon slot="start" name="add"></ion-icon>
Add Clearnet Address
</ion-button>
</div>
</ng-template>
</ion-item-group>
<!-- tor -->
<ion-item-divider>Tor</ion-item-divider>
<ion-item-group>
<ion-item>
<ion-label>
<h2>
Tor offers privacy and anonymity at the expense of speed and
reliability. A Tor-enabled browser is required to use a Tor address.
<a
href="https://docs.start9.com/latest/user-manual/os-addresses#tor"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</h2>
</ion-label>
</ion-item>
<ion-item *ngIf="server.ui.torHostname as torHostname">
<ion-label class="break-all">
<h2>Tor</h2>
<p>{{ torHostname }}</p>
</ion-label>
<div slot="end">
<ion-button fill="clear" (click)="launch(torHostname)">
<ion-icon
slot="icon-only"
size="small"
name="open-outline"
></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="copyService.copy(torHostname)">
<ion-icon
slot="icon-only"
size="small"
name="copy-outline"
></ion-icon>
</ion-button>
</div>
</ion-item>
</ion-item-group>
<!-- local -->
<ion-item-divider>LAN</ion-item-divider>
<ion-item-group>
<ion-item>
<ion-label>
<h2>
LAN offers a fast and private experience. These addresses can only
be accessed from a device connected to the same LAN as your server,
either directly or using a VPN.
<a
href="https://docs.start9.com/latest/user-manual/os-addresses#local"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</h2>
<div class="ion-padding-top ion-padding-bottom">
<ion-button
(click)="installCert()"
[disabled]="!(crtName$ | async)"
strong
>
<ion-icon slot="start" name="download-outline"></ion-icon>
Download Root CA
</ion-button>
</div>
</ion-label>
</ion-item>
<ion-item *ngIf="server.ui.lanHostname as lanHostname">
<ion-label class="break-all">
<h2>Local</h2>
<p>{{ lanHostname }}</p>
</ion-label>
<div slot="end">
<ion-button fill="clear" (click)="launch(lanHostname)">
<ion-icon
slot="icon-only"
size="small"
name="open-outline"
></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="copyService.copy(lanHostname)">
<ion-icon
slot="icon-only"
size="small"
name="copy-outline"
></ion-icon>
</ion-button>
</div>
</ion-item>
<ng-container *ngFor="let iface of server.ui.ipInfo | keyvalue">
<ion-item *ngIf="iface.value.ipv4 as ipv4">
<ion-label>
<h2>{{ iface.key }} (IPv4)</h2>
<p>{{ ipv4 }}</p>
</ion-label>
<div slot="end">
<ion-button fill="clear" (click)="launch(ipv4)">
<ion-icon
slot="icon-only"
size="small"
name="open-outline"
></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="copyService.copy(ipv4)">
<ion-icon
slot="icon-only"
size="small"
name="copy-outline"
></ion-icon>
</ion-button>
</div>
</ion-item>
<ion-item *ngIf="iface.value.ipv6 as ipv6">
<ion-label>
<h2>{{ iface.key }} (IPv6)</h2>
<p>{{ ipv6 }}</p>
</ion-label>
<div slot="end">
<ion-button fill="clear" (click)="launch(ipv6)">
<ion-icon
slot="icon-only"
size="small"
name="open-outline"
></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="copyService.copy(ipv6)">
<ion-icon
slot="icon-only"
size="small"
name="copy-outline"
></ion-icon>
</ion-button>
</div>
</ion-item>
</ng-container>
</ion-item-group>
</div>
<!-- hidden element for downloading cert -->
<a
id="install-cert"
href="/public/eos/local.crt"
[download]="crtName$ | async"
></a>
</ion-content>

View File

@@ -0,0 +1,42 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { RouterModule, Routes } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import {
TuiButtonModule,
TuiDataListModule,
TuiHostedDropdownModule,
TuiNotificationModule,
TuiSvgModule,
TuiWrapperModule,
} from '@taiga-ui/core'
import { TuiBadgeModule, TuiInputModule, TuiToggleModule } from '@taiga-ui/kit'
import { ProxiesPage } from './proxies.page'
const routes: Routes = [
{
path: '',
component: ProxiesPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
FormsModule,
TuiNotificationModule,
TuiButtonModule,
TuiInputModule,
TuiToggleModule,
TuiWrapperModule,
TuiBadgeModule,
TuiSvgModule,
TuiHostedDropdownModule,
TuiDataListModule,
],
declarations: [ProxiesPage],
})
export class ProxiesPageModule {}

View File

@@ -0,0 +1,144 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="/system"></ion-back-button>
</ion-buttons>
<ion-title>Proxies</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<div class="ion-padding-start ion-padding-end">
<tui-notification>
Currently, StartOS only supports Wireguard proxies, which can be used for:
<ol>
<li>
Proxying
<i>outbound</i>
traffic to mask your home/business IP from other servers accessed by
your server/services
</li>
<li>
Proxying
<i>inbound</i>
traffic to mask your home/business IP from anyone accessing your
server/services over clearnet
</li>
<li>
Creating a Virtual Local Area Network (VLAN) to enable private, remote
VPN access to your server/services
</li>
</ol>
<a [href]="docsUrl" target="_blank" rel="noreferrer">View instructions</a>
</tui-notification>
</div>
<ion-item-group>
<ion-item-divider>
Proxies
<ion-button
class="ion-padding-start"
strong
size="small"
(click)="presentModalAdd()"
>
<ion-icon slot="start" name="add"></ion-icon>
Add Proxy
</ion-button>
</ion-item-divider>
<div class="grid-fixed">
<ion-grid class="ion-padding">
<ion-row class="grid-headings">
<ion-col size="2">Name</ion-col>
<ion-col size="2">Created</ion-col>
<ion-col size="2">Type</ion-col>
<ion-col size="3">Primary</ion-col>
<ion-col size="2">Used By</ion-col>
<ion-col size="1"></ion-col>
</ion-row>
<ion-row
*ngFor="let proxy of proxies$ | async"
class="ion-align-items-center grid-row-border"
>
<ion-col size="2">{{ proxy.name }}</ion-col>
<ion-col size="2">{{ proxy.createdAt| date: 'short' }}</ion-col>
<ion-col size="2">{{ proxy.type }}</ion-col>
<ion-col size="3">
<tui-badge
*ngIf="proxy.primaryInbound"
status="success"
value="Inbound"
style="margin-right: 4px"
></tui-badge>
<tui-badge
*ngIf="proxy.primaryOutbound"
status="info"
value="Outbound"
></tui-badge>
</ion-col>
<ion-col size="2" *ngIf="proxy.usedBy as usedBy">
<a
*ngIf="usedBy.domains.length || usedBy.services.length; else unused"
(click)="presentAlertUsedBy(proxy.name, usedBy)"
>
{{ usedBy.domains.length + usedBy.services.length }} Connections
</a>
<ng-template #unused>
<span>N/A</span>
</ng-template>
</ion-col>
<ion-col size="1">
<tui-hosted-dropdown
style="float: right"
tuiDropdownAlign="left"
[sided]="true"
[content]="dropdown"
[(open)]="menuOpen"
>
<button
tuiIconButton
type="button"
appearance="flat"
tuiHostedDropdownHost
size="s"
[icon]="icon"
></button>
<ng-template #icon>
<tui-svg src="tuiIconMoreHorizontal" class="icon"></tui-svg>
</ng-template>
</tui-hosted-dropdown>
<ng-template #dropdown let-close="close">
<tui-data-list>
<tui-opt-group>
<button
*ngIf="!proxy.primaryInbound && proxy.type === 'inbound-outbound'"
tuiOption
(click)="update({ primaryInbound: true })"
>
Make Primary Inbound
</button>
<button
*ngIf="!proxy.primaryOutbound && (proxy.type === 'inbound-outbound' || proxy.type === 'outbound')"
tuiOption
(click)="update({ primaryOutbound: true })"
>
Make Primary Outbound
</button>
<button tuiOption (click)="presentModalRename(proxy)">
Rename
</button>
</tui-opt-group>
<tui-opt-group>
<button tuiOption (click)="presentAlertDelete(proxy.id)">
Delete
</button>
</tui-opt-group>
</tui-data-list>
</ng-template>
</ion-col>
</ion-row>
</ion-grid>
</div>
</ion-item-group>
</ion-content>

View File

@@ -0,0 +1,180 @@
import { Component, ViewChild } from '@angular/core'
import {
TuiDialogOptions,
TuiDialogService,
TuiHostedDropdownComponent,
} from '@taiga-ui/core'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { filter } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { DataModel, Proxy } from 'src/app/services/patch-db/data-model'
import { FormContext, FormPage } from '../../../modals/form/form.page'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { Config } from '@start9labs/start-sdk/lib/config/builder/config'
import { Value } from '@start9labs/start-sdk/lib/config/builder/value'
@Component({
selector: 'proxies',
templateUrl: './proxies.page.html',
styleUrls: ['./proxies.page.scss'],
})
export class ProxiesPage {
@ViewChild(TuiHostedDropdownComponent)
menuComponent?: TuiHostedDropdownComponent
menuOpen = false
readonly docsUrl = 'https://docs.start9.com/latest/user-manual/vpns/'
readonly proxies$ = this.patch.watch$('server-info', 'network', 'proxies')
constructor(
private readonly dialogs: TuiDialogService,
private readonly loader: LoadingService,
private readonly errorService: ErrorService,
private readonly api: ApiService,
private readonly patch: PatchDB<DataModel>,
private readonly formDialog: FormDialogService,
) {}
async presentModalAdd() {
const options: Partial<TuiDialogOptions<FormContext<WireguardSpec>>> = {
label: 'Add Proxy',
data: {
spec: await wireguardSpec.build({} as any),
buttons: [
{
text: 'Save',
handler: value => this.save(value).then(() => true),
},
],
},
}
this.formDialog.open(FormPage, options)
}
async presentModalRename(proxy: Proxy) {
const options: Partial<TuiDialogOptions<FormContext<{ name: string }>>> = {
label: `Rename ${proxy.name}`,
data: {
spec: {
name: await Value.text({
name: 'Name',
required: { default: proxy.name },
}).build({} as any),
},
buttons: [
{
text: 'Save',
handler: value => this.update(value).then(() => true),
},
],
},
}
this.formDialog.open(FormPage, options)
}
presentAlertDelete(id: string) {
this.dialogs
.open(TUI_PROMPT, {
label: 'Confirm',
size: 's',
data: {
content: 'Delete proxy? This action cannot be undone.',
yes: 'Delete',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(() => {
this.delete(id)
})
}
presentAlertUsedBy(name: string, usedBy: Proxy['usedBy']) {
let message = `Proxy "${name}" is currently used by:`
if (usedBy.domains.length) {
message = `${message}<h2>Domains (inbound)</h2><ul>${usedBy.domains.map(
d => `<li>${d}</li>`,
)}</ul>`
}
if (usedBy.services.length) {
message = `${message}<h2>Services (outbound)</h2>${usedBy.services.map(
s => `<li>${s.title}</li>`,
)}`
}
this.dialogs
.open(message, {
label: 'Used by',
size: 's',
})
.subscribe()
}
private async save(value: WireguardSpec): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
try {
await this.api.addProxy({
name: value.name,
config: value.config?.filePath || '',
})
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
async update(
value: Partial<{
name: string
primaryInbound: true
primaryOutbound: true
}>,
): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
try {
await this.api.updateProxy(value)
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
private async delete(id: string): Promise<void> {
const loader = this.loader.open('Deleting...').subscribe()
try {
await this.api.deleteProxy({ id })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}
const wireguardSpec = Config.of({
name: Value.text({
name: 'Name',
description: 'A friendly name to help you remember and identify this proxy',
required: { default: null },
}),
config: Value.file({
name: 'Wiregaurd Config',
required: { default: null },
extensions: ['.conf'],
}),
})
type WireguardSpec = typeof wireguardSpec.validator._TYPE

View File

@@ -2,14 +2,14 @@ import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { PortForwardsPage } from './port-forwards.page'
import { RouterPage } from './router.page'
import { PrimaryIpPipeModule } from 'src/app/common/primary-ip/primary-ip.module'
import { FormsModule } from '@angular/forms'
const routes: Routes = [
{
path: '',
component: PortForwardsPage,
component: RouterPage,
},
]
@@ -21,6 +21,6 @@ const routes: Routes = [
PrimaryIpPipeModule,
FormsModule,
],
declarations: [PortForwardsPage],
declarations: [RouterPage],
})
export class PortForwardsPageModule {}
export class RouterPageModule {}

View File

@@ -5,12 +5,12 @@ import { LoadingService, CopyService, ErrorService } from '@start9labs/shared'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({
selector: 'port-forwards',
templateUrl: './port-forwards.page.html',
styleUrls: ['./port-forwards.page.scss'],
selector: 'router',
templateUrl: './router.page.html',
styleUrls: ['./router.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PortForwardsPage {
export class RouterPage {
readonly server$ = this.patch.watch$('server-info')
editing: Record<string, boolean> = {}
overrides: Record<string, number> = {}

View File

@@ -66,6 +66,16 @@
</ng-template>
</ng-template>
</p>
<!-- "Outbound Proxy" button only -->
<p *ngIf="button.title === 'Outbound Proxy'">
<ion-text
[color]="!server.network.outboundProxy ? 'warning' : 'success'"
>
{{ !server.network.outboundProxy ? 'None' :
server.network.outboundProxy === 'primary' ? 'System Primary' :
server.network.outboundProxy.proxyId }}
</ion-text>
</p>
</ion-label>
</ion-item>
</ion-item-group>

View File

@@ -24,6 +24,7 @@ import { TUI_PROMPT } from '@taiga-ui/kit'
import { DOCUMENT } from '@angular/common'
import { getServerInfo } from 'src/app/util/get-server-info'
import * as argon2 from '@start9labs/argon2'
import { ProxyService } from 'src/app/services/proxy.service'
@Component({
selector: 'server-show',
@@ -46,7 +47,7 @@ export class ServerShowPage {
private readonly dialogs: TuiDialogService,
private readonly loader: LoadingService,
private readonly errorService: ErrorService,
private readonly embassyApi: ApiService,
private readonly api: ApiService,
private readonly navCtrl: NavController,
private readonly route: ActivatedRoute,
private readonly patch: PatchDB<DataModel>,
@@ -56,6 +57,7 @@ export class ServerShowPage {
private readonly alerts: TuiAlertService,
private readonly config: ConfigService,
private readonly formDialog: FormDialogService,
private readonly proxyService: ProxyService,
@Inject(DOCUMENT) private readonly document: Document,
) {}
@@ -156,7 +158,7 @@ export class ServerShowPage {
const loader = this.loader.open('Saving...').subscribe()
try {
await this.embassyApi.resetPassword({
await this.api.resetPassword({
'old-password': value.currentPassword,
'new-password': value.newPassword1,
})
@@ -256,7 +258,7 @@ export class ServerShowPage {
const loader = this.loader.open('Saving...').subscribe()
try {
await this.embassyApi.setDbValue<string | null>(['name'], value)
await this.api.setDbValue<string | null>(['name'], value)
} finally {
loader.unsubscribe()
}
@@ -264,7 +266,7 @@ export class ServerShowPage {
// should wipe cache independent of actual BE logout
private logout() {
this.embassyApi.logout({}).catch(e => console.error('Failed to log out', e))
this.api.logout({}).catch(e => console.error('Failed to log out', e))
this.authService.setUnverified()
}
@@ -273,7 +275,7 @@ export class ServerShowPage {
const loader = this.loader.open(`Beginning ${action}...`).subscribe()
try {
await this.embassyApi.restartServer({})
await this.api.restartServer({})
this.presentAlertInProgress(action, ` until ${action} completes.`)
} catch (e: any) {
this.errorService.handleError(e)
@@ -287,7 +289,7 @@ export class ServerShowPage {
const loader = this.loader.open(`Beginning ${action}...`).subscribe()
try {
await this.embassyApi.shutdownServer({})
await this.api.shutdownServer({})
this.presentAlertInProgress(
action,
'.<br /><br /><b>You will need to physically power cycle the device to regain connectivity.</b>',
@@ -304,7 +306,7 @@ export class ServerShowPage {
const loader = this.loader.open(`Beginning ${action}...`).subscribe()
try {
await this.embassyApi.systemRebuild({})
await this.api.systemRebuild({})
this.presentAlertInProgress(action, ` until ${action} completes.`)
} catch (e: any) {
this.errorService.handleError(e)
@@ -375,14 +377,6 @@ export class ServerShowPage {
detail: false,
disabled$: this.eosService.updatingOrBackingUp$,
},
{
title: 'Browser Tab Title',
description: `Customize the display name of your browser tab`,
icon: 'pricetag-outline',
action: () => this.setBrowserTab(),
detail: false,
disabled$: of(false),
},
{
title: 'Email',
description:
@@ -425,21 +419,9 @@ export class ServerShowPage {
},
],
Network: [
{
title: 'StartOS Web Interface',
description: 'Addresses for accessing this StartOS web interface',
icon: 'desktop-outline',
action: () =>
this.navCtrl.navigateForward(['addresses'], {
relativeTo: this.route,
}),
detail: true,
disabled$: of(false),
},
{
title: 'Domains',
description:
'Add domains to your server to enable clearnet connections',
description: 'Manage domains for clearnet connectivity',
icon: 'globe-outline',
action: () =>
this.navCtrl.navigateForward(['domains'], { relativeTo: this.route }),
@@ -447,12 +429,20 @@ export class ServerShowPage {
disabled$: of(false),
},
{
title: 'Port Forwards',
description:
'A list of ports that should be forwarded through your router',
icon: 'trail-sign-outline',
title: 'Proxies',
description: 'Manage proxies for inbound and outbound connections',
icon: 'shuffle-outline',
action: () =>
this.navCtrl.navigateForward(['port-forwards'], {
this.navCtrl.navigateForward(['proxies'], { relativeTo: this.route }),
detail: true,
disabled$: of(false),
},
{
title: 'Router Config',
description: 'Connect or configure your router for clearnet',
icon: 'radio-outline',
action: () =>
this.navCtrl.navigateForward(['router-config'], {
relativeTo: this.route,
}),
detail: true,
@@ -468,7 +458,36 @@ export class ServerShowPage {
disabled$: of(false),
},
],
Security: [
'User Interface': [
{
title: 'Browser Tab Title',
description: `Customize the display name of your browser tab`,
icon: 'pricetag-outline',
action: () => this.setBrowserTab(),
detail: false,
disabled$: of(false),
},
{
title: 'Web Addresses',
description: 'View and manage web addresses for accessing this UI',
icon: 'desktop-outline',
action: () =>
this.navCtrl.navigateForward(['interfaces', 'ui'], {
relativeTo: this.route,
}),
detail: true,
disabled$: of(false),
},
],
'Privacy and Security': [
{
title: 'Outbound Proxy',
description: 'Proxy outbound traffic from the StartOS main process',
icon: 'shield-outline',
action: () => this.proxyService.presentModalSetOutboundProxy(),
detail: false,
disabled$: of(false),
},
{
title: 'SSH',
description:
@@ -493,7 +512,7 @@ export class ServerShowPage {
],
Logs: [
{
title: 'System Resources',
title: 'Activity Monitor',
description: 'CPU, disk, memory, and other useful metrics',
icon: 'pulse',
action: () =>

View File

@@ -10,18 +10,14 @@ const routes: Routes = [
),
},
{
path: 'addresses',
path: 'interfaces/ui',
loadChildren: () =>
import('./os-addresses/os-addresses.module').then(
m => m.OSAddressesPageModule,
),
import('./ui-details/ui-details.module').then(m => m.UIDetailsPageModule),
},
{
path: 'port-forwards',
path: 'router-config',
loadChildren: () =>
import('./port-forwards/port-forwards.module').then(
m => m.PortForwardsPageModule,
),
import('./router/router.module').then(m => m.RouterPageModule),
},
{
path: 'logs',
@@ -71,6 +67,11 @@ const routes: Routes = [
loadChildren: () =>
import('./domains/domains.module').then(m => m.DomainsPageModule),
},
{
path: 'proxies',
loadChildren: () =>
import('./proxies/proxies.module').then(m => m.ProxiesPageModule),
},
{
path: 'ssh',
loadChildren: () =>

View File

@@ -0,0 +1,24 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { UIDetailsPage } from './ui-details.page'
import { InterfaceAddressesComponentModule } from 'src/app/common/interface-addresses/interface-addresses.module'
const routes: Routes = [
{
path: '',
component: UIDetailsPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
InterfaceAddressesComponentModule,
],
declarations: [UIDetailsPage],
})
export class UIDetailsPageModule {}

View File

@@ -0,0 +1,14 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="system"></ion-back-button>
</ion-buttons>
<ion-title>StartOS UI</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="with-widgets">
<div *ngIf="ui$ | async as ui" class="cap-width">
<interface-addresses [addressInfo]="ui" [isUi]="true"></interface-addresses>
</div>
</ion-content>

View File

@@ -0,0 +1,15 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Component({
selector: 'ui-details',
templateUrl: './ui-details.page.html',
styleUrls: ['./ui-details.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UIDetailsPage {
readonly ui$ = this.patch.watch$('server-info', 'ui')
constructor(private readonly patch: PatchDB<DataModel>) {}
}

View File

@@ -21,7 +21,7 @@ import { ERRORS } from '../form-group/form-group.component'
providers: [TuiDestroyService],
})
export class FormArrayComponent {
@Input()
@Input({ required: true })
spec!: ValueSpecList
@HostBinding('@tuiParentStop')

View File

@@ -28,7 +28,7 @@ export class FormControlComponent<
T extends ValueSpec,
V,
> extends AbstractTuiNullableControl<V> {
@Input()
@Input({ required: true })
spec!: T
@ViewChild('warning')

View File

@@ -16,7 +16,7 @@ import { ValueSpecObject } from '@start9labs/start-sdk/lib/config/configTypes'
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FormObjectComponent {
@Input()
@Input({ required: true })
spec!: ValueSpecObject
@Input()

View File

@@ -28,7 +28,7 @@ import { tuiPure } from '@taiga-ui/cdk'
],
})
export class FormUnionComponent implements OnChanges {
@Input()
@Input({ required: true })
spec!: ValueSpecUnion
selectSpec!: ValueSpecSelect

View File

@@ -0,0 +1,18 @@
<ion-item>
<ion-label class="break-all">
<h2>{{ label }}</h2>
<p>{{ hostname }}</p>
<ng-content></ng-content>
</ion-label>
<div slot="end">
<ion-button *ngIf="isUi" fill="clear" (click)="launch(hostname)">
<ion-icon slot="icon-only" size="small" name="open-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="showQR(hostname)">
<ion-icon size="small" slot="icon-only" name="qr-code-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="copyService.copy(hostname)">
<ion-icon slot="icon-only" size="small" name="copy-outline"></ion-icon>
</ion-button>
</div>
</ion-item>

View File

@@ -0,0 +1,125 @@
<ng-container *ngIf="network$ | async as network">
<!-- clearnet -->
<ion-item-divider style="--padding-top: 0">Clearnet</ion-item-divider>
<ion-item-group>
<ion-item>
<ion-label>
<h2>
Add clearnet to expose this interface to the public Internet.
<a
href="https://docs.start9.com/latest/user-manual/interface-addresses#clearnet"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</h2>
</ion-label>
</ion-item>
<ng-container *ngIf="addressInfo.domainInfo as domainInfo; else noClearnet">
<interface-addresses-item
label="Clearnet"
[hostname]="domainInfo | interfaceClearnetPipe"
[isUi]="isUi"
>
<div class="ion-padding-top">
<ion-button (click)="presentModalAddClearnet(network)">
Update
</ion-button>
<ion-button
class="ion-padding-start"
(click)="presentAlertRemoveClearnet()"
color="danger"
>
Remove
</ion-button>
</div>
</interface-addresses-item>
</ng-container>
<ng-template #noClearnet>
<div class="ion-padding">
<ion-button strong (click)="presentModalAddClearnet(network)">
<ion-icon slot="start" name="add"></ion-icon>
Add Clearnet
</ion-button>
</div>
</ng-template>
</ion-item-group>
<!-- tor -->
<ion-item-divider>Tor</ion-item-divider>
<ion-item-group>
<ion-item>
<ion-label>
<h2>
Use a Tor-enabled browser to access this address. Tor connections can
be slow and unreliable.
<a
href="https://docs.start9.com/latest/user-manual/interface-addresses#tor"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</h2>
</ion-label>
</ion-item>
<interface-addresses-item
label="Tor"
[hostname]="addressInfo.torHostname"
[isUi]="isUi"
></interface-addresses-item>
</ion-item-group>
<!-- local -->
<ion-item-divider>Local</ion-item-divider>
<ion-item-group>
<ion-item>
<ion-label>
<h2>
Local addresses can only be accessed while connected to the same Local
Area Network (LAN) as your server, either directly or using a VPN.
<a
href="https://docs.start9.com/latest/user-manual/interface-addresses#local"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</h2>
<div *ngIf="!packageContext" class="ion-padding-top ion-padding-bottom">
<ion-button (click)="installCert()" strong>
<ion-icon slot="start" name="download-outline"></ion-icon>
Download Root CA
</ion-button>
</div>
</ion-label>
</ion-item>
<interface-addresses-item
label="Local"
[hostname]="addressInfo.lanHostname"
[isUi]="isUi"
></interface-addresses-item>
<ng-container *ngFor="let iface of addressInfo.ipInfo | keyvalue">
<interface-addresses-item
*ngIf="iface.value.ipv4 as ipv4"
[label]="iface.key + ' (IPv4)'"
[hostname]="ipv4"
[isUi]="isUi"
></interface-addresses-item>
<interface-addresses-item
*ngIf="iface.value.ipv6 as ipv6"
[label]="iface.key + ' (IPv6)'"
[hostname]="ipv6"
[isUi]="isUi"
></interface-addresses-item>
</ng-container>
</ion-item-group>
<!-- hidden element for downloading cert -->
<a
id="install-cert"
href="/public/eos/local.crt"
[download]="addressInfo.lanHostname + '.crt'"
></a>
</ng-container>

View File

@@ -1,25 +1,32 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import {
ChangeDetectionStrategy,
Component,
Inject,
Input,
} from '@angular/core'
import { LoadingService, CopyService, ErrorService } from '@start9labs/shared'
import { Config } from '@start9labs/start-sdk/lib/config/builder/config'
import { Value } from '@start9labs/start-sdk/lib/config/builder/value'
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes'
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { filter, map } from 'rxjs'
import { filter } from 'rxjs'
import {
DomainInfo,
AddressInfo,
DataModel,
DomainInfo,
NetworkInfo,
ServerInfo,
} from 'src/app/services/patch-db/data-model'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormContext, FormPage } from '../../../modals/form/form.page'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { DOCUMENT } from '@angular/common'
import { Pipe, PipeTransform } from '@angular/core'
import { getClearnetAddress } from 'src/app/util/clearnetAddress'
import { DOCUMENT } from '@angular/common'
import { FormContext, FormPage } from 'src/app/apps/ui/modals/form/form.page'
import { PatchDB } from 'patch-db-client'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { QRComponent } from 'src/app/common/qr/qr.component'
export type ClearnetForm = {
domain: string
@@ -27,39 +34,37 @@ export type ClearnetForm = {
}
@Component({
selector: 'os-addresses',
templateUrl: './os-addresses.page.html',
styleUrls: ['./os-addresses.page.scss'],
selector: 'interface-addresses',
templateUrl: './interface-addresses.component.html',
styleUrls: ['./interface-addresses.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OSAddressesPage {
readonly server$ = this.patch.watch$('server-info')
export class InterfaceAddressesComponent {
@Input() packageContext?: {
packageId: string
interfaceId: string
}
@Input({ required: true }) addressInfo!: AddressInfo
@Input({ required: true }) isUi!: boolean
readonly crtName$ = this.server$.pipe(
map(server => `${server.ui.lanHostname}.crt`),
)
readonly network$ = this.patch.watch$('server-info', 'network')
constructor(
readonly copyService: CopyService,
private readonly loader: LoadingService,
private readonly formDialog: FormDialogService,
private readonly patch: PatchDB<DataModel>,
private readonly errorService: ErrorService,
private readonly api: ApiService,
private readonly dialogs: TuiDialogService,
private readonly patch: PatchDB<DataModel>,
@Inject(DOCUMENT) private readonly document: Document,
) {}
launch(url: string): void {
this.document.defaultView?.open(url, '_blank', 'noreferrer')
}
installCert(): void {
this.document.getElementById('install-cert')?.click()
}
async presentModalAddClearnet(server: ServerInfo) {
const domainInfo = server.ui.domainInfo
async presentModalAddClearnet(networkInfo: NetworkInfo) {
const domainInfo = this.addressInfo.domainInfo
const options: Partial<TuiDialogOptions<FormContext<ClearnetForm>>> = {
label: 'Select Domain/Subdomain',
data: {
@@ -67,7 +72,7 @@ export class OSAddressesPage {
domain: domainInfo?.domain || '',
subdomain: domainInfo?.subdomain || '',
},
spec: await this.getClearnetSpec(server.network),
spec: await getClearnetSpec(networkInfo),
buttons: [
{
text: 'Manage domains',
@@ -102,7 +107,14 @@ export class OSAddressesPage {
const loader = this.loader.open('Saving...').subscribe()
try {
await this.api.setServerClearnetAddress({ domainInfo })
if (this.packageContext) {
await this.api.setInterfaceClearnetAddress({
...this.packageContext,
domainInfo,
})
} else {
await this.api.setServerClearnetAddress({ domainInfo })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
@@ -116,52 +128,86 @@ export class OSAddressesPage {
const loader = this.loader.open('Removing...').subscribe()
try {
await this.api.setServerClearnetAddress({ domainInfo: null })
if (this.packageContext) {
await this.api.setInterfaceClearnetAddress({
...this.packageContext,
domainInfo: null,
})
} else {
await this.api.setServerClearnetAddress({ domainInfo: null })
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}
private async getClearnetSpec({
domains,
start9MeSubdomain,
}: NetworkInfo): Promise<InputSpec> {
const start9MeDomain = `${start9MeSubdomain?.value}.start9.me`
const base = start9MeSubdomain ? { [start9MeDomain]: start9MeDomain } : {}
function getClearnetSpec({
domains,
start9ToSubdomain,
}: NetworkInfo): Promise<InputSpec> {
const start9ToDomain = `${start9ToSubdomain?.value}.start9.to`
const base = start9ToSubdomain ? { [start9ToDomain]: start9ToDomain } : {}
return configBuilderToSpec(
Config.of({
domain: Value.dynamicSelect(() => {
return {
name: 'Domain',
required: { default: null },
values: domains.reduce((prev, curr) => {
return {
[curr.value]: curr.value,
...prev,
}
}, base),
}
}),
subdomain: Value.text({
name: 'Subdomain',
required: false,
}),
const values = domains.reduce((prev, curr) => {
return {
[curr.value]: curr.value,
...prev,
}
}, base)
return configBuilderToSpec(
Config.of({
domain: Value.select({
name: 'Domain',
required: { default: null },
values,
}),
)
subdomain: Value.text({
name: 'Subdomain',
required: false,
}),
}),
)
}
@Component({
selector: 'interface-addresses-item',
templateUrl: './interface-addresses-item.component.html',
styleUrls: ['./interface-addresses.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceAddressItemComponent {
@Input({ required: true }) label!: string
@Input({ required: true }) hostname!: string
@Input({ required: true }) isUi!: boolean
constructor(
readonly copyService: CopyService,
private readonly dialogs: TuiDialogService,
@Inject(DOCUMENT) private readonly document: Document,
) {}
launch(url: string): void {
this.document.defaultView?.open(url, '_blank', 'noreferrer')
}
asIsOrder(a: any, b: any) {
return 0
showQR(data: string) {
this.dialogs
.open(new PolymorpheusComponent(QRComponent), {
size: 'auto',
data,
})
.subscribe()
}
}
@Pipe({
name: 'osClearnetPipe',
name: 'interfaceClearnetPipe',
})
export class OsClearnetPipe implements PipeTransform {
export class InterfaceClearnetPipe implements PipeTransform {
transform(clearnet: DomainInfo): string {
return getClearnetAddress('https', clearnet)
}

View File

@@ -0,0 +1,19 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import {
InterfaceAddressesComponent,
InterfaceAddressItemComponent,
InterfaceClearnetPipe,
} from './interface-addresses.component'
@NgModule({
imports: [CommonModule, IonicModule],
declarations: [
InterfaceAddressesComponent,
InterfaceAddressItemComponent,
InterfaceClearnetPipe,
],
exports: [InterfaceAddressesComponent],
})
export class InterfaceAddressesComponentModule {}

View File

@@ -46,13 +46,15 @@ export class LogsComponent {
@ViewChild(IonContent)
private content?: IonContent
@Input() followLogs!: (
@Input({ required: true }) followLogs!: (
params: RR.FollowServerLogsReq,
) => Promise<RR.FollowServerLogsRes>
@Input() fetchLogs!: (params: ServerLogsReq) => Promise<LogsRes>
@Input() context!: string
@Input() defaultBack!: string
@Input() pageTitle!: string
@Input({ required: true }) fetchLogs!: (
params: ServerLogsReq,
) => Promise<LogsRes>
@Input({ required: true }) context!: string
@Input({ required: true }) defaultBack!: string
@Input({ required: true }) pageTitle!: string
loading = true
infiniteStatus: 0 | 1 | 2 = 0

View File

@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { QrCodeModule } from 'ng-qrcode'
import { QRComponent } from './qr.component'
@NgModule({
declarations: [QRComponent],
imports: [CommonModule, QrCodeModule],
exports: [QRComponent],
})
export class QRComponentModule {}

View File

@@ -12,7 +12,7 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AnyLinkComponent implements OnInit {
@Input() link!: string
@Input({ required: true }) link!: string
@Input() qp?: Record<string, string>
externalLink = false

View File

@@ -14,8 +14,8 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WidgetCardComponent {
@Input() cardDetails!: Card
@Input() containerDimensions!: Dimension
@Input({ required: true }) cardDetails!: Card
@Input({ required: true }) containerDimensions!: Dimension
@ViewChild('outerWrapper') outerWrapper: ElementRef<HTMLElement> =
{} as ElementRef<HTMLElement>
@ViewChild('innerWrapper') innerWrapper: ElementRef<HTMLElement> =

View File

@@ -1260,25 +1260,40 @@ export module Mock {
},
'dependency-errors': {},
},
'address-info': {
interfaceInfo: {
rpc: {
name: 'Bitcoin RPC',
description: `Bitcoin's RPC interface`,
addresses: [
'http://bitcoind-rpc-address.onion',
'https://bitcoind-rpc-address.local',
'https://192.168.1.1:8332',
],
ui: true,
addressInfo: {
ipInfo: {
eth0: {
wireless: false,
ipv4: '192.168.1.1:8333',
ipv6: 'FE80:CD00:0000:0CDE:1257:0000:211E:729CD:8333',
},
},
lanHostname: 'adjective-noun:8333',
torHostname: 'bitcoind-rpc-address.onion',
domainInfo: null,
},
type: 'ui',
},
p2p: {
name: 'Bitcoin P2P',
description: `Bitcoin's P2P interface`,
addresses: [
'bitcoin://bitcoind-rpc-address.onion',
'bitcoin://192.168.1.1:8333',
],
ui: true,
addressInfo: {
ipInfo: {
eth0: {
wireless: false,
ipv4: '192.168.1.1:8332',
ipv6: 'FE80:CD00:0000:0CDE:1257:0000:211E:729CD:8332',
},
},
lanHostname: 'adjective-noun:8332',
torHostname: 'bitcoind-p2p-address.onion',
domainInfo: null,
},
type: 'ui',
},
},
'current-dependencies': {},
@@ -1286,6 +1301,7 @@ export module Mock {
'marketplace-url': 'https://registry.start9.com/',
'developer-key': 'developer-key',
'has-config': true,
outboundProxy: null,
},
actions: {
resync: {
@@ -1336,15 +1352,23 @@ export module Mock {
},
'dependency-errors': {},
},
'address-info': {
interfaceInfo: {
rpc: {
name: 'Proxy RPC addresses',
description: `Use these addresses to access Proxy's RPC interface`,
addresses: [
'http://bitcoinproxy-rpc-address.onion',
'https://bitcoinproxy-rpc-address.local',
],
ui: false,
addressInfo: {
ipInfo: {
eth0: {
wireless: false,
ipv4: '192.168.1.1:8459',
ipv6: 'FE80:CD00:0000:0CDE:1257:0000:211E:729CD:8459',
},
},
lanHostname: 'adjective-noun.local:8459',
torHostname: 'btcrpc-proxy-address.onion',
domainInfo: null,
},
type: 'api',
},
},
'current-dependencies': {
@@ -1361,6 +1385,7 @@ export module Mock {
'marketplace-url': 'https://registry.start9.com/',
'developer-key': 'developer-key',
'has-config': true,
outboundProxy: null,
},
actions: {},
}
@@ -1384,26 +1409,40 @@ export module Mock {
},
},
},
'address-info': {
interfaceInfo: {
ui: {
name: 'Web UI',
description: 'The browser web interface for LND',
addresses: [
'http://lnd-ui-address.onion',
'https://lnd-ui-address.local',
'https://192.168.1.1:3449',
],
ui: true,
addressInfo: {
ipInfo: {
eth0: {
wireless: false,
ipv4: '192.168.1.1:7171',
ipv6: 'FE80:CD00:0000:0CDE:1257:0000:211E:729CD:7171',
},
},
lanHostname: 'adjective-noun.local:7171',
torHostname: 'lnd-ui-address.onion',
domainInfo: null,
},
type: 'ui',
},
grpc: {
name: 'gRPC',
description: 'For connecting to LND gRPC interface',
addresses: [
'http://lnd-grpc-address.onion',
'https://lnd-grpc-address.local',
'https://192.168.1.1:3449',
],
ui: true,
addressInfo: {
ipInfo: {
eth0: {
wireless: false,
ipv4: '192.168.1.1:9191',
ipv6: 'FE80:CD00:0000:0CDE:1257:0000:211E:729CD:9191',
},
},
lanHostname: 'adjective-noun.local:9191',
torHostname: 'lnd-grpc-address.onion',
domainInfo: null,
},
type: 'p2p',
},
},
'current-dependencies': {
@@ -1417,7 +1456,7 @@ export module Mock {
'dependency-info': {
bitcoind: {
title: 'Bitcoin Core',
icon: 'assets/img/service-icons/bitcoind.png',
icon: 'assets/img/service-icons/bitcoind.svg',
},
'btc-rpc-proxy': {
title: 'Bitcoin Proxy',
@@ -1427,6 +1466,7 @@ export module Mock {
'marketplace-url': 'https://registry.start9.com/',
'developer-key': 'developer-key',
'has-config': true,
outboundProxy: null,
},
actions: {},
}

View File

@@ -5,13 +5,12 @@ import {
DataModel,
DependencyError,
DomainInfo,
NetworkStrategy,
OsOutboundProxy,
ServiceOutboundProxy,
} from 'src/app/services/patch-db/data-model'
import { StartOSDiskInfo, LogsRes, ServerLogsReq } from '@start9labs/shared'
import { customSmtp } from '@start9labs/start-sdk/lib/config/configConstants'
import {
CustomSpec,
Start9MeSpec,
} from 'src/app/apps/ui/pages/system/domains/domain.const'
export module RR {
// DB
@@ -89,6 +88,11 @@ export module RR {
} // server.experimental.zram
export type ToggleZramRes = null
export type SetOsOutboundProxyReq = {
proxy: OsOutboundProxy
} // server.proxy.set-outbound
export type SetOsOutboundProxyRes = null
// sessions
export type GetSessionsReq = {} // sessions.list
@@ -114,16 +118,31 @@ export module RR {
export type DeleteAllNotificationsReq = { before: number } // notification.delete-before
export type DeleteAllNotificationsRes = null
// network
export type AddProxyReq = {
name: string
config: string
} // net.proxy.add
export type AddProxyRes = null
export type UpdateProxyReq = {
name?: string
primaryInbound?: true
primaryOutbound?: true
} // net.proxy.update
export type UpdateProxyRes = null
export type DeleteProxyReq = { id: string } // net.proxy.delete
export type DeleteProxyRes = null
// domains
export type ClaimStart9MeReq = {
networkStrategy: string
ipStrategy: string | null
} // net.domain.me.claim
export type ClaimStart9MeRes = null
export type ClaimStart9ToReq = { networkStrategy: NetworkStrategy } // net.domain.me.claim
export type ClaimStart9ToRes = null
export type DeleteStart9MeReq = {} // net.domain.me.delete
export type DeleteStart9MeRes = null
export type DeleteStart9ToReq = {} // net.domain.me.delete
export type DeleteStart9ToRes = null
export type AddDomainReq = {
hostname: string
@@ -132,8 +151,7 @@ export module RR {
username: string | null
password: string | null
}
networkStrategy: string
ipStrategy: string | null
networkStrategy: NetworkStrategy
} // net.domain.add
export type AddDomainRes = null
@@ -347,6 +365,18 @@ export module RR {
}
export type SideloadPacakgeRes = string //guid
export type SetInterfaceClearnetAddressReq = SetServerClearnetAddressReq & {
packageId: string
interfaceId: string
} // package.interface.set-clearnet
export type SetInterfaceClearnetAddressRes = null
export type SetServiceOutboundProxyReq = {
packageId: string
proxy: ServiceOutboundProxy
} // package.proxy.set-outbound
export type SetServiceOutboundProxyRes = null
// marketplace
export type EnvInfo = {

View File

@@ -125,6 +125,10 @@ export abstract class ApiService {
abstract toggleZram(params: RR.ToggleZramReq): Promise<RR.ToggleZramRes>
abstract setOsOutboundProxy(
params: RR.SetOsOutboundProxyReq,
): Promise<RR.SetOsOutboundProxyRes>
// marketplace URLs
abstract marketplaceProxy<T>(
@@ -150,15 +154,23 @@ export abstract class ApiService {
params: RR.DeleteAllNotificationsReq,
): Promise<RR.DeleteAllNotificationsRes>
// network
abstract addProxy(params: RR.AddProxyReq): Promise<RR.AddProxyRes>
abstract updateProxy(params: RR.UpdateProxyReq): Promise<RR.UpdateProxyRes>
abstract deleteProxy(params: RR.DeleteProxyReq): Promise<RR.DeleteProxyRes>
// domains
abstract claimStart9MeDomain(
params: RR.ClaimStart9MeReq,
): Promise<RR.ClaimStart9MeRes>
abstract claimStart9ToDomain(
params: RR.ClaimStart9ToReq,
): Promise<RR.ClaimStart9ToRes>
abstract deleteStart9MeDomain(
params: RR.DeleteStart9MeReq,
): Promise<RR.DeleteStart9MeRes>
abstract deleteStart9ToDomain(
params: RR.DeleteStart9ToReq,
): Promise<RR.DeleteStart9ToRes>
abstract addDomain(params: RR.AddDomainReq): Promise<RR.AddDomainRes>
@@ -322,4 +334,12 @@ export abstract class ApiService {
abstract getSetupStatus(): Promise<SetupStatus | null>
abstract followLogs(): Promise<string>
abstract setInterfaceClearnetAddress(
params: RR.SetInterfaceClearnetAddressReq,
): Promise<RR.SetInterfaceClearnetAddressRes>
abstract setServiceOutboundProxy(
params: RR.SetServiceOutboundProxyReq,
): Promise<RR.SetServiceOutboundProxyRes>
}

View File

@@ -233,6 +233,12 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'server.experimental.zram', params })
}
async setOsOutboundProxy(
params: RR.SetOsOutboundProxyReq,
): Promise<RR.SetOsOutboundProxyRes> {
return this.rpcRequest({ method: 'server.proxy.set-outbound', params })
}
// marketplace URLs
async marketplaceProxy<T>(
@@ -288,17 +294,31 @@ export class LiveApiService extends ApiService {
})
}
// network
async addProxy(params: RR.AddProxyReq): Promise<RR.AddProxyRes> {
return this.rpcRequest({ method: 'net.proxy.add', params })
}
async updateProxy(params: RR.UpdateProxyReq): Promise<RR.UpdateProxyRes> {
return this.rpcRequest({ method: 'net.proxy.update', params })
}
async deleteProxy(params: RR.DeleteProxyReq): Promise<RR.DeleteProxyRes> {
return this.rpcRequest({ method: 'net.proxy.delete', params })
}
// domains
async claimStart9MeDomain(
params: RR.ClaimStart9MeReq,
): Promise<RR.ClaimStart9MeRes> {
async claimStart9ToDomain(
params: RR.ClaimStart9ToReq,
): Promise<RR.ClaimStart9ToRes> {
return this.rpcRequest({ method: 'net.domain.me.claim', params })
}
async deleteStart9MeDomain(
params: RR.DeleteStart9MeReq,
): Promise<RR.DeleteStart9MeRes> {
async deleteStart9ToDomain(
params: RR.DeleteStart9ToReq,
): Promise<RR.DeleteStart9ToRes> {
return this.rpcRequest({ method: 'net.domain.me.delete', params })
}
@@ -544,6 +564,18 @@ export class LiveApiService extends ApiService {
})
}
async setInterfaceClearnetAddress(
params: RR.SetInterfaceClearnetAddressReq,
): Promise<RR.SetInterfaceClearnetAddressRes> {
return this.rpcRequest({ method: 'package.interface.set-clearnet', params })
}
async setServiceOutboundProxy(
params: RR.SetServiceOutboundProxyReq,
): Promise<RR.SetServiceOutboundProxyRes> {
return this.rpcRequest({ method: 'package.proxy.set-outbound', params })
}
async getSetupStatus() {
return this.rpcRequest<SetupStatus | null>({
method: 'setup.status',

View File

@@ -15,6 +15,7 @@ import {
PackageDataEntry,
PackageMainStatus,
PackageState,
Proxy,
} from 'src/app/services/patch-db/data-model'
import { BackupTargetType, Metrics, RR } from './api.types'
import { Mock } from './api.fixures'
@@ -371,6 +372,21 @@ export class MockApiService extends ApiService {
return this.withRevision(patch, null)
}
async setOsOutboundProxy(
params: RR.SetOsOutboundProxyReq,
): Promise<RR.SetOsOutboundProxyRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/server-info/network/outboundProxy',
value: params.proxy,
},
]
return this.withRevision(patch, null)
}
// marketplace URLs
async marketplaceProxy(
@@ -439,36 +455,97 @@ export class MockApiService extends ApiService {
return null
}
// network
async addProxy(params: RR.AddProxyReq): Promise<RR.AddProxyRes> {
await pauseFor(2000)
const type: Proxy['type'] = 'inbound-outbound'
const patch = [
{
op: PatchOp.REPLACE,
path: '/server-info/network/proxies',
value: [
{
id: 'abcd-efgh-ijkl-mnop',
name: params.name,
createdAt: new Date(),
type,
endpoint: '10.25.2.17',
usedBy: {
domains: [],
services: [],
},
primaryInbound: type === 'inbound-outbound' ? true : false,
primaryOutbound:
type === 'inbound-outbound' || type === 'outbound' ? true : false,
// primaryInbound: false,
// primaryOutbound: false,
},
],
},
]
return this.withRevision(patch, null)
}
async updateProxy(params: RR.UpdateProxyReq): Promise<RR.UpdateProxyRes> {
await pauseFor(2000)
const value = params.name || params.primaryInbound || params.primaryOutbound
const patch = [
{
op: PatchOp.REPLACE,
path: `/server-info/network/proxies/0/${Object.keys(params)[0]}`,
value,
},
]
return this.withRevision(patch, null)
}
async deleteProxy(params: RR.DeleteProxyReq): Promise<RR.DeleteProxyRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/server-info/network/proxies',
value: [],
},
]
return this.withRevision(patch, null)
}
// domains
async claimStart9MeDomain(
params: RR.ClaimStart9MeReq,
): Promise<RR.ClaimStart9MeRes> {
async claimStart9ToDomain(
params: RR.ClaimStart9ToReq,
): Promise<RR.ClaimStart9ToRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/server-info/network/start9MeSubdomain',
path: '/server-info/network/start9ToSubdomain',
value: {
value: 'xyz',
createdAt: new Date(),
networkStrategy: params.networkStrategy,
ipStrategy: params.ipStrategy,
usedBy: [],
},
},
]
return this.withRevision(patch, null)
}
async deleteStart9MeDomain(
params: RR.DeleteStart9MeReq,
): Promise<RR.DeleteStart9MeRes> {
async deleteStart9ToDomain(
params: RR.DeleteStart9ToReq,
): Promise<RR.DeleteStart9ToRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/server-info/network/start9MeSubdomain',
path: '/server-info/network/start9ToSubdomain',
value: null,
},
]
@@ -485,10 +562,10 @@ export class MockApiService extends ApiService {
value: [
{
value: params.hostname,
createdAt: new Date(),
provider: params.provider.name,
networkStrategy: params.networkStrategy,
ipStrategy: params.ipStrategy,
createdAt: new Date(),
usedBy: [],
},
],
},
@@ -1109,6 +1186,34 @@ export class MockApiService extends ApiService {
return 'fake-guid'
}
async setInterfaceClearnetAddress(
params: RR.SetInterfaceClearnetAddressReq,
): Promise<RR.SetInterfaceClearnetAddressRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: `/package-data/${params.packageId}/installed/interfaceInfo/${params.interfaceId}/addressInfo/domainInfo`,
value: params.domainInfo,
},
]
return this.withRevision(patch, null)
}
async setServiceOutboundProxy(
params: RR.SetServiceOutboundProxyReq,
): Promise<RR.SetServiceOutboundProxyRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: `/package-data/${params.packageId}/installed/outboundProxy`,
value: params.proxy,
},
]
return this.withRevision(patch, null)
}
private async updateProgress(id: string): Promise<void> {
const progress = { ...PROGRESS }
const phases = [

View File

@@ -45,11 +45,6 @@ export const mockPatchData: DataModel = {
eth0: {
wireless: false,
ipv4: '10.0.0.1',
ipv6: null,
},
wlan0: {
wireless: true,
ipv4: '10.0.90.12',
ipv6: 'FE80:CD00:0000:0CDE:1257:0000:211E:729CD',
},
},
@@ -57,7 +52,7 @@ export const mockPatchData: DataModel = {
},
network: {
domains: [],
start9MeSubdomain: null,
start9ToSubdomain: null,
wifi: {
enabled: false,
lastRegion: null,
@@ -85,6 +80,12 @@ export const mockPatchData: DataModel = {
},
],
},
proxies: [],
primaryProxies: {
inbound: null,
outbound: null,
},
outboundProxy: null,
},
'last-backup': new Date(new Date().valueOf() - 604800001).toISOString(),
'unread-notification-count': 4,
@@ -105,7 +106,6 @@ export const mockPatchData: DataModel = {
from: '',
login: '',
password: '',
tls: true,
},
'password-hash':
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',

View File

@@ -3,7 +3,7 @@ import { Inject, Injectable } from '@angular/core'
import { WorkspaceConfig } from '@start9labs/shared'
import {
InstalledPackageInfo,
PackageMainStatus,
InterfaceInfo,
} from 'src/app/services/patch-db/data-model'
const {
@@ -30,8 +30,6 @@ export class ConfigService {
api = api
marketplace = marketplace
skipStartupAlerts = useMocks && mocks.skipStartupAlerts
isConsulate = (window as any)['platform'] === 'ios'
supportsWebSockets = !!window.WebSocket || this.isConsulate
isTor(): boolean {
return (
@@ -39,23 +37,65 @@ export class ConfigService {
)
}
isLan(): boolean {
isLocal(): boolean {
return (
this.hostname.endsWith('.local') || (useMocks && mocks.maskAs === 'local')
)
}
isLocalhost(): boolean {
return (
this.hostname === 'localhost' ||
this.hostname.endsWith('.local') ||
(useMocks && mocks.maskAs === 'lan')
(useMocks && mocks.maskAs === 'localhost')
)
}
isIpv4(): boolean {
return isValidIpv4(this.hostname) || (useMocks && mocks.maskAs === 'ipv4')
}
isIpv6(): boolean {
return isValidIpv6(this.hostname) || (useMocks && mocks.maskAs === 'ipv6')
}
isClearnet(): boolean {
return (
(useMocks && mocks.maskAs === 'clearnet') ||
(!this.isTor() &&
!this.isLocal() &&
!this.isLocalhost() &&
!this.isIpv4() &&
!this.isIpv6())
)
}
isSecure(): boolean {
return window.isSecureContext || this.isTor()
}
launchableAddress(info: InterfaceInfo): string {
return this.isTor()
? info.addressInfo.torHostname
: this.isLocalhost()
? `https://${info.addressInfo.lanHostname}`
: this.isLocal() || this.isIpv4() || this.isIpv6()
? `https://${this.hostname}`
: info.addressInfo.domainInfo?.subdomain
? `https://${info.addressInfo.domainInfo.subdomain}${info.addressInfo.domainInfo.domain}`
: `https://${info.addressInfo.domainInfo?.domain}`
}
}
export function hasUi(
addressInfo: InstalledPackageInfo['address-info'],
): boolean {
return !!Object.values(addressInfo).find(a => a.ui)
export function isValidIpv4(address: string): boolean {
const regexExp =
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
return regexExp.test(address)
}
export function isValidIpv6(address: string): boolean {
const regexExp =
/(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/gi
return regexExp.test(address)
}
export function removeProtocol(str: string): string {

View File

@@ -3,6 +3,7 @@ import { Url } from '@start9labs/shared'
import { Manifest } from '@start9labs/marketplace'
import { BackupJob } from '../api/api.types'
import { customSmtp } from '@start9labs/start-sdk/lib/config/configConstants'
import { NetworkInterfaceType } from '@start9labs/start-sdk/lib/util/utils'
export interface DataModel {
'server-info': ServerInfo
@@ -55,7 +56,7 @@ export interface ServerInfo {
id: string
version: string
country: string
ui: StartOsUiInfo
ui: AddressInfo
network: NetworkInfo
'last-backup': string | null
'unread-notification-count': number
@@ -69,21 +70,20 @@ export interface ServerInfo {
'password-hash': string
}
export type StartOsUiInfo = {
ipInfo: IpInfo
lanHostname: string
torHostname: string
domainInfo: DomainInfo | null
}
export type NetworkInfo = {
wifi: WiFiInfo
start9MeSubdomain: Omit<Domain, 'provider'> | null
start9ToSubdomain: Omit<Domain, 'provider'> | null
domains: Domain[]
wanConfig: {
upnp: boolean
forwards: PortForward[]
}
proxies: Proxy[]
outboundProxy: OsOutboundProxy
primaryProxies: {
inbound: string | null
outbound: string | null
}
}
export type DomainInfo = {
@@ -91,6 +91,10 @@ export type DomainInfo = {
subdomain: string | null
}
export type InboundProxy = { proxyId: string } | 'primary' | null
export type OsOutboundProxy = InboundProxy
export type ServiceOutboundProxy = OsOutboundProxy | 'mirror'
export type PortForward = {
assigned: number
override: number | null
@@ -105,10 +109,32 @@ export type WiFiInfo = {
export type Domain = {
value: string
provider: string
networkStrategy: string
ipStrategy: string
createdAt: string
provider: string
networkStrategy: NetworkStrategy
usedBy: {
service: { id: string | null; title: string } // null means startos
interfaces: { id: string | null; title: string }[] // null means startos
}[]
}
export type NetworkStrategy =
| { proxyId: string | null } // null means system primary
| { ipStrategy: 'ipv4' | 'ipv6' | 'dualstack' }
export type Proxy = {
id: string
name: string
createdAt: string
type: 'outbound' | 'inbound-outbound' | 'vlan' | { error: string }
endpoint: string
// below is overlay only
usedBy: {
services: { id: string | null; title: string }[] // implies outbound - null means startos
domains: string[] // implies inbound
}
primaryInbound: boolean
primaryOutbound: boolean
}
export interface IpInfo {
@@ -199,21 +225,29 @@ export interface InstalledPackageInfo {
'installed-at': string
'current-dependencies': Record<string, CurrentDependencyInfo>
'dependency-info': Record<string, { title: string; icon: Url }>
'address-info': Record<string, AddressInfo>
interfaceInfo: Record<string, InterfaceInfo>
'marketplace-url': string | null
'developer-key': string
'has-config': boolean
outboundProxy: ServiceOutboundProxy
}
export interface CurrentDependencyInfo {
'health-checks': string[] // array of health check IDs
}
export interface AddressInfo {
export interface InterfaceInfo {
name: string
description: string
addresses: Url[]
ui: boolean
type: NetworkInterfaceType
addressInfo: AddressInfo
}
export interface AddressInfo {
ipInfo: IpInfo
lanHostname: string
torHostname: string
domainInfo: DomainInfo | null
}
export interface Action {

View File

@@ -0,0 +1,161 @@
import { Injectable } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import {
DataModel,
OsOutboundProxy,
ServiceOutboundProxy,
} from './patch-db/data-model'
import { firstValueFrom } from 'rxjs'
import { Config } from '@start9labs/start-sdk/lib/config/builder/config'
import { Value } from '@start9labs/start-sdk/lib/config/builder/value'
import { Variants } from '@start9labs/start-sdk/lib/config/builder/variants'
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
import { TuiDialogOptions } from '@taiga-ui/core'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { FormContext, FormPage } from '../apps/ui/modals/form/form.page'
import { ApiService } from './api/embassy-api.service'
import { ErrorService, LoadingService } from '@start9labs/shared'
@Injectable({
providedIn: 'root',
})
export class ProxyService {
constructor(
private readonly patch: PatchDB<DataModel>,
private readonly formDialog: FormDialogService,
private readonly api: ApiService,
private readonly loader: LoadingService,
private readonly errorService: ErrorService,
) {}
async presentModalSetOutboundProxy(serviceContext?: {
packageId: string
outboundProxy: ServiceOutboundProxy
hasP2P: boolean
}) {
const network = await firstValueFrom(
this.patch.watch$('server-info', 'network'),
)
const outboundProxy = serviceContext?.outboundProxy
const defaultValue = !outboundProxy
? 'none'
: outboundProxy === 'primary'
? 'primary'
: outboundProxy === 'mirror'
? 'mirror'
: 'other'
let variants: Record<string, { name: string; spec: Config<any> }> = {}
if (serviceContext) {
variants['mirror'] = {
name: 'Mirror P2P Interface',
spec: Config.of({}),
}
}
variants = {
...variants,
primary: {
name: 'Use System Primary',
spec: Config.of({}),
},
other: {
name: 'Other',
spec: Config.of({
proxyId: Value.select({
name: 'Select Specific Proxy',
required: {
default:
outboundProxy && typeof outboundProxy !== 'string'
? outboundProxy.proxyId
: null,
},
values: network.proxies
.filter(
p => p.type === 'outbound' || p.type === 'inbound-outbound',
)
.reduce((prev, curr) => {
return {
[curr.id]: curr.name,
...prev,
}
}, {}),
}),
}),
},
none: {
name: 'None',
spec: Config.of({}),
},
}
const config = Config.of({
proxy: Value.union(
{
name: 'Select Proxy',
required: { default: defaultValue },
description: `
<h5>Use System Primary</h5>The primary <i>inbound</i> proxy will be used. If you do not have a primary inbound proxy, no proxy will be used
<h5>Mirror Primary Interface</h5>If you have an inbound proxy enabled for the primary interface, outbound traffic will flow through the same proxy
<h5>Other</h5>The specific proxy you select will be used, overriding the default
`,
disabled: serviceContext?.hasP2P ? [] : ['mirror'],
},
Variants.of(variants),
),
})
const options: Partial<
TuiDialogOptions<FormContext<typeof config.validator._TYPE>>
> = {
label: 'Outbound Proxy',
data: {
spec: await configBuilderToSpec(config),
buttons: [
{
text: 'Manage proxies',
link: '/system/proxies',
},
{
text: 'Save',
handler: async value => {
const proxy =
value.proxy.unionSelectKey === 'none'
? null
: value.proxy.unionSelectKey === 'primary'
? 'primary'
: value.proxy.unionSelectKey === 'mirror'
? 'mirror'
: { proxyId: value.proxy.unionValueKey.proxyId }
await this.saveOutboundProxy(proxy, serviceContext?.packageId)
return true
},
},
],
},
}
this.formDialog.open(FormPage, options)
}
private async saveOutboundProxy(
proxy: OsOutboundProxy | ServiceOutboundProxy,
packageId?: string,
) {
const loader = this.loader.open(`Saving`).subscribe()
try {
if (packageId) {
await this.api.setServiceOutboundProxy({ packageId, proxy })
} else {
await this.api.setOsOutboundProxy({ proxy: proxy as OsOutboundProxy })
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -2,8 +2,8 @@ import { Config } from '@start9labs/start-sdk/lib/config/builder/config'
export async function configBuilderToSpec(
builder:
| Config<Record<string, unknown>, unknown, unknown>
| Config<Record<string, unknown>, never, never>,
| Config<Record<string, unknown>, unknown>
| Config<Record<string, unknown>, never>,
) {
return builder.build({} as any)
}

View File

@@ -330,7 +330,7 @@ h2 {
scrollbar-width: none;
ion-grid {
min-width: 840px;
min-width: 900px;
}
}