Feature/start tunnel (#3037)

* fix live-build resolv.conf

* improved debuggability

* wip: start-tunnel

* fixes for trixie and tor

* non-free-firmware on trixie

* wip

* web server WIP

* wip: tls refactor

* FE patchdb, mocks, and most endpoints

* fix editing records and patch mocks

* refactor complete

* finish api

* build and formatter update

* minor change toi viewing addresses and fix build

* fixes

* more providers

* endpoint for getting config

* fix tests

* api fixes

* wip: separate port forward controller into parts

* simplify iptables rules

* bump sdk

* misc fixes

* predict next subnet and ip, use wan ips, and form validation

* refactor: break big components apart and address todos (#3043)

* refactor: break big components apart and address todos

* starttunnel readme, fix pf mocks, fix adding tor domain in startos

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>

* better tui

* tui tweaks

* fix: address comments

* better regex for subnet

* fixes

* better validation

* handle rpc errors

* build fixes

* fix: address comments (#3044)

* fix: address comments

* fix unread notification mocks

* fix row click for notification

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>

* fix raspi build

* fix build

* fix build

* fix build

* fix build

* try to fix build

* fix tests

* fix tests

* fix rsync tests

* delete useless effectful test

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>
Co-authored-by: Alex Inkin <alexander@inkin.ru>
This commit is contained in:
Aiden McClelland
2025-11-07 03:12:05 -07:00
committed by GitHub
parent 1ea525feaa
commit 68f401bfa3
229 changed files with 17255 additions and 10553 deletions

View File

@@ -32,7 +32,7 @@ import { MarketplaceItemComponent } from './item.component'
let-completeWith="completeWith"
>
<tui-radio-list [items]="versions()" [(ngModel)]="data.version" />
<footer class="buttons">
<footer class="g-buttons">
<button
tuiButton
appearance="secondary"

View File

@@ -12,15 +12,15 @@ import { getErrorMessage } from '../services/error.service'
@Component({
template: `
@if (error()) {
<tui-notification appearance="negative" safeLinks>
{{ error() }}
</tui-notification>
<tui-notification appearance="negative" safeLinks [innerHTML]="error()" />
}
@if (content(); as result) {
<div safeLinks [innerHTML]="result | markdown | dompurify"></div>
} @else {
<tui-loader textContent="Loading" [style.height.%]="100" />
@if (!error()) {
<tui-loader textContent="Loading" [style.height.%]="100" />
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -34,14 +34,10 @@ import { getErrorMessage } from '../services/error.service'
],
})
export class MarkdownComponent {
private readonly data =
injectContext<TuiDialogContext<void, { content: Observable<string> }>>({
optional: true,
})?.data || inject(ActivatedRoute).snapshot.data
readonly content = toSignal<string>(this.data['content'])
readonly error = toSignal(
this.data['content'].pipe(
protected readonly data = injectContext<{ data: Observable<string> }>().data
protected readonly content = toSignal<string>(this.data)
protected readonly error = toSignal(
this.data.pipe(
ignoreElements(),
catchError(e => of(getErrorMessage(e))),
),

View File

@@ -95,6 +95,9 @@ $wide-modal: 900px;
--tw-color-zinc-800: 39 39 42;
--tw-color-zinc-900: 24 24 27;
--tw-color-zinc-950: 9 9 11;
--tui-font-text: 'Proxima Nova', system-ui;
--tui-font-heading: 'Proxima Nova', system-ui;
}
body {
@@ -172,14 +175,6 @@ a {
font-weight: 300;
text-transform: uppercase;
letter-spacing: 0.06rem;
margin: 0rem 0 1rem 0;
margin: 0 0 1rem 0;
pointer-events: none;
}
.buttons {
margin-top: 1rem;
:first-child {
margin-right: 0.5rem;
}
}

View File

@@ -0,0 +1,56 @@
import { tuiDropdownOptionsProvider } from '@taiga-ui/core'
import { provideEventPlugins } from '@taiga-ui/event-plugins'
import { provideAnimations } from '@angular/platform-browser/animations'
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
provideZonelessChangeDetection,
} from '@angular/core'
import { provideRouter, withRouterConfig } from '@angular/router'
import { tuiDialogOptionsProvider } from '@taiga-ui/experimental'
import { PatchDB } from 'patch-db-client'
import {
PATCH_CACHE,
PatchDbSource,
} from 'src/app/services/patch-db/patch-db-source'
import { routes } from './app.routes'
import { ApiService } from './services/api/api.service'
import { LiveApiService } from './services/api/live-api.service'
import { MockApiService } from './services/api/mock-api.service'
import { RELATIVE_URL, WorkspaceConfig } from '@start9labs/shared'
import {
provideHttpClient,
withFetch,
withInterceptorsFromDi,
} from '@angular/common/http'
const {
useMocks,
ui: { api },
} = require('../../../../config.json') as WorkspaceConfig
export const appConfig: ApplicationConfig = {
providers: [
provideAnimations(),
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideRouter(routes, withRouterConfig({ onSameUrlNavigation: 'reload' })),
provideEventPlugins(),
tuiDropdownOptionsProvider({ appearance: 'start-9' }),
tuiDialogOptionsProvider({ appearance: 'start-9 taiga' }),
{
provide: PatchDB,
deps: [PatchDbSource, PATCH_CACHE],
useClass: PatchDB,
},
{
provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService,
},
{
provide: RELATIVE_URL,
useValue: `/${api.url}/${api.version}`,
},
provideHttpClient(withInterceptorsFromDi(), withFetch()),
],
}

View File

@@ -0,0 +1,17 @@
import { inject } from '@angular/core'
import { Routes } from '@angular/router'
import { AuthService } from 'src/app/services/auth.service'
export const routes: Routes = [
{
path: '',
loadChildren: () => import('./routes/home'),
canMatch: [() => inject(AuthService).authenticated()],
},
{
path: '',
loadComponent: () => import('./routes/login'),
canMatch: [() => !inject(AuthService).authenticated()],
},
{ path: '**', redirectTo: '' },
]

View File

@@ -0,0 +1,27 @@
import { TuiRoot } from '@taiga-ui/core'
import { Component, inject } from '@angular/core'
import { RouterOutlet } from '@angular/router'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { PatchService } from './services/patch.service'
@Component({
selector: 'app-root',
imports: [RouterOutlet, TuiRoot],
template: '<tui-root><router-outlet /></tui-root>',
styles: `
:host {
height: 100%;
display: block;
}
tui-root {
height: 100%;
border-image: none;
}
`,
})
export class App {
readonly subscription = inject(PatchService)
.pipe(takeUntilDestroyed())
.subscribe()
}

View File

@@ -0,0 +1,57 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { TuiButton } from '@taiga-ui/core'
import { SidebarService } from 'src/app/services/sidebar.service'
@Component({
selector: 'header',
template: `
<img alt="Start9" src="assets/icons/favicon.svg" />
<h1>StartTunnel</h1>
<button
tuiIconButton
iconStart="@tui.menu"
appearance="action-grayscale"
(click.stop)="sidebars.start.set(!sidebars.start())"
>
Menu
</button>
`,
styles: `
:host {
grid-column: span 2;
display: flex;
align-items: center;
gap: 0.75rem;
padding-inline-start: 0.75rem;
background: var(--tui-background-neutral-2);
box-shadow: var(--tui-shadow-medium);
border-bottom: 1px solid var(--tui-border-normal);
}
h1 {
font: var(--tui-font-heading-6);
margin-inline-end: auto;
}
img {
width: 2rem;
}
button {
display: none;
}
:host-context(tui-root._mobile) {
grid-column: span 1;
button {
display: inherit;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton],
})
export class Header {
protected readonly sidebars = inject(SidebarService)
}

View File

@@ -0,0 +1,132 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { Router, RouterLink, RouterLinkActive } from '@angular/router'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { ApiService } from 'src/app/services/api/api.service'
import { AuthService } from 'src/app/services/auth.service'
import { SidebarService } from 'src/app/services/sidebar.service'
@Component({
selector: 'nav',
template: `
<div>
@for (route of routes; track $index) {
<a
tuiButton
size="s"
appearance="flat-grayscale"
routerLinkActive="active"
[iconStart]="route.icon"
[routerLink]="route.link"
>
{{ route.name }}
</a>
}
</div>
<button
tuiButton
iconStart="@tui.log-out"
appearance="neutral"
size="s"
(click)="logout()"
>
Logout
</button>
`,
styles: `
:host {
display: flex;
flex-direction: column;
background: var(--tui-background-neutral-1);
backdrop-filter: blur(1rem);
z-index: 1;
overflow: hidden;
transition: transform var(--tui-duration);
}
div {
flex: 1;
padding-top: 1rem;
}
a {
display: flex;
justify-content: start;
margin: 0 0.5rem;
&.active {
background: var(--tui-background-neutral-1);
}
}
button {
width: 100%;
border-radius: 0;
justify-content: flex-start;
}
:host-context(tui-root._mobile) {
position: absolute;
top: 3.5rem;
width: 14rem;
bottom: 0;
inset-inline-start: 0;
&:not(:focus-within, ._expanded) {
transform: translate3d(-100%, 0, 0);
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, RouterLink, RouterLinkActive],
host: {
'[class._expanded]': 'sidebars.start()',
'(document:click)': 'sidebars.start.set(false)',
'(mousedown.prevent)': '0',
},
})
export class Nav {
private readonly service = inject(AuthService)
private readonly router = inject(Router)
protected readonly sidebars = inject(SidebarService)
protected readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
protected readonly routes = [
{
name: 'Subnets',
icon: '@tui.network',
link: 'subnets',
},
{
name: 'Devices',
icon: '@tui.laptop',
link: 'devices',
},
{
name: 'Port Forwards',
icon: '@tui.globe',
link: 'port-forwards',
},
{
name: 'Settings',
icon: '@tui.settings',
link: 'settings',
},
] as const
protected async logout() {
const loader = this.loader.open().subscribe()
try {
await this.api.logout()
this.service.authenticated.set(false)
this.router.navigate(['.'])
} catch (e: any) {
console.error(e)
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,50 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { RouterOutlet } from '@angular/router'
import { TuiScrollbar } from '@taiga-ui/core'
import { Header } from 'src/app/routes/home/components/header'
import { Nav } from 'src/app/routes/home/components/nav'
@Component({
selector: 'app-outlet',
template: `
<header></header>
<nav></nav>
<main>
<tui-scrollbar>
<router-outlet />
</tui-scrollbar>
</main>
`,
styles: `
:host {
height: 100%;
display: grid;
grid-template: 3.5rem 1fr / 14rem 1fr;
overflow: hidden;
transition: grid-template var(--tui-duration);
}
main {
isolation: isolate;
overflow: hidden;
padding: 1rem;
}
tui-scrollbar {
max-width: 50rem;
margin: 0 auto;
border-radius: var(--tui-radius-s);
::ng-deep > tui-scroll-controls {
top: var(--tui-height-m);
}
}
:host-context(tui-root._mobile) {
grid-template: 3.5rem 1fr / 1fr;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [Header, Nav, RouterOutlet, TuiScrollbar],
})
export class Outlet {}

View File

@@ -0,0 +1,28 @@
import { Routes } from '@angular/router'
import { Outlet } from 'src/app/routes/home/components/outlet'
export default [
{
path: '',
component: Outlet,
children: [
{
path: 'subnets',
loadComponent: () => import('./routes/subnets'),
},
{
path: 'devices',
loadComponent: () => import('./routes/devices'),
},
{
path: 'port-forwards',
loadComponent: () => import('./routes/port-forwards'),
},
{
path: 'settings',
loadComponent: () => import('./routes/settings'),
},
{ path: '**', redirectTo: 'subnets' },
],
},
] satisfies Routes

View File

@@ -0,0 +1,183 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Signal,
} from '@angular/core'
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { utils } from '@start9labs/start-sdk'
import {
TUI_IS_MOBILE,
TuiAutoFocus,
tuiMarkControlAsTouchedAndValidate,
} from '@taiga-ui/cdk'
import {
TuiButton,
TuiDialogContext,
TuiError,
TuiTextfield,
} from '@taiga-ui/core'
import {
TuiChevron,
TuiDataListWrapper,
TuiElasticContainer,
TuiFieldErrorPipe,
TuiSelect,
} from '@taiga-ui/kit'
import { TuiForm } from '@taiga-ui/layout'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { ApiService } from 'src/app/services/api/api.service'
import {
getIp,
DeviceData,
MappedSubnet,
subnetValidator,
ipInSubnetValidator,
} from './utils'
@Component({
template: `
<form tuiForm [formGroup]="form">
<tui-textfield>
<label tuiLabel>Name</label>
<input tuiTextfield tuiAutoFocus formControlName="name" />
</tui-textfield>
<tui-error formControlName="name" [error]="[] | tuiFieldError | async" />
@if (!context.data.device) {
<tui-textfield tuiChevron [stringify]="stringify">
<label tuiLabel>Subnet</label>
@if (mobile) {
<select
tuiSelect
formControlName="subnet"
placeholder="Select Subnet"
[items]="context.data.subnets()"
></select>
} @else {
<input tuiSelect formControlName="subnet" />
}
@if (!mobile) {
<tui-data-list-wrapper
*tuiTextfieldDropdown
new
[items]="context.data.subnets()"
(itemClick)="onSubnet($event)"
/>
}
</tui-textfield>
<tui-error
formControlName="subnet"
[error]="[] | tuiFieldError | async"
/>
<tui-elastic-container>
@if (form.controls.subnet.value?.range) {
<tui-textfield>
<label tuiLabel>LAN IP</label>
<input tuiTextfield tuiAutoFocus formControlName="ip" />
</tui-textfield>
}
</tui-elastic-container>
@if (form.controls.subnet.value?.range) {
<tui-error
formControlName="ip"
[error]="[] | tuiFieldError | async"
/>
}
}
<footer>
<button tuiButton (click)="onSave()">Save</button>
</footer>
</form>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AsyncPipe,
ReactiveFormsModule,
TuiAutoFocus,
TuiButton,
TuiDataListWrapper,
TuiError,
TuiFieldErrorPipe,
TuiForm,
TuiSelect,
TuiTextfield,
TuiChevron,
TuiElasticContainer,
],
})
export class DevicesAdd {
private readonly loading = inject(LoadingService)
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
protected readonly mobile = inject(TUI_IS_MOBILE)
protected readonly context =
injectContext<TuiDialogContext<void, DeviceData>>()
protected readonly form = inject(NonNullableFormBuilder).group({
name: [this.context.data.device?.name || '', Validators.required],
subnet: [
this.context.data.device?.subnet,
[Validators.required, subnetValidator],
],
ip: [
this.context.data.device?.ip || '',
[Validators.required, Validators.pattern(utils.Patterns.ipv4.regex)],
],
})
protected readonly stringify = ({ range, name }: MappedSubnet) =>
range ? `${name} (${range})` : ''
protected onSubnet(subnet: MappedSubnet) {
this.form.controls.ip.clearValidators()
this.form.controls.ip.addValidators([
Validators.required,
ipInSubnetValidator(subnet.range),
])
const ip = getIp(subnet)
if (ip) {
this.form.controls.ip.setValue(ip)
} else {
this.form.controls.ip.disable()
}
this.form.controls.subnet.markAsTouched()
}
protected async onSave() {
if (this.form.invalid) {
tuiMarkControlAsTouchedAndValidate(this.form)
return
}
const loader = this.loading.open().subscribe()
const { ip, name, subnet } = this.form.getRawValue()
const data = { ip, name, subnet: subnet?.range || '' }
try {
this.context.data.device
? await this.api.editDevice(data)
: await this.api.addDevice(data)
} catch (e: any) {
console.error(e)
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
this.context.$implicit.complete()
}
}
}
export const DEVICES_ADD = new PolymorpheusComponent(DevicesAdd)

View File

@@ -0,0 +1,74 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import {
TuiButton,
TuiDialogContext,
TuiIcon,
TuiTextfield,
TuiTitle,
} from '@taiga-ui/core'
import { TuiCopy, TuiSegmented, TuiTextarea } from '@taiga-ui/kit'
import { TuiHeader } from '@taiga-ui/layout'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { QrCodeComponent } from 'ng-qrcode'
@Component({
template: `
<header tuiHeader>
<h2 tuiTitle>Device Config</h2>
<aside tuiAccessories>
<tui-segmented #segmented>
<button>
<tui-icon icon="@tui.file" />
File
</button>
<button>
<tui-icon icon="@tui.qr-code" />
QR
</button>
</tui-segmented>
</aside>
</header>
@if (segmented?.activeItemIndex) {
<qr-code [value]="config" size="352" />
} @else {
<tui-textfield>
<textarea
tuiTextarea
[min]="16"
[max]="16"
[readOnly]="true"
[value]="config"
></textarea>
<tui-icon tuiCopy />
<a
tuiIconButton
iconStart="@tui.download"
download="start-tunnel.conf"
size="s"
[href]="href"
>
Download
</a>
</tui-textfield>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
QrCodeComponent,
TuiButton,
TuiHeader,
TuiIcon,
TuiTitle,
TuiSegmented,
TuiTextfield,
TuiTextarea,
TuiCopy,
],
})
export class DevicesConfig {
protected readonly config =
injectContext<TuiDialogContext<void, string>>().data
protected readonly href = `data:text/plain;charset=utf-8,${encodeURIComponent(this.config)}`
}
export const DEVICES_CONFIG = new PolymorpheusComponent(DevicesConfig)

View File

@@ -0,0 +1,176 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
Signal,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { ErrorService, LoadingService } from '@start9labs/shared'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { TuiDialogService } from '@taiga-ui/experimental'
import { TUI_CONFIRM } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { filter, map } from 'rxjs'
import { ApiService } from 'src/app/services/api/api.service'
import { TunnelData } from 'src/app/services/patch-db/data-model'
import { DEVICES_ADD } from './add'
import { DEVICES_CONFIG } from './config'
import { MappedDevice, MappedSubnet } from './utils'
@Component({
template: `
<table class="g-table">
<thead>
<tr>
<th>Name</th>
<th>Subnet</th>
<th>LAN IP</th>
<th [style.padding-inline-end.rem]="0.625">
<button tuiButton size="xs" iconStart="@tui.plus" (click)="onAdd()">
Add
</button>
</th>
</tr>
</thead>
<tbody>
@for (device of devices(); track $index) {
<tr>
<td>{{ device.name }}</td>
<td>{{ device.subnet.name }}</td>
<td>{{ device.ip }}</td>
<td>
<button
tuiIconButton
size="xs"
tuiDropdown
tuiDropdownOpen
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
>
Actions
<tui-data-list *tuiTextfieldDropdown size="s">
<button
tuiOption
iconStart="@tui.pencil"
new
(click)="onEdit(device)"
>
Rename
</button>
<button
tuiOption
iconStart="@tui.settings"
new
(click)="onConfig(device)"
>
View Config
</button>
<button
tuiOption
iconStart="@tui.trash"
new
(click)="onDelete(device)"
>
Delete
</button>
</tui-data-list>
</button>
</td>
</tr>
}
</tbody>
</table>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiDropdown, TuiDataList, TuiTextfield],
})
export default class Devices {
private readonly dialogs = inject(TuiDialogService)
private readonly api = inject(ApiService)
private readonly loading = inject(LoadingService)
private readonly errorService = inject(ErrorService)
protected readonly subnets: Signal<readonly MappedSubnet[]> = toSignal(
inject<PatchDB<TunnelData>>(PatchDB)
.watch$('wg', 'subnets')
.pipe(
map(subnets =>
Object.entries(subnets).map(([range, { name, clients }]) => ({
range,
name,
clients,
})),
),
),
{ initialValue: [] },
)
protected readonly devices = computed(() =>
this.subnets().flatMap(subnet =>
Object.entries(subnet.clients).map(([ip, { name }]) => ({
subnet: {
name: subnet.name,
range: subnet.range,
},
ip,
name,
})),
),
)
protected onAdd() {
this.dialogs
.open(DEVICES_ADD, {
label: 'Add device',
data: { subnets: this.subnets },
})
.subscribe()
}
protected onEdit(device: MappedDevice) {
this.dialogs
.open(DEVICES_ADD, {
label: 'Rename device',
data: { device, subnets: this.subnets },
})
.subscribe()
}
async onConfig({ subnet, ip }: MappedDevice) {
const loader = this.loading.open().subscribe()
try {
const data = await this.api.showDeviceConfig({ subnet: subnet.range, ip })
this.dialogs.open(DEVICES_CONFIG, { data }).subscribe()
} catch (e: any) {
console.log(e)
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
protected onDelete({ subnet, ip }: MappedDevice): void {
this.dialogs
.open(TUI_CONFIRM, { label: 'Are you sure?' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loading.open().subscribe()
try {
await this.api.deleteDevice({ subnet: subnet.range, ip })
} catch (e: any) {
this.errorService.handleError(e)
console.log(e)
} finally {
loader.unsubscribe()
}
})
}
}

View File

@@ -0,0 +1,66 @@
import { Signal } from '@angular/core'
import { AbstractControl } from '@angular/forms'
import { utils } from '@start9labs/start-sdk'
import { IpNet } from '@start9labs/start-sdk/util'
import { WgServer } from 'src/app/services/patch-db/data-model'
export interface MappedDevice {
readonly subnet: {
readonly name: string
readonly range: string
}
readonly ip: string
readonly name: string
}
export interface MappedSubnet {
readonly range: string
readonly name: string
readonly clients: WgServer['subnets']['']['clients']
}
export interface DeviceData {
readonly subnets: Signal<readonly MappedSubnet[]>
readonly device?: MappedDevice
}
export function subnetValidator({ value }: AbstractControl<MappedSubnet>) {
return !value?.clients || getIp(value)
? null
: { noHosts: 'No hosts available' }
}
export const ipInSubnetValidator = (subnet: string | null = null) => {
const ipnet = subnet && utils.IpNet.parse(subnet)
return ({ value }: AbstractControl<string>) => {
let ip: utils.IpAddress
try {
ip = utils.IpAddress.parse(value)
} catch (e) {
return { invalidIp: 'Not a valid IP Address' }
}
if (!ipnet) return null
const zero = ipnet.zero().cmp(ip)
const broadcast = ipnet.broadcast().cmp(ip)
return zero + broadcast === 0
? null
: zero === 0
? { isZeroAddr: `Address cannot be the zero address` }
: broadcast === 0
? { isBroadcastAddress: `Address cannot be the broadcast address` }
: { notInSubnet: `Address is not part of ${subnet}` }
}
}
export function getIp({ clients, range }: MappedSubnet) {
const net = IpNet.parse(range)
const last = net.broadcast()
for (let ip = net.add(1); ip.cmp(last) === -1; ip.add(1)) {
if (!clients[ip.address]) {
return ip.address
}
}
return ''
}

View File

@@ -0,0 +1,180 @@
import { AsyncPipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import { ErrorService, LoadingService } from '@start9labs/shared'
import {
TUI_IS_MOBILE,
tuiMarkControlAsTouchedAndValidate,
} from '@taiga-ui/cdk'
import {
TuiButton,
TuiDialogContext,
TuiError,
TuiNumberFormat,
TuiTextfield,
} from '@taiga-ui/core'
import {
TuiChevron,
TuiDataListWrapper,
TuiFieldErrorPipe,
TuiInputNumber,
TuiSelect,
} from '@taiga-ui/kit'
import { TuiForm } from '@taiga-ui/layout'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { ApiService } from 'src/app/services/api/api.service'
import { MappedDevice, PortForwardsData } from './utils'
@Component({
template: `
<form tuiForm [formGroup]="form">
<tui-textfield tuiChevron>
<label tuiLabel>External IP</label>
@if (mobile) {
<select
tuiSelect
formControlName="externalip"
placeholder="Select IP"
[items]="context.data.ips()"
></select>
} @else {
<input tuiSelect formControlName="externalip" />
}
@if (!mobile) {
<tui-data-list-wrapper
*tuiTextfieldDropdown
new
[items]="context.data.ips()"
/>
}
</tui-textfield>
<tui-error
formControlName="externalip"
[error]="[] | tuiFieldError | async"
/>
<tui-textfield>
<label tuiLabel>External Port</label>
<input
tuiInputNumber
formControlName="externalport"
[min]="0"
[max]="65535"
[tuiNumberFormat]="{ thousandSeparator: '' }"
/>
</tui-textfield>
<tui-error
formControlName="externalport"
[error]="[] | tuiFieldError | async"
/>
<tui-textfield tuiChevron [stringify]="stringify">
<label tuiLabel>Device</label>
@if (mobile) {
<select
tuiSelect
formControlName="device"
placeholder="Select Device"
[items]="context.data.devices()"
></select>
} @else {
<input tuiSelect formControlName="device" />
}
@if (!mobile) {
<tui-data-list-wrapper
*tuiTextfieldDropdown
new
[items]="context.data.devices()"
/>
}
</tui-textfield>
<tui-error
formControlName="device"
[error]="[] | tuiFieldError | async"
/>
<tui-textfield>
<label tuiLabel>Internal Port</label>
<input
tuiInputNumber
formControlName="internalport"
[min]="0"
[max]="65535"
[tuiNumberFormat]="{ thousandSeparator: '' }"
/>
</tui-textfield>
<tui-error
formControlName="internalport"
[error]="[] | tuiFieldError | async"
/>
<footer>
<button tuiButton [disabled]="form.invalid" (click)="onSave()">
Save
</button>
</footer>
</form>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AsyncPipe,
ReactiveFormsModule,
TuiButton,
TuiChevron,
TuiDataListWrapper,
TuiError,
TuiInputNumber,
TuiNumberFormat,
TuiFieldErrorPipe,
TuiTextfield,
TuiSelect,
TuiForm,
],
})
export class PortForwardsAdd {
private readonly api = inject(ApiService)
private readonly loading = inject(LoadingService)
private readonly errorService = inject(ErrorService)
protected readonly mobile = inject(TUI_IS_MOBILE)
protected readonly context =
injectContext<TuiDialogContext<void, PortForwardsData>>()
protected readonly form = inject(NonNullableFormBuilder).group({
externalip: ['', Validators.required],
externalport: [null as number | null, Validators.required],
device: [null as MappedDevice | null, Validators.required],
internalport: [null as number | null, Validators.required],
})
protected readonly stringify = ({ ip, name }: MappedDevice) =>
ip ? `${name} (${ip})` : ''
protected async onSave() {
if (this.form.invalid) {
tuiMarkControlAsTouchedAndValidate(this.form)
return
}
const loader = this.loading.open().subscribe()
const { externalip, externalport, device, internalport } =
this.form.getRawValue()
try {
await this.api.addForward({
source: `${externalip}:${externalport}`,
target: `${device?.ip}:${internalport}`,
})
} catch (e: any) {
console.error(e)
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
this.context.$implicit.complete()
}
}
}
export const PORT_FORWARDS_ADD = new PolymorpheusComponent(PortForwardsAdd)

View File

@@ -0,0 +1,141 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
Signal,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { ReactiveFormsModule } from '@angular/forms'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { utils } from '@start9labs/start-sdk'
import { TuiButton } from '@taiga-ui/core'
import { TuiDialogService } from '@taiga-ui/experimental'
import { TUI_CONFIRM } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { filter, map } from 'rxjs'
import { PORT_FORWARDS_ADD } from 'src/app/routes/home/routes/port-forwards/add'
import { ApiService } from 'src/app/services/api/api.service'
import { TunnelData } from 'src/app/services/patch-db/data-model'
import { MappedDevice, MappedForward } from './utils'
@Component({
template: `
<table class="g-table">
<thead>
<tr>
<th>External IP</th>
<th>External Port</th>
<th>Device</th>
<th>Internal Port</th>
<th [style.padding-inline-end.rem]="0.625">
<button tuiButton size="xs" iconStart="@tui.plus" (click)="onAdd()">
Add
</button>
</th>
</tr>
</thead>
<tbody>
@for (forward of forwards(); track $index) {
<tr>
<td>{{ forward.externalip }}</td>
<td>{{ forward.externalport }}</td>
<td>{{ forward.device.name }}</td>
<td>{{ forward.internalport }}</td>
<td>
<button
tuiIconButton
size="xs"
appearance="flat-grayscale"
iconStart="@tui.trash"
(click)="onDelete(forward)"
>
Actions
</button>
</td>
</tr>
}
</tbody>
</table>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, TuiButton],
})
export default class PortForwards {
private readonly dialogs = inject(TuiDialogService)
private readonly api = inject(ApiService)
private readonly loading = inject(LoadingService)
private readonly patch = inject<PatchDB<TunnelData>>(PatchDB)
private readonly errorService = inject(ErrorService)
private readonly portForwards = toSignal(this.patch.watch$('portForwards'))
private readonly ips = toSignal(
this.patch.watch$('gateways').pipe(
map(g =>
Object.values(g)
.flatMap(
val => val.ipInfo?.subnets.map(s => utils.IpNet.parse(s)) || [],
)
.filter(s => s.isIpv4() && s.isPublic())
.map(s => s.address),
),
),
{ initialValue: [] },
)
private readonly devices: Signal<MappedDevice[]> = toSignal(
this.patch
.watch$('wg', 'subnets')
.pipe(
map(subnets =>
Object.values(subnets).flatMap(({ clients }) =>
Object.entries(clients).map(([ip, { name }]) => ({ ip, name })),
),
),
),
{ initialValue: [] },
)
protected readonly forwards = computed(() =>
Object.entries(this.portForwards() || {}).map(([source, target]) => {
const sourceSplit = source.split(':')
const targetSplit = target.split(':')
return {
externalip: sourceSplit[0]!,
externalport: sourceSplit[1]!,
device: this.devices().find(d => d.ip === targetSplit[0])!,
internalport: targetSplit[1]!,
}
}),
)
protected onAdd(): void {
this.dialogs
.open(PORT_FORWARDS_ADD, {
label: 'Add port forward',
data: { ips: this.ips, devices: this.devices },
})
.subscribe()
}
protected onDelete({ externalip, externalport }: MappedForward): void {
this.dialogs
.open(TUI_CONFIRM, { label: 'Are you sure?' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loading.open().subscribe()
const source = `${externalip}:${externalport}`
try {
await this.api.deleteForward({ source })
} catch (e: any) {
console.log(e)
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
}

View File

@@ -0,0 +1,18 @@
import { Signal } from '@angular/core'
export interface MappedDevice {
readonly ip: string
readonly name: string
}
export interface MappedForward {
readonly externalip: string
readonly externalport: string
readonly device: MappedDevice
readonly internalport: string
}
export interface PortForwardsData {
readonly ips: Signal<any>
readonly devices: Signal<readonly MappedDevice[]>
}

View File

@@ -0,0 +1,143 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
NonNullableFormBuilder,
ReactiveFormsModule,
ValidatorFn,
Validators,
} from '@angular/forms'
import { DialogService, ErrorService } from '@start9labs/shared'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import { tuiMarkControlAsTouchedAndValidate, TuiValidator } from '@taiga-ui/cdk'
import {
TuiAlertService,
TuiAppearance,
TuiButton,
TuiError,
TuiTextfield,
TuiTitle,
} from '@taiga-ui/core'
import {
TuiButtonLoading,
TuiFieldErrorPipe,
tuiValidationErrorsProvider,
} from '@taiga-ui/kit'
import { TuiCard, TuiForm, TuiHeader } from '@taiga-ui/layout'
import { map } from 'rxjs'
import { ApiService } from 'src/app/services/api/api.service'
@Component({
template: `
<form tuiCardLarge tuiAppearance="neutral" tuiForm [formGroup]="form">
<header tuiHeader>
<h2 tuiTitle>
Settings
<span tuiSubtitle>Change password</span>
</h2>
</header>
<tui-textfield>
<label tuiLabel>New password</label>
<input formControlName="password" tuiTextfield />
</tui-textfield>
<tui-error
formControlName="password"
[error]="[] | tuiFieldError | async"
/>
<tui-textfield>
<label tuiLabel>Confirm new password</label>
<input
formControlName="confirm"
tuiTextfield
[tuiValidator]="matchValidator()"
/>
</tui-textfield>
<tui-error
formControlName="confirm"
[error]="[] | tuiFieldError | async"
/>
<footer>
<button tuiButton (click)="onSave()" [loading]="loading()">Save</button>
</footer>
</form>
`,
providers: [
tuiValidationErrorsProvider({
required: 'This field is required',
minlength: 'Password must be at least 8 characters',
maxlength: 'Password cannot exceed 64 characters',
match: 'Passwords do not match',
}),
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ReactiveFormsModule,
AsyncPipe,
TuiCard,
TuiForm,
TuiHeader,
TuiTitle,
TuiTextfield,
TuiError,
TuiFieldErrorPipe,
TuiButton,
TuiButtonLoading,
TuiValidator,
TuiAppearance,
],
})
export default class Settings {
private readonly api = inject(ApiService)
private readonly alerts = inject(TuiAlertService)
private readonly errorService = inject(ErrorService)
protected readonly loading = signal(false)
protected readonly form = inject(NonNullableFormBuilder).group({
password: [
'',
[Validators.required, Validators.minLength(8), Validators.maxLength(64)],
],
confirm: [
'',
[Validators.required, Validators.minLength(8), Validators.maxLength(64)],
],
})
protected readonly matchValidator = toSignal(
this.form.controls.password.valueChanges.pipe(
map(
(password): ValidatorFn =>
({ value }) =>
value === password ? null : { match: true },
),
),
{ initialValue: Validators.nullValidator },
)
protected async onSave() {
if (this.form.invalid) {
tuiMarkControlAsTouchedAndValidate(this.form)
return
}
this.loading.set(true)
try {
await this.api.setPassword({ password: this.form.getRawValue().password })
this.alerts
.open('Password changed', { label: 'Success', appearance: 'positive' })
.subscribe()
this.form.reset()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading.set(false)
}
}
}

View File

@@ -0,0 +1,102 @@
import { AsyncPipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import { LoadingService } from '@start9labs/shared'
import { TuiAutoFocus, tuiMarkControlAsTouchedAndValidate } from '@taiga-ui/cdk'
import {
TuiButton,
TuiDialogContext,
TuiError,
TuiTextfield,
} from '@taiga-ui/core'
import { TuiFieldErrorPipe } from '@taiga-ui/kit'
import { TuiForm } from '@taiga-ui/layout'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { ApiService } from 'src/app/services/api/api.service'
@Component({
template: `
<form tuiForm [formGroup]="form">
<tui-textfield>
<label tuiLabel>Name</label>
<input tuiTextfield tuiAutoFocus formControlName="name" />
</tui-textfield>
<tui-error formControlName="name" [error]="[] | tuiFieldError | async" />
@if (!context.data.name) {
<tui-textfield>
<label tuiLabel>IP Range</label>
<input tuiTextfield formControlName="subnet" />
</tui-textfield>
<tui-error
formControlName="subnet"
[error]="[] | tuiFieldError | async"
/>
}
<footer>
<button tuiButton (click)="onSave()">Save</button>
</footer>
</form>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AsyncPipe,
ReactiveFormsModule,
TuiAutoFocus,
TuiButton,
TuiError,
TuiFieldErrorPipe,
TuiForm,
TuiTextfield,
],
})
export class SubnetsAdd {
private readonly api = inject(ApiService)
private readonly loading = inject(LoadingService)
protected readonly context = injectContext<TuiDialogContext<void, Data>>()
protected readonly form = inject(NonNullableFormBuilder).group({
name: [this.context.data.name, Validators.required],
subnet: [
this.context.data.subnet,
[
Validators.required,
Validators.pattern(
'^(?:(?:25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)/(?:[0-9]|1\\d|2[0-4])$',
),
],
],
})
protected async onSave() {
if (this.form.invalid) {
tuiMarkControlAsTouchedAndValidate(this.form)
return
}
const loader = this.loading.open().subscribe()
const value = this.form.getRawValue()
try {
this.context.data.name
? await this.api.editSubnet(value)
: await this.api.addSubnet(value)
} catch (e) {
console.log(e)
} finally {
loader.unsubscribe()
this.context.$implicit.complete()
}
}
}
export const SUBNETS_ADD = new PolymorpheusComponent(SubnetsAdd)
interface Data {
name: string
subnet: string
}

View File

@@ -0,0 +1,152 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { LoadingService } from '@start9labs/shared'
import { utils } from '@start9labs/start-sdk'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { TuiDialogService } from '@taiga-ui/experimental'
import { TUI_CONFIRM } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { filter, map } from 'rxjs'
import { ApiService } from 'src/app/services/api/api.service'
import { TunnelData } from 'src/app/services/patch-db/data-model'
import { SUBNETS_ADD } from './add'
@Component({
template: `
<table class="g-table">
<thead>
<tr>
<th>Name</th>
<th>IP Range</th>
<th [style.padding-inline-end.rem]="0.625">
<button tuiButton size="xs" iconStart="@tui.plus" (click)="onAdd()">
Add
</button>
</th>
</tr>
</thead>
<tbody>
@for (subnet of subnets(); track $index) {
<tr>
<td>{{ subnet.name }}</td>
<td>{{ subnet.range }}</td>
<td>
<button
tuiIconButton
size="xs"
tuiDropdown
tuiDropdownOpen
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
>
Actions
<tui-data-list *tuiTextfieldDropdown size="s">
<button
tuiOption
iconStart="@tui.pencil"
new
(click)="onEdit(subnet)"
>
Rename
</button>
<button
tuiOption
iconStart="@tui.trash"
new
(click)="onDelete($index)"
>
Delete
</button>
</tui-data-list>
</button>
</td>
</tr>
}
</tbody>
</table>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiDropdown, TuiDataList, TuiTextfield],
})
export default class Subnets {
private readonly dialogs = inject(TuiDialogService)
private readonly api = inject(ApiService)
private readonly loading = inject(LoadingService)
protected readonly subnets = toSignal<MappedSubnet[], []>(
inject<PatchDB<TunnelData>>(PatchDB)
.watch$('wg', 'subnets')
.pipe(
map(s =>
Object.entries(s).map(([range, info]) => ({
range,
name: info.name,
hasClients: !!Object.keys(info.clients).length,
})),
),
),
{ initialValue: [] },
)
protected onAdd(): void {
this.dialogs
.open(SUBNETS_ADD, {
label: 'Add Subnet',
data: { subnet: this.getNext() },
})
.subscribe()
}
protected onEdit({ range, name }: MappedSubnet): void {
this.dialogs
.open(SUBNETS_ADD, {
label: 'Rename Subnet',
data: { subnet: range, name },
})
.subscribe()
}
protected onDelete(index: number): void {
this.dialogs
.open(TUI_CONFIRM, { label: 'Are you sure?' })
.pipe(filter(Boolean))
.subscribe(async () => {
const subnet = this.subnets()[index]?.range || ''
const loader = this.loading.open().subscribe()
try {
await this.api.deleteSubnet({ subnet })
} catch (e) {
console.log(e)
} finally {
loader.unsubscribe()
}
})
}
private getNext(): string {
const last =
this.subnets()
.map(s => utils.IpNet.parse(s.range))
.sort((a, b) => -1 * a.cmp(b))[0] ?? utils.IpNet.parse('10.58.255.0/24')
const next = utils.IpNet.fromIpPrefix(last.broadcast().add(2), 24)
if (!next.isPublic()) {
return next.ipnet
}
// No recommendation if /24 subnets are used
return ''
}
}
type MappedSubnet = {
range: string
name: string
hasClients: boolean
}

View File

@@ -0,0 +1,86 @@
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { Router } from '@angular/router'
import { TuiButton, TuiError, TuiTextfield } from '@taiga-ui/core'
import { TuiButtonLoading } from '@taiga-ui/kit'
import { ApiService } from 'src/app/services/api/api.service'
import { AuthService } from 'src/app/services/auth.service'
@Component({
template: `
<img alt="Start9" src="assets/icons/favicon.svg" />
<form (ngSubmit)="login()">
<tui-textfield [tuiTextfieldCleaner]="false">
<input
tuiTextfield
type="password"
placeholder="Enter password"
[ngModelOptions]="{ standalone: true }"
[(ngModel)]="password"
(ngModelChange)="error.set(false)"
[disabled]="loading()"
/>
<button
tuiIconButton
appearance="action"
iconStart="@tui.log-in"
[loading]="loading()"
>
Login
</button>
</tui-textfield>
@if (error()) {
<tui-error error="Password is invalid" />
}
</form>
`,
styles: `
:host {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rem;
}
img {
width: 5rem;
height: 5rem;
}
tui-textfield {
width: 18rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiTextfield, FormsModule, TuiError, TuiButtonLoading],
})
export default class Login {
private readonly auth = inject(AuthService)
private readonly router = inject(Router)
private readonly api = inject(ApiService)
protected readonly error = signal(false)
protected readonly loading = signal(false)
password = ''
protected async login() {
this.loading.set(true)
try {
await this.api.login({ password: this.password })
this.auth.authenticated.set(true)
this.router.navigate(['.'])
} catch (e) {
this.error.set(true)
} finally {
this.loading.set(false)
}
}
}

View File

@@ -0,0 +1,64 @@
import { Injectable } from '@angular/core'
import { Dump } from 'patch-db-client'
import { TunnelData } from '../patch-db/data-model'
import { Observable } from 'rxjs'
@Injectable({
providedIn: 'root',
})
export abstract class ApiService {
abstract openWebsocket$<T>(guid: string): Observable<T>
abstract subscribe(): Promise<SubscribeRes> // db.subscribe
// auth
abstract login(params: LoginReq): Promise<null> // auth.login
abstract logout(): Promise<null> // auth.logout
abstract setPassword(params: LoginReq): Promise<null> // auth.set-password
// subnets
abstract addSubnet(params: UpsertSubnetReq): Promise<null> // subnet.add
abstract editSubnet(params: UpsertSubnetReq): Promise<null> // subnet.add
abstract deleteSubnet(params: DeleteSubnetReq): Promise<null> // subnet.remove
// devices
abstract addDevice(params: UpsertDeviceReq): Promise<null> // device.add
abstract editDevice(params: UpsertDeviceReq): Promise<null> // device.add
abstract deleteDevice(params: DeleteDeviceReq): Promise<null> // device.remove
abstract showDeviceConfig(params: DeleteDeviceReq): Promise<string> // device.show-config
// forwards
abstract addForward(params: AddForwardReq): Promise<null> // port-forward.add
abstract deleteForward(params: DeleteForwardReq): Promise<null> // port-forward.remove
}
export type SubscribeRes = {
dump: Dump<TunnelData>
guid: string
}
export type LoginReq = { password: string }
export type UpsertSubnetReq = {
name: string
subnet: string
}
export type DeleteSubnetReq = {
subnet: string
}
export type UpsertDeviceReq = {
name: string
subnet: string
ip: string
}
export type DeleteDeviceReq = {
subnet: string
ip: string
}
export type AddForwardReq = {
source: string // externalip:port
target: string // internalip:port
}
export type DeleteForwardReq = {
source: string
}

View File

@@ -0,0 +1,139 @@
import { Injectable, DOCUMENT, inject } from '@angular/core'
import {
HttpService,
RPCOptions,
isRpcError,
RpcError,
ErrorService,
} from '@start9labs/shared'
import { filter, firstValueFrom, Observable } from 'rxjs'
import { webSocket } from 'rxjs/webSocket'
import {
AddForwardReq,
ApiService,
DeleteDeviceReq,
DeleteForwardReq,
DeleteSubnetReq,
LoginReq,
SubscribeRes,
UpsertDeviceReq,
UpsertSubnetReq,
} from './api.service'
import { AuthService } from '../auth.service'
import { PATCH_CACHE } from '../patch-db/patch-db-source'
@Injectable({
providedIn: 'root',
})
export class LiveApiService extends ApiService {
private readonly http = inject(HttpService)
private readonly document = inject(DOCUMENT)
private readonly auth = inject(AuthService)
private readonly cache$ = inject(PATCH_CACHE)
private readonly errorService = inject(ErrorService)
constructor() {
super()
}
openWebsocket$<T>(guid: string): Observable<T> {
const { location } = this.document.defaultView!
const host = location.host
return webSocket({
url: `wss://${host}/ws/rpc/${guid}`,
})
}
async subscribe(): Promise<SubscribeRes> {
return this.rpcRequest({ method: 'db.subscribe', params: {} })
}
// auth
async login(params: LoginReq): Promise<null> {
return this.rpcRequest({ method: 'auth.login', params })
}
async logout(): Promise<null> {
return this.rpcRequest({ method: 'auth.logout', params: {} })
}
async setPassword(params: LoginReq): Promise<null> {
return this.rpcRequest({ method: 'auth.set-password', params })
}
async addSubnet(params: UpsertSubnetReq): Promise<null> {
return this.upsertSubnet(params)
}
async editSubnet(params: UpsertSubnetReq): Promise<null> {
return this.upsertSubnet(params)
}
async deleteSubnet(params: DeleteSubnetReq): Promise<null> {
return this.rpcRequest({ method: 'subnet.remove', params })
}
// devices
async addDevice(params: UpsertDeviceReq): Promise<null> {
return this.upsertDevice(params)
}
async editDevice(params: UpsertDeviceReq): Promise<null> {
return this.upsertDevice(params)
}
async deleteDevice(params: DeleteDeviceReq): Promise<null> {
return this.rpcRequest({ method: 'device.remove', params })
}
async showDeviceConfig(params: DeleteDeviceReq): Promise<string> {
return this.rpcRequest({ method: 'device.show-config', params })
}
// forwards
async addForward(params: AddForwardReq): Promise<null> {
return this.rpcRequest({ method: 'port-forward.add', params })
}
async deleteForward(params: DeleteForwardReq): Promise<null> {
return this.rpcRequest({ method: 'port-forward.remove', params })
}
// private
private async upsertSubnet(params: UpsertSubnetReq): Promise<null> {
return this.rpcRequest({ method: 'subnet.add', params })
}
private async upsertDevice(params: UpsertDeviceReq): Promise<null> {
return this.rpcRequest({ method: 'device.add', params })
}
private async rpcRequest<T>(
options: RPCOptions,
urlOverride?: string,
): Promise<T> {
const res = await this.http.rpcRequest<T>(options, urlOverride)
const body = res.body
if (isRpcError(body)) {
if (body.error.code === 34) {
console.error('Unauthenticated, logging out')
this.auth.authenticated.set(false)
}
throw new RpcError(body.error)
}
const patchSequence = res.headers.get('x-patch-sequence')
if (patchSequence)
await firstValueFrom(
this.cache$.pipe(filter(({ id }) => id >= Number(patchSequence))),
)
return body.result
}
}

View File

@@ -0,0 +1,228 @@
import { inject, Injectable } from '@angular/core'
import { shareReplay, Subject, tap } from 'rxjs'
import { WebSocketSubject } from 'rxjs/webSocket'
import {
AddForwardReq,
ApiService,
DeleteDeviceReq,
DeleteForwardReq,
DeleteSubnetReq,
LoginReq,
SubscribeRes,
UpsertDeviceReq,
UpsertSubnetReq,
} from './api.service'
import { pauseFor } from '@start9labs/shared'
import { AuthService } from '../auth.service'
import {
AddOperation,
Operation,
PatchOp,
RemoveOperation,
ReplaceOperation,
Revision,
} from 'patch-db-client'
import { toObservable } from '@angular/core/rxjs-interop'
import { mockTunnelData, WgClient, WgSubnet } from '../patch-db/data-model'
@Injectable({
providedIn: 'root',
})
export class MockApiService extends ApiService {
private readonly auth = inject(AuthService)
readonly mockWsSource$ = new Subject<Revision>()
sequence = 1
constructor() {
super()
toObservable(this.auth.authenticated)
.pipe(
tap(() => {
this.sequence = 1
}),
)
.subscribe()
}
openWebsocket$<T>(guid: string): WebSocketSubject<T> {
return this.mockWsSource$.pipe(
shareReplay({ bufferSize: 1, refCount: true }),
) as WebSocketSubject<T>
}
async subscribe(): Promise<SubscribeRes> {
await pauseFor(1000)
return {
dump: { id: 1, value: mockTunnelData },
guid: 'patch-db-guid',
}
}
async login(params: LoginReq): Promise<null> {
await pauseFor(1000)
return null
}
async logout(): Promise<null> {
await pauseFor(1000)
return null
}
async setPassword(params: LoginReq): Promise<null> {
await pauseFor(1000)
return null
}
async addSubnet(params: UpsertSubnetReq): Promise<null> {
await pauseFor(1000)
const patch: AddOperation<WgSubnet>[] = [
{
op: PatchOp.ADD,
path: `/wg/subnets/${replaceSlashes(params.subnet)}`,
value: { name: params.name, clients: {} },
},
]
this.mockRevision(patch)
return null
}
async editSubnet(params: UpsertSubnetReq): Promise<null> {
await pauseFor(1000)
const patch: ReplaceOperation<string>[] = [
{
op: PatchOp.REPLACE,
path: `/wg/subnets/${replaceSlashes(params.subnet)}/name`,
value: params.name,
},
]
this.mockRevision(patch)
return null
}
async deleteSubnet(params: DeleteSubnetReq): Promise<null> {
await pauseFor(1000)
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/wg/subnets/${replaceSlashes(params.subnet)}`,
},
]
this.mockRevision(patch)
return null
}
async addDevice(params: UpsertDeviceReq): Promise<null> {
await pauseFor(1000)
const patch: AddOperation<WgClient>[] = [
{
op: PatchOp.ADD,
path: `/wg/subnets/${replaceSlashes(params.subnet)}/clients/${params.ip}`,
value: { name: params.name },
},
]
this.mockRevision(patch)
return null
}
async editDevice(params: UpsertDeviceReq): Promise<null> {
await pauseFor(1000)
const patch: ReplaceOperation<string>[] = [
{
op: PatchOp.REPLACE,
path: `/wg/subnets/${replaceSlashes(params.subnet)}/clients/${params.ip}/name`,
value: params.name,
},
]
this.mockRevision(patch)
return null
}
async deleteDevice(params: DeleteDeviceReq): Promise<null> {
await pauseFor(1000)
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/wg/subnets/${replaceSlashes(params.subnet)}/clients/${params.ip}`,
},
]
this.mockRevision(patch)
return null
}
async showDeviceConfig(params: DeleteDeviceReq): Promise<string> {
await pauseFor(1000)
return MOCK_CONFIG
}
async addForward(params: AddForwardReq): Promise<null> {
await pauseFor(1000)
const patch: AddOperation<string>[] = [
{
op: PatchOp.ADD,
path: `/portForwards/${params.source}`,
value: params.target,
},
]
this.mockRevision(patch)
return null
}
async deleteForward(params: DeleteForwardReq): Promise<null> {
await pauseFor(1000)
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/portForwards/${params.source}`,
},
]
this.mockRevision(patch)
return null
}
private async mockRevision<T>(patch: Operation<T>[]): Promise<void> {
const revision = {
id: ++this.sequence,
patch,
}
this.mockWsSource$.next(revision)
}
}
function replaceSlashes(val: string) {
return val.replace(new RegExp('/', 'g'), '~1')
}
const MOCK_CONFIG = `[Interface]
# Server's private IP address for the WireGuard VPN subnet
Address = 10.20.10.1/24
# UDP port WireGuard listens on
ListenPort = 33333
# Server private key (generated)
PrivateKey = 4K68mdpQWdEz/FpdVuRoZYgWpQgpW63J9GFzn+iOulQ=
# Commands to run after starting/stopping WireGuard tunnel to enable forwarding and NAT (example)
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
# Add client peers below with their public keys and allowed IPs
[Peer]
# Client public key
PublicKey = MQBiYHxAj7u8paj3L4w4uav3P/9YBPbaN4gkWn90SSs=
# Allowed client IP address within VPN subnet`

View File

@@ -0,0 +1,20 @@
import { effect, inject, Injectable, signal } from '@angular/core'
import { WA_LOCAL_STORAGE } from '@ng-web-apis/common'
const KEY = '_startos/tunnel-loggedIn'
@Injectable({
providedIn: 'root',
})
export class AuthService {
private readonly storage = inject(WA_LOCAL_STORAGE)
private readonly effect = effect(() => {
if (this.authenticated()) {
this.storage.setItem(KEY, JSON.stringify(true))
} else {
this.storage.removeItem(KEY)
}
})
readonly authenticated = signal(Boolean(this.storage.getItem(KEY)))
}

View File

@@ -0,0 +1,62 @@
import { T } from '@start9labs/start-sdk'
export type TunnelData = {
wg: WgServer
portForwards: Record<string, string>
gateways: Record<string, T.NetworkInterfaceInfo>
}
export type WgServer = {
subnets: Record<string, WgSubnet>
}
export type WgSubnet = {
name: string
clients: Record<string, WgClient>
}
export type WgClient = {
name: string
}
export const mockTunnelData: TunnelData = {
wg: {
subnets: {
'10.59.0.0/24': {
name: 'Family',
clients: {
'10.59.0.2': {
name: 'Start9 Server',
},
'10.59.0.3': {
name: 'Phone',
},
'10.59.0.4': {
name: 'Laptop',
},
},
},
},
},
portForwards: {
'69.1.1.42:443': '10.59.0.2:5443',
'69.1.1.42:3000': '10.59.0.2:3000',
},
gateways: {
eth0: {
name: null,
public: null,
secure: null,
ipInfo: {
name: 'Wired Connection 1',
scopeId: 1,
deviceType: 'ethernet',
subnets: ['69.1.1.42/24'],
wanIp: null,
ntpServers: [],
lanIp: ['10.59.0.1'],
dnsServers: [],
},
},
},
}

View File

@@ -0,0 +1,47 @@
import { inject, Injectable, InjectionToken } from '@angular/core'
import { Dump, Revision, Update } from 'patch-db-client'
import { BehaviorSubject, EMPTY, Observable, timer } from 'rxjs'
import {
bufferTime,
catchError,
filter,
startWith,
switchMap,
} from 'rxjs/operators'
import { ApiService } from '../api/api.service'
import { AuthService } from '../auth.service'
import { TunnelData } from './data-model'
import { toObservable } from '@angular/core/rxjs-interop'
export const PATCH_CACHE = new InjectionToken('', {
factory: () =>
new BehaviorSubject<Dump<TunnelData>>({
id: 0,
value: {} as TunnelData,
}),
})
@Injectable({
providedIn: 'root',
})
export class PatchDbSource extends Observable<Update<TunnelData>[]> {
private readonly api = inject(ApiService)
private readonly auth = inject(AuthService)
private readonly stream$ = toObservable(this.auth.authenticated).pipe(
switchMap(verified => (verified ? this.api.subscribe() : EMPTY)),
switchMap(({ dump, guid }) =>
this.api.openWebsocket$<Revision>(guid).pipe(
bufferTime(250),
filter(revisions => !!revisions.length),
startWith([dump]),
),
),
catchError((_, watch$) => timer(500).pipe(switchMap(() => watch$))),
startWith([{ id: 0, value: {} as TunnelData }]),
)
constructor() {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -0,0 +1,22 @@
import { inject, Injectable } from '@angular/core'
import { tap, Observable } from 'rxjs'
import { PatchDB } from 'patch-db-client'
import { TunnelData } from './patch-db/data-model'
import { AuthService } from './auth.service'
import { toObservable } from '@angular/core/rxjs-interop'
@Injectable({
providedIn: 'root',
})
export class PatchService extends Observable<unknown> {
private readonly patch = inject<PatchDB<TunnelData>>(PatchDB)
private readonly auth = inject(AuthService)
private readonly stream$ = toObservable(this.auth.authenticated).pipe(
tap(authed => (authed ? this.patch.start() : this.patch.stop())),
)
constructor() {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -0,0 +1,8 @@
import { Injectable, signal } from '@angular/core'
@Injectable({
providedIn: 'root',
})
export class SidebarService {
readonly start = signal(false)
}

View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>StartTunnel</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
rel="icon"
type="image/png"
href="/assets/icons/favicon-96x96.png"
sizes="96x96"
/>
<link rel="icon" type="image/svg+xml" href="/assets/icons/favicon.svg" />
<link rel="shortcut icon" href="/assets/icons/favicon.ico" />
</head>
<body tuiTheme="dark">
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,5 @@
import { bootstrapApplication } from '@angular/platform-browser'
import { appConfig } from './app/app.config'
import { App } from './app/app'
bootstrapApplication(App, appConfig).catch(err => console.error(err))

View File

@@ -0,0 +1,90 @@
:root {
height: 100%;
--tui-text-action: #428bf9;
--tui-text-action-hover: #165eca;
--tui-background-accent-1: #428bf9;
--tui-background-accent-1-hover: #126df7;
--tui-background-accent-1-pressed: #156ed4;
}
body {
height: 100%;
isolation: isolate;
overflow-x: hidden;
background:
conic-gradient(var(--tui-background-base)),
radial-gradient(circle at top right, #5240a8, transparent 40%),
radial-gradient(circle at bottom right, #9236c9, transparent),
radial-gradient(circle at 25% 100%, #5b65d5, transparent 30%),
radial-gradient(circle at bottom left, #0090c0, transparent 50%),
radial-gradient(circle at top left, #2a5ba8, transparent 50%),
linear-gradient(to bottom, #5654b2, transparent);
background-blend-mode: hard-light;
&:not([tuiTheme]) {
background-blend-mode: soft-light;
&::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
background: rgb(255 255 255 / 15%);
backdrop-filter: brightness(1.5);
}
}
}
tui-dropdown[data-appearance='start-9'] {
background: none;
backdrop-filter: blur(1rem);
}
tui-dialog[new][data-appearance~='start-9'] {
background: var(--tui-background-neutral-1);
backdrop-filter: blur(5rem);
}
.g-table {
width: 100%;
border-collapse: collapse;
border-radius: var(--tui-radius-s);
background: var(--tui-background-neutral-1);
box-shadow: inset 0 0 0 1px var(--tui-background-neutral-1);
thead tr {
position: sticky;
top: 0;
background: var(--tui-background-neutral-1);
backdrop-filter: blur(5rem);
z-index: 1;
}
tr:nth-child(even) {
backdrop-filter: brightness(0.9);
}
th,
td {
height: var(--tui-height-m);
padding: 0 1rem;
text-align: start;
background: transparent;
border: none;
box-shadow: inset 0 1px var(--tui-background-neutral-1);
&:last-child {
text-align: end;
}
}
}
qr-code {
display: flex;
justify-content: center;
}
tui-data-list {
--tui-text-action: var(--tui-text-primary);
}

View File

@@ -0,0 +1,9 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "./"
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]
}

View File

@@ -5,7 +5,12 @@ import {
input,
output,
} from '@angular/core'
import { CopyService, DialogService, i18nPipe } from '@start9labs/shared'
import {
CopyService,
DialogService,
i18nKey,
i18nPipe,
} from '@start9labs/shared'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import {
TuiButton,
@@ -22,6 +27,14 @@ import { InterfaceComponent } from '../interface.component'
selector: 'td[actions]',
template: `
<div class="desktop">
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.info"
(click)="viewDetails()"
>
{{ 'Address details' | i18n }}
</button>
@if (interface.value()?.type === 'ui') {
<a
tuiIconButton
@@ -62,7 +75,7 @@ import { InterfaceComponent } from '../interface.component'
>
{{ 'Actions' | i18n }}
<tui-data-list *tuiTextfieldDropdown="let close">
<button tuiOption new iconStart="@tui.eye" (click)="onDetails.emit()">
<button tuiOption new iconStart="@tui.info" (click)="viewDetails()">
{{ 'Address details' | i18n }}
</button>
@if (interface.value()?.type === 'ui') {
@@ -125,12 +138,24 @@ export class AddressActionsComponent {
readonly interface = inject(InterfaceComponent)
readonly href = input.required<string>()
readonly bullets = input.required<string[]>()
readonly disabled = input.required<boolean>()
readonly onDetails = output<void>()
open = false
viewDetails() {
this.dialog
.openAlert(
`<ul>${this.bullets()
.map(b => `<li>${b}</li>`)
.join('')}</ul>` as i18nKey,
{
label: 'About this address' as i18nKey,
},
)
.subscribe()
}
showQR() {
this.dialog
.openComponent(new PolymorpheusComponent(QRModal), {

View File

@@ -12,7 +12,7 @@ import { InterfaceAddressItemComponent } from './item.component'
selector: 'section[addresses]',
template: `
<header>{{ 'Addresses' | i18n }}</header>
<table [appTable]="[null, 'Type', 'Access', 'Gateway', 'URL', null]">
<table [appTable]="['Type', 'Access', 'Gateway', 'URL', null]">
@for (address of addresses()?.common; track $index) {
<tr [address]="address" [isRunning]="isRunning()"></tr>
} @empty {

View File

@@ -1,11 +1,5 @@
import {
ChangeDetectionStrategy,
Component,
input,
inject,
} from '@angular/core'
import { DialogService, i18nKey, i18nPipe } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { DisplayAddress } from '../interface.service'
import { AddressActionsComponent } from './actions.component'
import { TuiBadge } from '@taiga-ui/kit'
@@ -14,16 +8,6 @@ import { TuiBadge } from '@taiga-ui/kit'
selector: 'tr[address]',
template: `
@if (address(); as address) {
<td [style.width.rem]="3">
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.info"
(click)="viewDetails(address.bullets)"
>
{{ 'Address details' | i18n }}
</button>
</td>
<td>{{ address.type }}</td>
<td>
@if (address.access === 'public') {
@@ -50,8 +34,8 @@ import { TuiBadge } from '@taiga-ui/kit'
actions
[disabled]="!isRunning()"
[href]="address.url"
[bullets]="address.bullets"
[style.width.rem]="5"
(onDetails)="viewDetails(address.bullets)"
></td>
}
`,
@@ -89,22 +73,10 @@ import { TuiBadge } from '@taiga-ui/kit'
}
}
`,
imports: [i18nPipe, AddressActionsComponent, TuiButton, TuiBadge],
imports: [i18nPipe, AddressActionsComponent, TuiBadge],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceAddressItemComponent {
readonly address = input.required<DisplayAddress>()
readonly isRunning = input.required<boolean>()
readonly dialog = inject(DialogService)
viewDetails(bullets: string[]) {
this.dialog
.openAlert(
`<ul>${bullets.map(b => `<li>${b}</li>`).join('')}</ul>` as i18nKey,
{
label: 'About this address' as i18nKey,
},
)
.subscribe()
}
}

View File

@@ -172,7 +172,7 @@ export class InterfaceTorDomainsComponent {
? await this.api.addTorKey({ key })
: await this.api.generateTorKey({})
if (this.interface.packageId) {
if (this.interface.packageId()) {
await this.api.pkgAddOnion({
onion,
package: this.interface.packageId(),

View File

@@ -182,16 +182,14 @@ export class MarketplacePreviewComponent {
}
onStatic() {
const content = this.pkg$.pipe(
filter(Boolean),
switchMap(pkg => this.marketplaceService.fetchStatic$(pkg)),
)
this.dialog
.openComponent(MARKDOWN, {
label: 'License',
size: 'l',
data: { content },
data: this.pkg$.pipe(
filter(Boolean),
switchMap(pkg => this.marketplaceService.fetchStatic$(pkg)),
),
})
.subscribe()
}

View File

@@ -67,7 +67,6 @@ import { i18nPipe } from '@start9labs/shared'
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class._new]': '!notificationItem.seen',
'(click)': 'onClick()',
},
styles: `
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;

View File

@@ -1,7 +1,9 @@
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import { TuiCheckbox, TuiSkeleton } from '@taiga-ui/kit'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
OnChanges,
signal,
@@ -43,7 +45,9 @@ import { i18nPipe } from '@start9labs/shared'
[notificationItem]="notification"
(longtap)="!selected().length && onToggle(notification)"
(click.capture)="
selected().length && onToggle(notification, $event)
selected().length &&
$any($event.target).closest('tui-root._mobile') &&
onToggle(notification, $event)
"
>
<input
@@ -81,6 +85,7 @@ import { i18nPipe } from '@start9labs/shared'
top: 0.875rem;
left: 1rem;
z-index: 1;
pointer-events: none;
}
:host:not(:has(:checked)) input {

View File

@@ -1,9 +1,4 @@
import {
ChangeDetectionStrategy,
Component,
inject,
INJECTOR,
} from '@angular/core'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
CopyService,
@@ -11,12 +6,12 @@ import {
getPkgId,
i18nKey,
i18nPipe,
MarkdownComponent,
MARKDOWN,
} from '@start9labs/shared'
import { TuiCell } from '@taiga-ui/layout'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { from, map } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
import {
@@ -58,15 +53,17 @@ import {
imports: [ServiceAdditionalItemComponent, TuiCell, i18nPipe],
})
export default class ServiceAboutRoute {
private readonly pkgId = getPkgId()
private readonly copyService = inject(CopyService)
private readonly markdown = inject(DialogService).openComponent(
new PolymorpheusComponent(MarkdownComponent, inject(INJECTOR)),
{ label: 'License', size: 'l' },
)
private readonly markdown = inject(DialogService).openComponent(MARKDOWN, {
label: 'License',
size: 'l',
data: from(inject(ApiService).getStaticInstalled(this.pkgId, 'LICENSE.md')),
})
readonly groups = toSignal<{ header: i18nKey; items: AdditionalItem[] }[]>(
inject<PatchDB<DataModel>>(PatchDB)
.watch$('packageData', getPkgId())
.watch$('packageData', this.pkgId)
.pipe(
map(pkg => {
const manifest = getManifest(pkg)

View File

@@ -1,8 +1,4 @@
import { inject } from '@angular/core'
import { ActivatedRouteSnapshot, ResolveFn, Routes } from '@angular/router'
import { defer, map, Observable, of } from 'rxjs'
import { share } from 'rxjs/operators'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { Routes } from '@angular/router'
import { titleResolver } from 'src/app/utils/title-resolver'
import { ServiceOutletComponent } from './routes/outlet.component'
@@ -33,7 +29,6 @@ export const ROUTES: Routes = [
{
path: 'about',
loadComponent: () => import('./routes/about.component'),
resolve: { content: getStatic() },
},
],
},
@@ -44,15 +39,4 @@ export const ROUTES: Routes = [
},
]
function getStatic(): ResolveFn<Observable<string>> {
return ({ paramMap }: ActivatedRouteSnapshot) =>
of(inject(ApiService)).pipe(
map(api =>
defer(() =>
api.getStaticInstalled(paramMap.get('pkgId')!, 'LICENSE.md'),
).pipe(share()),
),
)
}
export default ROUTES

View File

@@ -87,13 +87,11 @@ export class SideloadPackageComponent {
readonly file = input.required<File>()
onStatic() {
const content = of(this.pkg()['license'])
this.dialog
.openComponent(MARKDOWN, {
label: 'License',
size: 'l',
data: { content },
data: of(this.pkg()['license']),
})
.subscribe()
}

View File

@@ -41,7 +41,11 @@ import { i18nPipe } from '@start9labs/shared'
@for (session of sessions(); track $index) {
<tr
(longtap)="!selected().length && onToggle(session)"
(click)="selected().length && onToggle(session)"
(click)="
selected().length &&
$any($event.target).closest('tui-root._mobile') &&
onToggle(session)
"
>
<td [style.padding-left.rem]="single() ? null : 2.5">
@if (!single()) {
@@ -123,6 +127,7 @@ import { i18nPipe } from '@start9labs/shared'
input {
left: 0.25rem;
pointer-events: none;
}
td {

View File

@@ -31,7 +31,11 @@ import { SSHKey } from 'src/app/services/api/api.types'
@for (key of keys(); track $index) {
<tr
(longtap)="!selected().length && onToggle(key)"
(click)="selected().length && onToggle(key)"
(click)="
selected().length &&
$any($event.target).closest('tui-root._mobile') &&
onToggle(key)
"
>
<td [style.padding-left.rem]="2.5">
<input
@@ -104,6 +108,7 @@ import { SSHKey } from 'src/app/services/api/api.types'
input {
left: 0.25rem;
pointer-events: none;
}
td {

View File

@@ -149,6 +149,11 @@ import UpdatesComponent from './updates.component'
font-size: 1rem;
}
tui-progress-circle {
display: inline-block;
vertical-align: middle;
}
div {
display: flex;
align-items: center;
@@ -172,12 +177,7 @@ import UpdatesComponent from './updates.component'
}
&:last-child {
text-align: right;
white-space: nowrap;
div {
justify-content: flex-start;
}
}
&[colspan]:only-child {

View File

@@ -19,19 +19,17 @@ import {
TuiSkeleton,
} from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { combineLatest, map, tap } from 'rxjs'
import { combineLatest, tap } from 'rxjs'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { LocalPackagesService } from 'src/app/services/local-packages.service'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import {
DataModel,
InstalledState,
PackageDataEntry,
UpdatingState,
} from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import { isInstalled, isUpdating } from 'src/app/utils/get-package-data'
import { FilterUpdatesPipe } from './filter-updates.pipe'
import { UpdatesItemComponent } from './item.component'
import { i18nPipe } from '@start9labs/shared'
@@ -256,21 +254,7 @@ export default class UpdatesComponent {
),
),
marketplace: this.marketplaceService.marketplace$,
localPkgs: inject<PatchDB<DataModel>>(PatchDB)
.watch$('packageData')
.pipe(
map(pkgs =>
Object.entries(pkgs).reduce<
Record<string, PackageDataEntry<InstalledState | UpdatingState>>
>(
(acc, [id, val]) =>
isInstalled(val) || isUpdating(val)
? { ...acc, [id]: val }
: acc,
{},
),
),
),
localPkgs: inject(LocalPackagesService),
errors: this.marketplaceService.requestErrors$,
}),
)

View File

@@ -110,7 +110,7 @@ export namespace Mock {
squashfs: {
aarch64: {
publishedAt: '2025-04-21T20:58:48.140749883Z',
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.11/startos-0.4.0-alpha.11-33ae46f~dev_aarch64.squashfs',
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.12/startos-0.4.0-alpha.12-33ae46f~dev_aarch64.squashfs',
commitment: {
hash: '4elBFVkd/r8hNadKmKtLIs42CoPltMvKe2z3LRqkphk=',
size: 1343500288,
@@ -122,7 +122,7 @@ export namespace Mock {
},
'aarch64-nonfree': {
publishedAt: '2025-04-21T21:07:00.249285116Z',
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.11/startos-0.4.0-alpha.11-33ae46f~dev_aarch64-nonfree.squashfs',
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.12/startos-0.4.0-alpha.12-33ae46f~dev_aarch64-nonfree.squashfs',
commitment: {
hash: 'MrCEi4jxbmPS7zAiGk/JSKlMsiuKqQy6RbYOxlGHOIQ=',
size: 1653075968,
@@ -134,7 +134,7 @@ export namespace Mock {
},
raspberrypi: {
publishedAt: '2025-04-21T21:16:12.933319237Z',
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.11/startos-0.4.0-alpha.11-33ae46f~dev_raspberrypi.squashfs',
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.12/startos-0.4.0-alpha.12-33ae46f~dev_raspberrypi.squashfs',
commitment: {
hash: '/XTVQRCqY3RK544PgitlKu7UplXjkmzWoXUh2E4HCw0=',
size: 1490731008,
@@ -146,7 +146,7 @@ export namespace Mock {
},
x86_64: {
publishedAt: '2025-04-21T21:14:20.246908903Z',
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.11/startos-0.4.0-alpha.11-33ae46f~dev_x86_64.squashfs',
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.12/startos-0.4.0-alpha.12-33ae46f~dev_x86_64.squashfs',
commitment: {
hash: '/6romKTVQGSaOU7FqSZdw0kFyd7P+NBSYNwM3q7Fe44=',
size: 1411657728,
@@ -158,7 +158,7 @@ export namespace Mock {
},
'x86_64-nonfree': {
publishedAt: '2025-04-21T21:15:17.955265284Z',
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.11/startos-0.4.0-alpha.11-33ae46f~dev_x86_64-nonfree.squashfs',
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.12/startos-0.4.0-alpha.12-33ae46f~dev_x86_64-nonfree.squashfs',
commitment: {
hash: 'HCRq9sr/0t85pMdrEgNBeM4x11zVKHszGnD1GDyZbSE=',
size: 1731035136,
@@ -385,7 +385,7 @@ export namespace Mock {
docsUrl: 'https://bitcoin.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.41',
sdkVersion: '0.4.0-beta.42',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -420,7 +420,7 @@ export namespace Mock {
docsUrl: 'https://bitcoinknots.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.41',
sdkVersion: '0.4.0-beta.42',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -465,7 +465,7 @@ export namespace Mock {
docsUrl: 'https://bitcoin.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.41',
sdkVersion: '0.4.0-beta.42',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -500,7 +500,7 @@ export namespace Mock {
docsUrl: 'https://bitcoinknots.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.41',
sdkVersion: '0.4.0-beta.42',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -547,7 +547,7 @@ export namespace Mock {
docsUrl: 'https://lightning.engineering/',
releaseNotes: 'Upstream release to 0.17.5',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.41',
sdkVersion: '0.4.0-beta.42',
gitHash: 'fakehash',
icon: LND_ICON,
sourceVersion: null,
@@ -595,7 +595,7 @@ export namespace Mock {
docsUrl: 'https://lightning.engineering/',
releaseNotes: 'Upstream release to 0.17.4',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.41',
sdkVersion: '0.4.0-beta.42',
gitHash: 'fakehash',
icon: LND_ICON,
sourceVersion: null,
@@ -647,7 +647,7 @@ export namespace Mock {
docsUrl: 'https://bitcoin.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.41',
sdkVersion: '0.4.0-beta.42',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -682,7 +682,7 @@ export namespace Mock {
docsUrl: 'https://bitcoinknots.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.41',
sdkVersion: '0.4.0-beta.42',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -727,7 +727,7 @@ export namespace Mock {
docsUrl: 'https://lightning.engineering/',
releaseNotes: 'Upstream release and minor fixes.',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.41',
sdkVersion: '0.4.0-beta.42',
gitHash: 'fakehash',
icon: LND_ICON,
sourceVersion: null,
@@ -775,7 +775,7 @@ export namespace Mock {
marketingSite: '',
releaseNotes: 'Upstream release and minor fixes.',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.41',
sdkVersion: '0.4.0-beta.42',
gitHash: 'fakehash',
icon: PROXY_ICON,
sourceVersion: null,

View File

@@ -794,10 +794,18 @@ export class LiveApiService extends ApiService {
)
return res.body
}
const computedDigest = Buffer.from(blake3(data)).toString('base64')
if (`blake3=:${computedDigest}:` === digest) return res.body
console.debug(computedDigest, digest)
throw new Error('File digest mismatch.')
const [alg, hash] = digest.split('=', 2)
if (alg === 'blake3') {
if (
Buffer.from(blake3(data)).compare(
Buffer.from(hash?.replace(/:/g, '') || '', 'base64'),
) !== 0
) {
throw new Error('File digest mismatch.')
}
} else {
console.warn(`Unknown Repr-Digest algorithm ${alg}`)
}
}
return res.body
}

View File

@@ -194,7 +194,7 @@ export const mockPatchData: DataModel = {
staticServers: null,
},
},
unreadNotificationCount: 4,
unreadNotificationCount: 5,
// password is asdfasdf
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',

View File

@@ -1,23 +1,11 @@
import { inject, Injectable } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import {
combineLatest,
EMPTY,
filter,
first,
map,
Observable,
pairwise,
shareReplay,
startWith,
switchMap,
} from 'rxjs'
import { ConnectionService } from 'src/app/services/connection.service'
import { OSService } from 'src/app/services/os.service'
import { combineLatest, EMPTY, map, Observable, shareReplay } from 'rxjs'
import { LocalPackagesService } from 'src/app/services/local-packages.service'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { NotificationService } from 'src/app/services/notification.service'
import { OSService } from 'src/app/services/os.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
import { FilterUpdatesPipe } from '../routes/portal/routes/updates/filter-updates.pipe'
@Injectable({
@@ -35,32 +23,9 @@ export class BadgeService {
private readonly marketplaceService = inject(MarketplaceService)
private readonly filterUpdatesPipe = inject(FilterUpdatesPipe)
private readonly local$ = inject(ConnectionService).pipe(
filter(Boolean),
switchMap(() => this.patch.watch$('packageData').pipe(first())),
switchMap(outer =>
this.patch.watch$('packageData').pipe(
pairwise(),
filter(([prev, curr]) =>
Object.values(prev).some(p => {
const { id } = getManifest(p)
return (
!curr[id] ||
(p.stateInfo.installingInfo &&
!curr[id]?.stateInfo.installingInfo)
)
}),
),
map(([_, curr]) => curr),
startWith(outer),
),
),
)
private readonly updates$ = combineLatest([
this.marketplaceService.marketplace$,
this.local$,
inject(LocalPackagesService),
]).pipe(
map(
([marketplace, local]) =>

View File

@@ -24,6 +24,11 @@ export class GatewayService {
map(gateways =>
Object.entries(gateways)
.filter(([_, val]) => !!val?.ipInfo)
.filter(
([_, val]) =>
val?.ipInfo?.deviceType !== 'bridge' &&
val?.ipInfo?.deviceType !== 'loopback',
)
.map(([id, val]) => {
const subnets =
val.ipInfo?.subnets.map(s => utils.IpNet.parse(s)) ?? []

View File

@@ -0,0 +1,34 @@
import { inject, Injectable } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import { map, Observable, shareReplay } from 'rxjs'
import {
DataModel,
InstalledState,
PackageDataEntry,
UpdatingState,
} from 'src/app/services/patch-db/data-model'
import { isInstalled, isUpdating } from 'src/app/utils/get-package-data'
@Injectable({
providedIn: 'root',
})
export class LocalPackagesService extends Observable<
Record<string, PackageDataEntry<InstalledState | UpdatingState>>
> {
private readonly stream$ = inject<PatchDB<DataModel>>(PatchDB)
.watch$('packageData')
.pipe(
map(pkgs =>
Object.entries(pkgs).reduce(
(acc, [id, val]) =>
isInstalled(val) || isUpdating(val) ? { ...acc, [id]: val } : acc,
{},
),
),
shareReplay({ bufferSize: 1, refCount: true }),
)
constructor() {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -95,21 +95,26 @@ export class NotificationService {
viewModal(notification: ServerNotification<number>, full = false) {
const { data, createdAt, code, title, message } = notification
const label = code === 1 ? 'Backup Report' : (title as i18nKey)
const component = code === 1 ? REPORT : MARKDOWN
const content = code === 1 ? data : of(data)
this.markSeen([notification])
this.dialogs
.openComponent(full ? message : component, {
label,
data: {
content,
timestamp: createdAt,
},
size: code === 1 ? 'm' : 'l',
})
.subscribe()
if (code === 1) {
// Backup Report
this.dialogs
.openComponent(full ? message : REPORT, {
label: 'Backup Report',
data: { content: data, createdAt },
})
.subscribe()
} else {
// Markdown viewer
this.dialogs
.openComponent(full ? message : MARKDOWN, {
label: title as i18nKey,
data: of(data),
size: 'l',
})
.subscribe()
}
}
private async updateCount(toAdjust: number) {

View File

@@ -1,4 +1,5 @@
import { Inject, Injectable, DOCUMENT } from '@angular/core'
import { inject, Injectable } from '@angular/core'
import { WA_LOCAL_STORAGE } from '@ng-web-apis/common'
const PREFIX = '_startos/'
@@ -6,9 +7,7 @@ const PREFIX = '_startos/'
providedIn: 'root',
})
export class StorageService {
private readonly storage = this.document.defaultView!.localStorage
constructor(@Inject(DOCUMENT) private readonly document: Document) {}
private readonly storage = inject(WA_LOCAL_STORAGE)
get<T>(key: string): T {
return JSON.parse(String(this.storage.getItem(`${PREFIX}${key}`)))

View File

@@ -25,7 +25,6 @@ hr {
:root {
--bumper: 0.375rem;
--tui-font-text: 'Proxima Nova', system-ui;
}
.g-page {