mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 12:11:56 +00:00
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:
56
web/projects/start-tunnel/src/app/app.config.ts
Normal file
56
web/projects/start-tunnel/src/app/app.config.ts
Normal 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()),
|
||||
],
|
||||
}
|
||||
17
web/projects/start-tunnel/src/app/app.routes.ts
Normal file
17
web/projects/start-tunnel/src/app/app.routes.ts
Normal 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: '' },
|
||||
]
|
||||
27
web/projects/start-tunnel/src/app/app.ts
Normal file
27
web/projects/start-tunnel/src/app/app.ts
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
132
web/projects/start-tunnel/src/app/routes/home/components/nav.ts
Normal file
132
web/projects/start-tunnel/src/app/routes/home/components/nav.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
28
web/projects/start-tunnel/src/app/routes/home/index.ts
Normal file
28
web/projects/start-tunnel/src/app/routes/home/index.ts
Normal 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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 ''
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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[]>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
86
web/projects/start-tunnel/src/app/routes/login/index.ts
Normal file
86
web/projects/start-tunnel/src/app/routes/login/index.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
20
web/projects/start-tunnel/src/app/services/auth.service.ts
Normal file
20
web/projects/start-tunnel/src/app/services/auth.service.ts
Normal 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)))
|
||||
}
|
||||
@@ -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: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
22
web/projects/start-tunnel/src/app/services/patch.service.ts
Normal file
22
web/projects/start-tunnel/src/app/services/patch.service.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Injectable, signal } from '@angular/core'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SidebarService {
|
||||
readonly start = signal(false)
|
||||
}
|
||||
20
web/projects/start-tunnel/src/index.html
Normal file
20
web/projects/start-tunnel/src/index.html
Normal 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>
|
||||
5
web/projects/start-tunnel/src/main.ts
Normal file
5
web/projects/start-tunnel/src/main.ts
Normal 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))
|
||||
90
web/projects/start-tunnel/src/styles.scss
Normal file
90
web/projects/start-tunnel/src/styles.scss
Normal 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);
|
||||
}
|
||||
9
web/projects/start-tunnel/tsconfig.json
Normal file
9
web/projects/start-tunnel/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user