add clearnet functionality to frontend (#2814)

* add clearnet functionality to frontend

* add pattern and add sync db on rpcs

* add domain pattern

* show acme name instead of url if known

* dont blow up if domain not present after delete

* use common name for letsencrypt

* normalize urls

* refactor start-os ui net service

* backend migration and rpcs for serverInfo.host

* fix cors

* implement clearnet for main startos ui

* ability to add and remove tor addresses, including vanity

* add guard to prevent duplicate addresses

* misc bugfixes

* better heuristics for launching UIs

* fix ipv6 mocks

* fix ipv6 display bug

* rewrite url selection for launch ui

---------

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

6
web/package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
[(ngModel)]="selected"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}*
{{ spec.name }} *
<select
tuiSelect
[placeholder]="spec.name"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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