enable switching to https on login page (#2406)

* enable switching to https on login page

* add trust Root CA to http login page

* add node-jose back for setup wiz

* add tooltips, branding, logic for launch box spinner display, and enable config to toggle https mode on mocks

* cleanup

* copy changes

* style fixes

* abstract component, fix https mocks

* always show login from localhost

* launch .local when on IP

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>
This commit is contained in:
Lucy
2023-09-26 14:47:47 -04:00
committed by GitHub
parent 8e21504bdb
commit 0daaf3b1ec
25 changed files with 511 additions and 255 deletions

View File

@@ -36,6 +36,11 @@
"input": "node_modules/monaco-editor", "input": "node_modules/monaco-editor",
"output": "assets/monaco-editor/" "output": "assets/monaco-editor/"
}, },
{
"glob": "**/*",
"input": "node_modules/@taiga-ui/icons/src",
"output": "assets/taiga-ui/icons"
},
"projects/ui/src/manifest.webmanifest", "projects/ui/src/manifest.webmanifest",
{ {
"glob": "ngsw.json", "glob": "ngsw.json",
@@ -44,6 +49,7 @@
} }
], ],
"styles": [ "styles": [
"node_modules/@taiga-ui/core/styles/taiga-ui-theme.less",
"projects/shared/styles/variables.scss", "projects/shared/styles/variables.scss",
"projects/shared/styles/global.scss", "projects/shared/styles/global.scss",
"projects/shared/styles/shared.scss", "projects/shared/styles/shared.scss",

View File

@@ -14,6 +14,7 @@
}, },
"mocks": { "mocks": {
"maskAs": "tor", "maskAs": "tor",
"maskAsHttps": true,
"skipStartupAlerts": true "skipStartupAlerts": true
} }
}, },

View File

@@ -45,7 +45,7 @@
"monaco-editor": "^0.33.0", "monaco-editor": "^0.33.0",
"mustache": "^4.2.0", "mustache": "^4.2.0",
"ng-qrcode": "^7.0.0", "ng-qrcode": "^7.0.0",
"node-jose": "^2.1.1", "node-jose": "^2.2.0",
"patch-db-client": "file: ../../../patch-db/client", "patch-db-client": "file: ../../../patch-db/client",
"pbkdf2": "^3.1.2", "pbkdf2": "^3.1.2",
"rxjs": "^7.5.6", "rxjs": "^7.5.6",
@@ -9506,9 +9506,9 @@
} }
}, },
"node_modules/long": { "node_modules/long": {
"version": "5.2.0", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/long/-/long-5.2.0.tgz", "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz",
"integrity": "sha512-9RTUNjK60eJbx3uz+TEGF7fUr29ZDxR5QzXcyDpeSfeH28S9ycINflOgOlppit5U+4kNTe83KQnMEerw7GmE8w==" "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q=="
}, },
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "7.14.0", "version": "7.14.0",
@@ -10955,9 +10955,9 @@
} }
}, },
"node_modules/pako": { "node_modules/pako": {
"version": "2.0.4", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.0.4.tgz", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-v8tweI900AUkZN6heMU/4Uy4cXRc2AYNRggVmTR+dEncawDJgCdLMximOVA2p4qO57WMynangsfGRb5WD6L1Bg==" "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="
}, },
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
@@ -22148,9 +22148,9 @@
} }
}, },
"long": { "long": {
"version": "5.2.0", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/long/-/long-5.2.0.tgz", "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz",
"integrity": "sha512-9RTUNjK60eJbx3uz+TEGF7fUr29ZDxR5QzXcyDpeSfeH28S9ycINflOgOlppit5U+4kNTe83KQnMEerw7GmE8w==" "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q=="
}, },
"lru-cache": { "lru-cache": {
"version": "7.14.0", "version": "7.14.0",
@@ -23251,9 +23251,9 @@
} }
}, },
"pako": { "pako": {
"version": "2.0.4", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.0.4.tgz", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-v8tweI900AUkZN6heMU/4Uy4cXRc2AYNRggVmTR+dEncawDJgCdLMximOVA2p4qO57WMynangsfGRb5WD6L1Bg==" "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="
}, },
"parent-module": { "parent-module": {
"version": "1.0.1", "version": "1.0.1",

View File

@@ -70,7 +70,7 @@
"monaco-editor": "^0.33.0", "monaco-editor": "^0.33.0",
"mustache": "^4.2.0", "mustache": "^4.2.0",
"ng-qrcode": "^7.0.0", "ng-qrcode": "^7.0.0",
"node-jose": "^2.1.1", "node-jose": "^2.2.0",
"patch-db-client": "file: ../../../patch-db/client", "patch-db-client": "file: ../../../patch-db/client",
"pbkdf2": "^3.1.2", "pbkdf2": "^3.1.2",
"rxjs": "^7.5.6", "rxjs": "^7.5.6",

View File

@@ -38,12 +38,13 @@ export class HttpService {
async rpcRequest<T>( async rpcRequest<T>(
opts: RPCOptions, opts: RPCOptions,
fullUrl?: string,
): Promise<LocalHttpResponse<RPCResponse<T>>> { ): Promise<LocalHttpResponse<RPCResponse<T>>> {
const { method, headers, params, timeout } = opts const { method, headers, params, timeout } = opts
return this.httpRequest<RPCResponse<T>>({ return this.httpRequest<RPCResponse<T>>({
method: Method.POST, method: Method.POST,
url: this.relativeUrl, url: fullUrl || this.relativeUrl,
headers, headers,
body: { method, params }, body: { method, params },
timeout, timeout,

View File

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

View File

@@ -15,63 +15,33 @@
<!-- Installed --> <!-- Installed -->
<ng-template #installed> <ng-template #installed>
<!-- SECURE --> <ng-container *ngIf="pkg | toDependencies as dependencies">
<ng-container *ngIf="secure; else insecure"> <ion-item-group *ngIf="pkg | toStatus as status">
<ng-container *ngIf="pkg | toDependencies as dependencies"> <!-- ** status ** -->
<ion-item-group *ngIf="pkg | toStatus as status"> <app-show-status
<!-- ** status ** --> [pkg]="pkg"
<app-show-status [dependencies]="dependencies"
[status]="status"
></app-show-status>
<!-- ** installed && !backing-up ** -->
<ng-container *ngIf="isInstalled(pkg) && !isBackingUp(status)">
<!-- ** health checks ** -->
<app-show-health-checks
*ngIf="isRunning(status)"
[pkg]="pkg" [pkg]="pkg"
></app-show-health-checks>
<!-- ** dependencies ** -->
<app-show-dependencies
*ngIf="dependencies.length"
[dependencies]="dependencies" [dependencies]="dependencies"
[status]="status" ></app-show-dependencies>
></app-show-status> <!-- ** menu ** -->
<!-- ** installed && !backing-up ** --> <app-show-menu [buttons]="pkg | toButtons"></app-show-menu>
<ng-container *ngIf="isInstalled(pkg) && !isBackingUp(status)"> <!-- ** additional ** -->
<!-- ** health checks ** --> <app-show-additional [pkg]="pkg"></app-show-additional>
<app-show-health-checks </ng-container>
*ngIf="isRunning(status)" </ion-item-group>
[pkg]="pkg"
></app-show-health-checks>
<!-- ** dependencies ** -->
<app-show-dependencies
*ngIf="dependencies.length"
[dependencies]="dependencies"
></app-show-dependencies>
<!-- ** menu ** -->
<app-show-menu [buttons]="pkg | toButtons"></app-show-menu>
<!-- ** additional ** -->
<app-show-additional [pkg]="pkg"></app-show-additional>
</ng-container>
</ion-item-group>
</ng-container>
</ng-container> </ng-container>
<!-- INSECURE -->
<ng-template #insecure>
<ion-grid style="max-width: 540px">
<ion-row class="ion-align-items-center">
<ion-col class="ion-text-center">
<h2>
<ion-text color="warning">Http detected</ion-text>
</h2>
<p class="ion-padding-bottom">
Your connection is insecure.
<a
[routerLink]="['/system', 'root-ca']"
style="color: var(--ion-color-dark)"
>
Download and trust your server's Root CA
</a>
, then switch to https.
</p>
<ion-button (click)="launchHttps()">
Open https
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-button>
</ion-col>
</ion-row>
</ion-grid>
</ng-template>
</ng-template> </ng-template>
</ion-content> </ion-content>
</ng-container> </ng-container>

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core' import { ChangeDetectionStrategy, Component } from '@angular/core'
import { NavController } from '@ionic/angular' import { NavController } from '@ionic/angular'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { import {
@@ -13,9 +13,6 @@ import {
import { tap } from 'rxjs/operators' import { tap } from 'rxjs/operators'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { getPkgId } from '@start9labs/shared' import { getPkgId } from '@start9labs/shared'
import { DOCUMENT } from '@angular/common'
import { ConfigService } from 'src/app/services/config.service'
import { getServerInfo } from 'src/app/util/get-server-info'
const STATES = [ const STATES = [
PackageState.Installing, PackageState.Installing,
@@ -29,8 +26,6 @@ const STATES = [
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class AppShowPage { export class AppShowPage {
readonly secure = this.config.isSecure()
private readonly pkgId = getPkgId(this.route) private readonly pkgId = getPkgId(this.route)
readonly pkg$ = this.patch.watch$('package-data', this.pkgId).pipe( readonly pkg$ = this.patch.watch$('package-data', this.pkgId).pipe(
@@ -44,8 +39,6 @@ export class AppShowPage {
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
private readonly navCtrl: NavController, private readonly navCtrl: NavController,
private readonly patch: PatchDB<DataModel>, private readonly patch: PatchDB<DataModel>,
private readonly config: ConfigService,
@Inject(DOCUMENT) private readonly document: Document,
) {} ) {}
isInstalled({ state }: PackageDataEntry): boolean { isInstalled({ state }: PackageDataEntry): boolean {
@@ -63,11 +56,4 @@ export class AppShowPage {
showProgress({ state }: PackageDataEntry): boolean { showProgress({ state }: PackageDataEntry): boolean {
return STATES.includes(state) return STATES.includes(state)
} }
async launchHttps() {
const onTor = this.config.isTor()
const { 'lan-address': lanAddress, 'tor-address': torAddress } =
await getServerInfo(this.patch)
onTor ? window.open(torAddress) : window.open(lanAddress)
}
} }

View File

@@ -0,0 +1,108 @@
<ion-grid class="grid-wiz">
<img width="60px" height="60px" src="/assets/img/icon_transparent.png" />
<ion-row>
<ion-col class="ion-text-center">
<ion-icon name="lock-closed-outline" class="wiz-icon"></ion-icon>
</ion-col>
</ion-row>
<ion-row>
<ion-col class="ion-text-center">
<h2><b>Trust your Root Certificate Authority (CA)</b></h2>
<p>
Download and trust your server's Root CA to establish secure, encrypted
(
<b>HTTPS</b>
) connections with your server
</p>
</ion-col>
</ion-row>
<ion-row>
<ion-col sizeXs="12" sizeLg="4">
<div class="wiz-card">
<ion-row class="ion-justify-content-between">
<b class="wiz-step">1</b>
<tui-tooltip
content="Your server uses its Root CA to generate SSL/TLS certificates for itself and its installed services. These certificates are used to encrypt network traffic with your client devices."
direction="right"
></tui-tooltip>
</ion-row>
<div class="ion-text-center">
<h2>Download Root CA</h2>
<p>Download your server's Root CA</p>
</div>
<ion-button class="wiz-card-button" shape="round" (click)="download()">
<ion-icon slot="start" name="download-outline"></ion-icon>
Download
</ion-button>
</div>
</ion-col>
<ion-col sizeXs="12" sizeLg="4">
<div class="wiz-card" [class.disabled]="!downloadClicked">
<ion-row class="ion-justify-content-between">
<b class="wiz-step">2</b>
<tui-tooltip
content="By trusting your server's Root CA, your device can verify the authenticity of its encrypted communications with your server and installed services. You will need to trust the Root CA on every device used to connect to your server."
direction="right"
></tui-tooltip>
</ion-row>
<div class="ion-text-center">
<h2>Trust Root CA</h2>
<p>Follow instructions for your OS</p>
</div>
<ion-button
class="wiz-card-button"
shape="round"
(click)="instructions()"
[disabled]="!downloadClicked"
>
View Docs
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-button>
</div>
</ion-col>
<ion-col sizeXs="12" sizeLg="4">
<div class="wiz-card" [class.disabled]="!polling && !caTrusted">
<b class="wiz-step">3</b>
<div class="ion-text-center">
<h2>Go To Login</h2>
<p *ngIf="instructionsClicked; else space" class="inline-center">
<ion-spinner
class="wiz-spinner"
*ngIf="!caTrusted; else trusted"
></ion-spinner>
<ng-template #trusted>
<ion-icon name="ribbon-outline" color="success"></ion-icon>
</ng-template>
&nbsp;{{ caTrusted ? 'Root CA trusted!' : 'Waiting for trust...' }}
</p>
<ng-template #space>
<!-- to keep alignment -->
<p><br /></p>
</ng-template>
</div>
<ion-button
class="wiz-card-button"
shape="round"
(click)="launchHttps()"
[disabled]="!caTrusted"
>
Open
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-button>
</div>
</ion-col>
</ion-row>
<ion-row>
<ion-col class="ion-text-center">
<ion-button fill="clear" (click)="launchHttps()" [disabled]="caTrusted">
Skip
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-button>
</ion-col>
</ion-row>
</ion-grid>
<a
id="install-cert"
href="/public/eos/local.crt"
[download]="document.location.hostname"
></a>

View File

@@ -0,0 +1,44 @@
.grid-wiz {
--ion-grid-padding: 36px;
height: 100%
}
.wiz-icon {
font-size: 84px;
}
.wiz-card {
background: #414141;
margin: 24px;
padding: 16px;
height: 280px;
border-radius: 16px;
display: grid;
& h2 {
font-weight: 600;
}
}
.wiz-card-button {
justify-self: center;
white-space: normal;
}
.wiz-spinner {
width: 14px;
height: 14px;
}
.disabled {
filter: saturate(0.2) contrast(0.5)
}
.wiz-step {
margin-top: 4px;
}
.inline-center {
display: inline-flex;
align-items: center;
}

View File

@@ -0,0 +1,76 @@
import { Component, Inject } from '@angular/core'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service'
import { pauseFor, RELATIVE_URL } from '@start9labs/shared'
import { DOCUMENT } from '@angular/common'
import { WINDOW } from '@ng-web-apis/common'
@Component({
selector: 'ca-wizard',
templateUrl: './ca-wizard.component.html',
styleUrls: ['./ca-wizard.component.scss'],
})
export class CAWizardComponent {
downloadClicked = false
instructionsClicked = false
polling = false
caTrusted = false
constructor(
private readonly api: ApiService,
public readonly config: ConfigService,
@Inject(RELATIVE_URL) private readonly relativeUrl: string,
@Inject(DOCUMENT) public readonly document: Document,
@Inject(WINDOW) private readonly windowRef: Window,
) {}
async ngOnInit() {
if (!this.config.isSecure()) {
await this.testHttps().catch(e =>
console.warn('Failed Https connection attempt'),
)
}
}
download() {
this.downloadClicked = true
this.document.getElementById('install-cert')?.click()
}
instructions() {
this.windowRef.open(
'https://docs.start9.com/getting-started/trust-ca/#trust-your-root-ca',
'_blank',
'noreferrer',
)
this.instructionsClicked = true
this.startDaemon()
}
private async startDaemon(): Promise<void> {
this.polling = true
while (this.polling) {
try {
await this.testHttps()
this.polling = false
} catch (e) {
console.warn('Failed Https connection attempt')
await pauseFor(2000)
}
}
}
launchHttps() {
const host = this.config.getHost()
this.windowRef.open(`https://${host}`, '_blank', 'noreferrer')
}
private async testHttps() {
const url = `https://${this.document.location.host}${this.relativeUrl}`
await this.api.echo({ message: 'ping' }, url).then(() => {
this.downloadClicked = true
this.instructionsClicked = true
this.caTrusted = true
})
}
}

View File

@@ -4,7 +4,9 @@ import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { IonicModule } from '@ionic/angular' import { IonicModule } from '@ionic/angular'
import { LoginPage } from './login.page' import { LoginPage } from './login.page'
import { CAWizardComponent } from './ca-wizard/ca-wizard.component'
import { SharedPipesModule } from '@start9labs/shared' import { SharedPipesModule } from '@start9labs/shared'
import { TuiHintModule, TuiTooltipModule } from '@taiga-ui/core'
const routes: Routes = [ const routes: Routes = [
{ {
@@ -20,7 +22,9 @@ const routes: Routes = [
IonicModule, IonicModule,
SharedPipesModule, SharedPipesModule,
RouterModule.forChild(routes), RouterModule.forChild(routes),
TuiTooltipModule,
TuiHintModule,
], ],
declarations: [LoginPage], declarations: [LoginPage, CAWizardComponent],
}) })
export class LoginPageModule {} export class LoginPageModule {}

View File

@@ -1,55 +1,81 @@
<ion-content class="content"> <ion-content class="content">
<ion-grid class="grid"> <!-- Local HTTP -->
<ion-row class="row"> <ng-container *ngIf="config.isLocalHttp(); else notLanHttp">
<ion-col> <ca-wizard></ca-wizard>
<img src="assets/img/logo.png" alt="Start9" class="logo" /> </ng-container>
<ion-card class="card"> <!-- not Local HTTP -->
<ion-card-header> <ng-template #notLanHttp>
<ion-card-title class="title">StartOS Login</ion-card-title> <div *ngIf="config.isTorHttp()" class="banner">
</ion-card-header> <ion-item color="warning">
<ion-icon slot="start" name="warning-outline"></ion-icon>
<ion-label>
<h2 style="font-weight: bold">Http detected</h2>
<p style="font-weight: 600">Tor is faster over https.</p>
</ion-label>
<ion-button slot="end" color="light" (click)="launchHttps()">
Open Https
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-button>
</ion-item>
</div>
<ion-card-content class="ion-margin"> <ion-grid class="grid">
<form class="form" (submit)="submit()"> <ion-row class="row">
<ion-item-group> <ion-col>
<ion-item color="dark"> <img src="assets/img/logo.png" alt="Start9" class="logo" />
<ion-icon
slot="start" <ion-card class="card">
name="key-outline" <ion-card-header>
style="margin-right: 16px" <ion-card-title class="title">StartOS Login</ion-card-title>
></ion-icon> </ion-card-header>
<ion-input
name="password" <ion-card-content class="ion-margin">
placeholder="Password" <form class="form" (submit)="submit()">
[type]="unmasked ? 'text' : 'password'" <ion-item-group>
[(ngModel)]="password" <ion-item color="dark" class="login-item">
(ionChange)="error = ''"
maxlength="64"
></ion-input>
<ion-button fill="clear" color="light" (click)="toggleMask()">
<ion-icon <ion-icon
slot="icon-only" slot="start"
size="small" name="key-outline"
[name]="unmasked ? 'eye-off-outline' : 'eye-outline'" style="margin-right: 16px"
></ion-icon> ></ion-icon>
</ion-button> <ion-input
</ion-item> name="password"
</ion-item-group> placeholder="Password"
<ion-button [type]="unmasked ? 'text' : 'password'"
class="login-button" [(ngModel)]="password"
type="submit" (ionChange)="error = ''"
expand="block" maxlength="64"
color="tertiary" ></ion-input>
> <ion-button
Login fill="clear"
</ion-button> color="light"
</form> (click)="unmasked = !unmasked"
<p class="error"> >
<ion-text color="danger">{{ error }}</ion-text> <ion-icon
</p> slot="icon-only"
</ion-card-content> size="small"
</ion-card> [name]="unmasked ? 'eye-off-outline' : 'eye-outline'"
</ion-col> ></ion-icon>
</ion-row> </ion-button>
</ion-grid> </ion-item>
</ion-item-group>
<ion-button
class="login-button side-button"
type="submit"
expand="block"
color="tertiary"
>
Login
</ion-button>
</form>
<p class="error">
<ion-text color="danger">{{ error }}</ion-text>
</p>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</ng-template>
</ion-content> </ion-content>

View File

@@ -18,7 +18,7 @@
} }
.row { .row {
height: 90%; height: 100%;
align-items: center; align-items: center;
text-align: center; text-align: center;
} }
@@ -34,18 +34,30 @@
padding-top: 4px; padding-top: 4px;
} }
ion-button { .banner {
position: absolute;
padding: 20px;
width: 100%;
display: inline-block;
ion-item {
max-width: 800px;
margin: auto;
}
}
.side-button {
--border-radius: 0 4px 4px 0; --border-radius: 0 4px 4px 0;
} }
ion-item { .login-item {
--border-style: solid; --border-style: solid;
--border-color: var(--ion-color-light); --border-color: var(--ion-color-light);
--border-radius: 4px 0 0 4px; --border-radius: 4px 0 0 4px;
box-shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.2), 0 2px 2px 0 rgba(0, 0, 0, 0.14), box-shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.2), 0 2px 2px 0 rgba(0, 0, 0, 0.14),
0 1px 5px 0 rgba(0, 0, 0, 0.12); 0 1px 5px 0 rgba(0, 0, 0, 0.12);
ion-button { .side-button {
--border-radius: 4px; --border-radius: 4px;
} }
} }
@@ -80,12 +92,12 @@ ion-card {
@media (max-width: 500px) { @media (max-width: 500px) {
ion-button { .side-button {
--border-radius: 4px; --border-radius: 4px;
margin-top: 0.7rem; margin-top: 0.7rem;
} }
ion-item { .login-item {
--border-radius: 4px; --border-radius: 4px;
} }
} }

View File

@@ -1,9 +1,12 @@
import { Component } from '@angular/core' import { Component, Inject } from '@angular/core'
import { LoadingController, getPlatforms } from '@ionic/angular' import { getPlatforms, LoadingController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { AuthService } from 'src/app/services/auth.service' import { AuthService } from 'src/app/services/auth.service'
import { Router } from '@angular/router' import { Router } from '@angular/router'
import { ConfigService } from 'src/app/services/config.service' import { ConfigService } from 'src/app/services/config.service'
import { pauseFor, RELATIVE_URL } from '@start9labs/shared'
import { DOCUMENT } from '@angular/common'
import { WINDOW } from '@ng-web-apis/common'
@Component({ @Component({
selector: 'login', selector: 'login',
@@ -14,42 +17,71 @@ export class LoginPage {
password = '' password = ''
unmasked = false unmasked = false
error = '' error = ''
loader?: HTMLIonLoadingElement
secure = this.config.isSecure() downloadClicked = false
instructionsClicked = false
polling = false
caTrusted = false
constructor( constructor(
private readonly router: Router, private readonly router: Router,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly loadingCtrl: LoadingController, private readonly loadingCtrl: LoadingController,
private readonly api: ApiService, private readonly api: ApiService,
private readonly config: ConfigService, public readonly config: ConfigService,
@Inject(RELATIVE_URL) private readonly relativeUrl: string,
@Inject(DOCUMENT) public readonly document: Document,
@Inject(WINDOW) private readonly windowRef: Window,
) {} ) {}
async ionViewDidEnter() { async ngOnInit() {
if (!this.secure) { if (!this.config.isSecure()) {
await this.testHttps().catch(e =>
console.warn('Failed Https connection attempt'),
)
}
}
download() {
this.downloadClicked = true
this.document.getElementById('install-cert')?.click()
}
instructions() {
this.windowRef.open(
'https://docs.start9.com/getting-started/trust-ca/#trust-your-server-s-root-ca',
'_blank',
'noreferrer',
)
this.instructionsClicked = true
this.startDaemon()
}
private async startDaemon(): Promise<void> {
this.polling = true
while (this.polling) {
try { try {
await this.api.getPubKey() await this.testHttps()
} catch (e: any) { this.polling = false
this.error = e.message } catch (e) {
console.warn('Failed Https connection attempt')
await pauseFor(2000)
} }
} }
} }
ngOnDestroy() { launchHttps() {
this.loader?.dismiss() const host = this.config.getHost()
} this.windowRef.open(`https://${host}`, '_blank', 'noreferrer')
toggleMask() {
this.unmasked = !this.unmasked
} }
async submit() { async submit() {
this.error = '' this.error = ''
this.loader = await this.loadingCtrl.create({ const loader = await this.loadingCtrl.create({
message: 'Logging in...', message: 'Logging in...',
}) })
await this.loader.present() await loader.present()
try { try {
document.cookie = '' document.cookie = ''
@@ -58,9 +90,7 @@ export class LoginPage {
return return
} }
await this.api.login({ await this.api.login({
password: this.secure password: this.password,
? this.password
: await this.api.encrypt(this.password),
metadata: { platforms: getPlatforms() }, metadata: { platforms: getPlatforms() },
}) })
@@ -71,7 +101,16 @@ export class LoginPage {
// code 7 is for incorrect password // code 7 is for incorrect password
this.error = e.code === 7 ? 'Invalid Password' : e.message this.error = e.code === 7 ? 'Invalid Password' : e.message
} finally { } finally {
this.loader.dismiss() loader.dismiss()
} }
} }
private async testHttps() {
const url = `https://${this.document.location.host}${this.relativeUrl}`
await this.api.echo({ message: 'ping' }, url).then(() => {
this.downloadClicked = true
this.instructionsClicked = true
this.caTrusted = true
})
}
} }

View File

@@ -15,13 +15,12 @@
<!-- loaded --> <!-- loaded -->
<ion-item-group *ngIf="server$ | async as server; else loading"> <ion-item-group *ngIf="server$ | async as server; else loading">
<ion-item *ngIf="!secure || isTorHttp" color="warning"> <ion-item *ngIf="isTorHttp" color="warning">
<ion-icon slot="start" name="warning-outline"></ion-icon> <ion-icon slot="start" name="warning-outline"></ion-icon>
<ion-label> <ion-label>
<h2 style="font-weight: bold">Http detected</h2> <h2 style="font-weight: bold">Http detected</h2>
<p style="font-weight: 600"> <p style="font-weight: 600">
{{ isTorHttp ? 'Tor is faster over https.' : 'Your connection is Tor is faster over https.
insecure.' }}
<a <a
[routerLink]="['/system', 'root-ca']" [routerLink]="['/system', 'root-ca']"
style="color: var(--ion-color-light)" style="color: var(--ion-color-light)"

View File

@@ -9,7 +9,7 @@ import {
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { combineLatest, firstValueFrom, map, Observable, of } from 'rxjs' import { firstValueFrom, Observable, of } from 'rxjs'
import { ErrorToastService } from '@start9labs/shared' import { ErrorToastService } from '@start9labs/shared'
import { EOSService } from 'src/app/services/eos.service' import { EOSService } from 'src/app/services/eos.service'
import { ClientStorageService } from 'src/app/services/client-storage.service' import { ClientStorageService } from 'src/app/services/client-storage.service'
@@ -41,9 +41,7 @@ export class ServerShowPage {
readonly showUpdate$ = this.eosService.showUpdate$ readonly showUpdate$ = this.eosService.showUpdate$
readonly showDiskRepair$ = this.ClientStorageService.showDiskRepair$ readonly showDiskRepair$ = this.ClientStorageService.showDiskRepair$
readonly secure = this.config.isSecure() readonly isTorHttp = this.config.isTorHttp()
readonly isTorHttp =
this.config.isTor() && this.document.location.protocol === 'http:'
constructor( constructor(
private readonly alertCtrl: AlertController, private readonly alertCtrl: AlertController,
@@ -308,10 +306,8 @@ export class ServerShowPage {
} }
async launchHttps() { async launchHttps() {
const onTor = this.config.isTor() const { 'tor-address': torAddress } = await getServerInfo(this.patch)
const { 'lan-address': lanAddress, 'tor-address': torAddress } = window.open(torAddress)
await getServerInfo(this.patch)
onTor ? window.open(torAddress) : window.open(lanAddress)
} }
addClick(title: string) { addClick(title: string) {
@@ -465,7 +461,7 @@ export class ServerShowPage {
action: () => action: () =>
this.navCtrl.navigateForward(['backup'], { relativeTo: this.route }), this.navCtrl.navigateForward(['backup'], { relativeTo: this.route }),
detail: true, detail: true,
disabled$: of(!this.secure), disabled$: of(false),
}, },
{ {
title: 'Restore From Backup', title: 'Restore From Backup',
@@ -474,10 +470,7 @@ export class ServerShowPage {
action: () => action: () =>
this.navCtrl.navigateForward(['restore'], { relativeTo: this.route }), this.navCtrl.navigateForward(['restore'], { relativeTo: this.route }),
detail: true, detail: true,
disabled$: combineLatest([ disabled$: this.eosService.updatingOrBackingUp$,
this.eosService.updatingOrBackingUp$,
of(this.secure),
]).pipe(map(([updating, secure]) => updating || !secure)),
}, },
], ],
Manage: [ Manage: [
@@ -545,7 +538,7 @@ export class ServerShowPage {
icon: 'key-outline', icon: 'key-outline',
action: () => this.presentAlertResetPassword(), action: () => this.presentAlertResetPassword(),
detail: false, detail: false,
disabled$: of(!this.secure), disabled$: of(false),
}, },
{ {
title: 'Experimental Features', title: 'Experimental Features',

View File

@@ -57,7 +57,7 @@ export class WifiPage {
} }
async presentAlertCountry(): Promise<void> { async presentAlertCountry(): Promise<void> {
if (!this.config.isLan) { if (!this.config.isLan()) {
const alert = await this.alertCtrl.create({ const alert = await this.alertCtrl.create({
header: 'Cannot Complete Action', header: 'Cannot Complete Action',
message: message:

View File

@@ -22,7 +22,7 @@ export module RR {
// auth // auth
export type LoginReq = { export type LoginReq = {
password: Encrypted | string password: string
metadata: SessionMetadata metadata: SessionMetadata
} // auth.login - unauthed } // auth.login - unauthed
export type loginRes = null export type loginRes = null
@@ -465,7 +465,3 @@ declare global {
parse<T>(text: Stringified<T>, reviver?: (key: any, value: any) => any): T parse<T>(text: Stringified<T>, reviver?: (key: any, value: any) => any): T
} }
} }
export type Encrypted = {
encrypted: string
}

View File

@@ -1,28 +1,12 @@
import { BehaviorSubject, Observable } from 'rxjs' import { BehaviorSubject, Observable } from 'rxjs'
import { Update } from 'patch-db-client' import { Update } from 'patch-db-client'
import { Encrypted, RR } from './api.types' import { RR } from './api.types'
import { DataModel } from 'src/app/services/patch-db/data-model' import { DataModel } from 'src/app/services/patch-db/data-model'
import { Log } from '@start9labs/shared' import { Log } from '@start9labs/shared'
import { WebSocketSubjectConfig } from 'rxjs/webSocket' import { WebSocketSubjectConfig } from 'rxjs/webSocket'
import type { JWK } from 'node-jose'
export abstract class ApiService { export abstract class ApiService {
protected readonly jose = import('node-jose')
readonly patchStream$ = new BehaviorSubject<Update<DataModel>[]>([]) readonly patchStream$ = new BehaviorSubject<Update<DataModel>[]>([])
pubkey?: JWK.Key
async encrypt(toEncrypt: string): Promise<Encrypted> {
const { pubkey } = this
if (!pubkey) throw new Error('No pubkey found!')
const encrypted = await this.jose.then(jose =>
jose.JWE.createEncrypt(pubkey).update(toEncrypt).final(),
)
return { encrypted }
}
// http // http
@@ -41,8 +25,6 @@ export abstract class ApiService {
// auth // auth
abstract getPubKey(): Promise<void>
abstract login(params: RR.LoginReq): Promise<RR.loginRes> abstract login(params: RR.LoginReq): Promise<RR.loginRes>
abstract logout(params: RR.LogoutReq): Promise<RR.LogoutRes> abstract logout(params: RR.LogoutReq): Promise<RR.LogoutRes>
@@ -57,7 +39,7 @@ export abstract class ApiService {
// server // server
abstract echo(params: RR.EchoReq): Promise<RR.EchoRes> abstract echo(params: RR.EchoReq, urlOverride?: string): Promise<RR.EchoRes>
abstract openPatchWebsocket$(): Observable<Update<DataModel>> abstract openPatchWebsocket$(): Observable<Update<DataModel>>

View File

@@ -66,18 +66,6 @@ export class LiveApiService extends ApiService {
// auth // auth
/**
* We want to update the pubkey, which means that we will call in clearnet the
* getPubKey, and all the information is never in the clear, and only public
* information is sent across the network.
*/
async getPubKey() {
this.pubkey = await this.rpcRequest({
method: 'auth.get-pubkey',
params: {},
})
}
async login(params: RR.LoginReq): Promise<RR.loginRes> { async login(params: RR.LoginReq): Promise<RR.loginRes> {
return this.rpcRequest({ method: 'auth.login', params }, false) return this.rpcRequest({ method: 'auth.login', params }, false)
} }
@@ -102,8 +90,8 @@ export class LiveApiService extends ApiService {
// server // server
async echo(params: RR.EchoReq): Promise<RR.EchoRes> { async echo(params: RR.EchoReq, urlOverride?: string): Promise<RR.EchoRes> {
return this.rpcRequest({ method: 'echo', params }, false) return this.rpcRequest({ method: 'echo', params }, false, urlOverride)
} }
openPatchWebsocket$(): Observable<Update<DataModel>> { openPatchWebsocket$(): Observable<Update<DataModel>> {
@@ -453,6 +441,7 @@ export class LiveApiService extends ApiService {
private async rpcRequest<T>( private async rpcRequest<T>(
options: RPCOptions, options: RPCOptions,
addHeader = true, addHeader = true,
urlOverride?: string,
): Promise<T> { ): Promise<T> {
if (addHeader) { if (addHeader) {
options.headers = { options.headers = {
@@ -461,7 +450,7 @@ export class LiveApiService extends ApiService {
} }
} }
const res = await this.http.rpcRequest<T>(options) const res = await this.http.rpcRequest<T>(options, urlOverride)
const encodedUpdates = res.headers.get('x-patch-updates') const encodedUpdates = res.headers.get('x-patch-updates')
const encodedError = res.headers.get('x-patch-error') const encodedError = res.headers.get('x-patch-error')

View File

@@ -113,24 +113,6 @@ export class MockApiService extends ApiService {
// auth // auth
async getPubKey() {
await pauseFor(1000)
// randomly generated
// const keystore = jose.JWK.createKeyStore()
// this.pubkey = await keystore.generate('EC', 'P-256')
// generated from backend
this.pubkey = await this.jose.then(jose =>
jose.JWK.asKey({
kty: 'EC',
crv: 'P-256',
x: 'yHTDYSfjU809fkSv9MmN4wuojf5c3cnD7ZDN13n-jz4',
y: '8Mpkn744A5KDag0DmX2YivB63srjbugYZzWc3JOpQXI',
}),
)
}
async login(params: RR.LoginReq): Promise<RR.loginRes> { async login(params: RR.LoginReq): Promise<RR.loginRes> {
await pauseFor(2000) await pauseFor(2000)
@@ -165,7 +147,13 @@ export class MockApiService extends ApiService {
// server // server
async echo(params: RR.EchoReq): Promise<RR.EchoRes> { async echo(params: RR.EchoReq, url?: string): Promise<RR.EchoRes> {
if (url) {
const num = Math.floor(Math.random() * 10) + 1
console.warn(num)
if (num > 8) return params.message
throw new Error()
}
await pauseFor(2000) await pauseFor(2000)
return params.message return params.message
} }

View File

@@ -23,6 +23,10 @@ export class ConfigService {
constructor(@Inject(DOCUMENT) private readonly document: Document) {} constructor(@Inject(DOCUMENT) private readonly document: Document) {}
hostname = this.document.location.hostname hostname = this.document.location.hostname
// includes port
host = this.document.location.host
// includes ":" (e.g. "http:")
protocol = this.document.location.protocol
version = require('../../../../../package.json').version as string version = require('../../../../../package.json').version as string
useMocks = useMocks useMocks = useMocks
mocks = mocks mocks = mocks
@@ -36,17 +40,32 @@ export class ConfigService {
supportsWebSockets = !!window.WebSocket || this.isConsulate supportsWebSockets = !!window.WebSocket || this.isConsulate
isTor(): boolean { isTor(): boolean {
return ( return useMocks ? mocks.maskAs === 'tor' : this.hostname.endsWith('.onion')
this.hostname.endsWith('.onion') || (useMocks && mocks.maskAs === 'tor') }
)
isLocal(): boolean {
return useMocks
? mocks.maskAs === 'local'
: this.hostname.endsWith('.local')
}
isLocalhost(): boolean {
return useMocks
? mocks.maskAs === 'localhost'
: this.hostname === 'localhost'
} }
isLan(): boolean { isLan(): boolean {
return ( // @TODO will not work once clearnet arrives
this.hostname === 'localhost' || return !this.isTor()
this.hostname.endsWith('.local') || }
(useMocks && mocks.maskAs === 'lan')
) isTorHttp(): boolean {
return this.isTor() && !this.isHttps()
}
isLocalHttp(): boolean {
return this.isLocal() && !this.isHttps()
} }
isSecure(): boolean { isSecure(): boolean {
@@ -66,13 +85,21 @@ export class ConfigService {
} }
launchableURL(pkg: PackageDataEntry): string { launchableURL(pkg: PackageDataEntry): string {
if (this.isLan() && hasLanUi(pkg.manifest.interfaces)) { if (this.isLan() && hasLocalUi(pkg.manifest.interfaces)) {
return `https://${lanUiAddress(pkg)}` return `https://${lanUiAddress(pkg)}`
} else { } else {
// leave http for services // leave http for services
return `http://${torUiAddress(pkg)}` return `http://${torUiAddress(pkg)}`
} }
} }
getHost(): string {
return this.host
}
private isHttps(): boolean {
return useMocks ? mocks.maskAsHttps : this.protocol === 'https:'
}
} }
export function hasTorUi(interfaces: Record<string, InterfaceDef>): boolean { export function hasTorUi(interfaces: Record<string, InterfaceDef>): boolean {
@@ -80,7 +107,7 @@ export function hasTorUi(interfaces: Record<string, InterfaceDef>): boolean {
return !!int?.['tor-config'] return !!int?.['tor-config']
} }
export function hasLanUi(interfaces: Record<string, InterfaceDef>): boolean { export function hasLocalUi(interfaces: Record<string, InterfaceDef>): boolean {
const int = getUiInterfaceValue(interfaces) const int = getUiInterfaceValue(interfaces)
return !!int?.['lan-config'] return !!int?.['lan-config']
} }
@@ -102,7 +129,7 @@ export function lanUiAddress({
} }
export function hasUi(interfaces: Record<string, InterfaceDef>): boolean { export function hasUi(interfaces: Record<string, InterfaceDef>): boolean {
return hasTorUi(interfaces) || hasLanUi(interfaces) return hasTorUi(interfaces) || hasLocalUi(interfaces)
} }
export function removeProtocol(str: string): string { export function removeProtocol(str: string): string {

View File

@@ -53,8 +53,7 @@
*/ */
(window as any).global = window (window as any).global = window
global.Buffer = global.Buffer || require('buffer').Buffer; ; (window as any).process = { env: { DEBUG: undefined }, browser: true }
(window as any).process = { env: { DEBUG: undefined }, browser: true }
import './zone-flags' import './zone-flags'
@@ -62,8 +61,7 @@ import './zone-flags'
* Zone JS is required by default for Angular itself. * Zone JS is required by default for Angular itself.
*/ */
import 'zone.js/dist/zone' // Included with Angular CLI. import 'zone.js/dist/zone' // Included with Angular CLI.
/*************************************************************************************************** /***************************************************************************************************
* APPLICATION IMPORTS * APPLICATION IMPORTS

View File

@@ -339,3 +339,12 @@ ul {
padding-left: 40px; padding-left: 40px;
list-style-type: disc; list-style-type: disc;
} }
// override for taiga styles
p {
font-size: 1rem;
}
svg:not(:root) {
overflow: auto;
}